@cleocode/contracts 2026.5.90 → 2026.5.93

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 (50) hide show
  1. package/dist/__tests__/docs-taxonomy.test.d.ts +16 -0
  2. package/dist/__tests__/docs-taxonomy.test.d.ts.map +1 -0
  3. package/dist/__tests__/docs-taxonomy.test.js +404 -0
  4. package/dist/__tests__/docs-taxonomy.test.js.map +1 -0
  5. package/dist/docs-taxonomy.d.ts +286 -0
  6. package/dist/docs-taxonomy.d.ts.map +1 -0
  7. package/dist/docs-taxonomy.js +489 -0
  8. package/dist/docs-taxonomy.js.map +1 -0
  9. package/dist/doctor.d.ts +120 -0
  10. package/dist/doctor.d.ts.map +1 -0
  11. package/dist/doctor.js +16 -0
  12. package/dist/doctor.js.map +1 -0
  13. package/dist/index.d.ts +9 -3
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +2 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/logger.d.ts +42 -0
  18. package/dist/logger.d.ts.map +1 -0
  19. package/dist/logger.js +14 -0
  20. package/dist/logger.js.map +1 -0
  21. package/dist/memory.d.ts +145 -2
  22. package/dist/memory.d.ts.map +1 -1
  23. package/dist/memory.js +22 -2
  24. package/dist/memory.js.map +1 -1
  25. package/dist/operations/docs.d.ts +89 -11
  26. package/dist/operations/docs.d.ts.map +1 -1
  27. package/dist/operations/docs.js +19 -12
  28. package/dist/operations/docs.js.map +1 -1
  29. package/dist/operations/session.d.ts +66 -0
  30. package/dist/operations/session.d.ts.map +1 -1
  31. package/dist/operations/validate.d.ts +32 -0
  32. package/dist/operations/validate.d.ts.map +1 -1
  33. package/dist/release/evidence-atoms.d.ts +103 -0
  34. package/dist/release/evidence-atoms.d.ts.map +1 -0
  35. package/dist/release/evidence-atoms.js +89 -0
  36. package/dist/release/evidence-atoms.js.map +1 -0
  37. package/dist/task.d.ts +43 -0
  38. package/dist/task.d.ts.map +1 -1
  39. package/package.json +2 -2
  40. package/src/__tests__/docs-taxonomy.test.ts +465 -0
  41. package/src/docs-taxonomy.ts +682 -0
  42. package/src/doctor.ts +130 -0
  43. package/src/index.ts +37 -0
  44. package/src/logger.ts +42 -0
  45. package/src/memory.ts +154 -2
  46. package/src/operations/docs.ts +92 -18
  47. package/src/operations/session.ts +71 -0
  48. package/src/operations/validate.ts +34 -0
  49. package/src/release/evidence-atoms.ts +118 -0
  50. package/src/task.ts +44 -0
