mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-18 22:13:45 +02:00
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:
File diff suppressed because one or more lines are too long
@@ -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>`
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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']) {
|
||||
|
||||
Reference in New Issue
Block a user