@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.
Files changed (80) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +12 -11
  3. package/dist/chunks/{flows-DENt0uWV.mjs → flows-B7ZRCe-B.mjs} +1 -1
  4. package/dist/chunks/{rolldown-runtime-8H4AJuhK.mjs → rolldown-runtime-D7D4PA-g.mjs} +0 -1
  5. package/dist/chunks/{steering-DF_BMadH.mjs → steering-Cem5i6Xl.mjs} +422 -2
  6. package/dist/chunks/{testkit-DwypA7cH.mjs → testkit-DfWZQCWQ.mjs} +1073 -328
  7. package/dist/flows.mjs +1 -1
  8. package/dist/gtd.bundle.mjs +4372 -1310
  9. package/dist/gtd.core.mjs +126 -118
  10. package/dist/steering.mjs +2 -2
  11. package/dist/testkit/gherkin.mjs +2 -2
  12. package/dist/testkit.mjs +2 -2
  13. package/dist/workflow-kit.mjs +2 -2
  14. package/package.json +1 -1
  15. package/src/cli/Cli.test.ts +5 -8
  16. package/src/cli/Cli.ts +22 -28
  17. package/src/cli/Output.ts +2 -1
  18. package/src/cli/Program.test.ts +55 -53
  19. package/src/cli/Program.ts +34 -34
  20. package/src/cli/ToolCommands.ts +3 -4
  21. package/src/edge/Edge.test.ts +38 -12
  22. package/src/edge/Edge.ts +53 -19
  23. package/src/emit/SteeringMode.test.ts +4 -2
  24. package/src/emit/SteeringMode.ts +31 -10
  25. package/src/flows/index.ts +1 -0
  26. package/src/flows/runtime.test.ts +2 -1
  27. package/src/flows/runtime.ts +8 -2
  28. package/src/gherkin/messages.test.ts +1 -1
  29. package/src/gherkin/module-config.feature +1 -1
  30. package/src/gherkin/testkit.feature +2 -2
  31. package/src/install/Version.ts +14 -13
  32. package/src/platform/Host.ts +75 -22
  33. package/src/platform/NodePinFile.ts +44 -0
  34. package/src/platform/NodeRuntime.ts +19 -0
  35. package/src/platform/NodeSync.ts +64 -0
  36. package/src/platform/PinFile.ts +0 -50
  37. package/src/platform/Sha.ts +121 -0
  38. package/src/platform/SyncIo.ts +35 -0
  39. package/src/platform/Workspace.test.ts +8 -3
  40. package/src/platform/Workspace.ts +82 -123
  41. package/src/platform/WorktreeState.test.ts +8 -4
  42. package/src/platform/WorktreeState.ts +21 -19
  43. package/src/platform/index.ts +18 -5
  44. package/src/replay/Format.test.ts +16 -13
  45. package/src/replay/Format.ts +4 -2
  46. package/src/replay/Replay.test.ts +25 -5
  47. package/src/replay/Replay.ts +10 -6
  48. package/src/replay/Trailers.test.ts +10 -10
  49. package/src/replay/Trailers.ts +23 -1
  50. package/src/replay/index.ts +1 -0
  51. package/src/steering/Declared.test.ts +218 -0
  52. package/src/steering/Declared.ts +607 -0
  53. package/src/steering/FORMATS.md +3 -0
  54. package/src/steering/index.ts +12 -0
  55. package/src/steering/internal.ts +1 -0
  56. package/src/step/planStart.test.ts +2 -2
  57. package/src/step/planStep.test.ts +4 -4
  58. package/src/testkit/TestRepo.test.ts +1 -1
  59. package/src/testkit/TestRepo.ts +4 -18
  60. package/src/twins/EmittedScriptRecognizer.ts +5 -3
  61. package/src/twins/EmittedScripts.test.ts +19 -0
  62. package/src/twins/Host.test.ts +151 -0
  63. package/src/twins/Layers.test.ts +2 -2
  64. package/src/twins/Layers.ts +44 -18
  65. package/src/twins/Path.test.ts +52 -0
  66. package/src/twins/SyncIo.test.ts +153 -0
  67. package/src/twins/SyncIo.ts +159 -0
  68. package/src/twins/cliIo.ts +8 -1
  69. package/src/twins/index.ts +5 -0
  70. package/src/workflow/SteeringFormats.ts +4 -0
  71. package/src/workflow/Workflow.ts +2 -0
  72. package/src/workflow/discovery.test.ts +6 -5
  73. package/src/workflow/discovery.ts +43 -32
  74. package/src/workflow/fingerprint.test.ts +23 -3
  75. package/src/workflow/fingerprint.ts +9 -11
  76. package/src/workflow/index.ts +5 -1
  77. package/src/workflow/load.test.ts +16 -6
  78. package/src/workflow/load.ts +107 -69
  79. package/src/workflow/pin.test.ts +3 -2
  80. 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
  ])
@@ -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 gtd = async (...args: string[]): Promise<CapturedCliResult> => {
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>` — a non-empty findings array fails the script, mirroring a real invocation's non-zero exit under `set -e`. */
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 format = steeringFormatFor(mode)
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
+ })
@@ -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]}`,
@@ -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
- // `realPath` is a no-op here (there is no real filesystem to resolve
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
+ })