@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.
- package/CHANGELOG.md +18 -0
- package/GLOSSARY.template.md +33 -0
- package/README.md +96 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/presets/js.d.ts +63 -0
- package/dist/presets/js.d.ts.map +1 -0
- package/dist/presets/js.js +112 -0
- package/dist/presets/js.js.map +1 -0
- package/dist/presets/kmp.d.ts +27 -0
- package/dist/presets/kmp.d.ts.map +1 -0
- package/dist/presets/kmp.js +253 -0
- package/dist/presets/kmp.js.map +1 -0
- package/dist/presets/kotlin.d.ts +40 -0
- package/dist/presets/kotlin.d.ts.map +1 -0
- package/dist/presets/kotlin.js +172 -0
- package/dist/presets/kotlin.js.map +1 -0
- package/dist/presets/swift.d.ts +10 -0
- package/dist/presets/swift.d.ts.map +1 -0
- package/dist/presets/swift.js +285 -0
- package/dist/presets/swift.js.map +1 -0
- package/dist/rules/acceptance-language.d.ts +95 -0
- package/dist/rules/acceptance-language.d.ts.map +1 -0
- package/dist/rules/acceptance-language.js +443 -0
- package/dist/rules/acceptance-language.js.map +1 -0
- package/dist/rules/gates.d.ts +125 -0
- package/dist/rules/gates.d.ts.map +1 -0
- package/dist/rules/gates.js +285 -0
- package/dist/rules/gates.js.map +1 -0
- package/dist/rules/kotlin.d.ts +323 -0
- package/dist/rules/kotlin.d.ts.map +1 -0
- package/dist/rules/kotlin.js +722 -0
- package/dist/rules/kotlin.js.map +1 -0
- package/dist/rules/ports-and-adapters.d.ts +86 -0
- package/dist/rules/ports-and-adapters.d.ts.map +1 -0
- package/dist/rules/ports-and-adapters.js +366 -0
- package/dist/rules/ports-and-adapters.js.map +1 -0
- package/dist/rules/scoping.d.ts +68 -0
- package/dist/rules/scoping.d.ts.map +1 -0
- package/dist/rules/scoping.js +93 -0
- package/dist/rules/scoping.js.map +1 -0
- package/dist/rules/spec-test-parity.d.ts +164 -0
- package/dist/rules/spec-test-parity.d.ts.map +1 -0
- package/dist/rules/spec-test-parity.js +456 -0
- package/dist/rules/spec-test-parity.js.map +1 -0
- package/dist/rules/swift.d.ts +50 -0
- package/dist/rules/swift.d.ts.map +1 -0
- package/dist/rules/swift.js +50 -0
- package/dist/rules/swift.js.map +1 -0
- package/dist/rules/ubiquitous-language.d.ts +36 -0
- package/dist/rules/ubiquitous-language.d.ts.map +1 -0
- package/dist/rules/ubiquitous-language.js +140 -0
- package/dist/rules/ubiquitous-language.js.map +1 -0
- package/dist/scripts/scope-report.d.ts +3 -0
- package/dist/scripts/scope-report.d.ts.map +1 -0
- package/dist/scripts/scope-report.js +184 -0
- package/dist/scripts/scope-report.js.map +1 -0
- package/kiro/README.md +20 -0
- package/kiro/kiro-agent.template.json +45 -0
- package/kiro/kiro-transcript-to-claude.py +183 -0
- package/kiro/probity-kiro-translate.py +132 -0
- package/kiro/probity-kiro.sh +87 -0
- package/kiro/skill-activation-forced-eval.sh +45 -0
- package/package.json +68 -0
- package/probity.config.kmp.ts +40 -0
- package/probity.config.kotlin.ts +37 -0
- package/probity.config.swift.ts +38 -0
- package/probity.config.ts +43 -0
- 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)
|