diff --git a/.changeset/amber-cobalt-spark.md b/.changeset/amber-cobalt-spark.md new file mode 100644 index 000000000..f422f0b35 --- /dev/null +++ b/.changeset/amber-cobalt-spark.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3377 +--- +**`requirements mark-complete` now flips the traceability row when `## Traceability` holds more than one table** — `updateTableCell` no longer binds to the first table in the section; it scans for the table that actually carries the requested column. A section with a phase-summary table above the requirement rows previously made the Status write silently bail (`table_unmatched`) while the checkbox still flipped, leaving the row at `Pending` indefinitely. Single-table sections are unchanged. (#3255) diff --git a/src/markdown-table.cts b/src/markdown-table.cts index 0c76ce5b3..ee0c447a4 100644 --- a/src/markdown-table.cts +++ b/src/markdown-table.cts @@ -326,36 +326,50 @@ export function updateTableCell( ): Result { const lines = splitLinesWithOffsets(tableText); + // #3255: pick the first VALID table whose columns include `column`. The prior + // code bound to the FIRST table of any shape and returned 'unknown column' if + // that one lacked the column — so a section holding a summary table above the + // target (e.g. ## Traceability: a phase-summary table, then the requirement + // rows) never reached the target table. Scan for the first valid table that + // carries the column; if none does but a valid table exists, still return + // 'unknown column' (single-table behaviour unchanged). Track the first + // malformation reason so a lone malformed table keeps its specific error. let headerIdx = -1; + let columns: string[] = []; + let firstValidIdx = -1; + let firstMalformedReason: string | null = null; + const recordMalformed = (reason: string): void => { + if (firstMalformedReason === null && firstValidIdx === -1) firstMalformedReason = reason; + }; for (let i = 0; i < lines.length; i++) { const trimmed = lines[i].line.trim(); - if (trimmed.startsWith('|') && trimmed.indexOf('|', 1) !== -1) { + if (!trimmed.startsWith('|') || trimmed.indexOf('|', 1) === -1) continue; + const delimiterLine = lines[i + 1]?.line; + if (delimiterLine === undefined || !delimiterLine.trim().startsWith('|')) { + recordMalformed('missing delimiter row'); + continue; + } + const candidateRanges = splitTableRowRanges(lines[i].line, lines[i].start); + const candidateColumns = candidateRanges.map((r) => unescapeCellText(tableText.slice(r.start, r.end))); + const delimiterCells = splitTableRow(delimiterLine); + if (!isDelimiterRow(delimiterCells)) { + recordMalformed('missing delimiter row'); + continue; + } + if (delimiterCells.length !== candidateColumns.length) { + recordMalformed('delimiter/header column count mismatch'); + continue; + } + if (firstValidIdx === -1) firstValidIdx = i; + if (candidateColumns.includes(column)) { headerIdx = i; + columns = candidateColumns; break; } } if (headerIdx === -1) { - return { ok: false, reason: 'no table found' }; - } - - const delimiterLine = lines[headerIdx + 1]?.line; - if (delimiterLine === undefined || !delimiterLine.trim().startsWith('|')) { - return { ok: false, reason: 'missing delimiter row' }; - } - - const headerRanges = splitTableRowRanges(lines[headerIdx].line, lines[headerIdx].start); - const columns = headerRanges.map((r) => unescapeCellText(tableText.slice(r.start, r.end))); - - const delimiterCells = splitTableRow(delimiterLine); - if (!isDelimiterRow(delimiterCells)) { - return { ok: false, reason: 'missing delimiter row' }; - } - if (delimiterCells.length !== columns.length) { - return { ok: false, reason: 'delimiter/header column count mismatch' }; - } - - if (!columns.includes(column)) { - return { ok: false, reason: `unknown column: ${column}` }; + if (firstValidIdx !== -1) return { ok: false, reason: `unknown column: ${column}` }; + return { ok: false, reason: firstMalformedReason ?? 'no table found' }; } const targetColIdx = columns.indexOf(column); diff --git a/tests/markdown-table.test.cjs b/tests/markdown-table.test.cjs index f3d5ed37f..e7f7d179e 100644 --- a/tests/markdown-table.test.cjs +++ b/tests/markdown-table.test.cjs @@ -639,6 +639,29 @@ describe('updateTableCell', () => { assert.match(result.reason, /no table found/); }); + test('#3255: reaches a later table when the first table lacks the requested column', () => { + // A ## Traceability section holding a phase-summary table (no Status) ABOVE + // the requirement table (with Status). The prior code bound to the FIRST + // table and returned 'unknown column: Status' without ever reaching the + // requirement table, so requirements.mark-complete left the row at Pending. + const multiTable = [ + '| Phase | Name | Requirements |', + '|-------|------|--------------|', + '| 1 | Portal Spec | 4 (PORTAL-01..04) |', + '', + '| Requirement | Phase | Status |', + '|-------------|-------|--------|', + '| AUTHZ-02 | Phase 02.2 | Pending |', + ].join('\n'); + const byReq = (row) => (Object.values(row)[0] ?? '').trim().toLowerCase() === 'authz-02'; + const result = updateTableCell(multiTable, byReq, 'Status', ' Complete '); + + assert.equal(result.ok, true, `expected ok:true (should reach the requirement table); got: ${result && result.reason}`); + assert.ok(result.value.includes('| Phase | Name | Requirements |'), 'first (summary) table header untouched'); + assert.ok(result.value.includes('| 1 | Portal Spec | 4 (PORTAL-01..04) |'), 'first (summary) data row untouched'); + assert.ok(/AUTHZ-02 \| Phase 02\.2 \| Complete/.test(result.value), 'second table Status cell flipped Pending → Complete'); + }); + test('5-column milestone table: Milestone cell and other columns stay byte-identical', () => { const byPhase = (row) => row['Phase'].trim() === '1. Alpha'; const result = updateTableCell(fiveCol, byPhase, 'Plans Complete', ' 2/2 ');