fix(story-lifecycle): reject Points outside 1, 2, 3, 5, 8

A non-numeric, negative, or exponent Points value was persisted and
silently skewed the derived sprint total (bogus -> 0, -3, 1e3 -> 1000)
with no drift reported. parseStory now enforces the documented values,
so apply rejects the batch and status reports the invalid story.
This commit is contained in:
t0kubetsu
2026-10-07 12:05:53 +02:00
parent 7f797d65c4
commit bf5cd6c95e
4 changed files with 21 additions and 4 deletions
File diff suppressed because one or more lines are too long
+3 -2
View File
@@ -132,8 +132,9 @@ Guarantees the helper enforces:
deleted. If a process dies while holding `.lock.break`, writers time out with code `3`;
delete `.lock.break` by hand only after checking that no ledger command is running.
- **Valid transitions and sprints.** `apply` rejects a new story whose status is not
`todo`, any status change other than one step forward, and a `Sprint` value that is not
`unassigned` or an existing `sprints/sprint-<n>.md`.
`todo`, any status change other than one step forward, a `Sprint` value that is not
`unassigned` or an existing `sprints/sprint-<n>.md`, and a `Points` value other than
`1`, `2`, `3`, `5`, or `8`.
- **Confinement.** The helper refuses to run if `.stories/` or its `epics/`, `sprints/`,
`.txn/`, or `.ids/` directory is a symlink, and refuses to read or write any epic,
sprint, or story file that is a symlink. Recovery accepts only regular `backup-<n>`
+3 -1
View File
@@ -14,6 +14,7 @@ const EPIC_FILE = /^epics\/([a-z0-9]+(?:-[a-z0-9]+)*)\.md$/;
const SPRINT_FILE = /^sprints\/sprint-([1-9][0-9]*)\.md$/;
const STATUSES = ['todo', 'in-progress', 'review', 'done'];
const SPRINT_VALUE = /^(unassigned|[1-9][0-9]*)$/;
const POINTS = ['1', '2', '3', '5', '8'];
const BACKUP = /^backup-[0-9]+$/;
const DERIVED = '<!-- Derived from story files';
@@ -161,6 +162,7 @@ function parseStory(rel, md) {
if (field(md, 'ID') !== id || field(md, 'Epic') !== epic) throw new LedgerError(`${rel}: ID/Epic fields must match file name`);
if (!story.title || !STATUSES.includes(story.status)) throw new LedgerError(`${rel}: missing title or invalid status`);
if (!SPRINT_VALUE.test(story.sprint || '')) throw new LedgerError(`${rel}: Sprint must be unassigned or a sprint number`);
if (!POINTS.includes(story.points)) throw new LedgerError(`${rel}: Points must be one of ${POINTS.join(', ')}`);
return story;
}
@@ -199,7 +201,7 @@ function project(dir, pending = {}) {
for (const rel of sprints) {
const inSprint = stories.filter((s) => s.sprint === rel.match(SPRINT_FILE)[1]);
const rows = inSprint.map((s) => `| ${s.id} | ${s.title} | ${s.points} | ${s.status} |`);
const total = inSprint.reduce((sum, s) => sum + (Number(s.points) || 0), 0);
const total = inSprint.reduce((sum, s) => sum + Number(s.points), 0);
const next = replaceTable(read(rel), rel, '| ID | Title | Points | Status |\n| --- | --- | --- | --- |', rows)
.replace(/^## Total Points:.*$/m, `## Total Points: ${total}`);
if (next !== read(rel)) derived[rel] = next;
+14
View File
@@ -347,6 +347,20 @@ fs.readFileSync = function (p, ...a) { const r = orig.call(fs, p, ...a);
assert.match(status.err, /sprint 7 does not exist/);
}],
['story points must be one of 1, 2, 3, 5, 8 so sprint totals stay exact', () => {
const root = newRepo();
const [id] = run(root, ['allocate', '--epic', 'auth-flow']).out.ids;
for (const points of ['bogus', '4', '-3', '1e3', '0', '']) {
const input = writeJson(root, 'pts.json', { create: { [`${id}.md`]: story(id, 'Login', { points }) } });
assert.strictEqual(run(root, ['apply', '--input', input]).code, 2, `Points=${points}`);
}
assert.deepStrictEqual(storyFiles(root), []);
fs.writeFileSync(path.join(root, '.stories', `${id}.md`), story(id, 'Login', { points: 'bogus' }));
const status = run(root, ['status']);
assert.strictEqual(status.code, 2);
assert.match(status.err, /Points must be one of/);
}],
['ledger paths are confined to .stories/ file shapes', () => {
const dir = path.join(os.tmpdir(), '.stories');
for (const bad of ['../x-1.md', 'epics/../../x.md', 'Auth-1.md', 'a/b-1.md', 'epics/a.txt', 'sprints/sprint-0.md', '/abs-1.md']) {