@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.
Files changed (33) hide show
  1. package/dist/cli/polly.js +22 -1
  2. package/dist/cli/polly.js.map +3 -3
  3. package/dist/tools/bdd/src/args.d.ts +21 -0
  4. package/dist/tools/bdd/src/bus-driver.d.ts +36 -0
  5. package/dist/tools/bdd/src/check-verify.d.ts +15 -0
  6. package/dist/tools/bdd/src/cli.d.ts +2 -0
  7. package/dist/tools/bdd/src/cli.js +701 -0
  8. package/dist/tools/bdd/src/cli.js.map +19 -0
  9. package/dist/tools/bdd/src/config.d.ts +9 -0
  10. package/dist/tools/bdd/src/extract.d.ts +2 -0
  11. package/dist/tools/bdd/src/index.d.ts +19 -0
  12. package/dist/tools/bdd/src/index.js +540 -0
  13. package/dist/tools/bdd/src/index.js.map +17 -0
  14. package/dist/tools/bdd/src/parse.d.ts +3 -0
  15. package/dist/tools/bdd/src/report.d.ts +6 -0
  16. package/dist/tools/bdd/src/run.d.ts +8 -0
  17. package/dist/tools/bdd/src/scaffold.d.ts +7 -0
  18. package/dist/tools/bdd/src/steps.d.ts +55 -0
  19. package/dist/tools/bdd/src/types.d.ts +145 -0
  20. package/dist/tools/bdd/src/witness.d.ts +23 -0
  21. package/dist/tools/quality/src/cli.js +304 -15
  22. package/dist/tools/quality/src/cli.js.map +6 -4
  23. package/dist/tools/quality/src/index.d.ts +2 -0
  24. package/dist/tools/quality/src/index.js +309 -15
  25. package/dist/tools/quality/src/index.js.map +6 -4
  26. package/dist/tools/quality/src/no-fixed-waits.d.ts +52 -0
  27. package/dist/tools/quality/src/no-tautology-ensures.d.ts +67 -0
  28. package/dist/tools/quality/src/plugins/core.d.ts +1 -1
  29. package/dist/tools/test/src/tiers/cli.js +21 -1
  30. package/dist/tools/test/src/tiers/cli.js.map +3 -3
  31. package/dist/tools/verify/src/cli.js +521 -1
  32. package/dist/tools/verify/src/cli.js.map +8 -4
  33. 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