@abarcenas/ai-workflow-template 1.40.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 +150 -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 +90 -20
- 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
|
@@ -46,11 +46,12 @@ export const TEMPLATE_HOOKS = {
|
|
|
46
46
|
'#!/bin/sh',
|
|
47
47
|
'',
|
|
48
48
|
'node scripts/bump-version.js',
|
|
49
|
+
'node scripts/vocab-sync.js',
|
|
49
50
|
'node scripts/validate-memory-schema.js',
|
|
50
51
|
'',
|
|
51
52
|
].join('\n'),
|
|
52
53
|
description:
|
|
53
|
-
'Auto-bump version + validate memory-bank schema before commits',
|
|
54
|
+
'Auto-bump version + sync vocabulary + validate memory-bank schema before commits',
|
|
54
55
|
},
|
|
55
56
|
'post-merge': {
|
|
56
57
|
source: '.husky/post-merge',
|
package/scripts/sync.js
CHANGED
package/scripts/sync.test.js
CHANGED
|
@@ -53,6 +53,7 @@ describe('__scripts__ sync behavior', () => {
|
|
|
53
53
|
'memory-index.js',
|
|
54
54
|
'bump-version.js',
|
|
55
55
|
'validate-memory-schema.js',
|
|
56
|
+
'vocab-sync.js',
|
|
56
57
|
'mcp-memory-server.js',
|
|
57
58
|
'mcp/playwright-mcp-launcher.js',
|
|
58
59
|
]
|
|
@@ -75,7 +76,7 @@ describe('__scripts__ sync behavior', () => {
|
|
|
75
76
|
const result = runSync(tempDir)
|
|
76
77
|
expect(result.status).toBe(0)
|
|
77
78
|
|
|
78
|
-
// Verify all
|
|
79
|
+
// Verify all 7 script files were created in the consumer's scripts/ dir
|
|
79
80
|
for (const script of scriptsToSync) {
|
|
80
81
|
const targetFile = join(tempDir, 'scripts', script)
|
|
81
82
|
expect(
|
|
@@ -137,7 +138,7 @@ describe('__scripts__ sync behavior', () => {
|
|
|
137
138
|
const result = runSync(tempDir, ['--force'])
|
|
138
139
|
expect(result.status).toBe(0)
|
|
139
140
|
|
|
140
|
-
// Verify all
|
|
141
|
+
// Verify all 7 files were overwritten with source content
|
|
141
142
|
for (const script of scriptsToSync) {
|
|
142
143
|
const targetFile = join(tempDir, 'scripts', script)
|
|
143
144
|
const sourceFile = join(sourceScriptsDir, script)
|
|
@@ -178,7 +179,7 @@ describe('__scripts__ sync behavior', () => {
|
|
|
178
179
|
readFileSync(join(tempDir, '.agents-sync-manifest.json'), 'utf8'),
|
|
179
180
|
)
|
|
180
181
|
|
|
181
|
-
// Verify all
|
|
182
|
+
// Verify all 7 __scripts__ entries are tracked with valid SHA-256 hashes
|
|
182
183
|
for (const script of scriptsToSync) {
|
|
183
184
|
const key = `__scripts__/${script}`
|
|
184
185
|
expect(updatedManifest.files[key]).toBeDefined()
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import { execSync } from 'node:child_process';
|
|
16
16
|
import { existsSync, readFileSync } from 'node:fs';
|
|
17
17
|
import { basename, extname, resolve } from 'node:path';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
18
19
|
|
|
19
20
|
const MEMORY_BANK_DIR = resolve(process.cwd(), 'memory-bank');
|
|
20
21
|
const VOCAB_PATH = resolve(MEMORY_BANK_DIR, '.vocabulary.json');
|
|
@@ -128,8 +129,21 @@ function validateFile(filePath, vocab) {
|
|
|
128
129
|
|
|
129
130
|
// tags must be from vocabulary (warn only — don't block for new user-defined tags)
|
|
130
131
|
if (fm.tags && Array.isArray(fm.tags) && vocab?.tags) {
|
|
131
|
-
|
|
132
|
-
const
|
|
132
|
+
// Known tags = all tags.* group values PLUS entity_patterns keys (e.g. npm, vitest, playwright)
|
|
133
|
+
const allTags = [
|
|
134
|
+
...Object.values(vocab.tags).flat(),
|
|
135
|
+
...Object.keys(vocab.entity_patterns || {}),
|
|
136
|
+
];
|
|
137
|
+
// Normalize BOTH sides before the unknown-tag filter for parity with
|
|
138
|
+
// vocab-sync.js (which lowercases/kebab-cases tags before comparing and
|
|
139
|
+
// appending). Without this, a staged `My Tag` still warns "Unknown tags:
|
|
140
|
+
// [My Tag]" on every commit even after vocab-sync appends `my-tag`.
|
|
141
|
+
const normalizeTag = (tag) => String(tag).toLowerCase().trim().replace(/[\s_]+/g, '-');
|
|
142
|
+
const knownTags = new Set(allTags.map(normalizeTag).filter(Boolean));
|
|
143
|
+
const unknown = fm.tags.filter((t) => {
|
|
144
|
+
const normalized = normalizeTag(t);
|
|
145
|
+
return normalized && !knownTags.has(normalized);
|
|
146
|
+
});
|
|
133
147
|
if (unknown.length > 0) {
|
|
134
148
|
console.warn(
|
|
135
149
|
`[validate-memory] ⚠ ${filePath}: Unknown tags: [${unknown.join(', ')}]. ` +
|
|
@@ -187,4 +201,15 @@ function main() {
|
|
|
187
201
|
process.exit(0);
|
|
188
202
|
}
|
|
189
203
|
|
|
190
|
-
|
|
204
|
+
// Only run when executed directly (node scripts/validate-memory-schema.js).
|
|
205
|
+
// When the module is imported — e.g. by unit tests exercising validateFile —
|
|
206
|
+
// no side effects are triggered (main() calls process.exit(), which would
|
|
207
|
+
// terminate the test runner).
|
|
208
|
+
const isDirectRun =
|
|
209
|
+
process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
210
|
+
|
|
211
|
+
if (isDirectRun) {
|
|
212
|
+
main();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export { validateFile, main };
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// validate-memory-schema.test.js — Unit tests for the pre-commit validator
|
|
3
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
4
|
+
//
|
|
5
|
+
// Covers plan/feature-vocab-sync-1.md T10 (TEST-01..05) — the T3
|
|
6
|
+
// entity_patterns awareness fix: `entity_patterns` keys (npm, vitest, …) count
|
|
7
|
+
// as known tags, so they no longer produce false-positive "Unknown tags"
|
|
8
|
+
// warnings; genuinely unknown tags still warn; and the validator keeps the
|
|
9
|
+
// warn-don't-block contract (exit code stays 0 on warnings).
|
|
10
|
+
//
|
|
11
|
+
// The validator is exercised through its exported functions (testability seam
|
|
12
|
+
// added in T10: `export { validateFile, main }` + `isDirectRun` guard, matching
|
|
13
|
+
// scripts/vocab-sync.js / scripts/mcp-knowledgebase-server.js convention).
|
|
14
|
+
// child_process.execSync (staged-file detection) and node:fs.readFileSync
|
|
15
|
+
// (file + vocab content) are mocked hermetically so no git/FS side effects occur.
|
|
16
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
17
|
+
|
|
18
|
+
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
19
|
+
|
|
20
|
+
// Module-level mocks — the validator imports execSync from node:child_process
|
|
21
|
+
// and readFileSync/existsSync from node:fs. vi.mock is hoisted above imports,
|
|
22
|
+
// so the validator under test receives these mocked implementations.
|
|
23
|
+
vi.mock('node:child_process', async () => {
|
|
24
|
+
const actual = await vi.importActual('node:child_process')
|
|
25
|
+
return { ...actual, execSync: vi.fn() }
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
vi.mock('node:fs', async () => {
|
|
29
|
+
const actual = await vi.importActual('node:fs')
|
|
30
|
+
return {
|
|
31
|
+
...actual,
|
|
32
|
+
readFileSync: vi.fn(),
|
|
33
|
+
existsSync: vi.fn(() => true),
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
import { readFileSync } from 'node:fs'
|
|
38
|
+
import { execSync } from 'node:child_process'
|
|
39
|
+
|
|
40
|
+
import { validateFile, main } from './validate-memory-schema.js'
|
|
41
|
+
|
|
42
|
+
// ── Fixtures ─────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
const FILE_PATH = 'memory-bank/sample.md'
|
|
45
|
+
|
|
46
|
+
function makeFileContent({
|
|
47
|
+
id = 'sample',
|
|
48
|
+
title = 'Sample',
|
|
49
|
+
updated = '2026-08-03',
|
|
50
|
+
tags = [],
|
|
51
|
+
category = 'memory_ops',
|
|
52
|
+
} = {}) {
|
|
53
|
+
return [
|
|
54
|
+
'---',
|
|
55
|
+
`id: ${id}`,
|
|
56
|
+
`title: "${title}"`,
|
|
57
|
+
`updated: ${updated}`,
|
|
58
|
+
`tags: [${tags.join(', ')}]`,
|
|
59
|
+
'entities: [sample]',
|
|
60
|
+
`category: ${category}`,
|
|
61
|
+
'---',
|
|
62
|
+
'',
|
|
63
|
+
'# Sample',
|
|
64
|
+
'',
|
|
65
|
+
'Body.',
|
|
66
|
+
'',
|
|
67
|
+
].join('\n')
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Mirrors the real memory-bank/.vocabulary.json shape: tags.* groups hold
|
|
71
|
+
// known concept tags; entity_patterns keys hold component/tool names.
|
|
72
|
+
const VOCAB = {
|
|
73
|
+
categories: { memory_ops: 'Memory operations', context: 'Context files' },
|
|
74
|
+
tags: {
|
|
75
|
+
agent_roles: ['architect', 'implementer'],
|
|
76
|
+
workflow: ['planning', 'review'],
|
|
77
|
+
memory_ops: ['indexing'],
|
|
78
|
+
topic: ['mcp', 'graphify'],
|
|
79
|
+
},
|
|
80
|
+
entity_patterns: {
|
|
81
|
+
npm: { patterns: ['\\bnpm\\b'] },
|
|
82
|
+
vitest: { patterns: ['\\bvitest\\b'] },
|
|
83
|
+
playwright: { patterns: ['\\bplaywright\\b'] },
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── Test harness ─────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
let warnSpy
|
|
90
|
+
|
|
91
|
+
beforeEach(() => {
|
|
92
|
+
vi.clearAllMocks()
|
|
93
|
+
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
afterEach(() => {
|
|
97
|
+
vi.restoreAllMocks()
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
101
|
+
// T10 scenarios — entity_patterns awareness (plan TEST-01..05)
|
|
102
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
103
|
+
|
|
104
|
+
describe('validateFile — entity_patterns awareness (plan T10)', () => {
|
|
105
|
+
it('TEST-01: does NOT warn when a tag is an entity_patterns key (npm)', () => {
|
|
106
|
+
readFileSync.mockReturnValue(makeFileContent({ tags: ['npm'] }))
|
|
107
|
+
|
|
108
|
+
const errors = validateFile(FILE_PATH, VOCAB)
|
|
109
|
+
|
|
110
|
+
expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining('Unknown tags'))
|
|
111
|
+
expect(errors).toEqual([])
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('TEST-02: does NOT warn when a tag is in a tags.* group (mcp in tags.topic)', () => {
|
|
115
|
+
readFileSync.mockReturnValue(makeFileContent({ tags: ['mcp'] }))
|
|
116
|
+
|
|
117
|
+
const errors = validateFile(FILE_PATH, VOCAB)
|
|
118
|
+
|
|
119
|
+
expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining('Unknown tags'))
|
|
120
|
+
expect(errors).toEqual([])
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('TEST-03: warns when a tag is genuinely unknown (some-random-tag)', () => {
|
|
124
|
+
readFileSync.mockReturnValue(makeFileContent({ tags: ['some-random-tag'] }))
|
|
125
|
+
|
|
126
|
+
const errors = validateFile(FILE_PATH, VOCAB)
|
|
127
|
+
|
|
128
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Unknown tags'))
|
|
129
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('some-random-tag'))
|
|
130
|
+
// warn-don't-block: a warning is NOT a validation error
|
|
131
|
+
expect(errors).toEqual([])
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('TEST-04: warns ONLY for truly unknown tags when mixed with entity_patterns keys', () => {
|
|
135
|
+
readFileSync.mockReturnValue(
|
|
136
|
+
makeFileContent({ tags: ['npm', 'vitest', 'some-random-tag'] })
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
const errors = validateFile(FILE_PATH, VOCAB)
|
|
140
|
+
|
|
141
|
+
const warnText = warnSpy.mock.calls.map((call) => String(call[0])).join(' ')
|
|
142
|
+
expect(warnText).toContain('Unknown tags')
|
|
143
|
+
expect(warnText).toContain('some-random-tag')
|
|
144
|
+
expect(warnText).not.toContain('npm')
|
|
145
|
+
expect(warnText).not.toContain('vitest')
|
|
146
|
+
expect(errors).toEqual([])
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('TEST-05: handles a vocab with no entity_patterns key gracefully (no crash)', () => {
|
|
150
|
+
const vocabNoEntityPatterns = {
|
|
151
|
+
categories: { memory_ops: 'Memory operations' },
|
|
152
|
+
tags: { topic: ['mcp'] },
|
|
153
|
+
// deliberately NO entity_patterns key
|
|
154
|
+
}
|
|
155
|
+
readFileSync.mockReturnValue(makeFileContent({ tags: ['npm'] }))
|
|
156
|
+
|
|
157
|
+
// `Object.keys(vocab.entity_patterns || {})` must not throw on undefined
|
|
158
|
+
expect(() => validateFile(FILE_PATH, vocabNoEntityPatterns)).not.toThrow()
|
|
159
|
+
// without entity_patterns, 'npm' is genuinely unknown → degrades to a warning
|
|
160
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Unknown tags'))
|
|
161
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('npm'))
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('TEST-05b: does not crash with a completely empty vocab object ({})', () => {
|
|
165
|
+
readFileSync.mockReturnValue(makeFileContent({ tags: ['whatever'] }))
|
|
166
|
+
|
|
167
|
+
expect(() => validateFile(FILE_PATH, {})).not.toThrow()
|
|
168
|
+
// no tags.* groups and no entity_patterns → tag check skipped, no warning
|
|
169
|
+
expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining('Unknown tags'))
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
174
|
+
// Reviewer M1 — normalization parity with vocab-sync
|
|
175
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
176
|
+
// vocab-sync.js normalizes tags (lowercase, kebab-case) before comparing and
|
|
177
|
+
// appending, so after it appends `my-tag` a staged file whose frontmatter still
|
|
178
|
+
// says `My Tag` must NOT warn. The validator therefore normalizes both sides
|
|
179
|
+
// before the unknown-tag filter (reviewer finding M1). The three scenarios below
|
|
180
|
+
// lock in: (a) normalized-known no-warn, (b) genuinely unknown still warns,
|
|
181
|
+
// (c) mixed-case genuinely unknown still warns.
|
|
182
|
+
|
|
183
|
+
describe('validateFile — tag normalization parity with vocab-sync (reviewer M1)', () => {
|
|
184
|
+
it('M1-a: does NOT warn when a mixed-case staged tag normalizes to a known tag (My Tag → my-tag)', () => {
|
|
185
|
+
const vocabWithNormalized = {
|
|
186
|
+
...VOCAB,
|
|
187
|
+
tags: { ...VOCAB.tags, topic: [...VOCAB.tags.topic, 'my-tag'] },
|
|
188
|
+
}
|
|
189
|
+
readFileSync.mockReturnValue(makeFileContent({ tags: ['My Tag'] }))
|
|
190
|
+
|
|
191
|
+
const errors = validateFile(FILE_PATH, vocabWithNormalized)
|
|
192
|
+
|
|
193
|
+
expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining('Unknown tags'))
|
|
194
|
+
expect(errors).toEqual([])
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('M1-b: still warns when a tag is genuinely unknown after normalization (some-random-tag)', () => {
|
|
198
|
+
readFileSync.mockReturnValue(makeFileContent({ tags: ['some-random-tag'] }))
|
|
199
|
+
|
|
200
|
+
const errors = validateFile(FILE_PATH, VOCAB)
|
|
201
|
+
|
|
202
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Unknown tags'))
|
|
203
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('some-random-tag'))
|
|
204
|
+
// warn-don't-block: a warning is NOT a validation error
|
|
205
|
+
expect(errors).toEqual([])
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('M1-c: still warns for a mixed-case tag that is NOT in the vocabulary', () => {
|
|
209
|
+
readFileSync.mockReturnValue(makeFileContent({ tags: ['Random Tag'] }))
|
|
210
|
+
|
|
211
|
+
const errors = validateFile(FILE_PATH, VOCAB)
|
|
212
|
+
|
|
213
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Unknown tags'))
|
|
214
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Random Tag'))
|
|
215
|
+
// warn-don't-block: a warning is NOT a validation error
|
|
216
|
+
expect(errors).toEqual([])
|
|
217
|
+
})
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
221
|
+
// main() — exit-code contract (warn-don't-block, REQ-05 / CON-02)
|
|
222
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
223
|
+
|
|
224
|
+
describe('main() — exit code behavior', () => {
|
|
225
|
+
it('exits 0 when unknown-tag warnings occur (warnings do NOT block the commit)', () => {
|
|
226
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {})
|
|
227
|
+
execSync.mockReturnValue(`${FILE_PATH}\n`)
|
|
228
|
+
readFileSync.mockImplementation((filePath) => {
|
|
229
|
+
if (String(filePath).includes('.vocabulary.json')) return JSON.stringify(VOCAB)
|
|
230
|
+
return makeFileContent({ tags: ['some-random-tag'] })
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
main()
|
|
234
|
+
|
|
235
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Unknown tags'))
|
|
236
|
+
expect(exitSpy).toHaveBeenCalledWith(0)
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('exits 0 when no memory-bank files are staged', () => {
|
|
240
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {})
|
|
241
|
+
execSync.mockReturnValue('')
|
|
242
|
+
|
|
243
|
+
main()
|
|
244
|
+
|
|
245
|
+
expect(exitSpy).toHaveBeenCalledWith(0)
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('exits 1 when validation errors are found', () => {
|
|
249
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {})
|
|
250
|
+
execSync.mockReturnValue(`${FILE_PATH}\n`)
|
|
251
|
+
readFileSync.mockImplementation((filePath) => {
|
|
252
|
+
if (String(filePath).includes('.vocabulary.json')) return JSON.stringify(VOCAB)
|
|
253
|
+
// id does not match the filename → real validation error
|
|
254
|
+
return makeFileContent({ id: 'mismatched-id', tags: ['mcp'] })
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
main()
|
|
258
|
+
|
|
259
|
+
expect(exitSpy).toHaveBeenCalledWith(1)
|
|
260
|
+
})
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
264
|
+
// validateFile — error branches (robustness / coverage support)
|
|
265
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
266
|
+
|
|
267
|
+
describe('validateFile — error branches', () => {
|
|
268
|
+
it('returns a "Cannot read file" error when the file is unreadable', () => {
|
|
269
|
+
readFileSync.mockImplementation(() => {
|
|
270
|
+
throw new Error('ENOENT')
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
const errors = validateFile(FILE_PATH, VOCAB)
|
|
274
|
+
|
|
275
|
+
expect(errors).toEqual([`Cannot read file: ${FILE_PATH}`])
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it('reports missing YAML frontmatter', () => {
|
|
279
|
+
readFileSync.mockReturnValue('# No frontmatter here\n')
|
|
280
|
+
|
|
281
|
+
const errors = validateFile(FILE_PATH, VOCAB)
|
|
282
|
+
|
|
283
|
+
expect(errors).toEqual(['Missing YAML frontmatter (file must start with ---...---)'])
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it('reports missing required fields', () => {
|
|
287
|
+
readFileSync.mockReturnValue('---\nid: sample\n---\n')
|
|
288
|
+
|
|
289
|
+
const errors = validateFile(FILE_PATH, VOCAB)
|
|
290
|
+
|
|
291
|
+
expect(errors).toEqual([
|
|
292
|
+
"Missing required field: 'title'",
|
|
293
|
+
"Missing required field: 'updated'",
|
|
294
|
+
"Missing required field: 'tags'",
|
|
295
|
+
"Missing required field: 'category'",
|
|
296
|
+
])
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it('reports an id/filename mismatch', () => {
|
|
300
|
+
readFileSync.mockReturnValue(makeFileContent({ id: 'mismatched-id' }))
|
|
301
|
+
|
|
302
|
+
const errors = validateFile(FILE_PATH, VOCAB)
|
|
303
|
+
|
|
304
|
+
expect(errors).toContain(
|
|
305
|
+
`Field 'id' (mismatched-id) does not match filename (sample)`
|
|
306
|
+
)
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
it('reports an invalid updated date', () => {
|
|
310
|
+
readFileSync.mockReturnValue(makeFileContent({ updated: 'yesterday' }))
|
|
311
|
+
|
|
312
|
+
const errors = validateFile(FILE_PATH, VOCAB)
|
|
313
|
+
|
|
314
|
+
expect(errors).toContain(
|
|
315
|
+
`Field 'updated' (yesterday) is not a valid date. Use YYYY-MM-DD format.`
|
|
316
|
+
)
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
it('reports an invalid category', () => {
|
|
320
|
+
readFileSync.mockReturnValue(makeFileContent({ category: 'not-a-category' }))
|
|
321
|
+
|
|
322
|
+
const errors = validateFile(FILE_PATH, VOCAB)
|
|
323
|
+
|
|
324
|
+
expect(errors).toContain(
|
|
325
|
+
`Invalid category 'not-a-category'. Allowed: memory_ops, context`
|
|
326
|
+
)
|
|
327
|
+
})
|
|
328
|
+
})
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* vocab-sync.js — Pre-commit hook vocabulary sync.
|
|
5
|
+
*
|
|
6
|
+
* Scans STAGED memory-bank/*.md files for YAML frontmatter tags and appends
|
|
7
|
+
* any genuinely-new tags to memory-bank/.vocabulary.json under the `topic`
|
|
8
|
+
* group (the default catch-all). A tag is considered "known" when it exists
|
|
9
|
+
* in any `tags.*` group or as an `entity_patterns` key.
|
|
10
|
+
*
|
|
11
|
+
* Warning-only hook — never blocks the commit. Exits 0 unless an unexpected
|
|
12
|
+
* error occurs (then exits 1 with an error message).
|
|
13
|
+
*
|
|
14
|
+
* Usage (in .husky/pre-commit, BEFORE validate-memory-schema.js):
|
|
15
|
+
* node scripts/vocab-sync.js
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { execSync } from 'node:child_process';
|
|
19
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { resolve } from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
|
|
23
|
+
const MEMORY_BANK_DIR = resolve(process.cwd(), 'memory-bank');
|
|
24
|
+
const VOCAB_PATH = resolve(MEMORY_BANK_DIR, '.vocabulary.json');
|
|
25
|
+
|
|
26
|
+
// ── Load vocabulary ──────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
function loadVocabulary() {
|
|
29
|
+
if (!existsSync(VOCAB_PATH)) {
|
|
30
|
+
console.warn('[vocab-sync] ⚠ No .vocabulary.json found at memory-bank/.vocabulary.json');
|
|
31
|
+
console.warn('[vocab-sync] Vocabulary sync skipped.');
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(readFileSync(VOCAB_PATH, 'utf8'));
|
|
37
|
+
} catch (err) {
|
|
38
|
+
console.warn(`[vocab-sync] ⚠ Failed to parse .vocabulary.json: ${err.message}`);
|
|
39
|
+
console.warn('[vocab-sync] Vocabulary sync skipped.');
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── Get staged memory-bank files ─────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
function getStagedMemoryFiles() {
|
|
47
|
+
try {
|
|
48
|
+
const output = execSync(
|
|
49
|
+
'git diff --cached --name-only --diff-filter=ACM',
|
|
50
|
+
{ encoding: 'utf8', cwd: process.cwd() }
|
|
51
|
+
);
|
|
52
|
+
return output
|
|
53
|
+
.split('\n')
|
|
54
|
+
.filter(f => f.startsWith('memory-bank/') && f.endsWith('.md') && !f.includes('/.index/'));
|
|
55
|
+
} catch {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── Parse frontmatter ────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
function parseFrontmatter(content) {
|
|
63
|
+
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
64
|
+
if (!match) return null;
|
|
65
|
+
|
|
66
|
+
const raw = match[1];
|
|
67
|
+
const fm = {};
|
|
68
|
+
for (const line of raw.split('\n')) {
|
|
69
|
+
const kv = line.match(/^(\w[\w-]*):\s*(.*)/);
|
|
70
|
+
if (kv) {
|
|
71
|
+
const key = kv[1];
|
|
72
|
+
let value = kv[2].trim();
|
|
73
|
+
// Parse arrays
|
|
74
|
+
if (value.startsWith('[') && value.endsWith(']')) {
|
|
75
|
+
value = value.slice(1, -1).split(',').map(s => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
|
|
76
|
+
} else if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
77
|
+
value = value.slice(1, -1);
|
|
78
|
+
}
|
|
79
|
+
fm[key] = value;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return fm;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── Tag normalization ────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
function normalizeTag(tag) {
|
|
88
|
+
return String(tag)
|
|
89
|
+
.toLowerCase()
|
|
90
|
+
.trim()
|
|
91
|
+
.replace(/[\s_]+/g, '-');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── Known tag collection ─────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
function buildKnownTags(vocab) {
|
|
97
|
+
const known = new Set();
|
|
98
|
+
|
|
99
|
+
// All values across all tags.* groups
|
|
100
|
+
for (const group of Object.values(vocab?.tags || {})) {
|
|
101
|
+
// Guard against malformed vocab shapes (consumer-controlled input): if
|
|
102
|
+
// `vocab.tags` is an array or a group is a plain string, iterating the
|
|
103
|
+
// group directly would walk CHARACTERS instead of tags. Skip non-arrays.
|
|
104
|
+
if (!Array.isArray(group)) continue;
|
|
105
|
+
for (const tag of group) {
|
|
106
|
+
const normalized = normalizeTag(tag);
|
|
107
|
+
if (normalized) known.add(normalized);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// All entity_patterns keys (component/tool names)
|
|
112
|
+
for (const key of Object.keys(vocab?.entity_patterns || {})) {
|
|
113
|
+
const normalized = normalizeTag(key);
|
|
114
|
+
if (normalized) known.add(normalized);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return known;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── Collect new tags ─────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
function collectNewTags(stagedFiles, vocab) {
|
|
123
|
+
const known = buildKnownTags(vocab);
|
|
124
|
+
const newTags = new Set();
|
|
125
|
+
|
|
126
|
+
for (const file of stagedFiles) {
|
|
127
|
+
let content;
|
|
128
|
+
try {
|
|
129
|
+
content = readFileSync(resolve(process.cwd(), file), 'utf8');
|
|
130
|
+
} catch {
|
|
131
|
+
continue; // unreadable file — skip
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const fm = parseFrontmatter(content);
|
|
135
|
+
if (!fm || !Array.isArray(fm.tags)) continue;
|
|
136
|
+
|
|
137
|
+
for (const rawTag of fm.tags) {
|
|
138
|
+
const tag = normalizeTag(rawTag);
|
|
139
|
+
if (!tag) continue; // empty / whitespace-only
|
|
140
|
+
if (known.has(tag)) continue; // already in vocabulary
|
|
141
|
+
newTags.add(tag);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return [...newTags].sort();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── Sync ─────────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
function syncVocabulary(stagedFiles, vocab) {
|
|
151
|
+
const newTags = collectNewTags(stagedFiles, vocab);
|
|
152
|
+
if (newTags.length === 0) return false; // nothing to do — no-op
|
|
153
|
+
|
|
154
|
+
// Default placement: the `topic` catch-all group
|
|
155
|
+
if (!vocab.tags) vocab.tags = {};
|
|
156
|
+
if (!Array.isArray(vocab.tags.topic)) vocab.tags.topic = [];
|
|
157
|
+
|
|
158
|
+
// Append, normalize existing entries, dedupe, sort alphabetically
|
|
159
|
+
const merged = new Set([
|
|
160
|
+
...vocab.tags.topic.map(normalizeTag).filter(Boolean),
|
|
161
|
+
...newTags,
|
|
162
|
+
]);
|
|
163
|
+
vocab.tags.topic = [...merged].sort();
|
|
164
|
+
|
|
165
|
+
// Write back with 2-space indent + trailing newline (matches existing format)
|
|
166
|
+
writeFileSync(VOCAB_PATH, `${JSON.stringify(vocab, null, 2)}\n`, 'utf8');
|
|
167
|
+
|
|
168
|
+
// Stage the updated vocabulary so it commits with the same change
|
|
169
|
+
try {
|
|
170
|
+
execSync('git add memory-bank/.vocabulary.json', { cwd: process.cwd() });
|
|
171
|
+
} catch (err) {
|
|
172
|
+
console.warn(`[vocab-sync] ⚠ Could not stage memory-bank/.vocabulary.json: ${err.message}`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
console.warn(
|
|
176
|
+
`[vocab-sync] Added ${newTags.length} tag(s) to memory-bank/.vocabulary.json: [${newTags.join(', ')}]`
|
|
177
|
+
);
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── Main ─────────────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
function main() {
|
|
184
|
+
const stagedFiles = getStagedMemoryFiles();
|
|
185
|
+
if (stagedFiles.length === 0) return; // no memory-bank files staged — silent no-op
|
|
186
|
+
|
|
187
|
+
const vocab = loadVocabulary();
|
|
188
|
+
if (!vocab) return; // graceful skip when vocabulary is unavailable
|
|
189
|
+
|
|
190
|
+
syncVocabulary(stagedFiles, vocab);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Only run when executed directly (node scripts/vocab-sync.js). When the
|
|
194
|
+
// module is imported — e.g. by unit tests — no side effects are triggered.
|
|
195
|
+
const isDirectRun =
|
|
196
|
+
process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
197
|
+
|
|
198
|
+
if (isDirectRun) {
|
|
199
|
+
try {
|
|
200
|
+
main();
|
|
201
|
+
} catch (err) {
|
|
202
|
+
console.error(`[vocab-sync] ❌ Unhandled error: ${err.message}`);
|
|
203
|
+
process.exit(1);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export {
|
|
208
|
+
loadVocabulary,
|
|
209
|
+
getStagedMemoryFiles,
|
|
210
|
+
parseFrontmatter,
|
|
211
|
+
normalizeTag,
|
|
212
|
+
buildKnownTags,
|
|
213
|
+
collectNewTags,
|
|
214
|
+
syncVocabulary,
|
|
215
|
+
main,
|
|
216
|
+
};
|