@cleocode/contracts 2026.5.120 → 2026.5.122

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 (63) hide show
  1. package/dist/__tests__/changesets.test.d.ts +8 -0
  2. package/dist/__tests__/changesets.test.d.ts.map +1 -0
  3. package/dist/__tests__/changesets.test.js +146 -0
  4. package/dist/__tests__/changesets.test.js.map +1 -0
  5. package/dist/__tests__/evidence-atom-schema.test.js +75 -1
  6. package/dist/__tests__/evidence-atom-schema.test.js.map +1 -1
  7. package/dist/__tests__/satisfies-atom.test.d.ts +24 -0
  8. package/dist/__tests__/satisfies-atom.test.d.ts.map +1 -0
  9. package/dist/__tests__/satisfies-atom.test.js +302 -0
  10. package/dist/__tests__/satisfies-atom.test.js.map +1 -0
  11. package/dist/__tests__/validator-types.test.d.ts +13 -0
  12. package/dist/__tests__/validator-types.test.d.ts.map +1 -0
  13. package/dist/__tests__/validator-types.test.js +293 -0
  14. package/dist/__tests__/validator-types.test.js.map +1 -0
  15. package/dist/changesets.d.ts +91 -0
  16. package/dist/changesets.d.ts.map +1 -1
  17. package/dist/changesets.js +72 -2
  18. package/dist/changesets.js.map +1 -1
  19. package/dist/config.d.ts +56 -0
  20. package/dist/config.d.ts.map +1 -1
  21. package/dist/data-accessor.d.ts +117 -0
  22. package/dist/data-accessor.d.ts.map +1 -1
  23. package/dist/data-accessor.js.map +1 -1
  24. package/dist/dispatch/operations-registry.d.ts.map +1 -1
  25. package/dist/dispatch/operations-registry.js +29 -0
  26. package/dist/dispatch/operations-registry.js.map +1 -1
  27. package/dist/evidence-atom-schema.d.ts +143 -8
  28. package/dist/evidence-atom-schema.d.ts.map +1 -1
  29. package/dist/evidence-atom-schema.js +258 -1
  30. package/dist/evidence-atom-schema.js.map +1 -1
  31. package/dist/exit-codes.d.ts +41 -1
  32. package/dist/exit-codes.d.ts.map +1 -1
  33. package/dist/exit-codes.js +40 -0
  34. package/dist/exit-codes.js.map +1 -1
  35. package/dist/index.d.ts +8 -6
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +4 -1
  38. package/dist/index.js.map +1 -1
  39. package/dist/operations/tasks.d.ts +75 -0
  40. package/dist/operations/tasks.d.ts.map +1 -1
  41. package/dist/operations/tasks.js.map +1 -1
  42. package/dist/task.d.ts +38 -0
  43. package/dist/task.d.ts.map +1 -1
  44. package/dist/validator/index.d.ts +375 -0
  45. package/dist/validator/index.d.ts.map +1 -0
  46. package/dist/validator/index.js +162 -0
  47. package/dist/validator/index.js.map +1 -0
  48. package/package.json +2 -2
  49. package/src/__tests__/__snapshots__/operations-registry.test.ts.snap +1 -1
  50. package/src/__tests__/changesets.test.ts +176 -0
  51. package/src/__tests__/evidence-atom-schema.test.ts +88 -0
  52. package/src/__tests__/satisfies-atom.test.ts +363 -0
  53. package/src/__tests__/validator-types.test.ts +359 -0
  54. package/src/changesets.ts +93 -2
  55. package/src/config.ts +58 -0
  56. package/src/data-accessor.ts +120 -0
  57. package/src/dispatch/operations-registry.ts +31 -0
  58. package/src/evidence-atom-schema.ts +319 -8
  59. package/src/exit-codes.ts +42 -0
  60. package/src/index.ts +39 -0
  61. package/src/operations/tasks.ts +76 -0
  62. package/src/task.ts +39 -0
  63. package/src/validator/index.ts +385 -0
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Tests for CLEO changeset contracts and parser compatibility.
3
+ *
4
+ * @task T10480
5
+ * @epic T9759
6
+ */
7
+
8
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
12
+ import { ChangesetEntrySchema } from '../changesets.js';
13
+
14
+ type ParseChangesetFile = (path: string) => unknown;
15
+
16
+ let tempDir: string;
17
+
18
+ beforeEach(() => {
19
+ tempDir = mkdtempSync(join(tmpdir(), 'cleo-changesets-contract-'));
20
+ });
21
+
22
+ afterEach(() => {
23
+ rmSync(tempDir, { recursive: true, force: true });
24
+ });
25
+
26
+ function writeChangeset(filename: string, content: string): string {
27
+ const path = join(tempDir, filename);
28
+ writeFileSync(path, content, 'utf8');
29
+ return path;
30
+ }
31
+
32
+ async function loadParseChangesetFile(): Promise<ParseChangesetFile> {
33
+ const parserUrl = new URL('../../../core/src/changesets/parser.ts', import.meta.url).href;
34
+ const parser = (await import(parserUrl)) as { parseChangesetFile: ParseChangesetFile };
35
+ return parser.parseChangesetFile;
36
+ }
37
+
38
+ describe('ChangesetEntrySchema release-note metadata', () => {
39
+ it('accepts legacy changesets without releaseNotes unchanged', () => {
40
+ const legacy = {
41
+ id: 'T10480',
42
+ tasks: ['T10480'],
43
+ kind: 'fix',
44
+ summary: 'Legacy changesets still parse.',
45
+ };
46
+
47
+ const parsed = ChangesetEntrySchema.parse(legacy);
48
+
49
+ expect(parsed).toEqual(legacy);
50
+ expect(parsed.releaseNotes).toBeUndefined();
51
+ });
52
+
53
+ it('accepts deterministic zero-token release-note metadata', () => {
54
+ const entry = ChangesetEntrySchema.parse({
55
+ id: 't10480-release-notes',
56
+ tasks: ['T10480', 'T9759'],
57
+ kind: 'feat',
58
+ summary: 'Add structured release-note metadata.',
59
+ releaseNotes: {
60
+ section: 'added',
61
+ audience: ['operators', 'developers'],
62
+ scope: 'package',
63
+ targets: ['@cleocode/contracts', '@cleocode/core'],
64
+ impact: 'Release tooling can render richer notes from author-provided facts.',
65
+ migration: 'No migration required for legacy changesets.',
66
+ operatorNotes: 'No LLM, API token, or network call is required.',
67
+ includeInChangelog: true,
68
+ },
69
+ });
70
+
71
+ expect(entry.releaseNotes).toEqual({
72
+ section: 'added',
73
+ audience: ['operators', 'developers'],
74
+ scope: 'package',
75
+ targets: ['@cleocode/contracts', '@cleocode/core'],
76
+ impact: 'Release tooling can render richer notes from author-provided facts.',
77
+ migration: 'No migration required for legacy changesets.',
78
+ operatorNotes: 'No LLM, API token, or network call is required.',
79
+ includeInChangelog: true,
80
+ });
81
+ });
82
+
83
+ it('rejects malformed release-note metadata loudly', () => {
84
+ const result = ChangesetEntrySchema.safeParse({
85
+ id: 't10480-bad-release-notes',
86
+ tasks: ['T10480'],
87
+ kind: 'feat',
88
+ summary: 'Bad metadata.',
89
+ releaseNotes: {
90
+ section: 'feature',
91
+ },
92
+ });
93
+
94
+ expect(result.success).toBe(false);
95
+ if (result.success) return;
96
+ expect(result.error.issues[0]?.path).toEqual(['releaseNotes', 'section']);
97
+ });
98
+ });
99
+
100
+ describe('parseChangesetFile release-note metadata compatibility', () => {
101
+ it('parses legacy markdown changesets unchanged', async () => {
102
+ const parseChangesetFile = await loadParseChangesetFile();
103
+ const file = writeChangeset(
104
+ 'T10480.md',
105
+ `---
106
+ id: T10480
107
+ tasks: [T10480]
108
+ kind: fix
109
+ summary: Legacy markdown changesets still parse unchanged.
110
+ ---
111
+
112
+ Body notes survive.
113
+ `,
114
+ );
115
+
116
+ expect(parseChangesetFile(file)).toEqual({
117
+ id: 'T10480',
118
+ tasks: ['T10480'],
119
+ kind: 'fix',
120
+ summary: 'Legacy markdown changesets still parse unchanged.',
121
+ notes: 'Body notes survive.',
122
+ });
123
+ });
124
+
125
+ it('parses new release-note metadata from markdown frontmatter', async () => {
126
+ const parseChangesetFile = await loadParseChangesetFile();
127
+ const file = writeChangeset(
128
+ 't10480-new-metadata.md',
129
+ `---
130
+ id: t10480-new-metadata
131
+ tasks: [T10480]
132
+ kind: feat
133
+ summary: Parse release-note metadata.
134
+ releaseNotes:
135
+ section: changed
136
+ audience: [operators]
137
+ scope: package
138
+ targets: ['@cleocode/core']
139
+ impact: Deterministic release notes get structured context.
140
+ includeInChangelog: true
141
+ ---
142
+ `,
143
+ );
144
+
145
+ expect(parseChangesetFile(file)).toMatchObject({
146
+ releaseNotes: {
147
+ section: 'changed',
148
+ audience: ['operators'],
149
+ scope: 'package',
150
+ targets: ['@cleocode/core'],
151
+ impact: 'Deterministic release notes get structured context.',
152
+ includeInChangelog: true,
153
+ },
154
+ });
155
+ });
156
+
157
+ it('fails malformed release-note metadata with file and line context', async () => {
158
+ const parseChangesetFile = await loadParseChangesetFile();
159
+ const file = writeChangeset(
160
+ 't10480-bad-metadata.md',
161
+ `---
162
+ id: t10480-bad-metadata
163
+ tasks: [T10480]
164
+ kind: feat
165
+ summary: Bad release-note metadata.
166
+ releaseNotes:
167
+ section: feature
168
+ ---
169
+ `,
170
+ );
171
+
172
+ expect(() => parseChangesetFile(file)).toThrow(
173
+ new RegExp(`${file.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}:7[\\s\\S]*releaseNotes\\.section`),
174
+ );
175
+ });
176
+ });
@@ -19,6 +19,7 @@ import {
19
19
  EvidenceAtomSchema,
20
20
  EvidenceParseError,
21
21
  formatGateRequirement,
22
+ formatGateRequirementHint,
22
23
  GATE_EVIDENCE_REQUIREMENTS,
23
24
  parseEvidenceString,
24
25
  validateEvidenceForGate,
@@ -497,3 +498,90 @@ describe('formatGateRequirement (T10337)', () => {
497
498
  expect(formatGateRequirement('cleanupDone')).toBe('[note]');
498
499
  });
499
500
  });
