@pmelab/gtd 53.0.0 → 55.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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +56 -50
- package/dist/chunks/{reserved-CQ7amcKG.mjs → reserved-DHafcW7t.mjs} +47 -27
- package/dist/chunks/{review-CNgN_V8T.mjs → review-BJGKwlYv.mjs} +12 -5
- package/dist/chunks/{tail-JZ9PAOwp.mjs → tail-B9Hw2AFB.mjs} +12 -8
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +41 -94
- package/dist/workflows/fix.mjs +2 -2
- package/dist/workflows/review.mjs +3 -3
- package/dist/workflows/rollout.mjs +1 -1
- package/dist/workflows.mjs +107 -129
- package/package.json +1 -1
- package/src/testkit/steps.ts +23 -0
- package/src/workflows/feature/criteria-coverage.feature +109 -0
- package/src/workflows/feature/feature.ts +9 -4
- package/src/workflows/feature/green.ts +2 -5
- package/src/workflows/feature/lens-review.feature +50 -0
- package/src/workflows/feature/naive-acceptance.feature +31 -32
- package/src/workflows/feature/prototype.ts +5 -8
- package/src/workflows/feature/refactor-rebuild.feature +16 -12
- package/src/workflows/feature/refactor-review.feature +38 -0
- package/src/workflows/feature/reviewer-walkthrough.feature +0 -31
- package/src/workflows/feature/seam.test.ts +19 -101
- package/src/workflows/feature/seam.ts +13 -89
- package/src/workflows/fix/fix-quality.feature +4 -2
- package/src/workflows/shared/access.test.ts +1 -0
- package/src/workflows/shared/review.test.ts +3 -1
- package/src/workflows/shared/review.ts +12 -2
- package/src/workflows/shared/skills.test.ts +3 -1
- package/src/workflows/shared/steps.test.ts +9 -6
- package/src/workflows/shared/steps.ts +12 -3
- package/src/workflows/shared/tail.test.ts +20 -2
- package/src/workflows/shared/tail.ts +20 -7
- package/src/workflows/shared/text.test.ts +19 -6
- package/src/workflows/shared/text.ts +50 -29
- package/src/workflows/shared/vars.ts +1 -1
|
@@ -3,7 +3,6 @@ import {
|
|
|
3
3
|
agent,
|
|
4
4
|
applyScript,
|
|
5
5
|
changes,
|
|
6
|
-
changesSince,
|
|
7
6
|
check,
|
|
8
7
|
codeChanges,
|
|
9
8
|
env,
|
|
@@ -92,43 +91,19 @@ const reaches = (imports: ReturnType<typeof importsOf>, added: string): boolean
|
|
|
92
91
|
imports.some(({ path, tail }) => name === path || (tail && name.endsWith(`/${path}`))),
|
|
93
92
|
)
|
|
94
93
|
|
|
95
|
-
const named = (id: string, text: string): boolean =>
|
|
96
|
-
new RegExp(`(?<![\\w-])${id}(?!\\d)`).test(text)
|
|
97
|
-
|
|
98
|
-
/** The lines `change` added: an ID an earlier change left in the file does not count. */
|
|
99
|
-
const addedLines = (change: Change): string => {
|
|
100
|
-
const before = new Set((change.before ?? "").split("\n"))
|
|
101
|
-
return change
|
|
102
|
-
.after!.split("\n")
|
|
103
|
-
.filter((line) => !before.has(line))
|
|
104
|
-
.join("\n")
|
|
105
|
-
}
|
|
106
|
-
|
|
107
94
|
const acceptanceTests = (changes: readonly Change[]): readonly Change[] =>
|
|
108
95
|
changes.filter((c) => c.after !== undefined && isCode(c.path) && isTestFile(c.path))
|
|
109
96
|
|
|
110
|
-
const idsOf = (file: string): string[] => criterionIds(read(file) ?? "")
|
|
111
|
-
|
|
112
|
-
// One rule for both gates, so a naive test carried over verbatim always satisfies the rebuild's.
|
|
113
|
-
const unnamed = (ids: readonly string[], tests: readonly Change[]): string[] => {
|
|
114
|
-
const lines = tests.map(addedLines)
|
|
115
|
-
return ids.filter((id) => !lines.some((text) => named(id, text)))
|
|
116
|
-
}
|
|
117
|
-
|
|
118
97
|
/**
|
|
119
|
-
* Why `tip`'s tests fall short, or `undefined`: a
|
|
120
|
-
*
|
|
121
|
-
*
|
|
98
|
+
* Why `tip`'s tests fall short, or `undefined`: a test importing a file the
|
|
99
|
+
* pass added — naive structure a reset and refactor replaces, which the tests
|
|
100
|
+
* must survive.
|
|
122
101
|
*/
|
|
123
|
-
export const acceptanceFault = (
|
|
124
|
-
const tests = acceptanceTests(tip.changes)
|
|
125
|
-
const untested = unnamed(idsOf(file), tests)
|
|
126
|
-
if (untested.length > 0)
|
|
127
|
-
return `acceptance-tests: no test of the naive pass names ${untested.join(", ")} — write a behaviour-level test for each criterion with its ID in the test or scenario name, e.g. "[${untested[0]}] …"`
|
|
102
|
+
export const acceptanceFault = (tip: KeptTip): string | undefined => {
|
|
128
103
|
const added = tip.changes
|
|
129
104
|
.filter((c) => c.status === "added" && isCode(c.path) && !isTestFile(c.path))
|
|
130
105
|
.map((c) => c.path)
|
|
131
|
-
const reaching =
|
|
106
|
+
const reaching = acceptanceTests(tip.changes)
|
|
132
107
|
.filter((test) => holdsCallSites(test.path))
|
|
133
108
|
.flatMap((test) => {
|
|
134
109
|
const imports = importsOf(test)
|
|
@@ -141,48 +116,11 @@ export const acceptanceFault = (file: string, tip: KeptTip): string | undefined
|
|
|
141
116
|
return undefined
|
|
142
117
|
}
|
|
143
118
|
|
|
144
|
-
/** What a rebuild carries over from the naive pass `tip`:
|
|
145
|
-
export const naiveAcceptance = (tip: KeptTip): t.Acceptance => {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
return { path: c.path, ids: ids.filter((id) => named(id, lines)) }
|
|
150
|
-
})
|
|
151
|
-
return {
|
|
152
|
-
hash: tip.hash,
|
|
153
|
-
tests: perTest.filter((c) => c.ids.length > 0).map((c) => c.path),
|
|
154
|
-
ids: ids.filter((id) => perTest.some((c) => c.ids.includes(id))),
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* Refuse a rebuild whose test changes since `since` leave out an ID the naive
|
|
160
|
-
* pass's tests named. A skipped test still counts: the gate reads text, never runs it.
|
|
161
|
-
*/
|
|
162
|
-
export const requireAcceptance = (acceptance: t.Acceptance, since: string): void => {
|
|
163
|
-
const missing = unnamed(acceptance.ids, acceptanceTests(changesSince(since)))
|
|
164
|
-
if (missing.length === 0) return
|
|
165
|
-
refuse(
|
|
166
|
-
`gtd land: rebuild-acceptance: the rebuild's tests leave out ${missing.join(", ")} — carry over the naive pass's acceptance tests (\`git show ${acceptance.hash} -- ${acceptance.tests.map(quote).join(" ")}\`), each with its ID in the test or scenario name`,
|
|
167
|
-
)
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/**
|
|
171
|
-
* Refuse a fix turn that, on its own, drops an ID the naive pass's tests named
|
|
172
|
-
* from a test file. Turn-local, so a person's edit at an escalation rest never
|
|
173
|
-
* wedges later fix turns.
|
|
174
|
-
*/
|
|
175
|
-
export const requireAcceptanceKept = (acceptance: t.Acceptance): void => {
|
|
176
|
-
const dropped = acceptance.ids.filter((id) =>
|
|
177
|
-
changes()
|
|
178
|
-
.filter((c) => isCode(c.path) && isTestFile(c.path))
|
|
179
|
-
.some((c) => named(id, c.before ?? "") && !named(id, c.after ?? "")),
|
|
180
|
-
)
|
|
181
|
-
if (dropped.length === 0) return
|
|
182
|
-
refuse(
|
|
183
|
-
`gtd land: rebuild-acceptance: this turn drops ${dropped.join(", ")} from the tests — the naive pass's acceptance tests stay; fix the code, not them`,
|
|
184
|
-
)
|
|
185
|
-
}
|
|
119
|
+
/** What a rebuild carries over from the naive pass `tip`: every test file it added or changed. */
|
|
120
|
+
export const naiveAcceptance = (tip: KeptTip): t.Acceptance => ({
|
|
121
|
+
hash: tip.hash,
|
|
122
|
+
tests: acceptanceTests(tip.changes).map((c) => c.path),
|
|
123
|
+
})
|
|
186
124
|
|
|
187
125
|
const restore = (path: string): string =>
|
|
188
126
|
`if git cat-file -e ${quote(`HEAD:${path}`)} 2>/dev/null; then git checkout -q HEAD -- ${quote(path)}; else rm -f -- ${quote(path)}; fi`
|
|
@@ -243,12 +181,6 @@ const applied = async (tip: string): Promise<boolean> => {
|
|
|
243
181
|
return !wrote(FEEDBACK) && codeChanges().length > 0
|
|
244
182
|
}
|
|
245
183
|
|
|
246
|
-
/** `carried` limited to the criteria `.gtd/REQUIREMENTS.md` lists now; a file listing none narrows nothing. */
|
|
247
|
-
const settled = (carried: readonly string[]): readonly string[] => {
|
|
248
|
-
const listed = idsOf(REQUIREMENTS)
|
|
249
|
-
return listed.length === 0 ? carried : carried.filter((id) => listed.includes(id))
|
|
250
|
-
}
|
|
251
|
-
|
|
252
184
|
const build = async (options: ImplementOptions): Promise<void> => {
|
|
253
185
|
await t.agentWithSkills(
|
|
254
186
|
"building",
|
|
@@ -290,19 +222,11 @@ export async function implement(
|
|
|
290
222
|
/** `implement`, resolving with the criterion IDs the requirements listed when it closed. */
|
|
291
223
|
export const implementing = (options: ImplementOptions): Promise<readonly string[]> =>
|
|
292
224
|
scope("implement", async () => {
|
|
293
|
-
const since = head()
|
|
294
|
-
const acceptance = options.rebuild?.acceptance
|
|
295
225
|
const built = options.naive === undefined || !(await applied(options.naive))
|
|
296
226
|
if (built) await build(options)
|
|
297
227
|
else requireCriteriaFrozen(REQUIREMENTS)
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
requireAcceptance({ ...acceptance, ids: settled(acceptance.ids) }, since)
|
|
301
|
-
// Fix turns are held to the carried tests too: deleting a red one must not buy a green suite.
|
|
302
|
-
const keepsTests =
|
|
303
|
-
built && acceptance !== undefined ? () => requireAcceptanceKept(acceptance) : undefined
|
|
304
|
-
await green(keepsTests)
|
|
305
|
-
await prototypeTest(keepsTests)
|
|
228
|
+
await green()
|
|
229
|
+
await prototypeTest()
|
|
306
230
|
const ids = criterionIds(read(REQUIREMENTS) ?? "")
|
|
307
231
|
await run("closing", removeScript([REQUIREMENTS, REFACTOR_NOTE, PROTOTYPE]), {
|
|
308
232
|
label: "Closing out the implementation",
|
|
@@ -360,7 +284,7 @@ interface Vetting {
|
|
|
360
284
|
|
|
361
285
|
/** Sends a pass whose tests fall short or run red back (`undefined`: it goes on, an overruled one as it is). */
|
|
362
286
|
const vetted = async (tip: KeptTip, vetting: Vetting): Promise<Verdict | undefined> => {
|
|
363
|
-
const fault = vetting.waived ? undefined : acceptanceFault(
|
|
287
|
+
const fault = vetting.waived ? undefined : acceptanceFault(tip)
|
|
364
288
|
if (fault !== undefined) {
|
|
365
289
|
const verdict = await vetting.again({ fault, hash: tip.hash })
|
|
366
290
|
if (verdict.kind !== "overruled") return verdict
|
|
@@ -4,11 +4,12 @@ Feature: fix's quality lap is triaged, and its leftovers go to the hand-off revi
|
|
|
4
4
|
A judge sorts the lap's findings: mechanical ones are fixed for up to three
|
|
5
5
|
turns, noise is dropped, and design ones — with whatever the fix turns left —
|
|
6
6
|
are added to the reviewer's `.gtd/REVIEW.md` by gtd, never to a person's
|
|
7
|
-
gate before the review.
|
|
7
|
+
gate before the review. With no requirements to check against, the
|
|
8
|
+
`criteria-coverage` lens runs no turn.
|
|
8
9
|
|
|
9
10
|
Background:
|
|
10
11
|
Given the shipped workflows, as gtd install scaffolds them
|
|
11
|
-
And an environment variable "GTD_QUALITYREVIEWS" set to "correctness"
|
|
12
|
+
And an environment variable "GTD_QUALITYREVIEWS" set to "criteria-coverage, correctness"
|
|
12
13
|
And a file "src/calc.ts" with:
|
|
13
14
|
"""
|
|
14
15
|
export const add = (a: number, b: number) => a + b
|
|
@@ -81,6 +82,7 @@ Feature: fix's quality lap is triaged, and its leftovers go to the hand-off revi
|
|
|
81
82
|
Then the process rests at "build.review.await-review"
|
|
82
83
|
And ".gtd/QUALITY.md" does not exist
|
|
83
84
|
And no commit body contains "build.health.describe"
|
|
85
|
+
And no commit body contains "build.quality.criteria-coverage"
|
|
84
86
|
And ".gtd/REVIEW.md" contains:
|
|
85
87
|
"""
|
|
86
88
|
## sum has no negative test
|
|
@@ -19,6 +19,7 @@ describe("the bundled workflow's access export", () => {
|
|
|
19
19
|
"build.quality.test-audit": { write: [] },
|
|
20
20
|
"build.quality.conventions": { write: [] },
|
|
21
21
|
"build.quality.spec-challenge": { write: [] },
|
|
22
|
+
"build.quality.criteria-coverage": { write: [] },
|
|
22
23
|
"refactor.build.review": { write: [".gtd/REVIEW.md"] },
|
|
23
24
|
"refactor.build.quality.correctness": { write: [] },
|
|
24
25
|
"refactor.build.quality.ponytail-review": { write: [] },
|
|
@@ -575,7 +575,7 @@ describe("review verdict routing", () => {
|
|
|
575
575
|
})
|
|
576
576
|
|
|
577
577
|
describe("the default quality lenses", () => {
|
|
578
|
-
it("are the
|
|
578
|
+
it("are the seven lenses in the settled order", () => {
|
|
579
579
|
expect(bundled.defaults.qualityReviews!.split(",").map((l) => l.trim())).toEqual([
|
|
580
580
|
"correctness",
|
|
581
581
|
"owasp-security",
|
|
@@ -583,6 +583,7 @@ describe("the default quality lenses", () => {
|
|
|
583
583
|
"test-audit",
|
|
584
584
|
"conventions",
|
|
585
585
|
"spec-challenge",
|
|
586
|
+
"criteria-coverage",
|
|
586
587
|
])
|
|
587
588
|
})
|
|
588
589
|
|
|
@@ -590,6 +591,7 @@ describe("the default quality lenses", () => {
|
|
|
590
591
|
expect(Object.keys(bundled.builtInLenses).sort()).toEqual([
|
|
591
592
|
"conventions",
|
|
592
593
|
"correctness",
|
|
594
|
+
"criteria-coverage",
|
|
593
595
|
"spec-challenge",
|
|
594
596
|
])
|
|
595
597
|
})
|
|
@@ -380,6 +380,7 @@ const walkthrough = async (
|
|
|
380
380
|
report: t.ComposedReview,
|
|
381
381
|
lap: t.ReviewRange,
|
|
382
382
|
carry: string | undefined,
|
|
383
|
+
afterRiskFix: boolean,
|
|
383
384
|
): Promise<void> => {
|
|
384
385
|
const notes = t.titledNotes(report)
|
|
385
386
|
await reviewing(base, carry, lap, t.walkthroughBlock(notes, report.nitFix))
|
|
@@ -393,7 +394,8 @@ const walkthrough = async (
|
|
|
393
394
|
notes,
|
|
394
395
|
changesSince(base).some((c) => isCode(c.path)),
|
|
395
396
|
)
|
|
396
|
-
//
|
|
397
|
+
// Its risks are fixed and the review written again, so the re-reviewer edits this draft, never gtd's layout.
|
|
398
|
+
if (!afterRiskFix && reviewRisks(written).length > 0) return
|
|
397
399
|
const full = sectionedReview(
|
|
398
400
|
await ranked(review, t.withWalkthrough(review, report), new Set(notes.map((n) => n.title))),
|
|
399
401
|
new Set(reviewChunks(review).map((c) => c.title)),
|
|
@@ -432,12 +434,16 @@ export const humanReview = (
|
|
|
432
434
|
let nitFix: string | undefined
|
|
433
435
|
// Every note the human answered in this review, left out of a fresh one.
|
|
434
436
|
const answered = new Set<string>()
|
|
437
|
+
// Set by a risk fix, so the review written next reaches the human whatever it marks.
|
|
438
|
+
let riskFixed = false
|
|
435
439
|
return kit.review(base, {
|
|
436
440
|
file: REVIEW,
|
|
437
441
|
feedback: feedback.file,
|
|
438
442
|
isCode,
|
|
439
443
|
reviewing: (b, carry) => {
|
|
440
444
|
const fixedAt = nitFix
|
|
445
|
+
const afterRiskFix = riskFixed
|
|
446
|
+
riskFixed = false
|
|
441
447
|
const report = options.report ?? NO_FINDINGS
|
|
442
448
|
return options.walkthrough === true
|
|
443
449
|
? guarded(
|
|
@@ -447,12 +453,16 @@ export const humanReview = (
|
|
|
447
453
|
composedOf(b, report, fixedAt, answered),
|
|
448
454
|
options.lap ?? "first",
|
|
449
455
|
carry,
|
|
456
|
+
afterRiskFix,
|
|
450
457
|
),
|
|
451
458
|
guard,
|
|
452
459
|
)()
|
|
453
460
|
: composing(b, report, carry, fixedAt, answered)
|
|
454
461
|
},
|
|
455
|
-
fixRisks: guarded((notes) =>
|
|
462
|
+
fixRisks: guarded((notes) => {
|
|
463
|
+
riskFixed = true
|
|
464
|
+
return fixRisks(notes, fixAccess, rules)
|
|
465
|
+
}, guard),
|
|
456
466
|
keepGreen: () => healthy(() => fixCheck(rules), { escalations, guard, guardRests, rules }),
|
|
457
467
|
// Kept until the human sees a review, so a re-review after a risk fix still names it.
|
|
458
468
|
awaitReview: (b) => {
|
|
@@ -39,6 +39,7 @@ describe("the bundled workflow's skills export", () => {
|
|
|
39
39
|
"build.quality.test-audit",
|
|
40
40
|
"build.quality.conventions",
|
|
41
41
|
"build.quality.spec-challenge",
|
|
42
|
+
"build.quality.criteria-coverage",
|
|
42
43
|
"refactor.build",
|
|
43
44
|
"refactor.build.health",
|
|
44
45
|
"refactor.build.fix.quality",
|
|
@@ -98,10 +99,11 @@ describe("the bundled workflow's skills export", () => {
|
|
|
98
99
|
expect(Object.keys(none).filter((k) => k.startsWith("refactor.build.quality."))).toEqual([])
|
|
99
100
|
})
|
|
100
101
|
|
|
101
|
-
it("loads one lens skill per quality lens: correctness its review skill, conventions
|
|
102
|
+
it("loads one lens skill per quality lens: correctness its review skill, conventions, spec-challenge and criteria-coverage none, any other itself", () => {
|
|
102
103
|
expect(bundled["build.quality.correctness"]).toEqual(["code-review-and-quality"])
|
|
103
104
|
expect(bundled["build.quality.conventions"]).toEqual([])
|
|
104
105
|
expect(bundled["build.quality.spec-challenge"]).toEqual([])
|
|
106
|
+
expect(bundled["build.quality.criteria-coverage"]).toEqual([])
|
|
105
107
|
expect(bundled["build.quality.owasp-security"]).toEqual(["owasp-security"])
|
|
106
108
|
})
|
|
107
109
|
|
|
@@ -57,12 +57,15 @@ describe("the bundled workflow's steps declare skills — a bundled step's rende
|
|
|
57
57
|
expect(body).toContain(point)
|
|
58
58
|
})
|
|
59
59
|
|
|
60
|
-
it.each(["conventions", "spec-challenge"])(
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
steps.
|
|
64
|
-
|
|
65
|
-
|
|
60
|
+
it.each(["conventions", "spec-challenge", "criteria-coverage"])(
|
|
61
|
+
"%s carries its brief",
|
|
62
|
+
async (lens) => {
|
|
63
|
+
const request = await capture(() => steps.reviewQuality(lens), "build")
|
|
64
|
+
expect(JSON.stringify(request)).toContain(
|
|
65
|
+
steps.builtInLenses[lens]!.brief.split("\n")[0]!.slice(0, 40),
|
|
66
|
+
)
|
|
67
|
+
},
|
|
68
|
+
)
|
|
66
69
|
|
|
67
70
|
it("an unknown lens has no brief", async () => {
|
|
68
71
|
const request = await capture(() => steps.reviewQuality("my-lens"), "build")
|
|
@@ -66,15 +66,24 @@ export const builtInLenses: Readonly<Record<string, BuiltInLens>> = {
|
|
|
66
66
|
correctness: { skills: ["code-review-and-quality"], brief: t.correctnessBrief },
|
|
67
67
|
conventions: { skills: [], brief: t.conventionsBrief },
|
|
68
68
|
"spec-challenge": { skills: [], brief: t.specChallengeBrief },
|
|
69
|
+
"criteria-coverage": { skills: [], brief: t.criteriaCoverageBrief },
|
|
69
70
|
}
|
|
70
71
|
|
|
71
72
|
const coderLenses: ReadonlySet<string> = new Set(["conventions", "ponytail-review", "test-audit"])
|
|
72
73
|
|
|
73
|
-
/**
|
|
74
|
-
|
|
74
|
+
/**
|
|
75
|
+
* One quality review through `lens`; each lens is its own scope, so its own
|
|
76
|
+
* conversation and skills. `requirements`, when given, are quoted in the prompt.
|
|
77
|
+
*/
|
|
78
|
+
export const reviewQuality = (
|
|
79
|
+
lens: string,
|
|
80
|
+
base?: string,
|
|
81
|
+
model?: string,
|
|
82
|
+
requirements?: string,
|
|
83
|
+
): Promise<void> =>
|
|
75
84
|
t.agentWithSkills(
|
|
76
85
|
`quality.${lens}.reviewing`,
|
|
77
|
-
t.buildQualityReviewingPrompt(lens, builtInLenses[lens]?.brief, base),
|
|
86
|
+
t.buildQualityReviewingPrompt(lens, builtInLenses[lens]?.brief, base, requirements),
|
|
78
87
|
{
|
|
79
88
|
label: "Reviewing (one quality lens)",
|
|
80
89
|
file: QUALITY,
|
|
@@ -211,13 +211,17 @@ const prompts: string[] = []
|
|
|
211
211
|
const models: (string | undefined)[] = []
|
|
212
212
|
|
|
213
213
|
describe("the quality lap's lenses", () => {
|
|
214
|
-
const lap = async (
|
|
214
|
+
const lap = async (
|
|
215
|
+
run: () => Promise<unknown>,
|
|
216
|
+
vars: Record<string, string> = {},
|
|
217
|
+
files: Record<string, string> = {},
|
|
218
|
+
) => {
|
|
215
219
|
const log: string[] = []
|
|
216
220
|
prompts.length = 0
|
|
217
221
|
models.length = 0
|
|
218
222
|
installContext(
|
|
219
223
|
fixtureContext(
|
|
220
|
-
{ vars },
|
|
224
|
+
{ vars, read: (path) => files[path] },
|
|
221
225
|
{
|
|
222
226
|
step: (request: StepRequest) => {
|
|
223
227
|
if (request.kind !== "restart") log.push(request.name)
|
|
@@ -309,6 +313,20 @@ describe("the quality lap's lenses", () => {
|
|
|
309
313
|
expect(models).toEqual(["smart", "opus"])
|
|
310
314
|
})
|
|
311
315
|
|
|
316
|
+
it("criteria-coverage runs only with requirements, passed or in .gtd/REQUIREMENTS.md, and quotes them", async () => {
|
|
317
|
+
const lenses = ["criteria-coverage", "conventions"]
|
|
318
|
+
expect(await lap(() => qualityLap(undefined, lenses))).toEqual([
|
|
319
|
+
"quality.conventions.reviewing",
|
|
320
|
+
])
|
|
321
|
+
await lap(() => qualityLap(undefined, lenses), {}, { ".gtd/REQUIREMENTS.md": "- AC-1: file" })
|
|
322
|
+
expect(prompts[0]).toContain(" > - AC-1: file")
|
|
323
|
+
expect(prompts[1]).not.toContain("AC-1")
|
|
324
|
+
await lap(() =>
|
|
325
|
+
tail(() => Promise.resolve(), { green: true, lenses, requirements: "- AC-1: passed" }),
|
|
326
|
+
)
|
|
327
|
+
expect(prompts[0]).toContain(" > - AC-1: passed")
|
|
328
|
+
})
|
|
329
|
+
|
|
312
330
|
it("tail passes lenses to its lap and a guard after each lens turn", async () => {
|
|
313
331
|
let guards = 0
|
|
314
332
|
const log = await lap(() =>
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
fixCheck,
|
|
16
16
|
fixRisks,
|
|
17
17
|
QUALITY,
|
|
18
|
+
REQUIREMENTS,
|
|
18
19
|
REVIEW,
|
|
19
20
|
reviewing,
|
|
20
21
|
reviewMissing,
|
|
@@ -35,24 +36,31 @@ export const qualityLap = (
|
|
|
35
36
|
guard?: Guard,
|
|
36
37
|
lenses?: readonly string[],
|
|
37
38
|
base?: string,
|
|
38
|
-
): Promise<"clean" | "findings"> => lensLap(guard, lenses, base, () => {})
|
|
39
|
+
): Promise<"clean" | "findings"> => lensLap(guard, lenses, base, undefined, () => {})
|
|
40
|
+
|
|
41
|
+
const COVERAGE = "criteria-coverage"
|
|
39
42
|
|
|
40
43
|
// `seen` gets each lens and every finding in the file once its turn is done.
|
|
41
44
|
const lensLap = (
|
|
42
45
|
guard: Guard | undefined,
|
|
43
46
|
lenses: readonly string[] | undefined,
|
|
44
47
|
base: string | undefined,
|
|
48
|
+
given: string | undefined,
|
|
45
49
|
seen: (lens: string, findings: readonly Finding[]) => void,
|
|
46
|
-
): Promise<"clean" | "findings"> =>
|
|
47
|
-
|
|
48
|
-
|
|
50
|
+
): Promise<"clean" | "findings"> => {
|
|
51
|
+
const requirements = given ?? read(REQUIREMENTS)
|
|
52
|
+
return kit.qualityLap(
|
|
53
|
+
(lenses ?? kit.lensEntriesOf(vars.qualityReviews ?? "")).filter(
|
|
54
|
+
(entry) => requirements !== undefined || kit.lensOf(entry).lens !== COVERAGE,
|
|
55
|
+
),
|
|
49
56
|
guarded(async (entry: string) => {
|
|
50
57
|
const { lens, model } = kit.lensOf(entry)
|
|
51
|
-
await reviewQuality(lens, base, model)
|
|
58
|
+
await reviewQuality(lens, base, model, lens === COVERAGE ? requirements : undefined)
|
|
52
59
|
seen(lens, findingsOf(read(QUALITY) ?? ""))
|
|
53
60
|
}, guard),
|
|
54
61
|
QUALITY,
|
|
55
62
|
)
|
|
63
|
+
}
|
|
56
64
|
|
|
57
65
|
// What the fixer left is in the lap's report by now; the next lap must not append to it.
|
|
58
66
|
const closeQuality = async (): Promise<void> => {
|
|
@@ -91,6 +99,11 @@ export interface TailOptions {
|
|
|
91
99
|
readonly lensBase?: string | undefined
|
|
92
100
|
/** Appended to every fix and describe prompt of the tail. */
|
|
93
101
|
readonly rules?: string | undefined
|
|
102
|
+
/**
|
|
103
|
+
* The requirements the change was built to, for the `criteria-coverage`
|
|
104
|
+
* lens; absent: `.gtd/REQUIREMENTS.md`, and with neither the lens is skipped.
|
|
105
|
+
*/
|
|
106
|
+
readonly requirements?: string | undefined
|
|
94
107
|
}
|
|
95
108
|
|
|
96
109
|
/**
|
|
@@ -156,7 +169,7 @@ const tailForm = <T>(
|
|
|
156
169
|
|
|
157
170
|
const runTail = <T>(finish: Finish<T>, options: TailOptions): Promise<T> =>
|
|
158
171
|
scope("build", async () => {
|
|
159
|
-
const { guard, lenses, lensBase, rules } = options
|
|
172
|
+
const { guard, lenses, lensBase, rules, requirements } = options
|
|
160
173
|
// Only undefined defaults: a null reaches every escalation as falsy, as it reaches review's.
|
|
161
174
|
const guardRests = options.guardRests === undefined ? true : options.guardRests
|
|
162
175
|
const escalations: EscalationCount = { rounds: 0 }
|
|
@@ -170,7 +183,7 @@ const runTail = <T>(finish: Finish<T>, options: TailOptions): Promise<T> =>
|
|
|
170
183
|
// The lap runs once a tail: after its findings are fixed, `finish` follows.
|
|
171
184
|
// Lenses append, so a title the file did not hold before a lens's turn is that lens's.
|
|
172
185
|
const byTitle: { title: string; lens: string }[] = []
|
|
173
|
-
const lap = await lensLap(guard, lenses, lensBase, (lens, findings) => {
|
|
186
|
+
const lap = await lensLap(guard, lenses, lensBase, requirements, (lens, findings) => {
|
|
174
187
|
const before = byTitle.map((f) => f.title)
|
|
175
188
|
for (const { title } of findings) {
|
|
176
189
|
const at = before.indexOf(title)
|
|
@@ -898,14 +898,27 @@ describe("the seam phase's texts", () => {
|
|
|
898
898
|
expect(prompt).toContain("solved the task the wrong way")
|
|
899
899
|
})
|
|
900
900
|
|
|
901
|
-
it("asks for a breadth-first pass
|
|
901
|
+
it("asks for a breadth-first pass tested through existing code, green before it ends", () => {
|
|
902
902
|
const prompt = renderText(() => naivePassPrompt())
|
|
903
903
|
expect(prompt).toContain("breadth-first")
|
|
904
|
-
expect(prompt).toContain("`[AC-3] …`")
|
|
905
904
|
expect(prompt).toContain("never a file this pass adds")
|
|
906
905
|
expect(prompt).toContain("green")
|
|
907
906
|
})
|
|
908
907
|
|
|
908
|
+
it.each([
|
|
909
|
+
["naive", () => naivePassPrompt()],
|
|
910
|
+
["building", () => implementBuildingPrompt("abc123")],
|
|
911
|
+
["prototype fixing", () => prototypeFixingPrompt()],
|
|
912
|
+
])(
|
|
913
|
+
"the %s prompt asks for a test per behaviour, never a criterion ID in its name",
|
|
914
|
+
(_n, prompt) => {
|
|
915
|
+
const text = renderText(prompt)
|
|
916
|
+
expect(text).toContain("one test per behaviour, not per\n criterion")
|
|
917
|
+
expect(text).toContain("never put criterion IDs in test or scenario names")
|
|
918
|
+
expect(text).not.toContain("[AC-3]")
|
|
919
|
+
},
|
|
920
|
+
)
|
|
921
|
+
|
|
909
922
|
it("has a red retry pick up the kept attempt instead of starting over", () => {
|
|
910
923
|
const prompt = renderText(() => naivePassPrompt({ red: "abc123" }))
|
|
911
924
|
expect(prompt).toContain("left the suite red")
|
|
@@ -972,16 +985,16 @@ describe("the seam phase's texts", () => {
|
|
|
972
985
|
|
|
973
986
|
it("hands a faulty pass back with the fault and the kept attempt to pick up", () => {
|
|
974
987
|
const fault = {
|
|
975
|
-
fault: "acceptance-
|
|
988
|
+
fault: "acceptance-internals: these tests import files the naive pass added",
|
|
976
989
|
hash: "abc123",
|
|
977
990
|
}
|
|
978
991
|
const prompt = renderText(() => naivePassPrompt(fault))
|
|
979
|
-
expect(prompt).toContain("
|
|
992
|
+
expect(prompt).toContain("import files the naive pass added")
|
|
980
993
|
expect(prompt).toContain(
|
|
981
994
|
"git diff --binary --no-color --no-ext-diff --no-textconv abc123^ abc123 | git apply",
|
|
982
995
|
)
|
|
983
996
|
const message = renderText(() => seamSteerMessage(fault))
|
|
984
|
-
expect(message).toContain("
|
|
997
|
+
expect(message).toContain("import files the naive pass added")
|
|
985
998
|
expect(message).toContain("`seam.check` runs its suite")
|
|
986
999
|
expect(message).toContain("Overrule")
|
|
987
1000
|
})
|
|
@@ -997,7 +1010,7 @@ describe("the seam phase's texts", () => {
|
|
|
997
1010
|
})
|
|
998
1011
|
|
|
999
1012
|
it("promises no judge on an overrule with flagMode off", () => {
|
|
1000
|
-
for (const retry of [{ red: "abc123" }, { fault: "acceptance-
|
|
1013
|
+
for (const retry of [{ red: "abc123" }, { fault: "acceptance-internals: x", hash: "abc123" }]) {
|
|
1001
1014
|
const message = renderText(() => seamSteerMessage(retry, false))
|
|
1002
1015
|
expect(message).not.toContain("seam judge")
|
|
1003
1016
|
expect(message).toContain("it lands as the implementation")
|