@pmelab/gtd 58.0.0 → 58.2.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 +3 -2
- package/dist/chunks/{Schema-C6fLlQzx.mjs → Schema-B3o6eRNU.mjs} +637 -3957
- package/dist/chunks/{reserved-BzAFXuz_.mjs → reserved-lMYCaapY.mjs} +1 -1
- package/dist/chunks/{review-CVqRS0mc.mjs → review-Bfaror38.mjs} +2 -2
- package/dist/chunks/{tail-JQzB21Q3.mjs → tail-Bd7L940p.mjs} +1 -1
- package/dist/chunks/testkit-JDtpNKyi.mjs +10410 -0
- package/dist/gtd.bundle.mjs +8 -8
- package/dist/testkit/gherkin.mjs +283 -0
- package/dist/testkit.mjs +2 -15461
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +3 -3
- package/dist/workflows/fix.mjs +2 -2
- package/dist/workflows/review.mjs +3 -3
- package/dist/workflows/rollout.mjs +1 -1
- package/package.json +8 -1
- package/src/cli/Cli.test.ts +1 -1
- package/src/cli/History.test.ts +1 -1
- package/src/cli/Metrics.test.ts +1 -1
- package/src/cli/Program.test.ts +1 -1
- package/src/cli/Strays.test.ts +1 -1
- package/src/edge/Edge.test.ts +1 -1
- package/src/emit/Emit.ts +1 -1
- package/src/emit/GitScript.ts +1 -1
- package/src/emit/OutcomeScript.ts +1 -1
- package/src/gherkin/git-control.feature +78 -0
- package/src/{testkit/steps.ts → gherkin/index.ts} +55 -4
- package/src/gherkin/module-config.feature +24 -0
- package/src/gherkin/tiny.fixture.ts +8 -0
- package/src/platform/Git.test.ts +2 -2
- package/src/platform/RetainedHistory.test.ts +2 -2
- package/src/platform/Workspace.test.ts +2 -2
- package/src/platform/WorktreeState.test.ts +1 -1
- package/src/step/planStart.test.ts +1 -1
- package/src/testkit/TestRepo.test.ts +118 -2
- package/src/testkit/TestRepo.ts +64 -10
- package/src/testkit/index.ts +16 -9
- package/src/{testkit → twins}/EmittedScriptRecognizer.ts +12 -14
- package/src/{testkit → twins}/FlowScripts.ts +9 -8
- package/src/{testkit → twins}/InMemRepo.ts +26 -1
- package/src/{testkit → twins}/Layers.ts +14 -3
- package/src/twins/index.ts +13 -0
- package/src/workflow/discovery.test.ts +1 -1
- package/src/workflow/discovery.ts +3 -3
- package/src/workflow/load.ts +3 -3
- /package/src/{testkit → gherkin}/testkit.feature +0 -0
- /package/src/{testkit → twins}/EmittedScriptRecognizer.test.ts +0 -0
- /package/src/{testkit → twins}/EmittedScripts.test.ts +0 -0
- /package/src/{testkit → twins}/FakeGitOperations.ts +0 -0
- /package/src/{testkit → twins}/FlowScripts.test.ts +0 -0
- /package/src/{testkit → twins}/GitTiers.test.ts +0 -0
- /package/src/{testkit → twins}/GitTiers.ts +0 -0
- /package/src/{testkit → twins}/Layers.test.ts +0 -0
- /package/src/{testkit → twins}/StrictGitOperations.ts +0 -0
- /package/src/{testkit → twins}/cliIo.ts +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { agent, changes, glob, read, refuse, scope, start, vars } from "../flows.mjs";
|
|
2
|
-
import { It as registryEntry, Lt as requireFlag, Pt as flagReferences, St as envDefaults, a as flagShipped, i as skills, n as summary, r as access, t as steering, x as coder, xt as defaults } from "../chunks/reserved-
|
|
3
|
-
import { a as refusePendingHandOff, r as handOffTail } from "../chunks/tail-
|
|
2
|
+
import { It as registryEntry, Lt as requireFlag, Pt as flagReferences, St as envDefaults, a as flagShipped, i as skills, n as summary, r as access, t as steering, x as coder, xt as defaults } from "../chunks/reserved-lMYCaapY.mjs";
|
|
3
|
+
import { a as refusePendingHandOff, r as handOffTail } from "../chunks/tail-Bd7L940p.mjs";
|
|
4
4
|
//#region src/workflows/cleanup/cleanup.ts
|
|
5
5
|
const removingPrompt = (name, registry) => `Clean up the release flag "${name}", which is default-on: delete its entry in ${registry}, and remove every \`GTD_FLAG_${name.toUpperCase()}\` check from the shipped sources. Keep the flag-on path; delete code only the off path reached, including tests that pin the off path.`;
|
|
6
6
|
const cleanupFlow = async () => {
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { agent, answered, applyScript, changes, changesSince, check, codeChanges, env, hasThreadFor, head, human, judge, judgeDiffShape, kept, mark, matches, quote, read, refuse, removeScript, requireAnswers, requireProgress, requireReplies, requireThreadsClosed, restoreScript, revertScript, run, scope, start, vars, wrote } from "../flows.mjs";
|
|
2
2
|
import { lensEntriesOf } from "../workflow-kit.mjs";
|
|
3
|
-
import { B as designSystem, Ct as testFileGlobs, D as planner, F as clearCriteriaMessage, I as clearCriteriaQuestion, J as implementBuildingPrompt, L as criterionLine, M as buildReviewCollectingPrompt, P as builderSystem, Q as naiveTryMessage, R as designGateAnswerMessage, S as collecting, St as envDefaults, V as designTriagePrompt, X as naiveApplyFailure, Y as implementFixPrompt, Z as naivePassPrompt, _ as REQUIREMENTS, _t as unwindFailedMessage, at as refactorFixPrompt, d as healthy, et as normaliseCriteriaScript, f as FEEDBACK, g as REFACTOR_NOTE, gt as startGateBlockedMessage, h as REFACTOR, ht as seamSteerMessage, i as skills, it as refactorDroppedRefusal, j as agentWithSkills, l as gate, mt as seamByHandMessage, n as summary, o as naiveTestOn, ot as refactorNotePrompt, p as NAIVE_TEST, pt as reviewerSystem, q as idleMessage, r as access, rt as refactorBuildingPrompt, s as shipped, st as refactorRules, t as steering, vt as unwindFailure, w as fixNits, x as coder, xt as defaults, y as answerReviewQuestions, z as designImpactPrompt } from "../chunks/reserved-
|
|
4
|
-
import { a as refusePendingHandOff } from "../chunks/tail-
|
|
5
|
-
import { n as isCode$1, r as reUnwind, t as buildTail } from "../chunks/review-
|
|
3
|
+
import { B as designSystem, Ct as testFileGlobs, D as planner, F as clearCriteriaMessage, I as clearCriteriaQuestion, J as implementBuildingPrompt, L as criterionLine, M as buildReviewCollectingPrompt, P as builderSystem, Q as naiveTryMessage, R as designGateAnswerMessage, S as collecting, St as envDefaults, V as designTriagePrompt, X as naiveApplyFailure, Y as implementFixPrompt, Z as naivePassPrompt, _ as REQUIREMENTS, _t as unwindFailedMessage, at as refactorFixPrompt, d as healthy, et as normaliseCriteriaScript, f as FEEDBACK, g as REFACTOR_NOTE, gt as startGateBlockedMessage, h as REFACTOR, ht as seamSteerMessage, i as skills, it as refactorDroppedRefusal, j as agentWithSkills, l as gate, mt as seamByHandMessage, n as summary, o as naiveTestOn, ot as refactorNotePrompt, p as NAIVE_TEST, pt as reviewerSystem, q as idleMessage, r as access, rt as refactorBuildingPrompt, s as shipped, st as refactorRules, t as steering, vt as unwindFailure, w as fixNits, x as coder, xt as defaults, y as answerReviewQuestions, z as designImpactPrompt } from "../chunks/reserved-lMYCaapY.mjs";
|
|
4
|
+
import { a as refusePendingHandOff } from "../chunks/tail-Bd7L940p.mjs";
|
|
5
|
+
import { n as isCode$1, r as reUnwind, t as buildTail } from "../chunks/review-Bfaror38.mjs";
|
|
6
6
|
import { posix } from "node:path";
|
|
7
7
|
//#region src/workflows/shared/fences.ts
|
|
8
8
|
const markFenced = (text) => {
|
package/dist/workflows/fix.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { read, refuse, removeScript, run, scope, start } from "../flows.mjs";
|
|
2
2
|
import { b as reviewNotes, x as reviewRisks } from "../chunks/steering-CCNvngSH.mjs";
|
|
3
|
-
import { G as fixReviewNotesPrompt, St as envDefaults, W as finisherSystem, c as baseline, i as skills, j as agentWithSkills, n as summary, r as access, s as shipped, t as steering, v as REVIEW, x as coder, xt as defaults } from "../chunks/reserved-
|
|
4
|
-
import { r as handOffTail, t as REVIEW_ORIG } from "../chunks/tail-
|
|
3
|
+
import { G as fixReviewNotesPrompt, St as envDefaults, W as finisherSystem, c as baseline, i as skills, j as agentWithSkills, n as summary, r as access, s as shipped, t as steering, v as REVIEW, x as coder, xt as defaults } from "../chunks/reserved-lMYCaapY.mjs";
|
|
4
|
+
import { r as handOffTail, t as REVIEW_ORIG } from "../chunks/tail-Bd7L940p.mjs";
|
|
5
5
|
//#region src/workflows/fix/fix.ts
|
|
6
6
|
/**
|
|
7
7
|
* Fix every note a human left in a handed-off `.gtd/REVIEW.md` and every
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { read, refuse, removeScript, run, start } from "../flows.mjs";
|
|
2
|
-
import { St as envDefaults, W as finisherSystem, _ as REQUIREMENTS, dt as reviewGateBlockedMessage, i as skills, j as agentWithSkills, l as gate, n as summary, r as access, s as shipped, t as steering, ut as reviewFeedbackFixPrompt, x as coder, xt as defaults } from "../chunks/reserved-
|
|
3
|
-
import { a as refusePendingHandOff } from "../chunks/tail-
|
|
4
|
-
import { r as reUnwind, t as buildTail } from "../chunks/review-
|
|
2
|
+
import { St as envDefaults, W as finisherSystem, _ as REQUIREMENTS, dt as reviewGateBlockedMessage, i as skills, j as agentWithSkills, l as gate, n as summary, r as access, s as shipped, t as steering, ut as reviewFeedbackFixPrompt, x as coder, xt as defaults } from "../chunks/reserved-lMYCaapY.mjs";
|
|
3
|
+
import { a as refusePendingHandOff } from "../chunks/tail-Bd7L940p.mjs";
|
|
4
|
+
import { r as reUnwind, t as buildTail } from "../chunks/review-Bfaror38.mjs";
|
|
5
5
|
//#region src/workflows/review/review.ts
|
|
6
6
|
const fixFeedback = async () => {
|
|
7
7
|
await agentWithSkills("feedback.fixing", reviewFeedbackFixPrompt(), {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { agent, changesSince, glob, head, mark, read, refuse, removeScript, run, scope, vars } from "../flows.mjs";
|
|
2
|
-
import { Ft as flipProblems, Lt as requireFlag, Nt as flagMentions, St as envDefaults, a as flagShipped, f as FEEDBACK, i as skills, l as gate, n as summary, r as access, t as steering, x as coder, xt as defaults } from "../chunks/reserved-
|
|
2
|
+
import { Ft as flipProblems, Lt as requireFlag, Nt as flagMentions, St as envDefaults, a as flagShipped, f as FEEDBACK, i as skills, l as gate, n as summary, r as access, t as steering, x as coder, xt as defaults } from "../chunks/reserved-lMYCaapY.mjs";
|
|
3
3
|
//#region src/workflows/rollout/rollout.ts
|
|
4
4
|
const flippingPrompt = (name, registry) => `Roll out the release flag "${name}": in ${registry}, set \`defaultOn: true\` on its entry. Change nothing else — no other entry, no other field, no other file, no comments.`;
|
|
5
5
|
const suiteBlockedMessage = () => "The suite is red after the rollout. Fix it by hand — a rollout may only touch the flag's registry entry — then land.";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pmelab/gtd",
|
|
3
|
-
"version": "58.
|
|
3
|
+
"version": "58.2.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Git-aware CLI that emits the next prompt for an autonomous coding agent based on the current repository state",
|
|
6
6
|
"bin": {
|
|
@@ -66,6 +66,11 @@
|
|
|
66
66
|
"types": "./src/testkit/index.ts",
|
|
67
67
|
"source": "./src/testkit/index.ts",
|
|
68
68
|
"default": "./dist/testkit.mjs"
|
|
69
|
+
},
|
|
70
|
+
"./testkit/gherkin": {
|
|
71
|
+
"types": "./src/gherkin/index.ts",
|
|
72
|
+
"source": "./src/gherkin/index.ts",
|
|
73
|
+
"default": "./dist/testkit/gherkin.mjs"
|
|
69
74
|
}
|
|
70
75
|
},
|
|
71
76
|
"files": [
|
|
@@ -76,6 +81,8 @@
|
|
|
76
81
|
"src/wire/",
|
|
77
82
|
"src/steering/",
|
|
78
83
|
"src/testkit/",
|
|
84
|
+
"src/twins/",
|
|
85
|
+
"src/gherkin/",
|
|
79
86
|
"src/cli/",
|
|
80
87
|
"src/edge/",
|
|
81
88
|
"src/emit/",
|
package/src/cli/Cli.test.ts
CHANGED
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
import { EXIT_CODES, EXIT_RUNTIME_ERROR, EXIT_USAGE_ERROR } from "../wire/index.js"
|
|
26
26
|
import { Host } from "../platform/index.js"
|
|
27
27
|
import { type CommandRequirements } from "./Cli.js"
|
|
28
|
-
import { InMemRepo, makeCapturingCliIo, testLayers } from "../
|
|
28
|
+
import { InMemRepo, makeCapturingCliIo, testLayers } from "../twins/index.js"
|
|
29
29
|
import { CONFIG_SCAFFOLD } from "../install/index.js"
|
|
30
30
|
|
|
31
31
|
const FLAG_NAMES = [
|
package/src/cli/History.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Effect, Exit } from "effect"
|
|
2
2
|
import { describe, expect, it } from "vitest"
|
|
3
|
-
import { applyEmittedScript, InMemRepo, makeCapturingCliIo, testLayers } from "../
|
|
3
|
+
import { applyEmittedScript, InMemRepo, makeCapturingCliIo, testLayers } from "../twins/index.js"
|
|
4
4
|
import { runCli } from "./index.js"
|
|
5
5
|
import { historyEntries, resolveHistoryRef, type HistoryEntry } from "./History.js"
|
|
6
6
|
|
package/src/cli/Metrics.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Effect } from "effect"
|
|
2
2
|
import { describe, expect, it } from "vitest"
|
|
3
|
-
import { applyEmittedScript, InMemRepo, makeCapturingCliIo, testLayers } from "../
|
|
3
|
+
import { applyEmittedScript, InMemRepo, makeCapturingCliIo, testLayers } from "../twins/index.js"
|
|
4
4
|
import { runCli } from "./index.js"
|
|
5
5
|
import { processMetrics, readProcessMetrics, totalMetrics, type MetricCommit } from "./Metrics.js"
|
|
6
6
|
|
package/src/cli/Program.test.ts
CHANGED
|
@@ -18,7 +18,7 @@ vi.mock("node:os", async (importOriginal) => ({
|
|
|
18
18
|
import { runCli, type Command } from "./index.js"
|
|
19
19
|
import { EXIT_USAGE_ERROR, stallDiagnosis, noopText, WIRE_SCHEMA } from "../wire/index.js"
|
|
20
20
|
import { needsOf, runCommand, SelectorUsageError } from "./Program.js"
|
|
21
|
-
import { InMemRepo, makeCapturingCliIo, testLayers, applyEmittedScript } from "../
|
|
21
|
+
import { InMemRepo, makeCapturingCliIo, testLayers, applyEmittedScript } from "../twins/index.js"
|
|
22
22
|
import {
|
|
23
23
|
commitAll,
|
|
24
24
|
DID_NOT_RUN_COMMENT,
|
package/src/cli/Strays.test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Effect } from "effect"
|
|
2
2
|
import { describe, expect, it } from "vitest"
|
|
3
3
|
import { EXIT_OK, EXIT_RUNTIME_ERROR, EXIT_STRAYS_FOUND } from "../wire/index.js"
|
|
4
|
-
import { InMemRepo, makeCapturingCliIo } from "../
|
|
4
|
+
import { InMemRepo, makeCapturingCliIo } from "../twins/index.js"
|
|
5
5
|
import { runCli } from "./index.js"
|
|
6
6
|
|
|
7
7
|
const seed = (): InMemRepo => {
|
package/src/edge/Edge.test.ts
CHANGED
package/src/emit/Emit.ts
CHANGED
|
@@ -110,7 +110,7 @@ export const emitScripts = (
|
|
|
110
110
|
optional: assembleScript(optional),
|
|
111
111
|
})
|
|
112
112
|
|
|
113
|
-
/** Exported so `src/
|
|
113
|
+
/** Exported so `src/twins/EmittedScriptRecognizer.ts` can recognize these by exact string comparison. `printf`, not `echo`, avoids shell-specific backslash-escape quirks. */
|
|
114
114
|
export const DID_NOT_RUN_COMMENT =
|
|
115
115
|
"# gtd emitted this and did NOT run it — pipe it into `sh` to land the turn"
|
|
116
116
|
|
package/src/emit/GitScript.ts
CHANGED
|
@@ -130,7 +130,7 @@ export const fetchHistoryRefs = (remote: string): string =>
|
|
|
130
130
|
// No test asserts the two stay byte-identical; a drift between them is a
|
|
131
131
|
// review-time risk this boundary accepts.
|
|
132
132
|
|
|
133
|
-
/** Marks an emitted block as print-only — mirrors `OutcomeScript.ts`'s `OUTCOME_MARKER` verbatim, so `src/
|
|
133
|
+
/** Marks an emitted block as print-only — mirrors `OutcomeScript.ts`'s `OUTCOME_MARKER` verbatim, so `src/twins/EmittedScriptRecognizer.ts` recognizes either path's output identically. */
|
|
134
134
|
const OUTCOME_MARKER = "# gtd: outcome (print-only)"
|
|
135
135
|
|
|
136
136
|
const printfLine = (fmt: string, args: readonly string[]): string =>
|
|
@@ -19,7 +19,7 @@ const printfLine = (fmt: string, args: readonly string[]): string =>
|
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* Marks an emitted block as print-only. Load-bearing beyond documentation:
|
|
22
|
-
* `src/
|
|
22
|
+
* `src/twins/EmittedScriptRecognizer.ts` recognizes an outcome block by
|
|
23
23
|
* this line, and a bare `printf` would be ambiguous with a workflow
|
|
24
24
|
* `script:` command that happens to start the same way.
|
|
25
25
|
*/
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
@inmem
|
|
2
|
+
Feature: a scenario controls the commands and the history a workflow sees
|
|
3
|
+
|
|
4
|
+
A step's script may run programs the in-memory repository has none of: a
|
|
5
|
+
canned command stands in for each, matched by the command as the script
|
|
6
|
+
spells it. Branches and refs are set up before the process starts, so a
|
|
7
|
+
workflow that reads another branch or a remote ref finds them.
|
|
8
|
+
|
|
9
|
+
Background:
|
|
10
|
+
Given a gtd config with:
|
|
11
|
+
"""
|
|
12
|
+
import { check, human, run, showScript } from "@pmelab/gtd/flows"
|
|
13
|
+
|
|
14
|
+
export const tested = async () => {
|
|
15
|
+
const green = await check("test", "npm test", { report: ".gtd/FEEDBACK.md" })
|
|
16
|
+
await human(green ? "sign-off" : "red")
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const restored = async () => {
|
|
20
|
+
await run("restore", showScript("origin/main", "a.txt", "a.txt"))
|
|
21
|
+
await human("done")
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export default tested
|
|
25
|
+
"""
|
|
26
|
+
And a file "a.txt" with:
|
|
27
|
+
"""
|
|
28
|
+
base
|
|
29
|
+
"""
|
|
30
|
+
And the working tree is committed as "chore: base"
|
|
31
|
+
|
|
32
|
+
Scenario: a red canned command writes its output to the check's report
|
|
33
|
+
Given the command "npm test" exits 1 with:
|
|
34
|
+
"""
|
|
35
|
+
1 failing
|
|
36
|
+
"""
|
|
37
|
+
And step "test" runs its script
|
|
38
|
+
When gtd starts workflow "tested"
|
|
39
|
+
And the process is driven
|
|
40
|
+
Then the process rests at "red"
|
|
41
|
+
And ".gtd/FEEDBACK.md" contains "1 failing"
|
|
42
|
+
|
|
43
|
+
Scenario: a green canned command passes the check
|
|
44
|
+
Given the command "npm test" exits 0
|
|
45
|
+
And step "test" runs its script
|
|
46
|
+
When gtd starts workflow "tested"
|
|
47
|
+
And the process is driven
|
|
48
|
+
Then the process rests at "sign-off"
|
|
49
|
+
|
|
50
|
+
Scenario: a command no canned command matches fails the drive, naming it
|
|
51
|
+
Given step "test" runs its script
|
|
52
|
+
When gtd starts workflow "tested"
|
|
53
|
+
Then driving the process is refused with "no canned command matches `npm test`"
|
|
54
|
+
|
|
55
|
+
Scenario: a remote ref is read by its short name
|
|
56
|
+
Given the ref "refs/remotes/origin/main" points at HEAD
|
|
57
|
+
And a file "a.txt" with:
|
|
58
|
+
"""
|
|
59
|
+
local
|
|
60
|
+
"""
|
|
61
|
+
And the working tree is committed as "chore: local"
|
|
62
|
+
And step "restore" runs its script
|
|
63
|
+
When gtd starts workflow "restored"
|
|
64
|
+
And the process is driven
|
|
65
|
+
Then the process rests at "done"
|
|
66
|
+
And "a.txt" contains "base"
|
|
67
|
+
|
|
68
|
+
Scenario: a branch is checked out and back
|
|
69
|
+
Given a branch "feature/x"
|
|
70
|
+
And "feature/x" is checked out
|
|
71
|
+
And a file "a.txt" with:
|
|
72
|
+
"""
|
|
73
|
+
feature
|
|
74
|
+
"""
|
|
75
|
+
And the working tree is committed as "feat: x"
|
|
76
|
+
And "main" is checked out
|
|
77
|
+
Then "a.txt" contains "base"
|
|
78
|
+
And "a.txt" does not contain "feature"
|
|
@@ -11,7 +11,16 @@ import {
|
|
|
11
11
|
} from "quickpickle"
|
|
12
12
|
import assert from "node:assert"
|
|
13
13
|
import type { TestContext } from "vitest"
|
|
14
|
-
import {
|
|
14
|
+
import { resolve } from "node:path"
|
|
15
|
+
import { pathToFileURL } from "node:url"
|
|
16
|
+
import {
|
|
17
|
+
runsItsScript,
|
|
18
|
+
testRepo,
|
|
19
|
+
type Actor,
|
|
20
|
+
type CannedCommand,
|
|
21
|
+
type TestRepo,
|
|
22
|
+
type WorkflowModule,
|
|
23
|
+
} from "../testkit/index.js"
|
|
15
24
|
|
|
16
25
|
export class TestkitWorld extends QuickPickleWorld {
|
|
17
26
|
// Re-declared: under `nodenext` a subclass loses the base's members from TS's view.
|
|
@@ -21,8 +30,9 @@ export class TestkitWorld extends QuickPickleWorld {
|
|
|
21
30
|
super(context, info)
|
|
22
31
|
}
|
|
23
32
|
|
|
24
|
-
private gtdConfig:
|
|
33
|
+
private gtdConfig: WorkflowModule | undefined = undefined
|
|
25
34
|
private env: Record<string, string> = {}
|
|
35
|
+
private commands: CannedCommand[] = []
|
|
26
36
|
/** Per step, what its scripted actor does, in the order the scenario declared it. */
|
|
27
37
|
actions = new Map<string, Actor[]>()
|
|
28
38
|
verdicts = new Map<string, string>()
|
|
@@ -33,7 +43,7 @@ export class TestkitWorld extends QuickPickleWorld {
|
|
|
33
43
|
// The repository is created on first use and keeps the config and
|
|
34
44
|
// environment it was created with, so a later change to either is refused
|
|
35
45
|
// rather than silently ignored.
|
|
36
|
-
configure(config:
|
|
46
|
+
configure(config: WorkflowModule): void {
|
|
37
47
|
this.assertNotCreated("a gtd config")
|
|
38
48
|
this.gtdConfig = config
|
|
39
49
|
}
|
|
@@ -41,9 +51,16 @@ export class TestkitWorld extends QuickPickleWorld {
|
|
|
41
51
|
extendConfig(tail: string): void {
|
|
42
52
|
this.assertNotCreated("a gtd config")
|
|
43
53
|
if (this.gtdConfig === undefined) throw new Error("no gtd config to extend")
|
|
54
|
+
if (typeof this.gtdConfig !== "string")
|
|
55
|
+
throw new Error("a gtd config from a module cannot be extended")
|
|
44
56
|
this.gtdConfig += `\n${tail}`
|
|
45
57
|
}
|
|
46
58
|
|
|
59
|
+
command(command: CannedCommand): void {
|
|
60
|
+
if (this.created === undefined) this.commands.push(command)
|
|
61
|
+
else this.created.command(command)
|
|
62
|
+
}
|
|
63
|
+
|
|
47
64
|
setEnv(name: string, value: string): void {
|
|
48
65
|
this.assertNotCreated(`the environment variable ${name}`)
|
|
49
66
|
this.env[name] = value
|
|
@@ -58,7 +75,11 @@ export class TestkitWorld extends QuickPickleWorld {
|
|
|
58
75
|
repo(): TestRepo {
|
|
59
76
|
if (this.gtdConfig === undefined)
|
|
60
77
|
throw new Error("no gtd config: add a `gtd config` step first")
|
|
61
|
-
return (this.created ??= testRepo({
|
|
78
|
+
return (this.created ??= testRepo({
|
|
79
|
+
workflow: this.gtdConfig,
|
|
80
|
+
env: this.env,
|
|
81
|
+
commands: this.commands,
|
|
82
|
+
}))
|
|
62
83
|
}
|
|
63
84
|
|
|
64
85
|
act(step: string, action: Actor): void {
|
|
@@ -108,6 +129,13 @@ Given("a gtd config with:", (world: TestkitWorld, source: string) => {
|
|
|
108
129
|
world.configure(withNewline(source))
|
|
109
130
|
})
|
|
110
131
|
|
|
132
|
+
// Imported by the test runner itself, from the working directory: the module and
|
|
133
|
+
// the engine share one `@pmelab/gtd/flows`, and a config source string could
|
|
134
|
+
// import only files written into the in-memory repository.
|
|
135
|
+
Given("a gtd config from the module {string}", async (world: TestkitWorld, path: string) => {
|
|
136
|
+
world.configure({ ...(await import(pathToFileURL(resolve(path)).href)) })
|
|
137
|
+
})
|
|
138
|
+
|
|
111
139
|
Given("the gtd config ends with:", (world: TestkitWorld, source: string) => {
|
|
112
140
|
world.extendConfig(withNewline(source))
|
|
113
141
|
})
|
|
@@ -119,6 +147,29 @@ Given(
|
|
|
119
147
|
},
|
|
120
148
|
)
|
|
121
149
|
|
|
150
|
+
Given("the command {string} exits {int}", (world: TestkitWorld, run: string, exit: number) => {
|
|
151
|
+
world.command({ run, exit })
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
Given(
|
|
155
|
+
"the command {string} exits {int} with:",
|
|
156
|
+
(world: TestkitWorld, run: string, exit: number, output: string) => {
|
|
157
|
+
world.command({ run, exit, output: withNewline(output) })
|
|
158
|
+
},
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
Given("a branch {string}", (world: TestkitWorld, name: string) => {
|
|
162
|
+
world.repo().branch(name)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
Given("{string} is checked out", (world: TestkitWorld, name: string) => {
|
|
166
|
+
world.repo().checkout(name)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
Given("the ref {string} points at HEAD", (world: TestkitWorld, name: string) => {
|
|
170
|
+
world.repo().ref(name)
|
|
171
|
+
})
|
|
172
|
+
|
|
122
173
|
Given("a file {string} with:", (world: TestkitWorld, path: string, content: string) => {
|
|
123
174
|
world.repo().write(path, withNewline(content))
|
|
124
175
|
})
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
@inmem
|
|
2
|
+
Feature: a scenario runs a workflow module from disk
|
|
3
|
+
|
|
4
|
+
The test runner imports the module, so it shares the engine's runtime and
|
|
5
|
+
may import anything on disk. A config written as source can import only
|
|
6
|
+
files inside the in-memory repository.
|
|
7
|
+
|
|
8
|
+
Scenario: the module's default export starts on a change in the tree
|
|
9
|
+
Given a gtd config from the module "src/gherkin/tiny.fixture.ts"
|
|
10
|
+
And a file "IDEA.md" with:
|
|
11
|
+
"""
|
|
12
|
+
a calculator
|
|
13
|
+
"""
|
|
14
|
+
And step "plan" writes "PLAN.md" with:
|
|
15
|
+
"""
|
|
16
|
+
add two numbers
|
|
17
|
+
"""
|
|
18
|
+
When the process is driven
|
|
19
|
+
Then the process rests at "sign-off"
|
|
20
|
+
And the commit subjects end with:
|
|
21
|
+
"""
|
|
22
|
+
gtd(agent): plan → sign-off
|
|
23
|
+
"""
|
|
24
|
+
|
package/src/platform/Git.test.ts
CHANGED
|
@@ -7,11 +7,11 @@ import { Cause, Effect, Exit, Option } from "effect"
|
|
|
7
7
|
import { describe, expect, it } from "vitest"
|
|
8
8
|
import { GitService, InvalidRefError, resolvedRefOrCorrupted } from "./Git.js"
|
|
9
9
|
import { Host } from "./index.js"
|
|
10
|
-
import { gitTiers, runGitServiceContract, runGitScriptContract } from "../
|
|
10
|
+
import { gitTiers, runGitServiceContract, runGitScriptContract } from "../twins/index.js"
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* The `GitOperations` contract, run identically against both the Live and
|
|
14
|
-
* InMemory tiers (`src/
|
|
14
|
+
* InMemory tiers (`src/twins/GitTiers.ts`) — 20 operations × 2 tiers.
|
|
15
15
|
*/
|
|
16
16
|
for (const makeTier of gitTiers) {
|
|
17
17
|
const name = makeTier().name
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Effect, Option } from "effect"
|
|
2
2
|
import { describe, expect, it, vi } from "vitest"
|
|
3
|
-
import { strictGitOperations as stubGit } from "../
|
|
3
|
+
import { strictGitOperations as stubGit } from "../twins/index.js"
|
|
4
4
|
import {
|
|
5
5
|
clearRetainedHistory,
|
|
6
6
|
HISTORY_REF,
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* Unit coverage for the retention edge module (`src/platform/RetainedHistory.ts`),
|
|
16
|
-
* using `strictGitOperations` (`src/
|
|
16
|
+
* using `strictGitOperations` (`src/twins/StrictGitOperations.ts`) as its
|
|
17
17
|
* `GitOperations` double — a Proxy failing on any method a test didn't
|
|
18
18
|
* override, so an unexpected call fails loudly instead of silently succeeding.
|
|
19
19
|
*/
|
|
@@ -15,7 +15,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"
|
|
|
15
15
|
import { Effect, Layer } from "effect"
|
|
16
16
|
import { NodeContext } from "@effect/platform-node"
|
|
17
17
|
import { GitService, Host, Workspace, type WorkspaceOps } from "./index.js"
|
|
18
|
-
import { InMemRepo, makeInMemoryWorkspaceOps } from "../
|
|
18
|
+
import { InMemRepo, makeInMemoryWorkspaceOps } from "../twins/index.js"
|
|
19
19
|
|
|
20
20
|
// `execFileSync` is a named export of the built-in `node:child_process` ESM
|
|
21
21
|
// module — not configurable, so `vi.spyOn` can't wrap it directly. `vi.mock`
|
|
@@ -31,7 +31,7 @@ vi.mock("node:child_process", async (importOriginal) => {
|
|
|
31
31
|
})
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
|
-
* One `Workspace` tier, in the shape `src/
|
|
34
|
+
* One `Workspace` tier, in the shape `src/twins/GitTiers.ts` establishes
|
|
35
35
|
* for `GitOperations`: a `root`, a way to seed working-tree/committed
|
|
36
36
|
* content, and `provide` to run an Effect against this tier's `Workspace`.
|
|
37
37
|
*/
|
|
@@ -16,7 +16,7 @@ import { NodeContext } from "@effect/platform-node"
|
|
|
16
16
|
import { describe, expect, it } from "vitest"
|
|
17
17
|
import { loopLogPath, worktreeGitDir } from "./WorktreeState.js"
|
|
18
18
|
import { GitService, Host, Workspace } from "./index.js"
|
|
19
|
-
import { InMemRepo, testLayers } from "../
|
|
19
|
+
import { InMemRepo, testLayers } from "../twins/index.js"
|
|
20
20
|
|
|
21
21
|
const provide = <A>(
|
|
22
22
|
eff: Effect.Effect<A, never, Host | Workspace>,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Effect } from "effect"
|
|
2
2
|
import { describe, expect, it } from "vitest"
|
|
3
3
|
import { planStart, type StartOutcome } from "./planStart.js"
|
|
4
|
-
import { InMemRepo, testLayers } from "../
|
|
4
|
+
import { InMemRepo, testLayers } from "../twins/index.js"
|
|
5
5
|
import { ConfigService } from "../workflow/index.js"
|
|
6
6
|
|
|
7
7
|
const WORKFLOW = `import { agent, human } from "@pmelab/gtd/flows"
|
|
@@ -12,7 +12,16 @@ import {
|
|
|
12
12
|
softResetTo,
|
|
13
13
|
updateRef,
|
|
14
14
|
} from "../emit/index.js"
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
agent,
|
|
17
|
+
check,
|
|
18
|
+
human,
|
|
19
|
+
moveScript,
|
|
20
|
+
read,
|
|
21
|
+
removeScript,
|
|
22
|
+
run,
|
|
23
|
+
showScript,
|
|
24
|
+
} from "../flows/index.js"
|
|
16
25
|
import { runsItsScript, testRepo } from "./TestRepo.js"
|
|
17
26
|
|
|
18
27
|
const tiny = async (): Promise<void> => {
|
|
@@ -112,7 +121,7 @@ describe("testRepo", () => {
|
|
|
112
121
|
it("carries the recognizer's error when a git-free fallback fails", () => {
|
|
113
122
|
const repo = testRepo({ workflow: { default: tiny } })
|
|
114
123
|
expect(() => repo.runScript("printf 'x' > 'y'\n")).toThrow(
|
|
115
|
-
"testkit: cannot run script in memory — give the step an actor
|
|
124
|
+
"testkit: cannot run script in memory — add a canned command for it, or give the step an actor\nunrecognized script block: printf 'x' > 'y'",
|
|
116
125
|
)
|
|
117
126
|
})
|
|
118
127
|
|
|
@@ -194,4 +203,111 @@ describe("testRepo", () => {
|
|
|
194
203
|
repo.drive({ spin: (_, r) => r.write("n", `${++n}\n`) }, { maxBeats: 5 }),
|
|
195
204
|
).rejects.toThrow(/did not rest within 5 beats/)
|
|
196
205
|
})
|
|
206
|
+
|
|
207
|
+
describe("canned commands", () => {
|
|
208
|
+
const checked = async (): Promise<void> => {
|
|
209
|
+
const green = await check("test", "npm test", { report: ".gtd/FEEDBACK.md" })
|
|
210
|
+
await human(green ? "sign-off" : "red")
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
it("runs a check's command as canned, its output the report", async () => {
|
|
214
|
+
const repo = testRepo({
|
|
215
|
+
workflow: { default: checked },
|
|
216
|
+
commands: [{ run: "npm test", exit: 1, output: "1 failing" }],
|
|
217
|
+
})
|
|
218
|
+
repo.write("go", "\n")
|
|
219
|
+
const rest = await repo.drive({ test: runsItsScript })
|
|
220
|
+
expect(rest.state).toBe("red")
|
|
221
|
+
expect(repo.read(".gtd/FEEDBACK.md")).toContain("1 failing")
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it("exits 0 by default, and matches by pattern", async () => {
|
|
225
|
+
const repo = testRepo({ workflow: { default: checked }, commands: [{ run: /^npm / }] })
|
|
226
|
+
repo.write("go", "\n")
|
|
227
|
+
expect((await repo.drive({ test: runsItsScript })).state).toBe("sign-off")
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it("lets a command change the tree, and takes the first match", async () => {
|
|
231
|
+
const repo = testRepo({
|
|
232
|
+
workflow: { default: checked },
|
|
233
|
+
commands: [
|
|
234
|
+
{ run: "npm test", effect: (r) => r.write("ran", "first\n") },
|
|
235
|
+
{ run: /npm/, exit: 1 },
|
|
236
|
+
],
|
|
237
|
+
})
|
|
238
|
+
repo.write("go", "\n")
|
|
239
|
+
expect((await repo.drive({ test: runsItsScript })).state).toBe("sign-off")
|
|
240
|
+
expect(repo.read("ran")).toBe("first\n")
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it("takes a command added later", async () => {
|
|
244
|
+
const repo = testRepo({ workflow: { default: checked } })
|
|
245
|
+
repo.command({ run: "npm test", exit: 2 })
|
|
246
|
+
repo.write("go", "\n")
|
|
247
|
+
expect((await repo.drive({ test: runsItsScript })).state).toBe("red")
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it("runs a plain script line as canned", () => {
|
|
251
|
+
const repo = testRepo({
|
|
252
|
+
workflow: { default: tiny },
|
|
253
|
+
commands: [{ run: "make docs", effect: (r) => r.write("docs.html", "<p>\n") }],
|
|
254
|
+
})
|
|
255
|
+
repo.runScript("make docs\n")
|
|
256
|
+
expect(repo.read("docs.html")).toBe("<p>\n")
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it("fails the drive on a command none matches, naming it", async () => {
|
|
260
|
+
const repo = testRepo({ workflow: { default: checked } })
|
|
261
|
+
repo.write("go", "\n")
|
|
262
|
+
await expect(repo.drive({ test: runsItsScript })).rejects.toThrow(
|
|
263
|
+
"no canned command matches `npm test`",
|
|
264
|
+
)
|
|
265
|
+
})
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
describe("history", () => {
|
|
269
|
+
it("creates and checks out a branch, refusing uncommitted changes", () => {
|
|
270
|
+
const repo = testRepo({ workflow: { default: tiny }, files: { "a.txt": "main\n" } })
|
|
271
|
+
repo.branch("feature/x")
|
|
272
|
+
repo.checkout("feature/x")
|
|
273
|
+
expect(repo.branchName()).toBe("feature/x")
|
|
274
|
+
repo.write("a.txt", "feature\n")
|
|
275
|
+
expect(() => repo.checkout("main")).toThrow("uncommitted changes")
|
|
276
|
+
repo.commit("feat: x")
|
|
277
|
+
repo.checkout("main")
|
|
278
|
+
expect(repo.branchName()).toBe("main")
|
|
279
|
+
expect(repo.read("a.txt")).toBe("main\n")
|
|
280
|
+
expect(repo.show("feature/x", "a.txt")).toBe("feature\n")
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
it("branches at a given ref, and refuses an existing name or a missing ref", () => {
|
|
284
|
+
const repo = testRepo({ workflow: { default: tiny }, files: { "a.txt": "one\n" } })
|
|
285
|
+
repo.write("a.txt", "two\n")
|
|
286
|
+
repo.commit("chore: two")
|
|
287
|
+
repo.branch("old", "HEAD~1")
|
|
288
|
+
expect(repo.show("old", "a.txt")).toBe("one\n")
|
|
289
|
+
expect(() => repo.branch("old")).toThrow("already exists")
|
|
290
|
+
expect(() => repo.branch("new", "nowhere")).toThrow("not a valid object name")
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
it("points a remote ref that a script reads by its short name", async () => {
|
|
294
|
+
const restore = async (): Promise<void> => {
|
|
295
|
+
await run("restore", showScript("origin/main", "a.txt", "a.txt"))
|
|
296
|
+
await human("done")
|
|
297
|
+
}
|
|
298
|
+
const repo = testRepo({ workflow: { default: restore }, files: { "a.txt": "base\n" } })
|
|
299
|
+
repo.ref("refs/remotes/origin/main")
|
|
300
|
+
repo.write("a.txt", "local\n")
|
|
301
|
+
repo.commit("chore: local")
|
|
302
|
+
repo.write("go", "\n")
|
|
303
|
+
await repo.drive({ restore: runsItsScript })
|
|
304
|
+
expect(repo.read("a.txt")).toBe("base\n")
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
it("refuses a ref outside refs/ or at nothing", () => {
|
|
308
|
+
const repo = testRepo({ workflow: { default: tiny } })
|
|
309
|
+
expect(() => repo.ref("origin/main")).toThrow("must start with refs/")
|
|
310
|
+
expect(() => repo.ref("refs/tags/v1", "nowhere")).toThrow("resolves to no commit")
|
|
311
|
+
})
|
|
312
|
+
})
|
|
197
313
|
})
|