@warnyin/agents 0.11.0 → 0.12.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/CHANGELOG.md +120 -115
- package/README.md +148 -148
- package/package.json +38 -38
- package/src/.claude/agents/warnyin-infra.md +13 -13
- package/src/.claude/agents/warnyin-qa.md +13 -13
- package/src/.claude/agents/warnyin-sa.md +13 -13
- package/src/.claude/agents/warnyin-security.md +13 -13
- package/src/.claude/agents/warnyin-tech-lead.md +13 -13
- package/src/.claude/commands/warnyin/build.md +31 -31
- package/src/.claude/commands/warnyin/design.md +27 -27
- package/src/.claude/commands/warnyin/discovery.md +17 -17
- package/src/.claude/commands/warnyin/explore.md +14 -14
- package/src/.claude/commands/warnyin/init.md +12 -12
- package/src/.claude/commands/warnyin/install-skill.md +14 -14
- package/src/.claude/commands/warnyin/next.md +17 -17
- package/src/.claude/commands/warnyin/ship.md +28 -28
- package/src/.claude/commands/warnyin/triage.md +14 -0
- package/src/.claude/commands/warnyin/update-codemaps.md +12 -12
- package/src/.claude/commands/warnyin/verify.md +20 -20
- package/src/.claude/skills/explore/SKILL.md +8 -8
- package/src/.claude/skills/next/SKILL.md +8 -8
- package/src/.claude/skills/update-codemaps/SKILL.md +8 -8
- package/src/.warnyin/installer/templates/CLAUDE.md +30 -29
- package/src/.warnyin/template/docs/codemap/index.md +18 -18
- package/src/.warnyin/template/docs/features/[feature-name]/business.md +5 -5
- package/src/.warnyin/template/docs/features/[feature-name]/feature.md +5 -5
- package/src/.warnyin/template/docs/features/[feature-name]/spec.md +16 -16
- package/src/.warnyin/template/docs/infra.md +16 -16
- package/src/.warnyin/template/docs/project.md +18 -18
- package/src/.warnyin/template/docs/rule.md +7 -7
- package/src/.warnyin/template/docs/techstack/[component]/about.md +6 -6
- package/src/.warnyin/template/docs/techstack/[component]/rule.md +6 -6
- package/src/.warnyin/template/docs/techstack/[component]/standard.md +6 -6
- package/src/.warnyin/template/docs/techstack/[component]/structure.md +7 -7
- package/src/.warnyin/template/docs/techstack/[component]/test.md +7 -7
- package/src/.warnyin/template/docs/troubleshooting.md +32 -32
- package/src/.warnyin/template/stages/[topic]/build.md +58 -58
- package/src/.warnyin/template/stages/[topic]/business.md +21 -21
- package/src/.warnyin/template/stages/[topic]/design.md +63 -63
- package/src/.warnyin/template/stages/[topic]/discovery.md +69 -69
- package/src/.warnyin/template/stages/[topic]/proposal.md +43 -43
- package/src/.warnyin/template/stages/[topic]/research.md +49 -49
- package/src/.warnyin/template/stages/[topic]/ship.md +32 -32
- package/src/.warnyin/template/stages/[topic]/tasks/[task-name]/issue.md +19 -19
- package/src/.warnyin/template/stages/[topic]/tasks/[task-name]/rule.md +13 -13
- package/src/.warnyin/template/stages/[topic]/tasks/[task-name]/spec.md +36 -36
- package/src/.warnyin/template/stages/[topic]/tasks/[task-name]/standard.md +21 -21
- package/src/.warnyin/template/stages/[topic]/tasks/[task-name]/task.md +40 -40
- package/src/.warnyin/template/stages/[topic]/test.md +46 -46
- package/src/.warnyin/template/stages/[topic]/troubleshooting.md +34 -34
- package/src/.warnyin/template/stages/[topic]/verify.md +44 -44
- package/src/.warnyin/workflow/README.md +102 -101
- package/src/.warnyin/workflow/api-doc.md +93 -93
- package/src/.warnyin/workflow/codemap.md +91 -91
- package/src/.warnyin/workflow/contexts/README.md +51 -51
- package/src/.warnyin/workflow/contexts/build.md +25 -25
- package/src/.warnyin/workflow/contexts/research.md +25 -25
- package/src/.warnyin/workflow/contexts/review.md +25 -25
- package/src/.warnyin/workflow/explore.md +32 -32
- package/src/.warnyin/workflow/init.md +125 -125
- package/src/.warnyin/workflow/next.md +48 -48
- package/src/.warnyin/workflow/roles/README.md +47 -47
- package/src/.warnyin/workflow/roles/ba.md +25 -25
- package/src/.warnyin/workflow/roles/developer.md +31 -31
- package/src/.warnyin/workflow/roles/infra.md +24 -24
- package/src/.warnyin/workflow/roles/po.md +28 -28
- package/src/.warnyin/workflow/roles/qa.md +35 -35
- package/src/.warnyin/workflow/roles/sa.md +28 -28
- package/src/.warnyin/workflow/roles/security.md +39 -39
- package/src/.warnyin/workflow/roles/tech-lead.md +28 -28
- package/src/.warnyin/workflow/scripts/build-wave.mjs +143 -143
- package/src/.warnyin/workflow/scripts/validate-topic.mjs +378 -378
- package/src/.warnyin/workflow/stages/build.md +98 -98
- package/src/.warnyin/workflow/stages/design.md +131 -126
- package/src/.warnyin/workflow/stages/discovery.md +78 -78
- package/src/.warnyin/workflow/stages/ship.md +94 -92
- package/src/.warnyin/workflow/stages/verify.md +82 -80
- package/src/.warnyin/workflow/triage.md +74 -0
- package/src/AGENTS.md +48 -48
- package/src/bin/cli.mjs +193 -193
|
@@ -1,378 +1,378 @@
|
|
|
1
|
-
import { readdirSync, readFileSync } from 'node:fs'
|
|
2
|
-
import { join } from 'node:path'
|
|
3
|
-
import { fileURLToPath } from 'node:url'
|
|
4
|
-
|
|
5
|
-
// Structural validator ของ topic ใน docs/stages/ — zero-dep (mirror lint-md.mjs: pure fn + injectable IO + main-guard)
|
|
6
|
-
// 2 โหมด: status (ไม่มี arg, ตารางทุก topic, exit 0) · validate (<slug>, รายการ ✖/⚠, exit 1 เมื่อมี ✖ / 2 เมื่อ slug ผิด)
|
|
7
|
-
// เช็ค C1–C5 = structural เท่านั้น (semantic เป็นของ model ตาม gate เดิม) — canonical contract: design §4
|
|
8
|
-
//
|
|
9
|
-
// หลักการแยกระดับ (design §4.2): ✖ checks (C2/C3/C5) ไม่พึ่ง filled-detection (existence/structure ล้วน)
|
|
10
|
-
// · C1/C4 = ⚠ best-effort (heuristic เดา "เริ่มเติม" — ยอมรับ false ได้ ไม่ block)
|
|
11
|
-
// security (design §4.4): เฉพาะ node:fs/node:path/node:url — ไม่มี child_process/network/write
|
|
12
|
-
// · report structural เท่านั้น (ชื่อไฟล์/section/code — ไม่ echo เนื้อ artifact) · ENOENT/EACCES guard ไม่พ่น absolute path
|
|
13
|
-
|
|
14
|
-
// ── canonical: stage → artifact (design §4.3) ──────────────────────────────
|
|
15
|
-
// required = ต้องมีถึงจะนับว่าผ่าน stage · optional = ข้ามได้ปกติ (ไม่ count เป็น "ข้าม stage")
|
|
16
|
-
const STAGES = [
|
|
17
|
-
{ order: 1, stage: 'Discovery', required: [], optional: ['discovery.md', 'research.md'] },
|
|
18
|
-
{ order: 2, stage: 'DESIGN', required: ['proposal.md', 'design.md'], optional: ['business.md'] },
|
|
19
|
-
{ order: 4, stage: 'BUILD', required: ['build.md'], optional: [] },
|
|
20
|
-
{ order: 5, stage: 'VERIFY', required: ['verify.md', 'test.md'], optional: [] },
|
|
21
|
-
{ order: 6, stage: 'SHIP', required: ['ship.md'], optional: [] },
|
|
22
|
-
]
|
|
23
|
-
// ไฟล์ artifact ทั้งหมดที่ใช้ infer stage (รวม required + optional ของทุก stage)
|
|
24
|
-
const STAGE_FILES = STAGES.flatMap((s) => [...s.required, ...s.optional])
|
|
25
|
-
const TASK_REQUIRED = ['spec.md', 'standard.md', 'rule.md', 'task.md']
|
|
26
|
-
|
|
27
|
-
// ── filled heuristic (B1): "เริ่มเติม" = H1 (บรรทัดแรกที่ไม่ว่าง) ไม่มี placeholder <...> ─────
|
|
28
|
-
// ทุก template artifact มี `— <ชื่อ...>` ที่ H1; ห้ามใช้ const FILLED_MARKERS list (เปราะ)
|
|
29
|
-
function isFilled(content) {
|
|
30
|
-
if (content == null) return false
|
|
31
|
-
const lines = content.split('\n')
|
|
32
|
-
for (const line of lines) {
|
|
33
|
-
const t = line.trim()
|
|
34
|
-
if (t === '') continue
|
|
35
|
-
// H1 = บรรทัดแรกที่ไม่ว่าง — เริ่มเติมเมื่อไม่มี placeholder <...>
|
|
36
|
-
return !/<[^>]+>/.test(t)
|
|
37
|
-
}
|
|
38
|
-
return false
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// helper: เอา content ของไฟล์ระดับ topic (relPath = ชื่อไฟล์ตรง ๆ เช่น 'design.md')
|
|
42
|
-
function topLevel(files, name) {
|
|
43
|
-
return files.has(name) ? files.get(name) : null
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// ── C2: ทุกโฟลเดอร์ใน tasks/ (ข้าม [...]) มีครบ 4 ไฟล์ ──────────────────────
|
|
47
|
-
// files key รูปแบบ: 'tasks/<taskName>/<file>' — รวบ taskName + เซตไฟล์ที่มี
|
|
48
|
-
function checkTasks(files) {
|
|
49
|
-
const issues = []
|
|
50
|
-
const tasks = new Map() // taskName -> Set<file>
|
|
51
|
-
for (const key of files.keys()) {
|
|
52
|
-
const parts = key.split('/')
|
|
53
|
-
if (parts[0] !== 'tasks' || parts.length < 3) continue
|
|
54
|
-
const taskName = parts[1]
|
|
55
|
-
if (taskName.startsWith('[')) continue // skip template placeholder [task-name]
|
|
56
|
-
if (!tasks.has(taskName)) tasks.set(taskName, new Set())
|
|
57
|
-
tasks.get(taskName).add(parts[2])
|
|
58
|
-
}
|
|
59
|
-
for (const [taskName, present] of tasks) {
|
|
60
|
-
const missing = TASK_REQUIRED.filter((f) => !present.has(f))
|
|
61
|
-
if (missing.length) {
|
|
62
|
-
issues.push({
|
|
63
|
-
code: 'C2',
|
|
64
|
-
level: 'error',
|
|
65
|
-
msg: `tasks/${taskName} ขาด ${missing.join(', ')}`,
|
|
66
|
-
})
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
return issues
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// ── C3: ship.md เริ่มเติมแล้ว → ต้องมี '## 3. Learned rules' + ≥1 data row จริง ──────
|
|
73
|
-
// (B4) ยัง template H1 → ข้าม (chicken-egg) · (B3) ≥1 row จริง (ไม่นับ header/separator/row ว่าง)
|
|
74
|
-
function checkShipData(files) {
|
|
75
|
-
const issues = []
|
|
76
|
-
const ship = topLevel(files, 'ship.md')
|
|
77
|
-
if (ship == null) return issues // ไม่มีไฟล์ → ไม่เช็ค (ยังไม่ถึง SHIP)
|
|
78
|
-
if (!isFilled(ship)) return issues // ยัง template → ข้าม (B4)
|
|
79
|
-
|
|
80
|
-
const lines = ship.split('\n')
|
|
81
|
-
// หา section '## 3. Learned rules' (anchor H2 ที่ขึ้นต้นด้วยข้อความนี้)
|
|
82
|
-
let secStart = -1
|
|
83
|
-
for (let i = 0; i < lines.length; i++) {
|
|
84
|
-
if (/^##\s+3\.\s+Learned rules/.test(lines[i])) { secStart = i; break }
|
|
85
|
-
}
|
|
86
|
-
if (secStart === -1) {
|
|
87
|
-
issues.push({ code: 'C3', level: 'error', msg: 'ship.md ขาด section "## 3. Learned rules"' })
|
|
88
|
-
return issues
|
|
89
|
-
}
|
|
90
|
-
// ขอบเขต section = จนเจอ '## ' ถัดไป
|
|
91
|
-
let secEnd = lines.length
|
|
92
|
-
for (let i = secStart + 1; i < lines.length; i++) {
|
|
93
|
-
if (/^##\s/.test(lines[i])) { secEnd = i; break }
|
|
94
|
-
}
|
|
95
|
-
// หา data row จริงในตาราง: บรรทัดที่มี '|' ≥2, ไม่ใช่ separator (|---|), ไม่ใช่ header (มี cell ไม่ว่าง อย่างน้อย 1)
|
|
96
|
-
// header แยกจาก data ด้วย separator — นับ row หลัง separator ที่มี cell ไม่ว่าง
|
|
97
|
-
let sawSeparator = false
|
|
98
|
-
let hasDataRow = false
|
|
99
|
-
for (let i = secStart + 1; i < secEnd; i++) {
|
|
100
|
-
const t = lines[i].trim()
|
|
101
|
-
if (!t.startsWith('|')) continue
|
|
102
|
-
if (/^\|[\s|:-]*\|?$/.test(t) && t.includes('-')) { sawSeparator = true; continue } // separator |---|
|
|
103
|
-
if (!sawSeparator) continue // ยังไม่ถึง separator = ยังเป็น header
|
|
104
|
-
// data row: split cells, มี cell ที่ไม่ว่าง
|
|
105
|
-
const cells = t.split('|').slice(1, -1).map((c) => c.trim())
|
|
106
|
-
if (cells.some((c) => c !== '')) { hasDataRow = true; break }
|
|
107
|
-
}
|
|
108
|
-
if (!hasDataRow) {
|
|
109
|
-
issues.push({ code: 'C3', level: 'error', msg: 'ship.md section "Learned rules" ไม่มี data row (มีแค่ header/ตารางว่าง)' })
|
|
110
|
-
}
|
|
111
|
-
return issues
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// ── C1: artifact ของ stage N เริ่มเติม แต่ required ของ stage < N ยัง template (ข้ามลำดับ) → ⚠ ──
|
|
115
|
-
// + stage inference: stage ปัจจุบัน = stage สูงสุดที่มี artifact "เริ่มเติม"
|
|
116
|
-
function inferStageAndC1(files) {
|
|
117
|
-
const issues = []
|
|
118
|
-
const filledOf = (name) => {
|
|
119
|
-
const c = topLevel(files, name)
|
|
120
|
-
return c != null && isFilled(c)
|
|
121
|
-
}
|
|
122
|
-
// stage ที่ "เริ่มเติม" = มี artifact required หรือ optional ตัวใดตัวหนึ่ง filled
|
|
123
|
-
let maxOrder = 0
|
|
124
|
-
let stageName = '(ยังไม่เริ่ม)'
|
|
125
|
-
for (const s of STAGES) {
|
|
126
|
-
const anyFilled = [...s.required, ...s.optional].some(filledOf)
|
|
127
|
-
if (anyFilled && s.order > maxOrder) { maxOrder = s.order; stageName = s.stage }
|
|
128
|
-
}
|
|
129
|
-
// C1: stage ที่เริ่มเติม (order N) แต่ required ของ stage order < N ยังไม่ครบ filled
|
|
130
|
-
for (const s of STAGES) {
|
|
131
|
-
if (s.required.length === 0) continue
|
|
132
|
-
const anyFilledHere = [...s.required, ...s.optional].some(filledOf)
|
|
133
|
-
if (!anyFilledHere) continue
|
|
134
|
-
// เช็ค required ของ stage ก่อนหน้า (order < s.order) ที่ยังไม่ filled
|
|
135
|
-
for (const prev of STAGES) {
|
|
136
|
-
if (prev.order >= s.order || prev.required.length === 0) continue
|
|
137
|
-
const prevDone = prev.required.every(filledOf)
|
|
138
|
-
if (!prevDone) {
|
|
139
|
-
issues.push({
|
|
140
|
-
code: 'C1',
|
|
141
|
-
level: 'warn',
|
|
142
|
-
msg: `${s.stage} เริ่มเติมแต่ ${prev.stage} (${prev.required.join('/')}) ยังเป็น template (ข้ามลำดับ)`,
|
|
143
|
-
})
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
return { issues, stage: stageName }
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// ── C4: design.md เริ่มเติมแล้ว → ต้องมี section 'Spec delta' (หรือ 'ไม่มี delta') → ⚠ ─────
|
|
151
|
-
function checkSpecDelta(files) {
|
|
152
|
-
const issues = []
|
|
153
|
-
const design = topLevel(files, 'design.md')
|
|
154
|
-
if (design == null || !isFilled(design)) return issues // ไม่มี/ยัง template → ข้าม
|
|
155
|
-
const hasDelta = /Spec delta/i.test(design) || /ไม่มี delta/.test(design)
|
|
156
|
-
if (!hasDelta) {
|
|
157
|
-
issues.push({ code: 'C4', level: 'warn', msg: 'design.md เริ่มเติมแล้วแต่ไม่มี section "Spec delta"' })
|
|
158
|
-
}
|
|
159
|
-
return issues
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// ── pure fn หลัก: checkTopic(files) → {issues, stage} ────────────────────────
|
|
163
|
-
export function checkTopic(files) {
|
|
164
|
-
const issues = []
|
|
165
|
-
issues.push(...checkTasks(files)) // C2 ✖
|
|
166
|
-
issues.push(...checkShipData(files)) // C3 ✖
|
|
167
|
-
const { issues: c1Issues, stage } = inferStageAndC1(files) // C1 ⚠ + stage
|
|
168
|
-
issues.push(...c1Issues)
|
|
169
|
-
issues.push(...checkSpecDelta(files)) // C4 ⚠
|
|
170
|
-
return { issues, stage }
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// ── C5: feature spec format (checkFeatureSpec) ──────────────────────────────
|
|
174
|
-
// มี '## Requirement:' ≥1 (anchor H2 เป๊ะ) · ทุก Requirement มี '### Scenario:' ≥1
|
|
175
|
-
// · ทุก Scenario มี GIVEN+WHEN+THEN (case-insensitive, ไม่ enforce order)
|
|
176
|
-
// group ด้วย section boundary (เจอ '## Requirement:' ถัดไป = ปิด block ของ Requirement ก่อน) — defer #1
|
|
177
|
-
export function checkFeatureSpec(name, content) {
|
|
178
|
-
const issues = []
|
|
179
|
-
const lines = content.split('\n')
|
|
180
|
-
|
|
181
|
-
// index ของ '## Requirement:' (H2 เป๊ะ — กัน false-match #### ใน design.md §9; defer #2)
|
|
182
|
-
const reqIdx = []
|
|
183
|
-
for (let i = 0; i < lines.length; i++) {
|
|
184
|
-
if (/^##\s+Requirement:/.test(lines[i])) reqIdx.push(i)
|
|
185
|
-
}
|
|
186
|
-
if (reqIdx.length === 0) {
|
|
187
|
-
issues.push({ code: 'C5', level: 'error', msg: `${name}: ไม่มี "## Requirement:" (≥1)` })
|
|
188
|
-
return issues
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
// แต่ละ Requirement block = [reqIdx[k] .. reqIdx[k+1]) (หรือจบไฟล์)
|
|
192
|
-
for (let k = 0; k < reqIdx.length; k++) {
|
|
193
|
-
const start = reqIdx[k]
|
|
194
|
-
const end = k + 1 < reqIdx.length ? reqIdx[k + 1] : lines.length
|
|
195
|
-
const reqTitle = lines[start].replace(/^##\s+Requirement:\s*/, '').trim() || '(ไม่มีชื่อ)'
|
|
196
|
-
|
|
197
|
-
// หา '### Scenario:' ใน block นี้
|
|
198
|
-
const scenIdx = []
|
|
199
|
-
for (let i = start + 1; i < end; i++) {
|
|
200
|
-
if (/^###\s+Scenario:/.test(lines[i])) scenIdx.push(i)
|
|
201
|
-
}
|
|
202
|
-
if (scenIdx.length === 0) {
|
|
203
|
-
issues.push({ code: 'C5', level: 'error', msg: `${name}: Requirement "${reqTitle}" ไม่มี "### Scenario:"` })
|
|
204
|
-
continue
|
|
205
|
-
}
|
|
206
|
-
// แต่ละ Scenario block = [scenIdx[j] .. scenIdx[j+1] หรือ end)
|
|
207
|
-
for (let j = 0; j < scenIdx.length; j++) {
|
|
208
|
-
const sStart = scenIdx[j]
|
|
209
|
-
const sEnd = j + 1 < scenIdx.length ? scenIdx[j + 1] : end
|
|
210
|
-
const scenTitle = lines[sStart].replace(/^###\s+Scenario:\s*/, '').trim() || '(ไม่มีชื่อ)'
|
|
211
|
-
const body = lines.slice(sStart + 1, sEnd).join('\n')
|
|
212
|
-
const missing = []
|
|
213
|
-
if (!/\bGIVEN\b/i.test(body)) missing.push('GIVEN')
|
|
214
|
-
if (!/\bWHEN\b/i.test(body)) missing.push('WHEN')
|
|
215
|
-
if (!/\bTHEN\b/i.test(body)) missing.push('THEN')
|
|
216
|
-
if (missing.length) {
|
|
217
|
-
issues.push({
|
|
218
|
-
code: 'C5',
|
|
219
|
-
level: 'error',
|
|
220
|
-
msg: `${name}: Scenario "${scenTitle}" ขาด ${missing.join('/')}`,
|
|
221
|
-
})
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
return issues
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// ── render helper ───────────────────────────────────────────────────────────
|
|
229
|
-
const SYM = { error: '✖', warn: '⚠' }
|
|
230
|
-
function countLevels(issues) {
|
|
231
|
-
let err = 0
|
|
232
|
-
let warn = 0
|
|
233
|
-
for (const i of issues) {
|
|
234
|
-
if (i.level === 'error') err++
|
|
235
|
-
else if (i.level === 'warn') warn++
|
|
236
|
-
}
|
|
237
|
-
return { err, warn }
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
// ── fs walk (main เท่านั้น — pure fn ไม่รู้จัก fs) ──────────────────────────
|
|
241
|
-
const SKIP_TOPIC = new Set(['achieved'])
|
|
242
|
-
const SKIP_FILE = new Set(['context.md'])
|
|
243
|
-
|
|
244
|
-
// อ่านไฟล์ทั้งหมดใต้ dir ของ topic เป็น Map<relPath,content> (relPath relative จาก topic dir, POSIX)
|
|
245
|
-
function readTopicFiles(topicDir) {
|
|
246
|
-
const files = new Map()
|
|
247
|
-
const walk = (dir, prefix) => {
|
|
248
|
-
let entries
|
|
249
|
-
try {
|
|
250
|
-
entries = readdirSync(dir, { withFileTypes: true })
|
|
251
|
-
} catch {
|
|
252
|
-
return // ENOENT/EACCES guard — ข้ามเงียบ ไม่พ่น absolute path
|
|
253
|
-
}
|
|
254
|
-
for (const e of entries) {
|
|
255
|
-
const rel = prefix ? `${prefix}/${e.name}` : e.name
|
|
256
|
-
if (e.isDirectory()) {
|
|
257
|
-
walk(join(dir, e.name), rel)
|
|
258
|
-
} else if (e.isFile()) {
|
|
259
|
-
if (!e.name.endsWith('.md')) continue
|
|
260
|
-
try {
|
|
261
|
-
files.set(rel, readFileSync(join(dir, e.name), 'utf8'))
|
|
262
|
-
} catch {
|
|
263
|
-
// ENOENT/EACCES — ข้ามไฟล์ที่อ่านไม่ได้ ไม่ leak path
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
walk(topicDir, '')
|
|
269
|
-
return files
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
// list active topic slugs (dir ใต้ docs/stages/ ข้าม achieved) — ใช้ทั้ง status + slug whitelist (B7)
|
|
273
|
-
function listTopics(stagesDir) {
|
|
274
|
-
let entries
|
|
275
|
-
try {
|
|
276
|
-
entries = readdirSync(stagesDir, { withFileTypes: true })
|
|
277
|
-
} catch {
|
|
278
|
-
return []
|
|
279
|
-
}
|
|
280
|
-
return entries
|
|
281
|
-
.filter((e) => e.isDirectory() && !SKIP_TOPIC.has(e.name))
|
|
282
|
-
.map((e) => e.name)
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
// walk docs/features/*/spec.md → [{name, content}]
|
|
286
|
-
function readFeatureSpecs(featuresDir) {
|
|
287
|
-
const specs = []
|
|
288
|
-
let entries
|
|
289
|
-
try {
|
|
290
|
-
entries = readdirSync(featuresDir, { withFileTypes: true })
|
|
291
|
-
} catch {
|
|
292
|
-
return specs
|
|
293
|
-
}
|
|
294
|
-
for (const e of entries) {
|
|
295
|
-
if (!e.isDirectory()) continue
|
|
296
|
-
const specPath = join(featuresDir, e.name, 'spec.md')
|
|
297
|
-
try {
|
|
298
|
-
const content = readFileSync(specPath, 'utf8')
|
|
299
|
-
specs.push({ name: `docs/features/${e.name}/spec.md`, content })
|
|
300
|
-
} catch {
|
|
301
|
-
// ไม่มี spec.md → ข้าม (เช็คเฉพาะที่มีไฟล์)
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
return specs
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
// ── main: 2 โหมด ────────────────────────────────────────────────────────────
|
|
308
|
-
function main() {
|
|
309
|
-
const cwd = process.cwd()
|
|
310
|
-
const stagesDir = join(cwd, 'docs', 'stages')
|
|
311
|
-
const featuresDir = join(cwd, 'docs', 'features')
|
|
312
|
-
const args = process.argv.slice(2)
|
|
313
|
-
|
|
314
|
-
if (args.length > 1) {
|
|
315
|
-
console.error('✖ ใช้: validate-topic.mjs [<slug>] — รับได้สูงสุด 1 arg')
|
|
316
|
-
process.exit(2)
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
// C5 spec ใช้ทั้งสองโหมด (รวมเข้า total count)
|
|
320
|
-
const featureSpecs = readFeatureSpecs(featuresDir)
|
|
321
|
-
const featureIssues = featureSpecs.flatMap((s) => checkFeatureSpec(s.name, s.content))
|
|
322
|
-
|
|
323
|
-
if (args.length === 0) {
|
|
324
|
-
// ── โหมด status ──
|
|
325
|
-
const topics = listTopics(stagesDir)
|
|
326
|
-
if (topics.length === 0 && featureIssues.length === 0) {
|
|
327
|
-
console.log('ไม่มีงานค้าง')
|
|
328
|
-
process.exit(0)
|
|
329
|
-
}
|
|
330
|
-
if (topics.length === 0) {
|
|
331
|
-
console.log('ไม่มี topic ใน docs/stages/')
|
|
332
|
-
} else {
|
|
333
|
-
console.log('topic'.padEnd(28), 'stage'.padEnd(14), '✖/⚠')
|
|
334
|
-
console.log('-'.repeat(28), '-'.repeat(14), '-----')
|
|
335
|
-
for (const slug of topics.sort()) {
|
|
336
|
-
const files = readTopicFiles(join(stagesDir, slug))
|
|
337
|
-
const { issues, stage } = checkTopic(files)
|
|
338
|
-
const { err, warn } = countLevels(issues)
|
|
339
|
-
console.log(slug.padEnd(28), stage.padEnd(14), `✖${err}/⚠${warn}`)
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
if (featureIssues.length) {
|
|
343
|
-
console.log('')
|
|
344
|
-
console.log(`feature spec (C5): ✖${featureIssues.length}`)
|
|
345
|
-
}
|
|
346
|
-
process.exit(0) // status เป็นรายงาน ไม่ใช่ gate
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
// ── โหมด validate <slug> ──
|
|
350
|
-
const slug = args[0]
|
|
351
|
-
// slug whitelist (B7): ต้องตรง basename ของ dir ที่มีอยู่จริง — กัน path traversal
|
|
352
|
-
const topics = listTopics(stagesDir)
|
|
353
|
-
if (!topics.includes(slug)) {
|
|
354
|
-
console.error(`✖ ไม่พบ topic "${slug}" ใน docs/stages/`)
|
|
355
|
-
process.exit(2)
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
const files = readTopicFiles(join(stagesDir, slug))
|
|
359
|
-
const { issues, stage } = checkTopic(files)
|
|
360
|
-
// รวม C5 ของ feature spec (เป็น cross-cutting — report ในโหมด validate ด้วย)
|
|
361
|
-
const allIssues = [...issues, ...featureIssues]
|
|
362
|
-
|
|
363
|
-
console.log(`topic: ${slug} · stage (ประมาณการ): ${stage}`)
|
|
364
|
-
if (allIssues.length === 0) {
|
|
365
|
-
console.log('✓ โครงครบ (structural)')
|
|
366
|
-
process.exit(0)
|
|
367
|
-
}
|
|
368
|
-
for (const i of allIssues) {
|
|
369
|
-
console.log(`${SYM[i.level] || '?'} [${i.code}] ${i.msg}`)
|
|
370
|
-
}
|
|
371
|
-
const { err } = countLevels(allIssues)
|
|
372
|
-
process.exit(err > 0 ? 1 : 0)
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
// main-guard: argv[1] comparison (ไม่ใช่ import.meta.main ที่ undefined บน node 20) — import จาก unit ไม่ trigger main
|
|
376
|
-
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
377
|
-
main()
|
|
378
|
-
}
|
|
1
|
+
import { readdirSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
|
|
5
|
+
// Structural validator ของ topic ใน docs/stages/ — zero-dep (mirror lint-md.mjs: pure fn + injectable IO + main-guard)
|
|
6
|
+
// 2 โหมด: status (ไม่มี arg, ตารางทุก topic, exit 0) · validate (<slug>, รายการ ✖/⚠, exit 1 เมื่อมี ✖ / 2 เมื่อ slug ผิด)
|
|
7
|
+
// เช็ค C1–C5 = structural เท่านั้น (semantic เป็นของ model ตาม gate เดิม) — canonical contract: design §4
|
|
8
|
+
//
|
|
9
|
+
// หลักการแยกระดับ (design §4.2): ✖ checks (C2/C3/C5) ไม่พึ่ง filled-detection (existence/structure ล้วน)
|
|
10
|
+
// · C1/C4 = ⚠ best-effort (heuristic เดา "เริ่มเติม" — ยอมรับ false ได้ ไม่ block)
|
|
11
|
+
// security (design §4.4): เฉพาะ node:fs/node:path/node:url — ไม่มี child_process/network/write
|
|
12
|
+
// · report structural เท่านั้น (ชื่อไฟล์/section/code — ไม่ echo เนื้อ artifact) · ENOENT/EACCES guard ไม่พ่น absolute path
|
|
13
|
+
|
|
14
|
+
// ── canonical: stage → artifact (design §4.3) ──────────────────────────────
|
|
15
|
+
// required = ต้องมีถึงจะนับว่าผ่าน stage · optional = ข้ามได้ปกติ (ไม่ count เป็น "ข้าม stage")
|
|
16
|
+
const STAGES = [
|
|
17
|
+
{ order: 1, stage: 'Discovery', required: [], optional: ['discovery.md', 'research.md'] },
|
|
18
|
+
{ order: 2, stage: 'DESIGN', required: ['proposal.md', 'design.md'], optional: ['business.md'] },
|
|
19
|
+
{ order: 4, stage: 'BUILD', required: ['build.md'], optional: [] },
|
|
20
|
+
{ order: 5, stage: 'VERIFY', required: ['verify.md', 'test.md'], optional: [] },
|
|
21
|
+
{ order: 6, stage: 'SHIP', required: ['ship.md'], optional: [] },
|
|
22
|
+
]
|
|
23
|
+
// ไฟล์ artifact ทั้งหมดที่ใช้ infer stage (รวม required + optional ของทุก stage)
|
|
24
|
+
const STAGE_FILES = STAGES.flatMap((s) => [...s.required, ...s.optional])
|
|
25
|
+
const TASK_REQUIRED = ['spec.md', 'standard.md', 'rule.md', 'task.md']
|
|
26
|
+
|
|
27
|
+
// ── filled heuristic (B1): "เริ่มเติม" = H1 (บรรทัดแรกที่ไม่ว่าง) ไม่มี placeholder <...> ─────
|
|
28
|
+
// ทุก template artifact มี `— <ชื่อ...>` ที่ H1; ห้ามใช้ const FILLED_MARKERS list (เปราะ)
|
|
29
|
+
function isFilled(content) {
|
|
30
|
+
if (content == null) return false
|
|
31
|
+
const lines = content.split('\n')
|
|
32
|
+
for (const line of lines) {
|
|
33
|
+
const t = line.trim()
|
|
34
|
+
if (t === '') continue
|
|
35
|
+
// H1 = บรรทัดแรกที่ไม่ว่าง — เริ่มเติมเมื่อไม่มี placeholder <...>
|
|
36
|
+
return !/<[^>]+>/.test(t)
|
|
37
|
+
}
|
|
38
|
+
return false
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// helper: เอา content ของไฟล์ระดับ topic (relPath = ชื่อไฟล์ตรง ๆ เช่น 'design.md')
|
|
42
|
+
function topLevel(files, name) {
|
|
43
|
+
return files.has(name) ? files.get(name) : null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── C2: ทุกโฟลเดอร์ใน tasks/ (ข้าม [...]) มีครบ 4 ไฟล์ ──────────────────────
|
|
47
|
+
// files key รูปแบบ: 'tasks/<taskName>/<file>' — รวบ taskName + เซตไฟล์ที่มี
|
|
48
|
+
function checkTasks(files) {
|
|
49
|
+
const issues = []
|
|
50
|
+
const tasks = new Map() // taskName -> Set<file>
|
|
51
|
+
for (const key of files.keys()) {
|
|
52
|
+
const parts = key.split('/')
|
|
53
|
+
if (parts[0] !== 'tasks' || parts.length < 3) continue
|
|
54
|
+
const taskName = parts[1]
|
|
55
|
+
if (taskName.startsWith('[')) continue // skip template placeholder [task-name]
|
|
56
|
+
if (!tasks.has(taskName)) tasks.set(taskName, new Set())
|
|
57
|
+
tasks.get(taskName).add(parts[2])
|
|
58
|
+
}
|
|
59
|
+
for (const [taskName, present] of tasks) {
|
|
60
|
+
const missing = TASK_REQUIRED.filter((f) => !present.has(f))
|
|
61
|
+
if (missing.length) {
|
|
62
|
+
issues.push({
|
|
63
|
+
code: 'C2',
|
|
64
|
+
level: 'error',
|
|
65
|
+
msg: `tasks/${taskName} ขาด ${missing.join(', ')}`,
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return issues
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── C3: ship.md เริ่มเติมแล้ว → ต้องมี '## 3. Learned rules' + ≥1 data row จริง ──────
|
|
73
|
+
// (B4) ยัง template H1 → ข้าม (chicken-egg) · (B3) ≥1 row จริง (ไม่นับ header/separator/row ว่าง)
|
|
74
|
+
function checkShipData(files) {
|
|
75
|
+
const issues = []
|
|
76
|
+
const ship = topLevel(files, 'ship.md')
|
|
77
|
+
if (ship == null) return issues // ไม่มีไฟล์ → ไม่เช็ค (ยังไม่ถึง SHIP)
|
|
78
|
+
if (!isFilled(ship)) return issues // ยัง template → ข้าม (B4)
|
|
79
|
+
|
|
80
|
+
const lines = ship.split('\n')
|
|
81
|
+
// หา section '## 3. Learned rules' (anchor H2 ที่ขึ้นต้นด้วยข้อความนี้)
|
|
82
|
+
let secStart = -1
|
|
83
|
+
for (let i = 0; i < lines.length; i++) {
|
|
84
|
+
if (/^##\s+3\.\s+Learned rules/.test(lines[i])) { secStart = i; break }
|
|
85
|
+
}
|
|
86
|
+
if (secStart === -1) {
|
|
87
|
+
issues.push({ code: 'C3', level: 'error', msg: 'ship.md ขาด section "## 3. Learned rules"' })
|
|
88
|
+
return issues
|
|
89
|
+
}
|
|
90
|
+
// ขอบเขต section = จนเจอ '## ' ถัดไป
|
|
91
|
+
let secEnd = lines.length
|
|
92
|
+
for (let i = secStart + 1; i < lines.length; i++) {
|
|
93
|
+
if (/^##\s/.test(lines[i])) { secEnd = i; break }
|
|
94
|
+
}
|
|
95
|
+
// หา data row จริงในตาราง: บรรทัดที่มี '|' ≥2, ไม่ใช่ separator (|---|), ไม่ใช่ header (มี cell ไม่ว่าง อย่างน้อย 1)
|
|
96
|
+
// header แยกจาก data ด้วย separator — นับ row หลัง separator ที่มี cell ไม่ว่าง
|
|
97
|
+
let sawSeparator = false
|
|
98
|
+
let hasDataRow = false
|
|
99
|
+
for (let i = secStart + 1; i < secEnd; i++) {
|
|
100
|
+
const t = lines[i].trim()
|
|
101
|
+
if (!t.startsWith('|')) continue
|
|
102
|
+
if (/^\|[\s|:-]*\|?$/.test(t) && t.includes('-')) { sawSeparator = true; continue } // separator |---|
|
|
103
|
+
if (!sawSeparator) continue // ยังไม่ถึง separator = ยังเป็น header
|
|
104
|
+
// data row: split cells, มี cell ที่ไม่ว่าง
|
|
105
|
+
const cells = t.split('|').slice(1, -1).map((c) => c.trim())
|
|
106
|
+
if (cells.some((c) => c !== '')) { hasDataRow = true; break }
|
|
107
|
+
}
|
|
108
|
+
if (!hasDataRow) {
|
|
109
|
+
issues.push({ code: 'C3', level: 'error', msg: 'ship.md section "Learned rules" ไม่มี data row (มีแค่ header/ตารางว่าง)' })
|
|
110
|
+
}
|
|
111
|
+
return issues
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── C1: artifact ของ stage N เริ่มเติม แต่ required ของ stage < N ยัง template (ข้ามลำดับ) → ⚠ ──
|
|
115
|
+
// + stage inference: stage ปัจจุบัน = stage สูงสุดที่มี artifact "เริ่มเติม"
|
|
116
|
+
function inferStageAndC1(files) {
|
|
117
|
+
const issues = []
|
|
118
|
+
const filledOf = (name) => {
|
|
119
|
+
const c = topLevel(files, name)
|
|
120
|
+
return c != null && isFilled(c)
|
|
121
|
+
}
|
|
122
|
+
// stage ที่ "เริ่มเติม" = มี artifact required หรือ optional ตัวใดตัวหนึ่ง filled
|
|
123
|
+
let maxOrder = 0
|
|
124
|
+
let stageName = '(ยังไม่เริ่ม)'
|
|
125
|
+
for (const s of STAGES) {
|
|
126
|
+
const anyFilled = [...s.required, ...s.optional].some(filledOf)
|
|
127
|
+
if (anyFilled && s.order > maxOrder) { maxOrder = s.order; stageName = s.stage }
|
|
128
|
+
}
|
|
129
|
+
// C1: stage ที่เริ่มเติม (order N) แต่ required ของ stage order < N ยังไม่ครบ filled
|
|
130
|
+
for (const s of STAGES) {
|
|
131
|
+
if (s.required.length === 0) continue
|
|
132
|
+
const anyFilledHere = [...s.required, ...s.optional].some(filledOf)
|
|
133
|
+
if (!anyFilledHere) continue
|
|
134
|
+
// เช็ค required ของ stage ก่อนหน้า (order < s.order) ที่ยังไม่ filled
|
|
135
|
+
for (const prev of STAGES) {
|
|
136
|
+
if (prev.order >= s.order || prev.required.length === 0) continue
|
|
137
|
+
const prevDone = prev.required.every(filledOf)
|
|
138
|
+
if (!prevDone) {
|
|
139
|
+
issues.push({
|
|
140
|
+
code: 'C1',
|
|
141
|
+
level: 'warn',
|
|
142
|
+
msg: `${s.stage} เริ่มเติมแต่ ${prev.stage} (${prev.required.join('/')}) ยังเป็น template (ข้ามลำดับ)`,
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return { issues, stage: stageName }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── C4: design.md เริ่มเติมแล้ว → ต้องมี section 'Spec delta' (หรือ 'ไม่มี delta') → ⚠ ─────
|
|
151
|
+
function checkSpecDelta(files) {
|
|
152
|
+
const issues = []
|
|
153
|
+
const design = topLevel(files, 'design.md')
|
|
154
|
+
if (design == null || !isFilled(design)) return issues // ไม่มี/ยัง template → ข้าม
|
|
155
|
+
const hasDelta = /Spec delta/i.test(design) || /ไม่มี delta/.test(design)
|
|
156
|
+
if (!hasDelta) {
|
|
157
|
+
issues.push({ code: 'C4', level: 'warn', msg: 'design.md เริ่มเติมแล้วแต่ไม่มี section "Spec delta"' })
|
|
158
|
+
}
|
|
159
|
+
return issues
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── pure fn หลัก: checkTopic(files) → {issues, stage} ────────────────────────
|
|
163
|
+
export function checkTopic(files) {
|
|
164
|
+
const issues = []
|
|
165
|
+
issues.push(...checkTasks(files)) // C2 ✖
|
|
166
|
+
issues.push(...checkShipData(files)) // C3 ✖
|
|
167
|
+
const { issues: c1Issues, stage } = inferStageAndC1(files) // C1 ⚠ + stage
|
|
168
|
+
issues.push(...c1Issues)
|
|
169
|
+
issues.push(...checkSpecDelta(files)) // C4 ⚠
|
|
170
|
+
return { issues, stage }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ── C5: feature spec format (checkFeatureSpec) ──────────────────────────────
|
|
174
|
+
// มี '## Requirement:' ≥1 (anchor H2 เป๊ะ) · ทุก Requirement มี '### Scenario:' ≥1
|
|
175
|
+
// · ทุก Scenario มี GIVEN+WHEN+THEN (case-insensitive, ไม่ enforce order)
|
|
176
|
+
// group ด้วย section boundary (เจอ '## Requirement:' ถัดไป = ปิด block ของ Requirement ก่อน) — defer #1
|
|
177
|
+
export function checkFeatureSpec(name, content) {
|
|
178
|
+
const issues = []
|
|
179
|
+
const lines = content.split('\n')
|
|
180
|
+
|
|
181
|
+
// index ของ '## Requirement:' (H2 เป๊ะ — กัน false-match #### ใน design.md §9; defer #2)
|
|
182
|
+
const reqIdx = []
|
|
183
|
+
for (let i = 0; i < lines.length; i++) {
|
|
184
|
+
if (/^##\s+Requirement:/.test(lines[i])) reqIdx.push(i)
|
|
185
|
+
}
|
|
186
|
+
if (reqIdx.length === 0) {
|
|
187
|
+
issues.push({ code: 'C5', level: 'error', msg: `${name}: ไม่มี "## Requirement:" (≥1)` })
|
|
188
|
+
return issues
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// แต่ละ Requirement block = [reqIdx[k] .. reqIdx[k+1]) (หรือจบไฟล์)
|
|
192
|
+
for (let k = 0; k < reqIdx.length; k++) {
|
|
193
|
+
const start = reqIdx[k]
|
|
194
|
+
const end = k + 1 < reqIdx.length ? reqIdx[k + 1] : lines.length
|
|
195
|
+
const reqTitle = lines[start].replace(/^##\s+Requirement:\s*/, '').trim() || '(ไม่มีชื่อ)'
|
|
196
|
+
|
|
197
|
+
// หา '### Scenario:' ใน block นี้
|
|
198
|
+
const scenIdx = []
|
|
199
|
+
for (let i = start + 1; i < end; i++) {
|
|
200
|
+
if (/^###\s+Scenario:/.test(lines[i])) scenIdx.push(i)
|
|
201
|
+
}
|
|
202
|
+
if (scenIdx.length === 0) {
|
|
203
|
+
issues.push({ code: 'C5', level: 'error', msg: `${name}: Requirement "${reqTitle}" ไม่มี "### Scenario:"` })
|
|
204
|
+
continue
|
|
205
|
+
}
|
|
206
|
+
// แต่ละ Scenario block = [scenIdx[j] .. scenIdx[j+1] หรือ end)
|
|
207
|
+
for (let j = 0; j < scenIdx.length; j++) {
|
|
208
|
+
const sStart = scenIdx[j]
|
|
209
|
+
const sEnd = j + 1 < scenIdx.length ? scenIdx[j + 1] : end
|
|
210
|
+
const scenTitle = lines[sStart].replace(/^###\s+Scenario:\s*/, '').trim() || '(ไม่มีชื่อ)'
|
|
211
|
+
const body = lines.slice(sStart + 1, sEnd).join('\n')
|
|
212
|
+
const missing = []
|
|
213
|
+
if (!/\bGIVEN\b/i.test(body)) missing.push('GIVEN')
|
|
214
|
+
if (!/\bWHEN\b/i.test(body)) missing.push('WHEN')
|
|
215
|
+
if (!/\bTHEN\b/i.test(body)) missing.push('THEN')
|
|
216
|
+
if (missing.length) {
|
|
217
|
+
issues.push({
|
|
218
|
+
code: 'C5',
|
|
219
|
+
level: 'error',
|
|
220
|
+
msg: `${name}: Scenario "${scenTitle}" ขาด ${missing.join('/')}`,
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return issues
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ── render helper ───────────────────────────────────────────────────────────
|
|
229
|
+
const SYM = { error: '✖', warn: '⚠' }
|
|
230
|
+
function countLevels(issues) {
|
|
231
|
+
let err = 0
|
|
232
|
+
let warn = 0
|
|
233
|
+
for (const i of issues) {
|
|
234
|
+
if (i.level === 'error') err++
|
|
235
|
+
else if (i.level === 'warn') warn++
|
|
236
|
+
}
|
|
237
|
+
return { err, warn }
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ── fs walk (main เท่านั้น — pure fn ไม่รู้จัก fs) ──────────────────────────
|
|
241
|
+
const SKIP_TOPIC = new Set(['achieved'])
|
|
242
|
+
const SKIP_FILE = new Set(['context.md'])
|
|
243
|
+
|
|
244
|
+
// อ่านไฟล์ทั้งหมดใต้ dir ของ topic เป็น Map<relPath,content> (relPath relative จาก topic dir, POSIX)
|
|
245
|
+
function readTopicFiles(topicDir) {
|
|
246
|
+
const files = new Map()
|
|
247
|
+
const walk = (dir, prefix) => {
|
|
248
|
+
let entries
|
|
249
|
+
try {
|
|
250
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
251
|
+
} catch {
|
|
252
|
+
return // ENOENT/EACCES guard — ข้ามเงียบ ไม่พ่น absolute path
|
|
253
|
+
}
|
|
254
|
+
for (const e of entries) {
|
|
255
|
+
const rel = prefix ? `${prefix}/${e.name}` : e.name
|
|
256
|
+
if (e.isDirectory()) {
|
|
257
|
+
walk(join(dir, e.name), rel)
|
|
258
|
+
} else if (e.isFile()) {
|
|
259
|
+
if (!e.name.endsWith('.md')) continue
|
|
260
|
+
try {
|
|
261
|
+
files.set(rel, readFileSync(join(dir, e.name), 'utf8'))
|
|
262
|
+
} catch {
|
|
263
|
+
// ENOENT/EACCES — ข้ามไฟล์ที่อ่านไม่ได้ ไม่ leak path
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
walk(topicDir, '')
|
|
269
|
+
return files
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// list active topic slugs (dir ใต้ docs/stages/ ข้าม achieved) — ใช้ทั้ง status + slug whitelist (B7)
|
|
273
|
+
function listTopics(stagesDir) {
|
|
274
|
+
let entries
|
|
275
|
+
try {
|
|
276
|
+
entries = readdirSync(stagesDir, { withFileTypes: true })
|
|
277
|
+
} catch {
|
|
278
|
+
return []
|
|
279
|
+
}
|
|
280
|
+
return entries
|
|
281
|
+
.filter((e) => e.isDirectory() && !SKIP_TOPIC.has(e.name))
|
|
282
|
+
.map((e) => e.name)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// walk docs/features/*/spec.md → [{name, content}]
|
|
286
|
+
function readFeatureSpecs(featuresDir) {
|
|
287
|
+
const specs = []
|
|
288
|
+
let entries
|
|
289
|
+
try {
|
|
290
|
+
entries = readdirSync(featuresDir, { withFileTypes: true })
|
|
291
|
+
} catch {
|
|
292
|
+
return specs
|
|
293
|
+
}
|
|
294
|
+
for (const e of entries) {
|
|
295
|
+
if (!e.isDirectory()) continue
|
|
296
|
+
const specPath = join(featuresDir, e.name, 'spec.md')
|
|
297
|
+
try {
|
|
298
|
+
const content = readFileSync(specPath, 'utf8')
|
|
299
|
+
specs.push({ name: `docs/features/${e.name}/spec.md`, content })
|
|
300
|
+
} catch {
|
|
301
|
+
// ไม่มี spec.md → ข้าม (เช็คเฉพาะที่มีไฟล์)
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return specs
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ── main: 2 โหมด ────────────────────────────────────────────────────────────
|
|
308
|
+
function main() {
|
|
309
|
+
const cwd = process.cwd()
|
|
310
|
+
const stagesDir = join(cwd, 'docs', 'stages')
|
|
311
|
+
const featuresDir = join(cwd, 'docs', 'features')
|
|
312
|
+
const args = process.argv.slice(2)
|
|
313
|
+
|
|
314
|
+
if (args.length > 1) {
|
|
315
|
+
console.error('✖ ใช้: validate-topic.mjs [<slug>] — รับได้สูงสุด 1 arg')
|
|
316
|
+
process.exit(2)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// C5 spec ใช้ทั้งสองโหมด (รวมเข้า total count)
|
|
320
|
+
const featureSpecs = readFeatureSpecs(featuresDir)
|
|
321
|
+
const featureIssues = featureSpecs.flatMap((s) => checkFeatureSpec(s.name, s.content))
|
|
322
|
+
|
|
323
|
+
if (args.length === 0) {
|
|
324
|
+
// ── โหมด status ──
|
|
325
|
+
const topics = listTopics(stagesDir)
|
|
326
|
+
if (topics.length === 0 && featureIssues.length === 0) {
|
|
327
|
+
console.log('ไม่มีงานค้าง')
|
|
328
|
+
process.exit(0)
|
|
329
|
+
}
|
|
330
|
+
if (topics.length === 0) {
|
|
331
|
+
console.log('ไม่มี topic ใน docs/stages/')
|
|
332
|
+
} else {
|
|
333
|
+
console.log('topic'.padEnd(28), 'stage'.padEnd(14), '✖/⚠')
|
|
334
|
+
console.log('-'.repeat(28), '-'.repeat(14), '-----')
|
|
335
|
+
for (const slug of topics.sort()) {
|
|
336
|
+
const files = readTopicFiles(join(stagesDir, slug))
|
|
337
|
+
const { issues, stage } = checkTopic(files)
|
|
338
|
+
const { err, warn } = countLevels(issues)
|
|
339
|
+
console.log(slug.padEnd(28), stage.padEnd(14), `✖${err}/⚠${warn}`)
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
if (featureIssues.length) {
|
|
343
|
+
console.log('')
|
|
344
|
+
console.log(`feature spec (C5): ✖${featureIssues.length}`)
|
|
345
|
+
}
|
|
346
|
+
process.exit(0) // status เป็นรายงาน ไม่ใช่ gate
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ── โหมด validate <slug> ──
|
|
350
|
+
const slug = args[0]
|
|
351
|
+
// slug whitelist (B7): ต้องตรง basename ของ dir ที่มีอยู่จริง — กัน path traversal
|
|
352
|
+
const topics = listTopics(stagesDir)
|
|
353
|
+
if (!topics.includes(slug)) {
|
|
354
|
+
console.error(`✖ ไม่พบ topic "${slug}" ใน docs/stages/`)
|
|
355
|
+
process.exit(2)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const files = readTopicFiles(join(stagesDir, slug))
|
|
359
|
+
const { issues, stage } = checkTopic(files)
|
|
360
|
+
// รวม C5 ของ feature spec (เป็น cross-cutting — report ในโหมด validate ด้วย)
|
|
361
|
+
const allIssues = [...issues, ...featureIssues]
|
|
362
|
+
|
|
363
|
+
console.log(`topic: ${slug} · stage (ประมาณการ): ${stage}`)
|
|
364
|
+
if (allIssues.length === 0) {
|
|
365
|
+
console.log('✓ โครงครบ (structural)')
|
|
366
|
+
process.exit(0)
|
|
367
|
+
}
|
|
368
|
+
for (const i of allIssues) {
|
|
369
|
+
console.log(`${SYM[i.level] || '?'} [${i.code}] ${i.msg}`)
|
|
370
|
+
}
|
|
371
|
+
const { err } = countLevels(allIssues)
|
|
372
|
+
process.exit(err > 0 ? 1 : 0)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// main-guard: argv[1] comparison (ไม่ใช่ import.meta.main ที่ undefined บน node 20) — import จาก unit ไม่ trigger main
|
|
376
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
377
|
+
main()
|
|
378
|
+
}
|