@openparachute/vault 0.3.3 → 0.4.0

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.
Files changed (79) hide show
  1. package/.parachute/module.json +15 -0
  2. package/core/src/core.test.ts +2252 -7
  3. package/core/src/links.ts +1 -1
  4. package/core/src/mcp.ts +801 -67
  5. package/core/src/note-schemas.ts +232 -0
  6. package/core/src/notes.ts +313 -35
  7. package/core/src/obsidian.ts +3 -3
  8. package/core/src/paths.ts +1 -1
  9. package/core/src/query-operators.ts +23 -7
  10. package/core/src/schema-defaults.ts +287 -0
  11. package/core/src/schema.ts +393 -9
  12. package/core/src/store.ts +248 -6
  13. package/core/src/tag-hierarchy.ts +137 -0
  14. package/core/src/tag-schemas.ts +242 -42
  15. package/core/src/types.ts +100 -6
  16. package/core/src/wikilinks.ts +3 -3
  17. package/package.json +13 -3
  18. package/src/admin-spa.test.ts +161 -0
  19. package/src/admin-spa.ts +161 -0
  20. package/src/auth-hub-jwt.test.ts +231 -0
  21. package/src/auth-status.ts +84 -0
  22. package/src/auth.test.ts +135 -23
  23. package/src/auth.ts +144 -15
  24. package/src/backup.ts +4 -7
  25. package/src/cli.ts +322 -57
  26. package/src/config.test.ts +44 -0
  27. package/src/config.ts +68 -40
  28. package/src/hub-jwt.test.ts +296 -0
  29. package/src/hub-jwt.ts +79 -0
  30. package/src/init.test.ts +216 -0
  31. package/src/mcp-http.ts +30 -28
  32. package/src/mcp-install.ts +1 -1
  33. package/src/mcp-tools.ts +294 -6
  34. package/src/module-config.ts +1 -1
  35. package/src/oauth.test.ts +345 -0
  36. package/src/oauth.ts +85 -14
  37. package/src/owner-auth.ts +57 -1
  38. package/src/prompt.ts +6 -5
  39. package/src/routes.ts +686 -58
  40. package/src/routing.test.ts +466 -1
  41. package/src/routing.ts +108 -24
  42. package/src/scopes.test.ts +66 -8
  43. package/src/scopes.ts +163 -37
  44. package/src/server.ts +24 -2
  45. package/src/services-manifest.test.ts +20 -0
  46. package/src/services-manifest.ts +9 -2
  47. package/src/stop-signal.test.ts +85 -0
  48. package/src/storage.test.ts +92 -0
  49. package/src/tag-scope.ts +118 -0
  50. package/src/token-store.test.ts +47 -0
  51. package/src/token-store.ts +128 -13
  52. package/src/tokens-routes.test.ts +720 -0
  53. package/src/tokens-routes.ts +392 -0
  54. package/src/transcription-worker.test.ts +5 -0
  55. package/src/triggers.ts +1 -1
  56. package/src/two-factor.ts +2 -2
  57. package/src/vault-create.test.ts +193 -0
  58. package/src/vault-name.test.ts +123 -0
  59. package/src/vault-name.ts +80 -0
  60. package/src/vault.test.ts +868 -3
  61. package/tsconfig.json +8 -1
  62. package/.claude/settings.local.json +0 -8
  63. package/.dockerignore +0 -8
  64. package/.env.example +0 -9
  65. package/CHANGELOG.md +0 -175
  66. package/CLAUDE.md +0 -125
  67. package/Caddyfile +0 -3
  68. package/Dockerfile +0 -22
  69. package/bun.lock +0 -219
  70. package/bunfig.toml +0 -2
  71. package/deploy/parachute-vault.service +0 -20
  72. package/docker-compose.yml +0 -50
  73. package/docs/HTTP_API.md +0 -434
  74. package/docs/auth-model.md +0 -340
  75. package/fly.toml +0 -24
  76. package/package/package.json +0 -32
  77. package/railway.json +0 -14
  78. package/scripts/migrate-audio-to-opus.test.ts +0 -237
  79. package/scripts/migrate-audio-to-opus.ts +0 -499
