@educa-corp/sdd-framework 0.9.4 → 0.9.6
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 +11 -1
- package/bin/lint-trace.js +599 -2
- package/bin/self-check.js +195 -0
- package/bin/trace-schema.json +2656 -1927
- package/core/FRAMEWORK_VERSION +1 -1
- package/core/commands/dev-gen-test.md +62 -0
- package/core/commands/generate-bdd.md +1 -0
- package/core/commands/generate-code.md +39 -2
- package/core/commands/generate-tech-docs.md +24 -5
- package/core/commands/map-testids.md +164 -7
- package/core/commands/qc-analyze.md +163 -9
- package/core/commands/qc-design-test.md +294 -2
- package/core/commands/qc-plan.md +57 -3
- package/core/commands/qc-report.md +76 -60
- package/core/commands/qc-review.md +102 -1
- package/core/commands/qc-run-test.md +194 -5
- package/core/commands/review-tech-docs.md +20 -0
- package/core/commands/validate-traces.md +17 -2
- package/core/modules/qc-playwright/stack-profile.yaml +1 -1
- package/core/rules/data-protection.md +52 -0
- package/core/rules/workflow.md +40 -0
- package/core/skills/qc/_shared/self-review-principles.md +112 -0
- package/core/skills/qc/qa-analyst/DOC_GAP.template.md +9 -1
- package/core/skills/qc/qa-designer/shared/duplicate-check-procedure.md +33 -5
- package/core/skills/qc/qa-designer/shared/tc-metadata-format.md +24 -0
- package/core/skills/qc/qa-planner/test-plan.md +7 -0
- package/core/skills/qc/qa-runner/e2e.md +2 -2
- package/core/skills/qc/qa-runner/functional/gui-feature.md +9 -3
- package/core/skills/qc/qa-runner/functional/gui-screen.md +9 -3
- package/core/skills/qc/qa-runner/integration.md +1 -1
- package/core/skills/qc/qa-runner/non-functional.md +1 -1
- package/core/skills/spec/SKILL.md +1 -1
- package/core/steps/context-loader.md +7 -2
- package/core/steps/gap-verify.md +67 -0
- package/core/steps/qc-scope.md +67 -11
- package/core/steps/qc-stamp.md +142 -0
- package/core/steps/report-footer.md +15 -7
- package/core/templates/feature.template +1 -0
- package/core/templates/tech-design.template.md +4 -3
- package/docs/01-getting-started/quickstart.md +4 -3
- package/docs/02-concepts/architecture.md +14 -0
- package/docs/02-concepts/glossary.md +8 -0
- package/docs/02-concepts/overview.md +3 -2
- package/docs/02-concepts/pipeline-steps/04-bdd.md +1 -1
- package/docs/02-concepts/pipeline-steps/05-tech-docs.md +21 -5
- package/docs/02-concepts/pipeline-steps/06-code.md +12 -2
- package/docs/02-concepts/pipeline-steps/08-qc-automation.md +60 -12
- package/docs/02-concepts/pipeline-steps/README.md +4 -3
- package/docs/02-concepts/traceability.md +2 -2
- package/docs/03-guides/architect.md +2 -2
- package/docs/03-guides/developer.md +5 -2
- package/docs/03-guides/tester-qa.md +17 -5
- package/docs/04-reference/commands.md +7 -4
- package/docs/04-reference/trace-schema.md +38 -0
- package/docs/explain/07-generate-tech-docs.md +5 -3
- package/docs/explain/08-review-tech-docs.md +15 -3
- package/docs/explain/09-generate-code.md +30 -4
- package/docs/explain/10-review-code.md +1 -1
- package/docs/explain/11-map-testids.md +10 -7
- package/docs/explain/12-dev-gen-test.md +1 -1
- package/docs/explain/15-qc-analyze.md +14 -2
- package/docs/explain/16-qc-plan.md +5 -1
- package/docs/explain/17-qc-design-test.md +26 -3
- package/docs/explain/18-qc-review.md +6 -2
- package/docs/explain/19-qc-run-test.md +29 -6
- package/docs/explain/20-qc-report.md +5 -2
- package/docs/explain/README.md +4 -1
- package/docs/plans/qc-surgery/00-nhat-ky.md +497 -0
- package/docs/plans/qc-surgery/01-checklist.md +92 -0
- package/docs/plans/qc-surgery/02-lo-trinh.md +266 -0
- package/docs/plans/qc-surgery/buoc/0-01-testid-attr-co-cho-o.md +157 -0
- package/docs/plans/qc-surgery/buoc/0-02-mot-nguon-cho-testid-attr.md +135 -0
- package/docs/plans/qc-surgery/buoc/0-03-skill-thoi-day-do-dom.md +167 -0
- package/docs/plans/qc-surgery/buoc/0-04-may-canh-hop-dong.md +173 -0
- package/docs/plans/qc-surgery/buoc/0-05-don-nhan-cot-va-2b.md +133 -0
- package/docs/plans/qc-surgery/buoc/0-06-hop-dong-truoc-code.md +226 -0
- package/docs/plans/qc-surgery/buoc/1-01-guard-br-tag.md +156 -0
- package/docs/plans/qc-surgery/buoc/1-02-guard-sc-coverage.md +153 -0
- package/docs/plans/qc-surgery/buoc/1-03-fail-3-nhan.md +176 -0
- package/docs/plans/qc-surgery/buoc/1-04-self-review-dung-chung.md +175 -0
- package/docs/plans/qc-surgery/buoc/1-05-spec-la-du-lieu.md +164 -0
- package/docs/plans/qc-surgery/buoc/1-06-gap-verify-du-bo.md +162 -0
- package/docs/plans/qc-surgery/buoc/README.md +85 -0
- package/docs/plans/qc-surgery/exec-d0-b1-testid-attr-header.md +147 -0
- package/docs/plans/qc-surgery/exec-d0-b2-thong-nhat-nguon-testid-attr.md +152 -0
- package/docs/plans/qc-surgery/exec-d0-b3-sua-skill-probe-dom.md +173 -0
- package/docs/plans/qc-surgery/exec-d0-b4-may-canh-4-5-6.md +168 -0
- package/docs/plans/qc-surgery/exec-d0-b5-don-nhan-lech.md +196 -0
- package/docs/plans/qc-surgery/exec-d0-b6-contract-truoc-code.md +350 -0
- package/docs/plans/qc-surgery/exec-d1-b1-guard-br-tag.md +129 -0
- package/docs/plans/qc-surgery/exec-d1-b2-guard-sc-coverage.md +159 -0
- package/docs/plans/qc-surgery/exec-d1-b3-fail-3-bucket.md +158 -0
- package/docs/plans/qc-surgery/exec-d1-b4-self-review-principles.md +145 -0
- package/docs/plans/qc-surgery/exec-d1-b5-noi-quy-spec-la-du-lieu.md +156 -0
- package/docs/plans/qc-surgery/exec-d1-b6-gap-verify-mo-rong.md +179 -0
- package/docs/plans/qc-surgery/exec-d2-b1-tach-qc-review.md +166 -0
- package/docs/plans/qc-surgery/exec-d2-b2-tach-qc-run-test-atomic.md +267 -0
- package/docs/plans/qc-surgery/exec-d2-b3-qc-automation-assess.md +198 -0
- package/docs/plans/qc-surgery/exec-d3-b1-qc-report-gate-decision.md +209 -0
- package/docs/plans/qc-surgery/exec-d4-b1-qc-design-testdata.md +146 -0
- package/docs/plans/qc-surgery/exec-d4-b2-qc-smoke-test.md +179 -0
- package/docs/plans/qc-surgery/exec-d4-b3-qc-metrics-va-lint.md +198 -0
- package/docs/plans/qc-surgery/exec-d4-b4-lint-spec-injection.md +199 -0
- package/package.json +1 -1
package/bin/build.js
CHANGED
|
@@ -64,9 +64,19 @@ const RUNTIME_READ = {
|
|
|
64
64
|
'rồi mới tiếp tục phần bên dưới.\n' +
|
|
65
65
|
'\n' +
|
|
66
66
|
'Nó chốt bốn thứ mà mọi trạm QC đều cần: `TICKET-ID` · `active_platform` ·\n' +
|
|
67
|
-
'`qc_artifact_dir` · `uc_list` (kèm trạng thái BDD từng UC, và cờ `--
|
|
67
|
+
'`qc_artifact_dir` · `uc_list` (kèm trạng thái BDD từng UC, và cờ `--force`).\n' +
|
|
68
68
|
'Bỏ qua thì artifact QC ghi vào **sai thư mục** và `qc_status` ghi vào **sai sổ trace** —\n' +
|
|
69
69
|
'cả hai đều xảy ra trong im lặng, không có bước nào phía sau bắt được.',
|
|
70
|
+
|
|
71
|
+
// Cùng lý do với qc-scope: 5 lệnh qc-* include, inline là nhân 5 bản một quy trình.
|
|
72
|
+
'steps/qc-stamp.md':
|
|
73
|
+
'**BẮT BUỘC — đọc `.agent/steps/qc-stamp.md` và thực thi phần áp cho lệnh này**,\n' +
|
|
74
|
+
'rồi mới tiếp tục phần bên dưới.\n' +
|
|
75
|
+
'\n' +
|
|
76
|
+
'Nó có **hai vế**: §1 **ghi** khối `Nguồn & phiên bản` vào artifact lệnh này sinh ra ·\n' +
|
|
77
|
+
'§2 **so** stamp của artifact lệnh này ĐỌC với version hiện tại của spec.\n' +
|
|
78
|
+
'Bỏ vế ghi thì trạm sau không có gì để so; bỏ vế so thì stamp thành một con số không ai\n' +
|
|
79
|
+
'đọc — và một bộ TC lỗi thời sẽ chạy xanh rồi ghi `pass` **hợp lệ theo mọi phép kiểm**.',
|
|
70
80
|
};
|
|
71
81
|
|
|
72
82
|
/**
|
package/bin/lint-trace.js
CHANGED
|
@@ -97,6 +97,25 @@ const TRACE_DIRS = flag('--trace', '.trace').split(',').map(s => s.trim()).filte
|
|
|
97
97
|
const SPECS_DIR = flag('--specs', 'specs');
|
|
98
98
|
// Không có default: quét sai chỗ còn tệ hơn không quét. Thiếu cờ → T14 skip + in một dòng.
|
|
99
99
|
const CODE_DIRS = (flag('--code', '') || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
100
|
+
// Cùng khuôn --code, cùng lý do: gốc artifact QC ({qc_dir}, mặc định `docs`) KHÔNG được đoán.
|
|
101
|
+
// Nó là path QC duy nhất không remap theo spec_source, nên ở umbrella nó ở repo KHÁC với specs.
|
|
102
|
+
// Thiếu cờ → T19 skip CÓ TIẾNG.
|
|
103
|
+
const QC_DIRS = (flag('--qc', '') || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
104
|
+
// ── G87 — thu hẹp rule TECH-DOC theo PRD, KHÔNG thu hẹp rule SỔ ──────────────
|
|
105
|
+
//
|
|
106
|
+
// validate-traces:66 khai cố ý: "lint luôn chạy toàn bộ. SỔ HỎNG Ở DOMAIN KHÁC VẪN LÀ SỔ HỎNG".
|
|
107
|
+
// Đúng — cho T1–T14: .tsv là dữ liệu KHÔNG regenerate được, hỏng ở đâu cũng là hỏng.
|
|
108
|
+
// T15/T16/T19/T20 canh TECH-DOC, không canh sổ. Lý do kia không nối sang được, mà `exit 1` thì
|
|
109
|
+
// chung một cửa — nên một lệnh CÓ SCOPE (`/validate-traces --prd FEAT-01-1`) bị chặn bởi lỗi ở
|
|
110
|
+
// 29 PRD ngoài scope.
|
|
111
|
+
//
|
|
112
|
+
// VÌ SAO KHÔNG DÙNG `--specs` HẸP LẠI: lintTestidContract() cần `{domain}/{prd-slug}` để tìm bdd/,
|
|
113
|
+
// nên `--specs {specs}/{domain}/{prd-slug}` làm `parts.length < 3` ⇒ `continue` ⇒ BỎ QUA IM LẶNG.
|
|
114
|
+
// Cách lách hiển nhiên nhất cho ra XANH GIẢ — tệ hơn không lách.
|
|
115
|
+
const SCOPE_PRD = (flag('--scope-prd', '') || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
116
|
+
// `{domain}/{prd-slug}` có nằm trong scope không. Rỗng = không scope = kiểm tất.
|
|
117
|
+
const inScopePrd = (domain, prdSlug) =>
|
|
118
|
+
!SCOPE_PRD.length || SCOPE_PRD.includes(`${domain}/${prdSlug}`);
|
|
100
119
|
|
|
101
120
|
// ── Contract dẫn xuất từ schema ───────────────────────────────────────────────
|
|
102
121
|
//
|
|
@@ -807,6 +826,390 @@ function lintCodeVsLedger() {
|
|
|
807
826
|
}
|
|
808
827
|
}
|
|
809
828
|
|
|
829
|
+
// ── T15/T16 — hợp đồng test-id: bảng §4.5.6 vs .feature ─────────────────────
|
|
830
|
+
//
|
|
831
|
+
// Vì sao là PASS RIÊNG, không đi cùng vòng lặp sổ: hai rule này so tech-doc với .feature,
|
|
832
|
+
// không đụng một ô nào của sổ trace. Neo chúng vào sổ là làm chúng câm ở repo chưa có sổ.
|
|
833
|
+
//
|
|
834
|
+
// PHẠM VI neo vào SỰ TỒN TẠI CỦA HỢP ĐỒNG: doc không có block §4.5 client thì không kiểm gì.
|
|
835
|
+
// Dự án backend-only, hay dự án chưa từng chạy /map-testids, phải IM LẶNG HOÀN TOÀN. Hai rule
|
|
836
|
+
// này nói *"chỗ nào đã hứa thì phải giữ"*, KHÔNG nói *"mọi chỗ đều phải có hợp đồng"* — nếu
|
|
837
|
+
// không thì mọi dự án đang chạy đỏ ngay ngày nâng version, và việc đầu tiên người ta làm là
|
|
838
|
+
// thêm --warn-only vào CI. Mất luôn cả hai rule.
|
|
839
|
+
|
|
840
|
+
// Ô/dòng còn `{…}` là PLACEHOLDER của template. MỌI tech-doc vừa sinh ra đều mang dòng mẫu
|
|
841
|
+
// `| {uc}-{screen}-{element}-{type} | … | {UC1 · SC1, UC1 · SC3} |`. Không bỏ qua thì doc mới
|
|
842
|
+
// tinh đã đỏ vì "SC1 của UC1 không tồn tại" — báo oan ngay lần chạy đầu tiên.
|
|
843
|
+
const hasPlaceholder = s => /\{[^}\n]*\}/.test(s);
|
|
844
|
+
|
|
845
|
+
// ── G82 — `{…}` mang HAI nghĩa ở cùng một chỗ ────────────────────────────────
|
|
846
|
+
//
|
|
847
|
+
// Luật cũ: "dòng nào có `{…}` là dòng mẫu của template → bỏ qua". Đúng khi chỉ nhìn template.
|
|
848
|
+
// Trên dữ liệu thật (28 tech-doc · 344 row §4.5.6) thì 19 row — 5,5% — dùng `{…}` làm PATTERN ID
|
|
849
|
+
// CÓ THẬT: `shop-tab-{code}` · `shop-item-card-{itemId}` · `uc{n}-z2-region`. Một dòng đại diện
|
|
850
|
+
// cho sáu nút, hai mươi thẻ.
|
|
851
|
+
//
|
|
852
|
+
// HAI MẶT, mặt thứ hai mới là mặt giết rule:
|
|
853
|
+
// (1) IM LẶNG — row bị `return` TRƯỚC khi vào `declared` ⇒ T15·T17·T18·T19 đều mù với nó.
|
|
854
|
+
// (2) BÁO OAN — .Test.md dùng instance cụ thể (`uc1-z2-region`) thì T19 kêu "không có ở
|
|
855
|
+
// §4.5.6 nào". Nó bắt oan CHÍNH CÁI nó vừa bỏ sót. Và "một rule báo oan trong tuần đầu
|
|
856
|
+
// là một rule bị tắt" — lúc đó mất luôn cả phần đang chạy đúng.
|
|
857
|
+
//
|
|
858
|
+
// Phân biệt bằng PHÉP ĐẾM, không bằng danh sách tên placeholder (danh sách thì phải bảo trì,
|
|
859
|
+
// và template đổi tên là hỏng): dòng mẫu có `{…}` ở ≥2 Ô; pattern id thật chỉ có ở ô Test-ID.
|
|
860
|
+
const isTemplateSample = cells =>
|
|
861
|
+
cells.filter(c => hasPlaceholder(c)).length >= 2;
|
|
862
|
+
|
|
863
|
+
// `uc{n}-z2-region` → /^uc[^-]+-z2-region$/
|
|
864
|
+
// `{…}` khớp MỘT ĐOẠN, không vượt dấu `-`. Dữ liệu thật có cả `{n}` (số), `{code}` (chữ) và
|
|
865
|
+
// `{itemId}` (hỗn hợp) nên `\d+` quá chặt; còn `.+` thì `uc{n}-z2-region` sẽ nuốt cả
|
|
866
|
+
// `ucFOO-BAR-BAZ-z2-region` — lỏng tới mức vô nghĩa.
|
|
867
|
+
function patternToRegex(pat) {
|
|
868
|
+
const esc = pat.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // escape TẤT CẢ, kể cả { }
|
|
869
|
+
const body = esc.replace(/\\\{[^}]*\\\}/g, '[^-]+'); // rồi mở lại đúng các ô {…}
|
|
870
|
+
try { return new RegExp(`^${body}$`); } catch { return null; }
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// Một id khớp hợp đồng khi: trùng nguyên văn một id đã khai, HOẶC khớp một pattern đã khai.
|
|
874
|
+
const matchesDeclaredIds = (id, ids, patterns) =>
|
|
875
|
+
ids.has(id) || [...patterns].some(re => re.test(id));
|
|
876
|
+
|
|
877
|
+
// ── G81 — ô "Serves SC" có ÍT NHẤT 5 dạng ngoài đời ──────────────────────────
|
|
878
|
+
//
|
|
879
|
+
// Đo trên 229 row §4.5.6 thật của 28 tech-doc: regex cũ đọc được 143 = 62%. 82 row dùng dấu NỐI
|
|
880
|
+
// (`UC1-SC1`) thay vì `·`, và regex thứ hai — viết cho `FEAT-02-3-UC1-SC1` — khớp ĐÚNG 0 dòng.
|
|
881
|
+
//
|
|
882
|
+
// UC1 · SC1 ✅ dạng duy nhất bản cũ đọc được
|
|
883
|
+
// UC1-SC1 · UC2-SC1 · UC3-SC1 ❌ dấu nối
|
|
884
|
+
// UC1-SC8 ❌ dấu nối
|
|
885
|
+
// UC3-SC5..SC11 ❌ range
|
|
886
|
+
// UC6-SC4, SC5 ❌ nối tiếp (SC thứ hai mượn UC của SC đầu)
|
|
887
|
+
// mọi SC của UCn ❌ prose — KHÔNG parse được, và đó là chuyện thường
|
|
888
|
+
//
|
|
889
|
+
// TRẢ VỀ { ids, parsed }: `parsed=false` nghĩa là ô có chữ mà không rút được token nào. Gọi bên
|
|
890
|
+
// ngoài PHẢI đếm số đó và in ra — im lặng-vì-không-hiểu khác im lặng-vì-không-có-gì-để-báo, và
|
|
891
|
+
// gộp hai thứ đó là biến "chưa kiểm" thành "đã sạch" (đúng loại lỗi thứ tư mà T13 đặt tên).
|
|
892
|
+
function parseServesCell(cell, ticket) {
|
|
893
|
+
const ids = [];
|
|
894
|
+
let lastUC = null;
|
|
895
|
+
// Quét tuần tự: mỗi lần gặp UC{n} thì nhớ nó, để `, SC5` phía sau mượn được.
|
|
896
|
+
const tokenRe = /UC(\d+)\s*[·.\-]\s*SC(\d+)(?:\s*\.\.\s*SC(\d+))?|(?:^|[,;·]\s*)SC(\d+)(?:\s*\.\.\s*SC(\d+))?/g;
|
|
897
|
+
for (const m of cell.matchAll(tokenRe)) {
|
|
898
|
+
if (m[1]) { // dạng UC{a}-SC{b} (hoặc `·`, `.`)
|
|
899
|
+
lastUC = m[1];
|
|
900
|
+
const from = +m[2], to = m[3] ? +m[3] : +m[2];
|
|
901
|
+
for (let i = from; i <= to; i++) ids.push(`${ticket}-UC${lastUC}-SC${i}`);
|
|
902
|
+
} else if (m[4] && lastUC) { // dạng nối tiếp `, SC5` — mượn UC gần nhất
|
|
903
|
+
const from = +m[4], to = m[5] ? +m[5] : +m[4];
|
|
904
|
+
for (let i = from; i <= to; i++) ids.push(`${ticket}-UC${lastUC}-SC${i}`);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
// Dạng đầy đủ có tiền tố ticket (`FEAT-02-3-UC1-SC1`) — giữ từ bản cũ, 0 hit trên dữ liệu thật
|
|
908
|
+
// nhưng template vẫn cho phép.
|
|
909
|
+
for (const m of cell.matchAll(/\b([A-Za-z]+-[\d-]*\d-UC\d+-SC\d+)\b/g)) ids.push(m[1]);
|
|
910
|
+
|
|
911
|
+
const hasWords = /[A-Za-zÀ-ỹ0-9]/.test(cell);
|
|
912
|
+
return { ids: [...new Set(ids)], parsed: ids.length > 0 || !hasWords };
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// Hợp đồng đã khai, gom cho T17/T18: attr → { ids: Set, byDoc: Map(doc → Set) }.
|
|
916
|
+
// T18 phải so với HỢP NHẤT mọi bảng, không so từng doc: một monorepo có nhiều PRD, và id của
|
|
917
|
+
// PRD khác nằm trong code là chuyện bình thường — so per-doc sẽ báo oan gần hết.
|
|
918
|
+
const declared = new Map();
|
|
919
|
+
// G81 — ô "Serves SC" KHÔNG rút được token nào. Đếm để in ở cuối: "chưa kiểm" không được phép
|
|
920
|
+
// trông giống "đã sạch". Đo thật: 143/229 row đọc được (62%) — 86 row còn lại từng biến mất im lặng.
|
|
921
|
+
const servesUnparsed = [];
|
|
922
|
+
// G86 — doc LEGACY chưa có @trace.testid_attr. Gom để in MỘT dòng CÓ ĐẾM.
|
|
923
|
+
const testidLegacy = [];
|
|
924
|
+
|
|
925
|
+
function lintTestidContract() {
|
|
926
|
+
if (!fs.existsSync(SPECS_DIR)) return;
|
|
927
|
+
|
|
928
|
+
const docs = walk(SPECS_DIR).filter(f => /-tech-design\.md$/.test(path.basename(f)));
|
|
929
|
+
if (!docs.length) {
|
|
930
|
+
infos.push(`Không có tech-doc nào dưới ${rel(SPECS_DIR)} — bỏ qua T15/T16 (hợp đồng test-id).`);
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
for (const doc of docs) {
|
|
935
|
+
const lines = readLines(doc);
|
|
936
|
+
|
|
937
|
+
// Block §4.5 theo nền: "### 4.5 … — {platform} ({framework})".
|
|
938
|
+
// Chỉ nền CLIENT mới có test-id; `system` không có UI nên nằm ngoài phạm vi.
|
|
939
|
+
const blocks = [];
|
|
940
|
+
lines.forEach((l, i) => {
|
|
941
|
+
// G81 — nền có thể GỘP: "### 4.5 … — app+web". Tách thành nhiều nền thay vì một token;
|
|
942
|
+
// không tách thì bảng gộp chỉ được kiểm theo `bdd/app`, và mọi SC chỉ có ở `web` sẽ lọt.
|
|
943
|
+
const m = /^###\s+4\.5\b[^—]*—\s*([A-Za-z0-9_+-]+)/.exec(l);
|
|
944
|
+
if (m) {
|
|
945
|
+
for (const pf of m[1].split('+').map(x => x.trim()).filter(Boolean)) {
|
|
946
|
+
if (pf !== 'system') blocks.push({ platform: pf, at: i });
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
});
|
|
950
|
+
if (!blocks.length) continue;
|
|
951
|
+
|
|
952
|
+
// ── T16 — có §4.5 client thì header PHẢI khai TÊN THUỘC TÍNH test-id ──
|
|
953
|
+
const attr = lines.find(l => /^@trace\.testid_attr\s*:/.test(l.trim()));
|
|
954
|
+
if (!attr) {
|
|
955
|
+
// ── G86 — DÒNG KHÔNG TỒN TẠI = doc LEGACY, không phải "ai đó xoá" → WARN ──
|
|
956
|
+
//
|
|
957
|
+
// Doc sinh TRƯỚC khi field này vào template thì không có dòng nào cả. Đó là trạng thái HỢP LỆ
|
|
958
|
+
// trong quy trình, y như "placeholder chưa ai điền" ở nhánh dưới — chỉ khác thời điểm.
|
|
959
|
+
// `testid_contract.$comment` đã HỨA miễn trừ đúng ca này: "dự án chưa từng chạy /map-testids
|
|
960
|
+
// phải IM LẶNG HOÀN TOÀN … thiếu điều khoản này thì mọi dự án đang chạy đỏ ngay ngày nâng
|
|
961
|
+
// version, và việc đầu tiên người ta làm là thêm --warn-only vào CI". Nhưng thực thi chỉ miễn
|
|
962
|
+
// cho doc KHÔNG có §4.5 — mà doc chưa chạy /map-testids thì VẪN có §4.5 (generate-tech-docs
|
|
963
|
+
// vẽ). Điều khoản không phủ được đúng ca nó mô tả, và dự đoán của nó đã xảy ra từng chữ:
|
|
964
|
+
// 30 doc đỏ trên một dự án đang chạy, chặn /validate-traces của một PRD không liên quan.
|
|
965
|
+
//
|
|
966
|
+
// LẬP LUẬN GIỮ `error` ĐÃ HẾT ĐÚNG. Nó là "QC đoán theo nền ⇒ trượt 100% locator TRONG IM
|
|
967
|
+
// LẶNG". Vế "im lặng" bị hai consumer vô hiệu hoá từ lâu:
|
|
968
|
+
// qc-run-test "Cảnh báo mềm, KHÔNG im lặng hardcode: ⚠️ Tech-doc thiếu @trace.testid_attr…"
|
|
969
|
+
// generate-code "Cảnh báo mềm, KHÔNG im lặng hardcode, rồi mới fallback"
|
|
970
|
+
// Giữ `error` là dựng cổng thứ hai cho một chuyện đã có người canh — và cổng đó chặn nhầm
|
|
971
|
+
// người. Cùng lập luận đã dùng cho T14: "nợ tồn ở project đang chạy sẽ đỏ khắp nơi ở lần đầu
|
|
972
|
+
// và người ta sẽ TẮT CỔNG — mất luôn T1–T13 thật".
|
|
973
|
+
// Gom lại, in MỘT dòng có đếm ở cuối — 30 dòng ⚠️ riêng lẻ là ồn, và ồn thì người ta ngừng đọc.
|
|
974
|
+
testidLegacy.push(rel(doc));
|
|
975
|
+
} else if (/^@trace\.testid_attr\s*:\s*$/.test(attr.trim())) {
|
|
976
|
+
// ── Dòng CÓ mà giá trị RỖNG = ai đó xoá giá trị → vẫn ERROR ──
|
|
977
|
+
// Đây là chỗ DUY NHẤT máy phân biệt được "legacy" với "bị xoá": legacy thì DÒNG KHÔNG TỒN TẠI;
|
|
978
|
+
// bị xoá thì dòng còn đó, giá trị trống. Hạ cả hai xuống warn là mất luôn phép phân biệt.
|
|
979
|
+
err('T16', rel(doc),
|
|
980
|
+
'@trace.testid_attr có mặt nhưng RỖNG — giá trị bị xoá',
|
|
981
|
+
'Khác hẳn doc legacy (ở đó DÒNG không tồn tại). Dòng còn mà giá trị mất nghĩa là ai đó\n' +
|
|
982
|
+
' gỡ nó khỏi một hợp đồng ĐÃ CHỐT — mọi locator QC của PRD này đang bám vào đó.\n' +
|
|
983
|
+
' Sửa: điền lại, hoặc chạy /map-testids {UC-ID}.');
|
|
984
|
+
} else if (hasPlaceholder(attr)) {
|
|
985
|
+
warn('T16', rel(doc),
|
|
986
|
+
'@trace.testid_attr còn ở dạng placeholder — chưa ai điền',
|
|
987
|
+
'Tech-doc vừa sinh ra thì đây là trạng thái BÌNH THƯỜNG (nên là WARN, không ERROR).\n' +
|
|
988
|
+
' Điền bằng /map-testids {UC-ID} trước khi QC dựng locator.');
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// Giá trị THẬT của tên thuộc tính. Thiếu hoặc còn placeholder → null, và khi đó không gom
|
|
992
|
+
// id để so với code: không biết tìm thuộc tính nào trong code thì mọi so sánh đều là đoán.
|
|
993
|
+
const attrVal = attr && !hasPlaceholder(attr)
|
|
994
|
+
? attr.split(':').slice(1).join(':').trim()
|
|
995
|
+
: null;
|
|
996
|
+
|
|
997
|
+
// ── T15 — mọi SC ở cột "Phục vụ SC" phải CÓ THẬT trong .feature ──
|
|
998
|
+
const parts = path.relative(SPECS_DIR, doc).replace(/\\/g, '/').split('/');
|
|
999
|
+
if (parts.length < 3) continue; // không theo {domain}/{prd-slug}/ — bố cục cũ
|
|
1000
|
+
const [domain, prdSlug] = parts;
|
|
1001
|
+
if (!inScopePrd(domain, prdSlug)) continue; // G87 — ngoài scope PRD thì không soi tech-doc
|
|
1002
|
+
const ticket = path.basename(doc).replace(/-tech-design\.md$/, '');
|
|
1003
|
+
|
|
1004
|
+
for (const b of blocks) {
|
|
1005
|
+
const bddDir = path.join(SPECS_DIR, domain, prdSlug, 'bdd', b.platform);
|
|
1006
|
+
if (!fs.existsSync(bddDir)) continue;
|
|
1007
|
+
|
|
1008
|
+
const known = new Set();
|
|
1009
|
+
for (const f of fs.readdirSync(bddDir).filter(n => n.endsWith('.feature'))) {
|
|
1010
|
+
const t = fs.readFileSync(path.join(bddDir, f), 'utf8');
|
|
1011
|
+
for (const m of t.matchAll(/@trace\.scenario\s*:\s*(\S+)/g)) known.add(m[1]);
|
|
1012
|
+
}
|
|
1013
|
+
if (!known.size) continue; // chưa có scenario nào → không có gì để đối chiếu
|
|
1014
|
+
|
|
1015
|
+
const end = lines.findIndex((l, i) => i > b.at && /^###\s/.test(l));
|
|
1016
|
+
const blockEnd = end === -1 ? lines.length : end;
|
|
1017
|
+
|
|
1018
|
+
// Thu HẸP về đúng tiểu mục §4.5.6, không lấy cả block §4.5.
|
|
1019
|
+
//
|
|
1020
|
+
// Block §4.5 chứa §4.5.1 Cây Component, §4.5.2, … — toàn bảng, và chúng KHÔNG phải bảng
|
|
1021
|
+
// test-id. Quét cả block thì: (a) ô "Purpose" / "Màn đứng riêng…" bị parse như ô Serves,
|
|
1022
|
+
// (b) tên component (`Z2BlockController`) khớp regex id nên LỌT VÀO `declared` như một
|
|
1023
|
+
// test-id. Lỗi này có từ trước; nó vô hình vì hai thứ đó chỉ "im lặng không parse được".
|
|
1024
|
+
// Bộ đếm G81 làm nó lộ ra ngay lần chạy đầu trên dữ liệu thật — 146 ô, và không ô nào
|
|
1025
|
+
// thuộc §4.5.6.
|
|
1026
|
+
const subAt = lines.findIndex((l, i) =>
|
|
1027
|
+
i > b.at && i < blockEnd && /^#{3,5}\s*4\.5\.6\b/.test(l));
|
|
1028
|
+
// `continue`, KHÔNG `return`: đây là `for…of` lồng trong `for (const doc of docs)`, nên
|
|
1029
|
+
// `return` thoát CẢ HÀM và bỏ im lặng mọi doc còn lại. Đã gặp: T16 rơi 30 → 9 mà mọi lệnh
|
|
1030
|
+
// kiểm vẫn xanh, vì "ít cảnh báo hơn" không có gì báo là sai.
|
|
1031
|
+
if (subAt === -1) continue; // block này không có §4.5.6 → bỏ qua
|
|
1032
|
+
const subEnd = lines.findIndex((l, i) =>
|
|
1033
|
+
i > subAt && i < blockEnd && /^#{1,5}\s/.test(l));
|
|
1034
|
+
const body = lines.slice(subAt, subEnd === -1 ? blockEnd : subEnd);
|
|
1035
|
+
|
|
1036
|
+
// G80 — vị trí cột "Serves SC", tìm theo TÊN ở dòng tiêu đề; -1 = chưa thấy.
|
|
1037
|
+
// Đọc-theo-vị-trí ("ô cuối") chỉ an toàn khi MỌI bảng cùng số cột — mà G81 chứng minh không:
|
|
1038
|
+
// dữ liệu thật có bảng 3 cột lẫn 5 cột. Khi số cột đổi, "ô cuối" mất chỗ neo và không gì
|
|
1039
|
+
// bảo đảm nó là cột Serves. Tìm theo tên trước, rơi về vị trí sau — và NÓI RA khi phải rơi.
|
|
1040
|
+
let servesIdx = -1;
|
|
1041
|
+
|
|
1042
|
+
body.forEach((line, k) => {
|
|
1043
|
+
if (!line.trim().startsWith('|')) return;
|
|
1044
|
+
if (/^\|[\s|:-]+\|$/.test(line.trim())) return; // dòng phân cách markdown |---|---|
|
|
1045
|
+
const cells = line.split('|').map(s => s.trim());
|
|
1046
|
+
|
|
1047
|
+
// Dòng TIÊU ĐỀ — chốt vị trí cột Serves rồi bỏ qua, đừng parse nó như một row.
|
|
1048
|
+
// Nhận cả `Serves SC` (28/28 doc thật) lẫn `Phục vụ SC` (template cũ) — đổi tên là việc
|
|
1049
|
+
// của G80, nhưng doc đã viết thì không đổi theo, nên checker phải nhận cả hai.
|
|
1050
|
+
if (/^test-?id$/i.test((cells[1] || '').replace(/`/g, '').trim())) {
|
|
1051
|
+
servesIdx = cells.findIndex(c => /serves|phục vụ/i.test(c));
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
if (isTemplateSample(cells)) return; // dòng mẫu của template — xem G82
|
|
1056
|
+
const last = (servesIdx > 0 ? cells[servesIdx] : cells[cells.length - 2]) || '';
|
|
1057
|
+
|
|
1058
|
+
// Gom id đã khai (cột 1) cho T17/T18/T19. Làm ở đây vì đây là chỗ duy nhất đã lọc xong
|
|
1059
|
+
// dòng mẫu và dòng tiêu đề/phân cách.
|
|
1060
|
+
// Ô Test-ID có thể mang NHIỀU id. Dữ liệu thật:
|
|
1061
|
+
// | `uc1-z2-rank-avatar` / `uc1-z2-rank-name` | Avatar/tên HS | UC1-SC8 |
|
|
1062
|
+
// Lấy mọi token trong backtick; không có backtick nào thì rơi về cả ô (bảng viết trần).
|
|
1063
|
+
// Đọc cả ô như MỘT id thì hai id đó không bao giờ vào `declared`, và T19 BÁO OAN cả hai
|
|
1064
|
+
// khi .Test.md dùng chúng — xác nhận trên bảng thật của z2-tin-hieu-aha.
|
|
1065
|
+
const rawCell = cells[1] || '';
|
|
1066
|
+
const ticked = [...rawCell.matchAll(/`([^`]+)`/g)].map(m => m[1].trim());
|
|
1067
|
+
const idCells = ticked.length ? ticked : [rawCell.trim()];
|
|
1068
|
+
|
|
1069
|
+
for (const idCell of idCells) {
|
|
1070
|
+
// Phải BẮT ĐẦU bằng chữ/số: chặn dòng phân cách (`---`) và mọi rác không phải id.
|
|
1071
|
+
// `{}` được phép từ G82: `shop-tab-{code}` là pattern id THẬT, không phải placeholder.
|
|
1072
|
+
if (!attrVal || idCell === 'Test-ID') continue;
|
|
1073
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_.:{}-]*$/.test(idCell)) continue;
|
|
1074
|
+
if (!declared.has(attrVal)) {
|
|
1075
|
+
declared.set(attrVal, { ids: new Set(), patterns: new Set(), byDoc: new Map() });
|
|
1076
|
+
}
|
|
1077
|
+
const d = declared.get(attrVal);
|
|
1078
|
+
if (!d.byDoc.has(doc)) d.byDoc.set(doc, new Set());
|
|
1079
|
+
d.byDoc.get(doc).add(idCell);
|
|
1080
|
+
if (hasPlaceholder(idCell)) {
|
|
1081
|
+
const re = patternToRegex(idCell);
|
|
1082
|
+
if (re) d.patterns.add(re);
|
|
1083
|
+
} else {
|
|
1084
|
+
d.ids.add(idCell);
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
if (!last) return;
|
|
1089
|
+
// Ô "Phục vụ SC" còn `{…}` → row ĐANG ĐIỀN DỞ (ai đó thay id thật nhưng chưa thay ô này).
|
|
1090
|
+
// Kiểm SC ở đây là báo oan một doc chưa viết xong. Tách khỏi luật dòng-mẫu ở trên vì
|
|
1091
|
+
// G82 cần xử theo TỪNG Ô: ô Test-ID có `{…}` là PATTERN (phải kiểm), ô Serves có `{…}`
|
|
1092
|
+
// là CHƯA ĐIỀN (đừng kiểm). Cùng ký hiệu, hai nghĩa, hai ô.
|
|
1093
|
+
if (hasPlaceholder(last)) return;
|
|
1094
|
+
|
|
1095
|
+
// Năm dạng ô Serves — xem parseServesCell(). KHÔNG parse được token nào thì ĐẾM LẠI
|
|
1096
|
+
// và in ở cuối (G81): im lặng-vì-không-hiểu KHÁC im lặng-vì-không-có-gì-để-báo.
|
|
1097
|
+
const { ids, parsed } = parseServesCell(last, ticket);
|
|
1098
|
+
if (!parsed) { servesUnparsed.push({ doc, line: subAt + k + 1, cell: last }); return; }
|
|
1099
|
+
for (const id of ids) {
|
|
1100
|
+
if (known.has(id)) continue;
|
|
1101
|
+
err('T15', `${rel(doc)}:${subAt + k + 1}`,
|
|
1102
|
+
`§4.5.6 (${b.platform}) trỏ tới ${id} — scenario này KHÔNG có trong .feature`,
|
|
1103
|
+
`Đã tìm ở: ${rel(bddDir)}/\n` +
|
|
1104
|
+
` Consumer per-UC LỌC theo cột "Serves SC". Trỏ vào SC không tồn tại\n` +
|
|
1105
|
+
` ⇒ không khớp row nào ⇒ QC tưởng element không có test-id rồi đi dò DOM.\n` +
|
|
1106
|
+
` Nguyên nhân thường gặp: SC bị gộp/xoá mà bảng không được cập nhật.`);
|
|
1107
|
+
}
|
|
1108
|
+
});
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
// ── T17/T18 — hợp đồng §4.5.6 vs CODE thật ──────────────────────────────────
|
|
1114
|
+
//
|
|
1115
|
+
// T15/T16 canh bảng với .feature (hai tài liệu). Hai rule này canh bảng với CODE — chiều duy
|
|
1116
|
+
// nhất phát hiện được "FE chưa gắn" và "FE gắn ngoài hợp đồng".
|
|
1117
|
+
//
|
|
1118
|
+
// CẢ HAI LÀ WARN, không ERROR. Id đoán từ thiết kế KHÔNG sống sót 100%: lúc implement, dev có
|
|
1119
|
+
// thể gộp hai element thành một component hoặc tách một thành hai. Đây là NỢ CẦN THẤY, không
|
|
1120
|
+
// phải cái sai chặn người — cùng nhóm với TECHDOC_DRIFT.
|
|
1121
|
+
|
|
1122
|
+
// Dựng regex tìm test-id trong code theo ĐÚNG tên thuộc tính mà tech-doc khai.
|
|
1123
|
+
// Không đoán được dạng → trả null, và lệnh sẽ BỎ QUA attr đó thay vì so bừa.
|
|
1124
|
+
function testidPattern(attr) {
|
|
1125
|
+
if (/^[A-Za-z][A-Za-z0-9_-]*$/.test(attr)) {
|
|
1126
|
+
// web `data-testid`/`data-test`/`data-qa` · RN `testID` · iOS `accessibilityIdentifier`
|
|
1127
|
+
// Phủ: attr="x" · attr='x' · attr={"x"} · attr={'x'} · attr: "x"
|
|
1128
|
+
return new RegExp(`\\b${attr.replace(/-/g, '\\-')}\\s*[=:]\\s*\\{?\\s*['"\`]([^'"\`]+)['"\`]`, 'g');
|
|
1129
|
+
}
|
|
1130
|
+
if (/Key|Semantics/i.test(attr)) {
|
|
1131
|
+
// Flutter: Key('x') · ValueKey('x') · Semantics(identifier: 'x')
|
|
1132
|
+
return /(?:\bValueKey|\bKey)\s*\(\s*['"]([^'"]+)['"]|identifier\s*:\s*['"]([^'"]+)['"]/g;
|
|
1133
|
+
}
|
|
1134
|
+
return null;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
function lintTestidVsCode() {
|
|
1138
|
+
if (!declared.size) return; // chưa có hợp đồng nào → ngoài phạm vi
|
|
1139
|
+
|
|
1140
|
+
if (!CODE_DIRS.length) {
|
|
1141
|
+
infos.push(`T17/T18 BỎ QUA — không có --code nên chưa đối chiếu hợp đồng test-id với code. ` +
|
|
1142
|
+
`Ca "FE chưa gắn id đã khai" và "FE gắn id ngoài hợp đồng" đang KHÔNG được kiểm. ` +
|
|
1143
|
+
`Truyền --code {gốc mã nguồn} để bật.`);
|
|
1144
|
+
return;
|
|
1145
|
+
}
|
|
1146
|
+
const roots = CODE_DIRS.filter(d => fs.existsSync(path.resolve(d)));
|
|
1147
|
+
if (!roots.length) return; // T14 đã báo gốc code không tồn tại
|
|
1148
|
+
|
|
1149
|
+
const files = [];
|
|
1150
|
+
const walkCode = dir => {
|
|
1151
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
1152
|
+
if (e.isDirectory()) { if (!CODE_SKIP_DIR.has(e.name)) walkCode(path.join(dir, e.name)); }
|
|
1153
|
+
else if (CODE_EXT.includes(path.extname(e.name))) files.push(path.join(dir, e.name));
|
|
1154
|
+
}
|
|
1155
|
+
};
|
|
1156
|
+
for (const r of roots) walkCode(path.resolve(r));
|
|
1157
|
+
if (!files.length) return;
|
|
1158
|
+
|
|
1159
|
+
for (const [attr, d] of declared) {
|
|
1160
|
+
const re = testidPattern(attr);
|
|
1161
|
+
if (!re) {
|
|
1162
|
+
infos.push(`T17/T18 BỎ QUA cho @trace.testid_attr = "${attr}" — không dựng được phép tìm ` +
|
|
1163
|
+
`xác định cho dạng này. So bừa thì báo oan, nên im lặng và nói ra.`);
|
|
1164
|
+
continue;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
const inCode = new Map(); // id → file đầu tiên thấy
|
|
1168
|
+
for (const f of files) {
|
|
1169
|
+
const txt = fs.readFileSync(f, 'utf8');
|
|
1170
|
+
for (const m of txt.matchAll(re)) {
|
|
1171
|
+
const v = m[1] || m[2];
|
|
1172
|
+
if (v && !inCode.has(v)) inCode.set(v, f);
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
// Code CHƯA có id nào của thuộc tính này → nhiều khả năng UI chưa viết, hoặc --code trỏ
|
|
1177
|
+
// sai gốc. Phán "FE chưa gắn" lúc này là báo oan cả bảng.
|
|
1178
|
+
if (!inCode.size) continue;
|
|
1179
|
+
|
|
1180
|
+
// ── T17 — bảng có, code không có ──
|
|
1181
|
+
for (const [doc, ids] of d.byDoc) {
|
|
1182
|
+
// Pattern id (`shop-tab-{code}`) coi là ĐÃ GẮN khi code có BẤT KỲ instance nào khớp nó.
|
|
1183
|
+
const codeIds = [...inCode.keys()];
|
|
1184
|
+
const missing = [...ids].filter(id => hasPlaceholder(id)
|
|
1185
|
+
? !((r => r && codeIds.some(c => r.test(c)))(patternToRegex(id)))
|
|
1186
|
+
: !inCode.has(id));
|
|
1187
|
+
if (!missing.length) continue;
|
|
1188
|
+
const SHOW = 5;
|
|
1189
|
+
warn('T17', rel(doc),
|
|
1190
|
+
`${missing.length} test-id đã khai ở §4.5.6 mà KHÔNG tìm thấy trong code: ` +
|
|
1191
|
+
missing.slice(0, SHOW).join(', ') + (missing.length > SHOW ? ` (+${missing.length - SHOW})` : ''),
|
|
1192
|
+
`Thuộc tính đang tìm: ${attr} (theo @trace.testid_attr của doc này).\n` +
|
|
1193
|
+
` FE chưa gắn, gắn sai giá trị, hoặc element đã đổi lúc implement.\n` +
|
|
1194
|
+
` QC sẽ trượt locator ở đúng những id này. Sửa: /generate-code, hoặc\n` +
|
|
1195
|
+
` /map-testids {UC-ID} --from-code nếu element thật đã đổi.`);
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
// ── T18 — code có, bảng không có ──
|
|
1199
|
+
const undeclared = [...inCode.keys()]
|
|
1200
|
+
.filter(id => !matchesDeclaredIds(id, d.ids, d.patterns || new Set()));
|
|
1201
|
+
if (undeclared.length) {
|
|
1202
|
+
const SHOW = 5;
|
|
1203
|
+
warn('T18', rel(path.dirname([...inCode.values()][0])),
|
|
1204
|
+
`${undeclared.length} test-id nằm trong code mà KHÔNG có trong §4.5.6 nào: ` +
|
|
1205
|
+
undeclared.slice(0, SHOW).join(', ') + (undeclared.length > SHOW ? ` (+${undeclared.length - SHOW})` : ''),
|
|
1206
|
+
`Thuộc tính: ${attr}. Ai đó gắn id ngoài hợp đồng — thường là /generate-code chạy khi\n` +
|
|
1207
|
+
` §4.5.6 rỗng (id TẠM), hoặc sửa tay. Hợp đồng không còn đủ, và QC không biết\n` +
|
|
1208
|
+
` những id này tồn tại. Sửa: /map-testids {UC-ID} --from-code để đưa vào bảng.`);
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
|
|
810
1213
|
// ── Main ──────────────────────────────────────────────────────────────────────
|
|
811
1214
|
|
|
812
1215
|
let scanned = { books: 0, seams: 0, history: 0, files: 0 };
|
|
@@ -863,6 +1266,198 @@ for (const dir of TRACE_DIRS) {
|
|
|
863
1266
|
// phán khi chưa đọc hết sổ là phán trên dữ liệu thiếu.
|
|
864
1267
|
lintCodeVsLedger();
|
|
865
1268
|
|
|
1269
|
+
// ── T19 — chân thứ tư: §4.5.6 vs *.Test.md ──────────────────────────────────
|
|
1270
|
+
//
|
|
1271
|
+
// Hợp đồng test-id có BỐN chân. T15/T16 canh bảng ↔ .feature · T17/T18 canh bảng ↔ code.
|
|
1272
|
+
// Chân thứ tư — bảng ↔ *.Test.md — là chân DUY NHẤT mà giá trị id bị CHÉP CỨNG vào một
|
|
1273
|
+
// artifact BỀN rồi nằm lại:
|
|
1274
|
+
// generate-code → bake vào code → T17/T18 canh
|
|
1275
|
+
// qc-run-test → dựng locator LÚC CHẠY → đọc bảng tươi, tự cứu
|
|
1276
|
+
// qc-design-test → chép vào .Test.md → chân này
|
|
1277
|
+
//
|
|
1278
|
+
// VÌ SAO CẦN CẢ LỚP NÀY khi /qc-review và /qc-run-test ĐÃ có guard cho nó (G64):
|
|
1279
|
+
// rules/workflow.md — "Canh contract ≠ canh dữ liệu. Cần CẢ HAI". Guard ở trạm 4-5 phủ 100%
|
|
1280
|
+
// đường đi thật nhưng do LLM thực thi: nó không chặn được trước khi merge. T19 nông hơn nhưng
|
|
1281
|
+
// chạy trong CI. Hai lớp KHÔNG thay nhau — đúng cặp self-check ↔ lint-trace.
|
|
1282
|
+
//
|
|
1283
|
+
// MỘT CHIỀU. Chiều ngược (§4.5.6 có id mà không TC nào nhắc) KHÔNG kiểm: không phải element nào
|
|
1284
|
+
// cũng cần một bước TC, nên kiểm chiều đó là ỒN. Đúng bất đối xứng T17/T18 đã chọn.
|
|
1285
|
+
function lintTestidVsTestCases() {
|
|
1286
|
+
if (!declared.size) return; // không có §4.5 client → NGOÀI PHẠM VI, im lặng
|
|
1287
|
+
|
|
1288
|
+
if (!QC_DIRS.length) {
|
|
1289
|
+
infos.push(`T19 BỎ QUA — không có --qc nên chưa đối chiếu hợp đồng test-id với test-case. ` +
|
|
1290
|
+
`Ca "TC bám id không còn trong §4.5.6" đang KHÔNG được kiểm ở tầng CI ` +
|
|
1291
|
+
`(/qc-review và /qc-run-test vẫn bắt được, nhưng chỉ khi có người chạy chúng). ` +
|
|
1292
|
+
`Truyền --qc {qc_dir} để bật.`);
|
|
1293
|
+
return;
|
|
1294
|
+
}
|
|
1295
|
+
const roots = QC_DIRS.filter(d => fs.existsSync(path.resolve(d)));
|
|
1296
|
+
if (!roots.length) {
|
|
1297
|
+
infos.push(`T19 BỎ QUA — --qc trỏ tới thư mục không tồn tại: ${QC_DIRS.join(', ')}`);
|
|
1298
|
+
return;
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
const files = [];
|
|
1302
|
+
for (const r of roots) {
|
|
1303
|
+
for (const f of walk(path.resolve(r))) {
|
|
1304
|
+
if (/\.Test\.md$/.test(path.basename(f))) files.push(f);
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
if (!files.length) {
|
|
1308
|
+
infos.push(`T19 BỎ QUA — không tìm thấy *.Test.md dưới --qc. ` +
|
|
1309
|
+
`Trỏ nhầm đường mà im lặng thì đó là xanh giả.`);
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
// Tập id đã khai, gộp mọi thuộc tính: TC nhắc id nào cũng phải có ở MỘT bảng nào đó.
|
|
1314
|
+
// Gộp mọi thuộc tính: TC nhắc id nào cũng phải có ở MỘT bảng nào đó.
|
|
1315
|
+
// Pattern id đi riêng — instance trong .Test.md (`uc1-z2-region`) phải khớp được
|
|
1316
|
+
// `uc{n}-z2-region` ở bảng, nếu không T19 sẽ BÁO OAN chính id hợp lệ (G82 mặt 2).
|
|
1317
|
+
const allDeclared = new Set();
|
|
1318
|
+
const allPatterns = new Set();
|
|
1319
|
+
for (const [, d] of declared) {
|
|
1320
|
+
for (const id of d.ids) allDeclared.add(id);
|
|
1321
|
+
for (const re of (d.patterns || [])) allPatterns.add(re);
|
|
1322
|
+
}
|
|
1323
|
+
if (!allDeclared.size && !allPatterns.size) return;
|
|
1324
|
+
|
|
1325
|
+
for (const f of files) {
|
|
1326
|
+
const txt = fs.readFileSync(f, 'utf8');
|
|
1327
|
+
// Chỉ nhận id trong backtick — đúng cách qc-design-test ghi ("click `ft001-login-submit-btn`").
|
|
1328
|
+
// Quét chữ trần thì mọi từ gạch ngang trong câu văn đều thành ứng viên ⇒ báo oan.
|
|
1329
|
+
const used = new Set();
|
|
1330
|
+
for (const m of txt.matchAll(/`([a-z0-9][a-z0-9_.:-]*)`/gi)) {
|
|
1331
|
+
const v = m[1];
|
|
1332
|
+
// Chỉ xét token MANG DÁNG test-id (≥2 đoạn ngăn bằng `-`). Loại `--force`, `pass`, `UC1`…
|
|
1333
|
+
if (/^[a-z0-9]+(-[a-z0-9]+){2,}$/i.test(v)) used.add(v);
|
|
1334
|
+
}
|
|
1335
|
+
const unknown = [...used].filter(id => !matchesDeclaredIds(id, allDeclared, allPatterns));
|
|
1336
|
+
if (!unknown.length) continue;
|
|
1337
|
+
|
|
1338
|
+
const SHOW = 5;
|
|
1339
|
+
warn('T19', rel(f),
|
|
1340
|
+
`${unknown.length} test-id trong test-case KHÔNG có ở §4.5.6 nào: ` +
|
|
1341
|
+
unknown.slice(0, SHOW).join(', ') + (unknown.length > SHOW ? ` (+${unknown.length - SHOW})` : ''),
|
|
1342
|
+
'TC bám một id không còn trong hợp đồng ⇒ locator TRƯỢT lúc chạy, và test đỏ\n' +
|
|
1343
|
+
' "element not found" TRÔNG Y HỆT một bug sản phẩm — nên /qc-report phân loại\n' +
|
|
1344
|
+
' product-gap rồi in một /report-bug, và một lỗi hợp đồng NỘI BỘ đi ra khỏi\n' +
|
|
1345
|
+
' đội QC thành phiếu lỗi gửi PO.\n' +
|
|
1346
|
+
' Thường gặp: /map-testids đổi id mà .Test.md chưa chạy lại.\n' +
|
|
1347
|
+
' Sửa: /map-testids {UC-ID} (đưa vào hợp đồng) HOẶC /qc-design-test {UC-ID}.');
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
// T15/T16 độc lập với sổ trace — xem doc-comment của lintTestidContract().
|
|
1352
|
+
lintTestidContract();
|
|
1353
|
+
|
|
1354
|
+
// ── G86 — nợ hợp đồng test-id: MỘT dòng có đếm, không 30 dòng rời ────────────
|
|
1355
|
+
if (testidLegacy.length) {
|
|
1356
|
+
const SHOW = 3;
|
|
1357
|
+
infos.push(`T16: ${testidLegacy.length} tech-doc chưa có \`@trace.testid_attr\` (doc LEGACY — ` +
|
|
1358
|
+
`sinh trước khi field này vào template). QC sẽ fallback theo nền và TỰ CẢNH BÁO, nên ` +
|
|
1359
|
+
`đây là NỢ CẦN THẤY, KHÔNG chặn. Chốt hợp đồng: /map-testids {UC-ID}. ` +
|
|
1360
|
+
`Ví dụ: ${testidLegacy.slice(0, SHOW).join(' · ')}` +
|
|
1361
|
+
(testidLegacy.length > SHOW ? ` (+${testidLegacy.length - SHOW})` : ''));
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
|
|
1365
|
+
// ── G81 — ô Serves không đọc được PHẢI được đếm và in ra ─────────────────────
|
|
1366
|
+
//
|
|
1367
|
+
// Điều khoản cũ — "KHÔNG parse được token nào → BỎ QUA, không đoán" — đúng về KHÔNG ĐOÁN và sai
|
|
1368
|
+
// về KHÔNG NÓI. Nó chọn im lặng TUYỆT ĐỐI thay vì im lặng CÓ TIẾNG, nên 86/229 row đi qua mà báo
|
|
1369
|
+
// cáo vẫn nói "hợp đồng khớp". Đây là loại lỗi thứ tư mà T13 đặt tên: không phải "có cái sai",
|
|
1370
|
+
// mà là "KHÔNG BIẾT bảo vệ có bật hay không".
|
|
1371
|
+
if (servesUnparsed.length) {
|
|
1372
|
+
const SHOW = 3;
|
|
1373
|
+
const eg = servesUnparsed.slice(0, SHOW)
|
|
1374
|
+
.map(u => `"${u.cell.slice(0, 48)}"`).join(' · ');
|
|
1375
|
+
infos.push(`T15: ${servesUnparsed.length} ô "Serves SC" không đọc được dạng nào — CHƯA KIỂM ` +
|
|
1376
|
+
`(không phải "sạch", là "không biết"). Ví dụ: ${eg}` +
|
|
1377
|
+
(servesUnparsed.length > SHOW ? ` (+${servesUnparsed.length - SHOW})` : '') +
|
|
1378
|
+
`. Dạng đọc được: "UC1 · SC1" · "UC1-SC1" · "UC3-SC5..SC11" · "UC6-SC4, SC5".`);
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
// ── T20 — mô tả "lấy X từ Y" phải TÌM ĐƯỢC ở artifact thật ──────────────────
|
|
1382
|
+
//
|
|
1383
|
+
// G85: `qc_artifact_stamp.fields.bdd_version` khai nguồn là "| **Version** | của header .feature".
|
|
1384
|
+
// KHÔNG THỂ ĐÚNG — .feature là Gherkin, header là khối comment `# @trace.*`, không có bảng Markdown.
|
|
1385
|
+
// Đo thật: 0/278 file có dạng cũ; 276/278 có `@trace.bdd_version`.
|
|
1386
|
+
//
|
|
1387
|
+
// Nó đi qua MỌI phép kiểm vì chưa ai ĐI LẤY THỬ. Kiểm chỉ dẫn CÓ MẶT ("khối §Stamp có được include
|
|
1388
|
+
// vào 5 lệnh không?" → ✅ 5/5) khác hẳn kiểm chỉ dẫn DÙNG ĐƯỢC. Rule này đóng khoảng đó.
|
|
1389
|
+
function lintStampProbes() {
|
|
1390
|
+
const QS = schema.qc_artifact_stamp;
|
|
1391
|
+
if (!QS || !QS.fields) return;
|
|
1392
|
+
|
|
1393
|
+
if (!fs.existsSync(SPECS_DIR)) {
|
|
1394
|
+
infos.push(`T20 BỎ QUA — không có ${rel(SPECS_DIR)} nên chưa kiểm được mô tả nguồn của ` +
|
|
1395
|
+
`qc_artifact_stamp.fields có TÌM ĐƯỢC ở artifact thật không. Truyền --specs để bật.`);
|
|
1396
|
+
return;
|
|
1397
|
+
}
|
|
1398
|
+
const all = walk(SPECS_DIR);
|
|
1399
|
+
// Phân loại artifact theo path/đuôi — đúng bố cục feature-package.
|
|
1400
|
+
const pick = kind => all.filter(f => {
|
|
1401
|
+
const b = path.basename(f), p = f.replace(/\\/g, '/');
|
|
1402
|
+
if (kind === 'feature') return /\.feature$/.test(b);
|
|
1403
|
+
if (kind === 'tech-design') return /-tech-design\.md$/.test(b);
|
|
1404
|
+
if (kind === 'design-spec') return /\/design-spec\//.test(p) && /\.md$/.test(b);
|
|
1405
|
+
if (kind === 'prd') {
|
|
1406
|
+
// PRD = file .md ở GỐC một feature-package: specs/{domain}/{prd-slug}/{TICKET-ID}-{slug}.md,
|
|
1407
|
+
// và folder đó phải có `bdd/` bên cạnh. Lọc lỏng hơn (mọi .md ngoài design-spec/tech-docs)
|
|
1408
|
+
// nuốt cả specs/domain-knowledge/* và BÁO OAN ngay lần chạy đầu — một rule báo oan là một
|
|
1409
|
+
// rule bị tắt.
|
|
1410
|
+
if (!/\.md$/.test(b)) return false;
|
|
1411
|
+
if (/\/(design-spec|tech-docs|bdd|changelog)\//.test(p)) return false;
|
|
1412
|
+
const folder = path.dirname(f);
|
|
1413
|
+
const rel2 = path.relative(SPECS_DIR, folder).replace(/\\/g, '/');
|
|
1414
|
+
if (rel2.split('/').length !== 2) return false; // đúng {domain}/{prd-slug}
|
|
1415
|
+
return fs.existsSync(path.join(folder, 'bdd'));
|
|
1416
|
+
}
|
|
1417
|
+
return false;
|
|
1418
|
+
});
|
|
1419
|
+
|
|
1420
|
+
for (const [name, f] of Object.entries(QS.fields)) {
|
|
1421
|
+
if (!f || !f.probe || !f.artifact) continue; // R-rule canh phần khai; đây canh DỮ LIỆU
|
|
1422
|
+
let re;
|
|
1423
|
+
try { re = new RegExp(f.probe); } catch { continue; }
|
|
1424
|
+
const files = pick(f.artifact);
|
|
1425
|
+
if (!files.length) {
|
|
1426
|
+
infos.push(`T20 BỎ QUA cho \`${name}\` — không tìm thấy artifact loại \`${f.artifact}\` ` +
|
|
1427
|
+
`dưới ${rel(SPECS_DIR)}. Không có mẫu thì không kết luận được.`);
|
|
1428
|
+
continue;
|
|
1429
|
+
}
|
|
1430
|
+
const hit = files.filter(x => { try { return re.test(fs.readFileSync(x, 'utf8')); } catch { return false; } });
|
|
1431
|
+
if (hit.length) continue; // tìm được ở ≥1 file → mô tả dùng được
|
|
1432
|
+
|
|
1433
|
+
// Field do MỘT LỆNH KHÁC điền sau → "0/N file có" nghĩa là lệnh đó CHƯA CHẠY, không phải mô tả
|
|
1434
|
+
// sai. T20 không phân biệt được hai ca này từ dữ liệu; `filled_by` là chỗ khai ra sự khác biệt.
|
|
1435
|
+
// Thiếu nó thì rule báo oan trên MỌI dự án chưa chạy lệnh đó — và một rule báo oan là rule bị tắt.
|
|
1436
|
+
if (f.filled_by) {
|
|
1437
|
+
infos.push(`T20 BỎ QUA cho \`${name}\` — 0/${files.length} file \`${f.artifact}\` có, nhưng ` +
|
|
1438
|
+
`trường này do \`/${f.filled_by}\` điền SAU. "Chưa có" ở đây nghĩa là lệnh đó chưa ` +
|
|
1439
|
+
`chạy, không phải mô tả nguồn sai.`);
|
|
1440
|
+
continue;
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
warn('T20', `qc_artifact_stamp.fields.${name}`,
|
|
1444
|
+
`mô tả nguồn KHÔNG tìm thấy ở artifact nào (0/${files.length} file \`${f.artifact}\`)`,
|
|
1445
|
+
`probe: ${f.probe}\n` +
|
|
1446
|
+
` source: "${f.source || '—'}"\n` +
|
|
1447
|
+
` Một chỉ dẫn "lấy X từ Y" đi qua mọi phép kiểm khi chưa ai ĐI LẤY THỬ: kiểm chỉ dẫn\n` +
|
|
1448
|
+
` CÓ MẶT khác kiểm nó DÙNG ĐƯỢC. Agent làm theo sẽ không tìm ra, rồi bỏ trống hoặc\n` +
|
|
1449
|
+
` ĐOÁN — và stamp rỗng trông y hệt "artifact cũ chưa có stamp", nên điều khoản\n` +
|
|
1450
|
+
` backward_compat sẽ NUỐT LUÔN lỗi này.\n` +
|
|
1451
|
+
` Kiểm đúng ĐỊNH DẠNG file: .feature là Gherkin (comment # @trace.*), PRD và\n` +
|
|
1452
|
+
` design-spec là Markdown (bảng | **Version** |).`);
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
lintStampProbes();
|
|
1457
|
+
lintTestidVsTestCases();
|
|
1458
|
+
// T17/T18 SAU nó: cần `declared` mà nó vừa gom.
|
|
1459
|
+
lintTestidVsCode();
|
|
1460
|
+
|
|
866
1461
|
// ── Report ────────────────────────────────────────────────────────────────────
|
|
867
1462
|
|
|
868
1463
|
if (AS_JSON) {
|
|
@@ -880,9 +1475,11 @@ console.log('');
|
|
|
880
1475
|
|
|
881
1476
|
if (!foundDirs.length) {
|
|
882
1477
|
console.log(` ℹ️ Không tìm thấy trace dir nào (đã thử: ${TRACE_DIRS.join(', ')}).`);
|
|
883
|
-
console.log(`
|
|
1478
|
+
console.log(` Bỏ qua T1–T14 (rule về sổ trace) — chạy /generate-bdd để khởi tạo.`);
|
|
884
1479
|
console.log('');
|
|
885
|
-
|
|
1480
|
+
// T15/T16 KHÔNG cần sổ trace: chúng so tech-doc với .feature, và tech-doc có TRƯỚC sổ ở
|
|
1481
|
+
// repo chỉ-spec. Thoát vô điều kiện ở đây là làm hai rule đó câm đúng nơi chúng có việc.
|
|
1482
|
+
if (!errors.length && !warns.length && !infos.length) process.exit(0);
|
|
886
1483
|
}
|
|
887
1484
|
|
|
888
1485
|
const show = (list, icon) => {
|