@pmelab/gtd 59.0.0 → 60.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +42 -38
  3. package/claude/hooks/doors.ts +8 -4
  4. package/dist/chunks/flows-DENt0uWV.mjs +707 -0
  5. package/dist/chunks/{steering-CCNvngSH.mjs → steering-DF_BMadH.mjs} +292 -289
  6. package/dist/chunks/{testkit-CMBAAyRK.mjs → testkit-BvecVjQ0.mjs} +937 -772
  7. package/dist/chunks/{wire-C12dahrH.mjs → wire-DZ8R1RU9.mjs} +9 -2
  8. package/dist/flows.mjs +2 -676
  9. package/dist/gtd.bundle.mjs +1101 -1025
  10. package/dist/steering.mjs +2 -2
  11. package/dist/testkit/gherkin.mjs +371 -82
  12. package/dist/testkit.mjs +3 -2
  13. package/dist/web/main.js +234 -232
  14. package/dist/wire.mjs +2 -2
  15. package/dist/workflow-kit.mjs +3 -3
  16. package/package.json +1 -1
  17. package/skills/authoring/SKILL.md +68 -61
  18. package/src/cli/Cli.test.ts +311 -229
  19. package/src/cli/Cli.ts +486 -585
  20. package/src/cli/Help.test.ts +47 -0
  21. package/src/cli/Help.ts +70 -0
  22. package/src/cli/History.ts +9 -4
  23. package/src/cli/Judges.ts +1 -1
  24. package/src/cli/Output.test.ts +35 -0
  25. package/src/cli/Output.ts +31 -1
  26. package/src/cli/Program.test.ts +131 -82
  27. package/src/cli/Program.ts +197 -111
  28. package/src/edge/Edge.test.ts +64 -56
  29. package/src/edge/Edge.ts +4 -4
  30. package/src/emit/Emit.test.ts +1 -1
  31. package/src/emit/Emit.ts +1 -1
  32. package/src/flows/factory.test.ts +14 -0
  33. package/src/flows/factory.ts +25 -6
  34. package/src/flows/helpers.test.ts +35 -0
  35. package/src/flows/helpers.ts +19 -10
  36. package/src/flows/index.test.ts +66 -0
  37. package/src/flows/index.ts +109 -5
  38. package/src/flows/internal.ts +7 -0
  39. package/src/flows/runtime.test.ts +192 -1
  40. package/src/flows/runtime.ts +188 -54
  41. package/src/gherkin/git-control.feature +6 -6
  42. package/src/gherkin/index.test.ts +127 -0
  43. package/src/gherkin/index.ts +481 -98
  44. package/src/gherkin/messages.test.ts +124 -0
  45. package/src/gherkin/messages.ts +101 -0
  46. package/src/gherkin/more-steps.feature +170 -0
  47. package/src/gherkin/scenario-end.feature +101 -0
  48. package/src/gherkin/testkit.feature +83 -7
  49. package/src/install/Install.test.ts +7 -7
  50. package/src/install/Install.ts +8 -8
  51. package/src/replay/Replay.test.ts +13 -2
  52. package/src/replay/Replay.ts +19 -30
  53. package/src/replay/judgeDiffShape.test.ts +1 -1
  54. package/src/steering/Footnotes.test.ts +1 -1
  55. package/src/steering/SteeringFormats.test.ts +1 -1
  56. package/src/steering/freeform.test.ts +1 -1
  57. package/src/steering/index.test.ts +27 -1
  58. package/src/steering/index.ts +6 -104
  59. package/src/steering/internal.ts +11 -0
  60. package/src/steering/qa.test.ts +7 -2
  61. package/src/steering/reads.test.ts +42 -0
  62. package/src/steering/reads.ts +43 -0
  63. package/src/steering/registry.ts +93 -0
  64. package/src/steering/review.test.ts +2 -2
  65. package/src/step/planStart.test.ts +18 -16
  66. package/src/step/planStart.ts +9 -9
  67. package/src/testkit/TestRepo.test.ts +289 -6
  68. package/src/testkit/TestRepo.ts +317 -41
  69. package/src/testkit/imports.test.ts +58 -0
  70. package/src/testkit/imports.ts +102 -0
  71. package/src/testkit/index.test.ts +28 -0
  72. package/src/testkit/index.ts +14 -2
  73. package/src/twins/EmittedScriptRecognizer.ts +7 -14
  74. package/src/twins/EmittedScripts.test.ts +4 -2
  75. package/src/twins/InMemRepo.ts +1 -0
  76. package/src/wire/Land.ts +1 -1
  77. package/src/wire/Script.ts +11 -0
  78. package/src/wire/WireSchema.test.ts +3 -0
  79. package/src/wire/golden/1/script.json +4 -0
  80. package/src/wire/index.ts +1 -0
  81. package/src/workflow/SteeringFormats.test.ts +1 -1
  82. package/src/workflow/compile.ts +2 -2
  83. package/src/workflow/discovery.test.ts +3 -3
  84. package/src/workflow/load.test.ts +256 -116
  85. package/src/workflow/load.ts +175 -152
  86. package/src/workflow/skills.test.ts +5 -5
  87. package/src/workflow/skills.ts +1 -1
  88. package/src/workflow-kit/index.test.ts +25 -0
  89. package/src/workflow-kit/index.ts +37 -4
  90. package/src/workflow-kit/review.ts +2 -2
