@educa-corp/sdd-framework 0.4.0 → 0.5.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 (158) hide show
  1. package/bin/build.js +9 -0
  2. package/bin/index.js +115 -4
  3. package/bin/self-check.js +354 -0
  4. package/bin/trace-schema.json +1199 -0
  5. package/commands/debug.md +19 -12
  6. package/commands/define-product.md +19 -12
  7. package/commands/dev-gen-test.md +53 -19
  8. package/commands/dev-run-test.md +55 -20
  9. package/commands/dev-run-test.tmpl +2 -1
  10. package/commands/dev-smoke-test.md +19 -12
  11. package/commands/extend-prd.md +907 -0
  12. package/commands/extend-prd.tmpl +270 -0
  13. package/commands/fix-bug.md +101 -15
  14. package/commands/fix-bug.tmpl +29 -3
  15. package/commands/generate-architecture.md +19 -12
  16. package/commands/generate-bdd.md +174 -48
  17. package/commands/generate-bdd.tmpl +107 -18
  18. package/commands/generate-code.md +122 -29
  19. package/commands/generate-code.tmpl +69 -10
  20. package/commands/generate-design-spec.md +19 -12
  21. package/commands/generate-prd.md +44 -12
  22. package/commands/generate-prd.tmpl +25 -0
  23. package/commands/generate-spec-manifest.md +19 -12
  24. package/commands/generate-tech-docs.md +22 -15
  25. package/commands/generate-tech-docs.tmpl +2 -2
  26. package/commands/learn.md +19 -12
  27. package/commands/map-testids.md +19 -12
  28. package/commands/propose-scenario.md +91 -15
  29. package/commands/propose-scenario.tmpl +72 -3
  30. package/commands/qc-analyze.md +19 -12
  31. package/commands/qc-design-test.md +20 -12
  32. package/commands/qc-design-test.tmpl +1 -0
  33. package/commands/qc-plan.md +19 -12
  34. package/commands/qc-report.md +19 -12
  35. package/commands/qc-review.md +19 -12
  36. package/commands/qc-run-test.md +88 -22
  37. package/commands/qc-run-test.tmpl +35 -3
  38. package/commands/refine-prd.md +19 -12
  39. package/commands/report-bug.md +19 -12
  40. package/commands/review-code.md +60 -14
  41. package/commands/review-code.tmpl +41 -2
  42. package/commands/review-context.md +62 -16
  43. package/commands/review-context.tmpl +43 -4
  44. package/commands/review-tech-docs.md +50 -14
  45. package/commands/review-tech-docs.tmpl +31 -2
  46. package/commands/setup-ai-first.md +26 -16
  47. package/commands/setup-ai-first.tmpl +7 -4
  48. package/commands/sync.md +43 -18
  49. package/commands/sync.tmpl +37 -14
  50. package/commands/update-framework.md +43 -4
  51. package/commands/update-framework.tmpl +37 -0
  52. package/commands/validate-traces.md +481 -49
  53. package/commands/validate-traces.tmpl +462 -37
  54. package/core/FRAMEWORK_VERSION +1 -1
  55. package/core/README.md +56 -0
  56. package/core/commands/debug.md +19 -12
  57. package/core/commands/define-product.md +19 -12
  58. package/core/commands/dev-gen-test.md +53 -19
  59. package/core/commands/dev-run-test.md +55 -20
  60. package/core/commands/dev-smoke-test.md +19 -12
  61. package/core/commands/extend-prd.md +907 -0
  62. package/core/commands/fix-bug.md +101 -15
  63. package/core/commands/generate-architecture.md +19 -12
  64. package/core/commands/generate-bdd.md +174 -48
  65. package/core/commands/generate-code.md +122 -29
  66. package/core/commands/generate-design-spec.md +19 -12
  67. package/core/commands/generate-prd.md +44 -12
  68. package/core/commands/generate-spec-manifest.md +19 -12
  69. package/core/commands/generate-tech-docs.md +22 -15
  70. package/core/commands/learn.md +19 -12
  71. package/core/commands/map-testids.md +19 -12
  72. package/core/commands/propose-scenario.md +91 -15
  73. package/core/commands/qc-analyze.md +19 -12
  74. package/core/commands/qc-design-test.md +20 -12
  75. package/core/commands/qc-plan.md +19 -12
  76. package/core/commands/qc-report.md +19 -12
  77. package/core/commands/qc-review.md +19 -12
  78. package/core/commands/qc-run-test.md +88 -22
  79. package/core/commands/refine-prd.md +19 -12
  80. package/core/commands/report-bug.md +19 -12
  81. package/core/commands/review-code.md +60 -14
  82. package/core/commands/review-context.md +62 -16
  83. package/core/commands/review-tech-docs.md +50 -14
  84. package/core/commands/setup-ai-first.md +26 -16
  85. package/core/commands/sync.md +43 -18
  86. package/core/commands/update-framework.md +43 -4
  87. package/core/commands/validate-traces.md +481 -49
  88. package/core/modules/android-compose/stack-profile.yaml +1 -1
  89. package/core/modules/flutter/stack-profile.yaml +1 -1
  90. package/core/modules/ios-swiftui/stack-profile.yaml +1 -1
  91. package/core/modules/java-spring/stack-profile.yaml +1 -1
  92. package/core/modules/nextjs/stack-profile.yaml +1 -1
  93. package/core/modules/nuxt/stack-profile.yaml +1 -1
  94. package/core/modules/phaser-game/stack-profile.yaml +1 -1
  95. package/core/modules/php-laravel/stack-profile.yaml +1 -1
  96. package/core/modules/qc-playwright/stack-profile.yaml +1 -1
  97. package/core/modules/react/stack-profile.yaml +1 -1
  98. package/core/modules/react-native/stack-profile.yaml +1 -1
  99. package/core/modules/vue/stack-profile.yaml +1 -1
  100. package/core/rules/workflow.md +29 -0
  101. package/core/steps/gate.md +13 -8
  102. package/core/steps/report-footer.md +6 -4
  103. package/core/steps/trace-mirror.md +34 -7
  104. package/core/templates/README.md +47 -0
  105. package/core/templates/feature.template +14 -11
  106. package/core/templates/project-context.yaml +26 -14
  107. package/core/templates/tech-design.template.md +1 -1
  108. package/docs/01-getting-started/installation.md +18 -1
  109. package/docs/01-getting-started/what-is-sdd.md +4 -2
  110. package/docs/02-concepts/architecture.md +27 -3
  111. package/docs/02-concepts/pipeline-steps/02-specification.md +39 -3
  112. package/docs/02-concepts/pipeline-steps/04-bdd.md +24 -2
  113. package/docs/02-concepts/pipeline-steps/05-tech-docs.md +18 -1
  114. package/docs/02-concepts/pipeline-steps/06-code.md +35 -4
  115. package/docs/02-concepts/pipeline-steps/09-validate-traces.md +137 -12
  116. package/docs/02-concepts/pipeline-steps/10-feedback-loop.md +59 -3
  117. package/docs/02-concepts/roles-and-hitl.md +1 -1
  118. package/docs/02-concepts/traceability.md +126 -94
  119. package/docs/03-guides/developer.md +20 -4
  120. package/docs/03-guides/product-owner.md +72 -68
  121. package/docs/03-guides/tester-qa.md +81 -70
  122. package/docs/04-reference/commands.md +134 -105
  123. package/docs/04-reference/configuration.md +146 -94
  124. package/docs/04-reference/trace-schema.md +145 -37
  125. package/docs/explain/02-generate-prd.md +80 -78
  126. package/docs/explain/02b-extend-prd.md +125 -0
  127. package/docs/explain/03-refine-prd.md +86 -86
  128. package/docs/explain/04-review-context.md +18 -1
  129. package/docs/explain/06-generate-bdd.md +23 -0
  130. package/docs/explain/08-review-tech-docs.md +20 -5
  131. package/docs/explain/10-review-code.md +36 -2
  132. package/docs/explain/19-qc-run-test.md +87 -67
  133. package/docs/explain/21-validate-traces.md +74 -68
  134. package/docs/explain/23-fix-bug.md +19 -3
  135. package/docs/explain/26-propose-scenario.md +70 -63
  136. package/docs/explain/README.md +135 -134
  137. package/modules/android-compose/stack-profile.yaml +1 -1
  138. package/modules/flutter/stack-profile.yaml +1 -1
  139. package/modules/ios-swiftui/stack-profile.yaml +1 -1
  140. package/modules/java-spring/stack-profile.yaml +1 -1
  141. package/modules/nextjs/stack-profile.yaml +1 -1
  142. package/modules/nuxt/stack-profile.yaml +1 -1
  143. package/modules/phaser-game/stack-profile.yaml +1 -1
  144. package/modules/php-laravel/stack-profile.yaml +1 -1
  145. package/modules/qc-playwright/stack-profile.yaml +1 -1
  146. package/modules/react/stack-profile.yaml +1 -1
  147. package/modules/react-native/stack-profile.yaml +1 -1
  148. package/modules/vue/stack-profile.yaml +1 -1
  149. package/package.json +5 -4
  150. package/rules/workflow.md +29 -0
  151. package/scripts/migrate-bdd-platform.js +286 -0
  152. package/steps/gate.md +13 -8
  153. package/steps/report-footer.md +6 -4
  154. package/steps/trace-mirror.md +34 -7
  155. package/templates/README.md +47 -0
  156. package/templates/feature.template +14 -11
  157. package/templates/project-context.yaml +26 -14
  158. package/templates/tech-design.template.md +1 -1
