@pmelab/gtd 59.0.1 → 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 (83) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +37 -33
  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-07Z_ENch.mjs → testkit-BvecVjQ0.mjs} +570 -390
  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 +751 -645
  10. package/dist/steering.mjs +2 -2
  11. package/dist/testkit/gherkin.mjs +245 -36
  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 +66 -60
  18. package/src/cli/Cli.test.ts +183 -182
  19. package/src/cli/Cli.ts +164 -186
  20. package/src/cli/History.ts +9 -4
  21. package/src/cli/Judges.ts +1 -1
  22. package/src/cli/Output.test.ts +35 -0
  23. package/src/cli/Output.ts +31 -1
  24. package/src/cli/Program.test.ts +65 -79
  25. package/src/cli/Program.ts +157 -118
  26. package/src/edge/Edge.test.ts +64 -56
  27. package/src/edge/Edge.ts +3 -3
  28. package/src/flows/factory.test.ts +14 -0
  29. package/src/flows/factory.ts +22 -5
  30. package/src/flows/helpers.test.ts +35 -0
  31. package/src/flows/helpers.ts +19 -10
  32. package/src/flows/index.test.ts +66 -0
  33. package/src/flows/index.ts +109 -5
  34. package/src/flows/internal.ts +7 -0
  35. package/src/flows/runtime.test.ts +192 -1
  36. package/src/flows/runtime.ts +188 -54
  37. package/src/gherkin/git-control.feature +6 -6
  38. package/src/gherkin/index.test.ts +127 -0
  39. package/src/gherkin/index.ts +370 -51
  40. package/src/gherkin/messages.test.ts +38 -4
  41. package/src/gherkin/messages.ts +21 -4
  42. package/src/gherkin/more-steps.feature +170 -0
  43. package/src/gherkin/scenario-end.feature +101 -0
  44. package/src/gherkin/testkit.feature +83 -7
  45. package/src/install/Install.test.ts +7 -7
  46. package/src/install/Install.ts +8 -8
  47. package/src/replay/Replay.test.ts +13 -2
  48. package/src/replay/Replay.ts +19 -30
  49. package/src/replay/judgeDiffShape.test.ts +1 -1
  50. package/src/steering/Footnotes.test.ts +1 -1
  51. package/src/steering/SteeringFormats.test.ts +1 -1
  52. package/src/steering/freeform.test.ts +1 -1
  53. package/src/steering/index.test.ts +27 -1
  54. package/src/steering/index.ts +6 -104
  55. package/src/steering/internal.ts +11 -0
  56. package/src/steering/qa.test.ts +7 -2
  57. package/src/steering/reads.test.ts +42 -0
  58. package/src/steering/reads.ts +43 -0
  59. package/src/steering/registry.ts +93 -0
  60. package/src/steering/review.test.ts +2 -2
  61. package/src/step/planStart.test.ts +18 -16
  62. package/src/step/planStart.ts +9 -9
  63. package/src/testkit/TestRepo.test.ts +245 -7
  64. package/src/testkit/TestRepo.ts +271 -40
  65. package/src/testkit/imports.test.ts +58 -0
  66. package/src/testkit/imports.ts +102 -0
  67. package/src/testkit/index.test.ts +28 -0
  68. package/src/testkit/index.ts +14 -2
  69. package/src/twins/EmittedScriptRecognizer.ts +2 -6
  70. package/src/twins/EmittedScripts.test.ts +4 -2
  71. package/src/twins/InMemRepo.ts +1 -0
  72. package/src/wire/Script.ts +11 -0
  73. package/src/wire/WireSchema.test.ts +3 -0
  74. package/src/wire/golden/1/script.json +4 -0
  75. package/src/wire/index.ts +1 -0
  76. package/src/workflow/SteeringFormats.test.ts +1 -1
  77. package/src/workflow/compile.ts +2 -2
  78. package/src/workflow/discovery.test.ts +3 -3
  79. package/src/workflow/load.test.ts +256 -116
  80. package/src/workflow/load.ts +169 -146
  81. package/src/workflow-kit/index.test.ts +25 -0
  82. package/src/workflow-kit/index.ts +37 -4
  83. package/src/workflow-kit/review.ts +2 -2
@@ -10,6 +10,7 @@ import {
10
10
  type CapturedCliResult,
11
11
  type CommandTable,
12
12
  } from "../twins/index.js"
13
+ import { importsFrom } from "./imports.js"
13
14
 
14
15
  /**
15
16
  * The repository's `gtd.config.ts`: its source, or the module itself. A module
@@ -40,26 +41,81 @@ export interface TestRepoOptions {
40
41
  readonly files?: Readonly<Record<string, string>>
41
42
  /** The environment gtd sees; the real `process.env` is never read, except ambient `GTD_FLAG_*` (explicit env wins). */
