@dtlabs/ai-toolkit 0.17.5 → 0.19.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.
Files changed (35) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/lib/agent-registry.js +39 -0
  3. package/package.json +1 -1
  4. package/src/claude/agents/gaia-generate-requirements-use-case.md +97 -0
  5. package/src/claude/agents/gaia-generate-requirements.md +22 -27
  6. package/src/claude/agents/gaia-generate-tech-spec-component.md +93 -0
  7. package/src/claude/agents/gaia-generate-tech-spec.md +47 -103
  8. package/src/claude/agents/gaia-generate-work-breakdown-task.md +151 -0
  9. package/src/claude/agents/gaia-generate-work-breakdown.md +124 -105
  10. package/src/claude/agents/gaia-validate-feature-docs.md +13 -8
  11. package/src/claude/agents/gaia-validate-work-breakdown-semantic.md +9 -4
  12. package/src/claude/commands/assemble-docs.md +16 -0
  13. package/src/claude/commands/feature-status.md +9 -4
  14. package/src/claude/scripts/ac-task-matrix.js +121 -0
  15. package/src/claude/scripts/assemble-docs.js +66 -0
  16. package/src/claude/scripts/req-assembler.js +127 -0
  17. package/src/claude/scripts/req-graph-checks.js +71 -0
  18. package/src/claude/scripts/req-skeleton-check.js +64 -0
  19. package/src/claude/scripts/tech-spec-assembler.js +188 -0
  20. package/src/claude/scripts/tech-spec-graph-checks.js +84 -0
  21. package/src/claude/scripts/templates/gaia-component-template.md +64 -0
  22. package/src/claude/scripts/templates/gaia-dependency-graph-template.md +85 -0
  23. package/src/claude/scripts/templates/gaia-requirements-index-template.md +144 -0
  24. package/src/claude/scripts/templates/gaia-task-template.md +92 -0
  25. package/src/claude/scripts/templates/gaia-tech-spec-index-template.md +137 -0
  26. package/src/claude/scripts/templates/gaia-use-case-template.md +56 -0
  27. package/src/claude/scripts/templates/gaia-user-story-template.md +61 -0
  28. package/src/claude/scripts/wb-graph-checks.js +754 -0
  29. package/src/claude/scripts/wb-render.js +6 -11
  30. package/src/claude/scripts/wb-skeleton-check.js +99 -0
  31. package/src/claude/scripts/wb-validate.js +49 -655
  32. package/src/claude/scripts/wbs-assembler.js +368 -0
  33. package/src/claude/skills/implement-feature/SKILL.md +12 -6
  34. package/src/claude/workflows/pm-phase1.js +324 -18
  35. package/src/claude/workflows/pm-phase2.js +358 -15
