cyber-sdd 0.2.1 → 0.3.1
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 +6 -4
- package/.codex-plugin/plugin.json +6 -4
- package/.plugin/plugin.json +6 -4
- 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/LICENSE +0 -21
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
|
|
3
3
|
"name": "sdd",
|
|
4
4
|
"description": "Spec-Driven Development. Scaffold, validate, and maintain behavioral specs (spec.md + .feature files) for software features.",
|
|
5
|
-
"author": {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
"
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "unional"
|
|
7
|
+
},
|
|
8
|
+
"homepage": "https://github.com/cyberuni/cyber-sdd",
|
|
9
|
+
"repository": "https://github.com/cyberuni/cyber-sdd",
|
|
10
|
+
"version": "0.3.1",
|
|
9
11
|
"skills": "./skills",
|
|
10
12
|
"agents": [
|
|
11
13
|
"./agents/sdd-automaton.md",
|
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
"$schema": "https://raw.githubusercontent.com/cyberuni/marketplace/main/schema/claude/plugin.json",
|
|
3
3
|
"name": "sdd",
|
|
4
4
|
"description": "Spec-Driven Development. Scaffold, validate, and maintain behavioral specs (spec.md + .feature files) for software features.",
|
|
5
|
-
"author": {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
"
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "unional"
|
|
7
|
+
},
|
|
8
|
+
"homepage": "https://github.com/cyberuni/cyber-sdd",
|
|
9
|
+
"repository": "https://github.com/cyberuni/cyber-sdd",
|
|
10
|
+
"version": "0.3.1",
|
|
9
11
|
"skills": "./skills",
|
|
10
12
|
"agents": [
|
|
11
13
|
"./agents/sdd-automaton.md",
|
package/.plugin/plugin.json
CHANGED
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
|
|
3
3
|
"name": "sdd",
|
|
4
4
|
"description": "Spec-Driven Development. Scaffold, validate, and maintain behavioral specs (spec.md + .feature files) for software features.",
|
|
5
|
-
"author": {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
"
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "unional"
|
|
7
|
+
},
|
|
8
|
+
"homepage": "https://github.com/cyberuni/cyber-sdd",
|
|
9
|
+
"repository": "https://github.com/cyberuni/cyber-sdd",
|
|
10
|
+
"version": "0.3.1",
|
|
9
11
|
"skills": "./skills",
|
|
10
12
|
"agents": [
|
|
11
13
|
"./agents/sdd-automaton.md",
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cyber-sdd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Spec-Driven Development. Scaffold, validate, and maintain behavioral specs (spec.md + .feature files) for software features.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
|
-
"url": "git+https://github.com/cyberuni/
|
|
7
|
+
"url": "git+https://github.com/cyberuni/cyber-sdd.git",
|
|
8
8
|
"directory": "plugins/sdd"
|
|
9
9
|
},
|
|
10
10
|
"license": "MIT",
|
|
@@ -23,8 +23,9 @@
|
|
|
23
23
|
"dependencies": {
|
|
24
24
|
"gherkin-cli": "0.0.2"
|
|
25
25
|
},
|
|
26
|
+
"homepage": "https://github.com/cyberuni/cyber-sdd",
|
|
26
27
|
"scripts": {
|
|
27
|
-
"check:spec": "
|
|
28
|
+
"check:spec": "node ./skills/check-project-specs/scripts/check-project-specs.mts",
|
|
28
29
|
"test": "node --test \"skills/*/scripts/*.test.mts\"",
|
|
29
30
|
"typecheck": "tsc -p tsconfig.json"
|
|
30
31
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: check-plugin-manifests
|
|
3
|
+
description: "Partial Skill: invoke by name only — plugin/check-plugin-manifests' guard engine against a manifest declaring a component the package does not ship — the CI guard, not triggered by users directly."
|
|
4
|
+
user-invocable: false
|
|
5
|
+
metadata:
|
|
6
|
+
internal: true
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Check Plugin Manifests
|
|
10
|
+
|
|
11
|
+
The concrete engine for the rule that **a plugin manifest declares only components the package
|
|
12
|
+
ships**. A pointer naming something the package does not carry is valid JSON, satisfies the schema,
|
|
13
|
+
and is copied into every generated vendor manifest — it fails only on an installer's machine, after
|
|
14
|
+
publish, as a component the host runtime cannot load. Nothing else in the repo compares a manifest's
|
|
15
|
+
pointers against what ships.
|
|
16
|
+
|
|
17
|
+
Spec: [`.agents/specs/sdd/plugin/check-plugin-manifests/`](../../../../.agents/specs/sdd/plugin/check-plugin-manifests/README.md).
|
|
18
|
+
|
|
19
|
+
## What it checks
|
|
20
|
+
|
|
21
|
+
Two sub-checks, neither subsuming the other:
|
|
22
|
+
|
|
23
|
+
- **the disk check** — does the pointer resolve to a path that exists?
|
|
24
|
+
- **the publish check** — for a package that publishes, is the pointer inside its `files` allowlist?
|
|
25
|
+
|
|
26
|
+
A directory can exist and be excluded from the tarball; a `files` entry can name a directory nobody
|
|
27
|
+
created. The disk check **short-circuits**: a pointer dead on disk is reported once, as unresolved,
|
|
28
|
+
and is not also asked about `files`.
|
|
29
|
+
|
|
30
|
+
**A pointer is any `./`-prefixed string value**, at any depth, under any key — not a fixed key list.
|
|
31
|
+
The manifest format grows new component keys, and a guard hardcoding today's set fails open on the
|
|
32
|
+
next one added.
|
|
33
|
+
|
|
34
|
+
**A pointer resolves against the plugin root, not the manifest's own directory.** A vendor manifest
|
|
35
|
+
sits one level down; a guard resolving relative to the file it just read reports every vendor
|
|
36
|
+
manifest as entirely broken.
|
|
37
|
+
|
|
38
|
+
**A package that declares no `files` at all ships everything**, so there is no allowlist to be
|
|
39
|
+
outside of. Reading an absent allowlist as an empty one inverts npm's semantic and manufactures a
|
|
40
|
+
finding against every pointer in the package.
|
|
41
|
+
|
|
42
|
+
## Run it
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
node "<skill>/scripts/check-plugin-manifests.mts" [--root <dir>]
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`--root` defaults to the working directory. A finding always exits non-zero — there is deliberately
|
|
49
|
+
no report-only mode, because a chain that forgets the strict flag reports green over a defect, and
|
|
50
|
+
green is what the repo treats as clearance to commit. An unrecognized flag is an error, never an
|
|
51
|
+
input to ignore.
|
|
52
|
+
|
|
53
|
+
It joins the root check chain as **`check:plugins`**, so it runs on every `pnpm verify` and in CI.
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// check-plugin-manifests — a plugin manifest must declare only components the package ships.
|
|
3
|
+
//
|
|
4
|
+
// A manifest pointer naming something the package does not carry is valid JSON, satisfies the
|
|
5
|
+
// schema, and is copied into every generated vendor manifest. It fails only on an installer's
|
|
6
|
+
// machine, after publish, as a component the host runtime cannot load. Nothing else in the repo
|
|
7
|
+
// compares a manifest's pointers against what ships, so this engine does.
|
|
8
|
+
//
|
|
9
|
+
// Two sub-checks, neither subsuming the other (spec: .agents/specs/sdd/plugin/check-plugin-manifests/):
|
|
10
|
+
// disk — does the pointer resolve to a path that exists?
|
|
11
|
+
// publish — for a package that publishes, is the pointer inside its `files` allowlist?
|
|
12
|
+
// A directory can exist and be excluded from the tarball; a `files` entry can name a directory
|
|
13
|
+
// nobody created. The disk check short-circuits: a pointer dead on disk is reported once, as
|
|
14
|
+
// unresolved, and is not also asked about `files`.
|
|
15
|
+
//
|
|
16
|
+
// A pointer is any `./`-prefixed string value, at any depth, under any key — NOT a fixed key list.
|
|
17
|
+
// The manifest format grows new component keys, and a guard hardcoding today's set fails open on
|
|
18
|
+
// the next one added.
|
|
19
|
+
//
|
|
20
|
+
// Pure functions are exported for node:test; running the file directly drives the CLI. No
|
|
21
|
+
// dependencies (the repo's node-≥23.6 / no-deps convention).
|
|
22
|
+
|
|
23
|
+
import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs'
|
|
24
|
+
import { dirname, join, relative, sep } from 'node:path'
|
|
25
|
+
import { pathToFileURL } from 'node:url'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Directories that hold a manifest one level below the plugin root.
|
|
29
|
+
*
|
|
30
|
+
* This is a path-shape list, not a provenance one: `.plugin/` holds the **canonical** manifest and
|
|
31
|
+
* the other two hold its generated vendor copies, but all three sit one level down, so a pointer
|
|
32
|
+
* inside any of them resolves against the parent. Naming it for vendors would contradict the
|
|
33
|
+
* spec's own vocabulary and has already misled one reader.
|
|
34
|
+
*/
|
|
35
|
+
const MANIFEST_DIRS = ['.plugin', '.claude-plugin', '.codex-plugin']
|
|
36
|
+
const MANIFEST_NAME = 'plugin.json'
|
|
37
|
+
const SKIP_DIRS = new Set(['node_modules', '.git'])
|
|
38
|
+
|
|
39
|
+
export type FindingKind = 'unreadable' | 'unresolved' | 'unpublished'
|
|
40
|
+
|
|
41
|
+
export interface Finding {
|
|
42
|
+
kind: FindingKind
|
|
43
|
+
/** Repo-relative path of the manifest carrying the defect. */
|
|
44
|
+
manifest: string
|
|
45
|
+
/** The manifest key the pointer sits under. Absent for an unreadable manifest. */
|
|
46
|
+
key?: string
|
|
47
|
+
/** The pointer value. Absent for an unreadable manifest. */
|
|
48
|
+
pointer?: string
|
|
49
|
+
detail: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── Discovery ──
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Every manifest at a conventional location under `root`, repo-relative and path-sorted.
|
|
56
|
+
*
|
|
57
|
+
* A manifest is a `plugin.json` either inside a vendor directory, or sitting beside a
|
|
58
|
+
* `package.json` (a package-root manifest). Sorted so a sweep's report order is deterministic.
|
|
59
|
+
* A symlink is included: it resolves to a real manifest, and a broken one must surface as
|
|
60
|
+
* unreadable rather than vanish from the sweep.
|
|
61
|
+
*/
|
|
62
|
+
/** Directory entries, or none where the directory cannot be read. */
|
|
63
|
+
function safeReaddir(dir: string) {
|
|
64
|
+
try {
|
|
65
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
66
|
+
} catch {
|
|
67
|
+
return []
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function discoverManifests(root: string): string[] {
|
|
72
|
+
const found: string[] = []
|
|
73
|
+
const walk = (dir: string): void => {
|
|
74
|
+
for (const e of safeReaddir(dir)) {
|
|
75
|
+
const full = join(dir, e.name)
|
|
76
|
+
if (e.isDirectory()) {
|
|
77
|
+
if (!SKIP_DIRS.has(e.name)) walk(full)
|
|
78
|
+
continue
|
|
79
|
+
}
|
|
80
|
+
if (e.name !== MANIFEST_NAME) continue
|
|
81
|
+
const parent = dirname(full)
|
|
82
|
+
const inVendorDir = MANIFEST_DIRS.includes(parent.split(sep).pop() ?? '')
|
|
83
|
+
if (inVendorDir || existsSync(join(parent, 'package.json'))) found.push(full)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
walk(root)
|
|
87
|
+
return found.map((f) => relative(root, f).split(sep).join('/')).sort()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The plugin root a manifest's pointers resolve against — NOT the manifest's own directory.
|
|
92
|
+
*
|
|
93
|
+
* A vendor manifest sits one level down, and its `./skills` still names the plugin's skills
|
|
94
|
+
* directory. A guard resolving relative to the file it just read reports every vendor manifest as
|
|
95
|
+
* entirely broken.
|
|
96
|
+
*/
|
|
97
|
+
export function pluginRootOf(manifestRelPath: string): string {
|
|
98
|
+
const parts = manifestRelPath.split('/')
|
|
99
|
+
const parentName = parts[parts.length - 2]
|
|
100
|
+
const drop = parentName !== undefined && MANIFEST_DIRS.includes(parentName) ? 2 : 1
|
|
101
|
+
return parts.slice(0, parts.length - drop).join('/')
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── Pointer extraction ──
|
|
105
|
+
|
|
106
|
+
/** Every `./`-prefixed string value in the manifest, paired with the key it sits under. */
|
|
107
|
+
export function extractPointers(manifest: unknown): Array<{ key: string; pointer: string }> {
|
|
108
|
+
const out: Array<{ key: string; pointer: string }> = []
|
|
109
|
+
const visit = (value: unknown, key: string): void => {
|
|
110
|
+
if (typeof value === 'string') {
|
|
111
|
+
if (value.startsWith('./')) out.push({ key, pointer: value })
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
if (Array.isArray(value)) {
|
|
115
|
+
for (const item of value) visit(item, key)
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
if (value !== null && typeof value === 'object') {
|
|
119
|
+
for (const [k, v] of Object.entries(value)) visit(v, k)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
visit(manifest, '')
|
|
123
|
+
return out
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── The publish sub-check ──
|
|
127
|
+
|
|
128
|
+
export interface OwningPackage {
|
|
129
|
+
path: string
|
|
130
|
+
publishes: boolean
|
|
131
|
+
files: string[]
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The nearest `package.json` at or above the plugin root, and whether it publishes.
|
|
136
|
+
*
|
|
137
|
+
* A package publishes when it is not `private` AND declares a `files` allowlist. A `private`
|
|
138
|
+
* package ships no tarball, so its `files` constrains nothing. A package declaring no `files` at
|
|
139
|
+
* all ships everything, so there is no allowlist to be outside of — npm's own semantic, and
|
|
140
|
+
* inverting it manufactures a finding against every pointer in the package.
|
|
141
|
+
*/
|
|
142
|
+
export function owningPackage(root: string, pluginRoot: string): OwningPackage | null {
|
|
143
|
+
let dir = pluginRoot
|
|
144
|
+
for (;;) {
|
|
145
|
+
const candidate = dir === '' ? 'package.json' : `${dir}/package.json`
|
|
146
|
+
const abs = join(root, candidate)
|
|
147
|
+
if (existsSync(abs)) {
|
|
148
|
+
try {
|
|
149
|
+
const pkg = JSON.parse(readFileSync(abs, 'utf8')) as { private?: unknown; files?: unknown }
|
|
150
|
+
const files = Array.isArray(pkg.files) ? pkg.files.filter((f): f is string => typeof f === 'string') : null
|
|
151
|
+
return { path: candidate, publishes: pkg.private !== true && files !== null, files: files ?? [] }
|
|
152
|
+
} catch {
|
|
153
|
+
return { path: candidate, publishes: false, files: [] }
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (dir === '') return null
|
|
157
|
+
const up = dir.split('/').slice(0, -1).join('/')
|
|
158
|
+
dir = up
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Whether a `files` allowlist covers a pointer, compared on the leading path segment.
|
|
164
|
+
*
|
|
165
|
+
* Both sides are normalized past a leading `./`, because an allowlist may legitimately spell an
|
|
166
|
+
* entry either way and a pointer always carries the prefix — comparing them raw reports a shipped
|
|
167
|
+
* component as unpublished.
|
|
168
|
+
*
|
|
169
|
+
* A `!`-prefixed entry is an exclusion and needs no special case: its leading `!` is part of its
|
|
170
|
+
* first segment, so it can never equal a real segment and is never read as an inclusion.
|
|
171
|
+
*/
|
|
172
|
+
export function filesCovers(files: string[], pointer: string): boolean {
|
|
173
|
+
const head = (path: string): string | undefined => path.replace(/^\.\//, '').split('/')[0]
|
|
174
|
+
const segment = head(pointer)
|
|
175
|
+
if (segment === undefined || segment === '') return true
|
|
176
|
+
return files.some((entry) => {
|
|
177
|
+
const h = head(entry)
|
|
178
|
+
// A wildcard head ships everything below it, so it covers any segment.
|
|
179
|
+
return h === '*' || h === '**' || h === segment
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ── The sweep ──
|
|
184
|
+
|
|
185
|
+
export function sweep(root: string, manifests: string[]): Finding[] {
|
|
186
|
+
const findings: Finding[] = []
|
|
187
|
+
for (const rel of manifests) {
|
|
188
|
+
let parsed: unknown
|
|
189
|
+
try {
|
|
190
|
+
parsed = JSON.parse(readFileSync(join(root, rel), 'utf8'))
|
|
191
|
+
} catch {
|
|
192
|
+
findings.push({ kind: 'unreadable', manifest: rel, detail: 'not readable as JSON' })
|
|
193
|
+
continue
|
|
194
|
+
}
|
|
195
|
+
const pluginRoot = pluginRootOf(rel)
|
|
196
|
+
const pkg = owningPackage(root, pluginRoot)
|
|
197
|
+
for (const { key, pointer } of extractPointers(parsed)) {
|
|
198
|
+
const target = join(root, pluginRoot, pointer)
|
|
199
|
+
if (!existsSync(target)) {
|
|
200
|
+
findings.push({ kind: 'unresolved', manifest: rel, key, pointer, detail: 'names no path on disk' })
|
|
201
|
+
continue
|
|
202
|
+
}
|
|
203
|
+
if (pkg?.publishes && !filesCovers(pkg.files, pointer)) {
|
|
204
|
+
findings.push({
|
|
205
|
+
kind: 'unpublished',
|
|
206
|
+
manifest: rel,
|
|
207
|
+
key,
|
|
208
|
+
pointer,
|
|
209
|
+
detail: `declared but not published — ${pkg.path} files omits it`,
|
|
210
|
+
})
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return findings
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ── Report ──
|
|
218
|
+
|
|
219
|
+
export function formatReport(manifests: string[], findings: Finding[]): string {
|
|
220
|
+
if (manifests.length === 0) return 'check-plugin-manifests: no plugin manifest found\n'
|
|
221
|
+
if (findings.length === 0) {
|
|
222
|
+
return `check-plugin-manifests: ${manifests.length} manifest(s) OK\n`
|
|
223
|
+
}
|
|
224
|
+
const rows = findings
|
|
225
|
+
.map((f) => ` ${f.kind.padEnd(11)} ${f.manifest}${f.key ? ` [${f.key}] ${f.pointer}` : ''} — ${f.detail}`)
|
|
226
|
+
.join('\n')
|
|
227
|
+
return `${rows}\ncheck-plugin-manifests: ${findings.length} finding(s) across ${manifests.length} manifest(s)\n`
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ── CLI ──
|
|
231
|
+
|
|
232
|
+
export function main(argv: string[]): number {
|
|
233
|
+
let root = '.'
|
|
234
|
+
for (let i = 0; i < argv.length; i++) {
|
|
235
|
+
const a = argv[i]
|
|
236
|
+
if (a === '--root') {
|
|
237
|
+
const next = argv[++i]
|
|
238
|
+
if (next === undefined) {
|
|
239
|
+
process.stderr.write('check-plugin-manifests: --root needs a directory\n')
|
|
240
|
+
return 1
|
|
241
|
+
}
|
|
242
|
+
root = next
|
|
243
|
+
continue
|
|
244
|
+
}
|
|
245
|
+
process.stderr.write(`check-plugin-manifests: unrecognized flag ${a}\n`)
|
|
246
|
+
return 1
|
|
247
|
+
}
|
|
248
|
+
const manifests = discoverManifests(root)
|
|
249
|
+
const findings = sweep(root, manifests)
|
|
250
|
+
process.stdout.write(formatReport(manifests, findings))
|
|
251
|
+
return findings.length === 0 ? 0 : 1
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
|
|
255
|
+
process.exit(main(process.argv.slice(2)))
|
|
256
|
+
}
|
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
# check-project-specs
|
|
2
2
|
|
|
3
|
-
Runs every project-spec check
|
|
3
|
+
Runs every project-spec check — over the whole corpus, or over one project's spec.
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
|
-
node scripts/check-project-specs.mts
|
|
7
|
-
node scripts/check-project-specs.mts
|
|
6
|
+
node scripts/check-project-specs.mts --corpus # every project-spec + the coverage guard
|
|
7
|
+
node scripts/check-project-specs.mts # one project, resolved from cwd
|
|
8
|
+
node scripts/check-project-specs.mts --project <dir> # one project, explicit dir
|
|
8
9
|
```
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
|
|
11
|
+
The **corpus** scope is the repo's commit floor, wired as the root `check:specs`; it is total by
|
|
12
|
+
construction — the engine sweep and the coverage guard together, because neither alone sees a spec
|
|
13
|
+
the other misses. The **project** scope is each project's `check:spec` script through the
|
|
14
|
+
`sdd-check-specs` bin, so every project — `plugins/*` and `packages/*` alike — runs the identical,
|
|
15
|
+
path-free command.
|
|
16
|
+
|
|
17
|
+
A flag the harness does not define is an error, never a default scope.
|
|
12
18
|
|
|
13
19
|
Resolution is spec-first: the spec's own `project-path` names the project dir, and
|
|
14
20
|
`check-project-specs` inverts that map. The reverse map cannot be derived by name
|
|
@@ -16,4 +22,5 @@ Resolution is spec-first: the spec's own `project-path` names the project dir, a
|
|
|
16
22
|
|
|
17
23
|
A project no spec governs prints a skip and exits zero. Two specs claiming one project is an error.
|
|
18
24
|
|
|
19
|
-
See `SKILL.md` for the engine set and the cwd contract
|
|
25
|
+
See `SKILL.md` for the engine set and the cwd contract, and
|
|
26
|
+
`.agents/specs/sdd/corpus/spec-floor/` for the spec.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: check-project-specs
|
|
3
|
-
description: "Partial Skill: invoke by name only —
|
|
3
|
+
description: "Partial Skill: invoke by name only — corpus/spec-floor's engine that runs every project-spec check, over the whole corpus or over one project — the commit-floor and CI entrypoint, not triggered by users directly."
|
|
4
4
|
user-invocable: false
|
|
5
5
|
metadata:
|
|
6
6
|
internal: true
|
|
@@ -8,10 +8,27 @@ metadata:
|
|
|
8
8
|
|
|
9
9
|
# Check Project Specs
|
|
10
10
|
|
|
11
|
-
The **
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
The **entrypoint** for the project-spec checks — the harness behind `corpus/spec-floor`. It resolves
|
|
12
|
+
which spec governs which project and runs each project-spec engine against it. It carries a
|
|
13
|
+
self-contained `.mts` script (the repo's node-≥23.6 / no-deps convention).
|
|
14
|
+
|
|
15
|
+
Two scopes:
|
|
16
|
+
|
|
17
|
+
| Scope | Flag | Who runs it |
|
|
18
|
+
|---|---|---|
|
|
19
|
+
| **corpus** — every project-spec, plus the coverage guard | `--corpus` | the repo's `check:specs` chain, so every commit and every CI run |
|
|
20
|
+
| **project** — one project-spec | `--project <dir>`, or no flag (the cwd) | a maintainer inside one project; each project's own `check:spec` |
|
|
21
|
+
|
|
22
|
+
**The corpus scope is total, and that is the point.** It runs *both* the engine sweep and the
|
|
23
|
+
coverage guard, because neither subsumes the other: the sweep only visits the specs discovery
|
|
24
|
+
**recognizes**, so a spec whose lifecycle `status` is a typo is invisible to it and the sweep would
|
|
25
|
+
report clean; the coverage guard sees that file on disk and escalates it, but says nothing about
|
|
26
|
+
whether the engines pass. Run either alone and a whole project-spec leaves the floor silently.
|
|
27
|
+
|
|
28
|
+
**An unrecognized flag is an error.** The scope is chosen by a flag, so a flag that falls through to
|
|
29
|
+
a default silently downgrades the run — and project scope, which is what a fall-through reaches,
|
|
30
|
+
resolves no governing spec at a repo root and exits **0**. That is exactly how a coverage-only flag
|
|
31
|
+
guarded this repo's commits and CI while running no engine at all (issue #5).
|
|
15
32
|
|
|
16
33
|
## Resolution — spec-first, never by name
|
|
17
34
|
|
|
@@ -31,13 +48,26 @@ mapping and no path is ever written into a package's scripts.
|
|
|
31
48
|
## Run it
|
|
32
49
|
|
|
33
50
|
```bash
|
|
34
|
-
node "<skill>/scripts/check-project-specs.mts"
|
|
51
|
+
node "<skill>/scripts/check-project-specs.mts" --corpus # the whole corpus — the commit floor
|
|
52
|
+
node "<skill>/scripts/check-project-specs.mts" [--project <dir>] # one project-spec
|
|
35
53
|
```
|
|
36
54
|
|
|
37
|
-
|
|
55
|
+
The corpus scope is wired as the repo's root `check:specs`; the project scope is wired as each
|
|
56
|
+
project's `check:spec` script, via the `sdd-check-specs` bin. `--corpus` and `--project` name
|
|
57
|
+
contradictory scopes and are refused together.
|
|
38
58
|
|
|
39
59
|
## Outcomes
|
|
40
60
|
|
|
61
|
+
**Corpus scope**
|
|
62
|
+
|
|
63
|
+
- **Clean** — every recognized project-spec passed every engine and every spec file is covered; exits zero.
|
|
64
|
+
- **A coverage gap** — reports each gap with its reason, **then sweeps anyway**, and exits non-zero.
|
|
65
|
+
- **A failing engine** — names the engine and the project-spec, **continues to the next**, exits non-zero.
|
|
66
|
+
- **An empty corpus** — reports that plainly and exits zero. A repo with no project-spec is not a defect.
|
|
67
|
+
- **No repo root** — names the missing workspace marker and exits non-zero; it never falls back to the cwd.
|
|
68
|
+
|
|
69
|
+
**Project scope**
|
|
70
|
+
|
|
41
71
|
- **Resolved** — runs every engine against the spec dir, reports `ok` / `FAIL` per engine, and exits
|
|
42
72
|
non-zero if any failed.
|
|
43
73
|
- **No spec governs this project** — prints that and exits **zero**. The script is uniform across
|
|
@@ -48,13 +78,14 @@ Wired as each project's `check:spec` script, via the `sdd-check-specs` bin.
|
|
|
48
78
|
## The engines it runs
|
|
49
79
|
|
|
50
80
|
`check-spec-state` and `check-suite` (each `--root <specDir>`), then `concept-index`,
|
|
51
|
-
`check-spec-structure`,
|
|
81
|
+
`check-spec-structure`, `align-spec`, and `check-scenario-overlap` (each `--spec-dir <specDir>
|
|
82
|
+
--check`), and `check-spec-references` (`--spec-dir <specDir>`).
|
|
52
83
|
|
|
53
|
-
**`check-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
84
|
+
**`check-spec-references` takes no `--check`.** Every other engine here has a second mode a `--check`
|
|
85
|
+
selects between — write-vs-verify, or audit-vs-gate. This one does not: it is read-only and
|
|
86
|
+
single-severity, so a report mode would differ from the guard by exit code alone. A path that is
|
|
87
|
+
correct but not resolvable from the file carrying it (prose quoting a symlink target, say) is
|
|
88
|
+
excused inline by the engine's own marker, not by a severity.
|
|
58
89
|
|
|
59
90
|
Every engine is spawned with **cwd = the repo root**, never the project dir — they resolve
|
|
60
91
|
repo-root-relative references against the cwd.
|
|
@@ -62,6 +93,9 @@ repo-root-relative references against the cwd.
|
|
|
62
93
|
The two `--root` engines are corpus-shaped (they read the first path segment under root as a project
|
|
63
94
|
slug), but a single project-spec dir is a legal root: the slug is only a message tag.
|
|
64
95
|
|
|
96
|
+
A failing engine never stops the ones after it, at either scope: a floor that returns at the first
|
|
97
|
+
defect reports one defect per invocation, so an author fixes the tree one run at a time.
|
|
98
|
+
|
|
65
99
|
## Boundaries
|
|
66
100
|
|
|
67
101
|
It owns no checks of its own — it resolves and delegates. It writes nothing, and it never decides
|
|
@@ -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.
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// check-spec-references — resolve every explicitly-relative reference a project spec's .md files
|
|
3
|
+
// carry, and report the ones that resolve to nothing.
|
|
4
|
+
//
|
|
5
|
+
// The recurrence this closes was not a typo but a CONSISTENT OFF-BY-ONE: every `../../../src/…`
|
|
6
|
+
// reference in a spec corpus was one directory level short, and every one of them read as entirely
|
|
7
|
+
// plausible — right filename, right-looking depth, wrong level. Re-reading a reference by eye is
|
|
8
|
+
// not a check on it; only resolving it is. So the finding names BOTH the reference as written and
|
|
9
|
+
// the path it actually resolved to — the second is the half review cannot supply.
|
|
10
|
+
//
|
|
11
|
+
// Scope is deliberately narrow. Only an EXPLICITLY-RELATIVE path (`./` or `../`) is a reference; a
|
|
12
|
+
// bare path in inline code is prose. A spec corpus is full of bare paths that are illustrative or
|
|
13
|
+
// relative to somewhere other than the repo (`cli/`, `skills/doctor/`, `.claude/skills`,
|
|
14
|
+
// `~/.codex/config.toml`), and resolving those would reject nearly all of them. The prefix rule is
|
|
15
|
+
// what makes the repo-root-relative case (`.research/agentic-configuration-standards/`) pass by
|
|
16
|
+
// construction rather than by exception.
|
|
17
|
+
//
|
|
18
|
+
// There is exactly ONE escape hatch, deliberately. A second, implicit one was built and then cut:
|
|
19
|
+
// excluding whatever a doubled-backtick span holds, on the theory that such a span exhibits markup
|
|
20
|
+
// rather than citing it. It was unconditional, so a genuinely broken reference written that way
|
|
21
|
+
// escaped silently — reproducing, inside the exclusion, the exact "looks anchored, isn't" failure
|
|
22
|
+
// this engine exists to close. An escape must be explicit, reasoned, and visible where it applies.
|
|
23
|
+
//
|
|
24
|
+
// One false-positive class is genuine and recurs: prose QUOTING a path relative to something other
|
|
25
|
+
// than the file it sits in — the text held inside a bridge file, a symlink target relative to
|
|
26
|
+
// `.cursor/`. That is structurally indistinguishable from a real anchor, so an inline
|
|
27
|
+
// `<!-- spec-ref-ignore: why -->` marker suppresses its own line, keeping the justification beside
|
|
28
|
+
// the prose it excuses instead of in a registry that drifts away from what it covers.
|
|
29
|
+
//
|
|
30
|
+
// Pure functions are exported for node:test; running the file directly drives the CLI. No
|
|
31
|
+
// dependencies (the repo's node-≥23.6 / no-deps convention).
|
|
32
|
+
|
|
33
|
+
import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs'
|
|
34
|
+
import { dirname, join, relative, resolve } from 'node:path'
|
|
35
|
+
import { pathToFileURL } from 'node:url'
|
|
36
|
+
|
|
37
|
+
// ── Extraction ──
|
|
38
|
+
|
|
39
|
+
/** A reference as written, and the 1-based line it sits on. */
|
|
40
|
+
export interface Reference {
|
|
41
|
+
line: number
|
|
42
|
+
ref: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Suppresses every reference on the line it appears on. Matched as a COMPLETE html comment, not
|
|
46
|
+
* as a substring: a marker whose name merely starts with this one's (a typo, a future sibling)
|
|
47
|
+
* must not silently inherit its power to hide a reference. The optional `: reason` is convention,
|
|
48
|
+
* not syntax — the marker is recognized with or without one. */
|
|
49
|
+
const IGNORE_MARKER_RE = /<!--\s*spec-ref-ignore\s*(?::[^>]*)?-->/
|
|
50
|
+
|
|
51
|
+
/** A markdown inline link whose target is explicitly relative — covering the plain form, an
|
|
52
|
+
* angle-bracket-wrapped target, and an optional title in any of markdown's three quotings. Run only
|
|
53
|
+
* over the parts of a line that are NOT inside a code span: inside one, markup is literal text on
|
|
54
|
+
* display, not a link. */
|
|
55
|
+
const LINK_RE = /\]\(\s*(?:<([^>\n]*)>|(\.{1,2}\/[^)\s]*))(?:\s+(?:"[^"]*"|'[^']*'|\([^)]*\)))?\s*\)/g
|
|
56
|
+
|
|
57
|
+
/** A reference-style link definition (`[label]: ../path`) whose target is explicitly relative. It
|
|
58
|
+
* is a link target like any other — the label form changes where the path is written, not what it
|
|
59
|
+
* points at. */
|
|
60
|
+
const LINK_DEF_RE = /^\s{0,3}\[.+\]:\s*(?:<([^>\n]*)>|(\S+))/
|
|
61
|
+
|
|
62
|
+
/** An explicitly-relative path, and nothing else. The trailing class excludes whitespace and
|
|
63
|
+
* backticks so that "the span's WHOLE content is the path" means what it says: a span holding
|
|
64
|
+
* `../a` plus commentary is prose that starts with a path, not a citation. */
|
|
65
|
+
const RELATIVE_RE = /^\.{1,2}\/[^\s`]*$/
|
|
66
|
+
|
|
67
|
+
/** The same, minus the no-whitespace rule — for an ANGLE-BRACKET link target, whose whole reason
|
|
68
|
+
* for existing is to carry a path with a space in it. */
|
|
69
|
+
const RELATIVE_IN_ANGLES_RE = /^\.{1,2}\/[^`]*$/
|
|
70
|
+
|
|
71
|
+
/** An open fence, remembered so the close can be matched against it. */
|
|
72
|
+
interface Fence {
|
|
73
|
+
char: '`' | '~'
|
|
74
|
+
length: number
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The fence a line opens, or `undefined`. CommonMark: three or more backticks or tildes, indented
|
|
79
|
+
* fewer than four spaces.
|
|
80
|
+
*/
|
|
81
|
+
function fenceOpenedBy(line: string): Fence | undefined {
|
|
82
|
+
const m = /^ {0,3}(`{3,}|~{3,})/.exec(line)
|
|
83
|
+
if (m === null) return undefined
|
|
84
|
+
const run = m[1] as string
|
|
85
|
+
return { char: run[0] as '`' | '~', length: run.length }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Whether `line` CLOSES `fence`: the same character, at least as long, and nothing but whitespace
|
|
90
|
+
* after it.
|
|
91
|
+
*
|
|
92
|
+
* Matching the delimiter rather than toggling a boolean on any fence-shaped line is what keeps the
|
|
93
|
+
* exclusion honest. A bare toggle flips on a `~~~` line inside a backtick fence — so a reference
|
|
94
|
+
* genuinely inside a fence gets extracted, and, worse, a fence-shaped line inside another fence
|
|
95
|
+
* leaves the parity inverted and every genuinely broken reference AFTER the block is silently
|
|
96
|
+
* swallowed. That is this engine's own failure class, reached through the one rule meant to
|
|
97
|
+
* suppress noise.
|
|
98
|
+
*/
|
|
99
|
+
function fenceClosedBy(line: string, fence: Fence): boolean {
|
|
100
|
+
const m = /^ {0,3}(`{3,}|~{3,})\s*$/.exec(line)
|
|
101
|
+
if (m === null) return false
|
|
102
|
+
const run = m[1] as string
|
|
103
|
+
return run[0] === fence.char && run.length >= fence.length
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* A line that starts a new block: blank, an ATX heading, a blockquote, a list item, or a thematic
|
|
108
|
+
* break. Inline parsing happens WITHIN a block, so a code span cannot reach across one of these —
|
|
109
|
+
* and the boundary matters in the under-reporting direction. An unclosed backtick left by a typo
|
|
110
|
+
* would otherwise pair with the first backtick of the next block, swallowing the span that was
|
|
111
|
+
* about to open and every reference after it in the flow.
|
|
112
|
+
*/
|
|
113
|
+
export function startsNewBlock(line: string): boolean {
|
|
114
|
+
if (/^[ \t]*$/.test(line)) return true
|
|
115
|
+
if (/^ {0,3}#{1,6}(\s|$)/.test(line)) return true // ATX heading
|
|
116
|
+
if (/^ {0,3}>/.test(line)) return true // blockquote
|
|
117
|
+
if (/^ {0,3}([-+*]|\d{1,9}[.)])(\s|$)/.test(line)) return true // list item
|
|
118
|
+
if (/^ {0,3}((\*[ \t]*){3,}|(-[ \t]*){3,}|(_[ \t]*){3,})$/.test(line)) return true // thematic break
|
|
119
|
+
return false
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
interface CodeSpan {
|
|
123
|
+
/** The span's content, line endings folded to spaces and CommonMark's one-space padding stripped. */
|
|
124
|
+
content: string
|
|
125
|
+
/** Offset into the scanned text where the opening run begins. */
|
|
126
|
+
start: number
|
|
127
|
+
end: number
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Same length, same line breaks, no content — so blanking a region shifts no offset and moves no
|
|
131
|
+
* finding to another line. */
|
|
132
|
+
function blankOut(region: string): string {
|
|
133
|
+
return region.replace(/[^\n]/g, ' ')
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The inline-code spans on one line, scanned the way CommonMark delimits them: a run of N backticks
|
|
138
|
+
* opens, the next run of EXACTLY N closes, and one leading + trailing space is stripped when both
|
|
139
|
+
* are present.
|
|
140
|
+
*
|
|
141
|
+
* Scanning properly rather than matching a single-backtick pair is what makes the reference rule —
|
|
142
|
+
* "a code span is a reference when its WHOLE content is the path" — mean one thing everywhere. A
|
|
143
|
+
* doubled span written around another span (`` `x` ``) has content that still carries backticks, so
|
|
144
|
+
* it is not a path and not a reference; a doubled span written around a bare path has the path as
|
|
145
|
+
* its content, so it IS one. Neither is a special case, and neither leaves a broken reference
|
|
146
|
+
* anywhere to hide.
|
|
147
|
+
*/
|
|
148
|
+
export function scanCodeSpans(text: string, blockStarts: readonly number[] = []): CodeSpan[] {
|
|
149
|
+
const out: CodeSpan[] = []
|
|
150
|
+
let i = 0
|
|
151
|
+
while (i < text.length) {
|
|
152
|
+
if (text[i] !== '`') {
|
|
153
|
+
i++
|
|
154
|
+
continue
|
|
155
|
+
}
|
|
156
|
+
const open = i
|
|
157
|
+
while (text[i] === '`') i++
|
|
158
|
+
const runLength = i - open
|
|
159
|
+
// find the next run of exactly runLength backticks, stopping at the next block boundary — a
|
|
160
|
+
// code span lives inside one block and cannot reach past it
|
|
161
|
+
const limit = blockStarts.find((b) => b > open) ?? text.length
|
|
162
|
+
let j = i
|
|
163
|
+
let closeStart = -1
|
|
164
|
+
while (j < limit) {
|
|
165
|
+
if (text[j] !== '`') {
|
|
166
|
+
j++
|
|
167
|
+
continue
|
|
168
|
+
}
|
|
169
|
+
const runStart = j
|
|
170
|
+
while (text[j] === '`') j++
|
|
171
|
+
if (j - runStart === runLength) {
|
|
172
|
+
closeStart = runStart
|
|
173
|
+
break
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (closeStart === -1) {
|
|
177
|
+
// An unmatched run is literal text, and the scan RESUMES after it — CommonMark's own
|
|
178
|
+
// recovery. Abandoning the rest of the text instead would let one stray backtick
|
|
179
|
+
// silently swallow every reference after it, which is this engine's own failure class
|
|
180
|
+
// wearing a different hat.
|
|
181
|
+
i = open + runLength
|
|
182
|
+
continue
|
|
183
|
+
}
|
|
184
|
+
// Line endings inside a span are spaces, per CommonMark — which is what lets a span that
|
|
185
|
+
// WRAPS still be one span. Prose here hard-wraps, so a long path in backticks landing
|
|
186
|
+
// across a line break is ordinary, not exotic; scanning line by line would have left every
|
|
187
|
+
// wrapped reference unread.
|
|
188
|
+
let content = text.slice(open + runLength, closeStart).replace(/\r?\n/g, ' ')
|
|
189
|
+
if (content.length > 1 && content.startsWith(' ') && content.endsWith(' ') && content.trim() !== '') {
|
|
190
|
+
content = content.slice(1, -1)
|
|
191
|
+
}
|
|
192
|
+
out.push({ content, start: open, end: j })
|
|
193
|
+
i = j
|
|
194
|
+
}
|
|
195
|
+
return out
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Every explicitly-relative reference in `text`, in document order.
|
|
200
|
+
*
|
|
201
|
+
* Skipped: lines inside a fenced code block, and lines carrying the ignore marker. Extraction is
|
|
202
|
+
* line-scoped on purpose — it is what gives each finding a line number, and what lets the marker's
|
|
203
|
+
* scope be exactly the line it appears on rather than the whole file.
|
|
204
|
+
*/
|
|
205
|
+
export function extractReferences(text: string): Reference[] {
|
|
206
|
+
const lines = text.split('\n')
|
|
207
|
+
|
|
208
|
+
// 1. Blank the fenced blocks, keeping every line's length and every line break, so offsets and
|
|
209
|
+
// line numbers below still mean what they say.
|
|
210
|
+
let fence: Fence | undefined
|
|
211
|
+
const unfenced = lines.map((line) => {
|
|
212
|
+
if (fence !== undefined) {
|
|
213
|
+
if (fenceClosedBy(line, fence)) fence = undefined
|
|
214
|
+
return blankOut(line)
|
|
215
|
+
}
|
|
216
|
+
const opened = fenceOpenedBy(line)
|
|
217
|
+
if (opened !== undefined) {
|
|
218
|
+
fence = opened
|
|
219
|
+
return blankOut(line)
|
|
220
|
+
}
|
|
221
|
+
return line
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
// 2. Scan code spans across the WHOLE text, not line by line: a span that wraps is still one
|
|
225
|
+
// span, and reading it as two would leave a wrapped reference unread. Bounded at block
|
|
226
|
+
// starts, so widening the window past a line break does not let a stray backtick reach into
|
|
227
|
+
// the next block.
|
|
228
|
+
const scannable = unfenced.join('\n')
|
|
229
|
+
const lineStarts: number[] = []
|
|
230
|
+
let acc = 0
|
|
231
|
+
for (const line of lines) {
|
|
232
|
+
lineStarts.push(acc)
|
|
233
|
+
acc += line.length + 1
|
|
234
|
+
}
|
|
235
|
+
// A blanked fence line is all spaces, so it reads as a block start here too — the fence bounds
|
|
236
|
+
// a span by the same rule as everything else rather than by a happy accident.
|
|
237
|
+
const blockStarts = lineStarts.filter((_start, i) => i > 0 && startsNewBlock(unfenced[i] as string))
|
|
238
|
+
const lineOf = (offset: number): number => {
|
|
239
|
+
let lo = 0
|
|
240
|
+
let hi = lineStarts.length - 1
|
|
241
|
+
while (lo < hi) {
|
|
242
|
+
const mid = (lo + hi + 1) >> 1
|
|
243
|
+
if ((lineStarts[mid] as number) <= offset) lo = mid
|
|
244
|
+
else hi = mid - 1
|
|
245
|
+
}
|
|
246
|
+
return lo
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const spans = scanCodeSpans(scannable, blockStarts)
|
|
250
|
+
let outsideSpans = scannable
|
|
251
|
+
for (const span of spans) {
|
|
252
|
+
outsideSpans =
|
|
253
|
+
outsideSpans.slice(0, span.start) +
|
|
254
|
+
blankOut(outsideSpans.slice(span.start, span.end)) +
|
|
255
|
+
outsideSpans.slice(span.end)
|
|
256
|
+
}
|
|
257
|
+
const outsideLines = outsideSpans.split('\n')
|
|
258
|
+
|
|
259
|
+
// 3. The marker is read from OUTSIDE the code spans — the same rule, not an exception for the
|
|
260
|
+
// escape hatch. Read from the raw line it would fire on a line that merely QUOTES it, which
|
|
261
|
+
// is how this very node documents it, and that line's real references would vanish: an
|
|
262
|
+
// escape hatch a description of the escape hatch can trigger hides exactly what this engine
|
|
263
|
+
// exists to find. A wrapped span is judged by the line it OPENS on — the line a reader
|
|
264
|
+
// would put the marker beside.
|
|
265
|
+
const marked = outsideLines.map((line) => IGNORE_MARKER_RE.test(line))
|
|
266
|
+
|
|
267
|
+
const perLine = new Map<number, Set<string>>()
|
|
268
|
+
const add = (line: number, ref: string) => {
|
|
269
|
+
if (marked[line] === true) return
|
|
270
|
+
const set = perLine.get(line) ?? new Set<string>()
|
|
271
|
+
set.add(ref)
|
|
272
|
+
perLine.set(line, set)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
for (const span of spans) {
|
|
276
|
+
const content = span.content.trim()
|
|
277
|
+
if (RELATIVE_RE.test(content)) add(lineOf(span.start), content)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
outsideLines.forEach((line, i) => {
|
|
281
|
+
const addTarget = (angled: string | undefined, bare: string | undefined) => {
|
|
282
|
+
if (angled !== undefined) {
|
|
283
|
+
if (RELATIVE_IN_ANGLES_RE.test(angled)) add(i, angled)
|
|
284
|
+
return
|
|
285
|
+
}
|
|
286
|
+
if (bare !== undefined && RELATIVE_RE.test(bare)) add(i, bare)
|
|
287
|
+
}
|
|
288
|
+
for (const m of line.matchAll(LINK_RE)) addTarget(m[1], m[2])
|
|
289
|
+
const def = LINK_DEF_RE.exec(line)
|
|
290
|
+
if (def) addTarget(def[1], def[2])
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
const out: Reference[] = []
|
|
294
|
+
for (const line of [...perLine.keys()].sort((a, b) => a - b)) {
|
|
295
|
+
for (const ref of perLine.get(line) as Set<string>) out.push({ line: line + 1, ref })
|
|
296
|
+
}
|
|
297
|
+
return out
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ── Resolution ──
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Where `ref` points, resolved against `fileDir` — the directory of the file that CARRIES it,
|
|
304
|
+
* never the spec root and never the repo root. A trailing `#fragment` is stripped first; a
|
|
305
|
+
* trailing slash is immaterial (`resolve` drops it), so a directory reference resolves either way.
|
|
306
|
+
*/
|
|
307
|
+
export function resolveReference(fileDir: string, ref: string): string {
|
|
308
|
+
const withoutFragment = ref.replace(/#.*$/, '')
|
|
309
|
+
return resolve(fileDir, withoutFragment)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ── The walk ──
|
|
313
|
+
|
|
314
|
+
/** Every `.md` file under `dir`, at any depth, absolute and sorted so the report is stable. */
|
|
315
|
+
export function listMarkdownFiles(dir: string): string[] {
|
|
316
|
+
const out: string[] = []
|
|
317
|
+
const walk = (d: string) => {
|
|
318
|
+
for (const e of readdirSync(d, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : 1))) {
|
|
319
|
+
const p = join(d, e.name)
|
|
320
|
+
if (e.isDirectory()) walk(p)
|
|
321
|
+
else if (e.name.endsWith('.md')) out.push(p)
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
walk(dir)
|
|
325
|
+
return out
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ── Audit ──
|
|
329
|
+
|
|
330
|
+
export interface Finding {
|
|
331
|
+
/** Absolute path of the file carrying the reference. */
|
|
332
|
+
file: string
|
|
333
|
+
line: number
|
|
334
|
+
/** The reference exactly as written. */
|
|
335
|
+
ref: string
|
|
336
|
+
/** The absolute path it resolved to — the half a reader cannot supply by eye. */
|
|
337
|
+
resolved: string
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export interface AuditOptions {
|
|
341
|
+
/** Substitute the markdown walk (tests assert which files are visited). */
|
|
342
|
+
listMarkdownFiles?: (dir: string) => string[]
|
|
343
|
+
/** Substitute the file reader (tests assert nothing outside the walk is read). */
|
|
344
|
+
readFile?: (path: string) => string
|
|
345
|
+
/** Substitute the existence probe. */
|
|
346
|
+
exists?: (path: string) => boolean
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Every unresolved reference under `specDir`, ordered by file, then line, then reference — so two
|
|
351
|
+
* runs over an unchanged tree render byte-identically.
|
|
352
|
+
*
|
|
353
|
+
* EVERY finding, never the first: a single off-by-one lands as a whole family of broken
|
|
354
|
+
* references, and reporting one at a time would take as many runs to clear as there are levels
|
|
355
|
+
* wrong.
|
|
356
|
+
*/
|
|
357
|
+
export function audit(specDir: string, options: AuditOptions = {}): Finding[] {
|
|
358
|
+
const list = options.listMarkdownFiles ?? listMarkdownFiles
|
|
359
|
+
const read = options.readFile ?? ((p: string) => readFileSync(p, 'utf8'))
|
|
360
|
+
const has = options.exists ?? existsSync
|
|
361
|
+
|
|
362
|
+
const findings: Finding[] = []
|
|
363
|
+
for (const file of list(specDir)) {
|
|
364
|
+
const fileDir = dirname(file)
|
|
365
|
+
for (const { line, ref } of extractReferences(read(file))) {
|
|
366
|
+
const resolved = resolveReference(fileDir, ref)
|
|
367
|
+
if (!has(resolved)) findings.push({ file, line, ref, resolved })
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return findings.sort((a, b) =>
|
|
371
|
+
a.file !== b.file ? (a.file < b.file ? -1 : 1) : a.line !== b.line ? a.line - b.line : a.ref < b.ref ? -1 : 1,
|
|
372
|
+
)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ── Report ──
|
|
376
|
+
|
|
377
|
+
/** Renders each finding as `file:line: <ref> -> <resolved>`, both paths relative to `base` so the
|
|
378
|
+
* report reads the same from any checkout. */
|
|
379
|
+
export function formatFindings(findings: Finding[], base: string): string {
|
|
380
|
+
if (findings.length === 0) return 'check-spec-references: every relative reference resolves\n'
|
|
381
|
+
const lines = findings.map(
|
|
382
|
+
(f) =>
|
|
383
|
+
` ${relative(base, f.file)}:${f.line}: \`${f.ref}\` -> ${relative(base, f.resolved)} — no file or directory there\n`,
|
|
384
|
+
)
|
|
385
|
+
lines.push(`check-spec-references: ${findings.length} unresolved reference(s)\n`)
|
|
386
|
+
return lines.join('')
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ── CLI ──
|
|
390
|
+
|
|
391
|
+
// One mode, deliberately. A report-only mode would differ from the guard by exit code alone —
|
|
392
|
+
// the same findings, printed the same way — and no actor wants the list without wanting it fixed.
|
|
393
|
+
// Every finding is a defect, so every finding fails the run.
|
|
394
|
+
export function main(argv: string[], cwd: string = process.cwd()): number {
|
|
395
|
+
const i = argv.indexOf('--spec-dir')
|
|
396
|
+
const specDir = i === -1 ? '' : (argv[i + 1] ?? '')
|
|
397
|
+
if (specDir === '') {
|
|
398
|
+
process.stderr.write('check-spec-references: --spec-dir <dir> is required\n')
|
|
399
|
+
return 1
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const dir = resolve(cwd, specDir)
|
|
403
|
+
// A spec dir that is not there is refused by name, never as a raw stack trace: a mistyped path
|
|
404
|
+
// must not read like a corpus that has no markdown in it.
|
|
405
|
+
if (!existsSync(dir)) {
|
|
406
|
+
process.stderr.write(`check-spec-references: no directory at ${relative(cwd, dir)}\n`)
|
|
407
|
+
return 1
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const findings = audit(dir)
|
|
411
|
+
const report = formatFindings(findings, cwd)
|
|
412
|
+
if (findings.length === 0) {
|
|
413
|
+
process.stdout.write(report)
|
|
414
|
+
return 0
|
|
415
|
+
}
|
|
416
|
+
process.stderr.write(report)
|
|
417
|
+
return 1
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
|
|
421
|
+
process.exit(main(process.argv.slice(2)))
|
|
422
|
+
}
|
package/LICENSE
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2025 unional
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|