@educa-corp/sdd-framework 0.9.5 → 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.
Files changed (55) hide show
  1. package/bin/build.js +11 -1
  2. package/bin/lint-trace.js +397 -28
  3. package/bin/self-check.js +183 -12
  4. package/bin/trace-schema.json +2656 -1981
  5. package/core/FRAMEWORK_VERSION +1 -1
  6. package/core/commands/dev-gen-test.md +62 -0
  7. package/core/commands/generate-code.md +1 -1
  8. package/core/commands/generate-tech-docs.md +3 -3
  9. package/core/commands/map-testids.md +88 -11
  10. package/core/commands/qc-analyze.md +509 -425
  11. package/core/commands/qc-design-test.md +475 -247
  12. package/core/commands/qc-plan.md +134 -93
  13. package/core/commands/qc-review.md +216 -131
  14. package/core/commands/qc-run-test.md +346 -231
  15. package/core/commands/validate-traces.md +17 -2
  16. package/core/rules/workflow.md +40 -0
  17. package/core/skills/qc/qa-analyst/DOC_GAP.template.md +9 -1
  18. package/core/skills/qc/qa-designer/shared/duplicate-check-procedure.md +33 -5
  19. package/core/skills/qc/qa-designer/shared/tc-metadata-format.md +24 -0
  20. package/core/skills/qc/qa-planner/test-plan.md +7 -0
  21. package/core/skills/qc/qa-runner/functional/gui-feature.md +1 -1
  22. package/core/skills/qc/qa-runner/functional/gui-screen.md +1 -1
  23. package/core/steps/qc-scope.md +67 -11
  24. package/core/steps/qc-stamp.md +142 -0
  25. package/core/steps/report-footer.md +13 -5
  26. package/core/templates/tech-design.template.md +3 -3
  27. package/docs/01-getting-started/quickstart.md +4 -3
  28. package/docs/02-concepts/architecture.md +14 -0
  29. package/docs/02-concepts/glossary.md +8 -0
  30. package/docs/02-concepts/overview.md +3 -2
  31. package/docs/02-concepts/pipeline-steps/04-bdd.md +1 -1
  32. package/docs/02-concepts/pipeline-steps/05-tech-docs.md +21 -5
  33. package/docs/02-concepts/pipeline-steps/06-code.md +12 -2
  34. package/docs/02-concepts/pipeline-steps/08-qc-automation.md +60 -12
  35. package/docs/02-concepts/pipeline-steps/README.md +4 -3
  36. package/docs/02-concepts/traceability.md +2 -2
  37. package/docs/03-guides/architect.md +2 -2
  38. package/docs/03-guides/developer.md +5 -2
  39. package/docs/03-guides/tester-qa.md +17 -5
  40. package/docs/04-reference/commands.md +6 -3
  41. package/docs/04-reference/trace-schema.md +1 -1
  42. package/docs/explain/07-generate-tech-docs.md +5 -3
  43. package/docs/explain/08-review-tech-docs.md +15 -3
  44. package/docs/explain/09-generate-code.md +30 -4
  45. package/docs/explain/10-review-code.md +1 -1
  46. package/docs/explain/11-map-testids.md +72 -70
  47. package/docs/explain/12-dev-gen-test.md +1 -1
  48. package/docs/explain/15-qc-analyze.md +14 -2
  49. package/docs/explain/16-qc-plan.md +5 -1
  50. package/docs/explain/17-qc-design-test.md +26 -3
  51. package/docs/explain/18-qc-review.md +6 -2
  52. package/docs/explain/19-qc-run-test.md +29 -6
  53. package/docs/explain/20-qc-report.md +5 -2
  54. package/docs/explain/README.md +4 -1
  55. 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ờ `--include-draft`).\n' +
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
  //
@@ -823,10 +842,85 @@ function lintCodeVsLedger() {
823
842
  // tinh đã đỏ vì "SC1 của UC1 không tồn tại" — báo oan ngay lần chạy đầu tiên.
824
843
  const hasPlaceholder = s => /\{[^}\n]*\}/.test(s);
