@pmelab/gtd 27.5.0 → 27.7.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 -2
- package/dist/chunks/{reserved-DM5W_LAt.mjs → reserved-Df8GeSHw.mjs} +37 -9
- package/dist/chunks/{tail-6gvLXXcg.mjs → tail-DmIfEAI4.mjs} +1 -1
- package/dist/flows.mjs +17 -1
- package/dist/gtd.bundle.mjs +56 -11
- package/dist/testkit.mjs +67 -26
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +61 -10
- package/dist/workflows/fix.mjs +2 -2
- package/dist/workflows/review.mjs +2 -2
- package/dist/workflows/rollout.mjs +1 -1
- package/dist/workflows.mjs +110 -16
- package/package.json +1 -1
- package/src/cli/Cli.ts +2 -2
- package/src/edge/Edge.test.ts +111 -0
- package/src/edge/Edge.ts +15 -3
- package/src/flags/index.test.ts +13 -0
- package/src/flags/index.ts +1 -0
- package/src/flows/runtime.test.ts +42 -0
- package/src/flows/runtime.ts +36 -0
- package/src/replay/Replay.test.ts +43 -0
- package/src/replay/Replay.ts +10 -2
- package/src/replay/Trailers.test.ts +54 -0
- package/src/replay/Trailers.ts +15 -1
- package/src/testkit/EmittedScriptRecognizer.test.ts +41 -0
- package/src/testkit/EmittedScriptRecognizer.ts +16 -7
- package/src/testkit/TestRepo.test.ts +79 -1
- package/src/testkit/TestRepo.ts +17 -6
- package/src/testkit/steps.ts +44 -1
- package/src/testkit/testkit.feature +35 -0
- package/src/workflows/cleanup/cleanup-path.feature +94 -0
- package/src/workflows/cleanup/cleanup.feature +0 -29
- package/src/workflows/feature/fast-path.feature +114 -0
- package/src/workflows/feature/feature.ts +3 -1
- package/src/workflows/feature/one-step-land.feature +108 -0
- package/src/workflows/feature/refactor-first.feature +137 -0
- package/src/workflows/feature/risk-tier.feature +238 -0
- package/src/workflows/feature/seam-rejection.feature +146 -0
- package/src/workflows/feature/seam.feature +0 -67
- package/src/workflows/feature/seam.ts +5 -4
- package/src/workflows/feature/steps.ts +3 -2
- package/src/workflows/feature/tier.test.ts +164 -0
- package/src/workflows/feature/tier.ts +74 -0
- package/src/workflows/shared/options.test.ts +6 -1
- package/src/workflows/shared/text.test.ts +129 -0
- package/src/workflows/shared/text.ts +57 -9
- package/src/workflows/shared/vars.ts +2 -0
- package/src/workflows/shared/workflows.steps.ts +13 -4
package/src/replay/Replay.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
type ScopeOptions,
|
|
11
11
|
type StepRequest,
|
|
12
12
|
type Flow,
|
|
13
|
+
type Mark,
|
|
13
14
|
} from "../flows/index.js"
|
|
14
15
|
import {
|
|
15
16
|
headingSectionBodies,
|
|
@@ -114,12 +115,15 @@ export type ReplayOutcome =
|
|
|
114
115
|
readonly trace: readonly ReachedStep[]
|
|
115
116
|
/** The step the pending turn completed, when one was supplied. */
|
|
116
117
|
readonly landed: ReachedStep | undefined
|
|
118
|
+
/** Marks the flow made after the pending turn's step resolved; [] without a pending turn. */
|
|
119
|
+
readonly marks: readonly Mark[]
|
|
117
120
|
}
|
|
118
121
|
| {
|
|
119
122
|
readonly kind: "ended"
|
|
120
123
|
readonly via: "return" | "restart"
|
|
121
124
|
readonly trace: readonly ReachedStep[]
|
|
122
125
|
readonly landed: ReachedStep | undefined
|
|
126
|
+
readonly marks: readonly Mark[]
|
|
123
127
|
}
|
|
124
128
|
| { readonly kind: "divergence"; readonly message: string }
|
|
125
129
|
| { readonly kind: "refused"; readonly message: string }
|
|
@@ -292,6 +296,7 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
292
296
|
{ readonly hash: string; readonly before: TreeView; readonly after: TreeView }
|
|
293
297
|
>()
|
|
294
298
|
let landed: ReachedStep | undefined
|
|
299
|
+
const marks: Mark[] = []
|
|
295
300
|
let outcome: ReplayOutcome | undefined
|
|
296
301
|
let signal: (() => void) | undefined
|
|
297
302
|
const settled = new Promise<void>((resolve) => {
|
|
@@ -553,7 +558,7 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
553
558
|
const commit = commits[cursor]
|
|
554
559
|
if (commit !== undefined) return consumeCommit(step, commit)
|
|
555
560
|
if (input.pending === undefined || pendingUsed) {
|
|
556
|
-
return finish({ kind: "rest", rest: step, trace, landed })
|
|
561
|
+
return finish({ kind: "rest", rest: step, trace, landed, marks })
|
|
557
562
|
}
|
|
558
563
|
return landPending(step, input.pending)
|
|
559
564
|
}
|
|
@@ -598,7 +603,7 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
598
603
|
kind: "divergence",
|
|
599
604
|
message: `gtd: the flow ended, but commit ${short(commits[cursor]!.hash)} continues the episode — ${DIVERGED}`,
|
|
600
605
|
}
|
|
601
|
-
: { kind: "ended", via, trace, landed }
|
|
606
|
+
: { kind: "ended", via, trace, landed, marks }
|
|
602
607
|
|
|
603
608
|
const changesSince = (hash: string): readonly Change[] => {
|
|
604
609
|
const tree = treeAt(hash)
|
|
@@ -670,6 +675,9 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
670
675
|
.map((f) => f.message),
|
|
671
676
|
}))
|
|
672
677
|
}),
|
|
678
|
+
mark: (name, value) => {
|
|
679
|
+
if (pendingUsed) marks.push({ name, value })
|
|
680
|
+
},
|
|
673
681
|
kept: (name) => {
|
|
674
682
|
const tip = keptTips.get(scoped(name))
|
|
675
683
|
return tip === undefined
|
|
@@ -282,3 +282,57 @@ describe("isRecordableApproval", () => {
|
|
|
282
282
|
expect(isRecordableApproval(of("Ada", "a@x.io", 0))).toBe(false)
|
|
283
283
|
})
|
|
284
284
|
})
|
|
285
|
+
|
|
286
|
+
describe("Gtd-Mark", () => {
|
|
287
|
+
const body = (...lines: string[]) => `gtd(human): a → b\n\n${lines.join("\n")}`
|
|
288
|
+
|
|
289
|
+
it("round-trips in order, names repeating", () => {
|
|
290
|
+
const message = formatCommitMessage({
|
|
291
|
+
actor: "human",
|
|
292
|
+
from: "a",
|
|
293
|
+
to: "b",
|
|
294
|
+
marks: [
|
|
295
|
+
{ name: "risk-tier", value: "high" },
|
|
296
|
+
{ name: "production-signal", value: "error rate = 0" },
|
|
297
|
+
{ name: "production-signal", value: "p95" },
|
|
298
|
+
],
|
|
299
|
+
})
|
|
300
|
+
expect(parseCommitMessage(message).marks).toEqual([
|
|
301
|
+
{ name: "risk-tier", value: "high" },
|
|
302
|
+
{ name: "production-signal", value: "error rate = 0" },
|
|
303
|
+
{ name: "production-signal", value: "p95" },
|
|
304
|
+
])
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
it("is written after Gtd-Judge and before Gtd-Approved, with no Gtd-Format bump", () => {
|
|
308
|
+
const lines = formatCommitMessage({
|
|
309
|
+
actor: "human",
|
|
310
|
+
to: "b",
|
|
311
|
+
judge: [{ id: "v", answer: "x", p: 1 }],
|
|
312
|
+
marks: [{ name: "risk-tier", value: "high" }],
|
|
313
|
+
approved: { step: { name: "s", occurrence: 1 }, name: "Ada", email: "a@x.io" },
|
|
314
|
+
}).split("\n")
|
|
315
|
+
const at = lines.findIndex((l) => l.startsWith("Gtd-Mark:"))
|
|
316
|
+
expect(lines[at - 1]).toMatch(/^Gtd-Judge:/)
|
|
317
|
+
expect(lines[at + 1]).toMatch(/^Gtd-Approved:/)
|
|
318
|
+
expect(lines).toContain("Gtd-Format: 1")
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
it("writes nothing without marks", () => {
|
|
322
|
+
expect(formatCommitMessage({ actor: "human", to: "b" })).not.toContain("Gtd-Mark")
|
|
323
|
+
expect(parseCommitMessage("gtd(human): a → b").marks).toEqual([])
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
it("skips a malformed line, a missing =, an empty value or a control character", () => {
|
|
327
|
+
const parsed = parseCommitMessage(
|
|
328
|
+
body(
|
|
329
|
+
"Gtd-Mark: no-equals",
|
|
330
|
+
"Gtd-Mark: Bad_Name=x",
|
|
331
|
+
"Gtd-Mark: empty=",
|
|
332
|
+
"Gtd-Mark: ctl=a\u001bb",
|
|
333
|
+
"Gtd-Mark: ok=fine",
|
|
334
|
+
),
|
|
335
|
+
)
|
|
336
|
+
expect(parsed.marks).toEqual([{ name: "ok", value: "fine" }])
|
|
337
|
+
})
|
|
338
|
+
})
|
package/src/replay/Trailers.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// written by gtd) is skipped, not fatal — a corrupt line must never make every
|
|
4
4
|
// command unusable.
|
|
5
5
|
|
|
6
|
+
import type { Mark } from "../flows/index.js"
|
|
6
7
|
import { HISTORY_FORMAT } from "./Format.js"
|
|
7
8
|
|
|
8
9
|
const TRANSITION_SEP = " → "
|
|
@@ -73,6 +74,8 @@ export interface CommitMessage {
|
|
|
73
74
|
/** Written only on a process's opening commit. */
|
|
74
75
|
readonly workflow: string | undefined
|
|
75
76
|
readonly judge: readonly JudgeVerdict[]
|
|
77
|
+
/** `Gtd-Mark`, in order; a malformed or control-character line is skipped. */
|
|
78
|
+
readonly marks: readonly Mark[]
|
|
76
79
|
readonly vars: Readonly<Record<string, string>>
|
|
77
80
|
/** Written only on a process's first commit. */
|
|
78
81
|
readonly config: Readonly<Record<string, string>>
|
|
@@ -141,6 +144,7 @@ export const isRecordableApproval = (approval: Approval): boolean => {
|
|
|
141
144
|
const back = decodeApproval(encodeApproval(approval))
|
|
142
145
|
return back !== undefined && encodeApproval(back) === encodeApproval(approval)
|
|
143
146
|
}
|
|
147
|
+
const MARK_RE = /^([a-z][a-z0-9-]*)=(.+)$/
|
|
144
148
|
const FORMAT_RE = /^[1-9][0-9]*$/
|
|
145
149
|
const HASH_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/
|
|
146
150
|
|
|
@@ -153,6 +157,7 @@ interface Trailers {
|
|
|
153
157
|
kept: string | undefined
|
|
154
158
|
approved: Approval | undefined
|
|
155
159
|
readonly judge: JudgeVerdict[]
|
|
160
|
+
readonly marks: Mark[]
|
|
156
161
|
readonly vars: Record<string, string>
|
|
157
162
|
readonly config: Record<string, string>
|
|
158
163
|
readonly cost: CostEntry[]
|
|
@@ -170,6 +175,11 @@ const TRAILER_READERS: Readonly<Record<string, (value: string, into: Trailers) =
|
|
|
170
175
|
const parsed = parseJson(value)
|
|
171
176
|
if (isVerdict(parsed)) into.judge.push(parsed)
|
|
172
177
|
},
|
|
178
|
+
"Gtd-Mark": (value, into) => {
|
|
179
|
+
const kv = MARK_RE.exec(value)
|
|
180
|
+
if (kv !== null && !CONTROL_CHARACTER.test(value))
|
|
181
|
+
into.marks.push({ name: kv[1]!, value: kv[2]! })
|
|
182
|
+
},
|
|
173
183
|
"Gtd-Var": (value, into) => {
|
|
174
184
|
const kv = VAR_RE.exec(value)
|
|
175
185
|
if (kv !== null) into.vars[kv[1]!] = kv[2]!
|
|
@@ -215,6 +225,7 @@ export const parseCommitMessage = (message: string): CommitMessage => {
|
|
|
215
225
|
kept: undefined,
|
|
216
226
|
approved: undefined,
|
|
217
227
|
judge: [],
|
|
228
|
+
marks: [],
|
|
218
229
|
vars: {},
|
|
219
230
|
config: {},
|
|
220
231
|
cost: [],
|
|
@@ -239,7 +250,9 @@ export interface CommitSpec {
|
|
|
239
250
|
readonly config?: Readonly<Record<string, string>>
|
|
240
251
|
readonly cost?: { readonly cost: number; readonly model?: string }
|
|
241
252
|
readonly judge?: readonly JudgeVerdict[]
|
|
242
|
-
/** Written after `Gtd-Judge`, before `Gtd-
|
|
253
|
+
/** Written after `Gtd-Judge`, before `Gtd-Approved`. */
|
|
254
|
+
readonly marks?: readonly Mark[]
|
|
255
|
+
/** Written after `Gtd-Mark`, before `Gtd-Branch`. */
|
|
243
256
|
readonly approved?: Approval
|
|
244
257
|
readonly branch?: string
|
|
245
258
|
readonly version?: string
|
|
@@ -263,6 +276,7 @@ export const formatCommitMessage = (spec: CommitSpec): string => {
|
|
|
263
276
|
)
|
|
264
277
|
}
|
|
265
278
|
for (const verdict of spec.judge ?? []) lines.push(`Gtd-Judge: ${JSON.stringify(verdict)}`)
|
|
279
|
+
for (const m of spec.marks ?? []) lines.push(`Gtd-Mark: ${m.name}=${m.value}`)
|
|
266
280
|
if (spec.approved !== undefined) {
|
|
267
281
|
lines.push(`Gtd-Approved: ${encodeApproval(spec.approved)}`)
|
|
268
282
|
}
|
|
@@ -630,6 +630,18 @@ describe("applyEmittedScript — Emit.ts's combinedScript (the whole stdout of a
|
|
|
630
630
|
expect(repo.lastCommitMessage()).toBe("gtd(human): idle")
|
|
631
631
|
})
|
|
632
632
|
|
|
633
|
+
it("fails an unrecognized optional block under git: false", () => {
|
|
634
|
+
const repo = new InMemRepo()
|
|
635
|
+
const result = applyEmittedScript(
|
|
636
|
+
repo,
|
|
637
|
+
NO_COMMANDS,
|
|
638
|
+
combinedScript("set -eu", "totally-unrecognized-command"),
|
|
639
|
+
{ git: false },
|
|
640
|
+
)
|
|
641
|
+
expect(result.ok).toBe(false)
|
|
642
|
+
expect(result.error).toContain("totally-unrecognized-command")
|
|
643
|
+
})
|
|
644
|
+
|
|
633
645
|
it("still fails loudly on an unrecognized block outside the presentation-only subshell", () => {
|
|
634
646
|
const repo = new InMemRepo()
|
|
635
647
|
const script = `${combinedScript("totally-unrecognized-required-command", "")}`
|
|
@@ -691,3 +703,32 @@ describe("applyEmittedScript — commitClean", () => {
|
|
|
691
703
|
expect(snapshot(repo)).toEqual(before)
|
|
692
704
|
})
|
|
693
705
|
})
|
|
706
|
+
|
|
707
|
+
describe("applyEmittedScript — git: false reaches through the presentation subshell", () => {
|
|
708
|
+
it("refuses a git builder wrapped in combinedScript and changes nothing", () => {
|
|
709
|
+
const repo = new InMemRepo()
|
|
710
|
+
repo.writeFile("a.txt", "1")
|
|
711
|
+
const before = snapshot(repo)
|
|
712
|
+
const result = applyEmittedScript(
|
|
713
|
+
repo,
|
|
714
|
+
NO_COMMANDS,
|
|
715
|
+
combinedScript("set -eu", commitAll("chore: sneaky")),
|
|
716
|
+
{ git: false },
|
|
717
|
+
)
|
|
718
|
+
expect(result.ok).toBe(false)
|
|
719
|
+
expect(result.error).toMatch(/unrecognized script block/)
|
|
720
|
+
expect(snapshot(repo)).toEqual(before)
|
|
721
|
+
})
|
|
722
|
+
|
|
723
|
+
it("still applies a non-git write-lines optional half", () => {
|
|
724
|
+
const repo = new InMemRepo()
|
|
725
|
+
const result = applyEmittedScript(
|
|
726
|
+
repo,
|
|
727
|
+
NO_COMMANDS,
|
|
728
|
+
combinedScript("set -eu", "mkdir -p d\nprintf '%s\\n' 'x' > 'd/a.txt'"),
|
|
729
|
+
{ git: false },
|
|
730
|
+
)
|
|
731
|
+
expect(result).toMatchObject({ ok: true })
|
|
732
|
+
expect(repo.readFile("d/a.txt")).toBe("x\n")
|
|
733
|
+
})
|
|
734
|
+
})
|
|
@@ -563,7 +563,7 @@ const recognizeGtdUncheck = (repo: InMemRepo, block: string): BlockOutcome | und
|
|
|
563
563
|
const recognizeDidNotRunComment = (block: string): BlockOutcome | undefined =>
|
|
564
564
|
block === DID_NOT_RUN_COMMENT ? { kind: "noop" } : undefined
|
|
565
565
|
|
|
566
|
-
/** `combinedScript`'s optional half.
|
|
566
|
+
/** `combinedScript`'s optional half. Reports `noop` whatever the inner result, mirroring bash's `( … ) || <warning>`. Exception: under `git: false` any unrecognized inner block fails the outer script (git or not), so the in-memory twin knowingly diverges from `sh` rather than run git unseen. */
|
|
567
567
|
const PRESENTATION_FAILURE_TEXT = `${extractQuotedTokens(PRESENTATION_FAILURE_WARNING)[0]!.replace(/\\n$/, "")}\n`
|
|
568
568
|
const PRESENTATION_SUBSHELL_PREFIX = `${PRESENTATION_ONLY_COMMENT}\n(\n`
|
|
569
569
|
const PRESENTATION_SUBSHELL_SUFFIX = `\n) || ${PRESENTATION_FAILURE_WARNING}`
|
|
@@ -572,6 +572,7 @@ const recognizePresentationSubshell = (
|
|
|
572
572
|
repo: InMemRepo,
|
|
573
573
|
commands: ReadonlyMap<string, ScriptedCommand>,
|
|
574
574
|
block: string,
|
|
575
|
+
git: boolean,
|
|
575
576
|
): BlockOutcome | undefined => {
|
|
576
577
|
if (
|
|
577
578
|
!block.startsWith(PRESENTATION_SUBSHELL_PREFIX) ||
|
|
@@ -583,8 +584,11 @@ const recognizePresentationSubshell = (
|
|
|
583
584
|
PRESENTATION_SUBSHELL_PREFIX.length,
|
|
584
585
|
block.length - PRESENTATION_SUBSHELL_SUFFIX.length,
|
|
585
586
|
)
|
|
586
|
-
const result = applyEmittedScript(repo, commands, inner)
|
|
587
|
+
const result = applyEmittedScript(repo, commands, inner, { git })
|
|
587
588
|
if (result.ok) return { kind: "noop", output: result.output }
|
|
589
|
+
if (!git && result.error?.startsWith(UNRECOGNIZED_PREFIX)) {
|
|
590
|
+
return { kind: "failed", error: `${result.output}${result.error}` }
|
|
591
|
+
}
|
|
588
592
|
return { kind: "noop", output: `${result.output}${PRESENTATION_FAILURE_TEXT}` }
|
|
589
593
|
}
|
|
590
594
|
|
|
@@ -703,17 +707,18 @@ const recognizeWriteLines = (repo: InMemRepo, block: string): BlockOutcome | und
|
|
|
703
707
|
const recognizersFor = (
|
|
704
708
|
repo: InMemRepo,
|
|
705
709
|
commands: ReadonlyMap<string, ScriptedCommand>,
|
|
710
|
+
git: boolean,
|
|
706
711
|
): ReadonlyArray<(block: string) => BlockOutcome | undefined> => [
|
|
707
712
|
(block) => recognizeSetFlags(block),
|
|
708
713
|
(block) => recognizeDidNotRunComment(block),
|
|
709
714
|
(block) => recognizePrecondition(repo, block),
|
|
710
715
|
(block) => recognizeFileExistsGuard(repo, block),
|
|
711
716
|
(block) => recognizeBinaryGuard(commands, block),
|
|
712
|
-
(block) => recognizeGitBuilders(repo, block),
|
|
717
|
+
(block) => (git ? recognizeGitBuilders(repo, block) : undefined),
|
|
713
718
|
(block) => recognizeDeferNotes(repo, block),
|
|
714
719
|
(block) => recognizeWriteLines(repo, block),
|
|
715
720
|
(block) => recognizeFailurePromptWrapper(repo, commands, block),
|
|
716
|
-
(block) => recognizePresentationSubshell(repo, commands, block),
|
|
721
|
+
(block) => recognizePresentationSubshell(repo, commands, block, git),
|
|
717
722
|
(block) => recognizeModeContradictionSkipNotice(block),
|
|
718
723
|
(block) => recognizeModeContradictionCheck(repo, commands, block),
|
|
719
724
|
(block) => recognizeGtdCheck(repo, block),
|
|
@@ -746,6 +751,7 @@ export const applyEmittedScript = (
|
|
|
746
751
|
repo: InMemRepo,
|
|
747
752
|
commands: ReadonlyMap<string, ScriptedCommand>,
|
|
748
753
|
script: string,
|
|
754
|
+
{ git = true }: { readonly git?: boolean } = {},
|
|
749
755
|
): AppliedScriptResult => {
|
|
750
756
|
const flow = applyFlowScript(repo, commands, script)
|
|
751
757
|
if (flow !== undefined) {
|
|
@@ -753,15 +759,16 @@ export const applyEmittedScript = (
|
|
|
753
759
|
? { ok: true, status: 0, output: flow.output }
|
|
754
760
|
: { ok: false, status: 1, output: flow.output, error: flow.error ?? "" }
|
|
755
761
|
}
|
|
756
|
-
return script.startsWith("#!") ? unrecognized(script) : applyBlocks(repo, commands, script)
|
|
762
|
+
return script.startsWith("#!") ? unrecognized(script) : applyBlocks(repo, commands, script, git)
|
|
757
763
|
}
|
|
758
764
|
|
|
759
765
|
const applyBlocks = (
|
|
760
766
|
repo: InMemRepo,
|
|
761
767
|
commands: ReadonlyMap<string, ScriptedCommand>,
|
|
762
768
|
script: string,
|
|
769
|
+
git: boolean,
|
|
763
770
|
): AppliedScriptResult => {
|
|
764
|
-
const recognizers = recognizersFor(repo, commands)
|
|
771
|
+
const recognizers = recognizersFor(repo, commands, git)
|
|
765
772
|
let output = ""
|
|
766
773
|
for (const block of splitBlocks(script)) {
|
|
767
774
|
const outcome = recognizeBlock(recognizers, block)
|
|
@@ -786,9 +793,11 @@ const failedAt = (
|
|
|
786
793
|
}
|
|
787
794
|
}
|
|
788
795
|
|
|
796
|
+
const UNRECOGNIZED_PREFIX = "unrecognized script block: "
|
|
797
|
+
|
|
789
798
|
const unrecognized = (block: string, output = ""): AppliedScriptResult => ({
|
|
790
799
|
ok: false,
|
|
791
800
|
status: 1,
|
|
792
801
|
output,
|
|
793
|
-
error:
|
|
802
|
+
error: `${UNRECOGNIZED_PREFIX}${block.split("\n")[0]}`,
|
|
794
803
|
})
|
|
@@ -1,4 +1,17 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest"
|
|
2
|
+
import {
|
|
3
|
+
commitAll,
|
|
4
|
+
combinedScript,
|
|
5
|
+
commitAsIs,
|
|
6
|
+
commitClean,
|
|
7
|
+
deleteRef,
|
|
8
|
+
discardPending,
|
|
9
|
+
hardResetTo,
|
|
10
|
+
keepTurn,
|
|
11
|
+
mixedResetTo,
|
|
12
|
+
softResetTo,
|
|
13
|
+
updateRef,
|
|
14
|
+
} from "../emit/index.js"
|
|
2
15
|
import { agent, human, moveScript, read, removeScript, run } from "../flows/index.js"
|
|
3
16
|
import { runsItsScript, testRepo } from "./TestRepo.js"
|
|
4
17
|
|
|
@@ -93,7 +106,72 @@ describe("testRepo", () => {
|
|
|
93
106
|
|
|
94
107
|
it("refuses to fake a script it cannot run", () => {
|
|
95
108
|
const repo = testRepo({ workflow: { default: tiny } })
|
|
96
|
-
expect(() => repo.runScript("npm test\n")).toThrow(/cannot run
|
|
109
|
+
expect(() => repo.runScript("npm test\n")).toThrow(/cannot run script in memory[\s\S]*npm test/)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it("carries the recognizer's error when a git-free fallback fails", () => {
|
|
113
|
+
const repo = testRepo({ workflow: { default: tiny } })
|
|
114
|
+
expect(() => repo.runScript("printf 'x' > 'y'\n")).toThrow(
|
|
115
|
+
"testkit: cannot run script in memory — give the step an actor instead\nunrecognized script block: printf 'x' > 'y'",
|
|
116
|
+
)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it("names the block that failed, not an earlier line that ran", () => {
|
|
120
|
+
const repo = testRepo({ workflow: { default: tiny } })
|
|
121
|
+
let message = ""
|
|
122
|
+
try {
|
|
123
|
+
repo.runScript("mkdir -p d\nprintf '%s\\n' 'x' > 'd/a.txt'\n\nnpm test\n")
|
|
124
|
+
} catch (e) {
|
|
125
|
+
message = (e as Error).message
|
|
126
|
+
}
|
|
127
|
+
expect(message).toContain("unrecognized script block: npm test")
|
|
128
|
+
expect(message).not.toContain("printf")
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it.each([
|
|
132
|
+
["commitAll", commitAll("chore: sneaky")],
|
|
133
|
+
["commitClean", commitClean("chore: sneaky")],
|
|
134
|
+
["commitAsIs", commitAsIs("chore: sneaky")],
|
|
135
|
+
["keepTurn", keepTurn("refs/x", "chore: sneaky")],
|
|
136
|
+
["discardPending", discardPending()],
|
|
137
|
+
["softResetTo", softResetTo("HEAD~1")],
|
|
138
|
+
["mixedResetTo", mixedResetTo("HEAD~1")],
|
|
139
|
+
["hardResetTo", hardResetTo("HEAD~1")],
|
|
140
|
+
["updateRef", updateRef("refs/x", "HEAD")],
|
|
141
|
+
["deleteRef", deleteRef("refs/x")],
|
|
142
|
+
])("refuses a %s script instead of mutating git in memory", (_, script) => {
|
|
143
|
+
const repo = testRepo({ workflow: { default: tiny } })
|
|
144
|
+
repo.write("a.txt", "dirty\n")
|
|
145
|
+
const before = repo.subjects()
|
|
146
|
+
expect(() => repo.runScript(script)).toThrow(/unrecognized script block/)
|
|
147
|
+
expect(repo.subjects()).toEqual(before)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it.each([
|
|
151
|
+
["commitAll", commitAll("chore: sneaky")],
|
|
152
|
+
["commitClean", commitClean("chore: sneaky")],
|
|
153
|
+
["commitAsIs", commitAsIs("chore: sneaky")],
|
|
154
|
+
["keepTurn", keepTurn("refs/x", "chore: sneaky")],
|
|
155
|
+
["discardPending", discardPending()],
|
|
156
|
+
["softResetTo", softResetTo("HEAD~1")],
|
|
157
|
+
["mixedResetTo", mixedResetTo("HEAD~1")],
|
|
158
|
+
["hardResetTo", hardResetTo("HEAD~1")],
|
|
159
|
+
["updateRef", updateRef("refs/x", "HEAD")],
|
|
160
|
+
["deleteRef", deleteRef("refs/x")],
|
|
161
|
+
])("refuses a wrapped %s script instead of mutating git in memory", (_, script) => {
|
|
162
|
+
const repo = testRepo({ workflow: { default: tiny } })
|
|
163
|
+
repo.write("a.txt", "dirty\n")
|
|
164
|
+
const before = repo.subjects()
|
|
165
|
+
expect(() => repo.runScript(combinedScript("set -eu", script))).toThrow(
|
|
166
|
+
/cannot run script in memory[\s\S]*unrecognized script block/,
|
|
167
|
+
)
|
|
168
|
+
expect(repo.subjects()).toEqual(before)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it("applies a write block `LINES` lacks through the git-free recognizers", () => {
|
|
172
|
+
const repo = testRepo({ workflow: { default: tiny } })
|
|
173
|
+
repo.runScript("mkdir -p d\nprintf '%s\\n' 'echo $(git rev-parse HEAD)' > 'd/a.txt'\n")
|
|
174
|
+
expect(repo.read("d/a.txt")).toBe("echo $(git rev-parse HEAD)\n")
|
|
97
175
|
})
|
|
98
176
|
|
|
99
177
|
it("reads a file as committed", () => {
|
package/src/testkit/TestRepo.ts
CHANGED
|
@@ -150,12 +150,23 @@ const LINES: ReadonlyArray<readonly [RegExp, (repo: InMemRepo, words: string[])
|
|
|
150
150
|
],
|
|
151
151
|
]
|
|
152
152
|
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
153
|
+
const knownLine = (line: string) => LINES.find(([pattern]) => pattern.test(line))
|
|
154
|
+
|
|
155
|
+
const cannotRun = (error: string): Error =>
|
|
156
|
+
new Error(`testkit: cannot run script in memory — give the step an actor instead\n${error}`)
|
|
157
|
+
|
|
158
|
+
// A script with a line `LINES` lacks goes to the contract-tested recognizers
|
|
159
|
+
// whole, minus the git builders: they would apply a commit/reset/update-ref in
|
|
160
|
+
// memory, so any git-mutating block is refused whatever its spelling.
|
|
161
|
+
const runLines = (repo: InMemRepo, script: string): void => {
|
|
162
|
+
const lines = script.split("\n").map((line) => line.trim())
|
|
163
|
+
const known = lines.map(knownLine)
|
|
164
|
+
if (known.every(Boolean)) {
|
|
165
|
+
known.forEach((k, i) => k))
|
|
166
|
+
return
|
|
157
167
|
}
|
|
158
|
-
|
|
168
|
+
const applied = applyEmittedScript(repo, new Map(), script, { git: false })
|
|
169
|
+
if (!applied.ok) throw cannotRun(applied.error ?? "")
|
|
159
170
|
}
|
|
160
171
|
|
|
161
172
|
const failed = (what: string, result: CapturedCliResult): Error =>
|
|
@@ -251,7 +262,7 @@ export const testRepo = (options: TestRepoOptions): TestRepo => {
|
|
|
251
262
|
show: (ref, path) => repo.fileAtRef(ref, path) ?? undefined,
|
|
252
263
|
runScript: (script) => {
|
|
253
264
|
const flow = applyFlowScript(repo, new Map(), script)
|
|
254
|
-
if (flow === undefined)
|
|
265
|
+
if (flow === undefined) runLines(repo, script)
|
|
255
266
|
else if (!flow.ok) throw new Error(`testkit: ${flow.error ?? ""}`)
|
|
256
267
|
},
|
|
257
268
|
gtd,
|
package/src/testkit/steps.ts
CHANGED
|
@@ -70,7 +70,15 @@ export class TestkitWorld extends QuickPickleWorld {
|
|
|
70
70
|
for (const action of actions) await action(beat, repo)
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
|
-
for (const [step, verdict] of this.verdicts)
|
|
73
|
+
for (const [step, verdict] of this.verdicts) {
|
|
74
|
+
// A verdict would overwrite the queued actions, silently skipping their checks.
|
|
75
|
+
if (this.actions.has(step)) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
`step "${step}" has a judge verdict and queued actions; the actions would never run`,
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
actors[step] = () => verdict
|
|
81
|
+
}
|
|
74
82
|
await this.repo().drive(actors)
|
|
75
83
|
}
|
|
76
84
|
}
|
|
@@ -120,6 +128,19 @@ Given(
|
|
|
120
128
|
},
|
|
121
129
|
)
|
|
122
130
|
|
|
131
|
+
// A standing assertion like any queued action: it applies on every later visit
|
|
132
|
+
// to the step until `from now on` clears the list.
|
|
133
|
+
Given(
|
|
134
|
+
"step {string} is prompted with {string}",
|
|
135
|
+
(world: TestkitWorld, step: string, text: string) => {
|
|
136
|
+
world.act(step, (beat) => {
|
|
137
|
+
if (!beat.content.includes(text)) {
|
|
138
|
+
throw new Error(`step "${step}" was not prompted with "${text}":\n${beat.content}`)
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
},
|
|
142
|
+
)
|
|
143
|
+
|
|
123
144
|
Given("step {string} runs its script", (world: TestkitWorld, step: string) => {
|
|
124
145
|
world.act(step, runsItsScript)
|
|
125
146
|
})
|
|
@@ -207,6 +228,28 @@ Then("the commit subjects end with:", (world: TestkitWorld, list: string) => {
|
|
|
207
228
|
assert.deepStrictEqual(world.repo().subjects().slice(-expected.length), expected)
|
|
208
229
|
})
|
|
209
230
|
|
|
231
|
+
const outputOf = async (world: TestkitWorld, list: string): Promise<string> => {
|
|
232
|
+
const result = await world.repo().gtd(...args(list))
|
|
233
|
+
assert.strictEqual(result.exitCode, 0, result.stderr)
|
|
234
|
+
return result.stdout
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
Then(
|
|
238
|
+
"the output of gtd {string} contains {string}",
|
|
239
|
+
async (world: TestkitWorld, list: string, text: string) => {
|
|
240
|
+
const out = await outputOf(world, list)
|
|
241
|
+
assert.ok(out.includes(text), out)
|
|
242
|
+
},
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
Then(
|
|
246
|
+
"the output of gtd {string} does not contain {string}",
|
|
247
|
+
async (world: TestkitWorld, list: string, text: string) => {
|
|
248
|
+
const out = await outputOf(world, list)
|
|
249
|
+
assert.ok(!out.includes(text), out)
|
|
250
|
+
},
|
|
251
|
+
)
|
|
252
|
+
|
|
210
253
|
Then("{string} contains {string}", (world: TestkitWorld, path: string, text: string) => {
|
|
211
254
|
assert.ok(world.repo().read(path)?.includes(text), `${path}: ${world.repo().read(path)}`)
|
|
212
255
|
})
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
@inmem
|
|
1
2
|
Feature: testkit drives any workflow against the real engine
|
|
2
3
|
|
|
3
4
|
A workflow the scenario writes itself, never a shipped one: scripted actors
|
|
@@ -47,6 +48,8 @@ Feature: testkit drives any workflow against the real engine
|
|
|
47
48
|
gtd(judge): ready → ship
|
|
48
49
|
"""
|
|
49
50
|
And "PLAN.md" contains "add two numbers"
|
|
51
|
+
And the output of gtd "next" contains "ship"
|
|
52
|
+
And the output of gtd "next" does not contain "rework"
|
|
50
53
|
|
|
51
54
|
Scenario: a red check sends the process to repair, and an unscripted step is where it rests
|
|
52
55
|
Given step "plan" writes "PLAN.md" with:
|
|
@@ -63,6 +66,38 @@ Feature: testkit drives any workflow against the real engine
|
|
|
63
66
|
And the resting step's content contains "Make the suite pass."
|
|
64
67
|
And the last commit subject is "gtd(check): check → repair"
|
|
65
68
|
|
|
69
|
+
Scenario: a step's beat content is asserted as a queued actor
|
|
70
|
+
Given step "plan" is prompted with "Write PLAN.md."
|
|
71
|
+
And step "plan" writes "PLAN.md" with:
|
|
72
|
+
"""
|
|
73
|
+
add two numbers
|
|
74
|
+
"""
|
|
75
|
+
And step "check" changes nothing
|
|
76
|
+
And judge step "ready" answers:
|
|
77
|
+
"""
|
|
78
|
+
[{ "id": "ready", "answer": true, "p": 0.9 }]
|
|
79
|
+
"""
|
|
80
|
+
When gtd starts workflow "tiny"
|
|
81
|
+
And the process is driven
|
|
82
|
+
Then the process rests at "ship"
|
|
83
|
+
And "PLAN.md" contains "add two numbers"
|
|
84
|
+
|
|
85
|
+
Scenario: a judge step with queued actions refuses the drive
|
|
86
|
+
Given step "plan" changes nothing
|
|
87
|
+
And step "check" changes nothing
|
|
88
|
+
And step "ready" is prompted with "never seen"
|
|
89
|
+
And judge step "ready" answers:
|
|
90
|
+
"""
|
|
91
|
+
[{ "id": "ready", "answer": true, "p": 0.9 }]
|
|
92
|
+
"""
|
|
93
|
+
When gtd starts workflow "tiny"
|
|
94
|
+
Then driving the process is refused with "queued actions"
|
|
95
|
+
|
|
96
|
+
Scenario: a beat that lacks the expected text refuses the drive
|
|
97
|
+
Given step "plan" is prompted with "Write a poem"
|
|
98
|
+
When gtd starts workflow "tiny"
|
|
99
|
+
Then driving the process is refused with 'was not prompted with "Write a poem"'
|
|
100
|
+
|
|
66
101
|
Scenario: a start the workflow refuses is reported with its message
|
|
67
102
|
Then starting workflow "picky" with "--var mode=bad" is refused with "picky refuses every start"
|
|
68
103
|
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# One SDLC path per file, so a changed path reds exactly one fixture — do not merge.
|
|
2
|
+
@inmem
|
|
3
|
+
Feature: cleanup walks one fixed path from the opening commit to idle
|
|
4
|
+
|
|
5
|
+
A default-on release flag is cleaned up by an agent that removes the registry
|
|
6
|
+
entry and the off path; health, review and closing follow and the process
|
|
7
|
+
rests at idle. Cleanup reads no release flag, so no flag is pinned here and
|
|
8
|
+
every flag state takes the same path.
|
|
9
|
+
|
|
10
|
+
Background:
|
|
11
|
+
Given the shipped workflows, as gtd install scaffolds them
|
|
12
|
+
And a gtd config with:
|
|
13
|
+
"""
|
|
14
|
+
export {
|
|
15
|
+
feature as default,
|
|
16
|
+
feature,
|
|
17
|
+
defaults,
|
|
18
|
+
envDefaults,
|
|
19
|
+
summary,
|
|
20
|
+
steering,
|
|
21
|
+
skills,
|
|
22
|
+
access,
|
|
23
|
+
} from "@pmelab/gtd/workflows/feature"
|
|
24
|
+
import { cleanup } from "@pmelab/gtd/workflows"
|
|
25
|
+
export { cleanup }
|
|
26
|
+
"""
|
|
27
|
+
And an environment variable "GTD_QUALITYREVIEWS" set to ""
|
|
28
|
+
And a file "src/flags/index.ts" with:
|
|
29
|
+
"""
|
|
30
|
+
export const FLAGS = [
|
|
31
|
+
{ name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release" },
|
|
32
|
+
{ name: "live", owner: "d", expiry: "2027-04-01", kind: "release", defaultOn: true },
|
|
33
|
+
] as const
|
|
34
|
+
"""
|
|
35
|
+
And a file "src/calc.ts" with:
|
|
36
|
+
"""
|
|
37
|
+
export const on = process.env.GTD_FLAG_LIVE !== "0"
|
|
38
|
+
"""
|
|
39
|
+
And the working tree is committed as "feat: registry"
|
|
40
|
+
And step "cleanup.removing" writes "src/flags/index.ts" with:
|
|
41
|
+
"""
|
|
42
|
+
export const FLAGS = [
|
|
43
|
+
{ name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release" },
|
|
44
|
+
] as const
|
|
45
|
+
"""
|
|
46
|
+
And step "cleanup.removing" writes "src/calc.ts" with:
|
|
47
|
+
"""
|
|
48
|
+
export const on = true
|
|
49
|
+
"""
|
|
50
|
+
And step "build.health.check" changes nothing
|
|
51
|
+
And step "build.review.reviewing" writes ".gtd/REVIEW.md" with:
|
|
52
|
+
"""
|
|
53
|
+
# Review: abc1234
|
|
54
|
+
|
|
55
|
+
<!-- base: 0000000 -->
|
|
56
|
+
|
|
57
|
+
## calc
|
|
58
|
+
|
|
59
|
+
- [ ] ./src/calc.ts#1-1
|
|
60
|
+
flag removed
|
|
61
|
+
"""
|
|
62
|
+
And step "build.review.closing" runs its script
|
|
63
|
+
|
|
64
|
+
Scenario: removing the entry and the off path runs health, review and closing to idle
|
|
65
|
+
When gtd starts workflow "cleanup" with "--var flag=live"
|
|
66
|
+
And the process is driven
|
|
67
|
+
Then the process rests at "build.review.await-review"
|
|
68
|
+
Given a file ".gtd/REVIEW.md" with:
|
|
69
|
+
"""
|
|
70
|
+
# Review: abc1234
|
|
71
|
+
|
|
72
|
+
<!-- base: 0000000 -->
|
|
73
|
+
|
|
74
|
+
## calc
|
|
75
|
+
|
|
76
|
+
- [x] ./src/calc.ts#1-1
|
|
77
|
+
flag removed
|
|
78
|
+
"""
|
|
79
|
+
When gtd lands
|
|
80
|
+
And the process is driven
|
|
81
|
+
Then the process rests at "idle"
|
|
82
|
+
And "src/flags/index.ts" does not contain "live"
|
|
83
|
+
And "src/calc.ts" does not contain "GTD_FLAG_LIVE"
|
|
84
|
+
And the commit subjects end with:
|
|
85
|
+
"""
|
|
86
|
+
gtd(human): cleanup.removing
|
|
87
|
+
gtd(agent): cleanup.removing → build.health.check
|
|
88
|
+
gtd(check): build.health.check → build.review.reviewing
|
|
89
|
+
gtd(agent): build.review.reviewing → build.review.await-review
|
|
90
|
+
gtd(human): build.review.await-review → build.review.closing
|
|
91
|
+
gtd(check): build.review.closing → idle
|
|
92
|
+
"""
|
|
93
|
+
And the commit "gtd(human): cleanup.removing" has a body containing "Gtd-Workflow: cleanup"
|
|
94
|
+
And the commit "gtd(human): cleanup.removing" has a body containing "Gtd-Var: flag=live"
|