Files
OpenSpec/test/core/task-checkboxes.test.ts
T
Clay GoodandClaude Opus 5 09984b8242 fix(validate): warn when tracked tasks have no checkboxes (#1774)
* 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>
2026-09-16 15:47:04 +00:00

257 lines
10 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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([]);
});
});