cyber-sdd 0.4.2 → 0.6.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 +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.plugin/plugin.json +1 -1
- package/agents/sdd-impl-judge.md +1 -1
- package/agents/sdd-spec-judge.md +1 -1
- package/package.json +1 -1
- package/skills/check-field-mandates/SKILL.md +61 -0
- package/skills/check-field-mandates/scripts/check-field-mandates.mts +337 -0
- package/skills/check-plugin-manifests/SKILL.md +3 -3
- package/skills/check-plugin-manifests/scripts/check-plugin-manifests.mts +51 -4
- package/skills/impl-producer-governance/SKILL.md +2 -1
- package/skills/solution-producer-governance/SKILL.md +1 -1
- package/skills/spec-producer-governance/SKILL.md +2 -2
package/.plugin/plugin.json
CHANGED
package/agents/sdd-impl-judge.md
CHANGED
|
@@ -44,7 +44,7 @@ when you grade against that bar. The **impl-gate lens set is {builder, architect
|
|
|
44
44
|
## Input
|
|
45
45
|
|
|
46
46
|
```
|
|
47
|
-
ARTIFACT_TYPE, NODE_PATH(s), SPEC_PATH, FEATURE_PATH
|
|
47
|
+
ARTIFACT_TYPE, NODE_PATH(s), SPEC_PATH, FEATURE_PATH: the touched artifact-type, the node folder(s), the spec.md, and the .feature
|
|
48
48
|
IMPLEMENTATION_PATHS: impl-layer paths from the ## Artifacts table
|
|
49
49
|
VERIFICATION_PATHS: the verification the impl-producer authored (or discoverable across IMPLEMENTATION_PATHS)
|
|
50
50
|
```
|
package/agents/sdd-spec-judge.md
CHANGED
|
@@ -44,7 +44,7 @@ them):
|
|
|
44
44
|
## Input
|
|
45
45
|
|
|
46
46
|
```
|
|
47
|
-
ARTIFACT_TYPE, NODE_PATH(s), SPEC_PATH, FEATURE_PATH
|
|
47
|
+
ARTIFACT_TYPE, NODE_PATH(s), SPEC_PATH, FEATURE_PATH: the touched artifact-type, the node folder(s), the spec.md, and the .feature
|
|
48
48
|
PRODUCER_GOVERNANCES_DECLARED: [ the spec-producer's declared governances_loaded, relayed by the conductor — or [] ]
|
|
49
49
|
```
|
|
50
50
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: check-field-mandates
|
|
3
|
+
description: "Partial Skill: invoke by name only"
|
|
4
|
+
user-invocable: false
|
|
5
|
+
metadata:
|
|
6
|
+
internal: true
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Check Field Mandates
|
|
10
|
+
|
|
11
|
+
The concrete engine for the rule that **a definition's declared fields and its prose agree**. Skill
|
|
12
|
+
and agent definitions ship structured blocks — an `Input`, an `Output`, a dispatch payload — whose
|
|
13
|
+
fields the surrounding prose also mandates. A field the prose mandates and the block omits does not
|
|
14
|
+
fail loudly: the stage that depends on it silently never fires. Nothing else in the repo diffs the
|
|
15
|
+
two token sets.
|
|
16
|
+
|
|
17
|
+
## What it checks
|
|
18
|
+
|
|
19
|
+
Every `plugins/<plugin>/skills/**/SKILL.md` and `plugins/<plugin>/agents/*.md`, both directions:
|
|
20
|
+
|
|
21
|
+
- **unexplained** — a block declares a field that carries no gloss and that the prose never names.
|
|
22
|
+
- **undeclared** — the prose names a known field in a code span, and none of the file's blocks
|
|
23
|
+
declare it.
|
|
24
|
+
- **miscased** — the prose names, in a code span, a field this file's block declares, but spelled
|
|
25
|
+
in the other case (`governances_loaded` against `GOVERNANCES_LOADED`).
|
|
26
|
+
|
|
27
|
+
**A field token** is `UPPER_CASE` or `snake_case`; the two spellings name the same field. A lowercase
|
|
28
|
+
word needs an underscore to be a token, so ordinary prose (`owner`) is never a field.
|
|
29
|
+
|
|
30
|
+
**A structured block** is a fenced code block with no info string (or `text`). A **declaration** is a
|
|
31
|
+
block line opening with field tokens then a colon (`STATUS: complete | blocked`), a comma list of two
|
|
32
|
+
or more tokens (`SPEC_PATH, FEATURE_PATH`), or a list item opening with bold tokens
|
|
33
|
+
(`- **SUBJECT** — …`). A **gloss** is the text after the colon or the bold lead, through to the next
|
|
34
|
+
declaration.
|
|
35
|
+
|
|
36
|
+
**A field is explained by a gloss or by the prose.** A glossed declaration already says what it
|
|
37
|
+
carries; the rule catches the **bare** one nothing explains.
|
|
38
|
+
|
|
39
|
+
**A mandate is a code span naming a known field** — a token some definition in the tree declares.
|
|
40
|
+
That vocabulary separates a field from an uppercase literal such as `TODO`. A definition that
|
|
41
|
+
declares no field at all is a consumer, and its prose is not held to a block.
|
|
42
|
+
|
|
43
|
+
**Naming another agent's field on purpose** — a conductor describing what it passes a judge — is
|
|
44
|
+
marked beside the line, with its reason:
|
|
45
|
+
|
|
46
|
+
```markdown
|
|
47
|
+
Pass the zero-based `ROW` to the case judge. <!-- field-mandate-ignore: the case judge's input, not ours -->
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
A marker with no reason excuses nothing. There is no allow-list file: a real mismatch is fixed.
|
|
51
|
+
|
|
52
|
+
## Run it
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
node "<skill>/scripts/check-field-mandates.mts" [--root <dir>]
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`--root` defaults to the working directory. A finding always exits non-zero, as does a root that is
|
|
59
|
+
not a readable directory and an unrecognized flag — there is deliberately no report-only mode.
|
|
60
|
+
|
|
61
|
+
It joins the root check chain as **`check:fields`**, so it runs on every `pnpm verify` and in CI.
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// check-field-mandates — a definition's declared fields and its prose must agree.
|
|
3
|
+
//
|
|
4
|
+
// Skill and agent definitions ship structured blocks (an Input, an Output, a dispatch payload)
|
|
5
|
+
// whose fields the surrounding prose also mandates. Nothing else compares the two, so a field can be
|
|
6
|
+
// mandated and never carried, or carried and never explained, and both read as complete. This
|
|
7
|
+
// engine diffs the token sets (spec: .agents/specs/sdd/plugin/check-field-mandates/):
|
|
8
|
+
// unexplained — a block declares a field that carries no gloss and the prose never names
|
|
9
|
+
// undeclared — the prose mandates a known field (a code span) that none of the file's blocks declare
|
|
10
|
+
// miscased — the prose mandates a field this file declares, but spelled in the other case
|
|
11
|
+
//
|
|
12
|
+
// A field is explained by a gloss OR by the prose — requiring the prose to repeat a glossed
|
|
13
|
+
// declaration would demand filler in every definition. A mandate is a code span naming a KNOWN field
|
|
14
|
+
// (declared somewhere in the tree); that vocabulary is what separates a field from `TODO`.
|
|
15
|
+
//
|
|
16
|
+
// A field token is UPPER_CASE or snake_case. A lowercase word needs an underscore to be a token, so
|
|
17
|
+
// ordinary prose words (`owner`, `summary`) never become fields. Case decides whether two tokens are the
|
|
18
|
+
// same field only for the miscased finding: `governances_loaded` and `GOVERNANCES_LOADED` are one
|
|
19
|
+
// field, spelled two ways, and a file that does both is reported.
|
|
20
|
+
//
|
|
21
|
+
// Pure functions are exported for node:test; running the file directly drives the CLI. No
|
|
22
|
+
// dependencies (the repo's node-≥23.6 / no-deps convention).
|
|
23
|
+
|
|
24
|
+
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs'
|
|
25
|
+
import { join, relative, sep } from 'node:path'
|
|
26
|
+
import { pathToFileURL } from 'node:url'
|
|
27
|
+
|
|
28
|
+
export type FindingKind = 'unexplained' | 'undeclared' | 'miscased'
|
|
29
|
+
|
|
30
|
+
export interface Finding {
|
|
31
|
+
kind: FindingKind
|
|
32
|
+
/** Root-relative path of the definition, `/`-separated. */
|
|
33
|
+
file: string
|
|
34
|
+
/** 1-based line: the declaration for `unexplained`, the prose line for `undeclared`. */
|
|
35
|
+
line: number
|
|
36
|
+
token: string
|
|
37
|
+
/** For `miscased`: the spelling this file's declaration uses. */
|
|
38
|
+
declared?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface Declaration {
|
|
42
|
+
token: string
|
|
43
|
+
line: number
|
|
44
|
+
glossed: boolean
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface Mandate {
|
|
48
|
+
token: string
|
|
49
|
+
line: number
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ParsedDefinition {
|
|
53
|
+
declarations: Declaration[]
|
|
54
|
+
mandates: Mandate[]
|
|
55
|
+
/** Every field-shaped word the prose names, code span or not. */
|
|
56
|
+
proseWords: Set<string>
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** UPPER_CASE, or snake_case with at least one underscore — a bare lowercase word is prose. */
|
|
60
|
+
const TOKEN = '(?:[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*|[a-z][a-z0-9]*(?:_[a-z0-9]+)+)'
|
|
61
|
+
const TOKEN_ITEM = `${TOKEN}(?:\\(s\\))?`
|
|
62
|
+
/** A block line opening with a comma list of tokens, then a colon, a trailing comment, or nothing. */
|
|
63
|
+
const BLOCK_DECLARATION_RE = new RegExp(`^\\s*(${TOKEN_ITEM}(?:\\s*,\\s*${TOKEN_ITEM})*)\\s*(:.*|#.*)?$`)
|
|
64
|
+
/** A list item opening with one or more bold tokens joined by `+`. */
|
|
65
|
+
const LIST_DECLARATION_RE = new RegExp(`^\\s*[-*+]\\s+(\\*\\*${TOKEN}\\*\\*(?:\\s*\\+\\s*\\*\\*${TOKEN}\\*\\*)*)(.*)$`)
|
|
66
|
+
const BOLD_TOKEN_RE = new RegExp(`\\*\\*(${TOKEN})\\*\\*`, 'g')
|
|
67
|
+
const WORD_RE = new RegExp(`(?<![A-Za-z0-9_])${TOKEN}(?![A-Za-z0-9_])`, 'g')
|
|
68
|
+
const CODE_SPAN_RE = /`([^`\n]+)`/g
|
|
69
|
+
const MANDATE_LEAD_RE = new RegExp(`^<?(${TOKEN})(?=$|[\\s.:\\[\\]>(,=])`)
|
|
70
|
+
const FENCE_RE = /^ {0,3}(`{3,}|~{3,})(.*)$/
|
|
71
|
+
/** The marker must carry a reason — a bare marker excuses nothing. */
|
|
72
|
+
const IGNORE_MARKER_RE = /<!--\s*field-mandate-ignore\s*:\s*[^\s>-][^>]*-->/
|
|
73
|
+
|
|
74
|
+
/** A field token is two or more characters: a lone capital is a word, not a field. */
|
|
75
|
+
function isField(token: string): boolean {
|
|
76
|
+
return token.length >= 2
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A token's case-folded form: the spelling-independent name of the field. */
|
|
80
|
+
function fold(token: string): string {
|
|
81
|
+
return token.toUpperCase()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The folded forms a token may take and still be the same field — equal, or one trailing `S` apart. */
|
|
85
|
+
function sameFieldForms(token: string): string[] {
|
|
86
|
+
const t = fold(token)
|
|
87
|
+
const forms = [t, `${t}S`]
|
|
88
|
+
if (t.endsWith('S') && t.length > 2) forms.push(t.slice(0, -1))
|
|
89
|
+
return forms
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Does the set of folded tokens hold the same field as `token`? */
|
|
93
|
+
function hasSameField(folded: Set<string>, token: string): boolean {
|
|
94
|
+
return sameFieldForms(token).some((f) => folded.has(f))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Is the token spelled lowercase (snake_case) rather than uppercase? */
|
|
98
|
+
function isLower(token: string): boolean {
|
|
99
|
+
return token !== fold(token)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── Parse ──
|
|
103
|
+
|
|
104
|
+
export function parseDefinition(text: string): ParsedDefinition {
|
|
105
|
+
const lines = text.split(/\r?\n/)
|
|
106
|
+
const declarations: Declaration[] = []
|
|
107
|
+
const mandates: Mandate[] = []
|
|
108
|
+
const proseWords = new Set<string>()
|
|
109
|
+
|
|
110
|
+
let i = 0
|
|
111
|
+
// YAML frontmatter is metadata, not prose.
|
|
112
|
+
if (lines[0]?.trim() === '---') {
|
|
113
|
+
const end = lines.findIndex((l, n) => n > 0 && l.trim() === '---')
|
|
114
|
+
if (end > 0) i = end + 1
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let fence: { char: string; length: number; structured: boolean } | null = null
|
|
118
|
+
// The declarations a continuation line glosses: the most recent group in the open block.
|
|
119
|
+
let group: Declaration[] = []
|
|
120
|
+
|
|
121
|
+
/** Record a span of text as prose: its field-shaped words, and any code-span mandate. */
|
|
122
|
+
function scanProse(text: string, lineNo: number): void {
|
|
123
|
+
for (const m of text.matchAll(WORD_RE)) if (isField(m[0])) proseWords.add(m[0])
|
|
124
|
+
if (IGNORE_MARKER_RE.test(text)) return
|
|
125
|
+
for (const m of text.matchAll(CODE_SPAN_RE)) {
|
|
126
|
+
const lead = (m[1] ?? '').trim().match(MANDATE_LEAD_RE)
|
|
127
|
+
const token = lead?.[1]
|
|
128
|
+
if (token && isField(token)) mandates.push({ token, line: lineNo })
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
for (; i < lines.length; i++) {
|
|
133
|
+
const line = lines[i] ?? ''
|
|
134
|
+
const lineNo = i + 1
|
|
135
|
+
const fenceMatch = line.match(FENCE_RE)
|
|
136
|
+
|
|
137
|
+
if (fence) {
|
|
138
|
+
if (
|
|
139
|
+
fenceMatch &&
|
|
140
|
+
fenceMatch[1]?.[0] === fence.char &&
|
|
141
|
+
(fenceMatch[1]?.length ?? 0) >= fence.length &&
|
|
142
|
+
fenceMatch[2]?.trim() === ''
|
|
143
|
+
) {
|
|
144
|
+
fence = null
|
|
145
|
+
group = []
|
|
146
|
+
continue
|
|
147
|
+
}
|
|
148
|
+
if (!fence.structured) continue
|
|
149
|
+
const decl = line.match(BLOCK_DECLARATION_RE)
|
|
150
|
+
const tokens = decl ? (decl[1] ?? '').split(',').map((t) => t.trim().replace(/\(s\)$/, '')) : []
|
|
151
|
+
const rest = decl?.[2]
|
|
152
|
+
const isDeclaration = decl !== null && (rest?.startsWith(':') || tokens.length >= 2)
|
|
153
|
+
if (isDeclaration) {
|
|
154
|
+
const glossed = rest?.startsWith(':') === true && rest.slice(1).trim() !== ''
|
|
155
|
+
group = tokens.filter(isField).map((token) => ({ token, line: lineNo, glossed }))
|
|
156
|
+
declarations.push(...group)
|
|
157
|
+
} else if (line.trim() !== '') {
|
|
158
|
+
for (const d of group) d.glossed = true
|
|
159
|
+
}
|
|
160
|
+
continue
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (fenceMatch) {
|
|
164
|
+
const info = fenceMatch[2]?.trim() ?? ''
|
|
165
|
+
fence = {
|
|
166
|
+
char: fenceMatch[1]?.[0] ?? '`',
|
|
167
|
+
length: fenceMatch[1]?.length ?? 3,
|
|
168
|
+
structured: info === '' || info === 'text',
|
|
169
|
+
}
|
|
170
|
+
group = []
|
|
171
|
+
continue
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const listDecl = line.match(LIST_DECLARATION_RE)
|
|
175
|
+
if (listDecl) {
|
|
176
|
+
const rest = listDecl[2] ?? ''
|
|
177
|
+
const glossed = rest.trim() !== ''
|
|
178
|
+
for (const m of (listDecl[1] ?? '').matchAll(BOLD_TOKEN_RE)) {
|
|
179
|
+
const token = m[1] ?? ''
|
|
180
|
+
if (isField(token)) declarations.push({ token, line: lineNo, glossed })
|
|
181
|
+
}
|
|
182
|
+
// The text after a list item's bold lead is that declaration's gloss AND prose: a code
|
|
183
|
+
// span in it mandates like any other (it is scanned the same as a full prose line).
|
|
184
|
+
scanProse(rest, lineNo)
|
|
185
|
+
continue
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Prose.
|
|
189
|
+
scanProse(line, lineNo)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return { declarations, mandates, proseWords }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ── Discovery ──
|
|
196
|
+
|
|
197
|
+
function walk(dir: string, out: string[]): void {
|
|
198
|
+
let entries: import('node:fs').Dirent[]
|
|
199
|
+
try {
|
|
200
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
201
|
+
} catch {
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
for (const e of entries) {
|
|
205
|
+
const full = join(dir, e.name)
|
|
206
|
+
if (e.isDirectory()) walk(full, out)
|
|
207
|
+
else if (e.isFile() && e.name.endsWith('.md')) out.push(full)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Is this root-relative path a skill (`plugins/<p>/skills/**\/SKILL.md`) or agent (`plugins/<p>/agents/*.md`)? */
|
|
212
|
+
export function isDefinitionPath(rel: string): boolean {
|
|
213
|
+
const parts = rel.split('/')
|
|
214
|
+
if (parts[0] !== 'plugins' || parts.length < 4) return false
|
|
215
|
+
if (parts[2] === 'agents') return parts.length === 4 && (parts[3] ?? '').endsWith('.md')
|
|
216
|
+
if (parts[2] === 'skills') return parts.length >= 5 && parts[parts.length - 1] === 'SKILL.md'
|
|
217
|
+
return false
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Every definition under `root`, root-relative, `/`-separated, path-sorted. */
|
|
221
|
+
export function discoverDefinitions(root: string): string[] {
|
|
222
|
+
const pluginsDir = join(root, 'plugins')
|
|
223
|
+
if (!existsSync(pluginsDir)) return []
|
|
224
|
+
const files: string[] = []
|
|
225
|
+
walk(pluginsDir, files)
|
|
226
|
+
return files
|
|
227
|
+
.map((f) => relative(root, f).split(sep).join('/'))
|
|
228
|
+
.filter(isDefinitionPath)
|
|
229
|
+
.sort()
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ── The check ──
|
|
233
|
+
|
|
234
|
+
export function check(parsed: Map<string, ParsedDefinition>): Finding[] {
|
|
235
|
+
const known = new Set<string>()
|
|
236
|
+
for (const p of parsed.values()) for (const d of p.declarations) known.add(fold(d.token))
|
|
237
|
+
|
|
238
|
+
const findings: Finding[] = []
|
|
239
|
+
for (const [file, p] of parsed) {
|
|
240
|
+
if (p.declarations.length === 0) continue
|
|
241
|
+
const declared = new Set(p.declarations.map((d) => fold(d.token)))
|
|
242
|
+
const proseWords = new Set([...p.proseWords].map(fold))
|
|
243
|
+
|
|
244
|
+
const seen = new Set<string>()
|
|
245
|
+
for (const d of p.declarations) {
|
|
246
|
+
if (seen.has(d.token)) continue
|
|
247
|
+
seen.add(d.token)
|
|
248
|
+
const glossed = p.declarations.some((o) => o.token === d.token && o.glossed)
|
|
249
|
+
if (!glossed && !hasSameField(proseWords, d.token)) {
|
|
250
|
+
findings.push({ kind: 'unexplained', file, line: d.line, token: d.token })
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const reported = new Set<string>()
|
|
255
|
+
for (const m of p.mandates) {
|
|
256
|
+
if (!hasSameField(known, m.token)) continue
|
|
257
|
+
const key = `${m.line}:${m.token}`
|
|
258
|
+
if (reported.has(key)) continue
|
|
259
|
+
if (!hasSameField(declared, m.token)) {
|
|
260
|
+
reported.add(key)
|
|
261
|
+
findings.push({ kind: 'undeclared', file, line: m.line, token: m.token })
|
|
262
|
+
continue
|
|
263
|
+
}
|
|
264
|
+
// Declared here — but is any same-field declaration spelled in the mandate's case?
|
|
265
|
+
const forms = sameFieldForms(m.token)
|
|
266
|
+
const same = p.declarations.filter((d) => forms.includes(fold(d.token)))
|
|
267
|
+
if (same.some((d) => isLower(d.token) === isLower(m.token))) continue
|
|
268
|
+
reported.add(key)
|
|
269
|
+
findings.push({ kind: 'miscased', file, line: m.line, token: m.token, declared: same[0]?.token })
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return findings.sort((a, b) => (a.file === b.file ? a.line - b.line : a.file < b.file ? -1 : 1))
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ── Report ──
|
|
276
|
+
|
|
277
|
+
const DETAIL: Record<FindingKind, string> = {
|
|
278
|
+
unexplained: 'declared in a block, but no gloss and no prose explains it',
|
|
279
|
+
undeclared: 'mandated in the prose, but no block in this file declares it',
|
|
280
|
+
miscased: 'mandated in the prose, but this file declares it in the other case',
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function formatReport(definitions: string[], findings: Finding[]): string {
|
|
284
|
+
if (definitions.length === 0) return 'check-field-mandates: no skill or agent definition found\n'
|
|
285
|
+
if (findings.length === 0) return `check-field-mandates: ${definitions.length} definition(s) OK\n`
|
|
286
|
+
const rows = findings
|
|
287
|
+
.map(
|
|
288
|
+
(f) =>
|
|
289
|
+
` ${f.kind.padEnd(11)} ${f.file}:${f.line} ${f.token} — ${DETAIL[f.kind]}${f.declared ? ` (${f.declared})` : ''}`,
|
|
290
|
+
)
|
|
291
|
+
.join('\n')
|
|
292
|
+
return `${rows}\ncheck-field-mandates: ${findings.length} finding(s) across ${definitions.length} definition(s)\n`
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ── CLI ──
|
|
296
|
+
|
|
297
|
+
function isReadableDirectory(path: string): boolean {
|
|
298
|
+
try {
|
|
299
|
+
if (!statSync(path).isDirectory()) return false
|
|
300
|
+
readdirSync(path)
|
|
301
|
+
return true
|
|
302
|
+
} catch {
|
|
303
|
+
return false
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function main(argv: string[]): number {
|
|
308
|
+
let root = '.'
|
|
309
|
+
for (let i = 0; i < argv.length; i++) {
|
|
310
|
+
const a = argv[i]
|
|
311
|
+
if (a === '--root') {
|
|
312
|
+
const next = argv[++i]
|
|
313
|
+
if (next === undefined) {
|
|
314
|
+
process.stderr.write('check-field-mandates: --root needs a directory\n')
|
|
315
|
+
return 1
|
|
316
|
+
}
|
|
317
|
+
root = next
|
|
318
|
+
continue
|
|
319
|
+
}
|
|
320
|
+
process.stderr.write(`check-field-mandates: unrecognized flag ${a}\n`)
|
|
321
|
+
return 1
|
|
322
|
+
}
|
|
323
|
+
if (!isReadableDirectory(root)) {
|
|
324
|
+
process.stderr.write(`check-field-mandates: root ${root} is not a readable directory\n`)
|
|
325
|
+
return 1
|
|
326
|
+
}
|
|
327
|
+
const definitions = discoverDefinitions(root)
|
|
328
|
+
const parsed = new Map<string, ParsedDefinition>()
|
|
329
|
+
for (const rel of definitions) parsed.set(rel, parseDefinition(readFileSync(join(root, rel), 'utf8')))
|
|
330
|
+
const findings = check(parsed)
|
|
331
|
+
process.stdout.write(formatReport(definitions, findings))
|
|
332
|
+
return findings.length === 0 ? 0 : 1
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
|
|
336
|
+
process.exit(main(process.argv.slice(2)))
|
|
337
|
+
}
|
|
@@ -14,14 +14,14 @@ and is copied into every generated vendor manifest — it fails only on an insta
|
|
|
14
14
|
publish, as a component the host runtime cannot load. Nothing else in the repo compares a manifest's
|
|
15
15
|
pointers against what ships.
|
|
16
16
|
|
|
17
|
-
Spec: [`.agents/specs/sdd/plugin/check-plugin-manifests/`](../../../../.agents/specs/sdd/plugin/check-plugin-manifests/README.md).
|
|
18
|
-
|
|
19
17
|
## What it checks
|
|
20
18
|
|
|
21
|
-
|
|
19
|
+
Three sub-checks:
|
|
22
20
|
|
|
23
21
|
- **the disk check** — does the pointer resolve to a path that exists?
|
|
24
22
|
- **the publish check** — for a package that publishes, is the pointer inside its `files` allowlist?
|
|
23
|
+
- **the pack check** — for a package that is not `private`, does the manifest or a component it
|
|
24
|
+
declares ship as (or hold) a symbolic link? The npm registry rejects such a tarball outright.
|
|
25
25
|
|
|
26
26
|
A directory can exist and be excluded from the tarball; a `files` entry can name a directory nobody
|
|
27
27
|
created. The disk check **short-circuits**: a pointer dead on disk is reported once, as unresolved,
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
// Two sub-checks, neither subsuming the other (spec: .agents/specs/sdd/plugin/check-plugin-manifests/):
|
|
10
10
|
// disk — does the pointer resolve to a path that exists?
|
|
11
11
|
// publish — for a package that publishes, is the pointer inside its `files` allowlist?
|
|
12
|
+
// pack — for a package that packs a tarball, does it ship a symbolic link? npm's registry
|
|
13
|
+
// rejects the whole package ("Symbolic link is not allowed"), so it never arrives.
|
|
12
14
|
// A directory can exist and be excluded from the tarball; a `files` entry can name a directory
|
|
13
15
|
// nobody created. The disk check short-circuits: a pointer dead on disk is reported once, as
|
|
14
16
|
// unresolved, and is not also asked about `files`.
|
|
@@ -20,7 +22,7 @@
|
|
|
20
22
|
// Pure functions are exported for node:test; running the file directly drives the CLI. No
|
|
21
23
|
// dependencies (the repo's node-≥23.6 / no-deps convention).
|
|
22
24
|
|
|
23
|
-
import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs'
|
|
25
|
+
import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from 'node:fs'
|
|
24
26
|
import { dirname, join, relative, sep } from 'node:path'
|
|
25
27
|
import { pathToFileURL } from 'node:url'
|
|
26
28
|
|
|
@@ -36,7 +38,7 @@ const MANIFEST_DIRS = ['.plugin', '.claude-plugin', '.codex-plugin']
|
|
|
36
38
|
const MANIFEST_NAME = 'plugin.json'
|
|
37
39
|
const SKIP_DIRS = new Set(['node_modules', '.git'])
|
|
38
40
|
|
|
39
|
-
export type FindingKind = 'unreadable' | 'unresolved' | 'unpublished'
|
|
41
|
+
export type FindingKind = 'unreadable' | 'unresolved' | 'unpublished' | 'symlink'
|
|
40
42
|
|
|
41
43
|
export interface Finding {
|
|
42
44
|
kind: FindingKind
|
|
@@ -127,6 +129,8 @@ export function extractPointers(manifest: unknown): Array<{ key: string; pointer
|
|
|
127
129
|
|
|
128
130
|
export interface OwningPackage {
|
|
129
131
|
path: string
|
|
132
|
+
/** Not `private` — the package packs a tarball, whether or not it declares `files`. */
|
|
133
|
+
packs: boolean
|
|
130
134
|
publishes: boolean
|
|
131
135
|
files: string[]
|
|
132
136
|
}
|
|
@@ -148,9 +152,10 @@ export function owningPackage(root: string, pluginRoot: string): OwningPackage |
|
|
|
148
152
|
try {
|
|
149
153
|
const pkg = JSON.parse(readFileSync(abs, 'utf8')) as { private?: unknown; files?: unknown }
|
|
150
154
|
const files = Array.isArray(pkg.files) ? pkg.files.filter((f): f is string => typeof f === 'string') : null
|
|
151
|
-
|
|
155
|
+
const packs = pkg.private !== true
|
|
156
|
+
return { path: candidate, packs, publishes: packs && files !== null, files: files ?? [] }
|
|
152
157
|
} catch {
|
|
153
|
-
return { path: candidate, publishes: false, files: [] }
|
|
158
|
+
return { path: candidate, packs: false, publishes: false, files: [] }
|
|
154
159
|
}
|
|
155
160
|
}
|
|
156
161
|
if (dir === '') return null
|
|
@@ -180,10 +185,47 @@ export function filesCovers(files: string[], pointer: string): boolean {
|
|
|
180
185
|
})
|
|
181
186
|
}
|
|
182
187
|
|
|
188
|
+
// ── The pack sub-check ──
|
|
189
|
+
|
|
190
|
+
/** Whether a package that packs a tarball ships the package-relative path `./<rel>`. */
|
|
191
|
+
function ships(pkg: OwningPackage | null, rel: string): pkg is OwningPackage {
|
|
192
|
+
return pkg?.packs === true && (!pkg.publishes || filesCovers(pkg.files, `./${rel}`))
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Every symbolic link at or below `abs` — the path itself included, links never followed. */
|
|
196
|
+
export function findSymlinks(abs: string): string[] {
|
|
197
|
+
let stat: ReturnType<typeof lstatSync>
|
|
198
|
+
try {
|
|
199
|
+
stat = lstatSync(abs)
|
|
200
|
+
} catch {
|
|
201
|
+
return []
|
|
202
|
+
}
|
|
203
|
+
if (stat.isSymbolicLink()) return [abs]
|
|
204
|
+
if (!stat.isDirectory()) return []
|
|
205
|
+
return safeReaddir(abs)
|
|
206
|
+
.filter((e) => !SKIP_DIRS.has(e.name))
|
|
207
|
+
.flatMap((e) => findSymlinks(join(abs, e.name)))
|
|
208
|
+
}
|
|
209
|
+
|
|
183
210
|
// ── The sweep ──
|
|
184
211
|
|
|
185
212
|
export function sweep(root: string, manifests: string[]): Finding[] {
|
|
186
213
|
const findings: Finding[] = []
|
|
214
|
+
// One link reported once, however many manifests ship it.
|
|
215
|
+
const linksSeen = new Set<string>()
|
|
216
|
+
const reportLinks = (rel: string, abs: string, key?: string, pointer?: string): void => {
|
|
217
|
+
for (const link of findSymlinks(abs)) {
|
|
218
|
+
const linkRel = relative(root, link).split(sep).join('/')
|
|
219
|
+
if (linksSeen.has(linkRel)) continue
|
|
220
|
+
linksSeen.add(linkRel)
|
|
221
|
+
findings.push({
|
|
222
|
+
kind: 'symlink',
|
|
223
|
+
manifest: rel,
|
|
224
|
+
...(key === undefined ? {} : { key, pointer }),
|
|
225
|
+
detail: `${linkRel} is a symbolic link — the registry rejects a package that ships one`,
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
}
|
|
187
229
|
for (const rel of manifests) {
|
|
188
230
|
let parsed: unknown
|
|
189
231
|
try {
|
|
@@ -194,6 +236,9 @@ export function sweep(root: string, manifests: string[]): Finding[] {
|
|
|
194
236
|
}
|
|
195
237
|
const pluginRoot = pluginRootOf(rel)
|
|
196
238
|
const pkg = owningPackage(root, pluginRoot)
|
|
239
|
+
const pkgDir = pkg ? dirname(pkg.path) : ''
|
|
240
|
+
const fromPkg = (path: string): string => relative(join(root, pkgDir), join(root, path)).split(sep).join('/')
|
|
241
|
+
if (ships(pkg, fromPkg(rel))) reportLinks(rel, join(root, rel))
|
|
197
242
|
for (const { key, pointer } of extractPointers(parsed)) {
|
|
198
243
|
const target = join(root, pluginRoot, pointer)
|
|
199
244
|
if (!existsSync(target)) {
|
|
@@ -208,7 +253,9 @@ export function sweep(root: string, manifests: string[]): Finding[] {
|
|
|
208
253
|
pointer,
|
|
209
254
|
detail: `declared but not published — ${pkg.path} files omits it`,
|
|
210
255
|
})
|
|
256
|
+
continue
|
|
211
257
|
}
|
|
258
|
+
if (ships(pkg, fromPkg(join(pluginRoot, pointer)))) reportLinks(rel, target, key, pointer)
|
|
212
259
|
}
|
|
213
260
|
}
|
|
214
261
|
return findings
|
|
@@ -16,7 +16,7 @@ declares its own pass.
|
|
|
16
16
|
## Inputs (folded in by the conductor)
|
|
17
17
|
|
|
18
18
|
```
|
|
19
|
-
DOMAIN, DOMAIN_PATH, SPEC_PATH, FEATURE_PATH, SOLUTION_PATH
|
|
19
|
+
DOMAIN, DOMAIN_PATH, SPEC_PATH, FEATURE_PATH, SOLUTION_PATH: the domain under work, its spec folder, its spec.md, its .feature, and its <unit>.solution.md
|
|
20
20
|
MODE: explore | implement
|
|
21
21
|
```
|
|
22
22
|
|
|
@@ -71,6 +71,7 @@ rule governing the artifact · account for provenance, where a regression stops
|
|
|
71
71
|
```
|
|
72
72
|
REMEDIATION: <per finding answered: verdict, rule, swept, ruled-out, provenance — `sdd:remediation-governance`; omit when no verdict was answered>
|
|
73
73
|
STATUS: complete | needs-input | blocked
|
|
74
|
+
BLOCKER: <what blocked it — the behavior the frozen contract omits — when STATUS is blocked, else null>
|
|
74
75
|
ARTIFACTS_WRITTEN: [ paths ]
|
|
75
76
|
VERIFICATION_WRITTEN: [ paths ] # one per frozen scenario, each with its level + why
|
|
76
77
|
CHANGES_MADE: <what was built>
|
|
@@ -15,7 +15,7 @@ Load alongside this governance: the resolved **architect** actor bar (structural
|
|
|
15
15
|
## Inputs (folded in by the conductor)
|
|
16
16
|
|
|
17
17
|
```
|
|
18
|
-
DOMAIN, DOMAIN_PATH, SPEC_PATH, FEATURE_PATH, SOLUTION_PATH
|
|
18
|
+
DOMAIN, DOMAIN_PATH, SPEC_PATH, FEATURE_PATH, SOLUTION_PATH: the domain under work, its spec folder, its spec.md, its .feature, and its <unit>.solution.md
|
|
19
19
|
MODE: explore | implement
|
|
20
20
|
EXISTING_SOLUTION: <the current <unit>.solution.md, on a revise — or null>
|
|
21
21
|
```
|
|
@@ -15,7 +15,7 @@ Load alongside this governance: `sdd:spec-format-governance` (the required `## U
|
|
|
15
15
|
## Inputs (folded in by the conductor)
|
|
16
16
|
|
|
17
17
|
```
|
|
18
|
-
DOMAIN, DOMAIN_PATH, SPEC_PATH
|
|
18
|
+
DOMAIN, DOMAIN_PATH, SPEC_PATH: the domain under work, its spec folder, and its spec.md
|
|
19
19
|
COMMAND_SURFACE: <command syntax / signatures / events — or null>
|
|
20
20
|
DESIGN_DECISIONS: <known choices — or null>
|
|
21
21
|
USER_INPUT: <What / Why / command surface for a new feature — or null>
|
|
@@ -83,7 +83,7 @@ REMEDIATION: <per finding answered: verdict, rule, swept, ruled-out, prove
|
|
|
83
83
|
STATUS: complete | needs-input | blocked
|
|
84
84
|
SCENARIOS_WRITTEN: <count>
|
|
85
85
|
NOTES: <what was written / revised>
|
|
86
|
-
|
|
86
|
+
governances_loaded: [ every governance name loaded before writing — required, [] when none, never written into spec.md or the .feature ]
|
|
87
87
|
QUESTIONS: [ batched, when needs-input ]
|
|
88
88
|
CONTENT_GAPS: [ { artifact, location, gap } ] # become <!-- open: --> markers
|
|
89
89
|
OBSERVATIONS: [ { owner: architect | strategist, note, evidence } ]
|