@educa-corp/sdd-framework 0.5.0 → 0.7.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 (243) hide show
  1. package/bin/build.js +113 -19
  2. package/bin/gate-trace.js +487 -0
  3. package/bin/index.js +445 -146
  4. package/bin/lint-trace.js +643 -0
  5. package/bin/self-check.js +804 -2
  6. package/bin/trace-schema.json +621 -10
  7. package/core/FRAMEWORK_VERSION +1 -1
  8. package/core/README.md +20 -0
  9. package/core/commands/amend-prd.md +518 -0
  10. package/core/commands/debug.md +123 -511
  11. package/core/commands/define-product.md +86 -510
  12. package/core/commands/dev-gen-test.md +86 -510
  13. package/core/commands/dev-run-test.md +133 -519
  14. package/core/commands/dev-smoke-test.md +86 -510
  15. package/core/commands/extend-prd.md +128 -522
  16. package/core/commands/fix-bug.md +118 -509
  17. package/core/commands/generate-architecture.md +94 -515
  18. package/core/commands/generate-bdd.md +128 -513
  19. package/core/commands/generate-code.md +119 -510
  20. package/core/commands/generate-design-spec.md +86 -510
  21. package/core/commands/generate-prd.md +89 -510
  22. package/core/commands/generate-spec-manifest.md +86 -510
  23. package/core/commands/generate-tech-docs.md +120 -512
  24. package/core/commands/learn.md +172 -496
  25. package/core/commands/map-testids.md +86 -510
  26. package/core/commands/propose-scenario.md +86 -510
  27. package/core/commands/qc-analyze.md +86 -510
  28. package/core/commands/qc-design-test.md +86 -510
  29. package/core/commands/qc-plan.md +86 -510
  30. package/core/commands/qc-report.md +86 -510
  31. package/core/commands/qc-review.md +86 -510
  32. package/core/commands/qc-run-test.md +115 -513
  33. package/core/commands/refine-prd.md +112 -522
  34. package/core/commands/report-bug.md +86 -510
  35. package/core/commands/review-code.md +123 -511
  36. package/core/commands/review-context.md +136 -522
  37. package/core/commands/review-tech-docs.md +90 -511
  38. package/core/commands/setup-ai-first.md +166 -138
  39. package/core/commands/sync.md +155 -107
  40. package/core/commands/update-framework.md +16 -103
  41. package/core/commands/validate-traces.md +426 -511
  42. package/core/hooks/data-guard.js +174 -83
  43. package/core/hooks/settings.json +2 -1
  44. package/core/rules/workflow.md +64 -4
  45. package/core/steps/capture-lesson.md +34 -1
  46. package/core/steps/context-loader.md +50 -8
  47. package/core/steps/gate.md +92 -35
  48. package/core/steps/report-footer.md +23 -0
  49. package/core/templates/README.md +24 -1
  50. package/core/templates/ci/trace-gate.yml +146 -0
  51. package/core/templates/feature.template +1 -1
  52. package/core/templates/hooks/pre-push +61 -0
  53. package/docs/02-concepts/architecture.md +61 -6
  54. package/docs/02-concepts/traceability.md +57 -0
  55. package/docs/03-guides/architect.md +63 -0
  56. package/docs/04-reference/commands.md +148 -134
  57. package/docs/04-reference/model-selection.md +32 -19
  58. package/docs/04-reference/trace-schema.md +39 -0
  59. package/docs/explain/02b-extend-prd.md +1 -1
  60. package/docs/explain/02c-amend-prd.md +152 -0
  61. package/docs/explain/21-validate-traces.md +2 -1
  62. package/docs/explain/27-learn.md +5 -3
  63. package/docs/explain/28-sync.md +25 -0
  64. package/docs/explain/README.md +136 -135
  65. package/package.json +5 -9
  66. package/commands/debug.md +0 -917
  67. package/commands/debug.tmpl +0 -257
  68. package/commands/define-product.md +0 -862
  69. package/commands/define-product.tmpl +0 -225
  70. package/commands/dev-gen-test.md +0 -1124
  71. package/commands/dev-gen-test.tmpl +0 -490
  72. package/commands/dev-run-test.md +0 -859
  73. package/commands/dev-run-test.tmpl +0 -225
  74. package/commands/dev-smoke-test.md +0 -798
  75. package/commands/dev-smoke-test.tmpl +0 -217
  76. package/commands/extend-prd.md +0 -907
  77. package/commands/extend-prd.tmpl +0 -270
  78. package/commands/fix-bug.md +0 -910
  79. package/commands/fix-bug.tmpl +0 -197
  80. package/commands/generate-architecture.md +0 -775
  81. package/commands/generate-architecture.tmpl +0 -194
  82. package/commands/generate-bdd.md +0 -1347
  83. package/commands/generate-bdd.tmpl +0 -590
  84. package/commands/generate-code.md +0 -1283
  85. package/commands/generate-code.tmpl +0 -649
  86. package/commands/generate-design-spec.md +0 -1161
  87. package/commands/generate-design-spec.tmpl +0 -524
  88. package/commands/generate-prd.md +0 -1143
  89. package/commands/generate-prd.tmpl +0 -223
  90. package/commands/generate-spec-manifest.md +0 -745
  91. package/commands/generate-spec-manifest.tmpl +0 -164
  92. package/commands/generate-tech-docs.md +0 -1344
  93. package/commands/generate-tech-docs.tmpl +0 -273
  94. package/commands/learn.md +0 -723
  95. package/commands/learn.tmpl +0 -63
  96. package/commands/map-testids.md +0 -662
  97. package/commands/map-testids.tmpl +0 -81
  98. package/commands/propose-scenario.md +0 -783
  99. package/commands/propose-scenario.tmpl +0 -202
  100. package/commands/qc-analyze.md +0 -693
  101. package/commands/qc-analyze.tmpl +0 -112
  102. package/commands/qc-design-test.md +0 -650
  103. package/commands/qc-design-test.tmpl +0 -69
  104. package/commands/qc-plan.md +0 -630
  105. package/commands/qc-plan.tmpl +0 -49
  106. package/commands/qc-report.md +0 -641
  107. package/commands/qc-report.tmpl +0 -60
  108. package/commands/qc-review.md +0 -634
  109. package/commands/qc-review.tmpl +0 -53
  110. package/commands/qc-run-test.md +0 -750
  111. package/commands/qc-run-test.tmpl +0 -116
  112. package/commands/refine-prd.md +0 -1074
  113. package/commands/refine-prd.tmpl +0 -278
  114. package/commands/report-bug.md +0 -729
  115. package/commands/report-bug.tmpl +0 -148
  116. package/commands/review-code.md +0 -803
  117. package/commands/review-code.tmpl +0 -143
  118. package/commands/review-context.md +0 -1323
  119. package/commands/review-context.tmpl +0 -527
  120. package/commands/review-tech-docs.md +0 -982
  121. package/commands/review-tech-docs.tmpl +0 -401
  122. package/commands/setup-ai-first.md +0 -574
  123. package/commands/setup-ai-first.tmpl +0 -378
  124. package/commands/sync.md +0 -486
  125. package/commands/sync.tmpl +0 -384
  126. package/commands/update-framework.md +0 -290
  127. package/commands/update-framework.tmpl +0 -188
  128. package/commands/validate-traces.md +0 -1435
  129. package/commands/validate-traces.tmpl +0 -854
  130. package/hooks/data-guard.js +0 -141
  131. package/hooks/settings.json +0 -18
  132. package/modules/android-compose/module.yaml +0 -13
  133. package/modules/android-compose/stack-profile.yaml +0 -57
  134. package/modules/angular/architecture-snippets/component-patterns.md +0 -187
  135. package/modules/angular/module.yaml +0 -6
  136. package/modules/angular/stack-profile.yaml +0 -38
  137. package/modules/context-engineering/architecture-snippets/context-design.md +0 -119
  138. package/modules/context-engineering/module.yaml +0 -9
  139. package/modules/context-engineering/stack-profile.yaml +0 -61
  140. package/modules/dotnet/architecture-snippets/clean-arch.md +0 -160
  141. package/modules/dotnet/module.yaml +0 -6
  142. package/modules/dotnet/stack-profile.yaml +0 -50
  143. package/modules/flutter/module.yaml +0 -14
  144. package/modules/flutter/stack-profile.yaml +0 -59
  145. package/modules/golang/architecture-snippets/domain-layout.md +0 -283
  146. package/modules/golang/module.yaml +0 -6
  147. package/modules/golang/stack-profile.yaml +0 -40
  148. package/modules/ios-swiftui/module.yaml +0 -13
  149. package/modules/ios-swiftui/stack-profile.yaml +0 -55
  150. package/modules/java-spring/architecture-snippets/layered-arch.md +0 -201
  151. package/modules/java-spring/module.yaml +0 -15
  152. package/modules/java-spring/stack-profile.yaml +0 -28
  153. package/modules/nextjs/architecture-snippets/app-router-patterns.md +0 -269
  154. package/modules/nextjs/module.yaml +0 -14
  155. package/modules/nextjs/stack-profile.yaml +0 -74
  156. package/modules/nuxt/module.yaml +0 -14
  157. package/modules/nuxt/stack-profile.yaml +0 -58
  158. package/modules/phaser-game/architecture-snippets/phaser-scene-patterns.md +0 -646
  159. package/modules/phaser-game/module.yaml +0 -15
  160. package/modules/phaser-game/stack-profile.yaml +0 -90
  161. package/modules/php-laravel/architecture-snippets/service-repository.md +0 -302
  162. package/modules/php-laravel/module.yaml +0 -15
  163. package/modules/php-laravel/stack-profile.yaml +0 -56
  164. package/modules/qc-playwright/stack-profile.yaml +0 -66
  165. package/modules/react/architecture-snippets/hooks-query-patterns.md +0 -254
  166. package/modules/react/module.yaml +0 -14
  167. package/modules/react/stack-profile.yaml +0 -63
  168. package/modules/react-native/module.yaml +0 -14
  169. package/modules/react-native/stack-profile.yaml +0 -56
  170. package/modules/vue/module.yaml +0 -14
  171. package/modules/vue/stack-profile.yaml +0 -65
  172. package/rules/data-protection.md +0 -80
  173. package/rules/workflow.md +0 -73
  174. package/scripts/init.sh +0 -49
  175. package/scripts/upgrade.sh +0 -94
  176. package/skills/code/SKILL.md +0 -19
  177. package/skills/code/SKILL.tmpl +0 -19
  178. package/skills/debug/SKILL.md +0 -19
  179. package/skills/debug/SKILL.tmpl +0 -19
  180. package/skills/design-spec/SKILL.md +0 -11
  181. package/skills/design-spec/SKILL.tmpl +0 -11
  182. package/skills/discovery/SKILL.md +0 -14
  183. package/skills/discovery/SKILL.tmpl +0 -14
  184. package/skills/prd/SKILL.md +0 -19
  185. package/skills/prd/SKILL.tmpl +0 -19
  186. package/skills/qc/qa-analyst/DOC_GAPS.template.md +0 -63
  187. package/skills/qc/qa-analyst/acceptance-criteria.md +0 -60
  188. package/skills/qc/qa-analyst/business-rules.md +0 -59
  189. package/skills/qc/qa-analyst/data-flow.md +0 -64
  190. package/skills/qc/qa-analyst/spec-breakdown.md +0 -61
  191. package/skills/qc/qa-designer/e2e/journey.md +0 -41
  192. package/skills/qc/qa-designer/exploratory/charter.md +0 -68
  193. package/skills/qc/qa-designer/exploratory/explore-to-functional.md +0 -43
  194. package/skills/qc/qa-designer/functional/api.md +0 -45
  195. package/skills/qc/qa-designer/functional/gui-feature.md +0 -46
  196. package/skills/qc/qa-designer/functional/gui-screen.md +0 -52
  197. package/skills/qc/qa-designer/integration/api.md +0 -42
  198. package/skills/qc/qa-designer/integration/db.md +0 -39
  199. package/skills/qc/qa-designer/integration/gui.md +0 -40
  200. package/skills/qc/qa-designer/integration/kafka.md +0 -40
  201. package/skills/qc/qa-designer/non-functional.md +0 -40
  202. package/skills/qc/qa-planner/test-plan.md +0 -120
  203. package/skills/qc/qa-reviewer/script/e2e.md +0 -87
  204. package/skills/qc/qa-reviewer/script/exploratory.md +0 -45
  205. package/skills/qc/qa-reviewer/script/functional.md +0 -101
  206. package/skills/qc/qa-reviewer/script/integration.md +0 -91
  207. package/skills/qc/qa-reviewer/script/non-functional.md +0 -126
  208. package/skills/qc/qa-reviewer/test-case/e2e.md +0 -73
  209. package/skills/qc/qa-reviewer/test-case/exploratory.md +0 -43
  210. package/skills/qc/qa-reviewer/test-case/functional.md +0 -76
  211. package/skills/qc/qa-reviewer/test-case/integration.md +0 -69
  212. package/skills/qc/qa-reviewer/test-case/non-functional.md +0 -73
  213. package/skills/qc/qa-runner/e2e.md +0 -49
  214. package/skills/qc/qa-runner/exploratory/session.md +0 -36
  215. package/skills/qc/qa-runner/functional/api.md +0 -35
  216. package/skills/qc/qa-runner/functional/gui-feature.md +0 -51
  217. package/skills/qc/qa-runner/functional/gui-screen.md +0 -55
  218. package/skills/qc/qa-runner/integration.md +0 -47
  219. package/skills/qc/qa-runner/non-functional.md +0 -49
  220. package/skills/qc/qa-runner/report/report.md +0 -37
  221. package/skills/setup-ai-first/SKILL.md +0 -19
  222. package/skills/setup-ai-first/SKILL.tmpl +0 -19
  223. package/skills/spec/SKILL.md +0 -19
  224. package/skills/spec/SKILL.tmpl +0 -19
  225. package/skills/test/SKILL.md +0 -18
  226. package/skills/test/SKILL.tmpl +0 -18
  227. package/steps/business-language.md +0 -56
  228. package/steps/capture-lesson.md +0 -79
  229. package/steps/context-loader.md +0 -385
  230. package/steps/gate.md +0 -94
  231. package/steps/report-footer.md +0 -102
  232. package/steps/review-fanout.md +0 -159
  233. package/steps/spawn-agent.md +0 -129
  234. package/steps/trace-mirror.md +0 -53
  235. package/templates/README.md +0 -47
  236. package/templates/architecture.template.md +0 -394
  237. package/templates/design-spec.template.md +0 -217
  238. package/templates/feature.template +0 -123
  239. package/templates/platform-guide.template.md +0 -145
  240. package/templates/prd.template.md +0 -283
  241. package/templates/product-definition.template.md +0 -188
  242. package/templates/project-context.yaml +0 -212
  243. package/templates/tech-design.template.md +0 -490
