@pmelab/gtd 24.3.0 → 25.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 (74) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +28 -22
  3. package/dist/chunks/{reserved-DJwU5C4z.mjs → reserved-6pAgFGgB.mjs} +119 -135
  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 +780 -813
  7. package/dist/steering.mjs +2 -2
  8. package/dist/testkit.mjs +666 -161
  9. package/dist/workflow-kit.mjs +55 -96
  10. package/dist/workflows/feature.mjs +171 -76
  11. package/dist/workflows/fix.mjs +2 -2
  12. package/dist/workflows/review.mjs +1 -1
  13. package/dist/workflows.mjs +353 -310
  14. package/package.json +1 -2
  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/replay/Replay.ts +2 -1
  23. package/src/steering/FORMATS.md +1 -1
  24. package/src/steering/SteeringFormat.test.ts +9 -1
  25. package/src/steering/SteeringFormat.ts +7 -0
  26. package/src/steering/contract.test.ts +3 -0
  27. package/src/steering/index.ts +1 -0
  28. package/src/steering/review.test.ts +30 -0
  29. package/src/steering/review.ts +8 -2
  30. package/src/testkit/EmittedScriptRecognizer.test.ts +29 -30
  31. package/src/testkit/EmittedScriptRecognizer.ts +148 -51
  32. package/src/testkit/EmittedScripts.test.ts +166 -0
  33. package/src/testkit/FlowScripts.test.ts +242 -0
  34. package/src/testkit/FlowScripts.ts +326 -0
  35. package/src/testkit/GitTiers.ts +140 -49
  36. package/src/testkit/InMemRepo.ts +35 -2
  37. package/src/testkit/Layers.ts +10 -2
  38. package/src/testkit/TestRepo.ts +4 -1
  39. package/src/testkit/index.ts +1 -1
  40. package/src/workflow/discovery.test.ts +147 -0
  41. package/src/workflow/discovery.ts +5 -0
  42. package/src/workflow/fingerprint.test.ts +20 -4
  43. package/src/workflow/fingerprint.ts +8 -9
  44. package/src/workflow/index.ts +1 -6
  45. package/src/workflow/load.test.ts +5 -19
  46. package/src/workflow/load.ts +94 -10
  47. package/src/workflow-kit/review.test.ts +84 -0
  48. package/src/workflow-kit/review.ts +9 -6
  49. package/src/workflow-kit/scenarios.test.ts +116 -0
  50. package/src/workflow-kit/scenarios.ts +53 -113
  51. package/src/workflows/feature/feature.ts +2 -5
  52. package/src/workflows/feature/packages.test.ts +17 -34
  53. package/src/workflows/feature/packages.ts +13 -52
  54. package/src/workflows/feature/planning.ts +1 -1
  55. package/src/workflows/feature/review.test.ts +233 -54
  56. package/src/workflows/feature/review.ts +178 -41
  57. package/src/workflows/feature/steps.test.ts +12 -0
  58. package/src/workflows/feature/steps.ts +8 -1
  59. package/src/workflows/index.ts +0 -2
  60. package/src/workflows/shared/access.test.ts +4 -2
  61. package/src/workflows/shared/access.ts +1 -0
  62. package/src/workflows/shared/health.test.ts +1 -21
  63. package/src/workflows/shared/health.ts +4 -39
  64. package/src/workflows/shared/options.test.ts +8 -0
  65. package/src/workflows/shared/options.ts +1 -1
  66. package/src/workflows/shared/steps.ts +18 -11
  67. package/src/workflows/shared/tail.test.ts +1 -41
  68. package/src/workflows/shared/tail.ts +17 -41
  69. package/src/workflows/shared/text.fixture.ts +1 -0
  70. package/src/workflows/shared/text.test.ts +129 -41
  71. package/src/workflows/shared/text.ts +111 -75
  72. package/src/workflows/shared/vars.ts +1 -1
  73. package/src/workflows/shared/scenarios.test.ts +0 -256
  74. package/src/workflows/shared/scenarios.ts +0 -35
