@abarcenas/ai-workflow-template 1.48.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/.agents/instructions/knowledgebase.instructions.md +2 -2
  2. package/.agents/instructions/learned-knowledge.instructions.md +47 -2
  3. package/.agents/instructions/memory-schema.instructions.md +1 -1
  4. package/.github/workflows/npm-publish.yml +69 -1
  5. package/.opencode/agents/implementer.md +3 -1
  6. package/.opencode/agents/unit-tester.md +21 -2
  7. package/.opencode/plugins/graphify.js +22 -0
  8. package/LICENSE +15 -0
  9. package/README.md +56 -22
  10. package/memory-bank/.vocabulary.json +3 -0
  11. package/opencode.mcp.example.json +13 -0
  12. package/package.json +20 -10
  13. package/scripts/bump-version.js +18 -2
  14. package/scripts/mcp/playwright-mcp-launcher.js +45 -2
  15. package/scripts/bump-version.test.js +0 -238
  16. package/scripts/knowledgebase-cli.test.js +0 -576
  17. package/scripts/knowledgebase-index.test.js +0 -759
  18. package/scripts/mcp/playwright-mcp-launcher.test.js +0 -217
  19. package/scripts/mcp-knowledgebase-server.test.js +0 -745
  20. package/scripts/mcp-memory-server.test.js +0 -398
  21. package/scripts/memory-cli.test.js +0 -297
  22. package/scripts/memory-index.test.js +0 -1173
  23. package/scripts/normalize-memory.test.js +0 -753
  24. package/scripts/setup/discover.test.js +0 -530
  25. package/scripts/setup/hooks.test.js +0 -722
  26. package/scripts/setup/husky-init.test.js +0 -311
  27. package/scripts/setup/index.test.js +0 -1021
  28. package/scripts/setup/knowledgebase.test.js +0 -352
  29. package/scripts/setup/prepare.test.js +0 -718
  30. package/scripts/setup/skills-phase.test.js +0 -1274
  31. package/scripts/setup/sync-phase.test.js +0 -373
  32. package/scripts/setup/ui.test.js +0 -579
  33. package/scripts/setup/utils.test.js +0 -586
  34. package/scripts/sync-unit.test.js +0 -1881
  35. package/scripts/sync.test.js +0 -472
  36. package/scripts/validate-memory-schema.test.js +0 -328
  37. package/scripts/vocab-sync.test.js +0 -902
