@pmelab/gtd 24.3.0 → 24.4.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 (60) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +15 -4
  3. package/dist/chunks/{reserved-DJwU5C4z.mjs → reserved-B9pG93Fc.mjs} +52 -12
  4. package/dist/chunks/{steering-D-PvRC5W.mjs → steering-DVILanVf.mjs} +16 -5
  5. package/dist/flows.mjs +9 -1
  6. package/dist/gtd.bundle.mjs +779 -812
  7. package/dist/steering.mjs +2 -2
  8. package/dist/testkit.mjs +663 -158
  9. package/dist/workflow-kit.mjs +9 -7
  10. package/dist/workflows/feature.mjs +152 -35
  11. package/dist/workflows/fix.mjs +2 -2
  12. package/dist/workflows/review.mjs +1 -1
  13. package/dist/workflows.mjs +223 -54
  14. package/package.json +1 -1
  15. package/src/cli/Program.test.ts +3 -8
  16. package/src/cli/Program.ts +1 -8
  17. package/src/edge/Edge.test.ts +5 -2
  18. package/src/edge/Edge.ts +3 -4
  19. package/src/emit/index.ts +1 -0
  20. package/src/flows/scripts.ts +6 -0
  21. package/src/platform/Git.test.ts +6 -9
  22. package/src/steering/FORMATS.md +1 -1
  23. package/src/steering/SteeringFormat.test.ts +9 -1
  24. package/src/steering/SteeringFormat.ts +7 -0
  25. package/src/steering/contract.test.ts +3 -0
  26. package/src/steering/index.ts +1 -0
  27. package/src/steering/review.test.ts +30 -0
  28. package/src/steering/review.ts +8 -2
  29. package/src/testkit/EmittedScriptRecognizer.test.ts +29 -30
  30. package/src/testkit/EmittedScriptRecognizer.ts +148 -51
  31. package/src/testkit/EmittedScripts.test.ts +166 -0
  32. package/src/testkit/FlowScripts.test.ts +242 -0
  33. package/src/testkit/FlowScripts.ts +326 -0
  34. package/src/testkit/GitTiers.ts +140 -49
  35. package/src/testkit/InMemRepo.ts +35 -2
  36. package/src/testkit/Layers.ts +10 -2
  37. package/src/testkit/TestRepo.ts +4 -1
  38. package/src/testkit/index.ts +1 -1
  39. package/src/workflow/discovery.test.ts +147 -0
  40. package/src/workflow/discovery.ts +5 -0
  41. package/src/workflow/fingerprint.test.ts +20 -4
  42. package/src/workflow/fingerprint.ts +8 -9
  43. package/src/workflow/index.ts +1 -6
  44. package/src/workflow/load.test.ts +5 -19
  45. package/src/workflow/load.ts +94 -10
  46. package/src/workflow-kit/review.test.ts +84 -0
  47. package/src/workflow-kit/review.ts +9 -6
  48. package/src/workflows/feature/review.test.ts +238 -12
  49. package/src/workflows/feature/review.ts +172 -31
  50. package/src/workflows/feature/steps.test.ts +12 -0
  51. package/src/workflows/feature/steps.ts +8 -1
  52. package/src/workflows/shared/access.test.ts +4 -2
  53. package/src/workflows/shared/access.ts +1 -0
  54. package/src/workflows/shared/options.test.ts +8 -0
  55. package/src/workflows/shared/options.ts +1 -0
  56. package/src/workflows/shared/steps.ts +2 -0
  57. package/src/workflows/shared/tail.ts +3 -11
  58. package/src/workflows/shared/text.test.ts +106 -0
  59. package/src/workflows/shared/text.ts +57 -1
  60. package/src/workflows/shared/vars.ts +1 -0
@@ -5,11 +5,13 @@ import {
5
5
  writeFileSync,
6
6
  existsSync,
7
7
  readFileSync,
8
+ readdirSync,
8
9
  realpathSync,
10
+ statSync,
9
11
  } from "node:fs"
10
- import { join, dirname, basename } from "node:path"
12
+ import { join, dirname, basename, sep } from "node:path"
11
13
  import { tmpdir } from "node:os"
12
- import { execSync, execFileSync } from "node:child_process"
14
+ import { execFileSync, execSync, spawnSync } from "node:child_process"
13
15
  // Imported directly (not just from *.test.ts) — a leak of this module into
14
16
  // the shipped CLI bundle would try to inline `vitest` and fail the build
15
17
  // loudly (`deps.alwaysBundle` in `tsdown.config.ts`), a guard alongside the
@@ -29,8 +31,9 @@ import {
29
31
  fetchHistoryRefs,
30
32
  deleteRef,
31
33
  } from "../emit/index.js"