@@ -0,0 +1,465 @@
1
+ /**
2
+ * Tests for the canonical doc-kind taxonomy registry (T9788).
3
+ *
4
+ * Covers:
5
+ * - Built-in registry shape (every kind has the required metadata)
6
+ * - Backward-compatible enumeration (the 6 prior kinds still resolve)
7
+ * - Extension load — happy-path
8
+ * - Extension load — every invalid-config branch
9
+ * - validateSlug pass/fail for each `requiresEntityId` kind
10
+ * - validateSlug semantics for kinds without an entityIdPattern
11
+ *
12
+ * @epic T9787
13
+ * @task T9788
14
+ */
15
+
16
+ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
17
+ import { tmpdir } from 'node:os';
18
+ import { join } from 'node:path';
19
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
20
+ import {
21
+ BUILTIN_DOC_KIND_VALUES,
22
+ BUILTIN_DOC_KINDS,
23
+ DocKindConfigError,
24
+ DocKindRegistry,
25
+ } from '../docs-taxonomy.js';
26
+
27
+ /** Build a throwaway project root with a `.cleo/` dir for config-file tests. */
28
+ function newProjectRoot(): string {
29
+ const root = mkdtempSync(join(tmpdir(), 'docs-taxonomy-test-'));
30
+ mkdirSync(join(root, '.cleo'), { recursive: true });
31
+ return root;
32
+ }
33
+
34
+ /** Convenience — write a `.cleo/docs-config.json` to a project root. */
35
+ function writeConfig(root: string, body: unknown): void {
36
+ writeFileSync(join(root, '.cleo', 'docs-config.json'), JSON.stringify(body), 'utf-8');
37
+ }
38
+
39
+ // ──────────────────────────────────────────────────────────────────────────
40
+ // BUILTIN_DOC_KINDS shape
41
+ // ──────────────────────────────────────────────────────────────────────────
42
+
43
+ describe('BUILTIN_DOC_KINDS — registry shape', () => {
44
+ it('declares every built-in kind once with kebab-case ids', () => {
45
+ const seen = new Set<string>();
46
+ for (const meta of BUILTIN_DOC_KINDS) {
47
+ expect(meta.kind).toMatch(/^[a-z][a-z0-9-]*$/);
48
+ expect(seen.has(meta.kind)).toBe(false);
49
+ seen.add(meta.kind);
50
+ }
51
+ expect(seen.size).toBe(BUILTIN_DOC_KINDS.length);
52
+ });
53
+
54
+ it('attaches an entityIdPattern to every requiresEntityId entry', () => {
55
+ for (const meta of BUILTIN_DOC_KINDS) {
56
+ if (meta.requiresEntityId) {
57
+ expect(meta.entityIdPattern).toBeInstanceOf(RegExp);
58
+ }
59
+ }
60
+ });
61
+
62
+ it('exposes every label, description, publishDir, and defaultOwnerKind', () => {
63
+ for (const meta of BUILTIN_DOC_KINDS) {
64
+ expect(meta.label.length).toBeGreaterThan(0);
65
+ expect(meta.description.length).toBeGreaterThan(0);
66
+ expect(meta.publishDir.length).toBeGreaterThan(0);
67
+ expect(['task', 'session', 'observation', 'project']).toContain(meta.defaultOwnerKind);
68
+ }
69
+ });
70
+
71
+ it('preserves backward compatibility with the prior 6-kind DOCS_TYPE_VALUES', () => {
72
+ // The original closed set of kinds must remain registered so existing
73
+ // CLI flags and stored attachments keep working.
74
+ const legacyKinds = ['adr', 'spec', 'research', 'handoff', 'note', 'llm-readme'];
75
+ for (const kind of legacyKinds) {
76
+ expect(BUILTIN_DOC_KIND_VALUES).toContain(kind);
77
+ }
78
+ });
79
+
80
+ it('introduces the four new kinds defined by the T9788 spec', () => {
81
+ for (const kind of ['changeset', 'release-note', 'plan', 'rcasd']) {
82
+ expect(BUILTIN_DOC_KIND_VALUES).toContain(kind);
83
+ }
84
+ });
85
+ });
86
+
87
+ // ──────────────────────────────────────────────────────────────────────────
88
+ // DocKindRegistry.load — built-ins only (no config file)
89
+ // ──────────────────────────────────────────────────────────────────────────
90
+
91
+ describe('DocKindRegistry.load — built-ins only', () => {
92
+ let root: string;
93
+
94
+ beforeEach(() => {
95
+ root = newProjectRoot();
96
+ });
97
+
98
+ it('returns every built-in kind when no config file exists', () => {
99
+ const registry = DocKindRegistry.load(root);
100
+ const kinds = registry.list().map((d) => d.kind);
101
+ expect(kinds).toEqual(BUILTIN_DOC_KIND_VALUES);
102
+ });
103
+
104
+ it('reports has() correctly for built-ins and rejects unknowns', () => {
105
+ const registry = DocKindRegistry.load(root);
106
+ expect(registry.has('adr')).toBe(true);
107
+ expect(registry.has('rcasd')).toBe(true);
108
+ expect(registry.has('wishlist')).toBe(false);
109
+ expect(registry.has('')).toBe(false);
110
+ });
111
+
112
+ it('publishDirFor returns the registry-declared dir for every built-in', () => {
113
+ const registry = DocKindRegistry.load(root);
114
+ expect(registry.publishDirFor('adr')).toBe('docs/adr');
115
+ expect(registry.publishDirFor('changeset')).toBe('.changeset');
116
+ expect(registry.publishDirFor('rcasd')).toBe('.cleo/rcasd');
117
+ expect(registry.publishDirFor('llm-readme')).toBe('.');
118
+ });
119
+
120
+ it('publishDirFor returns undefined for unknown kinds', () => {
121
+ const registry = DocKindRegistry.load(root);
122
+ expect(registry.publishDirFor('wishlist')).toBeUndefined();
123
+ });
124
+ });
125
+
126
+ // ──────────────────────────────────────────────────────────────────────────
127
+ // DocKindRegistry.load — with extensions
128
+ // ──────────────────────────────────────────────────────────────────────────
129
+
130
+ describe('DocKindRegistry.load — with valid extensions', () => {
131
+ let root: string;
132
+
133
+ beforeEach(() => {
134
+ root = newProjectRoot();
135
+ });
136
+
137
+ it('appends extension kinds after the built-ins', () => {
138
+ writeConfig(root, {
139
+ extensions: [
140
+ {
141
+ kind: 'incident',
142
+ label: 'Incident',
143
+ description: 'Post-mortem record',
144
+ defaultOwnerKind: 'task',
145
+ publishDir: 'docs/incident',
146
+ requiresEntityId: true,
147
+ entityIdPattern: '^inc-\\d{4}-\\d{2}-\\d{2}-[a-z0-9-]+$',
148
+ },
149
+ ],
150
+ });
151
+
152
+ const registry = DocKindRegistry.load(root);
153
+ const kinds = registry.list();
154
+ expect(kinds.length).toBe(BUILTIN_DOC_KINDS.length + 1);
155
+ expect(kinds[kinds.length - 1].kind).toBe('incident');
156
+ expect(kinds[kinds.length - 1].isExtension).toBe(true);
157
+ expect(kinds[kinds.length - 1].entityIdPattern).toBeInstanceOf(RegExp);
158
+ });
159
+
160
+ it('marks extensions with isExtension=true and leaves built-ins untouched', () => {
161
+ writeConfig(root, {
162
+ extensions: [
163
+ {
164
+ kind: 'retro',
165
+ label: 'Retro',
166
+ description: 'Retrospective note',
167
+ defaultOwnerKind: 'session',
168
+ publishDir: 'docs/retro',
169
+ requiresEntityId: false,
170
+ },
171
+ ],
172
+ });
173
+
174
+ const registry = DocKindRegistry.load(root);
175
+ const builtin = registry.get('adr');
176
+ const ext = registry.get('retro');
177
+ expect(builtin?.isExtension).toBeUndefined();
178
+ expect(ext?.isExtension).toBe(true);
179
+ });
180
+
181
+ it('rejects an extension that shadows a built-in kind', () => {
182
+ writeConfig(root, {
183
+ extensions: [
184
+ {
185
+ kind: 'adr',
186
+ label: 'CUSTOM ADR',
187
+ description: 'shadow attempt',
188
+ defaultOwnerKind: 'task',
189
+ publishDir: 'docs/custom-adr',
190
+ requiresEntityId: false,
191
+ },
192
+ ],
193
+ });
194
+
195
+ expect(() => DocKindRegistry.load(root)).toThrow(DocKindConfigError);
196
+ });
197
+
198
+ it('supports an empty extensions array', () => {
199
+ writeConfig(root, { extensions: [] });
200
+ const registry = DocKindRegistry.load(root);
201
+ expect(registry.list().length).toBe(BUILTIN_DOC_KINDS.length);
202
+ });
203
+ });
204
+
205
+ // ──────────────────────────────────────────────────────────────────────────
206
+ // DocKindRegistry.load — invalid configs
207
+ // ──────────────────────────────────────────────────────────────────────────
208
+
209
+ describe('DocKindRegistry.load — invalid configs', () => {
210
+ let root: string;
211
+
212
+ beforeEach(() => {
213
+ root = newProjectRoot();
214
+ });
215
+
216
+ it('throws DocKindConfigError on invalid JSON', () => {
217
+ writeFileSync(join(root, '.cleo', 'docs-config.json'), '{ not json', 'utf-8');
218
+ expect(() => DocKindRegistry.load(root)).toThrow(DocKindConfigError);
219
+ });
220
+
221
+ it('throws when the top-level value is an array', () => {
222
+ writeFileSync(join(root, '.cleo', 'docs-config.json'), '[]', 'utf-8');
223
+ expect(() => DocKindRegistry.load(root)).toThrow(/must be an object/);
224
+ });
225
+
226
+ it('throws when extensions is not an array', () => {
227
+ writeConfig(root, { extensions: { kind: 'x' } });
228
+ expect(() => DocKindRegistry.load(root)).toThrow(/'extensions' must be an array/);
229
+ });
230
+
231
+ it('throws when an extension entry is missing required fields', () => {
232
+ writeConfig(root, {
233
+ extensions: [{ kind: 'incident', label: 'I' /* missing description, etc. */ }],
234
+ });
235
+ expect(() => DocKindRegistry.load(root)).toThrow(DocKindConfigError);
236
+ });
237
+
238
+ it('throws when kind is not kebab-case', () => {
239
+ writeConfig(root, {
240
+ extensions: [
241
+ {
242
+ kind: 'BadKind',
243
+ label: 'X',
244
+ description: 'Y',
245
+ defaultOwnerKind: 'task',
246
+ publishDir: 'docs/x',
247
+ requiresEntityId: false,
248
+ },
249
+ ],
250
+ });
251
+ expect(() => DocKindRegistry.load(root)).toThrow(/lowercase kebab-case/);
252
+ });
253
+
254
+ it('throws when defaultOwnerKind is unsupported', () => {
255
+ writeConfig(root, {
256
+ extensions: [
257
+ {
258
+ kind: 'incident',
259
+ label: 'I',
260
+ description: 'd',
261
+ defaultOwnerKind: 'mystery',
262
+ publishDir: 'docs/incident',
263
+ requiresEntityId: false,
264
+ },
265
+ ],
266
+ });
267
+ expect(() => DocKindRegistry.load(root)).toThrow(/defaultOwnerKind/);
268
+ });
269
+
270
+ it('throws when requiresEntityId=true but entityIdPattern is missing', () => {
271
+ writeConfig(root, {
272
+ extensions: [
273
+ {
274
+ kind: 'incident',
275
+ label: 'I',
276
+ description: 'd',
277
+ defaultOwnerKind: 'task',
278
+ publishDir: 'docs/i',
279
+ requiresEntityId: true,
280
+ },
281
+ ],
282
+ });
283
+ expect(() => DocKindRegistry.load(root)).toThrow(/entityIdPattern.*required/);
284
+ });
285
+
286
+ it('throws when entityIdPattern is malformed regex', () => {
287
+ writeConfig(root, {
288
+ extensions: [
289
+ {
290
+ kind: 'incident',
291
+ label: 'I',
292
+ description: 'd',
293
+ defaultOwnerKind: 'task',
294
+ publishDir: 'docs/i',
295
+ requiresEntityId: true,
296
+ entityIdPattern: '[oops',
297
+ },
298
+ ],
299
+ });
300
+ expect(() => DocKindRegistry.load(root)).toThrow(/invalid regex/);
301
+ });
302
+
303
+ it('throws when entityIdPattern exceeds the safe length limit', () => {
304
+ const huge = 'a'.repeat(DocKindRegistry.SAFE_REGEX_LENGTH_LIMIT + 1);
305
+ writeConfig(root, {
306
+ extensions: [
307
+ {
308
+ kind: 'incident',
309
+ label: 'I',
310
+ description: 'd',
311
+ defaultOwnerKind: 'task',
312
+ publishDir: 'docs/i',
313
+ requiresEntityId: true,
314
+ entityIdPattern: huge,
315
+ },
316
+ ],
317
+ });
318
+ expect(() => DocKindRegistry.load(root)).toThrow(/exceeds/);
319
+ });
320
+ });
321
+
322
+ // ──────────────────────────────────────────────────────────────────────────
323
+ // DocKindRegistry.builtinOnly + fromConfig
324
+ // ──────────────────────────────────────────────────────────────────────────
325
+
326
+ describe('DocKindRegistry.builtinOnly / fromConfig', () => {
327
+ it('builtinOnly returns exactly the built-ins regardless of disk state', () => {
328
+ const registry = DocKindRegistry.builtinOnly();
329
+ expect(registry.list().length).toBe(BUILTIN_DOC_KINDS.length);
330
+ expect(registry.list().every((d) => d.isExtension !== true)).toBe(true);
331
+ });
332
+
333
+ it('fromConfig(undefined) is equivalent to builtinOnly', () => {
334
+ const a = DocKindRegistry.fromConfig(undefined);
335
+ const b = DocKindRegistry.builtinOnly();
336
+ expect(a.list().map((d) => d.kind)).toEqual(b.list().map((d) => d.kind));
337
+ });
338
+
339
+ it('fromConfig merges extensions like load() does', () => {
340
+ const registry = DocKindRegistry.fromConfig({
341
+ extensions: [
342
+ {
343
+ kind: 'incident',
344
+ label: 'Incident',
345
+ description: 'Post-mortem',
346
+ defaultOwnerKind: 'task',
347
+ publishDir: 'docs/incident',
348
+ requiresEntityId: false,
349
+ },
350
+ ],
351
+ });
352
+ expect(registry.has('incident')).toBe(true);
353
+ expect(registry.get('incident')?.isExtension).toBe(true);
354
+ });
355
+ });
356
+
357
+ // ──────────────────────────────────────────────────────────────────────────
358
+ // DocKindRegistry.validateSlug
359
+ // ──────────────────────────────────────────────────────────────────────────
360
+
361
+ describe('DocKindRegistry.validateSlug', () => {
362
+ const registry = DocKindRegistry.builtinOnly();
363
+
364
+ it('returns ok=true for kinds without requiresEntityId', () => {
365
+ expect(registry.validateSlug('spec', 'anything-here').ok).toBe(true);
366
+ expect(registry.validateSlug('note', 'free-form-1').ok).toBe(true);
367
+ expect(registry.validateSlug('plan', '').ok).toBe(true);
368
+ });
369
+
370
+ it('accepts conforming slugs for ADR (adr-NNN-<rest>)', () => {
371
+ expect(registry.validateSlug('adr', 'adr-001-intro').ok).toBe(true);
372
+ expect(registry.validateSlug('adr', 'adr-9999-complex-rationale').ok).toBe(true);
373
+ });
374
+
375
+ it('rejects non-conforming ADR slugs with an example hint', () => {
376
+ const result = registry.validateSlug('adr', 'random-name');
377
+ expect(result.ok).toBe(false);
378
+ if (!result.ok) {
379
+ expect(result.error).toContain('does not match');
380
+ expect(result.example).toBe('adr-001-intro');
381
+ }
382
+ });
383
+
384
+ it('accepts conforming changeset slugs (t####-<rest>)', () => {
385
+ expect(registry.validateSlug('changeset', 't9788-docs-taxonomy').ok).toBe(true);
386
+ expect(registry.validateSlug('changeset', 't1-x').ok).toBe(true);
387
+ });
388
+
389
+ it('rejects malformed changeset slugs', () => {
390
+ expect(registry.validateSlug('changeset', 'release-notes').ok).toBe(false);
391
+ expect(registry.validateSlug('changeset', 'T9788-UPPERCASE').ok).toBe(false);
392
+ });
393
+
394
+ it('accepts conforming release-note slugs (v####.MM.N)', () => {
395
+ expect(registry.validateSlug('release-note', 'v2026.5.93').ok).toBe(true);
396
+ expect(registry.validateSlug('release-note', 'v2026.12.99-rc1').ok).toBe(true);
397
+ });
398
+
399
+ it('rejects malformed release-note slugs', () => {
400
+ expect(registry.validateSlug('release-note', '2026.5.93').ok).toBe(false);
401
+ expect(registry.validateSlug('release-note', 'v1.2').ok).toBe(false);
402
+ });
403
+
404
+ it('accepts conforming rcasd slugs (t#### or t####-<rest>)', () => {
405
+ expect(registry.validateSlug('rcasd', 't9788').ok).toBe(true);
406
+ expect(registry.validateSlug('rcasd', 't9788-investigation').ok).toBe(true);
407
+ });
408
+
409
+ it('reports unknown kinds with a useful error', () => {
410
+ const result = registry.validateSlug('wishlist', 'anything');
411
+ expect(result.ok).toBe(false);
412
+ if (!result.ok) {
413
+ expect(result.error).toContain("unknown kind 'wishlist'");
414
+ }
415
+ });
416
+
417
+ it('reports the defensive branch when entityIdPattern is missing on a require-entity entry', () => {
418
+ // Construct a hand-crafted registry that bypasses the load-time guard.
419
+ const broken = new DocKindRegistry([
420
+ {
421
+ kind: 'broken',
422
+ label: 'Broken',
423
+ description: 'pattern intentionally omitted',
424
+ defaultOwnerKind: 'task',
425
+ publishDir: 'docs/broken',
426
+ requiresEntityId: true,
427
+ // entityIdPattern intentionally omitted to exercise the defensive
428
+ // branch in validateSlug.
429
+ },
430
+ ]);
431
+ const result = broken.validateSlug('broken', 'anything');
432
+ expect(result.ok).toBe(false);
433
+ });
434
+ });
435
+
436
+ // ──────────────────────────────────────────────────────────────────────────
437
+ // DocKindRegistry.get / has — extension precedence
438
+ // ──────────────────────────────────────────────────────────────────────────
439
+
440
+ describe('DocKindRegistry — built-ins beat extensions on collision', () => {
441
+ it('first-write-wins semantics keep built-ins authoritative', () => {
442
+ // The constructor uses first-write-wins; load() always passes builtins
443
+ // first. This test exercises the constructor directly so any future
444
+ // refactor can't regress the invariant silently.
445
+ const registry = new DocKindRegistry([
446
+ ...BUILTIN_DOC_KINDS,
447
+ {
448
+ kind: 'adr',
449
+ label: 'shadow attempt',
450
+ description: 'should never win',
451
+ defaultOwnerKind: 'project',
452
+ publishDir: 'docs/SHADOW',
453
+ requiresEntityId: false,
454
+ isExtension: true,
455
+ },
456
+ ]);
457
+ const meta = registry.get('adr');
458
+ expect(meta?.label).toBe('ADR');
459
+ expect(meta?.publishDir).toBe('docs/adr');
460
+ });
461
+ });
462
+
463
+ // keep one afterEach stub so vitest reports the suite cleanly even when
464
+ // tests share root state under the OS temp dir.
465
+ afterEach(() => {});