package/core/src/store.ts CHANGED
@@ -7,6 +7,19 @@ import * as tagSchemaOps from "./tag-schemas.js";
7
7
  import { syncWikilinks, resolveUnresolvedWikilinks } from "./wikilinks.js";
8
8
  import { pathTitle } from "./paths.js";
9
9
  import { HookRegistry } from "./hooks.js";
10
+ import {
11
+ loadTagHierarchy,
12
+ getTagDescendants,
13
+ TAG_CONFIG_PREFIX,
14
+ type TagHierarchy,
15
+ } from "./tag-hierarchy.js";
16
+ import {
17
+ loadSchemaConfig,
18
+ validateNote as runValidateNote,
19
+ type ResolvedSchemas,
20
+ type ValidationStatus,
21
+ } from "./schema-defaults.js";
22
+ import * as noteSchemaOps from "./note-schemas.js";
10
23
 
11
24
  /**
12
25
  * bun:sqlite-backed Store implementation. Internally everything is
@@ -16,11 +29,68 @@ import { HookRegistry } from "./hooks.js";
16
29
  export class BunSqliteStore implements Store {
17
30
  public readonly hooks: HookRegistry;
18
31
 
32
+ // Lazy-built caches over the post-v14 `tags` table (hierarchy via
33
+ // parent_names) and the post-v15 `note_schemas` + `schema_mappings`
34
+ // tables (validation). Null means "not yet loaded or invalidated"; the
35
+ // next read rebuilds. We invalidate synchronously inside the writers
36
+ // that mutate the source tables so reads after writes always see the
37
+ // post-write state.
38
+ private _tagHierarchy: TagHierarchy | null = null;
39
+ private _schemaConfig: ResolvedSchemas | null = null;
40
+
19
41
  constructor(public readonly db: Database, opts?: { hooks?: HookRegistry }) {
20
42
  initSchema(db);
21
43
  this.hooks = opts?.hooks ?? new HookRegistry();
22
44
  }
23
45
 
46
+ /**
47
+ * Lazy accessor for the `_tags/*` config-note hierarchy. First call after
48
+ * boot or after an invalidation does the scan; subsequent calls hit the
49
+ * cache. Returns the same object until invalidated, so callers can rely
50
+ * on identity for memoizing per-tag descendant sets.
51
+ */
52
+ private getTagHierarchy(): TagHierarchy {
53
+ if (!this._tagHierarchy) this._tagHierarchy = loadTagHierarchy(this.db);
54
+ return this._tagHierarchy;
55
+ }
56
+
57
+ /**
58
+ * Lazy accessor for the `note_schemas` + `schema_mappings` resolution.
59
+ * Same lifecycle as the tag hierarchy cache.
60
+ */
61
+ private getSchemaConfig(): ResolvedSchemas {
62
+ if (!this._schemaConfig) this._schemaConfig = loadSchemaConfig(this.db);
63
+ return this._schemaConfig;
64
+ }
65
+
66
+ /**
67
+ * Run the resolved schemas against a note and return the resulting
68
+ * validation status, or null when no schema applies. Public so the MCP
69
+ * layer can surface `validation_status` on create/update responses
70
+ * without re-importing the config loader.
71
+ */
72
+ validateNoteAgainstSchemas(note: { path?: string | null; tags?: string[]; metadata?: Record<string, unknown> }): ValidationStatus | null {
73
+ return runValidateNote(this.getSchemaConfig(), note);
74
+ }
75
+
76
+ /**
77
+ * Drop the tag-hierarchy cache if the mutated path is in the `_tags/*`
78
+ * namespace. Called from create/update/delete — old path is passed
79
+ * alongside new for rename cases (a note moved out of `_tags/` should
80
+ * still invalidate).
81
+ *
82
+ * Post-v15 the schema-config cache is no longer note-driven — its
83
+ * invalidation hook is on `upsertNoteSchema` / `setSchemaMapping` /
84
+ * `deleteNoteSchema` / `deleteSchemaMapping` instead.
85
+ */
86
+ private invalidateConfigCachesForPath(path: string | null | undefined, oldPath?: string | null): void {
87
+ const isTagConfig = (p: string | null | undefined): boolean =>
88
+ typeof p === "string" && p.startsWith(TAG_CONFIG_PREFIX);
89
+ if (isTagConfig(path) || isTagConfig(oldPath)) {
90
+ this._tagHierarchy = null;
91
+ }
92
+ }
93
+
24
94
  // ---- Notes ----