34
+ import { applyEmittedScript } from "./EmittedScriptRecognizer.js"
32
35
  import { InMemRepo } from "./InMemRepo.js"
33
- import { gitTestLayer } from "./Layers.js"
36
+ import { gitTestLayer, type ScriptedCommand } from "./Layers.js"
34
37
 
35
38
  export interface GitTierCapabilities {
36
39
  /** Assert file BYTES on disk, not just index/HEAD state (`hardResetTo`) — only the Live tier has a real working-tree file to read back. */
@@ -41,6 +44,8 @@ export interface GitTierCapabilities {
41
44
  readonly linkedWorktrees: boolean
42
45
  /** A cwd→home directory chain for config discovery — both tiers walk it through `Workspace`/`Host` now, so this is `true` for either. */
43
46
  readonly directoryChainConfig: boolean
47
+ /** Fetch from another repository — only the Live tier has a remote to reach. */
48
+ readonly remotes: boolean
44
49
  }
45
50
 
46
51
  export interface GitTierSeed {
@@ -59,6 +64,11 @@ export interface GitTierObserve {
59
64
  readonly refExists: (ref: string) => boolean
60
65
  readonly readWorktreeFile: (path: string) => string
61
66
  readonly existsPath: (path: string) => boolean
67
+ /** Everything git keeps on disk; equal snapshots mean nothing was written. Settles git's racy-index stat refresh first. */
68
+ readonly snapshot: () => Promise<string>
69
+ readonly headSubject: () => string
70
+ /** Paths HEAD's own commit touched, against its parent — not the whole tree. */
71
+ readonly headTouchedPaths: () => ReadonlyArray<string>
62
72
  }
63
73
 
64
74
  export interface GitTier {
@@ -74,6 +84,15 @@ export interface GitTier {
74
84
  readonly emptyRepo: () => GitTier
75
85
  readonly seed: GitTierSeed
76
86
  readonly observe: GitTierObserve
87
+ /**
88
+ * Runs a script gtd hands a driver: real `sh` on Live, the in-memory twin on
89
+ * InMemory. `commands` declares what each program a check runs does — Live
90
+ * runs the real thing, so a contract case passes only when the two agree.
91
+ */
92
+ readonly runScript: (
93
+ script: string,
94
+ commands?: ReadonlyMap<string, ScriptedCommand>,
95
+ ) => { readonly ok: boolean; readonly status: number; readonly output: string }
77
96
  readonly dispose: () => void
78
97
  }
79
98
 
@@ -141,6 +160,7 @@ const makeLiveTier = (initialCommit = true): GitTier => {
141
160
  commitHooks: true,
142
161
  linkedWorktrees: true,
143
162
  directoryChainConfig: true,
163
+ remotes: true,
144
164
  },
145
165
  provide,
146
166
  provideExit,
@@ -187,11 +207,51 @@ const makeLiveTier = (initialCommit = true): GitTier => {
187
207
  },
188
208
  readWorktreeFile: (path) => readFileSync(join(root, path), "utf8"),
189
209
  existsPath: (path) => existsSync(join(root, path)),
210
+ snapshot: async () => {
211
+ // A tracked file's mtime in the same second as the index makes the next
212
+ // `git status` rewrite the index — git's own racy-git refresh, not a write
213
+ // of ours. Let the clock pass it before taking the baseline.
214
+ gitExec("status", "--porcelain")
215
+ await new Promise((r) => setTimeout(r, 1100))
216
+ gitExec("status", "--porcelain")
217
+ const gitDir = join(root, ".git")
218
+ const files = readdirSync(gitDir, { recursive: true, withFileTypes: true })
219
+ .filter((entry) => entry.isFile())
220
+ .map((entry) => join(entry.parentPath, entry.name))
221
+ .filter((path) => !path.includes(`${sep}gtd-cache${sep}`))
222
+ .sort()
223
+ .map((path) => `${path}:${statSync(path).size}:${statSync(path).mtimeMs}`)
224
+ return JSON.stringify({ files, status: gitExec("status", "--porcelain", "-uall") })
225
+ },
226
+ headSubject: () => gitExec("log", "-1", "--pretty=%s"),
227
+ headTouchedPaths: () =>
228
+ gitExec("show", "--name-only", "--pretty=format:", "HEAD")
229
+ .split("\n")
230
+ .filter((line) => line.length > 0),
231
+ },
232
+ runScript: (script) => {
233
+ // One stream, as a driver's terminal shows it.
234
+ const result = spawnSync("sh", ["-c", `exec 2>&1\n${script}`], {
235
+ cwd: root,
236
+ encoding: "utf8",
237
+ env: { ...process.env, PATH: `${gtdShimDir()}:${process.env["PATH"] ?? ""}` },
238
+ })
239
+ return { ok: result.status === 0, status: result.status ?? 1, output: result.stdout }
190
240
  },
191
241
  dispose: () => rmSync(root, { recursive: true, force: true }),
192
242
  }
193
243
  }
