@jjchill/probity-rules 0.1.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.
Files changed (71) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/GLOSSARY.template.md +33 -0
  3. package/README.md +96 -0
  4. package/dist/index.d.ts +20 -0
  5. package/dist/index.d.ts.map +1 -0
  6. package/dist/index.js +20 -0
  7. package/dist/index.js.map +1 -0
  8. package/dist/presets/js.d.ts +63 -0
  9. package/dist/presets/js.d.ts.map +1 -0
  10. package/dist/presets/js.js +112 -0
  11. package/dist/presets/js.js.map +1 -0
  12. package/dist/presets/kmp.d.ts +27 -0
  13. package/dist/presets/kmp.d.ts.map +1 -0
  14. package/dist/presets/kmp.js +253 -0
  15. package/dist/presets/kmp.js.map +1 -0
  16. package/dist/presets/kotlin.d.ts +40 -0
  17. package/dist/presets/kotlin.d.ts.map +1 -0
  18. package/dist/presets/kotlin.js +172 -0
  19. package/dist/presets/kotlin.js.map +1 -0
  20. package/dist/presets/swift.d.ts +10 -0
  21. package/dist/presets/swift.d.ts.map +1 -0
  22. package/dist/presets/swift.js +285 -0
  23. package/dist/presets/swift.js.map +1 -0
  24. package/dist/rules/acceptance-language.d.ts +95 -0
  25. package/dist/rules/acceptance-language.d.ts.map +1 -0
  26. package/dist/rules/acceptance-language.js +443 -0
  27. package/dist/rules/acceptance-language.js.map +1 -0
  28. package/dist/rules/gates.d.ts +125 -0
  29. package/dist/rules/gates.d.ts.map +1 -0
  30. package/dist/rules/gates.js +285 -0
  31. package/dist/rules/gates.js.map +1 -0
  32. package/dist/rules/kotlin.d.ts +323 -0
  33. package/dist/rules/kotlin.d.ts.map +1 -0
  34. package/dist/rules/kotlin.js +722 -0
  35. package/dist/rules/kotlin.js.map +1 -0
  36. package/dist/rules/ports-and-adapters.d.ts +86 -0
  37. package/dist/rules/ports-and-adapters.d.ts.map +1 -0
  38. package/dist/rules/ports-and-adapters.js +366 -0
  39. package/dist/rules/ports-and-adapters.js.map +1 -0
  40. package/dist/rules/scoping.d.ts +68 -0
  41. package/dist/rules/scoping.d.ts.map +1 -0
  42. package/dist/rules/scoping.js +93 -0
  43. package/dist/rules/scoping.js.map +1 -0
  44. package/dist/rules/spec-test-parity.d.ts +164 -0
  45. package/dist/rules/spec-test-parity.d.ts.map +1 -0
  46. package/dist/rules/spec-test-parity.js +456 -0
  47. package/dist/rules/spec-test-parity.js.map +1 -0
  48. package/dist/rules/swift.d.ts +50 -0
  49. package/dist/rules/swift.d.ts.map +1 -0
  50. package/dist/rules/swift.js +50 -0
  51. package/dist/rules/swift.js.map +1 -0
  52. package/dist/rules/ubiquitous-language.d.ts +36 -0
  53. package/dist/rules/ubiquitous-language.d.ts.map +1 -0
  54. package/dist/rules/ubiquitous-language.js +140 -0
  55. package/dist/rules/ubiquitous-language.js.map +1 -0
  56. package/dist/scripts/scope-report.d.ts +3 -0
  57. package/dist/scripts/scope-report.d.ts.map +1 -0
  58. package/dist/scripts/scope-report.js +184 -0
  59. package/dist/scripts/scope-report.js.map +1 -0
  60. package/kiro/README.md +20 -0
  61. package/kiro/kiro-agent.template.json +45 -0
  62. package/kiro/kiro-transcript-to-claude.py +183 -0
  63. package/kiro/probity-kiro-translate.py +132 -0
  64. package/kiro/probity-kiro.sh +87 -0
  65. package/kiro/skill-activation-forced-eval.sh +45 -0
  66. package/package.json +68 -0
  67. package/probity.config.kmp.ts +40 -0
  68. package/probity.config.kotlin.ts +37 -0
  69. package/probity.config.swift.ts +38 -0
  70. package/probity.config.ts +43 -0
  71. package/scripts/spec-parity.mjs +345 -0
