@pmelab/gtd 59.0.0 → 60.0.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 (90) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +42 -38
  3. package/claude/hooks/doors.ts +8 -4
  4. package/dist/chunks/flows-DENt0uWV.mjs +707 -0
  5. package/dist/chunks/{steering-CCNvngSH.mjs → steering-DF_BMadH.mjs} +292 -289
  6. package/dist/chunks/{testkit-CMBAAyRK.mjs → testkit-BvecVjQ0.mjs} +937 -772
  7. package/dist/chunks/{wire-C12dahrH.mjs → wire-DZ8R1RU9.mjs} +9 -2
  8. package/dist/flows.mjs +2 -676
  9. package/dist/gtd.bundle.mjs +1101 -1025
  10. package/dist/steering.mjs +2 -2
  11. package/dist/testkit/gherkin.mjs +371 -82
  12. package/dist/testkit.mjs +3 -2
  13. package/dist/web/main.js +234 -232
  14. package/dist/wire.mjs +2 -2
  15. package/dist/workflow-kit.mjs +3 -3
  16. package/package.json +1 -1
  17. package/skills/authoring/SKILL.md +68 -61
  18. package/src/cli/Cli.test.ts +311 -229
  19. package/src/cli/Cli.ts +486 -585
  20. package/src/cli/Help.test.ts +47 -0
  21. package/src/cli/Help.ts +70 -0
  22. package/src/cli/History.ts +9 -4
  23. package/src/cli/Judges.ts +1 -1
  24. package/src/cli/Output.test.ts +35 -0
  25. package/src/cli/Output.ts +31 -1
  26. package/src/cli/Program.test.ts +131 -82
  27. package/src/cli/Program.ts +197 -111
  28. package/src/edge/Edge.test.ts +64 -56
  29. package/src/edge/Edge.ts +4 -4
  30. package/src/emit/Emit.test.ts +1 -1
  31. package/src/emit/Emit.ts +1 -1
  32. package/src/flows/factory.test.ts +14 -0
  33. package/src/flows/factory.ts +25 -6
  34. package/src/flows/helpers.test.ts +35 -0
  35. package/src/flows/helpers.ts +19 -10
  36. package/src/flows/index.test.ts +66 -0
  37. package/src/flows/index.ts +109 -5
  38. package/src/flows/internal.ts +7 -0
  39. package/src/flows/runtime.test.ts +192 -1
  40. package/src/flows/runtime.ts +188 -54
  41. package/src/gherkin/git-control.feature +6 -6
  42. package/src/gherkin/index.test.ts +127 -0
  43. package/src/gherkin/index.ts +481 -98
  44. package/src/gherkin/messages.test.ts +124 -0
  45. package/src/gherkin/messages.ts +101 -0
  46. package/src/gherkin/more-steps.feature +170 -0
  47. package/src/gherkin/scenario-end.feature +101 -0
  48. package/src/gherkin/testkit.feature +83 -7
  49. package/src/install/Install.test.ts +7 -7
  50. package/src/install/Install.ts +8 -8
  51. package/src/replay/Replay.test.ts +13 -2
  52. package/src/replay/Replay.ts +19 -30
  53. package/src/replay/judgeDiffShape.test.ts +1 -1
  54. package/src/steering/Footnotes.test.ts +1 -1
  55. package/src/steering/SteeringFormats.test.ts +1 -1
  56. package/src/steering/freeform.test.ts +1 -1
  57. package/src/steering/index.test.ts +27 -1
  58. package/src/steering/index.ts +6 -104
  59. package/src/steering/internal.ts +11 -0
  60. package/src/steering/qa.test.ts +7 -2
  61. package/src/steering/reads.test.ts +42 -0
  62. package/src/steering/reads.ts +43 -0
  63. package/src/steering/registry.ts +93 -0
  64. package/src/steering/review.test.ts +2 -2
  65. package/src/step/planStart.test.ts +18 -16
  66. package/src/step/planStart.ts +9 -9
  67. package/src/testkit/TestRepo.test.ts +289 -6
  68. package/src/testkit/TestRepo.ts +317 -41
  69. package/src/testkit/imports.test.ts +58 -0
  70. package/src/testkit/imports.ts +102 -0
  71. package/src/testkit/index.test.ts +28 -0
  72. package/src/testkit/index.ts +14 -2
  73. package/src/twins/EmittedScriptRecognizer.ts +7 -14
  74. package/src/twins/EmittedScripts.test.ts +4 -2
  75. package/src/twins/InMemRepo.ts +1 -0
  76. package/src/wire/Land.ts +1 -1
  77. package/src/wire/Script.ts +11 -0
  78. package/src/wire/WireSchema.test.ts +3 -0
  79. package/src/wire/golden/1/script.json +4 -0
  80. package/src/wire/index.ts +1 -0
  81. package/src/workflow/SteeringFormats.test.ts +1 -1
  82. package/src/workflow/compile.ts +2 -2
  83. package/src/workflow/discovery.test.ts +3 -3
  84. package/src/workflow/load.test.ts +256 -116
  85. package/src/workflow/load.ts +175 -152
  86. package/src/workflow/skills.test.ts +5 -5
  87. package/src/workflow/skills.ts +1 -1
  88. package/src/workflow-kit/index.test.ts +25 -0
  89. package/src/workflow-kit/index.ts +37 -4
  90. package/src/workflow-kit/review.ts +2 -2