@@ -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("'\\''", "'"))
@@ -204,39 +327,136 @@ const runLines = (repo: InMemRepo, commands: CommandTable, script: string): void
204
327
  const failed = (what: string, result: CapturedCliResult): Error =>
205
328
  new Error(`${what} exited ${result.exitCode}\n${result.stderr}${result.stdout}`)
206
329
 
330
+ const ANSWER: Readonly<Record<string, string>> = {
331
+ noul: "true",
332
+ choice: '"<option>"',
333
+ score: "0.5",
334
+ }
335
+
336
+ /** The verdict a judge beat's questions take, as a template to copy. */
337
+ const expectedVerdict = (doc: BeatDocument): string => {
338
+ let questions: readonly { id: string; primitive: string }[] = []
339
+ try {
340
+ questions = (JSON.parse(doc.judge ?? "") as { questions: typeof questions }).questions
341
+ } catch {
342
+ // An unreadable judge document leaves the template without entries.
343
+ }
344
+ const entries = questions.map(
345
+ ({ id, primitive }) =>
346
+ `{ "id": ${JSON.stringify(id)}, "answer": ${ANSWER[primitive] ?? '"<answer>"'}, "p": 0.9 }`,
347
+ )
348
+ return [
349
+ "expected a JSON array, one entry per question — answer a boolean for a noul, a string for a choice, a number for a score; p between 0 and 1:",
350
+ `[${entries.join(", ")}]`,
351
+ ].join("\n")
352
+ }
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
+
207
405
  export const testRepo = (options: TestRepoOptions): TestRepo => {
208
406
  const repo = new InMemRepo()
209
- const env = { ...options.env }
407
+ const env: Record<string, string | undefined> = { ...options.env }
210
408
  const { workflow } = options
211
409
  const id = `module-${++registered}`
410
+ const modules = Object.entries(options.modules ?? {})
411
+ const moduleKey = (specifier: string): string => `${id}:${specifier}`
212
412
  repo.writeFile(
213
413
  "gtd.config.ts",
214
- 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))),
215
419
  )
216
420
  for (const [path, content] of Object.entries(options.files ?? {})) repo.writeFile(path, content)
217
421
  repo.seedCommit("chore: initial commit")
218
422
 
219
423
  const canned = [...(options.commands ?? [])]
