@pmelab/gtd 59.0.1 → 60.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +48 -32
  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-BhTey1Do.mjs +30517 -0
  7. package/dist/chunks/{wire-BTUhRGXj.mjs → wire-DZ8R1RU9.mjs} +8 -1
  8. package/dist/flows.mjs +2 -676
  9. package/dist/gtd.bundle.mjs +11285 -19864
  10. package/dist/gtd.core.mjs +593 -0
  11. package/dist/steering.mjs +2 -2
  12. package/dist/testkit/gherkin.mjs +246 -37
  13. package/dist/testkit.mjs +3 -2
  14. package/dist/web/main.js +234 -232
  15. package/dist/wire.mjs +2 -2
  16. package/dist/workflow-kit.mjs +3 -3
  17. package/package.json +7 -7
  18. package/skills/authoring/SKILL.md +66 -60
  19. package/src/cli/Cli.test.ts +185 -184
  20. package/src/cli/Cli.ts +219 -224
  21. package/src/cli/History.ts +9 -4
  22. package/src/cli/Judges.ts +1 -1
  23. package/src/cli/Output.test.ts +35 -0
  24. package/src/cli/Output.ts +34 -3
  25. package/src/cli/Program.test.ts +78 -84
  26. package/src/cli/Program.ts +192 -128
  27. package/src/cli/ToolCommands.ts +2 -2
  28. package/src/edge/Edge.test.ts +72 -62
  29. package/src/edge/Edge.ts +6 -6
  30. package/src/flows/factory.test.ts +14 -0
  31. package/src/flows/factory.ts +22 -5
  32. package/src/flows/helpers.test.ts +35 -0
  33. package/src/flows/helpers.ts +19 -10
  34. package/src/flows/index.test.ts +66 -0
  35. package/src/flows/index.ts +109 -5
  36. package/src/flows/internal.ts +7 -0
  37. package/src/flows/runtime.test.ts +192 -1
  38. package/src/flows/runtime.ts +189 -55
  39. package/src/gherkin/git-control.feature +6 -6
  40. package/src/gherkin/index.test.ts +127 -0
  41. package/src/gherkin/index.ts +370 -51
  42. package/src/gherkin/messages.test.ts +38 -4
  43. package/src/gherkin/messages.ts +21 -4
  44. package/src/gherkin/more-steps.feature +170 -0
  45. package/src/gherkin/scenario-end.feature +101 -0
  46. package/src/gherkin/testkit.feature +83 -7
  47. package/src/install/Install.test.ts +7 -7
  48. package/src/install/Install.ts +8 -8
  49. package/src/install/Version.ts +9 -3
  50. package/src/install/index.ts +1 -1
  51. package/src/platform/CommandRunner.ts +13 -11
  52. package/src/platform/Commentary.ts +2 -2
  53. package/src/platform/Git.test.ts +4 -5
  54. package/src/platform/Git.ts +42 -38
  55. package/src/platform/Host.ts +1 -1
  56. package/src/platform/NodeServices.ts +11 -0
  57. package/src/platform/Workspace.test.ts +4 -5
  58. package/src/platform/Workspace.ts +3 -3
  59. package/src/platform/WorktreeState.test.ts +2 -3
  60. package/src/platform/WorktreeState.ts +1 -1
  61. package/src/platform/index.ts +2 -1
  62. package/src/replay/Replay.test.ts +13 -2
  63. package/src/replay/Replay.ts +19 -30
  64. package/src/replay/judgeDiffShape.test.ts +1 -1
  65. package/src/steering/Footnotes.test.ts +1 -1
  66. package/src/steering/SteeringFormats.test.ts +1 -1
  67. package/src/steering/freeform.test.ts +1 -1
  68. package/src/steering/index.test.ts +27 -1
  69. package/src/steering/index.ts +6 -104
  70. package/src/steering/internal.ts +11 -0
  71. package/src/steering/qa.test.ts +7 -2
  72. package/src/steering/reads.test.ts +42 -0
  73. package/src/steering/reads.ts +43 -0
  74. package/src/steering/registry.ts +93 -0
  75. package/src/steering/review.test.ts +2 -2
  76. package/src/step/planStart.test.ts +18 -16
  77. package/src/step/planStart.ts +12 -12
  78. package/src/testkit/TestRepo.test.ts +245 -7
  79. package/src/testkit/TestRepo.ts +271 -40
  80. package/src/testkit/imports.test.ts +58 -0
  81. package/src/testkit/imports.ts +102 -0
  82. package/src/testkit/index.test.ts +28 -0
  83. package/src/testkit/index.ts +14 -2
  84. package/src/twins/EmittedScriptRecognizer.ts +2 -6
  85. package/src/twins/EmittedScripts.test.ts +5 -3
  86. package/src/twins/GitTiers.ts +6 -7
  87. package/src/twins/InMemRepo.ts +1 -0
  88. package/src/twins/Layers.test.ts +1 -1
  89. package/src/twins/Layers.ts +10 -3
  90. package/src/twins/cliIo.ts +9 -4
  91. package/src/wire/Script.ts +11 -0
  92. package/src/wire/WireSchema.test.ts +3 -0
  93. package/src/wire/golden/1/script.json +4 -0
  94. package/src/wire/index.ts +1 -0
  95. package/src/workflow/SteeringFormats.test.ts +1 -1
  96. package/src/workflow/compile.ts +2 -2
  97. package/src/workflow/discovery.test.ts +27 -10
  98. package/src/workflow/discovery.ts +5 -6
  99. package/src/workflow/index.ts +1 -0
  100. package/src/workflow/load.test.ts +316 -119
  101. package/src/workflow/load.ts +478 -305
  102. package/src/workflow/pin.test.ts +159 -0
  103. package/src/workflow/pin.ts +72 -0
  104. package/src/workflow/vars.ts +3 -3
  105. package/src/workflow-kit/index.test.ts +25 -0
  106. package/src/workflow-kit/index.ts +37 -4
  107. package/src/workflow-kit/review.ts +2 -2
  108. package/dist/chunks/testkit-07Z_ENch.mjs +0 -32175
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,8 +1,8 @@
1
- import { n as testRepo, t as runsItsScript } from "../chunks/testkit-07Z_ENch.mjs";
1
+ import { n as runsItsScript, r as testRepo } from "../chunks/testkit-BhTey1Do.mjs";
2
2
  import { resolve } from "node:path";