package/dist/steering.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { A as parseThreads, C as unansweredNotes, D as THREAD_REPLY_ACTION_TITLE, E as FOOTNOTE_ACTION_TITLE, F as formatFinding, M as getParseCount, N as headingSectionBodies, O as openThreadFindings, P as headingSections, S as reviewSections, T as isAnswered, _ as REVIEW_SECTIONS, a as unansweredQuestions, b as reviewNotes, c as stripCodeThreads, d as FIX_BOX, f as NOISE_HEADING, g as ANSWER_WONT_FIX, h as ANSWER_LEAD, i as steeringFormatOrFreeForm, j as parseThreadsWithFindings, k as parseFootnotes, l as freeFormFormat, m as ANSWER_FIX, n as openThreads, o as viewOf, p as findingEntries, r as steeringFormatFor, s as parseCodeThreads, t as BUILT_IN_MODE_NAMES, u as applySteeringEdits, v as reviewChunks, w as FREE_TEXT_PLACEHOLDER, x as reviewRisks, y as reviewDecisions } from "./chunks/steering-CCNvngSH.mjs";
2
- export { ANSWER_FIX, ANSWER_LEAD, ANSWER_WONT_FIX, BUILT_IN_MODE_NAMES, FIX_BOX, FOOTNOTE_ACTION_TITLE, FREE_TEXT_PLACEHOLDER, NOISE_HEADING, REVIEW_SECTIONS, THREAD_REPLY_ACTION_TITLE, applySteeringEdits, findingEntries, formatFinding, freeFormFormat, getParseCount, headingSectionBodies, headingSections, isAnswered, openThreadFindings, openThreads, parseCodeThreads, parseFootnotes, parseThreads, parseThreadsWithFindings, reviewChunks, reviewDecisions, reviewNotes, reviewRisks, reviewSections, steeringFormatFor, steeringFormatOrFreeForm, stripCodeThreads, unansweredNotes, unansweredQuestions, viewOf };
1
+ import { A as parseThreads, C as FREE_TEXT_PLACEHOLDER, D as findingEntries, E as NOISE_HEADING, N as formatFinding, O as openThreadFindings, S as unansweredNotes, T as FIX_BOX, _ as reviewChunks, a as sections, b as reviewRisks, c as openThreads, d as freeFormFormat, f as applySteeringEdits, g as REVIEW_SECTIONS, h as ANSWER_WONT_FIX, i as sectionBodies, j as parseThreadsWithFindings, k as parseFootnotes, l as steeringFormatFor, m as ANSWER_LEAD, n as stripCodeThreads, o as threads, p as ANSWER_FIX, r as openQuestions, t as parseCodeThreads, v as reviewDecisions, w as isAnswered, x as reviewSections, y as reviewNotes } from "./chunks/steering-DF_BMadH.mjs";
2
+ export { ANSWER_FIX, ANSWER_LEAD, ANSWER_WONT_FIX, FIX_BOX, FREE_TEXT_PLACEHOLDER, NOISE_HEADING, REVIEW_SECTIONS, applySteeringEdits, findingEntries, formatFinding, freeFormFormat, isAnswered, openQuestions, openThreadFindings, openThreads, parseCodeThreads, parseFootnotes, parseThreads, parseThreadsWithFindings, reviewChunks, reviewDecisions, reviewNotes, reviewRisks, reviewSections, sectionBodies, sections, steeringFormatFor, stripCodeThreads, threads, unansweredNotes };
@@ -1,9 +1,72 @@
1
- import { n as testRepo, t as runsItsScript } from "../chunks/testkit-CMBAAyRK.mjs";
1
+ import { n as runsItsScript, r as testRepo } from "../chunks/testkit-BvecVjQ0.mjs";
2
2
  import { resolve } from "node:path";
3
3
  import assert from "node:assert";
4
4
  import { pathToFileURL } from "node:url";
