* test(#3255): updateTableCell reaches a later table when the first lacks the column A ## Traceability section holding a summary table (no Status) above the requirement table (with Status) made updateTableCell bind to the first table and return 'unknown column: Status', so requirements.mark-complete left the row at Pending. Add a failing-first regression at the primitive. RED — fails on next; fix follows. * fix(#3255): updateTableCell scans for the table carrying the requested column updateTableCell bound to the FIRST GFM table in the scoped text and returned 'unknown column' if that table lacked the column — so a ## Traceability section holding a phase-summary table above the requirement table never reached the requirement table, and requirements.mark-complete left the row at Pending while reporting table_unmatched (the #2140 silent-divergence class one level deeper, flagged but not closed by the #2245 cross-section scoping fix). Scan candidate table headers and pick the first VALID table (delimiter + matching column count) whose columns include the requested column. Single-table behaviour is byte-identical (the one table carries the column). Error semantics preserved: no table -> 'no table found'; valid table without the column -> 'unknown column'; lone malformed table keeps its specific reason. Also fixes the derived hasRow/doneTable reasoning, which probes via the same call. * chore(#3255): add changeset fragment * chore(#3255): backfill changeset PR number (#3377) --------- Co-authored-by: sim <sim@local>
This commit is contained in:
5
.changeset/amber-cobalt-spark.md
Normal file
5
.changeset/amber-cobalt-spark.md
Normal file
@@ -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)
|
||||
@@ -326,36 +326,50 @@ export function updateTableCell(
|
||||
): Result<string> {
|
||||
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);
|
||||
|
||||
|
||||
@@ -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 ');
|
||||
|
||||
Reference in New Issue
Block a user