3
- import assert from "node:assert";
4
3
  import { pathToFileURL } from "node:url";
5
- import { Given, QuickPickleWorld, Then, When, setWorldConstructor } from "quickpickle";
4
+ import { After, AfterAll, Given, QuickPickleWorld, Then, When, setWorldConstructor } from "quickpickle";
5
+ import assert from "node:assert";
6
6
  //#region src/gherkin/messages.ts
7
7
  const CONTENT_LINES = 5;
8
8
  const reason = (error) => error instanceof Error ? error.message : String(error);
@@ -21,6 +21,7 @@ const beatLines = async (repo) => {
21
21
  return [`unknown — gtd next failed: ${reason(error)}`];
22
22
  }
23
23
  };
24
+ const unreachedList = (steps) => steps.map(({ step, origin }) => `"${step}" (${origin})`).join(", ");
24
25
  /** Where the process rests, for a failed outcome to end with. Never throws: it describes a failure. */
25
26
  const restDescription = async (repo, neverUsed) => {
26
27
  let lines;
@@ -34,7 +35,7 @@ const restDescription = async (repo, neverUsed) => {
34
35
  } catch (error) {
35
36
  lines = [`unknown — ${reason(error)}`];
36
37
  }
37
- if (neverUsed.length > 0) lines.push(`actors never used: ${neverUsed.map((step) => `"${step}"`).join(", ")}`);
38
+ if (neverUsed.length > 0) lines.push(`actors never used: ${unreachedList(neverUsed)}`);
38
39
  return ["where the process rests:", ...lines.map((line) => ` ${line}`)].join("\n");
39
40
  };
40
41
  /** Both sides of a failed `contains`: the text looked for, and what was there. */
