@abarcenas/ai-workflow-template 1.45.0 → 1.47.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.
- package/.agents/config/README.md +43 -0
- package/.agents/config/domain.md +37 -0
- package/.agents/instructions/learned-knowledge.instructions.md +128 -0
- package/.agents/instructions/memory-bank.instructions.md +11 -0
- package/.agents/skills/domain-modeling/ADR-FORMAT.md +47 -0
- package/.agents/skills/domain-modeling/GLOSSARY-FORMAT.md +60 -0
- package/.agents/skills/domain-modeling/SKILL.md +74 -0
- package/.agents/skills/grill-me/SKILL.md +7 -0
- package/.agents/skills/grill-with-docs/SKILL.md +7 -0
- package/.agents/skills/grilling/SKILL.md +28 -0
- package/.agents/skills/prototype/LOGIC.md +67 -0
- package/.agents/skills/prototype/SKILL.md +26 -0
- package/.agents/skills/prototype/UI.md +112 -0
- package/.github/workflows/npm-publish.yml +10 -0
- package/.github/workflows/pr-test.yml +31 -0
- package/.opencode/agents/orchestrator/orchestrator.agent.md +68 -3
- package/.opencode/agents/scope.agent.md +299 -0
- package/.prettierignore +26 -0
- package/AGENTS.md +46 -3
- package/README.md +29 -2
- package/opencode.mcp.example.json +1 -0
- package/package.json +3 -1
- package/scripts/bump-version.test.js +238 -0
- package/scripts/mcp/playwright-mcp-launcher.js +55 -29
- package/scripts/mcp/playwright-mcp-launcher.test.js +217 -0
- package/scripts/mcp-memory-server.js +93 -76
- package/scripts/mcp-memory-server.test.js +398 -0
- package/scripts/memory-cli.test.js +297 -0
- package/scripts/memory-index.js +119 -31
- package/scripts/memory-index.test.js +1173 -0
- package/scripts/normalize-memory.js +26 -1
- package/scripts/normalize-memory.test.js +753 -0
- package/scripts/setup/discover.test.js +124 -2
- package/scripts/setup/hooks.test.js +188 -1
- package/scripts/setup/husky-init.test.js +311 -0
- package/scripts/setup/index.js +35 -5
- package/scripts/setup/index.test.js +279 -0
- package/scripts/setup/knowledgebase.test.js +100 -0
- package/scripts/setup/skills-phase.js +568 -0
- package/scripts/setup/skills-phase.test.js +1274 -0
- package/scripts/setup/sync-phase.js +6 -1
- package/scripts/setup/sync-phase.test.js +180 -2
- package/scripts/setup/ui.test.js +579 -0
- package/scripts/setup/utils.test.js +586 -0
- package/scripts/sync-unit.test.js +1881 -0
- package/scripts/sync.js +816 -239
- package/scripts/sync.test.js +283 -0
- package/scripts/vocab-sync.test.js +88 -0
- package/skills-lock.json +90 -0
|
@@ -0,0 +1,753 @@
|
|
|
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
|
+
})
|