@@ -0,0 +1,643 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * lint-trace — kiểm SỔ SÁCH THẬT (.trace/**) đối chiếu bin/trace-schema.json.
4
+ *
5
+ * Vì sao tồn tại (GAPS-v3 G38): bin/self-check.js canh CONTRACT — nó mở commands/*.tmpl
6
+ * và kiểm "lệnh có gọi đúng tên cột không". Nó không bao giờ mở một file .tsv thật.
7
+ * Trong khi đó sổ trace 24 cột được một LLM ghi BẰNG TAY, hàng chục lần mỗi feature,
8
+ * rồi được đọc lại bởi CÙNG một loại tác nhân (/validate-traces cũng là lệnh LLM).
9
+ * Không có mắt xác định nào trong vòng đó.
10
+ *
11
+ * Ca thuần khiết nhất: LLM quên một dấu `—` ở ô 17 (fe_tech_doc_revision — ô hay rỗng,
12
+ * nằm sát một ô revision khác). Mọi ô sau đó dồn sang trái một bậc. Ô 21 `status` —
13
+ * ô quyết định tô xanh/đỏ trên dashboard — nhận giá trị `2026-08-16`. Không gì báo lỗi.
14
+ * /validate-traces đọc tiếp, tính lại status, GHI NGƯỢC VÀO FILE → nướng vĩnh viễn phần
15
+ * dồn lệch, và giờ trông còn hợp lệ hơn trước. Sổ này "không regenerate được"
16
+ * (validate-traces.tmpl:645) nên phát hiện muộn là khôi phục bằng tay.
17
+ *
18
+ * Rule:
19
+ * T1 header khớp tsv_columns (cho phép vắng cột ở ĐUÔI — backward-compat) → ERROR
20
+ * + dòng giữa file LẶP LẠI header (artifact union merge) → ERROR
21
+ * T2 mọi row đúng số ô bằng header (+ chẩn đoán chỗ lệch) → ERROR
22
+ * T3 ô có enum ∈ vocabularies (theo binding khai trong schema) → ERROR
23
+ * T4 sc_id unique trong mỗi sổ → ERROR
24
+ * T5 tên file khớp {UC-ID}-{platform}.tsv → ERROR
25
+ * T6 ô ngày parse được thành ngày hợp lệ → ERROR
26
+ * T7 không có marker conflict git trong bất kỳ file .trace/** → ERROR
27
+ * T8 trace-history*.jsonl: mỗi dòng một JSON object đủ key bắt buộc → ERROR
28
+ * T9 mỗi sổ TSV có .feature đúng platform tồn tại (sổ mồ côi) → WARN
29
+ * T10 sổ có luật merge (merge=union + text eol=lf) — hỏi `git check-attr` → WARN
30
+ *
31
+ * T4 + T7 + T10 là cụm G40 (luật merge git): `merge=union` đổi *mất-row-im-lặng* thành
32
+ * *trùng-row-bắt-được*, nên union KHÔNG được bật mà thiếu T4. T10 canh chính luật đó — đặt
33
+ * ở đây chứ không chỉ ở /sync để CI kiểm được, thay vì phụ thuộc ai tự nguyện chạy lệnh chat.
34
+ *
35
+ * KHÔNG phát biểu lại contract: header, enum, tên bảng phụ đều DẪN XUẤT từ
36
+ * bin/trace-schema.json. Thêm cột/vocabulary mới vào schema mà quên dạy file này →
37
+ * self-check.js R8 fail build.
38
+ *
39
+ * Chạy: node bin/lint-trace.js [--trace DIR[,DIR]] [--specs DIR] [--warn-only] [--json]
40
+ * exit 1 nếu có ERROR (0 với --warn-only)
41
+ */
42
+
43
+ const fs = require('fs');
44
+ const path = require('path');
45
+
46
+ const SCHEMA_PATH = path.join(__dirname, 'trace-schema.json');
47
+ const schema = JSON.parse(fs.readFileSync(SCHEMA_PATH, 'utf8'));
48
+
49
+ // ── CLI ───────────────────────────────────────────────────────────────────────
50
+
51
+ const argv = process.argv.slice(2);
52
+ const flag = (name, def) => {
53
+ const i = argv.indexOf(name);
54
+ return i !== -1 && argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : def;
55
+ };
56
+ const WARN_ONLY = argv.includes('--warn-only');
57
+ const AS_JSON = argv.includes('--json');
58
+ const TRACE_DIRS = flag('--trace', '.trace').split(',').map(s => s.trim()).filter(Boolean);
59
+ const SPECS_DIR = flag('--specs', 'specs');
60
+
61
+ // ── Contract dẫn xuất từ schema ───────────────────────────────────────────────
62
+ //
63
+ // tsv_columns KHÔNG xếp theo thứ tự cột trong file schema (ba entry cuối là n:23,
64
+ // n:24, n:22). `n` là thứ tự authoritative — sort theo nó, đừng dùng thứ tự array.
65
+ // self-check.js R8c canh việc `n` là 1..N liền mạch, không trùng.
66
+ const CANON = schema.tsv_columns
67
+ .slice()
68
+ .sort((a, b) => a.n - b.n)
69
+ .map(c => c.name);
70
+
71
+ const AUX = schema.aux_tables.find(t => t.name === '_seams.tsv') || null;
72
+
73
+ /**
74
+ * Binding enum → nơi kiểm, đọc từ vocabularies. Mỗi vocabulary khai một trong:
75
+ * column → một cột TSV chính
76
+ * columns → nhiều cột TSV chính
77
+ * aux_table + aux_column → một cột của bảng phụ
78
+ * filename_segment → một đoạn trong tên file
79
+ * $lint: "n/a — …" → cố ý không thuộc phạm vi lint (phải nêu lý do)
80
+ * Thiếu tất cả → self-check.js R8a fail build.
81
+ */
82
+ function buildEnumBindings() {
83
+ const mainCols = new Map(); // tên cột → { values, vocab }
84
+ const auxCols = new Map();
85
+ let platformValues = null;
86
+
87
+ for (const [vocab, def] of Object.entries(schema.vocabularies)) {
88
+ if (vocab.startsWith('$') || !def || !def.values) continue;
89
+ const cols = def.columns || (def.column ? [def.column] : []);
90
+ for (const c of cols) mainCols.set(c, { values: def.values, vocab });
91
+ if (def.aux_column) auxCols.set(def.aux_column, { values: def.values, vocab });
92
+ if (def.filename_segment) platformValues = def.values;
93
+ }
94
+ return { mainCols, auxCols, platformValues };
95
+ }
96
+ const ENUMS = buildEnumBindings();
97
+
98
+ // ── T12 — quyền khẳng định `pass` (G55) ───────────────────────────────────────
99
+ //
100
+ // T1–T8 kiểm HÌNH DẠNG TỪNG Ô (đủ cột · giá trị trong vocabulary · ngày parse được). Không
101
+ // rule nào kiểm NHẤT QUÁN GIỮA CÁC Ô — nên `status=DRIFT` + `dev_selftest=pass` là một sổ
102
+ // HOÀN TOÀN HỢP LỆ với cả 11 rule cũ. T12 là rule đầu tiên nhìn nhiều ô cùng lúc.
103
+ //
104
+ // Vì sao cần dù /dev-run-test đã có guard: guard đó chặn NGUỒN. T12 bắt TRẠNG THÁI, bất kể
105
+ // ai ghi ra — sổ sửa tay, sổ sinh bởi version framework cũ, hay một lệnh tương lai quên luật.
106
+ // Hai tầng độc lập; đây là tầng chạy được trong CI mà không cần LLM.
107
+ //
108
+ // Đọc từ schema, KHÔNG hard-code: self-check R14 canh việc rule id ở đây khớp
109
+ // `positive_assertion_guards.lint_rule`.
110
+ const PAG = schema.positive_assertion_guards || null;
111
+
112
+ // Ô "chưa biết" — framework dùng `—` (em dash) làm placeholder ở mọi cột.
113
+ // Rỗng cũng chấp nhận: TSV cũ có cột vắng, và union merge có thể để ô trống.
114
+ const UNKNOWN = new Set(['—', '-', '', 'n/a', 'N/A']);
115
+ const isUnknown = v => UNKNOWN.has(String(v).trim());
116
+
117
+ // Cột mang ngày: hậu tố _at, hoặc last_updated. Dẫn xuất từ tên, không hard-code danh sách.
118
+ const isDateCol = name => /_at$/.test(name) || name === 'last_updated';
119
+ const isCountCol = name => /_count$/.test(name);
120
+
121
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:?\d{2})?)?$/;
122
+
123
+ function isValidDate(v) {
124
+ const s = String(v).trim();
125
+ if (!DATE_RE.test(s)) return false;
126
+ const [y, m, d] = s.slice(0, 10).split('-').map(Number);
127
+ const dt = new Date(Date.UTC(y, m - 1, d));
128
+ return dt.getUTCFullYear() === y && dt.getUTCMonth() === m - 1 && dt.getUTCDate() === d;
129
+ }
130
+
131
+ // ── Thu findings ──────────────────────────────────────────────────────────────
132
+
133
+ const errors = [];
134
+ const warns = [];
135
+ const infos = [];
136
+ const err = (rule, where, msg, hint) => errors.push({ rule, where, msg, hint });
137
+ const warn = (rule, where, msg, hint) => warns.push({ rule, where, msg, hint });
138
+
139
+ // ── Đi cây thư mục ────────────────────────────────────────────────────────────
140
+
141
+ function walk(dir, out = []) {
142
+ if (!fs.existsSync(dir)) return out;
143
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
144
+ const p = path.join(dir, e.name);
145
+ if (e.isDirectory()) walk(p, out);
146
+ else out.push(p);
147
+ }
148
+ return out;
149
+ }
150
+
151
+ const rel = p => path.relative(process.cwd(), p).replace(/\\/g, '/');
152
+
153
+ // Tách dòng, giữ số dòng thật (1-based) để báo lỗi trỏ đúng chỗ.
154
+ function readLines(file) {
155
+ return fs.readFileSync(file, 'utf8').replace(/\r\n/g, '\n').split('\n');
156
+ }
157
+
158
+ // ── T7 — marker conflict git ──────────────────────────────────────────────────
159
+ //
160
+ // Chạy TRƯỚC mọi rule khác và trên MỌI file: một marker lọt vào TSV làm T1/T2 báo
161
+ // một lỗi vô nghĩa ("row có 1 ô") che mất nguyên nhân thật. Có marker thì nói thẳng.
162
+ const CONFLICT_RE = /^(<{7}|={7}|>{7})(\s|$)/;
163
+
164
+ function lintConflictMarkers(file) {
165
+ const lines = readLines(file);
166
+ const hits = [];
167
+ lines.forEach((ln, i) => { if (CONFLICT_RE.test(ln)) hits.push({ line: i + 1, text: ln.slice(0, 60) }); });
168
+ if (!hits.length) return false;
169
+ err('T7', `${rel(file)}:${hits[0].line}`,
170
+ `${hits.length} marker conflict git còn trong sổ trace`,
171
+ [`Dòng: ${hits.map(h => h.line).join(', ')}`,
172
+ `Sổ trace là dữ liệu KHÔNG regenerate được. Giải conflict bằng cách GIỮ CẢ HAI BÊN,`,
173
+ `rồi chạy lại lint (T4 bắt row trùng) và /validate-traces để reconcile về một row.`,
174
+ `Đừng bao giờ giải conflict trace bằng "take mine/theirs" — mất row là mất vĩnh viễn.`].join('\n '));
175
+ return true;
176
+ }
177
+
178
+ // ── T1/T2/T3/T4/T6 — sổ TSV chính ─────────────────────────────────────────────
179
+
180
+ /**
181
+ * Chẩn đoán chỗ lệch khi số ô sai: tìm ô ĐẦU TIÊN mang giá trị không đúng kiểu của
182
+ * cột đó. Đây gần như luôn là chỗ ngay sau dấu tab bị thiếu/thừa. Không đoán chắc,
183
+ * chỉ chỉ hướng — người đọc còn phải so với row lành bên cạnh.
184
+ */
185
+ function diagnoseShift(header, fields) {
186
+ const delta = fields.length - header.length;
187
+ const gap = delta < 0
188
+ ? `Thiếu ${-delta} ô — nghi quên một dấu tab (hoặc quên placeholder \`—\` cho một ô rỗng).`
189
+ : `Thừa ${delta} ô — nghi một giá trị có chứa tab, hoặc thừa dấu tab.`;
190
+
191
+ for (let i = 0; i < Math.min(header.length, fields.length); i++) {
192
+ const col = header[i];
193
+ const v = fields[i];
194
+ if (isUnknown(v)) continue;
195
+ const enumDef = ENUMS.mainCols.get(col);
196
+ const bad =
197
+ (enumDef && !enumDef.values.includes(String(v).trim())) ||
198
+ (isDateCol(col) && !isValidDate(v)) ||
199
+ (isCountCol(col) && !/^\d+$/.test(String(v).trim()));
200
+ if (bad) {
201
+ return `${gap}\n` +
202
+ ` Dấu hiệu đầu tiên: ô ${i + 1} (${col}) đang chứa "${String(v).slice(0, 30)}" —` +
203
+ ` không đúng kiểu của cột đó,\n` +
204
+ ` nên chỗ lệch nằm ở đâu đó TRƯỚC ô ${i + 1}. So với một row lành trong cùng file.`;
205
+ }
206
+ }
207
+ return `${gap}\n So với một row lành trong cùng file để thấy chỗ thiếu/thừa tab.`;
208
+ }
209
+
210
+ function lintBook(file) {
211
+ const lines = readLines(file);
212
+ const nonEmpty = lines.map((t, i) => ({ t, n: i + 1 })).filter(x => x.t.trim() !== '');
213
+ if (!nonEmpty.length) {
214
+ warn('T1', rel(file), 'Sổ rỗng — không có cả header');
215
+ return;
216
+ }
217
+
218
+ // ── T1 — header ─────────────────────────────────────────────────────────────
219
+ const header = nonEmpty[0].t.split('\t').map(s => s.trim());
220
+
221
+ if (header.length > CANON.length) {
222
+ err('T1', `${rel(file)}:${nonEmpty[0].n}`,
223
+ `header có ${header.length} cột, contract khai ${CANON.length}`,
224
+ `Cột lạ: ${header.slice(CANON.length).join(', ')}\n` +
225
+ ` Thêm cột thật thì khai vào bin/trace-schema.json TRƯỚC (rules/workflow.md §Trace Contract).`);
226
+ return; // header sai thì mọi kiểm theo cột bên dưới đều vô nghĩa
227
+ }
228
+
229
+ let headerOk = true;
230
+ for (let i = 0; i < header.length; i++) {
231
+ if (header[i] !== CANON[i]) {
232
+ err('T1', `${rel(file)}:${nonEmpty[0].n}`,
233
+ `cột ${i + 1} tên là "${header[i]}", contract khai "${CANON[i]}"`,
234
+ `Header đúng (${CANON.length} cột, cho phép vắng ở ĐUÔI):\n ${CANON.join(' → ')}`);
235
+ headerOk = false;
236
+ break;
237
+ }
238
+ }
239
+ if (!headerOk) return;
240
+
241
+ if (header.length < CANON.length) {
242
+ infos.push(`${rel(file)} — header ${header.length}/${CANON.length} cột ` +
243
+ `(vắng ở đuôi: ${CANON.slice(header.length).join(', ')}). Backward-compat OK; ` +
244
+ `/validate-traces sẽ nâng header ở lần ghi tới.`);
245
+ }
246
+
247
+ // ── T2/T3/T4/T6 — từng row ─────────────────────────────────────────────────
248
+ const headerLine = nonEmpty[0].t.trim();
249
+ const seen = new Map();
250
+
251
+ for (const { t, n } of nonEmpty.slice(1)) {
252
+ const f = t.split('\t');
253
+ const at = `${rel(file)}:${n}`;
254
+
255
+ // Header lặp lại giữa file — artifact union merge thường gặp NHẤT. Không có check
256
+ // riêng thì nó hiện ra dưới dạng 5-6 lỗi T3/T6 rời rạc ("ô 21 (status) = 'status'"),
257
+ // đúng nhưng người đọc phải tự ghép lại mới hiểu chuyện gì đã xảy ra.
258
+ if (t.trim() === headerLine) {
259
+ err('T1', at,
260
+ `dòng này LẶP LẠI header — sổ đã bị gộp nhân đôi`,
261
+ `Gần như luôn là union merge trên file có line-ending lệch: một máy ghi CRLF,\n` +
262
+ ` git thấy MỌI dòng đã đổi, union giữ cả hai bản ⇒ nhân đôi cả file.\n` +
263
+ ` Sửa: xoá phần lặp (T4 bên dưới chỉ ra row nào trùng), rồi CHẶN tái diễn bằng\n` +
264
+ ` \`text eol=lf\` trong ${path.dirname(rel(file)).split('/')[0]}/.gitattributes —\n` +
265
+ ` /sync Step 4c kiểm và tạo hộ.`);
266
+ continue; // đừng báo thêm 6 lỗi T3/T6 cho cùng một dòng này
267
+ }
268
+
269
+ // T2
270
+ if (f.length !== header.length) {
271
+ err('T2', at,
272
+ `row có ${f.length} ô, header có ${header.length}`,
273
+ diagnoseShift(header, f));
274
+ continue; // ô đã lệch cột — kiểm enum/ngày theo vị trí sẽ báo lỗi rác
275
+ }
276
+
277
+ // T4
278
+ const scIdIdx = header.indexOf('sc_id');
279
+ if (scIdIdx !== -1) {
280
+ const id = f[scIdIdx].trim();
281
+ if (id && !isUnknown(id)) {
282
+ if (seen.has(id)) {
283
+ err('T4', at,
284
+ `sc_id "${id}" trùng với dòng ${seen.get(id)}`,
285
+ `Một sc_id = một row trong mỗi sổ. Nguyên nhân thường gặp: union merge vừa gộp\n` +
286
+ ` hai nhánh cùng ghi scenario này (đúng như thiết kế — xem .gitattributes).\n` +
287
+ ` Giữ row mới hơn theo last_updated, rồi /validate-traces reconcile.`);
288
+ } else {
289
+ seen.set(id, n);
290
+ }
291
+ }
292
+ }
293
+
294
+ // T3 + T6
295
+ for (let i = 0; i < header.length; i++) {
296
+ const col = header[i];
297
+ const v = f[i];
298
+ if (isUnknown(v)) continue;
299
+
300
+ const enumDef = ENUMS.mainCols.get(col);
301
+ if (enumDef && !enumDef.values.includes(String(v).trim())) {
302
+ err('T3', at,
303
+ `ô ${i + 1} (${col}) = "${String(v).slice(0, 40)}" không thuộc vocabulary "${enumDef.vocab}"`,
304
+ `Giá trị cho phép: ${enumDef.values.join(' | ')} (hoặc — nếu chưa biết)`);
305
+ }
306
+
307
+ if (isDateCol(col) && !isValidDate(v)) {
308
+ err('T6', at,
309
+ `ô ${i + 1} (${col}) = "${String(v).slice(0, 40)}" không phải ngày hợp lệ`,
310
+ `Định dạng: YYYY-MM-DD hoặc ISO-8601 đầy đủ (hoặc — nếu chưa biết)`);
311
+ }
312
+ }
313
+
314
+ // T12 — row không được vừa `status` ∈ {DRIFT, ORPHANED} vừa mang một giá trị KHẲNG ĐỊNH
315
+ // ở cột nghiệm thu. `fail`/`skip` KHÔNG bị chặn: chúng không phải lời khẳng định, và
316
+ // chặn chúng là biến guard chống-báo-cáo-sai thành guard che-tin-xấu.
317
+ if (PAG) {
318
+ const statusIdx = header.indexOf('status');
319
+ const st = statusIdx === -1 ? '' : String(f[statusIdx] || '').trim();
320
+ if (PAG.blocked_when_status.includes(st)) {
321
+ for (const g of PAG.guards) {
322
+ const gi = header.indexOf(g.column);
323
+ if (gi === -1) continue; // sổ cũ vắng cột ở đuôi — T1 lo việc đó
324
+ const val = String(f[gi] || '').trim();
325
+ if (!g.positive_values.includes(val)) continue;
326
+ warn('T12', at,
327
+ `status = "${st}" nhưng ${g.column} = "${val}"`,
328
+ `${g.why}
329
+ ` +
330
+ ` "${val}" không mang nghĩa "test đã chạy xanh" — nó mang nghĩa "scenario này
331
+ ` +
332
+ ` đã được nghiệm thu theo spec HIỆN TẠI". Trên row ${st} thì nghĩa đó SAI.
333
+ ` +
334
+ ` Hạ ${g.column} → ${g.downgrade_to} (và ${g.companion} → ${g.companion_downgrade}),
335
+ ` +
336
+ ` hoặc xử ${st} rồi chạy lại /${g.owner}.`);
337
+ }
338
+ }
339
+ }
340
+ }
341
+ }
342
+
343
+ // ── T3 — bảng phụ _seams.tsv ──────────────────────────────────────────────────
344
+
345
+ function lintSeams(file) {
346
+ if (!AUX) return;
347
+ const lines = readLines(file).map((t, i) => ({ t, n: i + 1 })).filter(x => x.t.trim() !== '');
348
+ if (!lines.length) return;
349
+
350
+ const header = lines[0].t.split('\t').map(s => s.trim());
351
+ const canon = AUX.columns;
352
+
353
+ if (header.length !== canon.length || header.some((h, i) => h !== canon[i])) {
354
+ err('T1', `${rel(file)}:${lines[0].n}`,
355
+ `header _seams.tsv lệch contract`,
356
+ `Đang có : ${header.join(' → ')}\n Contract: ${canon.join(' → ')}`);
357
+ return;
358
+ }
359
+
360
+ for (const { t, n } of lines.slice(1)) {
361
+ const f = t.split('\t');
362
+ const at = `${rel(file)}:${n}`;
363
+ if (f.length !== canon.length) {
364
+ err('T2', at, `row có ${f.length} ô, header có ${canon.length}`, diagnoseShift(header, f));
365
+ continue;
366
+ }
367
+ for (let i = 0; i < canon.length; i++) {
368
+ const enumDef = ENUMS.auxCols.get(canon[i]);
369
+ if (!enumDef || isUnknown(f[i])) continue;
370
+ if (!enumDef.values.includes(String(f[i]).trim())) {
371
+ err('T3', at,
372
+ `ô ${i + 1} (${canon[i]}) = "${String(f[i]).slice(0, 40)}" không thuộc vocabulary "${enumDef.vocab}"`,
373
+ `Giá trị cho phép: ${enumDef.values.join(' | ')}`);
374
+ }
375
+ }
376
+ }
377
+ }
378
+
379
+ // ── T5 — tên file sổ ──────────────────────────────────────────────────────────
380
+ //
381
+ // Tên sổ mang platform, và đó là thứ DUY NHẤT nói row này thuộc platform nào
382
+ // (validate-traces.tmpl Step 2 lấy platform từ tên file TSV). Thiếu đoạn đó thì
383
+ // /validate-traces không tìm ra .feature tương ứng và cả sổ trở nên vô hình.
384
+ function lintBookName(file) {
385
+ const base = path.basename(file);
386
+ const pf = ENUMS.platformValues;
387
+ if (!pf) return null; // schema chưa khai binding — R8a của self-check lo việc đó
388
+
389
+ const m = base.match(new RegExp(`^(.+)-(${pf.join('|')})\\.tsv$`));
390
+ if (!m) {
391
+ err('T5', rel(file),
392
+ `tên sổ không khớp {UC-ID}-{platform}.tsv`,
393
+ `platform phải là một trong: ${pf.join(' | ')}\n` +
394
+ ` Thiếu đoạn platform → /validate-traces không resolve được .feature và bỏ qua cả sổ.`);
395
+ return null;
396
+ }
397
+ return { ucId: m[1], platform: m[2] };
398
+ }
399
+
400
+ // ── T8 — trace-history*.jsonl ─────────────────────────────────────────────────
401
+
402
+ const HISTORY_KEYS = ['at', 'domain', 'summary', 'changed', 'flags_opened', 'flags_closed'];
403
+
404
+ function lintHistory(file) {
405
+ const lines = readLines(file);
406
+ lines.forEach((ln, i) => {
407
+ if (ln.trim() === '') return;
408
+ const at = `${rel(file)}:${i + 1}`;
409
+ let rec;
410
+ try {
411
+ rec = JSON.parse(ln);
412
+ } catch (e) {
413
+ err('T8', at, `dòng không phải JSON hợp lệ: ${e.message}`,
414
+ `Mỗi dòng là MỘT bản ghi trọn vẹn trên MỘT dòng (JSONL).\n` +
415
+ ` Đây là lịch sử tích luỹ — không regenerate được. Sửa tay, đừng xoá dòng.`);
416
+ return;
417
+ }
418
+ if (rec === null || typeof rec !== 'object' || Array.isArray(rec)) {
419
+ err('T8', at, 'dòng phải là một JSON object', null);
420
+ return;
421
+ }
422
+ const missing = HISTORY_KEYS.filter(k => !(k in rec));
423
+ if (missing.length) {
424
+ err('T8', at, `thiếu key bắt buộc: ${missing.join(', ')}`,
425
+ `Contract: {"at","domain","summary","changed","flags_opened","flags_closed"}\n` +
426
+ ` (validate-traces.tmpl Step 8c)`);
427
+ }
428
+ if ('at' in rec && !isValidDate(rec.at)) {
429
+ err('T8', at, `"at" = "${String(rec.at).slice(0, 40)}" không phải ISO-8601 hợp lệ`, null);
430
+ }
431
+ });
432
+ }
433
+
434
+ // ── T10 — luật merge cho sổ ───────────────────────────────────────────────────
435
+ //
436
+ // Vì sao ở đây và không chỉ ở /sync Step 4c (G40): luật merge là thứ giữ cho sổ không
437
+ // mất row khi hai nhánh gặp nhau. Nếu chỉ /sync kiểm thì nó phụ thuộc việc có người
438
+ // TỰ NGUYỆN chạy một lệnh chat — đúng lớp vấn đề G39. Ở đây thì CI canh được.
439
+ //
440
+ // KHÔNG tự đọc .gitattributes: luật có thể nằm ở gốc repo, trong {trace_dir}, hay bất kỳ
441
+ // cấp thư mục nào ở giữa, và git có thứ tự ưu tiên riêng. Hỏi thẳng git bằng check-attr —
442
+ // đó là câu trả lời authoritative, và nó đúng bất kể luật được đặt ở đâu.
443
+ function lintMergeAttr(sampleFile) {
444
+ let out;
445
+ try {
446
+ out = require('child_process')
447
+ .execSync(`git check-attr merge eol -- "${sampleFile}"`,
448
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
449
+ } catch {
450
+ return; // không có git, hoặc không nằm trong repo — không phán gì
451
+ }
452
+
453
+ const merge = /merge:\s*(\S+)/.exec(out);
454
+ const eol = /eol:\s*(\S+)/.exec(out);
455
+ const mergeVal = merge ? merge[1] : 'unspecified';
456
+ const eolVal = eol ? eol[1] : 'unspecified';
457
+
458
+ const missing = [];
459
+ if (mergeVal !== 'union') missing.push('merge=union');
460
+ if (eolVal !== 'lf') missing.push('text eol=lf');
461
+ if (!missing.length) return;
462
+
463
+ warn('T10', rel(sampleFile),
464
+ `sổ trace chưa có luật merge (${missing.join(' + ')} — git đang báo merge:${mergeVal} eol:${eolVal})`,
465
+ [`Sổ trace PHẢI commit và được nhiều người ghi trên nhiều nhánh song song. Không có luật:`,
466
+ ` • thiếu merge=union → merge song song CONFLICT, và giải bằng "take mine" là MẤT ROW`,
467
+ ` của người kia, im lặng. Với trace-history.jsonl thì MỌI cặp nhánh đều conflict.`,
468
+ ` • thiếu text eol=lf → một máy ghi CRLF, git thấy MỌI dòng đã đổi, union giữ cả hai`,
469
+ ` bản ⇒ NHÂN ĐÔI CẢ FILE (gồm dòng header).`,
470
+ `Sửa — tạo ${path.dirname(rel(sampleFile)).split('/')[0]}/.gitattributes:`,
471
+ ` *.tsv text eol=lf merge=union`,
472
+ ` *.jsonl text eol=lf merge=union`,
473
+ `(union là driver built-in của git — không ai cần chạy git config gì thêm.)`,
474
+ `KHÔNG thêm *.json: trace-report.json nằm cùng chỗ và union trên JSON tạo JSON không hợp lệ.`,
475
+ `Hoặc để /sync Step 4c tạo hộ.`].join('\n '));
476
+ }
477
+
478
+ // ── T11 — sổ có nằm trong git không ───────────────────────────────────────────
479
+ //
480
+ // Cách mất sổ TỆ NHẤT, và tệ hơn T10 hẳn một bậc: sổ không được đưa vào git ngay từ
481
+ // đầu. Máy người viết vẫn có sổ, dashboard vẫn hiện số — nhìn hoàn toàn bình thường.
482
+ // Người thứ hai clone repo về thì KHÔNG THẤY GÌ, và lịch sử tích luỹ mất vĩnh viễn.
483
+ //
484
+ // Phép kiểm này ĐÃ tồn tại trong `/sync` Step 4b từ G29 — nhưng chỉ ở đó, tức chỉ chạy
485
+ // khi có người tự nguyện gõ một lệnh chat. Không máy nào canh. Đưa vào đây để
486
+ // `--gate-trace` nâng nó thành cổng chặn (xem `gate.config_preconditions` trong schema).
487
+ //
488
+ // Hỏi thẳng git thay vì đọc .gitignore: luật ignore có thể đến từ .gitignore ở bất kỳ
489
+ // cấp thư mục nào, từ .git/info/exclude, hay từ core.excludesFile toàn cục.
490
+ function lintGitTracked(traceRoot) {
491
+ const cp = require('child_process');
492
+ const rel_ = rel(traceRoot);
493
+ let out;
494
+ try {
495
+ // check-ignore: exit 0 = ĐANG bị ignore. Dùng spawnSync để đọc được exit code.
496
+ out = cp.spawnSync('git', ['check-ignore', '-q', '--', traceRoot],
497
+ { encoding: 'utf8', stdio: ['ignore', 'ignore', 'ignore'] });
498
+ } catch {
499
+ return; // không có git — không phán gì
500
+ }
501
+ if (out.error || out.status === 128) return; // không nằm trong git repo
502
+ if (out.status !== 0) return; // exit 1 = không bị ignore → sạch
503
+
504
+ warn('T11', rel_,
505
+ 'SỔ TRACE ĐANG BỊ GITIGNORE — nó không được lưu vào git',
506
+ [`Đây là cách mất sổ tệ nhất, và nó KHÔNG ỒN ÀO:`,
507
+ ` • máy bạn vẫn có sổ, dashboard vẫn hiện số → nhìn như bình thường`,
508
+ ` • người thứ hai clone repo về → KHÔNG THẤY GÌ`,
509
+ ` • trace-history.jsonl (lịch sử tích luỹ) → mất VĨNH VIỄN, không dựng lại được`,
510
+ `Sửa:`,
511
+ ` 1. gỡ dòng khớp "${rel_}" khỏi .gitignore (hoặc .git/info/exclude)`,
512
+ ` 2. git add -f ${rel_} && git commit -m "restore trace state"`,
513
+ `Nguyên nhân thường gặp: bản trước v0.4.3 gọi panel mirror là ".trace" (trùng tên sổ`,
514
+ `gốc), nên một luật gitignore theo TÊN đã nhắm trúng sổ gốc. Xem GAPS-v2 G29.`].join('\n '));
515
+ }
516
+
517
+ // ── T9 — sổ mồ côi ────────────────────────────────────────────────────────────
518
+ //
519
+ // WARN không ERROR: có ca hợp lệ — scenario bị xoá nhưng row được GIỮ LẠI với
520
+ // status ORPHANED để người quyết định (G4). Sổ mồ côi thật thì cũng nên biết.
521
+ function lintOrphanBook(file, meta, traceRoot) {
522
+ if (!meta || !fs.existsSync(SPECS_DIR)) return;
523
+
524
+ const relPath = path.relative(traceRoot, file).replace(/\\/g, '/').split('/');
525
+ if (relPath.length < 3) return; // không theo {domain}/{prd-slug}/ — bố cục cũ, để migrate lo
526
+ const [domain, prdSlug] = relPath;
527
+
528
+ const bddDir = path.join(SPECS_DIR, domain, prdSlug, 'bdd', meta.platform);
529
+ if (!fs.existsSync(bddDir)) {
530
+ warn('T9', rel(file),
531
+ `không tìm thấy thư mục BDD tương ứng`,
532
+ `Mong đợi: ${rel(bddDir)}/\n` +
533
+ ` Sổ có thể mồ côi (feature đã đổi tên/xoá), hoặc --specs đang trỏ sai chỗ.`);
534
+ return;
535
+ }
536
+ const hasFeature = fs.readdirSync(bddDir)
537
+ .some(f => f.endsWith('.feature') && f.includes(meta.ucId));
538
+ if (!hasFeature) {
539
+ warn('T9', rel(file),
540
+ `không có file .feature nào cho ${meta.ucId} trong ${meta.platform}`,
541
+ `Đã tìm ở: ${rel(bddDir)}/\n` +
542
+ ` Sổ mồ côi, hoặc UC-ID đã đổi. /validate-traces sẽ báo ORPHANED/TRACE_ORPHAN.`);
543
+ }
544
+ }
545
+
546
+ // ── Main ──────────────────────────────────────────────────────────────────────
547
+
548
+ let scanned = { books: 0, seams: 0, history: 0, files: 0 };
549
+ const foundDirs = [];
550
+
551
+ for (const dir of TRACE_DIRS) {
552
+ const abs = path.resolve(dir);
553
+ if (!fs.existsSync(abs)) continue;
554
+ foundDirs.push(dir);
555
+
556
+ let sampleForAttr = null;
557
+
558
+ for (const file of walk(abs)) {
559
+ scanned.files++;
560
+ const base = path.basename(file);
561
+
562
+ // Phân loại + ĐẾM trước khi có thể short-circuit: một file bị T7 chặn vẫn LÀ một sổ
563
+ // đã quét. Đếm sau nhánh `continue` thì report nói "đã quét 0 sổ" trong đúng lúc nó
564
+ // vừa tìm ra lỗi nặng nhất — tự làm mình mất tin cậy.
565
+ const kind = base === '_seams.tsv' ? 'seams'
566
+ : base.endsWith('.tsv') ? 'book'
567
+ : /^trace-history(\.\d{4}-\d{2})?\.jsonl$/.test(base) ? 'history'
568
+ : null;
569
+ if (kind === 'seams') scanned.seams++;
570
+ if (kind === 'book') scanned.books++;
571
+ if (kind === 'history') scanned.history++;
572
+
573
+ // Một file mẫu là đủ cho T10: luật áp theo pattern, không theo từng file.
574
+ if ((kind === 'book' || kind === 'history') && !sampleForAttr) sampleForAttr = file;
575
+
576
+ // T7 trước mọi rule khác — marker che mất nguyên nhân thật của T1/T2
577
+ if (lintConflictMarkers(file)) continue;
578
+
579
+ if (kind === 'seams') {
580
+ lintSeams(file);
581
+ } else if (kind === 'book') {
582
+ const meta = lintBookName(file);
583
+ lintBook(file);
584
+ lintOrphanBook(file, meta, abs);
585
+ } else if (kind === 'history') {
586
+ lintHistory(file);
587
+ }
588
+ }
589
+
590
+ if (sampleForAttr) lintMergeAttr(sampleForAttr);
591
+ lintGitTracked(abs);
592
+ }
593
+
594
+ // ── Report ────────────────────────────────────────────────────────────────────
595
+
596
+ if (AS_JSON) {
597
+ console.log(JSON.stringify({
598
+ ok: errors.length === 0,
599
+ scanned, trace_dirs: foundDirs, errors, warns, infos,
600
+ }, null, 2));
601
+ process.exit(errors.length && !WARN_ONLY ? 1 : 0);
602
+ }
603
+
604
+ console.log('');
605
+ console.log('Lint trace (bin/trace-schema.json) ...');
606
+ console.log('');
607
+
608
+ if (!foundDirs.length) {
609
+ console.log(` ℹ️ Không tìm thấy trace dir nào (đã thử: ${TRACE_DIRS.join(', ')}).`);
610
+ console.log(` Chưa có sổ trace thì chưa có gì để kiểm — chạy /generate-bdd để khởi tạo.`);
611
+ console.log('');
612
+ process.exit(0);
613
+ }
614
+
615
+ const show = (list, icon) => {
616
+ for (const f of list) {
617
+ console.log(` ${icon} ${f.where}`);
618
+ console.log(` ${f.rule}: ${f.msg}`);
619
+ if (f.hint) console.log(` ${f.hint}`);
620
+ console.log('');
621
+ }
622
+ };
623
+
624
+ show(errors, '🔴');
625
+ show(warns, '⚠️ ');
626
+
627
+ for (const i of infos) console.log(` ℹ️ ${i}\n`);
628
+
629
+ const tally = `${scanned.books} sổ + ${scanned.seams} bảng phụ + ${scanned.history} file lịch sử`;
630
+
631
+ if (errors.length === 0 && warns.length === 0) {
632
+ console.log(` ✅ ${tally} — khớp contract (${CANON.length} cột)`);
633
+ } else {
634
+ console.log(` ${errors.length} error · ${warns.length} warning — đã quét ${tally}`);
635
+ if (errors.length) {
636
+ console.log('');
637
+ console.log(' Sổ trace là dữ liệu KHÔNG regenerate được. Sửa file rồi chạy lại;');
638
+ console.log(' `git log -p {file}` để tìm lần ghi làm hỏng.');
639
+ }
640
+ }
641
+ console.log('');
642
+
643
+ process.exit(errors.length && !WARN_ONLY ? 1 : 0);