@pmelab/gtd 24.4.0 → 25.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +13 -18
  3. package/dist/chunks/{reserved-B9pG93Fc.mjs → reserved-6pAgFGgB.mjs} +69 -125
  4. package/dist/gtd.bundle.mjs +1 -1
  5. package/dist/testkit.mjs +3 -3
  6. package/dist/workflow-kit.mjs +47 -90
  7. package/dist/workflows/feature.mjs +25 -47
  8. package/dist/workflows/fix.mjs +1 -1
  9. package/dist/workflows/review.mjs +1 -1
  10. package/dist/workflows.mjs +135 -261
  11. package/package.json +1 -2
  12. package/src/replay/Replay.ts +2 -1
  13. package/src/workflow-kit/scenarios.test.ts +116 -0
  14. package/src/workflow-kit/scenarios.ts +53 -113
  15. package/src/workflows/feature/feature.ts +2 -5
  16. package/src/workflows/feature/packages.test.ts +17 -34
  17. package/src/workflows/feature/packages.ts +13 -52
  18. package/src/workflows/feature/planning.ts +1 -1
  19. package/src/workflows/feature/review.test.ts +0 -47
  20. package/src/workflows/feature/review.ts +6 -10
  21. package/src/workflows/index.ts +0 -2
  22. package/src/workflows/shared/health.test.ts +1 -21
  23. package/src/workflows/shared/health.ts +4 -39
  24. package/src/workflows/shared/options.ts +0 -1
  25. package/src/workflows/shared/steps.ts +16 -11
  26. package/src/workflows/shared/tail.test.ts +1 -41
  27. package/src/workflows/shared/tail.ts +14 -30
  28. package/src/workflows/shared/text.fixture.ts +1 -0
  29. package/src/workflows/shared/text.test.ts +23 -41
  30. package/src/workflows/shared/text.ts +54 -74
  31. package/src/workflows/shared/vars.ts +0 -1
  32. package/src/workflows/shared/scenarios.test.ts +0 -256
  33. package/src/workflows/shared/scenarios.ts +0 -35