@@ -0,0 +1,345 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Standalone spec↔test parity check — the CI mirror of the
4
+ * enforceSpecTestParity probity rule (hooks/probity/rules/
5
+ * spec-test-parity.ts). Probity hooks only run in agent sessions;
6
+ * this script makes the same invariant hold for human commits and
7
+ * PRs. Zero dependencies.
8
+ *
9
+ * Usage:
10
+ * node spec-parity.mjs --specs docs/specs [--tests .] [--pattern acceptance]
11
+ * [--baseline <file>] [--write-baseline]
12
+ * [--scope name=<regex>]... [--default-scopes a,b]
13
+ *
14
+ * --specs specs directory containing *.feature.md (required)
15
+ * --tests root(s) to scan for acceptance tests (repeatable, default .)
16
+ * --pattern substring/regex a test file path must match
17
+ * (default: /[/\\]acceptance[/\\]/)
18
+ * --baseline incremental-adoption baseline file: scenarios listed
19
+ * there (one `<spec>.feature.md :: <title>` per line, `#`
20
+ * comments allowed) are exempt from the orphan check.
21
+ * Missing file → full enforcement.
22
+ * --write-baseline with --baseline: write the current orphan set to
23
+ * the baseline file and exit 0. Run once when adopting the
24
+ * gate on a brownfield spec suite; burn the file down over
25
+ * time by deleting lines. Dangling Covers tags are never
26
+ * baselined — they are actively wrong, not legacy.
27
+ * --scope declare a driver scope: a name and the regex its test
28
+ * files match (repeatable), e.g.
29
+ * --scope system=AcceptanceTests/UITests/
30
+ * A scenario tagged `## Scenario [system]:` must then be
31
+ * covered by a test matching that scope — in addition to
32
+ * the base one-covering-test requirement. Tags are
33
+ * floors, not ceilings. Tags naming undeclared scopes
34
+ * fail the check (misspelling protection).
35
+ * --default-scopes comma-separated scope names required of every
36
+ * non-wip, non-baselined scenario even without a tag —
37
+ * the project's standard driver set.
38
+ *
39
+ * Exit codes: 0 parity holds, 1 orphaned scenarios, dangling Covers
40
+ * tags, or unmet driver scopes, 2 usage error.
41
+ *
42
+ * Conventions (shared with the probity rule):
43
+ * spec heading: ## Scenario: <title> (## Scenario (wip): exempt)
44
+ * or Gherkin `Scenario: <title>` (preceded by @wip / @scope tags)
45
+ * scope tag: ## Scenario [system]: <title> (after any (wip) marker)
46
+ * or a Gherkin `@system` tag on the scenario
47
+ * test tag: Covers: <spec>.feature[.md] :: Scenario: <title>
48
+ *
49
+ * Spec files may be either <name>.feature.md (Markdown) or <name>.feature
50
+ * (Gherkin); the two extensions are interchangeable (matched by stem) so a
51
+ * spec can convert one file at a time with parity green throughout. The
52
+ * same stem in both extensions at once is rejected (unfinished rename).
53
+ */
54
+ import { readdirSync, readFileSync, existsSync, writeFileSync } from 'node:fs'
55
+ import { basename, join } from 'node:path'
56
+ import process from 'node:process'
57
+
58
+ // Scenario headings come in two forms during the migration from Markdown
59
+ // feature files to real Gherkin:
60
+ // Markdown: ## Scenario (wip) [system]: Title
61
+ // Gherkin: @wip @system\n Scenario: Title
62
+ // Both are parsed per-line by parseScenarios below.
63
+ const MD_SCENARIO =
64
+ /^##\s*Scenario(\s*\((?:wip|planned)\))?(\s*\[([^\]]+)\])?\s*:\s*(.+?)\s*$/
65
+ const GHERKIN_SCENARIO = /^Scenario(?:\s+Outline)?:\s*(.+?)\s*$/
66
+ // Covers tags accept either extension so a tag survives its spec's
67
+ // conversion unchanged: Covers: <spec>.feature[.md] :: Scenario: <title>
68
+ const COVERS_TAG = /Covers:\s*([\w.-]+\.feature(?:\.md)?)\s*::\s*Scenario:\s*([^\n]+)/g
69
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'build', '.gradle', 'out', 'dist', '.idea'])
70
+
71
+ function parseArgs(argv) {
72
+ const args = {
73
+ specs: undefined,
74
+ tests: [],
75
+ pattern: undefined,
76
+ baseline: undefined,
77
+ writeBaseline: false,
78
+ scopes: new Map(),
79
+ defaultScopes: [],
80
+ }
81
+ for (let i = 0; i < argv.length; i++) {
82
+ if (argv[i] === '--specs') args.specs = argv[++i]
83
+ else if (argv[i] === '--tests') args.tests.push(argv[++i])
84
+ else if (argv[i] === '--pattern') args.pattern = argv[++i]
85
+ else if (argv[i] === '--baseline') args.baseline = argv[++i]
86
+ else if (argv[i] === '--write-baseline') args.writeBaseline = true
87
+ else if (argv[i] === '--scope') {
88
+ const value = argv[++i] ?? ''
89
+ const idx = value.indexOf('=')
90
+ if (idx < 1) {
91
+ console.error(`--scope expects name=<regex>, got: ${value}`)
92
+ process.exit(2)
93
+ }
94
+ args.scopes.set(value.slice(0, idx).trim().toLowerCase(), new RegExp(value.slice(idx + 1)))
95
+ } else if (argv[i] === '--default-scopes') {
96
+ args.defaultScopes = (argv[++i] ?? '')
97
+ .split(',')
98
+ .map((name) => name.trim().toLowerCase())
99
+ .filter(Boolean)
100
+ } else {
101
+ console.error(`Unknown argument: ${argv[i]}`)
102
+ process.exit(2)
103
+ }
104
+ }
105
+ if (!args.specs || (args.writeBaseline && !args.baseline)) {
106
+ console.error(
107
+ 'Usage: spec-parity.mjs --specs <dir> [--tests <root>]... [--pattern <regex>] ' +
108
+ '[--baseline <file>] [--write-baseline] [--scope name=<regex>]... ' +
109
+ '[--default-scopes a,b]',
110
+ )
111
+ process.exit(2)
112
+ }
113
+ if (args.tests.length === 0) args.tests = ['.']
114
+ return args
115
+ }
116
+
117
+ function normalizeTitle(title) {
118
+ return title
119
+ .replace(/\s*(?:\*\/|["')\]]+)?\s*$/, '')
120
+ .replace(/\s+/g, ' ')
121
+ .trim()
122
+ .toLowerCase()
123
+ }
124
+
125
+ // A spec is identified by its stem (filename without .feature or
126
+ // .feature.md), so a scenario keeps the same key across the extension
127
+ // change and Covers tags resolve regardless of which extension either side
128
+ // currently uses.
129
+ const specStem = (name) => basename(name).replace(/\.feature(?:\.md)?$/i, '')
130
+ const key = (specFile, title) => `${specStem(specFile)} :: ${normalizeTitle(title)}`
131
+
132
+ function walk(dir, out = []) {
133
+ let entries
134
+ try {
135
+ entries = readdirSync(dir, { withFileTypes: true })
136
+ } catch {
137
+ return out
138
+ }
139
+ for (const entry of entries) {
140
+ if (entry.isDirectory()) {
141
+ if (!SKIP_DIRS.has(entry.name)) walk(join(dir, entry.name), out)
142
+ } else {
143
+ out.push(join(dir, entry.name))
144
+ }
145
+ }
146
+ return out
147
+ }
148
+
149
+ const splitScopeList = (raw) =>
150
+ (raw ?? '')
151
+ .split(',')
152
+ .map((scope) => scope.trim().toLowerCase())
153
+ .filter(Boolean)
154
+
155
+ /**
156
+ * Parse one spec file (Markdown `## Scenario:` or Gherkin `Scenario:`)
157
+ * into scenario records. Gherkin `@tags` on the lines preceding a
158
+ * `Scenario:` map to markers: `@wip`/`@planned` mark work-in-progress,
159
+ * every other tag is a driver-scope name (mirroring Markdown's
160
+ * `(wip)` marker and `[scope]` list).
161
+ */
162
+ function parseScenarios(file) {
163
+ const scenarios = []
164
+ let tags = []
165
+ for (const line of readFileSync(file, 'utf8').split(/\r?\n/)) {
166
+ const md = line.match(MD_SCENARIO)
167
+ if (md) {
168
+ scenarios.push({
169
+ key: key(file, md[4]),
170
+ specFile: basename(file),
171
+ title: md[4].trim(),
172
+ wip: md[1] !== undefined,
173
+ scopes: splitScopeList(md[3]),
174
+ })
175
+ tags = []
176
+ continue
177
+ }
178
+ const trimmed = line.trim()
179
+ if (/^@\S/.test(trimmed)) {
180
+ tags.push(
181
+ ...trimmed
182
+ .split(/\s+/)
183
+ .filter((token) => token.startsWith('@'))
184
+ .map((token) => token.slice(1).toLowerCase()),
185
+ )
186
+ continue
187
+ }
188
+ const gherkin = trimmed.match(GHERKIN_SCENARIO)
189
+ if (gherkin) {
190
+ scenarios.push({
191
+ key: key(file, gherkin[1]),
192
+ specFile: basename(file),
193
+ title: gherkin[1].trim(),
194
+ wip: tags.includes('wip') || tags.includes('planned'),
195
+ scopes: tags.filter((tag) => tag !== 'wip' && tag !== 'planned'),
196
+ })
197
+ tags = []
198
+ continue
199
+ }
200
+ // Tags only attach to the next Scenario. A Feature line, or any other
201
+ // non-tag, non-comment content, ends a dangling tag block.
202
+ if (trimmed !== '' && !trimmed.startsWith('#')) tags = []
203
+ }
204
+ return scenarios
205
+ }
206
+
207
+ const args = parseArgs(process.argv.slice(2))
208
+ if (!existsSync(args.specs)) {
209
+ console.log(`No specs directory at ${args.specs} — nothing to enforce.`)
210
+ process.exit(0)
211
+ }
212
+ const pattern = args.pattern ? new RegExp(args.pattern) : /[/\\]acceptance[/\\]/
213
+
214
+ const specFiles = walk(args.specs).filter(
215
+ (file) => file.endsWith('.feature.md') || file.endsWith('.feature'),
216
+ )
217
+
218
+ // During the .feature.md -> .feature migration a spec is one extension or
219
+ // the other. The same stem in both forms would double-count scenarios and
220
+ // mask a half-finished rename, so fail loudly instead of silently passing.
221
+ const seenStems = new Map()
222
+ for (const file of specFiles) {
223
+ const stem = specStem(file)
224
+ const existing = seenStems.get(stem)
225
+ if (existing) {
226
+ console.error(`Spec "${stem}" exists in both .feature and .feature.md forms:`)
227
+ console.error(` ${existing}`)
228
+ console.error(` ${file}`)
229
+ console.error('Finish the migration for this spec — keep a single extension.')
230
+ process.exit(2)
231
+ }
232
+ seenStems.set(stem, file)
233
+ }
234
+
235
+ const scenarios = specFiles.flatMap(parseScenarios)
236
+
237
+ const refs = args.tests
238
+ .flatMap((root) => walk(root))
239
+ .filter(
240
+ (file) =>
241
+ pattern.test(file) &&
242
+ !file.endsWith('.feature.md') &&
243
+ !file.endsWith('.feature'),
244
+ )
245
+ .flatMap((file) =>
246
+ [...readFileSync(file, 'utf8').matchAll(COVERS_TAG)].map((m) => ({
247
+ key: key(m[1], m[2]),
248
+ testFile: file,
249
+ specFile: m[1],
250
+ title: m[2].replace(/\s*(?:\*\/|["')\]]+)?\s*$/, '').trim(),
251
+ })),
252
+ )
253
+
254
+ const claimed = new Set(refs.map((ref) => ref.key))
255
+ const known = new Set(scenarios.map((scenario) => scenario.key))
256
+ const unclaimed = scenarios.filter((s) => !s.wip && !claimed.has(s.key))
257
+ const dangling = refs.filter((ref) => !known.has(ref.key))
258
+
259
+ if (args.writeBaseline) {
260
+ const header =
261
+ '# spec-parity incremental-adoption baseline — scenarios exempt from the\n' +
262
+ '# orphan check. Burn down by deleting lines as coverage lands.\n' +
263
+ `# Generated by spec-parity.mjs --write-baseline (${unclaimed.length} scenario(s)).\n`
264
+ writeFileSync(
265
+ args.baseline,
266
+ header + unclaimed.map((s) => `${s.specFile} :: ${s.title}`).join('\n') + '\n',
267
+ )
268
+ console.log(`Wrote ${unclaimed.length} baseline entr(ies) to ${args.baseline}.`)
269
+ if (dangling.length > 0) {
270
+ console.error('Dangling Covers tags are never baselined — fix these:')
271
+ for (const r of dangling) console.error(` ${r.testFile} → ${r.specFile} :: ${r.title}`)
272
+ process.exit(1)
273
+ }
274
+ process.exit(0)
275
+ }
276
+
277
+ const baseline = new Set(
278
+ args.baseline && existsSync(args.baseline)
279
+ ? readFileSync(args.baseline, 'utf8')
280
+ .split('\n')
281
+ .map((line) => line.trim())
282
+ .filter((line) => line && !line.startsWith('#'))
283
+ .flatMap((line) => {
284
+ const idx = line.indexOf(' :: ')
285
+ return idx === -1 ? [] : [key(line.slice(0, idx).trim(), line.slice(idx + 4))]
286
+ })
287
+ : [],
288
+ )
289
+ const orphaned = unclaimed.filter((s) => !baseline.has(s.key))
290
+
291
+ const unknownScopes = []
292
+ const missingScopes = []
293
+ for (const s of scenarios) {
294
+ if (s.wip || baseline.has(s.key)) continue
295
+ const required = [...new Set([...args.defaultScopes, ...s.scopes])]
296
+ for (const name of required) {
297
+ const scopePattern = args.scopes.get(name)
298
+ if (!scopePattern) {
299
+ unknownScopes.push(`${s.specFile} :: ${s.title} — [${name}]`)
300
+ } else if (
301
+ !refs.some((ref) => ref.key === s.key && scopePattern.test(ref.testFile))
302
+ ) {
303
+ missingScopes.push(`${s.specFile} :: ${s.title} — needs [${name}] coverage`)
304
+ }
305
+ }
306
+ }
307
+
308
+ if (
309
+ orphaned.length === 0 &&
310
+ dangling.length === 0 &&
311
+ unknownScopes.length === 0 &&
312
+ missingScopes.length === 0
313
+ ) {
314
+ const wip = scenarios.filter((s) => s.wip).length
315
+ const baselined = unclaimed.length
316
+ console.log(
317
+ `Spec↔test parity holds: ${scenarios.length - wip - baselined} scenario(s) covered` +
318
+ (wip > 0 ? `, ${wip} wip` : '') +
319
+ (baselined > 0 ? `, ${baselined} baselined (burn-down)` : '') +
320
+ `, ${refs.length} Covers tag(s) resolved.`,
321
+ )
322
+ process.exit(0)
323
+ }
324
+ if (orphaned.length > 0) {
325
+ console.error('Scenarios with no covering acceptance test:')
326
+ for (const s of orphaned) console.error(` ${s.specFile} :: ${s.title}`)
327
+ }
328
+ if (dangling.length > 0) {
329
+ console.error('Covers tags pointing at no existing scenario:')
330
+ for (const r of dangling) console.error(` ${r.testFile} → ${r.specFile} :: ${r.title}`)
331
+ }
332
+ if (missingScopes.length > 0) {
333
+ console.error('Scenarios not covered by every driver scope they require:')
334
+ for (const line of missingScopes) console.error(` ${line}`)
335
+ }
336
+ if (unknownScopes.length > 0) {
337
+ const knownNames = [...args.scopes.keys()]
338
+ console.error(
339
+ `Scenario tags naming undeclared driver scopes (declared: ${
340
+ knownNames.length > 0 ? knownNames.join(', ') : 'none'
341
+ }):`,
342
+ )
343
+ for (const line of unknownScopes) console.error(` ${line}`)
344
+ }
345
+ process.exit(1)