@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
@@ -0,0 +1,159 @@
1
+ import { pureHash, type FileInfo, type SyncIo } from "../platform/index.js"
2
+ import type { InMemRepo } from "./InMemRepo.js"
3
+
4
+ const MAX_LINKS = 40
5
+
6
+ const fault = (code: string, op: string, path: string): Error =>
7
+ Object.assign(new Error(`${code}: ${op} '${path}'`), { code })
8
+
9
+ const parentOf = (path: string): string => path.slice(0, path.lastIndexOf("/")) || "/"
10
+
11
+ const joined = (dir: string, name: string): string => (dir === "/" ? `/${name}` : `${dir}/${name}`)
12
+
13
+ const encoder = new TextEncoder()
14
+ const decoder = new TextDecoder("utf-8", { ignoreBOM: true })
15
+
16
+ export interface InMemorySyncIo extends SyncIo {
17
+ readonly symlink: (path: string, target: string) => void
18
+ }
19
+
20
+ /**
21
+ * A POSIX file system in memory, absolute paths only. Its git answers the
22
+ * reads gtd makes from `repo`, rooted at `root`.
23
+ */
24
+ export const inMemorySyncIo = (
25
+ opts: { readonly repo?: InMemRepo; readonly root?: string; readonly uid?: number } = {},
26
+ ): InMemorySyncIo => {
27
+ const files = new Map<string, Uint8Array>()
28
+ const links = new Map<string, string>()
29
+ const dirs = new Set<string>(["/"])
30
+
31
+ /** `path` with every symlink resolved, the last component's only when `followLast`. */
32
+ const resolve = (path: string, followLast: boolean): string => {
33
+ let pending = path.split("/").filter((part) => part.length > 0)
34
+ let at = "/"
35
+ let hops = 0
36
+ while (pending.length > 0) {
37
+ const [name, ...rest] = pending as [string, ...string[]]
38
+ pending = rest
39
+ if (name === ".") continue
40
+ if (name === "..") {
41
+ at = parentOf(at)
42
+ continue
43
+ }
44
+ const next = joined(at, name)
45
+ const target = links.get(next)
46
+ if (target === undefined || (rest.length === 0 && !followLast)) {
47
+ at = next
48
+ continue
49
+ }
50
+ if (++hops > MAX_LINKS) throw fault("ELOOP", "resolve", path)
51
+ if (target.startsWith("/")) at = "/"
52
+ pending = [...target.split("/").filter((part) => part.length > 0), ...rest]
53
+ }
54
+ return at
55
+ }
56
+
57
+ const typeOf = (path: string): FileInfo["type"] | undefined =>
58
+ links.has(path)
59
+ ? "symlink"
60
+ : files.has(path)
61
+ ? "file"
62
+ : dirs.has(path)
63
+ ? "directory"
64
+ : undefined
65
+
66
+ const parentExists = (path: string): boolean =>
67
+ typeOf(resolve(parentOf(path), true)) === "directory"
68
+
69
+ const info = (path: string): FileInfo | undefined => {
70
+ const type = typeOf(path)
71
+ return type === undefined ? undefined : { type, uid: opts.uid }
72
+ }
73
+
74
+ const readFile = (path: string): Uint8Array | undefined => {
75
+ const real = resolve(path, true)
76
+ if (dirs.has(real)) throw fault("EISDIR", "read", path)
77
+ return files.get(real)
78
+ }
79
+
80
+ const writeFile = (path: string, content: Uint8Array): void => {
81
+ const real = resolve(path, true)
82
+ if (!parentExists(real)) throw fault("ENOENT", "open", path)
83
+ if (dirs.has(real)) throw fault("EISDIR", "open", path)
84
+ files.set(real, content)
85
+ }
86
+
87
+ const makeDirectory = (path: string): void => {
88
+ const real = resolve(path, true)
89
+ const type = typeOf(real)
90
+ if (type === "directory") return
91
+ if (type !== undefined) throw fault("EEXIST", "mkdir", path)
92
+ makeDirectory(parentOf(real))
93
+ dirs.add(real)
94
+ }
95
+
96
+ const show = (repo: InMemRepo, spec: string): string => {
97
+ const colon = spec.indexOf(":")
98
+ const content = repo.fileAtRef(spec.slice(0, colon), spec.slice(colon + 1))
99
+ if (content === null) throw new Error(`fatal: invalid object name '${spec}'`)
100
+ return content
101
+ }
102
+
103
+ const revParse = (repo: InMemRepo, arg: string): string => {
104
+ if (arg === "--show-object-format") return "sha1\n"
105
+ const hash = repo.resolveRef(arg)
106
+ if (hash === null) throw new Error(`fatal: ambiguous argument '${arg}'`)
107
+ return `${hash}\n`
108
+ }
109
+
110
+ const git = (cwd: string, args: readonly string[]): string => {
111
+ const { repo, root } = opts
112
+ if (repo === undefined || resolve(cwd, true) !== root) {
113
+ throw new Error(`fatal: not a git repository: ${cwd}`)
114
+ }
115
+ const [command, arg, ...rest] = args
116
+ if (arg === undefined || rest.length > 0 || (command !== "show" && command !== "rev-parse")) {
117
+ throw new Error(`in-memory git: unsupported \`git ${args.join(" ")}\``)
118
+ }
119
+ return command === "show" ? show(repo, arg) : revParse(repo, arg)
120
+ }
121
+
122
+ return {
123
+ readFile,
124
+ readText: (path) => {
125
+ const bytes = readFile(path)
126
+ return bytes === undefined ? undefined : decoder.decode(bytes)
127
+ },
128
+ writeText: (path, content) => writeFile(path, encoder.encode(content)),
129
+ makeDirectory,
130
+ rename: (from, to) => {
131
+ const source = resolve(from, false)
132
+ const target = resolve(to, false)
133
+ const bytes = files.get(source)
134
+ if (bytes === undefined) throw fault("ENOENT", "rename", from)
135
+ if (!parentExists(target)) throw fault("ENOENT", "rename", to)
136
+ links.delete(target)
137
+ files.set(target, bytes)
138
+ files.delete(source)
139
+ },
140
+ stat: (path) => info(resolve(path, true)),
141
+ lstat: (path) => info(resolve(path, false)),
142
+ readLink: (path) => {
143
+ const target = links.get(resolve(path, false))
144
+ if (target === undefined) throw fault("EINVAL", "readlink", path)
145
+ return target
146
+ },
147
+ realPath: (path) => {
148
+ const real = resolve(path, true)
149
+ if (typeOf(real) === undefined) throw fault("ENOENT", "realpath", path)
150
+ return real
151
+ },
152
+ git,
153
+ gitBytes: (cwd, args) => encoder.encode(git(cwd, args)),
154
+ hash: pureHash,
155
+ symlink: (path, target) => {
156
+ links.set(resolve(path, false), target)
157
+ },
158
+ }
159
+ }
@@ -14,6 +14,7 @@ export const makeCapturingCliIo = (
14
14
  repo: InMemRepo,
15
15
  env: Readonly<Record<string, string | undefined>> = {},
16
16
  tools: Partial<ToolCommands["Service"]> = {},
17
+ stdin = "",
17
18
  ): { readonly io: CliIo; readonly result: () => CapturedCliResult } => {
18
19
  let stdout: Buffer = Buffer.alloc(0)
19
20
  let stderr = ""
@@ -32,7 +33,13 @@ export const makeCapturingCliIo = (
32
33
  // errors into — narration lands in the exact same buffer, exactly like a
33
34
  // real invocation's narration and remediation share one fd.
34
35
  layers: (verbose) =>
35
- testLayers(repo, { env, narrate: (line) => (stderr += line), verbose, tools }),
36
+ testLayers(repo, {
37
+ env,
38
+ narrate: (line) => (stderr += line),
39
+ verbose,
40
+ tools,
41
+ stdin,
42
+ }),
36
43
  }
37
44
  return {
38
45
  io,
@@ -1,5 +1,9 @@
1
1
  export { InMemRepo } from "./InMemRepo.js"
2
2
  export {
3
+ inMemPath,
4
+ inMemoryHost,
5
+ IN_MEMORY_PID,
6
+ IN_MEMORY_SCRATCH,
3
7
  makeInMemoryWorkspaceOps,
4
8
  testLayers,
5
9
  type CommandTable,
@@ -13,3 +17,4 @@ export { strictGitOperations } from "./StrictGitOperations.js"
13
17
  export { fakeGitOperations } from "./FakeGitOperations.js"
14
18
  export { takePinBeat } from "./PinBeats.js"
15
19
  export { inMemPinFile } from "./PinFile.js"
20
+ export { inMemorySyncIo } from "./SyncIo.js"
@@ -4,3 +4,7 @@ export const seededValidateCommand = (mode: string): string => `gtd check ${mode
4
4
  /** True when `command` is exactly `mode`'s seeded template (compared literally, not rendered) — distinguishes gtd's own seeding from a user override. */
5
5
  export const isSeededValidateCommand = (mode: string, command: string): boolean =>
6
6
  command === seededValidateCommand(mode)
7
+
8
+ /** The `validate:` command for a declared format: its spec travels on the command line, so `gtd check` loads no workflow to find it. */
9
+ export const declaredValidateCommand = (mode: string, spec: string): string =>
10
+ `gtd check ${mode} "$GTD_FILE" --spec '${spec.replace(/'/g, "'\\''")}'`
@@ -32,6 +32,8 @@ export interface StepDef {
32
32
  readonly label?: string
33
33
  readonly file?: string
34
34
  readonly mode?: StateMode
35
+ /** A declared format's spec, when `mode` names one rather than a built-in or `modes:` entry. */
36
+ readonly modeSpec?: string
35
37
  readonly model?: string
36
38
  readonly system?: string
37
39
  readonly judge?: string
@@ -2,7 +2,7 @@ import { execSync } from "node:child_process"
2
2
  import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"
3
3
  import { tmpdir } from "node:os"
4
4
  import { dirname, join } from "node:path"
5
- import { Effect, Layer } from "effect"
5
+ import { Effect, Layer, type Path } from "effect"
6
6
  import { afterEach, describe, expect, it } from "vitest"
7
7
  import { GitService, Host, Narrator, Workspace, NodeServices } from "../platform/index.js"
8
8
  import { InMemRepo, testLayers } from "../twins/index.js"
@@ -34,7 +34,7 @@ const config = (imports: string): string =>
34
34
  ].join("\n")
35
35
 
36
36
  const loadWith = (
37
- layer: Layer.Layer<ConfigService | Narrator | Workspace | Host | ConfigDiscovery>,
37
+ layer: Layer.Layer<ConfigService | Narrator | Workspace | Host | Path.Path | ConfigDiscovery>,
38
38
  ) =>
39
39
  Effect.runPromise(
40
40
  Effect.flatMap(ConfigService, (service) => service.load).pipe(
@@ -63,15 +63,16 @@ const live = (): Tier => {
63
63
  }
64
64
  const host = Host.layer({ root, home: root, env: {} })
65
65
  const git = GitService.Live.pipe(Layer.provide(Layer.merge(host, NodeServices.layer)))
66
- const workspace = Workspace.Live.pipe(Layer.provide(Layer.merge(host, git)))
66
+ const workspace = Workspace.Live.pipe(
67
+ Layer.provide(Layer.mergeAll(host, git, NodeServices.layer)),
68
+ )
67
69
  return loadWith(
68
70
  Layer.mergeAll(
69
71
  ConfigService.Live,
70
72
  ConfigDiscovery.Live,
71
- host,
72
73
  workspace,
73
74
  Narrator.layer(() => {}, false),
74
- ),
75
+ ).pipe(Layer.provideMerge(host), Layer.provideMerge(NodeServices.layer)),
75
76
  )
76
77
  },
77
78
  dispose: () => rmSync(root, { recursive: true, force: true }),
@@ -1,6 +1,5 @@
1
- import { existsSync, readFileSync, statSync } from "node:fs"
2
- import { dirname, join } from "node:path"
3
- import { Context, Effect, Layer } from "effect"
1
+ import { Context, Effect, Layer, Path } from "effect"
2
+ import { Host, nodeSyncIo, type SyncIo } from "../platform/index.js"
4
3
 
5
4
  /** The workflow module found on the cwd→home walk, with its source. */
6
5
  export interface WorkflowModule {
@@ -50,17 +49,21 @@ export const LEGACY_RC_FILES = [
50
49
  ]
51
50
 
52
51
  /** The directories searched for a leftover rc file: the walk, plus `home` when the walk misses it. */
53
- export const legacyRcDirs = (root: string, home: string): readonly string[] => {
54
- const chain = walkUp(root, home)
52
+ export const legacyRcDirs = (path: Path.Path, root: string, home: string): readonly string[] => {
53
+ const chain = walkUp(path, root, home)
55
54
  return chain.includes(home) ? chain : [...chain, home]
56
55
  }
57
56
 
58
- const legacyRcFileAt = (path: string): LegacyRcFile | undefined => {
57
+ const legacyRcFileAt = (
58
+ io: SyncIo,
59
+ path: string,
60
+ uid: number | undefined,
61
+ ): LegacyRcFile | undefined => {
59
62
  try {
60
- const stat = statSync(path)
61
- if (!stat.isFile()) return undefined
63
+ const stat = io.stat(path)
64
+ if (stat?.type !== "file") return undefined
62
65
  // Above home, a shared directory (`/tmp`) can hold another user's file.
63
- return { path, owned: process.getuid === undefined || stat.uid === process.getuid() }
66
+ return { path, owned: uid === undefined || stat.uid === uid }
64
67
  } catch {
65
68
  return undefined
66
69
  }
@@ -74,40 +77,43 @@ const legacyRcFileAt = (path: string): LegacyRcFile | undefined => {
74
77
  * shared implementation, not a second copy in `src/twins/Layers.ts` that
75
78
  * could silently diverge.
76
79
  */
77
- export const walkUp = (from: string, home: string): ReadonlyArray<string> => {
80
+ export const walkUp = (path: Path.Path, from: string, home: string): ReadonlyArray<string> => {
78
81
  const chain: Array<string> = []
79
82
  let dir = from
80
83
  while (true) {
81
84
  chain.push(dir)
82
85
  if (dir === home) break
83
- const parent = dirname(dir)
86
+ const parent = path.dirname(dir)
84
87
  if (parent === dir) break // filesystem root
85
88
  dir = parent
86
89
  }
87
90
  return chain
88
91
  }
89
92
 
90
- const legacyRcFiles = (root: string, home: string): Effect.Effect<readonly LegacyRcFile[], Error> =>
91
- Effect.sync(() =>
92
- legacyRcDirs(root, home).flatMap((dir) =>
93
- LEGACY_RC_FILES.flatMap((name) => legacyRcFileAt(join(dir, name)) ?? []),
93
+ const liveDiscovery = (
94
+ io: SyncIo,
95
+ path: Path.Path,
96
+ uid: number | undefined,
97
+ ): ConfigDiscoveryOps => ({
98
+ legacyRcFiles: (root, home) =>
99
+ Effect.sync(() =>
100
+ legacyRcDirs(path, root, home).flatMap((dir) =>
101
+ LEGACY_RC_FILES.flatMap((name) => legacyRcFileAt(io, path.join(dir, name), uid) ?? []),
102
+ ),
94
103
  ),
95
- )
96
-
97
- const workflowModule = (
98
- root: string,
99
- home: string,
100
- ): Effect.Effect<WorkflowModule | undefined, Error> =>
101
- Effect.try({
102
- try: () => {
103
- for (const dir of walkUp(root, home)) {
104
- const filepath = join(dir, WORKFLOW_MODULE)
105
- if (existsSync(filepath)) return { filepath, source: readFileSync(filepath, "utf8") }
106
- }
107
- return undefined
108
- },
109
- catch: (e) => (e instanceof Error ? e : new Error(String(e))),
110
- })
104
+ workflowModule: (root, home) =>
105
+ Effect.try({
106
+ try: () => {
107
+ for (const dir of walkUp(path, root, home)) {
108
+ const filepath = path.join(dir, WORKFLOW_MODULE)
109
+ const source = io.readText(filepath)
110
+ if (source !== undefined) return { filepath, source }
111
+ }
112
+ return undefined
113
+ },
114
+ catch: (e) => (e instanceof Error ? e : new Error(String(e))),
115
+ }),
116
+ })
111
117
 
112
118
  /**
113
119
  * The discovery seam — a `Context.Service`, not part of `WorkflowFiles` (that
@@ -122,5 +128,10 @@ const workflowModule = (
122
128
  export class ConfigDiscovery extends Context.Service<ConfigDiscovery, ConfigDiscoveryOps>()(
123
129
  "ConfigDiscovery",
124
130
  ) {
125
- static Live = Layer.succeed(ConfigDiscovery, { legacyRcFiles, workflowModule })
131
+ static Live = Layer.effect(
132
+ ConfigDiscovery,
133
+ Effect.gen(function* () {
134
+ return liveDiscovery(nodeSyncIo, yield* Path.Path, (yield* Host).uid)
135
+ }),
136
+ )
126
137
  }
@@ -9,6 +9,8 @@ import {
9
9
  import { tmpdir } from "node:os"
10
10
  import { join } from "node:path"
11
11
  import { describe, expect, it } from "vitest"
12
+ import { nodeSyncIo } from "../platform/index.js"
13
+ import { inMemPath } from "../twins/index.js"
12
14
  import { changedConfigFiles, configFingerprint } from "./fingerprint.js"
13
15
 
14
16
  const read = (path: string): string | undefined => {
@@ -27,6 +29,8 @@ describe("the config fingerprint", () => {
27
29
  writeFileSync(join(root, "lib", "flow.ts"), "export const a = 1\n")
28
30
 
29
31
  const fingerprint = configFingerprint(
32
+ nodeSyncIo,
33
+ inMemPath,
30
34
  [join(root, "gtd.config.ts"), join(root, "lib", "flow.ts"), join(root, "gone.ts")],
31
35
  root,
32
36
  read,
@@ -38,7 +42,13 @@ describe("the config fingerprint", () => {
38
42
  expect(
39
43
  changedConfigFiles(
40
44
  fingerprint,
41
- configFingerprint([join(root, "gtd.config.ts"), join(root, "lib", "flow.ts")], root, read),
45
+ configFingerprint(
46
+ nodeSyncIo,
47
+ inMemPath,
48
+ [join(root, "gtd.config.ts"), join(root, "lib", "flow.ts")],
49
+ root,
50
+ read,
51
+ ),
42
52
  ),
43
53
  ).toEqual(["lib/flow.ts"])
44
54
  })
@@ -50,7 +60,15 @@ describe("the config fingerprint", () => {
50
60
  symlinkSync(real, link)
51
61
 
52
62
  expect(
53
- Object.keys(configFingerprint([join(realpathSync(real), "gtd.config.ts")], link, read)),
63
+ Object.keys(
64
+ configFingerprint(
65
+ nodeSyncIo,
66
+ inMemPath,
67
+ [join(realpathSync(real), "gtd.config.ts")],
68
+ link,
69
+ read,
70
+ ),
71
+ ),
54
72
  ).toEqual(["gtd.config.ts"])
55
73
  })
56
74
 
@@ -58,7 +76,9 @@ describe("the config fingerprint", () => {
58
76
  const root = mkdtempSync(join(tmpdir(), "gtd-fingerprint-"))
59
77
  writeFileSync(join(root, "a\x1b.ts"), "export const a = 1\n")
60
78
 
61
- expect(configFingerprint([join(root, "a\x1b.ts")], root, read)).toEqual({})
79
+ expect(configFingerprint(nodeSyncIo, inMemPath, [join(root, "a\x1b.ts")], root, read)).toEqual(
80
+ {},
81
+ )
62
82
  })
63
83
 
64
84
  it("names a file only one side has as changed", () => {
@@ -1,6 +1,5 @@
1
- import { createHash } from "node:crypto"
2
- import { realpathSync } from "node:fs"
3
- import { relative, sep } from "node:path"
1
+ import type { Path } from "effect"
2
+ import type { SyncIo } from "../platform/index.js"
4
3
 
5
4
  /** Path (relative to `root`) → content hash of every file a config load touched. */
6
5
  export type ConfigFingerprint = Readonly<Record<string, string>>
@@ -12,12 +11,9 @@ const HASH_LENGTH = 16
12
11
  // eslint-disable-next-line no-control-regex -- the control range IS the point
13
12
  const CONTROL_CHARACTER = /[\x00-\x1f\x7f-\x9f]/
14
13
 
15
- const contentHash = (content: string | Buffer): string =>
16
- createHash("sha256").update(content).digest("hex").slice(0, HASH_LENGTH)
17
-
18
- const realOr = (path: string): string => {
14
+ const realOr = (io: SyncIo, path: string): string => {
19
15
  try {
20
- return realpathSync(path)
16
+ return io.realPath(path)
21
17
  } catch {
22
18
  return path
23
19
  }
@@ -28,17 +24,19 @@ const realOr = (path: string): string => {
28
24
  // Paths are real paths: a loader may report `/private/var/…` for a root
29
25
  // reached through the `/var` symlink, and the two must still relate.
30
26
  export const configFingerprint = (
27
+ io: SyncIo,
28
+ paths: Path.Path,
31
29
  files: readonly string[],
32
30
  root: string,
33
31
  read: (path: string) => string | undefined,
34
32
  ): ConfigFingerprint => {
35
33
  const fingerprint: Record<string, string> = {}
36
- const realRoot = realOr(root)
34
+ const realRoot = realOr(io, root)
37
35
  for (const file of files) {
38
- const path = relative(realRoot, realOr(file)).split(sep).join("/")
36
+ const path = paths.relative(realRoot, realOr(io, file)).split(paths.sep).join("/")
39
37
  if (CONTROL_CHARACTER.test(path)) continue
40
38
  const content = read(file)
41
- if (content !== undefined) fingerprint[path] = contentHash(content)
39
+ if (content !== undefined) fingerprint[path] = io.hash("sha256", content).slice(0, HASH_LENGTH)
42
40
  }
43
41
  return fingerprint
44
42
  }
@@ -23,7 +23,11 @@ export {
23
23
  SETTING_NAME_RULE,
24
24
  } from "./vars.js"
25
25
  export { resolveScopeAccess, resolveScopeSkills } from "./skills.js"
26
- export { seededValidateCommand, isSeededValidateCommand } from "./SteeringFormats.js"
26
+ export {
27
+ declaredValidateCommand,
28
+ seededValidateCommand,
29
+ isSeededValidateCommand,
30
+ } from "./SteeringFormats.js"
27
31
  export {
28
32
  type ChangeStatus,
29
33
  type PendingChange,
@@ -3,7 +3,7 @@ import { existsSync, readFileSync, mkdtempSync, rmSync, writeFileSync, mkdirSync
3
3
  import { join } from "node:path"
4
4
  import { tmpdir } from "node:os"
5
5
  import { afterEach, beforeEach, describe, expect, it } from "vitest"
6
- import { Cause, Effect, Exit, Layer } from "effect"
6
+ import { Cause, Effect, Exit, Layer, type Path } from "effect"
7
7
  import { Narrator, GitService, Host, Workspace, NodeServices } from "../platform/index.js"
8
8
  import {
9
9
  ConfigDiscovery,
@@ -23,7 +23,9 @@ type Env = Readonly<Record<string, string>>
23
23
  const baseLayer = (dir: string, env: Env = {}) => {
24
24
  const hostLayer = Host.layer({ root: dir, home: homeDir, env })
25
25
  const gitLayer = GitService.Live.pipe(Layer.provide(Layer.merge(hostLayer, NodeServices.layer)))
26
- const workspaceLayer = Workspace.Live.pipe(Layer.provide(Layer.merge(hostLayer, gitLayer)))
26
+ const workspaceLayer = Workspace.Live.pipe(
27
+ Layer.provide(Layer.mergeAll(hostLayer, gitLayer, NodeServices.layer)),
28
+ )
27
29
  return Layer.mergeAll(hostLayer, gitLayer, workspaceLayer)
28
30
  }
29
31
 
@@ -34,18 +36,25 @@ const layer = (dir: string, env: Env = {}) =>
34
36
  Layer.mergeAll(
35
37
  ConfigService.Live,
36
38
  ConfigDiscovery.Live,
37
- baseLayer(dir, env),
38
39
  Narrator.layer(() => {}, false),
39
- )
40
+ ).pipe(Layer.provideMerge(baseLayer(dir, env)), Layer.provideMerge(NodeServices.layer))
40
41
 
41
42
  const run = <A>(
42
- eff: Effect.Effect<A, Error, ConfigService | ConfigDiscovery | Narrator | Workspace | Host>,
43
+ eff: Effect.Effect<
44
+ A,
45
+ Error,
46
+ ConfigService | ConfigDiscovery | Narrator | Workspace | Host | Path.Path
47
+ >,
43
48
  dir: string = projectDir,
44
49
  env: Env = {},
45
50
  ) => Effect.runPromise(eff.pipe(Effect.provide(layer(dir, env))))
46
51
 
47
52
  const runExit = <A>(
48
- eff: Effect.Effect<A, Error, ConfigService | ConfigDiscovery | Narrator | Workspace | Host>,
53
+ eff: Effect.Effect<
54
+ A,
55
+ Error,
56
+ ConfigService | ConfigDiscovery | Narrator | Workspace | Host | Path.Path
57
+ >,
49
58
  dir: string = projectDir,
50
59
  ) => Effect.runPromiseExit(eff.pipe(Effect.provide(layer(dir))))
51
60
 
@@ -265,6 +274,7 @@ describe("ConfigService", () => {
265
274
  others,
266
275
  baseLayer(projectDir),
267
276
  Narrator.layer((m) => warnings.push(m), false),
277
+ NodeServices.layer,
268
278
  ),
269
279
  ),
270
280
  ),