@@ -1,256 +0,0 @@
1
- import { afterEach, describe, expect, it } from "vitest"
2
- import { installContext, type StepRequest } from "../../flows/index.js"
3
- import {
4
- driftedScenarios,
5
- freezeScenarios,
6
- guarded,
7
- holdWording,
8
- scenarioText,
9
- } from "./scenarios.js"
10
- import { fixtureContext, renderText } from "./text.fixture.js"
11
-
12
- afterEach(() => installContext(undefined))
13
-
14
- const tree = (files: Record<string, string>) => ({
15
- read: (path: string) => files[path],
16
- })
17
-
18
- describe("scenarioText", () => {
19
- it("ignores comments, tags, blank lines and indentation", () => {
20
- const loose = [
21
- "@wip @slow",
22
- "Feature: x",
23
- "",
24
- " # a comment",
25
- " @tag",
26
- " Scenario: y",
27
- " Given a thing",
28
- ].join("\n")
29
- const tight = "Feature: x\nScenario: y\nGiven a thing"
30
- expect(scenarioText(loose)).toBe(scenarioText(tight))
31
- })
32
-
33
- it("keeps changed wording", () => {
34
- expect(scenarioText("Given a")).not.toBe(scenarioText("Given b"))
35
- })
36
-
37
- it.each([
38
- ["a # line", "# Review: abc"],
39
- ["an @ line", "@mention"],
40
- ["a blank line", ""],
41
- ])("keeps %s inside a doc string", (_, line) => {
42
- const doc = (body: string[]) =>
43
- ["Given a file with:", ' """', ...body.map((l) => (l === "" ? "" : ` ${l}`)), ' """'].join(
44
- "\n",
45
- )
46
- expect(scenarioText(doc(["top", line, "end"]))).not.toBe(scenarioText(doc(["top", "end"])))
47
- })
48
-
49
- it("keeps indentation inside a doc string", () => {
50
- const doc = (line: string) => ["Given a file with:", ' """', ` ${line}`, ' """'].join("\n")
51
- expect(scenarioText(doc(" nested"))).not.toBe(scenarioText(doc("nested")))
52
- })
53
-
54
- it("ignores a doc string re-indented together with its delimiters", () => {
55
- const at = (indent: string) =>
56
- [
57
- "Given a file with:",
58
- `${indent}\`\`\``,
59
- `${indent}# Review`,
60
- `${indent} - x`,
61
- `${indent}\`\`\``,
62
- ].join("\n")
63
- expect(scenarioText(at(" "))).toBe(scenarioText(at(" ")))
64
- })
65
- })
66
-
67
- describe("freezeScenarios", () => {
68
- it("snapshots wording at head, nothing frozen is undefined", () => {
69
- const files = { "a.feature": "@t\nGiven a" }
70
- renderText(() => {
71
- expect(freezeScenarios([])).toBeUndefined()
72
- })
73
- installContext(fixtureContext({ head: "h1" }, tree(files)))
74
- expect(freezeScenarios(["a.feature"])).toEqual({
75
- at: "h1",
76
- texts: { "a.feature": scenarioText("Given a") },
77
- })
78
- })
79
-
80
- it("merges over previous, a new path overriding an old one", () => {
81
- installContext(
82
- fixtureContext({ head: "h2" }, tree({ "b.feature": "Given b", "a.feature": "Given a2" })),
83
- )
84
- const merged = freezeScenarios(["a.feature", "b.feature"], {
85
- at: "h1",
86
- texts: { "a.feature": "Given a", "c.feature": "Given c" },
87
- })
88
- expect(merged).toEqual({
89
- at: "h2",
90
- texts: {
91
- "a.feature": "Given a2",
92
- "b.feature": "Given b",
93
- "c.feature": "Given c",
94
- },
95
- })
96
- })
97
-
98
- it("keeps previous untouched when no paths are given", () => {
99
- const previous = { at: "h1", texts: { "a.feature": "Given a" } }
100
- installContext(fixtureContext({ head: "h2" }, tree({})))
101
- expect(freezeScenarios([], previous)).toBe(previous)
102
- })
103
- })
104
-
105
- describe("driftedScenarios", () => {
106
- const frozen = { at: "h1", texts: { "a.feature": "Given a", "b.feature": "Given b" } }
107
-
108
- it("flags edited and deleted files, ignores comment-only edits and other paths", () => {
109
- installContext(
110
- fixtureContext({}, tree({ "a.feature": "# note\nGiven a2", "other.feature": "Given z" })),
111
- )
112
- expect(driftedScenarios(frozen)).toEqual(["a.feature", "b.feature"])
113
- })
114
-
115
- it("is empty while wording is unchanged", () => {
116
- installContext(
117
- fixtureContext({}, tree({ "a.feature": " @x\n Given a", "b.feature": "Given b" })),
118
- )
119
- expect(driftedScenarios(frozen)).toEqual([])
120
- })
121
- })
122
-
123
- describe("holdWording", () => {
124
- const run = async (
125
- files: () => Record<string, string>,
126
- frozen: Parameters<typeof holdWording>[0],
127
- head = "h2",
128
- ): Promise<StepRequest[]> => {
129
- const steps: StepRequest[] = []
130
- installContext(
131
- fixtureContext(
132
- { head },
133
- {
134
- read: (path) => files()[path],
135
- step: (request) => {
136
- steps.push(request)
137
- return Promise.resolve()
138
- },
139
- },
140
- ),
141
- )
142
- await holdWording(frozen)
143
- return steps
144
- }
145
-
146
- it("does nothing without a freeze or without drift", async () => {
147
- expect(await run(() => ({}), undefined)).toEqual([])
148
- const frozen = { at: "h1", texts: { "a.feature": "Given a" } }
149
- expect(await run(() => ({ "a.feature": "Given a" }), frozen)).toEqual([])
150
- })
151
-
152
- it("rests at scenario-wording with the removed and added lines and the restore command", async () => {
153
- const frozen = { at: "h1", texts: { "my dir/a.feature": "Given a\nThen ok" } }
154
- const steps = await run(() => ({ "my dir/a.feature": "Given a\nThen better" }), frozen)
155
- const [step] = steps
156
- if (step?.kind !== "human") throw new Error("expected a human step")
157
- expect(step.name).toBe("scenario-wording")
158
- expect(step.options.acceptClean).toBe(true)
159
- expect(step.options.base).toBe("h1")
160
- const message = step.options.message ?? ""
161
- expect(message).toContain("- Then ok")
162
- expect(message).toContain("+ Then better")
163
- expect(message).toContain("git checkout h1 -- 'my dir/a.feature'")
164
- expect(message).toContain("partial accept")
165
- })
166
-
167
- const shownDiff = async (before: string, after: string): Promise<string[]> => {
168
- const frozen = { at: "h1", texts: { "a.feature": before } }
169
- const [step] = await run(() => ({ "a.feature": after }), frozen)
170
- if (step?.kind !== "human") throw new Error("expected a human step")
171
- return (step.options.message ?? "").split("\n").filter((l) => /^[-+] /.test(l))
172
- }
173
-
174
- it("shows a moved step as one removal and one addition of it, in place", async () => {
175
- expect(await shownDiff("Given a\nWhen b\nThen c", "When b\nGiven a\nThen c")).toEqual([
176
- "- Given a",
177
- "+ Given a",
178
- ])
179
- })
180
-
181
- it("shows a blank line added inside a doc string", async () => {
182
- expect(await shownDiff('"""\na\nb\n"""', '"""\na\n\nb\n"""')).toEqual(["+ "])
183
- })
184
-
185
- it("shows a repeated step as its added copy", async () => {
186
- expect(await shownDiff("Given a\nThen c", "Given a\nGiven a\nThen c")).toEqual(["+ Given a"])
187
- })
188
-
189
- it("accepts still-differing files by moving the snapshot to head", async () => {
190
- const frozen = { at: "h1", texts: { "a.feature": "Given a", "b.feature": "Given b" } }
191
- await run(() => ({ "a.feature": "Given a2", "b.feature": "Given b2" }), frozen)
192
- expect(frozen).toEqual({
193
- at: "h2",
194
- texts: { "a.feature": "Given a2", "b.feature": "Given b2" },
195
- })
196
- })
197
-
198
- it("rejects restored files, keeping the original wording frozen", async () => {
199
- const frozen = { at: "h1", texts: { "a.feature": "Given a", "b.feature": "Given b" } }
200
- let files: Record<string, string> = { "a.feature": "Given a2", "b.feature": "Given b2" }
201
- installContext(
202
- fixtureContext(
203
- { head: "h2" },
204
- {
205
- read: (path) => files[path],
206
- step: () => {
207
- files = { "a.feature": "Given a", "b.feature": "Given b2" }
208
- return Promise.resolve()
209
- },
210
- },
211
- ),
212
- )
213
- await holdWording(frozen)
214
- expect(frozen.texts).toEqual({ "a.feature": "Given a", "b.feature": "Given b2" })
215
- })
216
-
217
- it("drops an accepted deletion from the snapshot", async () => {
218
- const frozen = { at: "h1", texts: { "a.feature": "Given a" } }
219
- await run(() => ({}), frozen)
220
- expect(frozen.texts).toEqual({})
221
- })
222
- })
223
-
224
- describe("guarded", () => {
225
- it("runs the turn, then holds wording", async () => {
226
- const order: string[] = []
227
- const frozen = { at: "h1", texts: { "a.feature": "Given a" } }
228
- installContext(
229
- fixtureContext(
230
- { head: "h2" },
231
- {
232
- read: () => "Given changed",
233
- step: (request) => {
234
- order.push(request.kind)
235
- return Promise.resolve()
236
- },
237
- },
238
- ),
239
- )
240
- await guarded(frozen, () => {
241
- order.push("turn")
242
- return Promise.resolve()
243
- })()
244
- expect(order).toEqual(["turn", "human"])
245
- })
246
-
247
- it("is the bare turn without a freeze", async () => {
248
- const order: string[] = []
249
- installContext(fixtureContext())
250
- await guarded(undefined, () => {
251
- order.push("turn")
252
- return Promise.resolve()
253
- })()
254
- expect(order).toEqual(["turn"])
255
- })
256
- })
@@ -1,35 +0,0 @@
1
- import { human, scope } from "../../flows/index.js"
2
- import * as kit from "../../workflow-kit/index.js"
3
- import * as t from "./text.js"
4
-
5
- export {
6
- driftedScenarios,
7
- freezeScenarios,
8
- scenarioText,
9
- wordingDrift,
10
- type FrozenScenarios,
11
- } from "../../workflow-kit/index.js"
12
-
13
- /** After a turn: stop at `scenario-wording` while wording drifted; accept moves the snapshot. */
14
- export const holdWording = (frozen: kit.FrozenScenarios | undefined): Promise<void> =>
15
- kit.holdWording(frozen, (at, drifted) =>
16
- human("scenario-wording", {
17
- message: t.scenarioWordingMessage(at, drifted),
18
- label: "Scenario wording changed",
19
- acceptClean: true,
20
- base: at,
21
- }),
22
- )
23
-
24
- /** `turn`, then `holdWording`; `within` names the scope the gate rests in. */
25
- export const guarded =
26
- (
27
- frozen: kit.FrozenScenarios | undefined,
28
- turn: () => Promise<void>,
29
- within?: string,
30
- ): (() => Promise<void>) =>
31
- async () => {
32
- await turn()
33
- if (within === undefined) await holdWording(frozen)
34
- else await scope(within, () => holdWording(frozen))
35
- }