@pmelab/gtd 63.0.0 → 64.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 +12 -11
- package/dist/chunks/{flows-DENt0uWV.mjs → flows-B7ZRCe-B.mjs} +1 -1
- package/dist/chunks/{rolldown-runtime-8H4AJuhK.mjs → rolldown-runtime-D7D4PA-g.mjs} +0 -1
- package/dist/chunks/{steering-DF_BMadH.mjs → steering-Cem5i6Xl.mjs} +422 -2
- package/dist/chunks/{testkit-DwypA7cH.mjs → testkit-DfWZQCWQ.mjs} +1073 -328
- package/dist/flows.mjs +1 -1
- package/dist/gtd.bundle.mjs +4372 -1310
- package/dist/gtd.core.mjs +126 -118
- package/dist/steering.mjs +2 -2
- package/dist/testkit/gherkin.mjs +2 -2
- package/dist/testkit.mjs +2 -2
- package/dist/workflow-kit.mjs +2 -2
- package/package.json +1 -1
- package/src/cli/Cli.test.ts +5 -8
- package/src/cli/Cli.ts +22 -28
- package/src/cli/Output.ts +2 -1
- package/src/cli/Program.test.ts +55 -53
- package/src/cli/Program.ts +34 -34
- package/src/cli/ToolCommands.ts +3 -4
- package/src/edge/Edge.test.ts +38 -12
- package/src/edge/Edge.ts +53 -19
- package/src/emit/SteeringMode.test.ts +4 -2
- package/src/emit/SteeringMode.ts +31 -10
- package/src/flows/index.ts +1 -0
- package/src/flows/runtime.test.ts +2 -1
- package/src/flows/runtime.ts +8 -2
- package/src/gherkin/messages.test.ts +1 -1
- package/src/gherkin/module-config.feature +1 -1
- package/src/gherkin/testkit.feature +2 -2
- package/src/install/Version.ts +14 -13
- package/src/platform/Host.ts +75 -22
- package/src/platform/NodePinFile.ts +44 -0
- package/src/platform/NodeRuntime.ts +19 -0
- package/src/platform/NodeSync.ts +64 -0
- package/src/platform/PinFile.ts +0 -50
- package/src/platform/Sha.ts +121 -0
- package/src/platform/SyncIo.ts +35 -0
- package/src/platform/Workspace.test.ts +8 -3
- package/src/platform/Workspace.ts +82 -123
- package/src/platform/WorktreeState.test.ts +8 -4
- package/src/platform/WorktreeState.ts +21 -19
- package/src/platform/index.ts +18 -5
- package/src/replay/Format.test.ts +16 -13
- package/src/replay/Format.ts +4 -2
- package/src/replay/Replay.test.ts +25 -5
- package/src/replay/Replay.ts +10 -6
- package/src/replay/Trailers.test.ts +10 -10
- package/src/replay/Trailers.ts +23 -1
- package/src/replay/index.ts +1 -0
- package/src/steering/Declared.test.ts +218 -0
- package/src/steering/Declared.ts +607 -0
- package/src/steering/FORMATS.md +3 -0
- package/src/steering/index.ts +12 -0
- package/src/steering/internal.ts +1 -0
- package/src/step/planStart.test.ts +2 -2
- package/src/step/planStep.test.ts +4 -4
- package/src/testkit/TestRepo.test.ts +1 -1
- package/src/testkit/TestRepo.ts +4 -18
- package/src/twins/EmittedScriptRecognizer.ts +5 -3
- package/src/twins/EmittedScripts.test.ts +19 -0
- package/src/twins/Host.test.ts +151 -0
- package/src/twins/Layers.test.ts +2 -2
- package/src/twins/Layers.ts +44 -18
- package/src/twins/Path.test.ts +52 -0
- package/src/twins/SyncIo.test.ts +153 -0
- package/src/twins/SyncIo.ts +159 -0
- package/src/twins/cliIo.ts +8 -1
- package/src/twins/index.ts +5 -0
- package/src/workflow/SteeringFormats.ts +4 -0
- package/src/workflow/Workflow.ts +2 -0
- package/src/workflow/discovery.test.ts +6 -5
- package/src/workflow/discovery.ts +43 -32
- package/src/workflow/fingerprint.test.ts +23 -3
- package/src/workflow/fingerprint.ts +9 -11
- package/src/workflow/index.ts +5 -1
- package/src/workflow/load.test.ts +16 -6
- package/src/workflow/load.ts +107 -69
- package/src/workflow/pin.test.ts +3 -2
- package/src/workflow/pin.ts +17 -11
|
@@ -61,7 +61,7 @@ describe("testRepo", () => {
|
|
|
61
61
|
expect(repo.subjects()).toEqual([
|
|
62
62
|
"chore: initial commit",
|
|
63
63
|
"gtd(agent): plan → build",
|
|
64
|
-
"gtd(check): pin → build",
|
|
64
|
+
"gtd(check): gtd:pin → build",
|
|
65
65
|
"gtd(agent): build → check",
|
|
66
66
|
"gtd(check): check → sign-off",
|
|
67
67
|
])
|
package/src/testkit/TestRepo.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { Effect } from "effect"
|
|
2
|
-
import { PassThrough } from "node:stream"
|
|
3
2
|
import { runCli } from "../cli/index.js"
|
|
4
3
|
import { EXIT_OK, type BeatDocument } from "../wire/index.js"
|
|
5
4
|
import {
|
|
@@ -450,8 +449,8 @@ export const testRepo = (options: TestRepoOptions): TestRepo => {
|
|
|
450
449
|
|
|
451
450
|
// The modules are registered only while gtd runs, so nothing outlives the
|
|
452
451
|
// call that loads them and no test can reach another's module.
|
|
453
|
-
const
|
|
454
|
-
const { io, result } = makeCapturingCliIo(repo, seenEnv())
|
|
452
|
+
const gtdWithStdin = async (stdin: string, ...args: string[]): Promise<CapturedCliResult> => {
|
|
453
|
+
const { io, result } = makeCapturingCliIo(repo, seenEnv(), {}, stdin)
|
|
455
454
|
if (typeof workflow !== "string") registry().set(id, workflow)
|
|
456
455
|
for (const [specifier, module] of modules) registry().set(moduleKey(specifier), module)
|
|
457
456
|
try {
|
|
@@ -462,19 +461,7 @@ export const testRepo = (options: TestRepoOptions): TestRepo => {
|
|
|
462
461
|
}
|
|
463
462
|
return result()
|
|
464
463
|
}
|
|
465
|
-
|
|
466
|
-
// `gtd judge answer` reads its verdict off stdin, so it is swapped for the call.
|
|
467
|
-
const withStdin = async <T>(stdin: string, fn: () => Promise<T>): Promise<T> => {
|
|
468
|
-
const saved = Object.getOwnPropertyDescriptor(process, "stdin")
|
|
469
|
-
const fake = new PassThrough()
|
|
470
|
-
Object.defineProperty(process, "stdin", { value: fake, configurable: true })
|
|
471
|
-
fake.end(stdin)
|
|
472
|
-
try {
|
|
473
|
-
return await fn()
|
|
474
|
-
} finally {
|
|
475
|
-
if (saved) Object.defineProperty(process, "stdin", saved)
|
|
476
|
-
}
|
|
477
|
-
}
|
|
464
|
+
const gtd = (...args: string[]): Promise<CapturedCliResult> => gtdWithStdin("", ...args)
|
|
478
465
|
|
|
479
466
|
const apply = (what: string, script: string): void => {
|
|
480
467
|
if (script.length === 0) return
|
|
@@ -488,8 +475,7 @@ export const testRepo = (options: TestRepoOptions): TestRepo => {
|
|
|
488
475
|
stdin?: string,
|
|
489
476
|
refused: (error: Error) => Error = (error) => error,
|
|
490
477
|
): Promise<CapturedCliResult> => {
|
|
491
|
-
const call = (...extra: string[]) =>
|
|
492
|
-
stdin === undefined ? gtd(...args, ...extra) : withStdin(stdin, () => gtd(...args, ...extra))
|
|
478
|
+
const call = (...extra: string[]) => gtdWithStdin(stdin ?? "", ...args, ...extra)
|
|
493
479
|
const reported = await call()
|
|
494
480
|
if (reported.exitCode !== EXIT_OK) throw refused(failed(`gtd ${args.join(" ")}`, reported))
|
|
495
481
|
const script = await call("--json=script")
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
import {
|
|
33
33
|
BUILT_IN_MODE_NAMES,
|
|
34
34
|
formatFinding,
|
|
35
|
+
formatFromSpec,
|
|
35
36
|
steeringFormatFor,
|
|
36
37
|
type SteeringFormat,
|
|
37
38
|
} from "../steering/internal.js"
|
|
@@ -538,13 +539,14 @@ const recognizeModeContradictionCheck = (
|
|
|
538
539
|
|
|
539
540
|
const GTD_CHECK_RE = /^gtd check (\S+) (.+)$/
|
|
540
541
|
|
|
541
|
-
/** `gtd check <mode> <file
|
|
542
|
+
/** `gtd check <mode> <file> [--spec <json>]` — a non-empty findings array fails the script, mirroring a real invocation's non-zero exit under `set -e`. */
|
|
542
543
|
const recognizeGtdCheck = (repo: InMemRepo, block: string): BlockOutcome | undefined => {
|
|
543
544
|
const match = GTD_CHECK_RE.exec(block)
|
|
544
545
|
if (!match) return undefined
|
|
545
546
|
const mode = match[1]!
|
|
546
|
-
const [file] = extractQuotedTokens(match[2]!)
|
|
547
|
-
const
|
|
547
|
+
const [file, spec] = extractQuotedTokens(match[2]!)
|
|
548
|
+
const declared = / --spec '/.test(match[2]!)
|
|
549
|
+
const format = declared && spec !== undefined ? formatFromSpec(spec) : steeringFormatFor(mode)
|
|
548
550
|
if (format === undefined || file === undefined) {
|
|
549
551
|
const known = BUILT_IN_MODE_NAMES.join(", ")
|
|
550
552
|
return { kind: "failed", error: `gtd check: unknown mode "${mode}" — known modes: ${known}` }
|
|
@@ -16,6 +16,8 @@ import {
|
|
|
16
16
|
import { Effect } from "effect"
|
|
17
17
|
import { ScriptSurface } from "../emit/index.js"
|
|
18
18
|
import { GitService } from "../platform/index.js"
|
|
19
|
+
import { listFormat } from "../steering/index.js"
|
|
20
|
+
import { declaredValidateCommand } from "../workflow/index.js"
|
|
19
21
|
import { gitTiers, type GitTier, type ScriptedCommand } from "./index.js"
|
|
20
22
|
|
|
21
23
|
// Every case runs through real `sh` — with this build's `gtd` on PATH — and
|
|
@@ -158,6 +160,23 @@ for (const makeTier of gitTiers) {
|
|
|
158
160
|
),
|
|
159
161
|
)
|
|
160
162
|
})
|
|
163
|
+
|
|
164
|
+
it("checks a declared format from the spec on its command line", () => {
|
|
165
|
+
const ids = listFormat({
|
|
166
|
+
name: "ids",
|
|
167
|
+
item: { id: /^A-\d+$/, separator: ":", example: "A-1" },
|
|
168
|
+
ids: { unique: true },
|
|
169
|
+
})
|
|
170
|
+
const check = declaredValidateCommand("ids", ids.spec).replace('"$GTD_FILE"', `'${FILE}'`)
|
|
171
|
+
t.seed.writeFile(FILE, "- A-1: it's\n")
|
|
172
|
+
expect(run(script(check))).toEqual({ ok: true, status: 0, output: "" })
|
|
173
|
+
t.seed.writeFile(FILE, "- A-1: a\n- A-1: b\n")
|
|
174
|
+
const result = run(script(check))
|
|
175
|
+
expect(result).toMatchObject({ ok: false, status: 1 })
|
|
176
|
+
expect(result.output).toBe(
|
|
177
|
+
`gtd check: ${FILE} is not valid under mode "ids" (1 finding(s)):\n${FILE}:2: duplicate ID A-1 — first on line 1\n`,
|
|
178
|
+
)
|
|
179
|
+
})
|
|
161
180
|
})
|
|
162
181
|
|
|
163
182
|
it("gtd uncheck clears a review file's ticks", () => {
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs"
|
|
2
|
+
import { tmpdir } from "node:os"
|
|
3
|
+
import { isAbsolute } from "node:path"
|
|
4
|
+
import { PassThrough } from "node:stream"
|
|
5
|
+
import { Effect, Layer } from "effect"
|
|
6
|
+
import { describe, expect, it } from "vitest"
|
|
7
|
+
import { Host, nodeStdio, type HostOps, type NodeProcess, type Stdio } from "../platform/index.js"
|
|
8
|
+
import { InMemRepo, inMemoryHost, makeCapturingCliIo } from "./index.js"
|
|
9
|
+
|
|
10
|
+
type Env = Readonly<Record<string, string | undefined>>
|
|
11
|
+
|
|
12
|
+
const root = realpathSync(process.cwd())
|
|
13
|
+
|
|
14
|
+
const fakeProcess = (stdin: string | Buffer[], argv = ["node"]) => {
|
|
15
|
+
const input = new PassThrough()
|
|
16
|
+
for (const chunk of typeof stdin === "string" ? [stdin] : stdin) input.write(chunk)
|
|
17
|
+
input.end()
|
|
18
|
+
const out = { stdout: "", stderr: "" }
|
|
19
|
+
const sink = (name: keyof typeof out) => ({
|
|
20
|
+
write: (chunk: string | Uint8Array, done?: () => void) => {
|
|
21
|
+
out[name] += Buffer.from(chunk).toString("utf8")
|
|
22
|
+
done?.()
|
|
23
|
+
return true
|
|
24
|
+
},
|
|
25
|
+
})
|
|
26
|
+
const proc = {
|
|
27
|
+
argv,
|
|
28
|
+
execPath: "/usr/bin/node",
|
|
29
|
+
pid: 7,
|
|
30
|
+
getuid: () => 501,
|
|
31
|
+
stdin: input,
|
|
32
|
+
stdout: sink("stdout"),
|
|
33
|
+
stderr: sink("stderr"),
|
|
34
|
+
exitCode: undefined,
|
|
35
|
+
} as unknown as NodeProcess
|
|
36
|
+
return { proc, out }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The core reaches the machine through `Host`: Node's process live, nothing real in memory.
|
|
40
|
+
describe.each([
|
|
41
|
+
[
|
|
42
|
+
"live",
|
|
43
|
+
(env: Env, stdin: string | Buffer[]) =>
|
|
44
|
+
Host.layer({ root, home: root, env }, fakeProcess(stdin).proc),
|
|
45
|
+
],
|
|
46
|
+
[
|
|
47
|
+
"in memory",
|
|
48
|
+
(env: Env, stdin: string | Buffer[]) =>
|
|
49
|
+
Layer.succeed(
|
|
50
|
+
Host,
|
|
51
|
+
inMemoryHost(
|
|
52
|
+
root,
|
|
53
|
+
root,
|
|
54
|
+
env,
|
|
55
|
+
typeof stdin === "string" ? stdin : Buffer.concat(stdin).toString("utf8"),
|
|
56
|
+
),
|
|
57
|
+
),
|
|
58
|
+
],
|
|
59
|
+
])("Host (%s)", (_, layer) => {
|
|
60
|
+
const run = <A>(
|
|
61
|
+
f: (host: HostOps) => A | Effect.Effect<A, Error>,
|
|
62
|
+
env: Env = {},
|
|
63
|
+
stdin: string | Buffer[] = "",
|
|
64
|
+
): Promise<A> =>
|
|
65
|
+
Effect.runPromise(
|
|
66
|
+
Effect.flatMap(Host, (host) => {
|
|
67
|
+
const result = f(host)
|
|
68
|
+
return Effect.isEffect(result) ? result : Effect.succeed(result)
|
|
69
|
+
}).pipe(Effect.provide(layer(env, stdin))),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
it("puts scratch files under TMPDIR when it is set", async () => {
|
|
73
|
+
expect(await run((host) => host.scratchDir, { TMPDIR: "/fixture-scratch" })).toBe(
|
|
74
|
+
"/fixture-scratch",
|
|
75
|
+
)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it("falls back to an absolute scratch dir when TMPDIR is unset or empty", async () => {
|
|
79
|
+
expect(isAbsolute(await run((host) => host.scratchDir))).toBe(true)
|
|
80
|
+
expect(await run((host) => host.scratchDir, { TMPDIR: "" })).toBe(
|
|
81
|
+
await run((host) => host.scratchDir),
|
|
82
|
+
)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it("reads stdin to its end as UTF-8, a character split across chunks included", async () => {
|
|
86
|
+
const bytes = Buffer.from("verdict: ✓\n")
|
|
87
|
+
const split = [bytes.subarray(0, 10), bytes.subarray(10)]
|
|
88
|
+
expect(await run((host) => host.stdin, {}, split)).toBe("verdict: ✓\n")
|
|
89
|
+
expect(await run((host) => host.stdin)).toBe("")
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it("resolves the repository root to itself", async () => {
|
|
93
|
+
expect(await run((host) => host.realPath(root))).toBe(root)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it("names gtd off PATH when no script runs it, and has a process id", async () => {
|
|
97
|
+
const [self, pid] = await run((host) => [host.self, host.pid] as const)
|
|
98
|
+
expect(self).toEqual(["gtd"])
|
|
99
|
+
expect(Number.isInteger(pid)).toBe(true)
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe("Host (live) alone", () => {
|
|
104
|
+
const host = (proc: NodeProcess, env: Env = {}): Promise<HostOps> =>
|
|
105
|
+
Effect.runPromise(
|
|
106
|
+
Effect.gen(function* () {
|
|
107
|
+
return yield* Host
|
|
108
|
+
}).pipe(Effect.provide(Host.layer({ root, home: root, env }, proc))),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
it("falls back to node:os's tmpdir()", async () => {
|
|
112
|
+
expect((await host(fakeProcess("").proc)).scratchDir).toBe(tmpdir())
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it("runs itself again as its script under the running runtime", async () => {
|
|
116
|
+
const { proc } = fakeProcess("", ["node", "/opt/gtd/gtd.js", "lsp"])
|
|
117
|
+
const live = await host(proc)
|
|
118
|
+
expect([live.self, live.pid, live.uid]).toEqual([["/usr/bin/node", "/opt/gtd/gtd.js"], 7, 501])
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
// `runCli` writes through `Stdio` before any layer exists: Node's process live, a capture in memory.
|
|
123
|
+
describe.each([
|
|
124
|
+
[
|
|
125
|
+
"live",
|
|
126
|
+
() => {
|
|
127
|
+
const { proc, out } = fakeProcess("")
|
|
128
|
+
return {
|
|
129
|
+
stdio: nodeStdio(proc),
|
|
130
|
+
seen: () => ({ ...out, exitCode: proc.exitCode }),
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
[
|
|
135
|
+
"in memory",
|
|
136
|
+
() => {
|
|
137
|
+
const { io, result } = makeCapturingCliIo(new InMemRepo())
|
|
138
|
+
return { stdio: io as Stdio, seen: () => result() }
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
])("Stdio (%s)", (_, make) => {
|
|
142
|
+
it("writes stdout text and bytes, stderr, and exits with the code", () => {
|
|
143
|
+
const { stdio, seen } = make()
|
|
144
|
+
stdio.stdout("artifact")
|
|
145
|
+
stdio.stdout(new TextEncoder().encode(" bytes ✓"))
|
|
146
|
+
stdio.stderr("narration")
|
|
147
|
+
stdio.exit(3)
|
|
148
|
+
const { stdout, stderr, exitCode } = seen()
|
|
149
|
+
expect([stdout, stderr, exitCode]).toEqual(["artifact bytes ✓", "narration", 3])
|
|
150
|
+
})
|
|
151
|
+
})
|
package/src/twins/Layers.test.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { Effect } from "effect"
|
|
|
2
2
|
import { describe, expect, it } from "vitest"
|
|
3
3
|
import { ConfigDiscovery, LEGACY_RC_FILES, walkUp } from "../workflow/index.js"
|
|
4
4
|
import { InMemRepo } from "./index.js"
|
|
5
|
-
import { ambientFlagEnv, testLayers } from "./Layers.js"
|
|
5
|
+
import { ambientFlagEnv, inMemPath, testLayers } from "./Layers.js"
|
|
6
6
|
import { Host } from "../platform/index.js"
|
|
7
7
|
|
|
8
8
|
// The in-memory `ConfigDiscovery` counterpart walks the SAME root→home chain
|
|
@@ -40,7 +40,7 @@ describe("the in-memory ConfigDiscovery counterpart", () => {
|
|
|
40
40
|
seed(repo, ROOT, LEGACY_RC_FILES[1]!, "{}\n")
|
|
41
41
|
seed(repo, ABOVE_HOME, LEGACY_RC_FILES[0]!, "never-walked: true\n")
|
|
42
42
|
const found = (await runDiscovery(repo, (d) => d.legacyRcFiles(ROOT, HOME))).map((f) => f.path)
|
|
43
|
-
expect(walkUp(ROOT, HOME)).toEqual([ROOT, MIDDLE, HOME])
|
|
43
|
+
expect(walkUp(inMemPath, ROOT, HOME)).toEqual([ROOT, MIDDLE, HOME])
|
|
44
44
|
expect(found).toEqual([
|
|
45
45
|
`${ROOT}/${LEGACY_RC_FILES[0]}`,
|
|
46
46
|
`${ROOT}/${LEGACY_RC_FILES[1]}`,
|
package/src/twins/Layers.ts
CHANGED
|
@@ -8,12 +8,13 @@
|
|
|
8
8
|
// simulates a driver running gtd's emitted scripts against the in-memory
|
|
9
9
|
// worktree — a wholly separate mechanism from any gtd-internal layer.
|
|
10
10
|
|
|
11
|
-
import { Effect, Layer } from "effect"
|
|
12
|
-
import { isAbsolute, join } from "node:path"
|
|
11
|
+
import { Effect, Layer, Path } from "effect"
|
|
13
12
|
import {
|
|
14
13
|
Narrator,
|
|
15
14
|
GitService,
|
|
16
15
|
Host,
|
|
16
|
+
type HostOps,
|
|
17
|
+
scratchDirOf,
|
|
17
18
|
Workspace,
|
|
18
19
|
type WorkspaceOps,
|
|
19
20
|
CommandRunner,
|
|
@@ -48,6 +49,10 @@ const toError = (e: unknown): Error => (e instanceof Error ? e : new Error(Strin
|
|
|
48
49
|
*/
|
|
49
50
|
const isGitDirKey = (key: string): boolean => key.startsWith(".git/")
|
|
50
51
|
|
|
52
|
+
// The in-memory tier's `Path`: Effect's own POSIX one, which needs no Node.
|
|
53
|
+
export const inMemPath = Effect.runSync(Effect.provide(Path.Path, Path.layer))
|
|
54
|
+
const paths = inMemPath
|
|
55
|
+
|
|
51
56
|
/**
|
|
52
57
|
* `Workspace` for the in-memory tier. `readSync`/`read`/`write`/`committed`
|
|
53
58
|
* take a repo-relative key ONLY (mirroring the live adapter's
|
|
@@ -63,12 +68,12 @@ const isGitDirKey = (key: string): boolean => key.startsWith(".git/")
|
|
|
63
68
|
*/
|
|
64
69
|
export const makeInMemoryWorkspaceOps = (repo: InMemRepo, root: string): WorkspaceOps => {
|
|
65
70
|
const toKey = (path: string): string => {
|
|
66
|
-
if (!isAbsolute(path)) return path
|
|
71
|
+
if (!paths.isAbsolute(path)) return path
|
|
67
72
|
const prefix = `${root}/`
|
|
68
73
|
return path === root ? "" : path.startsWith(prefix) ? path.slice(prefix.length) : path
|
|
69
74
|
}
|
|
70
75
|
const assertRepoRelative = (path: string): string => {
|
|
71
|
-
if (isAbsolute(path)) {
|
|
76
|
+
if (paths.isAbsolute(path)) {
|
|
72
77
|
throw new Error(
|
|
73
78
|
`Workspace: "${path}" is an absolute path — this member takes repo-relative paths only (use "atPath" to read outside the repo)`,
|
|
74
79
|
)
|
|
@@ -146,8 +151,8 @@ const makeInMemoryConfigDiscovery = (
|
|
|
146
151
|
legacyRcFiles: (levelRoot, levelHome) =>
|
|
147
152
|
Effect.try({
|
|
148
153
|
try: () =>
|
|
149
|
-
legacyRcDirs(levelRoot, levelHome).flatMap((dir) =>
|
|
150
|
-
LEGACY_RC_FILES.map((name) => join(dir, name))
|
|
154
|
+
legacyRcDirs(paths, levelRoot, levelHome).flatMap((dir) =>
|
|
155
|
+
LEGACY_RC_FILES.map((name) => paths.join(dir, name))
|
|
151
156
|
.filter((filepath) => workspace.atPath(filepath) !== undefined)
|
|
152
157
|
.map((path) => ({ path, owned: true })),
|
|
153
158
|
),
|
|
@@ -156,8 +161,8 @@ const makeInMemoryConfigDiscovery = (
|
|
|
156
161
|
workflowModule: (levelRoot, levelHome) =>
|
|
157
162
|
Effect.try({
|
|
158
163
|
try: () => {
|
|
159
|
-
for (const dir of walkUp(levelRoot, levelHome)) {
|
|
160
|
-
const filepath = join(dir, WORKFLOW_MODULE)
|
|
164
|
+
for (const dir of walkUp(paths, levelRoot, levelHome)) {
|
|
165
|
+
const filepath = paths.join(dir, WORKFLOW_MODULE)
|
|
161
166
|
const source = workspace.atPath(filepath)
|
|
162
167
|
if (source !== undefined) return { filepath, source, read: workspace.atPath }
|
|
163
168
|
}
|
|
@@ -214,6 +219,8 @@ export interface TestWorldOptions {
|
|
|
214
219
|
readonly verbose?: boolean
|
|
215
220
|
/** Real or fake driver and tool commands — each one left out fails when called. */
|
|
216
221
|
readonly tools?: Partial<ToolCommands["Service"]>
|
|
222
|
+
/** What gtd reads on stdin; empty by default, like a closed pipe. */
|
|
223
|
+
readonly stdin?: string
|
|
217
224
|
}
|
|
218
225
|
|
|
219
226
|
const missingTool = (name: string) => () =>
|
|
@@ -247,6 +254,33 @@ export const ambientFlagEnv = (
|
|
|
247
254
|
),
|
|
248
255
|
)
|
|
249
256
|
|
|
257
|
+
/** Where the in-memory host's scratch files go when `TMPDIR` is unset. */
|
|
258
|
+
export const IN_MEMORY_SCRATCH = "/scratch"
|
|
259
|
+
export const IN_MEMORY_PID = 4242
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* The in-memory `Host`: nothing read off the real process or `node:os`.
|
|
263
|
+
* `realPath` is a no-op (there is no real filesystem to resolve symlinks
|
|
264
|
+
* against), so both sides of `assertRunningFromRepoRoot`'s comparison
|
|
265
|
+
* collapse to `root`.
|
|
266
|
+
*/
|
|
267
|
+
export const inMemoryHost = (
|
|
268
|
+
root: string,
|
|
269
|
+
home: string,
|
|
270
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
271
|
+
stdin = "",
|
|
272
|
+
): HostOps => ({
|
|
273
|
+
root,
|
|
274
|
+
home,
|
|
275
|
+
env,
|
|
276
|
+
scratchDir: scratchDirOf(env, IN_MEMORY_SCRATCH),
|
|
277
|
+
realPath: () => Effect.succeed(root),
|
|
278
|
+
pid: IN_MEMORY_PID,
|
|
279
|
+
uid: undefined,
|
|
280
|
+
self: ["gtd"],
|
|
281
|
+
stdin: Effect.succeed(stdin),
|
|
282
|
+
})
|
|
283
|
+
|
|
250
284
|
/** 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. */
|
|
251
285
|
export function testLayers(
|
|
252
286
|
repo: InMemRepo,
|
|
@@ -258,20 +292,12 @@ export function testLayers(
|
|
|
258
292
|
return Layer.mergeAll(
|
|
259
293
|
gitTestLayer(repo, root),
|
|
260
294
|
Layer.succeed(Workspace, makeInMemoryWorkspaceOps(repo, root)),
|
|
261
|
-
|
|
262
|
-
// symlinks against): both sides of `assertRunningFromRepoRoot`'s
|
|
263
|
-
// comparison collapse to `root` when given anything, exactly like the
|
|
264
|
-
// fake's `root` "resolving" to itself before this port existed.
|
|
265
|
-
Host.layer({
|
|
266
|
-
root,
|
|
267
|
-
home,
|
|
268
|
-
env: { ...ambientFlagEnv(), ...opts.env },
|
|
269
|
-
realPath: () => Effect.succeed(root),
|
|
270
|
-
}),
|
|
295
|
+
Layer.succeed(Host, inMemoryHost(root, home, { ...ambientFlagEnv(), ...opts.env }, opts.stdin)),
|
|
271
296
|
ConfigService.Live,
|
|
272
297
|
makeInMemoryConfigDiscovery(repo, root),
|
|
273
298
|
Layer.succeed(EngineCore, { source: Effect.succeed(IN_MEMORY_ENGINE) }),
|
|
274
299
|
Narrator.layer(opts.narrate ?? (() => {}), opts.verbose ?? true),
|
|
300
|
+
Path.layer,
|
|
275
301
|
// Drivers and tools are unit-tested in their own folders; a call reaching
|
|
276
302
|
// one of these is a test gap, not silence. A test opts into one via `tools`.
|
|
277
303
|
Layer.succeed(ToolCommands, { ...missingToolCommands, ...opts.tools }),
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Effect, Layer, Path } from "effect"
|
|
2
|
+
import { describe, expect, it } from "vitest"
|
|
3
|
+
import { NodeServices } from "../platform/index.js"
|
|
4
|
+
import { InMemRepo, testLayers } from "./index.js"
|
|
5
|
+
|
|
6
|
+
// The core reaches paths through `Path`: Node's live, Effect's own POSIX one in memory.
|
|
7
|
+
describe.each([
|
|
8
|
+
["live", NodeServices.layer as Layer.Layer<Path.Path>],
|
|
9
|
+
["in memory", testLayers(new InMemRepo())],
|
|
10
|
+
])("Path (%s)", (_, layer) => {
|
|
11
|
+
const run = <A>(f: (path: Path.Path) => A | Effect.Effect<A, unknown>): Promise<A> =>
|
|
12
|
+
Effect.runPromise(
|
|
13
|
+
Effect.flatMap(Path.Path, (path) => {
|
|
14
|
+
const result = f(path)
|
|
15
|
+
return Effect.isEffect(result) ? result : Effect.succeed(result)
|
|
16
|
+
}).pipe(Effect.provide(layer)),
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
it("joins, splits and relates paths", async () => {
|
|
20
|
+
expect(
|
|
21
|
+
await run((path) => [
|
|
22
|
+
path.sep,
|
|
23
|
+
path.join("/repo", ".gtd", "../gtd.config.ts"),
|
|
24
|
+
path.dirname("/repo/.gtd/TODO.md"),
|
|
25
|
+
path.dirname("/"),
|
|
26
|
+
path.resolve("/repo", "lib/flow.ts"),
|
|
27
|
+
path.resolve("/repo", "/elsewhere"),
|
|
28
|
+
path.relative("/repo", "/repo/lib/flow.ts"),
|
|
29
|
+
path.relative("/repo", "/home/.gtdrc"),
|
|
30
|
+
path.isAbsolute("/repo"),
|
|
31
|
+
path.isAbsolute("lib/flow.ts"),
|
|
32
|
+
]),
|
|
33
|
+
).toEqual([
|
|
34
|
+
"/",
|
|
35
|
+
"/repo/gtd.config.ts",
|
|
36
|
+
"/repo/.gtd",
|
|
37
|
+
"/",
|
|
38
|
+
"/repo/lib/flow.ts",
|
|
39
|
+
"/elsewhere",
|
|
40
|
+
"lib/flow.ts",
|
|
41
|
+
"../home/.gtdrc",
|
|
42
|
+
true,
|
|
43
|
+
false,
|
|
44
|
+
])
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it("converts a path to a file URL and back", async () => {
|
|
48
|
+
const url = await run((path) => path.toFileUrl("/repo/a b.ts"))
|
|
49
|
+
expect(url.href).toBe("file:///repo/a%20b.ts")
|
|
50
|
+
expect(await run((path) => path.fromFileUrl(url))).toBe("/repo/a b.ts")
|
|
51
|
+
})
|
|
52
|
+
})
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process"
|
|
2
|
+
import { mkdtempSync, realpathSync, rmSync, symlinkSync } from "node:fs"
|
|
3
|
+
import { tmpdir } from "node:os"
|
|
4
|
+
import { join } from "node:path"
|
|
5
|
+
import { afterEach, describe, expect, it } from "vitest"
|
|
6
|
+
import { nodeSyncIo, type SyncIo } from "../platform/index.js"
|
|
7
|
+
import { InMemRepo, inMemorySyncIo } from "./index.js"
|
|
8
|
+
|
|
9
|
+
interface Tier {
|
|
10
|
+
readonly io: SyncIo
|
|
11
|
+
/** A git repository, empty until `commit`. */
|
|
12
|
+
readonly root: string
|
|
13
|
+
readonly uid: number | undefined
|
|
14
|
+
readonly symlink: (path: string, target: string) => void
|
|
15
|
+
/** Commits `files`, repository-relative, as the only change. */
|
|
16
|
+
readonly commit: (files: Record<string, string>) => void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const cleanups: (() => void)[] = []
|
|
20
|
+
afterEach(() => cleanups.splice(0).forEach((cleanup) => cleanup()))
|
|
21
|
+
|
|
22
|
+
const liveTier = (): Tier => {
|
|
23
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), "gtd-sync-io-")))
|
|
24
|
+
cleanups.push(() => rmSync(root, { recursive: true, force: true }))
|
|
25
|
+
const git = (...args: string[]) => execFileSync("git", args, { cwd: root, stdio: "ignore" })
|
|
26
|
+
git("init", "-q")
|
|
27
|
+
return {
|
|
28
|
+
io: nodeSyncIo,
|
|
29
|
+
root,
|
|
30
|
+
uid: process.getuid?.(),
|
|
31
|
+
symlink: (path, target) => symlinkSync(target, path),
|
|
32
|
+
commit: (files) => {
|
|
33
|
+
for (const [path, content] of Object.entries(files))
|
|
34
|
+
nodeSyncIo.writeText(join(root, path), content)
|
|
35
|
+
git("add", "-A")
|
|
36
|
+
git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-m", "c")
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const inMemTier = (): Tier => {
|
|
42
|
+
const repo = new InMemRepo()
|
|
43
|
+
const root = "/repo"
|
|
44
|
+
const io = inMemorySyncIo({ repo, root, uid: 501 })
|
|
45
|
+
io.makeDirectory(root)
|
|
46
|
+
return {
|
|
47
|
+
io,
|
|
48
|
+
root,
|
|
49
|
+
uid: 501,
|
|
50
|
+
symlink: io.symlink,
|
|
51
|
+
commit: (files) => {
|
|
52
|
+
for (const [path, content] of Object.entries(files)) {
|
|
53
|
+
io.writeText(`${root}/${path}`, content)
|
|
54
|
+
repo.writeFile(path, content)
|
|
55
|
+
}
|
|
56
|
+
repo.seedCommit("c")
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe.each([
|
|
62
|
+
["live", liveTier],
|
|
63
|
+
["in memory", inMemTier],
|
|
64
|
+
])("SyncIo (%s)", (_, makeTier) => {
|
|
65
|
+
it("reads absence as a value and a directory as a fault", () => {
|
|
66
|
+
const { io, root } = makeTier()
|
|
67
|
+
io.writeText(`${root}/file`, "x")
|
|
68
|
+
|
|
69
|
+
expect(io.readText(`${root}/missing`)).toBeUndefined()
|
|
70
|
+
expect(io.readFile(`${root}/file/below`)).toBeUndefined()
|
|
71
|
+
expect(() => io.readText(root)).toThrow()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it("writes text and reads it back as text and bytes", () => {
|
|
75
|
+
const { io, root } = makeTier()
|
|
76
|
+
io.makeDirectory(`${root}/a/b`)
|
|
77
|
+
io.makeDirectory(`${root}/a/b`)
|
|
78
|
+
io.writeText(`${root}/a/b/f`, "\uFEFFhéllo")
|
|
79
|
+
|
|
80
|
+
expect(io.readText(`${root}/a/b/f`)).toBe("\uFEFFhéllo")
|
|
81
|
+
expect(new Uint8Array(io.readFile(`${root}/a/b/f`)!)).toEqual(
|
|
82
|
+
new TextEncoder().encode("\uFEFFhéllo"),
|
|
83
|
+
)
|
|
84
|
+
expect(() => io.writeText(`${root}/none/f`, "x")).toThrow()
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it("renames a file over another, and over a symlink rather than through it", () => {
|
|
88
|
+
const { io, root, symlink } = makeTier()
|
|
89
|
+
io.writeText(`${root}/from`, "new")
|
|
90
|
+
io.writeText(`${root}/to`, "old")
|
|
91
|
+
io.rename(`${root}/from`, `${root}/to`)
|
|
92
|
+
io.writeText(`${root}/other`, "moved")
|
|
93
|
+
symlink(`${root}/link`, "to")
|
|
94
|
+
io.rename(`${root}/other`, `${root}/link`)
|
|
95
|
+
|
|
96
|
+
expect(io.readText(`${root}/from`)).toBeUndefined()
|
|
97
|
+
expect(io.readText(`${root}/to`)).toBe("new")
|
|
98
|
+
expect(io.lstat(`${root}/link`)?.type).toBe("file")
|
|
99
|
+
expect(io.readText(`${root}/link`)).toBe("moved")
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it("stats through a symlink, lstats the link itself, and resolves real paths", () => {
|
|
103
|
+
const { io, root, uid, symlink } = makeTier()
|
|
104
|
+
io.makeDirectory(`${root}/dir`)
|
|
105
|
+
io.writeText(`${root}/dir/f`, "x")
|
|
106
|
+
symlink(`${root}/link`, "dir")
|
|
107
|
+
symlink(`${root}/dangling`, "nowhere")
|
|
108
|
+
|
|
109
|
+
expect(io.stat(`${root}/dir/f`)).toEqual({ type: "file", uid })
|
|
110
|
+
expect(io.stat(`${root}/link`)).toEqual({ type: "directory", uid })
|
|
111
|
+
expect(io.lstat(`${root}/link`)).toEqual({ type: "symlink", uid })
|
|
112
|
+
expect(io.lstat(`${root}/missing`)).toBeUndefined()
|
|
113
|
+
expect(io.stat(`${root}/dangling`)).toBeUndefined()
|
|
114
|
+
expect(io.readLink(`${root}/link`)).toBe("dir")
|
|
115
|
+
expect(io.realPath(`${root}/link/f`)).toBe(`${root}/dir/f`)
|
|
116
|
+
expect(io.readText(`${root}/link/f`)).toBe("x")
|
|
117
|
+
expect(() => io.realPath(`${root}/dangling`)).toThrow()
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it("reads committed content through git", () => {
|
|
121
|
+
const { io, root, commit } = makeTier()
|
|
122
|
+
commit({ "a.txt": "committed\n" })
|
|
123
|
+
|
|
124
|
+
expect(io.git(root, ["show", "HEAD:a.txt"])).toBe("committed\n")
|
|
125
|
+
expect(new Uint8Array(io.gitBytes(root, ["show", "HEAD:a.txt"]))).toEqual(
|
|
126
|
+
new TextEncoder().encode("committed\n"),
|
|
127
|
+
)
|
|
128
|
+
expect(io.git(root, ["rev-parse", "--show-object-format"])).toBe("sha1\n")
|
|
129
|
+
expect(io.git(root, ["rev-parse", "HEAD"])).toMatch(/^[0-9a-f]{40}\n$/)
|
|
130
|
+
expect(() => io.git(root, ["show", "HEAD:missing"])).toThrow()
|
|
131
|
+
expect(() => io.gitBytes(root, ["show", "HEAD:missing"])).toThrow()
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it("hashes like git and the standard test vectors", () => {
|
|
135
|
+
const { io } = makeTier()
|
|
136
|
+
const million = "a".repeat(1_000_000)
|
|
137
|
+
|
|
138
|
+
expect(io.hash("sha1", "blob 5\0", new TextEncoder().encode("hello"))).toBe(
|
|
139
|
+
"b6fc4c620b67d95f953a5c1c1230aaab5db5a1b0",
|
|
140
|
+
)
|
|
141
|
+
expect(io.hash("sha1", "")).toBe("da39a3ee5e6b4b0d3255bfef95601890afd80709")
|
|
142
|
+
expect(io.hash("sha1", million)).toBe("34aa973cd4c4daa4f61eeb2bdbad27316534016f")
|
|
143
|
+
expect(io.hash("sha256", "abc")).toBe(
|
|
144
|
+
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
|
|
145
|
+
)
|
|
146
|
+
expect(io.hash("sha256", "a".repeat(55), "a".repeat(9))).toBe(
|
|
147
|
+
"ffe054fe7ae0cb6dc65c3af9b61d5209f439851db43d0ba5997337df154668eb",
|
|
148
|
+
)
|
|
149
|
+
expect(io.hash("sha256", million)).toBe(
|
|
150
|
+
"cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0",
|
|
151
|
+
)
|
|
152
|
+
})
|
|
153
|
+
})
|