@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,753 +0,0 @@
1
- // ═════════════════════════════════════════════════════════════════════════════
2
- // normalize-memory.test.js — Unit tests for the memory-bank normalization script
3
- // ═════════════════════════════════════════════════════════════════════════════
4
- //
5
- // The script is exercised through the testability seam added alongside the
6
- // isDirectRun guard (matching scripts/vocab-sync.js): every cohesive unit is
7
- // exported and `main()` only runs when the file is executed directly.
8
- //
9
- // Because the module resolves memory-bank/, docs/, plan/ and the vocabulary
10
- // path from INIT_CWD (or process.cwd()) at import time, each scenario:
11
- // 1. creates a temp consumer root,
12
- // 2. chdir()s into it (and/or sets INIT_CWD),
13
- // 3. vi.resetModules() + dynamic import so the paths point at the fixture.
14
- // No test touches the repository's real memory-bank/ or docs/.
15
- // ═════════════════════════════════════════════════════════════════════════════
16
-
17
- import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
18
- import {
19
- existsSync,
20
- mkdirSync,
21
- mkdtempSync,
22
- readFileSync,
23
- realpathSync,
24
- rmSync,
25
- symlinkSync,
26
- utimesSync,
27
- writeFileSync,
28
- } from 'node:fs'
29
- import { dirname, join } from 'node:path'
30
- import { tmpdir } from 'node:os'
31
- import { fileURLToPath } from 'node:url'
32
-
33
- // ── Fixture helpers ─────────────────────────────────────────────────────────
34
-
35
- const ORIGINAL_CWD = process.cwd()
36
- const ORIGINAL_ARGV = [...process.argv]
37
- const ORIGINAL_INIT_CWD = process.env.INIT_CWD
38
- const SCRIPT_PATH = fileURLToPath(new URL('./normalize-memory.js', import.meta.url))
39
- const tempDirs = new Set()
40
-
41
- function createTempDir(prefix = 'normalize-memory-test-') {
42
- // realpathSync keeps fixture paths identical to the module's resolve()d
43
- // constants on macOS, where /var is a symlink to /private/var.
44
- const dir = realpathSync(mkdtempSync(join(tmpdir(), prefix)))
45
- tempDirs.add(dir)
46
- return dir
47
- }
48
-
49
- /** Write a repo-relative file, creating parent directories. */
50
- function writeRepoFile(root, relPath, content) {
51
- const filePath = join(root, relPath)
52
- mkdirSync(dirname(filePath), { recursive: true })
53
- writeFileSync(filePath, content, 'utf8')
54
- return filePath
55
- }
56
-
57
- function readRepoFile(root, relPath) {
58
- return readFileSync(join(root, relPath), 'utf8')
59
- }
60
-
61
- const VOCAB = {
62
- categories: { context: 'Context files', task: 'Task files' },
63
- tags: {
64
- workflow: ['planning', 'review'],
65
- memory_ops: ['indexing'],
66
- },
67
- entity_patterns: {
68
- vitest: ['\\bvitest\\b'],
69
- npm: ['npm'],
70
- broken: ['([', '\\bbroken\\b'],
71
- },
72
- file_category_map: {
73
- activeContext: 'memory_ops',
74
- progress: 'status',
75
- },
76
- }
77
-
78
- function writeVocab(root, vocab = VOCAB) {
79
- writeRepoFile(root, 'memory-bank/.vocabulary.json', `${JSON.stringify(vocab, null, 2)}\n`)
80
- }
81
-
82
- /**
83
- * Import a fresh copy of the script with its module-level paths resolved
84
- * against `root`. `initCwd` is either a path (sets INIT_CWD) or 'unset'
85
- * (deletes it so process.cwd() is used).
86
- */
87
- async function loadModule({ root, argv = [], initCwd = 'unset' } = {}) {
88
- process.chdir(root)
89
- if (initCwd === 'unset') delete process.env.INIT_CWD
90
- else process.env.INIT_CWD = initCwd
91
- process.argv = [ORIGINAL_ARGV[0], 'normalize-memory.js', ...argv]
92
- vi.resetModules()
93
- return await import('./normalize-memory.js')
94
- }
95
-
96
- /** Fresh temp consumer root with the standard vocabulary installed. */
97
- function createFixtureRoot() {
98
- const root = createTempDir()
99
- writeVocab(root)
100
- return root
101
- }
102
-
103
- const MEMORY_FILE = [
104
- '# My Title',
105
- '',
106
- 'Planning and indexing content lives here.',
107
- '',
108
- '## Details',
109
- '',
110
- 'Vitest and npm are referenced in this section.',
111
- '',
112
- ].join('\n')
113
-
114
- const DOC_FILE = ['# Architecture Context', '', 'A decision record for the project.', ''].join('\n')
115
-
116
- function expectFrontmatter(content, expected) {
117
- const match = content.match(/^---\n([\s\S]*?)\n---\n/)
118
- expect(match).not.toBeNull()
119
- for (const line of expected) {
120
- expect(match[1]).toContain(line)
121
- }
122
- }
123
-
124
- function bodyAfterFrontmatter(content) {
125
- const match = content.match(/^---\n[\s\S]*?\n---\n/)
126
- return match ? content.slice(match[0].length) : content
127
- }
128
-
129
- // ── Test lifecycle ──────────────────────────────────────────────────────────
130
-
131
- let mod
132
- let root
133
- let logSpy
134
- let warnSpy
135
-
136
- beforeEach(async () => {
137
- root = createFixtureRoot()
138
- mod = await loadModule({ root })
139
- logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
140
- warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
141
- })
142
-
143
- afterEach(() => {
144
- process.chdir(ORIGINAL_CWD)
145
- process.argv = [...ORIGINAL_ARGV]
146
- if (ORIGINAL_INIT_CWD === undefined) delete process.env.INIT_CWD
147
- else process.env.INIT_CWD = ORIGINAL_INIT_CWD
148
- for (const dir of tempDirs) {
149
- try {
150
- rmSync(dir, { recursive: true, force: true })
151
- } catch {
152
- /* best-effort cleanup */
153
- }
154
- }
155
- tempDirs.clear()
156
- vi.restoreAllMocks()
157
- })
158
-
159
- // ── parseFrontmatter ───────────────────────────────────────────────────────
160
-
161
- describe('parseFrontmatter', () => {
162
- it('returns null frontmatter and the untouched body when absent', () => {
163
- const content = '# Title\n\nBody text'
164
- expect(mod.parseFrontmatter(content)).toEqual({ frontmatter: null, body: content })
165
- })
166
-
167
- it('parses scalar key/value pairs and strips the block from the body', () => {
168
- const content = '---\nid: sample\ntitle: "My Title"\ncategory: context\n---\n# Body\n'
169
- const { frontmatter, body } = mod.parseFrontmatter(content)
170
-
171
- expect(frontmatter).toEqual({ id: 'sample', title: '"My Title"', category: 'context' })
172
- expect(body).toBe('# Body\n')
173
- })
174
-
175
- it('keeps array-looking values verbatim (normalization script does no array parsing)', () => {
176
- const { frontmatter } = mod.parseFrontmatter('---\ntags: [a, b]\n---\nbody')
177
- expect(frontmatter.tags).toBe('[a, b]')
178
- })
179
-
180
- it('skips non key/value lines', () => {
181
- const { frontmatter } = mod.parseFrontmatter('---\nid: x\n- a list item\n---\nbody')
182
- expect(frontmatter).toEqual({ id: 'x' })
183
- })
184
-
185
- it('preserves an empty value', () => {
186
- const { frontmatter } = mod.parseFrontmatter('---\ntags:\n---\nbody')
187
- expect(frontmatter.tags).toBe('')
188
- })
189
-
190
- it('tolerates a closing fence without a trailing newline', () => {
191
- const { frontmatter, body } = mod.parseFrontmatter('---\nid: x\n---')
192
- expect(frontmatter).toEqual({ id: 'x' })
193
- expect(body).toBe('')
194
- })
195
- })
196
-
197
- // ── extractTitle ───────────────────────────────────────────────────────────
198
-
199
- describe('extractTitle', () => {
200
- it('returns the trimmed H1 text', () => {
201
- expect(mod.extractTitle('# Spaced Title \nbody')).toBe('Spaced Title')
202
- })
203
-
204
- it('falls back to the basename when no H1 exists', () => {
205
- expect(mod.extractTitle('just a body with no heading')).toBe('just a body with no heading')
206
- })
207
- })
208
-
209
- // ── getAllTags / suggestTags / suggestEntities ─────────────────────────────
210
-
211
- describe('getAllTags', () => {
212
- it('returns [] for missing vocabulary or missing tags group', () => {
213
- expect(mod.getAllTags(null)).toEqual([])
214
- expect(mod.getAllTags({})).toEqual([])
215
- expect(mod.getAllTags({ tags: undefined })).toEqual([])
216
- })
217
-
218
- it('flattens every tag group in declaration order', () => {
219
- expect(mod.getAllTags(VOCAB)).toEqual(['planning', 'review', 'indexing'])
220
- })
221
- })
222
-
223
- describe('suggestTags', () => {
224
- it('matches tags case-insensitively against the body', () => {
225
- expect(mod.suggestTags('We do PLANNING here', ['planning', 'review'])).toEqual(['planning'])
226
- })
227
-
228
- it('returns [] when nothing matches or no tags are known', () => {
229
- expect(mod.suggestTags('body', ['planning'])).toEqual([])
230
- expect(mod.suggestTags('planning', [])).toEqual([])
231
- })
232
- })
233
-
234
- describe('suggestEntities', () => {
235
- it('returns [] without vocabulary or entity_patterns', () => {
236
- expect(mod.suggestEntities('vitest', null)).toEqual([])
237
- expect(mod.suggestEntities('vitest', {})).toEqual([])
238
- })
239
-
240
- it('matches entity regexes case-insensitively', () => {
241
- expect(mod.suggestEntities('We use VITEST and npm.', VOCAB)).toEqual(['vitest', 'npm'])
242
- })
243
-
244
- it('skips invalid regexes and still matches later patterns', () => {
245
- expect(mod.suggestEntities('this is broken', VOCAB)).toEqual(['broken'])
246
- })
247
- })
248
-
249
- // ── inferCategory / inferDocType ───────────────────────────────────────────
250
-
251
- describe('inferCategory', () => {
252
- it('returns context when no vocabulary or category map is available', () => {
253
- expect(mod.inferCategory('progress.md', null)).toBe('context')
254
- expect(mod.inferCategory('progress.md', {})).toBe('context')
255
- })
256
-
257
- it('uses the file_category_map entry by basename', () => {
258
- expect(mod.inferCategory('activeContext.md', VOCAB)).toBe('memory_ops')
259
- expect(mod.inferCategory('.activeContext.md', VOCAB)).toBe('memory_ops')
260
- })
261
-
262
- it('falls back to task for /tasks/ paths, decision for docs, else context', () => {
263
- expect(mod.inferCategory('memory-bank/tasks/T1.md', VOCAB)).toBe('task')
264
- expect(mod.inferCategory('docs/guide.md', VOCAB)).toBe('decision')
265
- expect(mod.inferCategory('memory-bank/other.md', VOCAB)).toBe('context')
266
- })
267
- })
268
-
269
- describe('inferDocType', () => {
270
- it('maps known document ids', () => {
271
- expect(mod.inferDocType('tracker-log')).toBe('tracker-log')
272
- expect(mod.inferDocType('orchestrator-log')).toBe('orchestrator-log')
273
- expect(mod.inferDocType('architecture-context')).toBe('architecture')
274
- expect(mod.inferDocType('playwright-mcp-configuration')).toBe('configuration')
275
- })
276
-
277
- it('strips a leading dot before the lookup', () => {
278
- expect(mod.inferDocType('.tracker-log')).toBe('tracker-log')
279
- })
280
-
281
- it('detects spikes and defaults to decision', () => {
282
- expect(mod.inferDocType('spike-memory')).toBe('spike')
283
- expect(mod.inferDocType('random-doc')).toBe('decision')
284
- })
285
- })
286
-
287
- // ── getGitMtime / buildFrontmatterYaml ─────────────────────────────────────
288
-
289
- describe('getGitMtime', () => {
290
- it('formats the file mtime as YYYY-MM-DD', () => {
291
- const filePath = writeRepoFile(root, 'memory-bank/dated.md', '# Dated\n')
292
- utimesSync(filePath, new Date(2024, 2, 5, 12), new Date(2024, 2, 5, 12))
293
- expect(mod.getGitMtime(filePath)).toBe('2024-03-05')
294
- })
295
-
296
- it('falls back to today when stat fails', () => {
297
- const today = new Date().toISOString().slice(0, 10)
298
- expect(mod.getGitMtime(join(root, 'memory-bank/does-not-exist.md'))).toBe(today)
299
- })
300
- })
301
-
302
- describe('buildFrontmatterYaml', () => {
303
- it('renders arrays inline and scalars quoted', () => {
304
- const yaml = mod.buildFrontmatterYaml({ id: 'x', tags: ['a', 'b'], updated: '2024-01-01' })
305
- expect(yaml).toBe('---\nid: "x"\ntags: [a, b]\nupdated: "2024-01-01"\n---\n')
306
- })
307
-
308
- it('renders an empty array', () => {
309
- expect(mod.buildFrontmatterYaml({ tags: [] })).toBe('---\ntags: []\n---\n')
310
- })
311
- })
312
-
313
- // ── collectMdFiles ─────────────────────────────────────────────────────────
314
-
315
- describe('collectMdFiles', () => {
316
- it('returns [] for a missing directory', () => {
317
- expect(mod.collectMdFiles(join(root, 'nope'))).toEqual([])
318
- })
319
-
320
- it('collects nested markdown files and ignores non-markdown files', () => {
321
- writeRepoFile(root, 'tree/a.md', 'a')
322
- writeRepoFile(root, 'tree/nested/b.md', 'b')
323
- writeRepoFile(root, 'tree/notes.txt', 'nope')
324
-
325
- const files = mod.collectMdFiles(join(root, 'tree'))
326
- expect(files).toHaveLength(2)
327
- expect(files.some((f) => f.endsWith('a.md'))).toBe(true)
328
- expect(files.some((f) => f.endsWith('b.md'))).toBe(true)
329
- })
330
-
331
- it('skips the configured skipDir and hidden directories, but keeps .index', () => {
332
- writeRepoFile(root, 'tree/keep.md', 'keep')
333
- writeRepoFile(root, 'tree/.index/indexed.md', 'indexed')
334
- writeRepoFile(root, 'tree/tasks/t.md', 'task')
335
- writeRepoFile(root, 'tree/.git/config.md', 'git')
336
-
337
- const withoutTasks = mod.collectMdFiles(join(root, 'tree'), 'tasks')
338
- expect(withoutTasks.some((f) => f.endsWith('keep.md'))).toBe(true)
339
- expect(withoutTasks.some((f) => f.endsWith('indexed.md'))).toBe(true)
340
- expect(withoutTasks.some((f) => f.endsWith('t.md'))).toBe(false)
341
- expect(withoutTasks.some((f) => f.endsWith('config.md'))).toBe(false)
342
- })
343
-
344
- it('ignores symbolic links (neither file nor directory entries)', () => {
345
- writeRepoFile(root, 'tree/real.md', 'real')
346
- symlinkSync(join(root, 'tree/real.md'), join(root, 'tree/link.md'))
347
-
348
- const files = mod.collectMdFiles(join(root, 'tree'))
349
- expect(files).toHaveLength(1)
350
- expect(files[0].endsWith('real.md')).toBe(true)
351
- })
352
- })
353
-
354
- // ── loadVocabulary ─────────────────────────────────────────────────────────
355
-
356
- describe('loadVocabulary', () => {
357
- it('reads the consumer vocabulary', () => {
358
- expect(mod.loadVocabulary()).toEqual(VOCAB)
359
- })
360
-
361
- it('falls back to the package vocabulary when the consumer has none', async () => {
362
- const fallbackRoot = createTempDir()
363
- const fallbackMod = await loadModule({ root: fallbackRoot })
364
-
365
- const vocab = fallbackMod.loadVocabulary()
366
- expect(vocab).toBeTruthy()
367
- expect(vocab.tags).toBeTruthy()
368
- expect(warnSpy).not.toHaveBeenCalled()
369
- })
370
-
371
- it('prefers INIT_CWD over process.cwd() for path resolution', async () => {
372
- const consumerRoot = createTempDir()
373
- writeVocab(consumerRoot, { tags: { topic: ['from-init-cwd'] } })
374
-
375
- // chdir somewhere else entirely; INIT_CWD must win.
376
- process.chdir(createTempDir())
377
- process.env.INIT_CWD = consumerRoot
378
- process.argv = [ORIGINAL_ARGV[0], 'normalize-memory.js']
379
- vi.resetModules()
380
- const initCwdMod = await import('./normalize-memory.js')
381
-
382
- expect(initCwdMod.loadVocabulary()).toEqual({ tags: { topic: ['from-init-cwd'] } })
383
- })
384
-
385
- it('throws on malformed JSON', () => {
386
- writeRepoFile(root, 'memory-bank/.vocabulary.json', '{ not json')
387
- expect(() => mod.loadVocabulary()).toThrow()
388
- })
389
- })
390
-
391
- // ── normalizeFile ──────────────────────────────────────────────────────────
392
-
393
- describe('normalizeFile', () => {
394
- it('adds frontmatter to a memory-bank file and preserves the body byte-for-byte', () => {
395
- const filePath = writeRepoFile(root, 'memory-bank/activeContext.md', MEMORY_FILE)
396
-
397
- const result = mod.normalizeFile(filePath, VOCAB, mod.getAllTags(VOCAB), true)
398
-
399
- expect(result.action).toBe('normalized')
400
- expect(result.tags).toEqual(['planning', 'indexing'])
401
- expect(result.entities).toEqual(['vitest', 'npm'])
402
- expect(result.category).toBe('memory_ops')
403
-
404
- const content = readRepoFile(root, 'memory-bank/activeContext.md')
405
- expectFrontmatter(content, [
406
- 'id: "activeContext"',
407
- 'title: "My Title"',
408
- 'tags: [planning, indexing]',
409
- 'entities: [vitest, npm]',
410
- 'category: "memory_ops"',
411
- ])
412
- expect(/^updated: "\d{4}-\d{2}-\d{2}"$/m.test(content)).toBe(true)
413
- // The writer joins the YAML block and the original body with one blank
414
- // line; everything after that separator is byte-identical to the input.
415
- expect(bodyAfterFrontmatter(content)).toBe(`\n${MEMORY_FILE}`)
416
- })
417
-
418
- it('writes tags: [] when nothing matches', () => {
419
- const filePath = writeRepoFile(root, 'memory-bank/plain.md', '# Plain\n\nNothing notable here.\n')
420
- mod.normalizeFile(filePath, VOCAB, mod.getAllTags(VOCAB), true)
421
-
422
- expect(readRepoFile(root, 'memory-bank/plain.md')).toContain('tags: []\n')
423
- })
424
-
425
- it('uses entities: [] and the default category when no patterns/files map match', () => {
426
- const filePath = writeRepoFile(root, 'memory-bank/misc.md', '# Misc\n\nUnrelated prose only.\n')
427
- const result = mod.normalizeFile(filePath, null, [], true)
428
-
429
- expect(result.entities).toEqual([])
430
- expect(result.category).toBe('context')
431
- expect(readRepoFile(root, 'memory-bank/misc.md')).toContain('entities: []\n')
432
- })
433
-
434
- it('adds doc_type instead of entities/category for docs files', () => {
435
- const filePath = writeRepoFile(root, 'docs/architecture-context.md', DOC_FILE)
436
-
437
- const result = mod.normalizeFile(filePath, VOCAB, mod.getAllTags(VOCAB), false)
438
-
439
- expect(result.category).toBe('architecture')
440
- const content = readRepoFile(root, 'docs/architecture-context.md')
441
- expect(content).toContain('doc_type: "architecture"\n')
442
- expect(content).not.toContain('entities:')
443
- expect(content).not.toContain('category:')
444
- })
445
-
446
- it('skips a file that already has frontmatter', () => {
447
- const original = '---\nid: old\ntitle: "Old"\n---\n# Body\n\ncontent\n'
448
- const filePath = writeRepoFile(root, 'memory-bank/activeContext.md', original)
449
-
450
- const result = mod.normalizeFile(filePath, VOCAB, mod.getAllTags(VOCAB), true)
451
-
452
- expect(result).toEqual({
453
- file: filePath,
454
- action: 'skipped',
455
- reason: 'already normalized',
456
- })
457
- expect(readRepoFile(root, 'memory-bank/activeContext.md')).toBe(original)
458
- })
459
-
460
- it('replaces existing frontmatter and keeps the body under --force', async () => {
461
- const original = '---\nid: old\ntitle: "Old"\n---\n# New Title\n\nKept body.\n'
462
- const filePath = writeRepoFile(root, 'memory-bank/activeContext.md', original)
463
- const forced = await loadModule({ root, argv: ['--force'] })
464
-
465
- const result = forced.normalizeFile(filePath, VOCAB, forced.getAllTags(VOCAB), true)
466
-
467
- expect(result.action).toBe('normalized')
468
- const content = readRepoFile(root, 'memory-bank/activeContext.md')
469
- expect(content).not.toContain('id: old')
470
- expect(content).toContain('id: "activeContext"')
471
- expect(bodyAfterFrontmatter(content)).toBe('\n# New Title\n\nKept body.\n')
472
- })
473
-
474
- it('does not write files under --dry-run and reports would-normalize', async () => {
475
- const filePath = writeRepoFile(root, 'memory-bank/activeContext.md', MEMORY_FILE)
476
- const dry = await loadModule({ root, argv: ['--dry-run'] })
477
-
478
- const result = dry.normalizeFile(filePath, VOCAB, dry.getAllTags(VOCAB), true)
479
-
480
- expect(result.action).toBe('would-normalize')
481
- expect(readRepoFile(root, 'memory-bank/activeContext.md')).toBe(MEMORY_FILE)
482
- })
483
-
484
- it('defaults docs files to doc_type decision when nothing else matches', () => {
485
- const filePath = writeRepoFile(root, 'docs/x.md', '# X\n\nbody\n')
486
- const result = mod.normalizeFile(filePath, VOCAB, [], false)
487
- expect(result.category).toBe('decision')
488
- })
489
- })
490
-
491
- // ── migratePlanToDocs ──────────────────────────────────────────────────────
492
-
493
- describe('migratePlanToDocs', () => {
494
- it('returns [] when plan/ does not exist', () => {
495
- expect(mod.migratePlanToDocs(VOCAB, mod.getAllTags(VOCAB))).toEqual([])
496
- })
497
-
498
- it('returns [] when plan/ contains no markdown', () => {
499
- writeRepoFile(root, 'plan/notes.txt', 'not markdown')
500
- expect(mod.migratePlanToDocs(VOCAB, mod.getAllTags(VOCAB))).toEqual([])
501
- })
502
-
503
- it('migrates markdown into docs/ and renames the source .md to .migrated', () => {
504
- writeRepoFile(root, 'plan/feature-x.md', '# Feature X\n\nPlanning notes.\n')
505
-
506
- const results = mod.migratePlanToDocs(VOCAB, mod.getAllTags(VOCAB))
507
-
508
- expect(results).toHaveLength(1)
509
- expect(results[0].action).toBe('migrated')
510
- expect(results[0].to).toBe(join(root, 'docs/feature-x.md'))
511
-
512
- const migrated = readRepoFile(root, 'docs/feature-x.md')
513
- expectFrontmatter(migrated, [
514
- 'id: "feature-x"',
515
- 'title: "Feature X"',
516
- 'doc_type: "decision"',
517
- 'tags: [planning]',
518
- ])
519
- expect(bodyAfterFrontmatter(migrated)).toBe('\n# Feature X\n\nPlanning notes.\n')
520
-
521
- expect(existsSync(join(root, 'plan/feature-x.md'))).toBe(false)
522
- expect(existsSync(join(root, 'plan/feature-x.md.migrated'))).toBe(true)
523
- // rmdirSync cannot succeed while the .migrated sources remain, so the
524
- // directory survives and the "Removed empty plan/" log arm stays dead.
525
- expect(existsSync(join(root, 'plan'))).toBe(true)
526
- expect(logSpy).not.toHaveBeenCalledWith(
527
- '[normalize-memory] Removed empty plan/ directory',
528
- )
529
- })
530
-
531
- it('avoids overwriting an existing docs file by suffixing .migrated', () => {
532
- writeRepoFile(root, 'docs/feature-x.md', '# Existing\n')
533
- writeRepoFile(root, 'plan/feature-x.md', '# Feature X\n\nPlanning migration body.\n')
534
-
535
- const results = mod.migratePlanToDocs(VOCAB, mod.getAllTags(VOCAB))
536
-
537
- expect(results[0].to).toBe(join(root, 'docs/feature-x.migrated.md'))
538
- expect(readRepoFile(root, 'docs/feature-x.md')).toBe('# Existing\n')
539
- const migrated = readRepoFile(root, 'docs/feature-x.migrated.md')
540
- expect(migrated).toContain('id: "feature-x.migrated"')
541
- expect(migrated).toContain('tags: [planning]')
542
- })
543
-
544
- it('preserves a note when the plan file already had frontmatter', () => {
545
- writeRepoFile(root, 'plan/spike-x.md', '---\nid: spike-x\n---\n# Spike X\n\nbody text\n')
546
-
547
- mod.migratePlanToDocs(VOCAB, mod.getAllTags(VOCAB))
548
-
549
- const migrated = readRepoFile(root, 'docs/spike-x.md')
550
- expect(migrated).toContain('note: "Migrated from plan/ directory during normalization on ')
551
- expect(bodyAfterFrontmatter(migrated)).toBe('\n# Spike X\n\nbody text\n')
552
- })
553
-
554
- it('falls back to the migration tag when no vocabulary tags match', () => {
555
- writeRepoFile(root, 'plan/feature-y.md', '# Feature Y\n\nUnmatched prose.\n')
556
-
557
- mod.migratePlanToDocs(null, [])
558
-
559
- expect(readRepoFile(root, 'docs/feature-y.md')).toContain('tags: [migration]')
560
- expect(readRepoFile(root, 'docs/feature-y.md')).toContain('doc_type: "decision"')
561
- })
562
-
563
- it('keeps the plan directory when non-markdown files remain', () => {
564
- writeRepoFile(root, 'plan/feature-z.md', '# Feature Z\n\nbody text\n')
565
- writeRepoFile(root, 'plan/keep.txt', 'keep me')
566
-
567
- mod.migratePlanToDocs(VOCAB, mod.getAllTags(VOCAB))
568
-
569
- expect(existsSync(join(root, 'plan'))).toBe(true)
570
- expect(existsSync(join(root, 'plan/keep.txt'))).toBe(true)
571
- expect(existsSync(join(root, 'plan/feature-z.md.migrated'))).toBe(true)
572
- })
573
-
574
- it('writes nothing under --dry-run and reports would-migrate', async () => {
575
- writeRepoFile(root, 'plan/feature-d.md', '# Feature D\n\nbody text\n')
576
- const dry = await loadModule({ root, argv: ['--dry-run'] })
577
-
578
- const results = dry.migratePlanToDocs(VOCAB, dry.getAllTags(VOCAB))
579
-
580
- expect(results[0].action).toBe('would-migrate')
581
- expect(existsSync(join(root, 'docs/feature-d.md'))).toBe(false)
582
- expect(existsSync(join(root, 'plan/feature-d.md'))).toBe(true)
583
- })
584
- })
585
-
586
- // ── ensureDir ──────────────────────────────────────────────────────────────
587
-
588
- describe('ensureDir', () => {
589
- it('creates a missing directory recursively', () => {
590
- const target = join(root, 'a/b/c')
591
- mod.ensureDir(target)
592
- expect(existsSync(target)).toBe(true)
593
- })
594
-
595
- it('is a no-op when the directory exists', () => {
596
- expect(() => mod.ensureDir(join(root, 'memory-bank'))).not.toThrow()
597
- expect(existsSync(join(root, 'memory-bank'))).toBe(true)
598
- })
599
-
600
- it('does not create anything under --dry-run', async () => {
601
- const dry = await loadModule({ root, argv: ['--dry-run'] })
602
- const target = join(root, 'dry/new-dir')
603
-
604
- dry.ensureDir(target)
605
-
606
- expect(existsSync(target)).toBe(false)
607
- })
608
- })
609
-
610
- // ── main ───────────────────────────────────────────────────────────────────
611
-
612
- describe('main', () => {
613
- function seedConsumer() {
614
- writeRepoFile(root, 'memory-bank/activeContext.md', MEMORY_FILE)
615
- writeRepoFile(root, 'memory-bank/progress.md', '# Progress\n\nplanning progress\n')
616
- writeRepoFile(root, 'memory-bank/tasks/T1.md', '# Task One\n\nindexing task detail\n')
617
- writeRepoFile(root, 'memory-bank/.index/indexed.md', '# Indexed\n\nshould stay untouched\n')
618
- writeRepoFile(root, 'docs/guide.md', DOC_FILE)
619
- writeRepoFile(root, 'docs/.hidden.md', '# Hidden\n\nhidden doc body\n')
620
- writeRepoFile(root, 'docs/.index/idx.md', '# Indexed Doc\n\nshould stay untouched\n')
621
- writeRepoFile(root, 'plan/feature-main.md', '# Feature Main\n\nplanning body\n')
622
- writeRepoFile(root, 'memory-bank/notes.txt', 'not markdown')
623
- }
624
-
625
- it('normalizes memory-bank and docs files, migrates plan/, and writes the marker', () => {
626
- seedConsumer()
627
-
628
- mod.main()
629
-
630
- expect(readRepoFile(root, 'memory-bank/activeContext.md')).toContain('category: "memory_ops"')
631
- // inferCategory receives the basename in normalizeFile, so the /tasks/
632
- // heuristic only applies when it is called with a path (unit-tested above).
633
- expect(readRepoFile(root, 'memory-bank/tasks/T1.md')).toContain('category: "context"')
634
- expect(readRepoFile(root, 'docs/guide.md')).toContain('doc_type: "decision"')
635
- expect(readRepoFile(root, 'docs/.hidden.md')).toContain('id: ".hidden"')
636
-
637
- // .index content is skipped by both collectMdFiles (dir skip) and the docs
638
- // loop's explicit guard.
639
- expect(readRepoFile(root, 'memory-bank/.index/indexed.md')).toBe('# Indexed\n\nshould stay untouched\n')
640
- expect(readRepoFile(root, 'docs/.index/idx.md')).toBe('# Indexed Doc\n\nshould stay untouched\n')
641
-
642
- // plan migration ran before docs normalization; the source survives as
643
- // plan/<name>.md.migrated because plan/ is never empty afterwards.
644
- expect(readRepoFile(root, 'docs/feature-main.md')).toContain('id: "feature-main"')
645
- expect(existsSync(join(root, 'plan/feature-main.md.migrated'))).toBe(true)
646
-
647
- const marker = readRepoFile(root, 'memory-bank/.normalized')
648
- expect(marker).toMatch(/^\d{4}-\d{2}-\d{2}T/)
649
-
650
- expect(logSpy).toHaveBeenCalledWith('[normalize-memory] Mode: normal')
651
- expect(logSpy).toHaveBeenCalledWith('[normalize-memory] Found 3 memory-bank files')
652
- expect(logSpy).toHaveBeenCalledWith('[normalize-memory] Created .normalized marker')
653
- expect(logSpy).toHaveBeenCalledWith('[normalize-memory] Done.')
654
- })
655
-
656
- it('logs migrated plan files and per-file normalize lines', () => {
657
- seedConsumer()
658
-
659
- mod.main()
660
-
661
- expect(logSpy).toHaveBeenCalledWith(
662
- expect.stringContaining('[normalize-memory] Migrated 1 files from plan/ to docs/'),
663
- )
664
- expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('normalized: activeContext.md (tags: ['))
665
- expect(logSpy).toHaveBeenCalledWith(expect.stringMatching(/normalized: guide\.md \(tags: \[/))
666
- })
667
-
668
- it('skips files that are already normalized (no per-file log line)', () => {
669
- writeRepoFile(root, 'memory-bank/activeContext.md', '---\nid: activeContext\n---\n# Done\n\nalready\n')
670
-
671
- mod.main()
672
-
673
- expect(logSpy).not.toHaveBeenCalledWith(expect.stringContaining('normalized: activeContext.md'))
674
- expect(logSpy).toHaveBeenCalledWith('[normalize-memory] Found 1 memory-bank files')
675
- })
676
-
677
- it('exits early when the .normalized marker already exists', () => {
678
- writeRepoFile(root, 'memory-bank/activeContext.md', MEMORY_FILE)
679
- writeRepoFile(root, 'memory-bank/.normalized', 'already\n')
680
- const exitError = new Error('process.exit(0)')
681
- const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {
682
- throw exitError
683
- })
684
-
685
- expect(() => mod.main()).toThrow(exitError)
686
- expect(exitSpy).toHaveBeenCalledWith(0)
687
- expect(logSpy).toHaveBeenCalledWith(
688
- '[normalize-memory] Already normalized. Use --force to re-run.',
689
- )
690
- // Nothing was normalized because the early return happened first.
691
- expect(readRepoFile(root, 'memory-bank/activeContext.md')).toBe(MEMORY_FILE)
692
- })
693
-
694
- it('re-normalizes when the marker exists under --force', async () => {
695
- writeRepoFile(root, 'memory-bank/activeContext.md', MEMORY_FILE)
696
- writeRepoFile(root, 'memory-bank/.normalized', 'already\n')
697
- const forced = await loadModule({ root, argv: ['--force'] })
698
-
699
- forced.main()
700
-
701
- expect(readRepoFile(root, 'memory-bank/activeContext.md')).toContain('id: "activeContext"')
702
- expect(logSpy).toHaveBeenCalledWith('[normalize-memory] Mode: force')
703
- })
704
-
705
- it('ignores the marker under --dry-run, changes nothing and writes no marker', async () => {
706
- writeRepoFile(root, 'memory-bank/activeContext.md', MEMORY_FILE)
707
- writeRepoFile(root, 'plan/feature-dry.md', '# Feature Dry\n\nbody text\n')
708
- const dry = await loadModule({ root, argv: ['--dry-run'] })
709
-
710
- dry.main()
711
-
712
- expect(readRepoFile(root, 'memory-bank/activeContext.md')).toBe(MEMORY_FILE)
713
- expect(existsSync(join(root, 'plan/feature-dry.md'))).toBe(true)
714
- expect(existsSync(join(root, 'docs/feature-dry.md'))).toBe(false)
715
- expect(existsSync(join(root, 'memory-bank/.normalized'))).toBe(false)
716
- expect(logSpy).toHaveBeenCalledWith('[normalize-memory] Mode: dry-run')
717
- })
718
-
719
- it('creates missing memory-bank/ and docs/ directories in normal mode', () => {
720
- const emptyRoot = createTempDir()
721
- return loadModule({ root: emptyRoot }).then((fresh) => {
722
- fresh.main()
723
- expect(existsSync(join(emptyRoot, 'memory-bank/.normalized'))).toBe(true)
724
- })
725
- })
726
- })
727
-
728
- // ── direct-run guard ───────────────────────────────────────────────────────
729
-
730
- describe('direct-run guard', () => {
731
- it('does not run main() when imported', async () => {
732
- writeRepoFile(root, 'memory-bank/activeContext.md', MEMORY_FILE)
733
-
734
- const imported = await loadModule({ root })
735
-
736
- expect(imported.main).toBeTypeOf('function')
737
- expect(existsSync(join(root, 'memory-bank/.normalized'))).toBe(false)
738
- expect(readRepoFile(root, 'memory-bank/activeContext.md')).toBe(MEMORY_FILE)
739
- })
740
-
741
- it('runs main() on import when executed directly', async () => {
742
- writeRepoFile(root, 'memory-bank/activeContext.md', MEMORY_FILE)
743
- process.chdir(root)
744
- delete process.env.INIT_CWD
745
- process.argv = [ORIGINAL_ARGV[0], SCRIPT_PATH]
746
- vi.resetModules()
747
-
748
- await import('./normalize-memory.js')
749
-
750
- expect(existsSync(join(root, 'memory-bank/.normalized'))).toBe(true)
751
- expect(readRepoFile(root, 'memory-bank/activeContext.md')).toContain('id: "activeContext"')
752
- })
753
- })