@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
@@ -1,6 +1,8 @@
1
1
  // Generic quickpickle steps over `testRepo`: they name no workflow, so any
2
2
  // workflow's `.feature` can use them. A workflow's own steps live beside it.
3
3
  import {
4
+ After,
5
+ AfterAll,
4
6
  Given,
5
7
  QuickPickleWorld,
6
8
  Then,
@@ -16,19 +18,38 @@ import { pathToFileURL } from "node:url"
16
18
  import {
17
19
  runsItsScript,
18
20
  testRepo,
19
- type Actor,
20
21
  type CannedCommand,
22
+ type DriveOptions,
23
+ type ScriptedActor,
21
24
  type TestRepo,
22
25
  type WorkflowModule,
23
26
  } from "../testkit/index.js"
24
- import { assertContains, assertLacks, refusal, refusedWith, restDescription } from "./messages.js"
25
-
26
- // Kept off the class so the exported world's type stays as it is.
27
- const usedActors = new WeakMap<TestkitWorld, Set<string>>()
27
+ import {
28
+ assertContains,
29
+ assertLacks,
30
+ assertRestsAt,
31
+ refusal,
32
+ refusedWith,
33
+ restDescription,
34
+ unreachedList,
35
+ type Unreached,
36
+ } from "./messages.js"
37
+
38
+ // Kept on quickpickle's per-feature `common`: which steps a Background scripted,
39
+ // and whether any scenario reached each.
40
+ const BACKGROUND_ACTORS = "gtdBackgroundActors"
41
+
42
+ interface BackgroundActors {
43
+ readonly reached: Map<string, boolean>
44
+ // The feature file's vitest task, to tell whether every scenario ran.
45
+ file: { readonly tasks?: readonly unknown[] } | undefined
46
+ }
28
47
 
29
48
  export class TestkitWorld extends QuickPickleWorld {
30
49
  // Re-declared: under `nodenext` a subclass loses the base's members from TS's view.
31
50
  declare info: QuickPickleWorldInterface["info"]
51
+ declare common: QuickPickleWorldInterface["common"]
52
+ declare context: TestContext
32
53
 
33
54
  constructor(context: TestContext, info: InfoConstructor) {
34
55
  super(context, info)
@@ -37,11 +58,16 @@ export class TestkitWorld extends QuickPickleWorld {
37
58
  private gtdConfig: WorkflowModule | undefined = undefined
38
59
  private env: Record<string, string> = {}
39
60
  private commands: CannedCommand[] = []
40
- /** Per step, what its scripted actor does, in the order the scenario declared it. */
41
- actions = new Map<string, Actor[]>()
42
- verdicts = new Map<string, string>()
43
- /** Per judge step, a verdict for each text its questions may contain; the first match answers. */
44
- conditionalVerdicts = new Map<string, [text: string, verdict: string][]>()
61
+ private modules: Record<string, Readonly<Record<string, unknown>>> = {}
62
+ private actions = new Map<string, ScriptedActor[]>()
63
+ private verdicts = new Map<string, string>()
64
+ private conditionalVerdicts = new Map<string, [text: string, verdict: string][]>()
65
+ private prompts = new Map<string, string[]>()
66
+ private used = new Set<string>()
67
+ // A step a scenario step scripts is the scenario's, even if a Background scripted it too.
68
+ private origins = new Map<string, Unreached["origin"]>()
69
+ private backgroundScripted = new Set<string>()
70
+ private excused = new Set<string>()
45
71
  private created: TestRepo | undefined = undefined
46
72
 
47
73
  // The repository is created on first use and keeps the config and
@@ -70,6 +96,21 @@ export class TestkitWorld extends QuickPickleWorld {
70
96
  this.env[name] = value
71
97
  }
72
98
 
99
+ /** Unlike `setEnv`, also once the repository exists: gtd sees it from its next call on. */
100
+ changeEnv(name: string, value: string): void {
101
+ if (this.created === undefined) this.env[name] = value
102
+ else this.created.setEnv(name, value)
103
+ }
104
+
105
+ /**
106
+ * Lets a config given as source import `module` as `specifier`. A support
107
+ * file may call it for every scenario, e.g. from a `Before` hook.
108
+ */
109
+ importable(specifier: string, module: Readonly<Record<string, unknown>>): void {
110
+ this.assertNotCreated(`the module ${specifier}`)
111
+ this.modules[specifier] = module
112
+ }
113
+
73
114
  private assertNotCreated(what: string): void {
74
115
  if (this.created !== undefined) {
75
116
  throw new Error(`${what} must be set before any step that touches the repository`)
@@ -83,17 +124,87 @@ export class TestkitWorld extends QuickPickleWorld {
83
124
  workflow: this.gtdConfig,
84
125
  env: this.env,
85
126
  commands: this.commands,
127
+ modules: this.modules,
86
128
  }))
87
129
  }
88
130
 
89
- act(step: string, action: Actor): void {
131
+ // quickpickle numbers a (Rule) Background's steps negatively.
132
+ private scripting(step: string, replace = false): void {
133
+ const fromBackground = (this.info.stepIdx ?? 0) < 0
134
+ if (fromBackground) this.backgroundScripted.add(step)
135
+ if (!fromBackground || replace || !this.origins.has(step))
136
+ this.origins.set(step, fromBackground ? "Background" : "scenario")
137
+ }
138
+
139
+ /** Adds `action` to what `step`'s actor does at every visit. */
140
+ act(step: string, action: ScriptedActor): void {
141
+ this.scripting(step)
90
142
  this.actions.set(step, [...(this.actions.get(step) ?? []), action])
91
143
  }
92
144
 
93
- async drive(): Promise<void> {
94
- const actors: Record<string, Actor> = {}
95
- const used = usedActors.get(this) ?? new Set<string>()
96
- usedActors.set(this, used)
145
+ /** Drops every action and prompt queued for `step`, leaving `action` alone. */
146
+ rescript(step: string, action: ScriptedActor): void {
147
+ this.scripting(step, true)
148
+ this.actions.set(step, [action])
149
+ this.prompts.delete(step)
150
+ }
151
+
152
+ /** Drops every action and prompt queued for `step`, so a drive rests there. */
153
+ unscript(step: string): void {
154
+ this.actions.delete(step)
155
+ this.prompts.delete(step)
156
+ }
157
+
158
+ /**
159
+ * `step`'s beat content must contain `text`. A scripted step is checked at
160
+ * every visit, before its actor acts; a step with nothing else scripted is
161
+ * not scripted at all, so a drive rests there and checks it at the rest.
162
+ */
163
+ expectPrompt(step: string, text: string): void {
164
+ this.scripting(step)
165
+ this.prompts.set(step, [...(this.prompts.get(step) ?? []), text])
166
+ }
167
+
168
+ /** Judge step `step` answers `verdict`, a JSON array. */
169
+ answer(step: string, verdict: string): void {
170
+ this.scripting(step)
171
+ this.verdicts.set(step, verdict)
172
+ }
173
+
174
+ /** Judge step `step` answers `verdict` when its questions contain `text`; the first match answers. */
175
+ answerWhen(step: string, text: string, verdict: string): void {
176
+ this.scripting(step)
177
+ this.conditionalVerdicts.set(step, [
178
+ ...(this.conditionalVerdicts.get(step) ?? []),
179
+ [text, verdict],
180
+ ])
181
+ }
182
+
183
+ private scripted(): Set<string> {
184
+ return new Set([
185
+ ...this.actions.keys(),
186
+ ...this.verdicts.keys(),
187
+ ...this.conditionalVerdicts.keys(),
188
+ ...this.prompts.keys(),
189
+ ])
190
+ }
191
+
192
+ /** The steps the scenario scripted, or expects a prompt at, that no drive has reached. */
193
+ neverUsed(): Unreached[] {
194
+ return [...this.scripted()]
195
+ .filter((step) => !this.used.has(step))
196
+ .map((step) => ({ step, origin: this.origins.get(step) ?? "scenario" }))
197
+ }
198
+
199
+ private checkPrompts(step: string, content: string): void {
200
+ for (const text of this.prompts.get(step) ?? []) {
201
+ if (!content.includes(text))
202
+ throw new Error(`step "${step}" was not prompted with "${text}":\n${content}`)
203
+ }
204
+ }
205
+
206
+ private actors(): Record<string, ScriptedActor> {
207
+ const actors: Record<string, ScriptedActor> = {}
97
208
  for (const [step, actions] of this.actions) {
98
209
  actors[step] = async (beat, repo) => {
99
210
  for (const action of actions) await action(beat, repo)
@@ -121,32 +232,106 @@ export class TestkitWorld extends QuickPickleWorld {
121
232
  return hit[1]
122
233
  }
123
234
  }
124
- const tracked: Record<string, Actor> = {}
125
- for (const [step, actor] of Object.entries(actors)) {
126
- tracked[step] = (beat, repo) => {
127
- used.add(step)
128
- return actor(beat, repo)
235
+ return actors
236
+ }
237
+
238
+ /**
239
+ * `strict: false` lets what a strict drive refuses pass. Every drive leaves
240
+ * scripted steps it never reached to `assertReached`: an earlier step may
241
+ * script one for a later drive.
242
+ */
243
+ async drive(options: DriveOptions = {}): Promise<void> {
244
+ try {
245
+ const actors = this.actors()
246
+ const tracked: Record<string, ScriptedActor> = {}
247
+ for (const [step, actor] of Object.entries(actors)) {
248
+ tracked[step] = (beat, repo) => {
249
+ this.used.add(step)
250
+ this.checkPrompts(step, beat.content)
251
+ return actor(beat, repo)
252
+ }
253
+ }
254
+ const rest = await this.repo().drive(tracked, { ...options, unreachedActors: "pass" })
255
+ if (rest.kind !== "stalled" && this.prompts.has(rest.state) && !(rest.state in actors)) {
256
+ this.used.add(rest.state)
257
+ this.checkPrompts(rest.state, rest.content)
129
258
  }
259
+ } catch (error) {
260
+ // Where a refused drive would have gone is unknown, so every step it was handed is excused.
261
+ for (const step of this.scripted()) this.excused.add(step)
262
+ throw error
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Fails on a step a scenario step scripted, or expects a prompt at, that no
268
+ * drive reached. Run at scenario end. A Background's steps are left to the
269
+ * feature's end, and the steps a refused drive was handed are excused.
270
+ */
271
+ async assertReached(): Promise<void> {
272
+ const never = this.neverUsed()
273
+ const dead = never.filter(
274
+ ({ step, origin }) => origin === "scenario" && !this.excused.has(step),
275
+ )
276
+ if (dead.length === 0) return
277
+ throw new Error(
278
+ `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)}`,
279
+ )
280
+ }
281
+
282
+ /** Records, for the feature's end, which Background-scripted steps this scenario reached. */
283
+ recordBackground(): void {
284
+ const record = (this.common[BACKGROUND_ACTORS] ??= {
285
+ reached: new Map(),
286
+ file: undefined,
287
+ }) as BackgroundActors
288
+ record.file ??= (this.context as Partial<TestContext> | undefined)?.task?.file
289
+ // A failed scenario stopped early, so it cannot tell a dead actor from one it did not get to.
290
+ const failed = this.info.errors.length > 0
291
+ for (const step of this.backgroundScripted) {
292
+ record.reached.set(step, record.reached.get(step) === true || failed || this.used.has(step))
130
293
  }
131
- await this.repo().drive(tracked)
132
294
  }
133
295
  }
134
296
 
297
+ After(async (world: QuickPickleWorldInterface) => {
298
+ if (!(world instanceof TestkitWorld)) return
299
+ world.recordBackground()
300
+ // Only for a scenario whose steps all passed: a failed one already reports.
301
+ if (world.info.errors.length === 0) await world.assertReached()
302
+ })
303
+
304
+ // vitest marks a test it filtered out, or skipped, as not run.
305
+ const allRan = (tasks: readonly unknown[]): boolean =>
306
+ tasks.every((task) => {
307
+ const t = task as { mode?: string; result?: { state?: string }; tasks?: readonly unknown[] }
308
+ if (t.tasks !== undefined) return allRan(t.tasks)
309
+ return t.mode === "run" && t.result?.state !== "skip"
310
+ })
311
+
312
+ // Per feature: a Background step no scenario reached scripts nothing. Left out
313
+ // when a scenario did not run, since it may be the one that reaches it.
314
+ AfterAll((common: QuickPickleWorldInterface["common"]) => {
315
+ const record = common[BACKGROUND_ACTORS] as BackgroundActors | undefined
316
+ if (record === undefined) return
317
+ if (record.file?.tasks !== undefined && !allRan(record.file.tasks)) return
318
+ const dead = [...record.reached].filter(([, reached]) => !reached)
319
+ if (dead.length === 0) return
320
+ throw new Error(
321
+ `${common.info.feature}: no scenario reached the steps scripted for ${unreachedList(dead.map(([step]) => ({ step, origin: "Background" })))} — script a step in the Background only when some scenario reaches it`,
322
+ )
323
+ })
324
+
135
325
  setWorldConstructor(TestkitWorld)
136
326
 
137
327
  const withNewline = (content: string): string => (content.endsWith("\n") ? content : `${content}\n`)
138
328
 
139
- const args = (list: string): string[] => list.split(" ").filter((arg) => arg !== "")
140
-
141
- const neverUsed = (world: TestkitWorld): string[] => {
142
- const used = usedActors.get(world) ?? new Set<string>()
143
- const scripted = new Set([
144
- ...world.actions.keys(),
145
- ...world.verdicts.keys(),
146
- ...world.conditionalVerdicts.keys(),
147
- ])
148
- return [...scripted].filter((step) => !used.has(step))
149
- }
329
+ // Space-separated; a word that opens with a quote runs to its closing quote,
330
+ // spaces included, so `--var 'title=a b'` is two arguments.
331
+ const args = (list: string): string[] =>
332
+ [...list.matchAll(/(["'])(.*?)\1(?=\s|$)|\S+/g)].map(([word, quote, inner]) =>
333
+ quote === undefined ? word : inner!,
334
+ )
150
335
 
151
336
  /** An outcome step: a failure ends with where the process rests. */
152
337
  const outcome =
@@ -156,7 +341,7 @@ const outcome =
156
341
  await check(world, ...rest)
157
342
  } catch (error) {
158
343
  const failure = error instanceof Error ? error : new Error(String(error))
159
- failure.message += `\n\n${await restDescription(() => world.repo(), neverUsed(world))}`
344
+ failure.message += `\n\n${await restDescription(() => world.repo(), world.neverUsed())}`
160
345
  throw failure
161
346
  }
162
347
  }
@@ -177,12 +362,27 @@ Given("the gtd config ends with:", (world: TestkitWorld, source: string) => {
177
362
  })
178
363
 
179
364
  Given(
180
- "an environment variable {string} set to {string}",
365
+ "the environment variable {string} is {string}",
181
366
  (world: TestkitWorld, name: string, value: string) => {
182
367
  world.setEnv(name, value)
183
368
  },
184
369
  )
185
370
 
371
+ Given(
372
+ "from now on the environment variable {string} is {string}",
373
+ (world: TestkitWorld, name: string, value: string) => {
374
+ world.changeEnv(name, value)
375
+ },
376
+ )
377
+
378
+ // Imported by the test runner, like `a gtd config from the module`.
379
+ Given(
380
+ "the module {string} is importable as {string}",
381
+ async (world: TestkitWorld, path: string, specifier: string) => {
382
+ world.importable(specifier, { ...(await import(pathToFileURL(resolve(path)).href)) })
383
+ },
384
+ )
385
+
186
386
  Given("the command {string} exits {int}", (world: TestkitWorld, run: string, exit: number) => {
187
387
  world.command({ run, exit })
188
388
  })
@@ -194,10 +394,28 @@ Given(
194
394
  },
195
395
  )
196
396
 
397
+ Given(
398
+ "the command matching {string} exits {int}",
399
+ (world: TestkitWorld, pattern: string, exit: number) => {
400
+ world.command({ run: new RegExp(pattern), exit })
401
+ },
402
+ )
403
+
404
+ Given(
405
+ "the command matching {string} exits {int} with:",
406
+ (world: TestkitWorld, pattern: string, exit: number, output: string) => {
407
+ world.command({ run: new RegExp(pattern), exit, output: withNewline(output) })
408
+ },
409
+ )
410
+
197
411
  Given("a branch {string}", (world: TestkitWorld, name: string) => {
198
412
  world.repo().branch(name)
199
413
  })
200
414
 
415
+ Given("a branch {string} at {string}", (world: TestkitWorld, name: string, ref: string) => {
416
+ world.repo().branch(name, ref)
417
+ })
418
+
201
419
  Given("{string} is checked out", (world: TestkitWorld, name: string) => {
202
420
  world.repo().checkout(name)
203
421
  })
@@ -227,7 +445,7 @@ Given(
227
445
  },
228
446
  )
229
447
 
230
- Given("{string} gets appended:", (world: TestkitWorld, path: string, text: string) => {
448
+ Given("text is appended to {string}:", (world: TestkitWorld, path: string, text: string) => {
231
449
  const content = world.repo().read(path) ?? ""
232
450
  world.repo().write(path, `${content.trimEnd()}\n\n${text}\n`)
233
451
  })
@@ -246,12 +464,12 @@ Given("step {string} changes nothing", (world: TestkitWorld, step: string) => {
246
464
 
247
465
  // Unlike `changes nothing`, this drops every action already queued for the step.
248
466
  Given("step {string} from now on changes nothing", (world: TestkitWorld, step: string) => {
249
- world.actions.set(step, [() => undefined])
467
+ world.rescript(step, () => undefined)
250
468
  })
251
469
 
252
470
  // Lets a later visit rest at a step an earlier one had scripted.
253
471
  Given("step {string} is no longer scripted", (world: TestkitWorld, step: string) => {
254
- world.actions.delete(step)
472
+ world.unscript(step)
255
473
  })
256
474
 
257
475
  Given(
@@ -299,23 +517,19 @@ Given("step {string} deletes {string}", (world: TestkitWorld, step: string, path
299
517
  world.act(step, (_, repo) => repo.remove(path))
300
518
  })
301
519
 
302
- // A standing assertion like any queued action: it applies on every later visit
303
- // to the step until `from now on` clears the list.
520
+ // A standing assertion: it applies on every later visit to the step until
521
+ // `from now on` or `no longer scripted` clears it.
304
522
  Given(
305
523
  "step {string} is prompted with {string}",
306
524
  (world: TestkitWorld, step: string, text: string) => {
307
- world.act(step, (beat) => {
308
- if (!beat.content.includes(text)) {
309
- throw new Error(`step "${step}" was not prompted with "${text}":\n${beat.content}`)
310
- }
311
- })
525
+ world.expectPrompt(step, text)
312
526
  },
313
527
  )
314
528
 
315
529
  // A closing step deletes the file before its collecting turn runs, so this edit
316
530
  // leaves a closed turn changing nothing while still answering an in-gate one.
317
531
  Given(
318
- "step {string} rewrites {string} with:",
532
+ "step {string} overwrites {string}, if it exists, with:",
319
533
  (world: TestkitWorld, step: string, path: string, content: string) => {
320
534
  world.act(step, (_, repo) => {
321
535
  if (repo.read(path) !== undefined) repo.write(path, withNewline(content))
@@ -327,17 +541,31 @@ Given("step {string} runs its script", (world: TestkitWorld, step: string) => {
327
541
  world.act(step, runsItsScript)
328
542
  })
329
543
 
544
+ /** A check step's script, every command it runs answering as `command` does. */
545
+ const checkRuns = (world: TestkitWorld, step: string, command: Omit<CannedCommand, "run">) => {
546
+ world.act(step, (beat, repo) => {
547
+ if (beat.actor !== "check")
548
+ throw new Error(`step "${step}" is no check step; its actor is ${beat.actor}`)
549
+ repo.runScript(beat.content, { commands: [{ ...command, run: /[\s\S]*/ }] })
550
+ })
551
+ }
552
+
553
+ Given("check step {string} passes", (world: TestkitWorld, step: string) => {
554
+ checkRuns(world, step, {})
555
+ })
556
+
557
+ Given("check step {string} fails with:", (world: TestkitWorld, step: string, output: string) => {
558
+ checkRuns(world, step, { exit: 1, output: withNewline(output) })
559
+ })
560
+
330
561
  Given("judge step {string} answers:", (world: TestkitWorld, step: string, verdict: string) => {
331
- world.verdicts.set(step, String(verdict))
562
+ world.answer(step, String(verdict))
332
563
  })
333
564
 
334
565
  Given(
335
566
  "judge step {string}, when its questions contain {string}, answers:",
336
567
  (world: TestkitWorld, step: string, text: string, verdict: string) => {
337
- world.conditionalVerdicts.set(step, [
338
- ...(world.conditionalVerdicts.get(step) ?? []),
339
- [text, String(verdict)],
340
- ])
568
+ world.answerWhen(step, text, String(verdict))
341
569
  },
342
570
  )
343
571
 
@@ -371,6 +599,10 @@ When("the process is driven", async (world: TestkitWorld) => {
371
599
  await world.drive()
372
600
  })
373
601
 
602
+ When("the process is driven leniently", async (world: TestkitWorld) => {
603
+ await world.drive({ strict: false })
604
+ })
605
+
374
606
  Then(
375
607
  "opening door {string} is refused with {string}",
376
608
  outcome(async (world, door: string, message: string) => {
@@ -411,7 +643,56 @@ Then(
411
643
  Then(
412
644
  "the process rests at {string}",
413
645
  outcome(async (world, step: string) => {
414
- assert.strictEqual((await world.repo().next()).state, step)
646
+ assertRestsAt(await world.repo().next(), step)
647
+ }),
648
+ )
649
+
650
+ Then(
651
+ "the process stalls at {string}",
652
+ outcome(async (world, step: string) => {
653
+ const beat = await world.repo().next()
654
+ if (beat.kind !== "stalled" || beat.state !== step)
655
+ assert.fail(
656
+ `expected a stall at "${step}", but the process rests at "${beat.state}" (${beat.kind})`,
657
+ )
658
+ }),
659
+ )
660
+
661
+ /** A beat field by its dotted path (`model`, `access.write`), as text: a string as is, anything else as JSON. */
662
+ const beatField = async (world: TestkitWorld, path: string): Promise<unknown> => {
663
+ let value: unknown = await world.repo().next()
664
+ for (const key of path.split(".")) {
665
+ value =
666
+ typeof value === "object" && value !== null
667
+ ? (value as Record<string, unknown>)[key]
668
+ : undefined
669
+ }
670
+ return value
671
+ }
672
+
673
+ const asText = (value: unknown): string =>
674
+ typeof value === "string" ? value : (JSON.stringify(value) ?? "undefined")
675
+
676
+ Then(
677
+ "the resting step's {word} is {string}",
678
+ outcome(async (world, path: string, expected: string) => {
679
+ const value = asText(await beatField(world, path))
680
+ if (value !== expected)
681
+ assert.fail(`the resting step's ${path} is not "${expected}"; it is:\n${value}`)
682
+ }),
683
+ )
684
+
685
+ Then(
686
+ "the resting step's {word} includes {string}",
687
+ outcome(async (world, path: string, expected: string) => {
688
+ const value = await beatField(world, path)
689
+ const found = Array.isArray(value)
690
+ ? value.some((item) => asText(item) === expected)
691
+ : typeof value === "string" && value.includes(expected)
692
+ if (!found)
693
+ assert.fail(
694
+ `the resting step's ${path} does not include "${expected}"; it is:\n${asText(value)}`,
695
+ )
415
696
  }),
416
697
  )
417
698
 
@@ -454,6 +735,37 @@ Then(
454
735
  }),
455
736
  )
456
737
 
738
+ Then(
739
+ "the last commit body contains {string}",
740
+ outcome((world, text: string) => {
741
+ assertContains("the last commit's message", text, world.repo().messages().at(-1))
742
+ }),
743
+ )
744
+
745
+ // Every commit with the subject, so a repeated step is not judged by its first match.
746
+ Then(
747
+ "the commit {string} has a body containing {string}",
748
+ outcome((world, subject: string, text: string) => {
749
+ const matching = world
750
+ .repo()
751
+ .commits()
752
+ .filter((commit) => commit.subject === subject)
753
+ assert.ok(matching.length > 0, `no commit "${subject}"`)
754
+ for (const { message } of matching) assertContains(`the commit "${subject}"`, text, message)
755
+ }),
756
+ )
757
+
758
+ Then(
759
+ "no commit body contains {string}",
760
+ outcome((world, text: string) => {
761
+ const hit = world
762
+ .repo()
763
+ .messages()
764
+ .find((message) => message.includes(text))
765
+ if (hit !== undefined) assert.fail(`a commit's message contains "${text}":\n${hit}`)
766
+ }),
767
+ )
768
+
457
769
  const outputOf = async (world: TestkitWorld, list: string): Promise<string> => {
458
770
  const result = await world.repo().gtd(...args(list))
459
771
  assert.strictEqual(result.exitCode, 0, result.stderr)
@@ -509,6 +821,13 @@ Then(
509
821
  }),
510
822
  )
511
823
 
824
+ Then(
825
+ "{string} as committed at {string} contains {string}",
826
+ outcome((world, path: string, ref: string, text: string) => {
827
+ assertContains(`"${path}" at ${ref}`, text, world.repo().show(ref, path))
828
+ }),
829
+ )
830
+
512
831
  Then(
513
832
  "{string} contains {string} exactly once",
514
833
  outcome((world, path: string, text: string) => {
@@ -1,7 +1,13 @@
1
1
  import { describe, expect, it } from "vitest"
2
- import { agent, human } from "../flows/index.js"
2
+ import { agent, human, workflow } from "../flows/index.js"
3
3
  import { testRepo } from "../testkit/index.js"
4
- import { assertContains, assertLacks, refusedWith, restDescription } from "./messages.js"
4
+ import {
5
+ assertContains,
6
+ assertLacks,
7
+ assertRestsAt,
8
+ refusedWith,
9
+ restDescription,
10
+ } from "./messages.js"
5
11
 
6
12
  const flow = async (): Promise<void> => {
7
13
  await agent("plan", "Write PLAN.md.\nKeep it short.\n3\n4\n5\n6\n7")
@@ -22,7 +28,15 @@ describe("restDescription", () => {
22
28
  const repo = testRepo({ workflow: { default: flow } })
23
29
  repo.write("go", "\n")
24
30
  await repo.drive({ plan: (_, r) => r.write("PLAN.md", "p\n") })
25
- expect(await restDescription(() => repo, ["repair", "ready"])).toBe(
31
+ expect(
32
+ await restDescription(
33
+ () => repo,
34
+ [
35
+ { step: "repair", origin: "scenario" },
36
+ { step: "ready", origin: "Background" },
37
+ ],
38
+ ),
39
+ ).toBe(
26
40
  [
27
41
  "where the process rests:",
28
42
  ' step "sign-off" (message, human)',
@@ -31,7 +45,7 @@ describe("restDescription", () => {
31
45
  " last commits:",
32
46
  " chore: initial commit",
33
47
  " gtd(agent): plan → sign-off",
34
- ' actors never used: "repair", "ready"',
48
+ ' actors never used: "repair" (scenario), "ready" (Background)',
35
49
  ].join("\n"),
36
50
  )
37
51
  })
@@ -88,3 +102,23 @@ describe("contains", () => {
88
102
  expect(await thrown(() => assertLacks('"a.md"', "x", undefined))).toBe('"a.md" does not exist')
89
103
  })
90
104
  })
105
+
106
+ describe("assertRestsAt", () => {
107
+ it("fails on a stall at the step, pointing to the stall assertion", async () => {
108
+ const repo = testRepo({ workflow: { default: flow, flow: workflow(flow) } })
109
+ await repo.start("flow")
110
+ const stall = await repo.drive({ plan: () => undefined })
111
+ expect(stall.kind).toBe("stalled")
112
+ expect(await thrown(() => assertRestsAt(stall, "plan"))).toBe(
113
+ 'expected a rest at "plan", but the process stalls at "plan" — assert a stall with: the process stalls at "plan"',
114
+ )
115
+ })
116
+
117
+ it("passes at a rest at the step, and fails at another", async () => {
118
+ const repo = testRepo({ workflow: { default: flow } })
119
+ repo.write("go", "\n")
120
+ const rest = await repo.drive({ plan: (_, r) => r.write("PLAN.md", "p\n") })
121
+ assertRestsAt(rest, "sign-off")
122
+ expect(await thrown(() => assertRestsAt(rest, "plan"))).toContain("sign-off")
123
+ })
124
+ })