@openparachute/vault 0.7.0-rc.9 → 0.7.1

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.
@@ -1,7 +1,16 @@
1
1
  import { describe, it, expect, beforeEach } from "bun:test";
2
2
  import { Database } from "bun:sqlite";
3
3
  import { SqliteStore } from "./store.js";
4
- import { parseWikilinks, syncWikilinks, resolveWikilink, resolveUnresolvedWikilinks, listUnresolvedWikilinks } from "./wikilinks.js";
4
+ import {
5
+ parseWikilinks,
6
+ syncWikilinks,
7
+ resolveWikilink,
8
+ resolveWikilinkDetailed,
9
+ resolveUnresolvedWikilinks,
10
+ listUnresolvedWikilinks,
11
+ getContentWikilinkWarnings,
12
+ resolveOrQueueLink,
13
+ } from "./wikilinks.js";
5
14
 
6
15
  let store: SqliteStore;
7
16
  let db: Database;
@@ -142,6 +151,81 @@ describe("resolveWikilink", async () => {
142
151
  });
143
152
  });
144
153
 
154
+ // ---------------------------------------------------------------------------
155
+ // Title fallback (additive — id/path/basename still win first)
156
+ // ---------------------------------------------------------------------------
157
+
158
+ describe("resolveWikilink — title fallback", async () => {
159
+ it("resolves via H1 title when path/basename both miss", async () => {
160
+ const note = await store.createNote("# My Real Title\n\nSome body text.", { path: "Inbox/2026-07-10-abc123" });
161
+ const id = resolveWikilink(db, "My Real Title");
162
+ expect(id).toBe(note.id);
163
+ });
164
+
165
+ it("matches the H1 title case-insensitively", async () => {
166
+ const note = await store.createNote("# My Real Title\n\nBody.", { path: "Inbox/xyz" });
167
+ const id = resolveWikilink(db, "my real title");
168
+ expect(id).toBe(note.id);
169
+ });
170
+
171
+ it("exact path match wins over a title match on a DIFFERENT note", async () => {
172
+ // A note literally path-named "My Real Title" ...
173
+ const byPath = await store.createNote("Path note body", { path: "My Real Title" });
174
+ // ... and an unrelated note whose H1 happens to be the same string.
175
+ await store.createNote("# My Real Title\n\nOther body.", { path: "Inbox/other" });
176
+ const id = resolveWikilink(db, "My Real Title");
177
+ expect(id).toBe(byPath.id);
178
+ });
179
+
180
+ it("basename match wins over title fallback", async () => {
181
+ const byBasename = await store.createNote("Body", { path: "Projects/Parachute/README" });
182
+ await store.createNote("# README\n\nOther body.", { path: "Inbox/other-readme" });
183
+ const id = resolveWikilink(db, "README");
184
+ expect(id).toBe(byBasename.id);
185
+ });
186
+
187
+ it("stays unresolved when two notes share the same H1 title (ambiguous)", async () => {
188
+ await store.createNote("# Shared Title\n\nA.", { path: "Inbox/a" });
189
+ await store.createNote("# Shared Title\n\nB.", { path: "Inbox/b" });
190
+ const id = resolveWikilink(db, "Shared Title");
191
+ expect(id).toBeNull();
192
+ });
193
+
194
+ it("does not fall through to title when basename is itself ambiguous", async () => {
195
+ // Two notes share basename "README" (ambiguous at step 3) AND a third
196
+ // note has an H1 title of "README" too — the ambiguous basename step
197
+ // must not be rescued by the title step.
198
+ await store.createNote("A", { path: "Folder1/README" });
199
+ await store.createNote("B", { path: "Folder2/README" });
200
+ await store.createNote("# README\n\nC.", { path: "Inbox/c" });
201
+ const id = resolveWikilink(db, "README");
202
+ expect(id).toBeNull();
203
+ });
204
+
205
+ it("ignores H2+ headings — only a literal single-# line counts", async () => {
206
+ await store.createNote("## Not An H1\n\nBody.", { path: "Inbox/h2" });
207
+ const id = resolveWikilink(db, "Not An H1");
208
+ expect(id).toBeNull();
209
+ });
210
+
211
+ it("resolveWikilinkDetailed reports the title match as resolved", async () => {
212
+ const note = await store.createNote("# Detailed Title\n\nBody.", { path: "Inbox/detail" });
213
+ const result = resolveWikilinkDetailed(db, "Detailed Title");
214
+ expect(result.resolved).toBe(true);
215
+ expect(result.note_id).toBe(note.id);
216
+ expect(result.path).toBe("Inbox/detail");
217
+ });
218
+
219
+ it("resolveWikilinkDetailed reports an ambiguous title match", async () => {
220
+ await store.createNote("# Dup Title\n\nA.", { path: "Inbox/dup-a" });
221
+ await store.createNote("# Dup Title\n\nB.", { path: "Inbox/dup-b" });
222
+ const result = resolveWikilinkDetailed(db, "Dup Title");
223
+ expect(result.resolved).toBe(false);
224
+ expect(result.ambiguous).toBe(true);
225
+ expect(result.candidates).toHaveLength(2);
226
+ });
227
+ });
228
+
145
229
  // ---------------------------------------------------------------------------
