This commit is contained in:
5
.changeset/mellow-koalas-caper.md
Normal file
5
.changeset/mellow-koalas-caper.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Changed
|
||||
pr: 4384
|
||||
---
|
||||
**Pending todos now render as one bounded bullet per todo in STATE.md.** Each capture used to append to a single run-on sentence in "### Pending Todos", growing unbounded and wrecking `git diff` readability; captures now produce one bullet per todo, capped at 240 characters, with a fail-safe refresh that leaves the section untouched on a malformed lookup. (#2618)
|
||||
@@ -1938,6 +1938,8 @@ Capture ideas, tasks, notes, and seeds to their appropriate destination. Default
|
||||
|
||||
**Produces:** `.planning/todos/` (default), note files (--note), ROADMAP.md backlog section (--backlog), `.planning/seeds/SEED-NNN-slug.md` (--seed)
|
||||
|
||||
**STATE.md rendering:** each capture (or `--list` action that changes the pending count) refreshes STATE.md's "### Pending Todos" section to one bullet per pending todo, each capped at 240 characters — `- [date] [area] title — [todo file](path) — Needs ...`. A todo with no clear next step omits the "Needs ..." clause rather than the bullet. Refresh is fail-safe: a failed or malformed lookup leaves the existing section untouched rather than clearing it.
|
||||
|
||||
```bash
|
||||
/gsd-capture "Consider adding dark mode support" # Add todo
|
||||
/gsd-capture --note "Caching strategy idea" # Quick note
|
||||
|
||||
@@ -172,9 +172,11 @@ Updated after each plan completion.
|
||||
**Decisions:** Reference to PROJECT.md Key Decisions table, plus recent decisions summary for quick access. Full decision log lives in PROJECT.md.
|
||||
|
||||
**Pending Todos:** Ideas captured via /gsd-add-todo
|
||||
- Count of pending todos
|
||||
- Reference to .planning/todos/pending/
|
||||
- Brief list if few, count if many (e.g., "5 pending todos — see /gsd:capture --list")
|
||||
- One bullet per pending todo, rendered by `init.todos`'s `pending_todos_markdown`
|
||||
(each bullet capped at 240 characters: `- [date] [area] title — [todo file](path) — Needs ...`)
|
||||
- `None yet.` when there are no pending todos
|
||||
- No collapse-by-count fallback — every pending todo gets its own line, always
|
||||
(see #2618 design doc for why a "count if many" fallback was rejected)
|
||||
|
||||
**Blockers/Concerns:** From "Next Phase Readiness" sections
|
||||
- Issues that affect future work
|
||||
|
||||
@@ -145,8 +145,9 @@ files:
|
||||
<step name="update_state">
|
||||
If `.planning/STATE.md` exists:
|
||||
|
||||
1. Use `todo_count` from init context (or re-run `init todos` if count changed)
|
||||
2. Update "### Pending Todos" under "## Accumulated Context"
|
||||
1. Re-run `gsd_run query init.todos` to get a fresh, post-write JSON snapshot (the count and rendering must reflect the change just made).
|
||||
2. **Fail-safe check (#2618):** if the JSON failed to parse, or `pending_read_ok` is not `true`, or `pending_todos_markdown` is not a string, do NOT touch the "### Pending Todos" section — leave it exactly as-is and continue to the next step. A partial or malformed `init.todos` result must never overwrite a good existing section.
|
||||
3. Otherwise, replace the entire body of "### Pending Todos" (under "## Accumulated Context") with the literal value of `pending_todos_markdown` — verbatim, one bullet per pending todo already rendered and length-capped by `init.todos`. Do not reformat, re-wrap, re-order, or hand-edit the bullets; do not append to the old body — replace it wholesale (this is what makes an old run-on-sentence section get superseded cleanly with no migration step).
|
||||
</step>
|
||||
|
||||
<step name="git_commit">
|
||||
|
||||
@@ -150,9 +150,11 @@ Return to list_todos step.
|
||||
</step>
|
||||
|
||||
<step name="update_state">
|
||||
After any action that changes todo count:
|
||||
If `.planning/STATE.md` exists:
|
||||
|
||||
Re-run `init todos` to get updated count, then update STATE.md "### Pending Todos" section if exists.
|
||||
1. Re-run `gsd_run query init.todos` to get a fresh, post-write JSON snapshot (the count and rendering must reflect the change just made).
|
||||
2. **Fail-safe check (#2618):** if the JSON failed to parse, or `pending_read_ok` is not `true`, or `pending_todos_markdown` is not a string, do NOT touch the "### Pending Todos" section — leave it exactly as-is and continue to the next step. A partial or malformed `init.todos` result must never overwrite a good existing section.
|
||||
3. Otherwise, replace the entire body of "### Pending Todos" (under "## Accumulated Context") with the literal value of `pending_todos_markdown` — verbatim, one bullet per pending todo already rendered and length-capped by `init.todos`. Do not reformat, re-wrap, re-order, or hand-edit the bullets; do not append to the old body — replace it wholesale (this is what makes an old run-on-sentence section get superseded cleanly with no migration step).
|
||||
</step>
|
||||
|
||||
<step name="git_commit">
|
||||
|
||||
@@ -123,6 +123,7 @@
|
||||
"state-prune.test.cjs",
|
||||
"state-rebuild-cli.test.cjs",
|
||||
"state-rebuild.test.cjs",
|
||||
"state-todos-render.test.cjs",
|
||||
"state-write-path-drift-guard.test.cjs",
|
||||
"state.test.cjs"
|
||||
],
|
||||
|
||||
137
src/init.cts
137
src/init.cts
@@ -12,6 +12,7 @@ import os from 'node:os';
|
||||
import { execGit, platformWriteSync, platformReadSync, toNativePath, posixNormalize } from './shell-command-projection.cjs';
|
||||
import { realClock } from './clock.cjs';
|
||||
import { escapeRegex } from './pattern.cjs';
|
||||
import { collectSection } from './markdown-sectionizer.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- io.cjs is an export= CommonJS module
|
||||
import io = require('./io.cjs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- config-loader.cjs is an export= CommonJS module
|
||||
@@ -2233,15 +2234,118 @@ function cmdInitPhaseOp(cwd: string, phase: string, raw: boolean): void {
|
||||
output(withProjectRoot(cwd, result), raw);
|
||||
}
|
||||
|
||||
// #2618: bullet-cap and title-floor for renderPendingTodosMarkdown below.
|
||||
// 240 matches the bound already vetted by maintainer review on the prior
|
||||
// attempt at this issue (PR #2662) — re-deriving a different number would be
|
||||
// pure bikeshedding, not a correctness improvement. See
|
||||
// .gsd/phase/feat-2618-compact-todo-pointers/40-design.md.
|
||||
const PENDING_TODO_BULLET_MAX_CHARS = 240;
|
||||
const PENDING_TODO_TITLE_FLOOR = 15;
|
||||
const PENDING_TODO_AREA_FLOOR = 3;
|
||||
|
||||
function sanitizePendingTodoInline(value: string): string {
|
||||
// Defensive: the regex captures that populate title/area/needs can only
|
||||
// ever match a single line, so this is belt-and-suspenders against any
|
||||
// future non-regex-sourced input, not a reachable case today.
|
||||
return value.replace(/[\r\n]+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function truncatePendingTodoText(value: string, maxLen: number): string {
|
||||
if (value.length <= maxLen) return value;
|
||||
if (maxLen <= 1) return value.slice(0, Math.max(0, maxLen));
|
||||
return `${value.slice(0, maxLen - 1)}…`;
|
||||
}
|
||||
|
||||
/**
|
||||
* #2618: pure renderer for STATE.md's "### Pending Todos" section BODY (not
|
||||
* the heading). One bullet per todo, each capped at
|
||||
* PENDING_TODO_BULLET_MAX_CHARS. `gsd-core/workflows/add-todo.md` and
|
||||
* `check-todos.md` splice this string in verbatim instead of free-hand
|
||||
* editing STATE.md — see the design doc for why this is real, unit-tested
|
||||
* code rather than a prose algorithm (DEFECT.GENERATIVE-FIX: a prose
|
||||
* algorithm duplicated as a test oracle is exactly the divergence class
|
||||
* this avoids).
|
||||
*/
|
||||
function renderPendingTodosMarkdown(todos: Record<string, unknown>[]): string {
|
||||
if (!Array.isArray(todos) || todos.length === 0) {
|
||||
return 'None yet.';
|
||||
}
|
||||
return todos.map((todo) => renderPendingTodoBullet(todo)).join('\n');
|
||||
}
|
||||
|
||||
function pendingTodoFieldAsString(value: unknown, fallback: string): string {
|
||||
return typeof value === 'string' && value.length > 0 ? value : fallback;
|
||||
}
|
||||
|
||||
function renderPendingTodoBullet(todo: Record<string, unknown>): string {
|
||||
const date = sanitizePendingTodoInline(pendingTodoFieldAsString(todo['created'], 'unknown'));
|
||||
let area = sanitizePendingTodoInline(pendingTodoFieldAsString(todo['area'], 'general'));
|
||||
let title = sanitizePendingTodoInline(pendingTodoFieldAsString(todo['title'], 'Untitled'));
|
||||
// Strip trailing "." so the fixed "Needs ....` template below never
|
||||
// produces a doubled period when the source text already ended in one.
|
||||
let needs =
|
||||
typeof todo['needs'] === 'string'
|
||||
? sanitizePendingTodoInline(todo['needs']).replace(/\.+$/, '')
|
||||
: '';
|
||||
const link = `[todo file](${pendingTodoFieldAsString(todo['path'], '')})`;
|
||||
|
||||
const assemble = (): string => {
|
||||
const needsClause = needs ? ` — Needs ${needs}.` : '';
|
||||
return `- [${date}] [${area}] ${title} — ${link}${needsClause}`;
|
||||
};
|
||||
|
||||
let line = assemble();
|
||||
if (line.length <= PENDING_TODO_BULLET_MAX_CHARS) return line;
|
||||
|
||||
// 1) Drop the needs clause entirely first — date/area/title/link untouched.
|
||||
needs = '';
|
||||
line = assemble();
|
||||
if (line.length <= PENDING_TODO_BULLET_MAX_CHARS) return line;
|
||||
|
||||
// 2) Shorten the title next, down to a floor — date/area/link untouched.
|
||||
const titleOverage = line.length - PENDING_TODO_BULLET_MAX_CHARS;
|
||||
const targetTitleLen = Math.max(PENDING_TODO_TITLE_FLOOR, title.length - titleOverage);
|
||||
if (targetTitleLen < title.length) {
|
||||
title = truncatePendingTodoText(title, targetTitleLen);
|
||||
line = assemble();
|
||||
}
|
||||
if (line.length <= PENDING_TODO_BULLET_MAX_CHARS) return line;
|
||||
|
||||
// 3) Shorten area as a last resort — date and the markdown link are never
|
||||
// altered (link correctness > strict cap; see design doc "Known limits").
|
||||
const areaOverage = line.length - PENDING_TODO_BULLET_MAX_CHARS;
|
||||
const targetAreaLen = Math.max(PENDING_TODO_AREA_FLOOR, area.length - areaOverage);
|
||||
if (targetAreaLen < area.length) {
|
||||
area = truncatePendingTodoText(area, targetAreaLen);
|
||||
line = assemble();
|
||||
}
|
||||
|
||||
return line;
|
||||
}
|
||||
|
||||
function cmdInitTodos(cwd: string, area: string | undefined, raw: boolean): void {
|
||||
const config = loadConfig(cwd);
|
||||
|
||||
const pendingDir = path.join(planningDir(cwd), 'todos', 'pending');
|
||||
let count = 0;
|
||||
const todos: Record<string, unknown>[] = [];
|
||||
// #2618: distinct from "genuinely zero pending todos" — false only when
|
||||
// readdirSync itself failed for a reason OTHER than the directory simply
|
||||
// not existing yet (ENOENT), mirroring the ENOENT-vs-other-errno split
|
||||
// already used above in this file (#3885, ADR-3473 §8.5). Without this,
|
||||
// a real I/O/permission error on the pending dir would look identical to
|
||||
// "no pending todos" and could wipe an existing, non-empty Pending Todos
|
||||
// section in STATE.md on refresh — the fail-safe requirement for #2618.
|
||||
let pendingReadOk = true;
|
||||
|
||||
try {
|
||||
const files = fs.readdirSync(pendingDir).filter((f) => f.endsWith('.md'));
|
||||
// #2618: sorted so pending_todos_markdown's bullet order is stable across
|
||||
// runs — readdirSync's order is filesystem-dependent, not contractually
|
||||
// stable, and an unstable order would reorder every bullet on an
|
||||
// unrelated re-render, turning a one-line git diff into a full-section
|
||||
// rewrite (must-have #3). Filenames are `YYYY-MM-DD-slug.md`, so this
|
||||
// also yields a sensible chronological order as a side effect.
|
||||
const files = fs.readdirSync(pendingDir).filter((f) => f.endsWith('.md')).sort();
|
||||
for (const file of files) {
|
||||
const content = platformReadSync(path.join(pendingDir, file));
|
||||
if (content === null) continue;
|
||||
@@ -2252,6 +2356,21 @@ function cmdInitTodos(cwd: string, area: string | undefined, raw: boolean): void
|
||||
// #2337: kept in parity with cmdListTodos — surface severity when
|
||||
// present, omit the key entirely for todos with no severity line.
|
||||
const severityMatch = content.match(/^severity:\s*(.+)$/m);
|
||||
// #2618: first non-empty line of the `## Solution` body, used as the
|
||||
// bullet's "Needs ..." clause. "TBD" (the create_file template's own
|
||||
// placeholder for an unresolved solution) renders no clause at all
|
||||
// rather than the useless literal "Needs TBD.".
|
||||
const solutionSection = collectSection(content, (h) => h.level === 2 && h.text.trim() === 'Solution');
|
||||
let needs: string | undefined;
|
||||
if (solutionSection) {
|
||||
const firstLine = solutionSection.body
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.find((l) => l.length > 0);
|
||||
if (firstLine && firstLine.toUpperCase() !== 'TBD') {
|
||||
needs = firstLine;
|
||||
}
|
||||
}
|
||||
const todoArea = areaMatch ? areaMatch[1].trim() : 'general';
|
||||
|
||||
if (area && todoArea !== area) continue;
|
||||
@@ -2265,13 +2384,17 @@ function cmdInitTodos(cwd: string, area: string | undefined, raw: boolean): void
|
||||
// #2376: absolute — see comment on phase_dir in cmdInitExecutePhase.
|
||||
path: toPosixPath(path.join(planningDir(cwd), 'todos', 'pending', file)),
|
||||
...(severityMatch ? { severity: severityMatch[1].trim() } : {}),
|
||||
...(needs ? { needs } : {}),
|
||||
});
|
||||
} catch {
|
||||
/* intentionally empty */
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* intentionally empty */
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException)?.code;
|
||||
if (code !== 'ENOENT') {
|
||||
pendingReadOk = false;
|
||||
}
|
||||
}
|
||||
|
||||
const result: Record<string, unknown> = {
|
||||
@@ -2291,6 +2414,13 @@ function cmdInitTodos(cwd: string, area: string | undefined, raw: boolean): void
|
||||
planning_exists: fs.existsSync(planningDir(cwd)),
|
||||
todos_dir_exists: fs.existsSync(path.join(planningDir(cwd), 'todos')),
|
||||
pending_dir_exists: fs.existsSync(path.join(planningDir(cwd), 'todos', 'pending')),
|
||||
|
||||
// #2618: see PENDING_TODO_BULLET_MAX_CHARS comment / design doc. Consumed
|
||||
// by add-todo.md / check-todos.md's update_state step; omitted entirely
|
||||
// (rather than emitted with possibly-wrong data) when pendingReadOk is
|
||||
// false, so the workflow's fail-safe check can key off field presence.
|
||||
pending_read_ok: pendingReadOk,
|
||||
...(pendingReadOk ? { pending_todos_markdown: renderPendingTodosMarkdown(todos) } : {}),
|
||||
};
|
||||
|
||||
output(withProjectRoot(cwd, result), raw);
|
||||
@@ -4188,4 +4318,5 @@ export = {
|
||||
cmdAgentSkills,
|
||||
buildSkillManifest,
|
||||
cmdSkillManifest,
|
||||
renderPendingTodosMarkdown,
|
||||
};
|
||||
|
||||
270
tests/state-todos-render.test.cjs
Normal file
270
tests/state-todos-render.test.cjs
Normal file
@@ -0,0 +1,270 @@
|
||||
'use strict';
|
||||
|
||||
// #2618: deterministic renderer for STATE.md's "### Pending Todos" section
|
||||
// body. See .gsd/phase/feat-2618-compact-todo-pointers/40-design.md and
|
||||
// 50-test-matrix.md for the full rationale and case list.
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const fc = require('fast-check');
|
||||
|
||||
const initLib = require('../gsd-core/bin/lib/init.cjs');
|
||||
const { renderPendingTodosMarkdown } = initLib;
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
const { escapeRegex } = require('../gsd-core/bin/lib/pattern.cjs');
|
||||
|
||||
const MAX = 240;
|
||||
|
||||
function makeTodo(overrides) {
|
||||
return Object.assign(
|
||||
{
|
||||
created: '2026-09-01',
|
||||
area: 'api',
|
||||
title: 'Fix retry logic',
|
||||
path: '.planning/todos/pending/2026-09-01-fix-retry-logic.md',
|
||||
},
|
||||
overrides,
|
||||
);
|
||||
}
|
||||
|
||||
test('renderPendingTodosMarkdown: zero todos renders exactly "None yet."', () => {
|
||||
assert.equal(renderPendingTodosMarkdown([]), 'None yet.');
|
||||
});
|
||||
|
||||
test('renderPendingTodosMarkdown: one todo without a needs clause', () => {
|
||||
const body = renderPendingTodosMarkdown([makeTodo()]);
|
||||
const lines = body.split('\n');
|
||||
assert.equal(lines.length, 1);
|
||||
assert.match(lines[0], /^- \[2026-09-01\] \[api\] Fix retry logic — \[todo file\]\(.+\)$/);
|
||||
assert.doesNotMatch(lines[0], /Needs/);
|
||||
});
|
||||
|
||||
test('renderPendingTodosMarkdown: "TBD" solution omits the needs clause', () => {
|
||||
const body = renderPendingTodosMarkdown([makeTodo({ needs: undefined })]);
|
||||
assert.doesNotMatch(body, /Needs/);
|
||||
});
|
||||
|
||||
test('renderPendingTodosMarkdown: real solution text becomes a "Needs ..." clause', () => {
|
||||
const body = renderPendingTodosMarkdown([makeTodo({ needs: 'define retry behavior' })]);
|
||||
assert.match(body, /Needs define retry behavior\.$/);
|
||||
});
|
||||
|
||||
test('renderPendingTodosMarkdown: needs text already ending in "." does not double the period', () => {
|
||||
const body = renderPendingTodosMarkdown([makeTodo({ needs: 'Add a max-attempts cap.' })]);
|
||||
assert.match(body, /Needs Add a max-attempts cap\.$/);
|
||||
assert.doesNotMatch(body, /\.\.$/);
|
||||
});
|
||||
|
||||
test('renderPendingTodosMarkdown: many todos render one bullet per todo, in order', () => {
|
||||
const todos = Array.from({ length: 5 }, (_, i) =>
|
||||
makeTodo({ title: `Todo number ${i}`, path: `.planning/todos/pending/todo-${i}.md` }),
|
||||
);
|
||||
const body = renderPendingTodosMarkdown(todos);
|
||||
const lines = body.split('\n');
|
||||
assert.equal(lines.length, 5);
|
||||
lines.forEach((line, i) => {
|
||||
assert.match(line, new RegExp(`Todo number ${i} `));
|
||||
});
|
||||
});
|
||||
|
||||
test('renderPendingTodosMarkdown: markdown-special characters in title/area render verbatim', () => {
|
||||
const body = renderPendingTodosMarkdown([
|
||||
makeTodo({ title: 'Fix `parse()` for [bracketed] input', area: 'db|cache' }),
|
||||
]);
|
||||
assert.match(body, /Fix `parse\(\)` for \[bracketed\] input/);
|
||||
assert.match(body, /\[db\|cache\]/);
|
||||
});
|
||||
|
||||
test('renderPendingTodosMarkdown: boundary — 239 chars (limit-1) is not truncated', () => {
|
||||
// Assemble a title so the full line lands at exactly 239 chars, then assert
|
||||
// byte-identical (untruncated) output.
|
||||
const base = makeTodo({ needs: undefined, title: 'X' });
|
||||
const probe = renderPendingTodosMarkdown([base]);
|
||||
const pad = 239 - probe.length;
|
||||
assert.ok(pad > 0, 'test fixture sanity: base line must be shorter than 239 chars');
|
||||
const title = 'X'.repeat(1 + pad);
|
||||
const todo = makeTodo({ needs: undefined, title });
|
||||
const line = renderPendingTodosMarkdown([todo]);
|
||||
assert.equal(line.length, 239);
|
||||
assert.equal(line, `- [2026-09-01] [api] ${title} — [todo file](${todo.path})`);
|
||||
});
|
||||
|
||||
test('renderPendingTodosMarkdown: boundary — 240 chars (limit) is not truncated', () => {
|
||||
const base = makeTodo({ needs: undefined, title: 'X' });
|
||||
const probe = renderPendingTodosMarkdown([base]);
|
||||
const pad = 240 - probe.length;
|
||||
const title = 'X'.repeat(1 + pad);
|
||||
const todo = makeTodo({ needs: undefined, title });
|
||||
const line = renderPendingTodosMarkdown([todo]);
|
||||
assert.equal(line.length, 240);
|
||||
assert.equal(line, `- [2026-09-01] [api] ${title} — [todo file](${todo.path})`);
|
||||
});
|
||||
|
||||
test('renderPendingTodosMarkdown: boundary — 241 chars (limit+1) truncates, needs dropped first', () => {
|
||||
const base = makeTodo({ needs: 'x', title: 'X' });
|
||||
const probe = renderPendingTodosMarkdown([base]);
|
||||
const pad = 241 - probe.length;
|
||||
const title = 'X'.repeat(1 + Math.max(pad, 0));
|
||||
const todo = makeTodo({ needs: 'a real needs clause that should get dropped', title });
|
||||
const line = renderPendingTodosMarkdown([todo]);
|
||||
assert.ok(line.length <= MAX, `expected <= ${MAX}, got ${line.length}`);
|
||||
assert.doesNotMatch(line, /Needs/, 'needs clause must be dropped before title is touched');
|
||||
});
|
||||
|
||||
test('renderPendingTodosMarkdown: title truncated with floor + ellipsis when needs-drop is insufficient', () => {
|
||||
const longTitle = 'A'.repeat(400);
|
||||
const todo = makeTodo({ title: longTitle, needs: 'something' });
|
||||
const line = renderPendingTodosMarkdown([todo]);
|
||||
assert.ok(line.length <= MAX, `expected <= ${MAX}, got ${line.length}`);
|
||||
assert.doesNotMatch(line, /Needs/);
|
||||
assert.match(line, /…/);
|
||||
// date/area/link remain byte-identical to the untruncated assembly.
|
||||
assert.match(line, /^- \[2026-09-01\] \[api\] /);
|
||||
assert.match(line, new RegExp(`\\[todo file\\]\\(${escapeRegex(todo.path)}\\)$`));
|
||||
});
|
||||
|
||||
test('renderPendingTodosMarkdown: pathological — title floor + link alone exceeds cap is allowed to overflow', () => {
|
||||
const veryLongPath = `.planning/todos/pending/${'p'.repeat(400)}.md`;
|
||||
const todo = makeTodo({ title: 'A'.repeat(400), path: veryLongPath, needs: 'x' });
|
||||
const line = renderPendingTodosMarkdown([todo]);
|
||||
// Documented known limit: link is never sacrificed even if it blows the cap.
|
||||
assert.ok(line.includes(veryLongPath), 'link must remain verbatim even when cap is exceeded');
|
||||
});
|
||||
|
||||
test('property: rendered body always has one line per todo, each line <= 240 chars, link preserved verbatim', () => {
|
||||
// Path length is bounded so the algorithm's floor assembly (date + 3-char
|
||||
// area floor + 15-char title floor + link, needs always droppable) never
|
||||
// exceeds 240 — i.e. the cap is always achievable without touching the
|
||||
// link. This keeps the <= 240 assertion a real, provable invariant rather
|
||||
// than a vacuous one; the pathological "cap not achievable" case is
|
||||
// covered separately by the fixed "pathological" unit test above.
|
||||
const todoArb = fc.record({
|
||||
created: fc.constantFrom('2026-01-01', '2025-12-31', 'unknown'),
|
||||
area: fc.string({ minLength: 0, maxLength: 40 }),
|
||||
title: fc.string({ minLength: 0, maxLength: 500 }),
|
||||
path: fc
|
||||
.string({ minLength: 1, maxLength: 150 })
|
||||
.map((s) => `.planning/todos/pending/${s}.md`),
|
||||
needs: fc.option(fc.string({ minLength: 0, maxLength: 300 }), { nil: undefined }),
|
||||
});
|
||||
|
||||
fc.assert(
|
||||
fc.property(fc.array(todoArb, { minLength: 0, maxLength: 20 }), (todos) => {
|
||||
const body = renderPendingTodosMarkdown(todos);
|
||||
if (todos.length === 0) {
|
||||
return body === 'None yet.';
|
||||
}
|
||||
const lines = body.split('\n');
|
||||
if (lines.length !== todos.length) return false;
|
||||
return lines.every((line, i) => {
|
||||
const link = `[todo file](${todos[i].path})`;
|
||||
return line.length <= 240 && line.includes(link);
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// ─── pending_read_ok / pending_todos_markdown via the real CLI surface ─────
|
||||
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
function runQueryInitTodos(cwd) {
|
||||
const gsdTools = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
|
||||
const result = spawnSync(process.execPath, [gsdTools, 'query', 'init.todos'], {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
timeout: 15000,
|
||||
});
|
||||
assert.equal(result.status, 0, `gsd_run query init.todos failed: ${result.stderr}`);
|
||||
return JSON.parse(result.stdout);
|
||||
}
|
||||
|
||||
test('cmdInitTodos: pending_read_ok is true and pending_todos_markdown present for a healthy empty dir', (t) => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2618-'));
|
||||
t.after(() => cleanup(dir));
|
||||
fs.mkdirSync(path.join(dir, '.planning', 'todos', 'pending'), { recursive: true });
|
||||
|
||||
const json = runQueryInitTodos(dir);
|
||||
assert.equal(json.pending_read_ok, true);
|
||||
assert.equal(json.pending_todos_markdown, 'None yet.');
|
||||
assert.equal(json.todo_count, 0);
|
||||
});
|
||||
|
||||
test('cmdInitTodos: real todo file produces a rendered bullet via the CLI', (t) => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2618-'));
|
||||
t.after(() => cleanup(dir));
|
||||
const pendingDir = path.join(dir, '.planning', 'todos', 'pending');
|
||||
fs.mkdirSync(pendingDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(pendingDir, '2026-09-01-fix-retry-logic.md'),
|
||||
[
|
||||
'---',
|
||||
'created: 2026-09-01T00:00:00.000Z',
|
||||
'title: Fix retry logic',
|
||||
'area: api',
|
||||
'severity: major',
|
||||
'files:',
|
||||
' - src/api/client.cts:42',
|
||||
'---',
|
||||
'',
|
||||
'## Problem',
|
||||
'',
|
||||
'Retries are unbounded.',
|
||||
'',
|
||||
'## Solution',
|
||||
'',
|
||||
'Add a max-attempts cap.',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const json = runQueryInitTodos(dir);
|
||||
assert.equal(json.pending_read_ok, true);
|
||||
assert.equal(json.todo_count, 1);
|
||||
assert.match(json.pending_todos_markdown, /Fix retry logic/);
|
||||
assert.match(json.pending_todos_markdown, /Needs Add a max-attempts cap\.$/m);
|
||||
});
|
||||
|
||||
test('cmdInitTodos: bullet order is filename-sorted regardless of write/insertion order', (t) => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2618-'));
|
||||
t.after(() => cleanup(dir));
|
||||
const pendingDir = path.join(dir, '.planning', 'todos', 'pending');
|
||||
fs.mkdirSync(pendingDir, { recursive: true });
|
||||
// Written in reverse-alphabetical insertion order on purpose — readdirSync
|
||||
// order is filesystem-dependent, not contractually stable, so the ONLY way
|
||||
// to assert a deterministic bullet order is to sort explicitly. See #2618
|
||||
// spec review finding on git-diff stability (must-have #3).
|
||||
const todoBody = (title) =>
|
||||
['---', 'created: 2026-09-01', `title: ${title}`, 'area: api', '---', ''].join('\n');
|
||||
fs.writeFileSync(path.join(pendingDir, '2026-09-03-third.md'), todoBody('Third todo'));
|
||||
fs.writeFileSync(path.join(pendingDir, '2026-09-01-first.md'), todoBody('First todo'));
|
||||
fs.writeFileSync(path.join(pendingDir, '2026-09-02-second.md'), todoBody('Second todo'));
|
||||
|
||||
const json = runQueryInitTodos(dir);
|
||||
const titles = json.todos.map((t2) => t2.title);
|
||||
assert.deepEqual(titles, ['First todo', 'Second todo', 'Third todo']);
|
||||
const lines = json.pending_todos_markdown.split('\n');
|
||||
assert.ok(lines[0].includes('First todo'));
|
||||
assert.ok(lines[1].includes('Second todo'));
|
||||
assert.ok(lines[2].includes('Third todo'));
|
||||
});
|
||||
|
||||
// ─── workflow parity guard (DEFECT.GENERATIVE-FIX) ─────────────────────────
|
||||
|
||||
function extractUpdateStateStep(workflowPath) {
|
||||
const content = fs.readFileSync(workflowPath, 'utf8');
|
||||
const match = content.match(/<step name="update_state">([\s\S]{0,20000}?)<\/step>/);
|
||||
assert.ok(match, `update_state step not found in ${workflowPath}`);
|
||||
return match[1].trim();
|
||||
}
|
||||
|
||||
test('workflow parity: add-todo.md and check-todos.md update_state steps are byte-identical', () => {
|
||||
const addTodoPath = path.join(__dirname, '..', 'gsd-core', 'workflows', 'add-todo.md');
|
||||
const checkTodosPath = path.join(__dirname, '..', 'gsd-core', 'workflows', 'check-todos.md');
|
||||
const a = extractUpdateStateStep(addTodoPath);
|
||||
const b = extractUpdateStateStep(checkTodosPath);
|
||||
assert.equal(a, b, 'update_state step prose must be identical across both workflows (DEFECT.GENERATIVE-FIX)');
|
||||
});
|
||||
Reference in New Issue
Block a user