@pmelab/gtd 58.1.0 → 59.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 (142) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +115 -390
  3. package/dist/chunks/{Schema-B3o6eRNU.mjs → testkit-CMBAAyRK.mjs} +13409 -3351
  4. package/dist/gtd.bundle.mjs +76 -59
  5. package/dist/testkit/gherkin.mjs +30 -2
  6. package/dist/testkit.mjs +1 -1
  7. package/package.json +3 -42
  8. package/skills/authoring/SKILL.md +31 -25
  9. package/src/cli/Cli.test.ts +15 -19
  10. package/src/cli/Cli.ts +2 -4
  11. package/src/cli/Program.test.ts +60 -29
  12. package/src/cli/Program.ts +5 -7
  13. package/src/edge/Edge.ts +3 -3
  14. package/src/flags/index.test.ts +0 -13
  15. package/src/flags/index.ts +1 -3
  16. package/src/gherkin/git-control.feature +78 -0
  17. package/src/gherkin/index.ts +35 -1
  18. package/src/install/Install.test.ts +14 -11
  19. package/src/install/Install.ts +38 -15
  20. package/src/install/index.ts +1 -2
  21. package/src/replay/Replay.test.ts +1 -1
  22. package/src/replay/index.ts +1 -1
  23. package/src/step/planStart.ts +6 -1
  24. package/src/testkit/TestRepo.test.ts +118 -2
  25. package/src/testkit/TestRepo.ts +57 -6
  26. package/src/testkit/index.ts +1 -0
  27. package/src/twins/EmittedScriptRecognizer.ts +12 -14
  28. package/src/twins/FlowScripts.ts +10 -9
  29. package/src/twins/InMemRepo.ts +26 -1
  30. package/src/twins/Layers.ts +12 -1
  31. package/src/twins/index.ts +6 -1
  32. package/src/workflow/index.ts +1 -0
  33. package/src/workflow/load.test.ts +138 -113
  34. package/src/workflow/load.ts +1 -1
  35. package/src/workflow/vars.test.ts +30 -3
  36. package/src/workflow/vars.ts +34 -2
  37. package/src/workflow-kit/index.ts +1 -1
  38. package/dist/chunks/reserved-lMYCaapY.mjs +0 -1990
  39. package/dist/chunks/review-Bfaror38.mjs +0 -427
  40. package/dist/chunks/tail-Bd7L940p.mjs +0 -355
  41. package/dist/chunks/testkit-D1-auYau.mjs +0 -10362
  42. package/dist/workflows/cleanup.mjs +0 -36
  43. package/dist/workflows/feature.mjs +0 -1018
  44. package/dist/workflows/fix.mjs +0 -41
  45. package/dist/workflows/review.mjs +0 -45
  46. package/dist/workflows/rollout.mjs +0 -48
  47. package/dist/workflows.mjs +0 -40651
  48. package/src/install/ConfigScaffold.ts +0 -22
  49. package/src/workflows/cleanup/cleanup-path.feature +0 -94
  50. package/src/workflows/cleanup/cleanup.feature +0 -315
  51. package/src/workflows/cleanup/cleanup.test.ts +0 -276
  52. package/src/workflows/cleanup/cleanup.ts +0 -52
  53. package/src/workflows/cleanup/index.ts +0 -6
  54. package/src/workflows/feature/acceptance-criteria-tier.feature +0 -25
  55. package/src/workflows/feature/acceptance-criteria.feature +0 -389
  56. package/src/workflows/feature/criteria-coverage.feature +0 -102
  57. package/src/workflows/feature/criteria.test.ts +0 -147
  58. package/src/workflows/feature/criteria.ts +0 -267
  59. package/src/workflows/feature/fast-path.feature +0 -93
  60. package/src/workflows/feature/feature.test.ts +0 -338
  61. package/src/workflows/feature/feature.ts +0 -156
  62. package/src/workflows/feature/green.ts +0 -19
  63. package/src/workflows/feature/index.ts +0 -18
  64. package/src/workflows/feature/lens-models.feature +0 -69
  65. package/src/workflows/feature/lens-review.feature +0 -566
  66. package/src/workflows/feature/naive-acceptance.feature +0 -147
  67. package/src/workflows/feature/naive-test.feature +0 -263
  68. package/src/workflows/feature/one-step-land.feature +0 -93
  69. package/src/workflows/feature/planning.ts +0 -41
  70. package/src/workflows/feature/refactor-absorbs.feature +0 -131
  71. package/src/workflows/feature/refactor-first.feature +0 -158
  72. package/src/workflows/feature/refactor-lens-models.feature +0 -79
  73. package/src/workflows/feature/refactor-rebuild.feature +0 -273
  74. package/src/workflows/feature/refactor-review.feature +0 -1528
  75. package/src/workflows/feature/refactor.test.ts +0 -1220
  76. package/src/workflows/feature/refactor.ts +0 -368
  77. package/src/workflows/feature/release-flag.feature +0 -167
  78. package/src/workflows/feature/release-flag.test.ts +0 -49
  79. package/src/workflows/feature/release-flag.ts +0 -46
  80. package/src/workflows/feature/review-gate.feature +0 -241
  81. package/src/workflows/feature/reviewer-walkthrough.feature +0 -142
  82. package/src/workflows/feature/risk-tier.feature +0 -351
  83. package/src/workflows/feature/seam-rejection.feature +0 -146
  84. package/src/workflows/feature/seam.feature +0 -297
  85. package/src/workflows/feature/seam.test.ts +0 -527
  86. package/src/workflows/feature/seam.ts +0 -426
  87. package/src/workflows/feature/standards.feature +0 -354
  88. package/src/workflows/feature/steps.test.ts +0 -65
  89. package/src/workflows/feature/steps.ts +0 -45
  90. package/src/workflows/feature/tier-pin.feature +0 -66
  91. package/src/workflows/feature/tier.test.ts +0 -166
  92. package/src/workflows/feature/tier.ts +0 -56
  93. package/src/workflows/fix/fix-lens-origin.feature +0 -114
  94. package/src/workflows/fix/fix-quality.feature +0 -212
  95. package/src/workflows/fix/fix.feature +0 -79
  96. package/src/workflows/fix/fix.test.ts +0 -74
  97. package/src/workflows/fix/fix.ts +0 -52
  98. package/src/workflows/fix/index.ts +0 -6
  99. package/src/workflows/index.ts +0 -47
  100. package/src/workflows/review/index.ts +0 -6
  101. package/src/workflows/review/review-door.feature +0 -199
  102. package/src/workflows/review/review.test.ts +0 -27
  103. package/src/workflows/review/review.ts +0 -60
  104. package/src/workflows/rollout/index.ts +0 -6
  105. package/src/workflows/rollout/rollout.feature +0 -510
  106. package/src/workflows/rollout/rollout.test.ts +0 -263
  107. package/src/workflows/rollout/rollout.ts +0 -82
  108. package/src/workflows/shared/__fixtures__/feature-collecting-prompt.txt +0 -98
  109. package/src/workflows/shared/access.test.ts +0 -62
  110. package/src/workflows/shared/access.ts +0 -24
  111. package/src/workflows/shared/config.fixture.ts +0 -12
  112. package/src/workflows/shared/fences.ts +0 -24
  113. package/src/workflows/shared/health.test.ts +0 -148
  114. package/src/workflows/shared/health.ts +0 -191
  115. package/src/workflows/shared/learn.test.ts +0 -140
  116. package/src/workflows/shared/learn.ts +0 -201
  117. package/src/workflows/shared/lenses.test.ts +0 -39
  118. package/src/workflows/shared/lenses.ts +0 -294
  119. package/src/workflows/shared/options.test.ts +0 -157
  120. package/src/workflows/shared/options.ts +0 -92
  121. package/src/workflows/shared/progress.feature +0 -124
  122. package/src/workflows/shared/prose.ts +0 -263
  123. package/src/workflows/shared/recheck.feature +0 -60
  124. package/src/workflows/shared/registry.test.ts +0 -345
  125. package/src/workflows/shared/registry.ts +0 -183
  126. package/src/workflows/shared/reserved.ts +0 -11
  127. package/src/workflows/shared/review.test.ts +0 -1062
  128. package/src/workflows/shared/review.ts +0 -446
  129. package/src/workflows/shared/skills.test.ts +0 -121
  130. package/src/workflows/shared/skills.ts +0 -49
  131. package/src/workflows/shared/standards.test.ts +0 -90
  132. package/src/workflows/shared/standards.ts +0 -156
  133. package/src/workflows/shared/steps.test.ts +0 -219
  134. package/src/workflows/shared/steps.ts +0 -201
  135. package/src/workflows/shared/tail.test.ts +0 -454
  136. package/src/workflows/shared/tail.ts +0 -264
  137. package/src/workflows/shared/text.fixture.ts +0 -128
  138. package/src/workflows/shared/text.test.ts +0 -1430
  139. package/src/workflows/shared/text.ts +0 -1700
  140. package/src/workflows/shared/threads.test.ts +0 -129
  141. package/src/workflows/shared/vars.ts +0 -47
  142. package/src/workflows/shared/workflows.steps.ts +0 -54
