@abarcenas/ai-workflow-template 1.49.0 → 2.0.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 (35) hide show
  1. package/.agents/instructions/knowledgebase.instructions.md +2 -2
  2. package/.agents/instructions/learned-knowledge.instructions.md +47 -2
  3. package/.agents/instructions/memory-schema.instructions.md +1 -1
  4. package/.github/workflows/npm-publish.yml +69 -1
  5. package/.opencode/plugins/graphify.js +22 -0
  6. package/LICENSE +15 -0
  7. package/README.md +56 -22
  8. package/memory-bank/.vocabulary.json +3 -0
  9. package/opencode.mcp.example.json +13 -0
  10. package/package.json +20 -10
  11. package/scripts/bump-version.js +18 -2
  12. package/scripts/mcp/playwright-mcp-launcher.js +45 -2
  13. package/scripts/bump-version.test.js +0 -238
  14. package/scripts/knowledgebase-cli.test.js +0 -576
  15. package/scripts/knowledgebase-index.test.js +0 -759
  16. package/scripts/mcp/playwright-mcp-launcher.test.js +0 -217
  17. package/scripts/mcp-knowledgebase-server.test.js +0 -745
  18. package/scripts/mcp-memory-server.test.js +0 -398
  19. package/scripts/memory-cli.test.js +0 -297
  20. package/scripts/memory-index.test.js +0 -1173
  21. package/scripts/normalize-memory.test.js +0 -753
  22. package/scripts/setup/discover.test.js +0 -530
  23. package/scripts/setup/hooks.test.js +0 -722
  24. package/scripts/setup/husky-init.test.js +0 -311
  25. package/scripts/setup/index.test.js +0 -1021
  26. package/scripts/setup/knowledgebase.test.js +0 -352
  27. package/scripts/setup/prepare.test.js +0 -718
  28. package/scripts/setup/skills-phase.test.js +0 -1274
  29. package/scripts/setup/sync-phase.test.js +0 -373
  30. package/scripts/setup/ui.test.js +0 -579
  31. package/scripts/setup/utils.test.js +0 -586
  32. package/scripts/sync-unit.test.js +0 -1881
  33. package/scripts/sync.test.js +0 -472
  34. package/scripts/validate-memory-schema.test.js +0 -328
  35. package/scripts/vocab-sync.test.js +0 -902