42
43
  readonly env?: Readonly<Record<string, string>>
44
+ /**
45
+ * Modules a config given as source imports by specifier, e.g.
46
+ * `{ "#workflow": await import("./index.ts") }`: its `import`/`export … from
47
+ * "#workflow"` reads the module the test imported.
48
+ */
49
+ readonly modules?: Readonly<Record<string, Readonly<Record<string, unknown>>>>
50
+ /** `false` keeps the runner's `GTD_FLAG_*` out too, so gtd sees `env` alone. Defaults to `true`. */
51
+ readonly ambientFlags?: boolean
52
+ }
53
+
54
+ /** One answer of a {@link Verdict}. */
55
+ export interface VerdictEntry {
56
+ readonly id: string
57
+ readonly answer: boolean | string | number
58
+ readonly p: number
59
+ }
60
+
61
+ /** A judge step's verdict, as {@link verdict} builds it; an actor may return it in place of a JSON string. */
62
+ export interface Verdict {
63
+ readonly answers: readonly VerdictEntry[]
43
64
  }
44
65
 
45
66
  /** What a scripted actor does at the step it is registered for, before the beat lands. */
46
- export type Actor = (beat: BeatDocument, repo: TestRepo) => void | string | Promise<void | string>
67
+ export type ScriptedActor = (
68
+ beat: BeatDocument,
69
+ repo: TestRepo,
70
+ ) => void | string | Verdict | Promise<void | string | Verdict>
47
71
 
48
72
  /**
49
73
  * Scripted actors, keyed by full step name. At a judge step the actor returns
50
74
  * the verdict, which is answered through `gtd judge answer`; anywhere else it
51
75
  * edits the tree and the beat is landed.
52
76
  */
53
- export type Actors = Readonly<Record<string, Actor>>
77
+ export type Actors = Readonly<Record<string, ScriptedActor>>
54
78
 
55
79
  export interface DriveOptions {
56
80
  /** Fail instead of looping forever on a workflow that never rests. */
57
81
  readonly maxBeats?: number
82
+ /**
83
+ * Defaults to `true`: fail on an actor whose step no drive of this
84
+ * repository has reached, a check step without an actor, a human step whose
85
+ * actor changes nothing (the drive would repeat it), and an actor returning a
86
+ * verdict anywhere but at a judge step. `false` lets each of them pass.
87
+ */
88
+ readonly strict?: boolean
89
+ /**
90
+ * `"pass"`: a strict drive skips only its check for actors no drive has
91
+ * reached, for a harness that checks that once at its own end, as the
92
+ * gherkin world does at scenario end.
93
+ */
94
+ readonly unreachedActors?: "fail" | "pass"
95
+ /** `"run"`: a check step without an actor runs its own script, as `runsItsScript` would. */
96
+ readonly checks?: "run"
97
+ }
98
+
99
+ /** A commit, as `TestRepo.commits` lists it. */
100
+ export interface TestCommit {
101
+ readonly hash: string
102
+ readonly subject: string
103
+ /** The full message: subject, body and trailers. */
104
+ readonly message: string
105
+ /** The trailers of the message's last paragraph, each key's values in order. */
106
+ readonly trailers: Readonly<Record<string, readonly string[]>>
107
+ }
108
+
109
+ export interface RunScriptOptions {
110
+ /** Tried before the repository's own canned commands, for this run only. */
111
+ readonly commands?: readonly CannedCommand[]
58
112
  }
59
113
 