@@ -0,0 +1,242 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "vitest"
2
+ import {
3
+ checkScript,
4
+ moveScript,
5
+ removeScript,
6
+ restoreScript,
7
+ revertScript,
8
+ showScript,
9
+ } from "../flows/index.js"
10
+ import { gitTiers, type GitTier, type ScriptedCommand } from "./index.js"
11
+
12
+ // Every case runs through real `sh` and through `applyFlowScript`. A check's
13
+ // command is declared for the in-memory twin, and Live runs it for real — a
14
+ // declaration that lies about the command fails here, not in a scenario.
15
+ const FAILING_WITH_OUTPUT = "printf 'boom\\n'; exit 3"
16
+ const FAILING_SILENTLY = "exit 4"
17
+ const PASSING = "true"
18
+ const WRITING = "printf 'x\\n' > out.txt"
19
+
20
+ const commands = new Map<string, ScriptedCommand>([
21
+ [FAILING_WITH_OUTPUT, { kind: "exit", status: 3, output: "boom\n" }],
22
+ [FAILING_SILENTLY, { kind: "exit", status: 4, output: "" }],
23
+ [PASSING, { kind: "exit", status: 0, output: "" }],
24
+ [WRITING, { kind: "rewrite", file: "out.txt", content: "x\n" }],
25
+ ])
26
+
27
+ const REPORT = ".gtd/FEEDBACK.md"
28
+
29
+ for (const makeTier of gitTiers) {
30
+ describe(`flows scripts [${makeTier().name}]`, () => {
31
+ let t: GitTier
32
+ const run = (script: string) => t.runScript(script, commands)
33
+ const read = (path: string) =>
34
+ t.observe.existsPath(path) ? t.observe.readWorktreeFile(path) : undefined
35
+
36
+ beforeEach(() => {
37
+ t = makeTier()
38
+ })
39
+
40
+ afterEach(() => {
41
+ t.dispose()
42
+ })
43
+
44
+ describe("removeScript", () => {
45
+ it("removes files and whole directories, and tolerates absent paths", () => {
46
+ t.seed.writeFile("out/a.txt", "a")
47
+ t.seed.writeFile("out/deep/b.txt", "b")
48
+ t.seed.writeFile("c.txt", "c")
49
+ t.seed.writeFile("keep.txt", "k")
50
+ expect(run(removeScript(["out", "c.txt", "missing.txt"])).ok).toBe(true)
51
+ expect(read("out/a.txt")).toBeUndefined()
52
+ expect(read("out/deep/b.txt")).toBeUndefined()
53
+ expect(read("c.txt")).toBeUndefined()
54
+ expect(read("keep.txt")).toBe("k")
55
+ })
56
+ })
57
+
58
+ describe("moveScript", () => {
59
+ it("moves a file, creating the target directory", () => {
60
+ t.seed.writeFile("a.txt", "a")
61
+ expect(run(moveScript("a.txt", "deep/dir/b.txt")).ok).toBe(true)
62
+ expect(read("a.txt")).toBeUndefined()
63
+ expect(read("deep/dir/b.txt")).toBe("a")
64
+ })
65
+
66
+ it("fails on an absent source", () => {
67
+ expect(run(moveScript("missing.txt", "b.txt")).ok).toBe(false)
68
+ })
69
+ })
70
+
71
+ describe("showScript", () => {
72
+ it("writes the committed blob, not the working copy", () => {
73
+ t.seed.commit("feat: notes", { "notes.md": "committed" })
74
+ const head = t.observe.resolveRef("HEAD")
75
+ t.seed.writeFile("notes.md", "edited")
76
+ expect(run(showScript(head, "notes.md", "notes.orig.md")).ok).toBe(true)
77
+ expect(read("notes.orig.md")).toBe("committed")
78
+ })
79
+ })
80
+
81
+ describe("restoreScript", () => {
82
+ it("restores and removes only the paths still exactly as the commit left them", () => {
83
+ t.seed.commit("feat: base", { "a.txt": "a0", "b.txt": "b0" })
84
+ t.seed.commit("feat: edit", {
85
+ "a.txt": "a1",
86
+ "b.txt": "b1",
87
+ "new.txt": "n1",
88
+ "new-edited.txt": "e1",
89
+ })
90
+ const commit = t.observe.resolveRef("HEAD")
91
+ t.seed.writeFile("b.txt", "b2")
92
+ t.seed.writeFile("new-edited.txt", "e2")
93
+ const script = restoreScript(commit, {
94
+ restore: ["a.txt", "b.txt"],
95
+ remove: ["new.txt", "new-edited.txt"],
96
+ })
97
+ // `sh` exits with the last line's status, and new-edited.txt was left alone.
98
+ expect(run(script).ok).toBe(false)
99
+ expect(read("a.txt")).toBe("a0")
100
+ expect(read("b.txt")).toBe("b2")
101
+ expect(read("new.txt")).toBeUndefined()
102
+ expect(read("new-edited.txt")).toBe("e2")
103
+ })
104
+
105
+ it("restores from the given source, deleting a path the source lacks", () => {
106
+ const first = t.observe.resolveRef("HEAD")
107
+ t.seed.commit("feat: a", { "a.txt": "a1" })
108
+ t.seed.commit("feat: a again", { "a.txt": "a2" })
109
+ const commit = t.observe.resolveRef("HEAD")
110
+ expect(run(restoreScript(commit, { restore: ["a.txt"], remove: [] }, first)).ok).toBe(true)
111
+ expect(read("a.txt")).toBeUndefined()
112
+ })
113
+
114
+ it("fails on a source that names no commit, touching nothing", () => {
115
+ const root = t.observe.resolveRef("HEAD")
116
+ t.seed.commit("feat: a", { "a.txt": "a1" })
117
+ const commit = t.observe.resolveRef("HEAD")
118
+ const missing = "0".repeat(40)
119
+ expect(run(restoreScript(commit, { restore: ["a.txt"], remove: [] }, missing)).ok).toBe(
120
+ false,
121
+ )
122
+ expect(read("a.txt")).toBe("a1")
123
+ // The default source of a root commit is its absent parent.
124
+ expect(run(restoreScript(root, { restore: ["absent.txt"], remove: [] })).ok).toBe(false)
125
+ })
126
+
127
+ it("leaves a deleted path alone", () => {
128
+ t.seed.commit("feat: base", { "a.txt": "a0" })
129
+ t.seed.commit("feat: edit", { "a.txt": "a1" })
130
+ const commit = t.observe.resolveRef("HEAD")
131
+ t.seed.deleteFile("a.txt")
132
+ expect(run(restoreScript(commit, { restore: ["a.txt"], remove: [] })).ok).toBe(false)
133
+ expect(read("a.txt")).toBeUndefined()
134
+ })
135
+ })
136
+
137
+ describe("revertScript", () => {
138
+ it("reverses a commit's modifications, additions and deletions in the working tree", () => {
139
+ t.seed.commit("feat: base", { "mod.txt": "before", "gone.txt": "g" })
140
+ t.seed.commit("feat: change", { "mod.txt": "after", "added.txt": "new" })
141
+ t.seed.commitDeletion("gone.txt", "feat: delete")
142
+ t.seed.commit("feat: squash", { "mod.txt": "after2" })
143
+ const commit = t.observe.resolveRef("HEAD")
144
+ expect(run(revertScript(commit, REPORT, "could not revert")).ok).toBe(true)
145
+ expect(read("mod.txt")).toBe("after")
146
+ expect(read(REPORT)).toBeUndefined()
147
+ expect(t.observe.resolveRef("HEAD")).toBe(commit)
148
+ })
149
+
150
+ it("reverses an addition and a deletion", () => {
151
+ t.seed.commit("feat: base", { "gone.txt": "g" })
152
+ t.seed.writeFile("added.txt", "new")
153
+ t.seed.deleteFile("gone.txt")
154
+ t.seed.stageAll()
155
+ t.seed.commit("feat: swap", {})
156
+ const commit = t.observe.resolveRef("HEAD")
157
+ expect(run(revertScript(commit, REPORT, "could not revert")).ok).toBe(true)
158
+ expect(read("added.txt")).toBeUndefined()
159
+ expect(read("gone.txt")).toBe("g")
160
+ })
161
+
162
+ it("does nothing for a commit with nothing to undo", () => {
163
+ t.seed.commit("feat: empty", {})
164
+ const commit = t.observe.resolveRef("HEAD")
165
+ expect(run(revertScript(commit, REPORT, "could not revert")).ok).toBe(true)
166
+ expect(read(REPORT)).toBeUndefined()
167
+ expect(t.observe.statusPorcelain()).toBe("")
168
+ })
169
+
170
+ it("reports a revert that cannot apply, leaving the tree untouched", () => {
171
+ t.seed.commit("feat: base", { "a.txt": "a0" })
172
+ t.seed.commit("feat: change", { "a.txt": "a1", "b.txt": "b1" })
173
+ const commit = t.observe.resolveRef("HEAD")
174
+ t.seed.deleteFile("b.txt")
175
+ expect(run(revertScript(commit, REPORT, "could not revert")).ok).toBe(true)
176
+ expect(read(REPORT)).toMatch(/^could not revert\n\n\S/)
177
+ expect(read("a.txt")).toBe("a1")
178
+ })
179
+ })
180
+
181
+ describe("checkScript", () => {
182
+ const options = { report: REPORT, stamp: "abc1234" }
183
+
184
+ it("keeps a failing command's output in the report, stamped", () => {
185
+ expect(run(checkScript(FAILING_WITH_OUTPUT, options)).ok).toBe(true)
186
+ expect(read(REPORT)).toBe("boom\n\n<!-- gtd check abc1234 -->\n")
187
+ expect(read(`${REPORT}.output`)).toBeUndefined()
188
+ })
189
+
190
+ it("says so when a failing command printed nothing", () => {
191
+ run(checkScript(FAILING_SILENTLY, options))
192
+ expect(read(REPORT)).toBe(
193
+ "the test command failed with exit code 4 and produced no output.\n<!-- gtd check abc1234 -->\n",
194
+ )
195
+ })
196
+
197
+ it("removes the report and sweeps on green", () => {
198
+ t.seed.writeFile(REPORT, "old")
199
+ t.seed.writeFile("stale/a.txt", "a")
200
+ run(checkScript(PASSING, { ...options, sweepOnGreen: ["stale"] }))
201
+ expect(read(REPORT)).toBeUndefined()
202
+ expect(read("stale/a.txt")).toBeUndefined()
203
+ })
204
+
205
+ it("keeps what a passing command wrote", () => {
206
+ run(checkScript(WRITING, options))
207
+ expect(read("out.txt")).toBe("x\n")
208
+ })
209
+
210
+ it("sweeps before the command, and not on red", () => {
211
+ t.seed.writeFile("before.txt", "b")
212
+ t.seed.writeFile("green.txt", "g")
213
+ run(
214
+ checkScript(FAILING_SILENTLY, {
215
+ ...options,
216
+ sweep: ["before.txt"],
217
+ sweepOnGreen: ["green.txt"],
218
+ }),
219
+ )
220
+ expect(read("before.txt")).toBeUndefined()
221
+ expect(read("green.txt")).toBe("g")
222
+ })
223
+
224
+ it("runs the preamble first; its exit 0 skips the command and leaves the report", () => {
225
+ t.seed.writeFile(REPORT, "old")
226
+ t.seed.writeFile("drop.txt", "d")
227
+ const preamble = [
228
+ "mkdir -p '.gtd'",
229
+ "printf '%s\\n' 'set it\nup' > '.gtd/SETUP.md'",
230
+ "printf '%s\\n' 'set it up' >&2",
231
+ "rm -f 'drop.txt'",
232
+ "exit 0",
233
+ ]
234
+ const result = run(checkScript(FAILING_WITH_OUTPUT, { ...options, preamble }))
235
+ expect(result.output).toBe("set it up\n")
236
+ expect(read(".gtd/SETUP.md")).toBe("set it\nup\n")
237
+ expect(read("drop.txt")).toBeUndefined()
238
+ expect(read(REPORT)).toBe("old")
239
+ })
240
+ })
241
+ })
242
+ }
@@ -0,0 +1,326 @@
1
+ // The in-memory twin of `sh` for the scripts `src/flows/scripts.ts` builds.
2
+ // Each script is recognized by RE-RUNNING its builder on the values pulled
3
+ // out of it and comparing bytes, then applied to the `InMemRepo` the way the
4
+ // real shell would. `FlowScripts.test.ts` runs the same cases through real
5
+ // `sh` and this twin, so the two cannot drift apart silently.
6
+
7
+ import {
8
+ checkScript,
9
+ moveScript,
10
+ removeScript,
11
+ restoreScript,
12
+ revertScript,
13
+ showScript,
14
+ } from "../flows/index.js"
15
+ import type { InMemRepo } from "./InMemRepo.js"
16
+ import type { ScriptedCommand } from "./Layers.js"
17
+
18
+ export interface FlowScriptResult {
19
+ readonly ok: boolean
20
+ /** Present exactly when `ok` is `false`. */
21
+ readonly error?: string
22
+ /** What the script printed to output. */
23
+ readonly output: string
24
+ }
25
+
26
+ const SHEBANG = "#!/usr/bin/env sh"
27
+
28
+ const words = (text: string): string[] =>
29
+ [...text.matchAll(/'((?:[^']|'\\'')*)'/g)].map((m) => m[1]!.replaceAll("'\\''", "'"))
30
+
31
+ const ok = (output = ""): FlowScriptResult => ({ ok: true, output })
32
+
33
+ /** `rm -rf -- <paths>`. `InMemRepo.pathsUnder("")` is the whole tree, where a real `rm -- ''` removes nothing. */
34
+ const removeAll = (repo: InMemRepo, paths: readonly string[]): void => {
35
+ if (paths.includes("")) throw new Error("testkit: rm: refusing an empty path")
36
+ for (const path of paths) for (const file of repo.pathsUnder(path)) repo.deleteFile(file)
37
+ }
38
+
39
+ const deleteIfPresent = (repo: InMemRepo, path: string): void => {
40
+ if (repo.readFile(path) !== undefined) repo.deleteFile(path)
41
+ }
42
+
43
+ const recognizeRemove = (repo: InMemRepo, script: string): FlowScriptResult | undefined => {
44
+ const line = script.split("\n").find((l) => l.startsWith("rm -rf -- ")) ?? ""
45
+ const paths = words(line)
46
+ if (removeScript(paths) !== script) return undefined
47
+ removeAll(repo, paths)
48
+ return ok()
49
+ }
50
+
51
+ const recognizeMove = (repo: InMemRepo, script: string): FlowScriptResult | undefined => {
52
+ const line = script.split("\n").find((l) => l.startsWith("mv -- ")) ?? ""
53
+ const [from, to] = words(line)
54
+ if (from === undefined || to === undefined || moveScript(from, to) !== script) return undefined
55
+ const content = repo.readFile(from)
56
+ if (content === undefined) return { ok: false, error: `mv: ${from}: No such file`, output: "" }
57
+ repo.deleteFile(from)
58
+ repo.writeFile(to, content)
59
+ return ok()
60
+ }
61
+
62
+ const recognizeShow = (repo: InMemRepo, script: string): FlowScriptResult | undefined => {
63
+ const line = script.split("\n").find((l) => l.startsWith("git show ")) ?? ""
64
+ const [source, to] = words(line)
65
+ const at = source?.indexOf(":") ?? -1
66
+ if (source === undefined || to === undefined || at === -1) return undefined
67
+ const ref = source.slice(0, at)
68
+ const path = source.slice(at + 1)
69
+ if (showScript(ref, path, to) !== script) return undefined
70
+ const content = repo.fileAtRef(ref, path)
71
+ if (content === null)
72
+ return { ok: false, error: `git show: ${source} does not exist`, output: "" }
73
+ repo.writeFile(to, content)
74
+ return ok()
75
+ }
76
+
77
+ /** `git diff --quiet <commit> -- <path>`: the worktree copy of a tracked path, or nothing for an untracked one, against `commit`'s. */
78
+ const unchangedSince = (repo: InMemRepo, commit: string, path: string): boolean => {
79
+ const current = repo.isTracked(path) ? (repo.readFile(path) ?? null) : null
80
+ return current === repo.fileAtRef(commit, path)
81
+ }
82
+
83
+ const RESTORE_LINE = / && git restore --source=/
84
+ const REMOVE_LINE = / && rm -f -- /
85
+
86
+ interface RestoreLine {
87
+ readonly commit: string
88
+ readonly path: string
89
+ /** Set on a restore line, absent on a removal. */
90
+ readonly source?: string
91
+ }
92
+
93
+ const parseRestoreLine = (line: string): RestoreLine | undefined => {
94
+ const [commit, path, source] = words(line)
95
+ if (commit === undefined || path === undefined) return undefined
96
+ if (RESTORE_LINE.test(line) && source !== undefined) return { commit, path, source }
97
+ return REMOVE_LINE.test(line) ? { commit, path } : undefined
98
+ }
99
+
100
+ /** Applies one line, returning its exit status as `sh` would see it. */
101
+ const applyRestoreLine = (repo: InMemRepo, { commit, path, source }: RestoreLine): boolean => {
102
+ if (!unchangedSince(repo, commit, path)) return false
103
+ // `git restore` rejects a source that names no commit, touching nothing.
104
+ if (source !== undefined && repo.resolveRef(source) === null) return false
105
+ const content = source === undefined ? null : repo.fileAtRef(source, path)
106
+ if (content !== null) repo.writeFile(path, content)
107
+ else if (source === undefined || repo.isTracked(path)) deleteIfPresent(repo, path)
108
+ return true
109
+ }
110
+
111
+ const recognizeRestore = (repo: InMemRepo, script: string): FlowScriptResult | undefined => {
112
+ const parsed = script.split("\n").slice(2, -1).map(parseRestoreLine)
113
+ if (parsed.includes(undefined)) return undefined
114
+ const lines = parsed as RestoreLine[]
115
+ const restoring = lines.filter((l) => l.source !== undefined)
116
+ const paths = {
117
+ restore: restoring.map((l) => l.path),
118
+ remove: lines.filter((l) => l.source === undefined).map((l) => l.path),
119
+ }
120
+ const commit = lines[0]?.commit ?? ""
121
+ if (restoreScript(commit, paths, restoring[0]?.source) !== script) return undefined
122
+ // `sh` exits with the last line's status: a final path left alone fails it.
123
+ const statuses = lines.map((line) => applyRestoreLine(repo, line))
124
+ if (statuses.at(-1) === false) {
125
+ return { ok: false, error: "the last path changed since the commit", output: "" }
126
+ }
127
+ return ok()
128
+ }
129
+
130
+ const PATCH_FAILED = "error: patch does not apply"
131
+
132
+ /**
133
+ * `git diff C^ C | git apply -R`, which is all or nothing. A file still
134
+ * exactly as `C` left it reverts; a file gone, or one `C` added and that
135
+ * changed since, fails the whole patch. A file `C` modified and that changed
136
+ * since may still apply through its context — that needs real git, so it
137
+ * throws rather than guess.
138
+ */
139
+ const reverseApply = (repo: InMemRepo, commit: string): string | undefined => {
140
+ const parent = `${commit}^`
141
+ const paths = new Set([...repo.pathsAtRef(parent), ...repo.pathsAtRef(commit)])
142
+ const changed = [...paths].filter((p) => repo.fileAtRef(parent, p) !== repo.fileAtRef(commit, p))
143
+ for (const path of changed) {
144
+ const after = repo.fileAtRef(commit, path)
145
+ const current = repo.readFile(path) ?? null
146
+ if (current === after) continue
147
+ if (current === null || after === null || repo.fileAtRef(parent, path) === null) {
148
+ return `${PATCH_FAILED}: ${path}`
149
+ }
150
+ throw new Error(
151
+ `testkit: cannot tell in memory whether reverting ${commit} applies to the hand-edited ${path} — run this @live`,
152
+ )
153
+ }
154
+ for (const path of changed) {
155
+ const before = repo.fileAtRef(parent, path)
156
+ if (before === null) repo.deleteFile(path)
157
+ else repo.writeFile(path, before)
158
+ }
159
+ return undefined
160
+ }
161
+
162
+ const recognizeRevert = (repo: InMemRepo, script: string): FlowScriptResult | undefined => {
163
+ const lines = script.split("\n")
164
+ const [, commit] = words(lines.find((l) => l.startsWith("if ! git diff --quiet ")) ?? "")
165
+ const [, failure] = words(lines.find((l) => l.includes("printf '%s\\n\\n' ")) ?? "")
166
+ const [report] = words(lines.find((l) => l.startsWith(" } > ")) ?? "")
167
+ if (commit === undefined || failure === undefined || report === undefined) return undefined
168
+ if (revertScript(commit, report, failure) !== script) return undefined
169
+ const error = reverseApply(repo, commit)
170
+ if (error !== undefined) repo.writeFile(report, `${failure}\n\n${error}\n`)
171
+ return ok()
172
+ }
173
+
174
+ type PreambleOutcome = { readonly exit: boolean; readonly output: string }
175
+
176
+ /** One preamble line — the closed vocabulary the bundled workflows write there. */
177
+ const runPreambleLine = (repo: InMemRepo, line: string): PreambleOutcome => {
178
+ const quoted = words(line)
179
+ if (line === "exit 0") return { exit: true, output: "" }
180
+ if (line.startsWith("mkdir -p ")) return { exit: false, output: "" }
181
+ if (line.startsWith("rm -f ")) {
182
+ for (const path of quoted) deleteIfPresent(repo, path)
183
+ return { exit: false, output: "" }
184
+ }
185
+ if (line.startsWith("rm -rf -- ")) {
186
+ removeAll(repo, quoted)
187
+ return { exit: false, output: "" }
188
+ }
189
+ const printf = /^printf '%s\\n' '(?:[^']|'\\'')*' (>&2|> '(?:[^']|'\\'')*')$/.exec(line)
190
+ if (printf !== null) {
191
+ const [, text, file] = quoted
192
+ if (printf[1] === ">&2") return { exit: false, output: `${text}\n` }
193
+ repo.writeFile(file!, `${text}\n`)
194
+ return { exit: false, output: "" }
195
+ }
196
+ throw new Error(`testkit: cannot run the preamble line \`${line}\` in memory`)
197
+ }
198
+
199
+ /** Rejoins lines a single-quoted word spans, so a preamble entry carrying a newline stays one line. */
200
+ const shellLines = (lines: readonly string[]): string[] => {
201
+ const joined: string[] = []
202
+ let open = false
203
+ for (const line of lines) {
204
+ if (open) joined[joined.length - 1] += `\n${line}`
205
+ else joined.push(line)
206
+ for (let i = 0; i < line.length; i++) {
207
+ if (line[i] === "'" && (open || line[i - 1] !== "\\")) open = !open
208
+ }
209
+ }
210
+ return joined
211
+ }
212
+
213
+ interface ParsedCheck {
214
+ readonly command: string
215
+ readonly report: string
216
+ readonly stamp: string
217
+ readonly sweep: readonly string[]
218
+ readonly sweepOnGreen: readonly string[]
219
+ readonly preamble: readonly string[]
220
+ }
221
+
222
+ const outsideCommand = (lines: readonly string[], mkdir: number) => {
223
+ const before = shellLines(lines.slice(2, mkdir))
224
+ const swept = before.at(-1)?.startsWith("rm -rf -- ") === true
225
+ const green = lines.find((l) => l.startsWith(" rm -rf -- "))
226
+ return {
227
+ sweep: swept ? words(before.at(-1)!) : [],
228
+ sweepOnGreen: green === undefined ? [] : words(green),
229
+ preamble: swept ? before.slice(0, -1) : before,
230
+ }
231
+ }
232
+
233
+ const parseCheck = (script: string): ParsedCheck | undefined => {
234
+ const lines = script.split("\n")
235
+ const mkdir = lines.findIndex((l) => l.startsWith('mkdir -p "$(dirname '))
236
+ const close = lines.findIndex((l, i) => i > mkdir && /^\) > '.*' 2>&1$/.test(l))
237
+ if (mkdir === -1 || lines[mkdir + 1] !== "(" || close === -1) return undefined
238
+ const [report] = words(lines[mkdir]!)
239
+ const [, stamp] = words(lines.find((l) => l.includes("<!-- gtd check %s -->")) ?? "")
240
+ if (report === undefined || stamp === undefined) return undefined
241
+ return {
242
+ command: lines.slice(mkdir + 2, close).join("\n"),
243
+ report,
244
+ stamp,
245
+ ...outsideCommand(lines, mkdir),
246
+ }
247
+ }
248
+
249
+ const runCommand = (
250
+ repo: InMemRepo,
251
+ commands: ReadonlyMap<string, ScriptedCommand>,
252
+ command: string,
253
+ ): { readonly status: number; readonly output: string } => {
254
+ const scripted = commands.get(command)
255
+ if (scripted === undefined) {
256
+ throw new Error(
257
+ `testkit: cannot run \`${command}\` in memory — script it ("the shell command … exits …")`,
258
+ )
259
+ }
260
+ if (scripted.kind === "exit") return scripted
261
+ if (scripted.kind === "missing") return { status: 127, output: `sh: ${command}: not found\n` }
262
+ repo.writeFile(scripted.file, rewritten(repo, scripted))
263
+ return { status: 0, output: "" }
264
+ }
265
+
266
+ /** What a scripted rewrite leaves in its file. */
267
+ export const rewritten = (
268
+ repo: InMemRepo,
269
+ command: Extract<ScriptedCommand, { readonly kind: "rewrite" }>,
270
+ ): string =>
271
+ command.append === true
272
+ ? `${repo.readFile(command.file) ?? ""}${command.content}`
273
+ : command.content
274
+
275
+ const recognizeCheck = (
276
+ repo: InMemRepo,
277
+ commands: ReadonlyMap<string, ScriptedCommand>,
278
+ script: string,
279
+ ): FlowScriptResult | undefined => {
280
+ const parsed = parseCheck(script)
281
+ if (parsed === undefined) return undefined
282
+ const { command, report, stamp, sweep, sweepOnGreen, preamble } = parsed
283
+ if (checkScript(command, { report, stamp, sweep, sweepOnGreen, preamble }) !== script) {
284
+ return undefined
285
+ }
286
+ let printed = ""
287
+ for (const line of preamble) {
288
+ const outcome = runPreambleLine(repo, line)
289
+ printed += outcome.output
290
+ if (outcome.exit) return ok(printed)
291
+ }
292
+ removeAll(repo, sweep)
293
+ const { status, output } = runCommand(repo, commands, command)
294
+ if (status === 0) {
295
+ deleteIfPresent(repo, report)
296
+ removeAll(repo, sweepOnGreen)
297
+ return ok(printed)
298
+ }
299
+ const body =
300
+ output.length > 0
301
+ ? output
302
+ : `the test command failed with exit code ${status} and produced no output.`
303
+ repo.writeFile(report, `${body}\n<!-- gtd check ${stamp} -->\n`)
304
+ return ok(printed)
305
+ }
306
+
307
+ /**
308
+ * Applies a `src/flows/scripts.ts` script to `repo`, or `undefined` when
309
+ * `script` is none of them. `commands` stands in for the real programs a
310
+ * check runs, keyed by the exact command string.
311
+ */
312
+ export const applyFlowScript = (
313
+ repo: InMemRepo,
314
+ commands: ReadonlyMap<string, ScriptedCommand>,
315
+ script: string,
316
+ ): FlowScriptResult | undefined => {
317
+ if (!script.startsWith(`${SHEBANG}\n`)) return undefined
318
+ return (
319
+ recognizeRemove(repo, script) ??
320
+ recognizeMove(repo, script) ??
321
+ recognizeShow(repo, script) ??
322
+ recognizeRevert(repo, script) ??
323
+ recognizeRestore(repo, script) ??
324
+ recognizeCheck(repo, commands, script)
325
+ )
326
+ }