@fission-ai/openspec 1.13.0 → 1.13.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -4
- package/dist/commands/change.js +12 -1
- package/dist/commands/config.d.ts +9 -0
- package/dist/commands/config.js +161 -22
- package/dist/commands/feedback.js +6 -4
- package/dist/commands/schema.js +30 -9
- package/dist/commands/store.js +5 -2
- package/dist/commands/validate.d.ts +7 -0
- package/dist/commands/validate.js +63 -1
- package/dist/commands/workflow/instructions.js +69 -22
- package/dist/commands/workflow/new-change.js +20 -0
- package/dist/commands/workflow/shared.d.ts +5 -0
- package/dist/commands/workflow/shared.js +8 -0
- package/dist/commands/workflow/status.d.ts +5 -1
- package/dist/commands/workflow/status.js +42 -5
- package/dist/core/archive.js +260 -77
- package/dist/core/artifact-graph/outputs.d.ts +1 -1
- package/dist/core/artifact-graph/outputs.js +38 -8
- package/dist/core/artifact-graph/schema.d.ts +14 -0
- package/dist/core/artifact-graph/schema.js +52 -0
- package/dist/core/artifact-graph/types.js +12 -1
- package/dist/core/change-status-policy.d.ts +14 -0
- package/dist/core/change-status-policy.js +14 -6
- package/dist/core/command-generation/adapters/continue.js +4 -0
- package/dist/core/command-generation/adapters/kilocode.d.ts +1 -1
- package/dist/core/command-generation/adapters/kilocode.js +2 -2
- package/dist/core/command-generation/generator.js +2 -0
- package/dist/core/completion-tip.js +7 -7
- package/dist/core/completions/installers/bash-installer.js +15 -7
- package/dist/core/completions/installers/shell-quote.d.ts +11 -0
- package/dist/core/completions/installers/shell-quote.js +13 -0
- package/dist/core/completions/installers/zsh-installer.js +3 -2
- package/dist/core/config-schema.d.ts +1 -1
- package/dist/core/config.d.ts +16 -0
- package/dist/core/config.js +27 -1
- package/dist/core/global-config.d.ts +30 -2
- package/dist/core/global-config.js +57 -3
- package/dist/core/init.js +22 -6
- package/dist/core/legacy-cleanup.d.ts +5 -1
- package/dist/core/legacy-cleanup.js +207 -14
- package/dist/core/list.js +55 -11
- package/dist/core/migration.js +6 -1
- package/dist/core/parsers/change-parser.d.ts +12 -1
- package/dist/core/parsers/change-parser.js +85 -87
- package/dist/core/parsers/markdown-parser.js +4 -3
- package/dist/core/parsers/requirement-blocks.d.ts +78 -6
- package/dist/core/parsers/requirement-blocks.js +155 -38
- package/dist/core/parsers/requirement-text.d.ts +13 -2
- package/dist/core/parsers/requirement-text.js +34 -7
- package/dist/core/parsers/spec-structure.js +4 -1
- package/dist/core/project-config.d.ts +9 -1
- package/dist/core/project-config.js +11 -2
- package/dist/core/references.d.ts +20 -0
- package/dist/core/references.js +56 -0
- package/dist/core/shared/skill-content-equivalence.js +5 -1
- package/dist/core/shared/skill-generation.js +36 -8
- package/dist/core/shared/tool-detection.js +9 -3
- package/dist/core/specs-apply.d.ts +20 -0
- package/dist/core/specs-apply.js +101 -17
- package/dist/core/store/git.d.ts +33 -0
- package/dist/core/store/git.js +60 -6
- package/dist/core/store/operations.d.ts +1 -1
- package/dist/core/store/operations.js +32 -10
- package/dist/core/store/registry.d.ts +2 -1
- package/dist/core/store/registry.js +5 -1
- package/dist/core/templates/optional-workflow.d.ts +69 -0
- package/dist/core/templates/optional-workflow.js +140 -0
- package/dist/core/templates/workflows/apply-change.js +18 -3
- package/dist/core/templates/workflows/archive-change.js +70 -21
- package/dist/core/templates/workflows/bulk-archive-change.js +85 -21
- package/dist/core/templates/workflows/continue-change.js +14 -4
- package/dist/core/templates/workflows/explore.js +34 -16
- package/dist/core/templates/workflows/ff-change.js +21 -5
- package/dist/core/templates/workflows/new-change.js +15 -3
- package/dist/core/templates/workflows/onboard.js +76 -41
- package/dist/core/templates/workflows/project-root.d.ts +29 -0
- package/dist/core/templates/workflows/project-root.js +38 -0
- package/dist/core/templates/workflows/propose.js +22 -5
- package/dist/core/templates/workflows/sync-specs.js +40 -1
- package/dist/core/templates/workflows/update-change.js +66 -27
- package/dist/core/templates/workflows/verify-change.js +121 -49
- package/dist/core/update.d.ts +12 -0
- package/dist/core/update.js +78 -10
- package/dist/core/validation/purpose-placeholder.js +24 -9
- package/dist/core/validation/task-checkboxes.d.ts +34 -0
- package/dist/core/validation/task-checkboxes.js +181 -0
- package/dist/core/validation/validator.d.ts +53 -1
- package/dist/core/validation/validator.js +160 -45
- package/dist/core/version-check.d.ts +14 -7
- package/dist/core/version-check.js +63 -17
- package/dist/core/worksets.js +6 -4
- package/dist/prompts/searchable-multi-select.d.ts +7 -0
- package/dist/prompts/searchable-multi-select.js +35 -6
- package/dist/telemetry/config.js +14 -2
- package/dist/telemetry/index.d.ts +4 -3
- package/dist/telemetry/index.js +22 -10
- package/dist/telemetry/opt-out.d.ts +17 -0
- package/dist/telemetry/opt-out.js +27 -0
- package/dist/utils/file-system.js +20 -3
- package/dist/utils/line-endings.d.ts +34 -0
- package/dist/utils/line-endings.js +46 -0
- package/dist/utils/nested-change.d.ts +35 -0
- package/dist/utils/nested-change.js +186 -0
- package/dist/utils/spec-discovery.d.ts +20 -0
- package/dist/utils/spec-discovery.js +59 -0
- package/dist/utils/task-progress.d.ts +1 -1
- package/dist/utils/task-progress.js +38 -6
- package/package.json +5 -5
- package/schemas/spec-driven/schema.yaml +18 -3
- package/schemas/spec-driven/templates/design.md +2 -0
- package/schemas/spec-driven/templates/proposal.md +2 -0
- package/schemas/spec-driven/templates/spec.md +2 -0
- package/schemas/spec-driven/templates/tasks.md +2 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export interface TaskCheckboxDocument {
|
|
2
|
+
path: string;
|
|
3
|
+
content: string;
|
|
4
|
+
}
|
|
5
|
+
export interface TaskCheckboxIssue {
|
|
6
|
+
path: string;
|
|
7
|
+
line: number;
|
|
8
|
+
message: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Reports tracked task files that list work as plain bullets or numbered items
|
|
12
|
+
* instead of checkboxes (#354).
|
|
13
|
+
*
|
|
14
|
+
* Progress counts checkboxes and nothing else, so a tasks file written as a
|
|
15
|
+
* bare list is worse than an empty one: `openspec list` prints "No tasks",
|
|
16
|
+
* `openspec status` treats the change as having no work left, and
|
|
17
|
+
* `openspec archive` has no incomplete task to warn about. The file looks
|
|
18
|
+
* finished to the tool and unfinished to the reader.
|
|
19
|
+
*
|
|
20
|
+
* Reported only when the change's *whole* tracked set has zero checkboxes.
|
|
21
|
+
* One nested tasks file of prose beside a real checklist is not the failure
|
|
22
|
+
* this catches, and a change that is mid-authoring keeps its progress the
|
|
23
|
+
* moment a single checkbox exists.
|
|
24
|
+
*
|
|
25
|
+
* The scan for the offending line looks at rendered content only: fenced
|
|
26
|
+
* blocks, HTML comments, YAML front matter and top-level indented code are
|
|
27
|
+
* skipped. Every one of those
|
|
28
|
+
* exclusions can only *silence* a warning, never drop a real task — that is the
|
|
29
|
+
* opposite trade from the task parser, where fence awareness would hide work
|
|
30
|
+
* that `archive` must still refuse, and it is why the parser stays literal
|
|
31
|
+
* while this scan does not.
|
|
32
|
+
*/
|
|
33
|
+
export declare function findMissingTaskCheckboxIssues(documents: readonly TaskCheckboxDocument[]): TaskCheckboxIssue[];
|
|
34
|
+
//# sourceMappingURL=task-checkboxes.d.ts.map
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { parseTaskLines } from '../../utils/task-progress.js';
|
|
2
|
+
/**
|
|
3
|
+
* A list item that carries text but no checkbox: `- item`, `* item`, `+ item`,
|
|
4
|
+
* `1. item`, `1) item`. Checkbox lines match this too, so callers must rule the
|
|
5
|
+
* document set out on checkbox count first.
|
|
6
|
+
*
|
|
7
|
+
* Matches at any indent; the caller decides how deep is too deep. See
|
|
8
|
+
* `CODE_BLOCK_COLUMN`.
|
|
9
|
+
*
|
|
10
|
+
* A thematic break (`---`, `***`, `- - -`) is not a list item: the run has no
|
|
11
|
+
* text after it, and this pattern requires a non-space character. `* * *` is
|
|
12
|
+
* the one break spelled like a list of `*` items, and it is accepted as a list
|
|
13
|
+
* item rather than special-cased, because a file whose only list-shaped line is
|
|
14
|
+
* a horizontal rule still has zero tasks — the warning stays true, it just
|
|
15
|
+
* points at an odd line.
|
|
16
|
+
*
|
|
17
|
+
* An ordered marker runs at most nine digits, as CommonMark and the scenario
|
|
18
|
+
* bullet pattern in `specs-apply.ts` both require.
|
|
19
|
+
*/
|
|
20
|
+
const LIST_ITEM = /^\s*(?:[-*+]|\d{1,9}[.)])\s+\S/;
|
|
21
|
+
/**
|
|
22
|
+
* The indent at which a top-level line stops being content and becomes an
|
|
23
|
+
* indented code block, per CommonMark.
|
|
24
|
+
*
|
|
25
|
+
* The fence logic already treats four spaces as code; the list scan did not,
|
|
26
|
+
* so a sample of terminal output written as ` - example output` was
|
|
27
|
+
* reported as an uncheckboxed task list, and under `--strict` that false
|
|
28
|
+
* positive failed validation on a correct file.
|
|
29
|
+
*
|
|
30
|
+
* Genuine nested lists survive the cut, because this scan reports the *first*
|
|
31
|
+
* list item it finds and a nested item always sits under a shallower parent.
|
|
32
|
+
* That parent is what gets reported, exactly as before. A list-shaped line
|
|
33
|
+
* four columns deep with no shallower item above it is not nested under
|
|
34
|
+
* anything, which is precisely what makes it code.
|
|
35
|
+
*/
|
|
36
|
+
const CODE_BLOCK_COLUMN = 4;
|
|
37
|
+
/** Leading whitespace of a line in visual columns, a tab counting as four. */
|
|
38
|
+
function indentColumns(line) {
|
|
39
|
+
let column = 0;
|
|
40
|
+
for (const char of line) {
|
|
41
|
+
if (char === ' ')
|
|
42
|
+
column += 1;
|
|
43
|
+
else if (char === '\t')
|
|
44
|
+
column += CODE_BLOCK_COLUMN - (column % CODE_BLOCK_COLUMN);
|
|
45
|
+
else
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
return column;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* A fenced block delimiter: a run of three or more backticks or tildes, plus
|
|
52
|
+
* whatever follows it on the line (an info string on an opener, nothing on a
|
|
53
|
+
* valid closer).
|
|
54
|
+
*
|
|
55
|
+
* Indented by at most three spaces, as CommonMark requires: at four, the line is
|
|
56
|
+
* an indented code block rather than a fence, and treating it as an opener would
|
|
57
|
+
* leave the scan inside a block that never began and hide every list below it.
|
|
58
|
+
*
|
|
59
|
+
* Deliberately unanchored at the end: `.` does not match `\r`, so `(.*)$` would
|
|
60
|
+
* fail on every line of a CRLF file and blind the scan to fences entirely.
|
|
61
|
+
*/
|
|
62
|
+
const FENCE = /^ {0,3}(`{3,}|~{3,})(.*)/;
|
|
63
|
+
/**
|
|
64
|
+
* The YAML front-matter delimiter: exactly three dashes. A longer run is a
|
|
65
|
+
* thematic break, so `----` must not open a block that swallows the list under
|
|
66
|
+
* it until the next `---`.
|
|
67
|
+
*/
|
|
68
|
+
const FRONT_MATTER = /^-{3}\s*$/;
|
|
69
|
+
const COMMENT_OPEN = '<!--';
|
|
70
|
+
const COMMENT_CLOSE = '-->';
|
|
71
|
+
/**
|
|
72
|
+
* Reports tracked task files that list work as plain bullets or numbered items
|
|
73
|
+
* instead of checkboxes (#354).
|
|
74
|
+
*
|
|
75
|
+
* Progress counts checkboxes and nothing else, so a tasks file written as a
|
|
76
|
+
* bare list is worse than an empty one: `openspec list` prints "No tasks",
|
|
77
|
+
* `openspec status` treats the change as having no work left, and
|
|
78
|
+
* `openspec archive` has no incomplete task to warn about. The file looks
|
|
79
|
+
* finished to the tool and unfinished to the reader.
|
|
80
|
+
*
|
|
81
|
+
* Reported only when the change's *whole* tracked set has zero checkboxes.
|
|
82
|
+
* One nested tasks file of prose beside a real checklist is not the failure
|
|
83
|
+
* this catches, and a change that is mid-authoring keeps its progress the
|
|
84
|
+
* moment a single checkbox exists.
|
|
85
|
+
*
|
|
86
|
+
* The scan for the offending line looks at rendered content only: fenced
|
|
87
|
+
* blocks, HTML comments, YAML front matter and top-level indented code are
|
|
88
|
+
* skipped. Every one of those
|
|
89
|
+
* exclusions can only *silence* a warning, never drop a real task — that is the
|
|
90
|
+
* opposite trade from the task parser, where fence awareness would hide work
|
|
91
|
+
* that `archive` must still refuse, and it is why the parser stays literal
|
|
92
|
+
* while this scan does not.
|
|
93
|
+
*/
|
|
94
|
+
export function findMissingTaskCheckboxIssues(documents) {
|
|
95
|
+
if (documents.length === 0)
|
|
96
|
+
return [];
|
|
97
|
+
if (documents.some((document) => parseTaskLines(document.content).length > 0))
|
|
98
|
+
return [];
|
|
99
|
+
const issues = [];
|
|
100
|
+
for (const document of documents) {
|
|
101
|
+
const line = findFirstListItemLine(document.content);
|
|
102
|
+
if (line === undefined)
|
|
103
|
+
continue;
|
|
104
|
+
issues.push({
|
|
105
|
+
path: document.path,
|
|
106
|
+
line,
|
|
107
|
+
message: 'This change counts as 0 tasks: no line in its tracked task files is a checkbox, ' +
|
|
108
|
+
'so "openspec list" and "openspec status" report no work and "openspec archive" ' +
|
|
109
|
+
'has nothing to flag as incomplete. Write each task as "- [ ] 1.1 Description".',
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
return issues;
|
|
113
|
+
}
|
|
114
|
+
/** 1-based line of the first list item in rendered content, if any. */
|
|
115
|
+
function findFirstListItemLine(content) {
|
|
116
|
+
const lines = content.split('\n');
|
|
117
|
+
let openFence;
|
|
118
|
+
let inComment = false;
|
|
119
|
+
let index = skipFrontMatter(lines);
|
|
120
|
+
for (; index < lines.length; index++) {
|
|
121
|
+
const line = lines[index];
|
|
122
|
+
if (inComment) {
|
|
123
|
+
if (line.includes(COMMENT_CLOSE))
|
|
124
|
+
inComment = false;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const fence = line.match(FENCE);
|
|
128
|
+
if (fence) {
|
|
129
|
+
const marker = fence[1][0];
|
|
130
|
+
const length = fence[1].length;
|
|
131
|
+
if (openFence === undefined) {
|
|
132
|
+
openFence = { marker, length };
|
|
133
|
+
}
|
|
134
|
+
else if (marker === openFence.marker &&
|
|
135
|
+
length >= openFence.length &&
|
|
136
|
+
fence[2].trim() === '') {
|
|
137
|
+
// CommonMark: a closer matches its opener's character, runs at least as
|
|
138
|
+
// long, and carries no info string. A shorter or annotated run inside a
|
|
139
|
+
// block is content, so a ```` ``` ```` sample nested in a ```` ```` ````
|
|
140
|
+
// block does not end the block early and expose its bullets.
|
|
141
|
+
openFence = undefined;
|
|
142
|
+
}
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (openFence !== undefined)
|
|
146
|
+
continue;
|
|
147
|
+
// Only a comment that opens the line hides it. `- [ ] 1.1 do it <!-- note`
|
|
148
|
+
// is a task line first, and the template's own `## 1. <!-- Task Group -->`
|
|
149
|
+
// must not swallow the checklist that follows it.
|
|
150
|
+
if (line.trimStart().startsWith(COMMENT_OPEN)) {
|
|
151
|
+
if (!line.includes(COMMENT_CLOSE, line.indexOf(COMMENT_OPEN) + COMMENT_OPEN.length)) {
|
|
152
|
+
inComment = true;
|
|
153
|
+
}
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (LIST_ITEM.test(line) && indentColumns(line) < CODE_BLOCK_COLUMN)
|
|
157
|
+
return index + 1;
|
|
158
|
+
}
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Index of the first line after a YAML front-matter block, or 0 when the
|
|
163
|
+
* document does not open with one. A list under `tags:` is metadata about the
|
|
164
|
+
* file, never the work it tracks, and pointing a "write checkboxes" warning at
|
|
165
|
+
* it would name the wrong line.
|
|
166
|
+
*/
|
|
167
|
+
function skipFrontMatter(lines) {
|
|
168
|
+
// A UTF-8 BOM, prepended by Windows editors and PowerShell redirects, would
|
|
169
|
+
// otherwise hide the opener and expose a `tags:` list to the scan.
|
|
170
|
+
if (lines.length === 0 || !FRONT_MATTER.test(lines[0].replace(/^/, '').trimEnd())) {
|
|
171
|
+
return 0;
|
|
172
|
+
}
|
|
173
|
+
for (let index = 1; index < lines.length; index++) {
|
|
174
|
+
if (FRONT_MATTER.test(lines[index].trimEnd()))
|
|
175
|
+
return index + 1;
|
|
176
|
+
}
|
|
177
|
+
// An unterminated opener is a thematic break, not front matter: rewinding to
|
|
178
|
+
// the top keeps every list in the document visible to the scan.
|
|
179
|
+
return 0;
|
|
180
|
+
}
|
|
181
|
+
//# sourceMappingURL=task-checkboxes.js.map
|
|
@@ -29,7 +29,53 @@ export declare class Validator {
|
|
|
29
29
|
mainSpecsDir?: string;
|
|
30
30
|
projectRoot?: string;
|
|
31
31
|
}): Promise<ValidationReport>;
|
|
32
|
-
|
|
32
|
+
/**
|
|
33
|
+
* Lints the change's task files.
|
|
34
|
+
*
|
|
35
|
+
* Two checks with deliberately different reach. Checkbox formatting is read
|
|
36
|
+
* from whatever the change's own schema declares as its tracked task output,
|
|
37
|
+
* because every schema's progress, apply and archive behavior is computed by
|
|
38
|
+
* counting checkboxes in exactly those files. Numbering stays scoped to the
|
|
39
|
+
* built-in `spec-driven` schema, whose template is the one that numbers tasks
|
|
40
|
+
* in the first place.
|
|
41
|
+
*
|
|
42
|
+
* The checkbox check never falls back to a bare top-level `tasks.md`: a file
|
|
43
|
+
* no artifact declares is not a tracked task list, and warning about its
|
|
44
|
+
* formatting would be a guess about a file the tool does not read.
|
|
45
|
+
*/
|
|
46
|
+
private collectTaskFileIssues;
|
|
47
|
+
/**
|
|
48
|
+
* Reads task files into change-relative documents, counting the ones that
|
|
49
|
+
* exist but could not be read. A file that is simply absent is not counted:
|
|
50
|
+
* the resolver only returns files it found, so the remaining read failures
|
|
51
|
+
* are permissions and I/O, and a check that reasons over the whole set needs
|
|
52
|
+
* to know its evidence was incomplete.
|
|
53
|
+
*/
|
|
54
|
+
private readTaskDocuments;
|
|
55
|
+
/**
|
|
56
|
+
* Names a task file relative to its change, POSIX-separated.
|
|
57
|
+
*
|
|
58
|
+
* Both sides are canonicalized first. `resolveArtifactOutputs` hands back real
|
|
59
|
+
* paths, while `changeDir` carries whatever spelling the caller resolved, and
|
|
60
|
+
* the two can differ without being apart: on Windows a short 8.3 alias
|
|
61
|
+
* (`RUNNER~1`) against its expanded form turned `tasks.md` into a
|
|
62
|
+
* `../../../..`-prefixed absolute path in the report, and a symlinked project
|
|
63
|
+
* directory does the same elsewhere. Canonicalizing recovers the real
|
|
64
|
+
* relationship. The Windows CI job is the regression guard — the mismatch
|
|
65
|
+
* cannot be staged on POSIX, where the spawned CLI's `process.cwd()` is
|
|
66
|
+
* already physical.
|
|
67
|
+
*
|
|
68
|
+
* A path that still escapes would be a resolver bug rather than a spelling
|
|
69
|
+
* difference, but the report must never leak an absolute filesystem path, so
|
|
70
|
+
* the file name stands in.
|
|
71
|
+
*/
|
|
72
|
+
private taskDocumentPath;
|
|
73
|
+
/**
|
|
74
|
+
* True when the change resolves to the package's own `spec-driven` schema. A
|
|
75
|
+
* project schema that merely reuses the name is not it, so checks written
|
|
76
|
+
* against the built-in template never fire on someone else's task format.
|
|
77
|
+
*/
|
|
78
|
+
private usesBuiltInSpecDrivenSchema;
|
|
33
79
|
/**
|
|
34
80
|
* Report MODIFIED requirements whose block omits a scenario the main spec
|
|
35
81
|
* still carries. Uses the same comparison archive applies, so validate can
|
|
@@ -72,6 +118,12 @@ export declare class Validator {
|
|
|
72
118
|
*/
|
|
73
119
|
private buildMissingShallOrMustMessage;
|
|
74
120
|
private countScenarios;
|
|
121
|
+
/**
|
|
122
|
+
* Why a requirement with a visible scenario header still has no scenario:
|
|
123
|
+
* the header has no body, and the spec path archive validates against does
|
|
124
|
+
* not count it. Empty when the block has no bare scenario header.
|
|
125
|
+
*/
|
|
126
|
+
private emptyScenarioHint;
|
|
75
127
|
private formatSectionList;
|
|
76
128
|
}
|
|
77
129
|
//# sourceMappingURL=validator.d.ts.map
|
|
@@ -5,14 +5,15 @@ import { MarkdownParser } from '../parsers/markdown-parser.js';
|
|
|
5
5
|
import { ChangeParser } from '../parsers/change-parser.js';
|
|
6
6
|
import { findSpecUpdates, buildUpdatedSpec } from '../specs-apply.js';
|
|
7
7
|
import { MIN_PURPOSE_LENGTH, MAX_REQUIREMENT_TEXT_LENGTH, VALIDATION_MESSAGES } from './constants.js';
|
|
8
|
-
import { parseDeltaSpec, foldRequirementName, normalizeRequirementName, extractRequirementsSection,
|
|
9
|
-
import { extractRequirementBody as extractRequirementBodyShared, containsShallOrMust as containsShallOrMustShared, countScenarios as countScenariosShared, } from '../parsers/requirement-text.js';
|
|
8
|
+
import { parseDeltaSpec, foldRequirementName, normalizeRequirementName, extractRequirementsSection, diffScenarioNames, describeScenarioBalance, } from '../parsers/requirement-blocks.js';
|
|
9
|
+
import { extractRequirementBody as extractRequirementBodyShared, containsShallOrMust as containsShallOrMustShared, countScenarios as countScenariosShared, countEmptyScenarios, } from '../parsers/requirement-text.js';
|
|
10
10
|
import { findMainSpecStructureIssues } from '../parsers/spec-structure.js';
|
|
11
11
|
import { FileSystemUtils } from '../../utils/file-system.js';
|
|
12
|
-
import { discoverSpecFiles, hasAnyFileUnder } from '../../utils/spec-discovery.js';
|
|
12
|
+
import { discoverSpecFiles, findUnreadDeltaFiles, hasAnyFileUnder } from '../../utils/spec-discovery.js';
|
|
13
13
|
import { METADATA_FILENAME, readSkipSpecsMarker, resolveSchemaForChange, } from '../../utils/change-metadata.js';
|
|
14
14
|
import { resolveTaskFilesForChange } from '../../utils/task-progress.js';
|
|
15
15
|
import { findTaskNumberingIssues } from './task-numbering.js';
|
|
16
|
+
import { findMissingTaskCheckboxIssues } from './task-checkboxes.js';
|
|
16
17
|
import { findPurposePlaceholderIssue } from './purpose-placeholder.js';
|
|
17
18
|
import { getPackageSchemasDir, getSchemaDir } from '../artifact-graph/index.js';
|
|
18
19
|
export class Validator {
|
|
@@ -174,6 +175,34 @@ export class Validator {
|
|
|
174
175
|
: `Header "### ${stray.header}" in ${stray.section} is not a "### Requirement:" header and is ignored by validation. Use "### Requirement: ${stray.header}" if it should be validated as a requirement.`,
|
|
175
176
|
});
|
|
176
177
|
}
|
|
178
|
+
// A FROM:/TO: line that never formed a pair. Reported here so the author
|
|
179
|
+
// learns at authoring time, rather than having archive either skip the
|
|
180
|
+
// rename or apply it to a requirement they never named.
|
|
181
|
+
for (const unpaired of plan.unpairedRenames) {
|
|
182
|
+
const missing = unpaired.side === 'FROM' ? 'TO' : 'FROM';
|
|
183
|
+
issues.push({
|
|
184
|
+
level: 'ERROR',
|
|
185
|
+
path: entryPath,
|
|
186
|
+
line: unpaired.line,
|
|
187
|
+
message: `RENAMED ${unpaired.side}: "${unpaired.name}" has no matching ${missing}: line. Write each rename as a FROM: line followed immediately by its TO: line.`,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
// A well-formed requirement written outside every delta section. The
|
|
191
|
+
// reader only looks inside the four delta sections, so this block is
|
|
192
|
+
// ignored - reported as a WARNING rather than an ERROR because a
|
|
193
|
+
// handful of pre-format archived changes still carry this shape, and
|
|
194
|
+
// the fix is to move the block, not to reject the change outright.
|
|
195
|
+
for (const orphan of plan.orphanedRequirements) {
|
|
196
|
+
const where = orphan.section
|
|
197
|
+
? `under "## ${orphan.section}"`
|
|
198
|
+
: 'above the first "## " section';
|
|
199
|
+
issues.push({
|
|
200
|
+
level: 'WARNING',
|
|
201
|
+
path: entryPath,
|
|
202
|
+
line: orphan.line,
|
|
203
|
+
message: `Requirement "${orphan.name}" is ${where}, which is not a delta section, so it is ignored. Move it under "## ADDED Requirements", "## MODIFIED Requirements", "## REMOVED Requirements", or "## RENAMED Requirements".`,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
177
206
|
const sectionNames = [];
|
|
178
207
|
if (plan.sectionPresence.added)
|
|
179
208
|
sectionNames.push('## ADDED Requirements');
|
|
@@ -225,7 +254,7 @@ export class Validator {
|
|
|
225
254
|
}
|
|
226
255
|
const scenarioCount = this.countScenarios(block.raw);
|
|
227
256
|
if (scenarioCount < 1) {
|
|
228
|
-
issues.push({ level: 'ERROR', path: entryPath, message: `ADDED "${block.name}" must include at least one scenario` });
|
|
257
|
+
issues.push({ level: 'ERROR', path: entryPath, message: `ADDED "${block.name}" must include at least one scenario${this.emptyScenarioHint(block.raw)}` });
|
|
229
258
|
}
|
|
230
259
|
}
|
|
231
260
|
// Validate MODIFIED
|
|
@@ -257,7 +286,7 @@ export class Validator {
|
|
|
257
286
|
}
|
|
258
287
|
const scenarioCount = this.countScenarios(block.raw);
|
|
259
288
|
if (scenarioCount < 1) {
|
|
260
|
-
issues.push({ level: 'ERROR', path: entryPath, message: `MODIFIED "${block.name}" must include at least one scenario` });
|
|
289
|
+
issues.push({ level: 'ERROR', path: entryPath, message: `MODIFIED "${block.name}" must include at least one scenario${this.emptyScenarioHint(block.raw)}` });
|
|
261
290
|
}
|
|
262
291
|
}
|
|
263
292
|
// Run archive's scenario-loss check here too, so the change fails at
|
|
@@ -358,6 +387,17 @@ export class Validator {
|
|
|
358
387
|
throw error;
|
|
359
388
|
}
|
|
360
389
|
}
|
|
390
|
+
// The same drop happens to delta sections in any other file the merge
|
|
391
|
+
// path does not read (specs/<capability>.md, a note beside spec.md),
|
|
392
|
+
// while the artifact graph's specs/**/*.md glob counts it as written.
|
|
393
|
+
const unreadDeltaFiles = await findUnreadDeltaFiles(specsDir);
|
|
394
|
+
for (const file of unreadDeltaFiles) {
|
|
395
|
+
issues.push({
|
|
396
|
+
level: 'ERROR',
|
|
397
|
+
path: file.path,
|
|
398
|
+
message: `Delta spec found at specs/${file.path}. Delta specs must be a spec.md inside a capability folder — this file is ignored when the change is applied or archived. Move its requirements into specs/${file.expected}.`,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
361
401
|
for (const { path: specPath, sections } of emptySectionSpecs) {
|
|
362
402
|
issues.push({
|
|
363
403
|
level: 'ERROR',
|
|
@@ -397,10 +437,10 @@ export class Validator {
|
|
|
397
437
|
if (skipSpecs && specsDirHasFiles) {
|
|
398
438
|
issues.push({ level: 'ERROR', path: 'file', message: VALIDATION_MESSAGES.CHANGE_SKIP_SPECS_CONFLICT });
|
|
399
439
|
}
|
|
400
|
-
// The root-level
|
|
401
|
-
// deltas found" on top would contradict
|
|
402
|
-
// the
|
|
403
|
-
if (totalDeltas === 0 && !hasRootLevelSpec) {
|
|
440
|
+
// The root-level and unread-file errors already name the file and the fix;
|
|
441
|
+
// adding "No deltas found" on top would contradict them, since the deltas
|
|
442
|
+
// are sitting in the files just reported.
|
|
443
|
+
if (totalDeltas === 0 && !hasRootLevelSpec && unreadDeltaFiles.length === 0) {
|
|
404
444
|
if (skipSpecs && !specsDirHasFiles) {
|
|
405
445
|
issues.push({ level: 'INFO', path: 'file', message: VALIDATION_MESSAGES.CHANGE_SKIP_SPECS_ACCEPTED });
|
|
406
446
|
}
|
|
@@ -409,56 +449,120 @@ export class Validator {
|
|
|
409
449
|
}
|
|
410
450
|
}
|
|
411
451
|
if (options.projectRoot) {
|
|
412
|
-
issues.push(...await this.
|
|
452
|
+
issues.push(...await this.collectTaskFileIssues(changeDir, options.projectRoot));
|
|
413
453
|
}
|
|
414
454
|
return this.createReport(issues);
|
|
415
455
|
}
|
|
416
|
-
|
|
456
|
+
/**
|
|
457
|
+
* Lints the change's task files.
|
|
458
|
+
*
|
|
459
|
+
* Two checks with deliberately different reach. Checkbox formatting is read
|
|
460
|
+
* from whatever the change's own schema declares as its tracked task output,
|
|
461
|
+
* because every schema's progress, apply and archive behavior is computed by
|
|
462
|
+
* counting checkboxes in exactly those files. Numbering stays scoped to the
|
|
463
|
+
* built-in `spec-driven` schema, whose template is the one that numbers tasks
|
|
464
|
+
* in the first place.
|
|
465
|
+
*
|
|
466
|
+
* The checkbox check never falls back to a bare top-level `tasks.md`: a file
|
|
467
|
+
* no artifact declares is not a tracked task list, and warning about its
|
|
468
|
+
* formatting would be a guess about a file the tool does not read.
|
|
469
|
+
*/
|
|
470
|
+
async collectTaskFileIssues(changeDir, projectRoot) {
|
|
471
|
+
let trackedFiles;
|
|
417
472
|
try {
|
|
418
|
-
|
|
419
|
-
const schemaDir = getSchemaDir(schemaName, projectRoot);
|
|
420
|
-
const builtInSchemaDir = path.join(getPackageSchemasDir(), 'spec-driven');
|
|
421
|
-
if (schemaName !== 'spec-driven' ||
|
|
422
|
-
schemaDir === null ||
|
|
423
|
-
FileSystemUtils.canonicalizeExistingPath(schemaDir) !==
|
|
424
|
-
FileSystemUtils.canonicalizeExistingPath(builtInSchemaDir)) {
|
|
425
|
-
return [];
|
|
426
|
-
}
|
|
473
|
+
trackedFiles = resolveTaskFilesForChange(changeDir, projectRoot);
|
|
427
474
|
}
|
|
428
475
|
catch {
|
|
429
476
|
return [];
|
|
430
477
|
}
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
478
|
+
const files = trackedFiles.length > 0 ? trackedFiles : [path.join(changeDir, 'tasks.md')];
|
|
479
|
+
const { documents, unreadable } = await this.readTaskDocuments(changeDir, files);
|
|
480
|
+
const toWarning = (issue) => ({
|
|
481
|
+
level: 'WARNING',
|
|
482
|
+
path: issue.path,
|
|
483
|
+
line: issue.line,
|
|
484
|
+
message: issue.message,
|
|
485
|
+
});
|
|
486
|
+
const issues = [];
|
|
487
|
+
// "No file here holds a checkbox" is a claim about the whole tracked set, so
|
|
488
|
+
// a file that exists but could not be read withdraws it: the checkboxes may
|
|
489
|
+
// be in exactly that file. `validate --archived` is the surface that reports
|
|
490
|
+
// an unreadable task file loudly (#205); this one must not guess from it.
|
|
491
|
+
if (trackedFiles.length > 0 && unreadable === 0) {
|
|
492
|
+
issues.push(...findMissingTaskCheckboxIssues(documents).map(toWarning));
|
|
437
493
|
}
|
|
438
|
-
if (
|
|
439
|
-
|
|
494
|
+
if (this.usesBuiltInSpecDrivenSchema(changeDir, projectRoot)) {
|
|
495
|
+
issues.push(...findTaskNumberingIssues(documents).map(toWarning));
|
|
440
496
|
}
|
|
497
|
+
return issues;
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* Reads task files into change-relative documents, counting the ones that
|
|
501
|
+
* exist but could not be read. A file that is simply absent is not counted:
|
|
502
|
+
* the resolver only returns files it found, so the remaining read failures
|
|
503
|
+
* are permissions and I/O, and a check that reasons over the whole set needs
|
|
504
|
+
* to know its evidence was incomplete.
|
|
505
|
+
*/
|
|
506
|
+
async readTaskDocuments(changeDir, files) {
|
|
441
507
|
const documents = [];
|
|
442
|
-
|
|
508
|
+
let unreadable = 0;
|
|
509
|
+
for (const file of files) {
|
|
443
510
|
let content;
|
|
444
511
|
try {
|
|
445
|
-
content = await fs.readFile(
|
|
512
|
+
content = await fs.readFile(file, 'utf-8');
|
|
446
513
|
}
|
|
447
|
-
catch {
|
|
514
|
+
catch (error) {
|
|
515
|
+
if (error?.code !== 'ENOENT')
|
|
516
|
+
unreadable++;
|
|
448
517
|
continue;
|
|
449
518
|
}
|
|
450
|
-
documents.push({
|
|
451
|
-
path: FileSystemUtils.toPosixPath(path.relative(changeDir, taskFile)),
|
|
452
|
-
content,
|
|
453
|
-
});
|
|
519
|
+
documents.push({ path: this.taskDocumentPath(changeDir, file), content });
|
|
454
520
|
}
|
|
455
521
|
documents.sort((left, right) => left.path.localeCompare(right.path));
|
|
456
|
-
return
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
522
|
+
return { documents, unreadable };
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Names a task file relative to its change, POSIX-separated.
|
|
526
|
+
*
|
|
527
|
+
* Both sides are canonicalized first. `resolveArtifactOutputs` hands back real
|
|
528
|
+
* paths, while `changeDir` carries whatever spelling the caller resolved, and
|
|
529
|
+
* the two can differ without being apart: on Windows a short 8.3 alias
|
|
530
|
+
* (`RUNNER~1`) against its expanded form turned `tasks.md` into a
|
|
531
|
+
* `../../../..`-prefixed absolute path in the report, and a symlinked project
|
|
532
|
+
* directory does the same elsewhere. Canonicalizing recovers the real
|
|
533
|
+
* relationship. The Windows CI job is the regression guard — the mismatch
|
|
534
|
+
* cannot be staged on POSIX, where the spawned CLI's `process.cwd()` is
|
|
535
|
+
* already physical.
|
|
536
|
+
*
|
|
537
|
+
* A path that still escapes would be a resolver bug rather than a spelling
|
|
538
|
+
* difference, but the report must never leak an absolute filesystem path, so
|
|
539
|
+
* the file name stands in.
|
|
540
|
+
*/
|
|
541
|
+
taskDocumentPath(changeDir, file) {
|
|
542
|
+
const relative = path.relative(FileSystemUtils.canonicalizeExistingPath(changeDir), FileSystemUtils.canonicalizeExistingPath(file));
|
|
543
|
+
const escapes = relative === '' || relative.startsWith('..') || path.isAbsolute(relative);
|
|
544
|
+
return escapes ? path.basename(file) : FileSystemUtils.toPosixPath(relative);
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* True when the change resolves to the package's own `spec-driven` schema. A
|
|
548
|
+
* project schema that merely reuses the name is not it, so checks written
|
|
549
|
+
* against the built-in template never fire on someone else's task format.
|
|
550
|
+
*/
|
|
551
|
+
usesBuiltInSpecDrivenSchema(changeDir, projectRoot) {
|
|
552
|
+
try {
|
|
553
|
+
const schemaName = resolveSchemaForChange(changeDir, undefined, projectRoot).replace(/\.ya?ml$/, '');
|
|
554
|
+
if (schemaName !== 'spec-driven')
|
|
555
|
+
return false;
|
|
556
|
+
const schemaDir = getSchemaDir(schemaName, projectRoot);
|
|
557
|
+
if (schemaDir === null)
|
|
558
|
+
return false;
|
|
559
|
+
const builtInSchemaDir = path.join(getPackageSchemasDir(), 'spec-driven');
|
|
560
|
+
return (FileSystemUtils.canonicalizeExistingPath(schemaDir) ===
|
|
561
|
+
FileSystemUtils.canonicalizeExistingPath(builtInSchemaDir));
|
|
562
|
+
}
|
|
563
|
+
catch {
|
|
564
|
+
return false;
|
|
565
|
+
}
|
|
462
566
|
}
|
|
463
567
|
/**
|
|
464
568
|
* Report MODIFIED requirements whose block omits a scenario the main spec
|
|
@@ -535,15 +639,16 @@ export class Validator {
|
|
|
535
639
|
const current = currentBlockFor(key);
|
|
536
640
|
if (!current)
|
|
537
641
|
continue;
|
|
538
|
-
const
|
|
539
|
-
if (missing.length === 0)
|
|
642
|
+
const diff = diffScenarioNames(current, block);
|
|
643
|
+
if (diff.missing.length === 0)
|
|
540
644
|
continue;
|
|
541
645
|
issues.push({
|
|
542
646
|
level: 'ERROR',
|
|
543
647
|
path: entryPath,
|
|
544
648
|
message: `MODIFIED "${block.name}" omits scenario(s) the current spec still has: ` +
|
|
545
|
-
`${missing.map(name => `"${name}"`).join(', ')}. ` +
|
|
546
|
-
|
|
649
|
+
`${diff.missing.map(name => `"${name}"`).join(', ')}. ` +
|
|
650
|
+
`${describeScenarioBalance(diff)} ` +
|
|
651
|
+
'Copy the omitted scenarios into the MODIFIED block (a MODIFIED requirement replaces the whole block, so archive refuses to drop them).',
|
|
547
652
|
});
|
|
548
653
|
}
|
|
549
654
|
return issues;
|
|
@@ -797,6 +902,16 @@ export class Validator {
|
|
|
797
902
|
// example is not a real scenario. Drop the header line (index 0).
|
|
798
903
|
return countScenariosShared(blockRaw.split('\n').slice(1));
|
|
799
904
|
}
|
|
905
|
+
/**
|
|
906
|
+
* Why a requirement with a visible scenario header still has no scenario:
|
|
907
|
+
* the header has no body, and the spec path archive validates against does
|
|
908
|
+
* not count it. Empty when the block has no bare scenario header.
|
|
909
|
+
*/
|
|
910
|
+
emptyScenarioHint(blockRaw) {
|
|
911
|
+
if (countEmptyScenarios(blockRaw.split('\n').slice(1)) === 0)
|
|
912
|
+
return '';
|
|
913
|
+
return ' (a scenario header with no body under it does not count; add its steps, e.g. "- **WHEN** ..." and "- **THEN** ...")';
|
|
914
|
+
}
|
|
800
915
|
formatSectionList(sections) {
|
|
801
916
|
if (sections.length === 0)
|
|
802
917
|
return '';
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The registry to ask
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
2
|
+
* The registry to ask. A configured registry is honored only over TLS: a
|
|
3
|
+
* cleartext hop would aim the request at a link-local or internal address and
|
|
4
|
+
* let anyone on the path choose the "newer version" answer.
|
|
5
|
+
*
|
|
6
|
+
* A rejected registry disables the check rather than falling back to public
|
|
7
|
+
* npm. Falling back would send a request an organization on an internal mirror
|
|
8
|
+
* deliberately avoided, and would then report a version resolved against a
|
|
9
|
+
* registry the eventual `npm install -g` does not use.
|
|
8
10
|
*/
|
|
9
|
-
export declare function registryUrl(): string;
|
|
11
|
+
export declare function registryUrl(): string | null;
|
|
10
12
|
/**
|
|
11
13
|
* Compares two semver-ish versions. Returns 1 when a > b, -1 when a < b, 0
|
|
12
14
|
* otherwise. Prereleases sort below their release (1.7.0-beta.1 < 1.7.0).
|
|
@@ -92,6 +94,11 @@ export declare function buildUpgradeCommandLines(installDir: string | null, proj
|
|
|
92
94
|
* that may not be the one on PATH, a project dependency belongs to that
|
|
93
95
|
* project's package manager, an npx/dlx cache has nothing to upgrade, and a
|
|
94
96
|
* source checkout is not an install at all.
|
|
97
|
+
*
|
|
98
|
+
* A non-default registry is also disqualifying. `npm install -g` resolves
|
|
99
|
+
* against whatever `npm_config_registry` says, which npm may have read from a
|
|
100
|
+
* cloned repository's .npmrc — so such a registry may still inform the check,
|
|
101
|
+
* but must never be the thing that offers to install a global package.
|
|
95
102
|
*/
|
|
96
103
|
export declare function canSelfUpgrade(installDir: string | null, projectPath: string): boolean;
|
|
97
104
|
/**
|