@@ -0,0 +1,16 @@
1
+ ---
2
+ description: "Assemble Docs — renders the split Requirements/Tech-Spec/Work-Breakdown sources into single combined documents, on demand. Usage: /assemble-docs <feature-slug> [req|spec|wbs|all]"
3
+ argument-hint: <feature-slug> [req|spec|wbs|all]
4
+ allowed-tools: Bash, Glob, Read
5
+ ---
6
+
7
+ Produce an on-demand, single-file view of one or more of this feature's split documents.
8
+
9
+ Approach:
10
+ 1. Locate the feature folder under `docs/features/` (match by slug or FTR id) from the first argument.
11
+ 2. Extract the prefix from the folder name (`FTR-025-slug` → `FTR-025`).
12
+ 3. Determine `which` from the second argument (default `all`).
13
+ 4. Run: `node .claude/scripts/assemble-docs.js <feature-dir> <prefix> <which>`
14
+ 5. Report exactly what was written (the script's own JSON `written` array), or surface its error message verbatim if it failed — do not retry or guess an alternative path.
15
+
16
+ This command is a read-only rendering action: it never affects Gate 1/Gate 2, approvals, or any digest. Running it is always safe, at any point in the pipeline.
@@ -10,10 +10,15 @@ Steps:
10
10
  1. Locate the feature folder under `docs/features/`. Match by slug or FTR id (e.g. `FTR-001-user-login` matches both `001` and `user-login`).
11
11
  2. For each of the following artifacts, check presence and basic completeness:
12
12
  - `feature.md` — the source brief
13
- - `{PREFIX}-Requirements.md` — functional requirements
14
- - `{PREFIX}-Tech-Spec.md` — technical specification
15
- - `{PREFIX}-Work-Breakdown.md` — user stories + tasks
16
- - Implementation status: cross-check tasks in the work breakdown against the codebase. For each task, report `done` / `partial` / `not started` based on whether the referenced files/symbols exist.
13
+ - Requirements — ✅ if either `req/index.md` (split, default) or `{PREFIX}-Requirements.md`
14
+ (assembled, legacy or user-requested via `/assemble-docs`) exists; note which shape was found
15
+ - Tech-Spec — ✅ if either `spec/index.md` (split, default) or `{PREFIX}-Tech-Spec.md`
16
+ (assembled) exists; note which shape was found
17
+ - Work Breakdown — ✅ if either `wbs/index.json` (split, always the case since FTR-020) or
18
+ `{PREFIX}-Work-Breakdown.md` (rendered, only present if `/assemble-docs` was run) exists
19
+ - Implementation status: cross-check tasks in the work breakdown against the codebase (read
20
+ `wbs/` directly). For each task, report `done` / `partial` / `not started` based on whether
21
+ the referenced files/symbols exist.
17
22
  3. Output a single markdown table with columns: `Artifact | Status | Notes`. Status uses ✅ / ⚠️ / ❌. Keep notes under 80 chars.
18
23
  4. After the table, list the next 1–3 actionable items in priority order.
19
24
 
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env node
2
+ 'use strict'
3
+
4
+ // FTR-022: deterministic renderer producing {PREFIX}-AC-Task-Matrix.md — a table mapping every
5
+ // Requirements Acceptance Criterion to the Work Breakdown task(s)/phase(s) that cover it. No new
6
+ // data, no new validation: every fact here is already computed and enforced by wb-validate.js's
7
+ // checks 19-21 (checkAcCoverage, wb-graph-checks.js). This script never re-validates; it assumes
8
+ // its inputs already passed those checks and only renders what they already guarantee.
9
+
10
+ const fs = require('fs')
11
+ const path = require('path')
12
+ const { loadWorkBreakdown } = require('./wbs-assembler')
13
+ const { parseAcTable } = require('./wb-graph-checks')
14
+ const { parseFileInventoryEntries } = require('./tech-spec-graph-checks')
15
+
16
+ function computeCoverage(wb, acMap) {
17
+ // taskPhase mirrors checkAcCoverage's own seenTaskIds pattern — not read by this function's
18
+ // own return value, kept available for any future lookup that needs "which phase owns task X"
19
+ // directly instead of iterating coverage's per-AC sets.
20
+ const taskPhase = new Map()
21
+ for (const phase of wb.phases) for (const task of phase.tasks) taskPhase.set(task.id, phase.id)
22
+
23
+ const coverage = new Map([...acMap.keys()].map(acId => [acId, { taskIds: new Set(), phaseIds: new Set() }]))
24
+ for (const phase of wb.phases) {
25
+ for (const task of phase.tasks) {
26
+ for (const acId of (task.acceptanceCriteria || [])) {
27
+ if (!coverage.has(acId)) continue // AC_NOT_FOUND is wb-validate.js's job, not this script's
28
+ coverage.get(acId).taskIds.add(task.id)
29
+ coverage.get(acId).phaseIds.add(phase.id)
30
+ }
31
+ }
32
+ }
33
+ return coverage
34
+ }
35
+
36
+ // FTR-023: acId -> Set<filePath>, built from Tech-Spec §9 File Inventory "Addresses" cells.
37
+ function computeTechSpecCoverage(entries, acMap) {
38
+ const coverage = new Map([...acMap.keys()].map(acId => [acId, new Set()]))
39
+ for (const entry of entries) {
40
+ for (const acId of entry.acIds) {
41
+ if (coverage.has(acId)) coverage.get(acId).add(entry.filePath)
42
+ }
43
+ }
44
+ return coverage
45
+ }
46
+
47
+ function renderMatrix(prefix, acMap, coverage, techSpecCoverage = new Map()) {
48
+ const mustEntries = [...acMap.entries()].filter(([, ac]) => ac.priority === 'Must')
49
+ const mustCovered = mustEntries.filter(([id]) => coverage.get(id).taskIds.size > 0).length
50
+ const summary = `**Must-AC coverage:** ${mustCovered}/${mustEntries.length} covered. ` +
51
+ 'Machine-readable data: [index.json](index.json).'
52
+ const rows = [...acMap.entries()].map(([acId, ac]) => {
53
+ const relatedUc = ac.unscoped ? 'All UCs' : ac.relatedUCs.join(', ')
54
+ const cov = coverage.get(acId)
55
+ const taskIds = [...cov.taskIds].join(', ') || '—'
56
+ const phaseIds = [...cov.phaseIds].join(', ') || '—'
57
+ const techSpecFiles = [...(techSpecCoverage.get(acId) || [])].join(', ') || '—'
58
+ return `| ${acId} | ${ac.criterion} | ${ac.priority} | ${relatedUc} | ${techSpecFiles} | ${taskIds} | ${phaseIds} |`
59
+ })
60
+ return `# AC ↔ Task Traceability Matrix — ${prefix}\n\n${summary}\n\n` +
61
+ '| AC ID | Criterion | Priority | Related UC | Addressed by Tech-Spec File(s) | Covering Task(s) | Covering Phase(s) |\n' +
62
+ '|---|---|---|---|---|---|---|\n' +
63
+ rows.join('\n') + '\n\n' +
64
+ '> **Legend:** **AC ID** — unique identifier from the Requirements Acceptance Criteria ' +
65
+ 'table. **Criterion** — the testable Given/When/Then statement, copied verbatim. ' +
66
+ '**Priority** — MoSCoW; every `Must` row is guaranteed non-empty in **Covering Task(s)** by ' +
67
+ 'wb-validate.js check 21 before this file is ever generated. **Related UC** — which Use ' +
68
+ 'Case(s) this AC belongs to, or `All UCs`. **Addressed by Tech-Spec File(s)** — every ' +
69
+ 'Tech-Spec file whose `Addresses` column cites this AC, comma-separated, or `—` if none. ' +
70
+ '**Covering Task(s)** — every Work Breakdown task whose `acceptanceCriteria` cites this AC, ' +
71
+ 'comma-separated, or `—` if none. **Covering Phase(s)** — the distinct phase(s) those tasks ' +
72
+ 'belong to, comma-separated, or `—` if none.\n'
73
+ }
74
+
75
+ // FTR-026: serializes the same in-memory data renderMatrix() already consumes into a
76
+ // machine-readable shape — one entry per Requirements AC, in table order. No new computation.
77
+ function buildMatrixJson(prefix, acMap, coverage, techSpecCoverage = new Map()) {
78
+ const acceptanceCriteria = [...acMap.entries()].map(([acId, ac]) => {
79
+ const cov = coverage.get(acId)
80
+ return {
81
+ id: acId,
82
+ criterion: ac.criterion,
83
+ priority: ac.priority,
84
+ unscoped: !!ac.unscoped,
85
+ relatedUCs: ac.unscoped ? [] : ac.relatedUCs,
86
+ techSpecFiles: [...(techSpecCoverage.get(acId) || [])],
87
+ taskIds: [...cov.taskIds],
88
+ phaseIds: [...cov.phaseIds],
89
+ }
90
+ })
91
+ return { prefix, generatedAt: new Date().toISOString(), acceptanceCriteria }
92
+ }
93
+
94
+ if (require.main === module) {
95
+ // FTR-026: featureDir is REQUIRED (not optional) — confirmed by grep that pm-phase2.js and this
96
+ // script's own test suite are the only callers, so there is no backward-compatible no-arg case
97
+ // worth preserving. Output moves from one loose {prefix}-AC-Task-Matrix.md file (whose location
98
+ // used to be derived from path.dirname(requirementsPath) — a real bug: that would have silently
99
+ // moved once a caller passes a Requirements path living under req/) into a dedicated
100
+ // coverage-matrix/ subfolder under featureDir, as a JSON+MD pair.
101
+ const [wbsPath, requirementsPath, techSpecPath, prefix, featureDir] = process.argv.slice(2)
102
+ if (!wbsPath || !requirementsPath || !techSpecPath || !prefix || !featureDir) {
103
+ process.stderr.write(
104
+ 'Usage: node ac-task-matrix.js <wbs-path> <requirements-path> <tech-spec-path> <prefix> <feature-dir>\n')
105
+ process.exit(1)
106
+ }
107
+ const wb = loadWorkBreakdown(path.resolve(wbsPath))
108
+ const acMap = parseAcTable(path.resolve(requirementsPath))
109
+ const coverage = computeCoverage(wb, acMap)
110
+ const techSpecEntries = parseFileInventoryEntries(fs.readFileSync(path.resolve(techSpecPath), 'utf8'))
111
+ const techSpecCoverage = computeTechSpecCoverage(techSpecEntries, acMap)
112
+ const outDir = path.join(path.resolve(featureDir), 'coverage-matrix')
113
+ fs.mkdirSync(outDir, { recursive: true })
114
+ const jsonPath = path.join(outDir, 'index.json')
115
+ const mdPath = path.join(outDir, 'index.md')
116
+ fs.writeFileSync(jsonPath, JSON.stringify(buildMatrixJson(prefix, acMap, coverage, techSpecCoverage), null, 2))
117
+ fs.writeFileSync(mdPath, renderMatrix(prefix, acMap, coverage, techSpecCoverage))
118
+ console.log(JSON.stringify({ jsonPath, mdPath }))
119
+ }
120
+
121
+ module.exports = { computeCoverage, renderMatrix, computeTechSpecCoverage, buildMatrixJson }
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env node
2
+ 'use strict'
3
+
4
+ // FTR-025: on-demand assembly of the split Requirements/Tech-Spec/Work-Breakdown sources into
5
+ // single-file views. Runs as a plain Node CLI outside the sandboxed workflow runtime, so it can
6
+ // `require()` req-assembler.js/tech-spec-assembler.js directly (no subprocess round-trip needed)
7
+ // — it shells out only to wb-render.js, which is CLI-only (writes its own two output files and
8
+ // exits, no exported function to call in-process). This script never runs automatically as part
9
+ // of generation; it is the explicit, user-triggered alternative to that.
10
+
11
+ const fs = require('fs')
12
+ const path = require('path')
13
+ const { execFileSync } = require('child_process')
14
+ const { loadRequirements, AssemblyError: ReqAssemblyError } = require('./req-assembler')
15
+ const { loadTechSpec, AssemblyError: SpecAssemblyError } = require('./tech-spec-assembler')
16
+
17
+ // loadRequirements()/loadTechSpec() already throw a structured AssemblyError naming the exact
18
+ // missing path when the directory doesn't exist (fs.statSync(inputPath, {throwIfNoEntry:false})
19
+ // returning null) — no separate existence pre-check needed here, it would only duplicate what
20
+ // the assembler already does correctly.
21
+ function assembleReq(featureDir, prefix) {
22
+ const outPath = path.join(featureDir, `${prefix}-Requirements.md`)
23
+ fs.writeFileSync(outPath, loadRequirements(path.join(featureDir, 'req')))
24
+ return outPath
25
+ }
26
+
27
+ function assembleSpec(featureDir, prefix) {
28
+ const outPath = path.join(featureDir, `${prefix}-Tech-Spec.md`)
29
+ fs.writeFileSync(outPath, loadTechSpec(path.join(featureDir, 'spec')))
30
+ return outPath
31
+ }
32
+
33
+ function assembleWbs(featureDir, prefix) {
34
+ const wbsDir = path.join(featureDir, 'wbs')
35
+ if (!fs.existsSync(wbsDir)) throw new ReqAssemblyError(`wbs/ not found at ${wbsDir}`)
36
+ // wb-render.js is CLI-only (writes {prefix}-Work-Breakdown.md/.csv itself and exits) — shell
37
+ // out, same subprocess convention pm-phase2.js used for its own (now-removed) automatic call.
38
+ // No destination-dir argument needed: wb-render.js defaults it to the parent of its own
39
+ // <wbs-dir> argument, which is already featureDir.
40
+ execFileSync(process.execPath, [path.join(__dirname, 'wb-render.js'), wbsDir, prefix])
41
+ return [path.join(featureDir, `${prefix}-Work-Breakdown.md`), path.join(featureDir, `${prefix}-Work-Breakdown.csv`)]
42
+ }
43
+
44
+ const HANDLERS = { req: assembleReq, spec: assembleSpec, wbs: assembleWbs }
45
+
46
+ if (require.main === module) {
47
+ const [featureDir, prefix, which = 'all'] = process.argv.slice(2)
48
+ if (!featureDir || !prefix) {
49
+ process.stderr.write('Usage: node assemble-docs.js <feature-dir> <prefix> [req|spec|wbs|all]\n')
50
+ process.exit(1)
51
+ }
52
+ const targets = which === 'all' ? Object.keys(HANDLERS) : [which]
53
+ if (!targets.every(t => HANDLERS[t])) {
54
+ process.stderr.write(`Unknown target "${which}" — expected req, spec, wbs, or all\n`)
55
+ process.exit(1)
56
+ }
57
+ try {
58
+ const written = targets.map(t => HANDLERS[t](path.resolve(featureDir), prefix)).flat()
59
+ process.stdout.write(JSON.stringify({ written }) + '\n')
60
+ } catch (err) {
61
+ process.stderr.write(`Error: ${err.message}\n`)
62
+ process.exit((err instanceof ReqAssemblyError || err instanceof SpecAssemblyError) ? 2 : 1)
63
+ }
64
+ }
65
+
66
+ module.exports = { assembleReq, assembleSpec, assembleWbs }
@@ -0,0 +1,127 @@
1
+ 'use strict'
2
+
3
+ // FTR-021: loader/renderer for the split Requirements format (req/index.md +
4
+ // req/use-cases/{UC-ID}.md). Unlike wbs-assembler.js (JSON -> Markdown rendering),
5
+ // index.md is already Markdown narrative, so assembly here is textual reassembly:
6
+ // splice each use case's already-rendered file into Section 2's slot, in index-table
7
+ // order. Legacy input (a single monolithic file) is returned unchanged.
8
+
9
+ const fs = require('fs')
10
+ const path = require('path')
11
+
12
+ class AssemblyError extends Error {}
13
+
14
+ // Returns { start, end, lines }: `start` is the index of the "## N. " heading line for
15
+ // `sectionNumber`, `end` is the index of the next "## M. " heading (M > sectionNumber),
16
+ // or lines.length if none follows. Throws if the section is not found at all.
17
+ function findSectionRange(content, sectionNumber) {
18
+ const lines = content.split('\n')
19
+ let start = -1
20
+ let end = lines.length
21
+ for (let i = 0; i < lines.length; i++) {
22
+ const m = lines[i].match(/^## (\d+)\.\s/)
23
+ if (!m) continue
24
+ const n = Number(m[1])
25
+ if (n === sectionNumber) start = i
26
+ else if (start !== -1 && n > sectionNumber) { end = i; break }
27
+ }
28
+ if (start === -1) throw new AssemblyError(`missing section ${sectionNumber}`)
29
+ return { start, end, lines }
30
+ }
31
+
32
+ // Parses the first Markdown table found inside [start, end) into rows, dropping the
33
+ // header row and the "|---|---|" separator row. Returns an array of cell-arrays.
34
+ function parseTableRows(lines, start, end) {
35
+ const tableLines = lines.slice(start, end).filter(l => l.trim().startsWith('|'))
36
+ if (tableLines.length < 2) return []
37
+ return tableLines.slice(2).map(line =>
38
+ line.split('|').map(c => c.trim()).filter((_, i, a) => i > 0 && i < a.length - 1))
39
+ }
40
+
41
+ // The "## 2. Use Cases" index table: UC ID | Title | Actor | Priority | File.
42
+ // `File` is a Markdown link `[use-cases/UC-01.md](use-cases/UC-01.md)` — the link
43
+ // TARGET (inside the parens) is authoritative, not the link text.
44
+ function parseUseCaseIndexTable(indexContent) {
45
+ const { start, end, lines } = findSectionRange(indexContent, 2)
46
+ return parseTableRows(lines, start, end).map(cells => {
47
+ const [id, title, actor, priority, fileCol] = cells
48
+ const fileMatch = fileCol && fileCol.match(/\(([^)]+)\)/)
49
+ if (!fileMatch) throw new AssemblyError(`malformed File column for UC "${id}" in index.md`)
50
+ return { id, title, actor, priority, file: fileMatch[1] }
51
+ })
52
+ }
53
+
54
+ // The "## 7. Acceptance Criteria" table: ID | Criterion | Related UC | Priority.
55
+ function parseAcTable(indexContent) {
56
+ const { start, end, lines } = findSectionRange(indexContent, 7)
57
+ return parseTableRows(lines, start, end).map(cells => {
58
+ const [id, criterion, relatedUc, priority] = cells
59
+ return { id, criterion, relatedUc, priority }
60
+ })
61
+ }
62
+
63
+ // The "## 3. Business Rules" table: ID | Rule | Applies to.
64
+ function parseBusinessRulesTable(indexContent) {
65
+ const { start, end, lines } = findSectionRange(indexContent, 3)
66
+ return parseTableRows(lines, start, end).map(cells => {
67
+ const [id, rule, appliesTo] = cells
68
+ return { id, rule, appliesTo }
69
+ })
70
+ }
71
+
72
+ // Replaces Section 2's body (everything after its heading, up to but not including the
73
+ // next "## 3." heading) with `section2Body`, keeping the heading line itself and every
74
+ // other section untouched.
75
+ function spliceSection2(indexContent, section2Body) {
76
+ const { start, end, lines } = findSectionRange(indexContent, 2)
77
+ const heading = lines[start]
78
+ const before = lines.slice(0, start).concat([heading]).join('\n')
79
+ const after = lines.slice(end).join('\n')
80
+ return `${before}\n\n${section2Body}\n\n${after}`
81
+ }
82
+
83
+ function loadRequirements(inputPath) {
84
+ const stat = fs.statSync(inputPath, { throwIfNoEntry: false })
85
+ if (!stat) throw new AssemblyError(`path does not exist: ${inputPath}`)
86
+ if (stat.isFile()) return fs.readFileSync(inputPath, 'utf8') // legacy path (AC-07)
87
+
88
+ const indexPath = path.join(inputPath, 'index.md')
89
+ if (!fs.existsSync(indexPath)) throw new AssemblyError(`missing req/index.md at ${indexPath}`)
90
+ const indexContent = fs.readFileSync(indexPath, 'utf8')
91
+
92
+ const ucTable = parseUseCaseIndexTable(indexContent)
93
+ if (ucTable.length === 0) throw new AssemblyError('req/index.md has no rows in its Use Cases index table')
94
+
95
+ const ucBlocks = ucTable.map(row => {
96
+ const ucPath = path.join(inputPath, row.file)
97
+ if (!fs.existsSync(ucPath)) throw new AssemblyError(`use case "${row.id}" has no detail file at ${ucPath}`)
98
+ return fs.readFileSync(ucPath, 'utf8').trimEnd()
99
+ })
100
+
101
+ return spliceSection2(indexContent, ucBlocks.join('\n\n'))
102
+ }
103
+
104
+ if (require.main === module) {
105
+ const inputPath = process.argv[2]
106
+ if (!inputPath) {
107
+ process.stderr.write('Usage: node req-assembler.js <path-to-req-dir-or-legacy-file>\n')
108
+ process.exit(1)
109
+ }
110
+ try {
111
+ process.stdout.write(loadRequirements(path.resolve(inputPath)))
112
+ } catch (err) {
113
+ process.stderr.write(`Error: ${err.message}\n`)
114
+ process.exit(err instanceof AssemblyError ? 1 : 2)
115
+ }
116
+ }
117
+
118
+ module.exports = {
119
+ loadRequirements,
120
+ parseUseCaseIndexTable,
121
+ parseAcTable,
122
+ parseBusinessRulesTable,
123
+ spliceSection2,
124
+ findSectionRange,
125
+ parseTableRows,
126
+ AssemblyError,
127
+ }
@@ -0,0 +1,71 @@
1
+ 'use strict'
2
+
3
+ // FTR-021: 4 pure self-consistency check functions for the split Requirements format
4
+ // (req/index.md). Mirrors wb-graph-checks.js's convention: no file I/O, no process.exit,
5
+ // just plain functions over an already-parsed plain object, returning an array of error
6
+ // objects. The actual Markdown parsing lives in req-assembler.js / req-skeleton-check.js.
7
+
8
+ const UC_ID_RE = /^UC-\d{2,}$/
9
+
10
+ function checkUniqueUcIds({ ucIds }) {
11
+ const seen = new Set()
12
+ const duplicates = new Set()
13
+ for (const id of ucIds) {
14
+ if (seen.has(id)) duplicates.add(id)
15
+ else seen.add(id)
16
+ }
17
+ return [...duplicates].map(id => ({
18
+ category: 'DUPLICATE_UC_ID', severity: 'error', ucId: id,
19
+ message: `Duplicate UC ID "${id}" appears more than once in the use-case index table`,
20
+ }))
21
+ }
22
+
23
+ function checkUcIdFormat({ ucIds }) {
24
+ return ucIds.filter(id => !UC_ID_RE.test(id)).map(id => ({
25
+ category: 'INVALID_UC_ID_FORMAT', severity: 'error', ucId: id,
26
+ message: `UC ID "${id}" does not match the required UC-NN format`,
27
+ }))
28
+ }
29
+
30
+ // Orphan-file direction only: a use-cases/*.md file that no longer corresponds to any row
31
+ // in the current table (e.g. left over after a UC was renamed/removed from a hand-edited
32
+ // index.md). Deliberately NOT bidirectional — this check runs BEFORE per-use-case
33
+ // dispatch, when every row is expected to have no file yet, so "table row without a file"
34
+ // is the normal pre-dispatch state, not an error. That direction is enforced correctly,
35
+ // after generation, by req-assembler.js's own missing-file error at assembly time.
36
+ function checkUcTableFileParity({ ucIds }, existingUcFiles) {
37
+ const idSet = new Set(ucIds)
38
+ return existingUcFiles.filter(id => !idSet.has(id)).map(id => ({
39
+ category: 'UC_FILE_WITHOUT_TABLE_ROW', severity: 'error', ucId: id,
40
+ message: `use-cases/${id}.md exists but has no row in the use-case index table`,
41
+ }))
42
+ }
43
+
44
+ function checkCrossReferencesResolve({ ucIds, acTable, businessRules }) {
45
+ const idSet = new Set(ucIds)
46
+ const errors = []
47
+ const checkRefs = (rows, idField, refField, category) => {
48
+ for (const row of rows) {
49
+ const raw = row[refField]
50
+ if (raw == null || raw.trim() === 'All UCs') continue
51
+ for (const ref of raw.split(',').map(s => s.trim()).filter(Boolean)) {
52
+ if (!idSet.has(ref)) {
53
+ errors.push({
54
+ category, severity: 'error', id: row[idField], ucId: ref,
55
+ message: `${row[idField]}'s "${refField}" references unknown UC "${ref}"`,
56
+ })
57
+ }
58
+ }
59
+ }
60
+ }
61
+ checkRefs(acTable || [], 'id', 'relatedUc', 'DANGLING_AC_UC_REFERENCE')
62
+ checkRefs(businessRules || [], 'id', 'appliesTo', 'DANGLING_BR_UC_REFERENCE')
63
+ return errors
64
+ }
65
+
66
+ module.exports = {
67
+ checkUniqueUcIds,
68
+ checkUcIdFormat,
69
+ checkUcTableFileParity,
70
+ checkCrossReferencesResolve,
71
+ }
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env node
2
+ 'use strict'
3
+
4
+ // FTR-021: deterministic structural pre-check for a Requirements skeleton (req/index.md),
5
+ // run by pm-phase1.js before any per-use-case generation call is dispatched (UC-02).
6
+ //
7
+ // This is a separate script from req-assembler.js's CLI mode, not a reuse of it, because
8
+ // pm-phase1.js's workflow-script runtime has no `require` (lib/workflow-runner.js) — this
9
+ // is invoked as a real Node subprocess via `c.command(['run-asset',
10
+ // 'scripts/req-skeleton-check.js', ...])`, the same pattern already used for
11
+ // wb-skeleton-check.js/wb-validate.js/wb-render.js (FTR-020).
12
+
13
+ const fs = require('fs')
14
+ const path = require('path')
15
+ const graphChecks = require('./req-graph-checks')
16
+ const { parseUseCaseIndexTable, parseAcTable, parseBusinessRulesTable } = require('./req-assembler')
17
+
18
+ function main() {
19
+ const indexPath = process.argv[2]
20
+ if (!indexPath) {
21
+ process.stderr.write('Usage: node req-skeleton-check.js <path-to-req-index.md>\n')
22
+ process.exit(2)
23
+ }
24
+
25
+ let indexContent
26
+ try {
27
+ indexContent = fs.readFileSync(indexPath, 'utf8')
28
+ } catch (e) {
29
+ console.log(JSON.stringify({ valid: false, errors: [{ message: `cannot read ${indexPath}: ${e.message}` }] }))
30
+ process.exit(2)
31
+ }
32
+
33
+ let ucTable, acTable, businessRules
34
+ try {
35
+ ucTable = parseUseCaseIndexTable(indexContent)
36
+ acTable = parseAcTable(indexContent)
37
+ businessRules = parseBusinessRulesTable(indexContent)
38
+ } catch (e) {
39
+ console.log(JSON.stringify({ valid: false, errors: [{ message: `parse failure: ${e.message}` }] }))
40
+ process.exit(2)
41
+ }
42
+
43
+ const ucIds = ucTable.map(r => r.id)
44
+ const useCasesDir = path.join(path.dirname(indexPath), 'use-cases')
45
+ const existingUcFiles = fs.existsSync(useCasesDir)
46
+ ? fs.readdirSync(useCasesDir).filter(f => f.endsWith('.md')).map(f => f.replace(/\.md$/, ''))
47
+ : []
48
+
49
+ const errors = [
50
+ ...graphChecks.checkUniqueUcIds({ ucIds }),
51
+ ...graphChecks.checkUcIdFormat({ ucIds }),
52
+ ...graphChecks.checkUcTableFileParity({ ucIds }, existingUcFiles),
53
+ ...graphChecks.checkCrossReferencesResolve({
54
+ ucIds,
55
+ acTable: acTable.map(a => ({ id: a.id, relatedUc: a.relatedUc })),
56
+ businessRules: businessRules.map(b => ({ id: b.id, appliesTo: b.appliesTo })),
57
+ }),
58
+ ]
59
+
60
+ console.log(JSON.stringify({ valid: errors.length === 0, errors }, null, 2))
61
+ process.exit(errors.length === 0 ? 0 : 1)
62
+ }
63
+
64
+ main()