@fairfox/polly 0.82.1 → 0.83.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/dist/cli/polly.js +22 -1
- package/dist/cli/polly.js.map +3 -3
- package/dist/tools/bdd/src/args.d.ts +21 -0
- package/dist/tools/bdd/src/bus-driver.d.ts +36 -0
- package/dist/tools/bdd/src/check-verify.d.ts +15 -0
- package/dist/tools/bdd/src/cli.d.ts +2 -0
- package/dist/tools/bdd/src/cli.js +701 -0
- package/dist/tools/bdd/src/cli.js.map +19 -0
- package/dist/tools/bdd/src/config.d.ts +9 -0
- package/dist/tools/bdd/src/extract.d.ts +2 -0
- package/dist/tools/bdd/src/index.d.ts +19 -0
- package/dist/tools/bdd/src/index.js +540 -0
- package/dist/tools/bdd/src/index.js.map +17 -0
- package/dist/tools/bdd/src/parse.d.ts +3 -0
- package/dist/tools/bdd/src/report.d.ts +6 -0
- package/dist/tools/bdd/src/run.d.ts +8 -0
- package/dist/tools/bdd/src/scaffold.d.ts +7 -0
- package/dist/tools/bdd/src/steps.d.ts +55 -0
- package/dist/tools/bdd/src/types.d.ts +145 -0
- package/dist/tools/bdd/src/witness.d.ts +23 -0
- package/dist/tools/quality/src/cli.js +304 -15
- package/dist/tools/quality/src/cli.js.map +6 -4
- package/dist/tools/quality/src/index.d.ts +2 -0
- package/dist/tools/quality/src/index.js +309 -15
- package/dist/tools/quality/src/index.js.map +6 -4
- package/dist/tools/quality/src/no-fixed-waits.d.ts +52 -0
- package/dist/tools/quality/src/no-tautology-ensures.d.ts +67 -0
- package/dist/tools/quality/src/plugins/core.d.ts +1 -1
- package/dist/tools/test/src/tiers/cli.js +21 -1
- package/dist/tools/test/src/tiers/cli.js.map +3 -3
- package/dist/tools/verify/src/cli.js +521 -1
- package/dist/tools/verify/src/cli.js.map +8 -4
- package/package.json +7 -1
|
@@ -0,0 +1,701 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __returnValue = (v) => v;
|
|
5
|
+
function __exportSetter(name, newValue) {
|
|
6
|
+
this[name] = __returnValue.bind(null, newValue);
|
|
7
|
+
}
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, {
|
|
11
|
+
get: all[name],
|
|
12
|
+
enumerable: true,
|
|
13
|
+
configurable: true,
|
|
14
|
+
set: __exportSetter.bind(all, name)
|
|
15
|
+
});
|
|
16
|
+
};
|
|
17
|
+
var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
|
|
18
|
+
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
19
|
+
|
|
20
|
+
// tools/bdd/src/parse.ts
|
|
21
|
+
import { AstBuilder, GherkinClassicTokenMatcher, Parser } from "@cucumber/gherkin";
|
|
22
|
+
import { IdGenerator } from "@cucumber/messages";
|
|
23
|
+
function newParser() {
|
|
24
|
+
return new Parser(new AstBuilder(IdGenerator.uuid()), new GherkinClassicTokenMatcher);
|
|
25
|
+
}
|
|
26
|
+
function normalizeKeyword(raw, prev) {
|
|
27
|
+
const k = raw.trim().toLowerCase();
|
|
28
|
+
if (k === "given")
|
|
29
|
+
return "given";
|
|
30
|
+
if (k === "when")
|
|
31
|
+
return "when";
|
|
32
|
+
if (k === "then")
|
|
33
|
+
return "then";
|
|
34
|
+
return prev ?? "given";
|
|
35
|
+
}
|
|
36
|
+
function normalizeSteps(rawSteps) {
|
|
37
|
+
const out = [];
|
|
38
|
+
let prev = null;
|
|
39
|
+
for (const s of rawSteps) {
|
|
40
|
+
const keyword = normalizeKeyword(s.keyword, prev);
|
|
41
|
+
prev = keyword;
|
|
42
|
+
out.push({
|
|
43
|
+
keyword,
|
|
44
|
+
rawKeyword: s.keyword.trim(),
|
|
45
|
+
text: s.text.trim(),
|
|
46
|
+
line: s.location?.line ?? 0
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
function tagNames(tags) {
|
|
52
|
+
return (tags ?? []).map((t) => t.name.replace(/^@/, ""));
|
|
53
|
+
}
|
|
54
|
+
function fillOutline(text, headers, cells) {
|
|
55
|
+
let filled = text;
|
|
56
|
+
headers.forEach((h, i) => {
|
|
57
|
+
filled = filled.split(`<${h}>`).join(cells[i] ?? "");
|
|
58
|
+
});
|
|
59
|
+
return filled;
|
|
60
|
+
}
|
|
61
|
+
function buildScenarios(sc) {
|
|
62
|
+
const baseSteps = sc.steps ?? [];
|
|
63
|
+
const tags = tagNames(sc.tags);
|
|
64
|
+
const examples = sc.examples ?? [];
|
|
65
|
+
if (examples.length === 0) {
|
|
66
|
+
return [
|
|
67
|
+
{ name: sc.name, tags, steps: normalizeSteps(baseSteps), line: sc.location?.line ?? 0 }
|
|
68
|
+
];
|
|
69
|
+
}
|
|
70
|
+
const out = [];
|
|
71
|
+
for (const ex of examples) {
|
|
72
|
+
const headers = (ex.tableHeader?.cells ?? []).map((c) => c.value);
|
|
73
|
+
for (const row of ex.tableBody ?? []) {
|
|
74
|
+
const cells = (row.cells ?? []).map((c) => c.value);
|
|
75
|
+
const rowSteps = baseSteps.map((s) => ({
|
|
76
|
+
keyword: s.keyword,
|
|
77
|
+
text: fillOutline(s.text, headers, cells),
|
|
78
|
+
location: s.location
|
|
79
|
+
}));
|
|
80
|
+
const label = headers.map((h, i) => `${h}=${cells[i] ?? ""}`).join(", ");
|
|
81
|
+
out.push({
|
|
82
|
+
name: `${sc.name} [${label}]`,
|
|
83
|
+
tags: [...tags, ...tagNames(ex.tags)],
|
|
84
|
+
steps: normalizeSteps(rowSteps),
|
|
85
|
+
line: row.location?.line ?? sc.location?.line ?? 0,
|
|
86
|
+
fromOutline: true
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
function parseFeatureText(text, file) {
|
|
93
|
+
const doc = newParser().parse(text);
|
|
94
|
+
const feature = doc.feature;
|
|
95
|
+
if (!feature) {
|
|
96
|
+
return { name: "", description: "", tags: [], background: [], scenarios: [], file };
|
|
97
|
+
}
|
|
98
|
+
let background = [];
|
|
99
|
+
const scenarios = [];
|
|
100
|
+
for (const child of feature.children ?? []) {
|
|
101
|
+
if (child.background) {
|
|
102
|
+
background = normalizeSteps(child.background.steps ?? []);
|
|
103
|
+
} else if (child.scenario) {
|
|
104
|
+
scenarios.push(...buildScenarios(child.scenario));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
name: feature.name,
|
|
109
|
+
description: (feature.description ?? "").trim(),
|
|
110
|
+
tags: tagNames(feature.tags),
|
|
111
|
+
background,
|
|
112
|
+
scenarios,
|
|
113
|
+
file
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
async function parseFeatureFile(path) {
|
|
117
|
+
const text = await Bun.file(path).text();
|
|
118
|
+
return parseFeatureText(text, path);
|
|
119
|
+
}
|
|
120
|
+
var init_parse = () => {};
|
|
121
|
+
|
|
122
|
+
// tools/bdd/src/steps.ts
|
|
123
|
+
function state() {
|
|
124
|
+
globalThis.__pollyBddRegistry__ ??= { bindings: [], worldDef: null };
|
|
125
|
+
return globalThis.__pollyBddRegistry__;
|
|
126
|
+
}
|
|
127
|
+
function compilePattern(pattern) {
|
|
128
|
+
const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
129
|
+
const withGroups = escaped.replace(/\\\{string\\\}/g, `(?:"([^"]*)"|'([^']*)')`).replace(/\\\{int\\\}/g, "([-+]?\\d+)").replace(/\\\{float\\\}/g, "([-+]?\\d*\\.?\\d+)").replace(/\\\{word\\\}/g, "([^\\s]+)");
|
|
130
|
+
return new RegExp(`^${withGroups}$`);
|
|
131
|
+
}
|
|
132
|
+
function defineStep(binding) {
|
|
133
|
+
state().bindings.push({ binding, regex: compilePattern(binding.pattern) });
|
|
134
|
+
}
|
|
135
|
+
function defineWorld(def) {
|
|
136
|
+
state().worldDef = def;
|
|
137
|
+
}
|
|
138
|
+
function getWorldDef() {
|
|
139
|
+
return state().worldDef;
|
|
140
|
+
}
|
|
141
|
+
function resetRegistry() {
|
|
142
|
+
const s = state();
|
|
143
|
+
s.bindings.length = 0;
|
|
144
|
+
s.worldDef = null;
|
|
145
|
+
}
|
|
146
|
+
function matchStep(text, keyword) {
|
|
147
|
+
let textOnlyFallback = null;
|
|
148
|
+
for (const { binding, regex } of state().bindings) {
|
|
149
|
+
const m = regex.exec(text);
|
|
150
|
+
if (!m)
|
|
151
|
+
continue;
|
|
152
|
+
const args = m.slice(1).filter((g) => g !== undefined);
|
|
153
|
+
if (!keyword || binding[keyword])
|
|
154
|
+
return { binding, args };
|
|
155
|
+
textOnlyFallback ??= { binding, args };
|
|
156
|
+
}
|
|
157
|
+
return textOnlyFallback;
|
|
158
|
+
}
|
|
159
|
+
function registeredBindings() {
|
|
160
|
+
return state().bindings.map((c) => c.binding);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// tools/bdd/src/cli.ts
|
|
164
|
+
import { resolve as resolve4 } from "node:path";
|
|
165
|
+
|
|
166
|
+
// tools/bdd/src/args.ts
|
|
167
|
+
var VALUE_FLAGS = new Set(["--features", "--steps", "--tags"]);
|
|
168
|
+
function parseBddArgs(argv) {
|
|
169
|
+
const positionals = [];
|
|
170
|
+
const flags = new Map;
|
|
171
|
+
const bools = new Set;
|
|
172
|
+
let i = 0;
|
|
173
|
+
while (i < argv.length) {
|
|
174
|
+
const a = argv[i] ?? "";
|
|
175
|
+
if (VALUE_FLAGS.has(a)) {
|
|
176
|
+
flags.set(a, argv[i + 1] ?? "");
|
|
177
|
+
i += 2;
|
|
178
|
+
} else {
|
|
179
|
+
if (a.startsWith("-"))
|
|
180
|
+
bools.add(a);
|
|
181
|
+
else
|
|
182
|
+
positionals.push(a);
|
|
183
|
+
i += 1;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return {
|
|
187
|
+
verb: positionals[0] ?? "run",
|
|
188
|
+
rest: positionals.slice(1),
|
|
189
|
+
features: flags.get("--features"),
|
|
190
|
+
steps: flags.get("--steps"),
|
|
191
|
+
tags: flags.get("--tags"),
|
|
192
|
+
json: bools.has("--json"),
|
|
193
|
+
help: bools.has("--help") || bools.has("-h")
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// tools/bdd/src/check-verify.ts
|
|
198
|
+
import { resolve } from "node:path";
|
|
199
|
+
|
|
200
|
+
// tools/bdd/src/extract.ts
|
|
201
|
+
init_parse();
|
|
202
|
+
async function loadStepModules(stepFiles) {
|
|
203
|
+
resetRegistry();
|
|
204
|
+
for (const file of stepFiles) {
|
|
205
|
+
await import(`${file}?t=${Bun.nanoseconds()}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
function toTraceStep(text, keyword) {
|
|
209
|
+
const match = matchStep(text, keyword);
|
|
210
|
+
if (!match)
|
|
211
|
+
return { text, keyword, unbound: true };
|
|
212
|
+
return {
|
|
213
|
+
text,
|
|
214
|
+
keyword,
|
|
215
|
+
message: match.binding.message,
|
|
216
|
+
stateExpr: match.binding.stateExpr
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
async function extractTraces(featureFiles, stepFiles) {
|
|
220
|
+
await loadStepModules(stepFiles);
|
|
221
|
+
const traces = [];
|
|
222
|
+
for (const file of featureFiles) {
|
|
223
|
+
const feature = await parseFeatureFile(file);
|
|
224
|
+
for (const scenario of feature.scenarios) {
|
|
225
|
+
const allSteps = [...feature.background, ...scenario.steps];
|
|
226
|
+
const trace = {
|
|
227
|
+
feature: feature.name,
|
|
228
|
+
scenario: scenario.name,
|
|
229
|
+
tags: [...feature.tags, ...scenario.tags],
|
|
230
|
+
given: [],
|
|
231
|
+
when: [],
|
|
232
|
+
then: [],
|
|
233
|
+
file
|
|
234
|
+
};
|
|
235
|
+
for (const step of allSteps) {
|
|
236
|
+
trace[step.keyword].push(toTraceStep(step.text, step.keyword));
|
|
237
|
+
}
|
|
238
|
+
traces.push(trace);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return traces;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// tools/bdd/src/check-verify.ts
|
|
245
|
+
async function loadVerifyConfig(configPath) {
|
|
246
|
+
const mod = await import(`file://${resolve(configPath)}?t=${Bun.nanoseconds()}`);
|
|
247
|
+
const config = mod.verificationConfig ?? mod.default;
|
|
248
|
+
if (!config)
|
|
249
|
+
throw new Error(`no verificationConfig/default export in ${configPath}`);
|
|
250
|
+
return config;
|
|
251
|
+
}
|
|
252
|
+
function messageSet(config) {
|
|
253
|
+
const set = new Set;
|
|
254
|
+
for (const t of config.messages?.include ?? [])
|
|
255
|
+
set.add(t);
|
|
256
|
+
for (const t of Object.keys(config.messages?.perMessageBounds ?? {}))
|
|
257
|
+
set.add(t);
|
|
258
|
+
for (const sub of Object.values(config.subsystems ?? {})) {
|
|
259
|
+
for (const h of sub.handlers ?? [])
|
|
260
|
+
set.add(h);
|
|
261
|
+
}
|
|
262
|
+
return set;
|
|
263
|
+
}
|
|
264
|
+
function stateKeys(config) {
|
|
265
|
+
const keys = new Set(Object.keys(config.state ?? {}));
|
|
266
|
+
for (const sub of Object.values(config.subsystems ?? {})) {
|
|
267
|
+
for (const f of sub.state ?? [])
|
|
268
|
+
keys.add(f);
|
|
269
|
+
}
|
|
270
|
+
return [...keys];
|
|
271
|
+
}
|
|
272
|
+
function fieldsIn(expr) {
|
|
273
|
+
const noStrings = expr.replace(/"[^"]*"|'[^']*'/g, "");
|
|
274
|
+
const ids = noStrings.match(/[a-zA-Z_$][\w$]*(?:\.[a-zA-Z_$][\w$]*)*/g) ?? [];
|
|
275
|
+
const ignore = new Set(["true", "false", "null", "undefined", "length", "value"]);
|
|
276
|
+
return ids.filter((id) => !ignore.has(id) && Number.isNaN(Number(id)));
|
|
277
|
+
}
|
|
278
|
+
function fieldKnown(field, keys) {
|
|
279
|
+
return keys.some((k) => k === field || field.startsWith(`${k}.`) || k.startsWith(`${field}.`));
|
|
280
|
+
}
|
|
281
|
+
var NEGATIVE_TAGS = new Set(["negative", "formal"]);
|
|
282
|
+
function checkTrace(trace, messages, keys, findings) {
|
|
283
|
+
const id = `${trace.feature} › ${trace.scenario}`;
|
|
284
|
+
for (const step of [...trace.given, ...trace.when, ...trace.then]) {
|
|
285
|
+
if (step.unbound) {
|
|
286
|
+
findings.push({ kind: "warn", scenario: id, message: `step has no binding: "${step.text}"` });
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
for (const step of trace.when) {
|
|
290
|
+
if (step.message && !messages.has(step.message)) {
|
|
291
|
+
findings.push({
|
|
292
|
+
kind: "error",
|
|
293
|
+
scenario: id,
|
|
294
|
+
message: `When sends "${step.message}", which the verification config does not model`
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
for (const step of [...trace.given, ...trace.then]) {
|
|
299
|
+
if (!step.stateExpr)
|
|
300
|
+
continue;
|
|
301
|
+
for (const field of fieldsIn(step.stateExpr)) {
|
|
302
|
+
if (!fieldKnown(field, keys)) {
|
|
303
|
+
findings.push({
|
|
304
|
+
kind: "error",
|
|
305
|
+
scenario: id,
|
|
306
|
+
message: `${step.keyword} asserts on "${field}", absent from the config's state map`
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
function featureNeedsNegative(traces) {
|
|
313
|
+
return traces.some((t) => t.then.some((s) => /\bnot\b|exclud|reject|empty|invalid|limit|forbidden/i.test(s.text)));
|
|
314
|
+
}
|
|
315
|
+
function checkNegativeComplement(traces, findings) {
|
|
316
|
+
const byFeature = new Map;
|
|
317
|
+
for (const t of traces) {
|
|
318
|
+
const arr = byFeature.get(t.feature) ?? [];
|
|
319
|
+
arr.push(t);
|
|
320
|
+
byFeature.set(t.feature, arr);
|
|
321
|
+
}
|
|
322
|
+
for (const [feature, group] of byFeature) {
|
|
323
|
+
const hasNegative = group.some((t) => t.tags.some((tag) => NEGATIVE_TAGS.has(tag)));
|
|
324
|
+
if (!hasNegative && featureNeedsNegative(group)) {
|
|
325
|
+
findings.push({
|
|
326
|
+
kind: "warn",
|
|
327
|
+
scenario: feature,
|
|
328
|
+
message: "feature filters/selects/validates but has no negative complement (a @negative or @formal scenario) — an over-permissive build would still pass"
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
async function checkAgainstVerify(opts) {
|
|
334
|
+
const config = await loadVerifyConfig(opts.configPath);
|
|
335
|
+
const messages = messageSet(config);
|
|
336
|
+
const keys = stateKeys(config);
|
|
337
|
+
const traces = await extractTraces(opts.featureFiles, opts.stepFiles);
|
|
338
|
+
const findings = [];
|
|
339
|
+
for (const trace of traces)
|
|
340
|
+
checkTrace(trace, messages, keys, findings);
|
|
341
|
+
checkNegativeComplement(traces, findings);
|
|
342
|
+
return {
|
|
343
|
+
ok: findings.every((f) => f.kind !== "error"),
|
|
344
|
+
checked: traces.length,
|
|
345
|
+
findings
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// tools/bdd/src/config.ts
|
|
350
|
+
import { resolve as resolve2 } from "node:path";
|
|
351
|
+
import { Glob } from "bun";
|
|
352
|
+
async function expand(cwd, pattern) {
|
|
353
|
+
const out = [];
|
|
354
|
+
for await (const f of new Glob(pattern).scan({ cwd, absolute: true, onlyFiles: true })) {
|
|
355
|
+
out.push(f);
|
|
356
|
+
}
|
|
357
|
+
return out.sort();
|
|
358
|
+
}
|
|
359
|
+
async function resolveBddConfig(cwd, args) {
|
|
360
|
+
const pathArg = args.verb === "run" ? args.rest[0] : undefined;
|
|
361
|
+
let featurePattern = args.features ?? "features/**/*.feature";
|
|
362
|
+
if (pathArg) {
|
|
363
|
+
featurePattern = pathArg.endsWith(".feature") ? pathArg : `${pathArg.replace(/\/$/, "")}/**/*.feature`;
|
|
364
|
+
}
|
|
365
|
+
const stepPatterns = args.steps ? [args.steps] : ["features/**/*.steps.ts", "features/steps.ts"];
|
|
366
|
+
const featureFiles = pathArg?.endsWith(".feature") ? [resolve2(cwd, pathArg)] : await expand(cwd, featurePattern);
|
|
367
|
+
const stepSets = await Promise.all(stepPatterns.map((p) => expand(cwd, p)));
|
|
368
|
+
const stepFiles = [...new Set(stepSets.flat())];
|
|
369
|
+
return { cwd, featureFiles, stepFiles };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// tools/bdd/src/report.ts
|
|
373
|
+
var MARK = {
|
|
374
|
+
pass: "✓",
|
|
375
|
+
fail: "✗",
|
|
376
|
+
undefined: "?",
|
|
377
|
+
"deferred-formal": "→"
|
|
378
|
+
};
|
|
379
|
+
function relFile(file, cwd) {
|
|
380
|
+
return file.startsWith(cwd) ? file.slice(cwd.length + 1) : file;
|
|
381
|
+
}
|
|
382
|
+
function scenarioDetail(s) {
|
|
383
|
+
if (s.outcome === "deferred-formal") {
|
|
384
|
+
return [
|
|
385
|
+
" deferred to polly verify — precondition is formal-only (requires() is a runtime no-op)"
|
|
386
|
+
];
|
|
387
|
+
}
|
|
388
|
+
if (s.outcome !== "fail" && s.outcome !== "undefined")
|
|
389
|
+
return [];
|
|
390
|
+
const lines = [];
|
|
391
|
+
for (const step of s.steps) {
|
|
392
|
+
if (step.outcome !== "fail" && step.outcome !== "undefined")
|
|
393
|
+
continue;
|
|
394
|
+
lines.push(` ${step.outcome === "fail" ? "✗" : "?"} ${step.rawKeyword} ${step.text}`);
|
|
395
|
+
if (step.message)
|
|
396
|
+
lines.push(` ↳ ${step.message}`);
|
|
397
|
+
}
|
|
398
|
+
return lines;
|
|
399
|
+
}
|
|
400
|
+
function formatRun(result, cwd) {
|
|
401
|
+
const lines = [];
|
|
402
|
+
let currentFeature = "";
|
|
403
|
+
for (const s of result.scenarios) {
|
|
404
|
+
if (s.feature !== currentFeature) {
|
|
405
|
+
currentFeature = s.feature;
|
|
406
|
+
lines.push(`
|
|
407
|
+
Feature: ${s.feature} (${relFile(s.file, cwd)})`);
|
|
408
|
+
}
|
|
409
|
+
lines.push(` ${MARK[s.outcome]} ${s.scenario}`);
|
|
410
|
+
lines.push(...scenarioDetail(s));
|
|
411
|
+
}
|
|
412
|
+
lines.push("");
|
|
413
|
+
lines.push(`${result.ok ? "✓" : "✗"} ${result.passed} passed, ${result.failed} failed, ` + `${result.undefinedSteps} undefined, ${result.deferred} deferred (formal)`);
|
|
414
|
+
return lines.join(`
|
|
415
|
+
`);
|
|
416
|
+
}
|
|
417
|
+
function toJson(result) {
|
|
418
|
+
return JSON.stringify(result, null, 2);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// tools/bdd/src/run.ts
|
|
422
|
+
init_parse();
|
|
423
|
+
var FORMAL_TAG = "formal";
|
|
424
|
+
async function loadStepModules2(stepFiles) {
|
|
425
|
+
resetRegistry();
|
|
426
|
+
for (const file of stepFiles) {
|
|
427
|
+
await import(`${file}?t=${Bun.nanoseconds()}`);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
function tagMatches(tags, filter) {
|
|
431
|
+
if (!filter)
|
|
432
|
+
return true;
|
|
433
|
+
if (filter.startsWith("~"))
|
|
434
|
+
return !tags.includes(filter.slice(1));
|
|
435
|
+
return tags.includes(filter);
|
|
436
|
+
}
|
|
437
|
+
async function runStep(world, step) {
|
|
438
|
+
const base = { text: step.text, rawKeyword: step.rawKeyword };
|
|
439
|
+
const match = matchStep(step.text, step.keyword);
|
|
440
|
+
if (!match) {
|
|
441
|
+
return { ...base, outcome: "undefined", message: `no binding matches "${step.text}"` };
|
|
442
|
+
}
|
|
443
|
+
const fn = match.binding[step.keyword];
|
|
444
|
+
if (!fn) {
|
|
445
|
+
return {
|
|
446
|
+
...base,
|
|
447
|
+
outcome: "undefined",
|
|
448
|
+
message: `binding for "${step.text}" has no '${step.keyword}' callback`
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
try {
|
|
452
|
+
const ret = await fn(world, ...match.args);
|
|
453
|
+
if (ret !== undefined)
|
|
454
|
+
world.lastResponse = ret;
|
|
455
|
+
return { ...base, outcome: "pass" };
|
|
456
|
+
} catch (err) {
|
|
457
|
+
world.lastError = err;
|
|
458
|
+
return { ...base, outcome: "fail", message: err instanceof Error ? err.message : String(err) };
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
async function runScenario(world, feature, scenario, reset) {
|
|
462
|
+
const result = {
|
|
463
|
+
feature: feature.name,
|
|
464
|
+
scenario: scenario.name,
|
|
465
|
+
tags: scenario.tags,
|
|
466
|
+
outcome: "pass",
|
|
467
|
+
steps: [],
|
|
468
|
+
file: feature.file
|
|
469
|
+
};
|
|
470
|
+
await reset(world);
|
|
471
|
+
world.vars = {};
|
|
472
|
+
world.lastResponse = undefined;
|
|
473
|
+
world.lastError = undefined;
|
|
474
|
+
const steps = [...feature.background, ...scenario.steps];
|
|
475
|
+
let aborted = false;
|
|
476
|
+
for (const step of steps) {
|
|
477
|
+
if (aborted) {
|
|
478
|
+
result.steps.push({ text: step.text, rawKeyword: step.rawKeyword, outcome: "skipped" });
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
const sr = await runStep(world, step);
|
|
482
|
+
result.steps.push(sr);
|
|
483
|
+
if (sr.outcome === "fail") {
|
|
484
|
+
result.outcome = "fail";
|
|
485
|
+
aborted = true;
|
|
486
|
+
} else if (sr.outcome === "undefined") {
|
|
487
|
+
result.outcome = result.outcome === "fail" ? "fail" : "undefined";
|
|
488
|
+
aborted = true;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
return result;
|
|
492
|
+
}
|
|
493
|
+
async function runFeatures(options) {
|
|
494
|
+
await loadStepModules2(options.stepFiles);
|
|
495
|
+
const worldDef = getWorldDef();
|
|
496
|
+
if (!worldDef) {
|
|
497
|
+
throw new Error("no world defined. A step module must call defineWorld({ create, reset }) — see tools/bdd/README.md.");
|
|
498
|
+
}
|
|
499
|
+
const world = await worldDef.create();
|
|
500
|
+
const features = await Promise.all(options.featureFiles.map((f) => parseFeatureFile(f)));
|
|
501
|
+
const scenarios = [];
|
|
502
|
+
for (const feature of features) {
|
|
503
|
+
for (const scenario of feature.scenarios) {
|
|
504
|
+
const tags = [...feature.tags, ...scenario.tags];
|
|
505
|
+
if (!tagMatches(tags, options.tagFilter))
|
|
506
|
+
continue;
|
|
507
|
+
if (tags.includes(FORMAL_TAG)) {
|
|
508
|
+
scenarios.push({
|
|
509
|
+
feature: feature.name,
|
|
510
|
+
scenario: scenario.name,
|
|
511
|
+
tags,
|
|
512
|
+
outcome: "deferred-formal",
|
|
513
|
+
steps: [],
|
|
514
|
+
file: feature.file
|
|
515
|
+
});
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
scenarios.push(await runScenario(world, feature, { ...scenario, tags }, worldDef.reset));
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
const passed = scenarios.filter((s) => s.outcome === "pass").length;
|
|
522
|
+
const failed = scenarios.filter((s) => s.outcome === "fail").length;
|
|
523
|
+
const undef = scenarios.filter((s) => s.outcome === "undefined").length;
|
|
524
|
+
const deferred = scenarios.filter((s) => s.outcome === "deferred-formal").length;
|
|
525
|
+
return {
|
|
526
|
+
scenarios,
|
|
527
|
+
passed,
|
|
528
|
+
failed,
|
|
529
|
+
undefinedSteps: undef,
|
|
530
|
+
deferred,
|
|
531
|
+
ok: failed === 0 && undef === 0
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// tools/bdd/src/scaffold.ts
|
|
536
|
+
import { existsSync } from "node:fs";
|
|
537
|
+
import { resolve as resolve3 } from "node:path";
|
|
538
|
+
async function loadVocabulary(configPath) {
|
|
539
|
+
if (!existsSync(configPath))
|
|
540
|
+
return { messages: [], fields: [] };
|
|
541
|
+
const mod = await import(`file://${resolve3(configPath)}?t=${Bun.nanoseconds()}`);
|
|
542
|
+
const config = mod.verificationConfig ?? mod.default ?? {};
|
|
543
|
+
const messages = new Set;
|
|
544
|
+
for (const t of config.messages?.include ?? [])
|
|
545
|
+
messages.add(t);
|
|
546
|
+
for (const t of Object.keys(config.messages?.perMessageBounds ?? {}))
|
|
547
|
+
messages.add(t);
|
|
548
|
+
for (const sub of Object.values(config.subsystems ?? {})) {
|
|
549
|
+
for (const h of sub.handlers ?? [])
|
|
550
|
+
messages.add(h);
|
|
551
|
+
}
|
|
552
|
+
return { messages: [...messages].sort(), fields: Object.keys(config.state ?? {}).sort() };
|
|
553
|
+
}
|
|
554
|
+
function slug(name) {
|
|
555
|
+
return name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
556
|
+
}
|
|
557
|
+
async function scaffoldFeature(cwd, name, configPath) {
|
|
558
|
+
const { messages, fields } = await loadVocabulary(configPath);
|
|
559
|
+
const featurePath = resolve3(cwd, "features", `${slug(name)}.feature`);
|
|
560
|
+
if (existsSync(featurePath)) {
|
|
561
|
+
return { created: false, featurePath, messages, fields };
|
|
562
|
+
}
|
|
563
|
+
const body = `Feature: ${name}
|
|
564
|
+
# Three-amigos: state the user story, then converge on declarative scenarios.
|
|
565
|
+
# As a <role> I want <capability> so that <benefit>.
|
|
566
|
+
#
|
|
567
|
+
# Bind each step in features/steps.ts. The dual-use binding carries formal
|
|
568
|
+
# metadata so 'polly bdd check' can cross-check it against the verify config:
|
|
569
|
+
# When-steps declare \`message\` (a modeled message type)
|
|
570
|
+
# Given/Then-steps declare \`stateExpr\` (a tracked state field)
|
|
571
|
+
#
|
|
572
|
+
# Message types this project models:
|
|
573
|
+
${messages.map((m) => ` # - ${m}`).join(`
|
|
574
|
+
`) || " # (none found — is specs/verification.config.ts present?)"}
|
|
575
|
+
# State fields this project tracks:
|
|
576
|
+
${fields.map((f) => ` # - ${f}`).join(`
|
|
577
|
+
`) || " # (none found)"}
|
|
578
|
+
|
|
579
|
+
Scenario: <name the behaviour by its outcome>
|
|
580
|
+
Given <context that is already true>
|
|
581
|
+
When <a single action>
|
|
582
|
+
Then <an observable outcome>
|
|
583
|
+
|
|
584
|
+
@negative
|
|
585
|
+
Scenario: <the negative complement — what is excluded / rejected / empty>
|
|
586
|
+
Given <context>
|
|
587
|
+
When <a single action>
|
|
588
|
+
Then <the system observably says no>
|
|
589
|
+
`;
|
|
590
|
+
await Bun.write(featurePath, body);
|
|
591
|
+
return { created: true, featurePath, messages, fields };
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// tools/bdd/src/cli.ts
|
|
595
|
+
var HELP = `polly bdd — executable Gherkin against polly's handlers + state
|
|
596
|
+
|
|
597
|
+
Three-amigos sessions produce acceptance examples from the user's perspective;
|
|
598
|
+
this runs them across the real factory boundary and cross-checks them against
|
|
599
|
+
the verification config — so the example layer, the formal layer, and the
|
|
600
|
+
mutation layer all describe the same handlers and state.
|
|
601
|
+
|
|
602
|
+
Usage:
|
|
603
|
+
polly bdd [run] [path] Run .feature files (default: features/**/*.feature)
|
|
604
|
+
polly bdd check Cross-check scenarios against specs/verification.config.ts
|
|
605
|
+
polly bdd new <name> Scaffold a feature stub seeded from the verify vocabulary
|
|
606
|
+
polly bdd help Show this help
|
|
607
|
+
|
|
608
|
+
Flags:
|
|
609
|
+
--features <glob> feature files (default: features/**/*.feature)
|
|
610
|
+
--steps <glob> step modules to load (default: features/**/*.steps.ts + features/steps.ts)
|
|
611
|
+
--tags <tag> only run scenarios with this tag (~tag negates)
|
|
612
|
+
--json machine-readable output
|
|
613
|
+
-h, --help
|
|
614
|
+
|
|
615
|
+
Scenarios tagged @formal cover precondition-only behaviour (requires() is a
|
|
616
|
+
runtime no-op) — the runner defers them; 'polly verify' checks them, since the
|
|
617
|
+
requires() guard is extracted into the TLA+ model.`;
|
|
618
|
+
async function main() {
|
|
619
|
+
const args = parseBddArgs(process.argv.slice(2));
|
|
620
|
+
if (args.help || args.verb === "help") {
|
|
621
|
+
console.log(HELP);
|
|
622
|
+
return 0;
|
|
623
|
+
}
|
|
624
|
+
const cwd = process.cwd();
|
|
625
|
+
switch (args.verb) {
|
|
626
|
+
case "run": {
|
|
627
|
+
const cfg = await resolveBddConfig(cwd, args);
|
|
628
|
+
if (cfg.featureFiles.length === 0) {
|
|
629
|
+
console.log("No .feature files found (looked for features/**/*.feature).");
|
|
630
|
+
return 1;
|
|
631
|
+
}
|
|
632
|
+
if (cfg.stepFiles.length === 0) {
|
|
633
|
+
console.log("No step modules found (looked for features/**/*.steps.ts and features/steps.ts).");
|
|
634
|
+
return 1;
|
|
635
|
+
}
|
|
636
|
+
const result = await runFeatures({
|
|
637
|
+
featureFiles: cfg.featureFiles,
|
|
638
|
+
stepFiles: cfg.stepFiles,
|
|
639
|
+
tagFilter: args.tags
|
|
640
|
+
});
|
|
641
|
+
console.log(args.json ? toJson(result) : formatRun(result, cwd));
|
|
642
|
+
return result.ok ? 0 : 1;
|
|
643
|
+
}
|
|
644
|
+
case "check": {
|
|
645
|
+
const cfg = await resolveBddConfig(cwd, args);
|
|
646
|
+
const configPath = resolve4(cwd, "specs", "verification.config.ts");
|
|
647
|
+
const result = await checkAgainstVerify({
|
|
648
|
+
featureFiles: cfg.featureFiles,
|
|
649
|
+
stepFiles: cfg.stepFiles,
|
|
650
|
+
configPath
|
|
651
|
+
});
|
|
652
|
+
if (args.json) {
|
|
653
|
+
console.log(JSON.stringify(result, null, 2));
|
|
654
|
+
return result.ok ? 0 : 1;
|
|
655
|
+
}
|
|
656
|
+
console.log(`
|
|
657
|
+
Cross-checked ${result.checked} scenario(s) against the verification config:`);
|
|
658
|
+
if (result.findings.length === 0) {
|
|
659
|
+
console.log(" ✓ every When models a real message; every Given/Then names a tracked field");
|
|
660
|
+
}
|
|
661
|
+
for (const f of result.findings) {
|
|
662
|
+
console.log(` ${f.kind === "error" ? "✗" : "⚠"} ${f.scenario}
|
|
663
|
+
${f.message}`);
|
|
664
|
+
}
|
|
665
|
+
console.log(result.ok ? `
|
|
666
|
+
✓ BDD ↔ verify cross-check holds.` : `
|
|
667
|
+
✗ Cross-check failed.`);
|
|
668
|
+
return result.ok ? 0 : 1;
|
|
669
|
+
}
|
|
670
|
+
case "new": {
|
|
671
|
+
const name = args.rest.join(" ").trim();
|
|
672
|
+
if (!name) {
|
|
673
|
+
console.log("usage: polly bdd new <feature name>");
|
|
674
|
+
return 1;
|
|
675
|
+
}
|
|
676
|
+
const configPath = resolve4(cwd, "specs", "verification.config.ts");
|
|
677
|
+
const res = await scaffoldFeature(cwd, name, configPath);
|
|
678
|
+
if (!res.created) {
|
|
679
|
+
console.log(`${res.featurePath} already exists.`);
|
|
680
|
+
return 1;
|
|
681
|
+
}
|
|
682
|
+
console.log(`✓ wrote ${res.featurePath}`);
|
|
683
|
+
console.log(` seeded from ${res.messages.length} message type(s), ${res.fields.length} state field(s)`);
|
|
684
|
+
console.log(`
|
|
685
|
+
Next: bind the steps in features/steps.ts, then 'polly bdd run'.`);
|
|
686
|
+
return 0;
|
|
687
|
+
}
|
|
688
|
+
default:
|
|
689
|
+
console.log(`Unknown subcommand: ${args.verb}
|
|
690
|
+
`);
|
|
691
|
+
console.log(HELP);
|
|
692
|
+
return 1;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
main().then((code) => process.exit(code)).catch((err) => {
|
|
696
|
+
console.log(`
|
|
697
|
+
❌ ${err instanceof Error ? err.message : String(err)}`);
|
|
698
|
+
process.exit(1);
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
//# debugId=2DDEB2D363C2D86864756E2164756E21
|