From d5f8191f66f1a537b50e2288d66f8bc32b57ae44 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 3 Sep 2026 01:32:41 -0400 Subject: [PATCH] =?UTF-8?q?fix(#3730):=20quick-tasks-migrate=20=E2=80=94?= =?UTF-8?q?=20canonical-schema=20repair,=20auto-run=20on=20first=20quick?= =?UTF-8?q?=20(#4216)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(#3730): a legacy Quick Tasks table must be migratable to canonical * fix(#3730): quick-tasks-migrate — canonical-schema repair, auto-run on first quick * fix(#3730): review fixes — usage parity, contiguous table span, collision-safe bucket, template-width delimiter Emitted-Drift-Ack-Growth: fast.md — #3730 runs quick-tasks-migrate before the first append (auto-migration on first quick run) Emitted-Drift-Ack-Growth: quick.md — #3730 replaces the match-any-format note with the migration instruction * chore(#3730): backfill changeset pr number * fix(#3730): scope the quick-batch row-48 guard to branches touching quick-batch --------- Co-authored-by: sim --- .changeset/serene-badgers-purr.md | 5 + gsd-core/bin/gsd-tools.cjs | 34 ++++- gsd-core/workflows/fast.md | 4 + gsd-core/workflows/quick.md | 2 +- src/markdown-table.cts | 130 ++++++++++++++++++ .../gsd-quick-batch-quick-regression.test.cjs | 16 +++ tests/markdown-table.test.cjs | 125 ++++++++++++++++- 7 files changed, 313 insertions(+), 3 deletions(-) create mode 100644 .changeset/serene-badgers-purr.md diff --git a/.changeset/serene-badgers-purr.md b/.changeset/serene-badgers-purr.md new file mode 100644 index 000000000..01f7f9046 --- /dev/null +++ b/.changeset/serene-badgers-purr.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 4216 +--- +**Legacy Quick Tasks tables migrate automatically** — a STATE.md Quick Tasks table in a pre-registry column format (which `quick-tasks-append` rejects) is now repaired onto the canonical schema by the new `quick-tasks-migrate` command, run automatically before the first append in `/gsd-quick` and `/gsd-fast`; lossless (unmapped columns keep their data in Description), silent no-op when canonical or absent. (#3730) diff --git a/gsd-core/bin/gsd-tools.cjs b/gsd-core/bin/gsd-tools.cjs index df27288ee..65a45fc72 100755 --- a/gsd-core/bin/gsd-tools.cjs +++ b/gsd-core/bin/gsd-tools.cjs @@ -30,6 +30,9 @@ * list-todos [area] Count and enumerate pending todos * list-seeds [status] List captured seeds (optional status filter) * verify-path-exists Check file/directory existence + * quick-tasks-migrate Migrate STATE.md's "Quick Tasks Completed" + * table onto the canonical schema (#3730). + * No-op when absent or already canonical. * quick-tasks-append --task Append a row to STATE.md's "Quick Tasks * Completed" table (schema-backed via * markdown-table.cjs; #2133/ADR-2143). @@ -1182,6 +1185,34 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load commands.cmdVerifyPathExists(cwd, args[1], raw); } + function routeQuickTasksMigrate({ cwd, raw }) { + // #3730 (option b, maintainer decision 2026-09-02): the supported repair + // path for a legacy Quick Tasks table GSD's own pre-registry prose created. + // Silent no-op when the section is absent or the table is already canonical + // — the quick/fast workflows call this before their first append, so the + // migration runs exactly once, on the first quick run, unprompted otherwise. + const statePath = path.join(cwd, '.planning', 'STATE.md'); + if (!fs.existsSync(statePath)) { + output({ ok: true, migrated: false, reason: `STATE.md not found at ${statePath}` }, raw); + return; + } + const { migrateQuickTasksTable } = require('./lib/markdown-table.cjs'); + let report; + state.readModifyWriteStateMd(statePath, (content) => { + const result = migrateQuickTasksTable(content); + if (!result.ok) { + throw new ExitError(1, `⚠ quick-tasks-migrate: ${result.reason}`); + } + report = result.value; + return result.value.content; + }, cwd, { resync: false }); + output({ + ok: true, + migrated: report.migrated, + ...(report.migrated ? { from: report.from, rows: report.rows } : {}), + }, raw); + } + function routeQuickTasksAppend({ args, cwd, raw, error }) { // #2133 / ADR-2143 §3,§7: schema-backed replacement for fast.md's inline // `awk NF-2` Quick Tasks column arithmetic. Row construction is delegated @@ -4187,6 +4218,7 @@ const HOST_COMMAND_ROUTERS = { 'list-seeds': routeListSeeds, 'verify-path-exists': routeVerifyPathExists, 'quick-tasks-append': routeQuickTasksAppend, + 'quick-tasks-migrate': routeQuickTasksMigrate, // #3676 (Phase 4, epic #3344): quick-batch coordination verbs. 'quick-batch': routeQuickBatchCommand, 'normalize-test-command': routeNormalizeTestCommand, @@ -4452,7 +4484,7 @@ const TOP_LEVEL_USAGE = 'Usage: gsd-tools [args] [--raw] [--pick /dev/null; then + # #3730: bring a legacy pre-registry table onto the canonical schema BEFORE + # appending — silent no-op when already canonical, so this runs harmlessly + # on every fast task and migrates exactly once, on the first. + gsd_run quick-tasks-migrate || true gsd_run quick-tasks-append --task "$TASK" || echo "⚠ fast.md log_to_state: could not append Quick Tasks row (see message above); continuing." fi ``` diff --git a/gsd-core/workflows/quick.md b/gsd-core/workflows/quick.md index 2a37ed786..6f9b0d48b 100644 --- a/gsd-core/workflows/quick.md +++ b/gsd-core/workflows/quick.md @@ -629,7 +629,7 @@ Insert after `### Blockers/Concerns` section: |---|-------------|------|--------|-----------| ``` -**Note:** If the table already exists, match its existing column format. If adding `--validate` (or `--full`) to a project that already has quick tasks without a Status column, add the Status column to the header and separator rows, and leave Status empty for the new row's predecessors. +**Note:** If the table already exists in a legacy (pre-registry) column format, first run `gsd_run quick-tasks-migrate` — the maintainer-decided repair path (#3730) that rewrites the table onto the canonical schema, losslessly bucketing unmapped columns into Description. It is a silent no-op when the table is already canonical or the section is absent, so running it before the first append of every quick task migrates exactly once and never prompts otherwise. After migration, use the canonical column format below. **7c. Append new row to table:** diff --git a/src/markdown-table.cts b/src/markdown-table.cts index a9fa61e6c..41fbd1752 100644 --- a/src/markdown-table.cts +++ b/src/markdown-table.cts @@ -885,6 +885,136 @@ export function appendQuickTaskRow( return { ok: true, value: { content, row, variant: match.label } }; } + +// ─── migrateQuickTasksTable (#3730 option b) ─────────────────────────────── + +/** + * Column-name → canonical-cell mapping for migrating a legacy Quick Tasks table + * (#3730). Name-based, case-insensitive; every column NOT in this map lands in + * the Description bucket (joined with ' · ' in original column order, unknown + * names kept as `name: value`) so no historical datum is dropped. + */ +const QUICK_TASKS_COLUMN_ALIASES: Record = { + '#': '#', id: '#', + date: 'Date', when: 'Date', + commit: 'Commit', sha: 'Commit', + status: 'Status', + directory: 'Directory', artifacts: 'Directory', path: 'Directory', dir: 'Directory', + description: 'Description', task: 'Description', summary: 'Description', + slug: 'Description', scope: 'Description', title: 'Description', +}; + +/** + * Migrate STATE.md's "Quick Tasks Completed" table onto the canonical + * `with-status` schema (#3730, maintainer decision 2026-09-02: option b). + * + * The pre-registry prose template licensed arbitrary column shapes GSD itself + * emitted, and `appendQuickTaskRow` fails loud on every one of them — leaving a + * project permanently unappendable. This is the supported repair path: the + * `gsd-tools quick-tasks-migrate` subcommand, plus an automatic check on the + * first `quick`/`fast` run (the workflow calls this before appending), which is + * a silent no-op when the section is absent, the table is already canonical, or + * the user never runs quick at all. + * + * Same section-selection, parse, and fail-loud posture as `appendQuickTaskRow` + * (ADR-2143 §7 upheld — append still rejects; this REPAIRS). Returns + * `{ok:true, value:{content, migrated:false}}` for the no-op cases so callers + * can stay silent, and `{migrated:true, from, rows}` when a rewrite happened so + * the CLI can report exactly what moved. + */ +export function migrateQuickTasksTable( + stateContent: string, +): Result<{ content: string; migrated: boolean; from?: string[]; rows?: number }> { + const section = selectQuickTasksSection(stateContent); + if (!section) { + return { ok: true, value: { content: stateContent, migrated: false } }; + } + + const parsed = parseMarkdownTable(section.body); + if (!parsed.ok) { + return { ok: false, reason: `quick-tasks table: ${parsed.reason}` }; + } + + const match = matchTableSchema(parsed.value.columns); + if (match && match.id === 'QuickTasks') { + return { ok: true, value: { content: stateContent, migrated: false } }; + } + + const canonical = TABLE_SCHEMAS.QuickTasks.find((v) => v.label === 'with-status')!.columns; + // Map each legacy column to its canonical target; unmapped names keep their + // identity so the Description bucket can render them losslessly. + const targets = parsed.value.columns.map((c) => QUICK_TASKS_COLUMN_ALIASES[c.trim().toLowerCase()] ?? null); + + const rows = parsed.value.rows.map((row, rowIdx) => { + const bucket: Record = { + '#': '', Description: '', Date: '', Commit: '', Status: '', Directory: '', + }; + const descriptionParts: string[] = []; + // parseMarkdownTable hands back name-keyed records (rows[i][columnName]). + parsed.value.columns.forEach((col, i) => { + const raw = (row[col] ?? '').trim(); + const target = targets[i]; + if (target === 'Description') { + if (raw) descriptionParts.push(raw); + } else if (target) { + // Collision-safe (#3730 review): two legacy columns aliasing the same + // canonical target must not silently overwrite — the displaced value + // joins the Description bucket under its own name, same convention as + // an unknown column, so no historical datum is dropped. + if (raw && bucket[target]) { + descriptionParts.push(`${col.trim()}: ${raw}`); + } else if (raw) { + bucket[target] = raw; + } + } else if (raw) { + descriptionParts.push(`${col.trim()}: ${raw}`); + } + }); + bucket['#'] = bucket['#'] || String(rowIdx + 1); + bucket.Description = descriptionParts.join(' · '); + bucket.Date = bucket.Date || '—'; + bucket.Commit = bucket.Commit || '—'; + bucket.Status = bucket.Status || '—'; + bucket.Directory = bucket.Directory || '—'; + return `| ${canonical.map((c) => escapeCell(bucket[c])).join(' | ')} |`; + }); + + const eol = /\r\n/.test(section.body) ? '\r\n' : '\n'; + const lines = section.body.split(/\r?\n/); + // CONTIGUOUS-run bound (#3730 review), mirroring resetQuickTaskRows: the + // section body may carry a SECOND table (a `#### Notes` subsection or a + // trailing table after a blank line) that parseMarkdownTable never read — + // scanning for the last pipe line anywhere in the body would splice the + // rewrite across it and silently destroy it. Only the first CONTIGUOUS run + // of pipe lines — the table that was parsed — is replaced. + const firstTableLineIdx = lines.findIndex((l) => l.trim().startsWith('|')); + let lastTableLineIdx = firstTableLineIdx; + while (lastTableLineIdx + 1 < lines.length && lines[lastTableLineIdx + 1].trim().startsWith('|')) { + lastTableLineIdx++; + } + const header = `| ${canonical.join(' | ')} |`; + // Widths match workflows/quick.md's canonical template byte-for-byte + // (#3730 review): its delimiter is max(3, len+2) per column. + const delimiter = `| ${canonical.map((c) => '-'.repeat(Math.max(3, c.length + 2))).join(' | ')} |`; + const newBody = [ + ...lines.slice(0, firstTableLineIdx), + header, + delimiter, + ...rows, + ...lines.slice(lastTableLineIdx + 1), + ].join(eol); + + return { + ok: true, + value: { + content: replaceSection(stateContent, section, newBody), + migrated: true, + from: parsed.value.columns, + rows: rows.length, + }, + }; +} + // ─── resetQuickTaskRows (#2142) ──────────────────────────────────────────── /** diff --git a/tests/gsd-quick-batch-quick-regression.test.cjs b/tests/gsd-quick-batch-quick-regression.test.cjs index 3462f572f..cd7cde53d 100644 --- a/tests/gsd-quick-batch-quick-regression.test.cjs +++ b/tests/gsd-quick-batch-quick-regression.test.cjs @@ -43,6 +43,22 @@ describe('quick-batch: /gsd:quick command + workflow stay byte-identical (row 48 } const changed = resolveChangedPaths(resolved.ref); + // #3730 review: row 48 is the #3676 PHASE's invariant — quick-batch adds new + // call sites onto shared primitives without editing ordinary quick. Judging + // every FUTURE branch by it would freeze quick.md forever (observed: the + // #3730 migration note tripped this row on an unrelated branch). Scope the + // row to branches that actually touch the quick-batch surface; an unrelated + // branch's quick.md edit is none of this guard's business. + // tests/ excluded: this guard file's own name matches, which would make + // the scope check self-satisfying on every branch that edits it. + const touchesQuickBatch = changed.some((p) => /quick-batch/.test(p) && !p.startsWith('tests/')); + if (!touchesQuickBatch) { + t.skip( + `branch does not touch the quick-batch surface (${changed.length} changed paths) — ` + + 'row 48 governs #3676-phase branches only', + ); + return; + } assert.ok( !changed.includes('commands/gsd/quick.md'), 'commands/gsd/quick.md must stay untouched by the #3676 quick-batch phase', diff --git a/tests/markdown-table.test.cjs b/tests/markdown-table.test.cjs index 496ea5a73..6ceb80e73 100644 --- a/tests/markdown-table.test.cjs +++ b/tests/markdown-table.test.cjs @@ -23,7 +23,8 @@ const fs = require('node:fs'); const path = require('node:path'); const fc = require('./helpers/fast-check-setup.cjs'); -const { parseMarkdownTable, matchTableSchema, TABLE_SCHEMAS, appendQuickTaskRow, findTableBySchema, findTableWithColumns, updateTableCell, deleteTableRow, resetQuickTaskRows, QUICK_TASKS_SECTION_ABSENT } = require('../gsd-core/bin/lib/markdown-table.cjs'); +const { + migrateQuickTasksTable, parseMarkdownTable, matchTableSchema, TABLE_SCHEMAS, appendQuickTaskRow, findTableBySchema, findTableWithColumns, updateTableCell, deleteTableRow, resetQuickTaskRows, QUICK_TASKS_SECTION_ABSENT } = require('../gsd-core/bin/lib/markdown-table.cjs'); const { buildHeader, normalize } = require('../scripts/lint-table-schema-drift.cjs'); const ROOT = path.join(__dirname, '..'); @@ -1531,3 +1532,125 @@ describe('resetQuickTaskRows (#2142)', () => { ); }); }); + +describe('migrateQuickTasksTable (#3730 option b)', () => { + const legacyState = (eol = '\n') => [ + '# State', + '', + '## Quick Tasks Completed', + '', + '| Date | Slug | Scope | Artifacts |', + '|------|------|-------|-----------|', + '| 2026-04-17 | 260417-abc | bootstrap | roles/x |', + '| 2026-05-02 | 260502-def | cli | quick/y |', + ].join(eol) + eol; + + test('migrates the legacy pre-registry schema', () => { + const r = migrateQuickTasksTable(legacyState()); + assert.equal(r.ok, true, `migrate failed: ${r.reason}`); + assert.equal(r.value.migrated, true); + assert.ok(r.value.content.includes('| # | Description | Date | Commit | Status | Directory |'), + 'canonical with-status header is written'); + assert.ok(r.value.content.includes('| 1 | 260417-abc · bootstrap | 2026-04-17 | — | — | roles/x |'), + 'Slug+Scope bucket into Description; Artifacts maps to Directory; # renumbered'); + assert.ok(r.value.content.includes('| 2 | 260502-def · cli | 2026-05-02 | — | — | quick/y |'), + 'every data row migrates'); + assert.ok(!r.value.content.includes('| Date | Slug |'), 'the legacy header is gone'); + }); + + test('keeps unknown-named columns losslessly in the Description bucket', () => { + const state = [ + '# State', '', '## Quick Tasks Completed', '', + '| Date | Owner |', '|------|-------|', '| 2026-04-17 | sim |', + ].join('\n'); + const r = migrateQuickTasksTable(state); + assert.equal(r.ok, true); + assert.ok(r.value.content.includes('Owner: sim'), 'unknown column keeps its name: value'); + }); + + test('no-ops a canonical table (byte-identical)', () => { + const canonical = [ + '# State', '', '## Quick Tasks Completed', '', + '| # | Description | Date | Commit | Directory |', + '|---|-------------|------|--------|-----------|', + '| 1 | existing | 2026-04-17 | deadbee | ./quick/a/ |', + ].join('\n'); + const r = migrateQuickTasksTable(canonical); + assert.equal(r.ok, true); + assert.equal(r.value.migrated, false); + assert.equal(r.value.content, canonical); + }); + + test('no-ops an absent section (absence is normal, #2142)', () => { + const r = migrateQuickTasksTable('# State\n\n## Other\n'); + assert.equal(r.ok, true); + assert.equal(r.value.migrated, false); + }); + + test('fails loud on an unparseable table', () => { + const state = '# State\n\n## Quick Tasks Completed\n\nnot a table\n'; + const r = migrateQuickTasksTable(state); + assert.equal(r.ok, false); + assert.ok(r.reason.length > 0, 'carries the parse failure reason'); + }); + + test('preserves CRLF section endings', () => { + const r = migrateQuickTasksTable(legacyState('\r\n')); + assert.equal(r.ok, true); + assert.ok(r.value.content.includes('\r\n'), 'CRLF preserved'); + assert.ok(!/[^\r]\n/.test(r.value.content.split('## Quick Tasks Completed')[1] || ''), + 'no mixed EOL introduced into the section'); + }); + + test('a migrated table is appendable (round-trip)', () => { + const migrated = migrateQuickTasksTable(legacyState()); + const appended = appendQuickTaskRow(migrated.value.content, { + description: 'probe', date: '2026-08-20', commit: 'deadbee', directory: './quick/probe/', + }); + assert.equal(appended.ok, true, `append after migrate failed: ${appended.reason}`); + assert.ok(appended.value.row.includes('probe')); + }); +}); + +describe('quick-tasks-migrate CLI and workflow wiring (#3730)', () => { + const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs'); + const fs = require('node:fs'); + const path = require('node:path'); + + test('quick-tasks-migrate CLI round-trip', (t) => { + const tmpDir = createTempProject('gsd-3730-cli-'); + t.after(() => cleanup(tmpDir)); + const statePath = path.join(tmpDir, '.planning', 'STATE.md'); + fs.writeFileSync(statePath, [ + '# State', '', '## Quick Tasks Completed', '', + '| Date | Slug | Scope | Artifacts |', + '|------|------|-------|-----------|', + '| 2026-04-17 | 260417-abc | bootstrap | roles/x |', + ].join('\n')); + + const first = runGsdTools('quick-tasks-migrate', tmpDir); + assert.equal(first.success, true, `migrate failed: ${first.error}`); + const firstOut = JSON.parse(first.output); + assert.equal(firstOut.migrated, true); + assert.deepEqual(firstOut.from, ['Date', 'Slug', 'Scope', 'Artifacts']); + assert.ok(fs.readFileSync(statePath, 'utf8').includes('| # | Description | Date | Commit | Status | Directory |'), + 'STATE.md rewritten to canonical'); + + const second = runGsdTools('quick-tasks-migrate', tmpDir); + assert.equal(second.success, true, `second migrate failed: ${second.error}`); + assert.equal(JSON.parse(second.output).migrated, false, 'a canonical table is a no-op'); + }); + + test('quick and fast run the migration check first', () => { + const fast = fs.readFileSync(path.join(__dirname, '..', 'gsd-core', 'workflows', 'fast.md'), 'utf8'); + const quick = fs.readFileSync(path.join(__dirname, '..', 'gsd-core', 'workflows', 'quick.md'), 'utf8'); + // Compare the INVOCATIONS, not first prose mentions — fast.md's prose + // names the append helper (~line 77) before the bash block that runs both. + const fastMigrate = fast.indexOf('gsd_run quick-tasks-migrate'); + const fastAppend = fast.indexOf('gsd_run quick-tasks-append'); + assert.ok(fastMigrate !== -1, 'fast.md invokes quick-tasks-migrate'); + assert.ok(fastAppend !== -1 && fastMigrate < fastAppend, 'the migration runs BEFORE the append'); + assert.ok(quick.includes('quick-tasks-migrate'), + 'quick.md documents and invokes the migration path (#3730)'); + }); +});