25
95
 
26
96
  async createNote(content: string, opts?: { id?: string; path?: string; tags?: string[]; metadata?: Record<string, unknown>; created_at?: string }): Promise<Note> {
@@ -34,6 +104,7 @@ export class BunSqliteStore implements Store {
34
104
  resolveUnresolvedWikilinks(this.db, note.path, note.id);
35
105
  }
36
106
 
107
+ this.invalidateConfigCachesForPath(note.path);
37
108
  this.hooks.dispatch("created", note, this);
38
109
 
39
110
  return note;
@@ -55,6 +126,8 @@ export class BunSqliteStore implements Store {
55
126
  id: string,
56
127
  updates: {
57
128
  content?: string;
129
+ append?: string;
130
+ prepend?: string;
58
131
  path?: string;
59
132
  metadata?: Record<string, unknown>;
60
133
  created_at?: string;
@@ -70,8 +143,11 @@ export class BunSqliteStore implements Store {
70
143
 
71
144
  const note = noteOps.updateNote(this.db, id, updates);
72
145
 
73
- if (updates.content !== undefined) {
74
- syncWikilinks(this.db, id, updates.content);
146
+ // Wikilink sync runs against the *resulting* content. For append/prepend
147
+ // we don't have the new value pre-write — read it back off the returned
148
+ // note so a `[[Foo]]` introduced via append still creates the link.
149
+ if (updates.content !== undefined || updates.append !== undefined || updates.prepend !== undefined) {
150
+ syncWikilinks(this.db, id, note.content);
75
151
  }
76
152
 
77
153
  if (updates.path !== undefined && note.path) {
@@ -81,6 +157,12 @@ export class BunSqliteStore implements Store {
81
157
  resolveUnresolvedWikilinks(this.db, note.path, id);
82
158
  }
83
159
 
160
+ // Invalidate before the hook dispatch so any handler that re-queries
161
+ // the hierarchy from inside its own logic sees post-write state.
162
+ // `metadata` updates can change the `parents` field on a config note
163
+ // even when the path didn't change, so always invalidate when the
164
+ // current path is in a config namespace.
165
+ this.invalidateConfigCachesForPath(note.path, oldPath);
84
166
  this.hooks.dispatch("updated", note, this);
85
167
 
86
168
  return note;
@@ -122,14 +204,50 @@ export class BunSqliteStore implements Store {
122
204
  }
123
205
 
124
206
  async deleteNote(id: string): Promise<void> {
207
+ // Read before delete so we can invalidate config caches on the way out.
208
+ const existing = noteOps.getNote(this.db, id);
125
209
  noteOps.deleteNote(this.db, id);
210
+ if (existing?.path) this.invalidateConfigCachesForPath(existing.path);
126
211
  }
127
212
 
128
213
  async queryNotes(opts: QueryOpts): Promise<Note[]> {
129
- return noteOps.queryNotes(this.db, opts);
214
+ return noteOps.queryNotes(this.db, this.expandQueryTags(opts));
215
+ }
216
+
217
+ /**
218
+ * If `tags` are present, attach a parallel `_tagsExpanded` array where
219
+ * each input tag is replaced with `{tag} ∪ descendants(tag)`. The SQL
220
+ * builder uses this to widen the tag join from `name = ?` to
221
+ * `name IN (...)`, so a query for `#manual` matches notes tagged with
222
+ * any descendant declared via `_tags/*` config notes.
223
+ *
224
+ * No-op when no `_tags/*` notes exist (empty hierarchy → each tag
225
+ * expands to just itself, identical to the pre-expansion behavior).
226
+ */
227
+ private expandQueryTags(opts: QueryOpts): QueryOpts {
228
+ if (!opts.tags || opts.tags.length === 0) return opts;
229
+ const hierarchy = this.getTagHierarchy();
230
+ if (hierarchy.childrenOf.size === 0) return opts;
231
+ const expanded = opts.tags.map((t) => Array.from(getTagDescendants(hierarchy, t)));
232
+ return { ...opts, _tagsExpanded: expanded } as QueryOpts;
130
233
  }
131
234
 
132
235
  async searchNotes(query: string, opts?: { tags?: string[]; limit?: number }): Promise<Note[]> {
236
+ // Same hierarchy-expansion treatment as queryNotes — searching `#manual`
237
+ // should match notes tagged with any descendant tag. The underlying
238
+ // FTS path already uses `IN (...)` for tags, so we flatten the
239
+ // per-input expansions into a single union (search semantics are
240
+ // "any tag matches").
241
+ if (opts?.tags && opts.tags.length > 0) {
242
+ const hierarchy = this.getTagHierarchy();
243
+ if (hierarchy.childrenOf.size > 0) {
244
+ const expanded = new Set<string>();
245
+ for (const t of opts.tags) {
246
+ for (const x of getTagDescendants(hierarchy, t)) expanded.add(x);
247
+ }
248
+ return noteOps.searchNotes(this.db, query, { ...opts, tags: Array.from(expanded) });
249
+ }
250
+ }
133
251
  return noteOps.searchNotes(this.db, query, opts);
134
252
  }
135
253
 
@@ -143,23 +261,45 @@ export class BunSqliteStore implements Store {
143
261
  noteOps.untagNote(this.db, noteId, tags);
144
262
  }
145
263
 
264
+ async expandTagsWithDescendants(tags: string[]): Promise<Set<string>> {
265
+ const expanded = new Set<string>();
266
+ if (tags.length === 0) return expanded;
267
+ const hierarchy = this.getTagHierarchy();
268
+ for (const t of tags) {
269
+ for (const x of getTagDescendants(hierarchy, t)) expanded.add(x);
270
+ }
271
+ return expanded;
272
+ }
273
+
146
274
  async listTags(): Promise<{ name: string; count: number }[]> {
147
275
  return noteOps.listTags(this.db);
148
276
  }
149
277
 
150
278
  async deleteTag(name: string): Promise<{ deleted: boolean; notes_untagged: number }> {
151
- return noteOps.deleteTag(this.db, name);
279
+ const result = noteOps.deleteTag(this.db, name);
280
+ // The deleted tag may have been a parent or child in the hierarchy.
281
+ this._tagHierarchy = null;
282
+ return result;
152
283
  }
153
284
 
154
285
  async renameTag(oldName: string, newName: string): Promise<noteOps.RenameTagResult> {
155
- return noteOps.renameTag(this.db, oldName, newName);
286
+ const result = noteOps.renameTag(this.db, oldName, newName);
287
+ // Other tags' parent_names may reference oldName — though we don't
288
+ // rewrite those, the hierarchy cache should be rebuilt to pick up the
289
+ // new row identity.
290
+ this._tagHierarchy = null;
291
+ return result;
156
292
  }
157
293
 
158
294
  async mergeTags(
159
295
  sources: string[],
160
296
  target: string,
161
297
  ): Promise<{ merged: Record<string, number>; target: string }> {
162
- return noteOps.mergeTags(this.db, sources, target);
298
+ const result = noteOps.mergeTags(this.db, sources, target);
299
+ // Source tags drop out of the hierarchy; downstream callers asking
300
+ // for descendants of target should pick up any merged children.
301
+ this._tagHierarchy = null;
302
+ return result;
163
303
  }
164
304
 
165
305
  // ---- Vault Stats ----
@@ -191,6 +331,11 @@ export class BunSqliteStore implements Store {
191
331
  async createNotes(inputs: noteOps.BulkNoteInput[]): Promise<Note[]> {
192
332
  const notes = noteOps.createNotes(this.db, inputs);
193
333
  for (const note of notes) {
334
+ // Bulk path needs the same config-cache invalidation as singleton
335
+ // createNote — without it, a batch that includes `_tags/*` or
336
+ // `_schemas/*` notes would leave the cache stale until the next
337
+ // singleton write happened to bust it.
338
+ this.invalidateConfigCachesForPath(note.path);
194
339
  this.hooks.dispatch("created", note, this);
195
340
  }
196
341
  return notes;
@@ -236,16 +381,113 @@ export class BunSqliteStore implements Store {
236
381
  return tagSchemaOps.getTagSchemaMap(this.db);
237
382
  }
238
383
 
384
+ // ---- Tag Records (post-v14: full identity row) ----
385
+
386
+ async listTagRecords() {
387
+ return tagSchemaOps.listTagRecords(this.db);
388
+ }
389
+
390
+ async getTagRecord(tag: string) {
391
+ return tagSchemaOps.getTagRecord(this.db, tag);
392
+ }
393
+
394
+ /**
395
+ * Partial upsert of the full tag record. Any patch field left undefined
396
+ * is preserved; pass null to clear. Invalidates the tag-hierarchy cache
397
+ * when `parent_names` is touched.
398
+ */
399
+ async upsertTagRecord(
400
+ tag: string,
401
+ patch: {
402
+ description?: string | null;
403
+ fields?: Record<string, tagSchemaOps.TagFieldSchema> | null;
404
+ relationships?: Record<string, tagSchemaOps.TagRelationship> | null;
405
+ parent_names?: string[] | null;
406
+ },
407
+ ) {
408
+ const result = tagSchemaOps.upsertTagRecord(this.db, tag, patch);
409
+ if (patch.parent_names !== undefined) {
410
+ this._tagHierarchy = null;
411
+ }
412
+ return result;
413
+ }
414
+
239
415
  // ---- Batch Wikilink Sync ----
240
416
 
241
417
  /**
242
418
  * Create a note without triggering wikilink sync.
243
419
  * Use this during bulk imports, then call syncAllWikilinks() after.
420
+ *
421
+ * Does **not** invalidate the `_tags/*` config cache — importers writing
422
+ * tag-hierarchy notes through this path must call `rebuildConfigCaches()`
423
+ * once the import is done. (Default importers follow `createNoteRaw` with
424
+ * `syncAllWikilinks`, so adding the cache rebuild there is the natural
425
+ * place.)
244
426
  */
245
427
  async createNoteRaw(content: string, opts?: { id?: string; path?: string; tags?: string[]; metadata?: Record<string, unknown>; created_at?: string }): Promise<Note> {
246
428
  return noteOps.createNote(this.db, content, opts);
247
429
  }
248
430
 
431
+ /**
432
+ * Drop the config caches unconditionally. Used by bulk-import paths that
433
+ * skip per-note invalidation for throughput, and by importers that
434
+ * directly populate `note_schemas` / `schema_mappings`.
435
+ */
436
+ rebuildConfigCaches(): void {
437
+ this._tagHierarchy = null;
438
+ this._schemaConfig = null;
439
+ }
440
+
441
+ // ---- Note schemas (post-v15: validation by path-prefix or tag) ----
442
+
443
+ async listNoteSchemas() {
444
+ return noteSchemaOps.listNoteSchemas(this.db);
445
+ }
446
+
447
+ async getNoteSchema(name: string) {
448
+ return noteSchemaOps.getNoteSchema(this.db, name);
449
+ }
450
+
451
+ /**
452
+ * Partial-upsert a note schema. Auto-creates the row if missing. Any
453
+ * patch field left undefined is preserved; pass null to clear. Empty
454
+ * `required: []` collapses to null. Invalidates the schema-config cache.
455
+ */
456
+ async upsertNoteSchema(name: string, patch: noteSchemaOps.NoteSchemaPatch) {
457
+ const result = noteSchemaOps.upsertNoteSchema(this.db, name, patch);
458
+ this._schemaConfig = null;
459
+ return result;
460
+ }
461
+
462
+ async deleteNoteSchema(name: string) {
463
+ const removed = noteSchemaOps.deleteNoteSchema(this.db, name);
464
+ if (removed) this._schemaConfig = null;
465
+ return removed;
466
+ }
467
+
468
+ async listSchemaMappings(opts?: noteSchemaOps.ListMappingsOpts) {
469
+ return noteSchemaOps.listSchemaMappings(this.db, opts ?? {});
470
+ }
471
+
472
+ async setSchemaMapping(
473
+ schema_name: string,
474
+ match_kind: noteSchemaOps.SchemaMappingKind,
475
+ match_value: string,
476
+ ) {
477
+ noteSchemaOps.setSchemaMapping(this.db, schema_name, match_kind, match_value);
478
+ this._schemaConfig = null;
479
+ }
480
+
481
+ async deleteSchemaMapping(
482
+ schema_name: string,
483
+ match_kind: noteSchemaOps.SchemaMappingKind,
484
+ match_value: string,
485
+ ) {
486
+ const removed = noteSchemaOps.deleteSchemaMapping(this.db, schema_name, match_kind, match_value);
487
+ if (removed) this._schemaConfig = null;
488
+ return removed;
489
+ }
490
+
249
491
  /**
250
492
  * Sync wikilinks for all notes in the vault.
251
493
  * Efficient for bulk imports — call once after importing all notes.
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Tag hierarchy resolution from the `tags.parent_names` column.
3
+ *
4
+ * A `tags` row named `voice` with `parent_names = ["manual", "note"]`
5
+ * registers `voice` as a child of `manual` and `note`. Queries that ask for
6
+ * `tags: ["manual"]` then transparently match notes tagged `#voice` (or any
7
+ * other transitive descendant of `#manual`).
8
+ *
9
+ * History: pre-v14 vaults stored hierarchy in notes-as-config at
10
+ * `_tags/<name>`. The v14 migration (see core/src/schema.ts:migrateToV14)
11
+ * lifts those parent declarations onto the tags row and the resolver here
12
+ * was swapped accordingly. See parachute-patterns/patterns/tag-data-model.md.
13
+ *
14
+ * Resolution model:
15
+ * - Lazy: built on first access, cached on the store.
16
+ * - Invalidated synchronously when a tag's parent_names changes (see
17
+ * `BunSqliteStore.invalidateTagCaches`).
18
+ * - Tags without parent_names are treated as root-level (no parents, no
19
+ * children). They still match queries by their own name.
20
+ *
21
+ * Cycle handling:
22
+ * - Cycles in declared parents are tolerated at load — we don't reject the
23
+ * config (we don't have a "fail loud" signal at boot from inside a query).
24
+ * Descendant traversal uses a visited-set so a cycle can't loop forever;
25
+ * the resolved descendant set is well-defined regardless.
26
+ */
27
+
28
+ import { Database } from "bun:sqlite";
29
+
30
+ export interface TagHierarchy {
31
+ /** tag → set of immediate child tags (those that declared `tag` as a parent). */
32
+ childrenOf: Map<string, Set<string>>;
33
+ /** Memoization cache: tag → set including the tag itself plus all transitive descendants. */
34
+ descendantsCache: Map<string, Set<string>>;
35
+ }
36
+
37
+ /**
38
+ * Pre-v14 path prefix that marked a note as a tag-hierarchy declaration.
39
+ * Retained as an exported constant so call-sites that still need to know
40
+ * about historical `_tags/*` notes (cache-invalidation, importers) can
41
+ * reference a single source of truth.
42
+ */
43
+ export const TAG_CONFIG_PREFIX = "_tags/";
44
+
45
+ /**
46
+ * Decode a JSON-encoded `parent_names` column value, defending against
47
+ * malformed input. Non-string entries are dropped silently — the column
48
+ * is expected to be well-formed (we control all writers) but a single bad
49
+ * row shouldn't break the whole hierarchy resolution.
50
+ */
51
+ function readParentNames(raw: string | null): string[] {
52
+ if (!raw) return [];
53
+ let parsed: unknown;
54
+ try { parsed = JSON.parse(raw); } catch { return []; }
55
+ if (!Array.isArray(parsed)) return [];
56
+ return parsed.filter((x): x is string => typeof x === "string" && x.length > 0);
57
+ }
58
+
59
+ /**
60
+ * Scan the `tags` table and build the parent→children adjacency map.
61
+ * Each row's `parent_names` JSON array contributes one edge per parent.
62
+ */
63
+ export function loadTagHierarchy(db: Database): TagHierarchy {
64
+ const rows = db.prepare(
65
+ `SELECT name, parent_names FROM tags WHERE parent_names IS NOT NULL`,
66
+ ).all() as { name: string; parent_names: string | null }[];
67
+
68
+ const childrenOf = new Map<string, Set<string>>();
69
+
70
+ for (const row of rows) {
71
+ if (!row.name) continue;
72
+ const parents = readParentNames(row.parent_names);
73
+ for (const parent of parents) {
74
+ let children = childrenOf.get(parent);
75
+ if (!children) {
76
+ children = new Set();
77
+ childrenOf.set(parent, children);
78
+ }
79
+ children.add(row.name);
80
+ }
81
+ }
82
+
83
+ return { childrenOf, descendantsCache: new Map() };
84
+ }
85
+
86
+ /**
87
+ * Return the tag plus all transitive descendants. Always includes the tag
88
+ * itself, so callers can use the result as a drop-in replacement for the
89
+ * input tag when expanding queries.
90
+ */
91
+ export function getTagDescendants(h: TagHierarchy, tag: string): Set<string> {
92
+ const cached = h.descendantsCache.get(tag);
93
+ if (cached) return cached;
94
+
95
+ const result = new Set<string>([tag]);
96
+ const stack = [tag];
97
+ while (stack.length > 0) {
98
+ const current = stack.pop()!;
99
+ const children = h.childrenOf.get(current);
100
+ if (!children) continue;
101
+ for (const child of children) {
102
+ if (result.has(child)) continue;
103
+ result.add(child);
104
+ stack.push(child);
105
+ }
106
+ }
107
+
108
+ h.descendantsCache.set(tag, result);
109
+ return result;
110
+ }
111
+
112
+ /**
113
+ * Detect cycles in the declared hierarchy. Returns the list of tags
114
+ * reachable from themselves via parent declarations. Used by
115
+ * `update-tag` write paths to surface a warning to the caller without
116
+ * blocking the write — cycles are tolerated at runtime (descendant
117
+ * traversal uses a visited set), but they're almost always a config bug.
118
+ */
119
+ export function findHierarchyCycles(h: TagHierarchy): string[] {
120
+ const cycles: string[] = [];
121
+ for (const tag of h.childrenOf.keys()) {
122
+ const descendants = getTagDescendants(h, tag);
123
+ if (descendants.has(tag) && descendants.size > 1) {
124
+ // tag reaches itself through a non-trivial path
125
+ const ownChildren = h.childrenOf.get(tag);
126
+ if (ownChildren) {
127
+ for (const child of ownChildren) {
128
+ if (getTagDescendants(h, child).has(tag)) {
129
+ cycles.push(tag);
130
+ break;
131
+ }
132
+ }
133
+ }
134
+ }
135
+ }
136
+ return cycles;
137
+ }