@@ -1,902 +0,0 @@
1
- // ═════════════════════════════════════════════════════════════════════════════
2
- // vocab-sync.test.js — Unit tests for scripts/vocab-sync.js
3
- // ═════════════════════════════════════════════════════════════════════════════
4
- //
5
- // Tests the pre-commit vocabulary sync hook (plan/feature-vocab-sync-1.md T2).
6
- // Fixtures use REAL temp directories with a real `git init` repo (matches the
7
- // sync.test.js / hooks.test.js conventions) rather than heavy fs/child_process
8
- // mocking. Because the module resolves `memory-bank/` from `process.cwd()` at
9
- // import time (module-level MEMORY_BANK_DIR / VOCAB_PATH), each test:
10
- // 1. creates a temp dir,
11
- // 2. process.chdir() into it,
12
- // 3. vi.resetModules() + dynamic import so the paths point at the fixture.
13
- // A small set of CLI-level tests spawn the real script (`spawnSync`) to verify
14
- // the actual exit-code contract (exit 0 for graceful paths, exit 1 for
15
- // unhandled errors) through the isDirectRun guard.
16
- //
17
- // Covered scenarios (plan T2 TEST-01..14 → 54 tests implemented):
18
- // 1. parseFrontmatter: array tags, quoted values, quoted array entries,
19
- // non-kv lines skipped, none
20
- // 2. normalizeTag: lowercase/kebab, underscores→hyphens, whitespace runs,
21
- // trims, whitespace-only/empty → '', kebab preserved
22
- // 3. buildKnownTags: tags.* values + entity_patterns keys, normalization,
23
- // empty/undefined vocab, null entity_patterns
24
- // 4. loadVocabulary: present, missing (warn + null), malformed (warn + null)
25
- // 5. getStagedMemoryFiles: memory-bank .md filter, /.index/ exclusion,
26
- // non-git → []
27
- // 6. collectNewTags: new tags, known-tag skip, entity_patterns skip,
28
- // normalize + dedupe + sort across files, blank drop, unreadable file,
29
- // no frontmatter, non-array tags
30
- // 7. syncVocabulary: append + write + stage + warn, dedupe, existing-entry
31
- // normalization, no-op (no write / no stage), git-add-failure warns only,
32
- // missing tags object / missing topic group
33
- // 8. main(): TEST-01 new tag staged; TEST-02 tags.workflow skip;
34
- // TEST-03 entity_patterns skip (npm); TEST-04 "My Tag" → my-tag;
35
- // TEST-05 "my_tag" → my-tag; TEST-06/07 whitespace/empty drop;
36
- // TEST-08 dedupe; TEST-09 multi-file sort; TEST-10 all-known no-op;
37
- // TEST-11 idempotency; TEST-12 missing vocab; TEST-13 no staged files
38
- // 9. CLI spawned: add → exit 0; missing vocab → exit 0 warn;
39
- // malformed vocab → exit 0 warn; TEST-14 write failure → exit 1
40
- // 10. Direct-run guard (isDirectRun true): main() runs on import, unhandled
41
- // error → console.error + process.exit(1)
42
- // ═════════════════════════════════════════════════════════════════════════════
43
-
44
- import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
45
- import { execSync, spawnSync } from 'node:child_process'
46
- import {
47
- mkdtempSync,
48
- mkdirSync,
49
- writeFileSync,
50
- readFileSync,
51
- chmodSync,
52
- rmSync,
53
- } from 'node:fs'
54
- import { join, dirname } from 'node:path'
55
- import { tmpdir } from 'node:os'
56
- import { fileURLToPath } from 'node:url'
57
-
58
- // ── Fixture helpers ────────────────────────────────────────────────────────
59
-
60
- const ORIGINAL_CWD = process.cwd()
61
- const ORIG_ARGV1 = process.argv[1]
62
- const SCRIPT_PATH = fileURLToPath(new URL('./vocab-sync.js', import.meta.url))
63
- const tempDirs = new Set()
64
-
65
- function createTempDir() {
66
- const dir = mkdtempSync(join(tmpdir(), 'vocab-sync-test-'))
67
- tempDirs.add(dir)
68
- return dir
69
- }
70
-
71
- /** Write memory-bank/.vocabulary.json using the repo's canonical format. */
72
- function writeVocab(dir, vocab) {
73
- const mbDir = join(dir, 'memory-bank')
74
- mkdirSync(mbDir, { recursive: true })
75
- writeFileSync(join(mbDir, '.vocabulary.json'), `${JSON.stringify(vocab, null, 2)}\n`, 'utf8')
76
- }
77
-
78
- /** Read and parse memory-bank/.vocabulary.json from a fixture dir. */
79
- function readVocab(dir) {
80
- return JSON.parse(readFileSync(join(dir, 'memory-bank', '.vocabulary.json'), 'utf8'))
81
- }
82
-
83
- /** Write a file at a repo-relative path, creating parent directories. */
84
- function writeRepoFile(dir, relPath, content) {
85
- const filePath = join(dir, relPath)
86
- mkdirSync(dirname(filePath), { recursive: true })
87
- writeFileSync(filePath, content, 'utf8')
88
- }
89
-
90
- /** Build a markdown file with YAML frontmatter containing the given tags. */
91
- function frontmatterWithTags(tags) {
92
- return [
93
- '---',
94
- 'id: fixture',
95
- 'title: Fixture',
96
- `tags: [${tags.join(', ')}]`,
97
- 'entities: []',
98
- 'category: context',
99
- '---',
100
- '',
101
- '# Fixture',
102
- 'body',
103
- ].join('\n')
104
- }
105
-
106
- /** Initialize a fresh git repo in dir (staged diff works before any commit). */
107
- function gitInitRepo(dir) {
108
- execSync('git init -q', { cwd: dir })
109
- }
110
-
111
- /** Stage files by repo-relative path. */
112
- function gitAdd(dir, paths) {
113
- for (const p of paths) {
114
- execSync(`git add -- "${p}"`, { cwd: dir })
115
- }
116
- }
117
-
118
- /** Return the list of staged file paths via git diff --cached. */
119
- function gitStagedFiles(dir) {
120
- return execSync('git diff --cached --name-only', { cwd: dir, encoding: 'utf8' })
121
- .split('\n')
122
- .filter(Boolean)
123
- }
124
-
125
- /**
126
- * Load vocab-sync.js with its module-level paths resolved against `dir`.
127
- * Chdir must happen BEFORE import because MEMORY_BANK_DIR / VOCAB_PATH are
128
- * computed from process.cwd() at module evaluation time.
129
- */
130
- async function loadVocabSyncIn(dir) {
131
- process.chdir(dir)
132
- vi.resetModules()
133
- return await import('./vocab-sync.js')
134
- }
135
-
136
- /** Load the module in a fresh throwaway temp dir (for pure-function tests). */
137
- function loadFresh() {
138
- return loadVocabSyncIn(createTempDir())
139
- }
140
-
141
- /** Run the real script as a child process from dir; returns spawnSync result. */
142
- function runScript(dir) {
143
- return spawnSync('node', [SCRIPT_PATH], {
144
- cwd: dir,
145
- encoding: 'utf8',
146
- })
147
- }
148
-
149
- // ── Test lifecycle ─────────────────────────────────────────────────────────
150
-
151
- let warnSpy
152
-
153
- beforeEach(() => {
154
- process.chdir(ORIGINAL_CWD)
155
- warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
156
- })
157
-
158
- afterEach(() => {
159
- process.chdir(ORIGINAL_CWD)
160
- process.argv[1] = ORIG_ARGV1
161
- for (const dir of tempDirs) {
162
- try {
163
- rmSync(dir, { recursive: true, force: true })
164
- } catch {
165
- /* best-effort cleanup */
166
- }
167
- }
168
- tempDirs.clear()
169
- vi.restoreAllMocks()
170
- })
171
-
172
- // ── parseFrontmatter ───────────────────────────────────────────────────────
173
-
174
- describe('parseFrontmatter', () => {
175
- it('parses an array-valued tags field', async () => {
176
- const vs = await loadFresh()
177
- const fm = vs.parseFrontmatter(frontmatterWithTags(['alpha', 'beta']))
178
- expect(fm.tags).toEqual(['alpha', 'beta'])
179
- })
180
-
181
- it('strips surrounding quotes from array entries', async () => {
182
- const vs = await loadFresh()
183
- const content = '---\ntags: ["one", "two"]\n---\nBody'
184
- expect(vs.parseFrontmatter(content).tags).toEqual(['one', 'two'])
185
- })
186
-
187
- it('parses quoted scalar values', async () => {
188
- const vs = await loadFresh()
189
- const content = '---\ntitle: "My Title"\n---\nBody'
190
- expect(vs.parseFrontmatter(content).title).toBe('My Title')
191
- })
192
-
193
- it('skips lines that are not key: value pairs', async () => {
194
- const vs = await loadFresh()
195
- const content = '---\nid: x\n- list item\ntags: [alpha]\n---\nBody'
196
- const fm = vs.parseFrontmatter(content)
197
- expect(fm.id).toBe('x')
198
- expect(fm.tags).toEqual(['alpha'])
199
- })
200
-
201
- it('returns null when there is no frontmatter block', async () => {
202
- const vs = await loadFresh()
203
- expect(vs.parseFrontmatter('# Just a heading')).toBeNull()
204
- })
205
- })
206
-
207
- // ── normalizeTag ───────────────────────────────────────────────────────────
208
-
209
- describe('normalizeTag', () => {
210
- it('lowercases and converts spaces to kebab-case', async () => {
211
- const vs = await loadFresh()
212
- expect(vs.normalizeTag('My Tag')).toBe('my-tag')
213
- })
214
-
215
- it('converts underscores to hyphens', async () => {
216
- const vs = await loadFresh()
217
- expect(vs.normalizeTag('my_tag')).toBe('my-tag')
218
- })
219
-
220
- it('collapses runs of whitespace into a single hyphen', async () => {
221
- const vs = await loadFresh()
222
- expect(vs.normalizeTag(' Multi Word ')).toBe('multi-word')
223
- })
224
-
225
- it('trims surrounding whitespace', async () => {
226
- const vs = await loadFresh()
227
- expect(vs.normalizeTag(' padded ')).toBe('padded')
228
- })
229
-
230
- it('returns an empty string for whitespace-only input', async () => {
231
- const vs = await loadFresh()
232
- expect(vs.normalizeTag(' ')).toBe('')
233
- })
234
-
235
- it('returns an empty string for empty input', async () => {
236
- const vs = await loadFresh()
237
- expect(vs.normalizeTag('')).toBe('')
238
- })
239
-
240
- it('preserves already-normalized kebab-case tags', async () => {
241
- const vs = await loadFresh()
242
- expect(vs.normalizeTag('already-kebab')).toBe('already-kebab')
243
- })
244
- })
245
-
246
- // ── buildKnownTags ─────────────────────────────────────────────────────────
247
-
248
- describe('buildKnownTags', () => {
249
- it('collects every value across tags.* groups and entity_patterns keys', async () => {
250
- const vs = await loadFresh()
251
- const vocab = {
252
- tags: { topic: ['alpha', 'Beta Tag'], workflow: ['gamma'] },
253
- entity_patterns: { npm: ['npm'], vitest: ['vitest'] },
254
- }
255
- const known = vs.buildKnownTags(vocab)
256
- expect(known.has('alpha')).toBe(true)
257
- expect(known.has('beta-tag')).toBe(true)
258
- expect(known.has('gamma')).toBe(true)
259
- expect(known.has('npm')).toBe(true)
260
- expect(known.has('vitest')).toBe(true)
261
- })
262
-
263
- it('returns an empty set when vocab has no tags or entity_patterns', async () => {
264
- const vs = await loadFresh()
265
- expect(vs.buildKnownTags({}).size).toBe(0)
266
- })
267
-
268
- it('handles an undefined vocab gracefully', async () => {
269
- const vs = await loadFresh()
270
- expect(vs.buildKnownTags(undefined).size).toBe(0)
271
- })
272
-
273
- it('handles a null entity_patterns object', async () => {
274
- const vs = await loadFresh()
275
- const known = vs.buildKnownTags({
276
- tags: { topic: ['alpha'] },
277
- entity_patterns: null,
278
- })
279
- expect(known.has('alpha')).toBe(true)
280
- })
281
-
282
- it('skips non-array groups without iterating characters when tags is an array (wrong shape)', async () => {
283
- const vs = await loadFresh()
284
- const known = vs.buildKnownTags({ tags: ['alpha', 'beta'] })
285
- // If the guard were missing, iterating the string 'alpha' would add
286
- // per-character entries ('a', 'l', 'p', ...) to the known set.
287
- expect(known.size).toBe(0)
288
- })
289
-
290
- it('skips a string group while still collecting valid array groups (wrong shape)', async () => {
291
- const vs = await loadFresh()
292
- const known = vs.buildKnownTags({
293
- tags: { topic: 'alpha', workflow: ['beta'] },
294
- })
295
- expect(known.has('alpha')).toBe(false)
296
- expect(known.has('beta')).toBe(true)
297
- })
298
- })
299
-
300
- // ── loadVocabulary ─────────────────────────────────────────────────────────
301
-
302
- describe('loadVocabulary', () => {
303
- it('returns the parsed vocabulary when present', async () => {
304
- const dir = createTempDir()
305
- const vocab = { tags: { topic: ['alpha'] } }
306
- writeVocab(dir, vocab)
307
- const vs = await loadVocabSyncIn(dir)
308
- expect(vs.loadVocabulary()).toEqual(vocab)
309
- })
310
-
311
- it('warns and returns null when .vocabulary.json is missing', async () => {
312
- const dir = createTempDir()
313
- mkdirSync(join(dir, 'memory-bank'), { recursive: true })
314
- const vs = await loadVocabSyncIn(dir)
315
- expect(vs.loadVocabulary()).toBeNull()
316
- expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('No .vocabulary.json'))
317
- })
318
-
319
- it('warns and returns null when .vocabulary.json is malformed JSON', async () => {
320
- const dir = createTempDir()
321
- const mbDir = join(dir, 'memory-bank')
322
- mkdirSync(mbDir, { recursive: true })
323
- writeFileSync(join(mbDir, '.vocabulary.json'), '{ not valid json', 'utf8')
324
- const vs = await loadVocabSyncIn(dir)
325
- expect(vs.loadVocabulary()).toBeNull()
326
- expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to parse'))
327
- })
328
- })
329
-
330
- // ── getStagedMemoryFiles ───────────────────────────────────────────────────
331
-
332
- describe('getStagedMemoryFiles', () => {
333
- it('returns only staged memory-bank markdown files, excluding /.index/', async () => {
334
- const dir = createTempDir()
335
- gitInitRepo(dir)
336
- writeRepoFile(dir, 'memory-bank/activeContext.md', frontmatterWithTags(['a']))
337
- writeRepoFile(dir, 'memory-bank/tasks/task.md', frontmatterWithTags(['b']))
338
- writeRepoFile(dir, 'memory-bank/.index/cache.md', 'cache')
339
- writeRepoFile(dir, 'docs/readme.md', '# readme')
340
- gitAdd(dir, [
341
- 'memory-bank/activeContext.md',
342
- 'memory-bank/tasks/task.md',
343
- 'memory-bank/.index/cache.md',
344
- 'docs/readme.md',
345
- ])
346
- const vs = await loadVocabSyncIn(dir)
347
- const files = vs.getStagedMemoryFiles()
348
- expect(files).toContain('memory-bank/activeContext.md')
349
- expect(files).toContain('memory-bank/tasks/task.md')
350
- expect(files).not.toContain('memory-bank/.index/cache.md')
351
- expect(files).not.toContain('docs/readme.md')
352
- })
353
-
354
- it('returns an empty array when the directory is not a git repo', async () => {
355
- const dir = createTempDir()
356
- const vs = await loadVocabSyncIn(dir)
357
- expect(vs.getStagedMemoryFiles()).toEqual([])
358
- })
359
- })
360
-
361
- // ── collectNewTags ─────────────────────────────────────────────────────────
362
-
363
- describe('collectNewTags', () => {
364
- it('returns tags not present in the vocabulary', async () => {
365
- const dir = createTempDir()
366
- writeRepoFile(dir, 'memory-bank/a.md', frontmatterWithTags(['new-tag', 'known-tag']))
367
- const vs = await loadVocabSyncIn(dir)
368
- const vocab = { tags: { topic: ['known-tag'] }, entity_patterns: {} }
369
- expect(vs.collectNewTags(['memory-bank/a.md'], vocab)).toEqual(['new-tag'])
370
- })
371
-
372
- it('does not collect tags that are entity_patterns keys', async () => {
373
- const dir = createTempDir()
374
- writeRepoFile(dir, 'memory-bank/a.md', frontmatterWithTags(['npm']))
375
- const vs = await loadVocabSyncIn(dir)
376
- const vocab = { tags: { topic: [] }, entity_patterns: { npm: ['npm'] } }
377
- expect(vs.collectNewTags(['memory-bank/a.md'], vocab)).toEqual([])
378
- })
379
-
380
- it('normalizes candidates, dedupes, and sorts across multiple files', async () => {
381
- const dir = createTempDir()
382
- writeRepoFile(dir, 'memory-bank/a.md', frontmatterWithTags(['Zeta Tag', 'alpha_tag', 'beta', 'beta']))
383
- writeRepoFile(dir, 'memory-bank/b.md', frontmatterWithTags(['alpha_tag', 'gamma']))
384
- const vs = await loadVocabSyncIn(dir)
385
- const vocab = { tags: { topic: [] } }
386
- expect(
387
- vs.collectNewTags(['memory-bank/a.md', 'memory-bank/b.md'], vocab),
388
- ).toEqual(['alpha-tag', 'beta', 'gamma', 'zeta-tag'])
389
- })
390
-
391
- it('drops empty and whitespace-only candidates', async () => {
392
- const dir = createTempDir()
393
- writeRepoFile(dir, 'memory-bank/a.md', frontmatterWithTags(['', ' ', 'real-tag']))
394
- const vs = await loadVocabSyncIn(dir)
395
- expect(
396
- vs.collectNewTags(['memory-bank/a.md'], { tags: { topic: [] } }),
397
- ).toEqual(['real-tag'])
398
- })
399
-
400
- it('skips unreadable files without crashing', async () => {
401
- const dir = createTempDir()
402
- const vs = await loadVocabSyncIn(dir)
403
- expect(
404
- vs.collectNewTags(['memory-bank/missing.md'], { tags: { topic: [] } }),
405
- ).toEqual([])
406
- })
407
-
408
- it('returns no tags when a file has no frontmatter', async () => {
409
- const dir = createTempDir()
410
- writeRepoFile(dir, 'memory-bank/a.md', '# No frontmatter here\n\nbody')
411
- const vs = await loadVocabSyncIn(dir)
412
- expect(
413
- vs.collectNewTags(['memory-bank/a.md'], { tags: { topic: [] } }),
414
- ).toEqual([])
415
- })
416
-
417
- it('skips a tags field that is not an array', async () => {
418
- const dir = createTempDir()
419
- writeRepoFile(dir, 'memory-bank/a.md', '---\ntags: solo-tag\n---\nBody')
420
- const vs = await loadVocabSyncIn(dir)
421
- expect(
422
- vs.collectNewTags(['memory-bank/a.md'], { tags: { topic: [] } }),
423
- ).toEqual([])
424
- })
425
- })
426
-
427
- // ── syncVocabulary ─────────────────────────────────────────────────────────
428
-
429
- describe('syncVocabulary', () => {
430
- it('appends new tags to topic, writes the file, stages it, and warns', async () => {
431
- const dir = createTempDir()
432
- gitInitRepo(dir)
433
- writeRepoFile(dir, 'memory-bank/a.md', frontmatterWithTags(['fresh-tag']))
434
- gitAdd(dir, ['memory-bank/a.md'])
435
- writeVocab(dir, { tags: { topic: ['existing'] } })
436
- const vs = await loadVocabSyncIn(dir)
437
-
438
- const changed = vs.syncVocabulary(['memory-bank/a.md'], readVocab(dir))
439
-
440
- expect(changed).toBe(true)
441
- expect(readVocab(dir).tags.topic).toEqual(['existing', 'fresh-tag'])
442
- expect(gitStagedFiles(dir)).toContain('memory-bank/.vocabulary.json')
443
- expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Added 1 tag(s)'))
444
- })
445
-
446
- it('deduplicates duplicate candidates into a single topic entry', async () => {
447
- const dir = createTempDir()
448
- gitInitRepo(dir)
449
- writeRepoFile(dir, 'memory-bank/a.md', frontmatterWithTags(['dup-tag', 'dup-tag']))
450
- gitAdd(dir, ['memory-bank/a.md'])
451
- writeVocab(dir, { tags: { topic: [] } })
452
- const vs = await loadVocabSyncIn(dir)
453
-
454
- vs.syncVocabulary(['memory-bank/a.md'], readVocab(dir))
455
-
456
- expect(readVocab(dir).tags.topic).toEqual(['dup-tag'])
457
- })
458
-
459
- it('normalizes existing topic entries when merging new tags', async () => {
460
- const dir = createTempDir()
461
- gitInitRepo(dir)
462
- writeRepoFile(dir, 'memory-bank/a.md', frontmatterWithTags(['brand-new']))
463
- gitAdd(dir, ['memory-bank/a.md'])
464
- writeVocab(dir, { tags: { topic: ['Mixed Case'] } })
465
- const vs = await loadVocabSyncIn(dir)
466
-
467
- vs.syncVocabulary(['memory-bank/a.md'], readVocab(dir))
468
-
469
- expect(readVocab(dir).tags.topic).toEqual(['brand-new', 'mixed-case'])
470
- })
471
-
472
- it('returns false and writes nothing when there are no new tags', async () => {
473
- const dir = createTempDir()
474
- gitInitRepo(dir)
475
- writeRepoFile(dir, 'memory-bank/a.md', frontmatterWithTags(['known']))
476
- gitAdd(dir, ['memory-bank/a.md'])
477
- writeVocab(dir, { tags: { topic: ['known'] } })
478
- const vs = await loadVocabSyncIn(dir)
479
- const before = readVocab(dir)
480
-
481
- const changed = vs.syncVocabulary(['memory-bank/a.md'], before)
482
-
483
- expect(changed).toBe(false)
484
- expect(readVocab(dir)).toEqual(before)
485
- expect(gitStagedFiles(dir)).not.toContain('memory-bank/.vocabulary.json')
486
- })
487
-
488
- it('warns but still succeeds when git add fails (never blocks)', async () => {
489
- const dir = createTempDir()
490
- // NOT a git repo — the git add call throws and is caught
491
- writeRepoFile(dir, 'memory-bank/a.md', frontmatterWithTags(['new-tag']))
492
- writeVocab(dir, { tags: { topic: [] } })
493
- const vs = await loadVocabSyncIn(dir)
494
-
495
- const changed = vs.syncVocabulary(['memory-bank/a.md'], readVocab(dir))
496
-
497
- expect(changed).toBe(true)
498
- expect(readVocab(dir).tags.topic).toEqual(['new-tag'])
499
- expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Could not stage'))
500
- })
501
-
502
- it('creates the tags object and topic group when both are missing', async () => {
503
- const dir = createTempDir()
504
- writeRepoFile(dir, 'memory-bank/a.md', frontmatterWithTags(['new-tag']))
505
- writeVocab(dir, { entity_patterns: { npm: ['npm'] } })
506
- const vs = await loadVocabSyncIn(dir)
507
-
508
- vs.syncVocabulary(['memory-bank/a.md'], readVocab(dir))
509
-
510
- const vocab = readVocab(dir)
511
- expect(vocab.tags.topic).toEqual(['new-tag'])
512
- expect(vocab.entity_patterns).toEqual({ npm: ['npm'] })
513
- })
514
-
515
- it('creates the topic group when tags exists without it', async () => {
516
- const dir = createTempDir()
517
- writeRepoFile(dir, 'memory-bank/a.md', frontmatterWithTags(['new-tag']))
518
- writeVocab(dir, { tags: { topic: null } })
519
- const vs = await loadVocabSyncIn(dir)
520
-
521
- vs.syncVocabulary(['memory-bank/a.md'], readVocab(dir))
522
-
523
- expect(readVocab(dir).tags.topic).toEqual(['new-tag'])
524
- })
525
- })
526
-
527
- // ── main (end-to-end with a real temp git repo) ───────────────────────────
528
-
529
- describe('main (end-to-end with real temp git repo)', () => {
530
- it('TEST-01: new tag is appended to topic and staged', async () => {
531
- const dir = createTempDir()
532
- gitInitRepo(dir)
533
- writeRepoFile(dir, 'memory-bank/activeContext.md', frontmatterWithTags(['brand-new-tag']))
534
- gitAdd(dir, ['memory-bank/activeContext.md'])
535
- writeVocab(dir, { tags: { topic: ['existing'] } })
536
- const vs = await loadVocabSyncIn(dir)
537
-
538
- vs.main()
539
-
540
- expect(readVocab(dir).tags.topic).toEqual(['brand-new-tag', 'existing'])
541
- expect(gitStagedFiles(dir)).toContain('memory-bank/.vocabulary.json')
542
- })
543
-
544
- it('TEST-02: tag already in tags.workflow is not appended', async () => {
545
- const dir = createTempDir()
546
- gitInitRepo(dir)
547
- writeRepoFile(dir, 'memory-bank/a.md', frontmatterWithTags(['migration']))
548
- gitAdd(dir, ['memory-bank/a.md'])
549
- writeVocab(dir, { tags: { workflow: ['migration'], topic: [] } })
550
- const vs = await loadVocabSyncIn(dir)
551
- const before = readVocab(dir)
552
-
553
- vs.main()
554
-
555
- expect(readVocab(dir)).toEqual(before)
556
- })
557
-
558
- it('TEST-03: entity_patterns key (npm) is not appended', async () => {
559
- const dir = createTempDir()
560
- gitInitRepo(dir)
561
- writeRepoFile(dir, 'memory-bank/a.md', frontmatterWithTags(['npm']))
562
- gitAdd(dir, ['memory-bank/a.md'])
563
- writeVocab(dir, { tags: { topic: [] }, entity_patterns: { npm: ['npm'] } })
564
- const vs = await loadVocabSyncIn(dir)
565
- const before = readVocab(dir)
566
-
567
- vs.main()
568
-
569
- expect(readVocab(dir)).toEqual(before)
570
- })
571
-
572
- it('TEST-04: "My Tag" is normalized to my-tag and appended', async () => {
573
- const dir = createTempDir()
574
- gitInitRepo(dir)
575
- writeRepoFile(dir, 'memory-bank/a.md', frontmatterWithTags(['My Tag']))
576
- gitAdd(dir, ['memory-bank/a.md'])
577
- writeVocab(dir, { tags: { topic: [] } })
578
- const vs = await loadVocabSyncIn(dir)
579
-
580
- vs.main()
581
-
582
- expect(readVocab(dir).tags.topic).toContain('my-tag')
583
- })
584
-
585
- it('TEST-05: "my_tag" is normalized to my-tag and appended', async () => {
586
- const dir = createTempDir()
587
- gitInitRepo(dir)
588
- writeRepoFile(dir, 'memory-bank/a.md', frontmatterWithTags(['my_tag']))
589
- gitAdd(dir, ['memory-bank/a.md'])
590
- writeVocab(dir, { tags: { topic: [] } })
591
- const vs = await loadVocabSyncIn(dir)
592
-
593
- vs.main()
594
-
595
- expect(readVocab(dir).tags.topic).toContain('my-tag')
596
- })
597
-
598
- it('TEST-06/07: whitespace-only and empty tags are dropped (no-op)', async () => {
599
- const dir = createTempDir()
600
- gitInitRepo(dir)
601
- writeRepoFile(dir, 'memory-bank/a.md', frontmatterWithTags(['', ' ']))
602
- gitAdd(dir, ['memory-bank/a.md'])
603
- writeVocab(dir, { tags: { topic: [] } })
604
- const vs = await loadVocabSyncIn(dir)
605
- const before = readVocab(dir)
606
-
607
- vs.main()
608
-
609
- expect(readVocab(dir)).toEqual(before)
610
- })
611
-
612
- it('TEST-08: duplicate tags in a file are deduplicated to one entry', async () => {
613
- const dir = createTempDir()
614
- gitInitRepo(dir)
615
- writeRepoFile(dir, 'memory-bank/a.md', frontmatterWithTags(['dup-tag', 'dup-tag']))
616
- gitAdd(dir, ['memory-bank/a.md'])
617
- writeVocab(dir, { tags: { topic: [] } })
618
- const vs = await loadVocabSyncIn(dir)
619
-
620
- vs.main()
621
-
622
- const topic = readVocab(dir).tags.topic
623
- expect(topic).toEqual(['dup-tag'])
624
- expect(topic.filter((t) => t === 'dup-tag')).toHaveLength(1)
625
- })
626
-
627
- it('TEST-09: multiple new tags across files are appended sorted alphabetically', async () => {
628
- const dir = createTempDir()
629
- gitInitRepo(dir)
630
- writeRepoFile(dir, 'memory-bank/a.md', frontmatterWithTags(['zebra', 'alpha']))
631
- writeRepoFile(dir, 'memory-bank/b.md', frontmatterWithTags(['mid-tag']))
632
- gitAdd(dir, ['memory-bank/a.md', 'memory-bank/b.md'])
633
- writeVocab(dir, { tags: { topic: [] } })
634
- const vs = await loadVocabSyncIn(dir)
635
-
636
- vs.main()
637
-
638
- expect(readVocab(dir).tags.topic).toEqual(['alpha', 'mid-tag', 'zebra'])
639
- })
640
-
641
- it('TEST-10: all-known tags → no write and no git add', async () => {
642
- const dir = createTempDir()
643
- gitInitRepo(dir)
644
- writeRepoFile(dir, 'memory-bank/a.md', frontmatterWithTags(['existing']))
645
- gitAdd(dir, ['memory-bank/a.md'])
646
- writeVocab(dir, { tags: { topic: ['existing'] } })
647
- const vs = await loadVocabSyncIn(dir)
648
- const before = readVocab(dir)
649
-
650
- vs.main()
651
-
652
- expect(readVocab(dir)).toEqual(before)
653
- expect(gitStagedFiles(dir)).not.toContain('memory-bank/.vocabulary.json')
654
- })
655
-
656
- it('TEST-11: second run is a no-op (idempotency)', async () => {
657
- const dir = createTempDir()
658
- gitInitRepo(dir)
659
- writeRepoFile(dir, 'memory-bank/a.md', frontmatterWithTags(['new-tag']))
660
- gitAdd(dir, ['memory-bank/a.md'])
661
- writeVocab(dir, { tags: { topic: [] } })
662
- const vs = await loadVocabSyncIn(dir)
663
-
664
- vs.main()
665
- const afterFirst = readVocab(dir)
666
- expect(afterFirst.tags.topic).toEqual(['new-tag'])
667
-
668
- vs.main()
669
- expect(readVocab(dir)).toEqual(afterFirst)
670
- expect(readVocab(dir).tags.topic.filter((t) => t === 'new-tag')).toHaveLength(1)
671
- })
672
-
673
- it('TEST-12: missing .vocabulary.json warns gracefully without crashing', async () => {
674
- const dir = createTempDir()
675
- gitInitRepo(dir)
676
- writeRepoFile(dir, 'memory-bank/a.md', frontmatterWithTags(['new-tag']))
677
- gitAdd(dir, ['memory-bank/a.md'])
678
- const vs = await loadVocabSyncIn(dir)
679
-
680
- expect(() => vs.main()).not.toThrow()
681
- expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('No .vocabulary.json'))
682
- })
683
-
684
- it('TEST-13: no staged memory-bank files is a silent no-op', async () => {
685
- const dir = createTempDir()
686
- gitInitRepo(dir)
687
- writeRepoFile(dir, 'docs/readme.md', '# readme')
688
- gitAdd(dir, ['docs/readme.md'])
689
- writeVocab(dir, { tags: { topic: [] } })
690
- const vs = await loadVocabSyncIn(dir)
691
-
692
- vs.main()
693
-
694
- expect(warnSpy).not.toHaveBeenCalled()
695
- })
696
-
697
- it('TEST-13b: no staged files at all is a silent no-op', async () => {
698
- const dir = createTempDir()
699
- gitInitRepo(dir)
700
- const vs = await loadVocabSyncIn(dir)
701
-
702
- vs.main()
703
-
704
- expect(warnSpy).not.toHaveBeenCalled()
705
- })
706
- })
707
-
708
- // ── CLI behavior (spawned script — real exit codes) ───────────────────────
709
-
710
- describe('CLI behavior (spawned script)', () => {
711
- it('exits 0 and adds the tag when run as a real pre-commit hook', () => {
712
- const dir = createTempDir()
713
- gitInitRepo(dir)
714
- writeRepoFile(dir, 'memory-bank/a.md', frontmatterWithTags(['new-tag']))
715
- gitAdd(dir, ['memory-bank/a.md'])
716
- writeVocab(dir, { tags: { topic: [] } })
717
-
718
- const res = runScript(dir)
719
-
720
- expect(res.status).toBe(0)
721
- expect(res.stderr).toContain('Added 1 tag(s)')
722
- expect(readVocab(dir).tags.topic).toContain('new-tag')
723
- })
724
-
725
- it('exits 0 with a warning when .vocabulary.json is missing', () => {
726
- const dir = createTempDir()
727
- gitInitRepo(dir)
728
- writeRepoFile(dir, 'memory-bank/a.md', frontmatterWithTags(['new-tag']))
729
- gitAdd(dir, ['memory-bank/a.md'])
730
-
731
- const res = runScript(dir)
732
-
733
- expect(res.status).toBe(0)
734
- expect(res.stderr).toContain('No .vocabulary.json')
735
- })
736
-
737
- it('exits 0 with a warning when .vocabulary.json is malformed', () => {
738
- const dir = createTempDir()
739
- gitInitRepo(dir)
740
- writeRepoFile(dir, 'memory-bank/a.md', frontmatterWithTags(['new-tag']))
741
- gitAdd(dir, ['memory-bank/a.md'])
742
- const mbDir = join(dir, 'memory-bank')
743
- mkdirSync(mbDir, { recursive: true })
744
- writeFileSync(join(mbDir, '.vocabulary.json'), '{ not valid json', 'utf8')
745
-
746
- const res = runScript(dir)
747
-
748
- expect(res.status).toBe(0)
749
- expect(res.stderr).toContain('Failed to parse')
750
- })
751
-
752
- it('TEST-14: exits 1 with an error on stderr when the write fails', () => {
753
- const dir = createTempDir()
754
- gitInitRepo(dir)
755
- writeRepoFile(dir, 'memory-bank/a.md', frontmatterWithTags(['new-tag']))
756
- gitAdd(dir, ['memory-bank/a.md'])
757
- writeVocab(dir, { tags: { topic: [] } })
758
- // Read-only vocabulary file → writeFileSync throws EACCES
759
- chmodSync(join(dir, 'memory-bank', '.vocabulary.json'), 0o444)
760
-
761
- const res = runScript(dir)
762
-
763
- expect(res.status).toBe(1)
764
- expect(res.stderr).toContain('Unhandled error')
765
- expect(res.stderr).toContain('EACCES')
766
- })
767
- })
768
-
769
- // ── Direct-run guard (isDirectRun true — in-process) ──────────────────────
770
-
771
- describe('direct-run guard', () => {
772
- let exitSpy
773
- let errorSpy
774
-
775
- beforeEach(() => {
776
- exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {})
777
- errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
778
- })
779
-
780
- it('runs main() on import and exits normally when executed directly', async () => {
781
- const dir = createTempDir()
782
- gitInitRepo(dir)
783
- writeRepoFile(dir, 'memory-bank/a.md', frontmatterWithTags(['new-tag']))
784
- gitAdd(dir, ['memory-bank/a.md'])
785
- writeVocab(dir, { tags: { topic: [] } })
786
-
787
- process.chdir(dir)
788
- process.argv[1] = SCRIPT_PATH
789
- vi.resetModules()
790
- await import('./vocab-sync.js')
791
-
792
- expect(readVocab(dir).tags.topic).toContain('new-tag')
793
- expect(exitSpy).not.toHaveBeenCalled()
794
- expect(errorSpy).not.toHaveBeenCalled()
795
- })
796
-
797
- it('catches unhandled errors, logs to stderr, and exits 1', async () => {
798
- const dir = createTempDir()
799
- gitInitRepo(dir)
800
- writeRepoFile(dir, 'memory-bank/a.md', frontmatterWithTags(['new-tag']))
801
- gitAdd(dir, ['memory-bank/a.md'])
802
- writeVocab(dir, { tags: { topic: [] } })
803
- // Read-only vocabulary file → writeFileSync throws EACCES inside main()
804
- chmodSync(join(dir, 'memory-bank', '.vocabulary.json'), 0o444)
805
-
806
- process.chdir(dir)
807
- process.argv[1] = SCRIPT_PATH
808
- vi.resetModules()
809
- await import('./vocab-sync.js')
810
-
811
- expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Unhandled error'))
812
- expect(exitSpy).toHaveBeenCalledWith(1)
813
- })
814
- })
815
-
816
- // ── Additional branch coverage (scalar quoting, empty tags, guard) ─────────
817
-
818
- describe('parseFrontmatter — scalar quoting', () => {
819
- it('strips single quotes from scalar values', async () => {
820
- const vs = await loadFresh()
821
- const content = "---\ntitle: 'Single Quoted'\n---\nBody"
822
-
823
- expect(vs.parseFrontmatter(content).title).toBe('Single Quoted')
824
- })
825
-
826
- it('leaves unquoted scalars untouched', async () => {
827
- const vs = await loadFresh()
828
- const content = '---\ncategory: context\n---\nBody'
829
-
830
- expect(vs.parseFrontmatter(content).category).toBe('context')
831
- })
832
-
833
- it('keeps a value that only starts with a quote as-is', async () => {
834
- const vs = await loadFresh()
835
- const content = "---\ntitle: 'unterminated\n---\nBody"
836
-
837
- expect(vs.parseFrontmatter(content).title).toBe("'unterminated")
838
- })
839
- })
840
-
841
- describe('buildKnownTags — degenerate entries', () => {
842
- it('ignores whitespace-only tags inside a group', async () => {
843
- const vs = await loadFresh()
844
- const known = vs.buildKnownTags({ tags: { topic: [' ', 'real'] } })
845
-
846
- expect(known.has('real')).toBe(true)
847
- expect(known.size).toBe(1)
848
- })
849
-
850
- it('ignores entity_patterns keys that normalize to an empty string', async () => {
851
- const vs = await loadFresh()
852
- const known = vs.buildKnownTags({
853
- tags: { topic: ['real'] },
854
- entity_patterns: { ' ': ['x'], '': ['y'] },
855
- })
856
-
857
- expect([...known]).toEqual(['real'])
858
- })
859
- })
860
-
861
- describe('collectNewTags — normalization edge cases', () => {
862
- it('drops tags that normalize to an empty string but survive parsing', async () => {
863
- const dir = createTempDir()
864
- // Quoted whitespace is kept by parseFrontmatter (truthy raw string) and
865
- // only becomes empty after normalizeTag().
866
- writeRepoFile(dir, 'memory-bank/a.md', '---\ntags: [" "]\n---\nBody')
867
- const vs = await loadVocabSyncIn(dir)
868
-
869
- expect(
870
- vs.collectNewTags(['memory-bank/a.md'], { tags: { topic: [] } }),
871
- ).toEqual([])
872
- })
873
-
874
- it('still collects real tags alongside the empty ones', async () => {
875
- const dir = createTempDir()
876
- writeRepoFile(dir, 'memory-bank/a.md', '---\ntags: [" ", "real-tag"]\n---\nBody')
877
- const vs = await loadVocabSyncIn(dir)
878
-
879
- expect(
880
- vs.collectNewTags(['memory-bank/a.md'], { tags: { topic: [] } }),
881
- ).toEqual(['real-tag'])
882
- })
883
- })
884
-
885
- describe('direct-run guard — argv edge cases', () => {
886
- it('does not run main() when process.argv[1] is empty', async () => {
887
- const dir = createTempDir()
888
- gitInitRepo(dir)
889
- writeRepoFile(dir, 'memory-bank/a.md', frontmatterWithTags(['new-tag']))
890
- gitAdd(dir, ['memory-bank/a.md'])
891
- writeVocab(dir, { tags: { topic: [] } })
892
-
893
- process.chdir(dir)
894
- process.argv[1] = ''
895
- vi.resetModules()
896
- await import('./vocab-sync.js')
897
-
898
- // main() never ran, so nothing was written or warned about
899
- expect(readVocab(dir).tags.topic).toEqual([])
900
- expect(warnSpy).not.toHaveBeenCalled()
901
- })
902
- })