@educa-corp/sdd-framework 0.4.2 → 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.
- package/bin/build.js +113 -19
- package/bin/gate-trace.js +464 -0
- package/bin/index.js +418 -146
- package/bin/lint-trace.js +602 -0
- package/bin/self-check.js +499 -6
- package/bin/trace-schema.json +1449 -692
- package/commands/debug.md +123 -510
- package/commands/debug.tmpl +3 -0
- package/commands/define-product.md +86 -509
- package/commands/dev-gen-test.md +120 -516
- package/commands/dev-run-test.md +120 -516
- package/commands/dev-smoke-test.md +86 -509
- package/commands/extend-prd.md +486 -0
- package/commands/extend-prd.tmpl +273 -0
- package/commands/fix-bug.md +152 -515
- package/commands/generate-architecture.md +94 -514
- package/commands/generate-architecture.tmpl +3 -0
- package/commands/generate-bdd.md +138 -519
- package/commands/generate-bdd.tmpl +18 -3
- package/commands/generate-code.md +156 -523
- package/commands/generate-code.tmpl +36 -7
- package/commands/generate-design-spec.md +86 -509
- package/commands/generate-prd.md +114 -509
- package/commands/generate-prd.tmpl +28 -0
- package/commands/generate-spec-manifest.md +86 -509
- package/commands/generate-tech-docs.md +86 -509
- package/commands/learn.md +172 -495
- package/commands/learn.tmpl +70 -3
- package/commands/map-testids.md +86 -509
- package/commands/propose-scenario.md +136 -508
- package/commands/propose-scenario.tmpl +52 -1
- package/commands/qc-analyze.md +86 -509
- package/commands/qc-design-test.md +87 -509
- package/commands/qc-design-test.tmpl +1 -0
- package/commands/qc-plan.md +86 -509
- package/commands/qc-report.md +86 -509
- package/commands/qc-review.md +86 -509
- package/commands/qc-run-test.md +133 -517
- package/commands/qc-run-test.tmpl +13 -1
- package/commands/refine-prd.md +99 -519
- package/commands/refine-prd.tmpl +3 -0
- package/commands/report-bug.md +86 -509
- package/commands/review-code.md +127 -513
- package/commands/review-code.tmpl +7 -3
- package/commands/review-context.md +96 -515
- package/commands/review-context.tmpl +6 -2
- package/commands/review-tech-docs.md +90 -510
- package/commands/review-tech-docs.tmpl +3 -0
- package/commands/setup-ai-first.md +166 -137
- package/commands/setup-ai-first.tmpl +72 -0
- package/commands/sync.md +86 -118
- package/commands/sync.tmpl +84 -16
- package/commands/update-framework.md +16 -102
- package/commands/update-framework.tmpl +14 -0
- package/commands/validate-traces.md +458 -531
- package/commands/validate-traces.tmpl +381 -31
- package/core/FRAMEWORK_VERSION +1 -1
- package/core/README.md +20 -0
- package/core/commands/debug.md +123 -510
- package/core/commands/define-product.md +86 -509
- package/core/commands/dev-gen-test.md +120 -516
- package/core/commands/dev-run-test.md +120 -516
- package/core/commands/dev-smoke-test.md +86 -509
- package/core/commands/extend-prd.md +486 -0
- package/core/commands/fix-bug.md +152 -515
- package/core/commands/generate-architecture.md +94 -514
- package/core/commands/generate-bdd.md +138 -519
- package/core/commands/generate-code.md +156 -523
- package/core/commands/generate-design-spec.md +86 -509
- package/core/commands/generate-prd.md +114 -509
- package/core/commands/generate-spec-manifest.md +86 -509
- package/core/commands/generate-tech-docs.md +86 -509
- package/core/commands/learn.md +172 -495
- package/core/commands/map-testids.md +86 -509
- package/core/commands/propose-scenario.md +136 -508
- package/core/commands/qc-analyze.md +86 -509
- package/core/commands/qc-design-test.md +87 -509
- package/core/commands/qc-plan.md +86 -509
- package/core/commands/qc-report.md +86 -509
- package/core/commands/qc-review.md +86 -509
- package/core/commands/qc-run-test.md +133 -517
- package/core/commands/refine-prd.md +99 -519
- package/core/commands/report-bug.md +86 -509
- package/core/commands/review-code.md +127 -513
- package/core/commands/review-context.md +96 -515
- package/core/commands/review-tech-docs.md +90 -510
- package/core/commands/setup-ai-first.md +166 -137
- package/core/commands/sync.md +86 -118
- package/core/commands/update-framework.md +16 -102
- package/core/commands/validate-traces.md +458 -531
- package/core/hooks/data-guard.js +174 -83
- package/core/hooks/settings.json +2 -1
- package/core/rules/workflow.md +48 -4
- package/core/steps/capture-lesson.md +34 -1
- package/core/steps/context-loader.md +24 -3
- package/core/steps/gate.md +92 -35
- package/core/steps/report-footer.md +26 -2
- package/core/steps/trace-mirror.md +34 -7
- package/core/templates/README.md +24 -1
- package/core/templates/ci/trace-gate.yml +146 -0
- package/core/templates/feature.template +1 -1
- package/core/templates/hooks/pre-push +61 -0
- package/docs/01-getting-started/installation.md +18 -1
- package/docs/01-getting-started/what-is-sdd.md +4 -2
- package/docs/02-concepts/architecture.md +48 -5
- package/docs/02-concepts/pipeline-steps/02-specification.md +39 -3
- package/docs/02-concepts/pipeline-steps/04-bdd.md +24 -2
- package/docs/02-concepts/pipeline-steps/05-tech-docs.md +18 -1
- package/docs/02-concepts/pipeline-steps/06-code.md +35 -4
- package/docs/02-concepts/pipeline-steps/09-validate-traces.md +137 -12
- package/docs/02-concepts/pipeline-steps/10-feedback-loop.md +59 -3
- package/docs/02-concepts/roles-and-hitl.md +1 -1
- package/docs/02-concepts/traceability.md +183 -117
- package/docs/03-guides/architect.md +63 -0
- package/docs/03-guides/developer.md +20 -4
- package/docs/03-guides/product-owner.md +72 -68
- package/docs/03-guides/tester-qa.md +81 -70
- package/docs/04-reference/commands.md +134 -105
- package/docs/04-reference/configuration.md +146 -94
- package/docs/04-reference/model-selection.md +32 -19
- package/docs/04-reference/trace-schema.md +26 -9
- package/docs/explain/02-generate-prd.md +80 -78
- package/docs/explain/02b-extend-prd.md +125 -0
- package/docs/explain/03-refine-prd.md +86 -86
- package/docs/explain/04-review-context.md +18 -1
- package/docs/explain/06-generate-bdd.md +23 -0
- package/docs/explain/08-review-tech-docs.md +20 -5
- package/docs/explain/10-review-code.md +36 -2
- package/docs/explain/19-qc-run-test.md +87 -67
- package/docs/explain/21-validate-traces.md +75 -68
- package/docs/explain/23-fix-bug.md +19 -3
- package/docs/explain/26-propose-scenario.md +70 -63
- package/docs/explain/27-learn.md +5 -3
- package/docs/explain/README.md +135 -134
- package/hooks/data-guard.js +174 -83
- package/hooks/settings.json +2 -1
- package/package.json +53 -50
- package/rules/workflow.md +48 -4
- package/steps/capture-lesson.md +34 -1
- package/steps/context-loader.md +24 -3
- package/steps/gate.md +92 -35
- package/steps/report-footer.md +26 -2
- package/steps/trace-mirror.md +34 -7
- package/templates/README.md +24 -1
- package/templates/ci/trace-gate.yml +146 -0
- package/templates/feature.template +1 -1
- package/templates/hooks/pre-push +61 -0
- package/scripts/init.sh +0 -49
- 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
|
|
60
|
-
return loadStep(
|
|
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
|
-
//
|
|
166
|
-
|
|
167
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
211
|
-
|
|
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
|
|
215
|
-
fs.writeFileSync(path.join(
|
|
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(
|
|
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);
|