220
- const commands: CommandTable = {
424
+ const table = (first: readonly CannedCommand[] = []): CommandTable => ({
221
425
  get: (command) => {
222
- const hit = canned.find(({ run }) =>
426
+ const hit = [...first, ...canned].find(({ run }) =>
223
427
  typeof run === "string" ? run === command : run.test(command),
224
428
  )
225
429
  if (hit === undefined) return undefined
226
430
  const { exit = 0, output = "", effect } = hit
227
431
  return { kind: "exit", status: exit, output, effect: () => effect?.(self) }
228
432
  },
229
- }
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
230
448
 
231
- // The module is registered only while gtd runs, so nothing outlives the
232
- // 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.
233
451
  const gtd = async (...args: string[]): Promise<CapturedCliResult> => {
234
- const { io, result } = makeCapturingCliIo(repo, env)
452
+ const { io, result } = makeCapturingCliIo(repo, seenEnv())
235
453
  if (typeof workflow !== "string") registry().set(id, workflow)
454
+ for (const [specifier, module] of modules) registry().set(moduleKey(specifier), module)
236
455
  try {
237
456
  await Effect.runPromise(runCli(["node", "gtd", ...args], io))
238
457
  } finally {
239
458
  registry().delete(id)
459
+ for (const [specifier] of modules) registry().delete(moduleKey(specifier))
240
460
  }
241
461
  return result()
242
462
  }
@@ -261,17 +481,21 @@ export const testRepo = (options: TestRepoOptions): TestRepo => {
261
481
  }
262
482
 
263
483
  /** Runs a write command, then the script its `--json=script` twin prints. */
264
- const runWrite = async (args: readonly string[], stdin?: string): Promise<CapturedCliResult> => {
484
+ const runWrite = async (
485
+ args: readonly string[],
486
+ stdin?: string,
487
+ refused: (error: Error) => Error = (error) => error,
488
+ ): Promise<CapturedCliResult> => {
265
489
  const call = (...extra: string[]) =>
266
490
  stdin === undefined ? gtd(...args, ...extra) : withStdin(stdin, () => gtd(...args, ...extra))
267
491
  const reported = await call()
268
- if (reported.exitCode !== EXIT_OK) throw failed(`gtd ${args.join(" ")}`, reported)
492
+ if (reported.exitCode !== EXIT_OK) throw refused(failed(`gtd ${args.join(" ")}`, reported))
269
493
  const script = await call("--json=script")
270
494
  apply(`gtd ${args.join(" ")}`, script.stdout)
271
495
  return reported
272
496
  }
273
497
 
274
- // A start prints its script plainly; it has no `--json=script` twin.
498
+ // A start prints its script plainly.
275
499
  const begin = async (args: readonly string[]): Promise<CapturedCliResult> => {
276
500
  const result = await gtd(...args)
277
501
  if (result.exitCode !== EXIT_OK) throw failed(`gtd ${args.join(" ")}`, result)
@@ -286,22 +510,67 @@ export const testRepo = (options: TestRepoOptions): TestRepo => {
286
510
  }
287
511
 
288
512
  /** Lands what an actor did: a judge's verdict answered, anything else validated and landed. */
289
- const act = async (doc: BeatDocument, verdict: void | string): Promise<void> => {
513
+ const act = async (doc: BeatDocument, returned: unknown): Promise<void> => {
290
514
  if (doc.actor !== "judge") {
291
515
  if (doc.validate !== undefined) apply(`validating ${doc.file ?? doc.state}`, doc.validate)
292
516
  await runWrite(["land"])
293
517
  return
294
518
  }
295
- if (typeof verdict !== "string") {
296
- throw new Error(`the actor for judge step "${doc.state}" returned no verdict`)
519
+ const actor = `the actor for judge step "${doc.state}"`
520
+ if (returned === undefined) {
521
+ throw new Error(`${actor} returned no verdict\n${expectedVerdict(doc)}`)
522
+ }
523
+ const sent = isVerdict(returned) ? JSON.stringify(returned.answers) : returned
524
+ if (typeof sent !== "string") {
525
+ throw new Error(
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)}`,
527
+ )
297
528
  }
298
- await runWrite(["judge", "answer"], verdict)
529
+ await runWrite(
530
+ ["judge", "answer"],
531
+ sent,
532
+ (error) =>
533
+ new Error(
534
+ `${actor} sent a verdict gtd refused:\n${sent}\n${expectedVerdict(doc)}\n${error.message}`,
535
+ ),
536
+ )
537
+ }
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
299
564
  }
300
565
 
301
566
  const self: TestRepo = {
302
567
  command: (command) => {
303
568
  canned.push(command)
304
569
  },
570
+ setEnv: (name, value) => {
571
+ if (value === undefined) delete env[name]
572
+ else env[name] = value
573
+ },
305
574
  branch: (name, ref = "HEAD") => repo.createBranch(name, ref),
306
575
  checkout: (name) => repo.checkoutBranch(name),
307
576
  ref: (name, ref = "HEAD") => {
@@ -316,33 +585,40 @@ export const testRepo = (options: TestRepoOptions): TestRepo => {
316
585
  remove: (path) => repo.deleteFile(path),
317
586
  commit: (message) => repo.seedCommit(message),
318
587
  show: (ref, path) => repo.fileAtRef(ref, path) ?? undefined,
319
- runScript: (script) => {
320
- const flow = applyFlowScript(repo, commands, script)
321
- 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)
322
592
  else if (!flow.ok) throw new Error(`testkit: ${flow.error ?? ""}`)
323
593
  },
324
594
  gtd,
325
- start: (workflow, ...args) => begin(["--workflow", workflow, ...args]),
326
- door: (name, ...args) => begin(["door", name, ...args]),
595
+ start: (name, ...args) => begin(["start", name, ...args]),
596
+ door: (name, ...args) => begin(["start", name, ...args]),
327
597
  land: () => runWrite(["land"]),
328
598
  next,
329
- drive: async (actors = {}, { maxBeats = 50 } = {}) => {
330
- for (let beat = 0; beat < maxBeats; beat++) {
599
+ drive: async (actors = {}, options = {}) => {
600
+ const { maxBeats = 50 } = options
601
+ const passed: string[] = []
602
+ for (let n = 0; n < maxBeats; n++) {
331
603
  const doc = await next()
332
- if (doc.kind === "stalled") return doc
333
- if (doc.kind === "capture") {
334
- await runWrite(["land"])
335
- continue
336
- }
337
- const actor = actors[doc.state]
338
- if (actor === undefined) return doc
339
- await act(doc, await actor(doc, self))
604
+ passed.push(doc.state)
605
+ const rest = await beat(doc, actors, options)
606
+ if (rest !== undefined) return rest
340
607
  }
341
- throw new Error(`the process did not rest within ${maxBeats} beats`)
608
+ throw new Error(
609
+ `the process did not rest within ${maxBeats} beats; it passed through:\n${passed.join(" → ")}`,
610
+ )
342
611
  },
343
612
  subjects: () => repo.commitHistory().map(({ message }) => message.split("\n")[0] ?? ""),
344
613
  messages: () => repo.commitHistory().map(({ message }) => message),
345
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
+ })),
346
622
  }
347
623
  return self
348
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
+ }