@openparachute/vault 0.6.4-rc.9 → 0.6.5-rc.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.
package/README.md CHANGED
@@ -943,7 +943,7 @@ cp .env.example .env # edit with your config
943
943
  docker compose up -d
944
944
  ```
945
945
 
946
- Optionally set `PARACHUTE_VAULT_NAME` to choose a name for your first vault (defaults to `default`). Lowercase alphanumeric + hyphens or underscores, 2–32 chars.
946
+ Set `PARACHUTE_VAULT_NAME` to auto-create a first vault on startup (name: lowercase alphanumeric + hyphens or underscores, 2–32 chars). Without it the server boots with zero vaults — create one explicitly via the admin wizard, `parachute init --vault-name <name>`, or `parachute-vault create <name>`.
947
947
 
948
948
  ### Cloud platforms
949
949
 
@@ -968,9 +968,11 @@ typical v0.6 self-host wants. If you're not sure, use the hub-managed
968
968
  path above. The standalone Blueprint stays in tree because some
969
969
  operators specifically want this shape (vault#341).
970
970
 
971
- Optionally set `PARACHUTE_VAULT_NAME` to choose a name for your first
972
- vault (defaults to `default`). Lowercase alphanumeric + hyphens or
973
- underscores, 2–32 chars.
971
+ Set `PARACHUTE_VAULT_NAME` to auto-create a first vault on startup (name:
972
+ lowercase alphanumeric + hyphens or underscores, 2–32 chars). Without it
973
+ the server boots with zero vaults — create one explicitly via the admin
974
+ wizard, `parachute init --vault-name <name>`, or `parachute-vault create
975
+ <name>`.
974
976
 
975
977
  #### Other platforms
976
978
 
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Shared deterministic vault builder + export-tree serializer for the
3
+ * portable-md "old bytes == new bytes" golden fixture (Phase-1 streaming
4
+ * export refactor).
5
+ *
6
+ * Both the one-shot capture script (scripts/capture-portable-golden.ts,
7
+ * run against the PRE-refactor code) and the regression test
8
+ * (portable-md-golden.test.ts, run against the POST-refactor code) build
9
+ * the SAME vault here and serialize the export tree the SAME way — so a
10
+ * byte diff between the committed golden JSON and a fresh export is a real
11
+ * drift, not a fixture-vs-test mismatch.
12
+ *
13
+ * Determinism requirements met here:
14
+ * - explicit note ids + pinned created_at/updated_at (no wall-clock)
15
+ * - export called with `exportedAt` + `caseSensitiveOverride: true`
16
+ * (so the bytes don't depend on the runner's filesystem)
17
+ * - content that exercises every format field: pathed / unpathed /
18
+ * empty-content notes, a non-md (csv) note that forces a notes-meta
19
+ * sidecar, tags with schema + relationships, typed links, multi-line
20
+ * + control-character metadata, and created_at != updated_at.
21
+ */
22
+
23
+ import { readdirSync, readFileSync, statSync } from "fs";
24
+ import { join } from "path";
25
+ import type { Store } from "../types.js";
26
+
27
+ /** Fixed export timestamp — pins vault.yaml's `exported_at`. */
28
+ export const GOLDEN_EXPORTED_AT = "2026-07-02T00:00:00.000Z";
29
+
30
+ /**
31
+ * Populate `store` with a deterministic, format-exhaustive vault. Callers
32
+ * export it with `{ exportedAt: GOLDEN_EXPORTED_AT, caseSensitiveOverride: true }`.
33
+ */
34
+ export async function buildGoldenVault(store: Store): Promise<void> {
35
+ // Tag schema + opaque relationship vocabulary (vault#428 shape).
36
+ await store.upsertTagSchema("project", {
37
+ description: "A long-running effort",
38
+ fields: { status: { type: "string", enum: ["active", "done"], indexed: true } },
39
+ });
40
+ await store.upsertTagRecord("project", {
41
+ relationships: {
42
+ "works-on": { from: "person", to: "project" },
43
+ "based-at": { from: "project", to: "place", note: "freeform" },
44
+ },
45
+ });
46
+
47
+ const t0 = "2026-01-01T00:00:00.000Z";
48
+ const t1 = "2026-01-01T00:01:00.000Z";
49
+
50
+ await store.createNote("alpha body", {
51
+ id: "01HX001",
52
+ path: "Inbox/alpha",
53
+ tags: ["project", "z-other"],
54
+ metadata: {
55
+ priority: "high",
56
+ notes: "line1\nline2\nline3",
57
+ // control character exercises the \xNN escape path (vault#317 F1)
58
+ ctrl: "a\tb",
59
+ status: "active",
60
+ },
61
+ created_at: t0,
62
+ });
63
+ await store.createNote("beta body", {
64
+ id: "01HX002",
65
+ path: "Inbox/beta",
66
+ tags: ["project"],
67
+ metadata: { status: "done" },
68
+ created_at: t0,
69
+ });
70
+ await store.createNote("unpathed jot", { id: "01HX003", created_at: t0 });
71
+ // Empty-content skeleton note (vault#323).
72
+ await store.createNote("", {
73
+ id: "01HX004",
74
+ path: "Inbox/skeleton",
75
+ tags: ["project"],
76
+ created_at: t0,
77
+ });
78
+ // Non-md note → forces a .parachute/notes-meta/<id>.yaml sidecar.
79
+ await store.createNote("col1,col2\n1,2\n", {
80
+ id: "01HX005",
81
+ path: "Data/table",
82
+ extension: "csv",
83
+ tags: ["z-other"],
84
+ metadata: { rows: 2 },
85
+ created_at: t0,
86
+ });
87
+
88
+ await store.createLink("01HX001", "01HX002", "derived-from", { source: "git://example" });
89
+
90
+ // Pin every timestamp so the export bytes are wall-clock-independent.
91
+ // n1 gets a divergent updated_at to exercise restoreNoteTimestamps.
92
+ await store.restoreNoteTimestamps("01HX001", t0, t1);
93
+ for (const id of ["01HX002", "01HX003", "01HX004", "01HX005"]) {
94
+ await store.restoreNoteTimestamps(id, t0, t0);
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Recursively serialize an export directory into a sorted map of
100
+ * `<relative-posix-path>` → file content. Directories are recorded as
101
+ * entries with a trailing `/` and an empty value so an intentionally
102
+ * empty dir (e.g. `.parachute/schemas/` on a schema-less vault) still
103
+ * shows up in the byte comparison.
104
+ */
105
+ export function serializeExportTree(dir: string): Record<string, string> {
106
+ const out: Record<string, string> = {};
107
+ const walk = (abs: string, rel: string): void => {
108
+ const entries = readdirSync(abs).sort();
109
+ if (entries.length === 0 && rel !== "") {
110
+ out[rel + "/"] = "";
111
+ return;
112
+ }
113
+ for (const entry of entries) {
114
+ const childAbs = join(abs, entry);
115
+ const childRel = rel === "" ? entry : `${rel}/${entry}`;
116
+ if (statSync(childAbs).isDirectory()) {
117
+ walk(childAbs, childRel);
118
+ } else {
119
+ out[childRel] = readFileSync(childAbs, "utf-8");
120
+ }
121
+ }
122
+ };
123
+ walk(dir, "");
124
+ return out;
125
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ ".parachute/notes-meta/01HX005.yaml": "id: 01HX005\npath: Data/table\nextension: csv\ntags:\n - z-other\nmetadata:\n rows: 2\ncreated_at: 2026-01-01T00:00:00.000Z\nupdated_at: 2026-01-01T00:00:00.000Z\n",
3
+ ".parachute/schemas/project.yaml": "description: A long-running effort\nfields:\n status:\n enum:\n - active\n - done\n indexed: true\n type: string\nname: project\nrelationships:\n based-at:\n from: project\n note: freeform\n to: place\n works-on:\n from: person\n to: project\n",
4
+ ".parachute/vault.yaml": "description: portable-md byte-stability fixture\nexport_format_version: 1\nexported_at: 2026-07-02T00:00:00.000Z\nname: golden\n",
5
+ "Data/table.csv": "col1,col2\n1,2\n",
6
+ "Inbox/alpha.md": "---\nid: 01HX001\npath: Inbox/alpha\ntags:\n - project\n - z-other\nmetadata:\n ctrl: \"a\\tb\"\n notes: \"line1\\nline2\\nline3\"\n priority: high\n status: active\nlinks:\n - metadata:\n source: git://example\n relationship: derived-from\n target: 01HX002\ncreated_at: 2026-01-01T00:00:00.000Z\nupdated_at: 2026-01-01T00:01:00.000Z\n---\nalpha body\n",
7
+ "Inbox/beta.md": "---\nid: 01HX002\npath: Inbox/beta\ntags:\n - project\nmetadata:\n status: done\ncreated_at: 2026-01-01T00:00:00.000Z\nupdated_at: 2026-01-01T00:00:00.000Z\n---\nbeta body\n",
8
+ "Inbox/skeleton.md": "---\nid: 01HX004\npath: Inbox/skeleton\ntags:\n - project\ncreated_at: 2026-01-01T00:00:00.000Z\nupdated_at: 2026-01-01T00:00:00.000Z\n---\n",
9
+ "_unpathed/01HX003.md": "---\nid: 01HX003\ncreated_at: 2026-01-01T00:00:00.000Z\nupdated_at: 2026-01-01T00:00:00.000Z\n---\nunpathed jot\n"
10
+ }
@@ -0,0 +1,246 @@
1
+ import { describe, it, expect, beforeEach } from "bun:test";
2
+ import { Database } from "bun:sqlite";
3
+ import { SqliteStore } from "./store.js";
4
+ import { generateMcpTools } from "./mcp.js";
5
+ import { stripTagHash } from "./tag-hierarchy.js";
6
+
7
+ // Canonical-bare-tag guard (PR #516). A `#`-prefixed tag must never be stored
8
+ // or mismatched: the agent module stored `#agent/message/inbound` literally and
9
+ // consumers querying the bare `agent/message/inbound` convention found nothing.
10
+ // The guard strips a leading `#` (idempotently) on BOTH the write path and the
11
+ // query path, while preserving casing + structure (it is NOT normalizeTagValue).
12
+
13
+ let store: SqliteStore;
14
+ let db: Database;
15
+
16
+ beforeEach(() => {
17
+ db = new Database(":memory:");
18
+ store = new SqliteStore(db);
19
+ });
20
+
21
+ /** Raw read of a note's stored tag rows (bypasses any hydration). */
22
+ function storedTags(noteId: string): string[] {
23
+ return (
24
+ db
25
+ .prepare("SELECT tag_name FROM note_tags WHERE note_id = ? ORDER BY tag_name")
26
+ .all(noteId) as { tag_name: string }[]
27
+ ).map((r) => r.tag_name);
28
+ }
29
+
30
+ describe("stripTagHash helper", () => {
31
+ it("strips a single leading #", () => {
32
+ expect(stripTagHash("#agent/message")).toBe("agent/message");
33
+ });
34
+
35
+ it("is idempotent across one or more leading # (##x → x, x → x)", () => {
36
+ expect(stripTagHash("##x")).toBe("x");
37
+ expect(stripTagHash("###deep")).toBe("deep");
38
+ expect(stripTagHash("x")).toBe("x");
39
+ expect(stripTagHash(stripTagHash("#x"))).toBe("x");
40
+ });
41
+
42
+ it("does NOT lowercase or otherwise transform — casing + structure preserved", () => {
43
+ expect(stripTagHash("#Agent/Message/Inbound")).toBe("Agent/Message/Inbound");
44
+ expect(stripTagHash("CamelCase")).toBe("CamelCase");
45
+ });
46
+
47
+ it("only strips LEADING # — a mid-string # (e.g. c#) is left intact", () => {
48
+ expect(stripTagHash("c#")).toBe("c#");
49
+ expect(stripTagHash("#c#")).toBe("c#");
50
+ expect(stripTagHash("a#b")).toBe("a#b");
51
+ });
52
+
53
+ it("trims surrounding whitespace before stripping", () => {
54
+ expect(stripTagHash(" #tag ")).toBe("tag");
55
+ });
56
+ });
57
+
58
+ describe("write path — tags stored bare", () => {
59
+ it("createNote: #agent/message stored as agent/message", async () => {
60
+ const note = await store.createNote("inbound", { tags: ["#agent/message"] });
61
+ expect(storedTags(note.id)).toEqual(["agent/message"]);
62
+ expect(note.tags).toEqual(["agent/message"]);
63
+ });
64
+
65
+ it("##x stored as x; a bare foo is unchanged", async () => {
66
+ const note = await store.createNote("memo", { tags: ["##x", "foo"] });
67
+ expect(storedTags(note.id).sort()).toEqual(["foo", "x"]);
68
+ });
69
+
70
+ it("a # mid-string (c#) is NOT mangled", async () => {
71
+ const note = await store.createNote("memo", { tags: ["c#"] });
72
+ expect(storedTags(note.id)).toEqual(["c#"]);
73
+ });
74
+
75
+ it("casing is preserved through the write path", async () => {
76
+ const note = await store.createNote("memo", { tags: ["#Agent/Inbound"] });
77
+ expect(storedTags(note.id)).toEqual(["Agent/Inbound"]);
78
+ });
79
+
80
+ it("tagNote: #-prefixed add lands on the bare row", async () => {
81
+ const note = await store.createNote("memo");
82
+ await store.tagNote(note.id, ["#later"]);
83
+ expect(storedTags(note.id)).toEqual(["later"]);
84
+ });
85
+
86
+ it("untagNote: removing #tag deletes the bare row", async () => {
87
+ const note = await store.createNote("memo", { tags: ["later"] });
88
+ await store.untagNote(note.id, ["#later"]);
89
+ expect(storedTags(note.id)).toEqual([]);
90
+ });
91
+
92
+ it("batchTag/batchUntag normalize independently of tagNote", async () => {
93
+ const a = await store.createNote("a");
94
+ const b = await store.createNote("b");
95
+ await store.batchTag([a.id, b.id], ["#batch"]);
96
+ expect(storedTags(a.id)).toEqual(["batch"]);
97
+ expect(storedTags(b.id)).toEqual(["batch"]);
98
+ await store.batchUntag([a.id], ["#batch"]);
99
+ expect(storedTags(a.id)).toEqual([]);
100
+ expect(storedTags(b.id)).toEqual(["batch"]);
101
+ });
102
+
103
+ it("create-note via MCP also stores bare", async () => {
104
+ const tools = generateMcpTools(store);
105
+ const createNote = tools.find((t) => t.name === "create-note")!;
106
+ const res = (await createNote.execute({
107
+ content: "mcp inbound",
108
+ tags: ["#agent/message/inbound"],
109
+ })) as { id: string };
110
+ expect(storedTags(res.id)).toEqual(["agent/message/inbound"]);
111
+ });
112
+ });
113
+
114
+ describe("query path — # and bare forms equivalent", () => {
115
+ it("query tag:#agent/message and tag:agent/message both match the same bare-stored note", async () => {
116
+ const note = await store.createNote("inbound", { tags: ["agent/message"] });
117
+
118
+ const byHash = await store.queryNotes({ tags: ["#agent/message"] });
119
+ expect(byHash.map((n) => n.id)).toContain(note.id);
120
+
121
+ const byBare = await store.queryNotes({ tags: ["agent/message"] });
122
+ expect(byBare.map((n) => n.id)).toContain(note.id);
123
+ });
124
+
125
+ it("an old #-form query reaches a #-decorated WRITE (both normalize to the same bare row)", async () => {
126
+ // The non-breaking guarantee: a client that writes AND queries with the
127
+ // legacy #-form still works — both sides converge on the bare row. (Truly
128
+ // pre-fix #-STORED data is migrated away via renameTag, not auto-reached.)
129
+ const note = await store.createNote("inbound", { tags: ["#agent/message"] });
130
+ const byHash = await store.queryNotes({ tags: ["#agent/message"] });
131
+ expect(byHash.map((n) => n.id)).toContain(note.id);
132
+ const byBare = await store.queryNotes({ tags: ["agent/message"] });
133
+ expect(byBare.map((n) => n.id)).toContain(note.id);
134
+ });
135
+
136
+ it("exclude_tags #x excludes x-tagged notes", async () => {
137
+ const kept = await store.createNote("kept", { tags: ["keep"] });
138
+ const dropped = await store.createNote("dropped", { tags: ["keep", "x"] });
139
+
140
+ const results = await store.queryNotes({ tags: ["keep"], excludeTags: ["#x"] });
141
+ const ids = results.map((n) => n.id);
142
+ expect(ids).toContain(kept.id);
143
+ expect(ids).not.toContain(dropped.id);
144
+ });
145
+
146
+ it("searchNotes treats #tag and bare tag identically", async () => {
147
+ const note = await store.createNote("findable text here", { tags: ["agent/message"] });
148
+ const hits = await store.searchNotes("findable", { tags: ["#agent/message"] });
149
+ expect(hits.map((n) => n.id)).toContain(note.id);
150
+ });
151
+
152
+ it("query-notes via MCP: #-form matches bare-stored note", async () => {
153
+ const note = await store.createNote("mcp query", { tags: ["agent/message/inbound"] });
154
+ const tools = generateMcpTools(store);
155
+ const query = tools.find((t) => t.name === "query-notes")!;
156
+ // query-notes returns a plain array when no cursor is requested.
157
+ const res = (await query.execute({ tag: "#agent/message/inbound" })) as { id: string }[];
158
+ expect(res.map((n) => n.id)).toContain(note.id);
159
+ });
160
+ });
161
+
162
+ describe("tag schema path — update-tag name + parent_names stored bare", () => {
163
+ it("update-tag with name #foo + parent_names [#bar] → stored bare; inheritance works", async () => {
164
+ const tools = generateMcpTools(store);
165
+ const updateTag = tools.find((t) => t.name === "update-tag")!;
166
+
167
+ // Establish `bar` as a parent, then `foo` as its child via #-decorated input.
168
+ await updateTag.execute({ tag: "#bar", description: "parent" });
169
+ await updateTag.execute({ tag: "#foo", parent_names: ["#bar"] });
170
+
171
+ const fooRecord = await store.getTagRecord("foo");
172
+ expect(fooRecord).not.toBeNull();
173
+ expect(fooRecord!.parent_names).toEqual(["bar"]);
174
+ // The #-decorated upsert must NOT have created a phantom `#foo` row.
175
+ expect(await store.getTagRecord("#foo")).toBeNull();
176
+
177
+ // Inheritance: tag:bar expands to foo (foo's parent is bar).
178
+ const fooNote = await store.createNote("a foo note", { tags: ["foo"] });
179
+ const expanded = await store.queryNotes({ tags: ["bar"] });
180
+ expect(expanded.map((n) => n.id)).toContain(fooNote.id);
181
+ });
182
+
183
+ it("update-tag with #-name preserves the existing bare record's fields (merge correctness)", async () => {
184
+ const tools = generateMcpTools(store);
185
+ const updateTag = tools.find((t) => t.name === "update-tag")!;
186
+
187
+ await updateTag.execute({ tag: "thing", description: "first" });
188
+ // A #-decorated follow-up upsert must read the EXISTING bare record so the
189
+ // partial merge doesn't clobber the prior description.
190
+ await updateTag.execute({ tag: "#thing", parent_names: [] });
191
+
192
+ const record = await store.getTagRecord("thing");
193
+ expect(record!.description).toBe("first");
194
+ });
195
+ });
196
+
197
+ describe("rename carve-out — source name stays literal (migration escape hatch)", () => {
198
+ it("renameTag(#agent/message, agent/message) finds + renames the #-prefixed row", async () => {
199
+ // Plant a legacy #-prefixed tag row + a note carrying it (simulating the
200
+ // pre-fix agent data the migration must rename away).
201
+ db.prepare("INSERT OR IGNORE INTO tags (name) VALUES ('#agent/message')").run();
202
+ const note = await store.createNote("legacy inbound");
203
+ db.prepare("INSERT INTO note_tags (note_id, tag_name) VALUES (?, '#agent/message')").run(note.id);
204
+
205
+ const result = await store.renameTag("#agent/message", "agent/message");
206
+ expect("renamed" in result).toBe(true);
207
+
208
+ // The literal #-row is gone; the note now carries the bare tag.
209
+ expect(storedTags(note.id)).toEqual(["agent/message"]);
210
+ const tags = await store.listTags();
211
+ expect(tags.map((t) => t.name)).not.toContain("#agent/message");
212
+ expect(tags.map((t) => t.name)).toContain("agent/message");
213
+ });
214
+
215
+ it("renameTag normalizes the TARGET — renaming TO a #-name stores bare (can't re-introduce #)", async () => {
216
+ db.prepare("INSERT OR IGNORE INTO tags (name) VALUES ('plainsrc')").run();
217
+ const note = await store.createNote("x");
218
+ db.prepare("INSERT INTO note_tags (note_id, tag_name) VALUES (?, 'plainsrc')").run(note.id);
219
+
220
+ await store.renameTag("plainsrc", "#shiny");
221
+
222
+ // The target #-prefix is stripped — no `#shiny` row, the note carries `shiny`.
223
+ expect(storedTags(note.id)).toEqual(["shiny"]);
224
+ expect((await store.listTags()).map((t) => t.name)).not.toContain("#shiny");
225
+ });
226
+
227
+ it("mergeTags normalizes the TARGET — merging INTO a #-name stores bare", async () => {
228
+ db.prepare("INSERT OR IGNORE INTO tags (name) VALUES ('m1')").run();
229
+ const note = await store.createNote("y");
230
+ db.prepare("INSERT INTO note_tags (note_id, tag_name) VALUES (?, 'm1')").run(note.id);
231
+
232
+ await store.mergeTags(["m1"], "#dest");
233
+
234
+ expect(storedTags(note.id)).toEqual(["dest"]);
235
+ expect((await store.listTags()).map((t) => t.name)).not.toContain("#dest");
236
+ });
237
+ });
238
+
239
+ describe("regression — existing bare-tag behavior unchanged", () => {
240
+ it("bare write + bare query still works end to end", async () => {
241
+ const note = await store.createNote("plain", { tags: ["alpha", "beta"] });
242
+ expect(storedTags(note.id).sort()).toEqual(["alpha", "beta"]);
243
+ const results = await store.queryNotes({ tags: ["alpha"] });
244
+ expect(results.map((n) => n.id)).toContain(note.id);
245
+ });
246
+ });
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Conformance check (vault#283) — counting existing notes that would violate
3
+ * a proposed tightening of a tag's field spec. Backs the Schema editor's
4
+ * "N existing notes violate this" warning.
5
+ */
6
+ import { describe, it, expect, beforeEach } from "bun:test";
7
+ import { Database } from "bun:sqlite";
8
+ import { SqliteStore } from "./store.js";
9
+ import { countConformanceViolations } from "./conformance.js";
10
+
11
+ let db: Database;
12
+ let store: SqliteStore;
13
+
14
+ beforeEach(() => {
15
+ db = new Database(":memory:");
16
+ store = new SqliteStore(db);
17
+ });
18
+
19
+ describe("countConformanceViolations", () => {
20
+ it("returns zero violations when proposed fields is empty", async () => {
21
+ await store.createNote("n", { tags: ["task"], metadata: { status: "open" } });
22
+ const r = countConformanceViolations(db, "task", {});
23
+ expect(r.total_notes).toBe(1);
24
+ expect(r.violating_notes).toBe(0);
25
+ expect(r.checked_fields).toEqual([]);
26
+ });
27
+
28
+ it("counts notes missing a newly-required field", async () => {
29
+ await store.createNote("has", { tags: ["task"], metadata: { due: "2026-01-01" } });
30
+ await store.createNote("missing-1", { tags: ["task"], metadata: {} });
31
+ await store.createNote("missing-2", { tags: ["task"] });
32
+
33
+ const r = countConformanceViolations(db, "task", {
34
+ due: { type: "string", required: true },
35
+ });
36
+ expect(r.total_notes).toBe(3);
37
+ expect(r.violating_notes).toBe(2);
38
+ expect(r.checked_fields).toEqual(["due"]);
39
+ // missing_required reasons surface in the sample.
40
+ expect(r.sample.every((s) => s.fields.some((f) => f.reason === "missing_required"))).toBe(true);
41
+ });
42
+
43
+ it("counts notes whose value falls outside a narrowed enum", async () => {
44
+ await store.createNote("ok", { tags: ["task"], metadata: { status: "open" } });
45
+ await store.createNote("bad", { tags: ["task"], metadata: { status: "archived" } });
46
+
47
+ const r = countConformanceViolations(db, "task", {
48
+ status: { type: "string", enum: ["open", "done"] },
49
+ });
50
+ expect(r.violating_notes).toBe(1);
51
+ expect(r.sample[0]?.fields[0]?.reason).toBe("enum_mismatch");
52
+ });
53
+
54
+ it("counts notes whose value contradicts a changed type", async () => {
55
+ await store.createNote("num", { tags: ["task"], metadata: { count: 3 } });
56
+ await store.createNote("str", { tags: ["task"], metadata: { count: "three" } });
57
+
58
+ const r = countConformanceViolations(db, "task", {
59
+ count: { type: "integer" },
60
+ });
61
+ expect(r.violating_notes).toBe(1);
62
+ expect(r.sample[0]?.fields[0]?.reason).toBe("type_mismatch");
63
+ });
64
+
65
+ it("includes descendant-tag notes via inheritance", async () => {
66
+ // dev/log declares dev as a parent → a strict field on dev applies to
67
+ // dev/log notes too.
68
+ await store.upsertTagRecord("dev/log", { parent_names: ["dev"] });
69
+ await store.createNote("parent-note", { tags: ["dev"], metadata: {} });
70
+ await store.createNote("child-note", { tags: ["dev/log"], metadata: {} });
71
+
72
+ const r = countConformanceViolations(db, "dev", {
73
+ kind: { type: "string", required: true },
74
+ });
75
+ expect(r.total_notes).toBe(2);
76
+ expect(r.violating_notes).toBe(2);
77
+ });
78
+
79
+ it("ignores fields the proposal does not touch", async () => {
80
+ await store.createNote("n", { tags: ["task"], metadata: { other: 123 } });
81
+ // We only propose `status`; `other` being weird is irrelevant.
82
+ const r = countConformanceViolations(db, "task", {
83
+ status: { type: "string" },
84
+ });
85
+ // `status` absent + not required → no violation.
86
+ expect(r.violating_notes).toBe(0);
87
+ });
88
+
89
+ it("caps the sample at sampleLimit but counts all violations", async () => {
90
+ for (let i = 0; i < 5; i++) {
91
+ await store.createNote(`n${i}`, { tags: ["task"], metadata: {} });
92
+ }
93
+ const r = countConformanceViolations(
94
+ db,
95
+ "task",
96
+ { req: { type: "string", required: true } },
97
+ { sampleLimit: 2 },
98
+ );
99
+ expect(r.violating_notes).toBe(5);
100
+ expect(r.sample.length).toBe(2);
101
+ });
102
+ });
103
+
104
+ describe("store.countTagConformance", () => {
105
+ it("proxies to the core helper", async () => {
106
+ await store.createNote("n", { tags: ["task"], metadata: {} });
107
+ const r = await store.countTagConformance("task", {
108
+ due: { type: "string", required: true },
109
+ });
110
+ expect(r.violating_notes).toBe(1);
111
+ });
112
+ });