@pmelab/gtd 29.3.1 → 30.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +64 -63
- package/claude/hooks/register.tsx +5 -0
- package/dist/chunks/{reserved-Dmpg6dAp.mjs → reserved-DDiiAZ8U.mjs} +35 -239
- package/dist/chunks/{tail-CLyE5VFR.mjs → tail-ILPlELuy.mjs} +1 -1
- package/dist/gtd.bundle.mjs +0 -5
- package/dist/testkit.mjs +0 -5
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +17 -182
- 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 +47 -416
- package/package.json +1 -1
- package/skills/authoring/SKILL.md +15 -27
- package/src/cli/Cli.test.ts +6 -6
- package/src/edge/Edge.test.ts +7 -7
- package/src/flags/index.test.ts +0 -4
- package/src/flags/index.ts +0 -1
- package/src/workflow/load.test.ts +1 -1
- package/src/workflows/feature/fast-path.feature +1 -2
- package/src/workflows/feature/feature.test.ts +115 -63
- package/src/workflows/feature/feature.ts +21 -38
- package/src/workflows/feature/flag-mode.feature +157 -0
- package/src/workflows/feature/index.ts +0 -1
- package/src/workflows/feature/one-step-land.feature +4 -6
- package/src/workflows/feature/planning.ts +2 -27
- package/src/workflows/feature/refactor-absorbs.feature +1 -2
- package/src/workflows/feature/refactor-first.feature +1 -3
- package/src/workflows/feature/refactor-review.feature +0 -1
- package/src/workflows/feature/risk-tier.feature +2 -3
- package/src/workflows/feature/seam-rejection.feature +1 -3
- package/src/workflows/feature/seam.feature +4 -6
- package/src/workflows/feature/seam.ts +2 -0
- package/src/workflows/feature/steps.test.ts +0 -20
- package/src/workflows/feature/steps.ts +1 -49
- package/src/workflows/feature/{seam-pin.feature → tier-pin.feature} +17 -11
- package/src/workflows/feature/tier.test.ts +3 -5
- package/src/workflows/feature/tier.ts +2 -2
- package/src/workflows/index.ts +0 -1
- package/src/workflows/shared/access.test.ts +0 -2
- package/src/workflows/shared/access.ts +0 -2
- package/src/workflows/shared/options.test.ts +14 -3
- package/src/workflows/shared/options.ts +17 -2
- package/src/workflows/shared/prose.ts +4 -11
- package/src/workflows/shared/reserved.ts +2 -2
- package/src/workflows/shared/skills.test.ts +0 -11
- package/src/workflows/shared/skills.ts +2 -9
- package/src/workflows/shared/steps.ts +0 -1
- package/src/workflows/shared/text.test.ts +20 -104
- package/src/workflows/shared/text.ts +28 -229
- package/src/workflows/shared/vars.ts +2 -3
- package/src/workflows/feature/packages.test.ts +0 -368
- package/src/workflows/feature/packages.ts +0 -173
|
@@ -1,368 +0,0 @@
|
|
|
1
|
-
import { afterEach, describe, expect, it } from "vitest"
|
|
2
|
-
import { installContext, type Change, type Changes, type StepRequest } from "../../flows/index.js"
|
|
3
|
-
import { declaredTests, packages, requireDeclaredTests } from "./packages.js"
|
|
4
|
-
import { fixtureContext } from "../shared/text.fixture.js"
|
|
5
|
-
|
|
6
|
-
afterEach(() => installContext(undefined))
|
|
7
|
-
|
|
8
|
-
describe("declaredTests", () => {
|
|
9
|
-
it("parses unit and e2e lines of the Tests section", () => {
|
|
10
|
-
const text = [
|
|
11
|
-
"# Package",
|
|
12
|
-
"",
|
|
13
|
-
"## Tests",
|
|
14
|
-
"",
|
|
15
|
-
"- unit: `lib/a.test.ts`",
|
|
16
|
-
"- e2e: `spec/a.feature`",
|
|
17
|
-
].join("\n")
|
|
18
|
-
expect(declaredTests(text)).toEqual([
|
|
19
|
-
{ level: "unit", path: "lib/a.test.ts", unchanged: false },
|
|
20
|
-
{ level: "e2e", path: "spec/a.feature", unchanged: false },
|
|
21
|
-
])
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
it("ignores prose, malformed lines and other sections", () => {
|
|
25
|
-
const text = [
|
|
26
|
-
"## Tasks",
|
|
27
|
-
"- unit: `lib/not-here.test.ts`",
|
|
28
|
-
"## Tests",
|
|
29
|
-
"Some prose.",
|
|
30
|
-
"- unit: lib/unquoted.test.ts",
|
|
31
|
-
"- integration: `lib/x.test.ts`",
|
|
32
|
-
"- unit: `lib/ok.test.ts` extra",
|
|
33
|
-
"## Notes",
|
|
34
|
-
"- e2e: `spec/other.feature`",
|
|
35
|
-
].join("\n")
|
|
36
|
-
expect(declaredTests(text)).toEqual([
|
|
37
|
-
{ level: "unit", path: "lib/ok.test.ts", unchanged: false },
|
|
38
|
-
])
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
it("marks a whitespace-led, case-insensitive `(unchanged)` ending at a boundary", () => {
|
|
42
|
-
const text = [
|
|
43
|
-
"## Tests",
|
|
44
|
-
"- unit: `a.test.ts` (unchanged)",
|
|
45
|
-
"- unit: `b.test.ts` extra",
|
|
46
|
-
"- unit: `c.test.ts` (unchanged)x",
|
|
47
|
-
"- unit: `f.test.ts` (unchanged) — kept as is",
|
|
48
|
-
"- unit: `d.test.ts` (Unchanged)",
|
|
49
|
-
"- e2e: `e.feature` (unchanged)",
|
|
50
|
-
"- unit: `g.test.ts`\t(unchanged)",
|
|
51
|
-
"- unit: `h.test.ts` (unchanged)",
|
|
52
|
-
"- unit: `i.test.ts`(unchanged)",
|
|
53
|
-
"- Unit: `j.test.ts` (unchanged)",
|
|
54
|
-
].join("\n")
|
|
55
|
-
expect(declaredTests(text).map((t) => [t.path, t.unchanged])).toEqual([
|
|
56
|
-
["a.test.ts", true],
|
|
57
|
-
["b.test.ts", false],
|
|
58
|
-
["c.test.ts", false],
|
|
59
|
-
["f.test.ts", true],
|
|
60
|
-
["d.test.ts", true],
|
|
61
|
-
["e.feature", true],
|
|
62
|
-
["g.test.ts", true],
|
|
63
|
-
["h.test.ts", true],
|
|
64
|
-
["i.test.ts", false],
|
|
65
|
-
])
|
|
66
|
-
})
|
|
67
|
-
|
|
68
|
-
it("never joins a marker across an empty or whitespace-only line", () => {
|
|
69
|
-
for (const gap of ["\n\n", "\n \n", "\n\t\n"]) {
|
|
70
|
-
const text = `## Tests\n- unit: \`a.test.ts\`${gap} (unchanged)\n`
|
|
71
|
-
expect(declaredTests(text).map((t) => t.unchanged)).toEqual([false])
|
|
72
|
-
}
|
|
73
|
-
})
|
|
74
|
-
|
|
75
|
-
it("reads an entry whose path or marker is wrapped, in any line-end style", () => {
|
|
76
|
-
const forms = [
|
|
77
|
-
"- unit: `a/long.test.ts` (unchanged)",
|
|
78
|
-
"- unit:\n `a/long.test.ts` (unchanged)",
|
|
79
|
-
"- unit: `a/long.test.ts`\n (unchanged)",
|
|
80
|
-
"- unit:\n `a/long.test.ts`\n (unchanged)",
|
|
81
|
-
]
|
|
82
|
-
for (const form of forms) {
|
|
83
|
-
const lf = `## Tests\n${form}\n- e2e: \`b.feature\`\n`
|
|
84
|
-
const want = [
|
|
85
|
-
{ level: "unit", path: "a/long.test.ts", unchanged: true },
|
|
86
|
-
{ level: "e2e", path: "b.feature", unchanged: false },
|
|
87
|
-
]
|
|
88
|
-
expect(declaredTests(lf)).toEqual(want)
|
|
89
|
-
expect(declaredTests(lf.replace(/\n/g, "\r\n"))).toEqual(want)
|
|
90
|
-
expect(declaredTests(lf.replace(/\n/g, "\r"))).toEqual(want)
|
|
91
|
-
}
|
|
92
|
-
})
|
|
93
|
-
|
|
94
|
-
it("keeps the heading and its entries when an indented marker follows `## Tests`", () => {
|
|
95
|
-
const text = "## Tests\n (unchanged)\n- unit: `a.test.ts`\n"
|
|
96
|
-
expect(declaredTests(text)).toEqual([{ level: "unit", path: "a.test.ts", unchanged: false }])
|
|
97
|
-
})
|
|
98
|
-
|
|
99
|
-
it("never extends an entry across a new item or a heading", () => {
|
|
100
|
-
const text = [
|
|
101
|
-
"## Tests",
|
|
102
|
-
"- unit: `a.test.ts`",
|
|
103
|
-
" - unit: `b.test.ts` (unchanged)",
|
|
104
|
-
"- unit: `c.test.ts`",
|
|
105
|
-
"## Tests",
|
|
106
|
-
" (unchanged)",
|
|
107
|
-
].join("\n")
|
|
108
|
-
expect(declaredTests(text).map((t) => [t.path, t.unchanged])).toEqual([
|
|
109
|
-
["a.test.ts", false],
|
|
110
|
-
["b.test.ts", true],
|
|
111
|
-
["c.test.ts", false],
|
|
112
|
-
])
|
|
113
|
-
})
|
|
114
|
-
|
|
115
|
-
it("closes an entry on an unindented line", () => {
|
|
116
|
-
const text = "## Tests\n- unit: `a.test.ts`\n(unchanged)\n"
|
|
117
|
-
expect(declaredTests(text).map((t) => t.unchanged)).toEqual([false])
|
|
118
|
-
})
|
|
119
|
-
|
|
120
|
-
it("returns nothing without a Tests section", () => {
|
|
121
|
-
expect(declaredTests("# Chore\n\n## Tasks\n- do it\n")).toEqual([])
|
|
122
|
-
})
|
|
123
|
-
})
|
|
124
|
-
|
|
125
|
-
describe("packages", () => {
|
|
126
|
-
it("closes every queued package in name order, the plan with the last", async () => {
|
|
127
|
-
const queue = [".gtd/packages/02-b.md", ".gtd/packages/01-a.md"]
|
|
128
|
-
const closings: string[] = []
|
|
129
|
-
installContext(
|
|
130
|
-
fixtureContext(
|
|
131
|
-
{},
|
|
132
|
-
{
|
|
133
|
-
step: (request: StepRequest) => {
|
|
134
|
-
if (request.kind === "run" && request.name === "closing") {
|
|
135
|
-
closings.push(request.body)
|
|
136
|
-
queue.splice(queue.indexOf([...queue].sort()[0]!), 1)
|
|
137
|
-
}
|
|
138
|
-
return Promise.resolve()
|
|
139
|
-
},
|
|
140
|
-
pushScope: () => undefined,
|
|
141
|
-
popScope: () => undefined,
|
|
142
|
-
glob: () => [...queue],
|
|
143
|
-
changesSince: (): readonly Change[] => [],
|
|
144
|
-
head: () => "c",
|
|
145
|
-
},
|
|
146
|
-
),
|
|
147
|
-
)
|
|
148
|
-
await packages()
|
|
149
|
-
expect(closings).toHaveLength(2)
|
|
150
|
-
expect(closings[0]).toContain("01-a.md")
|
|
151
|
-
expect(closings[0]).not.toContain("ARCHITECTURE")
|
|
152
|
-
expect(closings[1]).toContain("02-b.md")
|
|
153
|
-
expect(closings[1]).toContain(".gtd/ARCHITECTURE.md")
|
|
154
|
-
})
|
|
155
|
-
})
|
|
156
|
-
|
|
157
|
-
describe("requireDeclaredTests", () => {
|
|
158
|
-
const run = (
|
|
159
|
-
text: string,
|
|
160
|
-
tree: readonly string[],
|
|
161
|
-
changes: readonly Change[],
|
|
162
|
-
satisfied = false,
|
|
163
|
-
turn: "build" | "fix" | "default" = "build",
|
|
164
|
-
) => {
|
|
165
|
-
const pkg = ".gtd/packages/01-a.md"
|
|
166
|
-
installContext(
|
|
167
|
-
fixtureContext(
|
|
168
|
-
{},
|
|
169
|
-
{
|
|
170
|
-
read: (path: string) =>
|
|
171
|
-
path === pkg
|
|
172
|
-
? text
|
|
173
|
-
: path === ".gtd/SATISFIED.md"
|
|
174
|
-
? satisfied
|
|
175
|
-
? "x"
|
|
176
|
-
: undefined
|
|
177
|
-
: tree.includes(path)
|
|
178
|
-
? "x"
|
|
179
|
-
: undefined,
|
|
180
|
-
refuse: (message: string): never => {
|
|
181
|
-
throw new Error(message)
|
|
182
|
-
},
|
|
183
|
-
changesSince: (): Changes => {
|
|
184
|
-
const list: Change[] = [
|
|
185
|
-
{ path: pkg, status: "modified", before: text, after: text },
|
|
186
|
-
...changes,
|
|
187
|
-
]
|
|
188
|
-
return Object.assign(list, {
|
|
189
|
-
paths: list.map((c) => c.path),
|
|
190
|
-
get: (path: string) => list.find((c) => c.path === path),
|
|
191
|
-
})
|
|
192
|
-
},
|
|
193
|
-
},
|
|
194
|
-
),
|
|
195
|
-
)
|
|
196
|
-
let message = ""
|
|
197
|
-
try {
|
|
198
|
-
if (turn === "default") requireDeclaredTests(pkg, "c")
|
|
199
|
-
else requireDeclaredTests(pkg, "c", turn)
|
|
200
|
-
} catch (error) {
|
|
201
|
-
message = String(error instanceof Error ? error.message : error)
|
|
202
|
-
}
|
|
203
|
-
return { message }
|
|
204
|
-
}
|
|
205
|
-
const deleted = (path: string): Change => ({
|
|
206
|
-
path,
|
|
207
|
-
status: "deleted",
|
|
208
|
-
before: "x",
|
|
209
|
-
after: undefined,
|
|
210
|
-
})
|
|
211
|
-
|
|
212
|
-
it("groups an unwritten unmarked test and a missing marked test in one refusal", () => {
|
|
213
|
-
const text = [
|
|
214
|
-
"## Tests",
|
|
215
|
-
"- unit: `lib/unwritten.test.ts`",
|
|
216
|
-
"- unit: `lib/gone.test.ts` (unchanged)",
|
|
217
|
-
"- unit: `lib/kept.test.ts` (unchanged)",
|
|
218
|
-
].join("\n")
|
|
219
|
-
const { message } = run(text, ["lib/kept.test.ts"], [])
|
|
220
|
-
expect(message).toContain("gtd land: declared-tests:")
|
|
221
|
-
expect(message).not.toMatch(/satisf/i)
|
|
222
|
-
const diffAt = message.indexOf("not in the package's diff")
|
|
223
|
-
const treeAt = message.indexOf("missing from the tree")
|
|
224
|
-
expect(diffAt).toBeGreaterThanOrEqual(0)
|
|
225
|
-
expect(treeAt).toBeGreaterThan(diffAt)
|
|
226
|
-
expect(message.indexOf(" - lib/unwritten.test.ts")).toBeGreaterThan(diffAt)
|
|
227
|
-
expect(message.indexOf(" - lib/unwritten.test.ts")).toBeLessThan(treeAt)
|
|
228
|
-
expect(message.indexOf(" - lib/gone.test.ts")).toBeGreaterThan(treeAt)
|
|
229
|
-
expect(message).not.toContain("kept.test.ts")
|
|
230
|
-
})
|
|
231
|
-
|
|
232
|
-
it("sends a marked deleted test to restore only", () => {
|
|
233
|
-
const { message } = run("## Tests\n- unit: `a.test.ts` (unchanged)", [], [deleted("a.test.ts")])
|
|
234
|
-
expect(message).toContain("declares tests this turn left unmet:")
|
|
235
|
-
expect(message).toContain("marked (unchanged) but deleted — restore them:\n - a.test.ts")
|
|
236
|
-
expect(message).not.toContain("missing from the tree")
|
|
237
|
-
expect(message).not.toContain("write them")
|
|
238
|
-
})
|
|
239
|
-
|
|
240
|
-
it("keeps a marked absent test with no diff change under missing", () => {
|
|
241
|
-
const { message } = run("## Tests\n- unit: `a.test.ts` (unchanged)", [], [])
|
|
242
|
-
expect(message).toContain("missing from the tree:\n - a.test.ts")
|
|
243
|
-
expect(message).not.toContain("restore")
|
|
244
|
-
})
|
|
245
|
-
|
|
246
|
-
it("keeps an unmarked deleted test under write, or missing once satisfied", () => {
|
|
247
|
-
const text = "## Tests\n- unit: `a.test.ts`"
|
|
248
|
-
expect(run(text, [], [deleted("a.test.ts")]).message).toContain(
|
|
249
|
-
"not in the package's diff — write them:\n - a.test.ts",
|
|
250
|
-
)
|
|
251
|
-
const done = run(text, [], [deleted("a.test.ts")], true).message
|
|
252
|
-
expect(done).toContain("missing from the tree:\n - a.test.ts")
|
|
253
|
-
expect(done).not.toContain("restore")
|
|
254
|
-
})
|
|
255
|
-
|
|
256
|
-
const modified = (path: string): Change => ({
|
|
257
|
-
path,
|
|
258
|
-
status: "modified",
|
|
259
|
-
before: "x",
|
|
260
|
-
after: "y",
|
|
261
|
-
})
|
|
262
|
-
|
|
263
|
-
it("refuses a marked test the build modified, even with SATISFIED.md", () => {
|
|
264
|
-
const text = "## Tests\n- unit: `a.test.ts` (unchanged)"
|
|
265
|
-
for (const satisfied of [false, true]) {
|
|
266
|
-
const { message } = run(text, ["a.test.ts"], [modified("a.test.ts")], satisfied)
|
|
267
|
-
expect(message).toContain("marked (unchanged) but modified — revert them:\n - a.test.ts")
|
|
268
|
-
}
|
|
269
|
-
})
|
|
270
|
-
|
|
271
|
-
it("lets a fix turn modify a marked test but not delete it", () => {
|
|
272
|
-
const text = "## Tests\n- unit: `a.test.ts` (unchanged)\n- unit: `b.test.ts` (unchanged)"
|
|
273
|
-
expect(run(text, ["a.test.ts"], [modified("a.test.ts")], false, "fix").message).toContain(
|
|
274
|
-
"missing from the tree:\n - b.test.ts",
|
|
275
|
-
)
|
|
276
|
-
expect(run(text, ["a.test.ts"], [modified("a.test.ts")], false, "fix").message).not.toContain(
|
|
277
|
-
"revert",
|
|
278
|
-
)
|
|
279
|
-
const gone = run(text, [], [deleted("a.test.ts"), deleted("b.test.ts")], false, "fix").message
|
|
280
|
-
expect(gone).toContain("restore them:\n - a.test.ts\n - b.test.ts")
|
|
281
|
-
const clean = "## Tests\n- unit: `a.test.ts` (unchanged)"
|
|
282
|
-
expect(run(clean, ["a.test.ts"], [modified("a.test.ts")], false, "fix").message).toBe("")
|
|
283
|
-
})
|
|
284
|
-
|
|
285
|
-
it("lets an unmarked entry for the same file win over a marked one", () => {
|
|
286
|
-
const text = "## Tests\n- e2e: `x.feature`\n- e2e: `x.feature` (unchanged)"
|
|
287
|
-
expect(run(text, ["x.feature"], [modified("x.feature")]).message).toBe("")
|
|
288
|
-
})
|
|
289
|
-
|
|
290
|
-
const added = (path: string): Change => ({
|
|
291
|
-
path,
|
|
292
|
-
status: "added",
|
|
293
|
-
before: undefined,
|
|
294
|
-
after: "x",
|
|
295
|
-
})
|
|
296
|
-
const addedGroup =
|
|
297
|
-
"marked (unchanged) but added by this turn — the marker promises an existing test; stop and leave it to a person"
|
|
298
|
-
|
|
299
|
-
it("refuses a marked test the build added, as added and never as modified", () => {
|
|
300
|
-
const { message } = run(
|
|
301
|
-
"## Tests\n- unit: `a.test.ts` (unchanged)",
|
|
302
|
-
["a.test.ts"],
|
|
303
|
-
[added("a.test.ts")],
|
|
304
|
-
)
|
|
305
|
-
expect(message).toContain(`${addedGroup}:\n - a.test.ts`)
|
|
306
|
-
expect(message).not.toContain("revert")
|
|
307
|
-
expect(message).not.toMatch(/delete|restore/i)
|
|
308
|
-
})
|
|
309
|
-
|
|
310
|
-
it("only the build turn refuses an added marked test (turn split, not a fix-turn flow)", () => {
|
|
311
|
-
const text = "## Tests\n- unit: `a.test.ts` (unchanged)"
|
|
312
|
-
expect(run(text, ["a.test.ts"], [added("a.test.ts")], false, "fix").message).toBe("")
|
|
313
|
-
})
|
|
314
|
-
|
|
315
|
-
it.each([
|
|
316
|
-
["modified", modified("a.test.ts")],
|
|
317
|
-
["added", added("a.test.ts")],
|
|
318
|
-
] as [string, Change][])("defaults to the fix turn with two arguments (%s)", (_label, change) => {
|
|
319
|
-
const text = "## Tests\n- unit: `a.test.ts` (unchanged)"
|
|
320
|
-
expect(run(text, ["a.test.ts"], [change], false, "default").message).toBe("")
|
|
321
|
-
expect(run(text, ["a.test.ts"], [change], false, "build").message).not.toBe("")
|
|
322
|
-
})
|
|
323
|
-
|
|
324
|
-
it("lets an unmarked entry win over a marked one the build added", () => {
|
|
325
|
-
const text = "## Tests\n- unit: `a.test.ts`\n- unit: `a.test.ts` (unchanged)"
|
|
326
|
-
expect(run(text, ["a.test.ts"], [added("a.test.ts")]).message).toBe("")
|
|
327
|
-
})
|
|
328
|
-
|
|
329
|
-
it("lists a duplicated unwritten line once", () => {
|
|
330
|
-
const text = "## Tests\n- unit: `a.test.ts`\n- unit: `a.test.ts`"
|
|
331
|
-
const { message } = run(text, [], [])
|
|
332
|
-
expect(message.split("a.test.ts").length - 1).toBe(1)
|
|
333
|
-
})
|
|
334
|
-
|
|
335
|
-
it("lists a duplicated marked line missing from the tree once", () => {
|
|
336
|
-
const text = "## Tests\n- unit: `a.test.ts` (unchanged)\n- unit: `a.test.ts` (unchanged)"
|
|
337
|
-
const { message } = run(text, [], [])
|
|
338
|
-
expect(message.split("a.test.ts").length - 1).toBe(1)
|
|
339
|
-
})
|
|
340
|
-
|
|
341
|
-
it("orders write, revert, added, restore, missing and never says satisfied", () => {
|
|
342
|
-
const text = [
|
|
343
|
-
"## Tests",
|
|
344
|
-
"- unit: `w.test.ts`",
|
|
345
|
-
"- unit: `r.test.ts` (unchanged)",
|
|
346
|
-
"- unit: `m.test.ts` (unchanged)",
|
|
347
|
-
"- unit: `v.test.ts` (unchanged)",
|
|
348
|
-
"- unit: `a.test.ts` (unchanged)",
|
|
349
|
-
].join("\n")
|
|
350
|
-
const { message } = run(
|
|
351
|
-
text,
|
|
352
|
-
["v.test.ts", "a.test.ts"],
|
|
353
|
-
[deleted("r.test.ts"), modified("v.test.ts"), added("a.test.ts")],
|
|
354
|
-
)
|
|
355
|
-
const w = message.indexOf("write them")
|
|
356
|
-
const v = message.indexOf("revert them")
|
|
357
|
-
expect(v).toBeGreaterThan(w)
|
|
358
|
-
const ad = message.indexOf("added by this turn")
|
|
359
|
-
expect(ad).toBeGreaterThan(v)
|
|
360
|
-
const r = message.indexOf("restore them")
|
|
361
|
-
expect(r).toBeGreaterThan(ad)
|
|
362
|
-
const m = message.indexOf("missing from the tree")
|
|
363
|
-
expect(w).toBeGreaterThan(0)
|
|
364
|
-
expect(r).toBeGreaterThan(w)
|
|
365
|
-
expect(m).toBeGreaterThan(r)
|
|
366
|
-
expect(message).not.toMatch(/satisf/i)
|
|
367
|
-
})
|
|
368
|
-
})
|
|
@@ -1,173 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
changesSince,
|
|
3
|
-
glob,
|
|
4
|
-
head,
|
|
5
|
-
read,
|
|
6
|
-
refuse,
|
|
7
|
-
removeScript,
|
|
8
|
-
run,
|
|
9
|
-
scope,
|
|
10
|
-
} from "../../flows/index.js"
|
|
11
|
-
import { healthy } from "../shared/health.js"
|
|
12
|
-
import { ARCHITECTURE } from "../shared/steps.js"
|
|
13
|
-
import { build, fixSuite } from "./steps.js"
|
|
14
|
-
|
|
15
|
-
export interface DeclaredTest {
|
|
16
|
-
readonly level: "unit" | "e2e"
|
|
17
|
-
readonly path: string
|
|
18
|
-
readonly unchanged: boolean
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const TEST_LINE = /^-\s+(unit|e2e):\s+`([^`]+)`(.*)$/
|
|
22
|
-
const MARKER = /^[ \t]+\(unchanged\)(?=\s|$)/i
|
|
23
|
-
const ITEM = /^\s*-\s/
|
|
24
|
-
|
|
25
|
-
/** The `## Tests` entries of a package file; malformed entries are ignored. */
|
|
26
|
-
export const declaredTests = (packageText: string): readonly DeclaredTest[] => {
|
|
27
|
-
const found: DeclaredTest[] = []
|
|
28
|
-
let inTests = false
|
|
29
|
-
let entry: string | undefined
|
|
30
|
-
const flush = () => {
|
|
31
|
-
const match = entry === undefined ? null : TEST_LINE.exec(entry)
|
|
32
|
-
if (match)
|
|
33
|
-
found.push({
|
|
34
|
-
level: match[1] as DeclaredTest["level"],
|
|
35
|
-
path: match[2]!,
|
|
36
|
-
unchanged: MARKER.test(match[3]!),
|
|
37
|
-
})
|
|
38
|
-
entry = undefined
|
|
39
|
-
}
|
|
40
|
-
// An entry is a list line plus its indented continuation lines (oxfmt wraps
|
|
41
|
-
// long ones); a blank line, a new item or a heading ends it.
|
|
42
|
-
for (const line of packageText.replace(/\r\n?/g, "\n").split("\n")) {
|
|
43
|
-
if (/^##\s/.test(line)) {
|
|
44
|
-
flush()
|
|
45
|
-
inTests = /^##\s+Tests\s*$/.test(line)
|
|
46
|
-
} else if (!inTests) continue
|
|
47
|
-
else if (ITEM.test(line)) {
|
|
48
|
-
flush()
|
|
49
|
-
entry = line.trim()
|
|
50
|
-
} else if (entry !== undefined && /^\s/.test(line) && line.trim() !== "")
|
|
51
|
-
entry = `${entry} ${line.trim()}`
|
|
52
|
-
else flush()
|
|
53
|
-
}
|
|
54
|
-
flush()
|
|
55
|
-
return found
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export type DeclaredTestsTurn = "build" | "fix"
|
|
59
|
-
|
|
60
|
-
type Unmet = "notInDiff" | "modifiedMarked" | "addedMarked" | "deletedMarked" | "notInTree"
|
|
61
|
-
|
|
62
|
-
type Declared = { path: string; unchanged: boolean }
|
|
63
|
-
|
|
64
|
-
const diffBucket = (
|
|
65
|
-
test: Declared,
|
|
66
|
-
status: string | undefined,
|
|
67
|
-
turn: DeclaredTestsTurn,
|
|
68
|
-
satisfied: boolean,
|
|
69
|
-
): Unmet | undefined => {
|
|
70
|
-
if (test.unchanged) {
|
|
71
|
-
if (turn !== "build") return undefined
|
|
72
|
-
if (status === "modified") return "modifiedMarked"
|
|
73
|
-
return status === "added" ? "addedMarked" : undefined
|
|
74
|
-
}
|
|
75
|
-
return !satisfied && (status === undefined || status === "deleted") ? "notInDiff" : undefined
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
const treeBucket = (test: Declared, status: string | undefined): Unmet | undefined => {
|
|
79
|
-
if (read(test.path) !== undefined) return undefined
|
|
80
|
-
return test.unchanged && status === "deleted" ? "deletedMarked" : "notInTree"
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const unmetBucket = (
|
|
84
|
-
test: Declared,
|
|
85
|
-
change: { status: string } | undefined,
|
|
86
|
-
turn: DeclaredTestsTurn,
|
|
87
|
-
satisfied: boolean,
|
|
88
|
-
): Unmet | undefined =>
|
|
89
|
-
diffBucket(test, change?.status, turn, satisfied) ?? treeBucket(test, change?.status)
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Refuse a build or fix turn unless every test `pkg` declares exists and is in the
|
|
93
|
-
* diff since `since`. The package text is read as of `since`, so a builder
|
|
94
|
-
* editing its own `## Tests` (or adding `(unchanged)`) cannot dodge the guard;
|
|
95
|
-
* with `.gtd/SATISFIED.md` written, or the line marked, existing suffices. The
|
|
96
|
-
* build turn must neither modify nor add a marked test; a fix turn may edit it.
|
|
97
|
-
*/
|
|
98
|
-
export const requireDeclaredTests = (
|
|
99
|
-
pkg: string,
|
|
100
|
-
since: string,
|
|
101
|
-
turn: DeclaredTestsTurn = "fix",
|
|
102
|
-
): void => {
|
|
103
|
-
const range = changesSince(since)
|
|
104
|
-
const text = range.get(pkg)?.before ?? read(pkg) ?? ""
|
|
105
|
-
const satisfied = read(".gtd/SATISFIED.md") !== undefined
|
|
106
|
-
const buckets = {
|
|
107
|
-
notInDiff: [] as string[],
|
|
108
|
-
modifiedMarked: [] as string[],
|
|
109
|
-
addedMarked: [] as string[],
|
|
110
|
-
deletedMarked: [] as string[],
|
|
111
|
-
notInTree: [] as string[],
|
|
112
|
-
}
|
|
113
|
-
const entries = declaredTests(text)
|
|
114
|
-
const seen = new Set<string>()
|
|
115
|
-
for (const test of entries) {
|
|
116
|
-
// A file listed both ways is written by the package: the unmarked entry wins.
|
|
117
|
-
if (test.unchanged && entries.some((e) => !e.unchanged && e.path === test.path)) continue
|
|
118
|
-
const bucket = unmetBucket(test, range.get(test.path), turn, satisfied)
|
|
119
|
-
if (bucket && !seen.has(test.path)) {
|
|
120
|
-
seen.add(test.path)
|
|
121
|
-
buckets[bucket].push(test.path)
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
const { notInDiff, modifiedMarked, addedMarked, deletedMarked, notInTree } = buckets
|
|
125
|
-
const groups = [
|
|
126
|
-
["not in the package's diff — write them", notInDiff],
|
|
127
|
-
["marked (unchanged) but modified — revert them", modifiedMarked],
|
|
128
|
-
[
|
|
129
|
-
"marked (unchanged) but added by this turn — the marker promises an existing test; stop and leave it to a person",
|
|
130
|
-
addedMarked,
|
|
131
|
-
],
|
|
132
|
-
["marked (unchanged) but deleted — restore them", deletedMarked],
|
|
133
|
-
["missing from the tree", notInTree],
|
|
134
|
-
] as const
|
|
135
|
-
const reasons = groups.filter(([, paths]) => paths.length > 0)
|
|
136
|
-
if (reasons.length === 0) return
|
|
137
|
-
refuse(
|
|
138
|
-
`gtd land: declared-tests: ${pkg} declares tests this turn left unmet:\n${reasons
|
|
139
|
-
.map(([reason, paths]) => `${reason}:\n${paths.map((path) => ` - ${path}`).join("\n")}`)
|
|
140
|
-
.join("\n")}`,
|
|
141
|
-
)
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* Build `pkg`, check its declared tests, keep the full suite green, and close
|
|
146
|
-
* it out, which removes it — and `.gtd/ARCHITECTURE.md` with the last one.
|
|
147
|
-
*/
|
|
148
|
-
export const packageItem = async (pkg: string): Promise<void> => {
|
|
149
|
-
const from = head()
|
|
150
|
-
// Rechecked after every fix too: a fix deleting a red declared test would
|
|
151
|
-
// otherwise green the suite.
|
|
152
|
-
const declared = (turn: () => Promise<void>, kind: DeclaredTestsTurn) => async () => {
|
|
153
|
-
await turn()
|
|
154
|
-
requireDeclaredTests(pkg, from, kind)
|
|
155
|
-
}
|
|
156
|
-
await declared(() => build(pkg), "build")()
|
|
157
|
-
await healthy(declared(fixSuite, "fix"))
|
|
158
|
-
const last = glob(".gtd/packages/*.md").length === 1
|
|
159
|
-
await run("closing", removeScript([pkg, ".gtd/SATISFIED.md", ...(last ? [ARCHITECTURE] : [])]), {
|
|
160
|
-
label: "Closing out the package",
|
|
161
|
-
})
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/** The first queued package — the one a package step works on. */
|
|
165
|
-
export const nextPackage = (): string | undefined => [...glob(".gtd/packages/*.md")].sort()[0]
|
|
166
|
-
|
|
167
|
-
/** Build every package file under `.gtd/packages/`, in name order. */
|
|
168
|
-
export const packages = (): Promise<void> =>
|
|
169
|
-
scope("packages", async () => {
|
|
170
|
-
for (let pkg = nextPackage(); pkg !== undefined; pkg = nextPackage()) {
|
|
171
|
-
await scope("item", () => packageItem(pkg))
|
|
172
|
-
}
|
|
173
|
-
})
|