@pmelab/gtd 23.2.0 → 24.1.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 +44 -11
- package/claude/hooks/access.ts +1 -1
- package/dist/chunks/Schema-Dh-mkwkq.mjs +25353 -0
- package/dist/chunks/reserved-ejn-LVYO.mjs +1388 -0
- package/dist/chunks/rolldown-runtime-8H4AJuhK.mjs +14 -0
- package/dist/chunks/steering-D-PvRC5W.mjs +16296 -0
- package/dist/chunks/wire-DA7q2ikx.mjs +473 -0
- package/dist/flows.mjs +525 -0
- package/dist/gtd.bundle.mjs +43830 -56044
- package/dist/steering.mjs +2 -0
- package/dist/testkit.mjs +13006 -0
- package/dist/wire.mjs +2 -0
- package/dist/workflow-kit.mjs +352 -0
- package/dist/workflows/feature.mjs +315 -0
- package/dist/workflows/fix.mjs +40 -0
- package/dist/workflows/review.mjs +25 -0
- package/dist/workflows.mjs +1953 -1724
- package/package.json +70 -8
- package/skills/authoring/SKILL.md +5 -4
- package/src/cli/Cli.test.ts +1329 -0
- package/src/cli/Cli.ts +1348 -0
- package/src/cli/Program.test.ts +2262 -0
- package/src/cli/Program.ts +1331 -0
- package/src/cli/ToolCommands.ts +43 -0
- package/src/cli/index.ts +14 -0
- package/src/edge/Edge.test.ts +1016 -0
- package/src/edge/Edge.ts +1225 -0
- package/src/edge/index.ts +16 -0
- package/src/emit/Emit.test.ts +425 -0
- package/src/emit/Emit.ts +139 -0
- package/src/emit/GitScript.test.ts +260 -0
- package/src/emit/GitScript.ts +131 -0
- package/src/emit/ModeContradiction.test.ts +120 -0
- package/src/emit/ModeContradiction.ts +66 -0
- package/src/emit/OutcomeScript.test.ts +238 -0
- package/src/emit/OutcomeScript.ts +73 -0
- package/src/emit/SteeringMode.test.ts +274 -0
- package/src/emit/SteeringMode.ts +256 -0
- package/src/emit/index.ts +40 -0
- package/src/flows/factory.ts +5 -1
- package/src/flows/runtime.ts +3 -0
- package/src/install/ConfigScaffold.ts +19 -0
- package/src/install/Install.test.ts +426 -0
- package/src/install/Install.ts +634 -0
- package/src/install/Version.ts +27 -0
- package/src/install/index.ts +3 -0
- package/src/platform/CommandRunner.ts +74 -0
- package/src/platform/Commentary.test.ts +126 -0
- package/src/platform/Commentary.ts +92 -0
- package/src/platform/Git.test.ts +50 -0
- package/src/platform/Git.ts +552 -0
- package/src/platform/Host.ts +42 -0
- package/src/platform/RetainedHistory.test.ts +65 -0
- package/src/platform/RetainedHistory.ts +34 -0
- package/src/platform/Workspace.test.ts +669 -0
- package/src/platform/Workspace.ts +631 -0
- package/src/platform/WorktreeState.test.ts +162 -0
- package/src/platform/WorktreeState.ts +50 -0
- package/src/platform/index.ts +22 -0
- package/src/replay/Access.test.ts +53 -0
- package/src/replay/Access.ts +46 -0
- package/src/replay/Format.test.ts +20 -0
- package/src/replay/Format.ts +14 -0
- package/src/replay/Replay.test.ts +887 -0
- package/src/replay/Replay.ts +669 -0
- package/src/replay/Trailers.test.ts +117 -0
- package/src/replay/Trailers.ts +213 -0
- package/src/replay/Tree.test.ts +25 -0
- package/src/replay/Tree.ts +45 -0
- package/src/replay/index.ts +11 -0
- package/src/steering/Blocks.fixture.ts +6 -0
- package/src/steering/Blocks.test.ts +167 -0
- package/src/steering/Blocks.ts +321 -0
- package/src/steering/CodeThreads.test.ts +127 -0
- package/src/steering/CodeThreads.ts +197 -0
- package/src/steering/Edits.test.ts +19 -0
- package/src/steering/Edits.ts +20 -0
- package/src/steering/Eol.test.ts +16 -0
- package/src/steering/Eol.ts +2 -0
- package/src/steering/FORMATS.md +58 -0
- package/src/steering/Footnotes.test.ts +977 -0
- package/src/steering/Footnotes.ts +661 -0
- package/src/steering/Inline.fixture.ts +7 -0
- package/src/steering/Inline.test.ts +227 -0
- package/src/steering/Inline.ts +195 -0
- package/src/steering/MarkdownTree.test.ts +247 -0
- package/src/steering/MarkdownTree.ts +260 -0
- package/src/steering/Outline.ts +19 -0
- package/src/steering/SteeringFormat.test.ts +91 -0
- package/src/steering/SteeringFormat.ts +267 -0
- package/src/steering/SteeringFormats.test.ts +163 -0
- package/src/steering/Threads.ts +163 -0
- package/src/steering/contract.test.ts +264 -0
- package/src/steering/fixtures/code-threads/cache.stripped.ts.txt +6 -0
- package/src/steering/fixtures/code-threads/cache.ts.md +15 -0
- package/src/steering/fixtures/free-form/notes.md +12 -0
- package/src/steering/fixtures/free-form/notes.read.json +42 -0
- package/src/steering/fixtures/qa/answered.md +23 -0
- package/src/steering/fixtures/qa/answered.read.json +119 -0
- package/src/steering/fixtures/qa/requirements.md +160 -0
- package/src/steering/fixtures/qa/requirements.read.json +475 -0
- package/src/steering/fixtures/review/review.md +76 -0
- package/src/steering/fixtures/review/review.read.json +237 -0
- package/src/steering/fixtures/review/ticked.md +23 -0
- package/src/steering/fixtures/review/ticked.read.json +99 -0
- package/src/steering/fixtures/review-notes/baseline.md +14 -0
- package/src/steering/fixtures/review-notes/noted.md +19 -0
- package/src/steering/freeform.test.ts +311 -0
- package/src/steering/freeform.ts +148 -0
- package/src/steering/index.test.ts +62 -0
- package/src/steering/index.ts +140 -0
- package/src/steering/qa.test.ts +2160 -0
- package/src/steering/qa.ts +1104 -0
- package/src/steering/review.test.ts +1829 -0
- package/src/steering/review.ts +1180 -0
- package/src/step/LandStep.ts +31 -0
- package/src/step/RepoSnapshot.ts +29 -0
- package/src/step/accessRefusal.test.ts +58 -0
- package/src/step/accessRefusal.ts +26 -0
- package/src/step/index.ts +5 -0
- package/src/step/planStart.test.ts +202 -0
- package/src/step/planStart.ts +167 -0
- package/src/step/planStep.test.ts +104 -0
- package/src/step/planStep.ts +89 -0
- package/src/step/snapshot.fixture.ts +28 -0
- package/src/testkit/EmittedScriptRecognizer.test.ts +639 -0
- package/src/testkit/EmittedScriptRecognizer.ts +642 -0
- package/src/testkit/FakeGitOperations.ts +95 -0
- package/src/testkit/GitTiers.test.ts +11 -0
- package/src/testkit/GitTiers.ts +1052 -0
- package/src/testkit/InMemRepo.ts +467 -0
- package/src/testkit/Layers.test.ts +58 -0
- package/src/testkit/Layers.ts +241 -0
- package/src/testkit/StrictGitOperations.ts +31 -0
- package/src/testkit/TestRepo.test.ts +119 -0
- package/src/testkit/TestRepo.ts +274 -0
- package/src/testkit/cliIo.ts +36 -0
- package/src/testkit/index.ts +9 -0
- package/src/testkit/steps.ts +177 -0
- package/src/testkit/testkit.feature +65 -0
- package/src/wire/BeatDocument.test.ts +419 -0
- package/src/wire/BeatDocument.ts +114 -0
- package/src/wire/BeatPlain.test.ts +113 -0
- package/src/wire/BeatPlain.ts +127 -0
- package/src/wire/BeatStatus.test.ts +59 -0
- package/src/wire/BeatStatus.ts +81 -0
- package/src/wire/Demand.test.ts +142 -0
- package/src/wire/Demand.ts +108 -0
- package/src/wire/ExitCodes.test.ts +21 -0
- package/src/wire/ExitCodes.ts +16 -0
- package/src/wire/Glob.ts +29 -0
- package/src/wire/Judge.ts +22 -0
- package/src/wire/Land.test.ts +120 -0
- package/src/wire/Land.ts +75 -0
- package/src/wire/Select.test.ts +148 -0
- package/src/wire/Select.ts +78 -0
- package/src/wire/Sessions.test.ts +59 -0
- package/src/wire/Sessions.ts +39 -0
- package/src/wire/WireSchema.test.ts +162 -0
- package/src/wire/constants.ts +22 -0
- package/src/wire/golden/1/beat-capture.json +34 -0
- package/src/wire/golden/1/beat-message.json +34 -0
- package/src/wire/golden/1/beat-prompt.json +44 -0
- package/src/wire/golden/1/beat-script.json +34 -0
- package/src/wire/golden/1/beat-stalled.json +34 -0
- package/src/wire/golden/1/judge.json +14 -0
- package/src/wire/golden/1/land-noop.json +10 -0
- package/src/wire/golden/1/land.json +10 -0
- package/src/wire/index.ts +12 -0
- package/src/wire/types.ts +46 -0
- package/src/workflow/Diagnostic.test.ts +93 -0
- package/src/workflow/Diagnostic.ts +54 -0
- package/src/workflow/SteeringFormats.test.ts +27 -0
- package/src/workflow/SteeringFormats.ts +6 -0
- package/src/workflow/Workflow.ts +77 -0
- package/src/workflow/compile.test.ts +53 -0
- package/src/workflow/compile.ts +56 -0
- package/src/workflow/discovery.ts +122 -0
- package/src/workflow/fingerprint.test.ts +52 -0
- package/src/workflow/fingerprint.ts +53 -0
- package/src/workflow/index.ts +33 -0
- package/src/workflow/load.test.ts +1017 -0
- package/src/workflow/load.ts +836 -0
- package/src/workflow/skills.test.ts +109 -0
- package/src/workflow/skills.ts +90 -0
- package/src/workflow/vars.ts +33 -0
- package/src/workflow-kit/health.test.ts +30 -0
- package/src/workflow-kit/health.ts +86 -0
- package/src/workflow-kit/index.ts +7 -0
- package/src/workflow-kit/quality.test.ts +17 -0
- package/src/workflow-kit/quality.ts +45 -0
- package/src/workflow-kit/review.ts +324 -0
- package/src/{workflows → workflow-kit}/scenarios.ts +21 -28
- package/src/workflows/feature/feature.test.ts +32 -0
- package/src/workflows/{bundled.ts → feature/feature.ts} +11 -63
- package/src/workflows/feature/index.ts +10 -0
- package/src/workflows/{packages.test.ts → feature/packages.test.ts} +2 -2
- package/src/workflows/{packages.ts → feature/packages.ts} +3 -3
- package/src/workflows/{planning.ts → feature/planning.ts} +2 -3
- package/src/workflows/{review.test.ts → feature/review.test.ts} +9 -44
- package/src/workflows/feature/review.ts +106 -0
- package/src/workflows/feature/steps.test.ts +82 -0
- package/src/workflows/feature/steps.ts +92 -0
- package/src/workflows/fix/fix.feature +80 -0
- package/src/workflows/fix/fix.test.ts +74 -0
- package/src/workflows/fix/fix.ts +52 -0
- package/src/workflows/fix/index.ts +6 -0
- package/src/workflows/index.ts +36 -2
- package/src/workflows/review/index.ts +6 -0
- package/src/workflows/review/review.test.ts +99 -0
- package/src/workflows/review/review.ts +33 -0
- package/src/workflows/{access.test.ts → shared/access.test.ts} +1 -1
- package/src/workflows/{access.ts → shared/access.ts} +3 -5
- package/src/workflows/shared/config.fixture.ts +12 -0
- package/src/workflows/{health.test.ts → shared/health.test.ts} +1 -1
- package/src/workflows/{health.ts → shared/health.ts} +38 -55
- package/src/workflows/{options.test.ts → shared/options.test.ts} +2 -2
- package/src/workflows/{options.ts → shared/options.ts} +14 -2
- package/src/workflows/shared/reserved.ts +11 -0
- package/src/workflows/{scenarios.test.ts → shared/scenarios.test.ts} +1 -1
- package/src/workflows/shared/scenarios.ts +35 -0
- package/src/workflows/{skills.test.ts → shared/skills.test.ts} +3 -1
- package/src/workflows/{skills.ts → shared/skills.ts} +7 -6
- package/src/workflows/{steps.test.ts → shared/steps.test.ts} +1 -66
- package/src/workflows/{steps.ts → shared/steps.ts} +8 -98
- package/src/workflows/shared/tail.test.ts +205 -0
- package/src/workflows/shared/tail.ts +135 -0
- package/src/workflows/{text.fixture.ts → shared/text.fixture.ts} +2 -2
- package/src/workflows/{text.ts → shared/text.ts} +48 -7
- package/src/workflows/{threads.test.ts → shared/threads.test.ts} +1 -1
- package/src/workflows/shared/workflows.steps.ts +7 -0
- package/src/workflows/review.ts +0 -362
- /package/src/workflows/{prose.ts → shared/prose.ts} +0 -0
- /package/src/workflows/{text.test.ts → shared/text.test.ts} +0 -0
- /package/src/workflows/{vars.ts → shared/vars.ts} +0 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// `testLayers` is the ONE layer-set builder every `@inmem` scenario and
|
|
2
|
+
// `src/**/*.test.ts` unit test provides.
|
|
3
|
+
//
|
|
4
|
+
// gtd itself spawns no subprocess at all any more (`gtd renders; the driver
|
|
5
|
+
// executes`) — a mode's `format:`/`validate:` command is emitted text, never
|
|
6
|
+
// run in-process. `ScriptedCommand` below still exists for the world's own
|
|
7
|
+
// fake-shell interpreter (`src/testkit/EmittedScriptRecognizer.ts`), which
|
|
8
|
+
// simulates a driver running gtd's emitted scripts against the in-memory
|
|
9
|
+
// worktree — a wholly separate mechanism from any gtd-internal layer.
|
|
10
|
+
|
|
11
|
+
import { Effect, Layer } from "effect"
|
|
12
|
+
import { isAbsolute, join } from "node:path"
|
|
13
|
+
import {
|
|
14
|
+
Narrator,
|
|
15
|
+
GitService,
|
|
16
|
+
Host,
|
|
17
|
+
Workspace,
|
|
18
|
+
type WorkspaceOps,
|
|
19
|
+
CommandRunner,
|
|
20
|
+
} from "../platform/index.js"
|
|
21
|
+
import {
|
|
22
|
+
ConfigDiscovery,
|
|
23
|
+
ConfigService,
|
|
24
|
+
LEGACY_RC_FILES,
|
|
25
|
+
legacyRcDirs,
|
|
26
|
+
WORKFLOW_MODULE,
|
|
27
|
+
walkUp,
|
|
28
|
+
} from "../workflow/index.js"
|
|
29
|
+
import { fakeGitOperations } from "./FakeGitOperations.js"
|
|
30
|
+
import { InMemRepo } from "./InMemRepo.js"
|
|
31
|
+
|
|
32
|
+
// Read off the CLI's own port rather than imported from the composition root.
|
|
33
|
+
type CommandRequirements = Layer.Layer.Success<ReturnType<CliIo["layers"]>>
|
|
34
|
+
import { ToolCommands, type CliIo } from "../cli/index.js"
|
|
35
|
+
|
|
36
|
+
const toError = (e: unknown): Error => (e instanceof Error ? e : new Error(String(e)))
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Keys UNDER `.git/` (never the bare `.git` FILE itself — `WorktreeState.ts`'s
|
|
40
|
+
* `worktreeGitDir` legitimately reads that one, a linked worktree's real
|
|
41
|
+
* gitdir-pointer file) have no production consumer through `Workspace` — gtd
|
|
42
|
+
* keeps no driver-scoped files inside the git dir (sessions are derived,
|
|
43
|
+
* stall is history) — so a stray read/write there FAILS/reads-absent rather
|
|
44
|
+
* than silently falling through to the worktree store, which would make it
|
|
45
|
+
* surface as a pending change (real git never reports a `.git/**` path as
|
|
46
|
+
* one). Mirrors the guard the deleted `makeInMemoryFileSystem` carried.
|
|
47
|
+
*/
|
|
48
|
+
const isGitDirKey = (key: string): boolean => key.startsWith(".git/")
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* `Workspace` for the in-memory tier. `readSync`/`read`/`write`/`committed`
|
|
52
|
+
* take a repo-relative key ONLY (mirroring the live adapter's
|
|
53
|
+
* `assertRepoRelative` — `Workspace.ts`'s `makeWorkspaceOps`), matching how
|
|
54
|
+
* every existing scenario already seeds the fake (`repo.writeFile("gtd.config.ts",
|
|
55
|
+
* …)`, `repo.writeFile("src/a.ts", …)`). // gtd-path-exempt: illustrative fixture key, not a repo file
|
|
56
|
+
* `atPath` alone accepts an absolute
|
|
57
|
+
* path: one rooted under `root` maps to its relative key (an already-absolute
|
|
58
|
+
* content file-ref resolved via `resolvePath`); one OUTSIDE root (an ancestor
|
|
59
|
+
* `gtd.config.ts`, or a leftover rc file in home) is used as the literal key
|
|
60
|
+
* verbatim, so a test seeds it exactly as given:
|
|
61
|
+
* `repo.writeFile("/home/user/.gtdrc", …)`.
|
|
62
|
+
*/
|
|
63
|
+
export const makeInMemoryWorkspaceOps = (repo: InMemRepo, root: string): WorkspaceOps => {
|
|
64
|
+
const toKey = (path: string): string => {
|
|
65
|
+
if (!isAbsolute(path)) return path
|
|
66
|
+
const prefix = `${root}/`
|
|
67
|
+
return path === root ? "" : path.startsWith(prefix) ? path.slice(prefix.length) : path
|
|
68
|
+
}
|
|
69
|
+
const assertRepoRelative = (path: string): string => {
|
|
70
|
+
if (isAbsolute(path)) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`Workspace: "${path}" is an absolute path — this member takes repo-relative paths only (use "atPath" to read outside the repo)`,
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
return path
|
|
76
|
+
}
|
|
77
|
+
const readAt = (key: string): string | undefined =>
|
|
78
|
+
isGitDirKey(key) ? undefined : repo.readFile(key)
|
|
79
|
+
|
|
80
|
+
const readSync = (path: string): string | undefined => readAt(assertRepoRelative(path))
|
|
81
|
+
|
|
82
|
+
const treeOf = (ref: string): ReadonlyMap<string, string> =>
|
|
83
|
+
new Map(repo.pathsAtRef(ref).map((path) => [path, repo.fileAtRef(ref, path) ?? ""]))
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
readSync,
|
|
87
|
+
read: (path) => Effect.try({ try: () => readSync(path), catch: toError }),
|
|
88
|
+
write: (path, content) =>
|
|
89
|
+
Effect.try({
|
|
90
|
+
try: () => {
|
|
91
|
+
const key = assertRepoRelative(path)
|
|
92
|
+
if (isGitDirKey(key)) {
|
|
93
|
+
throw new Error(`ENOENT: no such file or directory, open '${path}'`)
|
|
94
|
+
}
|
|
95
|
+
repo.writeFile(key, content)
|
|
96
|
+
},
|
|
97
|
+
catch: toError,
|
|
98
|
+
}),
|
|
99
|
+
committed: (path, ref = "HEAD") =>
|
|
100
|
+
Effect.try({ try: () => assertRepoRelative(path), catch: toError }).pipe(
|
|
101
|
+
Effect.map((key) =>
|
|
102
|
+
isGitDirKey(key) ? undefined : (repo.fileAtRef(ref, key) ?? undefined),
|
|
103
|
+
),
|
|
104
|
+
),
|
|
105
|
+
readCommittedSync: (path, ref = "HEAD") => {
|
|
106
|
+
const key = assertRepoRelative(path)
|
|
107
|
+
return isGitDirKey(key) ? undefined : (repo.fileAtRef(ref, key) ?? undefined)
|
|
108
|
+
},
|
|
109
|
+
treeSync: treeOf,
|
|
110
|
+
// No subprocess, no cache to warm — the in-memory fake reads its
|
|
111
|
+
// `InMemRepo` directly for each hash, same as `treeSync` above.
|
|
112
|
+
episodeTrees: (base, commits) =>
|
|
113
|
+
Effect.succeed(
|
|
114
|
+
new Map([...(base !== undefined ? [base] : []), ...commits].map((h) => [h, treeOf(h)])),
|
|
115
|
+
),
|
|
116
|
+
worktreeSync: () =>
|
|
117
|
+
new Map(
|
|
118
|
+
repo
|
|
119
|
+
.pathsUnder("")
|
|
120
|
+
.filter((path) => !isGitDirKey(path))
|
|
121
|
+
.map((path) => [path, repo.readFile(path)]),
|
|
122
|
+
),
|
|
123
|
+
atPath: (path) => readAt(toKey(path)),
|
|
124
|
+
writeAtPath: (path, content) =>
|
|
125
|
+
Effect.try({
|
|
126
|
+
try: () => {
|
|
127
|
+
const key = toKey(path)
|
|
128
|
+
if (isGitDirKey(key)) {
|
|
129
|
+
throw new Error(`ENOENT: no such file or directory, open '${path}'`)
|
|
130
|
+
}
|
|
131
|
+
repo.writeFile(key, content)
|
|
132
|
+
},
|
|
133
|
+
catch: toError,
|
|
134
|
+
}),
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The in-memory counterpart to `ConfigDiscovery.Live` (`src/workflow/discovery.ts`) — shares its `LEGACY_RC_FILES`/`legacyRcDirs`/`walkUp` exactly (see `Layers.test.ts`), reading through the fake `Workspace` instead of real `fs`. */
|
|
139
|
+
const makeInMemoryConfigDiscovery = (
|
|
140
|
+
repo: InMemRepo,
|
|
141
|
+
root: string,
|
|
142
|
+
): Layer.Layer<ConfigDiscovery> => {
|
|
143
|
+
const workspace = makeInMemoryWorkspaceOps(repo, root)
|
|
144
|
+
return Layer.succeed(ConfigDiscovery, {
|
|
145
|
+
legacyRcFiles: (levelRoot, levelHome) =>
|
|
146
|
+
Effect.try({
|
|
147
|
+
try: () =>
|
|
148
|
+
legacyRcDirs(levelRoot, levelHome).flatMap((dir) =>
|
|
149
|
+
LEGACY_RC_FILES.map((name) => join(dir, name))
|
|
150
|
+
.filter((filepath) => workspace.atPath(filepath) !== undefined)
|
|
151
|
+
.map((path) => ({ path, owned: true })),
|
|
152
|
+
),
|
|
153
|
+
catch: toError,
|
|
154
|
+
}),
|
|
155
|
+
workflowModule: (levelRoot, levelHome) =>
|
|
156
|
+
Effect.try({
|
|
157
|
+
try: () => {
|
|
158
|
+
for (const dir of walkUp(levelRoot, levelHome)) {
|
|
159
|
+
const filepath = join(dir, WORKFLOW_MODULE)
|
|
160
|
+
const source = workspace.atPath(filepath)
|
|
161
|
+
if (source !== undefined) return { filepath, source }
|
|
162
|
+
}
|
|
163
|
+
return undefined
|
|
164
|
+
},
|
|
165
|
+
catch: toError,
|
|
166
|
+
}),
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** One scripted `bash` command's canned behavior, keyed by the RENDERED command string a scenario's `Given` step declares. */
|
|
171
|
+
export type ScriptedCommand =
|
|
172
|
+
| { readonly kind: "exit"; readonly status: number; readonly output: string }
|
|
173
|
+
| { readonly kind: "rewrite"; readonly file: string; readonly content: string }
|
|
174
|
+
|
|
175
|
+
export interface TestWorldOptions {
|
|
176
|
+
readonly env?: Readonly<Record<string, string | undefined>>
|
|
177
|
+
readonly root?: string
|
|
178
|
+
/** Defaults to `root` — a real cwd→home chain has no natural counterpart in the fake, so a test not exercising it sees exactly one config level, same as before. */
|
|
179
|
+
readonly home?: string
|
|
180
|
+
/**
|
|
181
|
+
* Captures every narrated line — absent (the default) means the
|
|
182
|
+
* `Narrator` this builds is a no-op, exactly like a real invocation with no
|
|
183
|
+
* `--verbose`. A test asserting on narration passes its own sink here and
|
|
184
|
+
* gets every line regardless of `verbose` (see `verbose` below) — a direct
|
|
185
|
+
* Effect test has no `--verbose` flag of its own to gate on.
|
|
186
|
+
*/
|
|
187
|
+
readonly narrate?: (line: string) => void
|
|
188
|
+
/** 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. */
|
|
189
|
+
readonly verbose?: boolean
|
|
190
|
+
/** Real or fake driver and tool commands — each one left out fails when called. */
|
|
191
|
+
readonly tools?: Partial<ToolCommands["Type"]>
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const missingTool = (name: string) => () =>
|
|
195
|
+
Effect.fail(new Error(`gtd ${name}: ToolCommands has no test double wired into testLayers()`))
|
|
196
|
+
|
|
197
|
+
const missingToolCommands: ToolCommands["Type"] = {
|
|
198
|
+
ui: missingTool("ui"),
|
|
199
|
+
lsp: missingTool("lsp"),
|
|
200
|
+
loop: missingTool("loop"),
|
|
201
|
+
judge: () =>
|
|
202
|
+
Promise.reject(
|
|
203
|
+
new Error("gtd judge run: ToolCommands has no test double wired into testLayers()"),
|
|
204
|
+
),
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** The fine-grained `GitService` layer alone — for unit tests that need only git. */
|
|
208
|
+
export const gitTestLayer = (repo: InMemRepo, root = "/repo"): Layer.Layer<GitService> =>
|
|
209
|
+
Layer.succeed(GitService, fakeGitOperations(repo, root))
|
|
210
|
+
|
|
211
|
+
/** Every layer `makeProgram` needs — `CommandRequirements`'s return type here IS the guarantee: a new port added there fails this function's typecheck instead of silently under-providing. */
|
|
212
|
+
export function testLayers(
|
|
213
|
+
repo: InMemRepo,
|
|
214
|
+
opts: TestWorldOptions = {},
|
|
215
|
+
): Layer.Layer<CommandRequirements> {
|
|
216
|
+
const root = opts.root ?? "/repo"
|
|
217
|
+
const home = opts.home ?? root
|
|
218
|
+
|
|
219
|
+
return Layer.mergeAll(
|
|
220
|
+
gitTestLayer(repo, root),
|
|
221
|
+
Layer.succeed(Workspace, makeInMemoryWorkspaceOps(repo, root)),
|
|
222
|
+
// `realPath` is a no-op here (there is no real filesystem to resolve
|
|
223
|
+
// symlinks against): both sides of `assertRunningFromRepoRoot`'s
|
|
224
|
+
// comparison collapse to `root` when given anything, exactly like the
|
|
225
|
+
// fake's `root` "resolving" to itself before this port existed.
|
|
226
|
+
Host.layer({ root, home, env: opts.env ?? {}, realPath: () => Effect.succeed(root) }),
|
|
227
|
+
ConfigService.Live,
|
|
228
|
+
makeInMemoryConfigDiscovery(repo, root),
|
|
229
|
+
Narrator.layer(opts.narrate ?? (() => {}), opts.verbose ?? true),
|
|
230
|
+
// Drivers and tools are unit-tested in their own folders; a call reaching
|
|
231
|
+
// one of these is a test gap, not silence. A test opts into one via `tools`.
|
|
232
|
+
Layer.succeed(ToolCommands, { ...missingToolCommands, ...opts.tools }),
|
|
233
|
+
// The only `@inmem` scenarios that reach `gtd ui` refuse at a guard before
|
|
234
|
+
// its shell is touched (see `tests/integration/features/ui.feature`) — and
|
|
235
|
+
// gtd itself spawns no subprocess any more, so there is deliberately no
|
|
236
|
+
// scripted-command double to fall back on.
|
|
237
|
+
CommandRunner.layer(() =>
|
|
238
|
+
Effect.fail(new Error("gtd ui: CommandRunner has no test double wired into testLayers()")),
|
|
239
|
+
),
|
|
240
|
+
)
|
|
241
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Effect } from "effect"
|
|
2
|
+
import type { GitOperations } from "../platform/index.js"
|
|
3
|
+
import { TEST_DOUBLE_SENTINEL } from "./InMemRepo.js"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A `GitOperations` Proxy: `overrides` supply the methods a test actually
|
|
7
|
+
* exercises; every other method fails loudly the moment it's called — no
|
|
8
|
+
* method list to keep in sync with `GitReaderOperations`/`GitWriterOperations`
|
|
9
|
+
* (unlike a plain object literal, which needs a case per port method).
|
|
10
|
+
* `message`, when given, replaces the default per-method wording (e.g.
|
|
11
|
+
* `Program.test.ts`'s "GitService must not be called for --version/--help").
|
|
12
|
+
*/
|
|
13
|
+
export const strictGitOperations = (
|
|
14
|
+
overrides: Partial<GitOperations>,
|
|
15
|
+
message?: (name: string) => string,
|
|
16
|
+
): GitOperations =>
|
|
17
|
+
new Proxy(overrides, {
|
|
18
|
+
get(target, prop: string | symbol) {
|
|
19
|
+
if (typeof prop === "symbol" || prop in target) {
|
|
20
|
+
return (target as Record<string | symbol, unknown>)[prop as string]
|
|
21
|
+
}
|
|
22
|
+
const name = String(prop)
|
|
23
|
+
return () =>
|
|
24
|
+
Effect.fail(
|
|
25
|
+
new Error(
|
|
26
|
+
message?.(name) ??
|
|
27
|
+
`${name} should not have been called by this test (${TEST_DOUBLE_SENTINEL})`,
|
|
28
|
+
),
|
|
29
|
+
)
|
|
30
|
+
},
|
|
31
|
+
}) as GitOperations
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { agent, human, moveScript, read, removeScript, run } from "../flows/index.js"
|
|
3
|
+
import { runsItsScript, testRepo } from "./TestRepo.js"
|
|
4
|
+
|
|
5
|
+
const tiny = async (): Promise<void> => {
|
|
6
|
+
await agent("plan", "Write PLAN.md.")
|
|
7
|
+
await agent("build", `Build what PLAN.md says:\n${read("PLAN.md") ?? ""}`)
|
|
8
|
+
await run("check", "npm test > .gtd/FEEDBACK.md 2>&1 && rm .gtd/FEEDBACK.md")
|
|
9
|
+
if (read(".gtd/FEEDBACK.md") !== undefined) await agent("repair", "Make the suite pass.")
|
|
10
|
+
await human("sign-off")
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("testRepo", () => {
|
|
14
|
+
it("drives a workflow module defined in the test with scripted agents until it rests", async () => {
|
|
15
|
+
const repo = testRepo({ workflow: { default: tiny } })
|
|
16
|
+
repo.write("IDEA.md", "a calculator\n")
|
|
17
|
+
const rest = await repo.drive({
|
|
18
|
+
plan: (_, r) => r.write("PLAN.md", "add two numbers\n"),
|
|
19
|
+
build: (beat, r) => {
|
|
20
|
+
expect(beat.content).toContain("add two numbers")
|
|
21
|
+
r.write("src/add.ts", "export const add = (a: number, b: number) => a + b\n")
|
|
22
|
+
},
|
|
23
|
+
check: () => undefined,
|
|
24
|
+
})
|
|
25
|
+
expect(rest).toMatchObject({ state: "sign-off", actor: "human" })
|
|
26
|
+
expect(repo.subjects()).toEqual([
|
|
27
|
+
"chore: initial commit",
|
|
28
|
+
"gtd(agent): plan → build",
|
|
29
|
+
"gtd(agent): build → check",
|
|
30
|
+
"gtd(check): check → sign-off",
|
|
31
|
+
])
|
|
32
|
+
expect(repo.read("src/add.ts")).toContain("a + b")
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it("runs a workflow given as gtd.config.ts source", async () => {
|
|
36
|
+
const repo = testRepo({
|
|
37
|
+
workflow: [
|
|
38
|
+
`import { agent, human } from "@pmelab/gtd/flows"`,
|
|
39
|
+
`export const chores = async () => { await agent("sweep", "Sweep."); await human("done") }`,
|
|
40
|
+
`export default chores`,
|
|
41
|
+
].join("\n"),
|
|
42
|
+
})
|
|
43
|
+
await repo.start("chores")
|
|
44
|
+
const rest = await repo.drive({ sweep: (_, r) => r.write("swept", "yes\n") })
|
|
45
|
+
expect(rest.state).toBe("done")
|
|
46
|
+
expect(repo.subjects().at(-1)).toBe("gtd(agent): sweep → done")
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it("runs the file operations a step's script does", async () => {
|
|
50
|
+
const tidy = async (): Promise<void> => {
|
|
51
|
+
await run("archive", moveScript("NOTES.md", "archive/NOTES.md"))
|
|
52
|
+
await run("sweep", removeScript(["scratch.txt"]))
|
|
53
|
+
await human("done")
|
|
54
|
+
}
|
|
55
|
+
const repo = testRepo({
|
|
56
|
+
workflow: { default: tidy, tidy },
|
|
57
|
+
files: { "NOTES.md": "notes\n", "scratch.txt": "x\n" },
|
|
58
|
+
})
|
|
59
|
+
await repo.start("tidy")
|
|
60
|
+
await repo.drive({ archive: runsItsScript, sweep: runsItsScript })
|
|
61
|
+
expect(repo.read("archive/NOTES.md")).toBe("notes\n")
|
|
62
|
+
expect(repo.read("NOTES.md")).toBeUndefined()
|
|
63
|
+
expect(repo.read("scratch.txt")).toBeUndefined()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it("removes a directory with everything in it, as `rm -rf` does", () => {
|
|
67
|
+
const repo = testRepo({
|
|
68
|
+
workflow: { default: tiny },
|
|
69
|
+
files: { "out/a.txt": "a\n", "out/deep/b.txt": "b\n", "outside.txt": "c\n" },
|
|
70
|
+
})
|
|
71
|
+
repo.runScript(removeScript(["out"]))
|
|
72
|
+
expect(repo.read("out/a.txt")).toBeUndefined()
|
|
73
|
+
expect(repo.read("out/deep/b.txt")).toBeUndefined()
|
|
74
|
+
expect(repo.read("outside.txt")).toBe("c\n")
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it("refuses to remove an empty path rather than clear the tree", () => {
|
|
78
|
+
const repo = testRepo({ workflow: { default: tiny }, files: { "keep.txt": "k\n" } })
|
|
79
|
+
expect(() => repo.runScript(removeScript([""]))).toThrow(/refusing an empty path/)
|
|
80
|
+
expect(repo.read("keep.txt")).toBe("k\n")
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it("leaves no workflow module registered once gtd returns, so tests cannot reach each other's", async () => {
|
|
84
|
+
const repo = testRepo({ workflow: { default: tiny } })
|
|
85
|
+
repo.write("IDEA.md", "x\n")
|
|
86
|
+
await repo.drive({ plan: (_, r) => r.write("PLAN.md", "p\n") })
|
|
87
|
+
const registry = (globalThis as Record<symbol, Map<string, unknown> | undefined>)[
|
|
88
|
+
Symbol.for("@pmelab/gtd/testkit:modules")
|
|
89
|
+
]
|
|
90
|
+
expect(registry).toBeInstanceOf(Map)
|
|
91
|
+
expect(registry?.size).toBe(0)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it("refuses to fake a script it cannot run", () => {
|
|
95
|
+
const repo = testRepo({ workflow: { default: tiny } })
|
|
96
|
+
expect(() => repo.runScript("npm test\n")).toThrow(/cannot run `npm test` in memory/)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it("reads a file as committed", () => {
|
|
100
|
+
const repo = testRepo({ workflow: { default: tiny }, files: { "a.txt": "one\n" } })
|
|
101
|
+
repo.write("a.txt", "two\n")
|
|
102
|
+
repo.commit("chore: two")
|
|
103
|
+
expect(repo.show("HEAD~1", "a.txt")).toBe("one\n")
|
|
104
|
+
repo.runScript(`git show 'HEAD~1:a.txt' > 'b.txt'\n`)
|
|
105
|
+
expect(repo.read("b.txt")).toBe("one\n")
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it("stops a process that never rests", async () => {
|
|
109
|
+
const loop = async (): Promise<void> => {
|
|
110
|
+
for (;;) await agent("spin", "Again.")
|
|
111
|
+
}
|
|
112
|
+
const repo = testRepo({ workflow: { default: loop } })
|
|
113
|
+
repo.write("go", "\n")
|
|
114
|
+
let n = 0
|
|
115
|
+
await expect(
|
|
116
|
+
repo.drive({ spin: (_, r) => r.write("n", `${++n}\n`) }, { maxBeats: 5 }),
|
|
117
|
+
).rejects.toThrow(/did not rest within 5 beats/)
|
|
118
|
+
})
|
|
119
|
+
})
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { Effect } from "effect"
|
|
2
|
+
import { PassThrough } from "node:stream"
|
|
3
|
+
import { runCli } from "../cli/index.js"
|
|
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 { InMemRepo } from "./InMemRepo.js"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The repository's `gtd.config.ts`: its source, or the module itself. A module
|
|
11
|
+
* is handed to the engine through a global registry, so its functions run in
|
|
12
|
+
* this process against the same `@pmelab/gtd/flows` runtime the engine
|
|
13
|
+
* installs its context into.
|
|
14
|
+
*/
|
|
15
|
+
export type WorkflowModule = string | Readonly<Record<string, unknown>>
|
|
16
|
+
|
|
17
|
+
export interface TestRepoOptions {
|
|
18
|
+
readonly workflow: WorkflowModule
|
|
19
|
+
/** Committed alongside the config, before any process starts. */
|
|
20
|
+
readonly files?: Readonly<Record<string, string>>
|
|
21
|
+
/** The environment gtd sees; the real `process.env` is never read. */
|
|
22
|
+
readonly env?: Readonly<Record<string, string>>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** What a scripted actor does at the step it is registered for, before the beat lands. */
|
|
26
|
+
export type Actor = (beat: BeatDocument, repo: TestRepo) => void | string | Promise<void | string>
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Scripted actors, keyed by full step name. At a judge step the actor returns
|
|
30
|
+
* the verdict, which is answered through `gtd judge answer`; anywhere else it
|
|
31
|
+
* edits the tree and the beat is landed.
|
|
32
|
+
*/
|
|
33
|
+
export type Actors = Readonly<Record<string, Actor>>
|
|
34
|
+
|
|
35
|
+
export interface DriveOptions {
|
|
36
|
+
/** Fail instead of looping forever on a workflow that never rests. */
|
|
37
|
+
readonly maxBeats?: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface TestRepo {
|
|
41
|
+
read(path: string): string | undefined
|
|
42
|
+
write(path: string, content: string): void
|
|
43
|
+
remove(path: string): void
|
|
44
|
+
/** Every pending change committed with `message`, outside any process. */
|
|
45
|
+
commit(message: string): void
|
|
46
|
+
/** `path` as committed at `ref`. */
|
|
47
|
+
show(ref: string, path: string): string | undefined
|
|
48
|
+
/**
|
|
49
|
+
* A step's script, run against the tree. Only the file operations the flows
|
|
50
|
+
* script helpers emit are understood; anything else (a test suite, a git
|
|
51
|
+
* write) throws, and the step needs an actor that stands in for it.
|
|
52
|
+
*/
|
|
53
|
+
runScript(script: string): void
|
|
54
|
+
/** One in-process invocation; nothing it prints is run. */
|
|
55
|
+
gtd(...args: string[]): Promise<CapturedCliResult>
|
|
56
|
+
/** `gtd --workflow <name> [...args]`, its script applied. */
|
|
57
|
+
start(workflow: string, ...args: string[]): Promise<CapturedCliResult>
|
|
58
|
+
/** `gtd door <name> [...args]`, its script applied. */
|
|
59
|
+
door(name: string, ...args: string[]): Promise<CapturedCliResult>
|
|
60
|
+
land(): Promise<CapturedCliResult>
|
|
61
|
+
next(): Promise<BeatDocument>
|
|
62
|
+
/**
|
|
63
|
+
* Beat by beat, like a driver: a capture lands, a step with an actor runs it
|
|
64
|
+
* and lands, and the first step without one is where the process rests —
|
|
65
|
+
* returned for the test to inspect. A stall rests too. An agent turn's
|
|
66
|
+
* steering file is formatted and validated before it lands, as a driver
|
|
67
|
+
* does; a file that fails validation fails the drive.
|
|
68
|
+
*/
|
|
69
|
+
drive(actors?: Actors, options?: DriveOptions): Promise<BeatDocument>
|
|
70
|
+
/** Oldest first. */
|
|
71
|
+
subjects(): readonly string[]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const REGISTRY = Symbol.for("@pmelab/gtd/testkit:modules")
|
|
75
|
+
type Registry = Map<string, Readonly<Record<string, unknown>>>
|
|
76
|
+
const registry = (): Registry => {
|
|
77
|
+
const global = globalThis as { [REGISTRY]?: Registry }
|
|
78
|
+
return (global[REGISTRY] ??= new Map())
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const IDENTIFIER = /^[A-Za-z_$][\w$]*$/
|
|
82
|
+
let registered = 0
|
|
83
|
+
|
|
84
|
+
/** A config whose exports are module `id`'s, read off the registry when the engine loads it. */
|
|
85
|
+
const moduleSource = (id: string, module: Readonly<Record<string, unknown>>): string => {
|
|
86
|
+
const lines = [
|
|
87
|
+
`const m = globalThis[Symbol.for(${JSON.stringify(REGISTRY.description)})].get(${JSON.stringify(id)})`,
|
|
88
|
+
]
|
|
89
|
+
for (const name of Object.keys(module)) {
|
|
90
|
+
if (name === "default") lines.push(`export default m.default`)
|
|
91
|
+
else if (IDENTIFIER.test(name)) lines.push(`export const ${name} = m[${JSON.stringify(name)}]`)
|
|
92
|
+
else throw new Error(`testkit: export name "${name}" is not an identifier`)
|
|
93
|
+
}
|
|
94
|
+
return `${lines.join("\n")}\n`
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** An actor that runs the step's own script, for steps whose script `runScript` understands. */
|
|
98
|
+
export const runsItsScript: Actor = (beat, repo) => repo.runScript(beat.content)
|
|
99
|
+
|
|
100
|
+
const words = (text: string): string[] =>
|
|
101
|
+
[...text.matchAll(/'((?:[^']|'\\'')*)'/g)].map((m) => m[1]!.replaceAll("'\\''", "'"))
|
|
102
|
+
|
|
103
|
+
const fileOf = (repo: InMemRepo, path: string, what: string): string => {
|
|
104
|
+
const content = repo.readFile(path)
|
|
105
|
+
if (content === undefined) throw new Error(`testkit: ${what}: ${path} does not exist`)
|
|
106
|
+
return content
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// `InMemRepo.pathsUnder("")` is the whole tree, where a real `rm -- ''` removes nothing.
|
|
110
|
+
const nonEmpty = (paths: string[]): string[] => {
|
|
111
|
+
if (paths.includes("")) throw new Error("testkit: rm: refusing an empty path")
|
|
112
|
+
return paths
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The lines `runScript` understands, each with what it does to the tree. */
|
|
116
|
+
const LINES: ReadonlyArray<readonly [RegExp, (repo: InMemRepo, words: string[]) => void]> = [
|
|
117
|
+
[/^$|^#!|^set [-+]e$|^mkdir -p /, () => undefined],
|
|
118
|
+
[
|
|
119
|
+
/^rm -rf -- /,
|
|
120
|
+
(repo, paths) => {
|
|
121
|
+
for (const path of nonEmpty(paths))
|
|
122
|
+
for (const file of repo.pathsUnder(path)) repo.deleteFile(file)
|
|
123
|
+
},
|
|
124
|
+
],
|
|
125
|
+
[
|
|
126
|
+
/^rm -f -- /,
|
|
127
|
+
(repo, paths) => {
|
|
128
|
+
for (const path of paths) if (repo.readFile(path) !== undefined) repo.deleteFile(path)
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
[
|
|
132
|
+
/^mv -- '.*' '.*'$/,
|
|
133
|
+
(repo, [from, to]) => {
|
|
134
|
+
const content = fileOf(repo, from!, "mv")
|
|
135
|
+
repo.deleteFile(from!)
|
|
136
|
+
repo.writeFile(to!, content)
|
|
137
|
+
},
|
|
138
|
+
],
|
|
139
|
+
[
|
|
140
|
+
/^git show '.*' > '.*'$/,
|
|
141
|
+
(repo, [source, dest]) => {
|
|
142
|
+
const at = source!.indexOf(":")
|
|
143
|
+
const content = repo.fileAtRef(source!.slice(0, at), source!.slice(at + 1))
|
|
144
|
+
if (content === null) throw new Error(`testkit: git show: ${source} does not exist`)
|
|
145
|
+
repo.writeFile(dest!, content)
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
]
|
|
149
|
+
|
|
150
|
+
const runLine = (repo: InMemRepo, line: string): void => {
|
|
151
|
+
const known = LINES.find(([pattern]) => pattern.test(line))
|
|
152
|
+
if (known === undefined) {
|
|
153
|
+
throw new Error(`testkit: cannot run \`${line}\` in memory — give the step an actor instead`)
|
|
154
|
+
}
|
|
155
|
+
known[1](repo, words(line))
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const failed = (what: string, result: CapturedCliResult): Error =>
|
|
159
|
+
new Error(`${what} exited ${result.exitCode}\n${result.stderr}${result.stdout}`)
|
|
160
|
+
|
|
161
|
+
export const testRepo = (options: TestRepoOptions): TestRepo => {
|
|
162
|
+
const repo = new InMemRepo()
|
|
163
|
+
const env = { ...options.env }
|
|
164
|
+
const { workflow } = options
|
|
165
|
+
const id = `module-${++registered}`
|
|
166
|
+
repo.writeFile(
|
|
167
|
+
"gtd.config.ts",
|
|
168
|
+
typeof workflow === "string" ? workflow : moduleSource(id, workflow),
|
|
169
|
+
)
|
|
170
|
+
for (const [path, content] of Object.entries(options.files ?? {})) repo.writeFile(path, content)
|
|
171
|
+
repo.commitAllWithPrefix("chore: initial commit")
|
|
172
|
+
|
|
173
|
+
// The module is registered only while gtd runs, so nothing outlives the
|
|
174
|
+
// call that loads it and no test can reach another's module.
|
|
175
|
+
const gtd = async (...args: string[]): Promise<CapturedCliResult> => {
|
|
176
|
+
const { io, result } = makeCapturingCliIo(repo, env)
|
|
177
|
+
if (typeof workflow !== "string") registry().set(id, workflow)
|
|
178
|
+
try {
|
|
179
|
+
await Effect.runPromise(runCli(["node", "gtd", ...args], io))
|
|
180
|
+
} finally {
|
|
181
|
+
registry().delete(id)
|
|
182
|
+
}
|
|
183
|
+
return result()
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// `gtd judge answer` reads its verdict off stdin, so it is swapped for the call.
|
|
187
|
+
const withStdin = async <T>(stdin: string, fn: () => Promise<T>): Promise<T> => {
|
|
188
|
+
const saved = Object.getOwnPropertyDescriptor(process, "stdin")
|
|
189
|
+
const fake = new PassThrough()
|
|
190
|
+
Object.defineProperty(process, "stdin", { value: fake, configurable: true })
|
|
191
|
+
fake.end(stdin)
|
|
192
|
+
try {
|
|
193
|
+
return await fn()
|
|
194
|
+
} finally {
|
|
195
|
+
if (saved) Object.defineProperty(process, "stdin", saved)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const apply = (what: string, script: string): void => {
|
|
200
|
+
if (script.length === 0) return
|
|
201
|
+
const applied = applyEmittedScript(repo, new Map(), script)
|
|
202
|
+
if (!applied.ok) throw new Error(`${what}: its script failed — ${applied.error ?? ""}`)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Runs a write command, then the script its `--json=script` twin prints. */
|
|
206
|
+
const runWrite = async (args: readonly string[], stdin?: string): Promise<CapturedCliResult> => {
|
|
207
|
+
const call = (...extra: string[]) =>
|
|
208
|
+
stdin === undefined ? gtd(...args, ...extra) : withStdin(stdin, () => gtd(...args, ...extra))
|
|
209
|
+
const reported = await call()
|
|
210
|
+
if (reported.exitCode !== EXIT_OK) throw failed(`gtd ${args.join(" ")}`, reported)
|
|
211
|
+
const script = await call("--json=script")
|
|
212
|
+
apply(`gtd ${args.join(" ")}`, script.stdout)
|
|
213
|
+
return reported
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// A start prints its script plainly; it has no `--json=script` twin.
|
|
217
|
+
const begin = async (args: readonly string[]): Promise<CapturedCliResult> => {
|
|
218
|
+
const result = await gtd(...args)
|
|
219
|
+
if (result.exitCode !== EXIT_OK) throw failed(`gtd ${args.join(" ")}`, result)
|
|
220
|
+
apply(`gtd ${args.join(" ")}`, result.stdout)
|
|
221
|
+
return result
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const next = async (): Promise<BeatDocument> => {
|
|
225
|
+
const result = await gtd("next", "--json")
|
|
226
|
+
if (result.exitCode !== EXIT_OK) throw failed("gtd next --json", result)
|
|
227
|
+
return JSON.parse(result.stdout) as BeatDocument
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Lands what an actor did: a judge's verdict answered, anything else validated and landed. */
|
|
231
|
+
const act = async (doc: BeatDocument, verdict: void | string): Promise<void> => {
|
|
232
|
+
if (doc.actor !== "judge") {
|
|
233
|
+
if (doc.validate !== undefined) apply(`validating ${doc.file ?? doc.state}`, doc.validate)
|
|
234
|
+
await runWrite(["land"])
|
|
235
|
+
return
|
|
236
|
+
}
|
|
237
|
+
if (typeof verdict !== "string") {
|
|
238
|
+
throw new Error(`the actor for judge step "${doc.state}" returned no verdict`)
|
|
239
|
+
}
|
|
240
|
+
await runWrite(["judge", "answer"], verdict)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const self: TestRepo = {
|
|
244
|
+
read: (path) => repo.readFile(path),
|
|
245
|
+
write: (path, content) => repo.writeFile(path, content),
|
|
246
|
+
remove: (path) => repo.deleteFile(path),
|
|
247
|
+
commit: (message) => repo.commitAllWithPrefix(message),
|
|
248
|
+
show: (ref, path) => repo.fileAtRef(ref, path) ?? undefined,
|
|
249
|
+
runScript: (script) => {
|
|
250
|
+
for (const line of script.split("\n")) runLine(repo, line.trim())
|
|
251
|
+
},
|
|
252
|
+
gtd,
|
|
253
|
+
start: (workflow, ...args) => begin(["--workflow", workflow, ...args]),
|
|
254
|
+
door: (name, ...args) => begin(["door", name, ...args]),
|
|
255
|
+
land: () => runWrite(["land"]),
|
|
256
|
+
next,
|
|
257
|
+
drive: async (actors = {}, { maxBeats = 50 } = {}) => {
|
|
258
|
+
for (let beat = 0; beat < maxBeats; beat++) {
|
|
259
|
+
const doc = await next()
|
|
260
|
+
if (doc.kind === "stalled") return doc
|
|
261
|
+
if (doc.kind === "capture") {
|
|
262
|
+
await runWrite(["land"])
|
|
263
|
+
continue
|
|
264
|
+
}
|
|
265
|
+
const actor = actors[doc.state]
|
|
266
|
+
if (actor === undefined) return doc
|
|
267
|
+
await act(doc, await actor(doc, self))
|
|
268
|
+
}
|
|
269
|
+
throw new Error(`the process did not rest within ${maxBeats} beats`)
|
|
270
|
+
},
|
|
271
|
+
subjects: () => repo.commitHistory().map(({ message }) => message.split("\n")[0] ?? ""),
|
|
272
|
+
}
|
|
273
|
+
return self
|
|
274
|
+
}
|