Repository navigation
Expand file tree
/
Copy pathopenspec-visibility.cjs
More file actions
258 lines (240 loc) · 9.96 KB
/
Copy pathopenspec-visibility.cjs
File metadata and controls
258 lines (240 loc) · 9.96 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
// SPDX-License-Identifier: MIT
"use strict";
/**
* Requirement visibility check — catch specs the OpenSpec parser silently
* stops reading part-way through.
*
* WHY THIS EXISTS. `openspec validate --all --strict` answers "is what I read
* well-formed", not "did I read the whole file". A spec's requirements live
* under `## Requirements`, and a second `##` heading inside that section ends
* it: every `### Requirement:` below the intruding heading is no longer a
* requirement to the parser, it is prose. Those requirements are not invalid,
* they are INVISIBLE — so `--strict` reports success having never looked at
* them. Measured before this check existed, `infrastructure` carried 20
* requirements with 1 visible and `skills` carried 7 with 1, both green the
* whole time. A passing gate is an active claim that the spec was read, which
* makes this failure mode worse than a red check rather than milder.
*
* WHAT IT CHECKS. For each `openspec/specs/<capability>/spec.md`: the number of
* `### Requirement:` headings in the file must equal the number the parser can
* reach. Any difference fails, naming the spec and the count.
*
* FENCED CODE BLOCKS ARE NOT SPEC CONTENT. Lines inside a ``` or ~~~ fence are
* skipped entirely — they neither count as requirements nor open or close the
* requirements section. Spec files do contain fenced examples, and a fenced
* `### Requirement:` is documentation ABOUT the format, not a requirement the
* parser was ever meant to see; counting it would report a hidden requirement
* that does not exist and push authors to mangle their examples. Skipping
* fences costs nothing in detection power: the `cli-help` defect this family of
* bugs comes from was a LOST OPENING fence, which turns the block's `##` lines
* into real headings — and real headings are exactly what this check reads.
*
* AN UNCLOSED FENCE IS ITSELF A FAILURE. Skipping fenced content has one edge:
* a lost opening fence leaves the block's CLOSING ``` dangling, and a dangling
* fence opens a region that runs to end of file, hiding everything after it
* from this check as well as from the parser. So a fence still open at EOF
* fails outright rather than being tolerated — the one case where "I could not
* read the rest of the file" is the finding.
*
* Usage:
* node .github/scripts/openspec-visibility.cjs [specsDirectory]
*
* Exits non-zero when any spec has requirements the parser cannot see.
*/
const { readdirSync, readFileSync, existsSync } = require("node:fs");
const { join, relative, resolve } = require("node:path");
const REPO_ROOT = join(__dirname, "..", "..");
const DEFAULT_SPECS_DIRECTORY = join(REPO_ROOT, "openspec", "specs");
/**
* HEADING RECOGNITION MIRRORS OPENSPEC'S, DELIBERATELY. This check is only
* meaningful where it agrees with the parser it is speaking for — and the
* parser it speaks for is the one `openspec validate` runs: `MarkdownParser`
* (core/validation/validator.js constructs it, core/parsers/markdown-parser.js
* defines it). Its rules are:
*
* /^(#{1,6})\s+(.+)$/ what counts as a heading at all
* findSection(..., 'Requirements') the section, matched case-insensitively
* getContentUntilNextHeader(i, 2) any heading of level <= 2 ends it
* parseRequirements(section) EVERY `###` child is a requirement
*
* `requirement-blocks.js` carries a second, stricter set of patterns
* (`/^###\s*Requirement:\s*(.+)\s*$/` and friends). Those serve the delta/edit
* path, not validation, and where the two disagree this check follows the
* validating parser. Three consequences are worth stating because they look
* like bugs.
*
* A `#` HEADING ENDS THE SECTION TOO. `getContentUntilNextHeader` breaks on any
* heading whose level is <= the section's, so inside `## Requirements` a
* level-1 `# Something` truncates it exactly as a `##` does. Matching only
* `^##\s` here would report every requirement below such a heading as visible
* while the parser never reads one of them — the silent truncation this file
* exists to catch, reproduced in the checker.
*
* NO LEADING-SPACE TOLERANCE, ON PURPOSE. CommonMark accepts up to three
* spaces before an ATX heading; OpenSpec does not — every one of its patterns
* is anchored at column 0. So an indented ` ## Grouping` is prose to the
* parser and does NOT end the requirements section. Widening this to `^ {0,3}##`
* would make the check stricter than the thing it checks and report hidden
* requirements that are, in fact, read — the opposite of the failure it exists
* to catch. `fenceOf` tolerates leading spaces because CommonMark's fence rule
* is what governs fences here; headings are governed by OpenSpec's rule.
*
* WHITESPACE AFTER THE HASHES IS TOLERATED, AND SO IS A MISSING TITLE. OpenSpec
* matches `\s`, not a literal space, so `##\tGrouping` really does end the
* section and `### Requirement:` really is a requirement. And because
* `parseRequirements` promotes every `###` child of the section, a title-less
* `### Requirement:` is a requirement to the validating parser as well —
* demanding a non-empty title (`\s*\S`) would stop counting a heading the
* parser does read.
*/
const REQUIREMENTS_HEADING = /^##\s+Requirements\s*$/i;
const SECTION_HEADING = /^#{1,2}\s/;
const REQUIREMENT_HEADING = /^###\s*Requirement:/;
/**
* A fence opens on ``` or ~~~ (up to three leading spaces, per CommonMark) and
* closes on a run of the same character at least as long. Tracking the opener's
* length and character is what lets a ```` ```` ```` block contain a ``` line
* without the check losing its place.
*/
function fenceOf(line) {
const match = /^ {0,3}(`{3,}|~{3,})/.exec(line);
return match ? { char: match[1][0], length: match[1].length } : null;
}
/**
* Count requirements written in a spec against requirements the parser reaches.
* Pure and string-in, so the interesting cases are testable without a fixture
* tree on disk.
*/
function countRequirements(source) {
let total = 0;
let visible = 0;
let inRequirements = false;
let openFence = null;
for (const line of source.split("\n")) {
const fence = fenceOf(line);
if (openFence) {
if (
fence &&
fence.char === openFence.char &&
fence.length >= openFence.length
) {
openFence = null;
}
continue;
}
if (fence) {
openFence = fence;
continue;
}
if (SECTION_HEADING.test(line)) {
inRequirements = REQUIREMENTS_HEADING.test(line);
} else if (REQUIREMENT_HEADING.test(line)) {
total += 1;
if (inRequirements) {
visible += 1;
}
}
}
return {
total,
visible,
hidden: total - visible,
unclosedFence: openFence !== null,
};
}
/** Every `<capability>/spec.md` under the specs directory, sorted for stable output. */
function findSpecs(specsDirectory) {
if (!existsSync(specsDirectory)) {
return [];
}
return readdirSync(specsDirectory, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => join(specsDirectory, entry.name, "spec.md"))
.filter((path) => existsSync(path))
.sort();
}
/** Repo-relative when the spec lives in the repo, absolute otherwise (tests, ad-hoc runs). */
function displayPath(path) {
const relativePath = relative(REPO_ROOT, path);
return relativePath.startsWith("..") ? path : relativePath;
}
function checkSpecs(specsDirectory = DEFAULT_SPECS_DIRECTORY) {
return findSpecs(specsDirectory).map((path) => ({
path,
...countRequirements(readFileSync(path, "utf8")),
}));
}
/**
* `log`/`error` are injectable so tests can assert on the report without
* reassigning the global console — a mutation that is only safe while
* `node --test` runs this file's tests sequentially, and would start losing or
* crossing output the day anything here gains concurrency.
*/
function main({
argv = process.argv.slice(2),
log = console.log,
error = console.error,
} = {}) {
const specsDirectory = argv[0]
? resolve(process.cwd(), argv[0])
: DEFAULT_SPECS_DIRECTORY;
const results = checkSpecs(specsDirectory);
if (results.length === 0) {
error(`No specs found under ${specsDirectory}`);
return { results, ok: false };
}
for (const result of results) {
const name = displayPath(result.path);
const status =
result.hidden > 0
? "HIDDEN "
: result.unclosedFence
? "UNCLOSED"
: "ok ";
log(
` ${status} ${name} ${result.visible}/${result.total} requirement(s) visible`
);
}
const broken = results.filter(
(result) => result.hidden > 0 || result.unclosedFence
);
if (broken.length === 0) {
log(
`\nEvery requirement in ${results.length} spec(s) is visible to the parser.`
);
return { results, ok: true };
}
error("");
for (const result of broken) {
const name = displayPath(result.path);
if (result.hidden > 0) {
error(
`${name}: ${result.hidden} requirement(s) hidden by a '##' heading inside '## Requirements' (${result.visible} of ${result.total} visible)`
);
}
if (result.unclosedFence) {
error(
`${name}: a code fence is still open at end of file, so everything after it is unreadable to this check and to the parser (likely a lost opening fence leaving its closer dangling)`
);
}
}
if (broken.some((result) => result.hidden > 0)) {
error(
"\nA '##' heading ends the requirements section. Use a bold lead-in line for topical grouping instead, so the requirements below it stay readable to the parser."
);
}
return { results, ok: false };
}
// Exported so openspec-visibility.test.cjs can drive the counter over inline
// sources and main() over a temporary spec tree, with nothing on disk to keep.
module.exports = { countRequirements, findSpecs, checkSpecs, main };
if (require.main === module) {
try {
if (!main().ok) {
process.exitCode = 1;
}
} catch (error) {
console.error(`\nopenspec-visibility failed: ${error.message}`);
process.exitCode = 1;
}
}