@pmelab/gtd 45.2.0 → 46.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.
@@ -1,270 +0,0 @@
1
- import {
2
- changes,
3
- changesSince,
4
- glob,
5
- read,
6
- refuse,
7
- removeScript,
8
- run,
9
- scope,
10
- sectionBodies,
11
- start,
12
- vars,
13
- } from "../../flows/index.js"
14
- import { gate, healthy } from "../shared/health.js"
15
- import type { LensReport } from "../shared/lenses.js"
16
- import { testsShipped } from "../shared/options.js"
17
- import { fixFeedback, humanReview, isCode, reUnwind } from "../shared/review.js"
18
- import { coder, fixCheck, REQUIREMENTS } from "../shared/steps.js"
19
- import { refusePendingHandOff, tail } from "../shared/tail.js"
20
- import * as t from "../shared/text.js"
21
- import { testsDefaults } from "../shared/vars.js"
22
-
23
- const TESTS = ".gtd/TESTS.md"
24
- const RETIRE = ".gtd/RETIRE.md"
25
-
26
- /** The first two answer boxes of a deletion proposal; the third stays the human's own answer. */
27
- const RETIRE_ANSWERS: readonly [string, string] = ["delete it", "keep it skipped"]
28
-
29
- export const DEFAULT_SKIP_PATTERN = testsDefaults.skipPattern!
30
-
31
- interface Skip {
32
- readonly path: string
33
- /** 1-based. */
34
- readonly line: number
35
- readonly text: string
36
- }
37
-
38
- /** `skipPattern` as a per-line expression; refuses one that is blank, invalid, or matches every line. */
39
- const skipExpression = (pattern: string): RegExp => {
40
- if (pattern.trim() === "") return refuse("gtd: skipPattern must not be blank")
41
- let expression: RegExp
42
- try {
43
- expression = new RegExp(pattern)
44
- } catch (error) {
45
- return refuse(`gtd: skipPattern is not a valid regular expression: ${String(error)}`)
46
- }
47
- if (expression.test("")) return refuse("gtd: skipPattern must not match the empty string")
48
- return expression
49
- }
50
-
51
- /** Every line of `files` the expression calls skipped, in file then line order. */
52
- export const skipsIn = (
53
- files: readonly { readonly path: string; readonly text: string }[],
54
- expression: RegExp,
55
- ): readonly Skip[] =>
56
- files.flatMap(({ path, text }) =>
57
- text
58
- .split("\n")
59
- .flatMap((line, i) =>
60
- expression.test(line) ? [{ path, line: i + 1, text: line.trim() }] : [],
61
- ),
62
- )
63
-
64
- const testPaths = (): readonly string[] => {
65
- const globs = (vars.testFiles ?? testsDefaults.testFiles!)
66
- .split(",")
67
- .map((g) => g.trim())
68
- .filter((g) => g !== "")
69
- return [...new Set(globs.flatMap((g) => glob(g)))].filter(isCode).sort()
70
- }
71
-
72
- const scan = (paths: readonly string[], expression: RegExp): readonly Skip[] =>
73
- skipsIn(
74
- paths.map((path) => ({ path, text: read(path) ?? "" })),
75
- expression,
76
- )
77
-
78
- /** A section title as a repository path: backticks and a leading `./` dropped. */
79
- const pathOf = (title: string): string => title.replace(/`/g, "").trim().replace(/^\.\//, "")
80
-
81
- const below = (section: string): string => section.split("\n").slice(1).join("\n").trim()
82
-
83
- const sweepingPrompt = (skips: readonly Skip[]): string =>
84
- `Sweep this repository's tests for ones to revive.
85
-
86
- Skipped tests gtd found:
87
-
88
- ${skips.length === 0 ? "- none" : skips.map((s) => `- ${s.path}:${s.line} — ${s.text}`).join("\n")}
89
-
90
- - Audit every test in the repository with the test-audit skill — the whole
91
- suite, not a recent change
92
- - Write \`${TESTS}\`: \`# Tests\`, then one \`## <path>\` section per test file
93
- that needs work — every file listed above, and each file the audit found
94
- worth rewriting — saying which tests are skipped and what the audit found
95
- - Change nothing else; write nothing when there is nothing to do
96
- - Leave everything uncommitted and finish your turn`
97
-
98
- const rewritingPrompt = (path: string, found: string): string =>
99
- `Rewrite the tests in \`${path}\` until none is skipped and the suite is green.
100
-
101
- What the sweep found:
102
-
103
- ${found || "_Nothing more than the skipped tests._"}
104
-
105
- - Unskip every skipped test in the file and make it pass, rewriting it where
106
- it rotted, and fix what the audit found
107
- - A test that is obsolete — what it tests is gone, or another test already
108
- covers it — stays skipped: never delete it. Propose its deletion in
109
- \`${RETIRE}\` instead: a \`## <title>\` section naming \`${path}\`, the test,
110
- and why it should go. A person decides. Propose a line gtd misread as
111
- skipped (fixture text, a comment) the same way, saying so
112
- - Delete no test file
113
- - Leave everything uncommitted and finish your turn`
114
-
115
- const gateMessage = (): string =>
116
- `The test baseline is red — gtd will not revive skipped tests on a broken suite.
117
- \`.gtd/FEEDBACK.md\` holds the failing output.
118
-
119
- What each change does next (then run \`gtd land\`):
120
- - **Retry check** — edit the code and/or \`.gtd/FEEDBACK.md\` to fix the failing tests (**tests-gate.check**).
121
- `
122
-
123
- const awaitMessage = (base: string): string =>
124
- `\`.gtd/REVIEW.md\` holds one chunk per test the sweep would delete. Each ends in
125
- three boxes — tick exactly one: \`delete it\` (the test is deleted, then the
126
- suite runs), \`keep it skipped\` (dropped, recorded in the commit), or write your
127
- own answer over \`_your answer_\` and tick it (judged like a note). Every
128
- landing here is refused until each proposal holds exactly one tick. An agent
129
- turn that deleted a test file was refused; check the diff for tests removed
130
- inside a file.
131
-
132
- Review the revived tests yourself:
133
-
134
- git diff ${base}
135
-
136
- Then run \`gtd land\`. A note or code edit of your own is read as feedback and
137
- built before you review again.
138
- `
139
-
140
- /** A proposal's own text, made a request the nit fixer acts on once the human ticks `delete it`. */
141
- const proposals = (text: string): LensReport["notes"] =>
142
- sectionBodies(text).map((s, i) => ({
143
- id: `R${i + 1}`,
144
- title: s.title,
145
- body: `Delete this skipped test.\n\n${below(s.body)}`,
146
- answers: RETIRE_ANSWERS,
147
- }))
148
-
149
- /** Sections of `text` that name `path` as a whole token — a trailing full stop still counts. */
150
- const naming = (text: string, path: string): number => {
151
- const token = new RegExp(`(?<![\\w./-])${path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![\\w/-])`)
152
- // `./path` names the same file.
153
- return sectionBodies(text).filter((s) => token.test(s.body.replace(/(^|[^\w.])\.\//g, "$1")))
154
- .length
155
- }
156
-
157
- /** Refuses a test file a turn deleted, and a skip left in the file being rewritten that `.gtd/RETIRE.md` does not name. */
158
- const guardFor = (known: ReadonlySet<string>, expression: RegExp, path?: string) => (): void => {
159
- const deleted = changes()
160
- .filter((c) => c.status === "deleted" && known.has(c.path))
161
- .map((c) => c.path)
162
- if (deleted.length > 0)
163
- refuse(
164
- `gtd land: tests: ${deleted.join(", ")} deleted — a test goes only once a person ticks "delete it" at the review`,
165
- )
166
- // Only the file being rewritten: a skip a person kept at an earlier file's escalation stays theirs.
167
- if (path === undefined) return
168
- const skips = scan([path], expression)
169
- // One proposal per skip left: a section naming the file covers one skip in it, never all.
170
- const left = skips.slice(naming(read(RETIRE) ?? "", path))
171
- if (left.length > 0)
172
- refuse(
173
- `gtd land: tests: ${left.map((s) => `${s.path}:${s.line}`).join(", ")} still skipped — revive it, or propose its deletion in ${RETIRE}`,
174
- )
175
- }
176
-
177
- /** The sweep's packages, refused when it changed more than its plan or the plan misses a file. */
178
- const planned = (
179
- skips: readonly Skip[],
180
- known: ReadonlySet<string>,
181
- ): readonly { path: string; found: string }[] => {
182
- const strays = changes().filter((c) => c.path !== TESTS)
183
- if (strays.length > 0)
184
- refuse(
185
- `gtd land: tests: the sweep changed ${strays.map((c) => c.path).join(", ")} — it writes only ${TESTS}`,
186
- )
187
- const packages = sectionBodies(read(TESTS) ?? "").map((s) => ({
188
- path: pathOf(s.title),
189
- found: below(s.body),
190
- }))
191
- const missing = [
192
- ...packages.filter((p) => !known.has(p.path)).map((p) => `"${p.path}" is no test file`),
193
- ...[...new Set(skips.map((s) => s.path))]
194
- .filter((path) => !packages.some((p) => p.path === path))
195
- .map((path) => `${path} has no section`),
196
- ]
197
- if (missing.length > 0) refuse(`gtd land: tests: in ${TESTS}, ${missing.join("; ")}`)
198
- return packages
199
- }
200
-
201
- /**
202
- * Revive or retire skipped tests: a sweep plans one package per test file, each
203
- * is rewritten until unskipped and green, and what an agent calls obsolete
204
- * reaches the human review as a deletion proposal only a person can tick.
205
- */
206
- export const testsFlow = async (): Promise<void> => {
207
- refusePendingHandOff()
208
- for (const file of [REQUIREMENTS, TESTS, RETIRE]) {
209
- if (read(file) !== undefined) refuse(`gtd: ${file} is left over — finish or remove it first.`)
210
- }
211
- const expression = skipExpression(vars.skipPattern ?? testsDefaults.skipPattern!)
212
- await gate("tests-gate", gateMessage())
213
- const base = start()
214
- const known = new Set(testPaths())
215
- // Sweep and rewrites only: the review's "delete it" fix is the one turn allowed to delete a test.
216
- const guard = guardFor(known, expression)
217
- const report = await scope("tests", async (): Promise<LensReport | undefined> => {
218
- const skips = scan([...known], expression)
219
- await t.agentWithSkills("sweeping", sweepingPrompt(skips), {
220
- label: "Sweeping the tests",
221
- file: TESTS,
222
- model: coder(),
223
- system: t.finisherSystem(),
224
- allowEmpty: true,
225
- })
226
- guard()
227
- const packages = planned(skips, known)
228
- for (const { path, found } of packages) {
229
- const guarded = guardFor(known, expression, path)
230
- await t.agentWithSkills("rewriting", rewritingPrompt(path, found), {
231
- label: `Rewriting ${path}`,
232
- file: RETIRE,
233
- model: coder(),
234
- system: t.finisherSystem(),
235
- // A file the audit only flagged may already be fine.
236
- allowEmpty: true,
237
- })
238
- guarded()
239
- // No code changed: the suite is as green as the last loop left it.
240
- if (!changes().some((c) => isCode(c.path))) continue
241
- // Unguarded human rests: a person at the escalation may delete or keep what they like.
242
- await healthy(() => fixCheck(), { guard: guarded, guardRests: false })
243
- }
244
- const retired = proposals(read(RETIRE) ?? "")
245
- if (read(TESTS) !== undefined || read(RETIRE) !== undefined) {
246
- await run("closing", removeScript([TESTS, RETIRE]), { label: "Closing the sweep" })
247
- }
248
- // Nothing revived and nothing proposed: a review would hold no diff to sign off.
249
- const changed = changesSince(base).paths.some(isCode)
250
- return retired.length === 0 && !changed ? undefined : { notes: retired, fixed: [], noise: [] }
251
- })
252
- if (report === undefined) return
253
- let outcome = await tail(
254
- (escalations) =>
255
- humanReview(base, escalations, {
256
- report,
257
- awaitMessage: report.notes.length > 0 ? awaitMessage : undefined,
258
- }),
259
- { lenses: [], green: true },
260
- )
261
- while (outcome.verdict === "feedback") {
262
- await reUnwind(outcome)
263
- await fixFeedback()
264
- outcome = await tail((escalations) => humanReview(base, escalations), {
265
- lenses: [],
266
- })
267
- }
268
- }
269
-
270
- export const tests = testsShipped(testsFlow)