60
114
  export interface TestRepo {
61
115
  /** Adds a command a step's script may run, after those `testRepo` was given. */
62
116
  command(command: CannedCommand): void
117
+ /** Sets (or, with `undefined`, unsets) a variable gtd sees from the next call on. */
118
+ setEnv(name: string, value: string | undefined): void
63
119
  /** Creates branch `name` at `ref` (HEAD by default). Outside a process only. */
64
120
  branch(name: string, ref?: string): void
65
121
  /** Checks branch `name` out; refuses with uncommitted changes. Outside a process only. */
@@ -79,12 +135,12 @@ export interface TestRepo {
79
135
  * script helpers emit are understood; anything else (a test suite, a git
80
136
  * write) throws, and the step needs an actor that stands in for it.
81
137
  */
82
- runScript(script: string): void
138
+ runScript(script: string, options?: RunScriptOptions): void
83
139
  /** One in-process invocation; nothing it prints is run. */
84
140
  gtd(...args: string[]): Promise<CapturedCliResult>
85
- /** `gtd --workflow <name> [...args]`, its script applied. */
86
- start(workflow: string, ...args: string[]): Promise<CapturedCliResult>
87
- /** `gtd door <name> [...args]`, its script applied. */
141
+ /** `gtd start <name> [...args]`, its script applied: a door of that name, or else the workflow. */
142
+ start(name: string, ...args: string[]): Promise<CapturedCliResult>
143
+ /** The same as {@link TestRepo.start}. */
88
144
  door(name: string, ...args: string[]): Promise<CapturedCliResult>
89
145
  land(): Promise<CapturedCliResult>
90
146
  next(): Promise<BeatDocument>
@@ -102,6 +158,8 @@ export interface TestRepo {
102
158
  messages(): readonly string[]
103
159
  /** Every commit's full hash, oldest first. */
104
160
  hashes(): readonly string[]
161
+ /** Every commit, oldest first. */
162
+ commits(): readonly TestCommit[]
105
163
  }
106
164
 
107
165
  const REGISTRY = Symbol.for("@pmelab/gtd/testkit:modules")
@@ -114,11 +172,12 @@ const registry = (): Registry => {
114
172
  const IDENTIFIER = /^[A-Za-z_$][\w$]*$/
115
173
  let registered = 0
116
174
 
175
+ const lookup = (key: string): string =>
176
+ `globalThis[Symbol.for(${JSON.stringify(REGISTRY.description)})].get(${JSON.stringify(key)})`
177
+
117
178
  /** A config whose exports are module `id`'s, read off the registry when the engine loads it. */
118
179
  const moduleSource = (id: string, module: Readonly<Record<string, unknown>>): string => {
119
- const lines = [
120
- `const m = globalThis[Symbol.for(${JSON.stringify(REGISTRY.description)})].get(${JSON.stringify(id)})`,
121
- ]
180
+ const lines = [`const m = ${lookup(id)}`]
122
181
  for (const name of Object.keys(module)) {
123
182
  if (name === "default") lines.push(`export default m.default`)
124
183
  else if (IDENTIFIER.test(name)) lines.push(`export const ${name} = m[${JSON.stringify(name)}]`)
@@ -128,7 +187,71 @@ const moduleSource = (id: string, module: Readonly<Record<string, unknown>>): st
128
187
  }
129
188
 
130
189
  /** An actor that runs the step's own script, for steps whose script `runScript` understands. */
131
- export const runsItsScript: Actor = (beat, repo) => repo.runScript(beat.content)
190
+ export const runsItsScript: ScriptedActor = (beat, repo) => repo.runScript(beat.content)
191
+
192
+ /**
193
+ * A judge's verdict, keyed by question id: `verdict({ ready: true })`, or with
194
+ * a confidence, `verdict({ ready: { answer: true, p: 0.6 } })`. `p` defaults to 0.9.
195
+ */
196
+ export const verdict = (
197
+ answers: Readonly<
198
+ Record<
199
+ string,
200
+ VerdictEntry["answer"] | { readonly answer: VerdictEntry["answer"]; readonly p?: number }
201
+ >
202
+ >,
203
+ ): Verdict => ({
204
+ answers: Object.entries(answers).map(([id, entry]) =>
205
+ typeof entry === "object"
206
+ ? { id, answer: entry.answer, p: entry.p ?? 0.9 }
207
+ : { id, answer: entry, p: 0.9 },
208
+ ),
209
+ })
210
+
211
+ /** `actor`, at a visit whose beat content contains `text`; any other visit changes nothing. */
212
+ export const whenPrompted =
213
+ (text: string, actor: ScriptedActor): ScriptedActor =>
214
+ (beat, repo) =>
215
+ beat.content.includes(text) ? actor(beat, repo) : undefined
216
+
217
+ /** Each actor in order, at every visit; the first verdict any of them returns is the result. */
218
+ export const inTurn =
219
+ (actors: readonly ScriptedActor[]): ScriptedActor =>
220
+ async (beat, repo) => {
221
+ let result: string | Verdict | undefined
222
+ for (const actor of actors) {
223
+ const returned = await actor(beat, repo)
224
+ if (result === undefined && returned !== undefined) result = returned as string | Verdict
225
+ }
226
+ return result
227
+ }
228
+
229
+ /** At a judge step, `answer` when the pending questions (as `gtd judge` prints them) contain `text`. */
230
+ export const verdictWhen =
231
+ (text: string, answer: string | Verdict): ScriptedActor =>
232
+ async (_, repo) => {
233
+ const { stdout } = await repo.gtd("judge")
234
+ return stdout.includes(text) ? answer : undefined
235
+ }
236
+
237
+ const isVerdict = (value: unknown): value is Verdict =>
238
+ typeof value === "object" &&
239
+ value !== null &&
240
+ Array.isArray((value as { answers?: unknown }).answers)
241
+
242
+ const TRAILER = /^([A-Za-z0-9][A-Za-z0-9-]*): ?(.*)$/
243
+
244
+ const trailersOf = (message: string): Record<string, string[]> => {
245
+ const paragraphs = message.trimEnd().split(/\n\s*\n/)
246
+ const last = paragraphs.length > 1 ? paragraphs.at(-1)!.split("\n") : []
247
+ const trailers: Record<string, string[]> = {}
248
+ if (last.length === 0 || !last.every((line) => TRAILER.test(line))) return trailers
249
+ for (const line of last) {
250
+ const [, key, value] = TRAILER.exec(line)!
251
+ ;(trailers[key!] ??= []).push(value!)
252
+ }
253
+ return trailers
254
+ }
132
255
 
133
256
  const words = (text: string): string[] =>
134
257
  [...text.matchAll(/'((?:[^']|'\\'')*)'/g)].map((m) => m[1]!.replaceAll("'\\''", "'"))
@@ -228,39 +351,112 @@ const expectedVerdict = (doc: BeatDocument): string => {
228
351
  ].join("\n")
229
352
  }
230
353
 
354
+ const quoted = (names: readonly string[]): string => names.map((name) => `"${name}"`).join(", ")
355
+
356
+ /** Who acts at `doc`: its scripted actor, or a check's own script under `checks: "run"`. A stall has none. */
357
+ const actorAt = (
358
+ doc: BeatDocument,
359
+ actors: Actors,
360
+ checks: DriveOptions["checks"],
361
+ ): { readonly scripted: boolean; readonly actor: ScriptedActor | undefined } => {
362
+ if (doc.kind === "stalled") return { scripted: false, actor: undefined }
363
+ if (Object.hasOwn(actors, doc.state)) return { scripted: true, actor: actors[doc.state] }
364
+ const runs = checks === "run" && doc.actor === "check"
365
+ return { scripted: false, actor: runs ? runsItsScript : undefined }
366
+ }
367
+
368
+ /** A strict drive's rest: refused at an unscripted check step, or with an actor no drive reached. */
369
+ const strictRest = (
370
+ doc: BeatDocument,
371
+ actors: Actors,
372
+ reached: ReadonlySet<string> | undefined,
373
+ ): BeatDocument => {
374
+ if (doc.kind !== "stalled" && doc.actor === "check") {
375
+ throw new Error(
376
+ `strict drive: check step "${doc.state}" has no actor — script it, or drive with checks: "run"`,
377
+ )
378
+ }
379
+ const never =
380
+ reached === undefined ? [] : Object.keys(actors).filter((step) => !reached.has(step))
381
+ if (never.length > 0) {
382
+ throw new Error(
383
+ `strict drive: the process rests at "${doc.state}", and never reached the actors for ${quoted(never)} — script a step only for the drive that reaches it`,
384
+ )
385
+ }
386
+ return doc
387
+ }
388
+
389
+ const refuseVerdict = (doc: BeatDocument, returned: unknown): void => {
390
+ if (doc.actor !== "judge" && returned !== undefined) {
391
+ throw new Error(
392
+ `strict drive: the actor for ${doc.actor} step "${doc.state}" returned a verdict; only a judge step takes one`,
393
+ )
394
+ }
395
+ }
396
+
397
+ const refuseNothingLanded = (doc: BeatDocument, nothing: boolean): void => {
398
+ if (doc.actor === "human" && nothing) {
399
+ throw new Error(
400
+ `strict drive: the actor for human step "${doc.state}" changed nothing, so nothing landed and the drive would repeat the step`,
401
+ )
402
+ }
403
+ }
404
+
231
405
  export const testRepo = (options: TestRepoOptions): TestRepo => {
232
406
  const repo = new InMemRepo()
233
- const env = { ...options.env }
407
+ const env: Record<string, string | undefined> = { ...options.env }
234
408
  const { workflow } = options
235
409
  const id = `module-${++registered}`
410
+ const modules = Object.entries(options.modules ?? {})
411
+ const moduleKey = (specifier: string): string => `${id}:${specifier}`
236
412
  repo.writeFile(
237
413
  "gtd.config.ts",
238
- typeof workflow === "string" ? workflow : moduleSource(id, workflow),
414
+ typeof workflow !== "string"
415
+ ? moduleSource(id, workflow)
416
+ : modules.length === 0
417
+ ? workflow
418
+ : importsFrom(workflow, options.modules!, (specifier) => lookup(moduleKey(specifier))),
239
419
  )
240
420
  for (const [path, content] of Object.entries(options.files ?? {})) repo.writeFile(path, content)
241
421
  repo.seedCommit("chore: initial commit")
242
422
 
243
423
  const canned = [...(options.commands ?? [])]
244
- const commands: CommandTable = {
424
+ const table = (first: readonly CannedCommand[] = []): CommandTable => ({
245
425
  get: (command) => {
246
- const hit = canned.find(({ run }) =>
426
+ const hit = [...first, ...canned].find(({ run }) =>
247
427
  typeof run === "string" ? run === command : run.test(command),
248
428
  )
249
429
  if (hit === undefined) return undefined
250
430
  const { exit = 0, output = "", effect } = hit
251
431
  return { kind: "exit", status: exit, output, effect: () => effect?.(self) }
252
432
  },
253
- }
433
+ })
434
+ const commands = table()
435
+
436
+ // An `undefined` entry masks the ambient flag the in-memory host would add.
437
+ const seenEnv = (): Readonly<Record<string, string | undefined>> =>
438
+ options.ambientFlags === false
439
+ ? {
440
+ ...Object.fromEntries(
441
+ Object.keys(process.env)
442
+ .filter((name) => name.startsWith("GTD_FLAG_"))
443
+ .map((name) => [name, undefined]),
444
+ ),
445
+ ...env,
446
+ }
447
+ : env
254
448
 
255
- // The module is registered only while gtd runs, so nothing outlives the
256
- // call that loads it and no test can reach another's module.
449
+ // The modules are registered only while gtd runs, so nothing outlives the
450
+ // call that loads them and no test can reach another's module.
257
451
  const gtd = async (...args: string[]): Promise<CapturedCliResult> => {
258
- const { io, result } = makeCapturingCliIo(repo, env)
452
+ const { io, result } = makeCapturingCliIo(repo, seenEnv())
259
453
  if (typeof workflow !== "string") registry().set(id, workflow)
454
+ for (const [specifier, module] of modules) registry().set(moduleKey(specifier), module)
260
455
  try {
261
456
  await Effect.runPromise(runCli(["node", "gtd", ...args], io))
262
457
  } finally {
263
458
  registry().delete(id)
459
+ for (const [specifier] of modules) registry().delete(moduleKey(specifier))
264
460
  }
265
461
  return result()
266
462
  }
@@ -299,7 +495,7 @@ export const testRepo = (options: TestRepoOptions): TestRepo => {
299
495
  return reported
300
496
  }
301
497
 
302
- // A start prints its script plainly; it has no `--json=script` twin.
498
+ // A start prints its script plainly.
303
499
  const begin = async (args: readonly string[]): Promise<CapturedCliResult> => {
304
500
  const result = await gtd(...args)
305
501
  if (result.exitCode !== EXIT_OK) throw failed(`gtd ${args.join(" ")}`, result)
@@ -314,35 +510,67 @@ export const testRepo = (options: TestRepoOptions): TestRepo => {
314
510
  }
315
511
 
316
512
  /** Lands what an actor did: a judge's verdict answered, anything else validated and landed. */
317
- const act = async (doc: BeatDocument, verdict: unknown): Promise<void> => {
513
+ const act = async (doc: BeatDocument, returned: unknown): Promise<void> => {
318
514
  if (doc.actor !== "judge") {
319
515
  if (doc.validate !== undefined) apply(`validating ${doc.file ?? doc.state}`, doc.validate)
320
516
  await runWrite(["land"])
321
517
  return
322
518
  }
323
519
  const actor = `the actor for judge step "${doc.state}"`
324
- if (verdict === undefined) {
520
+ if (returned === undefined) {
325
521
  throw new Error(`${actor} returned no verdict\n${expectedVerdict(doc)}`)
326
522
  }
327
- if (typeof verdict !== "string") {
523
+ const sent = isVerdict(returned) ? JSON.stringify(returned.answers) : returned
524
+ if (typeof sent !== "string") {
328
525
  throw new Error(
329
- `${actor} returned ${Array.isArray(verdict) ? "an array" : typeof verdict === "object" ? "an object" : `a ${typeof verdict}`}, not a JSON string — JSON.stringify it\n${expectedVerdict(doc)}`,
526
+ `${actor} returned ${Array.isArray(sent) ? "an array" : typeof sent === "object" ? "an object" : `a ${typeof sent}`}, not a JSON string — JSON.stringify it\n${expectedVerdict(doc)}`,
330
527
  )
331
528
  }
332
529
  await runWrite(
333
530
  ["judge", "answer"],
334
- verdict,
531
+ sent,
335
532
  (error) =>
336
533
  new Error(
337
- `${actor} sent a verdict gtd refused:\n${verdict}\n${expectedVerdict(doc)}\n${error.message}`,
534
+ `${actor} sent a verdict gtd refused:\n${sent}\n${expectedVerdict(doc)}\n${error.message}`,
338
535
  ),
339
536
  )
340
537
  }
341
538
 
539
+ // Across drives, so a later drive need not revisit a step an earlier one passed.
540
+ const reached = new Set<string>()
541
+
542
+ /** One beat of a drive: the beat it rests at, or `undefined` to read the next. */
543
+ const beat = async (
544
+ doc: BeatDocument,
545
+ actors: Actors,
546
+ { strict = true, checks, unreachedActors = "fail" }: DriveOptions,
547
+ ): Promise<BeatDocument | undefined> => {
548
+ if (doc.kind === "capture") {
549
+ await runWrite(["land"])
550
+ return undefined
551
+ }
552
+ const { scripted, actor } = actorAt(doc, actors, checks)
553
+ if (actor === undefined)
554
+ return strict
555
+ ? strictRest(doc, actors, unreachedActors === "fail" ? reached : undefined)
556
+ : doc
557
+ if (scripted) reached.add(doc.state)
558
+ const returned = await actor(doc, self)
559
+ if (strict) refuseVerdict(doc, returned)
560
+ const before = repo.commitHistory().length
561
+ await act(doc, returned)
562
+ if (strict) refuseNothingLanded(doc, repo.commitHistory().length === before)
563
+ return undefined
564
+ }
565
+
342
566
  const self: TestRepo = {
343
567
  command: (command) => {
344
568
  canned.push(command)
345
569
  },
570
+ setEnv: (name, value) => {
571
+ if (value === undefined) delete env[name]
572
+ else env[name] = value
573
+ },
346
574
  branch: (name, ref = "HEAD") => repo.createBranch(name, ref),
347
575
  checkout: (name) => repo.checkoutBranch(name),
348
576
  ref: (name, ref = "HEAD") => {
@@ -357,29 +585,25 @@ export const testRepo = (options: TestRepoOptions): TestRepo => {
357
585
  remove: (path) => repo.deleteFile(path),
358
586
  commit: (message) => repo.seedCommit(message),
359
587
  show: (ref, path) => repo.fileAtRef(ref, path) ?? undefined,
360
- runScript: (script) => {
361
- const flow = applyFlowScript(repo, commands, script)
362
- if (flow === undefined) runLines(repo, commands, script)
588
+ runScript: (script, { commands: first = [] } = {}) => {
589
+ const scriptCommands = first.length === 0 ? commands : table(first)
590
+ const flow = applyFlowScript(repo, scriptCommands, script)
591
+ if (flow === undefined) runLines(repo, scriptCommands, script)
363
592
  else if (!flow.ok) throw new Error(`testkit: ${flow.error ?? ""}`)
364
593
  },
365
594
  gtd,
366
- start: (workflow, ...args) => begin(["--workflow", workflow, ...args]),
367
- door: (name, ...args) => begin(["door", name, ...args]),
595
+ start: (name, ...args) => begin(["start", name, ...args]),
596
+ door: (name, ...args) => begin(["start", name, ...args]),
368
597
  land: () => runWrite(["land"]),
369
598
  next,
370
- drive: async (actors = {}, { maxBeats = 50 } = {}) => {
599
+ drive: async (actors = {}, options = {}) => {
600
+ const { maxBeats = 50 } = options
371
601
  const passed: string[] = []
372
- for (let beat = 0; beat < maxBeats; beat++) {
602
+ for (let n = 0; n < maxBeats; n++) {
373
603
  const doc = await next()
374
604
  passed.push(doc.state)
375
- if (doc.kind === "stalled") return doc
376
- if (doc.kind === "capture") {
377
- await runWrite(["land"])
378
- continue
379
- }
380
- const actor = actors[doc.state]
381
- if (actor === undefined) return doc
382
- await act(doc, await actor(doc, self))
605
+ const rest = await beat(doc, actors, options)
606
+ if (rest !== undefined) return rest
383
607
  }
384
608
  throw new Error(
385
609
  `the process did not rest within ${maxBeats} beats; it passed through:\n${passed.join(" → ")}`,
@@ -388,6 +612,13 @@ export const testRepo = (options: TestRepoOptions): TestRepo => {
388
612
  subjects: () => repo.commitHistory().map(({ message }) => message.split("\n")[0] ?? ""),
389
613
  messages: () => repo.commitHistory().map(({ message }) => message),
390
614
  hashes: () => repo.commitHistory().map(({ hash }) => hash),
615
+ commits: () =>
616
+ repo.commitHistory().map(({ hash, message }) => ({
617
+ hash,
618
+ subject: message.split("\n")[0] ?? "",
619
+ message,
620
+ trailers: trailersOf(message),
621
+ })),
391
622
  }
392
623
  return self
393
624
  }
@@ -0,0 +1,58 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { importsFrom } from "./imports.js"
3
+
4
+ const lookup = (specifier: string): string => `REG(${JSON.stringify(specifier)})`
5
+ const mod = { default: 1, a: 2, b: 3 }
6
+ const rewrite = (source: string): string => importsFrom(source, { "#w": mod }, lookup)
7
+
8
+ describe("importsFrom", () => {
9
+ it("binds named, renamed, default and namespace imports", () => {
10
+ expect(rewrite(`import d, { a, b as c, type T } from "#w"\nimport * as ns from '#w';`)).toBe(
11
+ [
12
+ `const __testkit_module_1 = REG("#w")`,
13
+ `const d = __testkit_module_1["default"]`,
14
+ `const a = __testkit_module_1["a"]`,
15
+ `const c = __testkit_module_1["b"]`,
16
+ `const __testkit_module_2 = REG("#w")`,
17
+ `const ns = __testkit_module_2`,
18
+ ].join("\n"),
19
+ )
20
+ })
21
+
22
+ it("re-exports by name, as default, as a namespace and wholesale", () => {
23
+ expect(
24
+ rewrite(
25
+ `export {\n a as default,\n b,\n} from "#w"\nexport * as ns from "#w"\nexport * from "#w"`,
26
+ ),
27
+ ).toBe(
28
+ [
29
+ `const __testkit_module_1 = REG("#w")`,
30
+ `export default __testkit_module_1["a"]`,
31
+ `export const b = __testkit_module_1["b"]`,
32
+ `const __testkit_module_2 = REG("#w")`,
33
+ `export const ns = __testkit_module_2`,
34
+ `const __testkit_module_3 = REG("#w")`,
35
+ `export const a = __testkit_module_3["a"]`,
36
+ `export const b = __testkit_module_3["b"]`,
37
+ ].join("\n"),
38
+ )
39
+ })
40
+
41
+ it("drops type-only and bare imports, and leaves every other specifier alone", () => {
42
+ const other = `import { agent } from "@pmelab/gtd/flows"\nexport default agent`
43
+ expect(rewrite(`import type { T } from "#w"\nimport "#w"\n${other}`)).toBe(`\n\n${other}`)
44
+ })
45
+
46
+ it("never reads a statement across the code before it", () => {
47
+ const source = `export default tiny\nimport { a } from "#w"`
48
+ expect(rewrite(source)).toBe(
49
+ `export default tiny\nconst __testkit_module_1 = REG("#w")\nconst a = __testkit_module_1["a"]`,
50
+ )
51
+ })
52
+
53
+ it("refuses a name the module does not export", () => {
54
+ expect(() => rewrite(`import { nope } from "#w"`)).toThrow(
55
+ 'testkit: module "#w" has no export "nope"',
56
+ )
57
+ })
58
+ })
@@ -0,0 +1,102 @@
1
+ type Module = Readonly<Record<string, unknown>>
2
+
3
+ const IDENTIFIER = /^[A-Za-z_$][\w$]*$/
4
+
5
+ // Static `import`/`export … from` and bare `import "…"` statements, each
6
+ // starting a line. A clause holds names, `*`, commas and one brace group, and
7
+ // breaks lines only inside the braces, so a match cannot run past its statement.
8
+ const STATEMENT =
9
+ /^[ \t]*(import|export)([ \t]+type)?[ \t]+([\w$* \t,]*(?:\{[^}]*\})?[\w$* \t,]*?)\s*\bfrom\s*(["'])([^"'\n]+)\4[ \t]*;?|^[ \t]*import\s*(["'])([^"'\n]+)\6[ \t]*;?/gm
10
+
11
+ interface Binding {
12
+ readonly name: string
13
+ readonly as: string
14
+ }
15
+
16
+ const bindings = (list: string): Binding[] =>
17
+ list
18
+ .split(",")
19
+ .map((entry) => entry.trim())
20
+ .filter((entry) => entry !== "" && !entry.startsWith("type "))
21
+ .map((entry) => {
22
+ const [name, as] = entry.split(/\s+as\s+/)
23
+ return { name: name!, as: as ?? name! }
24
+ })
25
+
26
+ const exportLine = (as: string, value: string): string =>
27
+ as === "default" ? `export default ${value}` : `export const ${as} = ${value}`
28
+
29
+ type Read = (name: string) => string
30
+
31
+ const exportLines = (module: Module, m: string, read: Read, head: string, braces?: string) => {
32
+ if (braces !== undefined) return bindings(braces).map((b) => exportLine(b.as, read(b.name)))
33
+ if (/^\*\s+as\s+/.test(head)) return [exportLine(head.replace(/^\*\s+as\s+/, ""), m)]
34
+ return Object.keys(module)
35
+ .filter((name) => name !== "default" && IDENTIFIER.test(name))
36
+ .map((name) => exportLine(name, read(name)))
37
+ }
38
+
39
+ const importLines = (m: string, read: Read, head: string, braces?: string): string[] => [
40
+ ...head
41
+ .split(",")
42
+ .map((part) => part.trim())
43
+ .filter((part) => part !== "")
44
+ .map((part) =>
45
+ part.startsWith("*")
46
+ ? `const ${part.replace(/^\*\s+as\s+/, "")} = ${m}`
47
+ : `const ${part} = ${read("default")}`,
48
+ ),
49
+ ...(braces === undefined ? [] : bindings(braces).map((b) => `const ${b.as} = ${read(b.name)}`)),
50
+ ]
51
+
52
+ /**
53
+ * `source` with each import or re-export of a specifier in `modules` reading
54
+ * that module off `lookup(specifier)`, an expression evaluated when the
55
+ * config runs. A string config is evaluated in memory, where no resolver
56
+ * knows the specifier, so the binding is made by hand.
57
+ */
58
+ export const importsFrom = (
59
+ source: string,
60
+ modules: Readonly<Record<string, Module>>,
61
+ lookup: (specifier: string) => string,
62
+ ): string => {
63
+ let n = 0
64
+ const rewrite = (kind: string, clause: string, specifier: string, module: Module): string => {
65
+ const m = `__testkit_module_${++n}`
66
+ const read: Read = (name) => {
67
+ if (!(name in module))
68
+ throw new Error(`testkit: module "${specifier}" has no export "${name}"`)
69
+ return `${m}[${JSON.stringify(name)}]`
70
+ }
71
+ const text = clause.trim()
72
+ const braces = /\{([^}]*)\}/.exec(text)?.[1]
73
+ const head = text
74
+ .replace(/\{[^}]*\}/, "")
75
+ .replace(/,\s*$/, "")
76
+ .trim()
77
+ const lines =
78
+ kind === "export"
79
+ ? exportLines(module, m, read, head, braces)
80
+ : importLines(m, read, head, braces)
81
+ return [`const ${m} = ${lookup(specifier)}`, ...lines].join("\n")
82
+ }
83
+ return source.replace(
84
+ STATEMENT,
85
+ (
86
+ statement,
87
+ kind: string | undefined,
88
+ typeOnly: string | undefined,
89
+ clause: string | undefined,
90
+ _q: string | undefined,
91
+ from: string | undefined,
92
+ _bq: string | undefined,
93
+ bare: string | undefined,
94
+ ) => {
95
+ const specifier = from ?? bare!
96
+ const module = modules[specifier]
97
+ if (module === undefined) return statement
98
+ if (typeOnly !== undefined || bare !== undefined) return ""
99
+ return rewrite(kind!, clause!, specifier, module)
100
+ },
101
+ )
102
+ }
@@ -0,0 +1,28 @@
1
+ import { afterEach, describe, expect, it } from "vitest"
2
+ import { agent, read } from "../flows/index.js"
3
+ import { installContext, type FlowContext, type StepRequest } from "./index.js"
4
+
5
+ afterEach(() => installContext(undefined))
6
+
7
+ describe("the flow-context harness", () => {
8
+ it("renders a prompt and records a step against an installed context", async () => {
9
+ const requests: StepRequest[] = []
10
+ installContext({
11
+ read: (path: string) => (path === "PLAN.md" ? "add a button" : undefined),
12
+ step: async (request: StepRequest) => {
13
+ requests.push(request)
14
+ },
15
+ } as Partial<FlowContext> as FlowContext)
16
+
17
+ await agent("build", `Build: ${read("PLAN.md")}`)
18
+
19
+ expect(requests).toEqual([
20
+ { kind: "agent", name: "build", prompt: "Build: add a button", options: {} },
21
+ ])
22
+ })
23
+
24
+ it("leaves a flow failing outside a replay once uninstalled", () => {
25
+ installContext(undefined)
26
+ expect(() => read("PLAN.md")).toThrow("called outside a gtd replay")
27
+ })
28
+ })
@@ -2,15 +2,27 @@
2
2
  // repository. It imports no test runner, so any runner can use it; the
3
3
  // gherkin steps are the opt-in \`@pmelab/gtd/testkit/gherkin\`.
4
4
  export {
5
+ inTurn,
5
6
  runsItsScript,
6
7
  testRepo,
7
- type Actor,
8
+ verdict,
9
+ verdictWhen,
10
+ whenPrompted,
8
11
  type Actors,
9
12
  type CannedCommand,
10
13
  type DriveOptions,
14
+ type RunScriptOptions,
15
+ type ScriptedActor,
16
+ type TestCommit,
11
17
  type TestRepo,
12
18
  type TestRepoOptions,
19
+ type Verdict,
20
+ type VerdictEntry,
13
21
  type WorkflowModule,
14
22
  } from "./TestRepo.js"
15
- export { InMemRepo, type CapturedCliResult } from "../twins/index.js"
23
+ export type { BeatDocument } from "../wire/index.js"
24
+ export type { CapturedCliResult } from "../twins/index.js"
16
25
  export type { TestkitWorld } from "../gherkin/index.js"
26
+ // The flow-context harness: render a prompt or stub a step synchronously,
27
+ // against a replay context the test installs instead of the engine.
28
+ export { installContext, type FlowContext, type StepRequest } from "../flows/internal.js"