@educa-corp/sdd-framework 0.5.0 → 0.6.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 (115) hide show
  1. package/bin/build.js +113 -19
  2. package/bin/gate-trace.js +464 -0
  3. package/bin/index.js +418 -146
  4. package/bin/lint-trace.js +602 -0
  5. package/bin/self-check.js +376 -1
  6. package/bin/trace-schema.json +252 -2
  7. package/commands/debug.md +123 -511
  8. package/commands/debug.tmpl +3 -0
  9. package/commands/define-product.md +86 -510
  10. package/commands/dev-gen-test.md +86 -510
  11. package/commands/dev-run-test.md +86 -510
  12. package/commands/dev-smoke-test.md +86 -510
  13. package/commands/extend-prd.md +89 -510
  14. package/commands/extend-prd.tmpl +3 -0
  15. package/commands/fix-bug.md +118 -509
  16. package/commands/generate-architecture.md +94 -515
  17. package/commands/generate-architecture.tmpl +3 -0
  18. package/commands/generate-bdd.md +85 -509
  19. package/commands/generate-code.md +86 -510
  20. package/commands/generate-design-spec.md +86 -510
  21. package/commands/generate-prd.md +89 -510
  22. package/commands/generate-prd.tmpl +3 -0
  23. package/commands/generate-spec-manifest.md +86 -510
  24. package/commands/generate-tech-docs.md +86 -510
  25. package/commands/learn.md +172 -496
  26. package/commands/learn.tmpl +70 -3
  27. package/commands/map-testids.md +86 -510
  28. package/commands/propose-scenario.md +86 -510
  29. package/commands/qc-analyze.md +86 -510
  30. package/commands/qc-design-test.md +86 -510
  31. package/commands/qc-plan.md +86 -510
  32. package/commands/qc-report.md +86 -510
  33. package/commands/qc-review.md +86 -510
  34. package/commands/qc-run-test.md +86 -510
  35. package/commands/refine-prd.md +99 -520
  36. package/commands/refine-prd.tmpl +3 -0
  37. package/commands/report-bug.md +86 -510
  38. package/commands/review-code.md +123 -511
  39. package/commands/review-code.tmpl +3 -0
  40. package/commands/review-context.md +93 -514
  41. package/commands/review-context.tmpl +3 -0
  42. package/commands/review-tech-docs.md +90 -511
  43. package/commands/review-tech-docs.tmpl +3 -0
  44. package/commands/setup-ai-first.md +166 -138
  45. package/commands/setup-ai-first.tmpl +72 -0
  46. package/commands/sync.md +50 -106
  47. package/commands/sync.tmpl +48 -3
  48. package/commands/update-framework.md +16 -103
  49. package/commands/update-framework.tmpl +14 -0
  50. package/commands/validate-traces.md +153 -511
  51. package/commands/validate-traces.tmpl +67 -1
  52. package/core/FRAMEWORK_VERSION +1 -1
  53. package/core/README.md +20 -0
  54. package/core/commands/debug.md +123 -511
  55. package/core/commands/define-product.md +86 -510
  56. package/core/commands/dev-gen-test.md +86 -510
  57. package/core/commands/dev-run-test.md +86 -510
  58. package/core/commands/dev-smoke-test.md +86 -510
  59. package/core/commands/extend-prd.md +89 -510
  60. package/core/commands/fix-bug.md +118 -509
  61. package/core/commands/generate-architecture.md +94 -515
  62. package/core/commands/generate-bdd.md +85 -509
  63. package/core/commands/generate-code.md +86 -510
  64. package/core/commands/generate-design-spec.md +86 -510
  65. package/core/commands/generate-prd.md +89 -510
  66. package/core/commands/generate-spec-manifest.md +86 -510
  67. package/core/commands/generate-tech-docs.md +86 -510
  68. package/core/commands/learn.md +172 -496
  69. package/core/commands/map-testids.md +86 -510
  70. package/core/commands/propose-scenario.md +86 -510
  71. package/core/commands/qc-analyze.md +86 -510
  72. package/core/commands/qc-design-test.md +86 -510
  73. package/core/commands/qc-plan.md +86 -510
  74. package/core/commands/qc-report.md +86 -510
  75. package/core/commands/qc-review.md +86 -510
  76. package/core/commands/qc-run-test.md +86 -510
  77. package/core/commands/refine-prd.md +99 -520
  78. package/core/commands/report-bug.md +86 -510
  79. package/core/commands/review-code.md +123 -511
  80. package/core/commands/review-context.md +93 -514
  81. package/core/commands/review-tech-docs.md +90 -511
  82. package/core/commands/setup-ai-first.md +166 -138
  83. package/core/commands/sync.md +50 -106
  84. package/core/commands/update-framework.md +16 -103
  85. package/core/commands/validate-traces.md +153 -511
  86. package/core/hooks/data-guard.js +174 -83
  87. package/core/hooks/settings.json +2 -1
  88. package/core/rules/workflow.md +30 -4
  89. package/core/steps/capture-lesson.md +34 -1
  90. package/core/steps/context-loader.md +24 -3
  91. package/core/steps/gate.md +92 -35
  92. package/core/steps/report-footer.md +23 -0
  93. package/core/templates/README.md +24 -1
  94. package/core/templates/ci/trace-gate.yml +146 -0
  95. package/core/templates/hooks/pre-push +61 -0
  96. package/docs/02-concepts/architecture.md +25 -6
  97. package/docs/02-concepts/traceability.md +57 -0
  98. package/docs/03-guides/architect.md +63 -0
  99. package/docs/04-reference/commands.md +1 -1
  100. package/docs/04-reference/model-selection.md +32 -19
  101. package/docs/explain/21-validate-traces.md +2 -1
  102. package/docs/explain/27-learn.md +5 -3
  103. package/hooks/data-guard.js +174 -83
  104. package/hooks/settings.json +2 -1
  105. package/package.json +5 -2
  106. package/rules/workflow.md +30 -4
  107. package/steps/capture-lesson.md +34 -1
  108. package/steps/context-loader.md +24 -3
  109. package/steps/gate.md +92 -35
  110. package/steps/report-footer.md +23 -0
  111. package/templates/README.md +24 -1
  112. package/templates/ci/trace-gate.yml +146 -0
  113. package/templates/hooks/pre-push +61 -0
  114. package/scripts/init.sh +0 -49
  115. package/scripts/upgrade.sh +0 -94
