diff --git a/.changeset/silly-pandas-gather.md b/.changeset/silly-pandas-gather.md new file mode 100644 index 000000000..075f7e276 --- /dev/null +++ b/.changeset/silly-pandas-gather.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 2248 +--- +**`phase complete` now reads milestone-grouped ROADMAP progress tables** — progress reported 0% on projects whose Progress table carries a Milestone column, because the reader assumed a fixed column position; it now resolves progress columns by name so both flat and milestone-grouped tables work (#2137). Quick Tasks logging via `/gsd:fast` also appends schema-correct, lock-safe rows instead of guessing the column count in shell (#2133). (#2248) diff --git a/.gitignore b/.gitignore index ce01a3157..2fcfa036f 100644 --- a/.gitignore +++ b/.gitignore @@ -91,6 +91,7 @@ build/ /gsd-core/bin/lib/capability-consent.cjs /gsd-core/bin/lib/capability-lock.cjs /gsd-core/bin/lib/markdown-sectionizer.cjs +/gsd-core/bin/lib/markdown-table.cjs /gsd-core/bin/lib/resolution.cjs /gsd-core/bin/lib/research-store.cjs /gsd-core/bin/lib/research-provider.cjs diff --git a/CONTEXT.md b/CONTEXT.md index 37b22fc60..700331e44 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -142,6 +142,9 @@ Module owning the tool's CLI I/O primitives: `output()` result emission (with la ### Markdown Sectionizer Canonical markdown-structure parsing seam (`gsd-core/bin/lib/markdown-sectionizer.cjs`, generated from `src/markdown-sectionizer.cts`). Pure functions, Node built-ins only. Exports: `stripFencedCode(content) → { text, unterminatedFence }` (CommonMark-correct state machine, CRLF-safe, signals unterminated fences); `tokenizeHeadings(content) → HeadingToken[]` (ATX headings outside fenced blocks, `{ level, text, line, offset }`); `collectSections(content, stopPredicate) → Section[]` (line-by-line section collection driven by a heading predicate); `collectSection(content, headingPredicate, { levelBounded, stripFences }) → Section | null` (single named section with level-bounded stop); `iterateBullets(sectionText) → BulletItem[]` (dash/checkbox/numbered markers with indented continuation); `extractTaggedBlocks(content, tagName) → string[]` (inner text of every `…` block in document order, tagName regex-escaped, caller decides fence-stripping — generalises `decisions.cts`'s bespoke extractor for T1); `replaceSection(content, section, newBody) → string` (pure character-offset splice using `Section.bodyStart`/`bodyEnd` for read-modify-write callers — eliminates T6 `state.cts`'s 7× inline `content.replace` pattern). `Section` carries `bodyStart`/`bodyEnd` offsets for `replaceSection`. ADR-1372 (epic #1372) establishes this seam and a tiered migration plan (T0–T7) to retire the 8+ ad-hoc markdown parsers and ~20 inline section-collects across `src/*.cts`. New `src/*.cts` modules must import this seam instead of hand-rolling fence strippers or heading-regex section walks (enforced by the `no-adhoc-markdown-parsing` ESLint rule landing in tier T7). +### Markdown Table Model +Canonical GFM table parsing + schema registry seam (`gsd-core/bin/lib/markdown-table.cjs`, generated from `src/markdown-table.cts`; ADR-2143, epic #2143). Pure functions, Node built-ins only, string-in/value-out, no I/O. Exports: `parseMarkdownTable(sectionText) → Result` (parses the first GFM pipe table found; typed `{ok:false,reason}` parse errors for no-table, missing/misaligned delimiter row, and ragged data rows — never silently drops or coerces a malformed row); `MarkdownTable` (`{columns: string[], rows: Record[]}`, rows addressed by column name, not position); `Result` (`{ok:true,value}\|{ok:false,reason}` — deliberately distinct from command-routing-hub's dispatch `Result` `{ok,data\|kind}`; the two never mix); `TABLE_SCHEMAS` (`Record` — the canonical column-header variants for every GFM table GSD parses or generates: `RoadmapProgress` flat/milestone-grouped, `RequirementsTraceability`, `QuickTasks` no-status/with-status, `Security` trust-boundaries/threat-register/accepted-risks/audit-trail); `matchTableSchema(columns) → {id,label}\|null` (resolves a parsed header back to its canonical schema by exact column-name/order match). This registry is the single source of truth for ROADMAP/STATE/SECURITY canonical tables — a parity test (`tests/markdown-table.test.cjs`) asserts every variant's header appears verbatim in the template/workflow file that generates it, so the registry and templates can never silently drift (ADR-2143 §3 Generative-Fix-Divergence guard). `phase-lifecycle.cts`'s `deriveProgressFromRoadmap` is the first consumer: it locates the Progress section via the Markdown Sectionizer's `collectSection` and reads cells by column NAME through this seam, fixing #2137 (the prior position-anchored regex assumed `Status` was always the 3rd cell, which broke for the 5-column milestone-grouped `Milestone` variant). + ### Roadmap Parser Module Module owning ROADMAP.md parsing: shipped-milestone slicing, current-milestone extraction, milestone/phase lookups, and milestone-phase filtering (`stripShippedMilestones`, `extractCurrentMilestone`, `replaceInCurrentMilestone`, `getRoadmapPhaseInternal`, `getMilestoneInfo`, `getMilestonePhaseFilter`). Depends only on leaf modules (`phase-id`, `planning-workspace`, `shell-command-projection`) — no `loadConfig`, no other core dependency. Extracted from the Core module per ADR-857 rollout phase 2b (#870), resolving the ROADMAP.md parse/write straddle so the Roadmap module (`roadmap.cjs`, which owns ROADMAP.md mutation) imports parsing directly instead of through Core; the `core.cjs` re-export spine was retired in epic #1267, so callers import this leaf directly. Source of truth: `gsd-core/bin/lib/roadmap-parser.cjs` (generated from `src/roadmap-parser.cts`). diff --git a/docs/CLI-TOOLS.md b/docs/CLI-TOOLS.md index 923a1f757..749c64350 100644 --- a/docs/CLI-TOOLS.md +++ b/docs/CLI-TOOLS.md @@ -531,6 +531,9 @@ node gsd-tools.cjs list-seeds [status] # Check file/directory existence node gsd-tools.cjs verify-path-exists +# Append a row to STATE.md's "Quick Tasks Completed" table (schema-backed; #2133) +node gsd-tools.cjs quick-tasks-append --task "" + # Aggregate all SUMMARY.md data node gsd-tools.cjs history-digest diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 88ecb397b..f2b42220c 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -371,6 +371,7 @@ "loop-host-contract.cjs", "loop-resolver.cjs", "markdown-sectionizer.cjs", + "markdown-table.cjs", "mcp-server.cjs", "milestone.cjs", "model-adapter.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index b7dbbab4e..2aa86d030 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -466,6 +466,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `loop-host-contract.cjs` | Generated Loop Host Contract — 12 loop points, per-step agent roles, and core artifacts for the five-step pipeline (discuss/plan/execute/verify/ship); emitted by `scripts/gen-loop-host-contract.cjs --write` (ADR-894 §3); consumed by `gen-capability-registry.cjs` | | `loop-resolver.cjs` | Loop Extension Point resolver — ADR-857 phase 3c/6 registry-consuming query; given a canonical loop point, filters `byLoopPoint` by resolved Capability State plus config activation (`when` key traversal with prototype-pollution guard), returns `{ point, activeHooks, rendered }` envelope; `resolveLoopHooks` and `renderLoopHooks` are pure (no I/O); command surface: `gsd-tools loop render-hooks [--config-dir ]` | | `markdown-sectionizer.cjs` | Canonical markdown-structure parsing seam (ADR-1372, epic #1372) — pure, Node built-ins only; exports `stripFencedCode` (CommonMark-correct fence stripper, CRLF-safe), `tokenizeHeadings` (ATX headings outside fenced blocks), `collectSections`/`collectSection` (line-by-line section collection with `bodyStart`/`bodyEnd` offsets), `iterateBullets` (dash/checkbox/numbered markers), `extractTaggedBlocks` (inner text of `…` blocks, caller decides fence-stripping), and `replaceSection` (pure character-offset body splice for read-modify-write callers); foundation for T0–T7 migration tiers retiring 8+ ad-hoc parsers | +| `markdown-table.cjs` | Canonical GFM table model + `TABLE_SCHEMAS` registry seam (ADR-2143, epic #2143) — pure, Node built-ins only; exports `parseMarkdownTable(sectionText) → Result` (parses the first GFM pipe table, typed parse errors for ragged/malformed rows rather than silent coercion), `MarkdownTable` (`{columns, rows}`, rows addressed by column name), `Result` (`{ok:true,value}\|{ok:false,reason}` — distinct from command-routing-hub's dispatch `Result`), `TABLE_SCHEMAS` (canonical column-header variants for `RoadmapProgress`/`RequirementsTraceability`/`QuickTasks`/`Security` tables), and `matchTableSchema(columns) → {id,label}\|null` (resolves parsed headers back to a canonical schema); consumed by `phase-lifecycle.cts`'s `deriveProgressFromRoadmap` (fixes #2137, the 5-column milestone-grouped Progress table) | | `milestone.cjs` | Milestone archival, requirements marking | | `model-catalog.cjs` | CJS adapter over the shared model catalog JSON; exports canonical runtime tier defaults, agent profile maps, alias maps, and routing metadata for all CLI consumers | | `model-profiles.cjs` | Backward-compatible profile helpers derived from `model-catalog.cjs`; no longer owns its own model table | diff --git a/eslint.config.mjs b/eslint.config.mjs index fe922768c..dbeae1b7c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -213,6 +213,8 @@ export default tseslint.config( 'gsd-core/bin/lib/teams-status.cjs', // ADR-1372: tsc-generated runtime artifact — lint the src/markdown-sectionizer.cts source. 'gsd-core/bin/lib/markdown-sectionizer.cjs', + // ADR-2143: tsc-generated runtime artifact — lint the src/markdown-table.cts source. + 'gsd-core/bin/lib/markdown-table.cjs', // ADR-1239 Phase C-1 (#1680): tsc-generated — lint src/embedding-adapter.cts + src/adapter-declarative.cts. 'gsd-core/bin/lib/embedding-adapter.cjs', 'gsd-core/bin/lib/adapter-declarative.cjs', diff --git a/gsd-core/bin/gsd-tools.cjs b/gsd-core/bin/gsd-tools.cjs index fdf8aa129..c8ac7abd4 100755 --- a/gsd-core/bin/gsd-tools.cjs +++ b/gsd-core/bin/gsd-tools.cjs @@ -27,6 +27,12 @@ * 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-append --task Append a row to STATE.md's "Quick Tasks + * Completed" table (schema-backed via + * markdown-table.cjs; #2133/ADR-2143). + * Fails loud (non-zero exit) on a missing + * or unrecognized table instead of the old + * awk NF-2 silent-skip guess. * config-ensure-section Initialize .planning/config.json * history-digest Aggregate all SUMMARY.md data * summary-extract [--fields] Extract structured data from SUMMARY.md @@ -693,7 +699,7 @@ async function main() { 'from-gsd2, frontmatter, gap-analysis, generate-claude-md, generate-claude-profile, ' + 'generate-dev-preferences, generate-slug, graphify, history-digest, init, intel, ' + 'capability, classify-confidence, git, learnings, list-seeds, list-todos, loop, milestone, package-legitimacy, phase, phase-plan-index, phases, profile-questionnaire, ' + - 'profile-sample, progress, project-instruction-file, prompt-budget, requirements, research-plan, research-store, resolve-granularity, resolve-model, roadmap, scaffold, smart-entry, state, ' + + 'profile-sample, progress, project-instruction-file, prompt-budget, quick-tasks-append, requirements, research-plan, research-store, resolve-granularity, resolve-model, roadmap, scaffold, smart-entry, state, ' + 'task, template, user-story, validate, verify, verify-path-exists, verify-summary, eval, workstream, worktree\n\n' + 'Global flags:\n' + ' --raw Emit raw output without post-processing\n' + @@ -1213,6 +1219,59 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand break; } + case 'quick-tasks-append': { + // #2133 / ADR-2143 §3,§7: schema-backed replacement for fast.md's inline + // `awk NF-2` Quick Tasks column arithmetic. Row construction is delegated + // to the pure appendQuickTaskRow (markdown-table.cjs); this case only + // handles the I/O (read STATE.md, resolve date/commit, write STATE.md). + const qtaArgs = args.slice(1); + const qtaTask = parseNamedArgs(qtaArgs, ['task']).task || args[1]; + if (!qtaTask) { + error('quick-tasks-append requires --task (or a positional description)', ERROR_REASON.USAGE); + } + + const statePath = path.join(cwd, '.planning', 'STATE.md'); + if (!fs.existsSync(statePath)) { + error(`quick-tasks-append: STATE.md not found at ${statePath}`, ERROR_REASON.USAGE); + } + + const date = new Date().toISOString().slice(0, 10); + const { execGit } = require('./lib/shell-command-projection.cjs'); + const hashResult = execGit(['rev-parse', '--short', 'HEAD'], { cwd }); + const commit = hashResult.exitCode === 0 && hashResult.stdout ? hashResult.stdout : '—'; + + const { appendQuickTaskRow } = require('./lib/markdown-table.cjs'); + + // #2242 review fix: route the read -> mutate -> write cycle through + // state.readModifyWriteStateMd (lib/state.cjs) instead of a raw + // fs.readFileSync + fs.writeFileSync pair, so the whole read-modify-write + // is atomic under STATE.md's lockfile — closing the lost-update race a + // raw read/write pair left open (cf. #500/#905/#1230). This mirrors the + // pattern every other STATE.md-mutating case in state.cts uses (e.g. + // cmdStateAddBlocker, cmdStateAddDecision): a mutable outer variable + // captures the pure helper's side output, and a fail-loud reason throws + // ExitError from INSIDE the transform (readModifyWriteStateMd's finally + // still releases the lock before the throw propagates; the transform + // throws before returning new content, so nothing is ever written). + let mutation; + state.readModifyWriteStateMd(statePath, (content) => { + const result = appendQuickTaskRow(content, { description: qtaTask, date, commit }); + if (!result.ok) { + // Mirrors fast.md's old "skip with a brief log" behaviour (#2133): this + // is an expected, recoverable condition (no table / unrecognized + // schema), not a hard crash. ExitError sets a non-zero exit code (so + // fast.md's `|| echo ...` fallback fires) without calling + // process.exit() directly — stdout stays flushed and untouched. + throw new ExitError(1, `⚠ quick-tasks-append: ${result.reason}`); + } + mutation = result.value; + return result.value.content; + }, cwd); + + output({ ok: true, row: mutation.row, variant: mutation.variant }, raw, mutation.row); + break; + } + case 'config-ensure-section': { // Phase 6 (#3575): dispatch via SDK executeForCjs. The catalog rebinds // 'config-ensure-section' to configNewProject in diff --git a/gsd-core/workflows/fast.md b/gsd-core/workflows/fast.md index 6232580ef..7ca9adb93 100644 --- a/gsd-core/workflows/fast.md +++ b/gsd-core/workflows/fast.md @@ -64,40 +64,18 @@ Use conventional commit format: `fix:`, `feat:`, `docs:`, `chore:`, `refactor:` If `.planning/STATE.md` exists and has a "Quick Tasks Completed" table, append a row -that matches the existing table's schema. If no table exists, skip silently. -If the table's schema is unrecognized, skip with a brief log rather than append a -malformed row. +that matches the existing table's schema via the schema-backed `gsd-tools +quick-tasks-append` helper (`markdown-table.cjs`'s `appendQuickTaskRow`; #2133, +ADR-2143 §3/§7). If no table exists, skip silently. If the table's schema is +unrecognized, the helper fails loud (non-zero exit) instead of silently guessing +a column count — this replaces the prior inline `awk NF-2` arithmetic that was +the root cause of #2133. ```bash +_GSD_SHIM_NAME="gsd-tools.cjs"; _GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"; GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"; if [ -f "$GSD_TOOLS" ]; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.codex/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.codex/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif command -v gsd-tools >/dev/null 2>&1; then GSD_TOOLS="$(command -v gsd-tools)"; gsd_run() { "$GSD_TOOLS" "$@"; }; elif [ -f "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and gsd-tools is not on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi; if [ -n "${CLAUDE_ENV_FILE:-}" ] && [ -n "${GSD_TOOLS:-}" ]; then printf "export PATH='%s':\"\$PATH\"\n" "${GSD_TOOLS%/*}" >> "$CLAUDE_ENV_FILE" 2>/dev/null || true; fi # Detect whether STATE.md has a Quick Tasks Completed table if grep -q "Quick Tasks Completed" .planning/STATE.md 2>/dev/null; then - # Read the table header line to determine the column schema. - # quick.md Step 7b writes two shapes: - # 5-column (non-validate): | # | Description | Date | Commit | Directory | - # 6-column (validate): | # | Description | Date | Commit | Status | Directory | - HEADER_LINE=$(grep -A2 "Quick Tasks Completed" .planning/STATE.md 2>/dev/null | grep "^|" | head -1) - # Count REAL columns: a markdown header has a leading and a trailing pipe, so - # awk's NF counts (real columns + 2). NF-2 yields the real column count. - # (NF-1 was the off-by-one root cause of #2133: it returned the pipe count, - # making the `-eq 5` test unsatisfiable for the very header quick.md writes.) - COL_COUNT=$(echo "$HEADER_LINE" | awk -F'|' '{print NF-2}') - - # Next row number + latest commit hash are schema-independent. - NEXT_NUM=$(awk '/Quick Tasks Completed/{found=1} found && /^\|/ && !/^[|][-: |]*[|]$/ && !/Description/{count++} END{print count+1}' .planning/STATE.md 2>/dev/null || echo "1") - COMMIT_HASH=$(git rev-parse --short HEAD 2>/dev/null || echo "—") - - # Select the appended row's template by the detected column count so its cell - # count always matches the header (prevents the malformed-row symptom of #27). - if [ "$COL_COUNT" -eq 5 ] && echo "$HEADER_LINE" | grep -qi "Description" && echo "$HEADER_LINE" | grep -qi "Commit" && echo "$HEADER_LINE" | grep -qi "Directory"; then - # 5-column schema from quick.md Step 7b (non-validate). - echo "| $NEXT_NUM | $TASK | $(date +%Y-%m-%d) | $COMMIT_HASH | — |" >> .planning/STATE.md - elif [ "$COL_COUNT" -eq 6 ] && echo "$HEADER_LINE" | grep -qi "Status" && echo "$HEADER_LINE" | grep -qi "Directory"; then - # 6-column schema from quick.md Step 7b (validate, with Status). - echo "| $NEXT_NUM | $TASK | $(date +%Y-%m-%d) | $COMMIT_HASH | — | — |" >> .planning/STATE.md - else - # Unrecognized table schema — skip to avoid appending a malformed row (#27). - echo "⚠ fast.md log_to_state: Quick Tasks Completed table has unrecognized schema (${COL_COUNT} columns); skipping STATE.md update." - fi + 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 b3ec7b140..dae003b29 100644 --- a/gsd-core/workflows/quick.md +++ b/gsd-core/workflows/quick.md @@ -971,6 +971,8 @@ Use `date` from init: | ${quick_id} | ${DESCRIPTION} | ${date} | ${commit_hash} | [${quick_id}-${slug}](./quick/${quick_id}-${slug}/) | ``` +For a schema-safe append outside this workflow (e.g. from fast.md), `gsd-tools quick-tasks-append --task ` performs the equivalent write via the shared, schema-backed `appendQuickTaskRow` helper (#2133, ADR-2143 §3/§7). + **7d. Update "Last activity" line:** Use `date` from init: diff --git a/scripts/gen-golden-install-parity-zcode.cjs b/scripts/gen-golden-install-parity-zcode.cjs index 7c500699f..832608b87 100644 --- a/scripts/gen-golden-install-parity-zcode.cjs +++ b/scripts/gen-golden-install-parity-zcode.cjs @@ -70,16 +70,12 @@ function cleanup(root) { const targets = process.argv.slice(2).length > 0 ? process.argv.slice(2) : Object.keys(RUNTIME_META); fs.mkdirSync(FIXTURE_DIR, { recursive: true }); -// Track the claude root so we can reuse it for the local layout fixture below. -let claudeRoot = null; - for (const runtime of targets) { if (!Object.prototype.hasOwnProperty.call(RUNTIME_META, runtime)) { process.stderr.write(`[gen] unknown runtime '${runtime}' (not in RUNTIME_META) — skipping\n`); continue; } const { configDir, root } = runMinimalInstall({ runtime, scope: 'global' }); - if (runtime === 'claude') claudeRoot = root; let actual; try { actual = buildParityManifest(configDir, root); diff --git a/src/markdown-table.cts b/src/markdown-table.cts new file mode 100644 index 000000000..bc26eabe4 --- /dev/null +++ b/src/markdown-table.cts @@ -0,0 +1,367 @@ +/** + * Markdown Table Model — canonical GFM table parsing + schema registry seam + * (ADR-2143, epic #2143). Pure functions, Node built-ins only, string-in/value-out, + * no I/O. Compiled by tsc to gsd-core/bin/lib/markdown-table.cjs. + * + * NOTE: the `Result` here is the ADR-2143 parse-result shape {ok,value|reason} — + * deliberately distinct from command-routing-hub's dispatch `Result` {ok,data|kind}; + * the two never mix (different modules). + */ + +import { collectSection, replaceSection } from './markdown-sectionizer.cjs'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export type Result = { ok: true; value: T } | { ok: false; reason: string }; + +/** A parsed GFM pipe table: header column names + rows addressed by column name. */ +export interface MarkdownTable { + columns: string[]; + rows: Record[]; +} + +/** One recognised header-shape variant of a canonical table kind. */ +export interface CanonicalTableVariant { + label: string; + columns: string[]; +} + +// ─── Schema registry ────────────────────────────────────────────────────────── + +/** + * Canonical column-header shapes for every GFM table GSD parses or generates. + * Each entry in `TABLE_SCHEMAS[id]` is one accepted variant (exact column names, + * in order); `matchTableSchema` resolves a parsed header back to `{id, label}`. + * + * This registry is the single source of truth — a parity test + * (tests/markdown-table.test.cjs) asserts every variant's header appears + * verbatim in the template/workflow file that generates it, so the registry + * and the templates can never silently drift (ADR-2143 §3 Generative-Fix- + * Divergence guard). + */ +export const TABLE_SCHEMAS: Record = { + RoadmapProgress: [ + { label: 'flat', columns: ['Phase', 'Plans Complete', 'Status', 'Completed'] }, + { + label: 'milestone-grouped', + columns: ['Phase', 'Milestone', 'Plans Complete', 'Status', 'Completed'], + }, + ], + RequirementsTraceability: [ + { label: 'default', columns: ['Requirement', 'Phase', 'Status'] }, + ], + QuickTasks: [ + { label: 'no-status', columns: ['#', 'Description', 'Date', 'Commit', 'Directory'] }, + { + label: 'with-status', + columns: ['#', 'Description', 'Date', 'Commit', 'Status', 'Directory'], + }, + ], + Security: [ + { label: 'trust-boundaries', columns: ['Boundary', 'Description', 'Data Crossing'] }, + { + label: 'threat-register', + columns: [ + 'Threat ID', + 'Category', + 'Component', + 'Severity', + 'Disposition', + 'Mitigation', + 'Status', + ], + }, + { + label: 'accepted-risks', + columns: ['Risk ID', 'Threat Ref', 'Rationale', 'Accepted By', 'Date'], + }, + { + label: 'audit-trail', + columns: ['Audit Date', 'Threats Total', 'Closed', 'Open', 'Run By'], + }, + ], +}; + +/** + * Resolve a parsed table's header columns to the canonical schema it matches + * (exact column names, same length, same order), else `null`. + */ +export function matchTableSchema(columns: string[]): { id: string; label: string } | null { + for (const [id, variants] of Object.entries(TABLE_SCHEMAS)) { + for (const variant of variants) { + if ( + variant.columns.length === columns.length + && variant.columns.every((col, idx) => col === columns[idx]) + ) { + return { id, label: variant.label }; + } + } + } + return null; +} + +// ─── Parsing ────────────────────────────────────────────────────────────────── + +/** + * Split one GFM table row line into trimmed cell strings. + * Strips one leading and one trailing `|`, splits on unescaped `|`, trims + * each cell, and unescapes `\\` back to `\` and `\|` back to `|` (the exact + * reverse of `escapeCell`'s `\`->`\\` then `|`->`\|` order below), so cell + * values round-trip exactly — including literal backslashes. + */ +function splitTableRow(line: string): string[] { + let stripped = line.trim(); + if (stripped.startsWith('|')) stripped = stripped.slice(1); + if (stripped.endsWith('|')) stripped = stripped.slice(0, -1); + return stripped.split(/(? cell.trim().replace(/\\([\\|])/g, '$1')); +} + +/** True when every delimiter cell matches GFM's `:?-{1,}:?` shape (spaces removed). */ +function isDelimiterRow(cells: string[]): boolean { + return cells.every((cell) => /^:?-{1,}:?$/.test(cell.replace(/\s+/g, ''))); +} + +/** + * Parse the FIRST GFM pipe table found in `sectionText`. + * + * Defensive by design: never throws — every malformed shape (no table, + * missing/misaligned delimiter row, ragged data row) returns a typed + * `{ok:false, reason}` instead of silently coercing or dropping data + * (ADR-2143 §3 — ragged rows are errors, not silent). + * + * Scope note: GSD planning tables (STATE.md/ROADMAP.md/requirements.md/ + * SECURITY.md) are always fully-piped (leading + trailing `|` on every row) + * and non-indented — this parser targets THAT shape, not arbitrary + * CommonMark (which also allows non-piped rows and up to 3 leading spaces). + */ +export function parseMarkdownTable(sectionText: string): Result { + if (typeof sectionText !== 'string' || sectionText.trim() === '') { + return { ok: false, reason: 'empty or non-string input' }; + } + + const lines = sectionText.split(/\r?\n/); + + let headerIdx = -1; + for (let i = 0; i < lines.length; i++) { + const trimmed = lines[i].trim(); + if (trimmed.startsWith('|') && trimmed.indexOf('|', 1) !== -1) { + headerIdx = i; + break; + } + } + if (headerIdx === -1) { + return { ok: false, reason: 'no table found' }; + } + + const columns = splitTableRow(lines[headerIdx]); + + const delimiterLine = lines[headerIdx + 1]; + if (delimiterLine === undefined || !delimiterLine.trim().startsWith('|')) { + return { ok: false, reason: 'missing delimiter row' }; + } + 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' }; + } + + const rows: Record[] = []; + let rowNum = 0; + for (let i = headerIdx + 2; i < lines.length; i++) { + const trimmed = lines[i].trim(); + if (!trimmed.startsWith('|')) break; + + rowNum += 1; + const cells = splitTableRow(lines[i]); + if (cells.length !== columns.length) { + return { + ok: false, + reason: `row ${rowNum} has ${cells.length} cells, expected ${columns.length}`, + }; + } + + const row: Record = {}; + columns.forEach((col, idx) => { + row[col] = cells[idx]; + }); + rows.push(row); + } + + return { ok: true, value: { columns, rows } }; +} + +/** + * Find the first table in `text` whose header matches `TABLE_SCHEMAS[schemaId]`, + * scanning the WHOLE document (not just a named section). Returns `null` when + * no table with that schema is found. + * + * Fixes the regression where callers first located a named heading (e.g. + * `## Progress`) via `collectSection` and only then parsed a table inside it — + * a schema-matching table that lives under a differently-named heading (or no + * heading at all), or that isn't the first table in the document, was + * invisible to that approach. Scanning the whole document by schema restores + * the old "find the progress table anywhere" behaviour while staying + * seam-based (ADR-2143). + */ +export function findTableBySchema(text: string, schemaId: string): MarkdownTable | null { + if (typeof text !== 'string') return null; + const lines = text.split(/\r?\n/); + for (let i = 0; i < lines.length; i++) { + const t = lines[i].trim(); + if (!t.startsWith('|') || t.indexOf('|', 1) === -1) continue; + const cols = splitTableRow(lines[i]); + const m = matchTableSchema(cols); + if (m && m.id === schemaId) { + const parsed = parseMarkdownTable(lines.slice(i).join('\n')); + if (parsed.ok) return parsed.value; + } + } + return null; +} + +/** + * Find the first GFM table in `text` whose header contains ALL of `required` + * column names (order-independent; extra/injected columns allowed). Returns + * the parsed `MarkdownTable`, or `null` when no table's header is a superset + * of `required`. + * + * Column-NAME/order/count-invariant counterpart to `findTableBySchema` (ADR-2143 + * §3 "addressed by NAME, never ordinal"): where `findTableBySchema` requires an + * EXACT canonical column set+order registered in `TABLE_SCHEMAS`, this scans + * for any header that names the required columns, in any order, tolerating + * extra/unrelated injected columns. Cells remain addressable by column NAME + * via the returned `MarkdownTable`. + */ +export function findTableWithColumns(text: string, required: string[]): MarkdownTable | null { + if (typeof text !== 'string') return null; + const lines = text.split(/\r?\n/); + for (let i = 0; i < lines.length; i++) { + const t = lines[i].trim(); + if (!t.startsWith('|') || t.indexOf('|', 1) === -1) continue; + const cols = splitTableRow(lines[i]); + if (required.every((rq) => cols.includes(rq))) { + const parsed = parseMarkdownTable(lines.slice(i).join('\n')); + if (parsed.ok) return parsed.value; + } + } + return null; +} + +// ─── Quick Tasks row append (#2133) ──────────────────────────────────────────── + +/** + * Escape one dynamic cell value for insertion into a GFM pipe-table row. + * + * Escapes `\` -> `\\` FIRST, then `|` -> `\|` (in that order, so a literal + * backslash already in the value is never mistaken for part of an escape + * sequence introduced by this function — CodeQL js/incomplete-sanitization). + * `splitTableRow` reverses both in the opposite order (`\\` -> `\` then + * `\|` -> `|`, see line ~114 above), so escaping/unescaping round-trips + * exactly, including literal backslashes. Newlines are collapsed to a + * single space — a raw `|` or embedded newline in a cell value (e.g. a task + * `description`) would otherwise corrupt the table (extra column / a fake + * extra row) and get rejected by the now-fail-loud `parseMarkdownTable` as a + * ragged row. + */ +function escapeCell(value: string): string { + return String(value) + .replace(/\r?\n+/g, ' ') + .replace(/\\/g, '\\\\') // escape the escape char FIRST (CodeQL js/incomplete-sanitization) + .replace(/\|/g, '\\|') + .trim(); +} + +/** Fields needed to render one "Quick Tasks Completed" row (schema-driven). */ +export interface QuickTaskFields { + description: string; + date: string; + commit: string; + status?: string; + directory?: string; +} + +/** + * Append one row to STATE.md's "Quick Tasks Completed" table. + * + * Pure, schema-driven replacement for fast.md's inline `awk NF-2` column-count + * guess (#2133, ADR-2143 §3 schema registry / §7 fail-loud unrecognized-schema + * guard). Never touches disk, git, or the clock — callers (the `gsd-tools + * quick-tasks-append` subcommand) compute `date`/`commit` and pass them in. + * + * Fails loud (`{ok:false, reason}`, never a silent skip) when: + * - no "Quick Tasks Completed" heading exists in `stateContent` + * - the section's body doesn't parse as a GFM table (parseMarkdownTable failure) + * - the table's header doesn't match a known `TABLE_SCHEMAS.QuickTasks` variant + * (the old awk arithmetic silently skipped here instead — that silent-skip + * branch is the bug this replaces). + * + * The new row is inserted immediately after the LAST existing table row line + * (or immediately after the header/delimiter when the table has zero data + * rows), preserving any surrounding blank lines/trailing content in the section. + */ +export function appendQuickTaskRow( + stateContent: string, + fields: QuickTaskFields, +): Result<{ content: string; row: string; variant: string }> { + const section = collectSection(stateContent, (h) => /^quick tasks completed$/i.test(h.text.trim())); + if (!section) { + return { ok: false, reason: 'no Quick Tasks Completed section' }; + } + + 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: false, + reason: `unrecognized Quick Tasks schema (columns: ${parsed.value.columns.join(' | ')})`, + }; + } + + const variant = TABLE_SCHEMAS.QuickTasks.find((v) => v.label === match.label); + const columns = variant ? variant.columns : parsed.value.columns; + + const rowNumber = parsed.value.rows.length + 1; + const cellFor = (col: string): string => { + switch (col) { + case '#': return escapeCell(String(rowNumber)); + case 'Description': return escapeCell(fields.description); + case 'Date': return escapeCell(fields.date); + case 'Commit': return escapeCell(fields.commit); + case 'Status': return escapeCell(fields.status ?? '—'); + case 'Directory': return escapeCell(fields.directory ?? '—'); + default: return '—'; + } + }; + const row = `| ${columns.map(cellFor).join(' | ')} |`; + + // Detect the section's EOL BEFORE splitting on /\r?\n/ (which discards it) so + // the rejoin below preserves CRLF instead of downgrading a CRLF section to + // mixed EOL (the inserted `row` itself never contains a newline). + const eol = /\r\n/.test(section.body) ? '\r\n' : '\n'; + const lines = section.body.split(/\r?\n/); + let lastTableLineIdx = -1; + for (let i = 0; i < lines.length; i++) { + if (lines[i].trim().startsWith('|')) lastTableLineIdx = i; + } + // lastTableLineIdx is always >= 0 here — parseMarkdownTable already + // confirmed a header + delimiter row exist in this same `section.body`. + const newLines = [ + ...lines.slice(0, lastTableLineIdx + 1), + row, + ...lines.slice(lastTableLineIdx + 1), + ]; + const newBody = newLines.join(eol); + + const content = replaceSection(stateContent, section, newBody); + + return { ok: true, value: { content, row, variant: match.label } }; +} + +// Consumers: require('../gsd-core/bin/lib/markdown-table.cjs') +// Named CJS exports are the canonical surface (ADR-457 .cts → .cjs build-at-publish). diff --git a/src/phase-lifecycle.cts b/src/phase-lifecycle.cts index 9d2dd2507..315d109c1 100644 --- a/src/phase-lifecycle.cts +++ b/src/phase-lifecycle.cts @@ -20,6 +20,8 @@ * - Issue #4 (open-gsd/gsd-core) */ +import { findTableWithColumns } from './markdown-table.cjs'; + /** Result of deriveProgressFromRoadmap. */ export interface RoadmapProgress { completedPhases: number | null; @@ -30,6 +32,30 @@ export interface RoadmapProgress { /** * Derive completed_phases, total_phases, and total_plans from ROADMAP content. * Root cause fix for issue #4 — see gen-phase-lifecycle.mjs for full documentation. + * + * ADR-2143 §3 ("addressed by NAME, never ordinal"): the Progress table is + * located via the markdown-table seam's `findTableWithColumns`, which is + * column-NAME/order/count-invariant — it matches the first table whose header + * is a SUPERSET of the canonical `Phase` / `Plans Complete` / `Status` / + * `Completed` names, in any order, tolerating extra/injected unrelated + * columns (#2137's fast-check property test shuffles headers and injects + * columns and asserts the derived counts never change). This supersedes the + * earlier `findTableBySchema` exact-schema lookup, which required an exact + * canonical column SET+ORDER and returned all-null on any reordering or + * injection. + * + * Scoped to the `## Progress` section when the document has one (#2012 decoy + * avoidance — a differently-headed table sharing the same column names must + * not be picked up instead); a headingless milestone slice (#1445) falls back + * to scanning the whole input, preserving the "Progress table not under a + * `## Progress` heading, or not the first table in the document, still + * resolves" behaviour. + * + * Cells are read by column NAME (`r['Status']`, `r['Plans Complete']`, + * `r['Phase']`), fixing #2137 (the old position-based regex assumed "Status" + * was always the 3rd cell and "Plans Complete" the 2nd, which broke for the + * 5-column milestone-grouped variant that inserts a `Milestone` column ahead + * of them). */ export function deriveProgressFromRoadmap(roadmapContent: string): RoadmapProgress { let completedPhases: number | null = null; @@ -37,97 +63,40 @@ export function deriveProgressFromRoadmap(roadmapContent: string): RoadmapProgre let totalPlans: number | null = null; try { - // Parse the Progress table by HEADER, not by a fixed column count. The - // writer (cmdPhaseComplete) already branches on `cells.length === 5`, so it - // understands both the 4-column greenfield table - // | Phase | Plans Complete | Status | Completed | - // and the 5-column milestone-grouped table the same template ships - // | Phase | Milestone | Plans Complete | Status | Completed | - // The reader used two 4-column-only regexes, so every project past its v1.0 - // milestone (5-column shape) parsed to all-null and phase.complete silently - // skipped the STATE progress write. Reading column indices by NAME keeps the - // reader and writer in agreement across both shapes and any future column - // (#2137). The table is located by its header row rather than a `## Progress` - // heading because some callers pass a milestone slice with no heading (#1445). - // - // When a `## Progress` heading IS present, scope the search to that section - // (mirroring the writer's #2012 scoping in cmdPhaseComplete) so the reader - // cannot bind to an earlier Phase/Status/Completed-shaped table elsewhere in - // the roadmap. Callers that pass a headingless milestone slice fall back to - // scanning the whole input. - // Line-anchored h2 match — `indexOf('## Progress')` would also match inside - // an h3 `### Progress` (the `## Progress` substring starts at the 2nd hash), - // letting a decoy subheading hijack the slice. - // Case-insensitive to match the case-insensitive header-cell comparison below. - // - // allow-adhoc-markdown: line-based Progress-table scan (header lookup + - // positional cell indexing); table parsing is out of the markdown-sectionizer - // seam's scope. Superseded by the ADR-2143 parseMarkdownTable/TABLE_SCHEMAS - // seam; pending #2143. + // ADR-2143 §3: read the Progress table by column NAME (order/injection-invariant), + // via the markdown-table seam. Scope to the `## Progress` section when present + // (#2012 decoy avoidance); a headingless milestone slice (#1445) falls back to the + // whole input. Requires the canonical Phase/Plans Complete/Status/Completed columns + // in any order (extra columns ignored) — supersedes findTableBySchema's exact-schema lookup. const progressMatch = roadmapContent.match(/^##[ \t]+Progress\b/im); let scoped = roadmapContent; if (progressMatch && progressMatch.index !== undefined) { - // Slice from `## Progress` to the next h1/h2 heading (or end); h3+ headings - // inside the section do not terminate it. The heading sits at index 0 of - // this slice with no leading newline, so the `\n#` search cannot match it. const afterHeading = roadmapContent.slice(progressMatch.index); const nextHeading = afterHeading.search(/\n#{1,2}[ \t]/); scoped = nextHeading >= 0 ? afterHeading.slice(0, nextHeading) : afterHeading; } - const lines = scoped.split('\n'); + const table = findTableWithColumns(scoped, ['Phase', 'Plans Complete', 'Status', 'Completed']); - // Split a markdown table row into trimmed cells — the same - // `split('|').slice(1, -1)` boundary the writer uses (phase.cts). - const rowCells = (line: string): string[] => - line.split('|').slice(1, -1).map((c) => c.trim()); - const isTableRow = (line: string): boolean => line.trim().startsWith('|'); - const isSeparatorRow = (cells: string[]): boolean => - cells.length > 0 && cells.every((c) => /^:?-+:?$/.test(c)); + if (table) { + const allRows = table.rows; - let headerLine = -1; - let phaseIdx = -1; - let statusIdx = -1; - let plansIdx = -1; - for (let i = 0; i < lines.length; i++) { - if (!isTableRow(lines[i])) continue; - const lc = rowCells(lines[i]).map((c) => c.toLowerCase()); - const p = lc.indexOf('phase'); - const s = lc.indexOf('status'); - const c = lc.indexOf('completed'); - if (p >= 0 && s >= 0 && c >= 0) { - headerLine = i; - phaseIdx = p; - statusIdx = s; - plansIdx = lc.findIndex((h) => h.includes('plans')); - break; + const completed = allRows.filter((r) => /^complete$/i.test((r['Status'] ?? '').trim())).length; + completedPhases = completed > 0 ? completed : null; + + // Data rows only (exclude 999.x backlog phases). Mirrors init.cts /^999(?:\.|$)/ filter. + const dataRows = allRows.filter((r) => { + const phase = (r['Phase'] ?? '').trim(); + return /^\d/.test(phase) && !/^999\b/.test(phase); + }); + totalPhases = dataRows.length > 0 ? dataRows.length : null; + + let totalPlansSum = 0; + for (const r of allRows) { + const cell = (r['Plans Complete'] ?? '').trim(); + const m = /(\d+)\s*\/\s*(\d+)/.exec(cell); + if (m) totalPlansSum += parseInt(m[2], 10); } - } - - if (headerLine >= 0) { - let phaseCount = 0; - let completedCount = 0; - let plansSum = 0; - // Walk the contiguous rows after the header; a markdown table ends at the - // first non-`|` line. - for (let i = headerLine + 1; i < lines.length; i++) { - if (!isTableRow(lines[i])) break; - const cells = rowCells(lines[i]); - if (isSeparatorRow(cells)) continue; - const phaseToken = (cells[phaseIdx] ?? '').trim(); - if (!/^\d/.test(phaseToken)) continue; // not a data row - if (/^999\b/.test(phaseToken)) continue; // 999.x backlog sentinel (#1445) - phaseCount++; - if ((cells[statusIdx] ?? '').toLowerCase() === 'complete') completedCount++; - if (plansIdx >= 0) { - const mn = (cells[plansIdx] ?? '').match(/^(\d+)\/(\d+)$/); - if (mn) plansSum += parseInt(mn[2], 10); - } - } - // Preserve the prior contract: a count of 0 is reported as null (absent), - // so the consumer leaves the existing STATE value untouched. - completedPhases = completedCount > 0 ? completedCount : null; - totalPhases = phaseCount > 0 ? phaseCount : null; - totalPlans = plansSum > 0 ? plansSum : null; + totalPlans = totalPlansSum > 0 ? totalPlansSum : null; } } catch { /* intentionally empty — fall through to existing values */ } diff --git a/tests/fix-2133-fast-md-log-to-state-schema.test.cjs b/tests/fix-2133-fast-md-log-to-state-schema.test.cjs deleted file mode 100644 index 8445bd42b..000000000 --- a/tests/fix-2133-fast-md-log-to-state-schema.test.cjs +++ /dev/null @@ -1,178 +0,0 @@ -/** - * #2133 — fast.md log_to_state schema gate is unreachable. - * - * PR #85 added a column-count guard to fast.md's log_to_state step that used - * `awk -F'|' '{print NF-1}'`. A markdown table header has both a leading and a - * trailing pipe, so NF counts (real columns + 2) and NF-1 is always one too - * high. The `-eq 5` test could therefore never hold for the 5-column header - * quick.md writes, so /gsd-fast has never appended a Quick Task row since #85. - * - * This test does what the removed prose-regex test (bug-3805-*) did not: it - * EXTRACTS the actual bash block deployed in fast.md and EXECUTES it against - * real STATE.md fixtures, asserting on the filesystem result (row appended, - * cell count aligned with header). It fails on the NF-1 bug and passes once - * the count uses NF-2 and both 5/6-column schemas are accepted. - */ - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const { execFileSync } = require('node:child_process'); -const { createTempProject, cleanup } = require('./helpers.cjs'); - -const FAST_MD = path.join(__dirname, '..', 'gsd-core', 'workflows', 'fast.md'); - -const HEADER_5COL = '| # | Description | Date | Commit | Directory |'; -const SEP_5COL = '|---|-------------|------|--------|-----------|'; -const HEADER_6COL = '| # | Description | Date | Commit | Status | Directory |'; -const SEP_6COL = '|---|-------------|------|--------|--------|-----------|'; - -/** - * Extract the ```bash block embedded in fast.md's . - * This is the exact program the workflow runs — executing it is a behavioral - * test of the deployed product, not a source-grep over its prose. - */ -function extractLogToStateBash() { - const content = fs.readFileSync(FAST_MD, 'utf8'); - const stepTag = ''; - const stepStart = content.indexOf(stepTag); - assert.notEqual(stepStart, -1, 'fast.md must contain a log_to_state step'); - const stepEnd = content.indexOf('', stepStart); - assert.notEqual(stepEnd, -1, 'log_to_state step must close'); - const step = content.slice(stepStart, stepEnd); - const fenceStart = step.indexOf('```bash'); - assert.notEqual(fenceStart, -1, 'log_to_state step must contain a bash block'); - const codeStart = step.indexOf('\n', fenceStart) + 1; - const fenceEnd = step.indexOf('\n```', codeStart); - assert.notEqual(fenceEnd, -1, 'log_to_state bash block must close'); - return step.slice(codeStart, fenceEnd); -} - -function makeStateMd(headerLine, separatorLine, existingRows) { - return [ - '# Project State', - '', - '### Blockers/Concerns', - '', - 'None.', - '', - '### Quick Tasks Completed', - '', - headerLine, - separatorLine, - ...existingRows, - '', - ].join('\n'); -} - -/** Count `|` chars on a line — the invariant cell-count signal. */ -function pipeCount(line) { - return (line.match(/\|/g) || []).length; -} - -/** Data rows = lines starting with `|` that are not the separator or header. */ -function dataRows(content) { - return content.split(/\r?\n/).filter((l) => { - if (!l.startsWith('|')) return false; - if (/^[|][-: |]*[|]$/.test(l)) return false; // separator - if (/Description/.test(l)) return false; // header - return true; - }); -} - -describe('#2133 fast.md log_to_state schema gate', () => { - const bashBlock = extractLogToStateBash(); - const TASK_DESC = 'sample inline fix'; - - /** - * Run the extracted log_to_state bash against a temp project's STATE.md and - * return the post-run file content + captured stdout. - */ - function runAgainst(headerLine, separatorLine, existingRows) { - const tmpDir = createTempProject('fix-2133-'); - const statePath = path.join(tmpDir, '.planning', 'STATE.md'); - fs.writeFileSync(statePath, makeStateMd(headerLine, separatorLine, existingRows)); - // Cleanup is bound by each caller via t.after(tmpDir). - const stdout = execFileSync('bash', ['-c', bashBlock], { - cwd: tmpDir, - env: { ...process.env, TASK: TASK_DESC }, - encoding: 'utf8', - }); - const after = fs.readFileSync(statePath, 'utf8'); - return { tmpDir, stdout, after }; - } - - test('appends a 5-cell row to the 5-column (non-validate) schema (#27 stays fixed)', (t) => { - const before = makeStateMd(HEADER_5COL, SEP_5COL, ['| 1 | earlier task | 2026-07-01 | deadbee | — |']); - const { tmpDir, after } = runAgainst(HEADER_5COL, SEP_5COL, ['| 1 | earlier task | 2026-07-01 | deadbee | — |']); - t.after(() => cleanup(tmpDir)); - - const rowsBefore = dataRows(before).length; - const rowsAfter = dataRows(after).length; - assert.equal(rowsAfter - rowsBefore, 1, 'exactly one row must be appended'); - - const appended = dataRows(after).slice(-1)[0]; - assert.equal(pipeCount(appended), pipeCount(HEADER_5COL), - 'appended row pipe-count must match the 5-column header (no malformed row)'); - assert.ok(appended.includes(TASK_DESC), 'appended row must carry the task description'); - }); - - test('appends a 6-cell row to the 6-column (validate, with Status) schema', (t) => { - const before = makeStateMd(HEADER_6COL, SEP_6COL, []); - const { tmpDir, after } = runAgainst(HEADER_6COL, SEP_6COL, []); - t.after(() => cleanup(tmpDir)); - - const rowsBefore = dataRows(before).length; - const rowsAfter = dataRows(after).length; - assert.equal(rowsAfter - rowsBefore, 1, 'exactly one row must be appended to the 6-column table'); - - const appended = dataRows(after).slice(-1)[0]; - assert.equal(pipeCount(appended), pipeCount(HEADER_6COL), - 'appended row pipe-count must match the 6-column header (cell count aligned with header)'); - assert.ok(appended.includes(TASK_DESC), 'appended row must carry the task description'); - }); - - test('column count awk uses NF-2 (real columns — the off-by-one root cause)', () => { - // The deployed bash must compute the real column count. A 5-column header - // split on '|' yields NF=7; the correct real-column formula is NF-2=5. - // (NF-1 was the off-by-one bug: it returned 6, making `-eq 5` unsatisfiable.) - // Match the executable COL_COUNT assignment specifically — the explanatory - // comment may still reference "NF-1" to document the history. - assert.match( - bashBlock, - /COL_COUNT=\$\(.+awk -F'\|' '\{print NF-2\}'\)/, - 'COL_COUNT must be derived via awk NF-2 (NF-1 was the off-by-one bug)' - ); - }); - - test('unrecognized schema still skips with a warning (safety guard intact)', (t) => { - // A 3-column table quick.md never writes must NOT receive a row. - const weirdHeader = '| Alpha | Beta | Gamma |'; - const weirdSep = '|-------|------|-------|'; - const before = makeStateMd(weirdHeader, weirdSep, []); - const { tmpDir, stdout, after } = runAgainst(weirdHeader, weirdSep, []); - t.after(() => cleanup(tmpDir)); - - assert.equal(dataRows(after).length, dataRows(before).length, - 'no row may be appended for an unrecognized schema'); - assert.ok(/unrecognized schema/i.test(stdout), - 'the unrecognized-schema warning must be emitted'); - }); - - test('no Quick Tasks table → silent no-op', (t) => { - const tmpDir = createTempProject('fix-2133-noop-'); - t.after(() => cleanup(tmpDir)); - const statePath = path.join(tmpDir, '.planning', 'STATE.md'); - const before = '# Project State\n\n### Blockers/Concerns\n\nNone.\n'; - fs.writeFileSync(statePath, before); - const stdout = execFileSync('bash', ['-c', bashBlock], { - cwd: tmpDir, - env: { ...process.env, TASK: TASK_DESC }, - encoding: 'utf8', - }); - const after = fs.readFileSync(statePath, 'utf8'); - assert.equal(after, before, 'STATE.md must be untouched when no Quick Tasks table exists'); - assert.equal(stdout, '', 'no output when there is no table to update'); - }); -}); diff --git a/tests/fixtures/golden-install-parity/antigravity.json b/tests/fixtures/golden-install-parity/antigravity.json index 5f94d1224..b4af36875 100644 --- a/tests/fixtures/golden-install-parity/antigravity.json +++ b/tests/fixtures/golden-install-parity/antigravity.json @@ -38,7 +38,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "ea841e2865248e74", - "gsd-core/bin/gsd-tools.cjs": "600e9ed0cb6ed7bb", + "gsd-core/bin/gsd-tools.cjs": "efc88e7691c6c3e6", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4", @@ -242,7 +242,7 @@ "gsd-core/workflows/execute-plan.md": "907af77eafc3d97b", "gsd-core/workflows/explore.md": "934c00f9f216dbdb", "gsd-core/workflows/extract-learnings.md": "167ea7f0e23bf496", - "gsd-core/workflows/fast.md": "0242082ca646819a", + "gsd-core/workflows/fast.md": "568e6c3ec00b6e00", "gsd-core/workflows/forensics.md": "b64f0309b8c3fde1", "gsd-core/workflows/graduation.md": "a9d8f15ba81a993f", "gsd-core/workflows/health.md": "1ac4d567ee95acfb", @@ -280,7 +280,7 @@ "gsd-core/workflows/pr-branch.md": "dc5598ae8accdecd", "gsd-core/workflows/profile-user.md": "355af92ac285567f", "gsd-core/workflows/progress.md": "79a11ce798082054", - "gsd-core/workflows/quick.md": "7108075f69e88e7c", + "gsd-core/workflows/quick.md": "424c0eb61769e747", "gsd-core/workflows/reapply-patches.md": "4dcd6117d0a507ca", "gsd-core/workflows/remove-phase.md": "23b9eb0858a2535e", "gsd-core/workflows/remove-workspace.md": "d0bd7e0601138798", diff --git a/tests/fixtures/golden-install-parity/augment.json b/tests/fixtures/golden-install-parity/augment.json index 32f716961..7a3f2f0b4 100644 --- a/tests/fixtures/golden-install-parity/augment.json +++ b/tests/fixtures/golden-install-parity/augment.json @@ -109,7 +109,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62", - "gsd-core/bin/gsd-tools.cjs": "37fb6081fcb18038", + "gsd-core/bin/gsd-tools.cjs": "06c046925b7156b7", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4", @@ -313,7 +313,7 @@ "gsd-core/workflows/execute-plan.md": "2c412310dce31a0b", "gsd-core/workflows/explore.md": "6c04f2e658d93261", "gsd-core/workflows/extract-learnings.md": "b6f01ca3d8f58de4", - "gsd-core/workflows/fast.md": "8878ec034b401f36", + "gsd-core/workflows/fast.md": "11f5cd10ae5cc7d3", "gsd-core/workflows/forensics.md": "0d500a3f5ab26913", "gsd-core/workflows/graduation.md": "47f1594c88c08501", "gsd-core/workflows/health.md": "107e3c72e76d9535", @@ -351,7 +351,7 @@ "gsd-core/workflows/pr-branch.md": "c87db7ac8c28be1b", "gsd-core/workflows/profile-user.md": "14263db831230142", "gsd-core/workflows/progress.md": "893aa3c36983f74b", - "gsd-core/workflows/quick.md": "069fe37d083a94a3", + "gsd-core/workflows/quick.md": "25f0cc40bc22ed3e", "gsd-core/workflows/reapply-patches.md": "39050f72601aec89", "gsd-core/workflows/remove-phase.md": "df9a45f0b1880999", "gsd-core/workflows/remove-workspace.md": "a7ca66db6b7c132c", diff --git a/tests/fixtures/golden-install-parity/claude-local.json b/tests/fixtures/golden-install-parity/claude-local.json index d6bd623cd..ce773ce63 100644 --- a/tests/fixtures/golden-install-parity/claude-local.json +++ b/tests/fixtures/golden-install-parity/claude-local.json @@ -108,7 +108,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62", - "gsd-core/bin/gsd-tools.cjs": "37fb6081fcb18038", + "gsd-core/bin/gsd-tools.cjs": "06c046925b7156b7", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4", @@ -312,7 +312,7 @@ "gsd-core/workflows/execute-plan.md": "f17623fd47e795dd", "gsd-core/workflows/explore.md": "95e463d4bdd6dadd", "gsd-core/workflows/extract-learnings.md": "fd75072c339b58bd", - "gsd-core/workflows/fast.md": "0242082ca646819a", + "gsd-core/workflows/fast.md": "7f7687b920d79b29", "gsd-core/workflows/forensics.md": "857d7b064f4cca21", "gsd-core/workflows/graduation.md": "ecf8da93e094fd2e", "gsd-core/workflows/health.md": "551e63aa6f3df711", @@ -350,7 +350,7 @@ "gsd-core/workflows/pr-branch.md": "513f6cff722eff2d", "gsd-core/workflows/profile-user.md": "3b34dcb337d50f4b", "gsd-core/workflows/progress.md": "2be3a57916eccf87", - "gsd-core/workflows/quick.md": "20f9dbfcd20b4b4b", + "gsd-core/workflows/quick.md": "f1b474b46327034f", "gsd-core/workflows/reapply-patches.md": "44a96b52b975e9bb", "gsd-core/workflows/remove-phase.md": "8effc8742d58a11a", "gsd-core/workflows/remove-workspace.md": "10882656198d9075", diff --git a/tests/fixtures/golden-install-parity/claude.json b/tests/fixtures/golden-install-parity/claude.json index 34c6df3b6..afadae2e0 100644 --- a/tests/fixtures/golden-install-parity/claude.json +++ b/tests/fixtures/golden-install-parity/claude.json @@ -37,7 +37,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62", - "gsd-core/bin/gsd-tools.cjs": "37fb6081fcb18038", + "gsd-core/bin/gsd-tools.cjs": "06c046925b7156b7", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4", @@ -241,7 +241,7 @@ "gsd-core/workflows/execute-plan.md": "cce1a33fe9a0a32d", "gsd-core/workflows/explore.md": "b9eea1bac358c9ce", "gsd-core/workflows/extract-learnings.md": "d8177b0c13b7e5ee", - "gsd-core/workflows/fast.md": "0242082ca646819a", + "gsd-core/workflows/fast.md": "41a6568b873aef99", "gsd-core/workflows/forensics.md": "c01da0178fb97b21", "gsd-core/workflows/graduation.md": "47f1594c88c08501", "gsd-core/workflows/health.md": "f934b1f1e9f3ae72", @@ -279,7 +279,7 @@ "gsd-core/workflows/pr-branch.md": "ab157cd8e49621dd", "gsd-core/workflows/profile-user.md": "ff3820a27731ceb8", "gsd-core/workflows/progress.md": "bd1ecf9207331bda", - "gsd-core/workflows/quick.md": "3363bdfefd403686", + "gsd-core/workflows/quick.md": "b4237633eaf5a246", "gsd-core/workflows/reapply-patches.md": "ba9406b60f2c4041", "gsd-core/workflows/remove-phase.md": "ada8a0546c686483", "gsd-core/workflows/remove-workspace.md": "f3ab3a88a7e9e1ed", diff --git a/tests/fixtures/golden-install-parity/cline.json b/tests/fixtures/golden-install-parity/cline.json index 99f4728ec..9f44fd2ec 100644 --- a/tests/fixtures/golden-install-parity/cline.json +++ b/tests/fixtures/golden-install-parity/cline.json @@ -41,7 +41,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "476aa24e8c4f03cf", - "gsd-core/bin/gsd-tools.cjs": "a84914f7cab74332", + "gsd-core/bin/gsd-tools.cjs": "49dfaa890fdd5627", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4", @@ -245,7 +245,7 @@ "gsd-core/workflows/execute-plan.md": "0c5551f99ee0a017", "gsd-core/workflows/explore.md": "e83af8ceae314cf9", "gsd-core/workflows/extract-learnings.md": "6f39375b7dc775f9", - "gsd-core/workflows/fast.md": "8878ec034b401f36", + "gsd-core/workflows/fast.md": "e4f74a454b6ca5e8", "gsd-core/workflows/forensics.md": "9fc65a8eed5d8bfc", "gsd-core/workflows/graduation.md": "16fedecda36769eb", "gsd-core/workflows/health.md": "788fad84fa42eb9f", @@ -283,7 +283,7 @@ "gsd-core/workflows/pr-branch.md": "9923878a4f6a2d91", "gsd-core/workflows/profile-user.md": "26f74db0a7fcd268", "gsd-core/workflows/progress.md": "9f326d63afb4b76b", - "gsd-core/workflows/quick.md": "2afc046e94daad0a", + "gsd-core/workflows/quick.md": "797b1c8d62c3d33d", "gsd-core/workflows/reapply-patches.md": "eb4272145a117904", "gsd-core/workflows/remove-phase.md": "e336350f8113a328", "gsd-core/workflows/remove-workspace.md": "e685dfbd736dfd90", diff --git a/tests/fixtures/golden-install-parity/codebuddy.json b/tests/fixtures/golden-install-parity/codebuddy.json index b53a71fff..cce115927 100644 --- a/tests/fixtures/golden-install-parity/codebuddy.json +++ b/tests/fixtures/golden-install-parity/codebuddy.json @@ -109,7 +109,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62", - "gsd-core/bin/gsd-tools.cjs": "37fb6081fcb18038", + "gsd-core/bin/gsd-tools.cjs": "06c046925b7156b7", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4", @@ -313,7 +313,7 @@ "gsd-core/workflows/execute-plan.md": "933d10547116794a", "gsd-core/workflows/explore.md": "6c04f2e658d93261", "gsd-core/workflows/extract-learnings.md": "b6f01ca3d8f58de4", - "gsd-core/workflows/fast.md": "8878ec034b401f36", + "gsd-core/workflows/fast.md": "11f5cd10ae5cc7d3", "gsd-core/workflows/forensics.md": "0d500a3f5ab26913", "gsd-core/workflows/graduation.md": "47f1594c88c08501", "gsd-core/workflows/health.md": "107e3c72e76d9535", @@ -351,7 +351,7 @@ "gsd-core/workflows/pr-branch.md": "c87db7ac8c28be1b", "gsd-core/workflows/profile-user.md": "4fa910d15dea5695", "gsd-core/workflows/progress.md": "893aa3c36983f74b", - "gsd-core/workflows/quick.md": "cc530299ba461539", + "gsd-core/workflows/quick.md": "0bd83bd88c5e63d1", "gsd-core/workflows/reapply-patches.md": "39050f72601aec89", "gsd-core/workflows/remove-phase.md": "df9a45f0b1880999", "gsd-core/workflows/remove-workspace.md": "a7ca66db6b7c132c", diff --git a/tests/fixtures/golden-install-parity/codex.json b/tests/fixtures/golden-install-parity/codex.json index 1a76e4b16..35bd639ab 100644 --- a/tests/fixtures/golden-install-parity/codex.json +++ b/tests/fixtures/golden-install-parity/codex.json @@ -144,7 +144,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62", - "gsd-core/bin/gsd-tools.cjs": "37fb6081fcb18038", + "gsd-core/bin/gsd-tools.cjs": "06c046925b7156b7", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4", @@ -348,7 +348,7 @@ "gsd-core/workflows/execute-plan.md": "ac1f1d9ada00a91e", "gsd-core/workflows/explore.md": "2ef10d17c8864a04", "gsd-core/workflows/extract-learnings.md": "f716aa03fcb5f8da", - "gsd-core/workflows/fast.md": "bbf1f8e219ec9031", + "gsd-core/workflows/fast.md": "e4ed60f96a7b3ac8", "gsd-core/workflows/forensics.md": "2e8a01b5b44e65f3", "gsd-core/workflows/graduation.md": "47f1594c88c08501", "gsd-core/workflows/health.md": "5b835fc606fd3e9b", @@ -386,7 +386,7 @@ "gsd-core/workflows/pr-branch.md": "d13e1cc81de40896", "gsd-core/workflows/profile-user.md": "05828c8cc61ef384", "gsd-core/workflows/progress.md": "7bedc431bb55edb8", - "gsd-core/workflows/quick.md": "62b9e138dc70b479", + "gsd-core/workflows/quick.md": "bf70b7beb314aa50", "gsd-core/workflows/reapply-patches.md": "26297b84736e66a4", "gsd-core/workflows/remove-phase.md": "9ee0fddd11a0d9d4", "gsd-core/workflows/remove-workspace.md": "19d7465aaa50cb62", diff --git a/tests/fixtures/golden-install-parity/copilot.json b/tests/fixtures/golden-install-parity/copilot.json index ba60f8a6a..37c73dd09 100644 --- a/tests/fixtures/golden-install-parity/copilot.json +++ b/tests/fixtures/golden-install-parity/copilot.json @@ -39,7 +39,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "ea841e2865248e74", - "gsd-core/bin/gsd-tools.cjs": "600e9ed0cb6ed7bb", + "gsd-core/bin/gsd-tools.cjs": "efc88e7691c6c3e6", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4", @@ -243,7 +243,7 @@ "gsd-core/workflows/execute-plan.md": "c5e9dae726db15cc", "gsd-core/workflows/explore.md": "5fd91a8510e1114b", "gsd-core/workflows/extract-learnings.md": "f34d0b1927545b18", - "gsd-core/workflows/fast.md": "0242082ca646819a", + "gsd-core/workflows/fast.md": "66821090b6b8ed3b", "gsd-core/workflows/forensics.md": "459644dce26ee2ef", "gsd-core/workflows/graduation.md": "f013efc29096faf0", "gsd-core/workflows/health.md": "f343fd32a0e398e4", @@ -281,7 +281,7 @@ "gsd-core/workflows/pr-branch.md": "2833905f119b5722", "gsd-core/workflows/profile-user.md": "5cc032206c99ef71", "gsd-core/workflows/progress.md": "6b9a84a43dc55af5", - "gsd-core/workflows/quick.md": "eb68ea8748546331", + "gsd-core/workflows/quick.md": "4a53899b69b8c801", "gsd-core/workflows/reapply-patches.md": "8fd59e24b486f180", "gsd-core/workflows/remove-phase.md": "e262654e319d1bc4", "gsd-core/workflows/remove-workspace.md": "ceddfeef5f2d6754", diff --git a/tests/fixtures/golden-install-parity/cursor.json b/tests/fixtures/golden-install-parity/cursor.json index e19597ac9..33392e4bf 100644 --- a/tests/fixtures/golden-install-parity/cursor.json +++ b/tests/fixtures/golden-install-parity/cursor.json @@ -109,7 +109,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "2525f1ae8b086828", - "gsd-core/bin/gsd-tools.cjs": "db16ded31dd6eecf", + "gsd-core/bin/gsd-tools.cjs": "50658d517405cd63", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4", @@ -313,7 +313,7 @@ "gsd-core/workflows/execute-plan.md": "83dc1bf7f73735c0", "gsd-core/workflows/explore.md": "b9eea1bac358c9ce", "gsd-core/workflows/extract-learnings.md": "d8177b0c13b7e5ee", - "gsd-core/workflows/fast.md": "91f126c4ef24217e", + "gsd-core/workflows/fast.md": "77b49793e26b3323", "gsd-core/workflows/forensics.md": "a65f817d4a515291", "gsd-core/workflows/graduation.md": "53a4a6fa3b4e6613", "gsd-core/workflows/health.md": "e4d770484b5e7496", @@ -351,7 +351,7 @@ "gsd-core/workflows/pr-branch.md": "c67d90c65da47168", "gsd-core/workflows/profile-user.md": "8c943983241260b5", "gsd-core/workflows/progress.md": "65aabee5e8a6dd82", - "gsd-core/workflows/quick.md": "32a71f62041c8510", + "gsd-core/workflows/quick.md": "3b37350964cfe391", "gsd-core/workflows/reapply-patches.md": "ba9406b60f2c4041", "gsd-core/workflows/remove-phase.md": "ada8a0546c686483", "gsd-core/workflows/remove-workspace.md": "433affcd1a200826", diff --git a/tests/fixtures/golden-install-parity/hermes.json b/tests/fixtures/golden-install-parity/hermes.json index 556bf625a..a4ef83259 100644 --- a/tests/fixtures/golden-install-parity/hermes.json +++ b/tests/fixtures/golden-install-parity/hermes.json @@ -38,7 +38,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "3a3409215044af9f", - "gsd-core/bin/gsd-tools.cjs": "b3f927ee4b4c4711", + "gsd-core/bin/gsd-tools.cjs": "12ee14a48b678d2b", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4", @@ -242,7 +242,7 @@ "gsd-core/workflows/execute-plan.md": "4dbe9b6f0c976245", "gsd-core/workflows/explore.md": "48770d68e8b9c132", "gsd-core/workflows/extract-learnings.md": "e9e167c718949c0b", - "gsd-core/workflows/fast.md": "0242082ca646819a", + "gsd-core/workflows/fast.md": "c801145115755524", "gsd-core/workflows/forensics.md": "91961b811917c5c4", "gsd-core/workflows/graduation.md": "d1fd52bbe41dcf34", "gsd-core/workflows/health.md": "c622a5ad0d347d30", @@ -280,7 +280,7 @@ "gsd-core/workflows/pr-branch.md": "ecabd55e4eabf229", "gsd-core/workflows/profile-user.md": "de5030437226cf2c", "gsd-core/workflows/progress.md": "f18db000584d9cb1", - "gsd-core/workflows/quick.md": "e7a395b9e7786b56", + "gsd-core/workflows/quick.md": "731c20b5605bd319", "gsd-core/workflows/reapply-patches.md": "158083a310859594", "gsd-core/workflows/remove-phase.md": "fce799aae3ab2715", "gsd-core/workflows/remove-workspace.md": "8facde381657dd71", diff --git a/tests/fixtures/golden-install-parity/kilo.json b/tests/fixtures/golden-install-parity/kilo.json index f6456b8d3..e08e30b15 100644 --- a/tests/fixtures/golden-install-parity/kilo.json +++ b/tests/fixtures/golden-install-parity/kilo.json @@ -109,7 +109,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62", - "gsd-core/bin/gsd-tools.cjs": "37fb6081fcb18038", + "gsd-core/bin/gsd-tools.cjs": "06c046925b7156b7", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4", @@ -313,7 +313,7 @@ "gsd-core/workflows/execute-plan.md": "8dc89b35582407f7", "gsd-core/workflows/explore.md": "14242d36d4822df6", "gsd-core/workflows/extract-learnings.md": "d8177b0c13b7e5ee", - "gsd-core/workflows/fast.md": "0242082ca646819a", + "gsd-core/workflows/fast.md": "41a6568b873aef99", "gsd-core/workflows/forensics.md": "c01da0178fb97b21", "gsd-core/workflows/graduation.md": "5cc8638dabcbfd40", "gsd-core/workflows/health.md": "064b3668d5d9569a", @@ -351,7 +351,7 @@ "gsd-core/workflows/pr-branch.md": "ab157cd8e49621dd", "gsd-core/workflows/profile-user.md": "203ebe3f8f3876a8", "gsd-core/workflows/progress.md": "9381c59676ccb937", - "gsd-core/workflows/quick.md": "e71c92cf0463a461", + "gsd-core/workflows/quick.md": "4ddd6d3214f57f57", "gsd-core/workflows/reapply-patches.md": "becf9728cdb124c4", "gsd-core/workflows/remove-phase.md": "ada8a0546c686483", "gsd-core/workflows/remove-workspace.md": "fc83f362a2d0a1b7", diff --git a/tests/fixtures/golden-install-parity/kimi.json b/tests/fixtures/golden-install-parity/kimi.json index dafccaebe..178351d3e 100644 --- a/tests/fixtures/golden-install-parity/kimi.json +++ b/tests/fixtures/golden-install-parity/kimi.json @@ -102,7 +102,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62", - "gsd-core/bin/gsd-tools.cjs": "37fb6081fcb18038", + "gsd-core/bin/gsd-tools.cjs": "06c046925b7156b7", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4", @@ -306,7 +306,7 @@ "gsd-core/workflows/execute-plan.md": "c8502b7475d797a7", "gsd-core/workflows/explore.md": "6c04f2e658d93261", "gsd-core/workflows/extract-learnings.md": "b6f01ca3d8f58de4", - "gsd-core/workflows/fast.md": "8878ec034b401f36", + "gsd-core/workflows/fast.md": "11f5cd10ae5cc7d3", "gsd-core/workflows/forensics.md": "0d500a3f5ab26913", "gsd-core/workflows/graduation.md": "47f1594c88c08501", "gsd-core/workflows/health.md": "107e3c72e76d9535", @@ -344,7 +344,7 @@ "gsd-core/workflows/pr-branch.md": "c87db7ac8c28be1b", "gsd-core/workflows/profile-user.md": "5abfae83739fa978", "gsd-core/workflows/progress.md": "893aa3c36983f74b", - "gsd-core/workflows/quick.md": "09d88dd1c0b3e03a", + "gsd-core/workflows/quick.md": "8dcda37c46954ebe", "gsd-core/workflows/reapply-patches.md": "39050f72601aec89", "gsd-core/workflows/remove-phase.md": "df9a45f0b1880999", "gsd-core/workflows/remove-workspace.md": "a7ca66db6b7c132c", diff --git a/tests/fixtures/golden-install-parity/opencode.json b/tests/fixtures/golden-install-parity/opencode.json index e9161b5f0..26ddd926c 100644 --- a/tests/fixtures/golden-install-parity/opencode.json +++ b/tests/fixtures/golden-install-parity/opencode.json @@ -109,7 +109,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62", - "gsd-core/bin/gsd-tools.cjs": "37fb6081fcb18038", + "gsd-core/bin/gsd-tools.cjs": "06c046925b7156b7", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4", @@ -313,7 +313,7 @@ "gsd-core/workflows/execute-plan.md": "e8de8ea661c1fe81", "gsd-core/workflows/explore.md": "7f5f9231cfd3089b", "gsd-core/workflows/extract-learnings.md": "92b3c0979604b7d0", - "gsd-core/workflows/fast.md": "0242082ca646819a", + "gsd-core/workflows/fast.md": "12187b242e6af970", "gsd-core/workflows/forensics.md": "9354cb830152fd28", "gsd-core/workflows/graduation.md": "13080b5c24eec27d", "gsd-core/workflows/health.md": "a91e0a8e5d20b2e9", @@ -351,7 +351,7 @@ "gsd-core/workflows/pr-branch.md": "929b7cb0c99c7b9e", "gsd-core/workflows/profile-user.md": "248d59a31948e0ed", "gsd-core/workflows/progress.md": "8fc3404087f50b95", - "gsd-core/workflows/quick.md": "9bdccb7c6a5530c9", + "gsd-core/workflows/quick.md": "774d646560a74c93", "gsd-core/workflows/reapply-patches.md": "a0e9b53f90abceb2", "gsd-core/workflows/remove-phase.md": "dea4661e8f89596f", "gsd-core/workflows/remove-workspace.md": "446847e71aa52504", diff --git a/tests/fixtures/golden-install-parity/pi.json b/tests/fixtures/golden-install-parity/pi.json index d7201acd2..7408f056a 100644 --- a/tests/fixtures/golden-install-parity/pi.json +++ b/tests/fixtures/golden-install-parity/pi.json @@ -5,7 +5,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62", - "gsd-core/bin/gsd-tools.cjs": "37fb6081fcb18038", + "gsd-core/bin/gsd-tools.cjs": "06c046925b7156b7", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4", @@ -209,7 +209,7 @@ "gsd-core/workflows/execute-plan.md": "ff172c3540b52e9d", "gsd-core/workflows/explore.md": "6c04f2e658d93261", "gsd-core/workflows/extract-learnings.md": "b6f01ca3d8f58de4", - "gsd-core/workflows/fast.md": "8878ec034b401f36", + "gsd-core/workflows/fast.md": "11f5cd10ae5cc7d3", "gsd-core/workflows/forensics.md": "0d500a3f5ab26913", "gsd-core/workflows/graduation.md": "47f1594c88c08501", "gsd-core/workflows/health.md": "107e3c72e76d9535", @@ -247,7 +247,7 @@ "gsd-core/workflows/pr-branch.md": "c87db7ac8c28be1b", "gsd-core/workflows/profile-user.md": "1bac7f69142801ef", "gsd-core/workflows/progress.md": "893aa3c36983f74b", - "gsd-core/workflows/quick.md": "4471707540842a41", + "gsd-core/workflows/quick.md": "31787c1e8a867e3f", "gsd-core/workflows/reapply-patches.md": "39050f72601aec89", "gsd-core/workflows/remove-phase.md": "df9a45f0b1880999", "gsd-core/workflows/remove-workspace.md": "a7ca66db6b7c132c", diff --git a/tests/fixtures/golden-install-parity/qwen.json b/tests/fixtures/golden-install-parity/qwen.json index fdf23aea0..cb2b3b7a6 100644 --- a/tests/fixtures/golden-install-parity/qwen.json +++ b/tests/fixtures/golden-install-parity/qwen.json @@ -38,7 +38,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "6e98d76e955e35a2", - "gsd-core/bin/gsd-tools.cjs": "6454021dec4d9563", + "gsd-core/bin/gsd-tools.cjs": "205830afac36f33a", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4", @@ -242,7 +242,7 @@ "gsd-core/workflows/execute-plan.md": "503b0ced0731dc38", "gsd-core/workflows/explore.md": "e1a83a8982532e5b", "gsd-core/workflows/extract-learnings.md": "dd4fdb88605de49a", - "gsd-core/workflows/fast.md": "0242082ca646819a", + "gsd-core/workflows/fast.md": "93ed453edddd8c0c", "gsd-core/workflows/forensics.md": "82800a3138ac1da9", "gsd-core/workflows/graduation.md": "40401655435beee3", "gsd-core/workflows/health.md": "043de14edb6a9723", @@ -280,7 +280,7 @@ "gsd-core/workflows/pr-branch.md": "cef0f65b16d500b4", "gsd-core/workflows/profile-user.md": "263c0693563d98da", "gsd-core/workflows/progress.md": "3b1b2142a74af85c", - "gsd-core/workflows/quick.md": "bb9a4a145a7edc36", + "gsd-core/workflows/quick.md": "d0fffe516abaf47a", "gsd-core/workflows/reapply-patches.md": "de0ee8acfe7245b2", "gsd-core/workflows/remove-phase.md": "e8ae4fbbfac700f0", "gsd-core/workflows/remove-workspace.md": "4ac64de862dc650e", diff --git a/tests/fixtures/golden-install-parity/trae.json b/tests/fixtures/golden-install-parity/trae.json index abd6f9226..4bc108286 100644 --- a/tests/fixtures/golden-install-parity/trae.json +++ b/tests/fixtures/golden-install-parity/trae.json @@ -38,7 +38,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "de4627dff103d527", - "gsd-core/bin/gsd-tools.cjs": "c28c9076058c5e73", + "gsd-core/bin/gsd-tools.cjs": "c8283c0c8888e357", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4", @@ -242,7 +242,7 @@ "gsd-core/workflows/execute-plan.md": "4910f75bab2040ab", "gsd-core/workflows/explore.md": "8a5437aa0c239c38", "gsd-core/workflows/extract-learnings.md": "3fcc858b20d0d0e6", - "gsd-core/workflows/fast.md": "91f126c4ef24217e", + "gsd-core/workflows/fast.md": "0f05b1e008ac2fc2", "gsd-core/workflows/forensics.md": "665546666547875d", "gsd-core/workflows/graduation.md": "1ca877cda258a5de", "gsd-core/workflows/health.md": "1f9fd2deea45896f", @@ -280,7 +280,7 @@ "gsd-core/workflows/pr-branch.md": "79fd55b88ea2db9c", "gsd-core/workflows/profile-user.md": "672821e6b1266645", "gsd-core/workflows/progress.md": "94768f835b0b8908", - "gsd-core/workflows/quick.md": "a568015cccb53615", + "gsd-core/workflows/quick.md": "0529abcf9913ec04", "gsd-core/workflows/reapply-patches.md": "21b38c374f19fd78", "gsd-core/workflows/remove-phase.md": "a46c2fe853bf4e86", "gsd-core/workflows/remove-workspace.md": "ae0e1c6d4438d663", diff --git a/tests/fixtures/golden-install-parity/windsurf.json b/tests/fixtures/golden-install-parity/windsurf.json index 0c9d69c9c..d1e164ce6 100644 --- a/tests/fixtures/golden-install-parity/windsurf.json +++ b/tests/fixtures/golden-install-parity/windsurf.json @@ -38,7 +38,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "5636ca0b726871b2", - "gsd-core/bin/gsd-tools.cjs": "568e7c6bdd415c1a", + "gsd-core/bin/gsd-tools.cjs": "f21bb9ba5e55f642", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4", @@ -242,7 +242,7 @@ "gsd-core/workflows/execute-plan.md": "c8567fb4438b4404", "gsd-core/workflows/explore.md": "04e461ff8159a24e", "gsd-core/workflows/extract-learnings.md": "af793bdf4ffd1c8a", - "gsd-core/workflows/fast.md": "91f126c4ef24217e", + "gsd-core/workflows/fast.md": "b03b9f595892479a", "gsd-core/workflows/forensics.md": "3d1ce16b5f605592", "gsd-core/workflows/graduation.md": "a766039ff6ca653f", "gsd-core/workflows/health.md": "7b19d6e2c0357c3e", @@ -280,7 +280,7 @@ "gsd-core/workflows/pr-branch.md": "acd59f915d018ad4", "gsd-core/workflows/profile-user.md": "c4313672b81b5bcd", "gsd-core/workflows/progress.md": "18813a345bd2343a", - "gsd-core/workflows/quick.md": "b7813e1810c683e1", + "gsd-core/workflows/quick.md": "71fbcf38e2305de0", "gsd-core/workflows/reapply-patches.md": "d449a23d3acf6379", "gsd-core/workflows/remove-phase.md": "e7a6af429b36e77b", "gsd-core/workflows/remove-workspace.md": "b5e60fbb33b3e33a", diff --git a/tests/fixtures/golden-install-parity/zcode.json b/tests/fixtures/golden-install-parity/zcode.json index ee03a8512..51e2621bf 100644 --- a/tests/fixtures/golden-install-parity/zcode.json +++ b/tests/fixtures/golden-install-parity/zcode.json @@ -109,7 +109,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62", - "gsd-core/bin/gsd-tools.cjs": "37fb6081fcb18038", + "gsd-core/bin/gsd-tools.cjs": "06c046925b7156b7", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4", @@ -313,7 +313,7 @@ "gsd-core/workflows/execute-plan.md": "ed874410972d32b7", "gsd-core/workflows/explore.md": "6c04f2e658d93261", "gsd-core/workflows/extract-learnings.md": "b6f01ca3d8f58de4", - "gsd-core/workflows/fast.md": "8878ec034b401f36", + "gsd-core/workflows/fast.md": "11f5cd10ae5cc7d3", "gsd-core/workflows/forensics.md": "0d500a3f5ab26913", "gsd-core/workflows/graduation.md": "47f1594c88c08501", "gsd-core/workflows/health.md": "107e3c72e76d9535", @@ -351,7 +351,7 @@ "gsd-core/workflows/pr-branch.md": "c87db7ac8c28be1b", "gsd-core/workflows/profile-user.md": "e23bea0a69c0bb4b", "gsd-core/workflows/progress.md": "893aa3c36983f74b", - "gsd-core/workflows/quick.md": "12917fdc9a624a0c", + "gsd-core/workflows/quick.md": "7ad8d250c536c4dd", "gsd-core/workflows/reapply-patches.md": "39050f72601aec89", "gsd-core/workflows/remove-phase.md": "df9a45f0b1880999", "gsd-core/workflows/remove-workspace.md": "a7ca66db6b7c132c", diff --git a/tests/markdown-table.test.cjs b/tests/markdown-table.test.cjs new file mode 100644 index 000000000..2a5b95de4 --- /dev/null +++ b/tests/markdown-table.test.cjs @@ -0,0 +1,613 @@ +'use strict'; + +/** + * Behavioral tests for markdown-table.cjs + * + * Module: gsd-core/bin/lib/markdown-table.cjs + * Exports: parseMarkdownTable, matchTableSchema, TABLE_SCHEMAS + * + * Covers: + * - parseMarkdownTable happy path (4-col + 5-col headers, cells addressed by name) + * - BOUNDARY coverage: ragged data rows at limit-1 / limit / limit+1 cell counts + * - malformed-input error paths (empty, non-string, no table, missing delimiter row) + * - matchTableSchema resolving every canonical variant + null for unknown headers + * - fast-check round-trip property test + * - registry <-> template/workflow parity guard (ADR-2143 §3 Generative-Fix- + * Divergence guard) — every TABLE_SCHEMAS header must appear verbatim in the + * source file that generates it + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +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 } = require('../gsd-core/bin/lib/markdown-table.cjs'); + +const ROOT = path.join(__dirname, '..'); + +// ─── parseMarkdownTable: happy path ─────────────────────────────────────────── + +describe('parseMarkdownTable: happy path', () => { + test('parses a 4-column flat RoadmapProgress table, cells addressed by name', () => { + const src = [ + '| Phase | Plans Complete | Status | Completed |', + '| --- | --- | --- | --- |', + '| 1. Alpha | 2/2 | Complete | ✅ |', + '| 2. Beta | 1/2 | In Progress | |', + ].join('\n'); + + const result = parseMarkdownTable(src); + assert.equal(result.ok, true); + assert.deepEqual(result.value.columns, ['Phase', 'Plans Complete', 'Status', 'Completed']); + assert.equal(result.value.rows.length, 2); + assert.equal(result.value.rows[0]['Phase'], '1. Alpha'); + assert.equal(result.value.rows[0]['Plans Complete'], '2/2'); + assert.equal(result.value.rows[0]['Status'], 'Complete'); + assert.equal(result.value.rows[1]['Status'], 'In Progress'); + }); + + test('parses a 5-column milestone-grouped RoadmapProgress table, cells addressed by name', () => { + const src = [ + '| Phase | Milestone | Plans Complete | Status | Completed |', + '|---|---|---|---|---|', + '| 1. Alpha | v1.0 | 2/2 | Complete | ✅ |', + '| 2. Beta | v1.1 | 0/3 | Planned | |', + ].join('\n'); + + const result = parseMarkdownTable(src); + assert.equal(result.ok, true); + assert.deepEqual(result.value.columns, ['Phase', 'Milestone', 'Plans Complete', 'Status', 'Completed']); + assert.equal(result.value.rows[0]['Milestone'], 'v1.0'); + assert.equal(result.value.rows[1]['Milestone'], 'v1.1'); + assert.equal(result.value.rows[1]['Status'], 'Planned'); + }); + + test('finds the FIRST table when the section has leading prose', () => { + const src = [ + 'Some intro prose before the table.', + '', + '| Requirement | Phase | Status |', + '| --- | --- | --- |', + '| R1 | 1 | Done |', + ].join('\n'); + + const result = parseMarkdownTable(src); + assert.equal(result.ok, true); + assert.deepEqual(result.value.columns, ['Requirement', 'Phase', 'Status']); + assert.equal(result.value.rows[0]['Requirement'], 'R1'); + }); +}); + +// ─── BOUNDARY coverage: ragged data rows ────────────────────────────────────── + +describe('parseMarkdownTable: boundary coverage (ragged rows)', () => { + const header = '| Phase | Plans Complete | Status | Completed |'; + const delimiter = '| --- | --- | --- | --- |'; + + test('limit-1: a 3-cell data row (one short of the 4-column header) is a typed error', () => { + const src = [header, delimiter, '| 1. Alpha | 2/2 | Complete |'].join('\n'); + const result = parseMarkdownTable(src); + assert.equal(result.ok, false); + assert.match(result.reason, /row 1 has 3 cells, expected 4/); + }); + + test('limit: a 4-cell data row (exactly matching the 4-column header) parses ok', () => { + const src = [header, delimiter, '| 1. Alpha | 2/2 | Complete | ✅ |'].join('\n'); + const result = parseMarkdownTable(src); + assert.equal(result.ok, true); + assert.equal(result.value.rows.length, 1); + }); + + test('limit+1: a 5-cell data row (one over the 4-column header) is a typed error', () => { + const src = [header, delimiter, '| 1. Alpha | 2/2 | Complete | ✅ | extra |'].join('\n'); + const result = parseMarkdownTable(src); + assert.equal(result.ok, false); + assert.match(result.reason, /row 1 has 5 cells, expected 4/); + }); +}); + +// ─── Malformed input ────────────────────────────────────────────────────────── + +describe('parseMarkdownTable: malformed input', () => { + test('empty string returns a typed error', () => { + const result = parseMarkdownTable(''); + assert.equal(result.ok, false); + assert.equal(result.reason, 'empty or non-string input'); + }); + + test('whitespace-only string returns a typed error', () => { + const result = parseMarkdownTable(' \n \n '); + assert.equal(result.ok, false); + assert.equal(result.reason, 'empty or non-string input'); + }); + + test('non-string input returns a typed error, does not throw', () => { + let result; + assert.doesNotThrow(() => { + result = parseMarkdownTable(42); + }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'empty or non-string input'); + }); + + test('content with no pipe table returns a typed error', () => { + const result = parseMarkdownTable('# Heading\n\nJust some prose, no table here.\n'); + assert.equal(result.ok, false); + assert.equal(result.reason, 'no table found'); + }); + + test('missing delimiter row returns a typed error', () => { + const src = [ + '| Phase | Plans Complete | Status | Completed |', + '| 1. Alpha | 2/2 | Complete | ✅ |', + ].join('\n'); + const result = parseMarkdownTable(src); + assert.equal(result.ok, false); + assert.equal(result.reason, 'missing delimiter row'); + }); + + test('delimiter row present but column count mismatch returns a typed error', () => { + const src = [ + '| Phase | Plans Complete | Status | Completed |', + '| --- | --- | --- |', + '| 1. Alpha | 2/2 | Complete | ✅ |', + ].join('\n'); + const result = parseMarkdownTable(src); + assert.equal(result.ok, false); + assert.equal(result.reason, 'delimiter/header column count mismatch'); + }); +}); + +// ─── matchTableSchema ────────────────────────────────────────────────────────── + +describe('matchTableSchema', () => { + test('resolves every canonical variant header to its {id,label}', () => { + for (const [id, variants] of Object.entries(TABLE_SCHEMAS)) { + for (const variant of variants) { + const match = matchTableSchema(variant.columns); + assert.deepEqual( + match, + { id, label: variant.label }, + `expected ${id}/${variant.label} to resolve for columns ${JSON.stringify(variant.columns)}`, + ); + } + } + }); + + test('returns null for an unknown header', () => { + const match = matchTableSchema(['Foo', 'Bar', 'Baz']); + assert.equal(match, null); + }); + + test('returns null when column order differs from every variant', () => { + const match = matchTableSchema(['Status', 'Phase', 'Plans Complete', 'Completed']); + assert.equal(match, null); + }); + + test('returns null when column count differs from every variant', () => { + const match = matchTableSchema(['Phase', 'Plans Complete', 'Status']); + assert.equal(match, null); + }); +}); + +// ─── Property test: round-trip render -> parse ──────────────────────────────── + +describe('parseMarkdownTable: property-based round-trip', () => { + // Safe cell text: no '|' or newline, non-empty, bounded length. + const safeCell = fc + .string({ minLength: 1, maxLength: 8 }) + .filter((s) => !s.includes('|') && !s.includes('\n') && !s.includes('\r') && s.trim().length > 0) + .map((s) => s.trim()); + + const safeColumnName = fc + .string({ minLength: 1, maxLength: 6 }) + .filter((s) => !s.includes('|') && !s.includes('\n') && !s.includes('\r') && s.trim().length > 0) + .map((s) => s.trim()); + + function renderTable(columns, rows) { + const lines = []; + lines.push(`| ${columns.join(' | ')} |`); + lines.push(`| ${columns.map(() => '---').join(' | ')} |`); + for (const row of rows) { + lines.push(`| ${row.join(' | ')} |`); + } + return lines.join('\n'); + } + + // A table's rows depend on its column count, so derive the rows arbitrary + // from the generated columns via .chain() (dependent arbitrary generation) — + // never fc.sample() inside a property, which breaks shrinking/reproducibility. + const tableArb = fc.uniqueArray(safeColumnName, { minLength: 1, maxLength: 4 }).chain((columns) => + fc.tuple( + fc.constant(columns), + fc.array(fc.array(safeCell, { minLength: columns.length, maxLength: columns.length }), { maxLength: 4 }), + ), + ); + + test('property: rendering a table then parsing it round-trips columns and row values', () => { + fc.assert( + fc.property(tableArb, ([columns, rows]) => { + const src = renderTable(columns, rows); + + const result = parseMarkdownTable(src); + assert.equal(result.ok, true, `expected ok:true, got ${JSON.stringify(result)}`); + assert.deepEqual(result.value.columns, columns); + assert.equal(result.value.rows.length, rows.length); + rows.forEach((cells, i) => { + columns.forEach((col, j) => { + assert.equal(result.value.rows[i][col], cells[j]); + }); + }); + }), + ); + }); +}); + +// ─── appendQuickTaskRow (#2133) ──────────────────────────────────────────────── + +describe('appendQuickTaskRow (#2133)', () => { + const noStatusState = [ + '# STATE', + '', + '### Quick Tasks Completed', + '', + '| # | Description | Date | Commit | Directory |', + '|---|-------------|------|--------|-----------|', + '| 1 | fix typo | 2026-01-01 | abc1234 | — |', + '', + '### Blockers/Concerns', + 'None', + ].join('\n'); + + const withStatusState = [ + '# STATE', + '', + '### Quick Tasks Completed', + '', + '| # | Description | Date | Commit | Status | Directory |', + '|---|-------------|------|--------|--------|-----------|', + '| 1 | fix typo | 2026-01-01 | abc1234 | Pass | — |', + '', + '### Blockers/Concerns', + 'None', + ].join('\n'); + + test('5-col no-status table: appends a 5-cell row, content contains it, variant is no-status', () => { + const result = appendQuickTaskRow(noStatusState, { + description: 'add missing import', + date: '2026-07-13', + commit: 'a574966', + }); + assert.equal(result.ok, true); + assert.equal(result.value.variant, 'no-status'); + assert.equal(result.value.row, '| 2 | add missing import | 2026-07-13 | a574966 | — |'); + assert.ok(result.value.content.includes(result.value.row)); + }); + + test('6-col with-status table: appends a 6-cell row, variant is with-status', () => { + const result = appendQuickTaskRow(withStatusState, { + description: 'bump version', + date: '2026-07-13', + commit: 'b6fc5f6', + status: 'Needs Review', + }); + assert.equal(result.ok, true); + assert.equal(result.value.variant, 'with-status'); + assert.equal(result.value.row, '| 2 | bump version | 2026-07-13 | b6fc5f6 | Needs Review | — |'); + assert.ok(result.value.content.includes(result.value.row)); + }); + + test('unknown/garbled header (4-col table): fails loud with a reason instead of silently skipping', () => { + const garbled = [ + '# STATE', + '', + '### Quick Tasks Completed', + '', + '| Foo | Bar | Baz | Qux |', + '|---|---|---|---|', + '| 1 | 2 | 3 | 4 |', + ].join('\n'); + const result = appendQuickTaskRow(garbled, { description: 'x', date: '2026-07-13', commit: 'abc' }); + assert.equal(result.ok, false); + assert.match(result.reason, /unrecognized Quick Tasks schema/); + }); + + test('no "Quick Tasks Completed" section: fails loud with a reason', () => { + const noSection = '# STATE\n\n### Blockers/Concerns\nNone\n'; + const result = appendQuickTaskRow(noSection, { description: 'x', date: '2026-07-13', commit: 'abc' }); + assert.equal(result.ok, false); + assert.match(result.reason, /no Quick Tasks Completed section/); + }); + + test('boundary: next row number is 1 with zero data rows, 3 with two data rows', () => { + const zeroRows = [ + '# STATE', + '', + '### Quick Tasks Completed', + '', + '| # | Description | Date | Commit | Directory |', + '|---|-------------|------|--------|-----------|', + ].join('\n'); + const zeroResult = appendQuickTaskRow(zeroRows, { description: 'x', date: '2026-07-13', commit: 'abc' }); + assert.equal(zeroResult.ok, true); + assert.match(zeroResult.value.row, /^\| 1 \|/); + + const twoRows = [ + '# STATE', + '', + '### Quick Tasks Completed', + '', + '| # | Description | Date | Commit | Directory |', + '|---|-------------|------|--------|-----------|', + '| 1 | first | 2026-01-01 | aaa1111 | — |', + '| 2 | second | 2026-01-02 | bbb2222 | — |', + ].join('\n'); + const twoResult = appendQuickTaskRow(twoRows, { description: 'x', date: '2026-07-13', commit: 'abc' }); + assert.equal(twoResult.ok, true); + assert.match(twoResult.value.row, /^\| 3 \|/); + }); + + test('appended row cell count equals the header column count (round-trips via parseMarkdownTable)', () => { + const result = appendQuickTaskRow(noStatusState, { + description: 'round trip check', + date: '2026-07-13', + commit: 'ccc3333', + }); + assert.equal(result.ok, true); + const section = result.value.content.split('### Blockers/Concerns')[0]; + const reparsed = parseMarkdownTable(section); + assert.equal(reparsed.ok, true); + assert.equal(reparsed.value.rows.length, 2); + for (const row of reparsed.value.rows) { + assert.equal(Object.keys(row).length, reparsed.value.columns.length); + } + }); + + // ─── Regression: cell-value escaping (#2242 review Fix 1) ────────────────── + // A raw `|` or newline in `description` used to be inserted verbatim, + // corrupting the table (extra column / a fake extra row) — the now-fail-loud + // parseMarkdownTable rejects the resulting ragged row. + + test('description containing "|" round-trips: ok:true, no ragged row, cell value preserved', () => { + const result = appendQuickTaskRow(noStatusState, { + description: 'fix a | b bug', + date: '2026-07-13', + commit: 'a574966', + }); + assert.equal(result.ok, true); + + const section = result.value.content.split('### Blockers/Concerns')[0]; + const reparsed = parseMarkdownTable(section); + assert.equal(reparsed.ok, true, `expected ok:true (not a ragged-row error), got ${JSON.stringify(reparsed)}`); + assert.equal(reparsed.value.rows.length, 2); + assert.equal(reparsed.value.rows[1]['Description'], 'fix a | b bug'); + }); + + // ─── Regression: backslash escaping (CodeQL js/incomplete-sanitization) ──── + // escapeCell used to escape `|` -> `\|` without first escaping a literal `\`, + // so a description with a raw backslash (e.g. a Windows path) could produce + // an escape sequence that splitTableRow misreads on unescape. escapeCell now + // escapes `\` -> `\\` before `|` -> `\|`, and splitTableRow reverses both. + + test('description containing a literal backslash round-trips byte-for-byte', () => { + const result = appendQuickTaskRow(noStatusState, { + description: 'fix C:\\path bug', + date: '2026-07-13', + commit: 'a574966', + }); + assert.equal(result.ok, true); + + const section = result.value.content.split('### Blockers/Concerns')[0]; + const reparsed = parseMarkdownTable(section); + assert.equal(reparsed.ok, true, `expected ok:true (not a ragged-row error), got ${JSON.stringify(reparsed)}`); + assert.equal(reparsed.value.rows.length, 2); + assert.equal(reparsed.value.rows[1]['Description'], 'fix C:\\path bug'); + }); + + test('description containing backslash-pipe ("a\\|b") round-trips to exactly "a\\|b"', () => { + const result = appendQuickTaskRow(noStatusState, { + description: 'a\\|b', + date: '2026-07-13', + commit: 'a574966', + }); + assert.equal(result.ok, true); + + const section = result.value.content.split('### Blockers/Concerns')[0]; + const reparsed = parseMarkdownTable(section); + assert.equal(reparsed.ok, true, `expected ok:true (not a ragged-row error), got ${JSON.stringify(reparsed)}`); + assert.equal(reparsed.value.rows.length, 2); + assert.equal(reparsed.value.rows[1]['Description'], 'a\\|b'); + }); + + test('description containing a newline collapses to a single-line cell and round-trips', () => { + const result = appendQuickTaskRow(noStatusState, { + description: 'line one\nline two', + date: '2026-07-13', + commit: 'a574966', + }); + assert.equal(result.ok, true); + // Collapsed to a single line: the row itself must not contain a newline. + assert.ok(!result.value.row.includes('\n')); + + const section = result.value.content.split('### Blockers/Concerns')[0]; + const reparsed = parseMarkdownTable(section); + assert.equal(reparsed.ok, true, `expected ok:true (not a ragged-row error), got ${JSON.stringify(reparsed)}`); + assert.equal(reparsed.value.rows.length, 2); + assert.equal(reparsed.value.rows[1]['Description'], 'line one line two'); + }); + + // ─── Regression: CRLF preservation (#2242 review Fix 3) ───────────────────── + // section.body used to be split on /\r?\n/ and rejoined with '\n', downgrading + // a CRLF section to mixed EOL. + + test('CRLF-input STATE.md keeps \\r\\n in the touched section (no mixed EOL)', () => { + const crlfState = noStatusState.replace(/\n/g, '\r\n'); + const result = appendQuickTaskRow(crlfState, { + description: 'crlf check', + date: '2026-07-13', + commit: 'a574966', + }); + assert.equal(result.ok, true); + + const section = result.value.content.split('### Blockers/Concerns')[0]; + // No mixed EOL: every line break in the touched section is \r\n, and there + // must be no bare \n (i.e. no \n NOT preceded by \r). + assert.ok(!/(? { + test('finds a RoadmapProgress table that appears after other content, not under a "## Progress" heading', () => { + const doc = [ + '# Roadmap', + '', + '## Overview', + '', + 'Some prose describing the roadmap. No table here.', + '', + '## Milestone v1.0: Test', + '', + '| Phase | Plans Complete | Status | Completed |', + '| --- | --- | --- | --- |', + '| 1. Alpha | 2/2 | Complete | ✅ |', + '| 2. Beta | 1/2 | In Progress | |', + ].join('\n'); + + const table = findTableBySchema(doc, 'RoadmapProgress'); + assert.notEqual(table, null); + assert.deepEqual(table.columns, ['Phase', 'Plans Complete', 'Status', 'Completed']); + assert.equal(table.rows.length, 2); + assert.equal(table.rows[0]['Phase'], '1. Alpha'); + }); + + test('finds a RoadmapProgress table that is not the first table in the document', () => { + const doc = [ + '# Roadmap', + '', + '## Legend', + '', + '| Symbol | Meaning |', + '| --- | --- |', + '| ✅ | Done |', + '', + '## Progress', + '', + '| Phase | Plans Complete | Status | Completed |', + '| --- | --- | --- | --- |', + '| 1. Alpha | 2/2 | Complete | ✅ |', + ].join('\n'); + + const table = findTableBySchema(doc, 'RoadmapProgress'); + assert.notEqual(table, null); + assert.equal(table.rows.length, 1); + assert.equal(table.rows[0]['Phase'], '1. Alpha'); + }); + + test('returns null when no table matches the given schema', () => { + const doc = [ + '# Roadmap', + '', + '| Symbol | Meaning |', + '| --- | --- |', + '| ✅ | Done |', + ].join('\n'); + + assert.equal(findTableBySchema(doc, 'RoadmapProgress'), null); + }); + + test('returns null for non-string input', () => { + assert.equal(findTableBySchema(undefined, 'RoadmapProgress'), null); + }); +}); + +// ─── findTableWithColumns (#2242: column-order/count-invariant reader seam) ── + +describe('findTableWithColumns', () => { + test('finds a table whose header has the required columns in shuffled order, plus extra/injected columns', () => { + const doc = [ + '## Progress', + '', + '| Status | Foo | Phase | Plans Complete | Completed |', + '| --- | --- | --- | --- | --- |', + '| Complete | x | 1. Alpha | 2/2 | ✅ |', + '| In Progress | x | 2. Beta | 1/2 | |', + ].join('\n'); + + const table = findTableWithColumns(doc, ['Phase', 'Plans Complete', 'Status', 'Completed']); + assert.notEqual(table, null); + assert.deepEqual(table.columns, ['Status', 'Foo', 'Phase', 'Plans Complete', 'Completed']); + assert.equal(table.rows.length, 2); + assert.equal(table.rows[0]['Phase'], '1. Alpha'); + assert.equal(table.rows[0]['Status'], 'Complete'); + assert.equal(table.rows[1]['Plans Complete'], '1/2'); + }); + + test('returns null when a required column is absent from every table header', () => { + const doc = [ + '## Progress', + '', + '| Phase | Owner | Completed |', + '| --- | --- | --- |', + '| 1. Alpha | jo | ✅ |', + ].join('\n'); + + assert.equal(findTableWithColumns(doc, ['Phase', 'Plans Complete', 'Status', 'Completed']), null); + }); + + test('returns null for non-string input', () => { + assert.equal(findTableWithColumns(undefined, ['Phase']), null); + }); +}); + +// ─── PARITY / DRIFT guard: registry <-> template/workflow source files ─────── + +describe('TABLE_SCHEMAS parity: registry headers must appear verbatim in their source templates', () => { + /** + * Build the `| a | b | c |` header line for a variant and assert the given + * source file contains it verbatim (whitespace around pipes normalized so + * template formatting quirks don't cause false failures). + */ + function assertHeaderInFile(relPath, variant) { + const fullPath = path.join(ROOT, relPath); + const content = fs.readFileSync(fullPath, 'utf8'); // allow-test-rule: runtime-contract-is-the-product — template/registry parity (#2242) + const expectedHeader = `| ${variant.columns.join(' | ')} |`; + const normalize = (s) => s.replace(/[ \t]*\|[ \t]*/g, '|').trim(); + const normalizedExpected = normalize(expectedHeader); + const found = content + .split(/\r?\n/) + .some((line) => normalize(line) === normalizedExpected); + assert.ok( + found, + `expected header ${JSON.stringify(expectedHeader)} to appear verbatim in ${relPath}`, + ); + } + + test('RoadmapProgress variants appear in gsd-core/templates/roadmap.md', () => { + for (const variant of TABLE_SCHEMAS.RoadmapProgress) { + assertHeaderInFile('gsd-core/templates/roadmap.md', variant); + } + }); + + test('RequirementsTraceability variant appears in gsd-core/templates/requirements.md', () => { + for (const variant of TABLE_SCHEMAS.RequirementsTraceability) { + assertHeaderInFile('gsd-core/templates/requirements.md', variant); + } + }); + + test('QuickTasks variants appear in gsd-core/workflows/quick.md', () => { + for (const variant of TABLE_SCHEMAS.QuickTasks) { + assertHeaderInFile('gsd-core/workflows/quick.md', variant); + } + }); + + test('Security variants appear in gsd-core/templates/SECURITY.md', () => { + for (const variant of TABLE_SCHEMAS.Security) { + assertHeaderInFile('gsd-core/templates/SECURITY.md', variant); + } + }); +}); diff --git a/tests/phase-lifecycle.test.cjs b/tests/phase-lifecycle.test.cjs new file mode 100644 index 000000000..b947023af --- /dev/null +++ b/tests/phase-lifecycle.test.cjs @@ -0,0 +1,184 @@ +'use strict'; + +/** + * Behavioral tests for phase-lifecycle.cjs + * + * Module: gsd-core/bin/lib/phase-lifecycle.cjs + * Exports: deriveProgressFromRoadmap, clampPercent + * + * ADR-2143 (epic #2143) migrated deriveProgressFromRoadmap from position-based + * regexes to the markdown-table schema registry (collectSection + parseMarkdownTable + * + matchTableSchema against TABLE_SCHEMAS.RoadmapProgress). This suite pins: + * - the pre-existing 4-column flat Progress table behaviour (unchanged) + * - #2137: the 5-column milestone-grouped Progress table (Phase | Milestone | + * Plans Complete | Status | Completed) — the OLD position-anchored regex + * assumed "Status" was always the 3rd cell, so it silently returned all-null + * for this variant; the schema-registry rewrite reads cells by column NAME + * and fixes this. + * - the pre-existing 999.x backlog exclusion for totalPhases + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); + +const { deriveProgressFromRoadmap, clampPercent } = require('../gsd-core/bin/lib/phase-lifecycle.cjs'); + +describe('deriveProgressFromRoadmap', () => { + test('parses the 4-column flat Progress table (behaviour preserved)', () => { + const roadmap = [ + '# Roadmap', + '', + '## Progress', + '', + '| Phase | Plans Complete | Status | Completed |', + '| --- | --- | --- | --- |', + '| 1. Alpha | 2/2 | Complete | ✅ |', + '| 2. Beta | 1/2 | In Progress | |', + '| 3. Gamma | 0/1 | Planned | |', + ].join('\n'); + + const result = deriveProgressFromRoadmap(roadmap); + assert.equal(result.completedPhases, 1, `expected 1 completed phase, got ${result.completedPhases}`); + assert.equal(result.totalPhases, 3, `expected 3 total phases, got ${result.totalPhases}`); + assert.equal(result.totalPlans, 5, `expected totalPlans 5 (2+2+1), got ${result.totalPlans}`); + }); + + test('#2137: deriveProgressFromRoadmap parses the 5-column milestone-grouped Progress table', () => { + // Before ADR-2143: the old position-anchored regex assumed the 3rd cell was + // Status; here Status is the 4th cell (Milestone inserted at position 2), so + // the old code silently returned { completedPhases: null, totalPhases: null }. + const roadmap = [ + '# Roadmap', + '', + '## Progress', + '', + '| Phase | Milestone | Plans Complete | Status | Completed |', + '| --- | --- | --- | --- | --- |', + '| 1. Alpha | v1.0 | 2/2 | Complete | ✅ |', + '| 2. Beta | v1.0 | 1/2 | In Progress | |', + '| 3. Gamma | v1.1 | 0/1 | Planned | |', + '| 4. Delta | v1.1 | 3/3 | Complete | ✅ |', + ].join('\n'); + + const result = deriveProgressFromRoadmap(roadmap); + assert.equal(result.completedPhases, 2, `expected 2 completed phases (non-null), got ${result.completedPhases}`); + assert.equal(result.totalPhases, 4, `expected 4 total phases (non-null), got ${result.totalPhases}`); + assert.equal(result.totalPlans, 8, `expected totalPlans 8 (2+2+1+3), got ${result.totalPlans}`); + }); + + test('excludes 999.x backlog rows from totalPhases (4-column table)', () => { + const roadmap = [ + '## Milestone v1.0: Test', + '', + '| Phase | Plans Complete | Status | Completed |', + '| --- | --- | --- | --- |', + '| 1. Alpha | 2/2 | Complete | ✅ |', + '| 2. Beta | 1/2 | In Progress | |', + '| 3. Gamma | 0/1 | Planned | |', + '| 999.1 Backlog: Future Idea | 0/0 | Backlog | |', + ].join('\n'); + + const result = deriveProgressFromRoadmap(roadmap); + assert.equal( + result.totalPhases, + 3, + `total_phases must be 3 (not 4) — 999.1 backlog row must be excluded. Got ${result.totalPhases}`, + ); + assert.equal(result.completedPhases, 1, `completed_phases must be 1. Got ${result.completedPhases}`); + }); + + test('excludes 999.x backlog rows from totalPhases (5-column milestone-grouped table)', () => { + const roadmap = [ + '## Progress', + '', + '| Phase | Milestone | Plans Complete | Status | Completed |', + '| --- | --- | --- | --- | --- |', + '| 1. Alpha | v1.0 | 2/2 | Complete | ✅ |', + '| 2. Beta | v1.0 | 1/2 | In Progress | |', + '| 999.1 Backlog | v1.0 | 0/0 | Backlog | |', + ].join('\n'); + + const result = deriveProgressFromRoadmap(roadmap); + assert.equal( + result.totalPhases, + 2, + `total_phases must be 2 (not 3) — 999.1 backlog row must be excluded. Got ${result.totalPhases}`, + ); + assert.equal(result.completedPhases, 1, `completed_phases must be 1. Got ${result.completedPhases}`); + }); + + test('non-table content returns all-null (no throw)', () => { + const result = deriveProgressFromRoadmap('# Roadmap\n\nNo table here.\n'); + assert.equal(result.completedPhases, null); + assert.equal(result.totalPhases, null); + assert.equal(result.totalPlans, null); + }); + + // ─── Regression (#2242 review Fix 4): heading-dependency removed ─────────── + // The Progress table used to be located via collectSection(h => /^progress$/i) + // first, THEN parsed — so a schema-matching table under a non-"Progress" + // heading (or one that isn't the first table in the document) returned + // all-null. deriveProgressFromRoadmap now delegates to findTableBySchema, + // which scans the whole document for the schema, independent of heading name. + + test('Progress table under a NON-"Progress" heading, not the first table in the doc, still resolves', () => { + const roadmap = [ + '# Roadmap', + '', + '## Legend', + '', + '| Symbol | Meaning |', + '| --- | --- |', + '| ✅ | Done |', + '', + '## Milestone v1.0: Alpha Release', + '', + '| Phase | Plans Complete | Status | Completed |', + '| --- | --- | --- | --- |', + '| 1. Alpha | 2/2 | Complete | ✅ |', + '| 2. Beta | 1/2 | In Progress | |', + '| 3. Gamma | 0/1 | Planned | |', + ].join('\n'); + + const result = deriveProgressFromRoadmap(roadmap); + assert.equal(result.completedPhases, 1, `expected 1 completed phase (non-null), got ${result.completedPhases}`); + assert.equal(result.totalPhases, 3, `expected 3 total phases (non-null), got ${result.totalPhases}`); + assert.equal(result.totalPlans, 5, `expected totalPlans 5 (2+2+1), got ${result.totalPlans}`); + }); + + test('Progress table with NO heading at all, not the first table in the doc, still resolves', () => { + const roadmap = [ + '# Roadmap', + '', + 'Some intro prose describing conventions used below.', + '', + '| Symbol | Meaning |', + '| --- | --- |', + '| ✅ | Done |', + '', + '| Phase | Plans Complete | Status | Completed |', + '| --- | --- | --- | --- |', + '| 1. Alpha | 2/2 | Complete | ✅ |', + ].join('\n'); + + const result = deriveProgressFromRoadmap(roadmap); + assert.equal(result.completedPhases, 1, `expected 1 completed phase (non-null), got ${result.completedPhases}`); + assert.equal(result.totalPhases, 1, `expected 1 total phase (non-null), got ${result.totalPhases}`); + assert.equal(result.totalPlans, 2, `expected totalPlans 2, got ${result.totalPlans}`); + }); +}); + +describe('clampPercent', () => { + test('computes a normal percentage', () => { + assert.equal(clampPercent(1, 2), 50); + }); + + test('clamps to 100 when completed exceeds total', () => { + assert.equal(clampPercent(5, 2), 100); + }); + + test('returns 0 when total is 0 or negative', () => { + assert.equal(clampPercent(0, 0), 0); + assert.equal(clampPercent(3, -1), 0); + }); +}); diff --git a/tests/planner-language-regression.test.cjs b/tests/planner-language-regression.test.cjs index be1dc6514..e512f80da 100644 --- a/tests/planner-language-regression.test.cjs +++ b/tests/planner-language-regression.test.cjs @@ -441,25 +441,27 @@ describe('bug #3805: fast.md log_to_state must be schema-aware', () => { }); test('fast.md log_to_state step references the 5-column quick.md schema', () => { - // The fix must handle the 5-col schema: | # | Description | Date | Commit | Directory | - // Test that all 5 column names appear in the log_to_state step's context. - // Extract the log_to_state step content to scope the check. + // Schema-awareness no longer lives inline in fast.md as hardcoded column + // names — it was moved to the schema-backed `quick-tasks-append` helper + // (`appendQuickTaskRow` in markdown-table.cjs; #2133, ADR-2143 §3/§7), + // which introspects the existing table's schema (5-col or 6-col) itself. + // Assert the step delegates to that helper instead of requiring the old + // literal column names. const logToStateMatch = fastMdContent.match(/([\s\S]*?)<\/step>/); assert.ok(logToStateMatch, 'fast.md must contain a element'); const stepContent = logToStateMatch[1]; - // All 5 column names from quick.md Step 7 must be referenced in the step. - for (const col of QUICK_MD_STEP7_COLUMNS) { - assert.ok( - stepContent.toLowerCase().includes(col.toLowerCase()), - [ - `fast.md log_to_state step does not reference column "${col}"`, - `Expected all 5 columns from quick.md Step 7: ${QUICK_MD_STEP7_COLUMNS.join(', ')}`, - `(${QUICK_MD_STEP7_COL_COUNT}-column schema)`, - ].join('\n') - ); - } + assert.ok( + stepContent.includes('quick-tasks-append'), + [ + 'fast.md log_to_state step does not invoke the schema-aware quick-tasks-append helper.', + 'Schema-awareness now lives in the gsd-tools quick-tasks-append subcommand', + `(appendQuickTaskRow, handling both the ${QUICK_MD_STEP7_COL_COUNT}-column quick.md Step 7 schema`, + `${QUICK_MD_STEP7_COLUMNS.join(', ')} and the 6-column with-status variant) —`, + 'the step must delegate to it rather than hardcoding column names inline.', + ].join('\n') + ); }); test('fast.md log_to_state step skips STATE.md write on unrecognized schema', () => { diff --git a/tests/state-transition.test.cjs b/tests/state-transition.test.cjs index 71f3b090f..0f96c1386 100644 --- a/tests/state-transition.test.cjs +++ b/tests/state-transition.test.cjs @@ -541,10 +541,15 @@ function completePhaseBody() { // A roadmap with a progress table: 3 of 5 phases Complete → deriveProgressFromRoadmap // returns { completedPhases: 3, totalPhases: 5 }. +// ADR-2143 (epic #2143): deriveProgressFromRoadmap now resolves this table via the +// markdown-table schema registry (TABLE_SCHEMAS.RoadmapProgress), which requires the +// exact canonical header (gsd-core/templates/roadmap.md); the 2nd column is named +// "Plans Complete" to match (its cell values here are unused free text, not M/N +// counts — no test in this file asserts totalPlans). const ROADMAP_3_OF_5 = [ '## Roadmap', '', - '| Phase | Title | Status | Completed |', + '| Phase | Plans Complete | Status | Completed |', '| --- | --- | --- | --- |', '| 1 | A | Complete | 2026-01-01 |', '| 2 | B | Complete | 2026-02-01 |', diff --git a/tests/state.test.cjs b/tests/state.test.cjs index 93140a33f..db68736bc 100644 --- a/tests/state.test.cjs +++ b/tests/state.test.cjs @@ -9487,13 +9487,20 @@ const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); const { deriveProgressFromRoadmap } = require('../gsd-core/bin/lib/phase-lifecycle.cjs'); // ─── Scenario A: deriveProgressFromRoadmap unit test ──────────────────────── +// +// ADR-2143 (epic #2143) migrated deriveProgressFromRoadmap from position-based +// regexes to the markdown-table schema registry (TABLE_SCHEMAS.RoadmapProgress), +// which resolves the Progress table by exact column-name match. These fixtures' +// second column is renamed "Plans" -> "Plans Complete" to match the canonical +// header (gsd-core/templates/roadmap.md) the schema now requires; the assertions +// (999.x exclusion, Complete-row counting) are unchanged. describe('bug #1445 — deriveProgressFromRoadmap excludes 999.x rows', () => { test('3 real phases + 1 999.x backlog row → total_phases: 3, not 4', () => { const roadmap = [ '## Milestone v1.0: Test', '', - '| Phase | Plans | Status | Completed |', + '| Phase | Plans Complete | Status | Completed |', '| --- | --- | --- | --- |', '| 1. Alpha | 2/2 | Complete | ✅ |', '| 2. Beta | 1/2 | In Progress | |', @@ -9518,7 +9525,7 @@ describe('bug #1445 — deriveProgressFromRoadmap excludes 999.x rows', () => { const roadmap = [ '## Milestone v1.0: Test', '', - '| Phase | Plans | Status | Completed |', + '| Phase | Plans Complete | Status | Completed |', '| --- | --- | --- | --- |', '| 1. Alpha | 1/1 | Complete | ✅ |', '| 2. Beta | 1/1 | Complete | ✅ |', @@ -9542,7 +9549,7 @@ describe('bug #1445 — deriveProgressFromRoadmap excludes 999.x rows', () => { const roadmap = [ '## Milestone v1.0: Test', '', - '| Phase | Plans | Status | Completed |', + '| Phase | Plans Complete | Status | Completed |', '| --- | --- | --- | --- |', '| 999.1 Future A | 0/0 | Backlog | |', '| 999.2 Future B | 0/0 | Backlog | |', @@ -9721,9 +9728,13 @@ describe('#2137 regression: deriveProgressFromRoadmap parses the milestone-group }); test('ragged rows (more/fewer cells than the header) are handled without throwing', () => { - // The reader indexes cells positionally by header name, so an EXTRA trailing - // column is ignored and a SHORT row simply has absent Status/Plans cells - // (`cells[idx] ?? ''`) — neither should throw or corrupt the well-formed row. + // (#2242 review Fix 5 / ADR-2143 §3): deriveProgressFromRoadmap now resolves + // the Progress table via the markdown-table seam's parseMarkdownTable, which + // is fail-loud on ragged data rows by design — "ragged rows are errors, not + // silent" (src/markdown-table.cts) — rather than the pre-ADR-2143 reader's + // graceful cell-count degradation this test used to assert. A ragged row + // anywhere in the table now makes the WHOLE table unparseable, so the reader + // falls through to its existing (null) values instead of throwing. const roadmap = [ '## Progress', '', @@ -9737,13 +9748,10 @@ describe('#2137 regression: deriveProgressFromRoadmap parses the milestone-group assert.doesNotThrow(() => { result = deriveProgressFromRoadmap(roadmap); }, 'ragged rows must not throw'); - // Row 1: extra column ignored → counted, Complete, +2 plans. - // Row 2: short row → counted as a phase, but Status/Plans cells are absent so - // it is neither Complete nor plan-bearing. assert.deepEqual( result, - { completedPhases: 1, totalPhases: 2, totalPlans: 2 }, - `ragged rows must degrade gracefully to {1,2,2}, got ${JSON.stringify(result)}`, + { completedPhases: null, totalPhases: null, totalPlans: null }, + `a ragged-row table must fail loud to all-null (no throw), got ${JSON.stringify(result)}`, ); }); }); diff --git a/tests/workflow-size-baseline.json b/tests/workflow-size-baseline.json index a74bfd4d7..1a7d4662f 100644 --- a/tests/workflow-size-baseline.json +++ b/tests/workflow-size-baseline.json @@ -28,7 +28,7 @@ "execute-plan.md": 32655, "explore.md": 10541, "extract-learnings.md": 12893, - "fast.md": 4790, + "fast.md": 7613, "forensics.md": 12531, "graduation.md": 11622, "health.md": 11868, @@ -59,7 +59,7 @@ "pr-branch.md": 15963, "profile-user.md": 21246, "progress.md": 30599, - "quick.md": 50470, + "quick.md": 50699, "reapply-patches.md": 20312, "remove-phase.md": 8513, "remove-workspace.md": 7551,