cyber-sdd 0.5.0 → 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/package.json +1 -1
- package/skills/check-field-mandates/SKILL.md +5 -0
- package/skills/check-field-mandates/scripts/check-field-mandates.mts +48 -13
- package/skills/spec-producer-governance/SKILL.md +1 -1
package/.plugin/plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -21,6 +21,11 @@ Every `plugins/<plugin>/skills/**/SKILL.md` and `plugins/<plugin>/agents/*.md`,
|
|
|
21
21
|
- **unexplained** — a block declares a field that carries no gloss and that the prose never names.
|
|
22
22
|
- **undeclared** — the prose names a known field in a code span, and none of the file's blocks
|
|
23
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.
|
|
24
29
|
|
|
25
30
|
**A structured block** is a fenced code block with no info string (or `text`). A **declaration** is a
|
|
26
31
|
block line opening with field tokens then a colon (`STATUS: complete | blocked`), a comma list of two
|
|
@@ -7,11 +7,17 @@
|
|
|
7
7
|
// engine diffs the token sets (spec: .agents/specs/sdd/plugin/check-field-mandates/):
|
|
8
8
|
// unexplained — a block declares a field that carries no gloss and the prose never names
|
|
9
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
|
|
10
11
|
//
|
|
11
12
|
// A field is explained by a gloss OR by the prose — requiring the prose to repeat a glossed
|
|
12
13
|
// declaration would demand filler in every definition. A mandate is a code span naming a KNOWN field
|
|
13
14
|
// (declared somewhere in the tree); that vocabulary is what separates a field from `TODO`.
|
|
14
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
|
+
//
|
|
15
21
|
// Pure functions are exported for node:test; running the file directly drives the CLI. No
|
|
16
22
|
// dependencies (the repo's node-≥23.6 / no-deps convention).
|
|
17
23
|
|
|
@@ -19,7 +25,7 @@ import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'n
|
|
|
19
25
|
import { join, relative, sep } from 'node:path'
|
|
20
26
|
import { pathToFileURL } from 'node:url'
|
|
21
27
|
|
|
22
|
-
export type FindingKind = 'unexplained' | 'undeclared'
|
|
28
|
+
export type FindingKind = 'unexplained' | 'undeclared' | 'miscased'
|
|
23
29
|
|
|
24
30
|
export interface Finding {
|
|
25
31
|
kind: FindingKind
|
|
@@ -28,6 +34,8 @@ export interface Finding {
|
|
|
28
34
|
/** 1-based line: the declaration for `unexplained`, the prose line for `undeclared`. */
|
|
29
35
|
line: number
|
|
30
36
|
token: string
|
|
37
|
+
/** For `miscased`: the spelling this file's declaration uses. */
|
|
38
|
+
declared?: string
|
|
31
39
|
}
|
|
32
40
|
|
|
33
41
|
interface Declaration {
|
|
@@ -48,7 +56,8 @@ export interface ParsedDefinition {
|
|
|
48
56
|
proseWords: Set<string>
|
|
49
57
|
}
|
|
50
58
|
|
|
51
|
-
|
|
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]+)+)'
|
|
52
61
|
const TOKEN_ITEM = `${TOKEN}(?:\\(s\\))?`
|
|
53
62
|
/** A block line opening with a comma list of tokens, then a colon, a trailing comment, or nothing. */
|
|
54
63
|
const BLOCK_DECLARATION_RE = new RegExp(`^\\s*(${TOKEN_ITEM}(?:\\s*,\\s*${TOKEN_ITEM})*)\\s*(:.*|#.*)?$`)
|
|
@@ -67,15 +76,27 @@ function isField(token: string): boolean {
|
|
|
67
76
|
return token.length >= 2
|
|
68
77
|
}
|
|
69
78
|
|
|
70
|
-
/**
|
|
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. */
|
|
71
85
|
function sameFieldForms(token: string): string[] {
|
|
72
|
-
const
|
|
73
|
-
|
|
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))
|
|
74
89
|
return forms
|
|
75
90
|
}
|
|
76
91
|
|
|
77
|
-
|
|
78
|
-
|
|
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)
|
|
79
100
|
}
|
|
80
101
|
|
|
81
102
|
// ── Parse ──
|
|
@@ -212,30 +233,40 @@ export function discoverDefinitions(root: string): string[] {
|
|
|
212
233
|
|
|
213
234
|
export function check(parsed: Map<string, ParsedDefinition>): Finding[] {
|
|
214
235
|
const known = new Set<string>()
|
|
215
|
-
for (const p of parsed.values()) for (const d of p.declarations) known.add(d.token)
|
|
236
|
+
for (const p of parsed.values()) for (const d of p.declarations) known.add(fold(d.token))
|
|
216
237
|
|
|
217
238
|
const findings: Finding[] = []
|
|
218
239
|
for (const [file, p] of parsed) {
|
|
219
240
|
if (p.declarations.length === 0) continue
|
|
220
|
-
const declared = new Set(p.declarations.map((d) => d.token))
|
|
241
|
+
const declared = new Set(p.declarations.map((d) => fold(d.token)))
|
|
242
|
+
const proseWords = new Set([...p.proseWords].map(fold))
|
|
221
243
|
|
|
222
244
|
const seen = new Set<string>()
|
|
223
245
|
for (const d of p.declarations) {
|
|
224
246
|
if (seen.has(d.token)) continue
|
|
225
247
|
seen.add(d.token)
|
|
226
248
|
const glossed = p.declarations.some((o) => o.token === d.token && o.glossed)
|
|
227
|
-
if (!glossed && !hasSameField(
|
|
249
|
+
if (!glossed && !hasSameField(proseWords, d.token)) {
|
|
228
250
|
findings.push({ kind: 'unexplained', file, line: d.line, token: d.token })
|
|
229
251
|
}
|
|
230
252
|
}
|
|
231
253
|
|
|
232
254
|
const reported = new Set<string>()
|
|
233
255
|
for (const m of p.mandates) {
|
|
234
|
-
if (!hasSameField(known, m.token)
|
|
256
|
+
if (!hasSameField(known, m.token)) continue
|
|
235
257
|
const key = `${m.line}:${m.token}`
|
|
236
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
|
|
237
268
|
reported.add(key)
|
|
238
|
-
findings.push({ kind: '
|
|
269
|
+
findings.push({ kind: 'miscased', file, line: m.line, token: m.token, declared: same[0]?.token })
|
|
239
270
|
}
|
|
240
271
|
}
|
|
241
272
|
return findings.sort((a, b) => (a.file === b.file ? a.line - b.line : a.file < b.file ? -1 : 1))
|
|
@@ -246,13 +277,17 @@ export function check(parsed: Map<string, ParsedDefinition>): Finding[] {
|
|
|
246
277
|
const DETAIL: Record<FindingKind, string> = {
|
|
247
278
|
unexplained: 'declared in a block, but no gloss and no prose explains it',
|
|
248
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',
|
|
249
281
|
}
|
|
250
282
|
|
|
251
283
|
export function formatReport(definitions: string[], findings: Finding[]): string {
|
|
252
284
|
if (definitions.length === 0) return 'check-field-mandates: no skill or agent definition found\n'
|
|
253
285
|
if (findings.length === 0) return `check-field-mandates: ${definitions.length} definition(s) OK\n`
|
|
254
286
|
const rows = findings
|
|
255
|
-
.map(
|
|
287
|
+
.map(
|
|
288
|
+
(f) =>
|
|
289
|
+
` ${f.kind.padEnd(11)} ${f.file}:${f.line} ${f.token} — ${DETAIL[f.kind]}${f.declared ? ` (${f.declared})` : ''}`,
|
|
290
|
+
)
|
|
256
291
|
.join('\n')
|
|
257
292
|
return `${rows}\ncheck-field-mandates: ${findings.length} finding(s) across ${definitions.length} definition(s)\n`
|
|
258
293
|
}
|
|
@@ -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 } ]
|