package/bin/build.js CHANGED
@@ -23,6 +23,41 @@ const SKILLS_DIR = path.join(ROOT, 'skills');
23
23
  // Cache for step file contents
24
24
  const stepCache = {};
25
25
 
26
+ // ── Step nào ĐỌC LÚC CHẠY thay vì nướng vào từng lệnh (GAPS-v3 G45) ──────────
27
+ //
28
+ // Vì sao: build inline `{{include:}}` vào TỪNG file lệnh, nên 32 lệnh = 2069 KB mà chỉ
29
+ // 580 KB là nội dung riêng của chúng — 72% là vài step giống hệt nhau, chép 30 lần.
30
+ // `/generate-code` = 108 KB ≈ 27.000 token đọc TRƯỚC khi làm gì; gần một nửa không nói
31
+ // gì về việc sinh code. `/refine-prd` thì 75% là nội dung dùng chung.
32
+ //
33
+ // Cái giá thật không phải tiền: trên PRD nhiều UC, nạp 27k token chỉ để đọc chỉ dẫn làm
34
+ // tăng rủi ro CẠN CONTEXT GIỮA CHỪNG — mà thứ đang được ghi lúc đó là sổ trace.
35
+ //
36
+ // PHẠM VI CÓ CHỦ Ý — chỉ hai step, không phải tất cả:
37
+ // context-loader (64% lãng phí) · report-footer (16%) → đọc lúc chạy
38
+ // gate (15%) → GIỮ INLINE
39
+ // Gate là lưới an toàn (model check · resolve target · CHECKPOINT). Bỏ sót gate thì lệnh
40
+ // VẪN CHẠY, chỉ là không còn cổng nào — hỏng ÂM THẦM. Bỏ sót context-loader thì lệnh dừng
41
+ // ngay vì thiếu path/config — hỏng ỒN ÀO, thấy liền. Đổi 15% lấy một lưới an toàn là lỗ.
42
+ //
43
+ // MỘT biến thể duy nhất kể từ khi G50 gỡ legacy mode. Trước đó phải build hai bản —
44
+ // `commands/*.md` inline đầy đủ cho legacy (nó copy thẳng file đó mà KHÔNG cài `.agent/`,
45
+ // nên không có `.agent/steps/` để đọc) và `core/commands/*.md` slim cho `--init`. Legacy
46
+ // đi rồi thì mọi bản cài đều có `.agent/steps/`, và nhánh build thứ hai là nợ chờ lệch.
47
+ const RUNTIME_READ = {
48
+ 'steps/context-loader.md':
49
+ '**BẮT BUỘC — đọc `.agent/steps/context-loader.md` và thực thi TOÀN BỘ quy trình trong đó**,\n' +
50
+ 'rồi mới tiếp tục phần bên dưới.\n' +
51
+ '\n' +
52
+ 'Bỏ qua bước này thì `{paths.*}`, `{tech_stack.*}`, `{conventions.*}`, guardrail từ\n' +
53
+ '`project-lessons`, và routing service (chế độ umbrella) đều **chưa được phân giải** — mọi\n' +
54
+ 'placeholder bên dưới sẽ rỗng và lệnh sẽ đọc/ghi sai chỗ.',
55
+
56
+ 'steps/report-footer.md':
57
+ '**Đọc `.agent/steps/report-footer.md`** và áp đúng khuôn footer trong đó (Status Badge ·\n' +
58
+ 'Output Artifacts · Next) cho report cuối, kèm khối bên dưới.',
59
+ };
60
+
26
61
  /**
27
62
  * Load a step file by name, caching the result.
28
63
  * @param {string} stepPath - e.g. "steps/gate.md"
@@ -54,10 +89,11 @@ function buildTemplate(tmplFile, label) {
54
89
  try {
55
90
  let content = fs.readFileSync(tmplFile, 'utf8');
56
91
 
57
- // Replace all {{include:...}} markers
92
+ // Replace all {{include:...}} markers — trừ những step nằm trong RUNTIME_READ,
93
+ // chúng được thay bằng một chỉ dẫn ĐỌC LÚC CHẠY thay vì nướng 33 KB vào đây (G45).
58
94
  content = content.replace(/\{\{include:([^}]+)\}\}/g, (match, includePath) => {
59
- const trimmed = includePath.trim();
60
- return loadStep(trimmed);
95
+ const key = includePath.trim();
96
+ return RUNTIME_READ[key] !== undefined ? RUNTIME_READ[key] : loadStep(key);
61
97
  });
62
98
 
63
99
  fs.writeFileSync(outFile, content, 'utf8');
@@ -155,6 +191,8 @@ if (skillTmplFiles.length === 0) {
155
191
  // It is gitignored but included in the npm package (via "files" whitelist).
156
192
 
157
193
  const CORE_DIR = path.join(ROOT, 'core');
194
+ const CORE_TMP = path.join(ROOT, 'core.tmp');
195
+ const CORE_BAK = path.join(ROOT, 'core.bak');
158
196
  const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'));
159
197
  const VERSION = pkg.version;
160
198
 
@@ -162,11 +200,48 @@ console.log('');
162
200
  console.log('Populating core/ (distributable) ...');
163
201
  console.log('');
164
202
 
165
- // Clean and recreate core/
166
- if (fs.existsSync(CORE_DIR)) {
167
- fs.rmSync(CORE_DIR, { recursive: true });
203
+ // Dựng vào core.tmp/ rồi SWAP — không bao giờ xoá core/ trước khi có bản thay thế
204
+ // hoàn chỉnh (GAPS-v3 G43).
205
+ //
206
+ // Bản cũ làm `rmSync(core/)` rồi mới dựng lại. Nếu bước dựng fail nửa đường — đĩa đầy,
207
+ // thư mục read-only, Ctrl-C — thì core/ ĐÃ MẤT và không có gì thay thế. Với consumer thì
208
+ // hậu quả nặng hơn hẳn: `--init` chạy build ngay trong thư mục npx cache, nên một lần fail
209
+ // làm hỏng package trong cache VĨNH VIỄN, và mọi lần chạy sau đều báo "core/ not found.
210
+ // The package may not have been built correctly" — một thông điệp dẫn sai hướng, vì package
211
+ // được build đúng, chính installer đã xoá nó.
212
+ //
213
+ // Dọn rác của lần chạy trước (nếu có) — không thì swap sẽ trượt vào thư mục cũ.
214
+ for (const stale of [CORE_TMP, CORE_BAK]) {
215
+ if (fs.existsSync(stale)) fs.rmSync(stale, { recursive: true, force: true });
216
+ }
217
+
218
+ // Kiểm quyền ghi TRƯỚC khi làm gì cả: fail ở đây thì chưa có gì bị đụng tới.
219
+ try {
220
+ fs.mkdirSync(CORE_TMP, { recursive: true });
221
+ } catch (err) {
222
+ console.error('');
223
+ console.error(`❌ Không ghi được vào thư mục package: ${ROOT}`);
224
+ console.error(` (${err.code || err.message})`);
225
+ console.error('');
226
+ console.error(' Đây là bản cài read-only. core/ hiện tại KHÔNG bị đụng tới.');
227
+ console.error(' Thử: npx -y @educa-corp/sdd-framework@latest --init');
228
+ console.error(' Hoặc xoá cache npx: rm -rf ~/.npm/_npx');
229
+ console.error('');
230
+ process.exit(1);
231
+ }
232
+
233
+ /** Đưa core.tmp/ vào chỗ core/. Fail ở bất kỳ đâu thì khôi phục bản cũ. */
234
+ function swapCoreIntoPlace() {
235
+ const hadOld = fs.existsSync(CORE_DIR);
236
+ if (hadOld) fs.renameSync(CORE_DIR, CORE_BAK);
237
+ try {
238
+ fs.renameSync(CORE_TMP, CORE_DIR);
239
+ } catch (err) {
240
+ if (hadOld) fs.renameSync(CORE_BAK, CORE_DIR); // core/ trở lại nguyên trạng
241
+ throw err;
242
+ }
243
+ if (hadOld) fs.rmSync(CORE_BAK, { recursive: true, force: true });
168
244
  }
