@pmelab/gtd 44.0.0 → 45.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 +40 -9
- package/dist/chunks/{reserved-DJk_6-ju.mjs → reserved-ulcSkND6.mjs} +142 -5
- package/dist/chunks/{review-X7Jfz0dY.mjs → review-Da2ndnB1.mjs} +177 -7
- package/dist/chunks/{tail-5w5mxGTq.mjs → tail-BW030xz_.mjs} +1 -1
- package/dist/gtd.bundle.mjs +7 -5
- package/dist/testkit.mjs +7 -5
- package/dist/workflow-kit.mjs +22 -10
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +3 -3
- package/dist/workflows/fix.mjs +2 -2
- package/dist/workflows/review.mjs +4 -15
- package/dist/workflows/rollout.mjs +1 -1
- package/dist/workflows/tests.mjs +193 -0
- package/dist/workflows.mjs +533 -40
- package/package.json +6 -1
- package/src/install/ConfigScaffold.ts +2 -1
- package/src/install/Install.ts +5 -4
- package/src/workflow-kit/review.test.ts +80 -2
- package/src/workflow-kit/review.ts +45 -11
- package/src/workflows/feature/review-answers.feature +12 -3
- package/src/workflows/feature/standards.feature +286 -0
- package/src/workflows/index.ts +2 -1
- package/src/workflows/review/review-door.feature +11 -2
- package/src/workflows/review/review.ts +3 -16
- package/src/workflows/shared/learn.test.ts +154 -0
- package/src/workflows/shared/learn.ts +206 -0
- package/src/workflows/shared/lenses.ts +5 -1
- package/src/workflows/shared/options.ts +22 -1
- package/src/workflows/shared/review.ts +29 -2
- package/src/workflows/shared/skills.test.ts +2 -0
- package/src/workflows/shared/skills.ts +3 -0
- package/src/workflows/shared/standards.test.ts +90 -0
- package/src/workflows/shared/standards.ts +156 -0
- package/src/workflows/shared/text.test.ts +58 -1
- package/src/workflows/shared/text.ts +33 -3
- package/src/workflows/shared/vars.ts +10 -0
- package/src/workflows/tests/index.ts +6 -0
- package/src/workflows/tests/tests-options.feature +52 -0
- package/src/workflows/tests/tests.feature +266 -0
- package/src/workflows/tests/tests.test.ts +51 -0
- package/src/workflows/tests/tests.ts +270 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { DEFAULT_SKIP_PATTERN, skipsIn } from "./tests.js"
|
|
3
|
+
|
|
4
|
+
const skipped = (text: string): readonly number[] =>
|
|
5
|
+
skipsIn([{ path: "a", text }], new RegExp(DEFAULT_SKIP_PATTERN)).map((s) => s.line)
|
|
6
|
+
|
|
7
|
+
describe("the default skipPattern", () => {
|
|
8
|
+
it.each([
|
|
9
|
+
'it.skip("x", () => {})',
|
|
10
|
+
'test.skip("x", () => {})',
|
|
11
|
+
'describe.skip("x", () => {})',
|
|
12
|
+
'it.todo("x")',
|
|
13
|
+
'xit("x", () => {})',
|
|
14
|
+
'xdescribe("x", () => {})',
|
|
15
|
+
" @inmem @skip",
|
|
16
|
+
"@ignore",
|
|
17
|
+
])("reads %s as skipped", (line) => {
|
|
18
|
+
expect(skipped(line)).toEqual([1])
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it.each([
|
|
22
|
+
'it("x", () => {})',
|
|
23
|
+
'it.skipIf(windows)("x", () => {})',
|
|
24
|
+
'it.only("x", () => {})',
|
|
25
|
+
"@skipped-by-nobody",
|
|
26
|
+
"fixit()",
|
|
27
|
+
"user@skip.example",
|
|
28
|
+
' "it.skip(x)",',
|
|
29
|
+
"// xit( is how jasmine skips",
|
|
30
|
+
"@skip-ci",
|
|
31
|
+
])("does not read %s as skipped", (line) => {
|
|
32
|
+
expect(skipped(line)).toEqual([])
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
describe("skipsIn", () => {
|
|
37
|
+
it("names every skipped line by path and 1-based line, trimmed", () => {
|
|
38
|
+
expect(
|
|
39
|
+
skipsIn(
|
|
40
|
+
[
|
|
41
|
+
{ path: "a.test.ts", text: 'it("a", f)\n it.skip("b", f)\n' },
|
|
42
|
+
{ path: "b.feature", text: "@skip\nScenario: c" },
|
|
43
|
+
],
|
|
44
|
+
new RegExp(DEFAULT_SKIP_PATTERN),
|
|
45
|
+
),
|
|
46
|
+
).toEqual([
|
|
47
|
+
{ path: "a.test.ts", line: 2, text: 'it.skip("b", f)' },
|
|
48
|
+
{ path: "b.feature", line: 1, text: "@skip" },
|
|
49
|
+
])
|
|
50
|
+
})
|
|
51
|
+
})
|
|
@@ -0,0 +1,270 @@
|
|
|
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)
|