enterprise-delivery 0.1.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/.claude-plugin/plugin.json +21 -0
- package/.codex-plugin/plugin.json +36 -0
- package/AGENTS.md +73 -0
- package/README.md +127 -0
- package/package.json +28 -0
- package/scripts/enterprise-delivery-validate.mjs +11 -0
- package/skills/add-requirement/SKILL.md +52 -0
- package/skills/analyze-requirement/SKILL.md +48 -0
- package/skills/analyze-tech-stack/SKILL.md +36 -0
- package/skills/change-requirement/SKILL.md +37 -0
- package/skills/complete-task/SKILL.md +46 -0
- package/skills/delivery-lead/SKILL.md +221 -0
- package/skills/delivery-planning/SKILL.md +27 -0
- package/skills/enterprise-kickoff/SKILL.md +25 -0
- package/skills/feature-gate/SKILL.md +22 -0
- package/skills/release-gate/SKILL.md +24 -0
- package/skills/sprint-gate/SKILL.md +23 -0
- package/skills/start-task/SKILL.md +39 -0
- package/skills/story-intake/SKILL.md +23 -0
- package/skills/update-coding-rules/SKILL.md +32 -0
- package/src/cli.mjs +220 -0
- package/src/frontmatter.mjs +121 -0
- package/src/init.mjs +54 -0
- package/src/install-codex.mjs +121 -0
- package/src/markdown.mjs +58 -0
- package/src/repository.mjs +59 -0
- package/src/rules.mjs +814 -0
- package/templates/docs/enterprise/architecture-overview.md +17 -0
- package/templates/docs/enterprise/change-log.md +4 -0
- package/templates/docs/enterprise/changes/CHG-0000-template.md +37 -0
- package/templates/docs/enterprise/current-state.md +48 -0
- package/templates/docs/enterprise/decision-log.md +5 -0
- package/templates/docs/enterprise/product-backlog.md +5 -0
- package/templates/docs/enterprise/project-charter.md +25 -0
- package/templates/docs/enterprise/project-vision.md +61 -0
- package/templates/docs/enterprise/releases/RELEASE-ID/release-notes.md +21 -0
- package/templates/docs/enterprise/releases/RELEASE-ID/release-plan.md +18 -0
- package/templates/docs/enterprise/releases/RELEASE-ID/traceability-matrix.md +5 -0
- package/templates/docs/enterprise/risk-register.md +5 -0
- package/templates/docs/enterprise/roadmap.md +13 -0
- package/templates/docs/enterprise/sprints/SPRINT-ID/sprint-plan.md +21 -0
- package/templates/docs/enterprise/sprints/SPRINT-ID/status-report.md +29 -0
- package/templates/docs/enterprise/stakeholder-register.md +6 -0
- package/templates/docs/enterprise/stories/STORY-0000-template.md +59 -0
- package/templates/docs/enterprise/task-graph.json +24 -0
- package/templates/docs/enterprise/test-strategy.md +15 -0
package/src/rules.mjs
ADDED
|
@@ -0,0 +1,814 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { getSection, listIds, sectionHasContent } from './markdown.mjs';
|
|
4
|
+
import { loadChange, loadStory } from './repository.mjs';
|
|
5
|
+
|
|
6
|
+
export const REQUIRED_REPO_FILES = [
|
|
7
|
+
'docs/enterprise/project-charter.md',
|
|
8
|
+
'docs/enterprise/roadmap.md',
|
|
9
|
+
'docs/enterprise/architecture-overview.md',
|
|
10
|
+
'docs/enterprise/test-strategy.md',
|
|
11
|
+
'docs/enterprise/stakeholder-register.md',
|
|
12
|
+
'docs/enterprise/risk-register.md',
|
|
13
|
+
'docs/enterprise/decision-log.md',
|
|
14
|
+
'docs/enterprise/product-backlog.md',
|
|
15
|
+
'docs/enterprise/change-log.md',
|
|
16
|
+
'docs/enterprise/project-vision.md',
|
|
17
|
+
'docs/enterprise/current-state.md',
|
|
18
|
+
'docs/enterprise/task-graph.json'
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const REQUIRED_STORY_SECTIONS = [
|
|
22
|
+
'Requirement',
|
|
23
|
+
'Business Value',
|
|
24
|
+
'Acceptance Criteria',
|
|
25
|
+
'Non-Functional Criteria',
|
|
26
|
+
'Dependencies',
|
|
27
|
+
'Risk Impact',
|
|
28
|
+
'Docs Impact',
|
|
29
|
+
'Test Evidence',
|
|
30
|
+
'Implementation Links',
|
|
31
|
+
'Change History'
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
const REQUIRED_CHANGE_SECTIONS = [
|
|
35
|
+
'Reason',
|
|
36
|
+
'Impact',
|
|
37
|
+
'Approval',
|
|
38
|
+
'Evidence',
|
|
39
|
+
'Rollout Notes'
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
const DEDICATED_STORY_SECTION_ERRORS = new Set([
|
|
43
|
+
'Acceptance Criteria',
|
|
44
|
+
'Docs Impact',
|
|
45
|
+
'Implementation Links',
|
|
46
|
+
'Test Evidence'
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
const CHANGE_ID_PATTERN = /^CHG-[0-9]{4}$/;
|
|
50
|
+
const STORY_ID_PATTERN = /^STORY-[0-9]{4}$/;
|
|
51
|
+
const FEATURE_COMPLETION_CHANGE_STATUSES = new Set(['implemented', 'verified', 'released']);
|
|
52
|
+
const RELEASE_CHANGE_STATUSES = new Set(['verified', 'released']);
|
|
53
|
+
const TASK_STATUSES = new Set(['planned', 'in_progress', 'blocked', 'review', 'done']);
|
|
54
|
+
const REQUIRED_PROJECT_VISION_SECTIONS = [
|
|
55
|
+
'Product Goals',
|
|
56
|
+
'Non-Goals',
|
|
57
|
+
'Target Users',
|
|
58
|
+
'Spec Principles',
|
|
59
|
+
'Architecture Principles',
|
|
60
|
+
'Forbidden Patterns',
|
|
61
|
+
'Coding Philosophy',
|
|
62
|
+
'Quality Bar',
|
|
63
|
+
'AI Collaboration Rules'
|
|
64
|
+
];
|
|
65
|
+
const REQUIRED_CURRENT_STATE_SECTIONS = [
|
|
66
|
+
'Active Stories',
|
|
67
|
+
'Active Changes',
|
|
68
|
+
'Failing Tests',
|
|
69
|
+
'TODOs',
|
|
70
|
+
'Coverage',
|
|
71
|
+
'Unresolved Bugs',
|
|
72
|
+
'Recent Commits',
|
|
73
|
+
'Active Risks',
|
|
74
|
+
'Active Decisions',
|
|
75
|
+
'Current Task Graph',
|
|
76
|
+
'Next Verification Commands'
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
export function validateRepo(rootDir) {
|
|
80
|
+
const errors = [];
|
|
81
|
+
|
|
82
|
+
for (const relativePath of REQUIRED_REPO_FILES) {
|
|
83
|
+
const absolutePath = path.join(rootDir, relativePath);
|
|
84
|
+
if (!fs.existsSync(absolutePath)) {
|
|
85
|
+
errors.push(error(
|
|
86
|
+
'missing_repo_artifact',
|
|
87
|
+
`Missing required artifact: ${relativePath}`,
|
|
88
|
+
`Create ${relativePath} from the enterprise-delivery template.`
|
|
89
|
+
));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const taskGraphPath = path.join(rootDir, 'docs/enterprise/task-graph.json');
|
|
94
|
+
if (fs.existsSync(taskGraphPath)) {
|
|
95
|
+
try {
|
|
96
|
+
const taskGraph = JSON.parse(fs.readFileSync(taskGraphPath, 'utf8'));
|
|
97
|
+
errors.push(...validateTaskGraph(taskGraph));
|
|
98
|
+
} catch (parseError) {
|
|
99
|
+
errors.push(error(
|
|
100
|
+
'invalid_task_graph_json',
|
|
101
|
+
`docs/enterprise/task-graph.json contains invalid JSON: ${parseError.message}`,
|
|
102
|
+
'Fix docs/enterprise/task-graph.json so it can be parsed as JSON.'
|
|
103
|
+
));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
errors.push(...validateMarkdownSections(
|
|
108
|
+
rootDir,
|
|
109
|
+
'docs/enterprise/project-vision.md',
|
|
110
|
+
REQUIRED_PROJECT_VISION_SECTIONS
|
|
111
|
+
));
|
|
112
|
+
errors.push(...validateMarkdownSections(
|
|
113
|
+
rootDir,
|
|
114
|
+
'docs/enterprise/current-state.md',
|
|
115
|
+
REQUIRED_CURRENT_STATE_SECTIONS
|
|
116
|
+
));
|
|
117
|
+
|
|
118
|
+
return result('repo', 'repo', errors);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function validateMarkdownSections(rootDir, relativePath, sections) {
|
|
122
|
+
const errors = [];
|
|
123
|
+
const absolutePath = path.join(rootDir, relativePath);
|
|
124
|
+
if (!fs.existsSync(absolutePath)) {
|
|
125
|
+
return errors;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const content = fs.readFileSync(absolutePath, 'utf8');
|
|
129
|
+
for (const section of sections) {
|
|
130
|
+
if (!sectionHasContent(content, section)) {
|
|
131
|
+
errors.push(error(
|
|
132
|
+
'missing_repo_section',
|
|
133
|
+
`${relativePath} section "${section}" is empty or missing.`,
|
|
134
|
+
`Fill ${section} in ${relativePath}, using "unknown" when the fact is not known yet.`
|
|
135
|
+
));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return errors;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function validateTaskGraph(taskGraph) {
|
|
143
|
+
const errors = [];
|
|
144
|
+
if (!isPlainObject(taskGraph)) {
|
|
145
|
+
return [
|
|
146
|
+
error(
|
|
147
|
+
'invalid_task_graph_shape',
|
|
148
|
+
'docs/enterprise/task-graph.json must contain a JSON object.',
|
|
149
|
+
'Replace docs/enterprise/task-graph.json with the enterprise-delivery task graph template.'
|
|
150
|
+
)
|
|
151
|
+
];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (!Number.isInteger(taskGraph.schema_version)) {
|
|
155
|
+
errors.push(error(
|
|
156
|
+
'invalid_task_graph_schema_version',
|
|
157
|
+
'docs/enterprise/task-graph.json schema_version must be an integer.',
|
|
158
|
+
'Set schema_version to the current integer schema version.'
|
|
159
|
+
));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (typeof taskGraph.updated_at !== 'string' || taskGraph.updated_at.trim() === '') {
|
|
163
|
+
errors.push(error(
|
|
164
|
+
'invalid_task_graph_updated_at',
|
|
165
|
+
'docs/enterprise/task-graph.json updated_at must be a non-empty string.',
|
|
166
|
+
'Set updated_at to an ISO date or "unknown".'
|
|
167
|
+
));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (!isPlainObject(taskGraph.tasks)) {
|
|
171
|
+
errors.push(error(
|
|
172
|
+
'invalid_task_graph_tasks',
|
|
173
|
+
'docs/enterprise/task-graph.json tasks must be an object.',
|
|
174
|
+
'Define tasks as an object keyed by task IDs.'
|
|
175
|
+
));
|
|
176
|
+
return errors;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const taskIds = new Set(Object.keys(taskGraph.tasks));
|
|
180
|
+
for (const [taskId, task] of Object.entries(taskGraph.tasks)) {
|
|
181
|
+
if (!/^task_[A-Za-z0-9_-]+$/.test(taskId)) {
|
|
182
|
+
errors.push(error(
|
|
183
|
+
'invalid_task_id',
|
|
184
|
+
`${taskId} must use a task_* ID.`,
|
|
185
|
+
`Rename ${taskId} in docs/enterprise/task-graph.json to a task_* ID.`
|
|
186
|
+
));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (!isPlainObject(task)) {
|
|
190
|
+
errors.push(error(
|
|
191
|
+
'invalid_task_shape',
|
|
192
|
+
`${taskId} must be a JSON object.`,
|
|
193
|
+
`Replace ${taskId} with a task object in docs/enterprise/task-graph.json.`
|
|
194
|
+
));
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (typeof task.title !== 'string' || task.title.trim() === '') {
|
|
199
|
+
errors.push(error(
|
|
200
|
+
'missing_task_title',
|
|
201
|
+
`${taskId} title is empty or missing.`,
|
|
202
|
+
`Add a title to ${taskId} in docs/enterprise/task-graph.json.`
|
|
203
|
+
));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (!TASK_STATUSES.has(task.status)) {
|
|
207
|
+
errors.push(error(
|
|
208
|
+
'invalid_task_status',
|
|
209
|
+
`${taskId} has invalid status ${task.status}.`,
|
|
210
|
+
`Use one of ${Array.from(TASK_STATUSES).join(', ')} for ${taskId}.`
|
|
211
|
+
));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (!Array.isArray(task.depends_on)) {
|
|
215
|
+
errors.push(error(
|
|
216
|
+
'invalid_task_dependencies',
|
|
217
|
+
`${taskId} depends_on must be an array.`,
|
|
218
|
+
`Set depends_on to an array in ${taskId}.`
|
|
219
|
+
));
|
|
220
|
+
} else {
|
|
221
|
+
for (const dependencyId of task.depends_on) {
|
|
222
|
+
if (!taskIds.has(dependencyId)) {
|
|
223
|
+
errors.push(error(
|
|
224
|
+
'missing_task_dependency',
|
|
225
|
+
`${taskId} depends on missing task ${dependencyId}.`,
|
|
226
|
+
`Add ${dependencyId} to tasks or remove it from ${taskId}.depends_on.`
|
|
227
|
+
));
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (typeof task.story_id !== 'string' || !STORY_ID_PATTERN.test(task.story_id)) {
|
|
233
|
+
errors.push(error(
|
|
234
|
+
'invalid_task_story_id',
|
|
235
|
+
`${taskId} story_id must be a STORY-* ID.`,
|
|
236
|
+
`Set ${taskId}.story_id to a STORY-0000 style ID.`
|
|
237
|
+
));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (typeof task.change_id !== 'string' || !CHANGE_ID_PATTERN.test(task.change_id)) {
|
|
241
|
+
errors.push(error(
|
|
242
|
+
'invalid_task_change_id',
|
|
243
|
+
`${taskId} change_id must be a CHG-* ID.`,
|
|
244
|
+
`Set ${taskId}.change_id to a CHG-0000 style ID.`
|
|
245
|
+
));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (!Array.isArray(task.evidence)) {
|
|
249
|
+
errors.push(error(
|
|
250
|
+
'invalid_task_evidence',
|
|
251
|
+
`${taskId} evidence must be an array.`,
|
|
252
|
+
`Set ${taskId}.evidence to an array.`
|
|
253
|
+
));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (task.status === 'blocked' && (typeof task.blocked_reason !== 'string' || task.blocked_reason.trim() === '')) {
|
|
257
|
+
errors.push(error(
|
|
258
|
+
'missing_blocked_reason',
|
|
259
|
+
`${taskId} is blocked but has no blocked_reason.`,
|
|
260
|
+
`Add blocked_reason to ${taskId} or move it out of blocked status.`
|
|
261
|
+
));
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return errors;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function validateFeature(rootDir, storyId) {
|
|
269
|
+
const errors = [];
|
|
270
|
+
const story = loadStory(rootDir, storyId);
|
|
271
|
+
|
|
272
|
+
if (!story) {
|
|
273
|
+
return result('feature', storyId, [
|
|
274
|
+
error('missing_story', `Story ${storyId} does not exist.`, `Create docs/enterprise/stories/${storyId}-<slug>.md.`)
|
|
275
|
+
]);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (story.parseError) {
|
|
279
|
+
return result('feature', storyId, [
|
|
280
|
+
error(
|
|
281
|
+
'unparseable_story',
|
|
282
|
+
`Story ${storyId} could not be parsed: ${story.parseError.message}`,
|
|
283
|
+
`Fix frontmatter syntax in ${story.relativePath}.`
|
|
284
|
+
)
|
|
285
|
+
]);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (!['review', 'done'].includes(story.data.status)) {
|
|
289
|
+
errors.push(error(
|
|
290
|
+
'invalid_story_status',
|
|
291
|
+
`${storyId} status must be review or done for completion validation.`,
|
|
292
|
+
`Move ${storyId} to review only after implementation evidence is ready.`
|
|
293
|
+
));
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
for (const section of REQUIRED_STORY_SECTIONS) {
|
|
297
|
+
if (!DEDICATED_STORY_SECTION_ERRORS.has(section) && !sectionHasContent(story.body, section)) {
|
|
298
|
+
errors.push(error(
|
|
299
|
+
'missing_story_section',
|
|
300
|
+
`${storyId} section "${section}" is empty or missing.`,
|
|
301
|
+
`Fill ${section} in ${story.relativePath}.`
|
|
302
|
+
));
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const linkedChanges = Array.isArray(story.data.linked_changes) ? story.data.linked_changes : [];
|
|
307
|
+
if (linkedChanges.length === 0) {
|
|
308
|
+
errors.push(error(
|
|
309
|
+
'missing_linked_change',
|
|
310
|
+
`${storyId} has no linked CHG-* record.`,
|
|
311
|
+
`Create docs/enterprise/changes/CHG-<next>-<slug>.md and add it to linked_changes.`
|
|
312
|
+
));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
for (const changeId of linkedChanges) {
|
|
316
|
+
if (!CHANGE_ID_PATTERN.test(changeId)) {
|
|
317
|
+
errors.push(error(
|
|
318
|
+
'invalid_linked_change',
|
|
319
|
+
`Linked change ${changeId} must be a CHG-* ID.`,
|
|
320
|
+
`Replace ${changeId} in ${story.relativePath} with a CHG-0000 style change ID.`
|
|
321
|
+
));
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (!sectionHasContent(story.body, 'Acceptance Criteria')) {
|
|
326
|
+
errors.push(error(
|
|
327
|
+
'missing_acceptance_criteria',
|
|
328
|
+
`${storyId} has no acceptance criteria.`,
|
|
329
|
+
`Add acceptance criteria to ${story.relativePath}.`
|
|
330
|
+
));
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (!sectionHasContent(story.body, 'Test Evidence')) {
|
|
334
|
+
errors.push(error(
|
|
335
|
+
'missing_test_evidence',
|
|
336
|
+
`${storyId} has no test evidence.`,
|
|
337
|
+
`Add command and result evidence to ${story.relativePath}.`
|
|
338
|
+
));
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const docsImpact = getSection(story.body, 'Docs Impact').trim();
|
|
342
|
+
if (!docsImpact) {
|
|
343
|
+
errors.push(error(
|
|
344
|
+
'missing_docs_impact',
|
|
345
|
+
`${storyId} Docs Impact section is empty.`,
|
|
346
|
+
`Fill Docs Impact or state "No impact" with a reason.`
|
|
347
|
+
));
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (isBareNoImpact(docsImpact)) {
|
|
351
|
+
errors.push(error(
|
|
352
|
+
'missing_docs_impact_reason',
|
|
353
|
+
`${storyId} Docs Impact must include a reason when stating no impact.`,
|
|
354
|
+
`Change Docs Impact to "No impact: <reason>" or describe the documentation changes.`
|
|
355
|
+
));
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (!sectionHasContent(story.body, 'Implementation Links')) {
|
|
359
|
+
errors.push(error(
|
|
360
|
+
'missing_implementation_links',
|
|
361
|
+
`${storyId} has no implementation links.`,
|
|
362
|
+
`Add linked files, commit, pull request, or external implementation reference.`
|
|
363
|
+
));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
for (const changeId of linkedChanges) {
|
|
367
|
+
if (!CHANGE_ID_PATTERN.test(changeId)) {
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const change = loadChange(rootDir, changeId);
|
|
372
|
+
if (!change) {
|
|
373
|
+
errors.push(error(
|
|
374
|
+
'missing_change',
|
|
375
|
+
`Linked change ${changeId} does not exist.`,
|
|
376
|
+
`Create docs/enterprise/changes/${changeId}-<slug>.md.`
|
|
377
|
+
));
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (change.parseError) {
|
|
382
|
+
errors.push(error(
|
|
383
|
+
'unparseable_change',
|
|
384
|
+
`Change ${changeId} could not be parsed: ${change.parseError.message}`,
|
|
385
|
+
`Fix frontmatter syntax in ${change.relativePath}.`
|
|
386
|
+
));
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (!FEATURE_COMPLETION_CHANGE_STATUSES.has(change.data.status)) {
|
|
391
|
+
errors.push(error(
|
|
392
|
+
'invalid_change_status',
|
|
393
|
+
`${changeId} status must be implemented, verified, or released for feature validation.`,
|
|
394
|
+
`Move ${changeId} to implemented after implementation is complete, or link a completed change.`
|
|
395
|
+
));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const relatedStories = Array.isArray(change.data.related_stories) ? change.data.related_stories : [];
|
|
399
|
+
if (!relatedStories.includes(storyId)) {
|
|
400
|
+
errors.push(error(
|
|
401
|
+
'missing_reverse_story_link',
|
|
402
|
+
`${changeId} must list ${storyId} in related_stories.`,
|
|
403
|
+
`Add ${storyId} to related_stories in ${change.relativePath}.`
|
|
404
|
+
));
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const hasDedicatedEvidenceError = ['verified', 'released'].includes(change.data.status);
|
|
408
|
+
for (const section of REQUIRED_CHANGE_SECTIONS) {
|
|
409
|
+
if (!(hasDedicatedEvidenceError && section === 'Evidence') && !sectionHasContent(change.body, section)) {
|
|
410
|
+
errors.push(error(
|
|
411
|
+
'missing_change_section',
|
|
412
|
+
`${changeId} section "${section}" is empty or missing.`,
|
|
413
|
+
`Fill ${section} in ${change.relativePath}.`
|
|
414
|
+
));
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (['verified', 'released'].includes(change.data.status) && !sectionHasContent(change.body, 'Evidence')) {
|
|
419
|
+
errors.push(error(
|
|
420
|
+
'missing_change_evidence',
|
|
421
|
+
`${changeId} is ${change.data.status} but has no evidence.`,
|
|
422
|
+
`Add verification evidence to ${change.relativePath}.`
|
|
423
|
+
));
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
return result('feature', storyId, errors);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export function validateSprint(rootDir, sprintId) {
|
|
431
|
+
const errors = [];
|
|
432
|
+
const sprintDir = path.join(rootDir, 'docs/enterprise/sprints', sprintId);
|
|
433
|
+
const sprintPlanPath = path.join(sprintDir, 'sprint-plan.md');
|
|
434
|
+
const statusReportPath = path.join(sprintDir, 'status-report.md');
|
|
435
|
+
|
|
436
|
+
let sprintPlan = '';
|
|
437
|
+
if (!fs.existsSync(sprintPlanPath)) {
|
|
438
|
+
errors.push(error(
|
|
439
|
+
'missing_sprint_plan',
|
|
440
|
+
`Sprint ${sprintId} is missing sprint-plan.md.`,
|
|
441
|
+
`Create docs/enterprise/sprints/${sprintId}/sprint-plan.md.`
|
|
442
|
+
));
|
|
443
|
+
} else {
|
|
444
|
+
sprintPlan = fs.readFileSync(sprintPlanPath, 'utf8');
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
let statusReport = '';
|
|
448
|
+
if (!fs.existsSync(statusReportPath)) {
|
|
449
|
+
errors.push(error(
|
|
450
|
+
'missing_status_report',
|
|
451
|
+
`Sprint ${sprintId} is missing status-report.md.`,
|
|
452
|
+
`Create docs/enterprise/sprints/${sprintId}/status-report.md.`
|
|
453
|
+
));
|
|
454
|
+
} else {
|
|
455
|
+
statusReport = fs.readFileSync(statusReportPath, 'utf8');
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const committedStoriesSection = getSection(sprintPlan, 'Committed Stories');
|
|
459
|
+
const committedStories = listIds(committedStoriesSection, 'STORY');
|
|
460
|
+
for (const storyId of listInvalidIdReferences(committedStoriesSection, 'STORY', STORY_ID_PATTERN)) {
|
|
461
|
+
errors.push(error(
|
|
462
|
+
'invalid_committed_story',
|
|
463
|
+
`Committed story reference ${storyId} is not a valid STORY-* ID.`,
|
|
464
|
+
`Replace ${storyId} in docs/enterprise/sprints/${sprintId}/sprint-plan.md with a STORY-0000 style story ID.`
|
|
465
|
+
));
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (committedStories.length === 0) {
|
|
469
|
+
errors.push(error(
|
|
470
|
+
'missing_committed_stories',
|
|
471
|
+
`Sprint ${sprintId} has no committed stories.`,
|
|
472
|
+
`Add committed STORY-* IDs to docs/enterprise/sprints/${sprintId}/sprint-plan.md.`
|
|
473
|
+
));
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
for (const storyId of committedStories) {
|
|
477
|
+
const story = loadStory(rootDir, storyId);
|
|
478
|
+
if (!story) {
|
|
479
|
+
errors.push(error(
|
|
480
|
+
'missing_committed_story',
|
|
481
|
+
`Committed story ${storyId} does not exist.`,
|
|
482
|
+
`Create docs/enterprise/stories/${storyId}-<slug>.md or remove it from the sprint plan.`
|
|
483
|
+
));
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
if (story.parseError) {
|
|
488
|
+
errors.push(error(
|
|
489
|
+
'unparseable_story',
|
|
490
|
+
`Story ${storyId} could not be parsed: ${story.parseError.message}`,
|
|
491
|
+
`Fix frontmatter syntax in ${story.relativePath}.`
|
|
492
|
+
));
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const linkedChanges = Array.isArray(story.data.linked_changes) ? story.data.linked_changes : [];
|
|
497
|
+
const hasLinkedChange = linkedChanges.some((changeId) => CHANGE_ID_PATTERN.test(changeId));
|
|
498
|
+
const featureGateReportsMissingChange = story.data.status === 'done' && linkedChanges.length === 0;
|
|
499
|
+
if (!hasLinkedChange && !featureGateReportsMissingChange) {
|
|
500
|
+
errors.push(error(
|
|
501
|
+
'missing_linked_change',
|
|
502
|
+
`${storyId} has no linked CHG-* record.`,
|
|
503
|
+
`Create docs/enterprise/changes/CHG-<next>-<slug>.md and add it to linked_changes.`
|
|
504
|
+
));
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (story.data.status === 'done') {
|
|
508
|
+
errors.push(...validateFeature(rootDir, storyId).errors);
|
|
509
|
+
} else {
|
|
510
|
+
errors.push(...validateSprintLinkedChanges(rootDir, storyId, linkedChanges));
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (story.data.status === 'blocked' && !story.body.toLowerCase().includes('next action')) {
|
|
514
|
+
errors.push(error(
|
|
515
|
+
'missing_blocked_next_action',
|
|
516
|
+
`${storyId} is blocked but has no next action.`,
|
|
517
|
+
`Add a next action to ${story.relativePath}.`
|
|
518
|
+
));
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const risks = getSection(statusReport, 'Risks').toLowerCase();
|
|
523
|
+
if (!risks.includes('no new risks') && !risks.includes('risk-register')) {
|
|
524
|
+
errors.push(error(
|
|
525
|
+
'missing_risk_status',
|
|
526
|
+
`Sprint ${sprintId} status report must state risk status.`,
|
|
527
|
+
`Update Risks in docs/enterprise/sprints/${sprintId}/status-report.md with "No new risks" or a risk-register reference.`
|
|
528
|
+
));
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const decisions = getSection(statusReport, 'Decisions').toLowerCase();
|
|
532
|
+
if (!decisions.includes('no new decisions') && !decisions.includes('decision-log')) {
|
|
533
|
+
errors.push(error(
|
|
534
|
+
'missing_decision_status',
|
|
535
|
+
`Sprint ${sprintId} status report must state decision status.`,
|
|
536
|
+
`Update Decisions in docs/enterprise/sprints/${sprintId}/status-report.md with "No new decisions" or a decision-log reference.`
|
|
537
|
+
));
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
return result('sprint', sprintId, errors);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function validateSprintLinkedChanges(rootDir, storyId, linkedChanges) {
|
|
544
|
+
const errors = [];
|
|
545
|
+
|
|
546
|
+
for (const changeId of linkedChanges) {
|
|
547
|
+
if (!CHANGE_ID_PATTERN.test(changeId)) {
|
|
548
|
+
errors.push(error(
|
|
549
|
+
'invalid_linked_change',
|
|
550
|
+
`Linked change ${changeId} must be a CHG-* ID.`,
|
|
551
|
+
`Replace ${changeId} in the story frontmatter with a CHG-0000 style change ID.`
|
|
552
|
+
));
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const change = loadChange(rootDir, changeId);
|
|
557
|
+
if (!change) {
|
|
558
|
+
errors.push(error(
|
|
559
|
+
'missing_change',
|
|
560
|
+
`Linked change ${changeId} does not exist.`,
|
|
561
|
+
`Create docs/enterprise/changes/${changeId}-<slug>.md.`
|
|
562
|
+
));
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
if (change.parseError) {
|
|
567
|
+
errors.push(error(
|
|
568
|
+
'unparseable_change',
|
|
569
|
+
`Change ${changeId} could not be parsed: ${change.parseError.message}`,
|
|
570
|
+
`Fix frontmatter syntax in ${change.relativePath}.`
|
|
571
|
+
));
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const relatedStories = Array.isArray(change.data.related_stories) ? change.data.related_stories : [];
|
|
576
|
+
if (!relatedStories.includes(storyId)) {
|
|
577
|
+
errors.push(error(
|
|
578
|
+
'missing_reverse_story_link',
|
|
579
|
+
`${changeId} must list ${storyId} in related_stories.`,
|
|
580
|
+
`Add ${storyId} to related_stories in ${change.relativePath}.`
|
|
581
|
+
));
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
return errors;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
export function validateRelease(rootDir, releaseId) {
|
|
589
|
+
const errors = [];
|
|
590
|
+
const releaseDir = path.join(rootDir, 'docs/enterprise/releases', releaseId);
|
|
591
|
+
const requiredArtifacts = [
|
|
592
|
+
'release-plan.md',
|
|
593
|
+
'release-notes.md',
|
|
594
|
+
'traceability-matrix.md'
|
|
595
|
+
];
|
|
596
|
+
const contents = [];
|
|
597
|
+
let traceabilityMatrix = null;
|
|
598
|
+
|
|
599
|
+
for (const artifact of requiredArtifacts) {
|
|
600
|
+
const absolutePath = path.join(releaseDir, artifact);
|
|
601
|
+
const relativePath = path.join('docs/enterprise/releases', releaseId, artifact);
|
|
602
|
+
if (!fs.existsSync(absolutePath)) {
|
|
603
|
+
errors.push(error(
|
|
604
|
+
'missing_release_artifact',
|
|
605
|
+
`Release ${releaseId} is missing ${artifact}.`,
|
|
606
|
+
`Create ${relativePath}.`
|
|
607
|
+
));
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const content = fs.readFileSync(absolutePath, 'utf8');
|
|
612
|
+
contents.push(content);
|
|
613
|
+
if (artifact === 'traceability-matrix.md') {
|
|
614
|
+
traceabilityMatrix = content;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const releaseContent = contents.join('\n');
|
|
619
|
+
const traceabilityPairs = traceabilityMatrix === null ? [] : listReleaseTraceabilityPairs(traceabilityMatrix);
|
|
620
|
+
const storyIds = listIds(releaseContent, 'STORY');
|
|
621
|
+
const changeIds = listIds(releaseContent, 'CHG');
|
|
622
|
+
for (const storyId of listInvalidIdReferences(releaseContent, 'STORY', STORY_ID_PATTERN)) {
|
|
623
|
+
errors.push(error(
|
|
624
|
+
'invalid_release_story',
|
|
625
|
+
`Release story reference ${storyId} is not a valid STORY-* ID.`,
|
|
626
|
+
`Replace ${storyId} in docs/enterprise/releases/${releaseId} with a STORY-0000 style story ID.`
|
|
627
|
+
));
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
for (const changeId of listInvalidIdReferences(releaseContent, 'CHG', CHANGE_ID_PATTERN)) {
|
|
631
|
+
errors.push(error(
|
|
632
|
+
'invalid_release_change',
|
|
633
|
+
`Release change reference ${changeId} is not a valid CHG-* ID.`,
|
|
634
|
+
`Replace ${changeId} in docs/enterprise/releases/${releaseId} with a CHG-0000 style change ID.`
|
|
635
|
+
));
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
if (storyIds.length === 0) {
|
|
639
|
+
errors.push(error(
|
|
640
|
+
'missing_release_stories',
|
|
641
|
+
`Release ${releaseId} has no story references.`,
|
|
642
|
+
`Add included STORY-* IDs to docs/enterprise/releases/${releaseId}/release-plan.md or traceability-matrix.md.`
|
|
643
|
+
));
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
if (changeIds.length === 0) {
|
|
647
|
+
errors.push(error(
|
|
648
|
+
'missing_release_changes',
|
|
649
|
+
`Release ${releaseId} has no change references.`,
|
|
650
|
+
`Add included CHG-* IDs to docs/enterprise/releases/${releaseId}/release-plan.md, release-notes.md, or traceability-matrix.md.`
|
|
651
|
+
));
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
if (traceabilityMatrix !== null && traceabilityPairs.length === 0) {
|
|
655
|
+
errors.push(error(
|
|
656
|
+
'missing_release_traceability_pairs',
|
|
657
|
+
`Release ${releaseId} has no traceability story/change pairs.`,
|
|
658
|
+
`Add at least one same-line STORY-0000 and CHG-0000 pair to docs/enterprise/releases/${releaseId}/traceability-matrix.md.`
|
|
659
|
+
));
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
if (traceabilityMatrix !== null) {
|
|
663
|
+
const tracedStoryIds = new Set(traceabilityPairs.map((pair) => pair.storyId));
|
|
664
|
+
const tracedChangeIds = new Set(traceabilityPairs.map((pair) => pair.changeId));
|
|
665
|
+
|
|
666
|
+
for (const storyId of storyIds) {
|
|
667
|
+
if (!tracedStoryIds.has(storyId)) {
|
|
668
|
+
errors.push(error(
|
|
669
|
+
'missing_release_story_traceability',
|
|
670
|
+
`Release story ${storyId} is missing from traceability-matrix.md.`,
|
|
671
|
+
`Add ${storyId} to a same-line STORY/CHG pair in docs/enterprise/releases/${releaseId}/traceability-matrix.md.`
|
|
672
|
+
));
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
for (const changeId of changeIds) {
|
|
677
|
+
if (!tracedChangeIds.has(changeId)) {
|
|
678
|
+
errors.push(error(
|
|
679
|
+
'missing_release_change_traceability',
|
|
680
|
+
`Release change ${changeId} is missing from traceability-matrix.md.`,
|
|
681
|
+
`Add ${changeId} to a same-line STORY/CHG pair in docs/enterprise/releases/${releaseId}/traceability-matrix.md.`
|
|
682
|
+
));
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
for (const pair of traceabilityPairs) {
|
|
688
|
+
const story = loadStory(rootDir, pair.storyId);
|
|
689
|
+
const change = loadChange(rootDir, pair.changeId);
|
|
690
|
+
if (!story || story.parseError || !change || change.parseError) {
|
|
691
|
+
continue;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const linkedChanges = Array.isArray(story.data.linked_changes) ? story.data.linked_changes : [];
|
|
695
|
+
const relatedStories = Array.isArray(change.data.related_stories) ? change.data.related_stories : [];
|
|
696
|
+
if (!linkedChanges.includes(pair.changeId) || !relatedStories.includes(pair.storyId)) {
|
|
697
|
+
errors.push(error(
|
|
698
|
+
'mismatched_release_traceability',
|
|
699
|
+
`Release traceability pairs ${pair.storyId} with ${pair.changeId}, but they are not linked.`,
|
|
700
|
+
`Update ${story.relativePath} linked_changes and ${change.relativePath} related_stories so the release matrix pair is bidirectional.`
|
|
701
|
+
));
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
for (const storyId of storyIds) {
|
|
706
|
+
const story = loadStory(rootDir, storyId);
|
|
707
|
+
if (!story) {
|
|
708
|
+
errors.push(error(
|
|
709
|
+
'missing_release_story',
|
|
710
|
+
`Release story ${storyId} does not exist.`,
|
|
711
|
+
`Create docs/enterprise/stories/${storyId}-<slug>.md or remove it from release ${releaseId}.`
|
|
712
|
+
));
|
|
713
|
+
continue;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
if (story.parseError) {
|
|
717
|
+
errors.push(error(
|
|
718
|
+
'unparseable_story',
|
|
719
|
+
`Story ${storyId} could not be parsed: ${story.parseError.message}`,
|
|
720
|
+
`Fix frontmatter syntax in ${story.relativePath}.`
|
|
721
|
+
));
|
|
722
|
+
continue;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
errors.push(...validateFeature(rootDir, storyId).errors);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
for (const changeId of changeIds) {
|
|
729
|
+
const change = loadChange(rootDir, changeId);
|
|
730
|
+
if (!change) {
|
|
731
|
+
errors.push(error(
|
|
732
|
+
'missing_release_change',
|
|
733
|
+
`Release change ${changeId} does not exist.`,
|
|
734
|
+
`Create docs/enterprise/changes/${changeId}-<slug>.md or remove it from release ${releaseId}.`
|
|
735
|
+
));
|
|
736
|
+
continue;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
if (change.parseError) {
|
|
740
|
+
errors.push(error(
|
|
741
|
+
'unparseable_change',
|
|
742
|
+
`Change ${changeId} could not be parsed: ${change.parseError.message}`,
|
|
743
|
+
`Fix frontmatter syntax in ${change.relativePath}.`
|
|
744
|
+
));
|
|
745
|
+
continue;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
if (!RELEASE_CHANGE_STATUSES.has(change.data.status)) {
|
|
749
|
+
errors.push(error(
|
|
750
|
+
'unverified_release_change',
|
|
751
|
+
`${changeId} must be verified before release.`,
|
|
752
|
+
`Move ${changeId} to verified after verification evidence is complete, or remove it from release ${releaseId}.`
|
|
753
|
+
));
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
return result('release', releaseId, errors);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function listInvalidIdReferences(markdown, prefix, idPattern) {
|
|
761
|
+
const expression = new RegExp(`\\b${prefix}-[A-Za-z0-9]+\\b`, 'g');
|
|
762
|
+
const matches = markdown.match(expression) ?? [];
|
|
763
|
+
return Array.from(new Set(matches.filter((value) => !idPattern.test(value))));
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function listReleaseTraceabilityPairs(markdown) {
|
|
767
|
+
const pairs = new Map();
|
|
768
|
+
for (const line of markdown.split(/\r?\n/u)) {
|
|
769
|
+
const storyIds = line.match(/\bSTORY-[0-9]{4}\b/g) ?? [];
|
|
770
|
+
const changeIds = line.match(/\bCHG-[0-9]{4}\b/g) ?? [];
|
|
771
|
+
for (const storyId of storyIds) {
|
|
772
|
+
for (const changeId of changeIds) {
|
|
773
|
+
pairs.set(`${storyId}:${changeId}`, { storyId, changeId });
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
return Array.from(pairs.values());
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function isBareNoImpact(value) {
|
|
782
|
+
const candidates = [
|
|
783
|
+
value,
|
|
784
|
+
...value.split(/\r?\n/u).filter((line) => line.trim())
|
|
785
|
+
];
|
|
786
|
+
|
|
787
|
+
return candidates.some((candidate) => {
|
|
788
|
+
const normalized = candidate
|
|
789
|
+
.toLowerCase()
|
|
790
|
+
.replace(/^\s*(?:[-*+]|\d+[.)])\s+/u, '')
|
|
791
|
+
.replace(/[:.。]+$/u, '')
|
|
792
|
+
.trim();
|
|
793
|
+
return normalized === 'no impact' || normalized === 'not applicable';
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function isPlainObject(value) {
|
|
798
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
export function result(gate, target, errors, warnings = []) {
|
|
802
|
+
return {
|
|
803
|
+
gate,
|
|
804
|
+
target,
|
|
805
|
+
status: errors.length === 0 ? 'pass' : 'fail',
|
|
806
|
+
errors,
|
|
807
|
+
warnings,
|
|
808
|
+
nextActions: errors.map((item) => item.nextAction).filter(Boolean)
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function error(code, message, nextAction) {
|
|
813
|
+
return { code, message, nextAction };
|
|
814
|
+
}
|