@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
|
@@ -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
|
+
}
|
package/src/twins/cliIo.ts
CHANGED
|
@@ -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, {
|
|
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,
|
package/src/twins/index.ts
CHANGED
|
@@ -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, "'\\''")}'`
|
package/src/workflow/Workflow.ts
CHANGED
|
@@ -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(
|
|
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 {
|
|
2
|
-
import {
|
|
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 = (
|
|
57
|
+
const legacyRcFileAt = (
|
|
58
|
+
io: SyncIo,
|
|
59
|
+
path: string,
|
|
60
|
+
uid: number | undefined,
|
|
61
|
+
): LegacyRcFile | undefined => {
|
|
59
62
|
try {
|
|
60
|
-
const stat =
|
|
61
|
-
if (
|
|
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:
|
|
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
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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.
|
|
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(
|
|
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(
|
|
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 {
|
|
2
|
-
import {
|
|
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
|
|
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
|
|
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] =
|
|
39
|
+
if (content !== undefined) fingerprint[path] = io.hash("sha256", content).slice(0, HASH_LENGTH)
|
|
42
40
|
}
|
|
43
41
|
return fingerprint
|
|
44
42
|
}
|
package/src/workflow/index.ts
CHANGED
|
@@ -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 {
|
|
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(
|
|
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<
|
|
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<
|
|
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
|
),
|