194
244
 
245
+ /** A dir whose `gtd` runs this build's bundle, so a script's bare `gtd` is the code under test. */
246
+ let shimDir: string | undefined
247
+ const gtdShimDir = (): string => {
248
+ if (shimDir !== undefined) return shimDir
249
+ shimDir = mkdtempSync(join(tmpdir(), "gtd-tier-shim-"))
250
+ const bundle = join(import.meta.dirname, "../../dist/gtd.bundle.mjs")
251
+ writeFileSync(join(shimDir, "gtd"), `#!/bin/sh\nexec node "${bundle}" "$@"\n`, { mode: 0o755 })
252
+ return shimDir
253
+ }
254
+
195
255
  const IN_MEM_ROOT = "/repo"
196
256
 
197
257
  const makeInMemTier = (initialCommit = true): GitTier => {
@@ -218,6 +278,7 @@ const makeInMemTier = (initialCommit = true): GitTier => {
218
278
  commitHooks: false,
219
279
  linkedWorktrees: false,
220
280
  directoryChainConfig: true,
281
+ remotes: false,
221
282
  },
222
283
  provide,
223
284
  provideExit,
@@ -252,6 +313,19 @@ const makeInMemTier = (initialCommit = true): GitTier => {
252
313
  return content
253
314
  },
254
315
  existsPath: (path) => repo.hasPath(path),
316
+ snapshot: async () => repo.snapshot(),
317
+ headSubject: () => repo.lastCommitSubject() ?? "",
318
+ headTouchedPaths: () => {
319
+ const paths = new Set([...repo.pathsAtRef("HEAD~1"), ...repo.pathsAtRef("HEAD")])
320
+ return [...paths]
321
+ .filter((p) => repo.fileAtRef("HEAD~1", p) !== repo.fileAtRef("HEAD", p))
322
+ .sort()
323
+ },
324
+ },
325
+ runScript: (script, commands = new Map()) => {
326
+ const { ok, status, output, error } = applyEmittedScript(repo, commands, script)
327
+ if (error?.startsWith("unrecognized script block:") === true) throw new Error(error)
328
+ return { ok, status, output }
255
329
  },
256
330
  dispose: () => {},
257
331
  }
@@ -336,6 +410,36 @@ export const runGitServiceContract = (makeTier: () => GitTier): void => {
336
410
  t.dispose()
337
411
  })
338
412
 