@@ -12,7 +12,16 @@ import {
12
12
  softResetTo,
13
13
  updateRef,
14
14
  } from "../emit/index.js"
15
- import { agent, human, moveScript, read, removeScript, run } from "../flows/index.js"
15
+ import {
16
+ agent,
17
+ check,
18
+ human,
19
+ moveScript,
20
+ read,
21
+ removeScript,
22
+ run,
23
+ showScript,
24
+ } from "../flows/index.js"
16
25
  import { runsItsScript, testRepo } from "./TestRepo.js"
17
26
 
18
27
  const tiny = async (): Promise<void> => {
@@ -112,7 +121,7 @@ describe("testRepo", () => {
112
121
  it("carries the recognizer's error when a git-free fallback fails", () => {
113
122
  const repo = testRepo({ workflow: { default: tiny } })
114
123
  expect(() => repo.runScript("printf 'x' > 'y'\n")).toThrow(
115
- "testkit: cannot run script in memory — give the step an actor instead\nunrecognized script block: printf 'x' > 'y'",
124
+ "testkit: cannot run script in memory — add a canned command for it, or give the step an actor\nunrecognized script block: printf 'x' > 'y'",
116
125
  )
117
126
  })
118
127
 
@@ -194,4 +203,111 @@ describe("testRepo", () => {
194
203
  repo.drive({ spin: (_, r) => r.write("n", `${++n}\n`) }, { maxBeats: 5 }),
195
204
  ).rejects.toThrow(/did not rest within 5 beats/)
196
205
  })
206
+
207
+ describe("canned commands", () => {
208
+ const checked = async (): Promise<void> => {
209
+ const green = await check("test", "npm test", { report: ".gtd/FEEDBACK.md" })
210
+ await human(green ? "sign-off" : "red")
211
+ }
212
+
213
+ it("runs a check's command as canned, its output the report", async () => {
214
+ const repo = testRepo({
215
+ workflow: { default: checked },
216
+ commands: [{ run: "npm test", exit: 1, output: "1 failing" }],
217
+ })
218
+ repo.write("go", "\n")
219
+ const rest = await repo.drive({ test: runsItsScript })
220
+ expect(rest.state).toBe("red")
221
+ expect(repo.read(".gtd/FEEDBACK.md")).toContain("1 failing")
222
+ })
223
+
224
+ it("exits 0 by default, and matches by pattern", async () => {
225
+ const repo = testRepo({ workflow: { default: checked }, commands: [{ run: /^npm / }] })
226
+ repo.write("go", "\n")
227
+ expect((await repo.drive({ test: runsItsScript })).state).toBe("sign-off")
228
+ })
229
+
230
+ it("lets a command change the tree, and takes the first match", async () => {
231
+ const repo = testRepo({
232
+ workflow: { default: checked },
233
+ commands: [
234
+ { run: "npm test", effect: (r) => r.write("ran", "first\n") },
235
+ { run: /npm/, exit: 1 },
236
+ ],
237
+ })
238
+ repo.write("go", "\n")
239
+ expect((await repo.drive({ test: runsItsScript })).state).toBe("sign-off")
240
+ expect(repo.read("ran")).toBe("first\n")
241
+ })
242
+
243
+ it("takes a command added later", async () => {
244
+ const repo = testRepo({ workflow: { default: checked } })
245
+ repo.command({ run: "npm test", exit: 2 })
246
+ repo.write("go", "\n")
247
+ expect((await repo.drive({ test: runsItsScript })).state).toBe("red")
248
+ })
249
+
250
+ it("runs a plain script line as canned", () => {
251
+ const repo = testRepo({
252
+ workflow: { default: tiny },
253
+ commands: [{ run: "make docs", effect: (r) => r.write("docs.html", "<p>\n") }],
254
+ })
255
+ repo.runScript("make docs\n")
256
+ expect(repo.read("docs.html")).toBe("<p>\n")
257
+ })
258
+
259
+ it("fails the drive on a command none matches, naming it", async () => {
260
+ const repo = testRepo({ workflow: { default: checked } })
261
+ repo.write("go", "\n")
262
+ await expect(repo.drive({ test: runsItsScript })).rejects.toThrow(
263
+ "no canned command matches `npm test`",
264
+ )
265
+ })
266
+ })
267
+
268
+ describe("history", () => {
269
+ it("creates and checks out a branch, refusing uncommitted changes", () => {
270
+ const repo = testRepo({ workflow: { default: tiny }, files: { "a.txt": "main\n" } })
271
+ repo.branch("feature/x")
272
+ repo.checkout("feature/x")
273
+ expect(repo.branchName()).toBe("feature/x")
274
+ repo.write("a.txt", "feature\n")
275
+ expect(() => repo.checkout("main")).toThrow("uncommitted changes")
276
+ repo.commit("feat: x")
277
+ repo.checkout("main")
278
+ expect(repo.branchName()).toBe("main")
279
+ expect(repo.read("a.txt")).toBe("main\n")
280
+ expect(repo.show("feature/x", "a.txt")).toBe("feature\n")
281
+ })
282
+
283
+ it("branches at a given ref, and refuses an existing name or a missing ref", () => {
284
+ const repo = testRepo({ workflow: { default: tiny }, files: { "a.txt": "one\n" } })
285
+ repo.write("a.txt", "two\n")
286
+ repo.commit("chore: two")
287
+ repo.branch("old", "HEAD~1")
288
+ expect(repo.show("old", "a.txt")).toBe("one\n")
289
+ expect(() => repo.branch("old")).toThrow("already exists")
290
+ expect(() => repo.branch("new", "nowhere")).toThrow("not a valid object name")
291
+ })
292
+
293
+ it("points a remote ref that a script reads by its short name", async () => {
294
+ const restore = async (): Promise<void> => {
295
+ await run("restore", showScript("origin/main", "a.txt", "a.txt"))
296
+ await human("done")
297
+ }
298
+ const repo = testRepo({ workflow: { default: restore }, files: { "a.txt": "base\n" } })
299
+ repo.ref("refs/remotes/origin/main")
300
+ repo.write("a.txt", "local\n")
301
+ repo.commit("chore: local")
302
+ repo.write("go", "\n")
303
+ await repo.drive({ restore: runsItsScript })
304
+ expect(repo.read("a.txt")).toBe("base\n")
305
+ })
306
+
307
+ it("refuses a ref outside refs/ or at nothing", () => {
308
+ const repo = testRepo({ workflow: { default: tiny } })
309
+ expect(() => repo.ref("origin/main")).toThrow("must start with refs/")
310
+ expect(() => repo.ref("refs/tags/v1", "nowhere")).toThrow("resolves to no commit")
311
+ })
312
+ })
197
313
  })
