@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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +115 -390
- package/dist/chunks/{Schema-B3o6eRNU.mjs → testkit-CMBAAyRK.mjs} +13409 -3351
- package/dist/gtd.bundle.mjs +76 -59
- package/dist/testkit/gherkin.mjs +30 -2
- package/dist/testkit.mjs +1 -1
- package/package.json +3 -42
- package/skills/authoring/SKILL.md +31 -25
- package/src/cli/Cli.test.ts +15 -19
- package/src/cli/Cli.ts +2 -4
- package/src/cli/Program.test.ts +60 -29
- package/src/cli/Program.ts +5 -7
- package/src/edge/Edge.ts +3 -3
- package/src/flags/index.test.ts +0 -13
- package/src/flags/index.ts +1 -3
- package/src/gherkin/git-control.feature +78 -0
- package/src/gherkin/index.ts +35 -1
- package/src/install/Install.test.ts +14 -11
- package/src/install/Install.ts +38 -15
- package/src/install/index.ts +1 -2
- package/src/replay/Replay.test.ts +1 -1
- package/src/replay/index.ts +1 -1
- package/src/step/planStart.ts +6 -1
- package/src/testkit/TestRepo.test.ts +118 -2
- package/src/testkit/TestRepo.ts +57 -6
- package/src/testkit/index.ts +1 -0
- package/src/twins/EmittedScriptRecognizer.ts +12 -14
- package/src/twins/FlowScripts.ts +10 -9
- package/src/twins/InMemRepo.ts +26 -1
- package/src/twins/Layers.ts +12 -1
- package/src/twins/index.ts +6 -1
- package/src/workflow/index.ts +1 -0
- package/src/workflow/load.test.ts +138 -113
- package/src/workflow/load.ts +1 -1
- package/src/workflow/vars.test.ts +30 -3
- package/src/workflow/vars.ts +34 -2
- package/src/workflow-kit/index.ts +1 -1
- package/dist/chunks/reserved-lMYCaapY.mjs +0 -1990
- package/dist/chunks/review-Bfaror38.mjs +0 -427
- package/dist/chunks/tail-Bd7L940p.mjs +0 -355
- package/dist/chunks/testkit-D1-auYau.mjs +0 -10362
- package/dist/workflows/cleanup.mjs +0 -36
- package/dist/workflows/feature.mjs +0 -1018
- package/dist/workflows/fix.mjs +0 -41
- package/dist/workflows/review.mjs +0 -45
- package/dist/workflows/rollout.mjs +0 -48
- package/dist/workflows.mjs +0 -40651
- package/src/install/ConfigScaffold.ts +0 -22
- package/src/workflows/cleanup/cleanup-path.feature +0 -94
- package/src/workflows/cleanup/cleanup.feature +0 -315
- package/src/workflows/cleanup/cleanup.test.ts +0 -276
- package/src/workflows/cleanup/cleanup.ts +0 -52
- package/src/workflows/cleanup/index.ts +0 -6
- package/src/workflows/feature/acceptance-criteria-tier.feature +0 -25
- package/src/workflows/feature/acceptance-criteria.feature +0 -389
- package/src/workflows/feature/criteria-coverage.feature +0 -102
- package/src/workflows/feature/criteria.test.ts +0 -147
- package/src/workflows/feature/criteria.ts +0 -267
- package/src/workflows/feature/fast-path.feature +0 -93
- package/src/workflows/feature/feature.test.ts +0 -338
- package/src/workflows/feature/feature.ts +0 -156
- package/src/workflows/feature/green.ts +0 -19
- package/src/workflows/feature/index.ts +0 -18
- package/src/workflows/feature/lens-models.feature +0 -69
- package/src/workflows/feature/lens-review.feature +0 -566
- package/src/workflows/feature/naive-acceptance.feature +0 -147
- package/src/workflows/feature/naive-test.feature +0 -263
- package/src/workflows/feature/one-step-land.feature +0 -93
- package/src/workflows/feature/planning.ts +0 -41
- package/src/workflows/feature/refactor-absorbs.feature +0 -131
- package/src/workflows/feature/refactor-first.feature +0 -158
- package/src/workflows/feature/refactor-lens-models.feature +0 -79
- package/src/workflows/feature/refactor-rebuild.feature +0 -273
- package/src/workflows/feature/refactor-review.feature +0 -1528
- package/src/workflows/feature/refactor.test.ts +0 -1220
- package/src/workflows/feature/refactor.ts +0 -368
- package/src/workflows/feature/release-flag.feature +0 -167
- package/src/workflows/feature/release-flag.test.ts +0 -49
- package/src/workflows/feature/release-flag.ts +0 -46
- package/src/workflows/feature/review-gate.feature +0 -241
- package/src/workflows/feature/reviewer-walkthrough.feature +0 -142
- package/src/workflows/feature/risk-tier.feature +0 -351
- package/src/workflows/feature/seam-rejection.feature +0 -146
- package/src/workflows/feature/seam.feature +0 -297
- package/src/workflows/feature/seam.test.ts +0 -527
- package/src/workflows/feature/seam.ts +0 -426
- package/src/workflows/feature/standards.feature +0 -354
- package/src/workflows/feature/steps.test.ts +0 -65
- package/src/workflows/feature/steps.ts +0 -45
- package/src/workflows/feature/tier-pin.feature +0 -66
- package/src/workflows/feature/tier.test.ts +0 -166
- package/src/workflows/feature/tier.ts +0 -56
- package/src/workflows/fix/fix-lens-origin.feature +0 -114
- package/src/workflows/fix/fix-quality.feature +0 -212
- package/src/workflows/fix/fix.feature +0 -79
- package/src/workflows/fix/fix.test.ts +0 -74
- package/src/workflows/fix/fix.ts +0 -52
- package/src/workflows/fix/index.ts +0 -6
- package/src/workflows/index.ts +0 -47
- package/src/workflows/review/index.ts +0 -6
- package/src/workflows/review/review-door.feature +0 -199
- package/src/workflows/review/review.test.ts +0 -27
- package/src/workflows/review/review.ts +0 -60
- package/src/workflows/rollout/index.ts +0 -6
- package/src/workflows/rollout/rollout.feature +0 -510
- package/src/workflows/rollout/rollout.test.ts +0 -263
- package/src/workflows/rollout/rollout.ts +0 -82
- package/src/workflows/shared/__fixtures__/feature-collecting-prompt.txt +0 -98
- package/src/workflows/shared/access.test.ts +0 -62
- package/src/workflows/shared/access.ts +0 -24
- package/src/workflows/shared/config.fixture.ts +0 -12
- package/src/workflows/shared/fences.ts +0 -24
- package/src/workflows/shared/health.test.ts +0 -148
- package/src/workflows/shared/health.ts +0 -191
- package/src/workflows/shared/learn.test.ts +0 -140
- package/src/workflows/shared/learn.ts +0 -201
- package/src/workflows/shared/lenses.test.ts +0 -39
- package/src/workflows/shared/lenses.ts +0 -294
- package/src/workflows/shared/options.test.ts +0 -157
- package/src/workflows/shared/options.ts +0 -92
- package/src/workflows/shared/progress.feature +0 -124
- package/src/workflows/shared/prose.ts +0 -263
- package/src/workflows/shared/recheck.feature +0 -60
- package/src/workflows/shared/registry.test.ts +0 -345
- package/src/workflows/shared/registry.ts +0 -183
- package/src/workflows/shared/reserved.ts +0 -11
- package/src/workflows/shared/review.test.ts +0 -1062
- package/src/workflows/shared/review.ts +0 -446
- package/src/workflows/shared/skills.test.ts +0 -121
- package/src/workflows/shared/skills.ts +0 -49
- package/src/workflows/shared/standards.test.ts +0 -90
- package/src/workflows/shared/standards.ts +0 -156
- package/src/workflows/shared/steps.test.ts +0 -219
- package/src/workflows/shared/steps.ts +0 -201
- package/src/workflows/shared/tail.test.ts +0 -454
- package/src/workflows/shared/tail.ts +0 -264
- package/src/workflows/shared/text.fixture.ts +0 -128
- package/src/workflows/shared/text.test.ts +0 -1430
- package/src/workflows/shared/text.ts +0 -1700
- package/src/workflows/shared/threads.test.ts +0 -129
- package/src/workflows/shared/vars.ts +0 -47
- 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 {
|
|
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
|
|
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
|
})
|
package/src/testkit/TestRepo.ts
CHANGED
|
@@ -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(
|
|
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))
|
|
171
198
|
return
|
|
172
199
|
}
|
|
173
|
-
const applied = applyEmittedScript(repo,
|
|
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,
|
|
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,
|
|
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,
|
package/src/testkit/index.ts
CHANGED
|
@@ -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 {
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
751
|
+
commands: CommandTable,
|
|
754
752
|
script: string,
|
|
755
753
|
git: boolean,
|
|
756
754
|
): AppliedScriptResult => {
|
package/src/twins/FlowScripts.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
366
|
+
commands: CommandTable,
|
|
366
367
|
script: string,
|
|
367
368
|
): FlowScriptResult | undefined => {
|
|
368
369
|
if (!script.startsWith(`${SHEBANG}\n`)) return undefined
|
package/src/twins/InMemRepo.ts
CHANGED
|
@@ -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
|
-
|
|
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()]
|
package/src/twins/Layers.ts
CHANGED
|
@@ -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
|
-
| {
|
|
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
|
package/src/twins/index.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
export { InMemRepo } from "./InMemRepo.js"
|
|
2
|
-
export {
|
|
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"
|