@warnyin/agents 0.14.0 → 0.15.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 (81) hide show
  1. package/CHANGELOG.md +139 -133
  2. package/README.md +160 -160
  3. package/package.json +38 -38
  4. package/src/.claude/agents/warnyin-infra.md +13 -13
  5. package/src/.claude/agents/warnyin-qa.md +13 -13
  6. package/src/.claude/agents/warnyin-sa.md +13 -13
  7. package/src/.claude/agents/warnyin-security.md +13 -13
  8. package/src/.claude/agents/warnyin-tech-lead.md +13 -13
  9. package/src/.claude/commands/warnyin/build.md +31 -31
  10. package/src/.claude/commands/warnyin/design.md +27 -27
  11. package/src/.claude/commands/warnyin/discovery.md +22 -17
  12. package/src/.claude/commands/warnyin/explore.md +14 -14
  13. package/src/.claude/commands/warnyin/init.md +12 -12
  14. package/src/.claude/commands/warnyin/install-skill.md +14 -14
  15. package/src/.claude/commands/warnyin/next.md +17 -17
  16. package/src/.claude/commands/warnyin/ship.md +28 -28
  17. package/src/.claude/commands/warnyin/triage.md +14 -14
  18. package/src/.claude/commands/warnyin/update-codemaps.md +12 -12
  19. package/src/.claude/commands/warnyin/verify.md +20 -20
  20. package/src/.claude/skills/explore/SKILL.md +8 -8
  21. package/src/.claude/skills/next/SKILL.md +8 -8
  22. package/src/.claude/skills/update-codemaps/SKILL.md +8 -8
  23. package/src/.warnyin/installer/templates/CLAUDE.global.md +5 -5
  24. package/src/.warnyin/installer/templates/CLAUDE.md +34 -34
  25. package/src/.warnyin/template/docs/codemap/index.md +18 -18
  26. package/src/.warnyin/template/docs/features/[feature-name]/business.md +5 -5
  27. package/src/.warnyin/template/docs/features/[feature-name]/feature.md +5 -5
  28. package/src/.warnyin/template/docs/features/[feature-name]/spec.md +16 -16
  29. package/src/.warnyin/template/docs/infra.md +16 -16
  30. package/src/.warnyin/template/docs/project.md +18 -18
  31. package/src/.warnyin/template/docs/rule.md +7 -7
  32. package/src/.warnyin/template/docs/techstack/[component]/about.md +6 -6
  33. package/src/.warnyin/template/docs/techstack/[component]/rule.md +6 -6
  34. package/src/.warnyin/template/docs/techstack/[component]/standard.md +6 -6
  35. package/src/.warnyin/template/docs/techstack/[component]/structure.md +7 -7
  36. package/src/.warnyin/template/docs/techstack/[component]/test.md +7 -7
  37. package/src/.warnyin/template/docs/troubleshooting.md +32 -32
  38. package/src/.warnyin/template/stages/[topic]/build.md +58 -58
  39. package/src/.warnyin/template/stages/[topic]/business.md +21 -21
  40. package/src/.warnyin/template/stages/[topic]/design.md +63 -63
  41. package/src/.warnyin/template/stages/[topic]/discovery.md +69 -69
  42. package/src/.warnyin/template/stages/[topic]/proposal.md +43 -43
  43. package/src/.warnyin/template/stages/[topic]/research.md +49 -49
  44. package/src/.warnyin/template/stages/[topic]/ship.md +32 -32
  45. package/src/.warnyin/template/stages/[topic]/tasks/[task-name]/issue.md +19 -19
  46. package/src/.warnyin/template/stages/[topic]/tasks/[task-name]/rule.md +13 -13
  47. package/src/.warnyin/template/stages/[topic]/tasks/[task-name]/spec.md +36 -36
  48. package/src/.warnyin/template/stages/[topic]/tasks/[task-name]/standard.md +21 -21
  49. package/src/.warnyin/template/stages/[topic]/tasks/[task-name]/task.md +40 -40
  50. package/src/.warnyin/template/stages/[topic]/test.md +46 -46
  51. package/src/.warnyin/template/stages/[topic]/troubleshooting.md +34 -34
  52. package/src/.warnyin/template/stages/[topic]/verify.md +44 -44
  53. package/src/.warnyin/workflow/README.md +105 -102
  54. package/src/.warnyin/workflow/api-doc.md +93 -93
  55. package/src/.warnyin/workflow/codemap.md +91 -91
  56. package/src/.warnyin/workflow/contexts/README.md +51 -51
  57. package/src/.warnyin/workflow/contexts/build.md +25 -25
  58. package/src/.warnyin/workflow/contexts/research.md +25 -25
  59. package/src/.warnyin/workflow/contexts/review.md +25 -25
  60. package/src/.warnyin/workflow/explore.md +32 -32
  61. package/src/.warnyin/workflow/init.md +136 -136
  62. package/src/.warnyin/workflow/next.md +48 -48
  63. package/src/.warnyin/workflow/roles/README.md +47 -47
  64. package/src/.warnyin/workflow/roles/ba.md +25 -25
  65. package/src/.warnyin/workflow/roles/developer.md +31 -31
  66. package/src/.warnyin/workflow/roles/infra.md +24 -24
  67. package/src/.warnyin/workflow/roles/po.md +28 -28
  68. package/src/.warnyin/workflow/roles/qa.md +35 -35
  69. package/src/.warnyin/workflow/roles/sa.md +28 -28
  70. package/src/.warnyin/workflow/roles/security.md +39 -39
  71. package/src/.warnyin/workflow/roles/tech-lead.md +28 -28
  72. package/src/.warnyin/workflow/scripts/build-wave.mjs +145 -143
  73. package/src/.warnyin/workflow/scripts/validate-topic.mjs +378 -378
  74. package/src/.warnyin/workflow/stages/build.md +98 -98
  75. package/src/.warnyin/workflow/stages/design.md +154 -138
  76. package/src/.warnyin/workflow/stages/discovery.md +256 -78
  77. package/src/.warnyin/workflow/stages/ship.md +94 -94
  78. package/src/.warnyin/workflow/stages/verify.md +82 -82
  79. package/src/.warnyin/workflow/triage.md +74 -74
  80. package/src/AGENTS.md +54 -54
  81. package/src/bin/cli.mjs +310 -310
@@ -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
+ }