@pmelab/gtd 58.0.0 → 58.2.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 (55) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +3 -2
  3. package/dist/chunks/{Schema-C6fLlQzx.mjs → Schema-B3o6eRNU.mjs} +637 -3957
  4. package/dist/chunks/{reserved-BzAFXuz_.mjs → reserved-lMYCaapY.mjs} +1 -1
  5. package/dist/chunks/{review-CVqRS0mc.mjs → review-Bfaror38.mjs} +2 -2
  6. package/dist/chunks/{tail-JQzB21Q3.mjs → tail-Bd7L940p.mjs} +1 -1
  7. package/dist/chunks/testkit-JDtpNKyi.mjs +10410 -0
  8. package/dist/gtd.bundle.mjs +8 -8
  9. package/dist/testkit/gherkin.mjs +283 -0
  10. package/dist/testkit.mjs +2 -15461
  11. package/dist/workflows/cleanup.mjs +2 -2
  12. package/dist/workflows/feature.mjs +3 -3
  13. package/dist/workflows/fix.mjs +2 -2
  14. package/dist/workflows/review.mjs +3 -3
  15. package/dist/workflows/rollout.mjs +1 -1
  16. package/package.json +8 -1
  17. package/src/cli/Cli.test.ts +1 -1
  18. package/src/cli/History.test.ts +1 -1
  19. package/src/cli/Metrics.test.ts +1 -1
  20. package/src/cli/Program.test.ts +1 -1
  21. package/src/cli/Strays.test.ts +1 -1
  22. package/src/edge/Edge.test.ts +1 -1
  23. package/src/emit/Emit.ts +1 -1
  24. package/src/emit/GitScript.ts +1 -1
  25. package/src/emit/OutcomeScript.ts +1 -1
  26. package/src/gherkin/git-control.feature +78 -0
  27. package/src/{testkit/steps.ts → gherkin/index.ts} +55 -4
  28. package/src/gherkin/module-config.feature +24 -0
  29. package/src/gherkin/tiny.fixture.ts +8 -0
  30. package/src/platform/Git.test.ts +2 -2
  31. package/src/platform/RetainedHistory.test.ts +2 -2
  32. package/src/platform/Workspace.test.ts +2 -2
  33. package/src/platform/WorktreeState.test.ts +1 -1
  34. package/src/step/planStart.test.ts +1 -1
  35. package/src/testkit/TestRepo.test.ts +118 -2
  36. package/src/testkit/TestRepo.ts +64 -10
  37. package/src/testkit/index.ts +16 -9
  38. package/src/{testkit → twins}/EmittedScriptRecognizer.ts +12 -14
  39. package/src/{testkit → twins}/FlowScripts.ts +9 -8
  40. package/src/{testkit → twins}/InMemRepo.ts +26 -1
  41. package/src/{testkit → twins}/Layers.ts +14 -3
  42. package/src/twins/index.ts +13 -0
  43. package/src/workflow/discovery.test.ts +1 -1
  44. package/src/workflow/discovery.ts +3 -3
  45. package/src/workflow/load.ts +3 -3
  46. /package/src/{testkit → gherkin}/testkit.feature +0 -0
  47. /package/src/{testkit → twins}/EmittedScriptRecognizer.test.ts +0 -0
  48. /package/src/{testkit → twins}/EmittedScripts.test.ts +0 -0
  49. /package/src/{testkit → twins}/FakeGitOperations.ts +0 -0
  50. /package/src/{testkit → twins}/FlowScripts.test.ts +0 -0
  51. /package/src/{testkit → twins}/GitTiers.test.ts +0 -0
  52. /package/src/{testkit → twins}/GitTiers.ts +0 -0
  53. /package/src/{testkit → twins}/Layers.test.ts +0 -0
  54. /package/src/{testkit → twins}/StrictGitOperations.ts +0 -0
  55. /package/src/{testkit → twins}/cliIo.ts +0 -0
@@ -2,10 +2,14 @@ import { Effect } from "effect"
2
2
  import { PassThrough } from "node:stream"
3
3
  import { runCli } from "../cli/index.js"
4
4
  import { EXIT_OK, type BeatDocument } from "../wire/index.js"
