cyber-sdd 0.2.0 → 0.3.0
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/.claude-plugin/plugin.json +5 -3
- package/.codex-plugin/plugin.json +5 -3
- package/.plugin/pins.json +1 -1
- package/.plugin/plugin.json +5 -3
- package/package.json +4 -3
- package/skills/check-plugin-manifests/SKILL.md +53 -0
- package/skills/check-plugin-manifests/scripts/check-plugin-manifests.mts +256 -0
- package/skills/check-project-specs/README.md +13 -6
- package/skills/check-project-specs/SKILL.md +47 -13
- package/skills/check-project-specs/scripts/check-project-specs.mts +103 -24
- package/skills/check-spec-references/README.md +22 -0
- package/skills/check-spec-references/SKILL.md +96 -0
- package/skills/check-spec-references/scripts/check-spec-references.mts +422 -0
- package/skills/discover-specs/scripts/discover-specs.mts +23 -2
- package/skills/start-mission/SKILL.md +4 -4
- package/LICENSE +0 -21
|
@@ -48,6 +48,12 @@ export const ENGINES: Engine[] = [
|
|
|
48
48
|
args: (d) => ['--spec-dir', d, '--check'],
|
|
49
49
|
},
|
|
50
50
|
{ name: 'align-spec', script: 'align-spec/scripts/align-spec.mts', args: (d) => ['--spec-dir', d, '--check'] },
|
|
51
|
+
{
|
|
52
|
+
name: 'check-spec-references',
|
|
53
|
+
script: 'check-spec-references/scripts/check-spec-references.mts',
|
|
54
|
+
// No --check: the engine has one mode, because every finding it makes is a defect.
|
|
55
|
+
args: (d) => ['--spec-dir', d],
|
|
56
|
+
},
|
|
51
57
|
{
|
|
52
58
|
name: 'check-scenario-overlap',
|
|
53
59
|
script: 'check-scenario-overlap/scripts/check-scenario-overlap.mts',
|
|
@@ -143,7 +149,7 @@ export function findCoverageGaps(
|
|
|
143
149
|
* Without this, a per-project run resolves such a spec to `none` and prints "no spec
|
|
144
150
|
* governs <project> — skipped" with exit 0: a status typo silently exempts the whole
|
|
145
151
|
* project from every engine. A spec that exists but cannot be classified is escalated,
|
|
146
|
-
* not exempted — the same call the corpus
|
|
152
|
+
* not exempted — the same call the corpus scope’s coverage guard makes.
|
|
147
153
|
*/
|
|
148
154
|
export function findDroppedSpecFor(
|
|
149
155
|
specFiles: string[],
|
|
@@ -181,6 +187,32 @@ function readTextOrNull(path: string): string | null {
|
|
|
181
187
|
}
|
|
182
188
|
}
|
|
183
189
|
|
|
190
|
+
// ─── the engine run ───────────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Run the whole engine set against one project-spec dir. Every engine runs even
|
|
194
|
+
* after one fails: a run that stops at the first failure reports one defect per
|
|
195
|
+
* invocation, and the corpus scope exists to report the tree's defects in one go.
|
|
196
|
+
*/
|
|
197
|
+
function runEngines(repoRoot: string, specDir: string): number {
|
|
198
|
+
let failed = 0
|
|
199
|
+
for (const e of ENGINES) {
|
|
200
|
+
// cwd is the repo root, not the project dir: the engines resolve
|
|
201
|
+
// repo-root-relative references against process.cwd().
|
|
202
|
+
try {
|
|
203
|
+
execFileSync('node', [join(SKILLS_DIR, e.script), ...e.args(specDir)], {
|
|
204
|
+
cwd: repoRoot,
|
|
205
|
+
stdio: 'inherit',
|
|
206
|
+
})
|
|
207
|
+
process.stdout.write(` ok ${e.name}\n`)
|
|
208
|
+
} catch {
|
|
209
|
+
process.stderr.write(` FAIL ${e.name}\n`)
|
|
210
|
+
failed++
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return failed
|
|
214
|
+
}
|
|
215
|
+
|
|
184
216
|
function checkCoverage(root: string): number {
|
|
185
217
|
const gaps = findCoverageGaps(root, discoverSpecFiles(root), collectSpecs(root), (p) => {
|
|
186
218
|
try {
|
|
@@ -200,18 +232,80 @@ function checkCoverage(root: string): number {
|
|
|
200
232
|
|
|
201
233
|
// ─── run ──────────────────────────────────────────────────────────────────────
|
|
202
234
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
235
|
+
/** The flags the harness defines. Anything else is an error, never a default. */
|
|
236
|
+
export const KNOWN_FLAGS = ['--corpus', '--project'] as const
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* The scope is chosen by a flag, so an unrecognized one must not fall through to a
|
|
240
|
+
* default. It used to: project scope at a repo root resolves no governing spec and
|
|
241
|
+
* exits 0, so a misspelled or retired flag reported success having checked nothing.
|
|
242
|
+
* That is how `--check-coverage` guarded commits and CI while running no engine.
|
|
243
|
+
*/
|
|
244
|
+
export function unknownFlags(argv: string[]): string[] {
|
|
245
|
+
const out: string[] = []
|
|
246
|
+
for (let i = 0; i < argv.length; i++) {
|
|
247
|
+
const a = argv[i] as string
|
|
248
|
+
if (!a.startsWith('-')) continue
|
|
249
|
+
if (a === '--project') {
|
|
250
|
+
i++ // its value is not a flag
|
|
251
|
+
continue
|
|
209
252
|
}
|
|
210
|
-
|
|
253
|
+
if (!(KNOWN_FLAGS as readonly string[]).includes(a)) out.push(a)
|
|
254
|
+
}
|
|
255
|
+
return out
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function main(argv: string[]): number {
|
|
259
|
+
const unknown = unknownFlags(argv)
|
|
260
|
+
if (unknown.length) {
|
|
261
|
+
process.stderr.write(
|
|
262
|
+
`check-project-specs: unrecognized flag(s) ${unknown.join(', ')} — ` +
|
|
263
|
+
`the scope is ${KNOWN_FLAGS.join(' | ')} (or none, meaning the cwd's project)\n`,
|
|
264
|
+
)
|
|
265
|
+
return 1
|
|
266
|
+
}
|
|
267
|
+
if (argv.includes('--corpus') && argv.includes('--project')) {
|
|
268
|
+
process.stderr.write('check-project-specs: --corpus and --project name contradictory scopes — pass one\n')
|
|
269
|
+
return 1
|
|
211
270
|
}
|
|
271
|
+
if (argv.includes('--corpus')) return checkCorpus()
|
|
212
272
|
return checkProject(argv)
|
|
213
273
|
}
|
|
214
274
|
|
|
275
|
+
/**
|
|
276
|
+
* Corpus scope — the commit floor. Total by definition: every project-spec the
|
|
277
|
+
* corpus holds is checked, and a spec that cannot be classified fails rather than
|
|
278
|
+
* being skipped.
|
|
279
|
+
*
|
|
280
|
+
* It runs BOTH sub-checks because neither subsumes the other. The sweep iterates
|
|
281
|
+
* the specs discovery *recognizes*, so a spec whose lifecycle `status` is a typo is
|
|
282
|
+
* invisible to it — and would report clean. The coverage guard sees that file on
|
|
283
|
+
* disk and escalates it, but says nothing about whether the engines pass. Run either
|
|
284
|
+
* alone and a whole project-spec leaves the floor silently.
|
|
285
|
+
*/
|
|
286
|
+
function checkCorpus(): number {
|
|
287
|
+
const root = findRepoRoot(process.cwd())
|
|
288
|
+
if (!root) {
|
|
289
|
+
process.stderr.write('check-project-specs: no pnpm-workspace.yaml found above the cwd\n')
|
|
290
|
+
return 1
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Coverage first, but it never short-circuits the sweep: one run reports every
|
|
294
|
+
// defect it can see, so an author fixes them in one pass rather than one per run.
|
|
295
|
+
let failed = checkCoverage(root) === 0 ? 0 : 1
|
|
296
|
+
|
|
297
|
+
const specs = collectSpecs(root)
|
|
298
|
+
if (specs.length === 0) {
|
|
299
|
+
process.stdout.write('check-project-specs: the corpus holds no project-spec\n')
|
|
300
|
+
return failed
|
|
301
|
+
}
|
|
302
|
+
for (const s of specs) {
|
|
303
|
+
process.stdout.write(`check-project-specs: ${s.path}\n`)
|
|
304
|
+
if (runEngines(root, join(root, s.path)) > 0) failed = 1
|
|
305
|
+
}
|
|
306
|
+
return failed
|
|
307
|
+
}
|
|
308
|
+
|
|
215
309
|
function checkProject(argv: string[]): number {
|
|
216
310
|
const projectArg = argv.includes('--project') ? argv[argv.indexOf('--project') + 1] : undefined
|
|
217
311
|
const projectDir = resolve(projectArg ?? process.cwd())
|
|
@@ -256,22 +350,7 @@ function checkProject(argv: string[]): number {
|
|
|
256
350
|
const specDir = join(repoRoot, res.spec.path)
|
|
257
351
|
process.stdout.write(`check-project-specs: ${projectRel} -> ${res.spec.path}\n`)
|
|
258
352
|
|
|
259
|
-
|
|
260
|
-
for (const e of ENGINES) {
|
|
261
|
-
// cwd is the repo root, not the project dir: the engines resolve
|
|
262
|
-
// repo-root-relative references against process.cwd().
|
|
263
|
-
try {
|
|
264
|
-
execFileSync('node', [join(SKILLS_DIR, e.script), ...e.args(specDir)], {
|
|
265
|
-
cwd: repoRoot,
|
|
266
|
-
stdio: 'inherit',
|
|
267
|
-
})
|
|
268
|
-
process.stdout.write(` ok ${e.name}\n`)
|
|
269
|
-
} catch {
|
|
270
|
-
process.stderr.write(` FAIL ${e.name}\n`)
|
|
271
|
-
failed++
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
return failed === 0 ? 0 : 1
|
|
353
|
+
return runEngines(repoRoot, specDir) === 0 ? 0 : 1
|
|
275
354
|
}
|
|
276
355
|
|
|
277
356
|
if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# check-spec-references
|
|
2
|
+
|
|
3
|
+
Internal SDD skill — the concrete engine for the **spec-reference resolution check**. Walks every
|
|
4
|
+
`.md` under one project spec, resolves each explicitly-relative reference against the file's own
|
|
5
|
+
directory, and reports the ones that resolve to nothing.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
node scripts/check-spec-references.mts --spec-dir <specDir>
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Exits non-zero on any unresolved reference, reporting each as `file:line: <ref> -> <resolved path>` — naming the resolved path is the
|
|
12
|
+
point, since the reference as written is exactly what read as plausible during review. Two forms
|
|
13
|
+
are extracted: markdown link targets and inline-code spans, and only when the path begins `./` or
|
|
14
|
+
`../`. Bare paths, URLs, `~/`-relative paths, and anything inside a fenced code block are prose,
|
|
15
|
+
not references — which is what makes a repo-root-relative reference pass by construction. A
|
|
16
|
+
directory target resolves, trailing slash and all. A line carrying `<!-- spec-ref-ignore: why -->`
|
|
17
|
+
has none of its references extracted, for the one real false-positive class: prose quoting a path
|
|
18
|
+
relative to something other than the file it sits in.
|
|
19
|
+
|
|
20
|
+
Read-only; writes nothing. Run from `check-project-specs`' engine set. See [`SKILL.md`](./SKILL.md)
|
|
21
|
+
for the full contract; the `project-spec/check-spec-references` node of the SDD project spec
|
|
22
|
+
(repo-only) carries the frozen spec. Not user-invocable.
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: check-spec-references
|
|
3
|
+
description: "Partial Skill: invoke by name only — project-spec/check-spec-references' engine that resolves every relative reference in one project spec's markdown and fails on the ones pointing at nothing — run from the per-project CI entrypoint, not triggered by users directly."
|
|
4
|
+
user-invocable: false
|
|
5
|
+
metadata:
|
|
6
|
+
internal: true
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Check Spec References
|
|
10
|
+
|
|
11
|
+
The concrete engine for the **spec-reference resolution check**. It walks every `.md` under one
|
|
12
|
+
project spec, extracts each **explicitly-relative** reference, resolves it **against the file's own
|
|
13
|
+
directory**, and fails on every one that resolves to nothing. Read-only, deterministic, and — unlike
|
|
14
|
+
its `check-spec-structure` sibling — single-severity: a reference that does not resolve is always a
|
|
15
|
+
defect, so there is one mode, not a report mode and a gate mode. It carries a self-contained `.mts`
|
|
16
|
+
script (the repo's node-≥23.6 / no-deps convention).
|
|
17
|
+
|
|
18
|
+
It closes a failure **review cannot catch**: a consistent off-by-one reads as entirely plausible —
|
|
19
|
+
right filename, right-looking depth, wrong level — so re-reading a reference by eye is not a check
|
|
20
|
+
on it. The `project-spec/check-spec-references` node of the SDD project spec (repo-only) carries the
|
|
21
|
+
full design rationale, the actors, and the control-flow graph the scenarios derive from.
|
|
22
|
+
|
|
23
|
+
## What counts as a reference
|
|
24
|
+
|
|
25
|
+
Two forms, and only when the path is **explicitly relative** (`./` or `../`):
|
|
26
|
+
|
|
27
|
+
| Form | Where |
|
|
28
|
+
|---|---|
|
|
29
|
+
| a markdown link target — plain, angle-bracket-wrapped, titled, or a `[label]: path` definition | outside any code span |
|
|
30
|
+
| an inline-code span **whose whole content is the path** — a path followed by further text is prose | anywhere but a fenced block |
|
|
31
|
+
|
|
32
|
+
**Everything else is prose, not a reference** — which is what makes a repo-root-relative reference
|
|
33
|
+
(`.research/<topic>/`) pass by construction rather than by exception. Never extracted: a bare path,
|
|
34
|
+
a URL, an absolute path, a `~/`-relative path, and anything inside a **fenced code block** (tracked
|
|
35
|
+
by the character and run length that opened it, so a fence-shaped line of the other delimiter inside
|
|
36
|
+
a block neither ends it nor inverts the parity for everything after).
|
|
37
|
+
|
|
38
|
+
Code spans are read the way CommonMark delimits them — a run of N backticks opens, the next run of
|
|
39
|
+
exactly N closes, a run that never closes is literal text the scan resumes after (a stray backtick
|
|
40
|
+
never swallows what comes after it), and a span may **wrap across a line break** — the line ending
|
|
41
|
+
folds to a space, so a long path in wrapped prose is still one span, reported against the line it
|
|
42
|
+
opens on. A span is bounded by its **block** — it reaches no further than the next blank line,
|
|
43
|
+
heading, list item, blockquote, thematic break, or fence — which is what keeps one unclosed backtick
|
|
44
|
+
from pairing into the next block and swallowing the references there. One rule settles the exhibit case with no exception: a span written around another
|
|
45
|
+
span has content that still carries backticks, so it is not a path (this is how a spec shows the
|
|
46
|
+
reference form it specifies without firing on itself), while a span written around a bare path has
|
|
47
|
+
that path as its content and **is** a reference however many backticks opened it. A markdown link
|
|
48
|
+
inside a code span is text on display, not a link.
|
|
49
|
+
|
|
50
|
+
## Resolution
|
|
51
|
+
|
|
52
|
+
Against the directory of the file that **carries** the reference — never the spec root, never the
|
|
53
|
+
repo root. A trailing `#fragment` is stripped first. A reference resolves when the result exists as
|
|
54
|
+
a **file or a directory**; a trailing slash is immaterial.
|
|
55
|
+
|
|
56
|
+
Each finding names **both** the reference as written and the path it resolved to — the second is the
|
|
57
|
+
half a reader cannot supply by eye.
|
|
58
|
+
|
|
59
|
+
## The escape hatch
|
|
60
|
+
|
|
61
|
+
A line carrying `<!-- spec-ref-ignore -->` has **none of its references** extracted. It takes an
|
|
62
|
+
optional reason (`<!-- spec-ref-ignore: quoting the bridge file's own content -->`) and the
|
|
63
|
+
convention is to write one. Its scope is exactly the line it appears on; it is matched as a
|
|
64
|
+
**complete comment** (a marker whose name merely starts with this one's does not suppress) and read
|
|
65
|
+
from **outside the code spans**, so a line that merely quotes the marker keeps its references.
|
|
66
|
+
|
|
67
|
+
> Use it for a path that is **correct but not resolvable from here** — prose quoting a symlink
|
|
68
|
+
> target, or a bridge file's own content. A genuinely broken reference is fixed, never marked.
|
|
69
|
+
|
|
70
|
+
## Run the check
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
node "<skill>/scripts/check-spec-references.mts" --spec-dir <specDir>
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
- Exits **zero** on a spec whose every reference resolves, printing a definitive clean line.
|
|
77
|
+
- Exits **non-zero** on any unresolved reference, printing **every** one as
|
|
78
|
+
`file:line: <ref> -> <resolved>` plus a count — every finding, never the first, because a single
|
|
79
|
+
off-by-one lands as a whole family and one-at-a-time would take as many runs to clear as there are
|
|
80
|
+
levels wrong.
|
|
81
|
+
- Findings are ordered by file, then line, then reference, so two runs over an unchanged tree are
|
|
82
|
+
byte-identical. Paths render relative to the cwd.
|
|
83
|
+
- A missing `--spec-dir` is refused rather than defaulted.
|
|
84
|
+
- Run from `check-project-specs`' engine set, with cwd = the repo root.
|
|
85
|
+
|
|
86
|
+
When `node` is absent, an agent performs the same derivation by hand: list the `.md` files under the
|
|
87
|
+
spec dir, pull each `./`/`../` markdown link target and inline-code path (skipping fenced blocks,
|
|
88
|
+
displayed spans, and marked lines), resolve each against its own file's directory, and check the
|
|
89
|
+
path exists.
|
|
90
|
+
|
|
91
|
+
## Boundaries
|
|
92
|
+
|
|
93
|
+
Read-only — it writes nothing and fixes no reference (an author edits, or a formation pass does at
|
|
94
|
+
corpus scale). It never judges whether a reference is the *right* artifact to cite, only that the
|
|
95
|
+
cited path exists; it never follows a reference's content, never checks link text, and never reaches
|
|
96
|
+
outside the spec dir it is given.
|