thoth-agents 0.4.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +30 -0
- package/README.md +86 -58
- package/dist/agents/prompt-sections.d.ts +5 -3
- package/dist/agents/prompt-utils.d.ts +1 -1
- package/dist/agents/worker.d.ts +2 -0
- package/dist/{chunk-STCURXAQ.js → chunk-MRFCA3U2.js} +33 -67
- package/dist/{chunk-EFGSGPYM.js → chunk-OW6I7ZUU.js} +391 -416
- package/dist/chunk-V7HSGRMB.js +1016 -0
- package/dist/{chunk-WCLUZZHI.js → chunk-VUNYH263.js} +1580 -351
- package/dist/cli/claude-code-paths.d.ts +2 -2
- package/dist/cli/codex-install.d.ts +5 -2
- package/dist/cli/codex-paths.d.ts +2 -2
- package/dist/cli/index.js +47 -11
- package/dist/cli/operations/pi.d.ts +1 -0
- package/dist/cli/pi-effort.d.ts +1 -1
- package/dist/cli/pi-external-package.d.ts +24 -0
- package/dist/cli/pi-git-source.d.ts +1 -0
- package/dist/cli/pi-install.d.ts +92 -15
- package/dist/cli/pi-managed-write.d.ts +3 -0
- package/dist/cli/pi-model-config.d.ts +17 -0
- package/dist/cli/pi-native-probe.d.ts +2 -0
- package/dist/cli/pi-paths.d.ts +2 -0
- package/dist/cli/pi-resources.d.ts +3 -1
- package/dist/cli/pi-tool-config.d.ts +32 -0
- package/dist/cli/tui/index.js +3 -3
- package/dist/cli/types.d.ts +2 -0
- package/dist/config/constants.d.ts +4 -8
- package/dist/config/schema.d.ts +7 -36
- package/dist/harness/adapters/claude-code.d.ts +2 -4
- package/dist/harness/core/agent-pack.d.ts +52 -42
- package/dist/harness/core/memory-governance.d.ts +2 -2
- package/dist/harness/core/sdd.d.ts +159 -319
- package/dist/harness/writers/pi-agent.d.ts +3 -1
- package/dist/index.js +12 -6
- package/dist/pi/tools-panel.d.ts +51 -0
- package/dist/pi.d.ts +44 -2
- package/dist/pi.js +471 -9
- package/dist/utils/subprocess.d.ts +1 -0
- package/package.json +18 -4
- package/pi/.thoth-agents-assets.json +5 -6
- package/pi/agents/thoth-designer.md +23 -8
- package/pi/agents/thoth-explorer.md +29 -9
- package/pi/agents/thoth-librarian.md +30 -8
- package/pi/agents/thoth-oracle.md +18 -10
- package/pi/agents/thoth-worker.md +96 -0
- package/plugin/.claude-plugin/.thoth-agents-plugin-assets.json +7 -12
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/.codex-plugin/plugin.json +2 -2
- package/plugin/agents/designer.md +13 -5
- package/plugin/agents/explorer.md +16 -5
- package/plugin/agents/librarian.md +15 -4
- package/plugin/agents/oracle.md +13 -7
- package/plugin/agents/orchestrator.md +82 -91
- package/plugin/agents/worker.md +76 -0
- package/plugin/skills/plan-reviewer/SKILL.md +39 -141
- package/plugin/skills/thoth-archive/SKILL.md +50 -47
- package/plugin/skills/thoth-archive/scripts/archive.mjs +287 -654
- package/plugin/skills/thoth-constitution/SKILL.md +23 -42
- package/plugin/skills/thoth-constitution/templates/constitution.md +54 -44
- package/plugin/skills/thoth-init/SKILL.md +19 -29
- package/plugin/skills/thoth-init/scripts/init.mjs +73 -66
- package/plugin/skills/thoth-sdd/SKILL.md +164 -117
- package/plugin/skills/thoth-sdd/references/phases/checklist.md +2 -22
- package/plugin/skills/thoth-sdd/references/phases/clarify.md +14 -12
- package/plugin/skills/thoth-sdd/references/phases/converge.md +2 -13
- package/plugin/skills/thoth-sdd/references/phases/explore.md +3 -8
- package/plugin/skills/thoth-sdd/references/phases/implement.md +58 -57
- package/plugin/skills/thoth-sdd/references/phases/plan.md +2 -18
- package/plugin/skills/thoth-sdd/references/phases/specify.md +2 -33
- package/plugin/skills/thoth-sdd/references/phases/tasks.md +4 -45
- package/plugin/skills/thoth-sdd/references/phases/verify.md +4 -22
- package/plugin/skills/thoth-sdd/scripts/validate.mjs +610 -1232
- package/plugin/skills/thoth-sdd/templates/change.md +94 -0
- package/skills/README.md +11 -13
- package/skills/plan-reviewer/SKILL.md +39 -141
- package/skills/thoth-archive/SKILL.md +50 -47
- package/skills/thoth-archive/scripts/archive.mjs +287 -654
- package/skills/thoth-constitution/SKILL.md +23 -42
- package/skills/thoth-constitution/templates/constitution.md +54 -44
- package/skills/thoth-init/SKILL.md +19 -29
- package/skills/thoth-init/scripts/init.mjs +73 -66
- package/skills/thoth-sdd/SKILL.md +164 -117
- package/skills/thoth-sdd/references/phases/checklist.md +2 -22
- package/skills/thoth-sdd/references/phases/clarify.md +14 -12
- package/skills/thoth-sdd/references/phases/converge.md +2 -13
- package/skills/thoth-sdd/references/phases/explore.md +3 -8
- package/skills/thoth-sdd/references/phases/implement.md +58 -57
- package/skills/thoth-sdd/references/phases/plan.md +2 -18
- package/skills/thoth-sdd/references/phases/specify.md +2 -33
- package/skills/thoth-sdd/references/phases/tasks.md +4 -45
- package/skills/thoth-sdd/references/phases/verify.md +4 -22
- package/skills/thoth-sdd/scripts/validate.mjs +610 -1232
- package/skills/thoth-sdd/templates/change.md +94 -0
- package/thoth-agents.schema.json +9 -77
- package/dist/agents/deep.d.ts +0 -2
- package/dist/agents/quick.d.ts +0 -2
- package/dist/chunk-2U5MBLLV.js +0 -312
- package/pi/agents/thoth-deep.md +0 -81
- package/pi/agents/thoth-quick.md +0 -81
- package/plugin/agents/deep.md +0 -68
- package/plugin/agents/quick.md +0 -68
- package/plugin/skills/plan-reviewer/templates/plan-review.md +0 -65
- package/plugin/skills/thoth-sdd/templates/archive-report.md +0 -25
- package/plugin/skills/thoth-sdd/templates/checklist.md +0 -27
- package/plugin/skills/thoth-sdd/templates/plan.md +0 -46
- package/plugin/skills/thoth-sdd/templates/spec.md +0 -62
- package/plugin/skills/thoth-sdd/templates/tasks.md +0 -58
- package/plugin/skills/thoth-sdd/templates/verify-report.md +0 -28
- package/skills/plan-reviewer/templates/plan-review.md +0 -65
- package/skills/thoth-sdd/templates/archive-report.md +0 -25
- package/skills/thoth-sdd/templates/checklist.md +0 -27
- package/skills/thoth-sdd/templates/plan.md +0 -46
- package/skills/thoth-sdd/templates/spec.md +0 -62
- package/skills/thoth-sdd/templates/tasks.md +0 -58
- package/skills/thoth-sdd/templates/verify-report.md +0 -28
|
@@ -1,1389 +1,767 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { lstatSync, readdirSync, readFileSync } from 'node:fs';
|
|
4
|
+
import {
|
|
5
|
+
basename,
|
|
6
|
+
dirname,
|
|
7
|
+
isAbsolute,
|
|
8
|
+
join,
|
|
9
|
+
parse,
|
|
10
|
+
relative,
|
|
11
|
+
resolve,
|
|
12
|
+
sep,
|
|
13
|
+
} from 'node:path';
|
|
14
|
+
import { pathToFileURL } from 'node:url';
|
|
5
15
|
import {
|
|
6
|
-
CAPABILITY_PATTERN,
|
|
7
16
|
parseCanonicalSpec,
|
|
8
17
|
parseRequirementDelta,
|
|
9
18
|
preflightRequirementDeltas,
|
|
10
19
|
} from './durable-deltas.mjs';
|
|
11
20
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function issue(code, artifact, message) {
|
|
39
|
-
return { code, artifact, message };
|
|
21
|
+
const sha = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
22
|
+
const gates = [
|
|
23
|
+
'explore',
|
|
24
|
+
'specify',
|
|
25
|
+
'clarify',
|
|
26
|
+
'plan',
|
|
27
|
+
'tasks',
|
|
28
|
+
'checklist',
|
|
29
|
+
'ready',
|
|
30
|
+
'verify',
|
|
31
|
+
'closeout',
|
|
32
|
+
];
|
|
33
|
+
const slugPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
34
|
+
const reservedWindowsName = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
|
|
35
|
+
const issue = (code, message, recordName = 'record') => ({
|
|
36
|
+
code,
|
|
37
|
+
artifact: recordName,
|
|
38
|
+
message,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
function fail(code, message) {
|
|
42
|
+
const error = new Error(message);
|
|
43
|
+
error.code = code;
|
|
44
|
+
throw error;
|
|
40
45
|
}
|
|
41
46
|
|
|
42
|
-
function
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
'SDD-ARTIFACT-MISSING',
|
|
49
|
-
relativePath,
|
|
50
|
-
`Required artifact is missing: ${relativePath}`,
|
|
51
|
-
),
|
|
52
|
-
);
|
|
53
|
-
}
|
|
54
|
-
return undefined;
|
|
47
|
+
function lstatMaybe(path) {
|
|
48
|
+
try {
|
|
49
|
+
return lstatSync(path);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return undefined;
|
|
52
|
+
throw error;
|
|
55
53
|
}
|
|
56
|
-
return readFileSync(path, 'utf8');
|
|
57
54
|
}
|
|
58
55
|
|
|
59
|
-
function
|
|
60
|
-
return
|
|
56
|
+
export function isValidSddChangeId(id) {
|
|
57
|
+
return (
|
|
58
|
+
typeof id === 'string' &&
|
|
59
|
+
slugPattern.test(id) &&
|
|
60
|
+
!reservedWindowsName.test(id)
|
|
61
|
+
);
|
|
61
62
|
}
|
|
62
63
|
|
|
63
|
-
function
|
|
64
|
-
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const nextHeading = /^##\s+/m.exec(remainder);
|
|
70
|
-
return remainder.slice(0, nextHeading?.index ?? remainder.length).trim();
|
|
64
|
+
function isIsoDate(value) {
|
|
65
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value ?? '')) return false;
|
|
66
|
+
const date = new Date(`${value}T00:00:00.000Z`);
|
|
67
|
+
return (
|
|
68
|
+
!Number.isNaN(date.valueOf()) && date.toISOString().slice(0, 10) === value
|
|
69
|
+
);
|
|
71
70
|
}
|
|
72
71
|
|
|
73
|
-
function
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
)
|
|
81
|
-
|
|
82
|
-
}
|
|
83
|
-
const numbers = ids.map((id) => Number.parseInt(id, 10));
|
|
84
|
-
if (numbers.some((value, index) => value !== index + 1)) {
|
|
85
|
-
errors.push(
|
|
86
|
-
issue(
|
|
87
|
-
`SDD-${prefix}-SEQUENCE`,
|
|
88
|
-
artifact,
|
|
89
|
-
`${prefix} identifiers must start at 001 and remain sequential.`,
|
|
90
|
-
),
|
|
91
|
-
);
|
|
72
|
+
export function assertNoSymlinkAncestors(path) {
|
|
73
|
+
const absolute = resolve(path);
|
|
74
|
+
const { root } = parse(absolute);
|
|
75
|
+
let cursor = root;
|
|
76
|
+
for (const part of absolute.slice(root.length).split(sep).filter(Boolean)) {
|
|
77
|
+
cursor = join(cursor, part);
|
|
78
|
+
if (lstatMaybe(cursor)?.isSymbolicLink()) {
|
|
79
|
+
fail('SDD-SYMLINK', `Symlinked path component is not allowed: ${cursor}`);
|
|
80
|
+
}
|
|
92
81
|
}
|
|
93
82
|
}
|
|
94
83
|
|
|
95
|
-
function
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
);
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
return capabilities.length > 0 &&
|
|
104
|
-
capabilities.every((item) => CAPABILITY_PATTERN.test(item))
|
|
105
|
-
? capabilities
|
|
106
|
-
: undefined;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function contractCandidateLines(content, prefix) {
|
|
110
|
-
const pattern = new RegExp(`^\\s*-\\s+(?:\\*\\*)?${prefix}\\b`, 'i');
|
|
111
|
-
return content.split(/\r?\n/).filter((line) => pattern.test(line));
|
|
112
|
-
}
|
|
84
|
+
export function resolveSddChangeLocation(change) {
|
|
85
|
+
const changeRoot = resolve(change);
|
|
86
|
+
assertNoSymlinkAncestors(changeRoot);
|
|
87
|
+
const parent = dirname(changeRoot);
|
|
88
|
+
let projectRoot;
|
|
89
|
+
let id;
|
|
90
|
+
let archiveDate;
|
|
91
|
+
let archived = false;
|
|
113
92
|
|
|
114
|
-
function validateSpec(content, errors) {
|
|
115
|
-
const artifact = 'spec.md';
|
|
116
|
-
const intentSection = sectionContent(content, 'Intent and scope');
|
|
117
|
-
const affectedCapabilities = parseAffectedCapabilities(intentSection);
|
|
118
93
|
if (
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
!/^\*\*Impact\*\*:\s*\S.+$/im.test(intentSection) ||
|
|
122
|
-
affectedCapabilities === undefined
|
|
94
|
+
basename(parent) === 'changes' &&
|
|
95
|
+
basename(dirname(parent)) === '.thoth'
|
|
123
96
|
) {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
97
|
+
projectRoot = dirname(dirname(parent));
|
|
98
|
+
id = basename(changeRoot);
|
|
99
|
+
} else if (
|
|
100
|
+
basename(parent) === 'archive' &&
|
|
101
|
+
basename(dirname(parent)) === 'changes' &&
|
|
102
|
+
basename(dirname(dirname(parent))) === '.thoth'
|
|
103
|
+
) {
|
|
104
|
+
archived = true;
|
|
105
|
+
projectRoot = dirname(dirname(dirname(dirname(changeRoot))));
|
|
106
|
+
const match = /^(\d{4}-\d{2}-\d{2})-(.+)$/.exec(basename(changeRoot));
|
|
107
|
+
if (!match || !isIsoDate(match[1])) {
|
|
108
|
+
fail(
|
|
109
|
+
'SDD-CHANGE-PATH',
|
|
110
|
+
'Archived change directory must use YYYY-MM-DD-<id>',
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
archiveDate = match[1];
|
|
114
|
+
id = match[2];
|
|
115
|
+
} else {
|
|
116
|
+
fail(
|
|
117
|
+
'SDD-CHANGE-PATH',
|
|
118
|
+
'Change must be an immediate .thoth/changes/<id> or dated archive child',
|
|
130
119
|
);
|
|
131
120
|
}
|
|
132
121
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
const functionalRequirementsSection = sectionContent(
|
|
136
|
-
content,
|
|
137
|
-
'Functional requirements',
|
|
138
|
-
);
|
|
139
|
-
const successCriteriaSection = sectionContent(content, 'Success criteria');
|
|
140
|
-
const dependenciesSection = sectionContent(content, 'Dependencies');
|
|
141
|
-
const missingSections = [
|
|
142
|
-
['User stories', userStoriesSection],
|
|
143
|
-
['Edge cases', edgeCasesSection],
|
|
144
|
-
['Functional requirements', functionalRequirementsSection],
|
|
145
|
-
['Success criteria', successCriteriaSection],
|
|
146
|
-
['Dependencies', dependenciesSection],
|
|
147
|
-
]
|
|
148
|
-
.filter(([, section]) => section === undefined)
|
|
149
|
-
.map(([heading]) => heading);
|
|
150
|
-
if (missingSections.length > 0) {
|
|
151
|
-
errors.push(
|
|
152
|
-
issue(
|
|
153
|
-
'SDD-SPEC-SECTIONS',
|
|
154
|
-
artifact,
|
|
155
|
-
`Canonical specification sections are missing: ${missingSections.join(', ')}.`,
|
|
156
|
-
),
|
|
157
|
-
);
|
|
122
|
+
if (!isValidSddChangeId(id)) {
|
|
123
|
+
fail('SDD-CHANGE-ID', `Unsafe or reserved change ID: ${id}`);
|
|
158
124
|
}
|
|
125
|
+
const recordPath = join(changeRoot, `${id}.md`);
|
|
126
|
+
assertNoSymlinkAncestors(recordPath);
|
|
127
|
+
return { projectRoot, changeRoot, id, recordPath, archived, archiveDate };
|
|
128
|
+
}
|
|
159
129
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
];
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
errors.push(
|
|
169
|
-
issue(
|
|
170
|
-
'SDD-SPEC-STORIES',
|
|
171
|
-
artifact,
|
|
172
|
-
'Add prioritized US# stories with an independent test and acceptance scenarios.',
|
|
173
|
-
),
|
|
174
|
-
);
|
|
175
|
-
} else {
|
|
176
|
-
checkUniqueSequential(storyIds, 'STORY', artifact, errors);
|
|
177
|
-
for (const [index, match] of storyMatches.entries()) {
|
|
178
|
-
const start = match.index ?? 0;
|
|
179
|
-
const next = storyMatches[index + 1]?.index;
|
|
180
|
-
const end = next ?? userStoriesSection.length;
|
|
181
|
-
const story = userStoriesSection.slice(start, end);
|
|
182
|
-
if (!/\*\*Independent test\*\*:\s*\S.+/i.test(story)) {
|
|
183
|
-
errors.push(
|
|
184
|
-
issue(
|
|
185
|
-
'SDD-SPEC-INDEPENDENCE',
|
|
186
|
-
artifact,
|
|
187
|
-
`US${match[1]} must describe an independent test.`,
|
|
188
|
-
),
|
|
189
|
-
);
|
|
190
|
-
}
|
|
191
|
-
const covers = /^\*\*Covers\*\*:\s*(\S.+)$/im.exec(story);
|
|
192
|
-
const coveredIds = covers?.[1].match(/\b(?:FR|SC)-\d{3}\b/g) ?? [];
|
|
193
|
-
if (
|
|
194
|
-
!coveredIds.some((id) => id.startsWith('FR-')) ||
|
|
195
|
-
!coveredIds.some((id) => id.startsWith('SC-'))
|
|
196
|
-
) {
|
|
197
|
-
errors.push(
|
|
198
|
-
issue(
|
|
199
|
-
'SDD-SPEC-STORY-COVERAGE',
|
|
200
|
-
artifact,
|
|
201
|
-
`US${match[1]} must map to its FR-### and SC-### contracts.`,
|
|
202
|
-
),
|
|
203
|
-
);
|
|
204
|
-
}
|
|
205
|
-
for (const id of coveredIds) storyCoverage.add(id);
|
|
206
|
-
if (
|
|
207
|
-
!/\*\*Given\*\*[\s\S]+?\*\*When\*\*[\s\S]+?\*\*Then\*\*\s+\S/i.test(
|
|
208
|
-
story,
|
|
209
|
-
)
|
|
210
|
-
) {
|
|
211
|
-
errors.push(
|
|
212
|
-
issue(
|
|
213
|
-
'SDD-SPEC-ACCEPTANCE',
|
|
214
|
-
artifact,
|
|
215
|
-
`US${match[1]} must include a Given/When/Then acceptance scenario.`,
|
|
216
|
-
),
|
|
217
|
-
);
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
}
|
|
130
|
+
function section(text, name, { trim = true } = {}) {
|
|
131
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
132
|
+
const match = new RegExp(`^## ${escaped}\\s*$`, 'im').exec(text);
|
|
133
|
+
if (!match) return undefined;
|
|
134
|
+
const tail = text.slice(match.index + match[0].length);
|
|
135
|
+
const content = tail.slice(0, /^## /m.exec(tail)?.index ?? tail.length);
|
|
136
|
+
return trim ? content.trim() : content;
|
|
137
|
+
}
|
|
221
138
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
)
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
const
|
|
236
|
-
if (
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
}
|
|
260
|
-
const delta = parseRequirementDelta(match[3]);
|
|
261
|
-
if (!delta) {
|
|
262
|
-
errors.push(
|
|
263
|
-
issue(
|
|
264
|
-
'SDD-SPEC-FR-DELTA',
|
|
265
|
-
artifact,
|
|
266
|
-
`FR-${match[1]} must declare INTERNAL, ADDED, MODIFIED, REMOVED, or RENAMED metadata.`,
|
|
267
|
-
),
|
|
268
|
-
);
|
|
269
|
-
} else if (
|
|
270
|
-
delta.capability &&
|
|
271
|
-
!affectedCapabilities?.includes(delta.capability)
|
|
272
|
-
) {
|
|
273
|
-
errors.push(
|
|
274
|
-
issue(
|
|
275
|
-
'SDD-SPEC-CAPABILITY-COVERAGE',
|
|
276
|
-
artifact,
|
|
277
|
-
`FR-${match[1]} targets undeclared capability ${delta.capability}.`,
|
|
278
|
-
),
|
|
279
|
-
);
|
|
280
|
-
} else if (delta.operation !== 'INTERNAL') {
|
|
281
|
-
durableDeltas.push({
|
|
282
|
-
title: match[2].trim(),
|
|
283
|
-
...delta,
|
|
284
|
-
});
|
|
285
|
-
}
|
|
139
|
+
function localFile(root, name) {
|
|
140
|
+
if (
|
|
141
|
+
!name ||
|
|
142
|
+
isAbsolute(name) ||
|
|
143
|
+
name.includes('\\') ||
|
|
144
|
+
!/^[a-zA-Z0-9_./-]+$/.test(name)
|
|
145
|
+
)
|
|
146
|
+
throw new Error('Invalid reviewed source path');
|
|
147
|
+
const target = resolve(root, name);
|
|
148
|
+
const rel = relative(root, target);
|
|
149
|
+
if (!rel || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel))
|
|
150
|
+
throw new Error('Reviewed source escapes project');
|
|
151
|
+
assertNoSymlinkAncestors(target);
|
|
152
|
+
const stat = lstatMaybe(target);
|
|
153
|
+
if (!stat?.isFile() || stat.isSymbolicLink())
|
|
154
|
+
throw new Error('Reviewed source must be a regular file');
|
|
155
|
+
return target;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function parseDeltas(text, errors, recordName) {
|
|
159
|
+
const lines = text.split(/\r?\n/).filter((line) => line.trim());
|
|
160
|
+
if (lines.length === 1 && lines[0] === '- None.') return [];
|
|
161
|
+
const deltas = [];
|
|
162
|
+
for (let index = 0; index < lines.length; index++) {
|
|
163
|
+
const line = lines[index];
|
|
164
|
+
const match = /^- `([^`]+)` \*\*(.+?)\*\* — (\S.+)$/.exec(line);
|
|
165
|
+
const metadata = match && parseRequirementDelta(match[1]);
|
|
166
|
+
if (
|
|
167
|
+
!metadata ||
|
|
168
|
+
metadata.operation === 'INTERNAL' ||
|
|
169
|
+
!match[2].trim() ||
|
|
170
|
+
!match[3].trim()
|
|
171
|
+
) {
|
|
172
|
+
errors.push(
|
|
173
|
+
issue('SDD-DELTA-FORMAT', `Invalid durable delta: ${line}`, recordName),
|
|
174
|
+
);
|
|
175
|
+
continue;
|
|
286
176
|
}
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
errors.push(
|
|
290
|
-
issue(
|
|
291
|
-
'SDD-SPEC-SC-TYPE',
|
|
292
|
-
artifact,
|
|
293
|
-
'Every SC-### must be classified as buildable or outcome.',
|
|
294
|
-
),
|
|
177
|
+
const scenario = /^ {2}- GIVEN (.+); WHEN (.+); THEN (.+)\.$/.exec(
|
|
178
|
+
lines[index + 1] ?? '',
|
|
295
179
|
);
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
)
|
|
301
|
-
} else {
|
|
302
|
-
checkUniqueSequential(scIds, 'SC', artifact, errors);
|
|
303
|
-
const measurable =
|
|
304
|
-
/(?:\b\d+(?:\.\d+)?\b|%|\b(?:all|every|none|zero|under|over|within|at least|at most|no more|fewer|pass(?:es)?|fail(?:s)?|accept(?:s)?|reject(?:s)?|preserve(?:s)?)\b)/i;
|
|
305
|
-
if (successCriteria.some((match) => !measurable.test(match[3]))) {
|
|
180
|
+
if (scenario) index++;
|
|
181
|
+
if (
|
|
182
|
+
metadata.operation !== 'REMOVED' &&
|
|
183
|
+
(!scenario || scenario.slice(1).some((value) => !value.trim()))
|
|
184
|
+
) {
|
|
306
185
|
errors.push(
|
|
307
186
|
issue(
|
|
308
|
-
'SDD-
|
|
309
|
-
|
|
310
|
-
|
|
187
|
+
'SDD-DELTA-SCENARIO',
|
|
188
|
+
`Concrete GIVEN/WHEN/THEN scenario is required for ${match[2]}`,
|
|
189
|
+
recordName,
|
|
311
190
|
),
|
|
312
191
|
);
|
|
313
192
|
}
|
|
193
|
+
if (metadata.operation === 'REMOVED' && scenario) {
|
|
194
|
+
errors.push(
|
|
195
|
+
issue(
|
|
196
|
+
'SDD-DELTA-SCENARIO',
|
|
197
|
+
'Removed requirements do not define new scenarios',
|
|
198
|
+
recordName,
|
|
199
|
+
),
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
deltas.push({
|
|
203
|
+
...metadata,
|
|
204
|
+
title: match[2],
|
|
205
|
+
statement: match[3],
|
|
206
|
+
scenario: scenario?.slice(1),
|
|
207
|
+
});
|
|
314
208
|
}
|
|
315
|
-
if (
|
|
316
|
-
errors.push(
|
|
317
|
-
issue('SDD-SPEC-ASSUMPTIONS', artifact, 'Add an Assumptions section.'),
|
|
318
|
-
);
|
|
319
|
-
}
|
|
320
|
-
if (!/^##\s+Out of scope\s*$/im.test(content)) {
|
|
321
|
-
errors.push(
|
|
322
|
-
issue('SDD-SPEC-OUT-OF-SCOPE', artifact, 'Add an Out of scope section.'),
|
|
323
|
-
);
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
const knownContracts = new Set([
|
|
327
|
-
...frIds.map((id) => `FR-${id}`),
|
|
328
|
-
...scIds.map((id) => `SC-${id}`),
|
|
329
|
-
]);
|
|
330
|
-
const unknownCoverage = [...storyCoverage].filter(
|
|
331
|
-
(id) => !knownContracts.has(id),
|
|
332
|
-
);
|
|
333
|
-
const uncoveredContracts = [...knownContracts].filter(
|
|
334
|
-
(id) => !storyCoverage.has(id),
|
|
335
|
-
);
|
|
336
|
-
if (unknownCoverage.length > 0 || uncoveredContracts.length > 0) {
|
|
209
|
+
if (!lines.length) {
|
|
337
210
|
errors.push(
|
|
338
211
|
issue(
|
|
339
|
-
'SDD-
|
|
340
|
-
|
|
341
|
-
|
|
212
|
+
'SDD-DELTA-FORMAT',
|
|
213
|
+
'Declare deltas or explicitly use - None.',
|
|
214
|
+
recordName,
|
|
342
215
|
),
|
|
343
216
|
);
|
|
344
217
|
}
|
|
345
|
-
|
|
346
|
-
return {
|
|
347
|
-
storyIds,
|
|
348
|
-
frIds,
|
|
349
|
-
scIds,
|
|
350
|
-
buildableScIds: successCriteria
|
|
351
|
-
.filter((match) => match[2].toLowerCase() === 'buildable')
|
|
352
|
-
.map((match) => match[1]),
|
|
353
|
-
outcomeScIds: successCriteria
|
|
354
|
-
.filter((match) => match[2].toLowerCase() === 'outcome')
|
|
355
|
-
.map((match) => match[1]),
|
|
356
|
-
durableDeltas,
|
|
357
|
-
};
|
|
218
|
+
return deltas;
|
|
358
219
|
}
|
|
359
220
|
|
|
360
|
-
function
|
|
361
|
-
const
|
|
362
|
-
for (const delta of deltas)
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
const
|
|
370
|
-
const present = existsSync(path);
|
|
371
|
-
let canonical;
|
|
221
|
+
function preflightDeltas(root, deltas, errors, warnings, recordName) {
|
|
222
|
+
const groups = new Map();
|
|
223
|
+
for (const delta of deltas)
|
|
224
|
+
groups.set(delta.capability, [
|
|
225
|
+
...(groups.get(delta.capability) ?? []),
|
|
226
|
+
delta,
|
|
227
|
+
]);
|
|
228
|
+
for (const [capability, entries] of groups) {
|
|
229
|
+
const path = join(root, '.thoth', 'specs', capability, 'spec.md');
|
|
230
|
+
const present = Boolean(lstatMaybe(path));
|
|
372
231
|
try {
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
232
|
+
assertNoSymlinkAncestors(path);
|
|
233
|
+
const stat = present ? lstatSync(path) : undefined;
|
|
234
|
+
if (present && (!stat.isFile() || stat.isSymbolicLink()))
|
|
235
|
+
throw new Error('Not a regular canonical specification');
|
|
236
|
+
const requirements = present
|
|
237
|
+
? parseCanonicalSpec(readFileSync(path, 'utf8')).requirements
|
|
238
|
+
: new Map();
|
|
239
|
+
const result = preflightRequirementDeltas({
|
|
240
|
+
capability,
|
|
241
|
+
present,
|
|
242
|
+
requirements,
|
|
243
|
+
deltas: entries,
|
|
244
|
+
});
|
|
245
|
+
errors.push(
|
|
246
|
+
...result.errors.map((entry) =>
|
|
247
|
+
issue(entry.code, entry.message, recordName),
|
|
248
|
+
),
|
|
249
|
+
);
|
|
250
|
+
warnings.push(...result.warnings);
|
|
376
251
|
} catch (error) {
|
|
377
252
|
errors.push(
|
|
378
253
|
issue(
|
|
379
|
-
'SDD-
|
|
380
|
-
|
|
381
|
-
|
|
254
|
+
'SDD-DELTA-BASELINE',
|
|
255
|
+
`${capability}: ${error.message}`,
|
|
256
|
+
recordName,
|
|
382
257
|
),
|
|
383
258
|
);
|
|
384
|
-
continue;
|
|
385
259
|
}
|
|
386
|
-
|
|
387
|
-
const result = preflightRequirementDeltas({
|
|
388
|
-
capability,
|
|
389
|
-
present,
|
|
390
|
-
requirements: canonical.requirements,
|
|
391
|
-
deltas: capabilityDeltas,
|
|
392
|
-
});
|
|
393
|
-
errors.push(
|
|
394
|
-
...result.errors.map((entry) =>
|
|
395
|
-
issue(entry.code, 'spec.md', entry.message),
|
|
396
|
-
),
|
|
397
|
-
);
|
|
398
|
-
warnings.push(
|
|
399
|
-
...result.warnings.map((entry) =>
|
|
400
|
-
issue(entry.code, 'spec.md', entry.message),
|
|
401
|
-
),
|
|
402
|
-
);
|
|
403
260
|
}
|
|
404
261
|
}
|
|
405
262
|
|
|
406
|
-
function
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
.
|
|
410
|
-
|
|
411
|
-
.map((line) => {
|
|
412
|
-
const match =
|
|
413
|
-
/^- (?:\*\*)?(.+?)(?:\*\*)?:\s*(PASS|JUSTIFIED EXCEPTION|FAIL)\s+[—-]\s+(\S.+)$/i.exec(
|
|
414
|
-
line,
|
|
415
|
-
);
|
|
416
|
-
return match
|
|
417
|
-
? {
|
|
418
|
-
name: match[1].trim(),
|
|
419
|
-
status: match[2].toUpperCase(),
|
|
420
|
-
evidence: match[3],
|
|
421
|
-
}
|
|
422
|
-
: undefined;
|
|
423
|
-
});
|
|
263
|
+
function settled(text) {
|
|
264
|
+
return (
|
|
265
|
+
Boolean(text?.trim()) &&
|
|
266
|
+
!/\b(?:pending|unresolved|unknown|ask|tbd|todo)\b/i.test(text)
|
|
267
|
+
);
|
|
424
268
|
}
|
|
425
269
|
|
|
426
|
-
function
|
|
427
|
-
const
|
|
428
|
-
const
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
issue(
|
|
432
|
-
'SDD-PLAN-CONSTITUTION-PRE',
|
|
433
|
-
'plan.md',
|
|
434
|
-
'Record the pre-design Constitution Check.',
|
|
435
|
-
),
|
|
436
|
-
);
|
|
437
|
-
}
|
|
438
|
-
if (post === undefined) {
|
|
439
|
-
errors.push(
|
|
440
|
-
issue(
|
|
441
|
-
'SDD-PLAN-CONSTITUTION-POST',
|
|
442
|
-
'plan.md',
|
|
443
|
-
'Record the post-design Constitution Check.',
|
|
444
|
-
),
|
|
445
|
-
);
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
const preEntries = constitutionEntries(pre);
|
|
449
|
-
const postEntries = constitutionEntries(post);
|
|
270
|
+
function coverageErrors(text, ids, errors, recordName) {
|
|
271
|
+
const tasks = section(text, 'Tasks') ?? '';
|
|
272
|
+
const rows = tasks
|
|
273
|
+
.split(/\r?\n/)
|
|
274
|
+
.filter((line) => /^- \[[^\]]*\]/.test(line));
|
|
450
275
|
if (
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
)))
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
(entry) => !entry || !isConcreteEvidence(entry.evidence),
|
|
460
|
-
)))
|
|
276
|
+
!rows.length ||
|
|
277
|
+
rows.some(
|
|
278
|
+
(line) =>
|
|
279
|
+
!/^- \[[ x]\] AC-\d+: \S.+/.test(line) ||
|
|
280
|
+
!settled(line.slice(line.indexOf(':') + 1)),
|
|
281
|
+
) ||
|
|
282
|
+
ids.some((id) => !rows.some((line) => line.includes(` ${id}:`))) ||
|
|
283
|
+
rows.some((line) => !ids.some((id) => line.includes(` ${id}:`)))
|
|
461
284
|
) {
|
|
462
285
|
errors.push(
|
|
463
286
|
issue(
|
|
464
|
-
'SDD-
|
|
465
|
-
'
|
|
466
|
-
|
|
287
|
+
'SDD-TASK-COVERAGE',
|
|
288
|
+
'Tasks must cover only known acceptance with concrete work',
|
|
289
|
+
recordName,
|
|
467
290
|
),
|
|
468
291
|
);
|
|
469
292
|
}
|
|
293
|
+
return rows;
|
|
294
|
+
}
|
|
470
295
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
const
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
296
|
+
export function validate({ change, through }) {
|
|
297
|
+
if (!gates.includes(through)) throw new Error('Invalid SDD validation gate');
|
|
298
|
+
const errors = [];
|
|
299
|
+
const warnings = [];
|
|
300
|
+
const specBaselines = [];
|
|
301
|
+
let location;
|
|
302
|
+
try {
|
|
303
|
+
location = resolveSddChangeLocation(change);
|
|
304
|
+
const rootStat = lstatMaybe(location.changeRoot);
|
|
305
|
+
if (!rootStat?.isDirectory() || rootStat.isSymbolicLink())
|
|
306
|
+
throw new Error('Change directory must be a regular directory');
|
|
307
|
+
const entries = readdirSync(location.changeRoot);
|
|
308
|
+
for (const entry of entries)
|
|
309
|
+
if (entry !== `${location.id}.md`)
|
|
310
|
+
errors.push(
|
|
311
|
+
issue(
|
|
312
|
+
'SDD-AUXILIARY-ARTIFACT',
|
|
313
|
+
`Only ${location.id}.md is allowed in a change directory; found ${entry}`,
|
|
314
|
+
`${location.id}.md`,
|
|
315
|
+
),
|
|
316
|
+
);
|
|
317
|
+
} catch (error) {
|
|
318
|
+
const code = error.code?.startsWith('SDD-')
|
|
319
|
+
? error.code
|
|
320
|
+
: 'SDD-CHANGE-PATH';
|
|
321
|
+
return {
|
|
322
|
+
valid: false,
|
|
323
|
+
through,
|
|
324
|
+
changeRoot: resolve(change),
|
|
325
|
+
errors: [issue(code, error.message)],
|
|
326
|
+
warnings,
|
|
327
|
+
deltas: [],
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const { projectRoot, changeRoot, id, recordPath, archived, archiveDate } =
|
|
332
|
+
location;
|
|
333
|
+
let text;
|
|
334
|
+
try {
|
|
335
|
+
const stat = lstatMaybe(recordPath);
|
|
336
|
+
if (!stat?.isFile() || stat.isSymbolicLink())
|
|
337
|
+
throw new Error(`${id}.md must be a regular file`);
|
|
338
|
+
text = readFileSync(recordPath, 'utf8');
|
|
339
|
+
} catch (error) {
|
|
340
|
+
errors.push(issue('SDD-CHANGE-MISSING', error.message, `${id}.md`));
|
|
341
|
+
return {
|
|
342
|
+
valid: false,
|
|
343
|
+
through,
|
|
344
|
+
projectRoot,
|
|
345
|
+
changeRoot,
|
|
346
|
+
changeId: id,
|
|
347
|
+
recordPath,
|
|
348
|
+
archived,
|
|
349
|
+
archiveDate,
|
|
350
|
+
errors,
|
|
351
|
+
warnings,
|
|
352
|
+
deltas: [],
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const recordName = `${id}.md`;
|
|
483
357
|
if (
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
358
|
+
!new RegExp(
|
|
359
|
+
`^# Change: ${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`,
|
|
360
|
+
'm',
|
|
361
|
+
).test(text) ||
|
|
362
|
+
!/^\*\*Classification\*\*: substantial$/m.test(text) ||
|
|
363
|
+
!/^\*\*Scope\*\*: (?:local|coordinated|cross-cutting)$/m.test(text) ||
|
|
364
|
+
!/^\*\*Uncertainty\*\*: (?:low|medium|high)$/m.test(text) ||
|
|
365
|
+
!/^\*\*Risk\*\*: (?:low|medium|high)$/m.test(text)
|
|
489
366
|
) {
|
|
490
367
|
errors.push(
|
|
491
368
|
issue(
|
|
492
|
-
'SDD-
|
|
493
|
-
'
|
|
494
|
-
|
|
369
|
+
'SDD-HEADER',
|
|
370
|
+
'Record identity, substantial classification, scope, uncertainty, and risk are required',
|
|
371
|
+
recordName,
|
|
495
372
|
),
|
|
496
373
|
);
|
|
497
374
|
}
|
|
498
|
-
|
|
499
|
-
[
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
) {
|
|
375
|
+
const headingList = [...text.matchAll(/^## ([^\r\n]+)$/gm)].map((match) =>
|
|
376
|
+
match[1].trim().toLowerCase(),
|
|
377
|
+
);
|
|
378
|
+
if (new Set(headingList).size !== headingList.length) {
|
|
503
379
|
errors.push(
|
|
504
380
|
issue(
|
|
505
|
-
'SDD-
|
|
506
|
-
'
|
|
507
|
-
|
|
381
|
+
'SDD-DUPLICATE-SECTION',
|
|
382
|
+
'Canonical record sections must be unique',
|
|
383
|
+
recordName,
|
|
508
384
|
),
|
|
509
385
|
);
|
|
510
386
|
}
|
|
511
|
-
}
|
|
512
387
|
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
if (spans.length !== 1) return undefined;
|
|
517
|
-
|
|
518
|
-
const rawPath = spans[0][1].trim().replaceAll('\\', '/');
|
|
388
|
+
const needed = [];
|
|
389
|
+
if (through === 'explore') needed.push('Exploration');
|
|
390
|
+
else needed.push('Exploration', 'Intent', 'Non-goals', 'Acceptance');
|
|
519
391
|
if (
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
const normalized = posix.normalize(rawPath);
|
|
533
|
-
if (
|
|
534
|
-
normalized === '.' ||
|
|
535
|
-
normalized === '..' ||
|
|
536
|
-
normalized.startsWith('../')
|
|
537
|
-
) {
|
|
538
|
-
return undefined;
|
|
539
|
-
}
|
|
540
|
-
return normalized.toLowerCase();
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
function taskDependencyEdges(content) {
|
|
544
|
-
return [...content.matchAll(/\bT\d{3}(?:\s*->\s*T\d{3})+\b/g)].flatMap(
|
|
545
|
-
([chain]) => {
|
|
546
|
-
const ids = chain.match(/\bT\d{3}\b/g) ?? [];
|
|
547
|
-
return ids.slice(1).map((target, index) => ({
|
|
548
|
-
source: ids[index],
|
|
549
|
-
target,
|
|
550
|
-
}));
|
|
551
|
-
},
|
|
552
|
-
);
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
function parseParallelGroups(content) {
|
|
556
|
-
const lines = content.split(/\r?\n/);
|
|
557
|
-
const headingIndexes = lines
|
|
558
|
-
.map((line, index) => (/^###\s+Group\s+P\d+\s*$/.test(line) ? index : -1))
|
|
559
|
-
.filter((index) => index >= 0);
|
|
560
|
-
const groups = [];
|
|
561
|
-
let valid = headingIndexes.length > 0;
|
|
562
|
-
|
|
392
|
+
[
|
|
393
|
+
'clarify',
|
|
394
|
+
'plan',
|
|
395
|
+
'tasks',
|
|
396
|
+
'checklist',
|
|
397
|
+
'ready',
|
|
398
|
+
'verify',
|
|
399
|
+
'closeout',
|
|
400
|
+
].includes(through)
|
|
401
|
+
)
|
|
402
|
+
needed.push('Clarifications', 'Decisions');
|
|
563
403
|
if (
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
)
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
for (const
|
|
572
|
-
const
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
const lane =
|
|
581
|
-
/^- Lane L(\d+): (T\d{3}(?: -> T\d{3})*)(?: \| Owner: (\S+))?\s*$/.exec(
|
|
582
|
-
line,
|
|
583
|
-
);
|
|
584
|
-
if (lane) {
|
|
585
|
-
lanes.push({
|
|
586
|
-
number: Number.parseInt(lane[1], 10),
|
|
587
|
-
taskIds: lane[2].split(' -> '),
|
|
588
|
-
owner: lane[3],
|
|
589
|
-
});
|
|
590
|
-
continue;
|
|
591
|
-
}
|
|
592
|
-
const prerequisiteLine =
|
|
593
|
-
/^- Prerequisites: (None|T\d{3}(?:, T\d{3})*)\s*$/.exec(line);
|
|
594
|
-
if (prerequisiteLine && prerequisites === undefined) {
|
|
595
|
-
prerequisites =
|
|
596
|
-
prerequisiteLine[1] === 'None' ? [] : prerequisiteLine[1].split(', ');
|
|
597
|
-
continue;
|
|
598
|
-
}
|
|
599
|
-
const barrierLine = /^- Barrier: (Final verification|T\d{3})\s*$/.exec(
|
|
600
|
-
line,
|
|
404
|
+
['plan', 'tasks', 'checklist', 'ready', 'verify', 'closeout'].includes(
|
|
405
|
+
through,
|
|
406
|
+
)
|
|
407
|
+
)
|
|
408
|
+
needed.push('Durable deltas', 'Plan');
|
|
409
|
+
if (['tasks', 'ready', 'verify', 'closeout'].includes(through))
|
|
410
|
+
needed.push('Tasks');
|
|
411
|
+
for (const name of needed) {
|
|
412
|
+
const content = section(text, name);
|
|
413
|
+
if (!settled(content)) {
|
|
414
|
+
errors.push(
|
|
415
|
+
issue(
|
|
416
|
+
'SDD-SECTION',
|
|
417
|
+
`${name} is missing, incomplete, or unresolved`,
|
|
418
|
+
recordName,
|
|
419
|
+
),
|
|
601
420
|
);
|
|
602
|
-
if (barrierLine && barrier === undefined) {
|
|
603
|
-
barrier = barrierLine[1];
|
|
604
|
-
continue;
|
|
605
|
-
}
|
|
606
|
-
const rationaleLine = /^- Rationale: (\S.*)$/.exec(line);
|
|
607
|
-
if (rationaleLine && rationale === undefined) {
|
|
608
|
-
rationale = rationaleLine[1];
|
|
609
|
-
continue;
|
|
610
|
-
}
|
|
611
|
-
valid = false;
|
|
612
|
-
}
|
|
613
|
-
if (
|
|
614
|
-
groupId === undefined ||
|
|
615
|
-
lanes.length < 2 ||
|
|
616
|
-
rationale === undefined ||
|
|
617
|
-
!isConcreteEvidence(rationale) ||
|
|
618
|
-
lanes.some((lane, index) => lane.number !== index + 1)
|
|
619
|
-
) {
|
|
620
|
-
valid = false;
|
|
621
421
|
}
|
|
622
|
-
groups.push({
|
|
623
|
-
number: Number.parseInt(groupId ?? '0', 10),
|
|
624
|
-
lanes,
|
|
625
|
-
prerequisites,
|
|
626
|
-
barrier,
|
|
627
|
-
rationale,
|
|
628
|
-
});
|
|
629
422
|
}
|
|
630
|
-
if (groups.some((group, index) => group.number !== index + 1)) valid = false;
|
|
631
|
-
return { groups, valid };
|
|
632
|
-
}
|
|
633
423
|
|
|
634
|
-
|
|
635
|
-
const
|
|
636
|
-
const taskLines = content
|
|
424
|
+
const acceptance = section(text, 'Acceptance') ?? '';
|
|
425
|
+
const candidates = acceptance
|
|
637
426
|
.split(/\r?\n/)
|
|
638
|
-
.filter((line) => /^-
|
|
639
|
-
const
|
|
640
|
-
/^- \[[ x~]\] T(\d{3})(?: \[P\])?(?: \[US(\d+)\])? .+`[^`\r\n]+`(?: \| Verify: \S.+)?\s*$/;
|
|
427
|
+
.filter((line) => /^- AC-/.test(line));
|
|
428
|
+
const clauses = candidates.map((line) => /^- (AC-\d+): (\S.+)$/.exec(line));
|
|
641
429
|
if (
|
|
642
|
-
|
|
643
|
-
|
|
430
|
+
!clauses.length ||
|
|
431
|
+
clauses.some((match) => !match || !settled(match[2])) ||
|
|
432
|
+
new Set(clauses.map((match) => match?.[1])).size !== clauses.length
|
|
644
433
|
) {
|
|
645
434
|
errors.push(
|
|
646
435
|
issue(
|
|
647
|
-
'SDD-
|
|
648
|
-
|
|
649
|
-
|
|
436
|
+
'SDD-ACCEPTANCE',
|
|
437
|
+
'Acceptance requires unique, concrete AC identifiers',
|
|
438
|
+
recordName,
|
|
650
439
|
),
|
|
651
440
|
);
|
|
652
441
|
}
|
|
653
|
-
const
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
442
|
+
const ids = clauses.filter(Boolean).map((match) => match[1]);
|
|
443
|
+
if (['tasks', 'ready', 'verify', 'closeout'].includes(through)) {
|
|
444
|
+
const rows = coverageErrors(text, ids, errors, recordName);
|
|
445
|
+
if (
|
|
446
|
+
['verify', 'closeout'].includes(through) &&
|
|
447
|
+
rows.some((line) => !line.startsWith('- [x]'))
|
|
448
|
+
)
|
|
449
|
+
errors.push(
|
|
450
|
+
issue('SDD-TASK-INCOMPLETE', 'All tasks must be complete', recordName),
|
|
451
|
+
);
|
|
659
452
|
}
|
|
660
453
|
if (
|
|
661
|
-
|
|
662
|
-
|
|
454
|
+
[
|
|
455
|
+
'clarify',
|
|
456
|
+
'plan',
|
|
457
|
+
'tasks',
|
|
458
|
+
'checklist',
|
|
459
|
+
'ready',
|
|
460
|
+
'verify',
|
|
461
|
+
'closeout',
|
|
462
|
+
].includes(through)
|
|
663
463
|
) {
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
if (spec) {
|
|
674
|
-
const storyTags = new Set(
|
|
675
|
-
parsedTasks
|
|
676
|
-
.map(({ match }) => match[2])
|
|
677
|
-
.filter((value) => value !== undefined),
|
|
678
|
-
);
|
|
679
|
-
const missing = [
|
|
680
|
-
...spec.storyIds
|
|
681
|
-
.filter((id) => !storyTags.has(String(Number.parseInt(id, 10))))
|
|
682
|
-
.map((id) => `US${Number.parseInt(id, 10)}`),
|
|
683
|
-
...spec.frIds
|
|
684
|
-
.filter(
|
|
685
|
-
(id) => !new RegExp(`\\bFR-${id}\\b`).test(taskLines.join('\n')),
|
|
686
|
-
)
|
|
687
|
-
.map((id) => `FR-${id}`),
|
|
688
|
-
...spec.buildableScIds
|
|
689
|
-
.filter(
|
|
690
|
-
(id) => !new RegExp(`\\bSC-${id}\\b`).test(taskLines.join('\n')),
|
|
691
|
-
)
|
|
692
|
-
.map((id) => `SC-${id}`),
|
|
693
|
-
];
|
|
694
|
-
const unknownStories = [...storyTags]
|
|
695
|
-
.filter(
|
|
696
|
-
(id) =>
|
|
697
|
-
!spec.storyIds.some(
|
|
698
|
-
(storyId) =>
|
|
699
|
-
Number.parseInt(storyId, 10) === Number.parseInt(id, 10),
|
|
464
|
+
for (const name of ['Clarifications', 'Decisions']) {
|
|
465
|
+
if (!settled(section(text, name))) {
|
|
466
|
+
errors.push(
|
|
467
|
+
issue(
|
|
468
|
+
'SDD-DECISION',
|
|
469
|
+
'Material choices must be settled before classification proceeds',
|
|
470
|
+
recordName,
|
|
700
471
|
),
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
if (missing.length > 0 || unknownStories.length > 0) {
|
|
704
|
-
errors.push(
|
|
705
|
-
issue(
|
|
706
|
-
'SDD-TASK-COVERAGE',
|
|
707
|
-
artifact,
|
|
708
|
-
`Task coverage is incomplete. Missing: ${missing.join(', ') || 'none'}; unknown stories: ${unknownStories.join(', ') || 'none'}.`,
|
|
709
|
-
),
|
|
710
|
-
);
|
|
472
|
+
);
|
|
473
|
+
}
|
|
711
474
|
}
|
|
712
475
|
}
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
'Identify the independently testable MVP.',
|
|
719
|
-
),
|
|
720
|
-
);
|
|
721
|
-
}
|
|
722
|
-
if (!/^##\s+Dependencies\s*$/im.test(content)) {
|
|
723
|
-
errors.push(
|
|
724
|
-
issue('SDD-TASK-DEPENDENCIES', artifact, 'Document task dependencies.'),
|
|
725
|
-
);
|
|
726
|
-
}
|
|
727
|
-
const parallelSection = sectionContent(
|
|
728
|
-
content,
|
|
729
|
-
'Parallel execution(?: examples)?',
|
|
730
|
-
);
|
|
731
|
-
const taskRecords = parsedTasks.map(({ line, match }) => ({
|
|
732
|
-
id: `T${match[1]}`,
|
|
733
|
-
parallel: /\[P\]/.test(line),
|
|
734
|
-
path: exactTaskPath(line),
|
|
735
|
-
}));
|
|
736
|
-
const tasksById = new Map(taskRecords.map((task) => [task.id, task]));
|
|
737
|
-
const hasParallelTask = taskRecords.some((task) => task.parallel);
|
|
738
|
-
const noParallelMatch = /^-\s+None:\s*(\S.*)$/im.exec(parallelSection ?? '');
|
|
739
|
-
const explainsNoParallelWork =
|
|
740
|
-
noParallelMatch !== null && isConcreteEvidence(noParallelMatch[1]);
|
|
741
|
-
const pathsOverlap = (left, right) =>
|
|
742
|
-
left === right ||
|
|
743
|
-
left.startsWith(`${right}/`) ||
|
|
744
|
-
right.startsWith(`${left}/`);
|
|
745
|
-
const noneOnly =
|
|
746
|
-
explainsNoParallelWork &&
|
|
747
|
-
parallelSection?.trim() === noParallelMatch?.[0].trim();
|
|
748
|
-
const { groups, valid: validGroupGrammar } = parseParallelGroups(
|
|
749
|
-
parallelSection ?? '',
|
|
750
|
-
);
|
|
751
|
-
const declaredDependencies = taskDependencyEdges(
|
|
752
|
-
sectionContent(content, 'Dependencies') ?? '',
|
|
753
|
-
);
|
|
754
|
-
const hasGroups = groups.length > 0;
|
|
476
|
+
|
|
477
|
+
const deltaSection = section(text, 'Durable deltas');
|
|
478
|
+
const deltas = deltaSection
|
|
479
|
+
? parseDeltas(deltaSection, errors, recordName)
|
|
480
|
+
: [];
|
|
755
481
|
if (
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
482
|
+
deltaSection &&
|
|
483
|
+
['plan', 'tasks', 'checklist', 'ready', 'verify', 'closeout'].includes(
|
|
484
|
+
through,
|
|
485
|
+
)
|
|
760
486
|
) {
|
|
761
|
-
errors
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
)
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
),
|
|
784
|
-
);
|
|
785
|
-
const membershipCounts = new Map();
|
|
786
|
-
for (const { taskId } of memberships) {
|
|
787
|
-
membershipCounts.set(taskId, (membershipCounts.get(taskId) ?? 0) + 1);
|
|
788
|
-
}
|
|
789
|
-
const invalidMembership =
|
|
790
|
-
memberships.some(({ taskId }) => !tasksById.get(taskId)?.parallel) ||
|
|
791
|
-
taskRecords.some(
|
|
792
|
-
(task) => task.parallel && membershipCounts.get(task.id) !== 1,
|
|
793
|
-
);
|
|
794
|
-
if (invalidMembership) {
|
|
487
|
+
preflightDeltas(projectRoot, deltas, errors, warnings, recordName);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (['verify', 'closeout'].includes(through)) {
|
|
491
|
+
const authorizationHeading = /^## Authorization\s*$/m.exec(text);
|
|
492
|
+
const auth = section(text, 'Authorization') ?? '';
|
|
493
|
+
const reviewDisposition = /^\*\*Plan review\*\*: (SKIPPED|OKAY)$/m.exec(
|
|
494
|
+
auth,
|
|
495
|
+
)?.[1];
|
|
496
|
+
const selectionLines =
|
|
497
|
+
auth.match(/^\*\*Plan review selection\*\*:.*$/gm) ?? [];
|
|
498
|
+
const reviewSelection =
|
|
499
|
+
selectionLines.length === 1
|
|
500
|
+
? /^\*\*Plan review selection\*\*: (EXPLICIT_REVIEW|EXPLICIT_SKIP|DEFAULT_REVIEW_AFTER_3)$/.exec(
|
|
501
|
+
selectionLines[0],
|
|
502
|
+
)?.[1]
|
|
503
|
+
: undefined;
|
|
504
|
+
if (
|
|
505
|
+
!authorizationHeading ||
|
|
506
|
+
!reviewDisposition ||
|
|
507
|
+
!/^\*\*Implementation\*\*: AUTHORIZED$/m.test(auth)
|
|
508
|
+
) {
|
|
795
509
|
errors.push(
|
|
796
510
|
issue(
|
|
797
|
-
'SDD-
|
|
798
|
-
|
|
799
|
-
|
|
511
|
+
'SDD-AUTHORIZATION',
|
|
512
|
+
'Plan-review disposition and separate implementation authorization are required',
|
|
513
|
+
recordName,
|
|
800
514
|
),
|
|
801
515
|
);
|
|
802
516
|
}
|
|
803
|
-
|
|
804
517
|
if (
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
518
|
+
!(
|
|
519
|
+
(reviewDisposition === 'SKIPPED' &&
|
|
520
|
+
reviewSelection === 'EXPLICIT_SKIP') ||
|
|
521
|
+
(reviewDisposition === 'OKAY' &&
|
|
522
|
+
['EXPLICIT_REVIEW', 'DEFAULT_REVIEW_AFTER_3'].includes(
|
|
523
|
+
reviewSelection,
|
|
524
|
+
))
|
|
809
525
|
)
|
|
810
526
|
) {
|
|
811
527
|
errors.push(
|
|
812
528
|
issue(
|
|
813
|
-
'SDD-
|
|
814
|
-
|
|
815
|
-
|
|
529
|
+
'SDD-REVIEW-SELECTION',
|
|
530
|
+
'Plan-review selection must support the recorded disposition',
|
|
531
|
+
recordName,
|
|
816
532
|
),
|
|
817
533
|
);
|
|
818
534
|
}
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
if (!hasPathIndependenceEvidence || !hasDependencyIndependenceEvidence) {
|
|
535
|
+
const verificationText =
|
|
536
|
+
section(text, 'Verification', { trim: false }) ?? '';
|
|
537
|
+
const verified = verificationText.trim();
|
|
538
|
+
if (through === 'closeout') {
|
|
539
|
+
const prefix = text.slice(0, authorizationHeading?.index ?? text.length);
|
|
540
|
+
if (
|
|
541
|
+
!/^\*\*Reviewer\*\*: oracle$/m.test(verified) ||
|
|
542
|
+
!/^\*\*Independent from implementer\*\*: Yes$/m.test(verified) ||
|
|
543
|
+
!/^\*\*Verdict\*\*: PASS$/m.test(verified)
|
|
544
|
+
) {
|
|
830
545
|
errors.push(
|
|
831
546
|
issue(
|
|
832
|
-
'SDD-
|
|
833
|
-
|
|
834
|
-
|
|
547
|
+
'SDD-VERIFICATION',
|
|
548
|
+
'Fresh independent Oracle PASS is required',
|
|
549
|
+
recordName,
|
|
835
550
|
),
|
|
836
551
|
);
|
|
837
552
|
}
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
const
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
) {
|
|
553
|
+
const digestLines =
|
|
554
|
+
verificationText.match(/^\*\*Reviewed record SHA-256\*\*:.*$/gm) ?? [];
|
|
555
|
+
const reviewedDigest =
|
|
556
|
+
digestLines.length === 1
|
|
557
|
+
? /^\*\*Reviewed record SHA-256\*\*: ([a-f0-9]{64})$/.exec(
|
|
558
|
+
digestLines[0],
|
|
559
|
+
)?.[1]
|
|
560
|
+
: undefined;
|
|
561
|
+
if (reviewedDigest !== sha(prefix)) {
|
|
848
562
|
errors.push(
|
|
849
563
|
issue(
|
|
850
|
-
'SDD-
|
|
851
|
-
|
|
852
|
-
|
|
564
|
+
'SDD-VERIFICATION-STALE',
|
|
565
|
+
'Reviewed record does not match the content before Authorization',
|
|
566
|
+
recordName,
|
|
853
567
|
),
|
|
854
568
|
);
|
|
855
569
|
}
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
);
|
|
860
|
-
const barrierNumber = /^T\d{3}$/.test(group.barrier ?? '')
|
|
861
|
-
? Number.parseInt(group.barrier.slice(1), 10)
|
|
862
|
-
: undefined;
|
|
570
|
+
const rows = verified
|
|
571
|
+
.split(/\r?\n/)
|
|
572
|
+
.filter((line) => /^- AC-/.test(line));
|
|
863
573
|
if (
|
|
864
|
-
|
|
865
|
-
(
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
574
|
+
rows.length !== ids.length ||
|
|
575
|
+
ids.some(
|
|
576
|
+
(id) =>
|
|
577
|
+
!rows.some((row) =>
|
|
578
|
+
new RegExp(
|
|
579
|
+
`^- ${id}: PASS \\| (?!none|TBD)\\S.+ \\| (?!none|TBD)\\S.+$`,
|
|
580
|
+
'i',
|
|
581
|
+
).test(row),
|
|
582
|
+
),
|
|
583
|
+
)
|
|
870
584
|
) {
|
|
871
585
|
errors.push(
|
|
872
586
|
issue(
|
|
873
|
-
'SDD-
|
|
874
|
-
|
|
875
|
-
|
|
587
|
+
'SDD-VERIFICATION-COVERAGE',
|
|
588
|
+
'Every accepted outcome needs concrete PASS check and evidence',
|
|
589
|
+
recordName,
|
|
876
590
|
),
|
|
877
591
|
);
|
|
878
592
|
}
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
593
|
+
}
|
|
594
|
+
const sources = verified
|
|
595
|
+
.split(/\r?\n/)
|
|
596
|
+
.filter((line) => line.startsWith('- Source:'));
|
|
597
|
+
if (!sources.length) {
|
|
598
|
+
errors.push(
|
|
599
|
+
issue(
|
|
600
|
+
'SDD-VERIFICATION-SOURCE',
|
|
601
|
+
'Reviewed source digests are required',
|
|
602
|
+
recordName,
|
|
883
603
|
),
|
|
884
604
|
);
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
605
|
+
}
|
|
606
|
+
const affectedSpecs = new Map(
|
|
607
|
+
[...new Set(deltas.map((delta) => delta.capability))].map(
|
|
608
|
+
(capability) => [`.thoth/specs/${capability}/spec.md`, capability],
|
|
609
|
+
),
|
|
610
|
+
);
|
|
611
|
+
const reviewedSources = [];
|
|
612
|
+
for (const line of sources) {
|
|
613
|
+
const match = /^- Source: ([^ |]+) \| sha256:([a-f0-9]{64})$/.exec(line);
|
|
614
|
+
const absent = /^- Source: ([^ |]+) \| absent$/.exec(line);
|
|
615
|
+
if (match) {
|
|
616
|
+
const source = {
|
|
617
|
+
line,
|
|
618
|
+
path: match[1],
|
|
619
|
+
state: 'present',
|
|
620
|
+
sha256: match[2],
|
|
621
|
+
valid: false,
|
|
622
|
+
};
|
|
623
|
+
reviewedSources.push(source);
|
|
624
|
+
try {
|
|
625
|
+
if (
|
|
626
|
+
sha(readFileSync(localFile(projectRoot, source.path))) !==
|
|
627
|
+
source.sha256
|
|
628
|
+
)
|
|
629
|
+
throw new Error('Missing, invalid or stale source');
|
|
630
|
+
source.valid = true;
|
|
631
|
+
} catch (error) {
|
|
632
|
+
errors.push(
|
|
633
|
+
issue(
|
|
634
|
+
affectedSpecs.has(source.path)
|
|
635
|
+
? 'SDD-VERIFICATION-SPEC-BASELINE'
|
|
636
|
+
: 'SDD-VERIFICATION-SOURCE',
|
|
637
|
+
`${line}: ${error.message}`,
|
|
638
|
+
recordName,
|
|
639
|
+
),
|
|
640
|
+
);
|
|
641
|
+
}
|
|
642
|
+
} else if (absent && affectedSpecs.has(absent[1])) {
|
|
643
|
+
reviewedSources.push({
|
|
644
|
+
line,
|
|
645
|
+
path: absent[1],
|
|
646
|
+
state: 'absent',
|
|
647
|
+
valid: true,
|
|
648
|
+
});
|
|
649
|
+
} else {
|
|
893
650
|
errors.push(
|
|
894
651
|
issue(
|
|
895
|
-
'SDD-
|
|
896
|
-
|
|
897
|
-
|
|
652
|
+
'SDD-VERIFICATION-SOURCE',
|
|
653
|
+
`${line}: Missing, invalid or stale source`,
|
|
654
|
+
recordName,
|
|
898
655
|
),
|
|
899
656
|
);
|
|
900
657
|
}
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
658
|
+
}
|
|
659
|
+
for (const [specPath, capability] of affectedSpecs) {
|
|
660
|
+
const entries = reviewedSources.filter(
|
|
661
|
+
(source) => source.path === specPath,
|
|
904
662
|
);
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
path !== undefined &&
|
|
912
|
-
otherPaths.some(
|
|
913
|
-
(other) => other !== undefined && pathsOverlap(path, other),
|
|
914
|
-
),
|
|
915
|
-
),
|
|
663
|
+
if (entries.length !== 1) {
|
|
664
|
+
errors.push(
|
|
665
|
+
issue(
|
|
666
|
+
'SDD-VERIFICATION-SPEC-BASELINE',
|
|
667
|
+
`${specPath}: exactly one reviewed canonical baseline is required`,
|
|
668
|
+
recordName,
|
|
916
669
|
),
|
|
917
|
-
|
|
918
|
-
|
|
670
|
+
);
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
const [baseline] = entries;
|
|
674
|
+
const target = resolve(projectRoot, specPath);
|
|
675
|
+
try {
|
|
676
|
+
assertNoSymlinkAncestors(target);
|
|
677
|
+
if (baseline.state === 'absent') {
|
|
678
|
+
if (lstatMaybe(target))
|
|
679
|
+
throw new Error(
|
|
680
|
+
'Reviewed-absent canonical specification now exists',
|
|
681
|
+
);
|
|
682
|
+
let parent = dirname(target);
|
|
683
|
+
while (
|
|
684
|
+
parent !== projectRoot &&
|
|
685
|
+
parent.startsWith(`${projectRoot}${sep}`)
|
|
686
|
+
) {
|
|
687
|
+
const stat = lstatMaybe(parent);
|
|
688
|
+
if (stat && (!stat.isDirectory() || stat.isSymbolicLink()))
|
|
689
|
+
throw new Error(
|
|
690
|
+
'Canonical specification parent is not a directory',
|
|
691
|
+
);
|
|
692
|
+
parent = dirname(parent);
|
|
693
|
+
}
|
|
694
|
+
specBaselines.push({ capability, path: specPath, state: 'absent' });
|
|
695
|
+
} else if (baseline.valid) {
|
|
696
|
+
specBaselines.push({
|
|
697
|
+
capability,
|
|
698
|
+
path: specPath,
|
|
699
|
+
state: 'present',
|
|
700
|
+
sha256: baseline.sha256,
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
} catch (error) {
|
|
919
704
|
errors.push(
|
|
920
705
|
issue(
|
|
921
|
-
'SDD-
|
|
922
|
-
|
|
923
|
-
|
|
706
|
+
'SDD-VERIFICATION-SPEC-BASELINE',
|
|
707
|
+
`${specPath}: ${error.message}`,
|
|
708
|
+
recordName,
|
|
924
709
|
),
|
|
925
710
|
);
|
|
926
711
|
}
|
|
927
712
|
}
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
const artifact = 'checklists/requirements.md';
|
|
933
|
-
const activationMatch = /^\*\*Activation reason\*\*:\s*(\S.*)$/im.exec(
|
|
934
|
-
content,
|
|
935
|
-
);
|
|
936
|
-
if (activationMatch === null || !isConcreteEvidence(activationMatch[1])) {
|
|
937
|
-
errors.push(
|
|
938
|
-
issue(
|
|
939
|
-
'SDD-CHECKLIST-ACTIVATION',
|
|
940
|
-
artifact,
|
|
941
|
-
'Record the concrete risk or ambiguity that activated the checklist.',
|
|
942
|
-
),
|
|
943
|
-
);
|
|
944
|
-
}
|
|
945
|
-
const checklistLines = content
|
|
946
|
-
.split(/\r?\n/)
|
|
947
|
-
.filter((line) => /^- \[[ x~]\]/.test(line));
|
|
948
|
-
const checklistShape = /^- \[([ x~])\] CHK(\d{3})\b.+$/i;
|
|
949
|
-
const parsed = checklistLines
|
|
950
|
-
.map((line) => checklistShape.exec(line))
|
|
951
|
-
.filter((match) => match !== null);
|
|
952
|
-
if (
|
|
953
|
-
checklistLines.length === 0 ||
|
|
954
|
-
checklistLines.some((line) => !checklistShape.test(line))
|
|
955
|
-
) {
|
|
956
|
-
errors.push(
|
|
957
|
-
issue(
|
|
958
|
-
'SDD-CHECKLIST-ID',
|
|
959
|
-
artifact,
|
|
960
|
-
'Checklist items must use CHK### IDs.',
|
|
961
|
-
),
|
|
962
|
-
);
|
|
963
|
-
}
|
|
964
|
-
if (parsed.length > 0) {
|
|
965
|
-
checkUniqueSequential(
|
|
966
|
-
parsed.map((match) => match[2]),
|
|
967
|
-
'CHECKLIST',
|
|
968
|
-
artifact,
|
|
969
|
-
errors,
|
|
970
|
-
);
|
|
971
|
-
}
|
|
972
|
-
if (requireComplete && parsed.some((match) => match[1] !== 'x')) {
|
|
973
|
-
errors.push(
|
|
974
|
-
issue(
|
|
975
|
-
'SDD-CHECKLIST-INCOMPLETE',
|
|
976
|
-
artifact,
|
|
977
|
-
'Final validation requires every checklist item to be complete.',
|
|
978
|
-
),
|
|
979
|
-
);
|
|
980
|
-
}
|
|
981
|
-
const initial = sectionContent(content, 'Initial validation');
|
|
982
|
-
const domainLenses = sectionContent(content, 'Domain lenses');
|
|
983
|
-
const revalidation = sectionContent(content, 'Revalidation');
|
|
984
|
-
const noopRevalidationMatch =
|
|
985
|
-
revalidation === undefined
|
|
986
|
-
? null
|
|
987
|
-
: /^-\s+Not required:\s*(\S.*)$/im.exec(revalidation);
|
|
988
|
-
const explicitNoopRevalidation =
|
|
989
|
-
noopRevalidationMatch !== null &&
|
|
990
|
-
isConcreteEvidence(noopRevalidationMatch[1]);
|
|
991
|
-
const hasRevalidationItems =
|
|
992
|
-
revalidation !== undefined &&
|
|
993
|
-
/^- \[[ x~]\] CHK\d{3}\b/im.test(revalidation);
|
|
994
|
-
const hasDomainItems =
|
|
995
|
-
domainLenses !== undefined &&
|
|
996
|
-
/^- \[[ x~]\] CHK\d{3}\b/im.test(domainLenses);
|
|
997
|
-
const noDomainLensesMatch =
|
|
998
|
-
domainLenses === undefined
|
|
999
|
-
? null
|
|
1000
|
-
: /^-\s+None:\s*(\S.*)$/im.exec(domainLenses);
|
|
1001
|
-
const explicitNoDomainLenses =
|
|
1002
|
-
noDomainLensesMatch !== null && isConcreteEvidence(noDomainLensesMatch[1]);
|
|
1003
|
-
const missingDimensions = [
|
|
1004
|
-
'Completeness',
|
|
1005
|
-
'Clarity',
|
|
1006
|
-
'Consistency',
|
|
1007
|
-
'Measurability',
|
|
1008
|
-
'Coverage',
|
|
1009
|
-
].filter(
|
|
1010
|
-
(dimension) => !new RegExp(`\\[${dimension}\\]`, 'i').test(initial ?? ''),
|
|
1011
|
-
);
|
|
1012
|
-
if (missingDimensions.length > 0) {
|
|
1013
|
-
errors.push(
|
|
1014
|
-
issue(
|
|
1015
|
-
'SDD-CHECKLIST-TAXONOMY',
|
|
1016
|
-
artifact,
|
|
1017
|
-
`Missing requirement-quality dimensions: ${missingDimensions.join(', ')}.`,
|
|
1018
|
-
),
|
|
1019
|
-
);
|
|
1020
|
-
}
|
|
1021
|
-
if (
|
|
1022
|
-
domainLenses === undefined ||
|
|
1023
|
-
(!hasDomainItems && !explicitNoDomainLenses) ||
|
|
1024
|
-
(hasDomainItems && explicitNoDomainLenses)
|
|
1025
|
-
) {
|
|
1026
|
-
errors.push(
|
|
1027
|
-
issue(
|
|
1028
|
-
'SDD-CHECKLIST-DOMAIN-LENSES',
|
|
1029
|
-
artifact,
|
|
1030
|
-
'Add applicable domain-lens checks or an evidence-backed None decision.',
|
|
1031
|
-
),
|
|
1032
|
-
);
|
|
1033
|
-
}
|
|
1034
|
-
if (
|
|
1035
|
-
initial === undefined ||
|
|
1036
|
-
revalidation === undefined ||
|
|
1037
|
-
(!hasRevalidationItems && !explicitNoopRevalidation) ||
|
|
1038
|
-
(hasRevalidationItems && explicitNoopRevalidation)
|
|
1039
|
-
) {
|
|
1040
|
-
errors.push(
|
|
1041
|
-
issue(
|
|
1042
|
-
'SDD-CHECKLIST-REVALIDATION',
|
|
1043
|
-
artifact,
|
|
1044
|
-
'Record initial validation plus checked revalidation or an evidence-backed no-op.',
|
|
1045
|
-
),
|
|
1046
|
-
);
|
|
1047
|
-
}
|
|
1048
|
-
if (spec) {
|
|
1049
|
-
const missing = [
|
|
1050
|
-
...spec.storyIds
|
|
1051
|
-
.filter(
|
|
1052
|
-
(id) =>
|
|
1053
|
-
!new RegExp(`\\bUS${Number.parseInt(id, 10)}\\b`).test(content),
|
|
1054
|
-
)
|
|
1055
|
-
.map((id) => `US${Number.parseInt(id, 10)}`),
|
|
1056
|
-
...spec.frIds
|
|
1057
|
-
.filter((id) => !new RegExp(`\\bFR-${id}\\b`).test(content))
|
|
1058
|
-
.map((id) => `FR-${id}`),
|
|
1059
|
-
...spec.scIds
|
|
1060
|
-
.filter((id) => !new RegExp(`\\bSC-${id}\\b`).test(content))
|
|
1061
|
-
.map((id) => `SC-${id}`),
|
|
1062
|
-
];
|
|
1063
|
-
if (missing.length > 0) {
|
|
713
|
+
if (
|
|
714
|
+
through === 'closeout' &&
|
|
715
|
+
!/^\*\*Archive\*\*: READY$/m.test(section(text, 'Closeout') ?? '')
|
|
716
|
+
) {
|
|
1064
717
|
errors.push(
|
|
1065
|
-
issue(
|
|
1066
|
-
'SDD-CHECKLIST-COVERAGE',
|
|
1067
|
-
artifact,
|
|
1068
|
-
`Checklist coverage is missing: ${missing.join(', ')}.`,
|
|
1069
|
-
),
|
|
718
|
+
issue('SDD-ARCHIVE-READINESS', 'Archive must be READY', recordName),
|
|
1070
719
|
);
|
|
1071
720
|
}
|
|
1072
721
|
}
|
|
1073
|
-
}
|
|
1074
|
-
|
|
1075
|
-
function validateCloseoutTasks(content, errors) {
|
|
1076
|
-
if (/^- \[(?: |~)\] T\d{3}\b/m.test(content)) {
|
|
1077
|
-
errors.push(
|
|
1078
|
-
issue(
|
|
1079
|
-
'SDD-CLOSEOUT-TASKS',
|
|
1080
|
-
'tasks.md',
|
|
1081
|
-
'Closeout requires every task to be complete.',
|
|
1082
|
-
),
|
|
1083
|
-
);
|
|
1084
|
-
}
|
|
1085
|
-
}
|
|
1086
|
-
|
|
1087
|
-
function parseComplianceMatrix(content) {
|
|
1088
|
-
const matrix = sectionContent(content, 'Compliance matrix') ?? '';
|
|
1089
|
-
const candidates = matrix
|
|
1090
|
-
.split(/\r?\n/)
|
|
1091
|
-
.filter((line) => /^\s*\|\s*(?:FR|SC)-/i.test(line));
|
|
1092
|
-
const entries = [
|
|
1093
|
-
...matrix.matchAll(
|
|
1094
|
-
/^\|\s*((?:FR|SC)-\d{3})(?:\s+`?\[(?:buildable|outcome)\]`?)?\s*\|\s*(.*?)\s*\|\s*(.*?)\s*\|\s*(PASS|RISK|FAIL)\s*\|\s*$/gim,
|
|
1095
|
-
),
|
|
1096
|
-
].map((match) => ({
|
|
1097
|
-
id: match[1].toUpperCase(),
|
|
1098
|
-
evidence: match[2],
|
|
1099
|
-
check: match[3],
|
|
1100
|
-
result: match[4].toUpperCase(),
|
|
1101
|
-
}));
|
|
1102
|
-
return { entries, malformed: candidates.length !== entries.length };
|
|
1103
|
-
}
|
|
1104
|
-
|
|
1105
|
-
function isConcreteEvidence(value) {
|
|
1106
|
-
const normalized = value.replaceAll('`', '').trim();
|
|
1107
|
-
return (
|
|
1108
|
-
normalized.length > 0 &&
|
|
1109
|
-
!/^\[.*\]$/.test(normalized) &&
|
|
1110
|
-
!/^(?:N\/?A|NONE|PENDING|TBD|NOT YET|-)[.!]?$/i.test(normalized)
|
|
1111
|
-
);
|
|
1112
|
-
}
|
|
1113
|
-
|
|
1114
|
-
function hasExplicitResidualRisk(content, id) {
|
|
1115
|
-
const residualRisks = sectionContent(content, 'Residual risks') ?? '';
|
|
1116
|
-
const match = new RegExp(`^-\\s+${id}\\s*:\\s*(\\S.*)$`, 'im').exec(
|
|
1117
|
-
residualRisks,
|
|
1118
|
-
);
|
|
1119
|
-
return match !== null && isConcreteEvidence(match[1]);
|
|
1120
|
-
}
|
|
1121
|
-
|
|
1122
|
-
function hasBlockingCriticalFinding(content) {
|
|
1123
|
-
const findings = sectionContent(content, 'Findings') ?? '';
|
|
1124
|
-
return findings.split(/\r?\n/).some((line) => {
|
|
1125
|
-
if (!/\bCRITICAL\b/i.test(line)) return false;
|
|
1126
|
-
if (/\bRESOLVED\b/i.test(line)) return false;
|
|
1127
|
-
return !/\b(?:NO|ZERO)\s+(?:OPEN\s+)?CRITICAL\b/i.test(line);
|
|
1128
|
-
});
|
|
1129
|
-
}
|
|
1130
|
-
|
|
1131
|
-
function hasConcreteReviewDimensions(content) {
|
|
1132
|
-
const dimensions = sectionContent(content, 'Review dimensions') ?? '';
|
|
1133
|
-
return ['Completeness', 'Correctness', 'Coherence'].every((dimension) => {
|
|
1134
|
-
const match = new RegExp(
|
|
1135
|
-
`^-\\s*\\*\\*${dimension}\\*\\*:\\s*(\\S.*)$`,
|
|
1136
|
-
'im',
|
|
1137
|
-
).exec(dimensions);
|
|
1138
|
-
return match !== null && isConcreteEvidence(match[1]);
|
|
1139
|
-
});
|
|
1140
|
-
}
|
|
1141
|
-
|
|
1142
|
-
function validateVerifyReport(content, errors, spec) {
|
|
1143
|
-
const artifact = 'verify-report.md';
|
|
1144
|
-
if (
|
|
1145
|
-
!/^\*\*Reviewer\*\*:\s*oracle(?:<br>)?\s*$/im.test(content) ||
|
|
1146
|
-
!/^\*\*Independent from implementer\*\*:\s*Yes(?:<br>)?\s*$/im.test(content)
|
|
1147
|
-
) {
|
|
1148
|
-
errors.push(
|
|
1149
|
-
issue(
|
|
1150
|
-
'SDD-VERIFY-INDEPENDENCE',
|
|
1151
|
-
artifact,
|
|
1152
|
-
'Closeout requires an oracle reviewer independent from the implementer.',
|
|
1153
|
-
),
|
|
1154
|
-
);
|
|
1155
|
-
}
|
|
1156
|
-
if (!/^\*\*Verdict\*\*:\s*PASS\s*$/im.test(content)) {
|
|
1157
|
-
errors.push(
|
|
1158
|
-
issue(
|
|
1159
|
-
'SDD-VERIFY-VERDICT',
|
|
1160
|
-
artifact,
|
|
1161
|
-
'Closeout requires an explicit PASS verdict.',
|
|
1162
|
-
),
|
|
1163
|
-
);
|
|
1164
|
-
}
|
|
1165
|
-
if (hasBlockingCriticalFinding(content)) {
|
|
1166
|
-
errors.push(
|
|
1167
|
-
issue(
|
|
1168
|
-
'SDD-VERIFY-CRITICAL',
|
|
1169
|
-
artifact,
|
|
1170
|
-
'Unresolved CRITICAL verification findings block closeout.',
|
|
1171
|
-
),
|
|
1172
|
-
);
|
|
1173
|
-
}
|
|
1174
|
-
|
|
1175
|
-
if (!hasConcreteReviewDimensions(content)) {
|
|
1176
|
-
errors.push(
|
|
1177
|
-
issue(
|
|
1178
|
-
'SDD-VERIFY-DIMENSIONS',
|
|
1179
|
-
artifact,
|
|
1180
|
-
'Oracle must judge completeness, correctness, and coherence separately.',
|
|
1181
|
-
),
|
|
1182
|
-
);
|
|
1183
|
-
}
|
|
1184
|
-
|
|
1185
|
-
const matrix = parseComplianceMatrix(content);
|
|
1186
|
-
const knownIds = new Set([
|
|
1187
|
-
...spec.frIds.map((id) => `FR-${id}`),
|
|
1188
|
-
...spec.scIds.map((id) => `SC-${id}`),
|
|
1189
|
-
]);
|
|
1190
|
-
const rowIds = matrix.entries.map(({ id }) => id);
|
|
1191
|
-
const duplicateIds = rowIds.filter(
|
|
1192
|
-
(id, index) => rowIds.indexOf(id) !== index,
|
|
1193
|
-
);
|
|
1194
|
-
const unknownIds = rowIds.filter((id) => !knownIds.has(id));
|
|
1195
|
-
const failedIds = matrix.entries
|
|
1196
|
-
.filter(({ result }) => result === 'FAIL')
|
|
1197
|
-
.map(({ id }) => id);
|
|
1198
|
-
if (
|
|
1199
|
-
matrix.malformed ||
|
|
1200
|
-
duplicateIds.length > 0 ||
|
|
1201
|
-
unknownIds.length > 0 ||
|
|
1202
|
-
failedIds.length > 0
|
|
1203
|
-
) {
|
|
1204
|
-
errors.push(
|
|
1205
|
-
issue(
|
|
1206
|
-
'SDD-VERIFY-MATRIX',
|
|
1207
|
-
artifact,
|
|
1208
|
-
`Compliance matrix rows must be canonical, unique, known, and non-FAIL. Duplicates: ${[...new Set(duplicateIds)].join(', ') || 'none'}; unknown: ${[...new Set(unknownIds)].join(', ') || 'none'}; failed: ${[...new Set(failedIds)].join(', ') || 'none'}.`,
|
|
1209
|
-
),
|
|
1210
|
-
);
|
|
1211
|
-
}
|
|
1212
|
-
const rows = new Map(matrix.entries.map((entry) => [entry.id, entry]));
|
|
1213
|
-
const required = [
|
|
1214
|
-
...spec.frIds.map((id) => `FR-${id}`),
|
|
1215
|
-
...spec.buildableScIds.map((id) => `SC-${id}`),
|
|
1216
|
-
];
|
|
1217
|
-
const missing = required.filter((id) => {
|
|
1218
|
-
const row = rows.get(id);
|
|
1219
|
-
return (
|
|
1220
|
-
row?.result !== 'PASS' ||
|
|
1221
|
-
!isConcreteEvidence(row.evidence) ||
|
|
1222
|
-
!isConcreteEvidence(row.check)
|
|
1223
|
-
);
|
|
1224
|
-
});
|
|
1225
|
-
if (missing.length > 0) {
|
|
1226
|
-
errors.push(
|
|
1227
|
-
issue(
|
|
1228
|
-
'SDD-VERIFY-COVERAGE',
|
|
1229
|
-
artifact,
|
|
1230
|
-
`Verification evidence is missing for: ${missing.join(', ')}.`,
|
|
1231
|
-
),
|
|
1232
|
-
);
|
|
1233
|
-
}
|
|
1234
|
-
|
|
1235
|
-
const unresolvedOutcomes = spec.outcomeScIds
|
|
1236
|
-
.map((id) => `SC-${id}`)
|
|
1237
|
-
.filter((id) => {
|
|
1238
|
-
const row = rows.get(id);
|
|
1239
|
-
if (!row) return true;
|
|
1240
|
-
if (row.result === 'PASS') {
|
|
1241
|
-
return (
|
|
1242
|
-
!isConcreteEvidence(row.evidence) || !isConcreteEvidence(row.check)
|
|
1243
|
-
);
|
|
1244
|
-
}
|
|
1245
|
-
return row.result !== 'RISK' || !hasExplicitResidualRisk(content, id);
|
|
1246
|
-
});
|
|
1247
|
-
if (unresolvedOutcomes.length > 0) {
|
|
1248
|
-
errors.push(
|
|
1249
|
-
issue(
|
|
1250
|
-
'SDD-VERIFY-OUTCOME',
|
|
1251
|
-
artifact,
|
|
1252
|
-
`Outcome criteria require observed PASS evidence or an explicit residual RISK: ${unresolvedOutcomes.join(', ')}.`,
|
|
1253
|
-
),
|
|
1254
|
-
);
|
|
1255
|
-
}
|
|
1256
|
-
}
|
|
1257
|
-
|
|
1258
|
-
function validateArchiveReport(content, errors) {
|
|
1259
|
-
const artifact = 'archive-report.md';
|
|
1260
|
-
const valid =
|
|
1261
|
-
/^\*\*Status\*\*:\s*READY(?:<br>)?\s*$/im.test(content) &&
|
|
1262
|
-
/^\*\*Oracle verdict\*\*:\s*PASS(?:<br>)?\s*$/im.test(content) &&
|
|
1263
|
-
/^\*\*Archive path\*\*:\s*`openspec\/changes\/archive\/YYYY-MM-DD-\[feature\]\/`(?:<br>)?\s*$/im.test(
|
|
1264
|
-
content,
|
|
1265
|
-
) &&
|
|
1266
|
-
/verify-report\.md/i.test(
|
|
1267
|
-
sectionContent(content, 'Verification lineage') ?? '',
|
|
1268
|
-
) &&
|
|
1269
|
-
/^- Pending: archive applies declared durable deltas transactionally\.\s*$/im.test(
|
|
1270
|
-
sectionContent(content, 'Canonical specification sync') ?? '',
|
|
1271
|
-
);
|
|
1272
|
-
if (!valid) {
|
|
1273
|
-
errors.push(
|
|
1274
|
-
issue(
|
|
1275
|
-
'SDD-ARCHIVE-REPORT',
|
|
1276
|
-
artifact,
|
|
1277
|
-
'Archive report must be READY with oracle PASS, lineage, sync status, and the dated target placeholder.',
|
|
1278
|
-
),
|
|
1279
|
-
);
|
|
1280
|
-
}
|
|
1281
|
-
}
|
|
1282
|
-
|
|
1283
|
-
function validate({ change, route, through }) {
|
|
1284
|
-
const root = resolve(change);
|
|
1285
|
-
const errors = [];
|
|
1286
|
-
const warnings = [];
|
|
1287
|
-
|
|
1288
|
-
if (route === 'direct') {
|
|
1289
|
-
return { valid: true, route, through, changeRoot: root, errors, warnings };
|
|
1290
|
-
}
|
|
1291
|
-
|
|
1292
|
-
const spec = readArtifact(root, 'spec.md', errors);
|
|
1293
|
-
const requiresPlan = [
|
|
1294
|
-
'plan',
|
|
1295
|
-
'checklist',
|
|
1296
|
-
'tasks',
|
|
1297
|
-
'ready',
|
|
1298
|
-
'closeout',
|
|
1299
|
-
].includes(through);
|
|
1300
|
-
const requiresTasks = ['tasks', 'ready', 'closeout'].includes(through);
|
|
1301
|
-
const plan = requiresPlan ? readArtifact(root, 'plan.md', errors) : undefined;
|
|
1302
|
-
const tasks = requiresTasks
|
|
1303
|
-
? readArtifact(root, 'tasks.md', errors)
|
|
1304
|
-
: undefined;
|
|
1305
|
-
const checklist = readArtifact(
|
|
1306
|
-
root,
|
|
1307
|
-
'checklists/requirements.md',
|
|
1308
|
-
errors,
|
|
1309
|
-
through === 'checklist',
|
|
1310
|
-
);
|
|
1311
|
-
|
|
1312
|
-
const specContract =
|
|
1313
|
-
spec === undefined ? undefined : validateSpec(spec, errors);
|
|
1314
|
-
if (specContract !== undefined) {
|
|
1315
|
-
validateDurableDeltaIntent(
|
|
1316
|
-
join(root, '..', '..', 'specs'),
|
|
1317
|
-
specContract.durableDeltas,
|
|
1318
|
-
errors,
|
|
1319
|
-
warnings,
|
|
1320
|
-
);
|
|
1321
|
-
}
|
|
1322
|
-
const constitution = readArtifact(
|
|
1323
|
-
join(root, '..', '..'),
|
|
1324
|
-
'memory/constitution.md',
|
|
1325
|
-
errors,
|
|
1326
|
-
requiresPlan,
|
|
1327
|
-
);
|
|
1328
|
-
if (plan !== undefined) validatePlan(plan, errors, constitution);
|
|
1329
|
-
if (tasks !== undefined) validateTasks(tasks, errors, specContract);
|
|
1330
|
-
if (checklist !== undefined) {
|
|
1331
|
-
validateChecklist(
|
|
1332
|
-
checklist,
|
|
1333
|
-
errors,
|
|
1334
|
-
specContract,
|
|
1335
|
-
['checklist', 'ready', 'closeout'].includes(through),
|
|
1336
|
-
);
|
|
1337
|
-
} else if (route === 'full' && ['ready', 'closeout'].includes(through)) {
|
|
1338
|
-
warnings.push({
|
|
1339
|
-
code: 'SDD-CHECKLIST-CONDITIONAL',
|
|
1340
|
-
artifact: 'checklists/requirements.md',
|
|
1341
|
-
message:
|
|
1342
|
-
'No requirements checklist exists; confirm that risk did not activate the conditional checklist gate.',
|
|
1343
|
-
});
|
|
1344
|
-
}
|
|
1345
|
-
|
|
1346
|
-
if (
|
|
1347
|
-
through === 'closeout' &&
|
|
1348
|
-
tasks !== undefined &&
|
|
1349
|
-
specContract !== undefined
|
|
1350
|
-
) {
|
|
1351
|
-
validateCloseoutTasks(tasks, errors);
|
|
1352
|
-
const verifyReport = readArtifact(root, 'verify-report.md', errors);
|
|
1353
|
-
const archiveReport = readArtifact(root, 'archive-report.md', errors);
|
|
1354
|
-
if (verifyReport !== undefined) {
|
|
1355
|
-
validateVerifyReport(verifyReport, errors, specContract);
|
|
1356
|
-
}
|
|
1357
|
-
if (archiveReport !== undefined) {
|
|
1358
|
-
validateArchiveReport(archiveReport, errors);
|
|
1359
|
-
}
|
|
1360
|
-
}
|
|
1361
722
|
|
|
1362
723
|
return {
|
|
1363
724
|
valid: errors.length === 0,
|
|
1364
|
-
route,
|
|
1365
725
|
through,
|
|
1366
|
-
|
|
726
|
+
projectRoot,
|
|
727
|
+
changeRoot,
|
|
728
|
+
changeId: id,
|
|
729
|
+
recordPath,
|
|
730
|
+
archived,
|
|
731
|
+
archiveDate,
|
|
1367
732
|
errors,
|
|
1368
733
|
warnings,
|
|
734
|
+
deltas,
|
|
735
|
+
specBaselines,
|
|
1369
736
|
};
|
|
1370
737
|
}
|
|
1371
738
|
|
|
1372
|
-
|
|
1373
|
-
const options =
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
)
|
|
1388
|
-
|
|
739
|
+
function args(argv) {
|
|
740
|
+
const options = {};
|
|
741
|
+
for (let i = 0; i < argv.length; i++) {
|
|
742
|
+
if (argv[i] === '--json') options.json = true;
|
|
743
|
+
else if (['--change', '--through'].includes(argv[i]))
|
|
744
|
+
options[argv[i].slice(2)] = argv[++i];
|
|
745
|
+
else throw new Error(`Unknown argument: ${argv[i]}`);
|
|
746
|
+
}
|
|
747
|
+
if (!options.change || !options.through)
|
|
748
|
+
throw new Error('--change and --through are required');
|
|
749
|
+
return options;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
if (
|
|
753
|
+
process.argv[1] &&
|
|
754
|
+
import.meta.url === pathToFileURL(resolve(process.argv[1])).href
|
|
755
|
+
) {
|
|
756
|
+
try {
|
|
757
|
+
const options = args(process.argv.slice(2));
|
|
758
|
+
const result = validate(options);
|
|
759
|
+
process.stdout.write(
|
|
760
|
+
`${options.json ? JSON.stringify(result) : result.valid ? 'Substantial SDD record is structurally valid.' : result.errors.map((entry) => `${entry.code}: ${entry.message}`).join('\n')}\n`,
|
|
761
|
+
);
|
|
762
|
+
process.exitCode = result.valid ? 0 : 1;
|
|
763
|
+
} catch (error) {
|
|
764
|
+
process.stderr.write(`${error.message}\n`);
|
|
765
|
+
process.exitCode = 2;
|
|
766
|
+
}
|
|
1389
767
|
}
|