@educa-corp/sdd-framework 0.3.0 → 0.4.2

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 (126) hide show
  1. package/bin/build.js +9 -0
  2. package/bin/index.js +115 -4
  3. package/bin/self-check.js +236 -0
  4. package/bin/trace-schema.json +692 -0
  5. package/commands/debug.md +82 -17
  6. package/commands/define-product.md +82 -17
  7. package/commands/dev-gen-test.md +82 -17
  8. package/commands/dev-run-test.md +84 -18
  9. package/commands/dev-run-test.tmpl +2 -1
  10. package/commands/dev-smoke-test.md +82 -17
  11. package/commands/fix-bug.md +137 -20
  12. package/commands/fix-bug.tmpl +29 -3
  13. package/commands/generate-architecture.md +82 -17
  14. package/commands/generate-bdd.md +187 -44
  15. package/commands/generate-bdd.tmpl +92 -17
  16. package/commands/generate-code.md +115 -20
  17. package/commands/generate-code.tmpl +33 -3
  18. package/commands/generate-design-spec.md +82 -17
  19. package/commands/generate-prd.md +82 -17
  20. package/commands/generate-spec-manifest.md +82 -17
  21. package/commands/generate-tech-docs.md +85 -20
  22. package/commands/generate-tech-docs.tmpl +2 -2
  23. package/commands/learn.md +82 -17
  24. package/commands/map-testids.md +82 -17
  25. package/commands/propose-scenario.md +102 -19
  26. package/commands/propose-scenario.tmpl +20 -2
  27. package/commands/qc-analyze.md +82 -17
  28. package/commands/qc-design-test.md +82 -17
  29. package/commands/qc-plan.md +82 -17
  30. package/commands/qc-report.md +82 -17
  31. package/commands/qc-review.md +82 -17
  32. package/commands/qc-run-test.md +104 -19
  33. package/commands/qc-run-test.tmpl +22 -2
  34. package/commands/refine-prd.md +82 -17
  35. package/commands/report-bug.md +82 -17
  36. package/commands/review-code.md +122 -19
  37. package/commands/review-code.tmpl +40 -2
  38. package/commands/review-context.md +124 -21
  39. package/commands/review-context.tmpl +42 -4
  40. package/commands/review-tech-docs.md +113 -19
  41. package/commands/review-tech-docs.tmpl +31 -2
  42. package/commands/setup-ai-first.md +35 -16
  43. package/commands/setup-ai-first.tmpl +19 -6
  44. package/commands/sync.md +15 -4
  45. package/commands/sync.tmpl +12 -2
  46. package/commands/update-framework.md +40 -2
  47. package/commands/update-framework.tmpl +37 -0
  48. package/commands/validate-traces.md +231 -25
  49. package/commands/validate-traces.tmpl +149 -8
  50. package/core/FRAMEWORK_VERSION +1 -1
  51. package/core/README.md +56 -0
  52. package/core/commands/debug.md +82 -17
  53. package/core/commands/define-product.md +82 -17
  54. package/core/commands/dev-gen-test.md +82 -17
  55. package/core/commands/dev-run-test.md +84 -18
  56. package/core/commands/dev-smoke-test.md +82 -17
  57. package/core/commands/fix-bug.md +137 -20
  58. package/core/commands/generate-architecture.md +82 -17
  59. package/core/commands/generate-bdd.md +187 -44
  60. package/core/commands/generate-code.md +115 -20
  61. package/core/commands/generate-design-spec.md +82 -17
  62. package/core/commands/generate-prd.md +82 -17
  63. package/core/commands/generate-spec-manifest.md +82 -17
  64. package/core/commands/generate-tech-docs.md +85 -20
  65. package/core/commands/learn.md +82 -17
  66. package/core/commands/map-testids.md +82 -17
  67. package/core/commands/propose-scenario.md +102 -19
  68. package/core/commands/qc-analyze.md +82 -17
  69. package/core/commands/qc-design-test.md +82 -17
  70. package/core/commands/qc-plan.md +82 -17
  71. package/core/commands/qc-report.md +82 -17
  72. package/core/commands/qc-review.md +82 -17
  73. package/core/commands/qc-run-test.md +104 -19
  74. package/core/commands/refine-prd.md +82 -17
  75. package/core/commands/report-bug.md +82 -17
  76. package/core/commands/review-code.md +122 -19
  77. package/core/commands/review-context.md +124 -21
  78. package/core/commands/review-tech-docs.md +113 -19
  79. package/core/commands/setup-ai-first.md +35 -16
  80. package/core/commands/sync.md +15 -4
  81. package/core/commands/update-framework.md +40 -2
  82. package/core/commands/validate-traces.md +231 -25
  83. package/core/modules/android-compose/stack-profile.yaml +1 -1
  84. package/core/modules/flutter/stack-profile.yaml +1 -1
  85. package/core/modules/ios-swiftui/stack-profile.yaml +1 -1
  86. package/core/modules/java-spring/stack-profile.yaml +1 -1
  87. package/core/modules/nextjs/stack-profile.yaml +1 -1
  88. package/core/modules/nuxt/stack-profile.yaml +1 -1
  89. package/core/modules/phaser-game/stack-profile.yaml +1 -1
  90. package/core/modules/php-laravel/stack-profile.yaml +1 -1
  91. package/core/modules/qc-playwright/stack-profile.yaml +1 -1
  92. package/core/modules/react/stack-profile.yaml +1 -1
  93. package/core/modules/react-native/stack-profile.yaml +1 -1
  94. package/core/modules/vue/stack-profile.yaml +1 -1
  95. package/core/rules/workflow.md +11 -0
  96. package/core/steps/context-loader.md +66 -7
  97. package/core/steps/gate.md +13 -8
  98. package/core/steps/report-footer.md +3 -2
  99. package/core/templates/README.md +47 -0
  100. package/core/templates/feature.template +13 -10
  101. package/core/templates/project-context.yaml +49 -17
  102. package/core/templates/tech-design.template.md +1 -1
  103. package/docs/02-concepts/traceability.md +29 -6
  104. package/docs/04-reference/trace-schema.md +128 -37
  105. package/modules/android-compose/stack-profile.yaml +1 -1
  106. package/modules/flutter/stack-profile.yaml +1 -1
  107. package/modules/ios-swiftui/stack-profile.yaml +1 -1
  108. package/modules/java-spring/stack-profile.yaml +1 -1
  109. package/modules/nextjs/stack-profile.yaml +1 -1
  110. package/modules/nuxt/stack-profile.yaml +1 -1
  111. package/modules/phaser-game/stack-profile.yaml +1 -1
  112. package/modules/php-laravel/stack-profile.yaml +1 -1
  113. package/modules/qc-playwright/stack-profile.yaml +1 -1
  114. package/modules/react/stack-profile.yaml +1 -1
  115. package/modules/react-native/stack-profile.yaml +1 -1
  116. package/modules/vue/stack-profile.yaml +1 -1
  117. package/package.json +50 -49
  118. package/rules/workflow.md +11 -0
  119. package/scripts/migrate-bdd-platform.js +286 -0
  120. package/steps/context-loader.md +66 -7
  121. package/steps/gate.md +13 -8
  122. package/steps/report-footer.md +3 -2
  123. package/templates/README.md +47 -0
  124. package/templates/feature.template +13 -10
  125. package/templates/project-context.yaml +49 -17
  126. 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,236 @@
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
+ * 6 rule:
12
+ * R1 field có read_by nhưng KHÔNG có producer → ERROR (bắt được G1)
13
+ * R2 field có producer nhưng KHÔNG ai đọc → WARN (bắt được G15, fe_phase)
14
+ * R3 actor được khai là producer/consumer mà file của
15
+ * nó KHÔNG nhắc tên field → ERROR (bắt được G5, G12, G13)
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)
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
+ *
22
+ * Chạy: node bin/self-check.js (exit 1 nếu có ERROR)
23
+ * node bin/self-check.js --warn-only (luôn exit 0)
24
+ * Được `npm run build` gọi sau khi assemble template.
25
+ */
26
+
27
+ const fs = require('fs');
28
+ const path = require('path');
29
+
30
+ const ROOT = path.join(__dirname, '..');
31
+ const WARN_ONLY = process.argv.includes('--warn-only');
32
+
33
+ const schema = JSON.parse(fs.readFileSync(path.join(__dirname, 'trace-schema.json'), 'utf8'));
34
+
35
+ const errors = [];
36
+ const warns = [];
37
+ const err = (rule, msg, hint) => errors.push({ rule, msg, hint });
38
+ const warn = (rule, msg, hint) => warns.push({ rule, msg, hint });
39
+
40
+ // ── nạp nội dung actor ────────────────────────────────────────────────────────
41
+ const actorText = {};
42
+ const missingActors = [];
43
+ for (const [name, rel] of Object.entries(schema.actors)) {
44
+ if (name.startsWith('$')) continue;
45
+ const abs = path.join(ROOT, rel);
46
+ if (!fs.existsSync(abs)) { missingActors.push(`${name} → ${rel}`); continue; }
47
+ actorText[name] = fs.readFileSync(abs, 'utf8');
48
+ }
49
+ if (missingActors.length) {
50
+ err('R0', `${missingActors.length} actor trong schema trỏ tới file không tồn tại`,
51
+ missingActors.join('\n '));
52
+ }
53
+
54
+ /** Mọi file .tmpl + step + template — dùng cho R4/R5/R6 (quét rộng). */
55
+ function allSourceFiles() {
56
+ const out = [];
57
+ const push = (dir, filter) => {
58
+ if (!fs.existsSync(dir)) return;
59
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
60
+ const p = path.join(dir, e.name);
61
+ if (e.isDirectory()) push(p, filter);
62
+ else if (filter(e.name)) out.push(p);
63
+ }
64
+ };
65
+ push(path.join(ROOT, 'commands'), f => f.endsWith('.tmpl'));
66
+ push(path.join(ROOT, 'steps'), f => f.endsWith('.md'));
67
+ push(path.join(ROOT, 'templates'), () => true);
68
+ push(path.join(ROOT, 'rules'), f => f.endsWith('.md'));
69
+ push(path.join(ROOT, 'modules'), f => f.endsWith('.yaml') || f.endsWith('.md'));
70
+ return out;
71
+ }
72
+ const sources = allSourceFiles().map(p => ({
73
+ rel: path.relative(ROOT, p).replace(/\\/g, '/'),
74
+ text: fs.readFileSync(p, 'utf8'),
75
+ }));
76
+ const allText = sources.map(s => s.text).join('\n');
77
+
78
+ /**
79
+ * Field `name` có được nhắc trong `text` như một TOKEN TRỌN VẸN không?
80
+ *
81
+ * Phải khớp theo ranh giới token, không phải substring. Hai bẫy thật:
82
+ * - `@trace.XXsc_version` CHỨA chuỗi `sc_version` → một phép so substring sẽ coi là
83
+ * "có nhắc" và R3 im lặng đúng lúc contract vừa bị gỡ producer. (Chính test tái
84
+ * tạo G1 đã lộ ra bug này trong bản đầu của checker.)
85
+ * - `dev_selftest_at` CHỨA `dev_selftest` → lệnh chỉ nhắc cột này sẽ được coi là
86
+ * đã implement cả cột kia.
87
+ */
88
+ function mentions(text, name) {
89
+ const esc = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
90
+ // Trước: không được là phần đuôi của một token dài hơn (chặn `XXsc_version`).
91
+ // Sau: không được là phần đầu của token dài hơn (chặn `dev_selftest` khớp `..._at`).
92
+ const before = name.startsWith('@') ? '' : '(?<![A-Za-z0-9_.])';
93
+ return new RegExp(`${before}${esc}(?![A-Za-z0-9_])`).test(text);
94
+ }
95
+
96
+ // ── R1 + R2 + R3 — producer / consumer / prose khớp ───────────────────────────
97
+ function checkField(kind, name, def) {
98
+ const producers = [...(def.written_by || []), ...(def.bumped_by || [])];
99
+ const consumers = def.read_by || [];
100
+
101
+ // R1 — có người đọc mà không ai ghi
102
+ if (consumers.length && !producers.length) {
103
+ err('R1', `${kind} \`${name}\`: có ${consumers.length} consumer nhưng KHÔNG có producer`,
104
+ `Ai ghi/bump nó? Thêm written_by hoặc bumped_by vào schema, và implement ở lệnh đó.\n`
105
+ + ` Đây chính là hình dạng của G1 (@trace.sc_version) — DRIFT chết mà không ai báo.`);
106
+ }
107
+
108
+ // R2 — có người ghi mà không ai đọc
109
+ if (producers.length && !consumers.length && !def.allow_no_reader) {
110
+ warn('R2', `${kind} \`${name}\`: có producer nhưng KHÔNG ai đọc — field chết?`,
111
+ `Hoặc thêm consumer, hoặc bỏ field, hoặc khai allow_no_reader kèm lý do.`);
112
+ }
113
+
114
+ // R3 — actor được khai nhưng file của nó không nhắc field
115
+ for (const actor of new Set([...producers, ...consumers])) {
116
+ const text = actorText[actor];
117
+ if (text === undefined) continue; // đã báo ở R0
118
+ if (!mentions(text, name)) {
119
+ err('R3', `${kind} \`${name}\`: schema khai \`${actor}\` là producer/consumer nhưng ${schema.actors[actor]} KHÔNG nhắc tới nó`,
120
+ `Hoặc lệnh đó thiếu implement, hoặc schema khai sai. Prose lệch schema chính là\n`
121
+ + ` cách 19/23 GAP phát sinh.`);
122
+ }
123
+ }
124
+ }
125
+
126
+ for (const t of schema.tags) checkField('tag', t.name, t);
127
+ for (const c of schema.tsv_columns) checkField('cột TSV', c.name, c);
128
+
129
+ // ── R4 — {paths.X} vs project-context.yaml ────────────────────────────────────
130
+ const cfgPath = path.join(ROOT, schema.path_keys.config_file);
131
+ if (!fs.existsSync(cfgPath)) {
132
+ err('R4', `Không tìm thấy ${schema.path_keys.config_file}`, 'path_keys.config_file trong schema trỏ sai?');
133
+ } else {
134
+ const cfg = fs.readFileSync(cfgPath, 'utf8');
135
+ // key khai: dòng thụt 2 space dưới `paths:` dạng ` key: "value"` (bỏ comment)
136
+ const declared = new Set();
137
+ let inPaths = false;
138
+ for (const line of cfg.split(/\r?\n/)) {
139
+ if (/^paths:\s*$/.test(line)) { inPaths = true; continue; }
140
+ if (inPaths && /^\S/.test(line)) break; // sang section khác
141
+ const m = inPaths && line.match(/^ {2}([a-z_][a-z0-9_]*):/);
142
+ if (m) declared.add(m[1]);
143
+ }
144
+
145
+ // CHỈ quét commands/ + steps/ — đó là những file thực sự phân giải {paths.X} lúc chạy.
146
+ // templates/*.md là prose (vd templates/README.md giải thích các key ĐÃ BỎ) — tính nó
147
+ // vào "used" sẽ báo oan chính đoạn văn nói rằng key đó không còn nữa.
148
+ const runtimeText = sources
149
+ .filter(s => s.rel.startsWith('commands/') || s.rel.startsWith('steps/'))
150
+ .map(s => s.text).join('\n');
151
+ const used = new Set();
152
+ for (const m of runtimeText.matchAll(/\{paths\.([a-z_][a-z0-9_]*)\}/g)) used.add(m[1]);
153
+ // context-loader tham chiếu vài key không qua ngoặc nhọn (vd `paths.lessons_file`)
154
+ for (const m of runtimeText.matchAll(/\bpaths\.([a-z_][a-z0-9_]*)\b/g)) used.add(m[1]);
155
+
156
+ const undeclared = [...used].filter(k => !declared.has(k));
157
+ if (undeclared.length) {
158
+ err('R4', `${undeclared.length} key \`{paths.X}\` được dùng nhưng KHÔNG khai trong project-context.yaml`,
159
+ undeclared.join(', '));
160
+ }
161
+
162
+ const ignore = new Set(schema.path_keys.ignore_unused || []);
163
+ const dead = [...declared].filter(k => !used.has(k) && !ignore.has(k));
164
+ if (dead.length) {
165
+ warn('R4', `${dead.length} key khai trong project-context.yaml mà KHÔNG lệnh nào dùng — key chết?`,
166
+ `${dead.join(', ')}\n (Đây là hình dạng của G17. Nếu cố ý giữ, thêm vào path_keys.ignore_unused.)`);
167
+ }
168
+ }
169
+
170
+ // ── R5 — pattern bị cấm ───────────────────────────────────────────────────────
171
+ for (const fp of schema.forbidden_patterns) {
172
+ // `except` = file được phép chứa pattern vì nó đang TRÍCH DẪN pattern trong một cảnh
173
+ // báo ("đừng glob X"). Mỗi entry phải kèm lý do trong schema — không phải ignore mù.
174
+ const except = new Set(Object.keys(fp.except || {}));
175
+ const hits = sources.filter(s => s.text.includes(fp.pattern) && !except.has(s.rel));
176
+ if (hits.length) {
177
+ err('R5', `Pattern bị cấm quay lại: \`${fp.pattern}\` (${fp.gap})`,
178
+ `${fp.reason}\n Thấy ở: ${hits.map(h => h.rel).join(', ')}`);
179
+ }
180
+ // 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ỏ
181
+ for (const f of except) {
182
+ const src = sources.find(s => s.rel === f);
183
+ if (!src || !src.text.includes(fp.pattern)) {
184
+ warn('R5', `\`${fp.pattern}\`: except \`${f}\` không còn chứa pattern — bỏ except này khỏi schema`);
185
+ }
186
+ }
187
+ }
188
+
189
+ // ── R6 — giá trị enum chết ────────────────────────────────────────────────────
190
+ // 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
191
+ // 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ỗ
192
+ // khi giá trị đúng vẫn còn ở chỗ khác (vd `fe_phase: integration` lọt vào một lệnh trong
193
+ // khi các lệnh còn lại vẫn ghi `integrated`). Cho lớp đó, dùng forbidden_patterns — chính
194
+ // xác hơn và không có false positive.
195
+ // Khớp theo ranh giới token: `fe_integrated` KHÔNG được coi là đã dùng `integrated`.
196
+ for (const [vocab, def] of Object.entries(schema.vocabularies)) {
197
+ if (vocab.startsWith('$')) continue;
198
+ const unused = (def.values || []).filter(v => !mentions(allText, v));
199
+ if (unused.length) {
200
+ warn('R6', `vocabulary \`${vocab}\`: ${unused.length} giá trị không xuất hiện ở đâu — sai chính tả hay giá trị chết?`,
201
+ `${unused.join(', ')}\n (G19 là ca này: doc ghi fe_phase "integration" còn lệnh ghi "integrated".)`);
202
+ }
203
+ }
204
+
205
+ // ── report ────────────────────────────────────────────────────────────────────
206
+ const tagCount = schema.tags.length;
207
+ const colCount = schema.tsv_columns.length;
208
+ console.log('');
209
+ console.log('Self-check (bin/trace-schema.json) ...');
210
+ console.log('');
211
+
212
+ const show = (list, icon) => {
213
+ for (const e of list) {
214
+ console.log(` ${icon} [${e.rule}] ${e.msg}`);
215
+ if (e.hint) console.log(` ${e.hint}`);
216
+ }
217
+ };
218
+ show(errors, '❌');
219
+ show(warns, '⚠️ ');
220
+
221
+ if (!errors.length && !warns.length) {
222
+ console.log(` ✅ ${tagCount} tag + ${colCount} cột TSV + ${Object.keys(schema.vocabularies).length - 1} vocabulary — contract khớp mọi lệnh`);
223
+ }
224
+ console.log('');
225
+
226
+ if (errors.length && !WARN_ONLY) {
227
+ console.log(`❌ Self-check FAIL: ${errors.length} error, ${warns.length} warning.`);
228
+ console.log(' Sửa contract ở bin/trace-schema.json TRƯỚC, rồi sửa lệnh cho khớp.');
229
+ console.log(' (Bỏ qua tạm: node bin/self-check.js --warn-only)');
230
+ console.log('');
231
+ process.exit(1);
232
+ }
233
+ if (errors.length) {
234
+ console.log(`⚠️ ${errors.length} error bị bỏ qua do --warn-only.`);
235
+ console.log('');
236
+ }