package/bin/build.js CHANGED
@@ -215,6 +215,15 @@ for (const { src, dest, filter } of coreMappings) {
215
215
  fs.writeFileSync(path.join(CORE_DIR, 'FRAMEWORK_VERSION'), VERSION + '\n', 'utf8');
216
216
  console.log(` ✅ core/FRAMEWORK_VERSION (v${VERSION})`);
217
217
 
218
+ // .agent/README.md — which paths under .agent/ a project may edit, and which the
219
+ // upgrade overwrites (see GAPS.md G24). Source lives at repo root so it does not
220
+ // land inside .agent/templates/.
221
+ const agentReadmeSrc = path.join(ROOT, 'AGENT_README.md');
222
+ if (fs.existsSync(agentReadmeSrc)) {
223
+ fs.copyFileSync(agentReadmeSrc, path.join(CORE_DIR, 'README.md'));
224
+ console.log(' ✅ core/README.md (← AGENT_README.md)');
225
+ }
226
+
218
227
  // ── 4. Sync local dogfood .agent/ (dev repo only) ────────────────────────────
219
228
  //
220
229
  // In THIS framework repo, .agent/ is a pure mirror of core/ used to dogfood the
package/bin/index.js CHANGED
@@ -9,8 +9,15 @@ const ROOT = path.join(__dirname, '..');
9
9
  const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'));
10
10
  const VERSION = pkg.version;
11
11
 
12
+ // Records the hash of exactly what the last install wrote into .agent/, so the next
13
+ // install can tell a user-edited file from a framework-changed one (see G24 below).
14
+ // Declared up here, not next to installCore(): that function runs before the bottom
15
+ // of this file is evaluated, and `const` does not hoist.
16
+ const MANIFEST_NAME = '.install-manifest.json';
17
+
12
18
  const args = process.argv.slice(2);
13
19
  const isMigrateSpecs = args.includes('--migrate-specs');
20
+ const isMigrateBddPf = args.includes('--migrate-bdd-platform');
14
21
  const isRenamePrd = args.includes('--rename-prd-files');
15
22
  const isInit = args.includes('--init');
16
23
  const isProject = args.includes('--project');
@@ -85,6 +92,13 @@ if (showHelp) {
85
92
  console.log(' spec layout (specs/prd, specs/bdd, ...) to the feature-package');
86
93
  console.log(' layout (specs/{domain}/{prd-slug}/{ {TICKET-ID}-{prd-slug}.md,bdd/,...}).');
87
94
  console.log(' DRY-RUN by default; add --apply to execute. Uses git mv when possible.');
95
+ console.log(' --migrate-bdd-platform Move flat BDD files into the per-platform layout:');
96
+ console.log(' specs/{domain}/{slug}/bdd/*.feature → bdd/{platform}/*.feature.');
97
+ console.log(' For projects whose umbrella-mode runs produced the flat layout');
98
+ console.log(' (pre-v0.4.1). Resolves platform from @trace.platform, else @trace.module,');
99
+ console.log(' else step vocabulary; inserts @trace.platform when missing. Reports');
100
+ console.log(' filename COLLISION and LOST SPEC instead of guessing.');
101
+ console.log(' DRY-RUN by default; add --apply to execute. Uses git mv when possible.');
88
102
  console.log(' --rename-prd-files Rename feature-package PRD files from the old fixed name prd.md to the');
89
103
  console.log(' {TICKET-ID}-{prd-slug}.md convention (e.g. SEG01-segment-scoring-service.md).');
90
104
  console.log(' For projects already on the feature-package layout. DRY-RUN by default;');
@@ -122,6 +136,13 @@ if (isMigrateSpecs) {
122
136
  return; // migrate-specs.js calls process.exit itself
123
137
  }
124
138
 
139
+ // ── --migrate-bdd-platform: flat bdd/*.feature → bdd/{platform}/*.feature ──
140
+ // Also operates on the user's specs, so it runs BEFORE the build step. See GAPS.md G3.
141
+ if (isMigrateBddPf) {
142
+ require(path.join(__dirname, '..', 'scripts', 'migrate-bdd-platform.js'));
143
+ return; // migrate-bdd-platform.js calls process.exit itself
144
+ }
145
+
125
146
  // ── --rename-prd-files: rename feature-package prd.md → {TICKET-ID}-{slug}.md ──
126
147
  if (isRenamePrd) {
127
148
  require(path.join(__dirname, '..', 'scripts', 'rename-prd-files.js'));
@@ -165,18 +186,20 @@ if (isInit) {
165
186
  if (moduleName) console.log(`Module : ${moduleName}`);
166
187
  console.log('');
167
188
 
168
- // 1. Copy core/** → .agent/**
189
+ // 1. Copy core/** → .agent/** (backs up + reports locally edited files — G24)
169
190
  console.log('Installing framework files to .agent/ ...');
170
191
  console.log('');
171
- copyDirRecursive(coreDir, agentDir);
192
+ const agentInstall = installCore(coreDir, agentDir, frameworkVersion);
172
193
 
173
194
  const agentSubdirs = fs.readdirSync(agentDir, { withFileTypes: true })
174
195
  .filter(e => e.isDirectory())
196
+ .filter(e => !e.name.startsWith('.overwritten-'))
175
197
  .map(e => e.name);
176
198
  for (const sub of agentSubdirs) {
177
199
  console.log(` ✅ .agent/${sub}/`);
178
200
  }
179
201
  console.log(` ✅ .agent/FRAMEWORK_VERSION (v${frameworkVersion})`);
202
+ reportOverwritten(agentInstall, '.agent/');
180
203
 
181
204
  // 2. Create .claude/commands/ shortcuts that delegate to .agent/commands/
182
205
  console.log('');
@@ -340,9 +363,10 @@ if (isInit) {
340
363
  // Create service dir if needed
341
364
  fs.mkdirSync(svcDir, { recursive: true });
342
365
 
343
- // Copy core → service/.agent/
344
- copyDirRecursive(coreDir, svcAgentDir);
366
+ // Copy core → service/.agent/ (backs up + reports locally edited files — G24)
367
+ const svcInstall = installCore(coreDir, svcAgentDir, frameworkVersion);
345
368
  console.log(` ✅ ${svc.name}/.agent/`);
369
+ reportOverwritten(svcInstall, `${svc.name}/.agent/`);
346
370
 
347
371
  // Install module
348
372
  if (svc.module) {
@@ -595,6 +619,93 @@ function copyDirRecursive(src, dest) {
595
619
  }
596
620
  }
597
621
 
622
+ // ── Overwrite-aware install of core/ → .agent/ ───────────────────────────────
623
+ // .agent/ is a GENERATED mirror: --init (and therefore /update-framework) copies
624
+ // core/ over it unconditionally. Anything a project edited in there is destroyed
625
+ // silently. This wrapper makes that visible: it backs up the edited files and
626
+ // reports them. It does NOT change the overwrite behaviour. See GAPS.md G24.
627
+ //
628
+ // The hard part is telling "the USER edited this file" apart from "the FRAMEWORK
629
+ // changed this file between versions" — a plain core-vs-.agent content diff flags
630
+ // both, so every upgrade would report dozens of files that nobody touched.
631
+ // Hence a manifest: each install records the hash of exactly what it wrote, so
632
+ // the next install can tell a pristine file (hash matches) from an edited one.
633
+ function sha1(file) {
634
+ return require('crypto').createHash('sha1').update(fs.readFileSync(file)).digest('hex');
635
+ }
636
+
637
+ function readManifest(agentDir) {
638
+ try {
639
+ const raw = JSON.parse(fs.readFileSync(path.join(agentDir, MANIFEST_NAME), 'utf8'));
640
+ return raw && raw.files ? raw : null;
641
+ } catch { return null; }
642
+ }
643
+
644
+ /**
645
+ * Copy core/ → .agent/, backing up any file the project edited since the last install.
646
+ * @returns {{ edited: string[], backupDir: string|null, firstRun: boolean }}
647
+ * edited — repo-relative paths (posix) that were overwritten AND had local edits
648
+ * firstRun — no manifest existed, so edits could not be detected this time
649
+ */
650
+ function installCore(coreDir, agentDir, version) {
651
+ const manifest = readManifest(agentDir);
652
+ const firstRun = !manifest && fs.existsSync(path.join(agentDir, 'commands'));
653
+ const stamp = new Date().toISOString().slice(0, 10).replace(/-/g, '');
654
+ const backupDir = path.join(agentDir, `.overwritten-${version}-${stamp}`);
655
+ const edited = [];
656
+ const written = {};
657
+
658
+ const walk = (src, dst) => {
659
+ fs.mkdirSync(dst, { recursive: true });
660
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
661
+ const s = path.join(src, entry.name);
662
+ const d = path.join(dst, entry.name);
663
+ if (entry.isDirectory()) { walk(s, d); continue; }
664
+
665
+ const rel = path.relative(agentDir, d).replace(/\\/g, '/');
666
+ // Locally edited == exists, we have a record of what we last wrote, and the
667
+ // file no longer matches that record. Framework-only changes never match here.
668
+ if (manifest && fs.existsSync(d) && manifest.files[rel] && sha1(d) !== manifest.files[rel]) {
669
+ const bak = path.join(backupDir, rel);
670
+ fs.mkdirSync(path.dirname(bak), { recursive: true });
671
+ fs.copyFileSync(d, bak);
672
+ edited.push(rel);
673
+ }
674
+ fs.copyFileSync(s, d);
675
+ written[rel] = sha1(d);
676
+ }
677
+ };
678
+ walk(coreDir, agentDir);
679
+
680
+ fs.writeFileSync(
681
+ path.join(agentDir, MANIFEST_NAME),
682
+ JSON.stringify({ version, generated_at: new Date().toISOString(), files: written }, null, 2),
683
+ 'utf8'
684
+ );
685
+
686
+ return { edited, backupDir: edited.length ? backupDir : null, firstRun };
687
+ }
688
+
689
+ function reportOverwritten(result, agentLabel) {
690
+ if (result.firstRun) {
691
+ console.log('');
692
+ console.log(` ℹ️ ${agentLabel}${MANIFEST_NAME} created. Từ lần nâng cấp SAU, mọi file bạn`);
693
+ console.log(` sửa trong ${agentLabel} mà bị ghi đè sẽ được backup + liệt kê.`);
694
+ return;
695
+ }
696
+ if (!result.edited.length) return;
697
+
698
+ const rel = path.relative(process.cwd(), result.backupDir).replace(/\\/g, '/');
699
+ console.log('');
700
+ console.log(` ⚠️ ${result.edited.length} file trong ${agentLabel} đã bị bạn sửa và VỪA BỊ GHI ĐÈ:`);
701
+ for (const f of result.edited.slice(0, 12)) console.log(` ${agentLabel}${f}`);
702
+ if (result.edited.length > 12) console.log(` … và ${result.edited.length - 12} file nữa`);
703
+ console.log(` Bản cũ đã lưu ở: ${rel}/`);
704
+ console.log(` ${agentLabel} là bản mirror SINH RA — sửa trực tiếp ở đó không bền.`);
705
+ console.log(` Muốn giữ thay đổi: chuyển nó vào repo framework rồi phát hành,`);
706
+ console.log(` hoặc áp lại thủ công sau khi xem diff. Xoá thư mục backup khi xong.`);
707
+ }
708
+
598
709
  function installDataGuardHook() {
599
710
  const projectRoot = process.cwd();
600
711
  const hooksDir = path.join(projectRoot, '.claude', 'hooks');
@@ -0,0 +1,354 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * self-check — đối chiếu bin/trace-schema.json với các file lệnh/step/template thật.
4
+ *
5
+ * Vì sao tồn tại: 19/23 GAP trong GAPS.md là CÙNG một lỗi — contract (field, path,
6
+ * giá trị enum) được phát biểu lại bằng prose ở nhiều lệnh rồi lệch nhau qua từng
7
+ * lần sửa, và KHÔNG có gì phát hiện. Ca thuần khiết nhất là @trace.sc_version:
8
+ * 3 consumer, 0 producer, sống qua nhiều version mà không lệnh nào, không cổng
9
+ * nào, không test nào bắt được. Đây là cái bắt lớp lỗi đó một cách mechanical.
10
+ *
11
+ * 7 rule — áp cho tags · tsv_columns · aux_tables (_seams.tsv) · queues (feedback/*):
12
+ * R1 field/hàng đợi có read_by nhưng KHÔNG có producer → ERROR (bắt được G1; queue: G26)
13
+ * R2 field có producer nhưng KHÔNG ai đọc → WARN (bắt được G15, fe_phase, test_type)
14
+ * R3 actor được khai là producer/consumer mà file của
15
+ * nó KHÔNG nhắc tên field/cột/path_key → ERROR (bắt được G5, G12, G13, G31, G32)
16
+ * R4 {paths.X} dùng trong lệnh mà X không có trong
17
+ * project-context.yaml, và ngược lại key chết → ERROR / WARN (bắt được G17)
18
+ * R5 pattern bị cấm xuất hiện trở lại → ERROR (bắt được G3, G8, G10, G18, G21, G11)
19
+ * R6 giá trị enum khai trong vocabulary mà không actor
20
+ * nào dùng → WARN (bắt được G19: integration vs integrated)
21
+ * R7 audit flag thiếu counter {flag}_count trong CẢ
22
+ * Step 7 LẪN khối `summary` của validate-traces → ERROR (bắt được G33)
23
+ *
24
+ * PHẠM VI (G30): trước đó schema chỉ phủ 16/44 tag — R1/R3 mù với 28 cái còn lại, và ĐÓ LÀ
25
+ * cách G31 (@trace.service: 2 consumer khai, 0 nơi chứa) cùng G32 (design-spec: 0 cột/0 tag/
26
+ * 0 cờ) sống qua nhiều version với dấu ✅. Dấu ✅ chỉ có nghĩa "phần ĐÃ KHAI thì đúng".
27
+ * Giữ phạm vi đầy đủ: thêm tag/cột/bảng/hàng đợi mới thì khai NGAY, đừng để dồn.
28
+ *
29
+ * Chạy: node bin/self-check.js (exit 1 nếu có ERROR)
30
+ * node bin/self-check.js --warn-only (luôn exit 0)
31
+ * Được `npm run build` gọi sau khi assemble template.
32
+ */
33
+
34
+ const fs = require('fs');
35
+ const path = require('path');
36
+
37
+ const ROOT = path.join(__dirname, '..');
38
+ const WARN_ONLY = process.argv.includes('--warn-only');
39
+
40
+ const schema = JSON.parse(fs.readFileSync(path.join(__dirname, 'trace-schema.json'), 'utf8'));
41
+
42
+ const errors = [];
43
+ const warns = [];
44
+ const err = (rule, msg, hint) => errors.push({ rule, msg, hint });
45
+ const warn = (rule, msg, hint) => warns.push({ rule, msg, hint });
46
+
47
+ // ── nạp nội dung actor ────────────────────────────────────────────────────────
48
+ const actorText = {};
49
+ const missingActors = [];
50
+ for (const [name, rel] of Object.entries(schema.actors)) {
51
+ if (name.startsWith('$')) continue;
52
+ const abs = path.join(ROOT, rel);
53
+ if (!fs.existsSync(abs)) { missingActors.push(`${name} → ${rel}`); continue; }
54
+ actorText[name] = fs.readFileSync(abs, 'utf8');
55
+ }
56
+ if (missingActors.length) {
57
+ err('R0', `${missingActors.length} actor trong schema trỏ tới file không tồn tại`,
58
+ missingActors.join('\n '));
59
+ }
60
+
61
+ /** Mọi file .tmpl + step + template — dùng cho R4/R5/R6 (quét rộng). */
62
+ function allSourceFiles() {
63
+ const out = [];
64
+ const push = (dir, filter) => {
65
+ if (!fs.existsSync(dir)) return;
66
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
67
+ const p = path.join(dir, e.name);
68
+ if (e.isDirectory()) push(p, filter);
69
+ else if (filter(e.name)) out.push(p);
70
+ }
71
+ };
72
+ push(path.join(ROOT, 'commands'), f => f.endsWith('.tmpl'));
73
+ push(path.join(ROOT, 'steps'), f => f.endsWith('.md'));
74
+ push(path.join(ROOT, 'templates'), () => true);
75
+ push(path.join(ROOT, 'rules'), f => f.endsWith('.md'));
76
+ push(path.join(ROOT, 'modules'), f => f.endsWith('.yaml') || f.endsWith('.md'));
77
+ return out;
78
+ }
79
+ const sources = allSourceFiles().map(p => ({
80
+ rel: path.relative(ROOT, p).replace(/\\/g, '/'),
81
+ text: fs.readFileSync(p, 'utf8'),
82
+ }));
83
+ const allText = sources.map(s => s.text).join('\n');
84
+
85
+ /**
86
+ * Field `name` có được nhắc trong `text` như một TOKEN TRỌN VẸN không?
87
+ *
88
+ * Phải khớp theo ranh giới token, không phải substring. Hai bẫy thật:
89
+ * - `@trace.XXsc_version` CHỨA chuỗi `sc_version` → một phép so substring sẽ coi là
90
+ * "có nhắc" và R3 im lặng đúng lúc contract vừa bị gỡ producer. (Chính test tái
91
+ * tạo G1 đã lộ ra bug này trong bản đầu của checker.)
92
+ * - `dev_selftest_at` CHỨA `dev_selftest` → lệnh chỉ nhắc cột này sẽ được coi là
93
+ * đã implement cả cột kia.
94
+ */
95
+ function mentions(text, name) {
96
+ const esc = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
97
+ // Trước: không được là phần đuôi của một token dài hơn (chặn `XXsc_version`).
98
+ // Sau: không được là phần đầu của token dài hơn (chặn `dev_selftest` khớp `..._at`).
99
+ const before = name.startsWith('@') ? '' : '(?<![A-Za-z0-9_.])';
100
+ return new RegExp(`${before}${esc}(?![A-Za-z0-9_])`).test(text);
101
+ }
102
+
103
+ // ── R1 + R2 + R3 — producer / consumer / prose khớp ───────────────────────────
104
+ function checkField(kind, name, def) {
105
+ const producers = [...(def.written_by || []), ...(def.bumped_by || [])];
106
+ const consumers = def.read_by || [];
107
+
108
+ // R1 — có người đọc mà không ai ghi
109
+ if (consumers.length && !producers.length) {
110
+ err('R1', `${kind} \`${name}\`: có ${consumers.length} consumer nhưng KHÔNG có producer`,
111
+ `Ai ghi/bump nó? Thêm written_by hoặc bumped_by vào schema, và implement ở lệnh đó.\n`
112
+ + ` Đây chính là hình dạng của G1 (@trace.sc_version) — DRIFT chết mà không ai báo.`);
113
+ }
114
+
115
+ // R2 — có người ghi mà không ai đọc
116
+ if (producers.length && !consumers.length && !def.allow_no_reader) {
117
+ warn('R2', `${kind} \`${name}\`: có producer nhưng KHÔNG ai đọc — field chết?`,
118
+ `Hoặc thêm consumer, hoặc bỏ field, hoặc khai allow_no_reader kèm lý do.`);
119
+ }
120
+
121
+ // R3 — actor được khai nhưng file của nó không nhắc field
122
+ for (const actor of new Set([...producers, ...consumers])) {
123
+ const text = actorText[actor];
124
+ if (text === undefined) continue; // đã báo ở R0
125
+ if (!mentions(text, name)) {
126
+ err('R3', `${kind} \`${name}\`: schema khai \`${actor}\` là producer/consumer nhưng ${schema.actors[actor]} KHÔNG nhắc tới nó`,
127
+ `Hoặc lệnh đó thiếu implement, hoặc schema khai sai. Prose lệch schema chính là\n`
128
+ + ` cách 19/23 GAP phát sinh.`);
129
+ }
130
+ }
131
+ }
132
+
133
+ for (const t of schema.tags) checkField('tag', t.name, t);
134
+ for (const c of schema.tsv_columns) checkField('cột TSV', c.name, c);
135
+
136
+ // Bảng phụ (_seams.tsv) — nguồn của 2 cờ 🔴 chặn PR nhưng nằm ngoài SoT tới G30.
137
+ // Chạy CÙNG checkField, không cần rule mới: mỗi cột là một field có producer/consumer.
138
+ for (const t of schema.aux_tables || []) {
139
+ checkField(`bảng phụ \`${t.name}\``, t.name, t); // file phải được cả 2 đầu nhắc tên
140
+ // `column_actors` = actor thao tác ở MỨC CỘT (ghi/parse từng field). Actor chỉ dùng bảng ở
141
+ // MỨC BẢNG (vd review-code chỉ kiểm "có dòng PENDING không") KHÔNG phải nhắc đủ tên cột —
142
+ // đòi thế là báo oan. Không khai `column_actors` → mặc định mọi actor.
143
+ const colActors = t.column_actors || [...(t.written_by || []), ...(t.read_by || [])];
144
+ for (const col of t.columns || []) {
145
+ for (const actor of new Set(colActors)) {
146
+ const text = actorText[actor];
147
+ if (text === undefined) continue;
148
+ if (!mentions(text, col)) {
149
+ err('R3', `cột \`${t.name}:${col}\`: schema khai \`${actor}\` đụng bảng này nhưng ${schema.actors[actor]} KHÔNG nhắc cột \`${col}\``,
150
+ `Hoặc lệnh thiếu implement, hoặc cột đã đổi tên. Bảng này là NGUỒN của SEAM_UNWIRED/STUB_UNRESOLVED —\n`
151
+ + ` đổi tên cột mà không ai bắt được thì hai cờ 🔴 chặn PR sẽ mù trong im lặng.`);
152
+ }
153
+ }
154
+ }
155
+ }
156
+
157
+ // Hàng đợi feedback — bắt ca "hàng đợi có producer nhưng KHÔNG ai drain" (hình dạng của G26).
158
+ //
159
+ // KHÔNG dùng chung mentions() với tag/cột: path key luôn xuất hiện dưới dạng `{paths.X}`,
160
+ // tức LUÔN có dấu `.` đứng trước — mà mentions() cố ý chặn ký tự đó (để `@trace.XXsc_version`
161
+ // không bị coi là đã nhắc `sc_version`). Dùng thẳng thì 100% queue báo lỗi oan.
162
+ // Ở đây chỉ cần chặn hậu tố: `bug_reports_dir` không được khớp `bug_reports_dirX`.
163
+ const mentionsKey = (text, key) =>
164
+ new RegExp(`\\b${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![A-Za-z0-9_])`).test(text);
165
+
166
+ for (const q of schema.queues || []) {
167
+ const producers = q.written_by || [];
168
+ const consumers = q.read_by || [];
169
+ const label = `hàng đợi \`${q.name}\``;
170
+
171
+ if (producers.length && !consumers.length) {
172
+ err('R1', `${label}: có producer nhưng KHÔNG ai đọc — file vào đó rồi nằm lại vĩnh viễn`,
173
+ `Đây là hình dạng của G26 (prd-change-requests: có producer, có storage, có commit,\n`
174
+ + ` có mặt trong /sync — nhưng 0 consumer, và không gì báo).`);
175
+ }
176
+ if (consumers.length && !producers.length) {
177
+ err('R1', `${label}: có consumer nhưng KHÔNG ai ghi vào — hàng đợi chết?`);
178
+ }
179
+ for (const actor of new Set([...producers, ...consumers])) {
180
+ const text = actorText[actor];
181
+ if (text === undefined) continue;
182
+ if (!mentionsKey(text, q.path_key)) {
183
+ err('R3', `${label}: schema khai \`${actor}\` đụng hàng đợi này nhưng ${schema.actors[actor]} KHÔNG nhắc \`${q.path_key}\``,
184
+ `Hoặc lệnh thiếu implement, hoặc schema khai sai.`);
185
+ }
186
+ }
187
+ }
188
+
189
+ // ── R4 — {paths.X} vs project-context.yaml ────────────────────────────────────
190
+ const cfgPath = path.join(ROOT, schema.path_keys.config_file);
191
+ if (!fs.existsSync(cfgPath)) {
192
+ err('R4', `Không tìm thấy ${schema.path_keys.config_file}`, 'path_keys.config_file trong schema trỏ sai?');
193
+ } else {
194
+ const cfg = fs.readFileSync(cfgPath, 'utf8');
195
+ // key khai: dòng thụt 2 space dưới `paths:` dạng ` key: "value"` (bỏ comment)
196
+ const declared = new Set();
197
+ let inPaths = false;
198
+ for (const line of cfg.split(/\r?\n/)) {
199
+ if (/^paths:\s*$/.test(line)) { inPaths = true; continue; }
200
+ if (inPaths && /^\S/.test(line)) break; // sang section khác
201
+ const m = inPaths && line.match(/^ {2}([a-z_][a-z0-9_]*):/);
202
+ if (m) declared.add(m[1]);
203
+ }
204
+
205
+ // CHỈ quét commands/ + steps/ — đó là những file thực sự phân giải {paths.X} lúc chạy.
206
+ // templates/*.md là prose (vd templates/README.md giải thích các key ĐÃ BỎ) — tính nó
207
+ // vào "used" sẽ báo oan chính đoạn văn nói rằng key đó không còn nữa.
208
+ const runtimeText = sources
209
+ .filter(s => s.rel.startsWith('commands/') || s.rel.startsWith('steps/'))
210
+ .map(s => s.text).join('\n');
211
+ const used = new Set();
212
+ for (const m of runtimeText.matchAll(/\{paths\.([a-z_][a-z0-9_]*)\}/g)) used.add(m[1]);
213
+ // context-loader tham chiếu vài key không qua ngoặc nhọn (vd `paths.lessons_file`)
214
+ for (const m of runtimeText.matchAll(/\bpaths\.([a-z_][a-z0-9_]*)\b/g)) used.add(m[1]);
215
+
216
+ const undeclared = [...used].filter(k => !declared.has(k));
217
+ if (undeclared.length) {
218
+ err('R4', `${undeclared.length} key \`{paths.X}\` được dùng nhưng KHÔNG khai trong project-context.yaml`,
219
+ undeclared.join(', '));
220
+ }
221
+
222
+ const ignore = new Set(schema.path_keys.ignore_unused || []);
223
+ const dead = [...declared].filter(k => !used.has(k) && !ignore.has(k));
224
+ if (dead.length) {
225
+ warn('R4', `${dead.length} key khai trong project-context.yaml mà KHÔNG lệnh nào dùng — key chết?`,
226
+ `${dead.join(', ')}\n (Đây là hình dạng của G17. Nếu cố ý giữ, thêm vào path_keys.ignore_unused.)`);
227
+ }
228
+ }
229
+
230
+ // ── R5 — pattern bị cấm ───────────────────────────────────────────────────────
231
+ for (const fp of schema.forbidden_patterns) {
232
+ // `except` = file được phép chứa pattern vì nó đang TRÍCH DẪN pattern trong một cảnh
233
+ // báo ("đừng glob X"). Mỗi entry phải kèm lý do trong schema — không phải ignore mù.
234
+ const except = new Set(Object.keys(fp.except || {}));
235
+ const hits = sources.filter(s => s.text.includes(fp.pattern) && !except.has(s.rel));
236
+ if (hits.length) {
237
+ err('R5', `Pattern bị cấm quay lại: \`${fp.pattern}\` (${fp.gap})`,
238
+ `${fp.reason}\n Thấy ở: ${hits.map(h => h.rel).join(', ')}`);
239
+ }
240
+ // Cảnh báo nếu một except trở nên vô nghĩa — cảnh báo đã bị xoá thì except cũng nên bỏ
241
+ for (const f of except) {
242
+ const src = sources.find(s => s.rel === f);
243
+ if (!src || !src.text.includes(fp.pattern)) {
244
+ warn('R5', `\`${fp.pattern}\`: except \`${f}\` không còn chứa pattern — bỏ except này khỏi schema`);
245
+ }
246
+ }
247
+ }
248
+
249
+ // ── R6 — giá trị enum chết ────────────────────────────────────────────────────
250
+ // GIỚI HẠN ĐÃ BIẾT của R6: nó bắt "giá trị khai mà KHÔNG xuất hiện ở đâu" — tức là ca
251
+ // một giá trị bị đổi tên/xoá khỏi toàn bộ codebase. Nó KHÔNG bắt được typo ở MỘT chỗ
252
+ // khi giá trị đúng vẫn còn ở chỗ khác (vd `fe_phase: integration` lọt vào một lệnh trong
253
+ // khi các lệnh còn lại vẫn ghi `integrated`). Cho lớp đó, dùng forbidden_patterns — chính
254
+ // xác hơn và không có false positive.
255
+ // Khớp theo ranh giới token: `fe_integrated` KHÔNG được coi là đã dùng `integrated`.
256
+ for (const [vocab, def] of Object.entries(schema.vocabularies)) {
257
+ if (vocab.startsWith('$')) continue;
258
+ const unused = (def.values || []).filter(v => !mentions(allText, v));
259
+ if (unused.length) {
260
+ warn('R6', `vocabulary \`${vocab}\`: ${unused.length} giá trị không xuất hiện ở đâu — sai chính tả hay giá trị chết?`,
261
+ `${unused.join(', ')}\n (G19 là ca này: doc ghi fe_phase "integration" còn lệnh ghi "integrated".)`);
262
+ }
263
+ }
264
+
265
+ // ── R7 — mỗi audit flag phải có counter trong summary ─────────────────────────
266
+ // Vì sao tồn tại (G33): PRD_DRIFT / TECHDOC_DRIFT / FE_TECHDOC_DRIFT được TÍNH ở Step 4-5
267
+ // và CÓ mảng chi tiết trong `issues`, nhưng KHÔNG có counter trong `summary`. Dashboard đọc
268
+ // `summary` → ba cờ đó vô hình ở tầng tổng hợp. Bất đối xứng 7/10 còn tệ hơn thiếu cả 10:
269
+ // người viết dashboard thấy có counter cho các cờ khác nên tưởng `summary` là đủ.
270
+ //
271
+ // CỐ Ý KHÔNG CÓ ESCAPE HATCH (không `allow_no_counter`). Một luật có cửa thoát dùng được
272
+ // ngay là luật yếu — đó chính là cách G30 xảy ra (schema phủ 16/43 tag rồi dừng). Cờ nào
273
+ // "chỉ để nhắc, chưa phải lỗi" (SEAM_PENDING/STUB_PENDING) thì counter của nó vẫn có giá
274
+ // trị: PM đọc để xếp thứ tự gen UC. Thêm 2 dòng rẻ hơn nhiều so với một ngoại lệ vĩnh viễn.
275
+ // SIẾT (G30 lô 8): bản đầu chỉ hỏi "tên counter có xuất hiện Ở ĐÂU ĐÓ trong file không".
276
+ // Test G27 lộ ra lỗ: gỡ counter khỏi ĐÚNG hai chỗ luật đòi (Step 7 + `summary`) mà R7 vẫn PASS,
277
+ // vì tên còn sót trong khối report terminal. Tức nó bắt được ca "quên hoàn toàn" (ca thật của
278
+ // G33) nhưng KHÔNG bắt ca "có ở report mà quên ở summary" — mà đó CHÍNH LÀ ca làm dashboard mù.
279
+ // Giờ kiểm từng khối riêng.
280
+ {
281
+ const flagVocab = schema.vocabularies.audit_flags;
282
+ const vtName = 'validate-traces';
283
+ const vtText = actorText[vtName];
284
+ if (flagVocab && vtText !== undefined) {
285
+ /** Cắt lát văn bản giữa hai mốc; null nếu không tìm thấy mốc (đừng im lặng bỏ qua). */
286
+ const slice = (from, to) => {
287
+ const a = vtText.indexOf(from);
288
+ if (a < 0) return null;
289
+ const b = vtText.indexOf(to, a + from.length);
290
+ return vtText.slice(a, b < 0 ? undefined : b);
291
+ };
292
+ const zones = [
293
+ { label: 'Step 7 (aggregate)', text: slice('### Step 7 —', '### Step 7b') },
294
+ { label: '`summary` của trace-report.json', text: slice('"summary": {', '"prds"') },
295
+ ];
296
+
297
+ for (const z of zones) {
298
+ if (z.text === null) {
299
+ warn('R7', `Không định vị được khối "${z.label}" trong ${schema.actors[vtName]} — R7 bỏ qua khối này`,
300
+ 'Mốc cắt đã đổi? Cập nhật `slice()` trong self-check.js, đừng để R7 mù trong im lặng.');
301
+ }
302
+ }
303
+
304
+ const missing = [];
305
+ for (const flag of flagVocab.values || []) {
306
+ const counter = `${flag.toLowerCase()}_count`;
307
+ const gone = zones.filter(z => z.text !== null && !mentions(z.text, counter));
308
+ if (gone.length) missing.push({ flag, counter, zones: gone.map(z => z.label) });
309
+ }
310
+ if (missing.length) {
311
+ err('R7', `${missing.length} audit flag thiếu counter trong ${schema.actors[vtName]}`,
312
+ missing.map(m => `${m.flag} → thiếu \`${m.counter}\` ở: ${m.zones.join(' + ')}`).join('\n ')
313
+ + `\n Counter PHẢI có ở CẢ HAI khối. Chỉ có ở report terminal là KHÔNG đủ —`
314
+ + `\n dashboard đọc \`summary\`, nên cờ sẽ vô hình ở tầng tổng hợp (G33).`);
315
+ }
316
+ } else if (!flagVocab) {
317
+ warn('R7', 'Không tìm thấy vocabulary `audit_flags` — R7 không chạy được');
318
+ }
319
+ }
320
+
321
+ // ── report ────────────────────────────────────────────────────────────────────
322
+ const tagCount = schema.tags.length;
323
+ const colCount = schema.tsv_columns.length;
324
+ const auxCount = (schema.aux_tables || []).length;
325
+ const queueCount = (schema.queues || []).length;
326
+ console.log('');
327
+ console.log('Self-check (bin/trace-schema.json) ...');
328
+ console.log('');
329
+
330
+ const show = (list, icon) => {
331
+ for (const e of list) {
332
+ console.log(` ${icon} [${e.rule}] ${e.msg}`);
333
+ if (e.hint) console.log(` ${e.hint}`);
334
+ }
335
+ };
336
+ show(errors, '❌');
337
+ show(warns, '⚠️ ');
338
+
339
+ if (!errors.length && !warns.length) {
340
+ console.log(` ✅ ${tagCount} tag + ${colCount} cột TSV + ${auxCount} bảng phụ + ${queueCount} hàng đợi + ${Object.keys(schema.vocabularies).length - 1} vocabulary — contract khớp mọi lệnh`);
341
+ }
342
+ console.log('');
343
+
344
+ if (errors.length && !WARN_ONLY) {
345
+ console.log(`❌ Self-check FAIL: ${errors.length} error, ${warns.length} warning.`);
346
+ console.log(' Sửa contract ở bin/trace-schema.json TRƯỚC, rồi sửa lệnh cho khớp.');
347
+ console.log(' (Bỏ qua tạm: node bin/self-check.js --warn-only)');
348
+ console.log('');
349
+ process.exit(1);
350
+ }
351
+ if (errors.length) {
352
+ console.log(`⚠️ ${errors.length} error bị bỏ qua do --warn-only.`);
353
+ console.log('');
354
+ }