5
- import { makeCapturingCliIo, type CapturedCliResult } from "./cliIo.js"
6
- import { applyEmittedScript } from "./EmittedScriptRecognizer.js"
7
- import { applyFlowScript } from "./FlowScripts.js"
8
- import { InMemRepo } from "./InMemRepo.js"
5
+ import {
6
+ applyEmittedScript,
7
+ applyFlowScript,
8
+ InMemRepo,
9
+ makeCapturingCliIo,
10
+ type CapturedCliResult,
11
+ type CommandTable,
12
+ } from "../twins/index.js"
9
13
 
10
14
  /**
11
15
  * The repository's `gtd.config.ts`: its source, or the module itself. A module
@@ -15,8 +19,23 @@ import { InMemRepo } from "./InMemRepo.js"
15
19
  */
16
20
  export type WorkflowModule = string | Readonly<Record<string, unknown>>
17
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
+
18
35
  export interface TestRepoOptions {
19
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[]
20
39
  /** Committed alongside the config, before any process starts. */
21
40
  readonly files?: Readonly<Record<string, string>>
22
41
  /** The environment gtd sees; the real `process.env` is never read, except ambient `GTD_FLAG_*` (explicit env wins). */
@@ -39,6 +58,15 @@ export interface DriveOptions {
39
58
  }
40
59
 
41
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
42
70
  read(path: string): string | undefined
43
71
  write(path: string, content: string): void
44
72
  remove(path: string): void
@@ -155,19 +183,21 @@ const LINES: ReadonlyArray<readonly [RegExp, (repo: InMemRepo, words: string[])
155
183
  const knownLine = (line: string) => LINES.find(([pattern]) => pattern.test(line))
156
184
 
157
185
  const cannotRun = (error: string): Error =>
158
- 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
+ )
159
189
 
160
190
  // A script with a line `LINES` lacks goes to the contract-tested recognizers
161
191
  // whole, minus the git builders: they would apply a commit/reset/update-ref in
162
192
  // memory, so any git-mutating block is refused whatever its spelling.
163
- const runLines = (repo: InMemRepo, script: string): void => {
193
+ const runLines = (repo: InMemRepo, commands: CommandTable, script: string): void => {
164
194
  const lines = script.split("\n").map((line) => line.trim())
165
195
  const known = lines.map(knownLine)
166
196
  if (known.every(Boolean)) {
167
197
  known.forEach((k, i) => k![1](repo, words(lines[i]!)))
168
198
  return
169
199
  }
170
- const applied = applyEmittedScript(repo, new Map(), script, { git: false })
200
+ const applied = applyEmittedScript(repo, commands, script, { git: false })
171
201
  if (!applied.ok) throw cannotRun(applied.error ?? "")
172
202
  }
173
203
 
@@ -186,6 +216,18 @@ export const testRepo = (options: TestRepoOptions): TestRepo => {
186
216
  for (const [path, content] of Object.entries(options.files ?? {})) repo.writeFile(path, content)
187
217
  repo.seedCommit("chore: initial commit")
188
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
+
189
231
  // The module is registered only while gtd runs, so nothing outlives the
190
232
  // call that loads it and no test can reach another's module.
191
233
  const gtd = async (...args: string[]): Promise<CapturedCliResult> => {
@@ -214,7 +256,7 @@ export const testRepo = (options: TestRepoOptions): TestRepo => {
214
256
 
215
257
  const apply = (what: string, script: string): void => {
216
258
  if (script.length === 0) return
217
- const applied = applyEmittedScript(repo, new Map(), script)
259
+ const applied = applyEmittedScript(repo, commands, script)
218
260
  if (!applied.ok) throw new Error(`${what}: its script failed — ${applied.error ?? ""}`)
219
261
  }
220
262
 
@@ -257,14 +299,26 @@ export const testRepo = (options: TestRepoOptions): TestRepo => {
257
299
  }
258
300
 
259
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(),
260
314
  read: (path) => repo.readFile(path),
261
315
  write: (path, content) => repo.writeFile(path, content),
262
316
  remove: (path) => repo.deleteFile(path),
263
317
  commit: (message) => repo.seedCommit(message),
264
318
  show: (ref, path) => repo.fileAtRef(ref, path) ?? undefined,
265
319
  runScript: (script) => {
266
- const flow = applyFlowScript(repo, new Map(), script)
267
- if (flow === undefined) runLines(repo, script)
320
+ const flow = applyFlowScript(repo, commands, script)
321
+ if (flow === undefined) runLines(repo, commands, script)
268
322
  else if (!flow.ok) throw new Error(`testkit: ${flow.error ?? ""}`)
269
323
  },
270
324
  gtd,
@@ -1,9 +1,16 @@
1
- export { runsItsScript, testRepo, type Actor, type TestRepo } from "./TestRepo.js"
2
- export { InMemRepo } from "./InMemRepo.js"
3
- export { makeInMemoryWorkspaceOps, testLayers, type ScriptedCommand } from "./Layers.js"
4
- export { makeCapturingCliIo } from "./cliIo.js"
5
- export { applyEmittedScript } from "./EmittedScriptRecognizer.js"
6
- export { gitTiers, runGitServiceContract, runGitScriptContract, type GitTier } from "./GitTiers.js"
7
- export { strictGitOperations } from "./StrictGitOperations.js"
8
- export { fakeGitOperations } from "./FakeGitOperations.js"
9
- export type { TestkitWorld } from "./steps.js"
1
+ // The public testkit: runs any workflow against the real engine in an in-memory
2
+ // repository. It imports no test runner, so any runner can use it; the
3
+ // gherkin steps are the opt-in \`@pmelab/gtd/testkit/gherkin\`.
4
+ export {
5
+ runsItsScript,
6
+ testRepo,
7
+ type Actor,
8
+ type Actors,
9
+ type CannedCommand,
10
+ type DriveOptions,
11
+ type TestRepo,
12
+ type TestRepoOptions,
13
+ type WorkflowModule,
14
+ } from "./TestRepo.js"
15
+ export { InMemRepo, type CapturedCliResult } from "../twins/index.js"
16
+ export type { TestkitWorld } from "../gherkin/index.js"
@@ -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
@@ -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()]
@@ -4,7 +4,7 @@
4
4
  // gtd itself spawns no subprocess at all any more (`gtd renders; the driver
5
5
  // executes`) — a mode's `format:`/`validate:` command is emitted text, never
6
6
  // run in-process. `ScriptedCommand` below still exists for the world's own
7
- // fake-shell interpreter (`src/testkit/EmittedScriptRecognizer.ts`), which
7
+ // fake-shell interpreter (`src/twins/EmittedScriptRecognizer.ts`), which
8
8
  // simulates a driver running gtd's emitted scripts against the in-memory
9
9
  // worktree — a wholly separate mechanism from any gtd-internal layer.
10
10
 
@@ -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
@@ -193,7 +204,7 @@ export interface TestWorldOptions {
193
204
  * Effect test has no `--verbose` flag of its own to gate on.
194
205
  */
195
206
  readonly narrate?: (line: string) => void
196
- /** Gates `narrate` above — defaults to `true` (fires whenever a sink is given) since these tests aren't exercising `Cli.ts`'s own `--verbose` gate. `src/testkit/cliIo.ts` overrides this with the real decoded flag. */
207
+ /** Gates `narrate` above — defaults to `true` (fires whenever a sink is given) since these tests aren't exercising `Cli.ts`'s own `--verbose` gate. `src/twins/cliIo.ts` overrides this with the real decoded flag. */
197
208
  readonly verbose?: boolean
198
209
  /** Real or fake driver and tool commands — each one left out fails when called. */
199
210
  readonly tools?: Partial<ToolCommands["Type"]>
@@ -0,0 +1,13 @@
1
+ export { InMemRepo } from "./InMemRepo.js"
2
+ export {
3
+ makeInMemoryWorkspaceOps,
4
+ testLayers,
5
+ type CommandTable,
6
+ type ScriptedCommand,
7
+ } from "./Layers.js"
8
+ export { makeCapturingCliIo, type CapturedCliResult } from "./cliIo.js"
9
+ export { applyEmittedScript } from "./EmittedScriptRecognizer.js"
10
+ export { applyFlowScript } from "./FlowScripts.js"
11
+ export { gitTiers, runGitServiceContract, runGitScriptContract, type GitTier } from "./GitTiers.js"
12
+ export { strictGitOperations } from "./StrictGitOperations.js"
13
+ export { fakeGitOperations } from "./FakeGitOperations.js"
@@ -6,7 +6,7 @@ import { NodeContext } from "@effect/platform-node"
6
6
  import { Effect, Layer } from "effect"
7
7
  import { afterEach, describe, expect, it } from "vitest"
8
8
  import { GitService, Host, Narrator, Workspace } from "../platform/index.js"
9
- import { InMemRepo, testLayers } from "../testkit/index.js"
9
+ import { InMemRepo, testLayers } from "../twins/index.js"
10
10
  import { ConfigDiscovery, ConfigService } from "./index.js"
11
11
 
12
12
  // A config and the local modules it imports load the same from disk, where
@@ -38,7 +38,7 @@ export const WORKFLOW_MODULE = "gtd.config.ts"
38
38
  * The file names gtd read repository settings from before `gtd.config.ts` took
39
39
  * them over. Still looked for, only to refuse them: a leftover one would
40
40
  * otherwise be silently ignored. Shared with the in-memory counterpart in
41
- * `src/testkit/Layers.ts`, so both tiers refuse the same names.
41
+ * `src/twins/Layers.ts`, so both tiers refuse the same names.
42
42
  */
43
43
  export const LEGACY_RC_FILES = [
44
44
  ".gtdrc",
@@ -71,7 +71,7 @@ const legacyRcFileAt = (path: string): LegacyRcFile | undefined => {
71
71
  * the user's home dir (inclusive, when it is an ancestor) or after reaching the
72
72
  * filesystem root — whichever comes first. Returned innermost→outermost.
73
73
  * Exported (via the barrel) for the same reason `SEARCH_PLACES` is — one
74
- * shared implementation, not a second copy in `src/testkit/Layers.ts` that
74
+ * shared implementation, not a second copy in `src/twins/Layers.ts` that
75
75
  * could silently diverge.
76
76
  */
77
77
  export const walkUp = (from: string, home: string): ReadonlyArray<string> => {
@@ -115,7 +115,7 @@ const workflowModule = (
115
115
  * reads; discovery is effectful and environment-dependent: `.Live` reads
116
116
  * REAL disk, so an `@inmem` scenario's fake
117
117
  * `Workspace` — a pure in-memory `Map`, no real files anywhere on disk —
118
- * cannot back it. `src/testkit/Layers.ts` provides the in-memory
118
+ * cannot back it. `src/twins/Layers.ts` provides the in-memory
119
119
  * counterpart instead, mirroring how `Workspace`/`Host`/`GitService` already
120
120
  * each have a production and a fake layer behind one Tag.
121
121
  */
@@ -357,14 +357,14 @@ const shippedModules = (): Promise<ShippedModules> =>
357
357
  return { modules, missing }
358
358
  })())
359
359
 
360
- /** The file each subpath a config may import serves: all but `flows`, the engine's own, and `testkit`. */
360
+ /** The file each subpath a config may import serves: all but `flows`, the engine's own, and the testkit's. */
361
361
  const subpathFiles = (condition: string): ReadonlyMap<string, string> => {
362
362
  const { root, pkg } = ownPackage()
363
363
  const files = new Map<string, string>()
364
364
  for (const [subpath, target] of Object.entries(pkg.exports)) {
365
365
  const file = typeof target === "string" ? target : target[condition]
366
- // testkit imports the consumer's test runner, and no config runs one.
367
- if (subpath === "./flows" || subpath === "./testkit" || file === undefined) continue
366
+ // A config runs no tests, and the gherkin steps import the consumer's test runner.
367
+ if (subpath === "./flows" || subpath.startsWith("./testkit") || file === undefined) continue
368
368
  files.set(`${PACKAGE}/${subpath.replace(/^\.\//, "")}`, join(root, file))
369
369
  }
370
370
  return files
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes