@dtlabs/ai-toolkit 0.9.0 → 0.10.1
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/agents/generate-work-breakdown.md +212 -192
- package/.claude/agents/install-toolkit.md +6 -1
- package/.claude/agents/validate-work-breakdown-semantic.md +96 -0
- package/.claude/scripts/.gitkeep +0 -0
- package/.claude/scripts/wb-render.js +309 -0
- package/.claude/scripts/wb-validate.js +1007 -0
- package/.claude/workflows/pm-phase2.js +289 -28
- package/CHANGELOG.md +75 -0
- package/bin/cli.js +17 -0
- package/package.json +7 -2
- package/.claude/.ai-toolkit-version +0 -1
- package/.claude/settings.json +0 -5
|
@@ -0,0 +1,1007 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const fs = require('fs')
|
|
4
|
+
const path = require('path')
|
|
5
|
+
|
|
6
|
+
// Schema v2 constants — used by validation checks below
|
|
7
|
+
|
|
8
|
+
const SCHEMA_VERSION = 2
|
|
9
|
+
|
|
10
|
+
const REQUIRED_PHASE_FIELDS = [
|
|
11
|
+
'id',
|
|
12
|
+
'type',
|
|
13
|
+
'title',
|
|
14
|
+
'commit',
|
|
15
|
+
'tasks',
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
const VALID_DOMAINS = ['BE', 'FE', 'DB', 'DevOps', 'INFRA', 'TEST']
|
|
19
|
+
|
|
20
|
+
const VALID_AGENT_TYPES = [
|
|
21
|
+
'developer-backend',
|
|
22
|
+
'developer-frontend',
|
|
23
|
+
'developer-testing',
|
|
24
|
+
'developer-database',
|
|
25
|
+
'review-solution',
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
// Duration policy thresholds (agentMinutes) — used by check 15
|
|
29
|
+
const TARGET_MAX = 15 // tasks ≤15 min agent estimate: target band
|
|
30
|
+
const ABOVE_MAX = 20 // tasks 16–20 min: above target (no flag)
|
|
31
|
+
const WARNING_MAX = 30 // tasks 21–30 min: warning band (non-blocking); >30 = splitRequired (blocking)
|
|
32
|
+
|
|
33
|
+
// Error catalog — eliminates magic strings across validation checks
|
|
34
|
+
const ERRORS = {
|
|
35
|
+
SCHEMA_VERSION_INVALID: 'schema_version_invalid', // check 2
|
|
36
|
+
UNIQUE_ID_VIOLATION: 'unique_id_violation', // check 3
|
|
37
|
+
MISSING_FIELD: 'missing_field', // check 4
|
|
38
|
+
INVALID_ID_FORMAT: 'invalid_id_format', // check 5
|
|
39
|
+
INVALID_DOMAIN: 'invalid_domain', // check 6
|
|
40
|
+
INVALID_AGENT_TYPE: 'invalid_agent_type', // check 7
|
|
41
|
+
DEPENDENCY_NOT_FOUND: 'dependency_not_found', // check 8
|
|
42
|
+
SELF_DEPENDENCY: 'self_dependency', // check 9
|
|
43
|
+
PHASE_ID_MISMATCH: 'phase_id_mismatch', // check 10
|
|
44
|
+
TASK_CYCLE_DETECTED: 'task_cycle_detected', // check 11
|
|
45
|
+
PHASE_DEPENDENCY_NOT_FOUND: 'phase_dependency_not_found', // check 12
|
|
46
|
+
PHASE_CYCLE_DETECTED: 'phase_cycle_detected', // check 13
|
|
47
|
+
PHASE_UNSCHEDULABLE: 'phase_unschedulable', // check 14
|
|
48
|
+
DURATION_WARNING: 'duration_warning', // check 15 — non-blocking warning
|
|
49
|
+
SPLIT_REQUIRED: 'split_required', // check 15 — blocking error (>30 min)
|
|
50
|
+
EMPTY_VERIFICATION_COMMANDS: 'empty_verification_commands', // check 16
|
|
51
|
+
EMPTY_COMMIT_SUBJECT: 'empty_commit_subject', // check 17
|
|
52
|
+
MISSING_GROUPING_RATIONALE: 'missing_grouping_rationale', // check 18
|
|
53
|
+
AC_NOT_FOUND: 'ac_not_found', // check 19
|
|
54
|
+
AC_WRONG_US: 'ac_wrong_us', // check 20
|
|
55
|
+
AC_INVALID_UC_REF: 'ac_invalid_uc_ref', // check 20
|
|
56
|
+
UC_MISSING_PRIORITY: 'uc_missing_priority', // check 20
|
|
57
|
+
MUST_AC_UNCOVERED: 'must_ac_uncovered', // check 21
|
|
58
|
+
INVALID_TEXT_FIELD_CHARS: 'invalid_text_field_chars', // check 22
|
|
59
|
+
EMPTY_PHASE: 'empty_phase', // check 23
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── AC table parser (prerequisite for checks 19–21) ─────────────────────────
|
|
63
|
+
|
|
64
|
+
function parseAcTable(requirementsPath) {
|
|
65
|
+
if (!requirementsPath) return null
|
|
66
|
+
|
|
67
|
+
let text
|
|
68
|
+
try {
|
|
69
|
+
text = fs.readFileSync(path.resolve(requirementsPath), 'utf8')
|
|
70
|
+
} catch (err) {
|
|
71
|
+
process.stderr.write(`Error: cannot read requirements file "${requirementsPath}": ${err.message}\n`)
|
|
72
|
+
process.exit(2)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const lines = text.split(/\r?\n/)
|
|
76
|
+
|
|
77
|
+
// ── Parse UC priorities ────────────────────────────────────────────────────
|
|
78
|
+
// Scan for ### UC-NN: headings, then look for | Priority | <value> | in the
|
|
79
|
+
// following metadata table (stop at the next same-or-higher-level heading).
|
|
80
|
+
const ucPriorityMap = new Map()
|
|
81
|
+
const UC_HEADING_RE = /^###\s+(UC-\d+):/
|
|
82
|
+
|
|
83
|
+
for (let i = 0; i < lines.length; i++) {
|
|
84
|
+
const headingMatch = lines[i].match(UC_HEADING_RE)
|
|
85
|
+
if (!headingMatch) continue
|
|
86
|
+
const ucId = headingMatch[1]
|
|
87
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
88
|
+
if (/^#{1,3}\s/.test(lines[j])) break
|
|
89
|
+
const priorityMatch = lines[j].match(/^\|\s*Priority\s*\|\s*(\S+)\s*\|/)
|
|
90
|
+
if (priorityMatch) {
|
|
91
|
+
ucPriorityMap.set(ucId, priorityMatch[1])
|
|
92
|
+
break
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── Locate ## 7. Acceptance Criteria section ──────────────────────────────
|
|
98
|
+
let acSectionIdx = -1
|
|
99
|
+
for (let i = 0; i < lines.length; i++) {
|
|
100
|
+
if (/^## 7\. Acceptance Criteria\s*$/.test(lines[i])) {
|
|
101
|
+
acSectionIdx = i
|
|
102
|
+
break
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (acSectionIdx === -1) {
|
|
106
|
+
process.stderr.write('Error: requirements file is missing the "## 7. Acceptance Criteria" section\n')
|
|
107
|
+
process.exit(2)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── Find the first Markdown table (header row containing | ID |) ──────────
|
|
111
|
+
let tableHeaderIdx = -1
|
|
112
|
+
for (let i = acSectionIdx + 1; i < lines.length; i++) {
|
|
113
|
+
if (/^##\s/.test(lines[i])) break
|
|
114
|
+
if (/\|\s*ID\s*\|/.test(lines[i])) {
|
|
115
|
+
tableHeaderIdx = i
|
|
116
|
+
break
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (tableHeaderIdx === -1) {
|
|
120
|
+
process.stderr.write('Error: no AC table found after "## 7. Acceptance Criteria" (expected a header row containing "| ID |")\n')
|
|
121
|
+
process.exit(2)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── Validate column headers ────────────────────────────────────────────────
|
|
125
|
+
const headerCells = lines[tableHeaderIdx].split('|').slice(1, -1).map(c => c.trim())
|
|
126
|
+
if (headerCells.length < 3) {
|
|
127
|
+
process.stderr.write('Error: AC table header has fewer than 3 columns\n')
|
|
128
|
+
process.exit(2)
|
|
129
|
+
}
|
|
130
|
+
if (headerCells[0] !== 'ID' || headerCells[1] !== 'Criterion' || headerCells[2] !== 'Related UC') {
|
|
131
|
+
process.stderr.write(`Error: AC table columns must be "ID", "Criterion", "Related UC" in order; got "${headerCells[0]}", "${headerCells[1]}", "${headerCells[2]}"\n`)
|
|
132
|
+
process.exit(2)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── Parse data rows ────────────────────────────────────────────────────────
|
|
136
|
+
const PRIORITY_ORDER = ['Must', 'Should', 'Could']
|
|
137
|
+
const UC_REF_RE = /^UC-\d+$/
|
|
138
|
+
const acMap = new Map()
|
|
139
|
+
|
|
140
|
+
for (let i = tableHeaderIdx + 1; i < lines.length; i++) {
|
|
141
|
+
const line = lines[i]
|
|
142
|
+
if (/^##\s/.test(line)) break
|
|
143
|
+
if (!line.trim().startsWith('|')) break
|
|
144
|
+
|
|
145
|
+
// Skip separator rows: every cell is only dashes, colons, or spaces
|
|
146
|
+
const rawCells = line.split('|').slice(1, -1)
|
|
147
|
+
if (rawCells.every(c => /^[\s\-:]+$/.test(c))) continue
|
|
148
|
+
|
|
149
|
+
const cells = rawCells.map(c => c.trim())
|
|
150
|
+
if (cells.length < 3) continue
|
|
151
|
+
|
|
152
|
+
const acId = cells[0]
|
|
153
|
+
const relatedUcRaw = cells[2]
|
|
154
|
+
|
|
155
|
+
if (!acId) continue
|
|
156
|
+
|
|
157
|
+
if (acMap.has(acId)) {
|
|
158
|
+
process.stderr.write(`Error: duplicate AC ID "${acId}" in AC table\n`)
|
|
159
|
+
process.exit(2)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
let unscoped = false
|
|
163
|
+
let allowedUserStories = []
|
|
164
|
+
let acPriority
|
|
165
|
+
|
|
166
|
+
if (relatedUcRaw.trim().toLowerCase() === 'all ucs') {
|
|
167
|
+
unscoped = true
|
|
168
|
+
allowedUserStories = []
|
|
169
|
+
// Derive strongest priority from all known UCs
|
|
170
|
+
acPriority = [...ucPriorityMap.values()].reduce((best, p) => {
|
|
171
|
+
const bestIdx = PRIORITY_ORDER.indexOf(best)
|
|
172
|
+
const pIdx = PRIORITY_ORDER.indexOf(p)
|
|
173
|
+
return pIdx !== -1 && pIdx < bestIdx ? p : best
|
|
174
|
+
}, 'Could')
|
|
175
|
+
} else {
|
|
176
|
+
const ucTokens = relatedUcRaw.split(',').map(t => t.trim())
|
|
177
|
+
|
|
178
|
+
for (const token of ucTokens) {
|
|
179
|
+
if (!UC_REF_RE.test(token)) {
|
|
180
|
+
process.stderr.write(`Error: malformed UC reference "${token}" in AC "${acId}" Related UC field\n`)
|
|
181
|
+
process.exit(2)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
for (const ucRef of ucTokens) {
|
|
186
|
+
if (!ucPriorityMap.has(ucRef)) {
|
|
187
|
+
process.stderr.write(`Error: AC "${acId}" references UC "${ucRef}" which does not exist in the requirements\n`)
|
|
188
|
+
process.exit(2)
|
|
189
|
+
}
|
|
190
|
+
if (!ucPriorityMap.get(ucRef)) {
|
|
191
|
+
process.stderr.write(`Error: ${ucRef} has no declared priority\n`)
|
|
192
|
+
process.exit(2)
|
|
193
|
+
}
|
|
194
|
+
allowedUserStories.push(ucRef.replace('UC-', 'US-'))
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
acPriority = ucTokens.reduce((best, ucRef) => {
|
|
198
|
+
const p = ucPriorityMap.get(ucRef)
|
|
199
|
+
const bestIdx = PRIORITY_ORDER.indexOf(best)
|
|
200
|
+
const pIdx = PRIORITY_ORDER.indexOf(p)
|
|
201
|
+
return pIdx !== -1 && pIdx < bestIdx ? p : best
|
|
202
|
+
}, 'Could')
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
acMap.set(acId, { id: acId, priority: acPriority, allowedUserStories, unscoped })
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return acMap
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ── Entry point ──────────────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
const jsonPath = process.argv[2]
|
|
214
|
+
const requirementsPath = process.argv[3] // optional; used by AC checks (US-02-T10..T12)
|
|
215
|
+
|
|
216
|
+
if (!jsonPath) {
|
|
217
|
+
process.stderr.write('Error: missing required argument <path-to-work-breakdown.json>\n')
|
|
218
|
+
process.exit(2)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Read and parse the work breakdown JSON
|
|
222
|
+
let raw
|
|
223
|
+
try {
|
|
224
|
+
raw = fs.readFileSync(path.resolve(jsonPath), 'utf8')
|
|
225
|
+
} catch (err) {
|
|
226
|
+
process.stderr.write(`Error: cannot read file "${jsonPath}": ${err.message}\n`)
|
|
227
|
+
process.exit(2)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
let wb
|
|
231
|
+
try {
|
|
232
|
+
wb = JSON.parse(raw)
|
|
233
|
+
} catch (err) {
|
|
234
|
+
process.stderr.write(`Error: "${jsonPath}" is not valid JSON: ${err.message}\n`)
|
|
235
|
+
process.exit(2)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ── Report structure ─────────────────────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
const report = {
|
|
241
|
+
valid: true,
|
|
242
|
+
schemaVersion: wb.schemaVersion,
|
|
243
|
+
taskCount: 0,
|
|
244
|
+
errors: [],
|
|
245
|
+
warnings: [],
|
|
246
|
+
durationBands: {},
|
|
247
|
+
domainDistribution: {},
|
|
248
|
+
dependencies: {},
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ── Check 2: Schema version ──────────────────────────────────────────────────
|
|
252
|
+
|
|
253
|
+
if (wb.schemaVersion !== SCHEMA_VERSION) {
|
|
254
|
+
report.errors.push({
|
|
255
|
+
category: ERRORS.SCHEMA_VERSION_INVALID,
|
|
256
|
+
severity: 'error',
|
|
257
|
+
taskId: null,
|
|
258
|
+
field: 'schemaVersion',
|
|
259
|
+
message: `schemaVersion must be ${SCHEMA_VERSION}, got ${JSON.stringify(wb.schemaVersion)}`,
|
|
260
|
+
details: { expected: SCHEMA_VERSION, actual: wb.schemaVersion },
|
|
261
|
+
})
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ── Check 3: Unique task IDs ─────────────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
const phases = Array.isArray(wb.phases) ? wb.phases : []
|
|
267
|
+
const seenTaskIds = new Map() // taskId → first-seen phaseId
|
|
268
|
+
const duplicateTaskIds = new Set()
|
|
269
|
+
|
|
270
|
+
for (const phase of phases) {
|
|
271
|
+
const tasks = Array.isArray(phase.tasks) ? phase.tasks : []
|
|
272
|
+
report.taskCount += tasks.length
|
|
273
|
+
for (const task of tasks) {
|
|
274
|
+
if (task.id != null) {
|
|
275
|
+
if (seenTaskIds.has(task.id)) {
|
|
276
|
+
duplicateTaskIds.add(task.id)
|
|
277
|
+
} else {
|
|
278
|
+
seenTaskIds.set(task.id, phase.id)
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
for (const taskId of duplicateTaskIds) {
|
|
285
|
+
report.errors.push({
|
|
286
|
+
category: ERRORS.UNIQUE_ID_VIOLATION,
|
|
287
|
+
severity: 'error',
|
|
288
|
+
taskId,
|
|
289
|
+
field: 'id',
|
|
290
|
+
message: `Duplicate task ID "${taskId}" appears in multiple phases`,
|
|
291
|
+
details: { taskId },
|
|
292
|
+
})
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ── Check 4: Required phase fields ───────────────────────────────────────────
|
|
296
|
+
|
|
297
|
+
for (const phase of phases) {
|
|
298
|
+
const phaseId = phase.id || '(unknown)'
|
|
299
|
+
|
|
300
|
+
for (const field of REQUIRED_PHASE_FIELDS) {
|
|
301
|
+
if (phase[field] == null) {
|
|
302
|
+
report.errors.push({
|
|
303
|
+
category: ERRORS.MISSING_FIELD,
|
|
304
|
+
severity: 'error',
|
|
305
|
+
phaseId,
|
|
306
|
+
taskId: null,
|
|
307
|
+
field,
|
|
308
|
+
message: `Phase "${phaseId}" is missing required field "${field}"`,
|
|
309
|
+
details: { phaseId, field },
|
|
310
|
+
})
|
|
311
|
+
} else if (field === 'tasks' && !Array.isArray(phase[field])) {
|
|
312
|
+
report.errors.push({
|
|
313
|
+
category: ERRORS.MISSING_FIELD,
|
|
314
|
+
severity: 'error',
|
|
315
|
+
phaseId,
|
|
316
|
+
taskId: null,
|
|
317
|
+
field,
|
|
318
|
+
message: `Phase "${phaseId}" field "tasks" must be an array`,
|
|
319
|
+
details: { phaseId, field },
|
|
320
|
+
})
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ── Check 4: Required task fields ─────────────────────────────────────────
|
|
325
|
+
|
|
326
|
+
const tasks = Array.isArray(phase.tasks) ? phase.tasks : []
|
|
327
|
+
for (const task of tasks) {
|
|
328
|
+
const taskId = task.id || '(unknown)'
|
|
329
|
+
|
|
330
|
+
const topLevelFields = [
|
|
331
|
+
'id', 'title', 'outcome', 'domain', 'agentType', 'dependsOn',
|
|
332
|
+
'acceptanceCriteria', 'outputCount', 'groupingRationale', 'commit',
|
|
333
|
+
]
|
|
334
|
+
for (const field of topLevelFields) {
|
|
335
|
+
if (task[field] == null) {
|
|
336
|
+
report.errors.push({
|
|
337
|
+
category: ERRORS.MISSING_FIELD,
|
|
338
|
+
severity: 'error',
|
|
339
|
+
taskId,
|
|
340
|
+
field,
|
|
341
|
+
message: `Task "${taskId}" is missing required field "${field}"`,
|
|
342
|
+
details: { taskId, field },
|
|
343
|
+
})
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Nested: verification.commands — existence and array type only; emptiness checked later
|
|
348
|
+
if (task.verification == null || task.verification.commands == null) {
|
|
349
|
+
report.errors.push({
|
|
350
|
+
category: ERRORS.MISSING_FIELD,
|
|
351
|
+
severity: 'error',
|
|
352
|
+
taskId,
|
|
353
|
+
field: 'verification.commands',
|
|
354
|
+
message: `Task "${taskId}" is missing required field "verification.commands"`,
|
|
355
|
+
details: { taskId, field: 'verification.commands' },
|
|
356
|
+
})
|
|
357
|
+
} else if (!Array.isArray(task.verification.commands)) {
|
|
358
|
+
report.errors.push({
|
|
359
|
+
category: ERRORS.MISSING_FIELD,
|
|
360
|
+
severity: 'error',
|
|
361
|
+
taskId,
|
|
362
|
+
field: 'verification.commands',
|
|
363
|
+
message: `Task "${taskId}" field "verification.commands" must be an array`,
|
|
364
|
+
details: { taskId, field: 'verification.commands' },
|
|
365
|
+
})
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Nested: estimate.agentMinutes
|
|
369
|
+
if (task.estimate == null || task.estimate.agentMinutes == null) {
|
|
370
|
+
report.errors.push({
|
|
371
|
+
category: ERRORS.MISSING_FIELD,
|
|
372
|
+
severity: 'error',
|
|
373
|
+
taskId,
|
|
374
|
+
field: 'estimate.agentMinutes',
|
|
375
|
+
message: `Task "${taskId}" is missing required field "estimate.agentMinutes"`,
|
|
376
|
+
details: { taskId, field: 'estimate.agentMinutes' },
|
|
377
|
+
})
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Nested: estimate.tokens
|
|
381
|
+
if (task.estimate == null || task.estimate.tokens == null) {
|
|
382
|
+
report.errors.push({
|
|
383
|
+
category: ERRORS.MISSING_FIELD,
|
|
384
|
+
severity: 'error',
|
|
385
|
+
taskId,
|
|
386
|
+
field: 'estimate.tokens',
|
|
387
|
+
message: `Task "${taskId}" is missing required field "estimate.tokens"`,
|
|
388
|
+
details: { taskId, field: 'estimate.tokens' },
|
|
389
|
+
})
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// commit.subject — commit itself is checked above as a top-level field
|
|
393
|
+
if (task.commit != null && task.commit.subject == null) {
|
|
394
|
+
report.errors.push({
|
|
395
|
+
category: ERRORS.MISSING_FIELD,
|
|
396
|
+
severity: 'error',
|
|
397
|
+
taskId,
|
|
398
|
+
field: 'commit.subject',
|
|
399
|
+
message: `Task "${taskId}" is missing required field "commit.subject"`,
|
|
400
|
+
details: { taskId, field: 'commit.subject' },
|
|
401
|
+
})
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ── Check 5: Task ID format ──────────────────────────────────────────────────
|
|
407
|
+
|
|
408
|
+
const INFRA_TASK_ID_RE = /^INFRA-TASK-(BE|FE|DB|DevOps|INFRA|TEST)-\d+$/
|
|
409
|
+
const US_TASK_ID_RE = /^[A-Z0-9-]+-TASK-(BE|FE|DB|DevOps|INFRA|TEST)-\d+$/
|
|
410
|
+
|
|
411
|
+
for (const phase of phases) {
|
|
412
|
+
const isInfraPhase = phase.id === 'INFRA' || phase.type === 'infra'
|
|
413
|
+
const re = isInfraPhase ? INFRA_TASK_ID_RE : US_TASK_ID_RE
|
|
414
|
+
const tasks = Array.isArray(phase.tasks) ? phase.tasks : []
|
|
415
|
+
for (const task of tasks) {
|
|
416
|
+
if (task.id == null) continue
|
|
417
|
+
if (!re.test(task.id)) {
|
|
418
|
+
report.errors.push({
|
|
419
|
+
category: ERRORS.INVALID_ID_FORMAT,
|
|
420
|
+
severity: 'error',
|
|
421
|
+
taskId: task.id,
|
|
422
|
+
phaseId: phase.id,
|
|
423
|
+
field: 'id',
|
|
424
|
+
message: `Task "${task.id}" in phase "${phase.id}" has an invalid ID format`,
|
|
425
|
+
details: { taskId: task.id, phaseId: phase.id, expectedPattern: re.toString() },
|
|
426
|
+
})
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// ── Check 6: Domain whitelist ────────────────────────────────────────────────
|
|
432
|
+
|
|
433
|
+
for (const phase of phases) {
|
|
434
|
+
const tasks = Array.isArray(phase.tasks) ? phase.tasks : []
|
|
435
|
+
for (const task of tasks) {
|
|
436
|
+
if (task.domain == null) continue
|
|
437
|
+
if (!VALID_DOMAINS.includes(task.domain)) {
|
|
438
|
+
report.errors.push({
|
|
439
|
+
category: ERRORS.INVALID_DOMAIN,
|
|
440
|
+
severity: 'error',
|
|
441
|
+
taskId: task.id,
|
|
442
|
+
field: 'domain',
|
|
443
|
+
message: `Task "${task.id}" has invalid domain "${task.domain}"; must be one of ${VALID_DOMAINS.join(', ')}`,
|
|
444
|
+
details: { taskId: task.id, domain: task.domain, validDomains: VALID_DOMAINS },
|
|
445
|
+
})
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// ── Check 7: AgentType whitelist ─────────────────────────────────────────────
|
|
451
|
+
|
|
452
|
+
for (const phase of phases) {
|
|
453
|
+
const tasks = Array.isArray(phase.tasks) ? phase.tasks : []
|
|
454
|
+
for (const task of tasks) {
|
|
455
|
+
if (task.agentType == null) continue
|
|
456
|
+
if (!VALID_AGENT_TYPES.includes(task.agentType)) {
|
|
457
|
+
report.errors.push({
|
|
458
|
+
category: ERRORS.INVALID_AGENT_TYPE,
|
|
459
|
+
severity: 'error',
|
|
460
|
+
taskId: task.id,
|
|
461
|
+
field: 'agentType',
|
|
462
|
+
message: `Task "${task.id}" has invalid agentType "${task.agentType}"; must be one of ${VALID_AGENT_TYPES.join(', ')}`,
|
|
463
|
+
details: { taskId: task.id, agentType: task.agentType, validAgentTypes: VALID_AGENT_TYPES },
|
|
464
|
+
})
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ── Check 8: Dependency reference existence ──────────────────────────────────
|
|
470
|
+
|
|
471
|
+
const allTaskIds = new Set(seenTaskIds.keys())
|
|
472
|
+
|
|
473
|
+
for (const phase of phases) {
|
|
474
|
+
const tasks = Array.isArray(phase.tasks) ? phase.tasks : []
|
|
475
|
+
for (const task of tasks) {
|
|
476
|
+
if (!Array.isArray(task.dependsOn)) continue
|
|
477
|
+
for (const refId of task.dependsOn) {
|
|
478
|
+
if (!allTaskIds.has(refId)) {
|
|
479
|
+
report.errors.push({
|
|
480
|
+
category: ERRORS.DEPENDENCY_NOT_FOUND,
|
|
481
|
+
severity: 'error',
|
|
482
|
+
taskId: task.id,
|
|
483
|
+
field: 'dependsOn',
|
|
484
|
+
message: `Task "${task.id}" references non-existent task ID "${refId}" in dependsOn`,
|
|
485
|
+
details: { taskId: task.id, missingRef: refId },
|
|
486
|
+
})
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// ── Check 9: Self-dependency ─────────────────────────────────────────────────
|
|
493
|
+
|
|
494
|
+
for (const phase of phases) {
|
|
495
|
+
const tasks = Array.isArray(phase.tasks) ? phase.tasks : []
|
|
496
|
+
for (const task of tasks) {
|
|
497
|
+
if (!Array.isArray(task.dependsOn)) continue
|
|
498
|
+
if (task.dependsOn.includes(task.id)) {
|
|
499
|
+
report.errors.push({
|
|
500
|
+
category: ERRORS.SELF_DEPENDENCY,
|
|
501
|
+
severity: 'error',
|
|
502
|
+
taskId: task.id,
|
|
503
|
+
field: 'dependsOn',
|
|
504
|
+
message: `Task "${task.id}" lists itself in dependsOn (self-dependency)`,
|
|
505
|
+
details: { taskId: task.id },
|
|
506
|
+
})
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// ── Check 10: Phase ID consistency ───────────────────────────────────────────
|
|
512
|
+
|
|
513
|
+
const INFRA_PREFIX = 'INFRA-TASK-'
|
|
514
|
+
const TASK_SEP = '-TASK-'
|
|
515
|
+
|
|
516
|
+
for (const phase of phases) {
|
|
517
|
+
const tasks = Array.isArray(phase.tasks) ? phase.tasks : []
|
|
518
|
+
for (const task of tasks) {
|
|
519
|
+
if (task.id == null) continue
|
|
520
|
+
if (task.id.startsWith(INFRA_PREFIX)) {
|
|
521
|
+
if (phase.id !== 'INFRA') {
|
|
522
|
+
report.errors.push({
|
|
523
|
+
category: ERRORS.PHASE_ID_MISMATCH,
|
|
524
|
+
severity: 'error',
|
|
525
|
+
taskId: task.id,
|
|
526
|
+
field: 'id',
|
|
527
|
+
message: `Task "${task.id}" has INFRA prefix but is in phase "${phase.id}"; INFRA tasks must be in the INFRA phase`,
|
|
528
|
+
details: { taskId: task.id, phaseId: phase.id, expectedPhase: 'INFRA' },
|
|
529
|
+
})
|
|
530
|
+
}
|
|
531
|
+
} else {
|
|
532
|
+
const sepIdx = task.id.indexOf(TASK_SEP)
|
|
533
|
+
if (sepIdx !== -1) {
|
|
534
|
+
const extractedPrefix = task.id.substring(0, sepIdx)
|
|
535
|
+
if (extractedPrefix !== phase.id) {
|
|
536
|
+
report.errors.push({
|
|
537
|
+
category: ERRORS.PHASE_ID_MISMATCH,
|
|
538
|
+
severity: 'error',
|
|
539
|
+
taskId: task.id,
|
|
540
|
+
field: 'id',
|
|
541
|
+
message: `Task "${task.id}" has prefix "${extractedPrefix}" but is in phase "${phase.id}"`,
|
|
542
|
+
details: { taskId: task.id, phaseId: phase.id, extractedPrefix },
|
|
543
|
+
})
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// ── Check 11: Task cycle detection (DFS gray/black coloring) ─────────────────
|
|
551
|
+
|
|
552
|
+
// Build adjacency map; skip tasks with null IDs; skip refs not in graph and self-deps
|
|
553
|
+
const adjMap = new Map() // taskId → valid dependsOn[]
|
|
554
|
+
for (const phase of phases) {
|
|
555
|
+
const tasks = Array.isArray(phase.tasks) ? phase.tasks : []
|
|
556
|
+
for (const task of tasks) {
|
|
557
|
+
if (task.id == null) continue
|
|
558
|
+
const validDeps = Array.isArray(task.dependsOn)
|
|
559
|
+
? task.dependsOn.filter(dep => allTaskIds.has(dep) && dep !== task.id)
|
|
560
|
+
: []
|
|
561
|
+
adjMap.set(task.id, validDeps)
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// Three-color DFS: WHITE = unvisited, GRAY = in progress (on current path), BLACK = done
|
|
566
|
+
const WHITE = 0
|
|
567
|
+
const GRAY = 1
|
|
568
|
+
const BLACK = 2
|
|
569
|
+
|
|
570
|
+
const colors = new Map()
|
|
571
|
+
for (const taskId of adjMap.keys()) {
|
|
572
|
+
colors.set(taskId, WHITE)
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const detectedCycles = []
|
|
576
|
+
|
|
577
|
+
function dfsCycle(nodeId, pathStack) {
|
|
578
|
+
colors.set(nodeId, GRAY)
|
|
579
|
+
pathStack.push(nodeId)
|
|
580
|
+
|
|
581
|
+
for (const dep of adjMap.get(nodeId)) {
|
|
582
|
+
const color = colors.get(dep)
|
|
583
|
+
if (color === GRAY) {
|
|
584
|
+
// Back edge — collect all members of this cycle from the current path stack
|
|
585
|
+
const cycleStart = pathStack.indexOf(dep)
|
|
586
|
+
detectedCycles.push(pathStack.slice(cycleStart))
|
|
587
|
+
} else if (color === WHITE) {
|
|
588
|
+
dfsCycle(dep, pathStack)
|
|
589
|
+
}
|
|
590
|
+
// BLACK: node fully processed — no cycle through it
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
pathStack.pop()
|
|
594
|
+
colors.set(nodeId, BLACK)
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
for (const taskId of adjMap.keys()) {
|
|
598
|
+
if (colors.get(taskId) === WHITE) {
|
|
599
|
+
dfsCycle(taskId, [])
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Populate dependencies with task-level graph data
|
|
604
|
+
report.dependencies.taskGraph = {}
|
|
605
|
+
for (const [taskId, deps] of adjMap) {
|
|
606
|
+
report.dependencies.taskGraph[taskId] = deps
|
|
607
|
+
}
|
|
608
|
+
report.dependencies.taskCycles = detectedCycles
|
|
609
|
+
|
|
610
|
+
// Report each detected cycle as one error entry
|
|
611
|
+
for (const cycleMembers of detectedCycles) {
|
|
612
|
+
report.errors.push({
|
|
613
|
+
category: ERRORS.TASK_CYCLE_DETECTED,
|
|
614
|
+
severity: 'error',
|
|
615
|
+
taskId: null,
|
|
616
|
+
field: null,
|
|
617
|
+
message: `Dependency cycle detected among tasks: ${cycleMembers.join(' → ')} → ${cycleMembers[0]}`,
|
|
618
|
+
details: { cycleMembers },
|
|
619
|
+
})
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// ── Check 12 & 13: Phase dependency projection and cycle detection ────────────
|
|
623
|
+
|
|
624
|
+
// seenTaskIds already maps taskId → ownerPhaseId; alias for clarity
|
|
625
|
+
const taskToPhase = seenTaskIds // Map<taskId, phaseId>
|
|
626
|
+
|
|
627
|
+
// All known phase IDs for existence checks (Check 12)
|
|
628
|
+
const phaseIdSet = new Set(phases.map(p => p.id).filter(id => id != null))
|
|
629
|
+
|
|
630
|
+
// Step 2: Compute phase-level dependsOn projection (same algorithm as wb-render.js)
|
|
631
|
+
// For each phase: union all task dependsOn IDs → map to owner phase → remove self → deduplicate
|
|
632
|
+
const phaseDeps = new Map() // Map<phaseId, Set<phaseId>>
|
|
633
|
+
|
|
634
|
+
for (const phase of phases) {
|
|
635
|
+
if (phase.id == null) continue
|
|
636
|
+
const deps = new Set()
|
|
637
|
+
const tasks = Array.isArray(phase.tasks) ? phase.tasks : []
|
|
638
|
+
for (const task of tasks) {
|
|
639
|
+
if (!Array.isArray(task.dependsOn)) continue
|
|
640
|
+
for (const refId of task.dependsOn) {
|
|
641
|
+
const ownerPhase = taskToPhase.get(refId)
|
|
642
|
+
if (ownerPhase == null) continue // unresolved task dep — already reported in check 8
|
|
643
|
+
if (ownerPhase === phase.id) continue // remove self-loops (intra-phase)
|
|
644
|
+
deps.add(ownerPhase)
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
phaseDeps.set(phase.id, deps)
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// Check 12: Phase dependency existence (defensive — should not fire if check 8 passed)
|
|
651
|
+
for (const [phaseId, deps] of phaseDeps) {
|
|
652
|
+
for (const depPhaseId of deps) {
|
|
653
|
+
if (!phaseIdSet.has(depPhaseId)) {
|
|
654
|
+
report.errors.push({
|
|
655
|
+
category: ERRORS.PHASE_DEPENDENCY_NOT_FOUND,
|
|
656
|
+
severity: 'error',
|
|
657
|
+
taskId: null,
|
|
658
|
+
field: 'dependsOn',
|
|
659
|
+
message: `Phase "${phaseId}" has a projected dependency on phase "${depPhaseId}" which does not exist`,
|
|
660
|
+
details: { phaseId, missingPhase: depPhaseId },
|
|
661
|
+
})
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Check 13: Phase cycle detection (same DFS gray/black algorithm as check 11)
|
|
667
|
+
const phaseColors = new Map()
|
|
668
|
+
for (const phaseId of phaseDeps.keys()) {
|
|
669
|
+
phaseColors.set(phaseId, WHITE)
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const detectedPhaseCycles = []
|
|
673
|
+
|
|
674
|
+
function dfsPhaseCycle(nodeId, pathStack) {
|
|
675
|
+
phaseColors.set(nodeId, GRAY)
|
|
676
|
+
pathStack.push(nodeId)
|
|
677
|
+
|
|
678
|
+
for (const dep of phaseDeps.get(nodeId)) {
|
|
679
|
+
const color = phaseColors.get(dep)
|
|
680
|
+
if (color === GRAY) {
|
|
681
|
+
const cycleStart = pathStack.indexOf(dep)
|
|
682
|
+
detectedPhaseCycles.push(pathStack.slice(cycleStart))
|
|
683
|
+
} else if (color === WHITE) {
|
|
684
|
+
dfsPhaseCycle(dep, pathStack)
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
pathStack.pop()
|
|
689
|
+
phaseColors.set(nodeId, BLACK)
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
for (const phaseId of phaseDeps.keys()) {
|
|
693
|
+
if (phaseColors.get(phaseId) === WHITE) {
|
|
694
|
+
dfsPhaseCycle(phaseId, [])
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Store phase graph in report as plain object for JSON serialization
|
|
699
|
+
report.dependencies.phaseGraph = {}
|
|
700
|
+
for (const [phaseId, deps] of phaseDeps) {
|
|
701
|
+
report.dependencies.phaseGraph[phaseId] = [...deps].sort()
|
|
702
|
+
}
|
|
703
|
+
report.dependencies.phaseCycles = detectedPhaseCycles
|
|
704
|
+
|
|
705
|
+
// Report each detected phase cycle as one error entry
|
|
706
|
+
for (const cycleMembers of detectedPhaseCycles) {
|
|
707
|
+
report.errors.push({
|
|
708
|
+
category: ERRORS.PHASE_CYCLE_DETECTED,
|
|
709
|
+
severity: 'error',
|
|
710
|
+
taskId: null,
|
|
711
|
+
field: null,
|
|
712
|
+
message: `Phase dependency cycle detected: ${cycleMembers.join(' → ')} → ${cycleMembers[0]}`,
|
|
713
|
+
details: { cycleMembers },
|
|
714
|
+
})
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// ── Check 14: Phase schedulability (buildWaves) ──────────────────────────────
|
|
718
|
+
|
|
719
|
+
const buildWavesDone = new Set()
|
|
720
|
+
const phaseWaves = []
|
|
721
|
+
let phaseRemaining = phases.map(p => p.id).filter(id => id != null)
|
|
722
|
+
while (phaseRemaining.length > 0) {
|
|
723
|
+
const ready = phaseRemaining.filter(id => [...(phaseDeps.get(id) || [])].every(d => buildWavesDone.has(d)))
|
|
724
|
+
if (ready.length === 0) break // deadlock — unschedulable phases remain
|
|
725
|
+
ready.forEach(id => buildWavesDone.add(id))
|
|
726
|
+
phaseWaves.push(ready)
|
|
727
|
+
phaseRemaining = phaseRemaining.filter(id => !buildWavesDone.has(id))
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
report.dependencies.phaseWaves = phaseWaves
|
|
731
|
+
report.dependencies.phaseUnschedulable = phaseRemaining
|
|
732
|
+
|
|
733
|
+
for (const phaseId of phaseRemaining) {
|
|
734
|
+
report.errors.push({
|
|
735
|
+
category: ERRORS.PHASE_UNSCHEDULABLE,
|
|
736
|
+
severity: 'error',
|
|
737
|
+
taskId: null,
|
|
738
|
+
phaseId,
|
|
739
|
+
field: null,
|
|
740
|
+
message: `Phase "${phaseId}" cannot be scheduled: all predecessor phases are unschedulable or form a deadlock`,
|
|
741
|
+
details: { phaseId },
|
|
742
|
+
})
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// ── Check 15: Duration policy ────────────────────────────────────────────────
|
|
746
|
+
|
|
747
|
+
const durationBands = { target: 0, above_target: 0, warning: 0, split_required: 0 }
|
|
748
|
+
|
|
749
|
+
for (const phase of phases) {
|
|
750
|
+
const tasks = Array.isArray(phase.tasks) ? phase.tasks : []
|
|
751
|
+
for (const task of tasks) {
|
|
752
|
+
const minutes = task.estimate?.agentMinutes
|
|
753
|
+
if (minutes == null) continue // already caught by check 4; skip here
|
|
754
|
+
|
|
755
|
+
let band
|
|
756
|
+
if (minutes <= TARGET_MAX) {
|
|
757
|
+
band = 'target'
|
|
758
|
+
} else if (minutes <= ABOVE_MAX) {
|
|
759
|
+
band = 'above_target'
|
|
760
|
+
} else if (minutes <= WARNING_MAX) {
|
|
761
|
+
band = 'warning'
|
|
762
|
+
report.warnings.push({
|
|
763
|
+
category: ERRORS.DURATION_WARNING,
|
|
764
|
+
severity: 'warning',
|
|
765
|
+
taskId: task.id,
|
|
766
|
+
field: 'estimate.agentMinutes',
|
|
767
|
+
message: `Task "${task.id}" estimate ${minutes}min exceeds target (${TARGET_MAX}min); consider splitting`,
|
|
768
|
+
details: { taskId: task.id, agentMinutes: minutes, targetMax: TARGET_MAX, warningMax: WARNING_MAX },
|
|
769
|
+
})
|
|
770
|
+
} else {
|
|
771
|
+
band = 'split_required'
|
|
772
|
+
report.errors.push({
|
|
773
|
+
category: ERRORS.SPLIT_REQUIRED,
|
|
774
|
+
severity: 'error',
|
|
775
|
+
taskId: task.id,
|
|
776
|
+
field: 'estimate.agentMinutes',
|
|
777
|
+
message: `Task "${task.id}" estimate ${minutes}min exceeds maximum (${WARNING_MAX}min); must be split`,
|
|
778
|
+
details: { taskId: task.id, agentMinutes: minutes, warningMax: WARNING_MAX },
|
|
779
|
+
})
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
durationBands[band]++
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
report.durationBands = durationBands
|
|
787
|
+
|
|
788
|
+
// ── Check 16 / 17 / 18: Content-quality checks ───────────────────────────────
|
|
789
|
+
// Check 16: Empty verification.commands array
|
|
790
|
+
// Check 17: Empty or whitespace-only commit.subject
|
|
791
|
+
// Check 18: Empty or whitespace-only groupingRationale
|
|
792
|
+
|
|
793
|
+
for (const phase of phases) {
|
|
794
|
+
const tasks = Array.isArray(phase.tasks) ? phase.tasks : []
|
|
795
|
+
for (const task of tasks) {
|
|
796
|
+
// Check 16: Empty verification commands
|
|
797
|
+
if (
|
|
798
|
+
task.verification != null &&
|
|
799
|
+
task.verification.commands != null &&
|
|
800
|
+
Array.isArray(task.verification.commands) &&
|
|
801
|
+
task.verification.commands.length === 0
|
|
802
|
+
) {
|
|
803
|
+
report.errors.push({
|
|
804
|
+
category: ERRORS.EMPTY_VERIFICATION_COMMANDS,
|
|
805
|
+
severity: 'error',
|
|
806
|
+
taskId: task.id,
|
|
807
|
+
field: 'verification.commands',
|
|
808
|
+
message: `Task "${task.id}" has an empty verification.commands array; at least one command is required`,
|
|
809
|
+
details: { taskId: task.id },
|
|
810
|
+
})
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// Check 17: Empty commit subject
|
|
814
|
+
if (
|
|
815
|
+
task.commit != null &&
|
|
816
|
+
task.commit.subject != null &&
|
|
817
|
+
typeof task.commit.subject === 'string' &&
|
|
818
|
+
task.commit.subject.trim() === ''
|
|
819
|
+
) {
|
|
820
|
+
report.errors.push({
|
|
821
|
+
category: ERRORS.EMPTY_COMMIT_SUBJECT,
|
|
822
|
+
severity: 'error',
|
|
823
|
+
taskId: task.id,
|
|
824
|
+
field: 'commit.subject',
|
|
825
|
+
message: `Task "${task.id}" has an empty commit.subject; a non-empty commit subject is required`,
|
|
826
|
+
details: { taskId: task.id },
|
|
827
|
+
})
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// Check 18: Missing grouping rationale
|
|
831
|
+
if (
|
|
832
|
+
task.groupingRationale != null &&
|
|
833
|
+
typeof task.groupingRationale === 'string' &&
|
|
834
|
+
task.groupingRationale.trim() === ''
|
|
835
|
+
) {
|
|
836
|
+
report.errors.push({
|
|
837
|
+
category: ERRORS.MISSING_GROUPING_RATIONALE,
|
|
838
|
+
severity: 'error',
|
|
839
|
+
taskId: task.id,
|
|
840
|
+
field: 'groupingRationale',
|
|
841
|
+
message: `Task "${task.id}" has an empty groupingRationale; a non-empty rationale is required`,
|
|
842
|
+
details: { taskId: task.id },
|
|
843
|
+
})
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// ── Domain distribution ───────────────────────────────────────────────────────
|
|
849
|
+
|
|
850
|
+
const domainDist = { BE: 0, FE: 0, DB: 0, DevOps: 0, INFRA: 0, TEST: 0 }
|
|
851
|
+
for (const phase of phases) {
|
|
852
|
+
const tasks = Array.isArray(phase.tasks) ? phase.tasks : []
|
|
853
|
+
for (const task of tasks) {
|
|
854
|
+
if (task.domain != null && domainDist[task.domain] !== undefined) {
|
|
855
|
+
domainDist[task.domain]++
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
report.domainDistribution = domainDist
|
|
860
|
+
|
|
861
|
+
// ── AC table parsing (prerequisite for checks 19–21) ─────────────────────────
|
|
862
|
+
|
|
863
|
+
const acMap = parseAcTable(requirementsPath)
|
|
864
|
+
|
|
865
|
+
// ── Checks 19 & 20: AC existence and scope validation ────────────────────────
|
|
866
|
+
|
|
867
|
+
if (acMap !== null) {
|
|
868
|
+
for (const phase of phases) {
|
|
869
|
+
const tasks = Array.isArray(phase.tasks) ? phase.tasks : []
|
|
870
|
+
for (const task of tasks) {
|
|
871
|
+
if (!Array.isArray(task.acceptanceCriteria)) continue
|
|
872
|
+
for (const acId of task.acceptanceCriteria) {
|
|
873
|
+
const acEntry = acMap.get(acId)
|
|
874
|
+
|
|
875
|
+
// ── Check 19: AC existence ───────────────────────────────────────────
|
|
876
|
+
if (acEntry === undefined) {
|
|
877
|
+
report.errors.push({
|
|
878
|
+
category: ERRORS.AC_NOT_FOUND,
|
|
879
|
+
severity: 'error',
|
|
880
|
+
taskId: task.id,
|
|
881
|
+
field: 'acceptanceCriteria',
|
|
882
|
+
message: `Task "${task.id}" references AC "${acId}" which does not exist in the Requirements AC table`,
|
|
883
|
+
details: { taskId: task.id, acId },
|
|
884
|
+
})
|
|
885
|
+
continue
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// ── Check 20: AC scope validation ────────────────────────────────────
|
|
889
|
+
if (acEntry.unscoped === true) continue
|
|
890
|
+
const phaseId = seenTaskIds.get(task.id)
|
|
891
|
+
if (!acEntry.allowedUserStories.includes(phaseId)) {
|
|
892
|
+
report.errors.push({
|
|
893
|
+
category: ERRORS.AC_WRONG_US,
|
|
894
|
+
severity: 'error',
|
|
895
|
+
taskId: task.id,
|
|
896
|
+
field: 'acceptanceCriteria',
|
|
897
|
+
message: `Task "${task.id}" (in phase "${phaseId}") references AC "${acId}" which is scoped to ${JSON.stringify(acEntry.allowedUserStories)}, not this phase`,
|
|
898
|
+
details: { taskId: task.id, phaseId, acId, allowedUserStories: acEntry.allowedUserStories },
|
|
899
|
+
})
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// ── Check 21: Must AC coverage ───────────────────────────────────────────────
|
|
907
|
+
|
|
908
|
+
if (acMap !== null) {
|
|
909
|
+
const coveredAcIds = new Set()
|
|
910
|
+
for (const phase of phases) {
|
|
911
|
+
const tasks = Array.isArray(phase.tasks) ? phase.tasks : []
|
|
912
|
+
for (const task of tasks) {
|
|
913
|
+
if (!Array.isArray(task.acceptanceCriteria)) continue
|
|
914
|
+
for (const acId of task.acceptanceCriteria) {
|
|
915
|
+
coveredAcIds.add(acId)
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
for (const [acId, acEntry] of acMap) {
|
|
921
|
+
if (acEntry.priority === 'Must' && !coveredAcIds.has(acId)) {
|
|
922
|
+
report.errors.push({
|
|
923
|
+
category: ERRORS.MUST_AC_UNCOVERED,
|
|
924
|
+
severity: 'error',
|
|
925
|
+
taskId: null,
|
|
926
|
+
field: null,
|
|
927
|
+
message: `AC "${acId}" has Must priority but is not covered by any task's acceptanceCriteria`,
|
|
928
|
+
details: { acId, priority: 'Must' },
|
|
929
|
+
})
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// ── Check 22: Text field character validation ─────────────────────────────────
|
|
935
|
+
|
|
936
|
+
{
|
|
937
|
+
const INVALID_CHARS_RE = /[|\r\n]/
|
|
938
|
+
|
|
939
|
+
for (const phase of phases) {
|
|
940
|
+
if (typeof phase.title === 'string' && INVALID_CHARS_RE.test(phase.title)) {
|
|
941
|
+
report.errors.push({
|
|
942
|
+
category: ERRORS.INVALID_TEXT_FIELD_CHARS,
|
|
943
|
+
severity: 'error',
|
|
944
|
+
taskId: null,
|
|
945
|
+
phaseId: phase.id,
|
|
946
|
+
field: 'title',
|
|
947
|
+
message: `Phase "${phase.id}" field "title" contains invalid characters (pipe, CR, or LF)`,
|
|
948
|
+
details: { phaseId: phase.id, field: 'title' },
|
|
949
|
+
})
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
if (!Array.isArray(phase.tasks)) continue
|
|
953
|
+
|
|
954
|
+
for (const task of phase.tasks) {
|
|
955
|
+
if (typeof task.title === 'string' && INVALID_CHARS_RE.test(task.title)) {
|
|
956
|
+
report.errors.push({
|
|
957
|
+
category: ERRORS.INVALID_TEXT_FIELD_CHARS,
|
|
958
|
+
severity: 'error',
|
|
959
|
+
taskId: task.id,
|
|
960
|
+
field: 'title',
|
|
961
|
+
message: `Task "${task.id}" field "title" contains invalid characters (pipe, CR, or LF)`,
|
|
962
|
+
details: { taskId: task.id, field: 'title' },
|
|
963
|
+
})
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
if (task.commit && typeof task.commit.subject === 'string' && INVALID_CHARS_RE.test(task.commit.subject)) {
|
|
967
|
+
report.errors.push({
|
|
968
|
+
category: ERRORS.INVALID_TEXT_FIELD_CHARS,
|
|
969
|
+
severity: 'error',
|
|
970
|
+
taskId: task.id,
|
|
971
|
+
field: 'commit.subject',
|
|
972
|
+
message: `Task "${task.id}" field "commit.subject" contains invalid characters (pipe, CR, or LF)`,
|
|
973
|
+
details: { taskId: task.id, field: 'commit.subject' },
|
|
974
|
+
})
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
// ── Check 23: Empty phase detection ──────────────────────────────────────────
|
|
981
|
+
|
|
982
|
+
{
|
|
983
|
+
for (const phase of phases) {
|
|
984
|
+
if (Array.isArray(phase.tasks) && phase.tasks.length === 0) {
|
|
985
|
+
report.errors.push({
|
|
986
|
+
category: ERRORS.EMPTY_PHASE,
|
|
987
|
+
severity: 'error',
|
|
988
|
+
taskId: null,
|
|
989
|
+
phaseId: phase.id,
|
|
990
|
+
field: 'tasks',
|
|
991
|
+
message: `Phase "${phase.id}" has no tasks; each phase must contain at least one task`,
|
|
992
|
+
details: { phaseId: phase.id },
|
|
993
|
+
})
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
// ── Exit code routing ────────────────────────────────────────────────────────
|
|
999
|
+
|
|
1000
|
+
if (report.errors.length > 0) {
|
|
1001
|
+
report.valid = false
|
|
1002
|
+
process.stdout.write(JSON.stringify(report, null, 2) + '\n')
|
|
1003
|
+
process.exit(1)
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
process.stdout.write(JSON.stringify(report, null, 2) + '\n')
|
|
1007
|
+
process.exit(0)
|