@@ -1,586 +0,0 @@
1
- // ═════════════════════════════════════════════════════════════════════════════
2
- // utils.test.js — Unit tests for the setup utility helpers
3
- // ═════════════════════════════════════════════════════════════════════════════
4
- //
5
- // Everything filesystem-related runs against real temp directories; nothing is
6
- // written to the repository. Environment variables are saved/restored so the
7
- // CI-detection tests cannot leak into other suites.
8
- // ═════════════════════════════════════════════════════════════════════════════
9
-
10
- import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll, vi } from 'vitest'
11
- import {
12
- chmodSync,
13
- existsSync,
14
- mkdirSync,
15
- mkdtempSync,
16
- readFileSync,
17
- rmSync,
18
- statSync,
19
- writeFileSync,
20
- } from 'fs'
21
- import { createHash } from 'crypto'
22
- import { join, resolve } from 'path'
23
- import { tmpdir } from 'os'
24
-
25
- import {
26
- chmodX,
27
- dirExists,
28
- ensureDir,
29
- fileExists,
30
- getConsumerRoot,
31
- getNodeVersion,
32
- hashContent,
33
- isCI,
34
- isShellScript,
35
- logError,
36
- logInfo,
37
- logWarn,
38
- parseCliArgs,
39
- resolveConsumerPath,
40
- resolvePackageRoot,
41
- safeAppendFile,
42
- safeReadFile,
43
- safeReadJson,
44
- safeWriteFile,
45
- safeWriteJson,
46
- } from './utils.js'
47
- import { CI_ENV_VARS } from './constants.js'
48
-
49
- // ── Temp directory helpers ─────────────────────────────────────────────────
50
-
51
- const tempDirs = new Set()
52
-
53
- function createTempDir() {
54
- const dir = mkdtempSync(join(tmpdir(), 'utils-test-'))
55
- tempDirs.add(dir)
56
- return dir
57
- }
58
-
59
- function cleanupTempDirs() {
60
- for (const dir of tempDirs) {
61
- try {
62
- rmSync(dir, { recursive: true, force: true })
63
- } catch {
64
- /* best-effort cleanup */
65
- }
66
- }
67
- tempDirs.clear()
68
- }
69
-
70
- // ── Default flag shape ─────────────────────────────────────────────────────
71
-
72
- const DEFAULT_FLAG_KEYS = [
73
- 'force',
74
- 'dryRun',
75
- 'yes',
76
- 'skipHooks',
77
- 'skipPrepare',
78
- 'skipSync',
79
- 'skipKnowledgebase',
80
- 'knowledgebase',
81
- 'help',
82
- 'version',
83
- 'quiet',
84
- 'verbose',
85
- ]
86
-
87
- const allFlagsFalse = () =>
88
- Object.fromEntries(DEFAULT_FLAG_KEYS.map((k) => [k, false]))
89
-
90
- // ── Path resolution ────────────────────────────────────────────────────────
91
-
92
- describe('path resolution', () => {
93
- const originalInitCwd = process.env.INIT_CWD
94
-
95
- afterEach(() => {
96
- if (originalInitCwd === undefined) delete process.env.INIT_CWD
97
- else process.env.INIT_CWD = originalInitCwd
98
- cleanupTempDirs()
99
- })
100
-
101
- it('getConsumerRoot() returns the absolute INIT_CWD when set', () => {
102
- const dir = createTempDir()
103
- process.env.INIT_CWD = dir
104
-
105
- expect(getConsumerRoot()).toBe(dir)
106
- })
107
-
108
- it('getConsumerRoot() resolves a relative INIT_CWD to an absolute path', () => {
109
- process.env.INIT_CWD = 'some/relative/path'
110
-
111
- expect(getConsumerRoot()).toBe(resolve('some/relative/path'))
112
- })
113
-
114
- it('getConsumerRoot() warns and falls back to process.cwd() without INIT_CWD', () => {
115
- delete process.env.INIT_CWD
116
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
117
-
118
- try {
119
- expect(getConsumerRoot()).toBe(process.cwd())
120
- expect(warnSpy).toHaveBeenCalledWith(
121
- expect.stringContaining('INIT_CWD is not set'),
122
- )
123
- } finally {
124
- warnSpy.mockRestore()
125
- }
126
- })
127
-
128
- it('resolvePackageRoot() points at the directory containing package.json', () => {
129
- const root = resolvePackageRoot()
130
-
131
- expect(existsSync(join(root, 'package.json'))).toBe(true)
132
- expect(JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8')).name).toBe(
133
- '@abarcenas/ai-workflow-template',
134
- )
135
- })
136
-
137
- it('resolveConsumerPath() joins segments beneath the consumer root', () => {
138
- const dir = createTempDir()
139
- process.env.INIT_CWD = dir
140
-
141
- expect(resolveConsumerPath('.husky', 'pre-commit')).toBe(
142
- join(dir, '.husky', 'pre-commit'),
143
- )
144
- expect(resolveConsumerPath()).toBe(dir)
145
- })
146
- })
147
-
148
- // ── Directory helpers ──────────────────────────────────────────────────────
149
-
150
- describe('ensureDir()', () => {
151
- afterEach(cleanupTempDirs)
152
-
153
- it('creates nested directories and returns true', () => {
154
- const dir = join(createTempDir(), 'a', 'b', 'c')
155
-
156
- expect(ensureDir(dir)).toBe(true)
157
- expect(statSync(dir).isDirectory()).toBe(true)
158
- })
159
-
160
- it('returns true for a directory that already exists', () => {
161
- const dir = createTempDir()
162
-
163
- expect(ensureDir(dir)).toBe(true)
164
- })
165
-
166
- it('returns true when the path already exists as a file (EEXIST)', () => {
167
- const dir = createTempDir()
168
- const file = join(dir, 'already-a-file')
169
- writeFileSync(file, 'x')
170
-
171
- expect(ensureDir(file)).toBe(true)
172
- })
173
-
174
- it('rethrows unexpected filesystem errors', () => {
175
- expect(() => ensureDir(join(createTempDir(), 'bad\u0000name'))).toThrow()
176
- })
177
- })
178
-
179
- describe('dirExists()', () => {
180
- afterEach(cleanupTempDirs)
181
-
182
- it('returns true for a directory', () => {
183
- expect(dirExists(createTempDir())).toBe(true)
184
- })
185
-
186
- it('returns false for a regular file', () => {
187
- const file = join(createTempDir(), 'file.txt')
188
- writeFileSync(file, 'x')
189
-
190
- expect(dirExists(file)).toBe(false)
191
- })
192
-
193
- it('returns false for a missing path', () => {
194
- expect(dirExists(join(createTempDir(), 'missing'))).toBe(false)
195
- })
196
- })
197
-
198
- describe('fileExists()', () => {
199
- afterEach(cleanupTempDirs)
200
-
201
- it('returns true for files and directories', () => {
202
- const dir = createTempDir()
203
- const file = join(dir, 'present.txt')
204
- writeFileSync(file, 'x')
205
-
206
- expect(fileExists(file)).toBe(true)
207
- expect(fileExists(dir)).toBe(true)
208
- })
209
-
210
- it('returns false for a missing path', () => {
211
- expect(fileExists(join(createTempDir(), 'nope.txt'))).toBe(false)
212
- })
213
- })
214
-
215
- // ── File read/write helpers ────────────────────────────────────────────────
216
-
217
- describe('safeReadFile()', () => {
218
- afterEach(cleanupTempDirs)
219
-
220
- it('returns the UTF-8 contents of an existing file', () => {
221
- const file = join(createTempDir(), 'readme.txt')
222
- writeFileSync(file, 'héllo wörld', 'utf-8')
223
-
224
- expect(safeReadFile(file)).toBe('héllo wörld')
225
- })
226
-
227
- it('returns null for a missing file (ENOENT)', () => {
228
- expect(safeReadFile(join(createTempDir(), 'missing.txt'))).toBeNull()
229
- })
230
-
231
- it('rethrows non-ENOENT errors such as EISDIR', () => {
232
- const dir = createTempDir()
233
-
234
- expect(() => safeReadFile(dir)).toThrow()
235
- })
236
- })
237
-
238
- describe('safeWriteFile()', () => {
239
- afterEach(cleanupTempDirs)
240
-
241
- it('writes content and creates parent directories', () => {
242
- const file = join(createTempDir(), 'nested', 'deep', 'out.txt')
243
-
244
- expect(safeWriteFile(file, 'content')).toBe(true)
245
- expect(readFileSync(file, 'utf-8')).toBe('content')
246
- })
247
-
248
- it('overwrites an existing file', () => {
249
- const file = join(createTempDir(), 'out.txt')
250
- writeFileSync(file, 'old')
251
-
252
- expect(safeWriteFile(file, 'new')).toBe(true)
253
- expect(readFileSync(file, 'utf-8')).toBe('new')
254
- })
255
-
256
- it('returns false instead of throwing on EACCES', () => {
257
- const file = join(createTempDir(), 'readonly.txt')
258
- writeFileSync(file, 'locked')
259
- chmodSync(file, 0o444)
260
-
261
- expect(safeWriteFile(file, 'nope')).toBe(false)
262
- expect(readFileSync(file, 'utf-8')).toBe('locked')
263
- })
264
-
265
- it('rethrows unexpected errors (ENOTDIR)', () => {
266
- const dir = createTempDir()
267
- const blocker = join(dir, 'blocker')
268
- writeFileSync(blocker, 'i am a file')
269
-
270
- expect(() => safeWriteFile(join(blocker, 'child.txt'), 'x')).toThrow()
271
- })
272
- })
273
-
274
- describe('safeAppendFile()', () => {
275
- afterEach(cleanupTempDirs)
276
-
277
- it('creates the file and appends on repeated calls', () => {
278
- const file = join(createTempDir(), 'nested', 'log.txt')
279
-
280
- safeAppendFile(file, 'first\n')
281
- safeAppendFile(file, 'second\n')
282
-
283
- expect(readFileSync(file, 'utf-8')).toBe('first\nsecond\n')
284
- })
285
- })
286
-
287
- // ── Shell script inspection ───────────────────────────────────────────────
288
-
289
- describe('isShellScript()', () => {
290
- afterEach(cleanupTempDirs)
291
-
292
- it('returns true when the first line starts with a shebang', () => {
293
- const file = join(createTempDir(), 'hook')
294
- writeFileSync(file, '#!/bin/sh\necho hi\n')
295
-
296
- expect(isShellScript(file)).toBe(true)
297
- })
298
-
299
- it('returns false when the first line is not a shebang', () => {
300
- const file = join(createTempDir(), 'hook')
301
- writeFileSync(file, '# just a comment\n')
302
-
303
- expect(isShellScript(file)).toBe(false)
304
- })
305
-
306
- it('returns false for an empty file', () => {
307
- const file = join(createTempDir(), 'empty')
308
- writeFileSync(file, '')
309
-
310
- expect(isShellScript(file)).toBe(false)
311
- })
312
-
313
- it('returns false for a missing file', () => {
314
- expect(isShellScript(join(createTempDir(), 'missing'))).toBe(false)
315
- })
316
- })
317
-
318
- describe('chmodX()', () => {
319
- afterEach(cleanupTempDirs)
320
-
321
- it('sets 0755 permission bits', () => {
322
- const file = join(createTempDir(), 'script')
323
- writeFileSync(file, '#!/bin/sh\n')
324
-
325
- chmodX(file)
326
-
327
- expect(statSync(file).mode & 0o777).toBe(0o755)
328
- })
329
-
330
- it('throws for a missing file', () => {
331
- expect(() => chmodX(join(createTempDir(), 'missing'))).toThrow()
332
- })
333
- })
334
-
335
- // ── JSON helpers ───────────────────────────────────────────────────────────
336
-
337
- describe('safeReadJson()', () => {
338
- afterEach(cleanupTempDirs)
339
-
340
- it('parses a valid JSON file', () => {
341
- const file = join(createTempDir(), 'pkg.json')
342
- writeFileSync(file, JSON.stringify({ name: 'x', nested: { ok: true } }))
343
-
344
- expect(safeReadJson(file)).toEqual({ name: 'x', nested: { ok: true } })
345
- })
346
-
347
- it('returns undefined when the file is missing', () => {
348
- expect(safeReadJson(join(createTempDir(), 'missing.json'))).toBeUndefined()
349
- })
350
-
351
- it('returns undefined when the JSON is malformed', () => {
352
- const file = join(createTempDir(), 'broken.json')
353
- writeFileSync(file, '{ not json')
354
-
355
- expect(safeReadJson(file)).toBeUndefined()
356
- })
357
- })
358
-
359
- describe('safeWriteJson()', () => {
360
- afterEach(cleanupTempDirs)
361
-
362
- it('writes 2-space indented JSON with a trailing newline', () => {
363
- const file = join(createTempDir(), 'nested', 'data.json')
364
-
365
- safeWriteJson(file, { a: 1, b: [2, 3] })
366
-
367
- expect(readFileSync(file, 'utf-8')).toBe(
368
- '{\n "a": 1,\n "b": [\n 2,\n 3\n ]\n}\n',
369
- )
370
- expect(safeReadJson(file)).toEqual({ a: 1, b: [2, 3] })
371
- })
372
- })
373
-
374
- // ── Hashing ────────────────────────────────────────────────────────────────
375
-
376
- describe('hashContent()', () => {
377
- it('returns the SHA-256 hex digest', () => {
378
- // Well-known vector: sha256('abc')
379
- expect(hashContent('abc')).toBe(
380
- 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad',
381
- )
382
- expect(hashContent('abc')).toBe(
383
- createHash('sha256').update('abc').digest('hex'),
384
- )
385
- })
386
-
387
- it('produces different digests for different content', () => {
388
- expect(hashContent('a')).not.toBe(hashContent('b'))
389
- })
390
-
391
- it('handles the empty string', () => {
392
- expect(hashContent('')).toHaveLength(64)
393
- })
394
- })
395
-
396
- // ── CI detection ───────────────────────────────────────────────────────────
397
-
398
- describe('isCI()', () => {
399
- const saved = new Map()
400
-
401
- beforeAll(() => {
402
- for (const name of CI_ENV_VARS) saved.set(name, process.env[name])
403
- })
404
-
405
- beforeEach(() => {
406
- for (const name of CI_ENV_VARS) delete process.env[name]
407
- })
408
-
409
- afterAll(() => {
410
- for (const [name, value] of saved) {
411
- if (value === undefined) delete process.env[name]
412
- else process.env[name] = value
413
- }
414
- })
415
-
416
- it('returns false when no CI environment variable is set', () => {
417
- expect(isCI()).toBe(false)
418
- })
419
-
420
- it('returns true for a truthy generic CI variable', () => {
421
- process.env.CI = 'true'
422
-
423
- expect(isCI()).toBe(true)
424
- })
425
-
426
- it('detects provider-specific variables', () => {
427
- for (const name of ['GITHUB_ACTIONS', 'GITLAB_CI', 'CIRCLECI', 'JENKINS_URL']) {
428
- for (const other of CI_ENV_VARS) delete process.env[other]
429
- process.env[name] = '1'
430
-
431
- expect(isCI(), `${name} should be detected`).toBe(true)
432
- }
433
- })
434
-
435
- it('treats NODE_ENV=production as CI', () => {
436
- process.env.NODE_ENV = 'production'
437
-
438
- expect(isCI()).toBe(true)
439
- })
440
-
441
- it('does not treat other NODE_ENV values as CI', () => {
442
- process.env.NODE_ENV = 'development'
443
-
444
- expect(isCI()).toBe(false)
445
- })
446
-
447
- it('ignores empty-string CI variables', () => {
448
- process.env.CI = ''
449
- process.env.GITHUB_ACTIONS = ''
450
-
451
- expect(isCI()).toBe(false)
452
- })
453
- })
454
-
455
- describe('getNodeVersion()', () => {
456
- it('returns the major version parsed from process.version', () => {
457
- expect(getNodeVersion()).toBe(
458
- parseInt(process.version.slice(1).split('.')[0], 10),
459
- )
460
- expect(Number.isInteger(getNodeVersion())).toBe(true)
461
- })
462
- })
463
-
464
- // ── CLI argument parsing ───────────────────────────────────────────────────
465
-
466
- describe('parseCliArgs()', () => {
467
- it('returns every supported flag set to false for empty argv', () => {
468
- const flags = parseCliArgs([])
469
-
470
- expect(flags).toEqual(allFlagsFalse())
471
- expect(Object.keys(flags).sort()).toEqual([...DEFAULT_FLAG_KEYS].sort())
472
- })
473
-
474
- it('enables long-form boolean flags', () => {
475
- const flags = parseCliArgs([
476
- '--force',
477
- '--dry-run',
478
- '--yes',
479
- '--skip-hooks',
480
- '--skip-prepare',
481
- '--skip-sync',
482
- '--skip-knowledgebase',
483
- '--knowledgebase',
484
- '--help',
485
- '--version',
486
- '--quiet',
487
- '--verbose',
488
- ])
489
-
490
- expect(flags).toEqual(
491
- Object.fromEntries(DEFAULT_FLAG_KEYS.map((k) => [k, true])),
492
- )
493
- })
494
-
495
- it('maps short-form flags to their canonical names', () => {
496
- const flags = parseCliArgs(['-y', '-h', '-v', '-q', '-V'])
497
-
498
- expect(flags.yes).toBe(true)
499
- expect(flags.help).toBe(true)
500
- expect(flags.version).toBe(true)
501
- expect(flags.quiet).toBe(true)
502
- expect(flags.verbose).toBe(true)
503
- })
504
-
505
- it('supports --flag=value for known flags', () => {
506
- const flags = parseCliArgs(['--force=maybe', '--skip-sync=yes'])
507
-
508
- expect(flags.force).toBe('maybe')
509
- expect(flags.skipSync).toBe('yes')
510
- })
511
-
512
- it('supports --no-flag to explicitly disable a flag', () => {
513
- const flags = parseCliArgs(['--force', '--no-force', '--skip-hooks', '--no-skip-hooks'])
514
-
515
- expect(flags.force).toBe(false)
516
- expect(flags.skipHooks).toBe(false)
517
- })
518
-
519
- it('ignores unknown --no- flags', () => {
520
- const flags = parseCliArgs(['--no-unknown-flag'])
521
-
522
- expect(flags).toEqual(allFlagsFalse())
523
- })
524
-
525
- it('ignores unknown long flags, short flags and assignments', () => {
526
- const flags = parseCliArgs(['--nope', '-x', '--what=ever', 'positional'])
527
-
528
- expect(flags).toEqual(allFlagsFalse())
529
- })
530
-
531
- it('lets later arguments win over earlier ones', () => {
532
- expect(parseCliArgs(['--force', '--no-force']).force).toBe(false)
533
- expect(parseCliArgs(['--no-force', '--force']).force).toBe(true)
534
- })
535
-
536
- it('leaves unrelated flags untouched', () => {
537
- const flags = parseCliArgs(['--dry-run'])
538
-
539
- expect(flags.dryRun).toBe(true)
540
- expect(flags.force).toBe(false)
541
- expect(flags.verbose).toBe(false)
542
- })
543
- })
544
-
545
- // ── Logging wrappers ───────────────────────────────────────────────────────
546
-
547
- describe('logging wrappers', () => {
548
- it('logError() writes to stderr', () => {
549
- const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
550
-
551
- logError('boom')
552
-
553
- expect(spy).toHaveBeenCalledWith('boom')
554
- spy.mockRestore()
555
- })
556
-
557
- it('logWarn() writes to stderr', () => {
558
- const spy = vi.spyOn(console, 'warn').mockImplementation(() => {})
559
-
560
- logWarn('careful')
561
-
562
- expect(spy).toHaveBeenCalledWith('careful')
563
- spy.mockRestore()
564
- })
565
-
566
- it('logInfo() writes to stdout', () => {
567
- const spy = vi.spyOn(console, 'log').mockImplementation(() => {})
568
-
569
- logInfo('fyi')
570
-
571
- expect(spy).toHaveBeenCalledWith('fyi')
572
- spy.mockRestore()
573
- })
574
- })
575
-
576
- // ── Cross-cutting: no repository writes ───────────────────────────────────
577
-
578
- describe('temp-directory isolation', () => {
579
- it('all fixtures live under the OS temp directory', () => {
580
- const dir = createTempDir()
581
-
582
- expect(dir.startsWith(tmpdir())).toBe(true)
583
- mkdirSync(join(dir, 'sandbox'))
584
- expect(existsSync(join(dir, 'sandbox'))).toBe(true)
585
- })
586
- })