5
- import { Given, QuickPickleWorld, Then, When, setWorldConstructor } from "quickpickle";
5
+ import { After, AfterAll, Given, QuickPickleWorld, Then, When, setWorldConstructor } from "quickpickle";
6
+ //#region src/gherkin/messages.ts
7
+ const CONTENT_LINES = 5;
8
+ const reason = (error) => error instanceof Error ? error.message : String(error);
9
+ const beatLines = async (repo) => {
10
+ try {
11
+ const beat = await repo.next();
12
+ const content = beat.content.trimEnd().split("\n");
13
+ const more = content.length - CONTENT_LINES;
14
+ return [
15
+ `step "${beat.state}" (${beat.kind}, ${beat.actor}${beat.idle ? ", idle" : ""})`,
16
+ "content:",
17
+ ...content.slice(0, CONTENT_LINES).map((line) => ` | ${line}`),
18
+ ...more > 0 ? [` | … ${more} more lines`] : []
19
+ ];
20
+ } catch (error) {
21
+ return [`unknown — gtd next failed: ${reason(error)}`];
22
+ }
23
+ };
24
+ const unreachedList = (steps) => steps.map(({ step, origin }) => `"${step}" (${origin})`).join(", ");
25
+ /** Where the process rests, for a failed outcome to end with. Never throws: it describes a failure. */
26
+ const restDescription = async (repo, neverUsed) => {
27
+ let lines;
28
+ try {
29
+ const r = repo();
30
+ lines = [
31
+ ...await beatLines(r),
32
+ "last commits:",
33
+ ...r.subjects().slice(-5).map((subject) => ` ${subject}`)
34
+ ];
35
+ } catch (error) {
36
+ lines = [`unknown — ${reason(error)}`];
37
+ }
38
+ if (neverUsed.length > 0) lines.push(`actors never used: ${unreachedList(neverUsed)}`);
39
+ return ["where the process rests:", ...lines.map((line) => ` ${line}`)].join("\n");
40
+ };
41
+ /** Both sides of a failed `contains`: the text looked for, and what was there. */
42
+ const containsFailure = (where, text, content, expected) => content === void 0 ? `${where} does not exist${expected ? `, so it cannot contain "${text}"` : ""}` : `${where} ${expected ? "does not contain" : "contains"} "${text}"; it holds:\n${content}`;
43
+ /** What `attempt` was refused with; fails when it was not refused at all. */
44
+ const refusal = async (attempt, expectation) => {
45
+ try {
46
+ await attempt;
47
+ } catch (error) {
48
+ return error instanceof Error ? error.message : String(error);
49
+ }
50
+ assert.fail(`expected a refusal ${expectation}, but nothing was refused`);
51
+ };
52
+ const refusedWith = async (attempt, message) => {
53
+ const got = await refusal(attempt, `with "${message}"`);
54
+ if (!got.includes(message)) assert.fail(`expected a refusal with "${message}", but it was refused with:\n${got}`);
55
+ };
56
+ const assertContains = (where, text, content) => {
57
+ if (content === void 0 || !content.includes(text)) assert.fail(containsFailure(where, text, content, true));
58
+ };
59
+ const assertLacks = (where, text, content) => {
60
+ if (content === void 0 || content.includes(text)) assert.fail(containsFailure(where, text, content, false));
61
+ };
62
+ /** A rest at `step`; a stall there is no rest, so it points to the step that asserts one. */
63
+ const assertRestsAt = (beat, step) => {
64
+ if (beat.kind === "stalled") assert.fail(`expected a rest at "${step}", but the process stalls at "${beat.state}" — assert a stall with: the process stalls at "${beat.state}"`);
65
+ assert.strictEqual(beat.state, step);
66
+ };
67
+ //#endregion
6
68
  //#region src/gherkin/index.ts