169
- fs.mkdirSync(CORE_DIR, { recursive: true });
170
245
 
171
246
  /**
172
247
  * Recursively copy src → dest, optionally filtering files by name.
@@ -190,29 +265,31 @@ function copyDirToCore(src, dest, fileFilter) {
190
265
 
191
266
  const coreMappings = [
192
267
  // Built command .md files (not .tmpl sources)
193
- { src: COMMANDS_DIR, dest: path.join(CORE_DIR, 'commands'), filter: f => f.endsWith('.md') },
268
+ { src: COMMANDS_DIR, dest: path.join(CORE_TMP, 'commands'), filter: f => f.endsWith('.md') },
194
269
  // Step files (gate, context-loader, spawn-agent, report-footer)
195
- { src: path.join(ROOT, 'steps'), dest: path.join(CORE_DIR, 'steps'), filter: null },
270
+ { src: path.join(ROOT, 'steps'), dest: path.join(CORE_TMP, 'steps'), filter: null },
196
271
  // Hook scripts
197
- { src: path.join(ROOT, 'hooks'), dest: path.join(CORE_DIR, 'hooks'), filter: null },
272
+ { src: path.join(ROOT, 'hooks'), dest: path.join(CORE_TMP, 'hooks'), filter: null },
198
273
  // Data-protection and workflow rules
199
- { src: path.join(ROOT, 'rules'), dest: path.join(CORE_DIR, 'rules'), filter: null },
274
+ { src: path.join(ROOT, 'rules'), dest: path.join(CORE_TMP, 'rules'), filter: null },
200
275
  // PRD / architecture templates
201
- { src: path.join(ROOT, 'templates'), dest: path.join(CORE_DIR, 'templates'), filter: null },
276
+ { src: path.join(ROOT, 'templates'), dest: path.join(CORE_TMP, 'templates'), filter: null },
202
277
  // Stack module profiles
203
- { src: path.join(ROOT, 'modules'), dest: path.join(CORE_DIR, 'modules'), filter: null },
278
+ { src: path.join(ROOT, 'modules'), dest: path.join(CORE_TMP, 'modules'), filter: null },
204
279
  // Built skill .md files (not .tmpl sources)
205
- { src: SKILLS_DIR, dest: path.join(CORE_DIR, 'skills'), filter: f => !f.endsWith('.tmpl') },
280
+ { src: SKILLS_DIR, dest: path.join(CORE_TMP, 'skills'), filter: f => !f.endsWith('.tmpl') },
206
281
  ];
207
282
 
208
283
  for (const { src, dest, filter } of coreMappings) {
209
284
  copyDirToCore(src, dest, filter);
210
- const rel = path.relative(ROOT, dest);
211
- console.log(` ✅ ${rel}/`);
285
+ // Báo theo ĐÍCH CUỐI (core/…), không phải thư mục dựng trung gian. core.tmp/ là chi
286
+ // tiết của bước swap (G43) — người đọc log quan tâm cái gì được cài, không quan tâm
287
+ // nó đi qua đâu.
288
+ console.log(` ✅ core/${path.basename(dest)}/`);
212
289
  }
213
290
 
214
- // Write FRAMEWORK_VERSION so upgrade.sh can compare installed vs latest
215
- fs.writeFileSync(path.join(CORE_DIR, 'FRAMEWORK_VERSION'), VERSION + '\n', 'utf8');
291
+ // Write FRAMEWORK_VERSION so /update-framework can compare installed vs latest
292
+ fs.writeFileSync(path.join(CORE_TMP, 'FRAMEWORK_VERSION'), VERSION + '\n', 'utf8');
216
293
  console.log(` ✅ core/FRAMEWORK_VERSION (v${VERSION})`);
217
294
 
218
295
  // .agent/README.md — which paths under .agent/ a project may edit, and which the
@@ -220,10 +297,27 @@ console.log(` ✅ core/FRAMEWORK_VERSION (v${VERSION})`);
220
297
  // land inside .agent/templates/.
221
298
  const agentReadmeSrc = path.join(ROOT, 'AGENT_README.md');
222
299
  if (fs.existsSync(agentReadmeSrc)) {
223
- fs.copyFileSync(agentReadmeSrc, path.join(CORE_DIR, 'README.md'));
300
+ fs.copyFileSync(agentReadmeSrc, path.join(CORE_TMP, 'README.md'));
224
301
  console.log(' ✅ core/README.md (← AGENT_README.md)');
225
302
  }
226
303
 
304
+ // ── 3b. Swap core.tmp/ → core/ ───────────────────────────────────────────────
305
+ //
306
+ // Tới đây core.tmp/ đã hoàn chỉnh. Đây là điểm duy nhất core/ bị thay, và nó là
307
+ // một phép rename — gần như nguyên tử, không có cửa sổ nào core/ vắng mặt-và-không-
308
+ // có-bản-thay-thế. Fail thì swapCoreIntoPlace() khôi phục bản cũ (G43).
309
+ try {
310
+ swapCoreIntoPlace();
311
+ } catch (err) {
312
+ console.error('');
313
+ console.error(`❌ Không thay được core/: ${err.code || err.message}`);
314
+ console.error(' core/ cũ đã được khôi phục — package vẫn dùng được.');
315
+ console.error(` Bản vừa dựng còn ở: ${path.relative(ROOT, CORE_TMP)}/`);
316
+ console.error(' Nguyên nhân thường gặp: file trong core/ đang bị process khác giữ (antivirus, editor).');
317
+ console.error('');
318
+ process.exit(1);
319
+ }
320
+
227
321
  // ── 4. Sync local dogfood .agent/ (dev repo only) ────────────────────────────
228
322
  //
229
323
  // In THIS framework repo, .agent/ is a pure mirror of core/ used to dogfood the
@@ -0,0 +1,464 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * gate-trace — CỔNG CHẶN PR. Exit 1 khi trace có cờ 🔴.
4
+ *
5
+ * Vì sao tồn tại (GAPS-v3 G39): README khai bằng chữ in đậm rằng 4 cờ (ORPHANED ·
6
+ * TRACE_ORPHAN · SEAM_UNWIRED · STUB_UNRESOLVED) "chặn PR", kèm câu "build xanh + test
7
+ * từng-UC xanh KHÔNG phát hiện được" — đó là điểm bán hàng cốt lõi của framework.
8
+ * Nhưng trước file này KHÔNG CÓ GÌ CHẶN: 0 CI, 0 git hook, 0 exit code. /validate-traces
9
+ * in report vào terminal Claude Code, nơi không CI nào đọc được. Kỷ luật là honor-system,
10
+ * và cái gì không chặn thì sau sprint thứ ba không ai làm.
11
+ *
12
+ * Bốn tầng, theo thứ tự — mỗi tầng vô nghĩa nếu tầng trước chưa xanh:
13
+ *
14
+ * G0 Sổ có ĐƯỢC BẢO VỆ không? → nằm trong git? git biết cách gộp?
15
+ * Kiểm cấu trúc của một quyển sổ không nằm trong git thì vô nghĩa. Hai điều kiện
16
+ * này lint đã phát hiện dưới dạng WARN; ở đây chúng được NÂNG thành lỗi chặn —
17
+ * cố ý: pre-push nhắc, CI chặn (chặn push vì một dòng cấu hình sẽ dạy dev gõ
18
+ * --no-verify, và mất luôn phần lint thật).
19
+ * G1 Sổ có ĐÚNG HÌNH DẠNG không? → gọi bin/lint-trace.js (G38)
20
+ * Phán trạng thái trên sổ lệch cột là phán trên dữ liệu rác.
21
+ * G2 Report có TƯƠI không? → đối chiếu report ↔ sổ TSV
22
+ * Không có tầng này thì gate là sân khấu: chạy /validate-traces một lần, commit
23
+ * report, rồi sửa gì cũng được — CI đọc report cũ và cho qua mãi mãi.
24
+ * G3 Có cờ 🔴 nào không? → đếm counter khai trong schema `gate.blocking`
25
+ *
26
+ * PHẠM VI — nói thẳng để không ai tin quá:
27
+ * Gate chứng minh "report khớp SỔ". Chỉ /validate-traces chứng minh được "sổ khớp CODE"
28
+ * (nó cần quét tag trong source, đọc .feature, so version — việc của LLM, không phải
29
+ * của file này). Nên gate bắt được ca phổ biến "quên chạy lại", nhưng KHÔNG bắt được
30
+ * ca "sửa code mà không đụng sổ". Đường bịt: CI chạy /validate-traces, hoặc yêu cầu
31
+ * report có trong diff khi src đổi. §Output in ra đúng giới hạn này mỗi lần chạy.
32
+ *
33
+ * Cờ 🟠 (PRD_DRIFT, BDD_DRIFT, TECHDOC_DRIFT…) KHÔNG chặn — cố ý. Chúng là "code chưa
34
+ * theo kịp spec", một loại nợ khác. Chặn PR vì nợ là làm cổng bị tắt.
35
+ *
36
+ * Chạy: node bin/gate-trace.js [--trace DIR[,DIR]] [--report PATH]
37
+ * [--skip-lint] [--no-reconcile] [--warn-only] [--json]
38
+ * exit 1 nếu FAIL (0 với --warn-only)
39
+ */
40
+
41
+ const fs = require('fs');
42
+ const path = require('path');
43
+
44
+ const schema = JSON.parse(fs.readFileSync(path.join(__dirname, 'trace-schema.json'), 'utf8'));
45
+ const GATE = schema.gate;
46
+
47
+ if (!GATE || !Array.isArray(GATE.blocking) || !GATE.blocking.length) {
48
+ console.error('❌ bin/trace-schema.json thiếu khối `gate.blocking` — không biết chặn cờ nào.');
49
+ process.exit(1);
50
+ }
51
+
52
+ // ── CLI ───────────────────────────────────────────────────────────────────────
53
+
54
+ const argv = process.argv.slice(2);
55
+ const flag = (name, def) => {
56
+ const i = argv.indexOf(name);
57
+ return i !== -1 && argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : def;
58
+ };
59
+ const WARN_ONLY = argv.includes('--warn-only');
60
+ const AS_JSON = argv.includes('--json');
61
+ const SKIP_LINT = argv.includes('--skip-lint');
62
+ const NO_RECONCILE = argv.includes('--no-reconcile');
63
+ const TRACE_DIRS = flag('--trace', '.trace').split(',').map(s => s.trim()).filter(Boolean);
64
+ const REPORT_ARG = flag('--report', null);
65
+
66
+ const fails = [];
67
+ const warns = [];
68
+ const oks = [];
69
+ const fail = (stage, msg, hint) => fails.push({ stage, msg, hint });
70
+ const warn = (stage, msg, hint) => warns.push({ stage, msg, hint });
71
+
72
+ const rel = p => path.relative(process.cwd(), p).replace(/\\/g, '/');
73
+
74
+ // ── Chạy lint MỘT LẦN — G0 và G1 cùng đọc kết quả này ─────────────────────────
75
+
76
+ let lintSummary = 'bỏ qua (--skip-lint)';
77
+ let lintParsed = null;
78
+ let lintStderr = '';
79
+
80
+ if (!SKIP_LINT) {
81
+ const lintPath = path.join(__dirname, 'lint-trace.js');
82
+ const args = ['--trace', TRACE_DIRS.join(','), '--json'];
83
+ const r = require('child_process').spawnSync(process.execPath, [lintPath, ...args],
84
+ { encoding: 'utf8' });
85
+ lintStderr = r.stderr || '';
86
+ try { lintParsed = JSON.parse(r.stdout); } catch {}
87
+ }
88
+
89
+ // ── Tầng G0 — sổ có ĐƯỢC BẢO VỆ không (GAPS-v3 T10-debt) ──────────────────────
90
+ //
91
+ // Hỏi TRƯỚC G1 có lý do: G1 kiểm rất kỹ cấu trúc của một quyển sổ mà có thể KHÔNG NẰM
92
+ // TRONG GIT. Kiểm cấu trúc của thứ sắp mất thì vô nghĩa.
93
+ //
94
+ // Hai điều kiện đều đã được lint phát hiện dưới dạng WARN; ở đây chúng được NÂNG thành
95
+ // lỗi chặn. Một implementation, hai mức nghiêm trọng — cố ý: pre-push nhắc, CI chặn.
96
+ // Danh sách rule đọc từ schema (`gate.config_preconditions`), không hard-code — R9 canh.
97
+ const PRECOND = (GATE.config_preconditions || {}).promote_lint_rules || [];
98
+
99
+ if (SKIP_LINT) {
100
+ warn('G0', 'bỏ qua kiểm cấu hình (--skip-lint) — không biết sổ có được bảo vệ hay không');
101
+ } else if (!lintParsed) {
102
+ fail('G0', 'không chạy được bin/lint-trace.js — không xác nhận được sổ có được bảo vệ',
103
+ lintStderr.trim().split('\n').slice(0, 3).join('\n ')
104
+ || `Chạy tay để xem lỗi: node bin/lint-trace.js --trace ${TRACE_DIRS.join(',')}`);
105
+ } else {
106
+ const byRule = new Map(PRECOND.map(p => [p.rule, p.why]));
107
+ const hits = (lintParsed.warns || []).filter(w => byRule.has(w.rule));
108
+
109
+ for (const h of hits) {
110
+ fail('G0', `${h.where} — ${h.msg}`,
111
+ [`Vì sao chặn: ${byRule.get(h.rule)}`,
112
+ h.hint || ''].filter(Boolean).join('\n '));
113
+ }
114
+ if (!hits.length) {
115
+ oks.push(`G0 sổ được bảo vệ — nằm trong git · git biết cách gộp (${PRECOND.length}/${PRECOND.length} điều kiện)`);
116
+ }
117
+ }
118
+
119
+ // ── Tầng G1 — sổ có đúng hình dạng không (G38) ────────────────────────────────
120
+
121
+ if (!SKIP_LINT) {
122
+ const parsed = lintParsed;
123
+ if (!parsed) {
124
+ // G0 đã báo lý do — đừng nhân đôi thông điệp, chỉ ghi là không kiểm được.
125
+ fail('G1', 'không xác nhận được sổ có đúng hình dạng (lint không chạy được — xem G0)');
126
+ } else if (!parsed.ok) {
127
+ fail('G1', `sổ trace HỎNG CẤU TRÚC — ${parsed.errors.length} lỗi, gate không phán được`,
128
+ parsed.errors.slice(0, 6).map(e => `${e.where} — ${e.rule}: ${e.msg}`).join('\n ')
129
+ + (parsed.errors.length > 6 ? `\n … và ${parsed.errors.length - 6} lỗi nữa` : '')
130
+ + `\n Chi tiết: node bin/lint-trace.js --trace ${TRACE_DIRS.join(',')}`);
131
+ } else {
132
+ const rest = (parsed.warns || []).filter(w => !PRECOND.some(p => p.rule === w.rule));
133
+ lintSummary = `${parsed.scanned.books} sổ + ${parsed.scanned.seams} bảng phụ`
134
+ + (rest.length ? ` (${rest.length} warning)` : '');
135
+ oks.push(`G1 sổ đúng hình dạng — ${lintSummary}`);
136
+ // Warn đã được G0 nâng thành fail thì KHÔNG in lại ở đây.
137
+ for (const w of rest) warn('G1', `${w.where} — ${w.rule}: ${w.msg}`);
138
+ }
139
+ }
140
+
141
+ // ── Định vị report ────────────────────────────────────────────────────────────
142
+
143
+ const REPORT_BASENAME = path.basename(GATE.report_path.replace(/^.*[/\\]/, ''));
144
+
145
+ function findReport() {
146
+ if (REPORT_ARG) return fs.existsSync(REPORT_ARG) ? path.resolve(REPORT_ARG) : null;
147
+ for (const d of TRACE_DIRS) {
148
+ const p = path.resolve(d, REPORT_BASENAME);
149
+ if (fs.existsSync(p)) return p;
150
+ }
151
+ return null;
152
+ }
153
+
154
+ const reportPath = findReport();
155
+ let report = null;
156
+
157
+ if (!reportPath) {
158
+ fail('G2', `không tìm thấy ${REPORT_BASENAME} — trace CHƯA TỪNG được audit`,
159
+ [`Đã tìm ở: ${TRACE_DIRS.map(d => rel(path.resolve(d, REPORT_BASENAME))).join(' · ')}`,
160
+ `Gate không thể cho qua khi không có gì để đọc — "không có report" ≠ "không có lỗi".`,
161
+ `Chạy /validate-traces trong Claude Code, rồi commit ${REPORT_BASENAME}.`].join('\n '));
162
+ } else {
163
+ try {
164
+ report = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
165
+ } catch (e) {
166
+ fail('G2', `${rel(reportPath)} không phải JSON hợp lệ: ${e.message}`,
167
+ `Nghi conflict merge chưa giải. trace-report.json là file SINH LẠI ĐƯỢC —\n`
168
+ + ` đừng merge tay, chạy lại /validate-traces.`);
169
+ }
170
+ }
171
+
172
+ // ── Tầng G2 — report có tươi không ────────────────────────────────────────────
173
+
174
+ /**
175
+ * Đọc mọi row dữ liệu của các sổ TSV (bỏ _seams.tsv — nó không mang scenario).
176
+ * Mỗi row kèm `platform` lấy từ TÊN FILE (`{UC-ID}-{platform}.tsv`) — đó là nơi duy
177
+ * nhất sổ mang platform, và là thứ làm khoá {sc_id, platform} của G48 dựng được.
178
+ */
179
+ function readBooks(dirs, domainFilter) {
180
+ const rows = [];
181
+ const pf = (schema.vocabularies.platform || {}).values || [];
182
+ const nameRe = pf.length ? new RegExp(`-(${pf.join('|')})\\.tsv$`) : null;
183
+
184
+ const walk = (d) => {
185
+ if (!fs.existsSync(d)) return;
186
+ for (const e of fs.readdirSync(d, { withFileTypes: true })) {
187
+ const p = path.join(d, e.name);
188
+ if (e.isDirectory()) { walk(p); continue; }
189
+ if (!e.name.endsWith('.tsv') || e.name === '_seams.tsv') continue;
190
+ const m = nameRe && nameRe.exec(e.name);
191
+ const platform = m ? m[1] : '';
192
+ const lines = fs.readFileSync(p, 'utf8').replace(/\r\n/g, '\n').split('\n')
193
+ .filter(l => l.trim() !== '');
194
+ if (lines.length < 2) continue;
195
+ const header = lines[0].split('\t').map(s => s.trim());
196
+ const si = header.indexOf('status');
197
+ const ii = header.indexOf('sc_id');
198
+ for (const l of lines.slice(1)) {
199
+ const f = l.split('\t');
200
+ rows.push({
201
+ file: p, platform,
202
+ sc_id: ii !== -1 ? (f[ii] || '').trim() : '',
203
+ status: si !== -1 ? (f[si] || '').trim() : '',
204
+ });
205
+ }
206
+ }
207
+ };
208
+ for (const d of dirs) {
209
+ const abs = path.resolve(d);
210
+ // Report chỉ phủ một domain → chỉ đọc sổ của domain đó, else so lệch là chắc chắn.
211
+ walk(domainFilter && domainFilter !== 'all' ? path.join(abs, domainFilter) : abs);
212
+ }
213
+ return rows;
214
+ }
215
+
216
+ /** Duỗi cây prds[].ucs[].scenarios[] của report thành danh sách phẳng. */
217
+ function flattenReportScenarios(report) {
218
+ const out = [];
219
+ for (const prd of report.prds || []) {
220
+ for (const uc of prd.ucs || []) {
221
+ for (const sc of uc.scenarios || []) out.push(sc);
222
+ }
223
+ }
224
+ return out;
225
+ }
226
+
227
+ /** Domain nào có sổ trên đĩa — để bắt ca report chỉ phủ một phần repo. */
228
+ function domainsOnDisk(dirs) {
229
+ const out = new Set();
230
+ for (const d of dirs) {
231
+ const abs = path.resolve(d);
232
+ if (!fs.existsSync(abs)) continue;
233
+ for (const e of fs.readdirSync(abs, { withFileTypes: true })) {
234
+ if (e.isDirectory() && !e.name.startsWith('.')) out.add(e.name);
235
+ }
236
+ }
237
+ return [...out];
238
+ }
239
+
240
+ if (report && !NO_RECONCILE) {
241
+ const domain = report.domain || 'all';
242
+ const summary = report.summary || {};
243
+
244
+ // (a) Report phủ một phần repo → cờ 🔴 ở domain khác hoàn toàn vô hình với gate.
245
+ // Cho qua ở đây là cấp một tờ giấy xanh cho thứ chưa ai xem.
246
+ if (domain !== 'all') {
247
+ const others = domainsOnDisk(TRACE_DIRS).filter(d => d !== domain);
248
+ if (others.length) {
249
+ fail('G2', `report chỉ phủ domain "${domain}" — còn ${others.length} domain có sổ mà chưa được audit`,
250
+ [`Chưa phủ: ${others.join(' · ')}`,
251
+ `Gate cho qua lúc này là khẳng định sạch cho thứ chưa ai xem.`,
252
+ `Chạy /validate-traces KHÔNG kèm domain để audit toàn bộ, rồi commit report.`].join('\n '));
253
+ }
254
+ }
255
+
256
+ const rows = readBooks(TRACE_DIRS, domain);
257
+
258
+ // (b) hard — số row là tuyệt đối, không nhập nhằng quy ước.
259
+ const hardKeys = Object.keys(GATE.reconcile.hard || {});
260
+ for (const key of hardKeys) {
261
+ if (!(key in summary)) {
262
+ fail('G2', `report thiếu \`summary.${key}\` — không đối chiếu được độ tươi`,
263
+ `Counter bị đổi tên? Sửa bin/trace-schema.json \`gate.reconcile\` cho khớp\n`
264
+ + ` (và commands/validate-traces.tmpl khối \`summary\`).`);
265
+ continue;
266
+ }
267
+ if (summary[key] !== rows.length) {
268
+ fail('G2', `report CŨ — \`summary.${key}\` = ${summary[key]} nhưng sổ TSV đang có ${rows.length} scenario`,
269
+ [`Sổ đã thay đổi sau lần audit cuối (${report.generated_at || 'không rõ thời điểm'}).`,
270
+ `Mọi con số dưới đây — gồm cả các cờ 🔴 — nói về một trạng thái đã qua.`,
271
+ `Chạy /validate-traces rồi commit lại ${REPORT_BASENAME}.`].join('\n '));
272
+ }
273
+ }
274
+
275
+ // (c) soft — phụ thuộc quy ước đếm, nên WARN chứ không FAIL. Sai ở đây thì tệ nhất là
276
+ // ồn; ép cứng mà lệch quy ước thì cổng bị tắt — hỏng nặng hơn nhiều.
277
+ const byStatus = {};
278
+ for (const r of rows) byStatus[r.status] = (byStatus[r.status] || 0) + 1;
279
+
280
+ for (const [key, statusVal] of Object.entries(GATE.reconcile.soft || {})) {
281
+ if (!(key in summary)) continue;
282
+ const onDisk = byStatus[statusVal] || 0;
283
+ if (summary[key] !== onDisk) {
284
+ warn('G2', `\`summary.${key}\` = ${summary[key]} nhưng sổ có ${onDisk} row status=${statusVal}`,
285
+ `Report cũ, hoặc quy ước đếm khác. Không chặn — chạy /validate-traces để chắc.`);
286
+ }
287
+ }
288
+
289
+ // (d) per-SC (G48) — đối chiếu TỪNG scenario, không chỉ tổng số.
290
+ //
291
+ // So tổng bỏ lọt ca thường gặp nhất khi spec tiến hoá: một SC bị xoá + một SC được
292
+ // thêm trong cùng một lần ⇒ total_scs KHÔNG đổi ⇒ report cũ vẫn được coi là tươi.
293
+ // Khoá phải gồm platform: sc_id một mình không định danh được scenario.
294
+ const PS = GATE.reconcile.per_sc;
295
+ if (PS) {
296
+ const scs = flattenReportScenarios(report);
297
+ const hasPlatform = scs.length > 0 && scs.every(s => s[PS.key_fields[1]]);
298
+
299
+ if (!scs.length) {
300
+ warn('G2', 'report không kèm `prds[].ucs[].scenarios[]` — chỉ đối chiếu được tổng số',
301
+ 'Kiểm độ tươi đang ở mức yếu: một SC bị xoá + một SC được thêm sẽ không bị phát hiện.');
302
+ } else if (!hasPlatform) {
303
+ // Backward-compat: report sinh bởi version trước G48. Không fail — người dùng chưa
304
+ // chạy lại /validate-traces không phải lỗi của họ.
305
+ warn('G2', 'report thiếu `platform` trong `scenarios[]` — đối chiếu chỉ ở mức tổng số',
306
+ [`Report sinh bởi version trước G48. sc_id một mình không phân biệt được web/app,`,
307
+ `nên không khoá được từng scenario.`,
308
+ `Chạy lại /validate-traces bằng version hiện tại để bật đối chiếu đầy đủ.`].join('\n '));
309
+ } else {
310
+ const key = (o) => PS.key_fields.map(f => o[f]).join('@');
311
+ // Quy đổi ORPHANED: JSON cố ý chỉ có 4 giá trị status; row ORPHANED xuất ra là
312
+ // `status: DRIFT` + `orphaned: true` (validate-traces §Rules). Phải quy đổi TRƯỚC
313
+ // khi so, không thì mọi row ORPHANED đều báo lệch giả.
314
+ const norm = (s) => (s[PS.orphaned_flag] === true ? PS.orphaned_status : s[PS.compare]);
315
+
316
+ const inReport = new Map(scs.map(s => [key(s), norm(s)]));
317
+ const inBook = new Map(rows.map(r => [key(r), r[PS.compare]]));
318
+
319
+ const missing = [...inBook.keys()].filter(k => !inReport.has(k));
320
+ const extra = [...inReport.keys()].filter(k => !inBook.has(k));
321
+ const differ = [...inBook.entries()]
322
+ .filter(([k, v]) => inReport.has(k) && inReport.get(k) !== v)
323
+ .map(([k, v]) => `${k}: sổ=${v} report=${inReport.get(k)}`);
324
+
325
+ const show = (a) => a.slice(0, 6).join(' · ') + (a.length > 6 ? ` … +${a.length - 6}` : '');
326
+
327
+ if (missing.length || extra.length) {
328
+ fail('G2', `report CŨ — lệch ${missing.length + extra.length} scenario so với sổ`,
329
+ [missing.length ? `Có trong sổ, thiếu ở report: ${show(missing)}` : null,
330
+ extra.length ? `Có ở report, không còn trong sổ: ${show(extra)}` : null,
331
+ `Sổ đã thay đổi sau lần audit cuối (${report.generated_at || 'không rõ'}).`,
332
+ `Chạy /validate-traces rồi commit lại ${REPORT_BASENAME}.`].filter(Boolean).join('\n '));
333
+ } else if (differ.length) {
334
+ fail('G2', `report CŨ — ${differ.length} scenario khác trạng thái so với sổ`,
335
+ [show(differ),
336
+ `Cùng tập scenario nhưng status đã đổi ⇒ audit cuối nói về một trạng thái đã qua.`,
337
+ `Chạy /validate-traces rồi commit lại ${REPORT_BASENAME}.`].join('\n '));
338
+ }
339
+ }
340
+ }
341
+
342
+ if (!fails.some(f => f.stage === 'G2')) {
343
+ // `[].every()` là true — nên phải đòi CÓ scenario, không thì report rỗng sẽ khoe
344
+ // "đối chiếu từng SC" trong khi nó chẳng đối chiếu gì.
345
+ const scsNow = PS ? flattenReportScenarios(report) : [];
346
+ const PSok = scsNow.length > 0 && scsNow.every(s => s[PS.key_fields[1]]);
347
+ oks.push(`G2 report tươi — ${rows.length} scenario khớp sổ`
348
+ + (PSok ? ' (đối chiếu từng SC)' : ' (chỉ tổng số)')
349
+ + ` · audit lúc ${report.generated_at || '?'}`
350
+ + (domain !== 'all' ? ` · domain "${domain}"` : ''));
351
+ }
352
+ } else if (report && NO_RECONCILE) {
353
+ warn('G2', 'bỏ qua kiểm độ tươi (--no-reconcile) — report có thể đã cũ',
354
+ 'Cờ 🔴 dưới đây có thể nói về một trạng thái đã qua.');
355
+ }
356
+
357
+ // ── Tầng G3 — cờ 🔴 ───────────────────────────────────────────────────────────
358
+
359
+ const blocking = [];
360
+
361
+ if (report) {
362
+ const summary = report.summary || {};
363
+ const issues = report.issues || {};
364
+
365
+ for (const b of GATE.blocking) {
366
+ // Counter VẮNG MẶT không được coi là 0. `undefined > 0` là false ⇒ gate sẽ PASS trên
367
+ // code hỏng, im lặng. Đúng hình dạng G1 (consumer trỏ vào chỗ trống), và ở đây hậu quả
368
+ // là một PR hỏng được merge kèm dấu ✅.
369
+ if (!(b.counter in summary)) {
370
+ fail('G3', `report thiếu \`summary.${b.counter}\` — cờ này KHÔNG được kiểm`,
371
+ [`Không suy ra là 0: counter vắng mặt nghĩa là gate MÙ với cờ này, không phải sạch.`,
372
+ `Nguyên nhân: report sinh bởi version cũ, hoặc counter đã đổi tên.`,
373
+ `Sửa: chạy lại /validate-traces bằng version framework hiện tại. Nếu counter đổi tên`,
374
+ `thật thì cập nhật bin/trace-schema.json \`gate.blocking\` — self-check R9 canh chỗ đó.`].join('\n '));
375
+ continue;
376
+ }
377
+
378
+ const n = Number(summary[b.counter]) || 0;
379
+ if (n === 0) continue;
380
+
381
+ const rows = Array.isArray(issues[b.issues_key]) ? issues[b.issues_key] : [];
382
+ blocking.push({
383
+ counter: b.counter, count: n, why: b.why, issues_key: b.issues_key,
384
+ items: rows.map(r => ({
385
+ label: (b.label_fields || []).map(f => r[f]).filter(Boolean).join(' · ') || '(không rõ)',
386
+ fix: r.fix || null,
387
+ })),
388
+ });
389
+ }
390
+
391
+ if (blocking.length) {
392
+ const total = blocking.reduce((s, b) => s + b.count, 0);
393
+ fail('G3', `${total} vấn đề 🔴 — code đang hỏng, KHÔNG merge`, null);
394
+ } else if (!fails.length) {
395
+ oks.push(`G3 không có cờ 🔴 — ${GATE.blocking.map(b => b.counter).length}/${GATE.blocking.length} cờ chặn đều bằng 0`);
396
+ }
397
+ }
398
+
399
+ // ── Output ────────────────────────────────────────────────────────────────────
400
+
401
+ const passed = fails.length === 0;
402
+
403
+ if (AS_JSON) {
404
+ console.log(JSON.stringify({
405
+ ok: passed,
406
+ report: reportPath ? rel(reportPath) : null,
407
+ generated_at: report && report.generated_at || null,
408
+ blocking, fails, warns,
409
+ }, null, 2));
410
+ process.exit(passed || WARN_ONLY ? 0 : 1);
411
+ }
412
+
413
+ console.log('');
414
+ console.log('Trace gate (bin/trace-schema.json → gate) ...');
415
+ console.log('');
416
+
417
+ for (const o of oks) console.log(` ✅ ${o}`);
418
+ if (oks.length) console.log('');
419
+
420
+ for (const w of warns) {
421
+ console.log(` ⚠️ [${w.stage}] ${w.msg}`);
422
+ if (w.hint) console.log(` ${w.hint}`);
423
+ }
424
+ if (warns.length) console.log('');
425
+
426
+ for (const f of fails) {
427
+ console.log(` 🔴 [${f.stage}] ${f.msg}`);
428
+ if (f.hint) console.log(` ${f.hint}`);
429
+ console.log('');
430
+ }
431
+
432
+ for (const b of blocking) {
433
+ console.log(` 🔴 ${b.counter} = ${b.count} — ${b.why}`);
434
+ for (const it of b.items.slice(0, 8)) {
435
+ console.log(` • ${it.label}`);
436
+ if (it.fix) console.log(` → ${it.fix}`);
437
+ }
438
+ if (b.items.length > 8) console.log(` … và ${b.items.length - 8} nữa`);
439
+ if (!b.items.length) {
440
+ console.log(` (report không kèm chi tiết trong \`issues.${b.issues_key}\` — chạy /validate-traces để xem)`);
441
+ }
442
+ console.log('');
443
+ }
444
+
445
+ if (passed) {
446
+ console.log(' ✅ TRACE GATE PASSED');
447
+ console.log('');
448
+ // In giới hạn MỖI LẦN, kể cả khi xanh. Một cổng không nói rõ nó chưa chứng minh gì
449
+ // sẽ được người đọc hiểu là đã chứng minh mọi thứ — và đó là cách một cổng thật biến
450
+ // thành một cổng giả mà không ai sửa dòng code nào.
451
+ console.log(' Cổng này chứng minh: report khớp SỔ, và sổ không có cờ 🔴.');
452
+ console.log(' Cổng này KHÔNG chứng minh: sổ khớp CODE — việc đó cần /validate-traces');
453
+ console.log(' (quét tag trong source, đọc .feature, so version). Sửa code mà không đụng');
454
+ console.log(' sổ thì cổng vẫn xanh. Cho CI đầy đủ: chạy /validate-traces trước bước này.');
455
+ } else {
456
+ console.log(' 🔴 TRACE GATE FAILED');
457
+ console.log('');
458
+ console.log(' Đây là lớp lỗi mà build xanh + test từng-UC xanh KHÔNG phát hiện được:');
459
+ console.log(' luồng ghép chạy vào hàm rỗng, hoặc code trỏ vào scenario đã bị xoá.');
460
+ if (WARN_ONLY) console.log(' (--warn-only: exit 0 dù có lỗi)');
461
+ }
462
+ console.log('');
463
+
464
+ process.exit(passed || WARN_ONLY ? 0 : 1);