@pmelab/gtd 24.2.0 → 24.4.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 +41 -6
- package/dist/chunks/{Schema-Dh-mkwkq.mjs → Schema-B5yK7QrT.mjs} +47 -1
- package/dist/chunks/{reserved-ejn-LVYO.mjs → reserved-B9pG93Fc.mjs} +53 -13
- package/dist/chunks/{steering-D-PvRC5W.mjs → steering-DVILanVf.mjs} +16 -5
- package/dist/flows.mjs +9 -1
- package/dist/gtd.bundle.mjs +1114 -865
- package/dist/steering.mjs +2 -2
- package/dist/testkit.mjs +1135 -218
- package/dist/workflow-kit.mjs +9 -7
- package/dist/workflows/feature.mjs +152 -35
- package/dist/workflows/fix.mjs +2 -2
- package/dist/workflows/review.mjs +1 -1
- package/dist/workflows.mjs +223 -54
- package/package.json +1 -1
- package/src/cli/Cli.test.ts +57 -0
- package/src/cli/Cli.ts +89 -9
- package/src/cli/History.test.ts +225 -0
- package/src/cli/History.ts +143 -0
- package/src/cli/Output.ts +68 -0
- package/src/cli/Program.test.ts +10 -9
- package/src/cli/Program.ts +24 -72
- package/src/edge/Edge.test.ts +261 -3
- package/src/edge/Edge.ts +218 -41
- package/src/edge/index.ts +1 -0
- package/src/emit/GitScript.test.ts +27 -0
- package/src/emit/GitScript.ts +18 -2
- package/src/emit/index.ts +2 -0
- package/src/flows/scripts.ts +6 -0
- package/src/platform/Git.test.ts +6 -9
- package/src/platform/Git.ts +48 -10
- package/src/platform/RetainedHistory.test.ts +14 -0
- package/src/platform/RetainedHistory.ts +5 -0
- package/src/platform/index.ts +7 -1
- package/src/replay/Trailers.test.ts +32 -0
- package/src/replay/Trailers.ts +17 -0
- package/src/steering/FORMATS.md +1 -1
- package/src/steering/SteeringFormat.test.ts +9 -1
- package/src/steering/SteeringFormat.ts +7 -0
- package/src/steering/contract.test.ts +3 -0
- package/src/steering/index.ts +1 -0
- package/src/steering/review.test.ts +30 -0
- package/src/steering/review.ts +8 -2
- package/src/step/LandStep.ts +3 -1
- package/src/step/RepoSnapshot.ts +7 -1
- package/src/step/planStart.test.ts +6 -3
- package/src/step/planStep.test.ts +29 -1
- package/src/step/planStep.ts +8 -0
- package/src/testkit/EmittedScriptRecognizer.test.ts +29 -30
- package/src/testkit/EmittedScriptRecognizer.ts +149 -52
- package/src/testkit/EmittedScripts.test.ts +166 -0
- package/src/testkit/FakeGitOperations.ts +6 -2
- package/src/testkit/FlowScripts.test.ts +242 -0
- package/src/testkit/FlowScripts.ts +326 -0
- package/src/testkit/GitTiers.ts +269 -47
- package/src/testkit/InMemRepo.ts +61 -8
- package/src/testkit/Layers.ts +10 -2
- package/src/testkit/TestRepo.ts +4 -1
- package/src/testkit/index.ts +1 -1
- package/src/workflow/discovery.test.ts +147 -0
- package/src/workflow/discovery.ts +5 -0
- package/src/workflow/fingerprint.test.ts +20 -4
- package/src/workflow/fingerprint.ts +8 -9
- package/src/workflow/index.ts +1 -6
- package/src/workflow/load.test.ts +5 -19
- package/src/workflow/load.ts +94 -10
- package/src/workflow-kit/review.test.ts +84 -0
- package/src/workflow-kit/review.ts +9 -6
- package/src/workflows/feature/review.test.ts +238 -12
- package/src/workflows/feature/review.ts +172 -31
- package/src/workflows/feature/steps.test.ts +12 -0
- package/src/workflows/feature/steps.ts +8 -1
- package/src/workflows/shared/access.test.ts +4 -2
- package/src/workflows/shared/access.ts +1 -0
- package/src/workflows/shared/options.test.ts +8 -0
- package/src/workflows/shared/options.ts +1 -0
- package/src/workflows/shared/steps.ts +2 -0
- package/src/workflows/shared/tail.ts +3 -11
- package/src/workflows/shared/text.test.ts +106 -0
- package/src/workflows/shared/text.ts +57 -1
- package/src/workflows/shared/vars.ts +1 -0
package/src/testkit/index.ts
CHANGED
|
@@ -3,7 +3,7 @@ export { InMemRepo } from "./InMemRepo.js"
|
|
|
3
3
|
export { makeInMemoryWorkspaceOps, testLayers, type ScriptedCommand } from "./Layers.js"
|
|
4
4
|
export { makeCapturingCliIo } from "./cliIo.js"
|
|
5
5
|
export { applyEmittedScript } from "./EmittedScriptRecognizer.js"
|
|
6
|
-
export { gitTiers, runGitServiceContract, runGitScriptContract } from "./GitTiers.js"
|
|
6
|
+
export { gitTiers, runGitServiceContract, runGitScriptContract, type GitTier } from "./GitTiers.js"
|
|
7
7
|
export { strictGitOperations } from "./StrictGitOperations.js"
|
|
8
8
|
export { fakeGitOperations } from "./FakeGitOperations.js"
|
|
9
9
|
export type { TestkitWorld } from "./steps.js"
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { execSync } from "node:child_process"
|
|
2
|
+
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"
|
|
3
|
+
import { tmpdir } from "node:os"
|
|
4
|
+
import { dirname, join } from "node:path"
|
|
5
|
+
import { NodeContext } from "@effect/platform-node"
|
|
6
|
+
import { Effect, Layer } from "effect"
|
|
7
|
+
import { afterEach, describe, expect, it } from "vitest"
|
|
8
|
+
import { GitService, Host, Narrator, Workspace } from "../platform/index.js"
|
|
9
|
+
import { InMemRepo, testLayers } from "../testkit/index.js"
|
|
10
|
+
import { ConfigDiscovery, ConfigService } from "./index.js"
|
|
11
|
+
|
|
12
|
+
// A config and the local modules it imports load the same from disk, where
|
|
13
|
+
// jiti resolves them, and from an in-memory discovery, where the loader
|
|
14
|
+
// resolves them itself — the same workflows, the same fingerprint, the same
|
|
15
|
+
// failure for a module that is not there.
|
|
16
|
+
type Files = Readonly<Record<string, string>>
|
|
17
|
+
|
|
18
|
+
interface Tier {
|
|
19
|
+
readonly load: (files: Files) => Promise<{ names: readonly string[]; fingerprint: object }>
|
|
20
|
+
readonly dispose: () => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const config = (imports: string): string =>
|
|
24
|
+
[
|
|
25
|
+
'import { agent, human } from "@pmelab/gtd/flows"',
|
|
26
|
+
imports,
|
|
27
|
+
"export default async () => {",
|
|
28
|
+
' await human("idle", { message: "go" })',
|
|
29
|
+
' await agent("working", brief)',
|
|
30
|
+
"}",
|
|
31
|
+
"export const named = async () => {",
|
|
32
|
+
' await agent("working", brief)',
|
|
33
|
+
"}",
|
|
34
|
+
"",
|
|
35
|
+
].join("\n")
|
|
36
|
+
|
|
37
|
+
const loadWith = (
|
|
38
|
+
layer: Layer.Layer<ConfigService | Narrator | Workspace | Host | ConfigDiscovery>,
|
|
39
|
+
) =>
|
|
40
|
+
Effect.runPromise(
|
|
41
|
+
Effect.flatMap(ConfigService, (service) => service.load).pipe(
|
|
42
|
+
Effect.map((loaded) => ({ names: loaded.workflowNames, fingerprint: loaded.fingerprint })),
|
|
43
|
+
Effect.provide(layer),
|
|
44
|
+
),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
const live = (): Tier => {
|
|
48
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), "gtd-discovery-")))
|
|
49
|
+
execSync("git init -q && git commit -q --allow-empty -m init", {
|
|
50
|
+
cwd: root,
|
|
51
|
+
env: {
|
|
52
|
+
...process.env,
|
|
53
|
+
GIT_AUTHOR_NAME: "t",
|
|
54
|
+
GIT_AUTHOR_EMAIL: "t@t",
|
|
55
|
+
GIT_COMMITTER_NAME: "t",
|
|
56
|
+
GIT_COMMITTER_EMAIL: "t@t",
|
|
57
|
+
},
|
|
58
|
+
})
|
|
59
|
+
return {
|
|
60
|
+
load: (files) => {
|
|
61
|
+
for (const [path, content] of Object.entries(files)) {
|
|
62
|
+
mkdirSync(dirname(join(root, path)), { recursive: true })
|
|
63
|
+
writeFileSync(join(root, path), content)
|
|
64
|
+
}
|
|
65
|
+
const host = Host.layer({ root, home: root, env: {} })
|
|
66
|
+
const git = GitService.Live.pipe(Layer.provide(Layer.merge(host, NodeContext.layer)))
|
|
67
|
+
const workspace = Workspace.Live.pipe(Layer.provide(Layer.merge(host, git)))
|
|
68
|
+
return loadWith(
|
|
69
|
+
Layer.mergeAll(
|
|
70
|
+
ConfigService.Live,
|
|
71
|
+
ConfigDiscovery.Live,
|
|
72
|
+
host,
|
|
73
|
+
workspace,
|
|
74
|
+
Narrator.layer(() => {}, false),
|
|
75
|
+
),
|
|
76
|
+
)
|
|
77
|
+
},
|
|
78
|
+
dispose: () => rmSync(root, { recursive: true, force: true }),
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const inMemory = (): Tier => ({
|
|
83
|
+
load: (files) => {
|
|
84
|
+
const repo = new InMemRepo()
|
|
85
|
+
for (const [path, content] of Object.entries(files)) repo.writeFile(path, content)
|
|
86
|
+
repo.commitAllWithPrefix("chore: init")
|
|
87
|
+
return loadWith(testLayers(repo))
|
|
88
|
+
},
|
|
89
|
+
dispose: () => {},
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
for (const make of [live, inMemory]) {
|
|
93
|
+
describe(`a config's local modules [${make.name}]`, () => {
|
|
94
|
+
let t: Tier
|
|
95
|
+
afterEach(() => t.dispose())
|
|
96
|
+
|
|
97
|
+
it("loads an import by its .js spelling, and one it imports in turn", async () => {
|
|
98
|
+
t = make()
|
|
99
|
+
const loaded = await t.load({
|
|
100
|
+
"gtd.config.ts": config('import { brief } from "./lib/brief.js"'),
|
|
101
|
+
"lib/brief.ts": 'import { word } from "./word"\nexport const brief = `do ${word}`\n',
|
|
102
|
+
"lib/word.ts": 'export const word = "it"\n',
|
|
103
|
+
})
|
|
104
|
+
expect(loaded.names).toEqual(["named"])
|
|
105
|
+
expect(Object.keys(loaded.fingerprint).sort()).toEqual([
|
|
106
|
+
"gtd.config.ts",
|
|
107
|
+
"lib/brief.ts",
|
|
108
|
+
"lib/word.ts",
|
|
109
|
+
])
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it("hashes each file's content the same way", async () => {
|
|
113
|
+
t = make()
|
|
114
|
+
const loaded = await t.load({
|
|
115
|
+
"gtd.config.ts": config('import { brief } from "./brief.js"'),
|
|
116
|
+
"brief.ts": 'export const brief = "do the work"\n',
|
|
117
|
+
})
|
|
118
|
+
expect(loaded.fingerprint).toEqual({
|
|
119
|
+
"brief.ts": "c59960854e8032b7",
|
|
120
|
+
"gtd.config.ts": expect.stringMatching(/^[0-9a-f]{16}$/),
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it("leaves a specifier in a string alone, and loads a skipped require never", async () => {
|
|
125
|
+
t = make()
|
|
126
|
+
const loaded = await t.load({
|
|
127
|
+
"gtd.config.ts": config(
|
|
128
|
+
[
|
|
129
|
+
"const brief = \"look from './word.js'\"",
|
|
130
|
+
'if (brief === "") require("./optional.js")',
|
|
131
|
+
].join("\n"),
|
|
132
|
+
),
|
|
133
|
+
"word.ts": "export const word = 1\n",
|
|
134
|
+
"optional.ts": 'throw new Error("optional ran")\n',
|
|
135
|
+
})
|
|
136
|
+
expect(loaded.names).toEqual(["named"])
|
|
137
|
+
expect(Object.keys(loaded.fingerprint)).toEqual(["gtd.config.ts"])
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it("fails on an import that is not there, naming it", async () => {
|
|
141
|
+
t = make()
|
|
142
|
+
await expect(
|
|
143
|
+
t.load({ "gtd.config.ts": config('import { brief } from "./missing.js"') }),
|
|
144
|
+
).rejects.toThrow(/Cannot find module '\.\/missing\.js'/)
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
}
|
|
@@ -6,6 +6,11 @@ import { Context, Effect, Layer } from "effect"
|
|
|
6
6
|
export interface WorkflowModule {
|
|
7
7
|
readonly filepath: string
|
|
8
8
|
readonly source: string
|
|
9
|
+
/**
|
|
10
|
+
* The modules the config imports, by absolute path, for a discovery with no
|
|
11
|
+
* disk behind it. Absent, they are read from disk like any module import.
|
|
12
|
+
*/
|
|
13
|
+
readonly read?: (path: string) => string | undefined
|
|
9
14
|
}
|
|
10
15
|
|
|
11
16
|
/** A leftover rc file, and whether the user running gtd owns it — and so can delete it. */
|
|
@@ -1,9 +1,24 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
mkdirSync,
|
|
3
|
+
mkdtempSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
realpathSync,
|
|
6
|
+
symlinkSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from "node:fs"
|
|
2
9
|
import { tmpdir } from "node:os"
|
|
3
10
|
import { join } from "node:path"
|
|
4
11
|
import { describe, expect, it } from "vitest"
|
|
5
12
|
import { changedConfigFiles, configFingerprint } from "./fingerprint.js"
|
|
6
13
|
|
|
14
|
+
const read = (path: string): string | undefined => {
|
|
15
|
+
try {
|
|
16
|
+
return readFileSync(path, "utf8")
|
|
17
|
+
} catch {
|
|
18
|
+
return undefined
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
7
22
|
describe("the config fingerprint", () => {
|
|
8
23
|
it("hashes each file under its root-relative path, skipping one that cannot be read", () => {
|
|
9
24
|
const root = mkdtempSync(join(tmpdir(), "gtd-fingerprint-"))
|
|
@@ -14,6 +29,7 @@ describe("the config fingerprint", () => {
|
|
|
14
29
|
const fingerprint = configFingerprint(
|
|
15
30
|
[join(root, "gtd.config.ts"), join(root, "lib", "flow.ts"), join(root, "gone.ts")],
|
|
16
31
|
root,
|
|
32
|
+
read,
|
|
17
33
|
)
|
|
18
34
|
|
|
19
35
|
expect(Object.keys(fingerprint).sort()).toEqual(["gtd.config.ts", "lib/flow.ts"])
|
|
@@ -22,7 +38,7 @@ describe("the config fingerprint", () => {
|
|
|
22
38
|
expect(
|
|
23
39
|
changedConfigFiles(
|
|
24
40
|
fingerprint,
|
|
25
|
-
configFingerprint([join(root, "gtd.config.ts"), join(root, "lib", "flow.ts")], root),
|
|
41
|
+
configFingerprint([join(root, "gtd.config.ts"), join(root, "lib", "flow.ts")], root, read),
|
|
26
42
|
),
|
|
27
43
|
).toEqual(["lib/flow.ts"])
|
|
28
44
|
})
|
|
@@ -34,7 +50,7 @@ describe("the config fingerprint", () => {
|
|
|
34
50
|
symlinkSync(real, link)
|
|
35
51
|
|
|
36
52
|
expect(
|
|
37
|
-
Object.keys(configFingerprint([join(realpathSync(real), "gtd.config.ts")], link)),
|
|
53
|
+
Object.keys(configFingerprint([join(realpathSync(real), "gtd.config.ts")], link, read)),
|
|
38
54
|
).toEqual(["gtd.config.ts"])
|
|
39
55
|
})
|
|
40
56
|
|
|
@@ -42,7 +58,7 @@ describe("the config fingerprint", () => {
|
|
|
42
58
|
const root = mkdtempSync(join(tmpdir(), "gtd-fingerprint-"))
|
|
43
59
|
writeFileSync(join(root, "a\x1b.ts"), "export const a = 1\n")
|
|
44
60
|
|
|
45
|
-
expect(configFingerprint([join(root, "a\x1b.ts")], root)).toEqual({})
|
|
61
|
+
expect(configFingerprint([join(root, "a\x1b.ts")], root, read)).toEqual({})
|
|
46
62
|
})
|
|
47
63
|
|
|
48
64
|
it("names a file only one side has as changed", () => {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto"
|
|
2
|
-
import {
|
|
2
|
+
import { realpathSync } from "node:fs"
|
|
3
3
|
import { relative, sep } from "node:path"
|
|
4
4
|
|
|
5
5
|
/** Path (relative to `root`) → content hash of every file a config load touched. */
|
|
@@ -27,19 +27,18 @@ const realOr = (path: string): string => {
|
|
|
27
27
|
// feeds a warning, and the load that listed the file already read it.
|
|
28
28
|
// Paths are real paths: a loader may report `/private/var/…` for a root
|
|
29
29
|
// reached through the `/var` symlink, and the two must still relate.
|
|
30
|
-
export const configFingerprint = (
|
|
30
|
+
export const configFingerprint = (
|
|
31
|
+
files: readonly string[],
|
|
32
|
+
root: string,
|
|
33
|
+
read: (path: string) => string | undefined,
|
|
34
|
+
): ConfigFingerprint => {
|
|
31
35
|
const fingerprint: Record<string, string> = {}
|
|
32
36
|
const realRoot = realOr(root)
|
|
33
37
|
for (const file of files) {
|
|
34
38
|
const path = relative(realRoot, realOr(file)).split(sep).join("/")
|
|
35
39
|
if (CONTROL_CHARACTER.test(path)) continue
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
content = readFileSync(file)
|
|
39
|
-
} catch {
|
|
40
|
-
continue
|
|
41
|
-
}
|
|
42
|
-
fingerprint[path] = contentHash(content)
|
|
40
|
+
const content = read(file)
|
|
41
|
+
if (content !== undefined) fingerprint[path] = contentHash(content)
|
|
43
42
|
}
|
|
44
43
|
return fingerprint
|
|
45
44
|
}
|
package/src/workflow/index.ts
CHANGED
|
@@ -25,9 +25,4 @@ export {
|
|
|
25
25
|
type StateMode,
|
|
26
26
|
type StateName,
|
|
27
27
|
} from "./Workflow.js"
|
|
28
|
-
export {
|
|
29
|
-
changedConfigFiles,
|
|
30
|
-
configFingerprint,
|
|
31
|
-
contentHash,
|
|
32
|
-
type ConfigFingerprint,
|
|
33
|
-
} from "./fingerprint.js"
|
|
28
|
+
export { changedConfigFiles, contentHash, type ConfigFingerprint } from "./fingerprint.js"
|
|
@@ -1,13 +1,5 @@
|
|
|
1
1
|
import { execSync } from "node:child_process"
|
|
2
|
-
import {
|
|
3
|
-
existsSync,
|
|
4
|
-
readFileSync,
|
|
5
|
-
mkdtempSync,
|
|
6
|
-
realpathSync,
|
|
7
|
-
rmSync,
|
|
8
|
-
writeFileSync,
|
|
9
|
-
mkdirSync,
|
|
10
|
-
} from "node:fs"
|
|
2
|
+
import { existsSync, readFileSync, mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs"
|
|
11
3
|
import { join } from "node:path"
|
|
12
4
|
import { tmpdir } from "node:os"
|
|
13
5
|
import { afterEach, beforeEach, describe, expect, it } from "vitest"
|
|
@@ -310,7 +302,7 @@ describe("ConfigService", () => {
|
|
|
310
302
|
expect(cfg.workflow.steering).toEqual({ ".gtd/PLAN.md": "qa" })
|
|
311
303
|
})
|
|
312
304
|
|
|
313
|
-
it("
|
|
305
|
+
it("the fingerprint covers gtd.config.ts AND a sibling module it imports from", async () => {
|
|
314
306
|
writeFileSync(
|
|
315
307
|
join(projectDir, "steps.ts"),
|
|
316
308
|
[`export const steering = { ".gtd/PLAN.md": "qa" }`, ``].join("\n"),
|
|
@@ -323,19 +315,13 @@ describe("ConfigService", () => {
|
|
|
323
315
|
const cfg = await getConfig()
|
|
324
316
|
|
|
325
317
|
expect(cfg.workflow.steering[".gtd/PLAN.md"]).toBe("qa")
|
|
326
|
-
expect(cfg.
|
|
327
|
-
expect.arrayContaining([
|
|
328
|
-
realpathSync(join(projectDir, "gtd.config.ts")),
|
|
329
|
-
realpathSync(join(projectDir, "steps.ts")),
|
|
330
|
-
]),
|
|
331
|
-
)
|
|
318
|
+
expect(Object.keys(cfg.fingerprint).sort()).toEqual(["gtd.config.ts", "steps.ts"])
|
|
332
319
|
})
|
|
333
320
|
|
|
334
|
-
it("
|
|
321
|
+
it("the fingerprint covers gtd.config.ts but no shipped module it imports", async () => {
|
|
335
322
|
const cfg = await getConfig()
|
|
336
323
|
|
|
337
|
-
expect(cfg.
|
|
338
|
-
expect(cfg.workflowFiles[0]).toMatch(/\/gtd\.config\.ts$/)
|
|
324
|
+
expect(Object.keys(cfg.fingerprint)).toEqual(["gtd.config.ts"])
|
|
339
325
|
})
|
|
340
326
|
|
|
341
327
|
it.each([
|
package/src/workflow/load.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { Host, Workspace, type WorkspaceOps } from "../platform/index.js"
|
|
|
12
12
|
import { compileConfig, type CompiledConfig } from "./compile.js"
|
|
13
13
|
import { SETTING_NAME_RULE, isSettingName } from "./vars.js"
|
|
14
14
|
import { ConfigDiscovery, WORKFLOW_MODULE, type WorkflowModule } from "./discovery.js"
|
|
15
|
+
import { configFingerprint, type ConfigFingerprint } from "./fingerprint.js"
|
|
15
16
|
import {
|
|
16
17
|
dedupeDiagnostics,
|
|
17
18
|
formatDiagnostic,
|
|
@@ -41,8 +42,8 @@ export interface ConfigOperations {
|
|
|
41
42
|
readonly ui?: flows.UiSettings
|
|
42
43
|
/** Non-fatal findings, formatted through `formatDiagnostic` like an error. */
|
|
43
44
|
readonly warnings: readonly Diagnostic[]
|
|
44
|
-
/**
|
|
45
|
-
readonly
|
|
45
|
+
/** The content hash of every file `gtd.config.ts`'s evaluation touched — itself plus any relative import — by path relative to the root. */
|
|
46
|
+
readonly fingerprint: ConfigFingerprint
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
// Everything lives in `message` (not `GtdError.detail`) — `renderFailure`
|
|
@@ -175,10 +176,22 @@ export const load: Effect.Effect<
|
|
|
175
176
|
},
|
|
176
177
|
...(compiled.ui !== undefined ? { ui: compiled.ui } : {}),
|
|
177
178
|
warnings: diagnostics.filter((d) => d.severity === "warning"),
|
|
178
|
-
|
|
179
|
+
fingerprint: configFingerprint(
|
|
180
|
+
loaded.file.dependencyFiles,
|
|
181
|
+
host.root,
|
|
182
|
+
module.read ?? readFromDisk,
|
|
183
|
+
),
|
|
179
184
|
}
|
|
180
185
|
})
|
|
181
186
|
|
|
187
|
+
const readFromDisk = (path: string): string | undefined => {
|
|
188
|
+
try {
|
|
189
|
+
return readFileSync(path, "utf8")
|
|
190
|
+
} catch {
|
|
191
|
+
return undefined
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
182
195
|
interface Settings {
|
|
183
196
|
readonly defaults: Readonly<Record<string, string>>
|
|
184
197
|
readonly envDefaults: Readonly<Record<string, string>>
|
|
@@ -253,7 +266,14 @@ const memoized = (transform: Transform): Transform => {
|
|
|
253
266
|
|
|
254
267
|
let jitiModule: JitiModule | undefined
|
|
255
268
|
let transform: Transform | undefined
|
|
256
|
-
|
|
269
|
+
/** Relative specifiers a module imports, served from somewhere other than the disk. */
|
|
270
|
+
interface LocalModules {
|
|
271
|
+
/** The path a specifier names, or nothing when it names no module. */
|
|
272
|
+
readonly resolve: (specifier: string) => string | undefined
|
|
273
|
+
readonly load: (path: string) => unknown
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const jiti = (shipped: ShippedModules, local?: LocalModules): JitiInstance => {
|
|
257
277
|
const require = createRequire(import.meta.url)
|
|
258
278
|
jitiModule ??= require("jiti") as JitiModule
|
|
259
279
|
// jiti's own Babel transform, which its package exports don't name.
|
|
@@ -261,7 +281,16 @@ const jiti = (shipped: ShippedModules): JitiInstance => {
|
|
|
261
281
|
require(join(dirname(require.resolve("jiti/package.json")), "dist", "babel.cjs")) as Transform,
|
|
262
282
|
)
|
|
263
283
|
return jitiModule.createJiti(import.meta.url, {
|
|
264
|
-
|
|
284
|
+
// Consulted when a module is required, so a local module is evaluated
|
|
285
|
+
// only once an import actually runs.
|
|
286
|
+
virtualModules: new Proxy(shipped.modules, {
|
|
287
|
+
has: (modules, id) =>
|
|
288
|
+
id in modules || (typeof id === "string" && local?.resolve(id) !== undefined),
|
|
289
|
+
get: (modules, id) => {
|
|
290
|
+
const path = typeof id === "string" ? local?.resolve(id) : undefined
|
|
291
|
+
return path === undefined ? modules[id as string] : local!.load(path)
|
|
292
|
+
},
|
|
293
|
+
}),
|
|
265
294
|
alias: shipped.missing,
|
|
266
295
|
// No transpile cache on disk, and a fresh module every load — nothing a
|
|
267
296
|
// later command could read back instead of the source.
|
|
@@ -572,11 +601,12 @@ const namespaceOf = (exported: unknown): Record<string, unknown> =>
|
|
|
572
601
|
* pulled in, transitively.
|
|
573
602
|
*/
|
|
574
603
|
const loadWorkflows = (module: WorkflowModule, shipped: ShippedModules): Loaded => {
|
|
575
|
-
const
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
const
|
|
604
|
+
const { exports: evaluated, files } =
|
|
605
|
+
module.read === undefined
|
|
606
|
+
? evaluateFromDisk(module, shipped)
|
|
607
|
+
: evaluateRead(module, module.read, shipped)
|
|
608
|
+
const exports = namespaceOf(evaluated)
|
|
609
|
+
const file = fileFrom(exports, module.filepath, files)
|
|
580
610
|
const exported = Object.entries(exports).filter(
|
|
581
611
|
([name, value]) => !RESERVED_EXPORTS.has(name) && isWorkflow(value),
|
|
582
612
|
)
|
|
@@ -611,6 +641,60 @@ const loadWorkflows = (module: WorkflowModule, shipped: ShippedModules): Loaded
|
|
|
611
641
|
}
|
|
612
642
|
}
|
|
613
643
|
|
|
644
|
+
const evaluateFromDisk = (
|
|
645
|
+
module: WorkflowModule,
|
|
646
|
+
shipped: ShippedModules,
|
|
647
|
+
): { readonly exports: unknown; readonly files: readonly string[] } => {
|
|
648
|
+
const cache: Record<string, unknown> = {}
|
|
649
|
+
const exports = jiti(shipped).evalModule(module.source, { filename: module.filepath, cache })
|
|
650
|
+
return { exports, files: Object.keys(cache) }
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// How jiti finds a TypeScript module behind a specifier: as written, its
|
|
654
|
+
// `.js` spelling as `.ts`, with `.ts` appended, or as a directory index.
|
|
655
|
+
const moduleCandidates = (path: string): readonly string[] => [
|
|
656
|
+
path,
|
|
657
|
+
path.replace(/\.js$/, ".ts"),
|
|
658
|
+
path.replace(/\.mjs$/, ".mts"),
|
|
659
|
+
`${path}.ts`,
|
|
660
|
+
join(path, "index.ts"),
|
|
661
|
+
]
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Evaluates the config with every local module it imports taken from `read`
|
|
665
|
+
* instead of the disk. jiti matches a virtual module on the specifier as
|
|
666
|
+
* written, so each module gets its own instance resolving against its own
|
|
667
|
+
* directory. A specifier `read` cannot serve falls through to the disk, and
|
|
668
|
+
* fails the way a missing file does.
|
|
669
|
+
*/
|
|
670
|
+
const evaluateRead = (
|
|
671
|
+
module: WorkflowModule,
|
|
672
|
+
read: (path: string) => string | undefined,
|
|
673
|
+
shipped: ShippedModules,
|
|
674
|
+
): { readonly exports: unknown; readonly files: readonly string[] } => {
|
|
675
|
+
const files = [module.filepath]
|
|
676
|
+
const evaluated = new Map<string, unknown>()
|
|
677
|
+
const evaluate = (filepath: string, source: string): unknown =>
|
|
678
|
+
jiti(shipped, {
|
|
679
|
+
resolve: (specifier) =>
|
|
680
|
+
/^\.\.?\//.test(specifier)
|
|
681
|
+
? moduleCandidates(join(dirname(filepath), specifier)).find(
|
|
682
|
+
(candidate) => read(candidate) !== undefined,
|
|
683
|
+
)
|
|
684
|
+
: undefined,
|
|
685
|
+
load: (path) => {
|
|
686
|
+
if (!evaluated.has(path)) {
|
|
687
|
+
// A placeholder first, so an import cycle ends here.
|
|
688
|
+
evaluated.set(path, {})
|
|
689
|
+
files.push(path)
|
|
690
|
+
evaluated.set(path, evaluate(path, read(path)!))
|
|
691
|
+
}
|
|
692
|
+
return evaluated.get(path)
|
|
693
|
+
},
|
|
694
|
+
}).evalModule(source, { filename: filepath })
|
|
695
|
+
return { exports: evaluate(module.filepath, module.source), files }
|
|
696
|
+
}
|
|
697
|
+
|
|
614
698
|
const isWorkflow = (value: unknown): boolean =>
|
|
615
699
|
typeof value === "function" || flows.isFactoryWorkflow(value)
|
|
616
700
|
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest"
|
|
2
|
+
|
|
3
|
+
const state = vi.hoisted(() => ({ feedback: undefined as string | undefined, head: "h1" }))
|
|
4
|
+
|
|
5
|
+
vi.mock("../flows/index.js", () => ({
|
|
6
|
+
changes: () => [],
|
|
7
|
+
changesSince: () => [],
|
|
8
|
+
hasThreadFor: () => false,
|
|
9
|
+
head: () => state.head,
|
|
10
|
+
read: () => state.feedback,
|
|
11
|
+
refuse: (m: string) => {
|
|
12
|
+
throw new Error(m)
|
|
13
|
+
},
|
|
14
|
+
requireReplies: () => {},
|
|
15
|
+
requireThreadsClosed: () => {},
|
|
16
|
+
}))
|
|
17
|
+
|
|
18
|
+
import { routeNotes, type ReviewPolicy, type Verdict } from "./review.js"
|
|
19
|
+
import type { ReviewNote } from "../steering/index.js"
|
|
20
|
+
|
|
21
|
+
const note = (id: string) => ({ id }) as unknown as ReviewNote
|
|
22
|
+
|
|
23
|
+
const setup = (verdicts: Record<string, Verdict>) => {
|
|
24
|
+
const calls: string[] = []
|
|
25
|
+
const deferred: string[][] = []
|
|
26
|
+
const collected: ReviewNote[][] = []
|
|
27
|
+
const p = {
|
|
28
|
+
file: "r.md",
|
|
29
|
+
feedback: "f.md",
|
|
30
|
+
triage: async () => new Map(Object.entries(verdicts)),
|
|
31
|
+
answerQuestions: async () => void calls.push("answer"),
|
|
32
|
+
fixNits: async () => void calls.push("nits"),
|
|
33
|
+
keepGreen: async () => void calls.push("green"),
|
|
34
|
+
collecting: async () => {
|
|
35
|
+
calls.push("collect")
|
|
36
|
+
state.feedback = "x"
|
|
37
|
+
},
|
|
38
|
+
captures: {
|
|
39
|
+
editNotes: (_r: string, edits: readonly ReviewNote[]) => {
|
|
40
|
+
collected.push([...edits])
|
|
41
|
+
return "cap"
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
defer: async (ns: readonly ReviewNote[]) => {
|
|
45
|
+
calls.push("defer")
|
|
46
|
+
deferred.push(ns.map((n) => n.id))
|
|
47
|
+
},
|
|
48
|
+
} as unknown as ReviewPolicy
|
|
49
|
+
const r = {
|
|
50
|
+
round: "r1",
|
|
51
|
+
close: async () => void calls.push("close"),
|
|
52
|
+
outcome: (verdict: "signoff" | "feedback") => ({ verdict }) as never,
|
|
53
|
+
unfolded: async () => ({ verdict: "signoff" }) as never,
|
|
54
|
+
}
|
|
55
|
+
return { p, r, calls, deferred, collected }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
state.feedback = undefined
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
describe("routeNotes defer", () => {
|
|
63
|
+
it("defer-only round awaits again, no collecting turn", async () => {
|
|
64
|
+
const t = setup({ a: "defer", b: "defer" })
|
|
65
|
+
const res = await routeNotes(t.p, [note("a"), note("b")], t.r)
|
|
66
|
+
expect(res).toEqual({ next: "await", reviewed: "r1" })
|
|
67
|
+
expect(t.deferred).toEqual([["a", "b"]])
|
|
68
|
+
expect(t.calls).toEqual(["defer"])
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it("defer + edit collects only the edits and ends feedback", async () => {
|
|
72
|
+
const t = setup({ a: "defer", b: "edit" })
|
|
73
|
+
const res = await routeNotes(t.p, [note("a"), note("b")], t.r)
|
|
74
|
+
expect(res).toEqual({ verdict: "feedback" })
|
|
75
|
+
expect(t.calls).toEqual(["defer", "close", "collect"])
|
|
76
|
+
expect(t.collected[0]!.map((n) => n.id)).toEqual(["b"])
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it("runs after nit fixes and before the collecting turn", async () => {
|
|
80
|
+
const t = setup({ a: "nit", b: "defer", c: "edit" })
|
|
81
|
+
await routeNotes(t.p, [note("a"), note("b"), note("c")], t.r)
|
|
82
|
+
expect(t.calls).toEqual(["nits", "green", "defer", "close", "collect"])
|
|
83
|
+
})
|
|
84
|
+
})
|
|
@@ -24,7 +24,7 @@ export type ReviewOutcome =
|
|
|
24
24
|
readonly edited: readonly Change[]
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
export type Verdict = "edit" | "question" | "nit" | "praise"
|
|
27
|
+
export type Verdict = "edit" | "question" | "nit" | "praise" | "defer"
|
|
28
28
|
|
|
29
29
|
export type ReviewRisk = ReturnType<typeof reviewRisks>[number]
|
|
30
30
|
|
|
@@ -49,6 +49,8 @@ export interface ReviewPolicy {
|
|
|
49
49
|
readonly triage: (notes: readonly ReviewNote[]) => Promise<ReadonlyMap<string, Verdict>>
|
|
50
50
|
readonly answerQuestions: (notes: readonly ReviewNote[]) => Promise<void>
|
|
51
51
|
readonly fixNits: (notes: readonly ReviewNote[]) => Promise<void>
|
|
52
|
+
/** Leave `defer` notes out of the round. */
|
|
53
|
+
readonly defer: (notes: readonly ReviewNote[]) => Promise<void>
|
|
52
54
|
/** Remove `file` at the end of a round at commit `round`. */
|
|
53
55
|
readonly close: (round: string) => Promise<void>
|
|
54
56
|
/** What a collecting turn reads. */
|
|
@@ -127,16 +129,16 @@ interface Round {
|
|
|
127
129
|
readonly unfolded: () => Promise<Finish>
|
|
128
130
|
}
|
|
129
131
|
|
|
130
|
-
/** Judge every note, then run each verdict's route: answers, then nit fixes, then the edits' lap. */
|
|
131
|
-
const routeNotes = async (
|
|
132
|
+
/** Judge every note, then run each verdict's route: answers, then nit fixes, then deferrals, then the edits' lap. */
|
|
133
|
+
export const routeNotes = async (
|
|
132
134
|
p: ReviewPolicy,
|
|
133
135
|
notes: readonly ReviewNote[],
|
|
134
136
|
r: Round,
|
|
135
137
|
): Promise<Finish> => {
|
|
136
138
|
const verdicts = await p.triage(notes)
|
|
137
139
|
const of = (v: Verdict): ReviewNote[] => notes.filter((n) => (verdicts.get(n.id) ?? "edit") === v)
|
|
138
|
-
const [edits, questions, nits] = [of("edit"), of("question"), of("nit")]
|
|
139
|
-
if (edits.length + questions.length + nits.length === 0) return r.unfolded()
|
|
140
|
+
const [edits, questions, nits, deferred] = [of("edit"), of("question"), of("nit"), of("defer")]
|
|
141
|
+
if (edits.length + questions.length + nits.length + deferred.length === 0) return r.unfolded()
|
|
140
142
|
|
|
141
143
|
let answeredAt: string | undefined
|
|
142
144
|
if (questions.length > 0) {
|
|
@@ -147,6 +149,7 @@ const routeNotes = async (
|
|
|
147
149
|
await p.fixNits(nits)
|
|
148
150
|
await p.keepGreen()
|
|
149
151
|
}
|
|
152
|
+
if (deferred.length > 0) await p.defer(deferred)
|
|
150
153
|
if (edits.length > 0) {
|
|
151
154
|
await r.close()
|
|
152
155
|
return r.outcome(await collect(p, p.captures.editNotes(r.round, edits, answeredAt)))
|
|
@@ -155,7 +158,7 @@ const routeNotes = async (
|
|
|
155
158
|
await r.close()
|
|
156
159
|
return { next: "rereview", carry: answeredAt }
|
|
157
160
|
}
|
|
158
|
-
return { next: "await", reviewed: answeredAt
|
|
161
|
+
return { next: "await", reviewed: answeredAt ?? r.round }
|
|
159
162
|
}
|
|
160
163
|
|
|
161
164
|
/** A code edit, not a thread-only change: lines that only add, answer or remove code threads don't count. */
|