fix(#2140): distinguish checkbox-only reconcile from full in requirements mark-complete
cmdRequirementsMarkComplete OR-ed its two write surfaces (the - [ ] checkbox and the | ID | ... | Pending | traceability row) into one 'found' flag. When the checkbox matched and no table row did, it reported the same 'updated: true, marked_complete: [ID]' payload as a full reconcile — while the traceability row stayed Pending. The already_complete branch used OR (checkbox done OR row done), so re-running on the half-written file classified the drift as already_complete and moved on. audit-milestone (which reads the table) still saw Pending. - Track the two surfaces separately (checkboxHit / tableHit). - Add a 'table_unmatched' bucket: an ID whose checkbox reconciled (this run or before) but whose traceability table has no row for it — only when a table actually exists (a table-less REQUIREMENTS.md is legitimate). - Fix the already_complete predicate: fully reconciled requires the row Complete, OR the checkbox done with NO table — a [x] checkbox with an absent/Pending row is partial, not done. - A table-less REQUIREMENTS.md stays a clean success (the OR's legitimate use). The invariant: a not-fully-applied requirement no longer returns a payload indistinguishable from a fully-applied one. Closes #2140
This commit is contained in:
@@ -67,41 +67,58 @@ function cmdRequirementsMarkComplete(cwd: string, reqIdsRaw: string[], raw: bool
|
||||
const updated: string[] = [];
|
||||
const alreadyComplete: string[] = [];
|
||||
const notFound: string[] = [];
|
||||
// #2140: IDs reconciled on the checkbox surface only — a traceability table
|
||||
// exists but has no row for the ID. Without this bucket the payload for a
|
||||
// partial reconcile is byte-identical to a full one, and audit-milestone (which
|
||||
// reads the table) still sees Pending while the CLI reported success.
|
||||
const tableUnmatched: string[] = [];
|
||||
|
||||
// A traceability table is present if the file has a "| Requirement | … |"
|
||||
// header. A REQUIREMENTS.md with no such table is legitimate (mid-roadmap), so
|
||||
// a missing row only counts as drift when a table actually exists.
|
||||
const hasTable = /^\|\s*Requirement\s*\|/im.test(reqContent);
|
||||
|
||||
for (const reqId of reqIds) {
|
||||
let found = false;
|
||||
const reqEscaped = escapeRegex(reqId);
|
||||
|
||||
// Update checkbox: - [ ] **REQ-ID** → - [x] **REQ-ID**
|
||||
// Use replace() directly and compare — avoids test()+replace() global regex
|
||||
// Surface 1 — the checkbox: - [ ] **REQ-ID** → - [x] **REQ-ID**
|
||||
// Use replace() + compare to avoid the test()+replace() global regex
|
||||
// lastIndex bug where test() advances state and replace() misses matches.
|
||||
const checkboxPattern = new RegExp(`(-\\s*\\[)[ ](\\]\\s*\\*\\*${reqEscaped}\\*\\*)`, 'gi');
|
||||
const afterCheckbox = reqContent.replace(checkboxPattern, '$1x$2');
|
||||
if (afterCheckbox !== reqContent) {
|
||||
reqContent = afterCheckbox;
|
||||
found = true;
|
||||
}
|
||||
const checkboxHit = afterCheckbox !== reqContent;
|
||||
if (checkboxHit) reqContent = afterCheckbox;
|
||||
|
||||
// Update traceability table: | REQ-ID | Phase N | Pending | → | REQ-ID | Phase N | Complete |
|
||||
// Surface 2 — the traceability row: | REQ-ID | Phase N | Pending | → ... Complete |
|
||||
const tablePattern = new RegExp(`(\\|\\s*${reqEscaped}\\s*\\|[^|]+\\|)\\s*Pending\\s*(\\|)`, 'gi');
|
||||
const afterTable = reqContent.replace(tablePattern, '$1 Complete $2');
|
||||
if (afterTable !== reqContent) {
|
||||
reqContent = afterTable;
|
||||
found = true;
|
||||
}
|
||||
const tableHit = afterTable !== reqContent;
|
||||
if (tableHit) reqContent = afterTable;
|
||||
|
||||
if (found) {
|
||||
// Coverage of the traceability surface for this ID (computed after any flip).
|
||||
const hasRow = new RegExp(`\\|\\s*${reqEscaped}\\s*\\|`, 'i').test(reqContent);
|
||||
const doneCheckbox = new RegExp(`-\\s*\\[x\\]\\s*\\*\\*${reqEscaped}\\*\\*`, 'i').test(reqContent);
|
||||
const doneTable = new RegExp(`\\|\\s*${reqEscaped}\\s*\\|[^|]+\\|\\s*Complete\\s*\\|`, 'i').test(reqContent);
|
||||
|
||||
if (checkboxHit || tableHit) {
|
||||
updated.push(reqId);
|
||||
} else {
|
||||
// Check if already complete before declaring not_found.
|
||||
// Non-global flag is fine here — we only need to know if a match exists.
|
||||
const doneCheckbox = new RegExp(`-\\s*\\[x\\]\\s*\\*\\*${reqEscaped}\\*\\*`, 'i');
|
||||
const doneTable = new RegExp(`\\|\\s*${reqEscaped}\\s*\\|[^|]+\\|\\s*Complete\\s*\\|`, 'i');
|
||||
if (doneCheckbox.test(reqContent) || doneTable.test(reqContent)) {
|
||||
alreadyComplete.push(reqId);
|
||||
} else {
|
||||
notFound.push(reqId);
|
||||
}
|
||||
} else if (doneTable || (doneCheckbox && !hasTable)) {
|
||||
// Fully reconciled: the table row is Complete, OR the checkbox is done and
|
||||
// there is no table to reconcile against. (A [x] checkbox with a Pending or
|
||||
// absent row is NOT fully reconciled when a table exists — #2140.)
|
||||
alreadyComplete.push(reqId);
|
||||
} else if (!doneCheckbox && !doneTable) {
|
||||
notFound.push(reqId);
|
||||
}
|
||||
// else: doneCheckbox && hasTable && !doneTable — partially reconciled. It is
|
||||
// neither updated, already_complete, nor not_found; the table_unmatched bucket
|
||||
// below carries the truthful partial-reconcile signal.
|
||||
|
||||
// Surface traceability drift: checkbox reconciled (this run or before) but the
|
||||
// table has no row for this ID. This is what makes a partial reconcile
|
||||
// distinguishable from a full one (#2140).
|
||||
if (hasTable && doneCheckbox && !hasRow) {
|
||||
tableUnmatched.push(reqId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +132,7 @@ function cmdRequirementsMarkComplete(cwd: string, reqIdsRaw: string[], raw: bool
|
||||
marked_complete: updated,
|
||||
already_complete: alreadyComplete,
|
||||
not_found: notFound,
|
||||
table_unmatched: tableUnmatched,
|
||||
total: reqIds.length,
|
||||
},
|
||||
raw,
|
||||
|
||||
Reference in New Issue
Block a user