@@ -58,9 +59,14 @@ const assertContains = (where, text, content) => {
58
59
  const assertLacks = (where, text, content) => {
59
60
  if (content === void 0 || content.includes(text)) assert.fail(containsFailure(where, text, content, false));
60
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
+ };
61
67
  //#endregion
62
68
  //#region src/gherkin/index.ts
63
- const usedActors = /* @__PURE__ */ new WeakMap();
69
+ const BACKGROUND_ACTORS = "gtdBackgroundActors";
64
70
  var TestkitWorld = class extends QuickPickleWorld {
65
71
  constructor(context, info) {
66
72
  super(context, info);
@@ -68,11 +74,15 @@ var TestkitWorld = class extends QuickPickleWorld {
68
74
  gtdConfig = void 0;
69
75
  env = {};
70
76
  commands = [];
71
- /** Per step, what its scripted actor does, in the order the scenario declared it. */
77
+ modules = {};
72
78
  actions = /* @__PURE__ */ new Map();
73
79
  verdicts = /* @__PURE__ */ new Map();
74
- /** Per judge step, a verdict for each text its questions may contain; the first match answers. */
75
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();
76
86
  created = void 0;
77
87
  configure(config) {
78
88
  this.assertNotCreated("a gtd config");
@@ -92,6 +102,19 @@ var TestkitWorld = class extends QuickPickleWorld {
92
102
  this.assertNotCreated(`the environment variable ${name}`);
93
103
  this.env[name] = value;
94
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
+ }
95
118
  assertNotCreated(what) {
96
119
  if (this.created !== void 0) throw new Error(`${what} must be set before any step that touches the repository`);
97
120
  }
@@ -100,16 +123,70 @@ var TestkitWorld = class extends QuickPickleWorld {
100
123
  return this.created ??= testRepo({
101
124
  workflow: this.gtdConfig,
102
125
  env: this.env,
103
- commands: this.commands
126
+ commands: this.commands,
127
+ modules: this.modules
104
128
  });
105
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. */
106
136
  act(step, action) {
137
+ this.scripting(step);
107
138
  this.actions.set(step, [...this.actions.get(step) ?? [], action]);
108
139
  }
109
- 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() {
110
189
  const actors = {};
111
- const used = usedActors.get(this) ?? /* @__PURE__ */ new Set();
112
- usedActors.set(this, used);
113
190
  for (const [step, actions] of this.actions) actors[step] = async (beat, repo) => {
114
191
  for (const action of actions) await action(beat, repo);
115
192
  };
@@ -126,32 +203,88 @@ var TestkitWorld = class extends QuickPickleWorld {
126
203
  return hit[1];
127
204
  };
128
205
  }
129
- const tracked = {};
130
- for (const [step, actor] of Object.entries(actors)) tracked[step] = (beat, repo) => {
131
- used.add(step);
132
- return actor(beat, repo);
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
133
251
  };
134
- await this.repo().drive(tracked);
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));
135
255
  }
136
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
+ });
137
278
  setWorldConstructor(TestkitWorld);
138
279
  const withNewline = (content) => content.endsWith("\n") ? content : `${content}\n`;
139
- const args = (list) => list.split(" ").filter((arg) => arg !== "");
140
- const neverUsed = (world) => {
141
- const used = usedActors.get(world) ?? /* @__PURE__ */ new Set();
142
- return [.../* @__PURE__ */ new Set([
143
- ...world.actions.keys(),
144
- ...world.verdicts.keys(),
145
- ...world.conditionalVerdicts.keys()
146
- ])].filter((step) => !used.has(step));
147
- };
280
+ const args = (list) => [...list.matchAll(/(["'])(.*?)\1(?=\s|$)|\S+/g)].map(([word, quote, inner]) => quote === void 0 ? word : inner);
148
281
  /** An outcome step: a failure ends with where the process rests. */
149
282
  const outcome = (check) => async (world, ...rest) => {
150
283
  try {
151
284
  await check(world, ...rest);
152
285
  } catch (error) {
153
286
  const failure = error instanceof Error ? error : new Error(String(error));
154
- failure.message += `\n\n${await restDescription(() => world.repo(), neverUsed(world))}`;
287
+ failure.message += `\n\n${await restDescription(() => world.repo(), world.neverUsed())}`;
155
288
  throw failure;
156
289
  }
157
290
  };
@@ -164,9 +297,15 @@ Given("a gtd config from the module {string}", async (world, path) => {
164
297
  Given("the gtd config ends with:", (world, source) => {
165
298
  world.extendConfig(withNewline(source));
166
299
  });
167
- Given("an environment variable {string} set to {string}", (world, name, value) => {
300
+ Given("the environment variable {string} is {string}", (world, name, value) => {
168
301
  world.setEnv(name, value);
169
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
+ });
170
309
  Given("the command {string} exits {int}", (world, run, exit) => {
171
310
  world.command({
172
311
  run,
@@ -180,9 +319,25 @@ Given("the command {string} exits {int} with:", (world, run, exit, output) => {
180
319
  output: withNewline(output)
181
320
  });
182
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
+ });
183
335
  Given("a branch {string}", (world, name) => {
184
336
  world.repo().branch(name);
185
337
  });
338
+ Given("a branch {string} at {string}", (world, name, ref) => {
339
+ world.repo().branch(name, ref);
340
+ });
186
341
  Given("{string} is checked out", (world, name) => {
187
342
  world.repo().checkout(name);
188
343
  });
@@ -200,7 +355,7 @@ Given("the text {string} in {string} is replaced with {string}", (world, text, p
200
355
  assert.ok(content.includes(text), `${path}: ${content}`);
201
356
  world.repo().write(path, content.replace(text, () => replacement));
202
357
  });
203
- Given("{string} gets appended:", (world, path, text) => {
358
+ Given("text is appended to {string}:", (world, path, text) => {
204
359
  const content = world.repo().read(path) ?? "";
205
360
  world.repo().write(path, `${content.trimEnd()}\n\n${text}\n`);
206
361
  });
@@ -214,10 +369,10 @@ Given("step {string} changes nothing", (world, step) => {
214
369
  world.act(step, () => void 0);
215
370
  });
216
371
  Given("step {string} from now on changes nothing", (world, step) => {
217
- world.actions.set(step, [() => void 0]);
372
+ world.rescript(step, () => void 0);
218
373
  });
219
374
  Given("step {string} is no longer scripted", (world, step) => {
220
- world.actions.delete(step);
375
+ world.unscript(step);
221
376
  });
222
377
  Given("step {string} writes {string} with:", (world, step, path, content) => {
223
378
  world.act(step, (_, repo) => repo.write(path, withNewline(content)));
@@ -243,11 +398,9 @@ Given("step {string} deletes {string}", (world, step, path) => {
243
398
  world.act(step, (_, repo) => repo.remove(path));
244
399
  });
245
400
  Given("step {string} is prompted with {string}", (world, step, text) => {
246
- world.act(step, (beat) => {
247
- if (!beat.content.includes(text)) throw new Error(`step "${step}" was not prompted with "${text}":\n${beat.content}`);
248
- });
401
+ world.expectPrompt(step, text);
249
402
  });
250
- Given("step {string} rewrites {string} with:", (world, step, path, content) => {
403
+ Given("step {string} overwrites {string}, if it exists, with:", (world, step, path, content) => {
251
404
  world.act(step, (_, repo) => {
252
405
  if (repo.read(path) !== void 0) repo.write(path, withNewline(content));
253
406
  });
@@ -255,11 +408,30 @@ Given("step {string} rewrites {string} with:", (world, step, path, content) => {
255
408
  Given("step {string} runs its script", (world, step) => {
256
409
  world.act(step, runsItsScript);
257
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
+ });
258
430
  Given("judge step {string} answers:", (world, step, verdict) => {
259
- world.verdicts.set(step, String(verdict));
431
+ world.answer(step, String(verdict));
260
432
  });
261
433
  Given("judge step {string}, when its questions contain {string}, answers:", (world, step, text, verdict) => {
262
- world.conditionalVerdicts.set(step, [...world.conditionalVerdicts.get(step) ?? [], [text, String(verdict)]]);
434
+ world.answerWhen(step, text, String(verdict));
263
435
  });
264
436
  When("gtd starts workflow {string}", async (world, workflow) => {
265
437
  await world.repo().start(workflow);
@@ -279,6 +451,9 @@ When("gtd lands", async (world) => {
279
451
  When("the process is driven", async (world) => {
280
452
  await world.drive();
281
453
  });
454
+ When("the process is driven leniently", async (world) => {
455
+ await world.drive({ strict: false });
456
+ });
282
457
  Then("opening door {string} is refused with {string}", outcome(async (world, door, message) => {
283
458
  await refusedWith(world.repo().door(door), message);
284
459
  }));
@@ -296,7 +471,26 @@ Then("driving the process is refused without {string}", outcome(async (world, me
296
471
  if (got.includes(message)) assert.fail(`expected a refusal without "${message}", but it was refused with:\n${got}`);
297
472
  }));
298
473
  Then("the process rests at {string}", outcome(async (world, step) => {
299
- assert.strictEqual((await world.repo().next()).state, 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)}`);
300
494
  }));
301
495
  Then("the resting step's content contains {string}", outcome(async (world, text) => {
302
496
  assertContains("the resting step's content", text, (await world.repo().next()).content);
@@ -317,6 +511,18 @@ Then("the commit subjects end with:", outcome((world, list) => {
317
511
  const expected = list.split("\n").filter((line) => line.trim() !== "");
318
512
  assert.deepStrictEqual(world.repo().subjects().slice(-expected.length), expected);
319
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
+ }));
320
526
  const outputOf = async (world, list) => {
321
527
  const result = await world.repo().gtd(...args(list));
322
528
  assert.strictEqual(result.exitCode, 0, result.stderr);
@@ -347,6 +553,9 @@ Then("{string} contains the short hash of the commit {string}", outcome((world,
347
553
  assert.ok(hash !== void 0, `no commit "${subject}"`);
348
554
  assertContains(`"${path}"`, hash.slice(0, 7), repo.read(path));
349
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
+ }));
350
559
  Then("{string} contains {string} exactly once", outcome((world, path, text) => {
351
560
  const content = world.repo().read(path) ?? "";
352
561
  const times = content.split(text).length - 1;
package/dist/testkit.mjs CHANGED
@@ -1,2 +1,3 @@
1
- import { n as testRepo, r as InMemRepo, t as runsItsScript } from "./chunks/testkit-07Z_ENch.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-BhTey1Do.mjs";
3
+ export { inTurn, installContext, runsItsScript, testRepo, verdict, verdictWhen, whenPrompted };