69
+ const BACKGROUND_ACTORS = "gtdBackgroundActors";
7
70
  var TestkitWorld = class extends QuickPickleWorld {
8
71
  constructor(context, info) {
9
72
  super(context, info);
@@ -11,11 +74,15 @@ var TestkitWorld = class extends QuickPickleWorld {
11
74
  gtdConfig = void 0;
12
75
  env = {};
13
76
  commands = [];
14
- /** Per step, what its scripted actor does, in the order the scenario declared it. */
77
+ modules = {};
15
78
  actions = /* @__PURE__ */ new Map();
16
79
  verdicts = /* @__PURE__ */ new Map();
17
- /** Per judge step, a verdict for each text its questions may contain; the first match answers. */
18
80
  conditionalVerdicts = /* @__PURE__ */ new Map();
81
+ prompts = /* @__PURE__ */ new Map();
82
+ used = /* @__PURE__ */ new Set();
83
+ origins = /* @__PURE__ */ new Map();
84
+ backgroundScripted = /* @__PURE__ */ new Set();
85
+ excused = /* @__PURE__ */ new Set();
19
86
  created = void 0;
20
87
  configure(config) {
21
88
  this.assertNotCreated("a gtd config");
@@ -35,6 +102,19 @@ var TestkitWorld = class extends QuickPickleWorld {
35
102
  this.assertNotCreated(`the environment variable ${name}`);
36
103
  this.env[name] = value;
37
104
  }
105
+ /** Unlike `setEnv`, also once the repository exists: gtd sees it from its next call on. */
106
+ changeEnv(name, value) {
107
+ if (this.created === void 0) this.env[name] = value;
108
+ else this.created.setEnv(name, value);
109
+ }
110
+ /**
111
+ * Lets a config given as source import `module` as `specifier`. A support
112
+ * file may call it for every scenario, e.g. from a `Before` hook.
113
+ */
114
+ importable(specifier, module) {
115
+ this.assertNotCreated(`the module ${specifier}`);
116
+ this.modules[specifier] = module;
117
+ }
38
118
  assertNotCreated(what) {
39
119
  if (this.created !== void 0) throw new Error(`${what} must be set before any step that touches the repository`);
40
120
  }
@@ -43,13 +123,69 @@ var TestkitWorld = class extends QuickPickleWorld {
43
123
  return this.created ??= testRepo({
44
124
  workflow: this.gtdConfig,
45
125
  env: this.env,
46
- commands: this.commands
126
+ commands: this.commands,
127
+ modules: this.modules
47
128
  });
48
129
  }
130
+ scripting(step, replace = false) {
131
+ const fromBackground = (this.info.stepIdx ?? 0) < 0;
132
+ if (fromBackground) this.backgroundScripted.add(step);
133
+ if (!fromBackground || replace || !this.origins.has(step)) this.origins.set(step, fromBackground ? "Background" : "scenario");
134
+ }
135
+ /** Adds `action` to what `step`'s actor does at every visit. */
49
136
  act(step, action) {
137
+ this.scripting(step);
50
138
  this.actions.set(step, [...this.actions.get(step) ?? [], action]);
51
139
  }
52
- async drive() {
140
+ /** Drops every action and prompt queued for `step`, leaving `action` alone. */
141
+ rescript(step, action) {
142
+ this.scripting(step, true);
143
+ this.actions.set(step, [action]);
144
+ this.prompts.delete(step);
145
+ }
146
+ /** Drops every action and prompt queued for `step`, so a drive rests there. */
147
+ unscript(step) {
148
+ this.actions.delete(step);
149
+ this.prompts.delete(step);
150
+ }
151
+ /**
152
+ * `step`'s beat content must contain `text`. A scripted step is checked at
153
+ * every visit, before its actor acts; a step with nothing else scripted is
154
+ * not scripted at all, so a drive rests there and checks it at the rest.
155
+ */
156
+ expectPrompt(step, text) {
157
+ this.scripting(step);
158
+ this.prompts.set(step, [...this.prompts.get(step) ?? [], text]);
159
+ }
160
+ /** Judge step `step` answers `verdict`, a JSON array. */
161
+ answer(step, verdict) {
162
+ this.scripting(step);
163
+ this.verdicts.set(step, verdict);
164
+ }
165
+ /** Judge step `step` answers `verdict` when its questions contain `text`; the first match answers. */
166
+ answerWhen(step, text, verdict) {
167
+ this.scripting(step);
168
+ this.conditionalVerdicts.set(step, [...this.conditionalVerdicts.get(step) ?? [], [text, verdict]]);
169
+ }
170
+ scripted() {
171
+ return /* @__PURE__ */ new Set([
172
+ ...this.actions.keys(),
173
+ ...this.verdicts.keys(),
174
+ ...this.conditionalVerdicts.keys(),
175
+ ...this.prompts.keys()
176
+ ]);
177
+ }
178
+ /** The steps the scenario scripted, or expects a prompt at, that no drive has reached. */
179
+ neverUsed() {
180
+ return [...this.scripted()].filter((step) => !this.used.has(step)).map((step) => ({
181
+ step,
182
+ origin: this.origins.get(step) ?? "scenario"
183
+ }));
184
+ }
185
+ checkPrompts(step, content) {
186
+ for (const text of this.prompts.get(step) ?? []) if (!content.includes(text)) throw new Error(`step "${step}" was not prompted with "${text}":\n${content}`);
187
+ }
188
+ actors() {
53
189
  const actors = {};
54
190
  for (const [step, actions] of this.actions) actors[step] = async (beat, repo) => {
55
191
  for (const action of actions) await action(beat, repo);
@@ -67,12 +203,91 @@ var TestkitWorld = class extends QuickPickleWorld {
67
203
  return hit[1];
68
204
  };
69
205
  }
70
- await this.repo().drive(actors);
206
+ return actors;
207
+ }
208
+ /**
209
+ * `strict: false` lets what a strict drive refuses pass. Every drive leaves
210
+ * scripted steps it never reached to `assertReached`: an earlier step may
211
+ * script one for a later drive.
212
+ */
213
+ async drive(options = {}) {
214
+ try {
215
+ const actors = this.actors();
216
+ const tracked = {};
217
+ for (const [step, actor] of Object.entries(actors)) tracked[step] = (beat, repo) => {
218
+ this.used.add(step);
219
+ this.checkPrompts(step, beat.content);
220
+ return actor(beat, repo);
221
+ };
222
+ const rest = await this.repo().drive(tracked, {
223
+ ...options,
224
+ unreachedActors: "pass"
225
+ });
226
+ if (rest.kind !== "stalled" && this.prompts.has(rest.state) && !(rest.state in actors)) {
227
+ this.used.add(rest.state);
228
+ this.checkPrompts(rest.state, rest.content);
229
+ }
230
+ } catch (error) {
231
+ for (const step of this.scripted()) this.excused.add(step);
232
+ throw error;
233
+ }
234
+ }
235
+ /**
236
+ * Fails on a step a scenario step scripted, or expects a prompt at, that no
237
+ * drive reached. Run at scenario end. A Background's steps are left to the
238
+ * feature's end, and the steps a refused drive was handed are excused.
239
+ */
240
+ async assertReached() {
241
+ const never = this.neverUsed();
242
+ const dead = never.filter(({ step, origin }) => origin === "scenario" && !this.excused.has(step));
243
+ if (dead.length === 0) return;
244
+ throw new Error(`at scenario end, no drive reached the steps scripted for ${unreachedList(dead)} — script a step only for a drive that reaches it, or in the Background when only some scenarios reach it\n\n${await restDescription(() => this.repo(), never)}`);
245
+ }
246
+ /** Records, for the feature's end, which Background-scripted steps this scenario reached. */
247
+ recordBackground() {
248
+ const record = this.common[BACKGROUND_ACTORS] ??= {
249
+ reached: /* @__PURE__ */ new Map(),
250
+ file: void 0
251
+ };
252
+ record.file ??= this.context?.task?.file;
253
+ const failed = this.info.errors.length > 0;
254
+ for (const step of this.backgroundScripted) record.reached.set(step, record.reached.get(step) === true || failed || this.used.has(step));
71
255
  }
72
256
  };
257
+ After(async (world) => {
258
+ if (!(world instanceof TestkitWorld)) return;
259
+ world.recordBackground();
260
+ if (world.info.errors.length === 0) await world.assertReached();
261
+ });
262
+ const allRan = (tasks) => tasks.every((task) => {
263
+ const t = task;
264
+ if (t.tasks !== void 0) return allRan(t.tasks);
265
+ return t.mode === "run" && t.result?.state !== "skip";
266
+ });
267
+ AfterAll((common) => {
268
+ const record = common[BACKGROUND_ACTORS];
269
+ if (record === void 0) return;
270
+ if (record.file?.tasks !== void 0 && !allRan(record.file.tasks)) return;
271
+ const dead = [...record.reached].filter(([, reached]) => !reached);
272
+ if (dead.length === 0) return;
273
+ throw new Error(`${common.info.feature}: no scenario reached the steps scripted for ${unreachedList(dead.map(([step]) => ({
274
+ step,
275
+ origin: "Background"
276
+ })))} — script a step in the Background only when some scenario reaches it`);
277
+ });
73
278
  setWorldConstructor(TestkitWorld);
74
279
  const withNewline = (content) => content.endsWith("\n") ? content : `${content}\n`;
75
- const args = (list) => list.split(" ").filter((arg) => arg !== "");
280
+ const args = (list) => [...list.matchAll(/(["'])(.*?)\1(?=\s|$)|\S+/g)].map(([word, quote, inner]) => quote === void 0 ? word : inner);
281
+ /** An outcome step: a failure ends with where the process rests. */
282
+ const outcome = (check) => async (world, ...rest) => {
283
+ try {
284
+ await check(world, ...rest);
285
+ } catch (error) {
286
+ const failure = error instanceof Error ? error : new Error(String(error));
287
+ failure.message += `\n\n${await restDescription(() => world.repo(), world.neverUsed())}`;
288
+ throw failure;
289
+ }
290
+ };
76
291
  Given("a gtd config with:", (world, source) => {
77
292
  world.configure(withNewline(source));
78
293
  });
@@ -82,9 +297,15 @@ Given("a gtd config from the module {string}", async (world, path) => {
82
297
  Given("the gtd config ends with:", (world, source) => {
83
298
  world.extendConfig(withNewline(source));
84
299
  });
85
- Given("an environment variable {string} set to {string}", (world, name, value) => {
300
+ Given("the environment variable {string} is {string}", (world, name, value) => {
86
301
  world.setEnv(name, value);
87
302
  });
303
+ Given("from now on the environment variable {string} is {string}", (world, name, value) => {
304
+ world.changeEnv(name, value);
305
+ });
306
+ Given("the module {string} is importable as {string}", async (world, path, specifier) => {
307
+ world.importable(specifier, { ...await import(pathToFileURL(resolve(path)).href) });
308
+ });
88
309
  Given("the command {string} exits {int}", (world, run, exit) => {
89
310
  world.command({
90
311
  run,
@@ -98,9 +319,25 @@ Given("the command {string} exits {int} with:", (world, run, exit, output) => {
98
319
  output: withNewline(output)
99
320
  });
100
321
  });
322
+ Given("the command matching {string} exits {int}", (world, pattern, exit) => {
323
+ world.command({
324
+ run: new RegExp(pattern),
325
+ exit
326
+ });
327
+ });
328
+ Given("the command matching {string} exits {int} with:", (world, pattern, exit, output) => {
329
+ world.command({
330
+ run: new RegExp(pattern),
331
+ exit,
332
+ output: withNewline(output)
333
+ });
334
+ });
101
335
  Given("a branch {string}", (world, name) => {
102
336
  world.repo().branch(name);
103
337
  });
338
+ Given("a branch {string} at {string}", (world, name, ref) => {
339
+ world.repo().branch(name, ref);
340
+ });
104
341
  Given("{string} is checked out", (world, name) => {
105
342
  world.repo().checkout(name);
106
343
  });
@@ -118,7 +355,7 @@ Given("the text {string} in {string} is replaced with {string}", (world, text, p
118
355
  assert.ok(content.includes(text), `${path}: ${content}`);
119
356
  world.repo().write(path, content.replace(text, () => replacement));
120
357
  });
121
- Given("{string} gets appended:", (world, path, text) => {
358
+ Given("text is appended to {string}:", (world, path, text) => {
122
359
  const content = world.repo().read(path) ?? "";
123
360
  world.repo().write(path, `${content.trimEnd()}\n\n${text}\n`);
124
361
  });
@@ -132,10 +369,10 @@ Given("step {string} changes nothing", (world, step) => {
132
369
  world.act(step, () => void 0);
133
370
  });
134
371
  Given("step {string} from now on changes nothing", (world, step) => {
135
- world.actions.set(step, [() => void 0]);
372
+ world.rescript(step, () => void 0);
136
373
  });
137
374
  Given("step {string} is no longer scripted", (world, step) => {
138
- world.actions.delete(step);
375
+ world.unscript(step);
139
376
  });
140
377
  Given("step {string} writes {string} with:", (world, step, path, content) => {
141
378
  world.act(step, (_, repo) => repo.write(path, withNewline(content)));
@@ -161,11 +398,9 @@ Given("step {string} deletes {string}", (world, step, path) => {
161
398
  world.act(step, (_, repo) => repo.remove(path));
162
399
  });
163
400
  Given("step {string} is prompted with {string}", (world, step, text) => {
164
- world.act(step, (beat) => {
165
- if (!beat.content.includes(text)) throw new Error(`step "${step}" was not prompted with "${text}":\n${beat.content}`);
166
- });
401
+ world.expectPrompt(step, text);
167
402
  });
168
- Given("step {string} rewrites {string} with:", (world, step, path, content) => {
403
+ Given("step {string} overwrites {string}, if it exists, with:", (world, step, path, content) => {
169
404
  world.act(step, (_, repo) => {
170
405
  if (repo.read(path) !== void 0) repo.write(path, withNewline(content));
171
406
  });
@@ -173,11 +408,30 @@ Given("step {string} rewrites {string} with:", (world, step, path, content) => {
173
408
  Given("step {string} runs its script", (world, step) => {
174
409
  world.act(step, runsItsScript);
175
410
  });
411
+ /** A check step's script, every command it runs answering as `command` does. */
412
+ const checkRuns = (world, step, command) => {
413
+ world.act(step, (beat, repo) => {
414
+ if (beat.actor !== "check") throw new Error(`step "${step}" is no check step; its actor is ${beat.actor}`);
415
+ repo.runScript(beat.content, { commands: [{
416
+ ...command,
417
+ run: /[\s\S]*/
418
+ }] });
419
+ });
420
+ };
421
+ Given("check step {string} passes", (world, step) => {
422
+ checkRuns(world, step, {});
423
+ });
424
+ Given("check step {string} fails with:", (world, step, output) => {
425
+ checkRuns(world, step, {
426
+ exit: 1,
427
+ output: withNewline(output)
428
+ });
429
+ });
176
430
  Given("judge step {string} answers:", (world, step, verdict) => {
177
- world.verdicts.set(step, String(verdict));
431
+ world.answer(step, String(verdict));
178
432
  });
179
433
  Given("judge step {string}, when its questions contain {string}, answers:", (world, step, text, verdict) => {
180
- world.conditionalVerdicts.set(step, [...world.conditionalVerdicts.get(step) ?? [], [text, String(verdict)]]);
434
+ world.answerWhen(step, text, String(verdict));
181
435
  });
182
436
  When("gtd starts workflow {string}", async (world, workflow) => {
183
437
  await world.repo().start(workflow);
@@ -197,87 +451,122 @@ When("gtd lands", async (world) => {
197
451
  When("the process is driven", async (world) => {
198
452
  await world.drive();
199
453
  });
200
- Then("opening door {string} is refused with {string}", async (world, door, message) => {
201
- await assert.rejects(world.repo().door(door), (error) => error.message.includes(message));
202
- });
203
- Then("starting workflow {string} with {string} is refused with {string}", async (world, workflow, list, message) => {
204
- await assert.rejects(world.repo().start(workflow, ...args(list)), (error) => error.message.includes(message));
205
- });
206
- Then("landing is refused with {string}", async (world, message) => {
207
- await assert.rejects(world.repo().land(), (error) => error.message.includes(message));
208
- });
209
- Then("driving the process is refused with {string}", async (world, message) => {
210
- await assert.rejects(world.drive(), (error) => error.message.includes(message));
211
- });
212
- Then("driving the process is refused without {string}", async (world, message) => {
213
- await assert.rejects(world.drive(), (error) => !error.message.includes(message));
214
- });
215
- Then("the process rests at {string}", async (world, step) => {
216
- assert.strictEqual((await world.repo().next()).state, step);
217
- });
218
- Then("the resting step's content contains {string}", async (world, text) => {
219
- const { content } = await world.repo().next();
220
- assert.ok(content.includes(text), content);
221
- });
222
- Then("the resting step's content contains the hash of the commit {string}", async (world, subject) => {
454
+ When("the process is driven leniently", async (world) => {
455
+ await world.drive({ strict: false });
456
+ });
457
+ Then("opening door {string} is refused with {string}", outcome(async (world, door, message) => {
458
+ await refusedWith(world.repo().door(door), message);
459
+ }));
460
+ Then("starting workflow {string} with {string} is refused with {string}", outcome(async (world, workflow, list, message) => {
461
+ await refusedWith(world.repo().start(workflow, ...args(list)), message);
462
+ }));
463
+ Then("landing is refused with {string}", outcome(async (world, message) => {
464
+ await refusedWith(world.repo().land(), message);
465
+ }));
466
+ Then("driving the process is refused with {string}", outcome(async (world, message) => {
467
+ await refusedWith(world.drive(), message);
468
+ }));
469
+ Then("driving the process is refused without {string}", outcome(async (world, message) => {
470
+ const got = await refusal(world.drive(), `without "${message}"`);
471
+ if (got.includes(message)) assert.fail(`expected a refusal without "${message}", but it was refused with:\n${got}`);
472
+ }));
473
+ Then("the process rests at {string}", outcome(async (world, step) => {
474
+ assertRestsAt(await world.repo().next(), step);
475
+ }));
476
+ Then("the process stalls at {string}", outcome(async (world, step) => {
477
+ const beat = await world.repo().next();
478
+ if (beat.kind !== "stalled" || beat.state !== step) assert.fail(`expected a stall at "${step}", but the process rests at "${beat.state}" (${beat.kind})`);
479
+ }));
480
+ /** A beat field by its dotted path (`model`, `access.write`), as text: a string as is, anything else as JSON. */
481
+ const beatField = async (world, path) => {
482
+ let value = await world.repo().next();
483
+ for (const key of path.split(".")) value = typeof value === "object" && value !== null ? value[key] : void 0;
484
+ return value;
485
+ };
486
+ const asText = (value) => typeof value === "string" ? value : JSON.stringify(value) ?? "undefined";
487
+ Then("the resting step's {word} is {string}", outcome(async (world, path, expected) => {
488
+ const value = asText(await beatField(world, path));
489
+ if (value !== expected) assert.fail(`the resting step's ${path} is not "${expected}"; it is:\n${value}`);
490
+ }));
491
+ Then("the resting step's {word} includes {string}", outcome(async (world, path, expected) => {
492
+ const value = await beatField(world, path);
493
+ if (!(Array.isArray(value) ? value.some((item) => asText(item) === expected) : typeof value === "string" && value.includes(expected))) assert.fail(`the resting step's ${path} does not include "${expected}"; it is:\n${asText(value)}`);
494
+ }));
495
+ Then("the resting step's content contains {string}", outcome(async (world, text) => {
496
+ assertContains("the resting step's content", text, (await world.repo().next()).content);
497
+ }));
498
+ Then("the resting step's content contains the hash of the commit {string}", outcome(async (world, subject) => {
223
499
  const repo = world.repo();
224
500
  const hash = repo.hashes()[repo.subjects().lastIndexOf(subject)];
225
501
  assert.ok(hash !== void 0, `no commit "${subject}"`);
226
- const { content } = await repo.next();
227
- assert.ok(content.includes(hash), content);
228
- });
229
- Then("the resting step's content does not contain {string}", async (world, text) => {
230
- const { content } = await world.repo().next();
231
- assert.ok(!content.includes(text), content);
232
- });
233
- Then("the last commit subject is {string}", (world, subject) => {
502
+ assertContains("the resting step's content", hash, (await repo.next()).content);
503
+ }));
504
+ Then("the resting step's content does not contain {string}", outcome(async (world, text) => {
505
+ assertLacks("the resting step's content", text, (await world.repo().next()).content);
506
+ }));
507
+ Then("the last commit subject is {string}", outcome((world, subject) => {
234
508
  assert.strictEqual(world.repo().subjects().at(-1), subject);
235
- });
236
- Then("the commit subjects end with:", (world, list) => {
509
+ }));
510
+ Then("the commit subjects end with:", outcome((world, list) => {
237
511
  const expected = list.split("\n").filter((line) => line.trim() !== "");
238
512
  assert.deepStrictEqual(world.repo().subjects().slice(-expected.length), expected);
239
- });
513
+ }));
514
+ Then("the last commit body contains {string}", outcome((world, text) => {
515
+ assertContains("the last commit's message", text, world.repo().messages().at(-1));
516
+ }));
517
+ Then("the commit {string} has a body containing {string}", outcome((world, subject, text) => {
518
+ const matching = world.repo().commits().filter((commit) => commit.subject === subject);
519
+ assert.ok(matching.length > 0, `no commit "${subject}"`);
520
+ for (const { message } of matching) assertContains(`the commit "${subject}"`, text, message);
521
+ }));
522
+ Then("no commit body contains {string}", outcome((world, text) => {
523
+ const hit = world.repo().messages().find((message) => message.includes(text));
524
+ if (hit !== void 0) assert.fail(`a commit's message contains "${text}":\n${hit}`);
525
+ }));
240
526
  const outputOf = async (world, list) => {
241
527
  const result = await world.repo().gtd(...args(list));
242
528
  assert.strictEqual(result.exitCode, 0, result.stderr);
243
529
  return result.stdout;
244
530
  };
245
- Then("the output of gtd {string} contains {string}", async (world, list, text) => {
246
- const out = await outputOf(world, list);
247
- assert.ok(out.includes(text), out);
248
- });
249
- Then("the output of gtd {string} does not contain {string}", async (world, list, text) => {
250
- const out = await outputOf(world, list);
251
- assert.ok(!out.includes(text), out);
252
- });
253
- Then("the output lines of gtd {string} start with:", async (world, list, prefixes) => {
531
+ Then("the output of gtd {string} contains {string}", outcome(async (world, list, text) => {
532
+ assertContains(`the output of gtd ${list}`, text, await outputOf(world, list));
533
+ }));
534
+ Then("the output of gtd {string} does not contain {string}", outcome(async (world, list, text) => {
535
+ assertLacks(`the output of gtd ${list}`, text, await outputOf(world, list));
536
+ }));
537
+ Then("the output lines of gtd {string} start with:", outcome(async (world, list, prefixes) => {
254
538
  const out = (await outputOf(world, list)).split("\n").filter((line) => line !== "");
255
539
  const expected = prefixes.split("\n").filter((line) => line.trim() !== "");
256
- assert.strictEqual(out.length, expected.length, out.join("\n"));
257
- out.forEach((line, i) => assert.ok(line.startsWith(expected[i]), out.join("\n")));
258
- });
259
- Then("{string} contains {string}", (world, path, text) => {
260
- assert.ok(world.repo().read(path)?.includes(text), `${path}: ${world.repo().read(path)}`);
261
- });
262
- Then("{string} contains:", (world, path, text) => {
263
- assert.ok(world.repo().read(path)?.includes(text), `${path}: ${world.repo().read(path)}`);
264
- });
265
- Then("{string} contains the short hash of the commit {string}", (world, path, subject) => {
540
+ const sides = `expected lines starting with:\n${expected.join("\n")}\ngot:\n${out.join("\n")}`;
541
+ assert.strictEqual(out.length, expected.length, sides);
542
+ out.forEach((line, i) => assert.ok(line.startsWith(expected[i]), sides));
543
+ }));
544
+ Then("{string} contains {string}", outcome((world, path, text) => {
545
+ assertContains(`"${path}"`, text, world.repo().read(path));
546
+ }));
547
+ Then("{string} contains:", outcome((world, path, text) => {
548
+ assertContains(`"${path}"`, text, world.repo().read(path));
549
+ }));
550
+ Then("{string} contains the short hash of the commit {string}", outcome((world, path, subject) => {
266
551
  const repo = world.repo();
267
552
  const hash = repo.hashes()[repo.subjects().lastIndexOf(subject)];
268
553
  assert.ok(hash !== void 0, `no commit "${subject}"`);
269
- assert.ok(repo.read(path)?.includes(hash.slice(0, 7)), `${path}: ${repo.read(path)}`);
270
- });
271
- Then("{string} contains {string} exactly once", (world, path, text) => {
554
+ assertContains(`"${path}"`, hash.slice(0, 7), repo.read(path));
555
+ }));
556
+ Then("{string} as committed at {string} contains {string}", outcome((world, path, ref, text) => {
557
+ assertContains(`"${path}" at ${ref}`, text, world.repo().show(ref, path));
558
+ }));
559
+ Then("{string} contains {string} exactly once", outcome((world, path, text) => {
272
560
  const content = world.repo().read(path) ?? "";
273
- assert.strictEqual(content.split(text).length - 1, 1, `${path}: ${content}`);
274
- });
275
- Then("{string} does not contain {string}", (world, path, text) => {
561
+ const times = content.split(text).length - 1;
562
+ assert.strictEqual(times, 1, `"${path}" contains "${text}" ${times} times; it holds:\n${content}`);
563
+ }));
564
+ Then("{string} does not contain {string}", outcome((world, path, text) => {
565
+ assertLacks(`"${path}"`, text, world.repo().read(path));
566
+ }));
567
+ Then("{string} does not exist", outcome((world, path) => {
276
568
  const content = world.repo().read(path);
277
- assert.ok(content !== void 0 && !content.includes(text), `${path}: ${content}`);
278
- });
279
- Then("{string} does not exist", (world, path) => {
280
- assert.strictEqual(world.repo().read(path), void 0);
281
- });
569
+ assert.strictEqual(content, void 0, `"${path}" exists; it holds:\n${content}`);
570
+ }));
282
571
  //#endregion
283
572
  export { TestkitWorld };
package/dist/testkit.mjs CHANGED
@@ -1,2 +1,3 @@
1
- import { n as testRepo, r as InMemRepo, t as runsItsScript } from "./chunks/testkit-CMBAAyRK.mjs";
2
- export { InMemRepo, runsItsScript, testRepo };
1
+ import { H as installContext } from "./chunks/flows-DENt0uWV.mjs";
2
+ import { a as verdictWhen, i as verdict, n as runsItsScript, o as whenPrompted, r as testRepo, t as inTurn } from "./chunks/testkit-BvecVjQ0.mjs";
3
+ export { inTurn, installContext, runsItsScript, testRepo, verdict, verdictWhen, whenPrompted };