@openparachute/vault 0.7.0-rc.4 → 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.
- package/core/src/contract-taxonomy.test.ts +193 -17
- package/core/src/core.test.ts +29 -6
- package/core/src/doctor-scope.test.ts +117 -0
- package/core/src/doctor.ts +330 -0
- package/core/src/mcp.ts +124 -5
- package/core/src/notes.ts +98 -24
- package/core/src/portable-md.test.ts +107 -0
- package/core/src/portable-md.ts +51 -5
- package/core/src/store.ts +17 -3
- package/core/src/tag-hierarchy.ts +66 -0
- package/core/src/tag-schemas.ts +45 -0
- package/core/src/types.ts +23 -1
- package/package.json +1 -1
- package/src/mcp-http.ts +13 -0
- package/src/mcp-tools.ts +90 -5
- package/src/mirror-import.ts +5 -0
- package/src/routes.ts +74 -1
- package/src/routing.ts +29 -0
- package/src/tag-integrity-mcp.test.ts +288 -0
- package/src/tag-integrity-scope.test.ts +97 -0
- package/src/tag-scope.ts +43 -0
- package/src/vault.test.ts +25 -8
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `vault doctor` — read-only taxonomy/metadata integrity scan (vault#552).
|
|
3
|
+
*
|
|
4
|
+
* NOT to be confused with `parachute-vault doctor` (the CLI's install/daemon
|
|
5
|
+
* diagnostic in `src/cli.ts` — port reachability, config sanity, launchd/
|
|
6
|
+
* systemd registration). This module answers a different question: "did my
|
|
7
|
+
* tag reorg (rename/merge/delete) leak?" It never auto-fixes — every finding
|
|
8
|
+
* carries a suggested `remedy` the caller applies deliberately (usually via
|
|
9
|
+
* `rename-tag`/`merge-tags`/`prune-schema`/another `update-tag` call).
|
|
10
|
+
*
|
|
11
|
+
* Scans:
|
|
12
|
+
* - `dangling_parent_name` — a `parent_names` entry naming a tag with no
|
|
13
|
+
* identity row.
|
|
14
|
+
* - `parent_names_cycle` — a tag that reaches itself through its
|
|
15
|
+
* declared ancestor chain (traversal is cycle-safe; this just SURFACES
|
|
16
|
+
* data that predates the vault#552 write-time cycle guard, or was
|
|
17
|
+
* written by a path that bypasses it).
|
|
18
|
+
* - `mixed_type_indexed_field` — a note whose `metadata.<field>` JSON type
|
|
19
|
+
* disagrees with the field's declared indexed sqlite type. This is the
|
|
20
|
+
* WS4 typed-index "poison precursor": inconsistent column affinity
|
|
21
|
+
* coercion silently breaks ordering/comparison across rows. The finding
|
|
22
|
+
* shape here is intentionally reusable as WS4's migration pre-flight.
|
|
23
|
+
* - `orphaned_indexed_field_declarer` — an `indexed_fields` row naming a
|
|
24
|
+
* declarer tag with no `tags` row (overlaps `prune-schema`, which is the
|
|
25
|
+
* suggested remedy).
|
|
26
|
+
* - `dead_tag_metadata_reference` — HEURISTIC, always labeled `heuristic:
|
|
27
|
+
* true`. A metadata key is used elsewhere in the vault with values that
|
|
28
|
+
* ARE live tag names (establishing "this key holds tag-shaped values");
|
|
29
|
+
* a value under the same key that does NOT match any live tag is
|
|
30
|
+
* flagged as a possible stale reference to a renamed/merged/deleted tag
|
|
31
|
+
* — the PM's `metadata.epic` drift class from the #552 write-up. Vault
|
|
32
|
+
* has no history of past tag names, so this can never be certain —
|
|
33
|
+
* hence heuristic.
|
|
34
|
+
*
|
|
35
|
+
* Tag-scope (vault#552): pass `allowedTags` (the caller's EXPANDED
|
|
36
|
+
* allowlist, e.g. from `expandTokenTagScope`) to restrict every finding to
|
|
37
|
+
* in-scope tags/fields/notes. `null` (or omitted) — the default — scans the
|
|
38
|
+
* whole vault. The scan is re-run with the allowlist rather than filtering
|
|
39
|
+
* an unscoped result afterward, so the `summary` counts never leak
|
|
40
|
+
* out-of-scope activity.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { Database } from "bun:sqlite";
|
|
44
|
+
import { loadTagHierarchy, findHierarchyCycles } from "./tag-hierarchy.js";
|
|
45
|
+
import { listIndexedFields } from "./indexed-fields.js";
|
|
46
|
+
import { pruneOrphanedIndexedFields } from "./indexed-fields.js";
|
|
47
|
+
|
|
48
|
+
export type DoctorFindingType =
|
|
49
|
+
| "dangling_parent_name"
|
|
50
|
+
| "parent_names_cycle"
|
|
51
|
+
| "mixed_type_indexed_field"
|
|
52
|
+
| "orphaned_indexed_field_declarer"
|
|
53
|
+
| "dead_tag_metadata_reference";
|
|
54
|
+
|
|
55
|
+
export type DoctorSeverity = "error" | "warning" | "info";
|
|
56
|
+
|
|
57
|
+
export interface DoctorFinding {
|
|
58
|
+
type: DoctorFindingType;
|
|
59
|
+
severity: DoctorSeverity;
|
|
60
|
+
/** The tag / field / metadata-key this finding is about. */
|
|
61
|
+
subject: string;
|
|
62
|
+
detail: string;
|
|
63
|
+
remedy: string;
|
|
64
|
+
/**
|
|
65
|
+
* True ONLY for `dead_tag_metadata_reference` — flags the finding as a
|
|
66
|
+
* best-effort inference (no tag-rename history exists to confirm it),
|
|
67
|
+
* never present (not even `false`) on the other, structurally-certain
|
|
68
|
+
* finding types.
|
|
69
|
+
*/
|
|
70
|
+
heuristic?: true;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface DoctorReport {
|
|
74
|
+
findings: DoctorFinding[];
|
|
75
|
+
summary: string;
|
|
76
|
+
scanned_at: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface DoctorScanOpts {
|
|
80
|
+
/**
|
|
81
|
+
* The caller's expanded tag allowlist (root ∪ descendants). `null`
|
|
82
|
+
* (default) scans the whole vault. See the module doc comment for how
|
|
83
|
+
* each finding type is scoped.
|
|
84
|
+
*/
|
|
85
|
+
allowedTags?: Set<string> | null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Cap on exemplar note IDs embedded in a finding's `detail` (vault#552 — keep report bodies bounded). */
|
|
89
|
+
const MAX_EXEMPLARS = 5;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Build a per-note in-scope predicate for a tag-scoped doctor run (vault#552
|
|
93
|
+
* auth fold). A note is in-scope iff at least one of its tags is in
|
|
94
|
+
* `allowedTags`; an unscoped run (`allowedTags === null`) treats every note
|
|
95
|
+
* as in-scope. Caches each note's tag lookup so repeated checks across scans
|
|
96
|
+
* cost one query per note. Shared by the mixed-type-indexed-field and
|
|
97
|
+
* dead-tag-metadata scans, both of which query notes vault-wide and must NOT
|
|
98
|
+
* surface an out-of-scope note id (or count, or exemplar) to a scoped caller.
|
|
99
|
+
*/
|
|
100
|
+
function makeNoteInScope(db: Database, allowedTags: Set<string> | null): (id: string) => boolean {
|
|
101
|
+
if (!allowedTags) return () => true;
|
|
102
|
+
const cache = new Map<string, boolean>();
|
|
103
|
+
return (id: string): boolean => {
|
|
104
|
+
const cached = cache.get(id);
|
|
105
|
+
if (cached !== undefined) return cached;
|
|
106
|
+
const tags = (db.prepare("SELECT tag_name FROM note_tags WHERE note_id = ?").all(id) as { tag_name: string }[]).map((r) => r.tag_name);
|
|
107
|
+
const inScope = tags.some((t) => allowedTags.has(t));
|
|
108
|
+
cache.set(id, inScope);
|
|
109
|
+
return inScope;
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function runDoctorScan(db: Database, opts?: DoctorScanOpts): DoctorReport {
|
|
114
|
+
const allowedTags = opts?.allowedTags ?? null;
|
|
115
|
+
const findings: DoctorFinding[] = [
|
|
116
|
+
...scanDanglingParentNames(db, allowedTags),
|
|
117
|
+
...scanParentNamesCycles(db, allowedTags),
|
|
118
|
+
...scanMixedTypeIndexedFields(db, allowedTags),
|
|
119
|
+
...scanOrphanedIndexedFieldDeclarers(db, allowedTags),
|
|
120
|
+
...scanDeadTagMetadataReferences(db, allowedTags),
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
const errors = findings.filter((f) => f.severity === "error").length;
|
|
124
|
+
const warnings = findings.filter((f) => f.severity === "warning").length;
|
|
125
|
+
const infos = findings.filter((f) => f.severity === "info").length;
|
|
126
|
+
const summary =
|
|
127
|
+
findings.length === 0
|
|
128
|
+
? "clean — no integrity findings"
|
|
129
|
+
: `${findings.length} finding(s): ${errors} error(s), ${warnings} warning(s), ${infos} info(s)`;
|
|
130
|
+
|
|
131
|
+
return { findings, summary, scanned_at: new Date().toISOString() };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// A. dangling parent_names
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
function scanDanglingParentNames(db: Database, allowedTags: Set<string> | null): DoctorFinding[] {
|
|
139
|
+
const rows = db
|
|
140
|
+
.prepare("SELECT name, parent_names FROM tags WHERE parent_names IS NOT NULL")
|
|
141
|
+
.all() as { name: string; parent_names: string }[];
|
|
142
|
+
const allTags = new Set(
|
|
143
|
+
(db.prepare("SELECT name FROM tags").all() as { name: string }[]).map((r) => r.name),
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
const findings: DoctorFinding[] = [];
|
|
147
|
+
for (const row of rows) {
|
|
148
|
+
if (allowedTags && !allowedTags.has(row.name)) continue;
|
|
149
|
+
let parsed: unknown;
|
|
150
|
+
try { parsed = JSON.parse(row.parent_names); } catch { continue; }
|
|
151
|
+
if (!Array.isArray(parsed)) continue;
|
|
152
|
+
for (const parent of parsed) {
|
|
153
|
+
if (typeof parent !== "string" || parent.length === 0) continue;
|
|
154
|
+
if (allTags.has(parent)) continue;
|
|
155
|
+
findings.push({
|
|
156
|
+
type: "dangling_parent_name",
|
|
157
|
+
severity: "warning",
|
|
158
|
+
subject: row.name,
|
|
159
|
+
detail: `tag "${row.name}" declares parent "${parent}", which has no identity row — a typo, a tag that was never created, or a rename/delete that didn't update this reference.`,
|
|
160
|
+
remedy: `remove "${parent}" from "${row.name}"'s parent_names (update-tag), or create the missing "${parent}" tag if it's meant to exist`,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return findings;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// B. parent_names cycles (pre-existing data — the write-time guard blocks
|
|
169
|
+
// NEW ones; this surfaces anything already on disk)
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
function scanParentNamesCycles(db: Database, allowedTags: Set<string> | null): DoctorFinding[] {
|
|
173
|
+
const hierarchy = loadTagHierarchy(db);
|
|
174
|
+
const cycles = findHierarchyCycles(hierarchy);
|
|
175
|
+
const findings: DoctorFinding[] = [];
|
|
176
|
+
for (const tag of cycles) {
|
|
177
|
+
if (allowedTags && !allowedTags.has(tag)) continue;
|
|
178
|
+
findings.push({
|
|
179
|
+
type: "parent_names_cycle",
|
|
180
|
+
severity: "error",
|
|
181
|
+
subject: tag,
|
|
182
|
+
detail: `tag "${tag}" reaches itself through its declared parent_names ancestor chain. Query expansion tolerates this (a visited-set stops it looping), but it's confusing hierarchy state.`,
|
|
183
|
+
remedy: `clear or correct parent_names on one of the tags in "${tag}"'s ancestor chain (update-tag) to break the cycle`,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
return findings;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// C. mixed-type values in an indexed column (WS4 poison precursor)
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
/** `json_type()` results consistent with each declared indexed sqlite storage class. */
|
|
194
|
+
const EXPECTED_JSON_TYPES: Record<string, Set<string>> = {
|
|
195
|
+
INTEGER: new Set(["integer", "real", "true", "false"]),
|
|
196
|
+
TEXT: new Set(["text"]),
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
function scanMixedTypeIndexedFields(db: Database, allowedTags: Set<string> | null): DoctorFinding[] {
|
|
200
|
+
const findings: DoctorFinding[] = [];
|
|
201
|
+
const noteInScope = makeNoteInScope(db, allowedTags);
|
|
202
|
+
for (const f of listIndexedFields(db)) {
|
|
203
|
+
if (allowedTags && !f.declarerTags.some((t) => allowedTags.has(t))) continue;
|
|
204
|
+
const expected = EXPECTED_JSON_TYPES[f.sqliteType];
|
|
205
|
+
if (!expected) continue; // unknown sqlite_type — nothing to compare against
|
|
206
|
+
|
|
207
|
+
// `json_type(metadata, ?)` returns NULL when the key is absent — those
|
|
208
|
+
// notes simply don't declare the field and aren't a mismatch. Only rows
|
|
209
|
+
// where the key IS present get compared.
|
|
210
|
+
const path = `$."${f.field}"`;
|
|
211
|
+
const rows = db
|
|
212
|
+
.prepare(
|
|
213
|
+
`SELECT id, json_type(metadata, ?) as jt FROM notes WHERE metadata IS NOT NULL AND metadata != '' AND json_valid(metadata)`,
|
|
214
|
+
)
|
|
215
|
+
.all(path) as { id: string; jt: string | null }[];
|
|
216
|
+
// Tag-scope (vault#552 auth fold): the note query above is vault-wide —
|
|
217
|
+
// filter mismatches to IN-SCOPE notes so a scoped caller never sees an
|
|
218
|
+
// out-of-scope note id (nor a count/exemplar reflecting one). If every
|
|
219
|
+
// mismatch is on an out-of-scope note, the finding is dropped entirely.
|
|
220
|
+
const mismatches = rows.filter((r) => r.jt !== null && !expected.has(r.jt) && noteInScope(r.id));
|
|
221
|
+
if (mismatches.length === 0) continue;
|
|
222
|
+
|
|
223
|
+
// Also generalize the "Declared by" list to in-scope declarers only —
|
|
224
|
+
// the field can be co-declared by an out-of-scope tag whose name must
|
|
225
|
+
// not leak (the caller sees this field only via its in-scope declarer).
|
|
226
|
+
const visibleDeclarers = allowedTags
|
|
227
|
+
? f.declarerTags.filter((t) => allowedTags.has(t))
|
|
228
|
+
: f.declarerTags;
|
|
229
|
+
const exemplars = mismatches.slice(0, MAX_EXEMPLARS).map((r) => `${r.id} (${r.jt})`);
|
|
230
|
+
findings.push({
|
|
231
|
+
type: "mixed_type_indexed_field",
|
|
232
|
+
severity: "error",
|
|
233
|
+
subject: f.field,
|
|
234
|
+
detail: `field "${f.field}" is indexed as ${f.sqliteType} but ${mismatches.length} note(s) carry a metadata."${f.field}" value of a disagreeing JSON type — the generated column's affinity coercion makes ordering/filtering across these rows inconsistent. Declared by: ${visibleDeclarers.join(", ") || "(no live declarer)"}. Example note(s): ${exemplars.join(", ")}${mismatches.length > exemplars.length ? ", …" : ""}`,
|
|
235
|
+
remedy: `backfill each listed note's metadata."${f.field}" to a value matching the declared type, or relax the field's declared type (update-tag) if the mixed values are intentional`,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
return findings;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
// D. orphaned indexed_fields declarers (overlaps prune-schema)
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
function scanOrphanedIndexedFieldDeclarers(db: Database, allowedTags: Set<string> | null): DoctorFinding[] {
|
|
246
|
+
const plan = pruneOrphanedIndexedFields(db, { dryRun: true });
|
|
247
|
+
const findings: DoctorFinding[] = [];
|
|
248
|
+
for (const p of plan) {
|
|
249
|
+
if (allowedTags && !p.deadDeclarers.some((t) => allowedTags.has(t))) {
|
|
250
|
+
// Dead declarer names never survive `allowedTags` (it's derived from
|
|
251
|
+
// LIVE tags only) — this branch exists for completeness; in practice
|
|
252
|
+
// a scoped caller only sees this finding when the report is unscoped.
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
findings.push({
|
|
256
|
+
type: "orphaned_indexed_field_declarer",
|
|
257
|
+
severity: p.dropped ? "warning" : "info",
|
|
258
|
+
subject: p.field,
|
|
259
|
+
detail: p.dropped
|
|
260
|
+
? `field "${p.field}" has NO surviving live declarer (dead: ${p.deadDeclarers.join(", ")}) — the generated column + index are stale and will be dropped on next prune (metadata values are never lost, only the index).`
|
|
261
|
+
: `field "${p.field}" has dead declarer tag(s) [${p.deadDeclarers.join(", ")}] still listed alongside live ones — cosmetic, no functional impact.`,
|
|
262
|
+
remedy: `run the prune-schema tool (apply: true) to drop the dead declarer(s)${p.dropped ? " and the now-unreferenced column/index" : ""}`,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
return findings;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
// E. metadata values equal to a tag name that no longer exists (heuristic)
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
function scanDeadTagMetadataReferences(db: Database, allowedTags: Set<string> | null): DoctorFinding[] {
|
|
273
|
+
const liveTags = new Set(
|
|
274
|
+
(db.prepare("SELECT name FROM tags").all() as { name: string }[]).map((r) => r.name),
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
const rows = db
|
|
278
|
+
.prepare("SELECT id, metadata FROM notes WHERE metadata IS NOT NULL AND metadata != ''")
|
|
279
|
+
.all() as { id: string; metadata: string }[];
|
|
280
|
+
|
|
281
|
+
// key -> value -> note ids carrying that (key, value) pair.
|
|
282
|
+
const byKey = new Map<string, Map<string, string[]>>();
|
|
283
|
+
for (const row of rows) {
|
|
284
|
+
let parsed: unknown;
|
|
285
|
+
try { parsed = JSON.parse(row.metadata); } catch { continue; }
|
|
286
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue;
|
|
287
|
+
for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
|
|
288
|
+
if (typeof value !== "string" || value.length === 0) continue;
|
|
289
|
+
let valueMap = byKey.get(key);
|
|
290
|
+
if (!valueMap) { valueMap = new Map(); byKey.set(key, valueMap); }
|
|
291
|
+
let ids = valueMap.get(value);
|
|
292
|
+
if (!ids) { ids = []; valueMap.set(value, ids); }
|
|
293
|
+
ids.push(row.id);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Tag-scope (vault#552 auth fold): a note is in-scope iff at least one of
|
|
298
|
+
// its tags is in `allowedTags` (unscoped → every note in-scope).
|
|
299
|
+
const noteInScope = makeNoteInScope(db, allowedTags);
|
|
300
|
+
|
|
301
|
+
const findings: DoctorFinding[] = [];
|
|
302
|
+
for (const [key, valueMap] of byKey) {
|
|
303
|
+
// Signal: does this metadata key hold tag-shaped values on IN-SCOPE
|
|
304
|
+
// notes? Computed over in-scope notes ONLY (not vault-wide) so the
|
|
305
|
+
// example live value quoted in `detail` can never leak an out-of-scope
|
|
306
|
+
// tag name — a scoped caller must not learn a tag exists just because a
|
|
307
|
+
// note it can't see uses it as a metadata value. Only flag mismatches
|
|
308
|
+
// under a key that demonstrably holds tag-shaped values in-scope.
|
|
309
|
+
const liveValuesInScope = [...valueMap.entries()]
|
|
310
|
+
.filter(([v, ids]) => liveTags.has(v) && ids.some(noteInScope))
|
|
311
|
+
.map(([v]) => v);
|
|
312
|
+
if (liveValuesInScope.length === 0) continue;
|
|
313
|
+
|
|
314
|
+
for (const [value, noteIds] of valueMap) {
|
|
315
|
+
if (liveTags.has(value)) continue;
|
|
316
|
+
const inScopeIds = noteIds.filter(noteInScope);
|
|
317
|
+
if (inScopeIds.length === 0) continue;
|
|
318
|
+
const exemplars = inScopeIds.slice(0, MAX_EXEMPLARS);
|
|
319
|
+
findings.push({
|
|
320
|
+
type: "dead_tag_metadata_reference",
|
|
321
|
+
severity: "info",
|
|
322
|
+
subject: `metadata.${key}`,
|
|
323
|
+
detail: `${inScopeIds.length} note(s) carry metadata.${key} = "${value}", which matches no current tag — other notes' metadata.${key} values ARE live tags (e.g. "${liveValuesInScope[0]}"), suggesting "${value}" may be a stale reference to a tag that was renamed, merged, or deleted. Example note(s): ${exemplars.join(", ")}${inScopeIds.length > exemplars.length ? ", …" : ""}`,
|
|
324
|
+
remedy: `if "${value}" was renamed/merged/deleted, update these notes' metadata.${key} to the current tag name; if "${value}" was never a tag, ignore this finding`,
|
|
325
|
+
heuristic: true,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return findings;
|
|
330
|
+
}
|
package/core/src/mcp.ts
CHANGED
|
@@ -190,9 +190,11 @@ export interface GenerateMcpToolsOpts {
|
|
|
190
190
|
}
|
|
191
191
|
|
|
192
192
|
/**
|
|
193
|
-
* Generate the consolidated MCP tools for a vault. Surface (
|
|
193
|
+
* Generate the consolidated MCP tools for a vault. Surface (13):
|
|
194
194
|
* query-notes, create-note, update-note, delete-note, list-tags, update-tag,
|
|
195
|
-
* delete-tag, find-path, vault-info, prune-schema
|
|
195
|
+
* delete-tag, rename-tag, merge-tags, find-path, vault-info, prune-schema
|
|
196
|
+
* (admin), doctor (admin). `manage-token` (admin) is appended by the SERVER
|
|
197
|
+
* layer (src/mcp-tools.ts), not here — see that file's doc comment.
|
|
196
198
|
*/
|
|
197
199
|
export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): McpToolDef[] {
|
|
198
200
|
const db: Database = store.db;
|
|
@@ -1716,11 +1718,13 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1716
1718
|
// mgmt + future config writes; deletes are write-tier mutations.
|
|
1717
1719
|
// See delete-note rationale.
|
|
1718
1720
|
requiredVerb: "write",
|
|
1719
|
-
description: "Delete a tag, remove it from all notes, and delete its schema. Notes themselves are NOT deleted — just untagged.",
|
|
1721
|
+
description: "Delete a tag, remove it from all notes, and delete its schema. Notes themselves are NOT deleted — just untagged. Refused with error_type \"tag_referenced_as_parent\" (vault#552) when another tag's parent_names still names this one — pass cascade OR detach (either — both mean the same thing: strip the stale reference from the referencing tag(s)' parent_names, never delete them) to proceed anyway.",
|
|
1720
1722
|
inputSchema: {
|
|
1721
1723
|
type: "object",
|
|
1722
1724
|
properties: {
|
|
1723
1725
|
tag: { type: "string", description: "Tag name to delete" },
|
|
1726
|
+
cascade: { type: "boolean", description: "Proceed even though another tag's parent_names references this one, stripping the reference. Synonym of detach." },
|
|
1727
|
+
detach: { type: "boolean", description: "Same as cascade — proceed and strip the stale parent_names reference from referencing tag(s)." },
|
|
1724
1728
|
},
|
|
1725
1729
|
required: ["tag"],
|
|
1726
1730
|
},
|
|
@@ -1731,8 +1735,107 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1731
1735
|
// Indexed-field release is handled inside store.deleteTag →
|
|
1732
1736
|
// noteOps.deleteTag so every entry point (MCP, REST, import sweep)
|
|
1733
1737
|
// releases consistently with the co-declaration guard. See the
|
|
1734
|
-
// gitcoin orphaned-fields bug report.
|
|
1735
|
-
|
|
1738
|
+
// gitcoin orphaned-fields bug report. The referential-integrity
|
|
1739
|
+
// guard (vault#552) is ALSO inside store.deleteTag, so it returns
|
|
1740
|
+
// (not throws) `{error: "tag_referenced_as_parent", ...}` when
|
|
1741
|
+
// refused — same in-band shape delete-tag has always used for its
|
|
1742
|
+
// token-reference guard (applyTagDependencyGuards, src/mcp-tools.ts).
|
|
1743
|
+
return await store.deleteTag(tag, {
|
|
1744
|
+
cascade: params.cascade === true,
|
|
1745
|
+
detach: params.detach === true,
|
|
1746
|
+
});
|
|
1747
|
+
},
|
|
1748
|
+
},
|
|
1749
|
+
|
|
1750
|
+
// =====================================================================
|
|
1751
|
+
// 7a. rename-tag — atomic cascading rename (vault#552 MCP parity)
|
|
1752
|
+
// =====================================================================
|
|
1753
|
+
{
|
|
1754
|
+
name: "rename-tag",
|
|
1755
|
+
requiredVerb: "write",
|
|
1756
|
+
description:
|
|
1757
|
+
"Atomically rename a tag across EVERY surface that references it: note memberships, OTHER tags' parent_names, tag-scoped tokens' allowlists, indexed-field declarer lists, inline #tag mentions in note bodies, and _tags/<name> config-note paths — all in one transaction. THIS is the fix for the manual retag→delete dance (create the new tag, retag notes, delete the old one): that dance silently orphans parent_names references (the renamed-away tag stays a live query surface via subtype expansion while list-tags reports it at count 0, and the new tag misses every child-tagged note) and leaves stale #tag mentions behind. Sub-tags rename recursively — renaming \"task\" to \"todo\" also renames \"task/work\" to \"todo/work\". Does NOT rewrite metadata values that happen to equal the old tag name (e.g. metadata.epic: \"task\") — that's a distinct drift class the doctor tool's dead_tag_metadata_reference finding flags heuristically; rename-tag's job is structural (tags/note_tags/parent_names/tokens/content), not a blind string search-and-replace over arbitrary metadata.",
|
|
1758
|
+
inputSchema: {
|
|
1759
|
+
type: "object",
|
|
1760
|
+
properties: {
|
|
1761
|
+
old_name: { type: "string", description: "The tag to rename. Aliases: from, tag." },
|
|
1762
|
+
new_name: { type: "string", description: "The new name. Alias: to." },
|
|
1763
|
+
from: { type: "string", description: "Alias for old_name." },
|
|
1764
|
+
to: { type: "string", description: "Alias for new_name." },
|
|
1765
|
+
tag: { type: "string", description: "Alias for old_name." },
|
|
1766
|
+
},
|
|
1767
|
+
},
|
|
1768
|
+
execute: async (params) => {
|
|
1769
|
+
const oldName = (params.old_name ?? params.from ?? params.tag) as string | undefined;
|
|
1770
|
+
const newName = (params.new_name ?? params.to) as string | undefined;
|
|
1771
|
+
if (typeof oldName !== "string" || oldName.length === 0) {
|
|
1772
|
+
throw structuredError("rename-tag: old_name (or from/tag) is required", {
|
|
1773
|
+
error_type: "invalid_request",
|
|
1774
|
+
field: "old_name",
|
|
1775
|
+
});
|
|
1776
|
+
}
|
|
1777
|
+
if (typeof newName !== "string" || newName.length === 0) {
|
|
1778
|
+
throw structuredError("rename-tag: new_name (or to) is required", {
|
|
1779
|
+
error_type: "invalid_request",
|
|
1780
|
+
field: "new_name",
|
|
1781
|
+
});
|
|
1782
|
+
}
|
|
1783
|
+
const result = await store.renameTag(oldName, newName);
|
|
1784
|
+
if ("error" in result) {
|
|
1785
|
+
if (result.error === "not_found") {
|
|
1786
|
+
throw structuredError(`rename-tag: tag "${oldName}" not found`, {
|
|
1787
|
+
error_type: "tag_not_found",
|
|
1788
|
+
field: "old_name",
|
|
1789
|
+
});
|
|
1790
|
+
}
|
|
1791
|
+
throw Object.assign(
|
|
1792
|
+
new Error(
|
|
1793
|
+
`rename-tag: target "${newName}" (or one of its sub-tags) already exists — use merge-tags to combine them instead`,
|
|
1794
|
+
),
|
|
1795
|
+
{
|
|
1796
|
+
error_type: "target_exists" as const,
|
|
1797
|
+
target: newName,
|
|
1798
|
+
conflicting: result.conflicting,
|
|
1799
|
+
hint: "use merge-tags to combine the tags instead",
|
|
1800
|
+
},
|
|
1801
|
+
);
|
|
1802
|
+
}
|
|
1803
|
+
return result;
|
|
1804
|
+
},
|
|
1805
|
+
},
|
|
1806
|
+
|
|
1807
|
+
// =====================================================================
|
|
1808
|
+
// 7b. merge-tags — same machinery, N sources → one target
|
|
1809
|
+
// =====================================================================
|
|
1810
|
+
{
|
|
1811
|
+
name: "merge-tags",
|
|
1812
|
+
requiredVerb: "write",
|
|
1813
|
+
description:
|
|
1814
|
+
"Atomically merge one or more source tags into a target tag: every note carrying any source is retagged with the target, then the source tags (and their identity rows — description/fields/relationships/parent_names) are dropped. target is created if it doesn't exist yet; target's own schema is preserved (sources' schemas are consumed, not merged field-by-field). Sources that don't exist are reported at count 0. Refused with error_type \"tag_in_use_by_tokens\" if a source is referenced by a tag-scoped token — revoke or re-mint it first.",
|
|
1815
|
+
inputSchema: {
|
|
1816
|
+
type: "object",
|
|
1817
|
+
properties: {
|
|
1818
|
+
sources: { type: "array", items: { type: "string" }, description: "Tag names to merge away into target." },
|
|
1819
|
+
target: { type: "string", description: "The tag that survives; sources are retagged onto it and dropped." },
|
|
1820
|
+
},
|
|
1821
|
+
required: ["sources", "target"],
|
|
1822
|
+
},
|
|
1823
|
+
execute: async (params) => {
|
|
1824
|
+
const sources = params.sources;
|
|
1825
|
+
const target = params.target;
|
|
1826
|
+
if (!Array.isArray(sources) || sources.length === 0 || !sources.every((s) => typeof s === "string" && s.length > 0)) {
|
|
1827
|
+
throw structuredError("merge-tags: sources must be a non-empty array of strings", {
|
|
1828
|
+
error_type: "invalid_request",
|
|
1829
|
+
field: "sources",
|
|
1830
|
+
});
|
|
1831
|
+
}
|
|
1832
|
+
if (typeof target !== "string" || target.length === 0) {
|
|
1833
|
+
throw structuredError("merge-tags: target must be a non-empty string", {
|
|
1834
|
+
error_type: "invalid_request",
|
|
1835
|
+
field: "target",
|
|
1836
|
+
});
|
|
1837
|
+
}
|
|
1838
|
+
return await store.mergeTags(sources as string[], target);
|
|
1736
1839
|
},
|
|
1737
1840
|
},
|
|
1738
1841
|
|
|
@@ -1822,6 +1925,22 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1822
1925
|
},
|
|
1823
1926
|
},
|
|
1824
1927
|
|
|
1928
|
+
// =====================================================================
|
|
1929
|
+
// 11. doctor — read-only taxonomy/metadata integrity scan (vault#552)
|
|
1930
|
+
// =====================================================================
|
|
1931
|
+
{
|
|
1932
|
+
name: "doctor",
|
|
1933
|
+
// `admin` — same tier as prune-schema: a diagnostic over the WHOLE
|
|
1934
|
+
// vault's taxonomy, not scoped to any one tag's write authority.
|
|
1935
|
+
requiredVerb: "admin",
|
|
1936
|
+
description:
|
|
1937
|
+
"Read-only integrity scan across the tag/metadata taxonomy — run this after any bulk tag reorg (rename/merge/delete/subtree move) to confirm nothing leaked. Reports, per finding, {type, severity, subject, detail, remedy} — NEVER auto-fixes; apply the suggested remedy (usually rename-tag/merge-tags/update-tag/prune-schema) yourself. Finding types: dangling_parent_name (a parent_names entry naming a tag with no identity row), parent_names_cycle (a tag reaching itself through its ancestor chain — traversal tolerates this, but it's dishonest hierarchy state), mixed_type_indexed_field (a note's metadata value for an indexed field has a JSON type disagreeing with the field's declared storage type — the ordering/filtering-goes-silently-wrong precursor), orphaned_indexed_field_declarer (an indexed field naming a dead declarer tag — see prune-schema), and dead_tag_metadata_reference (HEURISTIC, always carries heuristic:true — a metadata value that looks like a stale reference to a renamed/merged/deleted tag, inferred from sibling notes using the same metadata key with values that ARE live tags; can never be certain since vault keeps no tag-rename history).",
|
|
1938
|
+
inputSchema: { type: "object", properties: {} },
|
|
1939
|
+
execute: async () => {
|
|
1940
|
+
return await store.doctor();
|
|
1941
|
+
},
|
|
1942
|
+
},
|
|
1943
|
+
|
|
1825
1944
|
];
|
|
1826
1945
|
}
|
|
1827
1946
|
|
package/core/src/notes.ts
CHANGED
|
@@ -1512,40 +1512,114 @@ export function listTags(db: Database): { name: string; count: number; expanded_
|
|
|
1512
1512
|
return rows.map((r) => ({ ...r, expanded_count: expandedCounts.get(r.name) ?? 0 }));
|
|
1513
1513
|
}
|
|
1514
1514
|
|
|
1515
|
-
|
|
1515
|
+
/** Options accepted by {@link deleteTag} (vault#552). */
|
|
1516
|
+
export interface DeleteTagOpts {
|
|
1517
|
+
/**
|
|
1518
|
+
* Proceed with the delete even though another tag's `parent_names`
|
|
1519
|
+
* references this one — strip the reference from every referencing tag's
|
|
1520
|
+
* `parent_names` array as part of the same transaction. Accepted as a
|
|
1521
|
+
* synonym of `detach` (both name the identical repair: remove the stale
|
|
1522
|
+
* reference, never delete the referencing tag itself); offered as two
|
|
1523
|
+
* flags because operators reach for either word depending on whether
|
|
1524
|
+
* they're thinking "cascade the delete" or "detach the children."
|
|
1525
|
+
*/
|
|
1526
|
+
cascade?: boolean;
|
|
1527
|
+
/** Synonym of `cascade` — see above. */
|
|
1528
|
+
detach?: boolean;
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
export type DeleteTagResult =
|
|
1532
|
+
| { deleted: boolean; notes_untagged: number; parent_refs_detached?: number }
|
|
1533
|
+
| { error: "tag_referenced_as_parent"; referencing_tags: string[] };
|
|
1534
|
+
|
|
1535
|
+
/**
|
|
1536
|
+
* Delete a tag: drop its identity row, untag every note carrying it, and
|
|
1537
|
+
* release any indexed fields it exclusively declared. Notes themselves are
|
|
1538
|
+
* NEVER deleted — only untagged.
|
|
1539
|
+
*
|
|
1540
|
+
* Referential integrity (vault#552): if another tag's `parent_names` array
|
|
1541
|
+
* names `name`, deleting it would silently orphan that reference — the
|
|
1542
|
+
* child tag's hierarchy edge would point at a name with no identity row
|
|
1543
|
+
* (the exact "renamed-away tag remains a live query surface" class of bug
|
|
1544
|
+
* the gardener found, one hop over from rename). Refuse by default with
|
|
1545
|
+
* `{ error: "tag_referenced_as_parent", referencing_tags }`; pass
|
|
1546
|
+
* `opts.cascade` or `opts.detach` (either — see {@link DeleteTagOpts}) to
|
|
1547
|
+
* proceed, stripping `name` from every referencing tag's `parent_names` in
|
|
1548
|
+
* the same transaction as the delete.
|
|
1549
|
+
*/
|
|
1550
|
+
export function deleteTag(db: Database, name: string, opts?: DeleteTagOpts): DeleteTagResult {
|
|
1516
1551
|
const row = db.prepare("SELECT fields FROM tags WHERE name = ?").get(name) as
|
|
1517
1552
|
| { fields: string | null }
|
|
1518
1553
|
| null;
|
|
1519
1554
|
if (!row) return { deleted: false, notes_untagged: 0 };
|
|
1520
1555
|
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
//
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1556
|
+
// Referential-integrity guard: who names `name` as a parent? Reuses the
|
|
1557
|
+
// SAME parent_names parsing loadTagHierarchy uses everywhere else in the
|
|
1558
|
+
// hierarchy (rather than a bespoke LIKE scan) so "who references this
|
|
1559
|
+
// tag" answers identically here as it does for query expansion.
|
|
1560
|
+
const hierarchy = loadTagHierarchy(db);
|
|
1561
|
+
const referencing = Array.from(hierarchy.childrenOf.get(name) ?? []);
|
|
1562
|
+
if (referencing.length > 0 && !opts?.cascade && !opts?.detach) {
|
|
1563
|
+
return { error: "tag_referenced_as_parent", referencing_tags: referencing.sort() };
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
return transaction(db, (): DeleteTagResult => {
|
|
1567
|
+
// Strip the stale reference from every referencing tag's parent_names
|
|
1568
|
+
// BEFORE dropping this tag's own row — order doesn't matter for
|
|
1569
|
+
// correctness (different rows), but doing the repair first means a
|
|
1570
|
+
// mid-transaction failure never leaves the identity row gone with
|
|
1571
|
+
// dangling references still pointing at it.
|
|
1572
|
+
let parentRefsDetached = 0;
|
|
1573
|
+
if (referencing.length > 0) {
|
|
1574
|
+
const readStmt = db.prepare("SELECT parent_names FROM tags WHERE name = ?");
|
|
1575
|
+
const updateStmt = db.prepare("UPDATE tags SET parent_names = ?, updated_at = ? WHERE name = ?");
|
|
1576
|
+
const now = new Date().toISOString();
|
|
1577
|
+
for (const refTag of referencing) {
|
|
1578
|
+
const r = readStmt.get(refTag) as { parent_names: string | null } | null;
|
|
1579
|
+
if (!r?.parent_names) continue;
|
|
1580
|
+
let parsed: unknown;
|
|
1581
|
+
try { parsed = JSON.parse(r.parent_names); } catch { continue; }
|
|
1582
|
+
if (!Array.isArray(parsed)) continue;
|
|
1583
|
+
const next = (parsed as unknown[]).filter((p) => p !== name);
|
|
1584
|
+
if (next.length === parsed.length) continue;
|
|
1585
|
+
updateStmt.run(next.length > 0 ? JSON.stringify(next) : null, now, refTag);
|
|
1586
|
+
parentRefsDetached++;
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
const countRow = db.prepare("SELECT COUNT(*) as c FROM note_tags WHERE tag_name = ?").get(name) as { c: number };
|
|
1591
|
+
const notesUntagged = countRow.c;
|
|
1592
|
+
|
|
1593
|
+
// Release any indexed fields this tag declared BEFORE the row drops.
|
|
1594
|
+
// `releaseField` only drops the generated column + index when this tag is
|
|
1595
|
+
// the last live declarer (co-declaration guard) — a field co-declared by
|
|
1596
|
+
// another live tag keeps its column and just loses this tag from the set.
|
|
1597
|
+
// This lives in the store-level delete (not the MCP layer) so every caller
|
|
1598
|
+
// — MCP delete-tag, the REST DELETE /tags/:name route, the import
|
|
1599
|
+
// blow-away sweep — releases consistently. See the gitcoin orphaned-fields
|
|
1600
|
+
// bug report.
|
|
1601
|
+
if (row.fields) {
|
|
1602
|
+
try {
|
|
1603
|
+
const fields = JSON.parse(row.fields) as Record<string, { indexed?: boolean }>;
|
|
1604
|
+
for (const [fieldName, spec] of Object.entries(fields)) {
|
|
1605
|
+
if (spec?.indexed === true) {
|
|
1606
|
+
releaseField(db, fieldName, name);
|
|
1607
|
+
}
|
|
1538
1608
|
}
|
|
1609
|
+
} catch {
|
|
1610
|
+
// Malformed fields JSON — nothing to release; proceed with the delete.
|
|
1539
1611
|
}
|
|
1540
|
-
} catch {
|
|
1541
|
-
// Malformed fields JSON — nothing to release; proceed with the delete.
|
|
1542
1612
|
}
|
|
1543
|
-
}
|
|
1544
1613
|
|
|
1545
|
-
|
|
1546
|
-
|
|
1614
|
+
db.prepare("DELETE FROM note_tags WHERE tag_name = ?").run(name);
|
|
1615
|
+
db.prepare("DELETE FROM tags WHERE name = ?").run(name);
|
|
1547
1616
|
|
|
1548
|
-
|
|
1617
|
+
return {
|
|
1618
|
+
deleted: true,
|
|
1619
|
+
notes_untagged: notesUntagged,
|
|
1620
|
+
...(parentRefsDetached > 0 ? { parent_refs_detached: parentRefsDetached } : {}),
|
|
1621
|
+
};
|
|
1622
|
+
});
|
|
1549
1623
|
}
|
|
1550
1624
|
|
|
1551
1625
|
// The UNIQUE PRIMARY KEY on tags.name means rename-to-existing is ambiguous:
|