@openparachute/vault 0.7.0-rc.3 → 0.7.0-rc.5

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.
@@ -9,9 +9,12 @@
9
9
  * flipped to real assertions in a later wave. See #552 for the full write-up.
10
10
  */
11
11
 
12
- import { describe, it, test, expect, beforeEach } from "bun:test";
12
+ import { describe, it, expect, beforeEach } from "bun:test";
13
13
  import { Database } from "bun:sqlite";
14
14
  import { SqliteStore } from "./store.js";
15
+ import { generateMcpTools } from "./mcp.js";
16
+ import { ParentCycleError } from "./tag-schemas.js";
17
+ import { declareField } from "./indexed-fields.js";
15
18
 
16
19
  let store: SqliteStore;
17
20
  let db: Database;
@@ -100,20 +103,193 @@ describe("contract: taxonomy — passing (lock in current behavior)", () => {
100
103
  });
101
104
  });
102
105
 
103
- describe("contract: taxonomy — todo (#552)", () => {
104
- test.todo(
105
- "#552: deleting a tag still referenced in another tag's parent_names errors unless cascade/detach is passed (today: store.deleteTag succeeds unconditionally and leaves the referencing tag's parent_names pointing at a now-nonexistent tag name — verified with a fresh parent/child fixture)",
106
- );
107
- test.todo(
108
- "#552: a parent_names cycle (A declares parent B, then B declares parent A) is rejected at write time (today: store.upsertTagRecord accepts both writes with no cycle check — traversal elsewhere is cycle-safe, but the write itself is not honest about creating one)",
109
- );
110
- test.todo(
111
- "#552: rename-tag is exposed as an MCP tool, not just a REST endpoint (today: POST /api/tags/:name/rename exists and store.renameTag is wired, but generateMcpTools has no rename-tag tool — an MCP-only agent cannot rename a tag)",
112
- );
113
- test.todo(
114
- "#552: merge-tags is exposed as an MCP tool, not just a REST endpoint (today: POST /api/tags/merge exists and store.mergeTags is wired, but generateMcpTools has no merge-tags tool)",
115
- );
116
- test.todo(
117
- "#552: a `vault doctor` integrity scan reports dangling parent_names references (today: no doctor/scan surface inspects parent_names for referential integrity — the delete-tag gap above goes undetected)",
118
- );
106
+ describe("contract: taxonomy — #552 (flipped from test.todo)", () => {
107
+ it("rename-tag is exposed as an MCP tool and cascades parent_names — the gardener's exact bug no longer happens", async () => {
108
+ // Root tag, a child that declares it as a parent (the parent_names
109
+ // surface the gardener's rename left orphaned), and a note tagged with
110
+ // the CHILD only (proves subtype expansion still finds it post-rename).
111
+ await store.upsertTagRecord("proj", { description: "root project tag" });
112
+ await store.upsertTagRecord("special", { parent_names: ["proj"] });
113
+ const childTagged = await store.createNote("filed under the child tag", { tags: ["special"] });
114
+
115
+ const tools = generateMcpTools(store);
116
+ const renameTag = tools.find((t) => t.name === "rename-tag");
117
+ expect(renameTag).toBeDefined();
118
+ expect(renameTag!.requiredVerb).toBe("write");
119
+
120
+ const result = await renameTag!.execute({ old_name: "proj", new_name: "initiative" }) as any;
121
+ expect("error" in result).toBe(false);
122
+ expect(result.parent_refs_updated).toBeGreaterThanOrEqual(1);
123
+
124
+ // The exact gardener's bug: "special"'s parent_names must follow the
125
+ // rename, not keep pointing at the now-dead "proj".
126
+ const special = await store.getTagRecord("special");
127
+ expect(special?.parent_names).toEqual(["initiative"]);
128
+
129
+ // The renamed-away tag is NOT a live query surface anymore.
130
+ const projRecord = await store.getTagRecord("proj");
131
+ expect(projRecord).toBeNull();
132
+
133
+ // The NEW tag doesn't silently miss child-tagged notes (the gardener's
134
+ // second symptom): subtype expansion of "initiative" still reaches the
135
+ // note tagged only with the child "special".
136
+ const viaExpansion = await store.queryNotes({ tags: ["initiative"] });
137
+ expect(viaExpansion.map((n) => n.id)).toContain(childTagged.id);
138
+ });
139
+
140
+ it("rename-tag MCP tool reports target_exists (not a silent overwrite) and tag_not_found for a missing source", async () => {
141
+ await store.upsertTagRecord("a", {});
142
+ await store.upsertTagRecord("b", {});
143
+ const tools = generateMcpTools(store);
144
+ const renameTag = tools.find((t) => t.name === "rename-tag")!;
145
+
146
+ let caught: any;
147
+ try {
148
+ await renameTag.execute({ old_name: "a", new_name: "b" });
149
+ } catch (e) { caught = e; }
150
+ expect(caught?.error_type).toBe("target_exists");
151
+ expect(caught?.conflicting).toContain("b");
152
+
153
+ caught = undefined;
154
+ try {
155
+ await renameTag.execute({ old_name: "nonexistent", new_name: "whatever" });
156
+ } catch (e) { caught = e; }
157
+ expect(caught?.error_type).toBe("tag_not_found");
158
+ });
159
+
160
+ it("merge-tags is exposed as an MCP tool and merges N sources into one target", async () => {
161
+ await store.upsertTagRecord("draft", {});
162
+ await store.upsertTagRecord("wip", {});
163
+ const draftNote = await store.createNote("draft note", { tags: ["draft"] });
164
+ const wipNote = await store.createNote("wip note", { tags: ["wip"] });
165
+
166
+ const tools = generateMcpTools(store);
167
+ const mergeTags = tools.find((t) => t.name === "merge-tags");
168
+ expect(mergeTags).toBeDefined();
169
+ expect(mergeTags!.requiredVerb).toBe("write");
170
+
171
+ const result = await mergeTags!.execute({ sources: ["draft", "wip"], target: "active" }) as any;
172
+ expect(result.target).toBe("active");
173
+ expect(result.merged.draft).toBe(1);
174
+ expect(result.merged.wip).toBe(1);
175
+
176
+ const draftAfter = await store.getNote(draftNote.id);
177
+ const wipAfter = await store.getNote(wipNote.id);
178
+ expect(draftAfter!.tags).toEqual(["active"]);
179
+ expect(wipAfter!.tags).toEqual(["active"]);
180
+ expect(await store.getTagRecord("draft")).toBeNull();
181
+ expect(await store.getTagRecord("wip")).toBeNull();
182
+ });
183
+
184
+ it("delete-tag refuses a tag still referenced in another tag's parent_names, then cascade/detach both proceed and strip the reference", async () => {
185
+ await store.upsertTagRecord("proj", { description: "root" });
186
+ await store.upsertTagRecord("child", { parent_names: ["proj"] });
187
+
188
+ const tools = generateMcpTools(store);
189
+ const deleteTag = tools.find((t) => t.name === "delete-tag")!;
190
+
191
+ // Default (no flag): refused, nothing mutated.
192
+ const refused = await deleteTag.execute({ tag: "proj" }) as any;
193
+ expect(refused.error).toBe("tag_referenced_as_parent");
194
+ expect(refused.referencing_tags).toEqual(["child"]);
195
+ expect(await store.getTagRecord("proj")).not.toBeNull();
196
+ expect((await store.getTagRecord("child"))?.parent_names).toEqual(["proj"]);
197
+
198
+ // cascade: true proceeds and strips the stale reference.
199
+ const cascaded = await deleteTag.execute({ tag: "proj", cascade: true }) as any;
200
+ expect(cascaded.deleted).toBe(true);
201
+ expect(cascaded.parent_refs_detached).toBe(1);
202
+ expect(await store.getTagRecord("proj")).toBeNull();
203
+ expect((await store.getTagRecord("child"))?.parent_names ?? null).toBeFalsy();
204
+
205
+ // detach is a synonym — same repair, exercised on a second fixture.
206
+ await store.upsertTagRecord("proj2", { description: "root2" });
207
+ await store.upsertTagRecord("child2", { parent_names: ["proj2"] });
208
+ const detached = await deleteTag.execute({ tag: "proj2", detach: true }) as any;
209
+ expect(detached.deleted).toBe(true);
210
+ expect(detached.parent_refs_detached).toBe(1);
211
+ expect((await store.getTagRecord("child2"))?.parent_names ?? null).toBeFalsy();
212
+ });
213
+
214
+ it("a parent_names cycle (direct A→B→A, and bare self-parent) is rejected at write time with error_type parent_cycle", async () => {
215
+ await store.upsertTagRecord("B", {});
216
+ await store.upsertTagRecord("A", { parent_names: ["B"] });
217
+
218
+ let caught: unknown;
219
+ try {
220
+ await store.upsertTagRecord("B", { parent_names: ["A"] });
221
+ } catch (e) { caught = e; }
222
+ expect(caught).toBeInstanceOf(ParentCycleError);
223
+ const cycleErr = caught as ParentCycleError;
224
+ expect(cycleErr.error_type).toBe("parent_cycle");
225
+ expect(cycleErr.tag).toBe("B");
226
+ expect(cycleErr.cycle).toContain("A");
227
+ expect(cycleErr.cycle).toContain("B");
228
+ // Nothing persisted — B's parent_names is untouched by the rejected write.
229
+ expect((await store.getTagRecord("B"))?.parent_names ?? null).toBeFalsy();
230
+
231
+ // Bare self-parent: also caught (getTagDescendants always includes the
232
+ // tag itself).
233
+ let selfCaught: unknown;
234
+ try {
235
+ await store.upsertTagRecord("C", { parent_names: ["C"] });
236
+ } catch (e) { selfCaught = e; }
237
+ expect(selfCaught).toBeInstanceOf(ParentCycleError);
238
+ expect((selfCaught as ParentCycleError).cycle).toEqual(["C", "C"]);
239
+ });
240
+
241
+ it("doctor reports each finding type on a seeded-broken fixture, and stays clean on a healthy one", async () => {
242
+ // 1. dangling_parent_name — "ghost-parent" is never created as a tag row.
243
+ await store.upsertTagRecord("orphan-child", { parent_names: ["ghost-parent"] });
244
+
245
+ // 2. parent_names_cycle — the write-time guard blocks NEW cycles, so
246
+ // simulate pre-existing/pre-guard cyclic data with a direct DB write
247
+ // (same technique core.test.ts's cycle-tolerance tests use).
248
+ await store.upsertTagRecord("cyc-a", {});
249
+ await store.upsertTagRecord("cyc-b", { parent_names: ["cyc-a"] });
250
+ db.prepare("UPDATE tags SET parent_names = ? WHERE name = ?").run(JSON.stringify(["cyc-b"]), "cyc-a");
251
+
252
+ // 3. mixed_type_indexed_field — "n" is indexed INTEGER, but this note's
253
+ // metadata.n is a JSON string. Not `strict`, so the write succeeds with
254
+ // just an advisory warning — landing exactly the mismatched value.
255
+ await store.upsertTagRecord("metric", { fields: { n: { type: "integer", indexed: true } } });
256
+ await store.createNote("bad metric", { tags: ["metric"], metadata: { n: "not-a-number" } });
257
+
258
+ // 4. orphaned_indexed_field_declarer — declare directly via the
259
+ // indexed-fields API, then drop the tag row WITHOUT releasing (the
260
+ // pre-fix gitcoin state prune-schema's own tests seed identically).
261
+ declareField(db, "legacy_status", "TEXT", "ghost-declarer");
262
+
263
+ // 5. dead_tag_metadata_reference (heuristic) — "epic" holds tag-shaped
264
+ // values elsewhere (a live tag), so a value that ISN'T a live tag is
265
+ // the drift signal.
266
+ await store.upsertTagRecord("launch-v2", {});
267
+ await store.createNote("current epic", { tags: ["launch-v2"], metadata: { epic: "launch-v2" } });
268
+ await store.createNote("stale epic reference", { tags: ["launch-v2"], metadata: { epic: "launch-v1" } });
269
+
270
+ const report = await store.doctor();
271
+ const byType = new Map(report.findings.map((f) => [f.type, f]));
272
+
273
+ expect(byType.get("dangling_parent_name")?.subject).toBe("orphan-child");
274
+ expect(byType.get("parent_names_cycle")).toBeDefined();
275
+ expect(byType.get("mixed_type_indexed_field")?.subject).toBe("n");
276
+ expect(byType.get("mixed_type_indexed_field")?.severity).toBe("error");
277
+ expect(byType.get("orphaned_indexed_field_declarer")?.subject).toBe("legacy_status");
278
+ const driftFinding = byType.get("dead_tag_metadata_reference");
279
+ expect(driftFinding?.subject).toBe("metadata.epic");
280
+ expect(driftFinding?.heuristic).toBe(true);
281
+ expect(driftFinding?.detail).toContain("launch-v1");
282
+
283
+ // Every finding type from the fixture is present — five distinct types.
284
+ expect(byType.size).toBe(5);
285
+
286
+ // A fresh, healthy vault reports clean.
287
+ const cleanDb = new Database(":memory:");
288
+ const cleanStore = new SqliteStore(cleanDb);
289
+ await cleanStore.upsertTagRecord("healthy", { description: "nothing wrong here" });
290
+ await cleanStore.createNote("fine", { tags: ["healthy"] });
291
+ const cleanReport = await cleanStore.doctor();
292
+ expect(cleanReport.findings).toEqual([]);
293
+ expect(cleanReport.summary).toContain("clean");
294
+ });
119
295
  });
@@ -22,6 +22,7 @@ import { Database } from "bun:sqlite";
22
22
  import { SqliteStore } from "./store.js";
23
23
  import { generateMcpTools } from "./mcp.js";
24
24
  import { TransitionConflictError } from "./notes.js";
25
+ import { TagFieldConflictError } from "./tag-schemas.js";
25
26
 
26
27
  let store: SqliteStore;
27
28
  let db: Database;
@@ -97,10 +98,98 @@ describe("contract: typed indexes — todo (#553)", () => {
97
98
  test.todo(
98
99
  `#553: an unset enum field stays ABSENT — no first-value backfill — so exists:false correctly matches a note that never set the field (today: applySchemaDefaults in core/src/mcp.ts backfills the schema's first enum value onto every note that gains the tag, so "never set" is indistinguishable from "explicitly set to the default" and exists:false never matches)`,
99
100
  );
100
- test.todo(
101
- `#553: update-tag reports ALL invalid fields in one call AND states explicitly that no changes were applied (today: the cross-tag type/indexed-flag validation loop in core/src/mcp.ts throws on the FIRST offending field and never reports the rest, and the thrown Error's message doesn't say whether the tag's other, valid fields were partially applied — reproduced live: declaring two invalid fields in one update-tag call surfaces only the first field's error)`,
102
- );
103
101
  test.todo(
104
102
  `#553: the indexed-field type list is honest about what's actually indexable (core/src/mcp.ts's update-tag field-type description advertises "string, boolean, integer, number, array, object" but indexed-fields.ts's TYPE_MAP only supports string/integer/boolean — "number" and the container types are accepted as declared but silently un-indexable)`,
105
103
  );
106
104
  });
105
+
106
+ describe("contract: update-tag messaging — #553/#554 (flipped from todo)", () => {
107
+ it("update-tag reports ALL invalid fields in one call (not just the first) and states explicitly that no changes were applied", async () => {
108
+ // Tag "a" declares two fields. Tag "b" then redeclares BOTH with
109
+ // conflicting specs — a NON-indexed type conflict on "x" AND an
110
+ // indexed-flag conflict on "y" — in the SAME update-tag call. Pre-#553
111
+ // the cross-tag validation loop threw on the first offending field
112
+ // ("x") and never even evaluated "y"; two testers independently assumed
113
+ // the whole call (including "y") had partially landed. (The
114
+ // BOTH-indexed type-conflict case is deliberately absent here — it
115
+ // keeps its pre-existing declareField → IndexedFieldError path; see
116
+ // `collectCrossTagFieldViolations`'s doc-comment exclusion 2 and the
117
+ // both-indexed test below.)
118
+ await store.upsertTagRecord("a", {
119
+ fields: {
120
+ x: { type: "string" },
121
+ y: { type: "boolean", indexed: true },
122
+ },
123
+ });
124
+
125
+ const tools = generateMcpTools(store);
126
+ const updateTag = tools.find((t) => t.name === "update-tag")!;
127
+
128
+ let caught: unknown;
129
+ try {
130
+ await updateTag.execute({
131
+ tag: "b",
132
+ fields: {
133
+ x: { type: "integer" }, // non-indexed type_conflict vs tag "a"
134
+ y: { type: "boolean", indexed: false }, // indexed_flag_conflict vs tag "a"
135
+ },
136
+ });
137
+ } catch (e) {
138
+ caught = e;
139
+ }
140
+
141
+ expect(caught).toBeInstanceOf(TagFieldConflictError);
142
+ const err = caught as TagFieldConflictError;
143
+ expect(err.violations).toHaveLength(2);
144
+ const byField = new Map(err.violations.map((v) => [v.field, v.reason]));
145
+ expect(byField.get("x")).toBe("type_conflict");
146
+ expect(byField.get("y")).toBe("indexed_flag_conflict");
147
+ // States explicitly that no changes were applied.
148
+ expect(err.message).toContain("no changes were applied");
149
+ // Structured conflicting-declarer field (scrubbed by the server layer
150
+ // for tag-scoped sessions; full detail here — core is scope-unaware).
151
+ expect(err.violations[0]!.other_tag).toBe("a");
152
+
153
+ // Nothing partially landed — tag "b" has no field declarations at all.
154
+ const bRecord = await store.getTagRecord("b");
155
+ expect(bRecord?.fields ?? null).toBeFalsy();
156
+ });
157
+
158
+ it("a BOTH-indexed cross-tag type conflict keeps its pre-existing IndexedFieldError path (not TagFieldConflictError)", async () => {
159
+ // Wire-contract floor (vault#554): this exact case already errored on
160
+ // main via store.upsertTagRecord → declareField's cross-declarer
161
+ // sqlite-type check → IndexedFieldError (REST maps it to 400
162
+ // invalid_indexed_field). The new pre-check must not intercept it.
163
+ await store.upsertTagRecord("a", {
164
+ fields: { x: { type: "string", indexed: true } },
165
+ });
166
+
167
+ const tools = generateMcpTools(store);
168
+ const updateTag = tools.find((t) => t.name === "update-tag")!;
169
+
170
+ let caught: any;
171
+ try {
172
+ await updateTag.execute({
173
+ tag: "b",
174
+ fields: { x: { type: "integer", indexed: true } },
175
+ });
176
+ } catch (e) {
177
+ caught = e;
178
+ }
179
+
180
+ expect(caught).toBeDefined();
181
+ expect(caught).not.toBeInstanceOf(TagFieldConflictError);
182
+ expect(caught.name).toBe("IndexedFieldError");
183
+ expect(caught.error_type).toBe("invalid_indexed_field");
184
+ // The structured declarer context the server's tag-scope scrub keys on.
185
+ expect(caught.field).toBe("x");
186
+ expect(caught.declarer_tags).toEqual(["a"]);
187
+
188
+ // The store's transaction rolled back — nothing persisted for "b".
189
+ const bRecord = await store.getTagRecord("b");
190
+ expect(bRecord?.fields ?? null).toBeFalsy();
191
+ });
192
+ // REST `PUT /api/tags/:name` coverage for the same behavior lives in
193
+ // src/contract-errors.test.ts (core/ shouldn't import from src/ — that
194
+ // boundary runs one direction only).
195
+ });
@@ -2030,6 +2030,12 @@ describe("MCP tools", async () => {
2030
2030
  // prune-schema (admin) — drops orphaned indexed-field columns whose
2031
2031
  // declaring tags are gone. The gitcoin orphaned-fields fix.
2032
2032
  expect(names).toContain("prune-schema");
2033
+ // rename-tag / merge-tags (vault#552) — MCP parity with the
2034
+ // pre-existing REST rename/merge engine. doctor (admin, vault#552) —
2035
+ // read-only taxonomy/metadata integrity scan.
2036
+ expect(names).toContain("rename-tag");
2037
+ expect(names).toContain("merge-tags");
2038
+ expect(names).toContain("doctor");
2033
2039
  // Six note-schema tools (list/update/delete-note-schema +
2034
2040
  // list/set/delete-schema-mapping) retired in v17 — the standalone
2035
2041
  // note_schemas + schema_mappings subsystem was a parallel path to
@@ -2043,7 +2049,7 @@ describe("MCP tools", async () => {
2043
2049
  // synthesize-notes retired in v17 — replicable with query-notes(near=) +
2044
2050
  // find-path + agent-side aggregation. See vault#268.
2045
2051
  expect(names).not.toContain("synthesize-notes");
2046
- expect(tools).toHaveLength(10);
2052
+ expect(tools).toHaveLength(13);
2047
2053
  });
2048
2054
 
2049
2055
  it("create-note tool works", async () => {
@@ -4170,7 +4176,17 @@ describe("tag hierarchy (tags.parent_names)", async () => {
4170
4176
 
4171
4177
  it("tolerates a cycle without infinite-looping", async () => {
4172
4178
  await store.upsertTagRecord("a", { parent_names: ["b"] });
4173
- await store.upsertTagRecord("b", { parent_names: ["a"] });
4179
+ await store.upsertTagRecord("b", {}); // bare row, no parent yet
4180
+ // vault#552: upsertTagRecord now REJECTS a parent_names write that would
4181
+ // close a cycle, so the second leg of the cycle can no longer be built
4182
+ // via the public API — simulate pre-existing cyclic data (written
4183
+ // before the guard shipped, or via any path that bypasses it) with a
4184
+ // direct DB write, same technique as "malformed parent_names JSON is
4185
+ // ignored silently" below. Traversal must stay cycle-safe regardless of
4186
+ // how the cycle got there.
4187
+ (store as any).db.prepare("UPDATE tags SET parent_names = ? WHERE name = ?")
4188
+ .run(JSON.stringify(["a"]), "b");
4189
+ (store as any)._tagHierarchy = null;
4174
4190
  await store.createNote("note-a", { tags: ["a"] });
4175
4191
 
4176
4192
  // Both a and b should resolve without hanging; both reach the same set {a, b}.
@@ -4803,14 +4819,21 @@ describe("schema inheritance via parent_names (vault#270)", async () => {
4803
4819
  });
4804
4820
 
4805
4821
  it("cycle: A→B, B→A — no infinite loop, both fields visible", async () => {
4822
+ await store.upsertTagRecord("B", {
4823
+ fields: { b_field: { type: "string" } },
4824
+ });
4806
4825
  await store.upsertTagRecord("A", {
4807
4826
  parent_names: ["B"],
4808
4827
  fields: { a_field: { type: "string" } },
4809
4828
  });
4810
- await store.upsertTagRecord("B", {
4811
- parent_names: ["A"],
4812
- fields: { b_field: { type: "string" } },
4813
- });
4829
+ // vault#552: upsertTagRecord now REJECTS the write that would close this
4830
+ // cycle (B declaring A as parent, when A already declares B) — simulate
4831
+ // pre-existing cyclic data with a direct DB write, same technique as the
4832
+ // "tolerates a cycle without infinite-looping" test above.
4833
+ (store as any).db.prepare("UPDATE tags SET parent_names = ? WHERE name = ?")
4834
+ .run(JSON.stringify(["A"]), "B");
4835
+ (store as any)._tagHierarchy = null;
4836
+ (store as any)._schemaConfig = null;
4814
4837
 
4815
4838
  const tools = generateMcpTools(store);
4816
4839
  const create = tools.find((t) => t.name === "create-note")!;
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Tag-scope confidentiality for the `vault doctor` integrity scan (vault#552
3
+ * auth fold). Two finding types query notes VAULT-WIDE and previously leaked
4
+ * out-of-scope data to a tag-scoped caller:
5
+ *
6
+ * - `mixed_type_indexed_field`: the note query + mismatch count + exemplars
7
+ * + the "Declared by: <tags>" detail were all unscoped — a caller scoped
8
+ * to `mine` could see an out-of-scope declarer tag name AND an
9
+ * out-of-scope note id.
10
+ * - `dead_tag_metadata_reference`: the example live value (the `e.g.
11
+ * "<value>"` in detail) was computed vault-wide — leaking an out-of-scope
12
+ * tag NAME — and (unscoped) exemplars could include out-of-scope note ids.
13
+ *
14
+ * Each test asserts the out-of-scope tag name / note id appears NOWHERE in
15
+ * the serialized findings when scoped, and pairs with an UNSCOPED positive
16
+ * control proving the fixture genuinely carries that leak-able data (so the
17
+ * scoped assertion isn't vacuous — remove the scope filter and the control's
18
+ * leak reappears in the scoped run).
19
+ */
20
+ import { describe, it, expect, beforeEach } from "bun:test";
21
+ import { Database } from "bun:sqlite";
22
+ import { SqliteStore } from "./store.js";
23
+ import { runDoctorScan } from "./doctor.js";
24
+
25
+ const OUT_OF_SCOPE_TAG = "theirs-secret";
26
+
27
+ let store: SqliteStore;
28
+ let db: Database;
29
+
30
+ beforeEach(() => {
31
+ db = new Database(":memory:");
32
+ store = new SqliteStore(db);
33
+ });
34
+
35
+ describe("doctor mixed_type_indexed_field × tag scope (vault#552 MUST-FIX 3)", () => {
36
+ beforeEach(async () => {
37
+ // Field `n` is indexed INTEGER, co-declared by an in-scope tag (`mine`)
38
+ // and an out-of-scope one (`theirs-secret`). Both carry a note whose
39
+ // metadata.n is a JSON string (type mismatch), not strict so the write
40
+ // lands the poison value.
41
+ await store.upsertTagRecord("mine", { fields: { n: { type: "integer", indexed: true } } });
42
+ await store.upsertTagRecord(OUT_OF_SCOPE_TAG, { fields: { n: { type: "integer", indexed: true } } });
43
+ await store.createNote("in-scope mismatch", { id: "mine-mm", tags: ["mine"], metadata: { n: "bad-mine" } });
44
+ await store.createNote("out-of-scope mismatch", { id: "theirs-mm", tags: [OUT_OF_SCOPE_TAG], metadata: { n: "bad-theirs" } });
45
+ });
46
+
47
+ it("scoped caller: the out-of-scope declarer name and out-of-scope note id appear NOWHERE", async () => {
48
+ const report = await store.doctor({ allowedTags: new Set(["mine"]) });
49
+ const finding = report.findings.find((f) => f.type === "mixed_type_indexed_field");
50
+ expect(finding).toBeDefined(); // the in-scope mismatch still fires it
51
+ expect(finding!.subject).toBe("n");
52
+
53
+ const serialized = JSON.stringify(report.findings);
54
+ expect(serialized).not.toContain(OUT_OF_SCOPE_TAG);
55
+ expect(serialized).not.toContain("theirs-mm");
56
+ // The in-scope exemplar IS present (proves the finding is real, not empty).
57
+ expect(serialized).toContain("mine-mm");
58
+ // Count reflects the single in-scope mismatch, not both.
59
+ expect(finding!.detail).toContain("1 note(s)");
60
+ expect(finding!.detail).toContain("Declared by: mine.");
61
+ });
62
+
63
+ it("unscoped control: the same fixture DOES surface the out-of-scope declarer + note (mutation-check)", async () => {
64
+ const report = await runDoctorScan(db); // no allowedTags
65
+ const serialized = JSON.stringify(report.findings);
66
+ // Absent the scope filter, both the out-of-scope declarer name and the
67
+ // out-of-scope note id leak — this is exactly what the scoped run above
68
+ // must suppress. If the scope filter were removed, the scoped assertions
69
+ // would see these too and fail.
70
+ expect(serialized).toContain(OUT_OF_SCOPE_TAG);
71
+ expect(serialized).toContain("theirs-mm");
72
+ });
73
+ });
74
+
75
+ describe("doctor dead_tag_metadata_reference × tag scope (vault#552 MUST-FIX 4)", () => {
76
+ beforeEach(async () => {
77
+ await store.upsertTagRecord("mine", {});
78
+ await store.upsertTagRecord(OUT_OF_SCOPE_TAG, {});
79
+ // Insertion ORDER matters: the out-of-scope live-tag value is created
80
+ // FIRST so the vault-wide `liveValues[0]` example would be
81
+ // "theirs-secret" absent the scope fix.
82
+ await store.createNote("oos live", { id: "theirs-live", tags: [OUT_OF_SCOPE_TAG], metadata: { epic: OUT_OF_SCOPE_TAG } });
83
+ await store.createNote("in-scope live", { id: "mine-live", tags: ["mine"], metadata: { epic: "mine" } });
84
+ await store.createNote("in-scope dead", { id: "mine-dead", tags: ["mine"], metadata: { epic: "ghost-epic" } });
85
+ await store.createNote("oos dead", { id: "theirs-dead", tags: [OUT_OF_SCOPE_TAG], metadata: { epic: "ghost-epic" } });
86
+ });
87
+
88
+ it("scoped caller: the out-of-scope example tag name and out-of-scope note id appear NOWHERE", async () => {
89
+ const report = await store.doctor({ allowedTags: new Set(["mine"]) });
90
+ const finding = report.findings.find((f) => f.type === "dead_tag_metadata_reference");
91
+ expect(finding).toBeDefined();
92
+ expect(finding!.subject).toBe("metadata.epic");
93
+ expect(finding!.heuristic).toBe(true);
94
+
95
+ const serialized = JSON.stringify(report.findings);
96
+ // No out-of-scope tag NAME (as the `e.g. "<value>"` live example) and no
97
+ // out-of-scope note id (as an exemplar of the dead value).
98
+ expect(serialized).not.toContain(OUT_OF_SCOPE_TAG);
99
+ expect(serialized).not.toContain("theirs-dead");
100
+ expect(serialized).not.toContain("theirs-live");
101
+ // The in-scope live example + in-scope exemplar + dead value ARE present.
102
+ expect(finding!.detail).toContain('e.g. "mine"');
103
+ expect(serialized).toContain("mine-dead");
104
+ expect(serialized).toContain("ghost-epic");
105
+ });
106
+
107
+ it("unscoped control: the same fixture leaks the out-of-scope example name + note id (mutation-check)", async () => {
108
+ const report = await runDoctorScan(db); // no allowedTags
109
+ const finding = report.findings.find((f) => f.type === "dead_tag_metadata_reference")!;
110
+ const serialized = JSON.stringify(report.findings);
111
+ // Vault-wide, the example live value is the first-inserted live tag
112
+ // ("theirs-secret"), and the out-of-scope note id is an exemplar — both
113
+ // the leaks the scoped run suppresses.
114
+ expect(finding.detail).toContain(`e.g. "${OUT_OF_SCOPE_TAG}"`);
115
+ expect(serialized).toContain("theirs-dead");
116
+ });
117
+ });