413
+ it("no reader writes to the repository", async () => {
414
+ t.seed.commit("feat: second", { "second.txt": "two" })
415
+ t.seed.writeFile("dirty.txt", "dirty")
416
+ const before = await t.observe.snapshot()
417
+ await runGit(t, (g) =>
418
+ Effect.all([
419
+ g.lastCommitSubject(),
420
+ g.lastCommitMessage(),
421
+ g.hasCommits(),
422
+ g.resolveRef("HEAD"),
423
+ g.readRefOption("refs/gtd/none"),
424
+ g.isAncestor("HEAD~1", "HEAD"),
425
+ g.mergeBase("HEAD~1", "HEAD"),
426
+ g.defaultBranch(),
427
+ g.topLevel(),
428
+ g.gitDir(),
429
+ g.gitCommonDir(),
430
+ g.commitHistory(),
431
+ g.subjectHistory(10, 0),
432
+ g.readFileAtRef("HEAD", "second.txt"),
433
+ g.changedPaths(),
434
+ g.changedPaths("HEAD~1"),
435
+ ]),
436
+ )
437
+ expect(await t.observe.snapshot()).toBe(before)
438
+ // The negative control: a snapshot that saw nothing would pass the above vacuously.
439
+ await runGit(t, (g) => g.updateRef("refs/gtd/marker", t.observe.resolveRef("HEAD")))
440
+ expect(await t.observe.snapshot()).not.toBe(before)
441
+ })
442
+
339
443
  describe("resolveRef", () => {
340
444
  it("resolves HEAD to a 40-char hash", async () => {
341
445
  const hash = await runGit(t, (g) => g.resolveRef("HEAD"))
@@ -998,35 +1102,20 @@ export const runGitServiceContract = (makeTier: () => GitTier): void => {
998
1102
  }
999
1103
 
1000
1104
  /**
1001
- * Runs a `GitScript.ts` builder's output through a REAL shell against `t.root`
1002
- * — the same execution model the eventual driver uses (`bash -c <script>`),
1003
- * so a builder that's syntactically fine but semantically wrong (a missing
1004
- * `&&`, a misquoted path) fails here exactly as it would in production.
1105
+ * The SAME writer postconditions `runGitServiceContract` asserts against
1106
+ * `GitOperations`, asserted here against `GitScript.ts`'s builders as a driver
1107
+ * runs them: real `sh` on Live, the in-memory twin on InMemory.
1005
1108
  */
1006
- const execScript = (t: GitTier, script: string): void => {
1007
- execFileSync("bash", ["-c", script], { cwd: t.root, stdio: "pipe" })
1109
+ const run = (t: GitTier, script: string): void => {
1110
+ const result = t.runScript(script)
1111
+ if (!result.ok) throw new Error(`script failed on ${t.name}: ${result.output}`)
1008
1112
  }
1009
1113
 
1010
- /** Names touched by HEAD's own commit — `git show`'s diff against its parent, not the whole tree. */
1011
- const headTouchedPaths = (t: GitTier): ReadonlyArray<string> =>
1012
- gitExecIn(t.root, "show", "--name-only", "--pretty=format:", "HEAD")
1013
- .split("\n")
1014
- .filter((line) => line.length > 0)
1015
-
1016
- const headSubject = (t: GitTier): string => gitExecIn(t.root, "log", "-1", "--pretty=%s")
1017
-
1018
- /**
1019
- * The SAME 9 writer postconditions `runGitServiceContract` asserts against
1020
- * `GitOperations`, asserted here against `GitScript.ts`'s bash builders
1021
- * instead — driven through real `bash` against a real repo (`makeLiveTier`),
1022
- * never the in-memory fake, since a fake root (`/repo`) has no shell to run
1023
- * against.
1024
- */
1025
- export const runGitScriptContract = (): void => {
1114
+ export const runGitScriptContract = (makeTier: () => GitTier): void => {
1026
1115
  let t: GitTier
1027
1116
 
1028
1117
  beforeEach(() => {
1029
- t = makeLiveTier()
1118
+ t = makeTier()
1030
1119
  })
1031
1120
 
1032
1121
  afterEach(() => {
@@ -1037,18 +1126,18 @@ export const runGitScriptContract = (): void => {
1037
1126
  it("stages (git add -A) before committing — a new untracked file lands in the commit", () => {
1038
1127
  t.seed.writeFile("new.ts", "export const x = 1")
1039
1128
  const headBefore = t.observe.resolveRef("HEAD")
1040
- execScript(t, commitAll("gtd: building"))
1129
+ run(t, commitAll("gtd: building"))
1041
1130
  expect(t.observe.resolveRef("HEAD")).not.toBe(headBefore)
1042
- expect(headSubject(t)).toBe("gtd: building")
1043
- expect(headTouchedPaths(t)).toEqual(["new.ts"])
1131
+ expect(t.observe.headSubject()).toBe("gtd: building")
1132
+ expect(t.observe.headTouchedPaths()).toEqual(["new.ts"])
1044
1133
  expect(t.observe.statusPorcelain().trim()).toBe("")
1045
1134
  })
1046
1135
 
1047
1136
  it("commits even on a clean tree (--allow-empty), never 'nothing to commit'", () => {
1048
1137
  const headBefore = t.observe.resolveRef("HEAD")
1049
- execScript(t, commitAll("gtd: grilled"))
1138
+ run(t, commitAll("gtd: grilled"))
1050
1139
  expect(t.observe.resolveRef("HEAD")).not.toBe(headBefore)
1051
- expect(headSubject(t)).toBe("gtd: grilled")
1140
+ expect(t.observe.headSubject()).toBe("gtd: grilled")
1052
1141
  })
1053
1142
  })
1054
1143
 
@@ -1057,16 +1146,16 @@ export const runGitScriptContract = (): void => {
1057
1146
  t.seed.writeFile("a.txt", "a")
1058
1147
  t.seed.stageAll()
1059
1148
  t.seed.writeFile("b.txt", "b")
1060
- execScript(t, commitAsIs("feat: squash"))
1061
- expect(headSubject(t)).toBe("feat: squash")
1062
- expect(headTouchedPaths(t)).toEqual(["a.txt"])
1149
+ run(t, commitAsIs("feat: squash"))
1150
+ expect(t.observe.headSubject()).toBe("feat: squash")
1151
+ expect(t.observe.headTouchedPaths()).toEqual(["a.txt"])
1063
1152
  })
1064
1153
 
1065
1154
  it("commits even on a clean index (--allow-empty), never 'nothing to commit'", () => {
1066
1155
  const headBefore = t.observe.resolveRef("HEAD")
1067
- execScript(t, commitAsIs("gtd: empty squash"))
1156
+ run(t, commitAsIs("gtd: empty squash"))
1068
1157
  expect(t.observe.resolveRef("HEAD")).not.toBe(headBefore)
1069
- expect(headSubject(t)).toBe("gtd: empty squash")
1158
+ expect(t.observe.headSubject()).toBe("gtd: empty squash")
1070
1159
  })
1071
1160
  })
1072
1161
 
@@ -1074,7 +1163,7 @@ export const runGitScriptContract = (): void => {
1074
1163
  it("moves HEAD back but keeps worktree changes from the second commit", () => {
1075
1164
  const firstHash = t.observe.resolveRef("HEAD")
1076
1165
  t.seed.commit("feat: second", { "second.txt": "second content" })
1077
- execScript(t, softResetTo(firstHash))
1166
+ run(t, softResetTo(firstHash))
1078
1167
  expect(t.observe.resolveRef("HEAD")).toBe(firstHash)
1079
1168
  expect(t.observe.statusPorcelain()).toContain("second.txt")
1080
1169
  })
@@ -1084,7 +1173,7 @@ export const runGitScriptContract = (): void => {
1084
1173
  it("moves HEAD, index, and working tree content back to the target ref", () => {
1085
1174
  const firstHash = t.observe.resolveRef("HEAD")
1086
1175
  t.seed.commit("feat: second", { "readme.txt": "modified content" })
1087
- execScript(t, hardResetTo(firstHash))
1176
+ run(t, hardResetTo(firstHash))
1088
1177
  expect(t.observe.resolveRef("HEAD")).toBe(firstHash)
1089
1178
  expect(t.observe.statusPorcelain()).toBe("")
1090
1179
  expect(t.observe.readWorktreeFile("readme.txt")).toBe("hello")
@@ -1095,7 +1184,7 @@ export const runGitScriptContract = (): void => {
1095
1184
  it("moves HEAD and index, leaving the working tree untouched", () => {
1096
1185
  const firstHash = t.observe.resolveRef("HEAD")
1097
1186
  t.seed.commit("feat: second", { "second.txt": "second content" })
1098
- execScript(t, mixedResetTo(firstHash))
1187
+ run(t, mixedResetTo(firstHash))
1099
1188
  expect(t.observe.resolveRef("HEAD")).toBe(firstHash)
1100
1189
  expect(t.observe.statusPorcelain()).toContain("second.txt")
1101
1190
  })
@@ -1105,7 +1194,7 @@ export const runGitScriptContract = (): void => {
1105
1194
  it("drops both tracked modifications and untracked files", () => {
1106
1195
  t.seed.writeFile("readme.txt", "modified")
1107
1196
  t.seed.writeFile("untracked.txt", "new")
1108
- execScript(t, discardPending())
1197
+ run(t, discardPending())
1109
1198
  expect(t.observe.statusPorcelain()).toBe("")
1110
1199
  })
1111
1200
  })
@@ -1113,33 +1202,34 @@ export const runGitScriptContract = (): void => {
1113
1202
  describe("updateRef / deleteRef", () => {
1114
1203
  it("updateRef points a ref at a commit", () => {
1115
1204
  const head = t.observe.resolveRef("HEAD")
1116
- execScript(t, updateRef("refs/gtd/marker", head))
1205
+ run(t, updateRef("refs/gtd/marker", head))
1117
1206
  expect(t.observe.refExists("refs/gtd/marker")).toBe(true)
1118
1207
  expect(t.observe.resolveRef("refs/gtd/marker")).toBe(head)
1119
1208
  })
1120
1209
 
1121
1210
  it("updateRef with HEAD points the ref at the commit just made", () => {
1122
- execScript(t, commitAll("feat: just made"))
1123
- execScript(t, updateRef("refs/gtd/history/x", "HEAD"))
1211
+ run(t, commitAll("feat: just made"))
1212
+ run(t, updateRef("refs/gtd/history/x", "HEAD"))
1124
1213
  expect(t.observe.resolveRef("refs/gtd/history/x")).toBe(t.observe.resolveRef("HEAD"))
1125
1214
  })
1126
1215
 
1127
1216
  it("deleteRef removes an existing ref, and tolerates deleting it again", () => {
1128
1217
  const head = t.observe.resolveRef("HEAD")
1129
- execScript(t, updateRef("refs/gtd/marker", head))
1130
- execScript(t, deleteRef("refs/gtd/marker"))
1131
- execScript(t, deleteRef("refs/gtd/marker"))
1218
+ run(t, updateRef("refs/gtd/marker", head))
1219
+ run(t, deleteRef("refs/gtd/marker"))
1220
+ run(t, deleteRef("refs/gtd/marker"))
1132
1221
  expect(t.observe.refExists("refs/gtd/marker")).toBe(false)
1133
1222
  })
1134
1223
 
1135
1224
  it("deleteRef tolerates a ref that was never created", () => {
1136
- expect(() => execScript(t, deleteRef("refs/gtd/never-created"))).not.toThrow()
1225
+ expect(() => run(t, deleteRef("refs/gtd/never-created"))).not.toThrow()
1137
1226
  expect(t.observe.refExists("refs/gtd/never-created")).toBe(false)
1138
1227
  })
1139
1228
  })
1140
1229
 
1141
1230
  describe("fetchHistoryRefs", () => {
1142
- it("pulls every refs/gtd/history/* from a bare remote and prints the count", () => {
1231
+ it("pulls every refs/gtd/history/* from a bare remote and prints the count", (ctx) => {
1232
+ if (!t.capabilities.remotes) ctx.skip()
1143
1233
  const bare = realpathSync(mkdtempSync(join(tmpdir(), "gtd-git-tier-bare-")))
1144
1234
  try {
1145
1235
  gitExecIn(bare, "init", "-q", "--bare")
@@ -1161,7 +1251,8 @@ export const runGitScriptContract = (): void => {
1161
1251
  }
1162
1252
  })
1163
1253
 
1164
- it("fails loudly when a local history ref conflicts with the remote's", () => {
1254
+ it("fails loudly when a local history ref conflicts with the remote's", (ctx) => {
1255
+ if (!t.capabilities.remotes) ctx.skip()
1165
1256
  const bare = realpathSync(mkdtempSync(join(tmpdir(), "gtd-git-tier-bare-")))
1166
1257
  try {
1167
1258
  gitExecIn(bare, "init", "-q", "--bare")
@@ -115,10 +115,11 @@ export class InMemRepo {
115
115
  return this.commits.has(ref) ? ref : null
116
116
  }
117
117
 
118
- const tildeMatch = /^(HEAD|[0-9a-f]{40})(~(\d+))?$/.exec(ref)
118
+ const tildeMatch = /^(HEAD|[0-9a-f]{40})(~(\d+)|\^)?$/.exec(ref)
119
119
  if (tildeMatch) {
120
120
  const base = tildeMatch[1]!
121
- const steps = tildeMatch[3] !== undefined ? parseInt(tildeMatch[3], 10) : 0
121
+ const steps =
122
+ tildeMatch[3] !== undefined ? parseInt(tildeMatch[3], 10) : tildeMatch[2] === "^" ? 1 : 0
122
123
  let cur: string | null = base === "HEAD" ? this.head : this.commits.has(base) ? base : null
123
124
  for (let i = 0; i < steps; i++) {
124
125
  if (cur === null) return null
@@ -297,6 +298,38 @@ export class InMemRepo {
297
298
  .sort()
298
299
  }
299
300
 
301
+ /** Everything git would keep on disk, serialized — two snapshots compare equal exactly when nothing was written. */
302
+ snapshot(): string {
303
+ const sorted = (m: Map<string, string>) =>
304
+ [...m.entries()].sort(([a], [b]) => a.localeCompare(b))
305
+ return JSON.stringify({
306
+ head: this.head,
307
+ branch: this.currentBranch,
308
+ commits: [...this.commits.keys()].sort(),
309
+ branches: sorted(this.branches),
310
+ refs: sorted(this.refs),
311
+ index: sorted(this.index),
312
+ worktree: sorted(this.worktree),
313
+ })
314
+ }
315
+
316
+ /** Every ref name under `prefix` (`refs/…`), sorted — `git for-each-ref <prefix>`. */
317
+ refNamesUnder(prefix: string): ReadonlyArray<string> {
318
+ return [...this.refs.keys()].filter((ref) => ref.startsWith(prefix)).sort()
319
+ }
320
+
321
+ /** Whether `path` is in the index — what `git diff <commit> -- <path>` compares against the worktree. */
322
+ isTracked(path: string): boolean {
323
+ return this.index.has(path)
324
+ }
325
+
326
+ /** `git add <path>` — the index takes `path` as the worktree has it, or drops it. */
327
+ stage(path: string): void {
328
+ const content = this.worktree.get(path)
329
+ if (content === undefined) this.index.delete(path)
330
+ else this.index.set(path, content)
331
+ }
332
+
300
333
  /** `git add -A` — the index becomes exactly the current worktree. */
301
334
  stageAll(): void {
302
335
  this.index = new Map(this.worktree)
@@ -158,7 +158,7 @@ const makeInMemoryConfigDiscovery = (
158
158
  for (const dir of walkUp(levelRoot, levelHome)) {
159
159
  const filepath = join(dir, WORKFLOW_MODULE)
160
160
  const source = workspace.atPath(filepath)
161
- if (source !== undefined) return { filepath, source }
161
+ if (source !== undefined) return { filepath, source, read: workspace.atPath }
162
162
  }
163
163
  return undefined
164
164
  },
@@ -170,7 +170,15 @@ const makeInMemoryConfigDiscovery = (
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
172
  | { readonly kind: "exit"; readonly status: number; readonly output: string }
173
- | { readonly kind: "rewrite"; readonly file: string; readonly content: string }
173
+ | {
174
+ readonly kind: "rewrite"
175
+ readonly file: string
176
+ readonly content: string
177
+ /** Adds `content` to the file's end instead of replacing it. */
178
+ readonly append?: boolean
179
+ }
180
+ /** Keyed by the binary's name: it is not on `$PATH`. */
181
+ | { readonly kind: "missing" }
174
182
 
175
183
  export interface TestWorldOptions {
176
184
  readonly env?: Readonly<Record<string, string | undefined>>
@@ -4,6 +4,7 @@ import { runCli } from "../cli/index.js"
4
4
  import { EXIT_OK, type BeatDocument } from "../wire/index.js"
5
5
  import { makeCapturingCliIo, type CapturedCliResult } from "./cliIo.js"
6
6
  import { applyEmittedScript } from "./EmittedScriptRecognizer.js"
7
+ import { applyFlowScript } from "./FlowScripts.js"
7
8
  import { InMemRepo } from "./InMemRepo.js"
8
9
 
9
10
  /**
@@ -247,7 +248,9 @@ export const testRepo = (options: TestRepoOptions): TestRepo => {
247
248
  commit: (message) => repo.commitAllWithPrefix(message),
248
249
  show: (ref, path) => repo.fileAtRef(ref, path) ?? undefined,
249
250
  runScript: (script) => {
250
- for (const line of script.split("\n")) runLine(repo, line.trim())
251
+ const flow = applyFlowScript(repo, new Map(), script)
252
+ if (flow === undefined) for (const line of script.split("\n")) runLine(repo, line.trim())
253
+ else if (!flow.ok) throw new Error(`testkit: ${flow.error ?? ""}`)
251
254
  },
252
255
  gtd,
253
256
  start: (workflow, ...args) => begin(["--workflow", workflow, ...args]),
@@ -3,7 +3,7 @@ export { InMemRepo } from "./InMemRepo.js"
3
3
  export { makeInMemoryWorkspaceOps, testLayers, type ScriptedCommand } from "./Layers.js"
4
4
  export { makeCapturingCliIo } from "./cliIo.js"
5
5
  export { applyEmittedScript } from "./EmittedScriptRecognizer.js"
6
- export { gitTiers, runGitServiceContract, runGitScriptContract } from "./GitTiers.js"
6
+ export { gitTiers, runGitServiceContract, runGitScriptContract, type GitTier } from "./GitTiers.js"
7
7
  export { strictGitOperations } from "./StrictGitOperations.js"
8
8
  export { fakeGitOperations } from "./FakeGitOperations.js"
9
9
  export type { TestkitWorld } from "./steps.js"
@@ -0,0 +1,147 @@
1
+ import { execSync } from "node:child_process"
2
+ import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"
3
+ import { tmpdir } from "node:os"
4
+ import { dirname, join } from "node:path"
5
+ import { NodeContext } from "@effect/platform-node"
6
+ import { Effect, Layer } from "effect"
7
+ import { afterEach, describe, expect, it } from "vitest"
8
+ import { GitService, Host, Narrator, Workspace } from "../platform/index.js"
9
+ import { InMemRepo, testLayers } from "../testkit/index.js"
10
+ import { ConfigDiscovery, ConfigService } from "./index.js"
11
+
12
+ // A config and the local modules it imports load the same from disk, where
13
+ // jiti resolves them, and from an in-memory discovery, where the loader
14
+ // resolves them itself — the same workflows, the same fingerprint, the same
15
+ // failure for a module that is not there.
16
+ type Files = Readonly<Record<string, string>>
17
+
18
+ interface Tier {
19
+ readonly load: (files: Files) => Promise<{ names: readonly string[]; fingerprint: object }>
20
+ readonly dispose: () => void
21
+ }
22
+
23
+ const config = (imports: string): string =>
24
+ [
25
+ 'import { agent, human } from "@pmelab/gtd/flows"',
26
+ imports,
27
+ "export default async () => {",
28
+ ' await human("idle", { message: "go" })',
29
+ ' await agent("working", brief)',
30
+ "}",
31
+ "export const named = async () => {",
32
+ ' await agent("working", brief)',
33
+ "}",
34
+ "",
35
+ ].join("\n")
36
+
37
+ const loadWith = (
38
+ layer: Layer.Layer<ConfigService | Narrator | Workspace | Host | ConfigDiscovery>,
39
+ ) =>
40
+ Effect.runPromise(
41
+ Effect.flatMap(ConfigService, (service) => service.load).pipe(
42
+ Effect.map((loaded) => ({ names: loaded.workflowNames, fingerprint: loaded.fingerprint })),
43
+ Effect.provide(layer),
44
+ ),
45
+ )
46
+
47
+ const live = (): Tier => {
48
+ const root = realpathSync(mkdtempSync(join(tmpdir(), "gtd-discovery-")))
49
+ execSync("git init -q && git commit -q --allow-empty -m init", {
50
+ cwd: root,
51
+ env: {
52
+ ...process.env,
53
+ GIT_AUTHOR_NAME: "t",
54
+ GIT_AUTHOR_EMAIL: "t@t",
55
+ GIT_COMMITTER_NAME: "t",
56
+ GIT_COMMITTER_EMAIL: "t@t",
57
+ },
58
+ })
59
+ return {
60
+ load: (files) => {
61
+ for (const [path, content] of Object.entries(files)) {
62
+ mkdirSync(dirname(join(root, path)), { recursive: true })
63
+ writeFileSync(join(root, path), content)
64
+ }
65
+ const host = Host.layer({ root, home: root, env: {} })
66
+ const git = GitService.Live.pipe(Layer.provide(Layer.merge(host, NodeContext.layer)))
67
+ const workspace = Workspace.Live.pipe(Layer.provide(Layer.merge(host, git)))
68
+ return loadWith(
69
+ Layer.mergeAll(
70
+ ConfigService.Live,
71
+ ConfigDiscovery.Live,
72
+ host,
73
+ workspace,
74
+ Narrator.layer(() => {}, false),
75
+ ),
76
+ )
77
+ },
78
+ dispose: () => rmSync(root, { recursive: true, force: true }),
79
+ }
80
+ }
81
+
82
+ const inMemory = (): Tier => ({
83
+ load: (files) => {
84
+ const repo = new InMemRepo()
85
+ for (const [path, content] of Object.entries(files)) repo.writeFile(path, content)
86
+ repo.commitAllWithPrefix("chore: init")
87
+ return loadWith(testLayers(repo))
88
+ },
89
+ dispose: () => {},
90
+ })
91
+
92
+ for (const make of [live, inMemory]) {
93
+ describe(`a config's local modules [${make.name}]`, () => {
94
+ let t: Tier
95
+ afterEach(() => t.dispose())
96
+
97
+ it("loads an import by its .js spelling, and one it imports in turn", async () => {
98
+ t = make()
99
+ const loaded = await t.load({
100
+ "gtd.config.ts": config('import { brief } from "./lib/brief.js"'),
101
+ "lib/brief.ts": 'import { word } from "./word"\nexport const brief = `do ${word}`\n',
102
+ "lib/word.ts": 'export const word = "it"\n',
103
+ })
104
+ expect(loaded.names).toEqual(["named"])
105
+ expect(Object.keys(loaded.fingerprint).sort()).toEqual([
106
+ "gtd.config.ts",
107
+ "lib/brief.ts",
108
+ "lib/word.ts",
109
+ ])
110
+ })
111
+
112
+ it("hashes each file's content the same way", async () => {
113
+ t = make()
114
+ const loaded = await t.load({
115
+ "gtd.config.ts": config('import { brief } from "./brief.js"'),
116
+ "brief.ts": 'export const brief = "do the work"\n',
117
+ })
118
+ expect(loaded.fingerprint).toEqual({
119
+ "brief.ts": "c59960854e8032b7",
120
+ "gtd.config.ts": expect.stringMatching(/^[0-9a-f]{16}$/),
121
+ })
122
+ })
123
+
124
+ it("leaves a specifier in a string alone, and loads a skipped require never", async () => {
125
+ t = make()
126
+ const loaded = await t.load({
127
+ "gtd.config.ts": config(
128
+ [
129
+ "const brief = \"look from './word.js'\"",
130
+ 'if (brief === "") require("./optional.js")',
131
+ ].join("\n"),
132
+ ),
133
+ "word.ts": "export const word = 1\n",
134
+ "optional.ts": 'throw new Error("optional ran")\n',
135
+ })
136
+ expect(loaded.names).toEqual(["named"])
137
+ expect(Object.keys(loaded.fingerprint)).toEqual(["gtd.config.ts"])
138
+ })
139
+
140
+ it("fails on an import that is not there, naming it", async () => {
141
+ t = make()
142
+ await expect(
143
+ t.load({ "gtd.config.ts": config('import { brief } from "./missing.js"') }),
144
+ ).rejects.toThrow(/Cannot find module '\.\/missing\.js'/)
145
+ })
146
+ })
147
+ }
@@ -6,6 +6,11 @@ import { Context, Effect, Layer } from "effect"
6
6
  export interface WorkflowModule {
7
7
  readonly filepath: string
8
8
  readonly source: string
9
+ /**
10
+ * The modules the config imports, by absolute path, for a discovery with no
11
+ * disk behind it. Absent, they are read from disk like any module import.
12
+ */
13
+ readonly read?: (path: string) => string | undefined
9
14
  }
10
15
 
11
16
  /** A leftover rc file, and whether the user running gtd owns it — and so can delete it. */