mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-11-02 08:23:13 +08:00
* fix(validate): warn when tracked tasks have no checkboxes Progress counts checkboxes and nothing else, so a tasks.md written as plain bullets or a numbered list is worse than an empty one: `openspec list` and `openspec status` report "No tasks", and `openspec archive` has no incomplete task to warn about. The file reads as finished to the tool and unfinished to a human. `openspec validate` now warns when every task file the change's schema tracks contains list items but not one checkbox, pointing at the first offending line. Reported per change, not per file, so a checklist alongside a prose file stays silent, and only files an artifact actually declares are linted - a bare tasks.md no schema tracks is left alone. Closes #354 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(validate): honor fence delimiter length and width CommonMark closes a fence only on the same character, a run at least as long as the opener's, and no info string. Comparing the first character alone let an inner ``` end an outer ```` block, exposing the bullets of a nested code sample as a task list. The delimiter pattern also loses its end anchor: `.` does not match `\r`, so an anchored info-string group matched nothing in a CRLF file and blinded the scan to fences. Adds the nested-fence, annotated-closer, tilde/backtick, longer-closer and CRLF cases, plus an e2e change whose nested task files are all bullets, asserting both reported paths stay POSIX-separated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(validate): scan rendered content and complete evidence only Hardening pass over the checkbox warning. The scan for the offending line now skips YAML front matter and HTML comment blocks alongside fenced code. A list under `tags:` is metadata about the file rather than the work it tracks, and a commented-out list is not work either; each exclusion can only silence a warning, never drop a real task, which is the opposite trade from the task parser. An unterminated `---` opener rewinds to the top, because that is a thematic break and everything below it is still content. Only a comment opening its own line hides that line, so the template's `## 1. <!-- Task Group Name -->` heading cannot swallow the checklist beneath it. A tracked file that exists but cannot be read now withdraws the warning entirely: "no file here holds a checkbox" is a claim about the whole tracked set, and the checkboxes may be in exactly the file that would not open. `validate --archived` stays the surface that reports an unreadable task file loudly (#205). The message leads with the consequence rather than an accusation, since a file may legitimately carry a bulleted note and no tasks yet. New coverage: every packaged tasks template is asserted checkbox-shaped (the guard fails if a template loses its boxes), a schema tracking tasks by artifact id with no `apply` block, the deprecated `change validate` text output, and an unreadable tracked file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(validate): match CommonMark on fence indent and front matter Two block-scanning defects, both of which hid list items. A fence indented four spaces is an indented code block, not an opener. Accepting it left the scan inside a block that never began, so every list below it went unseen. Fence recognition now stops at three spaces. `----` is a thematic break, not a YAML front-matter delimiter. Matching three-or-more dashes let one open a block that swallowed the list under it until the next `---`. Front matter is now exactly three dashes. Two test defects alongside them. The deprecated-command test claimed to assert the reported line, but the text renderer prints no line for any issue; it now asserts the level and path prefix that surface actually emits, with the line left to the JSON assertion that already covers it. The unreadable-file fixture would have passed for the wrong reason had the mode not taken, since the checkbox it hides would have silenced the warning by itself; the read failure is now asserted first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(validate): name task files relative to a canonical change dir Windows CI caught the report naming a task file `../../../../../../../../runneradmin/AppData/.../tasks.md` instead of `tasks.md`. `resolveArtifactOutputs` hands back real paths while `changeDir` carries whatever spelling the caller resolved, and a short 8.3 alias against its expanded form is a difference in spelling, not in location, so the relative path escaped the change. A symlinked project directory reproduces it off Windows. Canonicalizing both sides recovers the relationship. A path that still escapes falls back to the file name, so no report can leak an absolute filesystem path. Numbering issues are named through the same helper and gain the same fix. The deprecated-command test now asserts the `[WARNING] tasks.md:` prefix that exposed this, and the Windows job is its regression guard: the mismatch cannot be staged on POSIX, where the spawned CLI's cwd is already physical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(validate): keep indented code out of the uncheckboxed-task scan alfred-openspec on #1774: the scan said it reads rendered content, but LIST_ITEM began with \s* and so reported top-level four-space-indented code such as ' - example output' as an uncheckboxed task list. Under --strict that false positive failed validation on a correct file. A list-shaped line is now taken only below four visual columns of indent, the same cut the fence logic already applies, with tabs counting as four. Genuine nested lists are untouched: this scan reports the first list item it finds and a nested item always sits under a shallower parent, so the parent is reported exactly as before. A list-shaped line four columns deep with nothing shallower above it is not nested under anything, which is what makes it code. Regressions cover space-indented, tab-indented and numbered code samples, and pin both the nested-list case (parent still reported) and three-space indent (not code). Verified the guard bites: removing the column test fails them. Also moves the documentation to its canonical home. docs-lab/README.md says the old docs/ tree is legacy and must stay untouched, so the docs/concepts.md line is dropped and the warning is documented under 'openspec validate' in docs-lab/reference/cli.md, beside the archive merge findings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(validate): skip BOM-prefixed front matter and cap ordered markers at nine digits A prose-only task file that opened with a UTF-8 BOM before its front matter was warned about, because the opener never matched and the tags list was scanned. A number longer than nine digits followed by a period also matched as a list item, which CommonMark does not allow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(changeset): drop the em dash Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(tasks): use a multi-character marker for the unrecognised-checkbox case A single-character marker such as `[~]` becomes a task once #1773 lands, which would flip this expectation. `[ab]` is not a task under either parser, so the test keeps asserting that checkbox-looking list items that count as no task still warn, whichever PR merges first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
257 lines
10 KiB
TypeScript
257 lines
10 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
import { promises as fs } from 'fs';
|
||
import path from 'path';
|
||
import { fileURLToPath } from 'url';
|
||
import fg from 'fast-glob';
|
||
import { findMissingTaskCheckboxIssues } from '../../src/core/validation/task-checkboxes.js';
|
||
|
||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||
|
||
const findInSingleFile = (content: string) =>
|
||
findMissingTaskCheckboxIssues([{ path: 'tasks.md', content }]).map(
|
||
({ path: _path, ...issue }) => issue
|
||
);
|
||
|
||
describe('findMissingTaskCheckboxIssues', () => {
|
||
it('reports a task list written as plain bullets', () => {
|
||
const issues = findInSingleFile(
|
||
['# Tasks', '', '## 1. Implementation', '', '- Add the parser', '- Add the tests', ''].join(
|
||
'\n'
|
||
)
|
||
);
|
||
|
||
expect(issues).toEqual([
|
||
{
|
||
line: 5,
|
||
message: expect.stringContaining('counts as 0 tasks'),
|
||
},
|
||
]);
|
||
});
|
||
|
||
it('reports a task list written as a numbered list', () => {
|
||
expect(findInSingleFile('# Tasks\n\n1. Add the parser\n2. Add the tests\n')).toEqual([
|
||
{ line: 3, message: expect.any(String) },
|
||
]);
|
||
expect(findInSingleFile('1) Add the parser\n')).toEqual([
|
||
{ line: 1, message: expect.any(String) },
|
||
]);
|
||
});
|
||
|
||
it('stays silent when checkboxes are present', () => {
|
||
expect(findInSingleFile('- [ ] 1.1 Add the parser\n- Supporting note\n')).toEqual([]);
|
||
expect(findInSingleFile('- [x] 1.1 Add the parser\n')).toEqual([]);
|
||
expect(findInSingleFile(' - [ ] 1.1.1 A nested task\n')).toEqual([]);
|
||
});
|
||
|
||
it('stays silent on prose and on an empty file', () => {
|
||
expect(findInSingleFile('# Tasks\n\nNothing planned yet.\n')).toEqual([]);
|
||
expect(findInSingleFile('')).toEqual([]);
|
||
expect(findMissingTaskCheckboxIssues([])).toEqual([]);
|
||
});
|
||
|
||
it('ignores list items inside fenced blocks', () => {
|
||
expect(
|
||
findInSingleFile(['# Tasks', '', '```md', '- an example bullet', '```', ''].join('\n'))
|
||
).toEqual([]);
|
||
expect(
|
||
findInSingleFile(
|
||
['~~~', '- fenced with tildes', '~~~', '', '- a real bullet', ''].join('\n')
|
||
)
|
||
).toEqual([{ line: 5, message: expect.any(String) }]);
|
||
});
|
||
|
||
it('closes a fence only on a matching, long enough, bare delimiter', () => {
|
||
// A three-marker sample nested inside a four-marker block: the inner run is
|
||
// content, so the bullets after it are still fenced.
|
||
expect(
|
||
findInSingleFile(
|
||
['````md', '```', '- an example bullet', '```', '````', ''].join('\n')
|
||
)
|
||
).toEqual([]);
|
||
// An annotated run is an opener's shape, never a closer's.
|
||
expect(
|
||
findInSingleFile(['```', '```js', '- an example bullet', '```', ''].join('\n'))
|
||
).toEqual([]);
|
||
// Tildes do not close a backtick fence.
|
||
expect(findInSingleFile(['```', '~~~', '- an example bullet', ''].join('\n'))).toEqual([]);
|
||
// A longer closing run still closes.
|
||
expect(
|
||
findInSingleFile(['```', 'sample', '`````', '', '- a real bullet', ''].join('\n'))
|
||
).toEqual([{ line: 5, message: expect.any(String) }]);
|
||
});
|
||
|
||
it('tracks fences in CRLF files', () => {
|
||
expect(
|
||
findInSingleFile(['```md', '- an example bullet', '```', '', '- a real bullet', ''].join('\r\n'))
|
||
).toEqual([{ line: 5, message: expect.any(String) }]);
|
||
});
|
||
|
||
it('only treats a fence indented up to three spaces as a fence', () => {
|
||
// Four spaces makes an indented code block, not an opener. Reading it as one
|
||
// would leave the scan inside a block that never began.
|
||
expect(
|
||
findInSingleFile([' ```', '', '- a real bullet', ''].join('\n'))
|
||
).toEqual([{ line: 3, message: expect.any(String) }]);
|
||
expect(
|
||
findInSingleFile([' ```', '- an example bullet', ' ```', ''].join('\n'))
|
||
).toEqual([]);
|
||
});
|
||
|
||
it('does not read top-level indented code as a task list', () => {
|
||
// Four spaces of indent is a code block, the same rule the fence logic
|
||
// already applies. A tasks file that pastes terminal output was reported
|
||
// as an uncheckboxed task list, and under --strict that failed validation
|
||
// on a correct file.
|
||
expect(
|
||
findInSingleFile(['## 1. Notes', '', 'Example output:', '', ' - example output', ''].join('\n'))
|
||
).toEqual([]);
|
||
expect(
|
||
findInSingleFile(['## 1. Notes', '', 'Example output:', '', '\t- tabbed output', ''].join('\n'))
|
||
).toEqual([]);
|
||
expect(
|
||
findInSingleFile(['## 1. Notes', '', 'Example output:', '', ' 1. numbered output', ''].join('\n'))
|
||
).toEqual([]);
|
||
});
|
||
|
||
it('still reports a genuine nested list, by naming its parent', () => {
|
||
// The cut is safe because this scan reports the first list item it finds,
|
||
// and a nested item always sits under a shallower parent. Three spaces is
|
||
// not code, so an indented-but-shallow list is still reported on its own.
|
||
expect(
|
||
findInSingleFile(['## 1. Work', '', '- Parent task', ' - Nested detail', ''].join('\n'))
|
||
).toEqual([{ line: 3, message: expect.any(String) }]);
|
||
expect(
|
||
findInSingleFile(['## 1. Work', '', ' - Three spaces is not code', ''].join('\n'))
|
||
).toEqual([{ line: 3, message: expect.any(String) }]);
|
||
});
|
||
|
||
it('does not treat a horizontal rule or emphasis as a list item', () => {
|
||
expect(findInSingleFile('# Tasks\n\n---\n\n***\n')).toEqual([]);
|
||
});
|
||
|
||
it('skips YAML front matter', () => {
|
||
expect(
|
||
findInSingleFile(
|
||
['---', 'tags:', ' - planning', ' - backend', '---', '', 'Nothing planned yet.', ''].join(
|
||
'\n'
|
||
)
|
||
)
|
||
).toEqual([]);
|
||
expect(
|
||
findInSingleFile(
|
||
['---', 'tags:', ' - planning', '---', '', '- a real bullet', ''].join('\n')
|
||
)
|
||
).toEqual([{ line: 6, message: expect.any(String) }]);
|
||
});
|
||
|
||
it('treats an unterminated front-matter opener as a thematic break', () => {
|
||
expect(findInSingleFile(['---', '', '- a real bullet', ''].join('\n'))).toEqual([
|
||
{ line: 3, message: expect.any(String) },
|
||
]);
|
||
});
|
||
|
||
it('does not read a longer dash run as front matter', () => {
|
||
// `----` is a thematic break. Reading it as an opener would hide every list
|
||
// between it and the next `---`.
|
||
expect(
|
||
findInSingleFile(['----', '', '- a real bullet', '', '---', ''].join('\n'))
|
||
).toEqual([{ line: 3, message: expect.any(String) }]);
|
||
});
|
||
|
||
it('skips front matter behind a UTF-8 byte order mark', () => {
|
||
// Windows editors and PowerShell redirects prepend a BOM. Without stripping
|
||
// it the opener never matched, and the `tags:` list failed `--strict`.
|
||
expect(
|
||
findInSingleFile(['---', 'tags:', ' - planning', '---', '', 'Nothing planned yet.', ''].join('\n'))
|
||
).toEqual([]);
|
||
});
|
||
|
||
it('does not read a number longer than nine digits as a list marker', () => {
|
||
// CommonMark caps an ordered marker at nine digits, as specs-apply does.
|
||
expect(findInSingleFile('1234567890. is a year range, not a task\n')).toEqual([]);
|
||
expect(findInSingleFile('123456789. still a list item\n')).toEqual([
|
||
{ line: 1, message: expect.any(String) },
|
||
]);
|
||
});
|
||
|
||
it('skips HTML comments without hiding the line that follows them', () => {
|
||
expect(
|
||
findInSingleFile(['<!--', '- a retired task', '-->', '', 'Nothing planned yet.', ''].join('\n'))
|
||
).toEqual([]);
|
||
expect(
|
||
findInSingleFile(['<!-- a note -->', '- a real bullet', ''].join('\n'))
|
||
).toEqual([{ line: 2, message: expect.any(String) }]);
|
||
expect(
|
||
findInSingleFile(['<!--', '- a retired task', '-->', '- a real bullet', ''].join('\n'))
|
||
).toEqual([{ line: 4, message: expect.any(String) }]);
|
||
});
|
||
|
||
it('accepts every packaged tasks template', async () => {
|
||
// An agent writing a task file follows these. If one ever loses its
|
||
// checkboxes, every change built from it starts life counting zero tasks.
|
||
const templates = await fg('schemas/*/templates/tasks.md', {
|
||
cwd: repoRoot,
|
||
absolute: true,
|
||
});
|
||
expect(templates.length).toBeGreaterThan(0);
|
||
|
||
for (const template of templates) {
|
||
const content = await fs.readFile(template, 'utf-8');
|
||
expect({
|
||
template: path.relative(repoRoot, template),
|
||
issues: findMissingTaskCheckboxIssues([{ path: 'tasks.md', content }]),
|
||
}).toEqual({ template: path.relative(repoRoot, template), issues: [] });
|
||
}
|
||
});
|
||
|
||
it('does not let the template heading comment swallow its checklist', () => {
|
||
// The scaffolded tasks.md: a heading carrying an inline comment, then real
|
||
// checkboxes. It must stay silent, and would not if an inline comment on a
|
||
// heading opened a block.
|
||
expect(
|
||
findInSingleFile(
|
||
[
|
||
'## 1. <!-- Task Group Name -->',
|
||
'',
|
||
'- [ ] 1.1 <!-- Task description -->',
|
||
'- [ ] 1.2 <!-- Task description -->',
|
||
'',
|
||
].join('\n')
|
||
)
|
||
).toEqual([]);
|
||
});
|
||
|
||
it('reports a list of unrecognised checkbox markers, which count as no task', () => {
|
||
// `- [ab] ...` looks like a checkbox, but a marker longer than one
|
||
// character is not one the task parser recognises, so the change really
|
||
// does count zero tasks and the warning is the only signal.
|
||
expect(findInSingleFile('- [ab] 1.1 in progress\n')).toEqual([
|
||
{ line: 1, message: expect.any(String) },
|
||
]);
|
||
});
|
||
|
||
it('reports every file only when the whole change has no checkbox', () => {
|
||
const withoutCheckboxes = [
|
||
{ path: 'backend/tasks.md', content: '- build the api\n' },
|
||
{ path: 'frontend/tasks.md', content: '- build the ui\n' },
|
||
];
|
||
expect(findMissingTaskCheckboxIssues(withoutCheckboxes).map((issue) => issue.path)).toEqual([
|
||
'backend/tasks.md',
|
||
'frontend/tasks.md',
|
||
]);
|
||
|
||
const oneRealChecklist = [
|
||
{ path: 'backend/tasks.md', content: '- [ ] 1.1 build the api\n' },
|
||
{ path: 'frontend/tasks.md', content: '- build the ui\n' },
|
||
];
|
||
expect(findMissingTaskCheckboxIssues(oneRealChecklist)).toEqual([]);
|
||
});
|
||
|
||
it('handles CRLF files', () => {
|
||
expect(findInSingleFile('# Tasks\r\n\r\n- Add the parser\r\n')).toEqual([
|
||
{ line: 3, message: expect.any(String) },
|
||
]);
|
||
expect(findInSingleFile('- [ ] 1.1 Add the parser\r\n')).toEqual([]);
|
||
});
|
||
});
|