@pmelab/gtd 29.3.1 → 30.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 (50) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +55 -63
  3. package/dist/chunks/{reserved-Dmpg6dAp.mjs → reserved-iG86IR3I.mjs} +19 -233
  4. package/dist/chunks/{tail-CLyE5VFR.mjs → tail-DA2UuJNP.mjs} +1 -1
  5. package/dist/gtd.bundle.mjs +0 -5
  6. package/dist/testkit.mjs +0 -5
  7. package/dist/workflows/cleanup.mjs +2 -2
  8. package/dist/workflows/feature.mjs +14 -180
  9. package/dist/workflows/fix.mjs +2 -2
  10. package/dist/workflows/review.mjs +2 -2
  11. package/dist/workflows/rollout.mjs +1 -1
  12. package/dist/workflows.mjs +29 -409
  13. package/package.json +1 -1
  14. package/skills/authoring/SKILL.md +15 -27
  15. package/src/cli/Cli.test.ts +6 -6
  16. package/src/edge/Edge.test.ts +7 -7
  17. package/src/flags/index.test.ts +0 -4
  18. package/src/flags/index.ts +0 -1
  19. package/src/workflow/load.test.ts +1 -1
  20. package/src/workflows/feature/fast-path.feature +1 -2
  21. package/src/workflows/feature/feature.test.ts +74 -63
  22. package/src/workflows/feature/feature.ts +13 -35
  23. package/src/workflows/feature/index.ts +0 -1
  24. package/src/workflows/feature/one-step-land.feature +4 -6
  25. package/src/workflows/feature/planning.ts +2 -27
  26. package/src/workflows/feature/refactor-absorbs.feature +1 -2
  27. package/src/workflows/feature/refactor-first.feature +1 -3
  28. package/src/workflows/feature/refactor-review.feature +0 -1
  29. package/src/workflows/feature/risk-tier.feature +2 -3
  30. package/src/workflows/feature/seam-rejection.feature +1 -3
  31. package/src/workflows/feature/seam.feature +4 -6
  32. package/src/workflows/feature/steps.test.ts +0 -20
  33. package/src/workflows/feature/steps.ts +1 -49
  34. package/src/workflows/feature/{seam-pin.feature → tier-pin.feature} +17 -11
  35. package/src/workflows/feature/tier.test.ts +3 -5
  36. package/src/workflows/feature/tier.ts +2 -2
  37. package/src/workflows/index.ts +0 -1
  38. package/src/workflows/shared/access.test.ts +0 -2
  39. package/src/workflows/shared/access.ts +0 -2
  40. package/src/workflows/shared/options.test.ts +2 -3
  41. package/src/workflows/shared/prose.ts +4 -11
  42. package/src/workflows/shared/reserved.ts +2 -2
  43. package/src/workflows/shared/skills.test.ts +0 -11
  44. package/src/workflows/shared/skills.ts +2 -9
  45. package/src/workflows/shared/steps.ts +0 -1
  46. package/src/workflows/shared/text.test.ts +4 -104
  47. package/src/workflows/shared/text.ts +15 -223
  48. package/src/workflows/shared/vars.ts +1 -3
  49. package/src/workflows/feature/packages.test.ts +0 -368
  50. 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
- })