825
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
+
826
915
  // Hợp đồng đã khai, gom cho T17/T18: attr → { ids: Set, byDoc: Map(doc → Set) }.
827
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
828
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.
829
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 = [];
830
924
 
831
925
  function lintTestidContract() {
832
926
  if (!fs.existsSync(SPECS_DIR)) return;
@@ -844,20 +938,49 @@ function lintTestidContract() {
844
938
  // Chỉ nền CLIENT mới có test-id; `system` không có UI nên nằm ngoài phạm vi.
845
939
  const blocks = [];
846
940
  lines.forEach((l, i) => {
847
- const m = /^###\s+4\.5\b[^—]*—\s*([A-Za-z0-9_-]+)/.exec(l);
848
- if (m && m[1] !== 'system') blocks.push({ platform: m[1], at: 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
+ }
849
949
  });
850
950
  if (!blocks.length) continue;
851
951
 
852
952
  // ── T16 — có §4.5 client thì header PHẢI khai TÊN THUỘC TÍNH test-id ──
853
953
  const attr = lines.find(l => /^@trace\.testid_attr\s*:/.test(l.trim()));
854
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.
855
979
  err('T16', rel(doc),
856
- 'có block §4.5 (client) nhưng header thiếu @trace.testid_attr',
857
- 'Đây TÊN THUỘC TÍNH chứa test-id (data-testid | data-test | ValueKey…).\n' +
858
- ' Thiếuthì QC đoán theo nềndự án dùng data-test sẽ TRƯỢT 100% locator,\n' +
859
- ' test đỏ "element not found" trông y hệt một bug sản phẩm.\n' +
860
- ' Sửa: chạy /map-testids {UC-ID}.');
980
+ '@trace.testid_attr 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ỡ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}.');
861
984
  } else if (hasPlaceholder(attr)) {
862
985
  warn('T16', rel(doc),
863
986
  '@trace.testid_attr còn ở dạng placeholder — chưa ai điền',
@@ -875,6 +998,7 @@ function lintTestidContract() {
875
998
  const parts = path.relative(SPECS_DIR, doc).replace(/\\/g, '/').split('/');
876
999
  if (parts.length < 3) continue; // không theo {domain}/{prd-slug}/ — bố cục cũ
877
1000
  const [domain, prdSlug] = parts;
1001
+ if (!inScopePrd(domain, prdSlug)) continue; // G87 — ngoài scope PRD thì không soi tech-doc
878
1002
  const ticket = path.basename(doc).replace(/-tech-design\.md$/, '');
879
1003
 
880
1004
  for (const b of blocks) {
@@ -889,42 +1013,95 @@ function lintTestidContract() {
889
1013
  if (!known.size) continue; // chưa có scenario nào → không có gì để đối chiếu
890
1014
 
891
1015
  const end = lines.findIndex((l, i) => i > b.at && /^###\s/.test(l));
892
- const body = lines.slice(b.at, end === -1 ? lines.length : end);
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;
893
1041
 
894
1042
  body.forEach((line, k) => {
895
1043
  if (!line.trim().startsWith('|')) return;
896
1044
  if (/^\|[\s|:-]+\|$/.test(line.trim())) return; // dòng phân cách markdown |---|---|
897
- if (hasPlaceholder(line)) return; // dòng mẫu của template
898
1045
  const cells = line.split('|').map(s => s.trim());
899
- const last = cells[cells.length - 2] || ''; // cột "Phục vụ SC"
900
1046
 
901
- // Gom id đã khai (cột 1) cho T17/T18. Làm đây đây chỗ duy nhất đã lọc xong
1047
+ // Dòng TIÊU ĐỀ chốt vị trí cột Serves rồi bỏ qua, đừng parse 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
902
1059
  // dòng mẫu và dòng tiêu đề/phân cách.
903
- const idCell = (cells[1] || '').replace(/`/g, '').trim();
904
- // 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.
905
- if (attrVal && /^[A-Za-z0-9][A-Za-z0-9_.:-]*$/.test(idCell) && idCell !== 'Test-ID') {
906
- if (!declared.has(attrVal)) declared.set(attrVal, { ids: new Set(), byDoc: new Map() });
1060
+ // Ô Test-ID 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
+ }
907
1077
  const d = declared.get(attrVal);
908
- d.ids.add(idCell);
909
1078
  if (!d.byDoc.has(doc)) d.byDoc.set(doc, new Set());
910
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
+ }
911
1086
  }
912
1087
 
913
1088
  if (!last) return;
914
-
915
- // Hai dạng đã gặp: rút gọn "UC1 · SC1" đầy đủ "FT-101-UC1-SC1".
916
- // KHÔNG parse được token nào BỎ QUA, không đoán. Biến thể định dạng phải thành
917
- // "im lặng", không thành "báo oan" đó cách một rule mới mất niềm tin trong tuần đầu.
918
- const ids = [
919
- ...[...last.matchAll(/\bUC(\d+)\s*[·.]\s*SC(\d+)\b/g)].map(m => `${ticket}-UC${m[1]}-SC${m[2]}`),
920
- ...[...last.matchAll(/\b([A-Za-z]+-\d+-UC\d+-SC\d+)\b/g)].map(m => m[1]),
921
- ];
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 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 `{…}` PATTERN (phải kiểm), ô Serves có `{…}`
1092
+ // CHƯA ĐIỀN (đừng kiểm). Cùng 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; }
922
1099
  for (const id of ids) {
923
1100
  if (known.has(id)) continue;
924
- err('T15', `${rel(doc)}:${b.at + k + 1}`,
1101
+ err('T15', `${rel(doc)}:${subAt + k + 1}`,
925
1102
  `§4.5.6 (${b.platform}) trỏ tới ${id} — scenario này KHÔNG có trong .feature`,
926
1103
  `Đã tìm ở: ${rel(bddDir)}/\n` +
927
- ` Consumer per-UC LỌC theo cột "Phục vụ SC". Trỏ vào SC không tồn tại\n` +
1104
+ ` Consumer per-UC LỌC theo cột "Serves SC". Trỏ vào SC không tồn tại\n` +
928
1105
  ` ⇒ không khớp row nào ⇒ QC tưởng element không có test-id rồi đi dò DOM.\n` +
929
1106
  ` Nguyên nhân thường gặp: SC bị gộp/xoá mà bảng không được cập nhật.`);
930
1107
  }
@@ -1002,7 +1179,11 @@ function lintTestidVsCode() {
1002
1179
 
1003
1180
  // ── T17 — bảng có, code không có ──
1004
1181
  for (const [doc, ids] of d.byDoc) {
1005
- const missing = [...ids].filter(id => !inCode.has(id));
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));
1006
1187
  if (!missing.length) continue;
1007
1188
  const SHOW = 5;
1008
1189
  warn('T17', rel(doc),
@@ -1015,7 +1196,8 @@ function lintTestidVsCode() {
1015
1196
  }
1016
1197
 
1017
1198
  // ── T18 — code có, bảng không có ──
1018
- const undeclared = [...inCode.keys()].filter(id => !d.ids.has(id));
1199
+ const undeclared = [...inCode.keys()]
1200
+ .filter(id => !matchesDeclaredIds(id, d.ids, d.patterns || new Set()));
1019
1201
  if (undeclared.length) {
1020
1202
  const SHOW = 5;
1021
1203
  warn('T18', rel(path.dirname([...inCode.values()][0])),
@@ -1084,8 +1266,195 @@ for (const dir of TRACE_DIRS) {
1084
1266
  // phán khi chưa đọc hết sổ là phán trên dữ liệu thiếu.
1085
1267
  lintCodeVsLedger();
1086
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
+
1087
1351
  // T15/T16 độc lập với sổ trace — xem doc-comment của lintTestidContract().
1088
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();
1089
1458
  // T17/T18 SAU nó: cần `declared` mà nó vừa gom.
1090
1459
  lintTestidVsCode();
1091
1460