146
230
  // Sync
147
231
  // ---------------------------------------------------------------------------
@@ -157,6 +241,16 @@ describe("syncWikilinks", async () => {
157
241
  expect(links[0].relationship).toBe("wikilink");
158
242
  });
159
243
 
244
+ it("creates a link via the title fallback when path/basename miss", async () => {
245
+ const target = await store.createNote("# Weekly Review\n\nBody.", { path: "Inbox/2026-07-10-abc123" });
246
+ const source = await store.createNote("See [[Weekly Review]]");
247
+
248
+ const links = await store.getLinks(source.id, { direction: "outbound" });
249
+ expect(links).toHaveLength(1);
250
+ expect(links[0].targetId).toBe(target.id);
251
+ expect(links[0].relationship).toBe("wikilink");
252
+ });
253
+
160
254
  it("tracks unresolved wikilinks", async () => {
161
255
  const source = await store.createNote("See [[Missing Note]]");
162
256
 
@@ -254,6 +348,116 @@ describe("syncWikilinks", async () => {
254
348
  expect(links[0].metadata?.display).toBe("intro");
255
349
  expect(links[0].metadata?.anchor).toBe("Introduction");
256
350
  });
351
+
352
+ // vault#570 — an ambiguous target (≥2 notes share the same basename/
353
+ // title) must be reported distinctly from a genuine miss, via
354
+ // `syncWikilinks`'s `ambiguous` array, and must NOT be linked or queued
355
+ // into `unresolved_wikilinks` (queuing implies "wait for this to be
356
+ // created", which doesn't describe an already-existing ambiguity).
357
+ it("returns ambiguous targets separately from unresolved, creates no link, and does not queue them", async () => {
358
+ const a = await store.createNote("A", { path: "Folder1/Dup" });
359
+ const b = await store.createNote("B", { path: "Folder2/Dup" });
360
+ // Empty content on create so the note exists (satisfying the
361
+ // `unresolved_wikilinks` FK) without triggering `syncWikilinks` yet —
362
+ // the manual call below is what's under test, and its return value
363
+ // isn't otherwise observable through the Store API.
364
+ const source = await store.createNote("");
365
+
366
+ const content = "See [[Dup]] and also [[Truly Missing]]";
367
+ const result = syncWikilinks(db, source.id, content);
368
+ expect(result.added).toBe(0);
369
+ expect(result.unresolved).toEqual(["Truly Missing"]);
370
+ expect(result.ambiguous).toEqual([{ target: "Dup", count: 2 }]);
371
+
372
+ const links = await store.getLinks(source.id, { direction: "outbound" });
373
+ expect(links.some((l) => l.targetId === a.id || l.targetId === b.id)).toBe(false);
374
+
375
+ const pending = db.prepare(
376
+ "SELECT target_path FROM unresolved_wikilinks WHERE source_id = ?",
377
+ ).all(source.id) as { target_path: string }[];
378
+ // Only the genuinely-missing target is queued — "Dup" is absent.
379
+ expect(pending.map((r) => r.target_path)).toEqual(["Truly Missing"]);
380
+ });
381
+ });
382
+
383
+ // ---------------------------------------------------------------------------
384
+ // getContentWikilinkWarnings (vault#570) — read-only warning derivation
385
+ // ---------------------------------------------------------------------------
386
+
387
+ describe("getContentWikilinkWarnings", () => {
388
+ it("returns an unresolved_link warning for a content wikilink to a missing target", async () => {
389
+ const source = await store.createNote("See [[Nowhere]]");
390
+ const warnings = getContentWikilinkWarnings(db, source.id, "See [[Nowhere]]");
391
+ expect(warnings).toHaveLength(1);
392
+ expect(warnings[0]!.code).toBe("unresolved_link");
393
+ expect(warnings[0]!.target).toBe("Nowhere");
394
+ expect(warnings[0]!.relationship).toBe("wikilink");
395
+ });
396
+
397
+ it("returns an ambiguous_link warning (with candidate_count) for a content wikilink matching 2 notes", async () => {
398
+ await store.createNote("A", { path: "Folder1/Same" });
399
+ await store.createNote("B", { path: "Folder2/Same" });
400
+ const source = await store.createNote("See [[Same]]");
401
+ const warnings = getContentWikilinkWarnings(db, source.id, "See [[Same]]");
402
+ expect(warnings).toHaveLength(1);
403
+ expect(warnings[0]!.code).toBe("ambiguous_link");
404
+ expect(warnings[0]!.target).toBe("Same");
405
+ expect(warnings[0]!.candidate_count).toBe(2);
406
+ });
407
+
408
+ it("returns no warnings for a resolved wikilink, including a self-link", async () => {
409
+ const target = await store.createNote("Target", { path: "Resolvable" });
410
+ const source = await store.createNote("See [[Resolvable]]", { path: "Myself Again" });
411
+ expect(getContentWikilinkWarnings(db, source.id, "See [[Resolvable]]")).toEqual([]);
412
+ expect(getContentWikilinkWarnings(db, source.id, "See [[Myself Again]]")).toEqual([]);
413
+ void target;
414
+ });
415
+
416
+ it("returns no warnings when content has no wikilinks", () => {
417
+ expect(getContentWikilinkWarnings(db, "some-id", "plain text, no brackets")).toEqual([]);
418
+ });
419
+ });
420
+
421
+ // ---------------------------------------------------------------------------
422
+ // resolveOrQueueLink (vault#555 + vault#570) — discriminated outcome
423
+ // ---------------------------------------------------------------------------
424
+
425
+ describe("resolveOrQueueLink", () => {
426
+ it("returns {status: 'resolved', note_id} for a resolvable target", async () => {
427
+ const target = await store.createNote("Target", { path: "Findable" });
428
+ const source = await store.createNote("source");
429
+ const outcome = resolveOrQueueLink(db, source.id, "Findable", "knows");
430
+ expect(outcome.status).toBe("resolved");
431
+ if (outcome.status === "resolved") expect(outcome.note_id).toBe(target.id);
432
+ });
433
+
434
+ it("returns {status: 'queued'} and queues the pending row for a genuinely missing target", async () => {
435
+ const source = await store.createNote("source");
436
+ const outcome = resolveOrQueueLink(db, source.id, "Not There", "knows");
437
+ expect(outcome.status).toBe("queued");
438
+ const pending = db.prepare(
439
+ "SELECT target_path, relationship FROM unresolved_wikilinks WHERE source_id = ?",
440
+ ).all(source.id) as { target_path: string; relationship: string }[];
441
+ expect(pending).toEqual([{ target_path: "Not There", relationship: "knows" }]);
442
+ });
443
+
444
+ it("returns {status: 'ambiguous', candidates} and does NOT queue for a target matching 2 notes", async () => {
445
+ const a = await store.createNote("A", { path: "Folder1/Twice" });
446
+ const b = await store.createNote("B", { path: "Folder2/Twice" });
447
+ const source = await store.createNote("source");
448
+ const outcome = resolveOrQueueLink(db, source.id, "Twice", "knows");
449
+ expect(outcome.status).toBe("ambiguous");
450
+ if (outcome.status === "ambiguous") {
451
+ expect(outcome.candidates.map((c) => c.note_id).sort()).toEqual([a.id, b.id].sort());
452
+ }
453
+ const tableExists = (db.prepare("PRAGMA table_info(unresolved_wikilinks)").all() as unknown[]).length > 0;
454
+ if (tableExists) {
455
+ const pending = db.prepare(
456
+ "SELECT * FROM unresolved_wikilinks WHERE source_id = ?",
457
+ ).all(source.id) as unknown[];
458
+ expect(pending).toHaveLength(0);
459
+ }
460
+ });
257
461
  });
