@abarcenas/ai-workflow-template 1.41.0 → 1.43.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/instructions/learned-knowledge.instructions.md +107 -0
- package/.agents/instructions/memory-schema.instructions.md +22 -0
- package/.husky/pre-commit +1 -0
- package/.opencode/agents/reviewer.agent.md +1 -0
- package/README.md +124 -304
- package/memory-bank/.vocabulary.json +115 -31
- package/package.json +1 -1
- package/scripts/mcp-knowledgebase-server.js +85 -19
- package/scripts/mcp-knowledgebase-server.test.js +745 -0
- package/scripts/setup/constants.js +2 -1
- package/scripts/sync.js +1 -0
- package/scripts/sync.test.js +4 -3
- package/scripts/validate-memory-schema.js +28 -3
- package/scripts/validate-memory-schema.test.js +328 -0
- package/scripts/vocab-sync.js +216 -0
- package/scripts/vocab-sync.test.js +814 -0
|
@@ -0,0 +1,814 @@
|
|
|
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
|
+
})
|