@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
@@ -0,0 +1,602 @@
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
+ // Ô "chưa biết" — framework dùng `—` (em dash) làm placeholder ở mọi cột.
99
+ // Rỗng cũng chấp nhận: TSV cũ có cột vắng, và union merge có thể để ô trống.
100
+ const UNKNOWN = new Set(['—', '-', '', 'n/a', 'N/A']);
101
+ const isUnknown = v => UNKNOWN.has(String(v).trim());
102
+
103
+ // 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.
104
+ const isDateCol = name => /_at$/.test(name) || name === 'last_updated';
105
+ const isCountCol = name => /_count$/.test(name);
106
+
107
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:?\d{2})?)?$/;
108
+
109
+ function isValidDate(v) {
110
+ const s = String(v).trim();
111
+ if (!DATE_RE.test(s)) return false;
112
+ const [y, m, d] = s.slice(0, 10).split('-').map(Number);
113
+ const dt = new Date(Date.UTC(y, m - 1, d));
114
+ return dt.getUTCFullYear() === y && dt.getUTCMonth() === m - 1 && dt.getUTCDate() === d;
115
+ }
116
+
117
+ // ── Thu findings ──────────────────────────────────────────────────────────────
118
+
119
+ const errors = [];
120
+ const warns = [];
121
+ const infos = [];
122
+ const err = (rule, where, msg, hint) => errors.push({ rule, where, msg, hint });
123
+ const warn = (rule, where, msg, hint) => warns.push({ rule, where, msg, hint });
124
+
125
+ // ── Đi cây thư mục ────────────────────────────────────────────────────────────
126
+
127
+ function walk(dir, out = []) {
128
+ if (!fs.existsSync(dir)) return out;
129
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
130
+ const p = path.join(dir, e.name);
131
+ if (e.isDirectory()) walk(p, out);
132
+ else out.push(p);
133
+ }
134
+ return out;
135
+ }
136
+
137
+ const rel = p => path.relative(process.cwd(), p).replace(/\\/g, '/');
138
+
139
+ // Tách dòng, giữ số dòng thật (1-based) để báo lỗi trỏ đúng chỗ.
140
+ function readLines(file) {
141
+ return fs.readFileSync(file, 'utf8').replace(/\r\n/g, '\n').split('\n');
142
+ }
143
+
144
+ // ── T7 — marker conflict git ──────────────────────────────────────────────────
145
+ //
146
+ // 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
147
+ // 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.
148
+ const CONFLICT_RE = /^(<{7}|={7}|>{7})(\s|$)/;
149
+
150
+ function lintConflictMarkers(file) {
151
+ const lines = readLines(file);
152
+ const hits = [];
153
+ lines.forEach((ln, i) => { if (CONFLICT_RE.test(ln)) hits.push({ line: i + 1, text: ln.slice(0, 60) }); });
154
+ if (!hits.length) return false;
155
+ err('T7', `${rel(file)}:${hits[0].line}`,
156
+ `${hits.length} marker conflict git còn trong sổ trace`,
157
+ [`Dòng: ${hits.map(h => h.line).join(', ')}`,
158
+ `Sổ trace là dữ liệu KHÔNG regenerate được. Giải conflict bằng cách GIỮ CẢ HAI BÊN,`,
159
+ `rồi chạy lại lint (T4 bắt row trùng) và /validate-traces để reconcile về một row.`,
160
+ `Đừng bao giờ giải conflict trace bằng "take mine/theirs" — mất row là mất vĩnh viễn.`].join('\n '));
161
+ return true;
162
+ }
163
+
164
+ // ── T1/T2/T3/T4/T6 — sổ TSV chính ─────────────────────────────────────────────
165
+
166
+ /**
167
+ * 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
168
+ * 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,
169
+ * chỉ chỉ hướng — người đọc còn phải so với row lành bên cạnh.
170
+ */
171
+ function diagnoseShift(header, fields) {
172
+ const delta = fields.length - header.length;
173
+ const gap = delta < 0
174
+ ? `Thiếu ${-delta} ô — nghi quên một dấu tab (hoặc quên placeholder \`—\` cho một ô rỗng).`
175
+ : `Thừa ${delta} ô — nghi một giá trị có chứa tab, hoặc thừa dấu tab.`;
176
+
177
+ for (let i = 0; i < Math.min(header.length, fields.length); i++) {
178
+ const col = header[i];
179
+ const v = fields[i];
180
+ if (isUnknown(v)) continue;
181
+ const enumDef = ENUMS.mainCols.get(col);
182
+ const bad =
183
+ (enumDef && !enumDef.values.includes(String(v).trim())) ||
184
+ (isDateCol(col) && !isValidDate(v)) ||
185
+ (isCountCol(col) && !/^\d+$/.test(String(v).trim()));
186
+ if (bad) {
187
+ return `${gap}\n` +
188
+ ` Dấu hiệu đầu tiên: ô ${i + 1} (${col}) đang chứa "${String(v).slice(0, 30)}" —` +
189
+ ` không đúng kiểu của cột đó,\n` +
190
+ ` nên chỗ lệch nằm ở đâu đó TRƯỚC ô ${i + 1}. So với một row lành trong cùng file.`;
191
+ }
192
+ }
193
+ return `${gap}\n So với một row lành trong cùng file để thấy chỗ thiếu/thừa tab.`;
194
+ }
195
+
196
+ function lintBook(file) {
197
+ const lines = readLines(file);
198
+ const nonEmpty = lines.map((t, i) => ({ t, n: i + 1 })).filter(x => x.t.trim() !== '');
199
+ if (!nonEmpty.length) {
200
+ warn('T1', rel(file), 'Sổ rỗng — không có cả header');
201
+ return;
202
+ }
203
+
204
+ // ── T1 — header ─────────────────────────────────────────────────────────────
205
+ const header = nonEmpty[0].t.split('\t').map(s => s.trim());
206
+
207
+ if (header.length > CANON.length) {
208
+ err('T1', `${rel(file)}:${nonEmpty[0].n}`,
209
+ `header có ${header.length} cột, contract khai ${CANON.length}`,
210
+ `Cột lạ: ${header.slice(CANON.length).join(', ')}\n` +
211
+ ` Thêm cột thật thì khai vào bin/trace-schema.json TRƯỚC (rules/workflow.md §Trace Contract).`);
212
+ return; // header sai thì mọi kiểm theo cột bên dưới đều vô nghĩa
213
+ }
214
+
215
+ let headerOk = true;
216
+ for (let i = 0; i < header.length; i++) {
217
+ if (header[i] !== CANON[i]) {
218
+ err('T1', `${rel(file)}:${nonEmpty[0].n}`,
219
+ `cột ${i + 1} tên là "${header[i]}", contract khai "${CANON[i]}"`,
220
+ `Header đúng (${CANON.length} cột, cho phép vắng ở ĐUÔI):\n ${CANON.join(' → ')}`);
221
+ headerOk = false;
222
+ break;
223
+ }
224
+ }
225
+ if (!headerOk) return;
226
+
227
+ if (header.length < CANON.length) {
228
+ infos.push(`${rel(file)} — header ${header.length}/${CANON.length} cột ` +
229
+ `(vắng ở đuôi: ${CANON.slice(header.length).join(', ')}). Backward-compat OK; ` +
230
+ `/validate-traces sẽ nâng header ở lần ghi tới.`);
231
+ }
232
+
233
+ // ── T2/T3/T4/T6 — từng row ─────────────────────────────────────────────────
234
+ const headerLine = nonEmpty[0].t.trim();
235
+ const seen = new Map();
236
+
237
+ for (const { t, n } of nonEmpty.slice(1)) {
238
+ const f = t.split('\t');
239
+ const at = `${rel(file)}:${n}`;
240
+
241
+ // Header lặp lại giữa file — artifact union merge thường gặp NHẤT. Không có check
242
+ // riêng thì nó hiện ra dưới dạng 5-6 lỗi T3/T6 rời rạc ("ô 21 (status) = 'status'"),
243
+ // đúng nhưng người đọc phải tự ghép lại mới hiểu chuyện gì đã xảy ra.
244
+ if (t.trim() === headerLine) {
245
+ err('T1', at,
246
+ `dòng này LẶP LẠI header — sổ đã bị gộp nhân đôi`,
247
+ `Gần như luôn là union merge trên file có line-ending lệch: một máy ghi CRLF,\n` +
248
+ ` git thấy MỌI dòng đã đổi, union giữ cả hai bản ⇒ nhân đôi cả file.\n` +
249
+ ` 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` +
250
+ ` \`text eol=lf\` trong ${path.dirname(rel(file)).split('/')[0]}/.gitattributes —\n` +
251
+ ` /sync Step 4c kiểm và tạo hộ.`);
252
+ continue; // đừng báo thêm 6 lỗi T3/T6 cho cùng một dòng này
253
+ }
254
+
255
+ // T2
256
+ if (f.length !== header.length) {
257
+ err('T2', at,
258
+ `row có ${f.length} ô, header có ${header.length}`,
259
+ diagnoseShift(header, f));
260
+ continue; // ô đã lệch cột — kiểm enum/ngày theo vị trí sẽ báo lỗi rác
261
+ }
262
+
263
+ // T4
264
+ const scIdIdx = header.indexOf('sc_id');
265
+ if (scIdIdx !== -1) {
266
+ const id = f[scIdIdx].trim();
267
+ if (id && !isUnknown(id)) {
268
+ if (seen.has(id)) {
269
+ err('T4', at,
270
+ `sc_id "${id}" trùng với dòng ${seen.get(id)}`,
271
+ `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` +
272
+ ` hai nhánh cùng ghi scenario này (đúng như thiết kế — xem .gitattributes).\n` +
273
+ ` Giữ row mới hơn theo last_updated, rồi /validate-traces reconcile.`);
274
+ } else {
275
+ seen.set(id, n);
276
+ }
277
+ }
278
+ }
279
+
280
+ // T3 + T6
281
+ for (let i = 0; i < header.length; i++) {
282
+ const col = header[i];
283
+ const v = f[i];
284
+ if (isUnknown(v)) continue;
285
+
286
+ const enumDef = ENUMS.mainCols.get(col);
287
+ if (enumDef && !enumDef.values.includes(String(v).trim())) {
288
+ err('T3', at,
289
+ `ô ${i + 1} (${col}) = "${String(v).slice(0, 40)}" không thuộc vocabulary "${enumDef.vocab}"`,
290
+ `Giá trị cho phép: ${enumDef.values.join(' | ')} (hoặc — nếu chưa biết)`);
291
+ }
292
+
293
+ if (isDateCol(col) && !isValidDate(v)) {
294
+ err('T6', at,
295
+ `ô ${i + 1} (${col}) = "${String(v).slice(0, 40)}" không phải ngày hợp lệ`,
296
+ `Định dạng: YYYY-MM-DD hoặc ISO-8601 đầy đủ (hoặc — nếu chưa biết)`);
297
+ }
298
+ }
299
+ }
300
+ }
301
+
302
+ // ── T3 — bảng phụ _seams.tsv ──────────────────────────────────────────────────
303
+
304
+ function lintSeams(file) {
305
+ if (!AUX) return;
306
+ const lines = readLines(file).map((t, i) => ({ t, n: i + 1 })).filter(x => x.t.trim() !== '');
307
+ if (!lines.length) return;
308
+
309
+ const header = lines[0].t.split('\t').map(s => s.trim());
310
+ const canon = AUX.columns;
311
+
312
+ if (header.length !== canon.length || header.some((h, i) => h !== canon[i])) {
313
+ err('T1', `${rel(file)}:${lines[0].n}`,
314
+ `header _seams.tsv lệch contract`,
315
+ `Đang có : ${header.join(' → ')}\n Contract: ${canon.join(' → ')}`);
316
+ return;
317
+ }
318
+
319
+ for (const { t, n } of lines.slice(1)) {
320
+ const f = t.split('\t');
321
+ const at = `${rel(file)}:${n}`;
322
+ if (f.length !== canon.length) {
323
+ err('T2', at, `row có ${f.length} ô, header có ${canon.length}`, diagnoseShift(header, f));
324
+ continue;
325
+ }
326
+ for (let i = 0; i < canon.length; i++) {
327
+ const enumDef = ENUMS.auxCols.get(canon[i]);
328
+ if (!enumDef || isUnknown(f[i])) continue;
329
+ if (!enumDef.values.includes(String(f[i]).trim())) {
330
+ err('T3', at,
331
+ `ô ${i + 1} (${canon[i]}) = "${String(f[i]).slice(0, 40)}" không thuộc vocabulary "${enumDef.vocab}"`,
332
+ `Giá trị cho phép: ${enumDef.values.join(' | ')}`);
333
+ }
334
+ }
335
+ }
336
+ }
337
+
338
+ // ── T5 — tên file sổ ──────────────────────────────────────────────────────────
339
+ //
340
+ // Tên sổ mang platform, và đó là thứ DUY NHẤT nói row này thuộc platform nào
341
+ // (validate-traces.tmpl Step 2 lấy platform từ tên file TSV). Thiếu đoạn đó thì
342
+ // /validate-traces không tìm ra .feature tương ứng và cả sổ trở nên vô hình.
343
+ function lintBookName(file) {
344
+ const base = path.basename(file);
345
+ const pf = ENUMS.platformValues;
346
+ if (!pf) return null; // schema chưa khai binding — R8a của self-check lo việc đó
347
+
348
+ const m = base.match(new RegExp(`^(.+)-(${pf.join('|')})\\.tsv$`));
349
+ if (!m) {
350
+ err('T5', rel(file),
351
+ `tên sổ không khớp {UC-ID}-{platform}.tsv`,
352
+ `platform phải là một trong: ${pf.join(' | ')}\n` +
353
+ ` Thiếu đoạn platform → /validate-traces không resolve được .feature và bỏ qua cả sổ.`);
354
+ return null;
355
+ }
356
+ return { ucId: m[1], platform: m[2] };
357
+ }
358
+
359
+ // ── T8 — trace-history*.jsonl ─────────────────────────────────────────────────
360
+
361
+ const HISTORY_KEYS = ['at', 'domain', 'summary', 'changed', 'flags_opened', 'flags_closed'];
362
+
363
+ function lintHistory(file) {
364
+ const lines = readLines(file);
365
+ lines.forEach((ln, i) => {
366
+ if (ln.trim() === '') return;
367
+ const at = `${rel(file)}:${i + 1}`;
368
+ let rec;
369
+ try {
370
+ rec = JSON.parse(ln);
371
+ } catch (e) {
372
+ err('T8', at, `dòng không phải JSON hợp lệ: ${e.message}`,
373
+ `Mỗi dòng là MỘT bản ghi trọn vẹn trên MỘT dòng (JSONL).\n` +
374
+ ` Đây là lịch sử tích luỹ — không regenerate được. Sửa tay, đừng xoá dòng.`);
375
+ return;
376
+ }
377
+ if (rec === null || typeof rec !== 'object' || Array.isArray(rec)) {
378
+ err('T8', at, 'dòng phải là một JSON object', null);
379
+ return;
380
+ }
381
+ const missing = HISTORY_KEYS.filter(k => !(k in rec));
382
+ if (missing.length) {
383
+ err('T8', at, `thiếu key bắt buộc: ${missing.join(', ')}`,
384
+ `Contract: {"at","domain","summary","changed","flags_opened","flags_closed"}\n` +
385
+ ` (validate-traces.tmpl Step 8c)`);
386
+ }
387
+ if ('at' in rec && !isValidDate(rec.at)) {
388
+ err('T8', at, `"at" = "${String(rec.at).slice(0, 40)}" không phải ISO-8601 hợp lệ`, null);
389
+ }
390
+ });
391
+ }
392
+
393
+ // ── T10 — luật merge cho sổ ───────────────────────────────────────────────────
394
+ //
395
+ // Vì sao ở đây và không chỉ ở /sync Step 4c (G40): luật merge là thứ giữ cho sổ không
396
+ // 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
397
+ // TỰ NGUYỆN chạy một lệnh chat — đúng lớp vấn đề G39. Ở đây thì CI canh được.
398
+ //
399
+ // KHÔNG tự đọc .gitattributes: luật có thể nằm ở gốc repo, trong {trace_dir}, hay bất kỳ
400
+ // 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 —
401
+ // đó là câu trả lời authoritative, và nó đúng bất kể luật được đặt ở đâu.
402
+ function lintMergeAttr(sampleFile) {
403
+ let out;
404
+ try {
405
+ out = require('child_process')
406
+ .execSync(`git check-attr merge eol -- "${sampleFile}"`,
407
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
408
+ } catch {
409
+ return; // không có git, hoặc không nằm trong repo — không phán gì
410
+ }
411
+
412
+ const merge = /merge:\s*(\S+)/.exec(out);
413
+ const eol = /eol:\s*(\S+)/.exec(out);
414
+ const mergeVal = merge ? merge[1] : 'unspecified';
415
+ const eolVal = eol ? eol[1] : 'unspecified';
416
+
417
+ const missing = [];
418
+ if (mergeVal !== 'union') missing.push('merge=union');
419
+ if (eolVal !== 'lf') missing.push('text eol=lf');
420
+ if (!missing.length) return;
421
+
422
+ warn('T10', rel(sampleFile),
423
+ `sổ trace chưa có luật merge (${missing.join(' + ')} — git đang báo merge:${mergeVal} eol:${eolVal})`,
424
+ [`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:`,
425
+ ` • thiếu merge=union → merge song song CONFLICT, và giải bằng "take mine" là MẤT ROW`,
426
+ ` của người kia, im lặng. Với trace-history.jsonl thì MỌI cặp nhánh đều conflict.`,
427
+ ` • thiếu text eol=lf → một máy ghi CRLF, git thấy MỌI dòng đã đổi, union giữ cả hai`,
428
+ ` bản ⇒ NHÂN ĐÔI CẢ FILE (gồm dòng header).`,
429
+ `Sửa — tạo ${path.dirname(rel(sampleFile)).split('/')[0]}/.gitattributes:`,
430
+ ` *.tsv text eol=lf merge=union`,
431
+ ` *.jsonl text eol=lf merge=union`,
432
+ `(union là driver built-in của git — không ai cần chạy git config gì thêm.)`,
433
+ `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ệ.`,
434
+ `Hoặc để /sync Step 4c tạo hộ.`].join('\n '));
435
+ }
436
+
437
+ // ── T11 — sổ có nằm trong git không ───────────────────────────────────────────
438
+ //
439
+ // 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ừ
440
+ // đầ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.
441
+ // 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.
442
+ //
443
+ // Phép kiểm này ĐÃ tồn tại trong `/sync` Step 4b từ G29 — nhưng chỉ ở đó, tức chỉ chạy
444
+ // khi có người tự nguyện gõ một lệnh chat. Không máy nào canh. Đưa vào đây để
445
+ // `--gate-trace` nâng nó thành cổng chặn (xem `gate.config_preconditions` trong schema).
446
+ //
447
+ // Hỏi thẳng git thay vì đọc .gitignore: luật ignore có thể đến từ .gitignore ở bất kỳ
448
+ // cấp thư mục nào, từ .git/info/exclude, hay từ core.excludesFile toàn cục.
449
+ function lintGitTracked(traceRoot) {
450
+ const cp = require('child_process');
451
+ const rel_ = rel(traceRoot);
452
+ let out;
453
+ try {
454
+ // check-ignore: exit 0 = ĐANG bị ignore. Dùng spawnSync để đọc được exit code.
455
+ out = cp.spawnSync('git', ['check-ignore', '-q', '--', traceRoot],
456
+ { encoding: 'utf8', stdio: ['ignore', 'ignore', 'ignore'] });
457
+ } catch {
458
+ return; // không có git — không phán gì
459
+ }
460
+ if (out.error || out.status === 128) return; // không nằm trong git repo
461
+ if (out.status !== 0) return; // exit 1 = không bị ignore → sạch
462
+
463
+ warn('T11', rel_,
464
+ 'SỔ TRACE ĐANG BỊ GITIGNORE — nó không được lưu vào git',
465
+ [`Đây là cách mất sổ tệ nhất, và nó KHÔNG ỒN ÀO:`,
466
+ ` • máy bạn vẫn có sổ, dashboard vẫn hiện số → nhìn như bình thường`,
467
+ ` • người thứ hai clone repo về → KHÔNG THẤY GÌ`,
468
+ ` • trace-history.jsonl (lịch sử tích luỹ) → mất VĨNH VIỄN, không dựng lại được`,
469
+ `Sửa:`,
470
+ ` 1. gỡ dòng khớp "${rel_}" khỏi .gitignore (hoặc .git/info/exclude)`,
471
+ ` 2. git add -f ${rel_} && git commit -m "restore trace state"`,
472
+ `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ổ`,
473
+ `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 '));
474
+ }
475
+
476
+ // ── T9 — sổ mồ côi ────────────────────────────────────────────────────────────
477
+ //
478
+ // WARN không ERROR: có ca hợp lệ — scenario bị xoá nhưng row được GIỮ LẠI với
479
+ // status ORPHANED để người quyết định (G4). Sổ mồ côi thật thì cũng nên biết.
480
+ function lintOrphanBook(file, meta, traceRoot) {
481
+ if (!meta || !fs.existsSync(SPECS_DIR)) return;
482
+
483
+ const relPath = path.relative(traceRoot, file).replace(/\\/g, '/').split('/');
484
+ if (relPath.length < 3) return; // không theo {domain}/{prd-slug}/ — bố cục cũ, để migrate lo
485
+ const [domain, prdSlug] = relPath;
486
+
487
+ const bddDir = path.join(SPECS_DIR, domain, prdSlug, 'bdd', meta.platform);
488
+ if (!fs.existsSync(bddDir)) {
489
+ warn('T9', rel(file),
490
+ `không tìm thấy thư mục BDD tương ứng`,
491
+ `Mong đợi: ${rel(bddDir)}/\n` +
492
+ ` Sổ có thể mồ côi (feature đã đổi tên/xoá), hoặc --specs đang trỏ sai chỗ.`);
493
+ return;
494
+ }
495
+ const hasFeature = fs.readdirSync(bddDir)
496
+ .some(f => f.endsWith('.feature') && f.includes(meta.ucId));
497
+ if (!hasFeature) {
498
+ warn('T9', rel(file),
499
+ `không có file .feature nào cho ${meta.ucId} trong ${meta.platform}`,
500
+ `Đã tìm ở: ${rel(bddDir)}/\n` +
501
+ ` Sổ mồ côi, hoặc UC-ID đã đổi. /validate-traces sẽ báo ORPHANED/TRACE_ORPHAN.`);
502
+ }
503
+ }
504
+
505
+ // ── Main ──────────────────────────────────────────────────────────────────────
506
+
507
+ let scanned = { books: 0, seams: 0, history: 0, files: 0 };
508
+ const foundDirs = [];
509
+
510
+ for (const dir of TRACE_DIRS) {
511
+ const abs = path.resolve(dir);
512
+ if (!fs.existsSync(abs)) continue;
513
+ foundDirs.push(dir);
514
+
515
+ let sampleForAttr = null;
516
+
517
+ for (const file of walk(abs)) {
518
+ scanned.files++;
519
+ const base = path.basename(file);
520
+
521
+ // 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ổ
522
+ // đã quét. Đếm sau nhánh `continue` thì report nói "đã quét 0 sổ" trong đúng lúc nó
523
+ // vừa tìm ra lỗi nặng nhất — tự làm mình mất tin cậy.
524
+ const kind = base === '_seams.tsv' ? 'seams'
525
+ : base.endsWith('.tsv') ? 'book'
526
+ : /^trace-history(\.\d{4}-\d{2})?\.jsonl$/.test(base) ? 'history'
527
+ : null;
528
+ if (kind === 'seams') scanned.seams++;
529
+ if (kind === 'book') scanned.books++;
530
+ if (kind === 'history') scanned.history++;
531
+
532
+ // Một file mẫu là đủ cho T10: luật áp theo pattern, không theo từng file.
533
+ if ((kind === 'book' || kind === 'history') && !sampleForAttr) sampleForAttr = file;
534
+
535
+ // T7 trước mọi rule khác — marker che mất nguyên nhân thật của T1/T2
536
+ if (lintConflictMarkers(file)) continue;
537
+
538
+ if (kind === 'seams') {
539
+ lintSeams(file);
540
+ } else if (kind === 'book') {
541
+ const meta = lintBookName(file);
542
+ lintBook(file);
543
+ lintOrphanBook(file, meta, abs);
544
+ } else if (kind === 'history') {
545
+ lintHistory(file);
546
+ }
547
+ }
548
+
549
+ if (sampleForAttr) lintMergeAttr(sampleForAttr);
550
+ lintGitTracked(abs);
551
+ }
552
+
553
+ // ── Report ────────────────────────────────────────────────────────────────────
554
+
555
+ if (AS_JSON) {
556
+ console.log(JSON.stringify({
557
+ ok: errors.length === 0,
558
+ scanned, trace_dirs: foundDirs, errors, warns, infos,
559
+ }, null, 2));
560
+ process.exit(errors.length && !WARN_ONLY ? 1 : 0);
561
+ }
562
+
563
+ console.log('');
564
+ console.log('Lint trace (bin/trace-schema.json) ...');
565
+ console.log('');
566
+
567
+ if (!foundDirs.length) {
568
+ console.log(` ℹ️ Không tìm thấy trace dir nào (đã thử: ${TRACE_DIRS.join(', ')}).`);
569
+ console.log(` Chưa có sổ trace thì chưa có gì để kiểm — chạy /generate-bdd để khởi tạo.`);
570
+ console.log('');
571
+ process.exit(0);
572
+ }
573
+
574
+ const show = (list, icon) => {
575
+ for (const f of list) {
576
+ console.log(` ${icon} ${f.where}`);
577
+ console.log(` ${f.rule}: ${f.msg}`);
578
+ if (f.hint) console.log(` ${f.hint}`);
579
+ console.log('');
580
+ }
581
+ };
582
+
583
+ show(errors, '🔴');
584
+ show(warns, '⚠️ ');
585
+
586
+ for (const i of infos) console.log(` ℹ️ ${i}\n`);
587
+
588
+ const tally = `${scanned.books} sổ + ${scanned.seams} bảng phụ + ${scanned.history} file lịch sử`;
589
+
590
+ if (errors.length === 0 && warns.length === 0) {
591
+ console.log(` ✅ ${tally} — khớp contract (${CANON.length} cột)`);
592
+ } else {
593
+ console.log(` ${errors.length} error · ${warns.length} warning — đã quét ${tally}`);
594
+ if (errors.length) {
595
+ console.log('');
596
+ console.log(' Sổ trace là dữ liệu KHÔNG regenerate được. Sửa file rồi chạy lại;');
597
+ console.log(' `git log -p {file}` để tìm lần ghi làm hỏng.');
598
+ }
599
+ }
600
+ console.log('');
601
+
602
+ process.exit(errors.length && !WARN_ONLY ? 1 : 0);