diff --git a/lib/ac-line.cjs b/lib/ac-line.cjs index 6de7c1f..4b22886 100644 --- a/lib/ac-line.cjs +++ b/lib/ac-line.cjs @@ -17,11 +17,19 @@ // SUSPECT must be strictly LOOSER than AC_LINE or it detects nothing new — the bullet // marker is optional because real contracts declare criteria as bold headings too // (`**AC-1 — Strip shows …**`, 13 of them in video-compose-progress-ux). -const AC_SUSPECT = /^\s*(?:[-*]\s+)?\*{0,2}\s*AC-\d+\b/; +// NỚI (hồ sơ cong-nguoi-doc-du-nguon): 38 hợp đồng trên 11 kho khai tiêu chí bằng +// TIÊU ĐỀ `### AC-n`; với chúng khuôn cũ cho m = 0, nên nhánh `n===0 && m>0` không +// bao giờ đúng và bộ dò IM ở 34 trong 38 — đúng chỗ nó sinh ra để kêu. +const AC_SUSPECT = /^\s*(?:#{2,6}\s+)?(?:[-*]\s+)?\*{0,2}\s*AC-\d+\b/; // …but an id followed by a separator opens a CROSS-REFERENCE, not a declaration // (`**AC-5, AC-9, AC-10 chưa có gì**` in a Notes paragraph). Counting those cries wolf // on a healthy contract, which the contract's own control case forbids. -const AC_XREF = /^\s*(?:[-*]\s+)?\*{0,2}\s*AC-\d+\s*[,;/]/; +// TIỀN TỐ PHẢI CÂN VỚI AC_SUSPECT. Bản trước nới SUSPECT sang tiêu đề mà bỏ quên +// XREF sinh đôi, nên một tham chiếu chéo viết dạng tiêu đề vừa được đếm là nghi +// ngờ vừa không bóc được → m > n → cờ SHORT nổ oan trên hợp đồng lành, khoá luôn +// dòng một-chạm. Hai khuôn này đọc CÙNG một hình dạng dòng, nên tiền tố của chúng +// là MỘT thứ: sửa một cái mà quên cái kia là dựng lại đúng lớp lỗi ấy. +const AC_XREF = /^\s*(?:#{2,6}\s+)?(?:[-*]\s+)?\*{0,2}\s*AC-\d+\s*[,;/]/; // Scope: inside the criteria section when there IS one, whole file when there is not. // Whole-file everywhere over-counts — a Known-limits note like `- **AC-4** — …:` is // declaration-shaped but is not a declaration (measured: s4-scope-triage line 88 raised @@ -31,10 +39,9 @@ const AC_XREF = /^\s*(?:[-*]\s+)?\*{0,2}\s*AC-\d+\s*[,;/]/; // duyệt riêng trước đây của file này là đúng thứ `findings-section-boundary` vừa // gỡ khỏi gate-card.js ("xoá bản sao section()"); giữ nó lại là dựng lại y hệt // điều kiện sinh lỗi mà cả hai feature đang đi đóng. -const { sectionLines } = require('./md-section.cjs'); +const { sectionLines, dongKhoi } = require('./md-section.cjs'); function acBlindSpot(contractText, parsedIds) { - const inSec = sectionLines(contractText, 'Criteria'); - const scan = inSec.length ? inSec : contractText.split('\n').map((l, i) => ({ no: i + 1, l })); + const scan = criteriaLines(contractText); const suspect = []; for (const { no, l } of scan) { if (AC_SUSPECT.test(l) && !AC_XREF.test(l)) suspect.push(no); } const m = suspect.length, n = parsedIds.length; @@ -108,4 +115,84 @@ function parseAC(line) { return { id, gwt, judgment, crossLayer }; } -module.exports = { AC_LINE, AC_SUSPECT, AC_XREF, parseAC, acBlindSpot, blindSpotText }; +// Tên mục tiêu chí trôi qua ba cách viết trên 11 kho đo được. So khớp của +// sectionLines KHÔNG phân biệt hoa thường, nên `Acceptance criteria` trùng +// `Acceptance Criteria` và mục thứ ba chỉ là trang trí — bỏ, kẻo dạy sai người +// sau rằng danh sách này phân biệt hoa thường (lượt chấm 4). +const CRITERIA_HEADINGS = ['Criteria', 'Acceptance Criteria']; +// CHỈ acBlindSpot gọi hàm này, và quét cả tệp ở đây là ĐÚNG: nó chỉ làm số NGHI +// NGỜ to lên, tức làm cờ dễ kêu hơn — sai về phía an toàn. Bộ BÓC tiêu chí gọi +// criteriaLinesScoped; đừng đấu lại hai đường này vào nhau, lượt chấm 4 và 5 đều +// chết vì đúng chỗ đó. +function criteriaLines(contractText) { + const trongMuc = criteriaLinesScoped(contractText); + if (trongMuc.length) return trongMuc; + return String(contractText == null ? '' : contractText).split('\n').map((l, i) => ({ no: i + 1, l })); +} +// HAI phạm vi quét, CỐ Ý khác nhau — đây là chỗ lượt chấm 4 và 5 cùng chết. +// +// criteriaLinesScoped → bộ BÓC tiêu chí. Không có mục thì trả RỖNG. +// criteriaLines → bộ ĐẾM nghi ngờ của acBlindSpot. Không có mục thì quét cả tệp. +// +// Vì sao không dùng chung: quét cả tệp để ĐẾM là vô hại, nó chỉ làm cờ kêu to hơn. +// Quét cả tệp để BÓC thì máy bịa tiêu chí — đo được trên cây trước bản này: một +// hợp đồng không có mục tiêu chí, có `- AC-4:` và `- AC-9:` nằm trong mục «Known +// limits», cho ra thẻ Cổng Phạm vi liệt AC-1, AC-4, AC-9 như tiêu chí phải làm, +// cờ điểm-mù IM (vì hai bên cùng đếm một tập dòng nên n luôn ≥ m), và dòng một-chạm +// mở sẵn chữ «duyệt». Người bấm duyệt một danh sách máy tự nghĩ ra. +// +// Bán kính của nhát cắt, đo trên 1 243 hợp đồng thật của 22 kho: 139 hồ sơ với +// 1 110 tiêu chí đi qua nhánh CÓ MỤC và giữ nguyên; 81 hồ sơ với 380 tiêu chí đi +// qua nhánh quét-cả-tệp và bị trả về hành vi cũ — trong đó 78 hồ sơ vốn ĐÃ có cờ +// đỏ ở cây gốc, nên nhát cắt không sinh tiếng ồn mới, nó chỉ thôi bịa. +// Hợp đồng muốn được đọc thì khai một mục tiêu chí; cờ `blank` nói đúng câu đó. +function criteriaLinesScoped(contractText) { + for (const h of CRITERIA_HEADINGS) { + const ls = sectionLines(contractText, h); + if (ls.length) return ls; + } + return []; +} +// Tiêu chí khai bằng TIÊU ĐỀ: `### AC-n — nhãn`, thân là các dòng tới tiêu đề kế. +const AC_HEAD = /^#{2,6}\s+\*{0,2}\s*(AC-\d+)\b\*{0,2}\s*(.*)$/; +// parseACBlock — lối vào MỚI, đọc theo KHỐI. parseAC(line) giữ nguyên chữ ký nên +// ba bên gọi cũ và lớp vendored ở 9 kho không phải đổi cùng lượt. +// Dạng tiêu đề dựng lại thành một dòng gạch-đầu-dòng rồi cho parseAC chấm: hai +// dạng khai KHÔNG được có hai luật đọc tag, vì đó chính là lớp lỗi hồ sơ này đi +// đóng. Nhãn nối trước thân để `(judgment)` ở nhãn vẫn đọc theo luật nhãn-lỏng. +// TRẢ ĐỦ, KHÔNG GỘP id trùng: bên gọi tự gộp theo luật của mình, và thẻ Cổng +// Phạm vi dựa vào chính chỗ trùng đó để cảnh báo «trùng mã tiêu chí». Gộp ở đây +// là giết cảnh báo ấy một cách lặng lẽ — đo được: policy-graph-hub/registry-params +// khai AC-3, AC-7, AC-11, AC-16 mỗi mã hai lần. +function parseACBlock(contractText) { + const out = []; + let cur = null; + const chot = () => { + if (!cur) return; + const than = cur.than.join(' ').replace(/\s+/g, ' ').trim(); + const nhan = cur.nhan.replace(/^[\s—–-]+/, '').trim(); + const a = parseAC(`- ${cur.id}${nhan ? ' ' + nhan : ''}: ${than}`); + if (a) out.push(a); + cur = null; + }; + for (const { l } of criteriaLinesScoped(contractText)) { + const h = l.match(AC_HEAD); + if (h) { chot(); cur = { id: h[1], nhan: h[2] || '', than: [] }; continue; } + // ĐÓNG KHỐI theo ĐÚNG luật của md-section: h2..h6 đóng, h1 là NỘI DUNG. Bản + // trước tự viết `/^#{1,6}\s/` nên nó đóng ở cả h1, tức hai bộ duyệt bất đồng + // về ranh giới — chính lớp lỗi hồ sơ này đi đóng (lượt chấm 7). Luật ở + // md-section.cjs, không chép lại vào đây. + if (dongKhoi(l)) { chot(); continue; } + // Một dòng GẠCH ĐẦU DÒNG khai tiêu chí cũng ĐÓNG khối tiêu đề đang mở (lượt + // chấm 3 bắt được). Bản trước nuốt nó vào thân của tiêu chí phía trên, nên + // hợp đồng trộn hai cách khai mất tiêu chí ở giữa — đúng lớp «bên đọc hẹp + // hơn bên viết» mà chính bộ bóc này sinh ra để đóng. + const gach = parseAC(l); + if (gach) { chot(); out.push(gach); continue; } + if (cur) { cur.than.push(l); continue; } + } + chot(); + return out; +} + +module.exports = { AC_LINE, AC_SUSPECT, AC_XREF, AC_HEAD, CRITERIA_HEADINGS, criteriaLines, criteriaLinesScoped, parseAC, parseACBlock, acBlindSpot, blindSpotText }; diff --git a/lib/evidence-core.cjs b/lib/evidence-core.cjs index a474ac9..7c19941 100644 --- a/lib/evidence-core.cjs +++ b/lib/evidence-core.cjs @@ -356,22 +356,65 @@ function loadRepinRunIds(fileDir) { // until re-pinned with the eval lane; the kit's own debt is named in // tests/scripts/mirror-sync-grandfather.mjs (two-way checked), not hidden here. const REPIN_MACHINE_EXECUTORS = ['test', 'script']; +// Giá trị trạng-thái DUY NHẤT khiến một ô rời khỏi tập đáng ghim. Chuẩn hoá vì +// một lời khai bọc nháy hay viết hoa vẫn là cùng lời khai — cùng lớp với bộ +// giải nháy của 2.11.0, chỉ ở chiều đọc. +const EVAL_STATUS_NOT_RUN = 'not-run'; +function normaliseEvalStatus(v) { + return unquoteScalar(String(v == null ? '' : v)).trim().toLowerCase(); +} +// MỘT nơi quyết định «eval máy đáng ghim»: executor máy VÀ không tự khai là +// không chạy. Bên ĐỌC (machineEvalIds) và bên GHI (repin-lane.mjs) cùng gọi — +// hai bản luật là điều kiện đủ để writer/reader trôi khỏi nhau. +function isRepinMachineEval(e) { + if (!REPIN_MACHINE_EXECUTORS.includes(String(e.executor || '').trim().toLowerCase())) return false; + return normaliseEvalStatus(e.status) !== EVAL_STATUS_NOT_RUN; +} function loadEvalYaml() { try { return require(path.join(__dirname, 'eval-yaml.cjs')); } catch (_) { return null; } } -// ids of evals whose executor is machine-run (test/script), in file order. -// Null when the shared parser is not vendored next to this file — the caller -// fails closed instead of guessing. +// ids of evals whose executor is machine-run (test/script) AND not self- +// declared not-run, in file order. Null when the shared parser is not +// vendored next to this file — the caller fails closed instead of guessing. function machineEvalIds(evalsText) { const ey = loadEvalYaml(); if (!ey) return null; - return ey.parseEvals(evalsText, ['executor']) + return ey.parseEvals(evalsText, ['executor', 'status']).filter(isRepinMachineEval).map(e => e.id); +} +// Id bị loại vì tự khai không chạy — pin phải NÓI RA chúng, không được im. +function machineEvalIdsSkipped(evalsText) { + const ey = loadEvalYaml(); + if (!ey) return null; + return ey.parseEvals(evalsText, ['executor', 'status']) .filter(e => REPIN_MACHINE_EXECUTORS.includes(String(e.executor || '').trim().toLowerCase())) + .filter(e => normaliseEvalStatus(e.status) === EVAL_STATUS_NOT_RUN) .map(e => e.id); } +// Hai vế, theo tiền lệ mã thoát mong đợi đã khai (ADR 0016): một ô chỉ được loại khi hồ sơ +// khai VÀ báo cáo đã ký không mang mã thoát nào cho nó. Thiếu vế hai thì «thêm +// một dòng khai» thành đường né đo — một hồ sơ đã ký có mã thoát THẬT cho một +// eval, rồi ai đó thêm `status: not-run` vào evals.yaml, sẽ ghim lại XANH mà +// không ai đối chiếu gì với vật đã ký trước đó. +// +// Trả HAI kết luận TÁCH BẠCH, không một mảng id: `xungDot` = ô bị loại mà báo +// cáo đã ký CÓ mã thoát cho chính nó · `khongDoiChieuDuoc` = ô bị loại mà không +// có văn bản báo cáo đã ký nào để đối chiếu. Bản đầu trả `[]` cho vế sau (rà +// cuối 12/09/2026) — fail-OPEN, trái luật sinh đôi của nó (mã thoát mong đợi, +// ADR 0016) vốn fail-CLOSED ở đúng nhánh đó: bên gọi ba đối số (bản +// recheck-evidence.cjs / pre-merge-check.sh của repo tiêu thụ chưa chép tệp +// mới) sẽ tắt lưới LẶNG ở đúng kho lưới sinh ra để cứu. Hồ sơ KHÔNG có ô bị +// loại nào thì cả hai vế rỗng — vắng báo cáo không tự sinh vi phạm. +function notRunConflicts(evalsText, reportText) { + const skipped = machineEvalIdsSkipped(evalsText); + if (!skipped || !skipped.length) return { xungDot: [], khongDoiChieuDuoc: [] }; + if (reportText == null) return { xungDot: [], khongDoiChieuDuoc: skipped.slice() }; + const signed = extractEvalBlockExits(reportText); + return { xungDot: skipped.filter(id => signed.has(id)), khongDoiChieuDuoc: [] }; +} + // entry: the parsed repin line that backs verified_commit (sha == vc); // evalsText: evals.yaml content, or null when the file is missing; slug for // the message; reportText (NEW, optional) is the committed evidence-report.md @@ -414,6 +457,16 @@ function checkRepinEvals(entry, evalsText, slug, reportText) { const has = i => Object.prototype.hasOwnProperty.call(ex, i); const missing = ids.filter(i => !has(i)); if (missing.length) errs.push(`re-pin lane "${id}" evals_exit lacks eval(s) ${missing.join(', ')} declared in evals.yaml (executor test/script) — the lane did not re-run them at ${sha}; run a NEW lane`); + const boQua = machineEvalIdsSkipped(evalsText) || []; + // Ô bị loại KHÔNG còn đi qua vòng chấm mã thoát dưới (nó rời khỏi `ids`), nên + // một mã đỏ ghi cho chính nó sẽ không ai soi — răng đang sống bị gỡ lặng + // (rà cuối 12/09/2026). Làn thật không bao giờ ghi khoá cho ô nó không đo: + // một dòng pin như vậy là vật viết tay hoặc do một writer đã trôi. + const doTrongOBoQua = boQua.filter(i => has(i) && ex[i] !== 0); + if (doTrongOBoQua.length) errs.push(`re-pin lane "${id}" evals_exit mang mã thoát khác 0 cho ${doTrongOBoQua.map(i => `${i}=${JSON.stringify(ex[i])}`).join(', ')} mà evals.yaml khai không-chạy — ô khai không-chạy không được mang mã đỏ chống lưng một pin; chạy làn MỚI (làn không ghi mã cho ô nó không đo), đừng viết tay dòng pin`); + const { xungDot, khongDoiChieuDuoc } = notRunConflicts(evalsText, reportText); + if (xungDot.length) errs.push(`re-pin lane "${id}" bỏ qua eval ${xungDot.join(', ')} vì evals.yaml khai không-chạy, NHƯNG báo cáo đã ký có mã thoát cho chính eval đó — hai vế mâu thuẫn; sửa hồ sơ rồi chạy làn MỚI`); + if (khongDoiChieuDuoc.length) errs.push(`re-pin lane "${id}" bỏ qua eval ${khongDoiChieuDuoc.join(', ')} vì evals.yaml khai không-chạy, NHƯNG không đối chiếu được vế hai — không có văn bản báo cáo đã ký để soi (bên gọi không truyền reportText, hoặc evidence-report.md vắng); fail-closed như luật mã thoát mong đợi (ADR 0016): chép đủ INIT-CI-COPY-LIST rồi chạy lại, đừng tin lời khai một vế`); const ey = loadEvalYaml(); const expected = (ey && typeof ey.expectedExits === 'function') ? ey.expectedExits(evalsText).byId @@ -1035,8 +1088,102 @@ function machineClearedSignoffConflict(contractTxt, evidenceTxt) { } +// <<` `|` `-`. Mọi thứ khác là chữ ký thật (phạm vi hẹp có chủ đích — +// xem chú thích lịch sử ở placeholder_signoff của lưới trước khi nó gọi vào đây). +const CHU_KY_GIU_CHO = [ + { mau: 'pending', tienTo: true }, { mau: 'tbd', tienTo: true }, + { mau: 'todo', tienTo: true }, { mau: 'n/a', tienTo: true }, + { mau: 'unsigned', tienTo: true }, { mau: 'waiting', tienTo: true }, + { mau: '<', tienTo: true }, + { mau: 'none', tienTo: false }, { mau: '>', tienTo: false }, + { mau: '|', tienTo: false }, { mau: '-', tienTo: false }, +]; +function laGiuCho(s) { + const v = String(s == null ? '' : s).trim().toLowerCase(); + if (!v) return false; + return CHU_KY_GIU_CHO.some(p => (p.tienTo ? v.startsWith(p.mau) : v === p.mau)); +} +// { signed, value, placeholder, warn } — `warn` LUÔN có mặt (chuỗi rỗng khi hình +// dạng tệp chuẩn); không bao giờ nuốt im một sự cố đọc. +function chuKyThat(reportText) { + const ket = (o) => ({ signed: false, value: '', placeholder: false, warn: '', ...o }); + if (reportText == null) return ket({ warn: 'không có evidence-report.md' }); + const dong = String(reportText).split('\n'); + const dau = dong.findIndex(l => l.trim() !== ''); + if (dau < 0 || !/^---[ \t]*\r?$/.test(dong[dau])) + return ket({ warn: 'frontmatter không dẫn đầu báo cáo' }); + let het = -1; + for (let i = dau + 1; i < dong.length; i++) { + if (/^---[ \t]*\r?$/.test(dong[i])) { het = i; break; } + } + const warn = het === -1 + ? 'frontmatter thiếu dấu đóng «---» ở cột 0 — đọc tới hết tệp' + : ''; + const than = dong.slice(dau + 1, het === -1 ? dong.length : het); + const d = than.find(l => /^human_signoff[ \t]*[:=]/i.test(l)); + if (d === undefined) return ket({ warn }); + const val = d.replace(/^human_signoff[ \t]*[:=]/i, '') + .replace(/^#.*$/, '').replace(/\s+#.*$/, '').trim(); + const cap = val.match(/^"([\s\S]*)"$/) || val.match(/^'([\s\S]*)'$/); + const s = (cap ? cap[1] : val).trim(); + if (!s) return ket({ warn }); + if (laGiuCho(s)) return ket({ value: s, placeholder: true, warn }); + return ket({ signed: true, value: s, warn }); +} +// CLI cho bên gọi KHÔNG phải JS (scripts/pre-merge-check.sh), cùng nếp +// lib/lop-nhin-thay.cjs: in MỘT dòng tab, exit 0 kể cả khi kết luận là «chưa ký». +// node lib/evidence-core.cjs chu-ky-that <đường dẫn báo cáo> +// → "\t\t\t" +// node lib/evidence-core.cjs giu-cho → "1" hoặc "0" +// Exit 3 = không đọc được tệp (bên gọi phân biệt được với «đọc được, chưa ký»). +if (require.main === module) { + const [lenh, arg] = process.argv.slice(2); + const mot = s => String(s == null ? '' : s).replace(/[\t\r\n]+/g, ' '); + if (lenh === 'chu-ky-that') { + let txt; + try { txt = require('fs').readFileSync(arg, 'utf8'); } + catch { process.exit(3); } + const r = chuKyThat(txt); + process.stdout.write(`${r.signed ? 1 : 0}\t${r.placeholder ? 1 : 0}\t${mot(r.value)}\t${mot(r.warn)}\n`); + process.exit(0); + } + if (lenh === 'giu-cho') { process.stdout.write(laGiuCho(arg) ? '1\n' : '0\n'); process.exit(0); } + // `bang-mau` để câu NOTE của lưới KỂ TÊN bảng mà không chép bảng: một danh sách + // gõ tay trong văn xuôi cũng là bản sao, và nó trôi im lặng y như mã. + if (lenh === 'bang-mau') { + process.stdout.write(CHU_KY_GIU_CHO.map(p => p.mau + (p.tienTo ? '*' : '')).join(', ') + '\n'); + process.exit(0); + } + process.stderr.write('evidence-core: lệnh lạ — dùng chu-ky-that | giu-cho \n'); + process.exit(2); +} +// CHU-KY-THAT>>> + module.exports = { PASS_FAMILY, + chuKyThat, + laGiuCho, + // Bảng xuất ra để phép đo RÚT từ chính nguồn (không gõ lại danh sách ở test). + CHU_KY_GIU_CHO, machineClearedSignoffConflict, findAcceptanceConfig, unquoteScalar, @@ -1046,7 +1193,12 @@ module.exports = { resolveConfigKey, resolveConfigList, REPIN_MACHINE_EXECUTORS, + normaliseEvalStatus, + isRepinMachineEval, machineEvalIds, + machineEvalIdsSkipped, + notRunConflicts, + extractEvalBlockExits, checkRepinEvals, readSignedReportFor, frontmatterField, diff --git a/lib/md-section.cjs b/lib/md-section.cjs index 0530813..95688dd 100644 --- a/lib/md-section.cjs +++ b/lib/md-section.cjs @@ -41,6 +41,16 @@ function boundaryFor(heading) { // nào" — vd bộ dò mù criterion của lib/ac-line.cjs phải liệt số dòng bỏ sót cho // người duyệt — phải đi qua đây, KHÔNG tự duyệt lại: luật ranh giới per-section // nằm ở bảng marker trên đầu file này, hai bản duyệt sẽ trôi khỏi nhau. +// Luật TIÊU ĐỀ, một nguồn cho mọi bên duyệt trong tệp này và cho lib/ac-line.cjs. +// Trước lượt chấm 7 mỗi bên tự viết lại `/^#{1,6}\s/` của mình, và hai bên bất +// đồng đúng ở h1: sectionLines CỐ Ý coi `# guidance` là NỘI DUNG (luật +// same-or-higher chỉ đóng ở h2..h6) trong khi parseACBlock đóng khối ở cả h1. +// Hai luật cho một hình dạng dòng là điều kiện đủ để chúng trôi khỏi nhau — +// đúng lớp lỗi hồ sơ này đi đóng, nên nó không được sống ngay trong hồ sơ này. +const laTieuDe = (l) => /^\s*#{1,6}\s/.test(String(l == null ? '' : l)); +// Cấp ĐÓNG KHỐI: h2..h6. h1 không đóng — xem lý do ở chú thích trong sectionLines. +const dongKhoi = (l) => { const m = String(l == null ? '' : l).match(/^\s*(#{1,6})\s/); return !!m && m[1].length >= 2; }; + function sectionLines(t, h) { const rule = boundaryFor(h); const out = []; @@ -70,4 +80,61 @@ function sectionLines(t, h) { // không phải đổi. Nó là một lớp mỏng trên sectionLines: MỘT phép duyệt duy nhất. function section(t, h) { return sectionLines(t, h).map(x => x.l); } -module.exports = { SECTION_BOUNDARY, BOUNDARY_TABLE_RE, parseBoundaryTable, boundaryFor, section, sectionLines }; +// Dòng của một mục MANG CHỮ cho người đọc (hồ sơ cong-nguoi-doc-du-nguon). +// Gạch đầu dòng được ưu tiên; CHỈ khi mục không có gạch nào mới lấy dòng thường, +// và bỏ hàng phân cách của bảng vì nó không mang chữ. Ô khuôn `{{…}}` chưa điền +// không phải nội dung. +// +// VÌ SAO ở đây chứ không nội tuyến trong thẻ: 29 trên 244 hợp đồng có mục Coverage +// viết bằng BẢNG hoặc văn xuôi từng bị thẻ đọc ra rỗng rồi nổi cờ «chưa có section +// Coverage» trên một mục CÓ THẬT. Nếu luật này sống trong thẻ thì phép đo bán kính +// phải cài lại nó, và khi đó phép đo cho CÙNG một số trên mọi lớp — tức đo VĂN. +function contentLines(lines) { + // Nhánh GẠCH ĐẦU DÒNG giữ NGUYÊN hành vi đã có: dòng bọc 80 cột được NỐI vào + // gạch đang mở, dòng trắng đóng gạch, prose trước gạch đầu tiên bị bỏ. Lọc theo + // TỪNG DÒNG sẽ cắt cụt câu giữa chừng — lớp lỗi findings 05/08, ca P146 canh. + const raws = (lines || []).map(l => String(l == null ? '' : l)); + const gach = []; + let mo = false; + for (const l of raws) { + if (/^\s*-\s+\S/.test(l)) { gach.push(l.trim().replace(/^-\s+/, '')); mo = true; } + else if (!l.trim()) mo = false; + else if (mo && !/^\s*#{1,6}\s/.test(l)) gach[gach.length - 1] += ' ' + l.trim(); + } + const coGach = gach.filter(l => !/\{\{/.test(l)); + if (coGach.length) return coGach; + // Mục CÒN Ô KHUÔN chưa điền thì KHÔNG phải nội dung — dù ô ấy nằm ở dòng khác + // (sửa sau lượt chấm 1). Lọc `{{` theo TỪNG DÒNG là sai: khối hướng dẫn của + // khuôn trải nhiều dòng và chỉ dòng đầu mang ô, nên bốn dòng chữ dặn-việc lọt + // qua và thẻ coi một hợp đồng CHƯA HỀ quét độ phủ là «đã có độ phủ», tắt mất + // cờ vàng Cổng 1. Bản cũ (chỉ gạch đầu dòng) trả rỗng và cờ bắn đúng. + if (raws.some(l => /\{\{/.test(l))) return []; + // Không gạch nào và không ô khuôn nào: mục viết bằng BẢNG hoặc văn xuôi thật. + // Hàng PHÂN CÁCH của bảng không mang chữ cho người đọc — bỏ. Khuôn cũ + // `/^\s*\|?\s*:?-{2,}/` bỏ sót kiểu căn giữa `|:-:|:-:|` (chỉ MỘT gạch giữa hai + // dấu hai chấm), nên hàng ấy lên thẻ như một dòng nội dung. Khuôn dưới đây hỏi + // «cả dòng CHỈ gồm | - : và khoảng trắng», không đếm số gạch. + const LA_PHAN_CACH = /^[\s|:-]*$/; + const con = raws.filter(l => l.trim() && !LA_PHAN_CACH.test(l)); + // BẢNG: mỗi hàng là một phần tử, KHÔNG nối — nối hai hàng lại là bịa ra một + // hàng không có trong hợp đồng. + if (con.some(l => /^\s*\|/.test(l))) return con; + // VĂN XUÔI: nối dòng bọc 80 cột trong cùng một đoạn, đoạn cách nhau bằng dòng + // trắng — ĐÚNG cách nhánh gạch đầu dòng ở trên làm. Bản trước trả từng dòng thô + // nên thẻ cắt câu giữa chừng, đúng lớp lỗi mà chú thích của chính hàm này cấm + // (đo được ở ba hợp đồng ap-media-roadmap, lượt chấm 6). + // Tiêu đề và hàng phân cách ĐÓNG đoạn và KHÔNG vào nội dung — đúng như nhánh + // gạch đầu dòng ở trên vẫn làm. Bản trước chỉ `continue` nên `### Trục A` vừa + // lọt lên thẻ vừa dán chữ của hai mục con thành MỘT câu không có trong hợp + // đồng (lượt chấm 7), cùng lớp «bịa ra một hàng» mà nhánh BẢNG đã cấm. + const doan = []; + let dangMo = false; + for (const l of raws) { + if (!l.trim() || laTieuDe(l) || LA_PHAN_CACH.test(l)) { dangMo = false; continue; } + if (dangMo) doan[doan.length - 1] += ' ' + l.trim(); + else { doan.push(l.trim()); dangMo = true; } + } + return doan; +} + +module.exports = { SECTION_BOUNDARY, BOUNDARY_TABLE_RE, parseBoundaryTable, boundaryFor, section, sectionLines, contentLines, laTieuDe, dongKhoi }; diff --git a/scripts/pre-merge-check.sh b/scripts/pre-merge-check.sh index c728f33..e28ca42 100755 --- a/scripts/pre-merge-check.sh +++ b/scripts/pre-merge-check.sh @@ -54,6 +54,9 @@ violations=0 # Bật khi lưới giữ-chỗ nổ ít nhất một lần; dùng để in ĐÚNG MỘT dòng cảnh báo # về phạm vi hẹp của chính lưới đó ở cuối lần chạy. NARROW_NET_SEEN="" +# Lưới giữ-chỗ KHÔNG chạy được (thiếu node / thiếu lib/evidence-core.cjs / lib lỗi). +# Nói ra ở cuối lượt: một luật im lặng không chạy là luật không còn. +NARROW_NET_BLIND="" # Bật khi răng cross-layer phải chấm bằng khuôn awk nội bộ vì thiếu node hoặc # lib/ac-line.cjs. Răng VẪN chạy (awk rộng hơn nên không rụng dòng nào), nhưng đó # là một định nghĩa "dòng criterion" khác với ba consumer JS — in đúng một dòng ở @@ -410,6 +413,10 @@ claims_released() { # — 0 iff thư mục TỰ NHẬN đã qua cổng. return 1 } +# MỘT NGUỒN của vị từ «chữ ký thật» (đổi khuôn S4-r2). Đường dẫn suy từ vị trí +# script như các lib khác của lưới (AC_LINE_LIB, LNT_LIB, WSREC_LIB). +CHU_KY_LIB="$(cd "$(dirname "$0")/.." 2>/dev/null && pwd)/lib/evidence-core.cjs" + placeholder_signoff() { # — 0 iff chữ ký khớp một mẫu giữ-chỗ đã biết. # ĐÂY LÀ LUẬT CHỮ KÝ DUY NHẤT còn lại (ngoài chốt rỗng). Không có lớp dự # phòng nào phía sau: `signoff.approvers` KHÔNG được cổng đọc kể từ 1.24.0 — @@ -422,14 +429,76 @@ placeholder_signoff() { # — 0 iff chữ ký khớp một mẫu giữ # `x`, `.`), và mọi giữ-chỗ viết bằng ngôn ngữ khác (`chờ Manh gật`). # Khớp theo TIỀN TỐ vì chữ ký thật dẫn đầu bằng tên. LC_ALL=C để `tr` không # chết trên UTF-8. + # ĐỔI KHUÔN S4-r2 (owner quyết 12/09): bảng mẫu KHÔNG còn ở đây. Nó sống MỘT + # chỗ — `laGiuCho` trong lib/evidence-core.cjs — và hàm này hỏi nó qua node, + # cùng nếp `lib/lop-nhin-thay.cjs classify`. Bảng bash cũ (bốn dòng `case`) đã + # GỠ và liệt đích danh trong ALLOWED_REMOVALS của DV5: giữ nó lại là giữ đúng + # bản-dựng-thứ-hai mà hai lượt chấm vừa bắt. + # Không đọc được nguồn (thiếu node / thiếu lib / lib lỗi) → KHÔNG chặn, và nói + # ra: cùng doctrine với lớp nhìn-thấy và ac-line trong chính tệp này, và ca NO2 + # của lưới thường trực ghim đúng điều đó («thiếu node: NOTE không được chặn + # merge»). Bản đầu của lượt S4-r2 chọn fail-CLOSED ở đây và làm 6 ca đỏ — + # chốt rỗng phía trên vẫn chặn chữ ký trống, nên đường này không im lặng cho + # qua một hồ sơ chưa ký; nó chỉ thôi phân loại GIỮ-CHỖ khi không có bộ đọc. + _gc="$(node "$CHU_KY_LIB" giu-cho "$1" 2>/dev/null)" + case "$_gc" in + 1) return 0 ;; + 0) return 1 ;; + esac + # Engine không nạp được (thiếu node / thiếu lib / lib lỗi). Đây là luật CHẶN và + # trước đổi khuôn nó KHÔNG cần node — nên KHÔNG được im lặng cho qua (S4-r3: + # đo được «lib bị gỡ → exit 0, mất VIOLATION»). Bản lùi dưới đây là BẢN CHIẾU + # của bảng ở lib, KHÔNG phải nguồn thứ hai: chân `lui-khong-engine` chạy MỌI mẫu + # của nguồn qua chính đường này với lib đã gỡ và đòi kết luận khớp, nên hai bên + # không trôi khỏi nhau trong im lặng. Tiền lệ cùng hình dạng: khuôn awk của + # ac-line ở lại làm đường lùi cho máy thiếu node. + NARROW_NET_BLIND=1 +# <<'|'|'|'-') return 0 ;; '<'*) return 0 ;; # template chưa điền: " " pending*|tbd*|todo*|n/a*|none|unsigned*|waiting*) return 0 ;; esac +# BANG-LUI-GIU-CHO>>> return 1 } +# Chữ ký người ở Cổng Bằng chứng ĐÓNG cửa veto (hồ sơ cua-veto-sau-chu-ky). +# Cửa veto là đường đảo cho việc MÁY tự quyết mà người chưa nói gì; người đã +# phát ngôn rồi thì «owner chưa veto» là câu sai. Đo 11/09: 27 trên 30 tên ở +# dòng tổng của kit đã ký, media-library 3/3, floorplanstudio 1/1. +# +# Vị từ là QUAN HỆ, không phải nhãn: `status: signed-off` không đóng cửa (nhãn +# là lời khai, luật khác đã bắt nó), chữ ký giữ-chỗ cũng không — dùng lại ĐÚNG +# `placeholder_signoff` ở trên, không dựng bảng thứ hai. Chỉ đọc frontmatter DẪN +# ĐẦU qua front_field: một dòng `human_signoff:` ở THÂN báo cáo (trích khuôn) +# không phải chữ ký. +# MỘT hàm cho CẢ HAI chỗ đọc (NOTE làn V và vòng veto-trace) — hai bản trong +# cùng một file là hình dạng bên-viết-bên-đọc-trôi mà kit đã dẫm nhiều lần. +chu_ky_gia_tri() { # <đường dẫn báo cáo> — in GIÁ TRỊ chữ ký theo NGUỒN (rỗng nếu vắng) + local _l + _l="$(node "$CHU_KY_LIB" chu-ky-that "$1" 2>/dev/null)" + if [ -n "$_l" ]; then printf '%s' "$_l" | cut -f3; return 0; fi + NARROW_NET_BLIND=1 + front_field "$1" human_signoff +} + +signoff_that() { # — 0 iff có chữ ký THẬT; đặt $SIGNOFF_THAT + # ĐỔI KHUÔN S4-r2: không tự đọc frontmatter nữa. Ngữ pháp (dấu fence, luật cột, + # cách viết khoá, bảng giữ-chỗ) sống MỘT chỗ trong lib/evidence-core.cjs; hàm + # này hỏi nó qua node và đọc MỘT dòng tab — nếp lib/lop-nhin-thay.cjs. + # Không đọc được nguồn → return 1 (chưa ký): cửa veto GIỮ MỞ, chiều an toàn của + # luật này, và hồ sơ vẫn đi tiếp qua các chốt bằng chứng như thường. + SIGNOFF_THAT="" + [ -f "$1/evidence-report.md" ] || return 1 + local _line _signed + _line="$(node "$CHU_KY_LIB" chu-ky-that "$1/evidence-report.md" 2>/dev/null)" || return 1 + _signed="$(printf '%s' "$_line" | cut -f1)" + [ "$_signed" = "1" ] || return 1 + SIGNOFF_THAT="$(printf '%s' "$_line" | cut -f3)" + return 0 +} + # Mọi đường mà luật gap-probe KHÔNG chạy được đều đi qua ĐÂY. Một hàm, một # marker, một chỗ quyết định mode — vì kênh "NOTE rồi exit 0" đã giết contract @@ -774,6 +843,12 @@ for dir in "$ACC"/*/; do elif [ -z "$_vat" ] || ! date_parseable "$_vat"; then echo "VIOLATION [$slug]: status=$status but approved_by is empty — veto_opened_at ${_vat:+\"$_vat\" }không đọc được: làn V ĐÒI một mốc thời gian parse được, không có nó thì đây là bỏ cổng im lặng chứ không phải cửa veto có dấu vết." violations=$((violations+1)); continue + elif signoff_that "$dir"; then + # Đã ký ở Cổng Bằng chứng: cửa veto hết là chốt đang giữ hồ sơ, nên + # câu «cửa veto mở» là sai. Nhánh này đặt TRƯỚC nhánh cũ; vế + # `[ -n "$_vsig" ]` của nhánh dưới từ nay không còn tới được nhưng + # GIỮ NGUYÊN VĂN — luật diff-chỉ-thêm (DV5) cấm sửa dòng cũ. + echo "NOTE [$slug]: làn V — Cổng 1 không có chữ duyệt; Cổng 2 đã có chữ ký người ($SIGNOFF_THAT): cửa veto đã đóng bằng chữ ký" elif [ -n "$_vsig" ] || xanh_sach_check "$_vrep"; then echo "NOTE [$slug]: làn V — máy đi trước, Cổng 1 không có chữ duyệt; cửa veto mở" else @@ -978,7 +1053,12 @@ XLACS # provenance reads below, so a no-fence/offset-fence report can't pass verdict # while its provenance reads empty (would otherwise let a bypassed PASS slip). verdict="$(front_field "$report" verdict)" - signoff="$(front_field "$report" human_signoff)" + # S4-r3: chỗ đọc chữ ký của luật Cổng 2 cũng đi qua NGUỒN. Trước đó nó dùng + # front_field (awk chỉ nhận `human_signoff:`) trong khi engine nhận thêm `=` và + # khoảng trắng trước dấu — cùng một tệp cho hai kết luận: lưới in «đã đóng bằng + # chữ ký (…)» rồi ngay dưới in «xanh-sạch … cửa veto vẫn mở». Một ngữ pháp, một + # chỗ. Engine vắng → lùi về front_field (khai ở NOTE của lưới giữ-chỗ). + signoff="$(chu_ky_gia_tri "$report")" LAN_V=0 # DLPS-LAN-V-MOT-DUONG: 1 khi hồ sơ làn V xanh-sạch đi tiếp KHÔNG chữ ký (đổi khuôn, owner 08/09) # ── machine-cleared × chữ ký người = hai sự thật cãi nhau (hồ sơ ra-co-ten, AC-15) ── # Ký thì status phải sang signed-off; để chữ ký nằm trên hồ sơ máy-thông là mọi bên đọc @@ -1349,6 +1429,11 @@ if [ -d "$ACC" ]; then contract="$dir/contract.md" [ -f "$contract" ] || continue vstate="$(front_field "$contract" veto_state | tr '[:upper:]' '[:lower:]')" + # Hồ sơ đã có chữ ký người thì cửa veto không còn là chốt đang giữ nó — đổi + # nhãn TRƯỚC khi `case` đếm, để dòng `mo)` bên dưới giữ nguyên văn (DV5). + # Nhãn `mo-da-ky` cố ý KHÔNG rỗng: nhánh ghi-ngược bên dưới đọc `$vstate`, và + # một chuỗi rỗng ở đó nghĩa là «đã gỡ khoá» — nói dối về một hồ sơ còn khoá. + if [ "$vstate" = "mo" ] && signoff_that "$dir"; then vstate="mo-da-ky"; fi case "$vstate" in mo) VETO_OPEN_N=$((VETO_OPEN_N+1)) @@ -1357,6 +1442,11 @@ if [ -d "$ACC" ]; then echo "VIOLATION [$slug]: veto_state=da-veto chưa xử — owner đã veto, hồ sơ không được merge ở trạng thái này. Xử bằng một trong hai đường rồi ghi entry sổ quyết định: quay hồ sơ về status draft để làm lại phạm vi, hoặc owner duyệt tay (approved_by)." violations=$((violations+1)) ;; esac + # Trả nhãn tạm về ngay sau `case`: `mo-da-ky` chỉ sống ĐÚNG trong phép đếm ở + # trên. Để nó chảy tiếp thì thông điệp của luật ghi-ngược in «da-veto -> + # mo-da-ky» — một nhãn nội bộ rò ra câu nói với người, và đó là ĐỔI một câu + # chặn chứ không còn là đổi lời của cửa veto (chân luat-lan-can bắt sống). + if [ "$vstate" = "mo-da-ky" ]; then vstate="mo"; fi # chiều ghi-ngược — chỉ xét được khi dựng nổi phạm vi diff if [ "$DIFF_READY" -eq 1 ] && slug_in_diff "$slug"; then base_c="$(git -C "$ROOT" show "$BASE_SHA:_acceptance/$slug/contract.md" 2>/dev/null || true)" @@ -1476,7 +1566,11 @@ if [ -n "$AC_LINE_FALLBACK_SEEN" ]; then fi if [ -n "$NARROW_NET_SEEN" ]; then - echo "NOTE: the placeholder net that just fired matches a SHORT FIXED prefix list — pending, tbd, todo, n/a, none, unsigned, waiting, a bare > | or -, and an unfilled <...> template. NOTHING else. A holding note phrased any other way (\"FIXME\", \"LGTM\", \"ok\", or one written in another language) passes this gate. Rewording the line is NOT a fix; put a real approver name + date there." + echo "NOTE: the placeholder net that just fired matches a SHORT FIXED prefix list — $(node "$CHU_KY_LIB" bang-mau 2>/dev/null) (dấu * = khớp tiền tố). NOTHING else is treated as a placeholder: unlisted English holds (FIXME, placeholder, LGTM), curt words (ok, yes, x, .), and holds written in any other language ALL PASS the net. Bảng ở lib/evidence-core.cjs là MỘT nguồn — câu này đọc từ đó, không chép lại." +fi + +if [ -n "$NARROW_NET_BLIND" ]; then + echo "NOTE: lưới giữ-chỗ của chữ ký KHÔNG chạy được lượt này — thiếu node hoặc thiếu lib/evidence-core.cjs (mang cổng vào repo phải copy CẢ lib/). Chốt «chữ ký rỗng» vẫn chặn; riêng phép phân loại giữ-chỗ (bảng ở lib/evidence-core.cjs, xem \`node lib/evidence-core.cjs bang-mau\`) không kiểm được, nên một dòng giữ chỗ có thể lọt. NOTE này không chặn." fi if [ "$LEGACY_SIGN_KNOB" -eq 1 ]; then