@cleocode/contracts 2026.5.120 → 2026.5.121
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/dist/__tests__/changesets.test.d.ts +8 -0
- package/dist/__tests__/changesets.test.d.ts.map +1 -0
- package/dist/__tests__/changesets.test.js +146 -0
- package/dist/__tests__/changesets.test.js.map +1 -0
- package/dist/__tests__/evidence-atom-schema.test.js +75 -1
- package/dist/__tests__/evidence-atom-schema.test.js.map +1 -1
- package/dist/changesets.d.ts +91 -0
- package/dist/changesets.d.ts.map +1 -1
- package/dist/changesets.js +72 -2
- package/dist/changesets.js.map +1 -1
- package/dist/dispatch/operations-registry.d.ts.map +1 -1
- package/dist/dispatch/operations-registry.js +29 -0
- package/dist/dispatch/operations-registry.js.map +1 -1
- package/dist/evidence-atom-schema.d.ts +52 -8
- package/dist/evidence-atom-schema.d.ts.map +1 -1
- package/dist/evidence-atom-schema.js +96 -0
- package/dist/evidence-atom-schema.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/operations/tasks.d.ts +26 -0
- package/dist/operations/tasks.d.ts.map +1 -1
- package/dist/operations/tasks.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/operations-registry.test.ts.snap +1 -1
- package/src/__tests__/changesets.test.ts +176 -0
- package/src/__tests__/evidence-atom-schema.test.ts +88 -0
- package/src/changesets.ts +93 -2
- package/src/dispatch/operations-registry.ts +31 -0
- package/src/evidence-atom-schema.ts +131 -7
- package/src/index.ts +2 -0
- package/src/operations/tasks.ts +26 -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
|
+
});
|
package/src/changesets.ts
CHANGED
|
@@ -67,6 +67,91 @@ export const CHANGESET_KINDS = [
|
|
|
67
67
|
/** Allowed values of {@link ChangesetEntry.kind}. */
|
|
68
68
|
export type ChangesetKind = (typeof CHANGESET_KINDS)[number];
|
|
69
69
|
|
|
70
|
+
// ─── Release-note metadata ───────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Deterministic release-note section override used by zero-token renderers.
|
|
74
|
+
*
|
|
75
|
+
* These values intentionally mirror Keep-a-Changelog style headings so release
|
|
76
|
+
* tooling can render higher-quality notes without an LLM/API/token dependency.
|
|
77
|
+
*/
|
|
78
|
+
export const CHANGESET_RELEASE_NOTE_SECTIONS = [
|
|
79
|
+
'added',
|
|
80
|
+
'changed',
|
|
81
|
+
'fixed',
|
|
82
|
+
'deprecated',
|
|
83
|
+
'removed',
|
|
84
|
+
'security',
|
|
85
|
+
'breaking',
|
|
86
|
+
] as const;
|
|
87
|
+
|
|
88
|
+
/** Allowed values of {@link ChangesetReleaseNotesMetadata.section}. */
|
|
89
|
+
export type ChangesetReleaseNoteSection = (typeof CHANGESET_RELEASE_NOTE_SECTIONS)[number];
|
|
90
|
+
|
|
91
|
+
/** Audience labels for deterministic release-note routing. */
|
|
92
|
+
export const CHANGESET_RELEASE_NOTE_AUDIENCES = [
|
|
93
|
+
'users',
|
|
94
|
+
'operators',
|
|
95
|
+
'developers',
|
|
96
|
+
'maintainers',
|
|
97
|
+
] as const;
|
|
98
|
+
|
|
99
|
+
/** Allowed values of {@link ChangesetReleaseNotesMetadata.audience}. */
|
|
100
|
+
export type ChangesetReleaseNoteAudience = (typeof CHANGESET_RELEASE_NOTE_AUDIENCES)[number];
|
|
101
|
+
|
|
102
|
+
/** Scope labels for deterministic release-note routing. */
|
|
103
|
+
export const CHANGESET_RELEASE_NOTE_SCOPES = [
|
|
104
|
+
'project',
|
|
105
|
+
'package',
|
|
106
|
+
'component',
|
|
107
|
+
'docs',
|
|
108
|
+
'ops',
|
|
109
|
+
'security',
|
|
110
|
+
] as const;
|
|
111
|
+
|
|
112
|
+
/** Allowed values of {@link ChangesetReleaseNotesMetadata.scope}. */
|
|
113
|
+
export type ChangesetReleaseNoteScope = (typeof CHANGESET_RELEASE_NOTE_SCOPES)[number];
|
|
114
|
+
|
|
115
|
+
const nonEmptyReleaseNoteText = z.string().min(1, 'release-note metadata text must be non-empty');
|
|
116
|
+
const nonEmptyReleaseNoteList = z
|
|
117
|
+
.array(nonEmptyReleaseNoteText)
|
|
118
|
+
.min(1, 'release-note metadata list must contain at least one item');
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Optional deterministic metadata for release-note rendering.
|
|
122
|
+
*
|
|
123
|
+
* This is deliberately small, explicit, and strict: it gives release tooling
|
|
124
|
+
* structured author-provided facts (section, audience, target, impact, etc.)
|
|
125
|
+
* while preserving zero-token defaults and failing loudly on misspelled fields.
|
|
126
|
+
*/
|
|
127
|
+
export const ChangesetReleaseNotesMetadataSchema = z
|
|
128
|
+
.object({
|
|
129
|
+
/** Keep-a-Changelog style section/category override. */
|
|
130
|
+
section: z.enum(CHANGESET_RELEASE_NOTE_SECTIONS).optional(),
|
|
131
|
+
/** Intended readers for the note. */
|
|
132
|
+
audience: z.array(z.enum(CHANGESET_RELEASE_NOTE_AUDIENCES)).min(1).optional(),
|
|
133
|
+
/** Release-note scope. */
|
|
134
|
+
scope: z.enum(CHANGESET_RELEASE_NOTE_SCOPES).optional(),
|
|
135
|
+
/** Package/project/component targets affected by this entry. */
|
|
136
|
+
targets: nonEmptyReleaseNoteList.optional(),
|
|
137
|
+
/** User- or operator-visible impact statement. */
|
|
138
|
+
impact: nonEmptyReleaseNoteText.optional(),
|
|
139
|
+
/** Deterministic migration/action text, separate from breaking changes. */
|
|
140
|
+
migration: nonEmptyReleaseNoteText.optional(),
|
|
141
|
+
/** Deprecation detail for Deprecated sections. */
|
|
142
|
+
deprecation: nonEmptyReleaseNoteText.optional(),
|
|
143
|
+
/** Security detail for Security sections. */
|
|
144
|
+
security: nonEmptyReleaseNoteText.optional(),
|
|
145
|
+
/** Operator-facing rollout/verification note. */
|
|
146
|
+
operatorNotes: nonEmptyReleaseNoteText.optional(),
|
|
147
|
+
/** Explicit inclusion toggle for future release-scope filters. */
|
|
148
|
+
includeInChangelog: z.boolean().optional(),
|
|
149
|
+
})
|
|
150
|
+
.strict();
|
|
151
|
+
|
|
152
|
+
/** Inferred TypeScript type for deterministic release-note metadata. */
|
|
153
|
+
export type ChangesetReleaseNotesMetadata = z.infer<typeof ChangesetReleaseNotesMetadataSchema>;
|
|
154
|
+
|
|
70
155
|
// ─── Task ID validator ────────────────────────────────────────────────────────
|
|
71
156
|
|
|
72
157
|
/**
|
|
@@ -93,14 +178,18 @@ const TASK_ID_RE = /^(T\d+(-[A-Z][A-Za-z0-9]*)?|E-\d+(-[A-Z][A-Za-z0-9]*)?)$/;
|
|
|
93
178
|
* - `prs` — linked PR numbers
|
|
94
179
|
* - `notes` — longer-form markdown body
|
|
95
180
|
* - `breaking` — required migration note when `kind === 'breaking'`
|
|
181
|
+
* - `releaseNotes` — deterministic zero-token release-note metadata
|
|
96
182
|
*/
|
|
97
183
|
export const ChangesetEntrySchema = z
|
|
98
184
|
.object({
|
|
99
|
-
/** Filename
|
|
185
|
+
/** Filename identifier (without the `.md` extension); lowercase kebab-case for new files, legacy uppercase task IDs accepted. */
|
|
100
186
|
id: z
|
|
101
187
|
.string()
|
|
102
188
|
.min(1, 'id must be non-empty')
|
|
103
|
-
.regex(
|
|
189
|
+
.regex(
|
|
190
|
+
/^[A-Za-z0-9][A-Za-z0-9-]*$/,
|
|
191
|
+
'id must contain only ASCII letters, digits, and hyphens',
|
|
192
|
+
),
|
|
104
193
|
/** One or more CLEO task IDs this change is anchored to. */
|
|
105
194
|
tasks: z
|
|
106
195
|
.array(z.string().regex(TASK_ID_RE, 'task ID must match T#### or E-#### format'))
|
|
@@ -115,6 +204,8 @@ export const ChangesetEntrySchema = z
|
|
|
115
204
|
notes: z.string().optional(),
|
|
116
205
|
/** Migration note. Required iff `kind === 'breaking'`. */
|
|
117
206
|
breaking: z.string().optional(),
|
|
207
|
+
/** Structured author-provided metadata for deterministic release notes. */
|
|
208
|
+
releaseNotes: ChangesetReleaseNotesMetadataSchema.optional(),
|
|
118
209
|
})
|
|
119
210
|
.refine((entry) => entry.kind !== 'breaking' || (entry.breaking?.length ?? 0) > 0, {
|
|
120
211
|
message: 'breaking entries must include a non-empty `breaking` migration note',
|
|
@@ -7629,6 +7629,37 @@ export const OPERATIONS: OperationDef[] = [
|
|
|
7629
7629
|
] satisfies ParamDef[],
|
|
7630
7630
|
},
|
|
7631
7631
|
|
|
7632
|
+
// release query: validate-changelog (T9937 / Saga T9862 — canonical CHANGELOG
|
|
7633
|
+
// header validator that replaces the brittle inline grep step in
|
|
7634
|
+
// .github/workflows/release.yml). Read-only — exposed only under the query
|
|
7635
|
+
// gateway because no DB or filesystem mutation occurs.
|
|
7636
|
+
{
|
|
7637
|
+
gateway: 'query',
|
|
7638
|
+
domain: 'release',
|
|
7639
|
+
operation: 'validate-changelog',
|
|
7640
|
+
description:
|
|
7641
|
+
'release.validate-changelog (query) — validate that CHANGELOG.md contains the canonical `## [VERSION]` header per ADR-028 §2.5. Replaces the brittle inline grep in .github/workflows/release.yml (T9937 / Saga T9862).',
|
|
7642
|
+
tier: 1,
|
|
7643
|
+
idempotent: true,
|
|
7644
|
+
sessionRequired: false,
|
|
7645
|
+
requiredParams: ['version'],
|
|
7646
|
+
params: [
|
|
7647
|
+
{
|
|
7648
|
+
name: 'version',
|
|
7649
|
+
type: 'string',
|
|
7650
|
+
required: true,
|
|
7651
|
+
description: 'Release version (accepts v2026.5.94 or 2026.5.94)',
|
|
7652
|
+
cli: { positional: true },
|
|
7653
|
+
},
|
|
7654
|
+
{
|
|
7655
|
+
name: 'changelogPath',
|
|
7656
|
+
type: 'string',
|
|
7657
|
+
required: false,
|
|
7658
|
+
description: 'Override the CHANGELOG file path (default: <projectRoot>/CHANGELOG.md)',
|
|
7659
|
+
},
|
|
7660
|
+
] satisfies ParamDef[],
|
|
7661
|
+
},
|
|
7662
|
+
|
|
7632
7663
|
// ---------------------------------------------------------------------------
|
|
7633
7664
|
// Provenance domain — `cleo provenance` CLI surface (T9528 · Phase 2 of T9493)
|
|
7634
7665
|
// ---------------------------------------------------------------------------
|
|
@@ -332,14 +332,25 @@ export const GATE_EVIDENCE_REQUIREMENTS: Readonly<
|
|
|
332
332
|
* Result of {@link validateEvidenceForGate}.
|
|
333
333
|
*
|
|
334
334
|
* Success carries no extra payload (the caller already holds the atom list).
|
|
335
|
-
* Failure carries
|
|
336
|
-
*
|
|
337
|
-
*
|
|
338
|
-
*
|
|
335
|
+
* Failure carries:
|
|
336
|
+
* - {@link EvidenceValidationFailure.message} — the legacy single-line
|
|
337
|
+
* `Gate '<gate>' requires evidence: [<combo>] OR [<combo>]` format,
|
|
338
|
+
* preserved byte-for-byte for backward-compat with surfaces and tests
|
|
339
|
+
* that match against it.
|
|
340
|
+
* - {@link EvidenceValidationFailure.hint} — a richer, multi-line,
|
|
341
|
+
* example-bearing remediation hint suitable for CLI `fix:` surfaces.
|
|
342
|
+
* Added by T9949 so `E_EVIDENCE_INSUFFICIENT` errors point clearly at
|
|
343
|
+
* the alternative atom the caller needs (e.g. "this gate requires
|
|
344
|
+
* real proof — note: alone is not accepted; use commit+files,
|
|
345
|
+
* commit+note, or pr instead").
|
|
339
346
|
*/
|
|
340
|
-
export
|
|
341
|
-
|
|
342
|
-
|
|
347
|
+
export interface EvidenceValidationFailure {
|
|
348
|
+
readonly ok: false;
|
|
349
|
+
readonly message: string;
|
|
350
|
+
readonly hint: string;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export type EvidenceValidationResult = { readonly ok: true } | EvidenceValidationFailure;
|
|
343
354
|
|
|
344
355
|
/**
|
|
345
356
|
* Format the satisfying-combinations spec for a gate as the legacy
|
|
@@ -354,6 +365,114 @@ export function formatGateRequirement(gate: VerificationGate): string {
|
|
|
354
365
|
return requirement.oneOf.map((set) => `[${set.join(' AND ')}]`).join(' OR ');
|
|
355
366
|
}
|
|
356
367
|
|
|
368
|
+
/**
|
|
369
|
+
* Example `--evidence` strings for each atom kind. Used by
|
|
370
|
+
* {@link formatGateRequirementHint} to render copy-pasteable remediation
|
|
371
|
+
* commands in CLI error surfaces.
|
|
372
|
+
*
|
|
373
|
+
* @task T9949
|
|
374
|
+
*/
|
|
375
|
+
const ATOM_EXAMPLES: Readonly<Record<EvidenceAtomKind, string>> = Object.freeze({
|
|
376
|
+
commit: 'commit:<sha>',
|
|
377
|
+
files: 'files:path/a.ts,path/b.ts',
|
|
378
|
+
'test-run': 'test-run:/tmp/vitest-out.json',
|
|
379
|
+
tool: 'tool:test',
|
|
380
|
+
url: 'url:https://example.com/docs',
|
|
381
|
+
note: 'note:<short description>',
|
|
382
|
+
decision: 'decision:D-arch-001',
|
|
383
|
+
pr: 'pr:357',
|
|
384
|
+
'loc-drop': 'loc-drop:<fromLines>:<toLines>',
|
|
385
|
+
'callsite-coverage': 'callsite-coverage:<symbolName>:<relativeSourcePath>',
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Render a satisfying atom combination as a copy-pasteable `--evidence`
|
|
390
|
+
* string fragment, e.g. `['commit', 'files']` → `commit:<sha>;files:path/a.ts,path/b.ts`.
|
|
391
|
+
*
|
|
392
|
+
* @internal
|
|
393
|
+
*/
|
|
394
|
+
function renderCombinationAsEvidence(combination: ReadonlyArray<EvidenceAtomKind>): string {
|
|
395
|
+
return combination.map((kind) => ATOM_EXAMPLES[kind]).join(';');
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Produce a multi-line, example-bearing remediation hint for a gate.
|
|
400
|
+
*
|
|
401
|
+
* Designed for CLI `fix:` surfaces so `E_EVIDENCE_INSUFFICIENT` errors point
|
|
402
|
+
* the caller at the specific alternative atom shape they need. The hint lists
|
|
403
|
+
* every satisfying combination as a copy-pasteable `cleo verify` invocation
|
|
404
|
+
* plus a note-specific clarification when the gate does NOT accept `note:`
|
|
405
|
+
* alone.
|
|
406
|
+
*
|
|
407
|
+
* The motivating bug (T9949) was that note-only deliverables (decision-only
|
|
408
|
+
* tasks, deletion-only refactors) hit `E_EVIDENCE_INSUFFICIENT` with the
|
|
409
|
+
* legacy single-line error and had to consult ADR-051 to figure out which
|
|
410
|
+
* additional atom kind the gate required. The richer hint inlines the answer.
|
|
411
|
+
*
|
|
412
|
+
* @param gate - Verification gate the hint is for.
|
|
413
|
+
* @returns Multi-line remediation hint suitable for an `engineError({fix:})`
|
|
414
|
+
* surface. Always ends in a clarifying sentence about whether `note:` alone
|
|
415
|
+
* is accepted for the gate.
|
|
416
|
+
*
|
|
417
|
+
* @example
|
|
418
|
+
* ```ts
|
|
419
|
+
* formatGateRequirementHint('implemented');
|
|
420
|
+
* // "Gate 'implemented' requires programmatic evidence. Use ONE of:
|
|
421
|
+
* // - cleo verify T#### --gate implemented --evidence 'commit:<sha>;files:path/a.ts,path/b.ts'
|
|
422
|
+
* // - cleo verify T#### --gate implemented --evidence 'commit:<sha>;note:<short description>'
|
|
423
|
+
* // - cleo verify T#### --gate implemented --evidence 'decision:D-arch-001;files:path/a.ts,path/b.ts'
|
|
424
|
+
* // - cleo verify T#### --gate implemented --evidence 'decision:D-arch-001;note:<short description>'
|
|
425
|
+
* // - cleo verify T#### --gate implemented --evidence 'pr:357'
|
|
426
|
+
* // Note: 'note:' alone is NOT accepted for this gate — pair it with 'commit:' or 'decision:'."
|
|
427
|
+
* ```
|
|
428
|
+
*
|
|
429
|
+
* @task T9949
|
|
430
|
+
* @adr ADR-051 §2.3
|
|
431
|
+
*/
|
|
432
|
+
export function formatGateRequirementHint(gate: VerificationGate): string {
|
|
433
|
+
const requirement = GATE_EVIDENCE_REQUIREMENTS[gate];
|
|
434
|
+
if (!requirement) {
|
|
435
|
+
// Unknown gate — match validateEvidenceForGate's defensive default.
|
|
436
|
+
return `Gate '${gate}' has no documented evidence requirements.`;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const lines: string[] = [
|
|
440
|
+
`Gate '${gate}' requires programmatic evidence. Use ONE of:`,
|
|
441
|
+
...requirement.oneOf.map(
|
|
442
|
+
(combo) =>
|
|
443
|
+
` - cleo verify T#### --gate ${gate} --evidence '${renderCombinationAsEvidence(combo)}'`,
|
|
444
|
+
),
|
|
445
|
+
];
|
|
446
|
+
|
|
447
|
+
// Note-specific clarification — T9949's headline pain point.
|
|
448
|
+
// The gate accepts note: alone IFF some single-element combination is ['note'].
|
|
449
|
+
const noteAloneAccepted = requirement.oneOf.some(
|
|
450
|
+
(combo) => combo.length === 1 && combo[0] === 'note',
|
|
451
|
+
);
|
|
452
|
+
if (noteAloneAccepted) {
|
|
453
|
+
lines.push(`Note: 'note:' alone IS accepted for this gate (waiver-style evidence).`);
|
|
454
|
+
} else {
|
|
455
|
+
// Surface every alternative pairing involving note for at-a-glance routing.
|
|
456
|
+
const notePairings = requirement.oneOf.filter(
|
|
457
|
+
(combo) => combo.length > 1 && combo.includes('note'),
|
|
458
|
+
);
|
|
459
|
+
if (notePairings.length > 0) {
|
|
460
|
+
const partners = Array.from(
|
|
461
|
+
new Set(notePairings.flatMap((combo) => combo.filter((k) => k !== 'note'))),
|
|
462
|
+
).map((k) => `'${k}:'`);
|
|
463
|
+
lines.push(
|
|
464
|
+
`Note: 'note:' alone is NOT accepted for this gate — pair it with ${partners.join(' or ')}.`,
|
|
465
|
+
);
|
|
466
|
+
} else {
|
|
467
|
+
lines.push(
|
|
468
|
+
`Note: 'note:' is NOT accepted for this gate (even when paired). See ADR-051 for the full grammar.`,
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
return lines.join('\n');
|
|
474
|
+
}
|
|
475
|
+
|
|
357
476
|
/**
|
|
358
477
|
* Check whether a set of parsed atoms satisfies the requirement spec for
|
|
359
478
|
* `gate`.
|
|
@@ -401,6 +520,11 @@ export function validateEvidenceForGate(
|
|
|
401
520
|
return {
|
|
402
521
|
ok: false,
|
|
403
522
|
message: `Gate '${gate}' requires evidence: ${formatGateRequirement(gate)}`,
|
|
523
|
+
// T9949: rich, example-bearing remediation hint suitable for CLI `fix:`.
|
|
524
|
+
// Always populated alongside `message` so consumers can choose between
|
|
525
|
+
// the legacy single-line surface and the multi-line hint without re-
|
|
526
|
+
// computing the spec.
|
|
527
|
+
hint: formatGateRequirementHint(gate),
|
|
404
528
|
};
|
|
405
529
|
}
|
|
406
530
|
|
package/src/index.ts
CHANGED
|
@@ -475,6 +475,7 @@ export {
|
|
|
475
475
|
export type {
|
|
476
476
|
EvidenceAtom as EvidenceAtomInput,
|
|
477
477
|
EvidenceAtomKind,
|
|
478
|
+
EvidenceValidationFailure,
|
|
478
479
|
EvidenceValidationResult,
|
|
479
480
|
GateEvidenceRequirement,
|
|
480
481
|
} from './evidence-atom-schema.js';
|
|
@@ -486,6 +487,7 @@ export {
|
|
|
486
487
|
EvidenceParseError,
|
|
487
488
|
filesAtomSchema,
|
|
488
489
|
formatGateRequirement,
|
|
490
|
+
formatGateRequirementHint,
|
|
489
491
|
GATE_EVIDENCE_REQUIREMENTS,
|
|
490
492
|
locDropAtomSchema,
|
|
491
493
|
noteAtomSchema,
|
package/src/operations/tasks.ts
CHANGED
|
@@ -138,6 +138,32 @@ export interface TasksFindParams {
|
|
|
138
138
|
* @task T9905
|
|
139
139
|
*/
|
|
140
140
|
urgent?: boolean;
|
|
141
|
+
/**
|
|
142
|
+
* Filter by label — selects tasks whose `labels` array contains this
|
|
143
|
+
* value. Composes with other filters via AND.
|
|
144
|
+
*
|
|
145
|
+
* Closes GH#393 — gives `cleo find --label <name>` parity with the
|
|
146
|
+
* positional `cleo labels <name>` surface.
|
|
147
|
+
*
|
|
148
|
+
* @task T9904
|
|
149
|
+
*/
|
|
150
|
+
label?: string;
|
|
151
|
+
/**
|
|
152
|
+
* Filter by parent task ID — restricts the result set to tasks whose
|
|
153
|
+
* `parentId` equals this value. Mirrors the `--parent` axis on
|
|
154
|
+
* `cleo list`. When the parent task is a Saga (Epic with
|
|
155
|
+
* `label='saga'`), routing goes through `task_relations.type='groups'`
|
|
156
|
+
* member IDs instead of the `parentId` column (ADR-073 §1).
|
|
157
|
+
*
|
|
158
|
+
* Composes with other filters via AND. Closes T10108 — pre-fix,
|
|
159
|
+
* `cleo find "" --parent <id>` returned every task in the project
|
|
160
|
+
* because the empty-string query bypassed all filters via
|
|
161
|
+
* `fuzzyScore('', '<title>')===80`.
|
|
162
|
+
*
|
|
163
|
+
* @task T10108
|
|
164
|
+
* @saga T9862
|
|
165
|
+
*/
|
|
166
|
+
parent?: string;
|
|
141
167
|
}
|
|
142
168
|
export type TasksFindResult = MinimalTask[];
|
|
143
169
|
|