501
+
502
+ // ─── formatGateRequirementHint — rich CLI `fix:` surface (T9949) ────────────
503
+
504
+ describe('formatGateRequirementHint (T9949)', () => {
505
+ it('renders implemented as multi-line list of copy-pasteable verify commands', () => {
506
+ const h = formatGateRequirementHint('implemented');
507
+ // Header line names the gate and uses "ONE of" framing.
508
+ expect(h).toMatch(/^Gate 'implemented' requires programmatic evidence\. Use ONE of:/);
509
+ // Each combination renders as a complete `cleo verify ... --evidence '<atoms>'` line.
510
+ expect(h).toContain("--evidence 'commit:<sha>;files:path/a.ts,path/b.ts'");
511
+ expect(h).toContain("--evidence 'commit:<sha>;note:<short description>'");
512
+ expect(h).toContain("--evidence 'decision:D-arch-001;files:path/a.ts,path/b.ts'");
513
+ expect(h).toContain("--evidence 'decision:D-arch-001;note:<short description>'");
514
+ expect(h).toContain("--evidence 'pr:357'");
515
+ // Routes a `note:`-only caller at the partner atom they need.
516
+ expect(h).toContain("'note:' alone is NOT accepted for this gate");
517
+ expect(h).toContain("'commit:'");
518
+ expect(h).toContain("'decision:'");
519
+ });
520
+
521
+ it('routes note-only callers for testsPassed at the right alternatives', () => {
522
+ // testsPassed accepts test-run, tool, or pr — none of which involve note.
523
+ const h = formatGateRequirementHint('testsPassed');
524
+ expect(h).toContain("--evidence 'test-run:/tmp/vitest-out.json'");
525
+ expect(h).toContain("--evidence 'tool:test'");
526
+ expect(h).toContain("--evidence 'pr:357'");
527
+ // When no combination includes note at all, the helper falls through to a
528
+ // generic "not accepted, see ADR-051" clarifier rather than fabricating a
529
+ // partner suggestion.
530
+ expect(h).toContain("'note:' is NOT accepted for this gate");
531
+ expect(h).toContain('ADR-051');
532
+ });
533
+
534
+ it('confirms note-only acceptance for cleanupDone', () => {
535
+ const h = formatGateRequirementHint('cleanupDone');
536
+ expect(h).toContain("--evidence 'note:<short description>'");
537
+ expect(h).toContain("'note:' alone IS accepted for this gate");
538
+ });
539
+
540
+ it('confirms note-only acceptance for securityPassed and nexusImpact', () => {
541
+ for (const gate of ['securityPassed', 'nexusImpact'] as const) {
542
+ const h = formatGateRequirementHint(gate);
543
+ expect(h, `${gate}: should confirm note alone is accepted`).toContain(
544
+ "'note:' alone IS accepted for this gate",
545
+ );
546
+ }
547
+ });
548
+
549
+ it('renders documented with files and url, and points away from note', () => {
550
+ const h = formatGateRequirementHint('documented');
551
+ expect(h).toContain("--evidence 'files:path/a.ts,path/b.ts'");
552
+ expect(h).toContain("--evidence 'url:https://example.com/docs'");
553
+ // documented has no note pairings — generic clarifier path.
554
+ expect(h).toContain("'note:' is NOT accepted for this gate");
555
+ });
556
+ });
557
+
558
+ // ─── validateEvidenceForGate — failure carries `hint` (T9949) ───────────────
559
+
560
+ describe('validateEvidenceForGate failure surface (T9949)', () => {
561
+ it('populates a `hint` field alongside the legacy `message`', () => {
562
+ const r = validateEvidenceForGate('implemented', [
563
+ { kind: 'note', note: 'just a note' },
564
+ ] as EvidenceAtomInput[]);
565
+ expect(r.ok).toBe(false);
566
+ if (!r.ok) {
567
+ // Legacy single-line message preserved byte-for-byte.
568
+ expect(r.message).toBe(
569
+ "Gate 'implemented' requires evidence: [commit AND files] OR [commit AND note] OR [decision AND files] OR [decision AND note] OR [pr]",
570
+ );
571
+ // New rich hint includes the same content as formatGateRequirementHint.
572
+ expect(r.hint).toBe(formatGateRequirementHint('implemented'));
573
+ }
574
+ });
575
+
576
+ it('notes-only against testsPassed surfaces the right alternatives', () => {
577
+ const r = validateEvidenceForGate('testsPassed', [
578
+ { kind: 'note', note: 'I ran the tests by hand' },
579
+ ] as EvidenceAtomInput[]);
580
+ expect(r.ok).toBe(false);
581
+ if (!r.ok) {
582
+ expect(r.hint).toContain("--evidence 'tool:test'");
583
+ expect(r.hint).toContain("--evidence 'pr:357'");
584
+ expect(r.hint).toContain("'note:' is NOT accepted for this gate");
585
+ }
586
+ });
587
+ });
@@ -0,0 +1,363 @@
1
+ /**
2
+ * Tests for the ADR-079-r2 `satisfies:<task-id>#<ac-id>` evidence atom
3
+ * grammar (T10506).
4
+ *
5
+ * Coverage:
6
+ * - Schema-level: per-field regex validation (task-id, ac-uuid, ac-alias,
7
+ * version-pin) and discriminated-union acceptance.
8
+ * - Parser-level: full {@link parseEvidenceString} round-trips for both
9
+ * UUID and alias forms, mixed evidence strings, and the optional
10
+ * `@<version-pin>` suffix.
11
+ * - Malformed inputs: missing colon, missing hash, missing T-prefix,
12
+ * mixed-case UUID, non-UUID non-alias ac-id, malformed version-pin.
13
+ *
14
+ * This file covers PARSING ONLY per T10506 acceptance criteria. The 5-check
15
+ * validator semantics (target exists, target not terminal, AC exists, same-
16
+ * saga scope rule, alias drift detection) ship in T10507 and are tested
17
+ * separately in `packages/core/src/tasks/__tests__/satisfies-validator.test.ts`.
18
+ *
19
+ * @task T10506
20
+ * @saga T10377
21
+ * @adr ADR-079-r2
22
+ */
23
+
24
+ import { describe, expect, it } from 'vitest';
25
+
26
+ import {
27
+ AC_ALIAS_REGEX,
28
+ AC_UUID_REGEX,
29
+ EvidenceAtomSchema,
30
+ EvidenceParseError,
31
+ parseEvidenceString,
32
+ SATISFIES_TASK_ID_REGEX,
33
+ SATISFIES_VERSION_PIN_REGEX,
34
+ satisfiesAtomSchema,
35
+ } from '../index.js';
36
+
37
+ // ─── Per-field regex parity (ADR-079-r2 §2.1 ABNF) ────────────────────────
38
+
39
+ describe('satisfies atom regexes (ADR-079-r2 §2.1)', () => {
40
+ describe('SATISFIES_TASK_ID_REGEX', () => {
41
+ it('accepts T<1-7 digits>', () => {
42
+ expect(SATISFIES_TASK_ID_REGEX.test('T1')).toBe(true);
43
+ expect(SATISFIES_TASK_ID_REGEX.test('T1234')).toBe(true);
44
+ expect(SATISFIES_TASK_ID_REGEX.test('T1234567')).toBe(true);
45
+ });
46
+ it('rejects T<8+ digits>', () => {
47
+ expect(SATISFIES_TASK_ID_REGEX.test('T12345678')).toBe(false);
48
+ });
49
+ it('rejects missing T prefix', () => {
50
+ expect(SATISFIES_TASK_ID_REGEX.test('1234')).toBe(false);
51
+ });
52
+ it('rejects lowercase t', () => {
53
+ expect(SATISFIES_TASK_ID_REGEX.test('t1234')).toBe(false);
54
+ });
55
+ it('rejects non-digit suffix', () => {
56
+ expect(SATISFIES_TASK_ID_REGEX.test('T12a')).toBe(false);
57
+ });
58
+ });
59
+
60
+ describe('AC_UUID_REGEX', () => {
61
+ it('accepts strict lowercase UUIDv4', () => {
62
+ expect(AC_UUID_REGEX.test('a1b2c3d4-5e6f-4890-abcd-ef1234567890')).toBe(true);
63
+ expect(AC_UUID_REGEX.test('00000000-0000-4000-8000-000000000000')).toBe(true);
64
+ });
65
+ it('rejects mixed-case', () => {
66
+ // Council-mandated strictness (ADR-079-r2 §2.2): "Validators MUST
67
+ // reject mixed-case to prevent silent dedupe failures."
68
+ expect(AC_UUID_REGEX.test('A1B2C3D4-5E6F-4890-ABCD-EF1234567890')).toBe(false);
69
+ });
70
+ it('rejects wrong version nibble (not 4)', () => {
71
+ expect(AC_UUID_REGEX.test('a1b2c3d4-5e6f-5890-abcd-ef1234567890')).toBe(false);
72
+ });
73
+ it('rejects wrong variant nibble (not 8/9/a/b)', () => {
74
+ expect(AC_UUID_REGEX.test('a1b2c3d4-5e6f-4890-cbcd-ef1234567890')).toBe(false);
75
+ });
76
+ it('rejects non-UUID strings', () => {
77
+ expect(AC_UUID_REGEX.test('not-a-uuid')).toBe(false);
78
+ expect(AC_UUID_REGEX.test('a1b2c3d45e6f4890abcdef1234567890')).toBe(false);
79
+ });
80
+ });
81
+
82
+ describe('AC_ALIAS_REGEX', () => {
83
+ it('accepts AC<1-4 digits>', () => {
84
+ expect(AC_ALIAS_REGEX.test('AC1')).toBe(true);
85
+ expect(AC_ALIAS_REGEX.test('AC9999')).toBe(true);
86
+ });
87
+ it('rejects AC<5+ digits>', () => {
88
+ expect(AC_ALIAS_REGEX.test('AC10000')).toBe(false);
89
+ });
90
+ it('rejects lowercase ac', () => {
91
+ expect(AC_ALIAS_REGEX.test('ac1')).toBe(false);
92
+ });
93
+ it('rejects bare integer', () => {
94
+ expect(AC_ALIAS_REGEX.test('1')).toBe(false);
95
+ });
96
+ });
97
+
98
+ describe('SATISFIES_VERSION_PIN_REGEX', () => {
99
+ it('accepts exactly 14 digits', () => {
100
+ expect(SATISFIES_VERSION_PIN_REGEX.test('20260524223045')).toBe(true);
101
+ });
102
+ it('rejects fewer than 14 digits', () => {
103
+ expect(SATISFIES_VERSION_PIN_REGEX.test('2026052422304')).toBe(false);
104
+ });
105
+ it('rejects more than 14 digits', () => {
106
+ expect(SATISFIES_VERSION_PIN_REGEX.test('202605242230451')).toBe(false);
107
+ });
108
+ it('rejects non-digit characters', () => {
109
+ expect(SATISFIES_VERSION_PIN_REGEX.test('2026-05-24T22:30')).toBe(false);
110
+ });
111
+ });
112
+ });
113
+
114
+ // ─── Zod schema (post-parse client-side validation) ────────────────────────
115
+
116
+ describe('satisfiesAtomSchema (ADR-079-r2 §5.2)', () => {
117
+ it('accepts UUID form without version-pin', () => {
118
+ const a = satisfiesAtomSchema.parse({
119
+ kind: 'satisfies',
120
+ targetTaskId: 'T10495',
121
+ targetAcId: '8f4a2c1e-b09d-4f6a-9c3e-7a1d4f8c0b2e',
122
+ });
123
+ expect(a.targetTaskId).toBe('T10495');
124
+ expect(a.targetAcId).toBe('8f4a2c1e-b09d-4f6a-9c3e-7a1d4f8c0b2e');
125
+ expect(a.targetAcAlias).toBeUndefined();
126
+ expect(a.versionPin).toBeUndefined();
127
+ });
128
+
129
+ it('accepts alias form without version-pin', () => {
130
+ const a = satisfiesAtomSchema.parse({
131
+ kind: 'satisfies',
132
+ targetTaskId: 'T10495',
133
+ targetAcAlias: 'AC2',
134
+ });
135
+ expect(a.targetAcAlias).toBe('AC2');
136
+ expect(a.targetAcId).toBeUndefined();
137
+ });
138
+
139
+ it('accepts alias form with version-pin', () => {
140
+ const a = satisfiesAtomSchema.parse({
141
+ kind: 'satisfies',
142
+ targetTaskId: 'T10495',
143
+ targetAcAlias: 'AC2',
144
+ versionPin: '20260524223045',
145
+ });
146
+ expect(a.versionPin).toBe('20260524223045');
147
+ });
148
+
149
+ it('accepts UUID form with version-pin (highest-trust)', () => {
150
+ const a = satisfiesAtomSchema.parse({
151
+ kind: 'satisfies',
152
+ targetTaskId: 'T10495',
153
+ targetAcId: '8f4a2c1e-b09d-4f6a-9c3e-7a1d4f8c0b2e',
154
+ versionPin: '20260524223045',
155
+ });
156
+ expect(a.targetAcId).toBeDefined();
157
+ expect(a.versionPin).toBe('20260524223045');
158
+ });
159
+
160
+ it('rejects malformed targetTaskId via schema', () => {
161
+ const r = satisfiesAtomSchema.safeParse({
162
+ kind: 'satisfies',
163
+ targetTaskId: 'task-1234',
164
+ targetAcAlias: 'AC2',
165
+ });
166
+ expect(r.success).toBe(false);
167
+ });
168
+
169
+ it('rejects mixed-case UUID via schema', () => {
170
+ const r = satisfiesAtomSchema.safeParse({
171
+ kind: 'satisfies',
172
+ targetTaskId: 'T10495',
173
+ targetAcId: '8F4A2C1E-B09D-4F6A-9C3E-7A1D4F8C0B2E',
174
+ });
175
+ expect(r.success).toBe(false);
176
+ });
177
+
178
+ it('rejects malformed version-pin via schema', () => {
179
+ const r = satisfiesAtomSchema.safeParse({
180
+ kind: 'satisfies',
181
+ targetTaskId: 'T10495',
182
+ targetAcAlias: 'AC2',
183
+ versionPin: '2026-05-24T22:30',
184
+ });
185
+ expect(r.success).toBe(false);
186
+ });
187
+
188
+ it('round-trips through the EvidenceAtomSchema discriminated union', () => {
189
+ const a = EvidenceAtomSchema.parse({
190
+ kind: 'satisfies',
191
+ targetTaskId: 'T10495',
192
+ targetAcAlias: 'AC2',
193
+ });
194
+ expect(a.kind).toBe('satisfies');
195
+ });
196
+ });
197
+
198
+ // ─── parseEvidenceString (CLI parser) ──────────────────────────────────────
199
+
200
+ describe('parseEvidenceString — satisfies atom (T10506)', () => {
201
+ // ─── Valid forms ─────────────────────────────────────────────────────────
202
+
203
+ it('parses UUID form', () => {
204
+ const atoms = parseEvidenceString('satisfies:T10495#8f4a2c1e-b09d-4f6a-9c3e-7a1d4f8c0b2e');
205
+ expect(atoms).toHaveLength(1);
206
+ expect(atoms[0]).toEqual({
207
+ kind: 'satisfies',
208
+ targetTaskId: 'T10495',
209
+ targetAcId: '8f4a2c1e-b09d-4f6a-9c3e-7a1d4f8c0b2e',
210
+ });
211
+ });
212
+
213
+ it('parses alias form', () => {
214
+ const atoms = parseEvidenceString('satisfies:T10495#AC2');
215
+ expect(atoms).toHaveLength(1);
216
+ expect(atoms[0]).toEqual({
217
+ kind: 'satisfies',
218
+ targetTaskId: 'T10495',
219
+ targetAcAlias: 'AC2',
220
+ });
221
+ });
222
+
223
+ it('parses alias form with version-pin', () => {
224
+ const atoms = parseEvidenceString('satisfies:T10495#AC2@20260524223045');
225
+ expect(atoms).toHaveLength(1);
226
+ expect(atoms[0]).toEqual({
227
+ kind: 'satisfies',
228
+ targetTaskId: 'T10495',
229
+ targetAcAlias: 'AC2',
230
+ versionPin: '20260524223045',
231
+ });
232
+ });
233
+
234
+ it('parses UUID form with version-pin (canonical highest-trust)', () => {
235
+ const atoms = parseEvidenceString(
236
+ 'satisfies:T10495#8f4a2c1e-b09d-4f6a-9c3e-7a1d4f8c0b2e@20260524223045',
237
+ );
238
+ expect(atoms).toHaveLength(1);
239
+ expect(atoms[0]).toEqual({
240
+ kind: 'satisfies',
241
+ targetTaskId: 'T10495',
242
+ targetAcId: '8f4a2c1e-b09d-4f6a-9c3e-7a1d4f8c0b2e',
243
+ versionPin: '20260524223045',
244
+ });
245
+ });
246
+
247
+ it('parses multiple satisfies atoms in one evidence string', () => {
248
+ const atoms = parseEvidenceString('satisfies:T10495#AC2;satisfies:T10493#AC1');
249
+ expect(atoms).toHaveLength(2);
250
+ expect(atoms[0]).toMatchObject({
251
+ kind: 'satisfies',
252
+ targetTaskId: 'T10495',
253
+ targetAcAlias: 'AC2',
254
+ });
255
+ expect(atoms[1]).toMatchObject({
256
+ kind: 'satisfies',
257
+ targetTaskId: 'T10493',
258
+ targetAcAlias: 'AC1',
259
+ });
260
+ });
261
+
262
+ it('parses mixed evidence: commit + files + satisfies', () => {
263
+ const atoms = parseEvidenceString(
264
+ 'commit:abc1234;files:src/foo.ts,src/bar.ts;satisfies:T10495#AC2',
265
+ );
266
+ expect(atoms).toHaveLength(3);
267
+ expect(atoms[0]).toEqual({ kind: 'commit', sha: 'abc1234' });
268
+ expect(atoms[1]).toEqual({ kind: 'files', paths: ['src/foo.ts', 'src/bar.ts'] });
269
+ expect(atoms[2]).toEqual({
270
+ kind: 'satisfies',
271
+ targetTaskId: 'T10495',
272
+ targetAcAlias: 'AC2',
273
+ });
274
+ });
275
+
276
+ it('parses mixed evidence: satisfies + pr (single PR landing multiple ACs)', () => {
277
+ const atoms = parseEvidenceString('satisfies:T10506#AC1;satisfies:T10506#AC2;pr:773');
278
+ expect(atoms).toHaveLength(3);
279
+ expect(atoms[2]).toEqual({ kind: 'pr', prNumber: 773 });
280
+ });
281
+
282
+ // ─── Malformed: missing colon ────────────────────────────────────────────
283
+
284
+ it('rejects atom missing the kind-payload colon', () => {
285
+ // "satisfiesT10495#AC2" — no `:` separator at all
286
+ expect(() => parseEvidenceString('satisfiesT10495#AC2')).toThrow(EvidenceParseError);
287
+ });
288
+
289
+ // ─── Malformed: missing hash ─────────────────────────────────────────────
290
+
291
+ it('rejects atom missing the # separator between task-id and ac-id', () => {
292
+ expect(() => parseEvidenceString('satisfies:T10495-AC2')).toThrow(EvidenceParseError);
293
+ expect(() => parseEvidenceString('satisfies:T10495AC2')).toThrow(EvidenceParseError);
294
+ });
295
+
296
+ it('rejects atom with # at start of payload (empty task-id)', () => {
297
+ expect(() => parseEvidenceString('satisfies:#AC2')).toThrow(EvidenceParseError);
298
+ });
299
+
300
+ it('rejects atom with # at end of payload (empty ac-id)', () => {
301
+ expect(() => parseEvidenceString('satisfies:T10495#')).toThrow(EvidenceParseError);
302
+ });
303
+
304
+ // ─── Malformed: missing T-prefix on task-id ──────────────────────────────
305
+
306
+ it('rejects atom with non-T-prefixed task-id', () => {
307
+ expect(() => parseEvidenceString('satisfies:10495#AC2')).toThrow(EvidenceParseError);
308
+ expect(() => parseEvidenceString('satisfies:task-10495#AC2')).toThrow(EvidenceParseError);
309
+ });
310
+
311
+ it('rejects atom with lowercase t prefix', () => {
312
+ expect(() => parseEvidenceString('satisfies:t10495#AC2')).toThrow(EvidenceParseError);
313
+ });
314
+
315
+ it('rejects atom with task-id exceeding 7 digits', () => {
316
+ expect(() => parseEvidenceString('satisfies:T12345678#AC2')).toThrow(EvidenceParseError);
317
+ });
318
+
319
+ // ─── Malformed: ac-id ────────────────────────────────────────────────────
320
+
321
+ it('rejects atom with mixed-case UUID', () => {
322
+ // Council §2.2 strictness — silent dedupe prevention
323
+ expect(() =>
324
+ parseEvidenceString('satisfies:T10495#8F4A2C1E-B09D-4F6A-9C3E-7A1D4F8C0B2E'),
325
+ ).toThrow(EvidenceParseError);
326
+ });
327
+
328
+ it('rejects atom with ac-id that is neither UUID nor AC<digits> alias', () => {
329
+ expect(() => parseEvidenceString('satisfies:T10495#hello')).toThrow(EvidenceParseError);
330
+ expect(() => parseEvidenceString('satisfies:T10495#2')).toThrow(EvidenceParseError);
331
+ });
332
+
333
+ it('rejects atom with alias exceeding 4 digits', () => {
334
+ expect(() => parseEvidenceString('satisfies:T10495#AC10000')).toThrow(EvidenceParseError);
335
+ });
336
+
337
+ // ─── Malformed: version-pin ──────────────────────────────────────────────
338
+
339
+ it('rejects atom with malformed version-pin (not 14 digits)', () => {
340
+ expect(() => parseEvidenceString('satisfies:T10495#AC2@2026-05-24')).toThrow(
341
+ EvidenceParseError,
342
+ );
343
+ expect(() => parseEvidenceString('satisfies:T10495#AC2@2026052422304')).toThrow(
344
+ EvidenceParseError,
345
+ );
346
+ });
347
+
348
+ it('rejects atom with @ but empty version-pin payload', () => {
349
+ expect(() => parseEvidenceString('satisfies:T10495#AC2@')).toThrow(EvidenceParseError);
350
+ });
351
+
352
+ // ─── Error message smoke: ensures kind name appears in default-case ──────
353
+
354
+ it('includes "satisfies" in the unknown-kind error suggestion list', () => {
355
+ try {
356
+ parseEvidenceString('unknownkind:foo');
357
+ throw new Error('expected EvidenceParseError');
358
+ } catch (err) {
359
+ expect(err).toBeInstanceOf(EvidenceParseError);
360
+ expect((err as EvidenceParseError).fix).toContain('satisfies');
361
+ }
362
+ });
363
+ });