258
462
 
259
463
  // ---------------------------------------------------------------------------
@@ -1,9 +1,10 @@
1
1
  import { Database } from "bun:sqlite";
2
2
  import type { Note } from "./types.js";
3
3
  import * as linkOps from "./links.js";
4
- import { getNote } from "./notes.js";
4
+ import { getNote, findNotesByTitle } from "./notes.js";
5
5
  import { chunkForInClause } from "./sql-in.js";
6
6
  import { transaction } from "./txn.js";
7
+ import type { QueryWarning } from "./query-warnings.js";
7
8
 
8
9
  // ---------------------------------------------------------------------------
9
10
  // Parser — extract [[wikilinks]] from markdown content
@@ -128,6 +129,14 @@ function stripCode(content: string): string {
128
129
  * 3. Basename match — target matches the last segment of a path
129
130
  * (e.g., "README" matches "Projects/Parachute/README"). Same
130
131
  * cross-extension ambiguity policy as #2.
132
+ * 4. **Title fallback** (additive) — only tried when #2/#3 found NO
133
+ * candidates at all (a clean miss, not an ambiguous one). Matches the
134
+ * note whose H1 title (first `# ` line in its content, see
135
+ * {@link findNotesByTitle}) equals the target, case-insensitively.
136
+ * Resolves only when EXACTLY one note has that title; 2+ matches is
137
+ * ambiguous and stays unresolved rather than guessing. Rescues links
138
+ * into a note whose displayed title differs from its path/basename —
139
+ * the top source of silently-broken wikilinks.
131
140
  */
132
141
  export function resolveWikilink(db: Database, target: string): string | null {
133
142
  // 1. Explicit extension form: `[[path.ext]]` where `.ext` is a
@@ -176,6 +185,12 @@ export function resolveWikilink(db: Database, target: string): string | null {
176
185
  `).all(target, `%/${target}`) as { id: string }[];
177
186
 
178
187
  if (basename.length === 1) return basename[0]!.id;
188
+ if (basename.length > 1) return null; // ambiguous basename — don't fall through to title
189
+
190
+ // 4. Title fallback — only reached on a clean miss (0 basename
191
+ // candidates). See the doc comment above for the ambiguity policy.
192
+ const byTitle = findNotesByTitle(db, target);
193
+ if (byTitle.length === 1) return byTitle[0]!.id;
179
194
 
180
195
  // Ambiguous or no match
181
196
  return null;
@@ -246,6 +261,21 @@ export function resolveWikilinkDetailed(db: Database, target: string): WikilinkR
246
261
  };
247
262
  }
248
263
 
264
+ // 4. Title fallback — only reached on a clean basename miss (0
265
+ // candidates), mirroring resolveWikilink.
266
+ const byTitle = findNotesByTitle(db, target);
267
+ if (byTitle.length === 1) {
268
+ const match = byTitle[0]!;
269
+ return { resolved: true, note_id: match.id, path: match.path ?? undefined, candidates: [] };
270
+ }
271
+ if (byTitle.length > 1) {
272
+ return {
273
+ resolved: false,
274
+ ambiguous: true,
275
+ candidates: byTitle.map((r) => ({ note_id: r.id, path: r.path ?? "" })),
276
+ };
277
+ }
278
+
249
279
  return { resolved: false, ambiguous: false, candidates: [] };
250
280
  }
251
281
 
@@ -357,19 +387,14 @@ export function getUnresolvedLinksForNote(db: Database, noteId: string): BrokenL
357
387
  const WIKILINK_REL = "wikilink";
358
388
 
359
389
  /**
360
- * Sync wikilink-based links for a note.
361
- * Parses content for [[wikilinks]], resolves targets, creates/removes links.
362
- *
363
- * Returns counts of changes made.
390
+ * Deduplicate parsed wikilinks by target (case-insensitively) the same
391
+ * target mentioned multiple times in a note's content resolves to ONE
392
+ * link/warning, not one per mention. Shared by {@link syncWikilinks} (which
393
+ * mutates links/the pending-resolution table) and
394
+ * {@link getContentWikilinkWarnings} (which only reads) so the two can't
395
+ * drift on which targets they consider.
364
396
  */
365
- export function syncWikilinks(
366
- db: Database,
367
- noteId: string,
368
- content: string,
369
- ): { added: number; removed: number; unresolved: string[] } {
370
- const parsed = parseWikilinks(content);
371
-
372
- // Deduplicate by target (same target mentioned multiple times = one link)
397
+ function dedupeWikilinkTargets(parsed: ParsedWikilink[]): Map<string, ParsedWikilink> {
373
398
  const targetMap = new Map<string, ParsedWikilink>();
374
399
  for (const wl of parsed) {
375
400
  const key = wl.target.toLowerCase();
@@ -377,17 +402,53 @@ export function syncWikilinks(
377
402
  targetMap.set(key, wl);
378
403
  }
379
404
  }
405
+ return targetMap;
406
+ }
407
+
408
+ /** One target's ambiguity — surfaced target string + how many notes it matched. */
409
+ export interface AmbiguousWikilinkTarget {
410
+ target: string;
411
+ count: number;
412
+ }
413
+
414
+ /**
415
+ * Sync wikilink-based links for a note.
416
+ * Parses content for [[wikilinks]], resolves targets, creates/removes links.
417
+ *
418
+ * Returns counts of changes made. `unresolved` is content wikilinks whose
419
+ * target matched NO note (queued into `unresolved_wikilinks` for lazy
420
+ * backfill — see {@link resolveUnresolvedWikilinks}). `ambiguous` (vault#570)
421
+ * is content wikilinks whose target matched ≥2 notes — these are NEITHER
422
+ * linked NOR queued: a future note being created can't retroactively
423
+ * resolve an ambiguity between two notes that ALREADY exist, and queuing it
424
+ * would risk the pending-resolution sweep later linking to an arbitrary
425
+ * THIRD same-named note rather than reporting the collision. The caller
426
+ * (MCP `create-note`/`update-note`, REST `POST`/`PATCH /notes`) surfaces
427
+ * `ambiguous` as an `ambiguous_link` warning naming the target + match
428
+ * count, distinct from `unresolved`'s `unresolved_link`.
429
+ */
430
+ export function syncWikilinks(
431
+ db: Database,
432
+ noteId: string,
433
+ content: string,
434
+ ): { added: number; removed: number; unresolved: string[]; ambiguous: AmbiguousWikilinkTarget[] } {
435
+ const targetMap = dedupeWikilinkTargets(parseWikilinks(content));
380
436
 
381
437
  // Resolve each unique target
382
438
  const resolvedLinks = new Map<string, { targetId: string; wl: ParsedWikilink }>();
383
439
  const unresolved: string[] = [];
440
+ const ambiguous: AmbiguousWikilinkTarget[] = [];
384
441
 
385
442
  for (const [key, wl] of targetMap) {
386
- const targetId = resolveWikilink(db, wl.target);
387
- if (targetId && targetId !== noteId) {
388
- // Don't create self-links
389
- resolvedLinks.set(targetId, { targetId, wl });
390
- } else if (!targetId) {
443
+ const detail = resolveWikilinkDetailed(db, wl.target);
444
+ if (detail.resolved) {
445
+ // Don't create self-links — silently skipped, same as before.
446
+ if (detail.note_id !== noteId) {
447
+ resolvedLinks.set(detail.note_id!, { targetId: detail.note_id!, wl });
448
+ }
449
+ } else if (detail.ambiguous) {
450
+ ambiguous.push({ target: wl.target, count: detail.candidates.length });
451
+ } else {
391
452
  unresolved.push(wl.target);
392
453
  }
393
454
  }
@@ -429,10 +490,11 @@ export function syncWikilinks(
429
490
  }
430
491
  }
431
492
 
432
- // Store unresolved wikilinks for later resolution
493
+ // Store unresolved wikilinks for later resolution. Ambiguous targets are
494
+ // deliberately NOT queued here — see the doc comment above.
433
495
  syncUnresolvedWikilinks(db, noteId, unresolved);
434
496
 
435
- return { added, removed, unresolved };
497
+ return { added, removed, unresolved, ambiguous };
436
498
  }
437
499
 
438
500
  // ---------------------------------------------------------------------------
@@ -616,14 +678,29 @@ export function resolveUnresolvedWikilinks(
616
678
  /**
617
679
  * Resolve a structured-link `target` — an ID or a path/title — to a note
618
680
  * ID. ID lookup first (structured links accept "note ID or path" per the
619
- * tool docs; wikilinks never carry a raw ID), then the SAME path/basename
620
- * resolution `[[wikilinks]]` use ({@link resolveWikilink}: explicit
621
- * extension, exact path, basename).
681
+ * tool docs; wikilinks never carry a raw ID), then the SAME path/basename/
682
+ * title resolution `[[wikilinks]]` use ({@link resolveWikilink}: explicit
683
+ * extension, exact path, basename, then H1-title fallback on a clean miss).
622
684
  */
623
685
  export function resolveLinkTarget(db: Database, idOrPath: string): string | null {
624
- const byId = db.prepare("SELECT id FROM notes WHERE id = ?").get(idOrPath) as { id: string } | null;
625
- if (byId) return byId.id;
626
- return resolveWikilink(db, idOrPath);
686
+ const detail = resolveLinkTargetDetailed(db, idOrPath);
687
+ return detail.resolved ? detail.note_id! : null;
688
+ }
689
+
690
+ /**
691
+ * Detailed counterpart to {@link resolveLinkTarget} — same ID-then-path/
692
+ * basename resolution order, but surfaces AMBIGUOUS (≥2 path/basename
693
+ * matches, vault#570) as a distinct outcome instead of collapsing it into
694
+ * the same `null` a genuine miss returns. An ID lookup is never ambiguous
695
+ * (the `id` column is the primary key), so ambiguity can only arise from
696
+ * the path/basename fallback — {@link resolveWikilinkDetailed}.
697
+ */
698
+ export function resolveLinkTargetDetailed(db: Database, idOrPath: string): WikilinkResolution {
699
+ const byId = db.prepare("SELECT id, path FROM notes WHERE id = ?").get(idOrPath) as { id: string; path: string | null } | null;
700
+ if (byId) {
701
+ return { resolved: true, note_id: byId.id, path: byId.path ?? undefined, candidates: [] };
702
+ }
703
+ return resolveWikilinkDetailed(db, idOrPath);
627
704
  }
628
705
 
629
706
  /**
@@ -654,24 +731,112 @@ export function queueUnresolvedLink(
654
731
  ).run(sourceId, targetPath, relationship);
655
732
  }
656
733
 
734
+ /** Outcome of {@link resolveOrQueueLink} — a discriminated union so callers can't confuse "queued" with "ambiguous" (vault#570; both used to collapse to the same `null`). */
735
+ export type ResolveOrQueueOutcome =
736
+ | { status: "resolved"; note_id: string }
737
+ | { status: "ambiguous"; candidates: { note_id: string; path: string }[] }
738
+ | { status: "queued" };
739
+
740
+ /**
741
+ * Drop any pending forward-ref queued for `sourceId` under `relationship`,
742
+ * regardless of the (stale) `target_path` it names. Used by the
743
+ * `reference`-field auto-link sync (core/src/store.ts's
744
+ * `syncReferenceFieldLinks`, vault#typed-reference-field) before re-resolving
745
+ * a changed field value — without this, a field that used to point at an
746
+ * unresolved target and is then changed to a different (or removed) value
747
+ * would leave the OLD forward-ref queued forever, since
748
+ * {@link queueUnresolvedLink}'s primary key includes `target_path` and a
749
+ * changed value queues under a NEW key rather than replacing the old row.
750
+ * Safe no-op when the table doesn't exist yet.
751
+ */
752
+ export function clearQueuedLink(db: Database, sourceId: string, relationship: string): void {
753
+ try {
754
+ db.prepare(
755
+ "DELETE FROM unresolved_wikilinks WHERE source_id = ? AND relationship = ?",
756
+ ).run(sourceId, relationship);
757
+ } catch {
758
+ // Table may not exist yet — nothing to clear.
759
+ }
760
+ }
761
+
657
762
  /**
658
763
  * Resolve a structured link NOW, or queue it for lazy resolution when the
659
764
  * target doesn't exist yet — mirroring the wikilink forward-ref contract
660
765
  * (a target created later, in this same batch or a future call, backfills
661
- * the edge automatically via {@link resolveUnresolvedWikilinks}). Returns
662
- * the resolved note ID, or `null` when queued. Callers MUST surface an
663
- * `unresolved_link` warning naming the target when this returns `null` —
664
- * the write itself never fails, but silence is never the fallback
665
- * (vault#555"the API should never silently do the wrong thing").
766
+ * the edge automatically via {@link resolveUnresolvedWikilinks}).
767
+ *
768
+ * Returns a {@link ResolveOrQueueOutcome}:
769
+ * - `"resolved"` the edge should be created against `note_id` now.
770
+ * - `"ambiguous"` (vault#570) — the target matched ≥2 notes (e.g. two
771
+ * notes sharing an H1 title). NEITHER linked nor queued — see
772
+ * {@link syncWikilinks}'s doc comment for why queuing an ambiguous
773
+ * target would be wrong. Callers MUST surface a distinct
774
+ * `ambiguous_link` warning naming the target + `candidates.length`.
775
+ * - `"queued"` — the target matched NO note; queued for lazy backfill.
776
+ * Callers MUST surface an `unresolved_link` warning naming the target.
777
+ *
778
+ * The write itself never fails on either non-`"resolved"` outcome — silence
779
+ * is never the fallback (vault#555 — "the API should never silently do the
780
+ * wrong thing").
666
781
  */
667
782
  export function resolveOrQueueLink(
668
783
  db: Database,
669
784
  sourceId: string,
670
785
  target: string,
671
786
  relationship: string,
672
- ): string | null {
673
- const resolvedId = resolveLinkTarget(db, target);
674
- if (resolvedId) return resolvedId;
787
+ ): ResolveOrQueueOutcome {
788
+ const detail = resolveLinkTargetDetailed(db, target);
789
+ if (detail.resolved) return { status: "resolved", note_id: detail.note_id! };
790
+ if (detail.ambiguous) return { status: "ambiguous", candidates: detail.candidates };
675
791
  queueUnresolvedLink(db, sourceId, target, relationship);
676
- return null;
792
+ return { status: "queued" };
793
+ }
794
+
795
+ /**
796
+ * Content-wikilink counterpart of the `unresolved_link`/`ambiguous_link`
797
+ * warnings structured `links` already produce via {@link resolveOrQueueLink}
798
+ * (vault#570 — content-parsed `[[wikilinks]]` to a missing target used to
799
+ * fire NO write-time warning at all, even though the equivalent structured
800
+ * `links` entry did). READ-ONLY: does not mutate `unresolved_wikilinks` or
801
+ * create/remove any link — that mutation already happened inside
802
+ * {@link syncWikilinks} (called from `Store.createNote`/`updateNote`).
803
+ * Callers invoke this AFTER the note's content is committed, passing the
804
+ * SAME `noteId`/`content` pair, to recompute the identical per-target
805
+ * classification for the response's `warnings` array — single source of
806
+ * truth is {@link resolveWikilinkDetailed}, so this can't drift from what
807
+ * `syncWikilinks` actually did. Mirrors `syncWikilinks`'s target selection
808
+ * exactly: same lowercase-target dedupe key (via
809
+ * {@link dedupeWikilinkTargets}), same self-link skip (a wikilink resolving
810
+ * to the note's own id is neither linked nor warned about).
811
+ */
812
+ export function getContentWikilinkWarnings(
813
+ db: Database,
814
+ noteId: string,
815
+ content: string,
816
+ ): QueryWarning[] {
817
+ const targetMap = dedupeWikilinkTargets(parseWikilinks(content));
818
+ const warnings: QueryWarning[] = [];
819
+
820
+ for (const [, wl] of targetMap) {
821
+ const detail = resolveWikilinkDetailed(db, wl.target);
822
+ if (detail.resolved) continue; // resolved (incl. self-link) — nothing to warn about
823
+ if (detail.ambiguous) {
824
+ warnings.push({
825
+ code: "ambiguous_link",
826
+ 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.`,
827
+ target: wl.target,
828
+ relationship: WIKILINK_REL,
829
+ candidate_count: detail.candidates.length,
830
+ });
831
+ } else {
832
+ warnings.push({
833
+ code: "unresolved_link",
834
+ message: `wikilink target "${wl.target}" did not resolve to any note — queued and will backfill automatically if a matching note is created later.`,
835
+ target: wl.target,
836
+ relationship: WIKILINK_REL,
837
+ });
838
+ }
839
+ }
840
+
841
+ return warnings;
677
842
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.7.0-rc.9",
3
+ "version": "0.7.1",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",