@@ -8,6 +8,7 @@ import {
8
8
  InMemRepo,
9
9
  makeCapturingCliIo,
10
10
  type CapturedCliResult,
11
+ type CommandTable,
11
12
  } from "../twins/index.js"
12
13
 
13
14
  /**
@@ -18,8 +19,23 @@ import {
18
19
  */
19
20
  export type WorkflowModule = string | Readonly<Record<string, unknown>>
20
21
 
22
+ /**
23
+ * What a command a step's script runs does in memory. `run` matches the command
24
+ * as the script spells it, exactly or by a pattern; the first match wins.
25
+ */
26
+ export interface CannedCommand {
27
+ readonly run: string | RegExp
28
+ /** Defaults to 0. */
29
+ readonly exit?: number
30
+ readonly output?: string
31
+ /** What the command does to the tree, e.g. a formatter's rewrite. */
32
+ readonly effect?: (repo: TestRepo) => void
33
+ }
34
+
21
35
  export interface TestRepoOptions {
22
36
  readonly workflow: WorkflowModule
37
+ /** The commands a step's script may run. A command none matches fails the script. */
38
+ readonly commands?: readonly CannedCommand[]
23
39
  /** Committed alongside the config, before any process starts. */
24
40
  readonly files?: Readonly<Record<string, string>>
25
41
  /** The environment gtd sees; the real `process.env` is never read, except ambient `GTD_FLAG_*` (explicit env wins). */
@@ -42,6 +58,15 @@ export interface DriveOptions {
42
58
  }
43
59
 
44
60
  export interface TestRepo {
61
+ /** Adds a command a step's script may run, after those `testRepo` was given. */
62
+ command(command: CannedCommand): void
63
+ /** Creates branch `name` at `ref` (HEAD by default). Outside a process only. */
64
+ branch(name: string, ref?: string): void
65
+ /** Checks branch `name` out; refuses with uncommitted changes. Outside a process only. */
66
+ checkout(name: string): void
67
+ /** Points ref `name` (`refs/remotes/origin/main`, `refs/tags/v1`) at `ref` (HEAD by default). Outside a process only. */
68
+ ref(name: string, ref?: string): void
69
+ branchName(): string
45
70
  read(path: string): string | undefined
46
71
  write(path: string, content: string): void
47
72
  remove(path: string): void
@@ -158,19 +183,21 @@ const LINES: ReadonlyArray<readonly [RegExp, (repo: InMemRepo, words: string[])
158
183
  const knownLine = (line: string) => LINES.find(([pattern]) => pattern.test(line))
159
184
 
160
185
  const cannotRun = (error: string): Error =>
161
- new Error(`testkit: cannot run script in memory — give the step an actor instead\n${error}`)
186
+ new Error(
187
+ `testkit: cannot run script in memory — add a canned command for it, or give the step an actor\n${error}`,
188
+ )
162
189
 
163
190
  // A script with a line `LINES` lacks goes to the contract-tested recognizers
164
191
  // whole, minus the git builders: they would apply a commit/reset/update-ref in
165
192
  // memory, so any git-mutating block is refused whatever its spelling.
166
- const runLines = (repo: InMemRepo, script: string): void => {
193
+ const runLines = (repo: InMemRepo, commands: CommandTable, script: string): void => {
167
194
  const lines = script.split("\n").map((line) => line.trim())
168
195
  const known = lines.map(knownLine)
169
196
  if (known.every(Boolean)) {
170
197
  known.forEach((k, i) => k![1](repo, words(lines[i]!)))
171
198
  return
172
199
  }
173
- const applied = applyEmittedScript(repo, new Map(), script, { git: false })
200
+ const applied = applyEmittedScript(repo, commands, script, { git: false })
174
201
  if (!applied.ok) throw cannotRun(applied.error ?? "")
175
202
  }
176
203
 
@@ -189,6 +216,18 @@ export const testRepo = (options: TestRepoOptions): TestRepo => {
189
216
  for (const [path, content] of Object.entries(options.files ?? {})) repo.writeFile(path, content)
190
217
  repo.seedCommit("chore: initial commit")
191
218
 
219
+ const canned = [...(options.commands ?? [])]
220
+ const commands: CommandTable = {
221
+ get: (command) => {
222
+ const hit = canned.find(({ run }) =>
223
+ typeof run === "string" ? run === command : run.test(command),
224
+ )
225
+ if (hit === undefined) return undefined
226
+ const { exit = 0, output = "", effect } = hit
227
+ return { kind: "exit", status: exit, output, effect: () => effect?.(self) }
228
+ },
229
+ }
230
+
192
231
  // The module is registered only while gtd runs, so nothing outlives the
193
232
  // call that loads it and no test can reach another's module.
194
233
  const gtd = async (...args: string[]): Promise<CapturedCliResult> => {
@@ -217,7 +256,7 @@ export const testRepo = (options: TestRepoOptions): TestRepo => {
217
256
 
218
257
  const apply = (what: string, script: string): void => {
219
258
  if (script.length === 0) return
220
- const applied = applyEmittedScript(repo, new Map(), script)
259
+ const applied = applyEmittedScript(repo, commands, script)
221
260
  if (!applied.ok) throw new Error(`${what}: its script failed — ${applied.error ?? ""}`)
222
261
  }
223
262
 
@@ -260,14 +299,26 @@ export const testRepo = (options: TestRepoOptions): TestRepo => {
260
299
  }
261
300
 
262
301
  const self: TestRepo = {
302
+ command: (command) => {
303
+ canned.push(command)
304
+ },
305
+ branch: (name, ref = "HEAD") => repo.createBranch(name, ref),
306
+ checkout: (name) => repo.checkoutBranch(name),
307
+ ref: (name, ref = "HEAD") => {
308
+ if (!name.startsWith("refs/")) throw new Error(`testkit: ref "${name}" must start with refs/`)
309
+ const hash = repo.resolveRef(ref)
310
+ if (hash === null) throw new Error(`testkit: ref "${ref}" resolves to no commit`)
311
+ repo.updateRef(name, hash)
312
+ },
313
+ branchName: () => repo.branchName(),
263
314
  read: (path) => repo.readFile(path),
264
315
  write: (path, content) => repo.writeFile(path, content),
265
316
  remove: (path) => repo.deleteFile(path),
266
317
  commit: (message) => repo.seedCommit(message),
267
318
  show: (ref, path) => repo.fileAtRef(ref, path) ?? undefined,
268
319
  runScript: (script) => {
269
- const flow = applyFlowScript(repo, new Map(), script)
270
- if (flow === undefined) runLines(repo, script)
320
+ const flow = applyFlowScript(repo, commands, script)
321
+ if (flow === undefined) runLines(repo, commands, script)
271
322
  else if (!flow.ok) throw new Error(`testkit: ${flow.error ?? ""}`)
272
323
  },
273
324
  gtd,
@@ -6,6 +6,7 @@ export {
6
6
  testRepo,
7
7
  type Actor,
8
8
  type Actors,
9
+ type CannedCommand,
9
10
  type DriveOptions,
10
11
  type TestRepo,
11
12
  type TestRepoOptions,
@@ -36,7 +36,7 @@ import { refNameFault } from "../replay/index.js"
36
36
  import { renderFormat } from "../wire/index.js"
37
37
  import { applyFlowScript, rewritten } from "./FlowScripts.js"
38
38
  import type { InMemRepo } from "./InMemRepo.js"
39
- import type { ScriptedCommand } from "./Layers.js"
39
+ import type { CommandTable } from "./Layers.js"
40
40
 
41
41
  export interface AppliedScriptResult {
42
42
  readonly ok: boolean
@@ -266,10 +266,7 @@ const recognizeFileExistsGuard = (repo: InMemRepo, block: string): BlockOutcome
266
266
  /** `binaryGuard`. The in-memory tier has no `$PATH`: a binary is present unless declared `missing`. */
267
267
  const BINARY_GUARD_MESSAGE_RE = /^gtd: mode "([^"]+)": "(format|validate)" command not found: (.+)$/
268
268
 
269
- const recognizeBinaryGuard = (
270
- commands: ReadonlyMap<string, ScriptedCommand>,
271
- block: string,
272
- ): BlockOutcome | undefined => {
269
+ const recognizeBinaryGuard = (commands: CommandTable, block: string): BlockOutcome | undefined => {
273
270
  const [binary, , message] = extractQuotedTokens(block)
274
271
  if (binary === undefined || message === undefined) return undefined
275
272
  const match = BINARY_GUARD_MESSAGE_RE.exec(message)
@@ -330,7 +327,7 @@ const expandFileVar = (text: string): string | undefined => {
330
327
  */
331
328
  const recognizeModeCommand = (
332
329
  repo: InMemRepo,
333
- commands: ReadonlyMap<string, ScriptedCommand>,
330
+ commands: CommandTable,
334
331
  block: string,
335
332
  ): BlockOutcome | undefined => {
336
333
  const command = expandFileVar(block) ?? block
@@ -356,7 +353,7 @@ const FAILURE_PROMPT_FOOTER = ' "$gtd_validate_out"\n exit "$gtd_validate_statu
356
353
 
357
354
  const recognizeFailurePromptWrapper = (
358
355
  repo: InMemRepo,
359
- commands: ReadonlyMap<string, ScriptedCommand>,
356
+ commands: CommandTable,
360
357
  block: string,
361
358
  ): BlockOutcome | undefined => {
362
359
  if (!block.startsWith(FAILURE_PROMPT_HEADER) || !block.endsWith(FAILURE_PROMPT_FOOTER)) {
@@ -465,7 +462,7 @@ const parseModeContradictionCheck = (block: string): ParsedModeContradictionChec
465
462
  */
466
463
  const simulateModeContradictionCheck = (
467
464
  repo: InMemRepo,
468
- commands: ReadonlyMap<string, ScriptedCommand>,
465
+ commands: CommandTable,
469
466
  parsed: ParsedModeContradictionCheck,
470
467
  ): BlockOutcome => {
471
468
  const { mode, samplePath, sample, formatCommand, format } = parsed
@@ -502,7 +499,7 @@ const simulateModeContradictionCheck = (
502
499
  */
503
500
  const recognizeModeContradictionCheck = (
504
501
  repo: InMemRepo,
505
- commands: ReadonlyMap<string, ScriptedCommand>,
502
+ commands: CommandTable,
506
503
  block: string,
507
504
  ): BlockOutcome | undefined => {
508
505
  const parsed = parseModeContradictionCheck(block)
@@ -570,7 +567,7 @@ const PRESENTATION_SUBSHELL_SUFFIX = `\n) || ${PRESENTATION_FAILURE_WARNING}`
570
567
 
571
568
  const recognizePresentationSubshell = (
572
569
  repo: InMemRepo,
573
- commands: ReadonlyMap<string, ScriptedCommand>,
570
+ commands: CommandTable,
574
571
  block: string,
575
572
  git: boolean,
576
573
  ): BlockOutcome | undefined => {
@@ -595,7 +592,7 @@ const recognizePresentationSubshell = (
595
592
  /** Anything left over must be an EXACT hit in the scripted-command table — mirrors `ScriptedCommand`'s two `kind`s (`Layers.ts`). */
596
593
  const recognizeScriptedCommand = (
597
594
  repo: InMemRepo,
598
- commands: ReadonlyMap<string, ScriptedCommand>,
595
+ commands: CommandTable,
599
596
  block: string,
600
597
  ): BlockOutcome | undefined => {
601
598
  const scripted = commands.get(block)
@@ -607,6 +604,7 @@ const recognizeScriptedCommand = (
607
604
  if (scripted.kind === "missing") {
608
605
  return { kind: "failed", error: `sh: ${block.split(" ")[0]}: not found`, status: 127 }
609
606
  }
607
+ scripted.effect?.()
610
608
  if (scripted.status !== 0) {
611
609
  return { kind: "failed", error: scripted.output, status: scripted.status }
612
610
  }
@@ -693,7 +691,7 @@ const recognizeWriteLines = (repo: InMemRepo, block: string): BlockOutcome | und
693
691
  /** Every recognizer `applyEmittedScript` tries, first match wins — `recognizeScriptedCommand` stays last since it's the exact-hit fallback for anything the rest don't claim. */
694
692
  const recognizersFor = (
695
693
  repo: InMemRepo,
696
- commands: ReadonlyMap<string, ScriptedCommand>,
694
+ commands: CommandTable,
697
695
  git: boolean,
698
696
  ): ReadonlyArray<(block: string) => BlockOutcome | undefined> => [
699
697
  (block) => recognizeSetFlags(block),
@@ -735,7 +733,7 @@ const recognizeBlock = (
735
733
  */
736
734
  export const applyEmittedScript = (
737
735
  repo: InMemRepo,
738
- commands: ReadonlyMap<string, ScriptedCommand>,
736
+ commands: CommandTable,
739
737
  script: string,
740
738
  { git = true }: { readonly git?: boolean } = {},
741
739
  ): AppliedScriptResult => {
@@ -750,7 +748,7 @@ export const applyEmittedScript = (
750
748
 
751
749
  const applyBlocks = (
752
750
  repo: InMemRepo,
753
- commands: ReadonlyMap<string, ScriptedCommand>,
751
+ commands: CommandTable,
754
752
  script: string,
755
753
  git: boolean,
756
754
  ): AppliedScriptResult => {
@@ -15,7 +15,7 @@ import {
15
15
  writeScript,
16
16
  } from "../flows/index.js"
17
17
  import type { InMemRepo } from "./InMemRepo.js"
18
- import type { ScriptedCommand } from "./Layers.js"
18
+ import type { CommandTable, ScriptedCommand } from "./Layers.js"
19
19
 
20
20
  export interface FlowScriptResult {
21
21
  readonly ok: boolean
@@ -224,7 +224,7 @@ const recognizePatch = (repo: InMemRepo, script: string): FlowScriptResult | und
224
224
 
225
225
  type PreambleOutcome = { readonly exit: boolean; readonly output: string }
226
226
 
227
- /** One preamble line — the closed vocabulary the bundled workflows write there. */
227
+ /** One preamble line — the closed vocabulary workflows write there. */
228
228
  const runPreambleLine = (repo: InMemRepo, line: string): PreambleOutcome => {
229
229
  const quoted = words(line)
230
230
  if (line === "exit 0") return { exit: true, output: "" }
@@ -299,16 +299,17 @@ const parseCheck = (script: string): ParsedCheck | undefined => {
299
299
 
300
300
  const runCommand = (
301
301
  repo: InMemRepo,
302
- commands: ReadonlyMap<string, ScriptedCommand>,
302
+ commands: CommandTable,
303
303
  command: string,
304
304
  ): { readonly status: number; readonly output: string } => {
305
305
  const scripted = commands.get(command)
306
306
  if (scripted === undefined) {
307
- throw new Error(
308
- `testkit: cannot run \`${command}\` in memory — script it ("the shell command … exits …")`,
309
- )
307
+ throw new Error(`testkit: no canned command matches \`${command}\` — add one to \`commands\``)
308
+ }
309
+ if (scripted.kind === "exit") {
310
+ scripted.effect?.()
311
+ return scripted
310
312
  }
311
- if (scripted.kind === "exit") return scripted
312
313
  if (scripted.kind === "missing") return { status: 127, output: `sh: ${command}: not found\n` }
313
314
  repo.writeFile(scripted.file, rewritten(repo, scripted))
314
315
  return { status: 0, output: "" }
@@ -325,7 +326,7 @@ export const rewritten = (
325
326
 
326
327
  const recognizeCheck = (
327
328
  repo: InMemRepo,
328
- commands: ReadonlyMap<string, ScriptedCommand>,
329
+ commands: CommandTable,
329
330
  script: string,
330
331
  ): FlowScriptResult | undefined => {
331
332
  const parsed = parseCheck(script)
@@ -362,7 +363,7 @@ const recognizeCheck = (
362
363
  */
363
364
  export const applyFlowScript = (
364
365
  repo: InMemRepo,
365
- commands: ReadonlyMap<string, ScriptedCommand>,
366
+ commands: CommandTable,
366
367
  script: string,
367
368
  ): FlowScriptResult | undefined => {
368
369
  if (!script.startsWith(`${SHEBANG}\n`)) return undefined
@@ -134,7 +134,10 @@ export class InMemRepo {
134
134
  const refHash = this.refs.get(ref)
135
135
  if (refHash !== undefined) return refHash
136
136
 
137
- return this.branches.get(ref) ?? null
137
+ // git's own short names: `origin/main` for refs/remotes/origin/main, …
138
+ const short = this.refs.get(`refs/tags/${ref}`) ?? this.refs.get(`refs/remotes/${ref}`)
139
+ if (short !== undefined) return short
140
+ return this.branches.get(ref.replace(/^refs\/heads\//, "")) ?? null
138
141
  }
139
142
 
140
143
  lastCommitSubject(ref = "HEAD"): string | null {
@@ -154,6 +157,28 @@ export class InMemRepo {
154
157
  return this.currentBranch
155
158
  }
156
159
 
160
+ /** `git branch <name> <ref>`. */
161
+ createBranch(name: string, ref: string): void {
162
+ if (this.branches.has(name)) throw new Error(`a branch named '${name}' already exists`)
163
+ const hash = this.resolveRef(ref)
164
+ if (hash === null) throw new Error(`not a valid object name: '${ref}'`)
165
+ this.branches.set(name, hash)
166
+ }
167
+
168
+ /** `git checkout <name>`, refusing any uncommitted change rather than carrying it over. */
169
+ checkoutBranch(name: string): void {
170
+ const hash = this.branches.get(name)
171
+ if (hash === undefined) throw new Error(`pathspec '${name}' did not match any branch`)
172
+ if (this.statusPorcelain().trim() !== "") {
173
+ throw new Error(`cannot check out '${name}': the working tree has uncommitted changes`)
174
+ }
175
+ const tree = this.getCommit(hash)?.files ?? new Map<string, string>()
176
+ this.head = hash
177
+ this.currentBranch = name
178
+ this.worktree = new Map(tree)
179
+ this.index = new Map(tree)
180
+ }
181
+
157
182
  /** Refs under `prefix`, newest tip first; the commit sequence number stands in for the committer date. */
158
183
  listRefs(prefix: string): Array<{ ref: string; hash: string; date: string }> {
159
184
  return [...this.refs.entries()]
@@ -169,7 +169,13 @@ const makeInMemoryConfigDiscovery = (
169
169
 
170
170
  /** One scripted `bash` command's canned behavior, keyed by the RENDERED command string a scenario's `Given` step declares. */
171
171
  export type ScriptedCommand =
172
- | { readonly kind: "exit"; readonly status: number; readonly output: string }
172
+ | {
173
+ readonly kind: "exit"
174
+ readonly status: number
175
+ readonly output: string
176
+ /** What the command does to the tree before it exits. */
177
+ readonly effect?: () => void
178
+ }
173
179
  | {
174
180
  readonly kind: "rewrite"
175
181
  readonly file: string
@@ -180,6 +186,11 @@ export type ScriptedCommand =
180
186
  /** Keyed by the binary's name: it is not on `$PATH`. */
181
187
  | { readonly kind: "missing" }
182
188
 
189
+ /** The scripted commands, by the exact command a script runs; a `Map` is one. */
190
+ export interface CommandTable {
191
+ get(command: string): ScriptedCommand | undefined
192
+ }
193
+
183
194
  export interface TestWorldOptions {
184
195
  readonly env?: Readonly<Record<string, string | undefined>>
185
196
  readonly root?: string
@@ -1,5 +1,10 @@
1
1
  export { InMemRepo } from "./InMemRepo.js"
2
- export { makeInMemoryWorkspaceOps, testLayers, type ScriptedCommand } from "./Layers.js"
2
+ export {
3
+ makeInMemoryWorkspaceOps,
4
+ testLayers,
5
+ type CommandTable,
6
+ type ScriptedCommand,
7
+ } from "./Layers.js"
3
8
  export { makeCapturingCliIo, type CapturedCliResult } from "./cliIo.js"
4
9
  export { applyEmittedScript } from "./EmittedScriptRecognizer.js"
5
10
  export { applyFlowScript } from "./FlowScripts.js"
@@ -15,6 +15,7 @@ export {
15
15
  export { formatDiagnostic } from "./Diagnostic.js"
16
16
  export {
17
17
  resolveVars,
18
+ ownFlagRefusal,
18
19
  resolveStartVars,
19
20
  StartFlags,
20
21
  multilineSetting,