@pmelab/gtd 48.0.0 → 50.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 +31 -20
- package/dist/chunks/{reserved-DF1HD6jL.mjs → reserved-CdOzKFVX.mjs} +150 -24
- package/dist/chunks/{review-Bv40kZu9.mjs → review-BChXEl2Y.mjs} +132 -24
- package/dist/chunks/{steering-D7hWeMgE.mjs → steering-DtUVUz9l.mjs} +49 -13
- package/dist/chunks/{tail-D89fU_wf.mjs → tail-DRM2eC1z.mjs} +1 -1
- package/dist/gtd.bundle.mjs +22 -5
- package/dist/steering.mjs +2 -2
- package/dist/testkit.mjs +1 -1
- package/dist/web/main.js +27 -3
- package/dist/workflow-kit.mjs +1 -1
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +16 -9
- package/dist/workflows/fix.mjs +3 -3
- package/dist/workflows/review.mjs +3 -3
- package/dist/workflows/rollout.mjs +1 -1
- package/dist/workflows.mjs +338 -62
- package/package.json +1 -1
- package/src/steering/index.ts +2 -0
- package/src/steering/review.test.ts +87 -0
- package/src/steering/review.ts +75 -8
- package/src/workflows/feature/coverage.feature +3 -3
- package/src/workflows/feature/fast-path.feature +2 -2
- package/src/workflows/feature/feature.ts +10 -3
- package/src/workflows/feature/flag-mode.feature +2 -2
- package/src/workflows/feature/lens-review.feature +128 -13
- package/src/workflows/feature/one-step-land.feature +2 -2
- package/src/workflows/feature/refactor-absorbs.feature +2 -2
- package/src/workflows/feature/refactor-first.feature +2 -2
- package/src/workflows/feature/refactor-rebuild.feature +2 -2
- package/src/workflows/feature/refactor-review.feature +6 -6
- package/src/workflows/feature/review-answers.feature +51 -5
- package/src/workflows/feature/review-gate.feature +23 -6
- package/src/workflows/feature/reviewer-walkthrough.feature +240 -0
- package/src/workflows/feature/seam-rejection.feature +2 -2
- package/src/workflows/feature/seam.feature +2 -2
- package/src/workflows/feature/severity-budget.feature +111 -0
- package/src/workflows/feature/severity-sorted-review.feature +197 -0
- package/src/workflows/feature/slice-findings.feature +68 -5
- package/src/workflows/feature/slicing.feature +6 -6
- package/src/workflows/feature/standards.feature +29 -1
- package/src/workflows/shared/review.ts +226 -25
- package/src/workflows/shared/steps.ts +3 -2
- package/src/workflows/shared/text.test.ts +216 -0
- package/src/workflows/shared/text.ts +201 -19
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
judge,
|
|
6
6
|
numeric,
|
|
7
7
|
read,
|
|
8
|
+
refuse,
|
|
8
9
|
requireRevert,
|
|
9
10
|
restoreScript,
|
|
10
11
|
run,
|
|
@@ -15,7 +16,7 @@ import {
|
|
|
15
16
|
type Judgment,
|
|
16
17
|
type ScopeAccess,
|
|
17
18
|
} from "../../flows/index.js"
|
|
18
|
-
import type
|
|
19
|
+
import { reviewChunks, reviewRisks, type ReviewNote } from "../../steering/index.js"
|
|
19
20
|
import * as kit from "../../workflow-kit/index.js"
|
|
20
21
|
import { guarded, healthy, type EscalationCount } from "./health.js"
|
|
21
22
|
import { NO_FINDINGS, type LensReport } from "./lenses.js"
|
|
@@ -28,10 +29,11 @@ import {
|
|
|
28
29
|
fixRisks,
|
|
29
30
|
REQUIREMENTS,
|
|
30
31
|
REVIEW,
|
|
32
|
+
reviewing,
|
|
31
33
|
reviewMissing,
|
|
32
34
|
} from "./steps.js"
|
|
33
35
|
import { learnStandards, settlesStandard, standardsAddedSince, standardsPending } from "./learn.js"
|
|
34
|
-
import { parseStandards, STANDARDS } from "./standards.js"
|
|
36
|
+
import { isConflictTitle, parseStandards, STANDARDS } from "./standards.js"
|
|
35
37
|
import { closeReview, isOptionalObject, tail, type TailOptions } from "./tail.js"
|
|
36
38
|
import * as t from "./text.js"
|
|
37
39
|
|
|
@@ -68,10 +70,15 @@ interface Severity {
|
|
|
68
70
|
readonly reason: string
|
|
69
71
|
/** Capture order: 0 one-way door, 1 new concept, 2 everything else. */
|
|
70
72
|
readonly rank: 0 | 1 | 2
|
|
73
|
+
readonly security?: true
|
|
71
74
|
}
|
|
72
75
|
|
|
73
76
|
/** Anything unanswered, unrecognised, cut or below `minP` is `critical`, never `could`. */
|
|
74
|
-
export const severityOf = (
|
|
77
|
+
export const severityOf = (
|
|
78
|
+
note: { readonly id: string; readonly forced?: boolean | undefined },
|
|
79
|
+
judgment: Judgment,
|
|
80
|
+
minP: number,
|
|
81
|
+
): Severity => {
|
|
75
82
|
const ans = (q: string) => judgment.answers[`${note.id}.${q}`]
|
|
76
83
|
const a = (q: string): string | undefined => ans(q)?.answer
|
|
77
84
|
const rank = (): 0 | 1 | 2 =>
|
|
@@ -88,7 +95,12 @@ export const severityOf = (note: ReviewNote, judgment: Judgment, minP: number):
|
|
|
88
95
|
...(a("reversibility") === "one-way" ? ["one-way door"] : []),
|
|
89
96
|
]
|
|
90
97
|
if (critical.length > 0)
|
|
91
|
-
return {
|
|
98
|
+
return {
|
|
99
|
+
tier: "critical",
|
|
100
|
+
reason: `critical: ${critical.join(", ")}`,
|
|
101
|
+
rank: rank(),
|
|
102
|
+
...(a("security") === "yes" ? { security: true as const } : {}),
|
|
103
|
+
}
|
|
92
104
|
const should = [
|
|
93
105
|
...(a("blastRadius") === "contract" ? ["contract"] : []),
|
|
94
106
|
...(a("newConcept") === "yes" ? ["new concept"] : []),
|
|
@@ -98,31 +110,32 @@ export const severityOf = (note: ReviewNote, judgment: Judgment, minP: number):
|
|
|
98
110
|
return { tier: "could", reason: "could", rank: 2 }
|
|
99
111
|
}
|
|
100
112
|
|
|
101
|
-
|
|
113
|
+
/** `asks` names the change in full, `change` refers back to it. */
|
|
114
|
+
const severityQuestions = (id: string, asks: string, change: string): JudgeQuestion[] => [
|
|
102
115
|
{
|
|
103
|
-
id: `${
|
|
116
|
+
id: `${id}.security`,
|
|
104
117
|
primitive: "noul",
|
|
105
|
-
instructions: `Does
|
|
118
|
+
instructions: `Does ${asks} touch security — authentication, authorisation, secrets, untrusted input or data exposure?`,
|
|
106
119
|
criteria: "yes: it changes or could weaken a security property. no: it does not.",
|
|
107
120
|
},
|
|
108
121
|
{
|
|
109
|
-
id: `${
|
|
122
|
+
id: `${id}.reversibility`,
|
|
110
123
|
primitive: "choice",
|
|
111
|
-
instructions: `Once
|
|
124
|
+
instructions: `Once ${change} ships, can it be undone without breaking anyone who already depends on it?`,
|
|
112
125
|
criteria:
|
|
113
126
|
"one-way: a published API, file format, persisted data, protocol or other contract that cannot be taken back. two-way: can be reverted freely.",
|
|
114
127
|
},
|
|
115
128
|
{
|
|
116
|
-
id: `${
|
|
129
|
+
id: `${id}.blastRadius`,
|
|
117
130
|
primitive: "choice",
|
|
118
|
-
instructions: `How far does
|
|
131
|
+
instructions: `How far does ${change} reach?`,
|
|
119
132
|
criteria:
|
|
120
133
|
"local: one function or file. module: a whole module or several files. contract: a public interface other code or users depend on.",
|
|
121
134
|
},
|
|
122
135
|
{
|
|
123
|
-
id: `${
|
|
136
|
+
id: `${id}.newConcept`,
|
|
124
137
|
primitive: "noul",
|
|
125
|
-
instructions: `Does
|
|
138
|
+
instructions: `Does ${change} introduce a concept new to this codebase — a new abstraction, entity or term? Judge from the evidence, never a glossary; reusing or extending an existing concept is not new.`,
|
|
126
139
|
criteria: "yes: a genuinely new concept. no: reuses or extends an existing one.",
|
|
127
140
|
},
|
|
128
141
|
]
|
|
@@ -142,7 +155,14 @@ const triage =
|
|
|
142
155
|
asked.length === 0
|
|
143
156
|
? { answers: {}, truncated: [] }
|
|
144
157
|
: await judge("review.triage", {
|
|
145
|
-
questions: asked.flatMap((n) => [
|
|
158
|
+
questions: asked.flatMap((n) => [
|
|
159
|
+
verdictQuestion(n),
|
|
160
|
+
...severityQuestions(
|
|
161
|
+
n.id,
|
|
162
|
+
`the change the human asks for in note "${n.id}" (on "${n.anchor}")`,
|
|
163
|
+
`the change in note "${n.id}"`,
|
|
164
|
+
),
|
|
165
|
+
]),
|
|
146
166
|
evidence: Object.fromEntries(
|
|
147
167
|
asked.map((n) => [
|
|
148
168
|
n.id,
|
|
@@ -168,8 +188,108 @@ const triage =
|
|
|
168
188
|
)
|
|
169
189
|
}
|
|
170
190
|
|
|
191
|
+
/** Review order: security, one-way doors, the judge unsure, then should, then could. */
|
|
192
|
+
const chunkKey = (s: Severity): number =>
|
|
193
|
+
s.tier === "could" ? 4 : s.tier === "should" ? 3 : s.security === true ? 0 : s.rank === 0 ? 1 : 2
|
|
194
|
+
|
|
195
|
+
type Pointer = ReturnType<typeof reviewChunks>[number]["files"][number]
|
|
196
|
+
|
|
197
|
+
/** A chunk's text, then the code each of its pointers covers: a bare path the whole file. */
|
|
198
|
+
const chunkEvidence = (text: string, files: readonly Pointer[]): string =>
|
|
199
|
+
[
|
|
200
|
+
text,
|
|
201
|
+
...files.map(({ path, line, rangeEnd }) => {
|
|
202
|
+
const where = `${path}${line === undefined ? "" : `#${line}${rangeEnd === undefined ? "" : `-${rangeEnd}`}`}`
|
|
203
|
+
const lines = read(path.replace(/^\.\//, ""))?.split("\n")
|
|
204
|
+
if (lines === undefined) return `${where}: no longer exists`
|
|
205
|
+
const code = line === undefined ? lines : lines.slice(line - 1, rangeEnd ?? line)
|
|
206
|
+
return `${where}:\n${code.join("\n")}`
|
|
207
|
+
}),
|
|
208
|
+
].join("\n\n")
|
|
209
|
+
|
|
210
|
+
const bytes = (text: string): number => new TextEncoder().encode(text).length
|
|
211
|
+
|
|
212
|
+
/** Fills each batch while the budget's even share still holds its widest evidence, so nothing is cut that fits alone. */
|
|
213
|
+
const batched = <T extends { readonly evidence: string }>(
|
|
214
|
+
items: readonly T[],
|
|
215
|
+
budget: number,
|
|
216
|
+
): T[][] => {
|
|
217
|
+
const batches: T[][] = []
|
|
218
|
+
let batch: T[] = []
|
|
219
|
+
let widest = 0
|
|
220
|
+
for (const item of items) {
|
|
221
|
+
const size = bytes(item.evidence)
|
|
222
|
+
if (batch.length > 0 && Math.floor(budget / (batch.length + 1)) < Math.max(widest, size)) {
|
|
223
|
+
batches.push(batch)
|
|
224
|
+
batch = []
|
|
225
|
+
widest = 0
|
|
226
|
+
}
|
|
227
|
+
batch.push(item)
|
|
228
|
+
widest = Math.max(widest, size)
|
|
229
|
+
}
|
|
230
|
+
return batch.length === 0 ? batches : [...batches, batch]
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Rates every chunk the reviewer wrote — a walkthrough chunk or a carried
|
|
235
|
+
* finding — and sorts \`full\` by it, each chunk led by its tier and reason.
|
|
236
|
+
* gtd's summary chunks are not rated and stay last.
|
|
237
|
+
*/
|
|
238
|
+
const ranked = async (review: string, full: string): Promise<string> => {
|
|
239
|
+
const texts = t.chunkTexts(full)
|
|
240
|
+
const chunks = reviewChunks(review).map((c, i) => ({
|
|
241
|
+
id: `C${i + 1}`,
|
|
242
|
+
title: c.title,
|
|
243
|
+
evidence: chunkEvidence(texts.get(c.title) ?? "", c.files),
|
|
244
|
+
}))
|
|
245
|
+
if (chunks.length === 0) return full
|
|
246
|
+
const minP = numeric(vars.reviewSeverityMinP, Infinity)
|
|
247
|
+
const batches = batched(chunks, numeric(vars.judgeBudgetBytes, 32768))
|
|
248
|
+
const severities = new Map<string, Severity>()
|
|
249
|
+
for (const [i, batch] of batches.entries()) {
|
|
250
|
+
const judgment = await judge("review.ranking", {
|
|
251
|
+
questions: batch.flatMap((c) =>
|
|
252
|
+
severityQuestions(
|
|
253
|
+
c.id,
|
|
254
|
+
`the change review chunk "${c.id}" ("${c.title}") walks through`,
|
|
255
|
+
`the change in review chunk "${c.id}"`,
|
|
256
|
+
),
|
|
257
|
+
),
|
|
258
|
+
evidence: Object.fromEntries(batch.map((c) => [c.id, c.evidence])),
|
|
259
|
+
message: t.reviewRankingMessage(),
|
|
260
|
+
label:
|
|
261
|
+
batches.length === 1
|
|
262
|
+
? "Ranking the review"
|
|
263
|
+
: `Ranking the review (${i + 1}/${batches.length})`,
|
|
264
|
+
})
|
|
265
|
+
for (const c of batch) severities.set(c.id, severityOf(c, judgment, minP))
|
|
266
|
+
}
|
|
267
|
+
return t.rankedReview(
|
|
268
|
+
full,
|
|
269
|
+
chunks
|
|
270
|
+
.map((c, i) => ({ c, i, s: severities.get(c.id)! }))
|
|
271
|
+
.sort((x, y) => chunkKey(x.s) - chunkKey(y.s) || x.i - y.i)
|
|
272
|
+
.map(({ c, s }) => ({ title: c.title, label: s.reason })),
|
|
273
|
+
)
|
|
274
|
+
}
|
|
275
|
+
|
|
171
276
|
export const isCode = (path: string): boolean => path !== ".gtd" && !path.startsWith(".gtd/")
|
|
172
277
|
|
|
278
|
+
const composedOf = (
|
|
279
|
+
base: string,
|
|
280
|
+
report: LensReport,
|
|
281
|
+
nitFix: string | undefined,
|
|
282
|
+
answered: ReadonlySet<string>,
|
|
283
|
+
): t.ComposedReview => ({
|
|
284
|
+
...report,
|
|
285
|
+
nitFix,
|
|
286
|
+
answered,
|
|
287
|
+
standards: {
|
|
288
|
+
added: standardsAddedSince(base),
|
|
289
|
+
undecided: parseStandards(read(STANDARDS) ?? "").undecided,
|
|
290
|
+
},
|
|
291
|
+
})
|
|
292
|
+
|
|
173
293
|
/**
|
|
174
294
|
* gtd writes `.gtd/REVIEW.md` from the quality lap's `report` — no reviewer
|
|
175
295
|
* turn. A re-review
|
|
@@ -189,20 +309,84 @@ const composing = (
|
|
|
189
309
|
? writeScript(
|
|
190
310
|
REVIEW,
|
|
191
311
|
t.composedReview(base, {
|
|
192
|
-
...report,
|
|
312
|
+
...composedOf(base, report, nitFix, answered),
|
|
193
313
|
weakened: kit.removedAssertions(changesSince(base)),
|
|
194
|
-
nitFix,
|
|
195
|
-
answered,
|
|
196
|
-
standards: {
|
|
197
|
-
added: standardsAddedSince(base),
|
|
198
|
-
undecided: parseStandards(read(STANDARDS) ?? "").undecided,
|
|
199
|
-
},
|
|
200
314
|
}),
|
|
201
315
|
)
|
|
202
316
|
: showScript(carry, REVIEW, REVIEW),
|
|
203
317
|
{ label: "Composing the review", file: REVIEW, base },
|
|
204
318
|
)
|
|
205
319
|
|
|
320
|
+
/**
|
|
321
|
+
* Refuses a review with a chunk pointing at no code, or a lap note without a
|
|
322
|
+
* chunk of its own. A range with no code change has nothing to point at, so
|
|
323
|
+
* there only a note's chunk needs a pointer.
|
|
324
|
+
*/
|
|
325
|
+
const requirePointers = (
|
|
326
|
+
review: string,
|
|
327
|
+
notes: readonly { title: string }[],
|
|
328
|
+
codeChanged: boolean,
|
|
329
|
+
): void => {
|
|
330
|
+
const chunks = reviewChunks(review)
|
|
331
|
+
const titles = new Set(chunks.map((c) => c.title))
|
|
332
|
+
const noted = new Set(notes.map((n) => n.title))
|
|
333
|
+
const problems = [
|
|
334
|
+
...chunks
|
|
335
|
+
.filter((c) => c.pointers === 0 && (codeChanged || noted.has(c.title)))
|
|
336
|
+
.map((c) => ` - ${REVIEW}:${c.line + 1}: ${c.title} — points at no code`),
|
|
337
|
+
...chunks
|
|
338
|
+
.filter((c) => isConflictTitle(c.title))
|
|
339
|
+
.map((c) => ` - ${REVIEW}:${c.line + 1}: ${c.title} — gtd writes conflicts; leave it out`),
|
|
340
|
+
...notes.filter((n) => !titles.has(n.title)).map((n) => ` - ${n.title} — no chunk of its own`),
|
|
341
|
+
// Notes and answers are matched to their chunk by title.
|
|
342
|
+
...chunks
|
|
343
|
+
.filter((c, i) => chunks.findIndex((d) => d.title === c.title) !== i)
|
|
344
|
+
.map((c) => ` - ${REVIEW}:${c.line + 1}: ${c.title} — a second chunk with this title`),
|
|
345
|
+
// A risk is fixed before the human sees it; a note is the human's to decide.
|
|
346
|
+
...reviewRisks(review)
|
|
347
|
+
.filter((r) => [...noted].some((title) => r.anchor.startsWith(`${title} ./`)))
|
|
348
|
+
.map((r) => ` - ${r.anchor} — no \`Risk:\` in a quality finding's chunk`),
|
|
349
|
+
]
|
|
350
|
+
if (problems.length === 0) return
|
|
351
|
+
refuse(
|
|
352
|
+
`gtd land: review-pointers: ${problems.length} problem(s) in ${REVIEW} — every chunk needs a pointer to its code, and every quality finding its own chunk, headed exactly as the prompt gave it:\n${problems.join("\n")}`,
|
|
353
|
+
)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* A reviewer walks the human through everything since `base`, every chunk
|
|
358
|
+
* pointing at its code and each of the lap's notes a chunk of its own; gtd
|
|
359
|
+
* then adds each note's text and answer boxes, and the summary chunks.
|
|
360
|
+
*/
|
|
361
|
+
const walkthrough = async (
|
|
362
|
+
base: string,
|
|
363
|
+
report: t.ComposedReview,
|
|
364
|
+
lap: t.ReviewRange,
|
|
365
|
+
carry: string | undefined,
|
|
366
|
+
): Promise<void> => {
|
|
367
|
+
const notes = t.titledNotes(report)
|
|
368
|
+
await reviewing(base, carry, lap, t.walkthroughBlock(notes, report.nitFix))
|
|
369
|
+
const written = read(REVIEW)
|
|
370
|
+
// A review the reviewer did not write is the round's own refusal or rest.
|
|
371
|
+
if (written === undefined) return
|
|
372
|
+
const review = t.unranked(written)
|
|
373
|
+
// Against the file as written, so a refusal's line numbers match it.
|
|
374
|
+
requirePointers(
|
|
375
|
+
written,
|
|
376
|
+
notes,
|
|
377
|
+
changesSince(base).some((c) => isCode(c.path)),
|
|
378
|
+
)
|
|
379
|
+
// Ranked even when it marks risks: the review written after their fix reaches the human with its own.
|
|
380
|
+
const full = await ranked(review, t.withWalkthrough(review, report))
|
|
381
|
+
if (full !== written) {
|
|
382
|
+
await run("review.carrying", writeScript(REVIEW, full), {
|
|
383
|
+
label: "Ordering the review and adding the quality lap's findings",
|
|
384
|
+
file: REVIEW,
|
|
385
|
+
base,
|
|
386
|
+
})
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
206
390
|
/**
|
|
207
391
|
* The quality lap's notes reach a human over everything since `base`, who
|
|
208
392
|
* answers each one — fix as suggested, won't fix, or their own answer — and
|
|
@@ -234,12 +418,27 @@ export const humanReview = (
|
|
|
234
418
|
isCode,
|
|
235
419
|
reviewing: (b, carry) => {
|
|
236
420
|
const fixedAt = nitFix
|
|
237
|
-
|
|
238
|
-
return
|
|
421
|
+
const report = options.report ?? NO_FINDINGS
|
|
422
|
+
return options.walkthrough === true
|
|
423
|
+
? guarded(
|
|
424
|
+
() =>
|
|
425
|
+
walkthrough(
|
|
426
|
+
b,
|
|
427
|
+
composedOf(b, report, fixedAt, answered),
|
|
428
|
+
options.lap ?? "first",
|
|
429
|
+
carry,
|
|
430
|
+
),
|
|
431
|
+
guard,
|
|
432
|
+
)()
|
|
433
|
+
: composing(b, report, carry, fixedAt, answered)
|
|
239
434
|
},
|
|
240
435
|
fixRisks: guarded((notes) => fixRisks(notes, fixAccess, rules), guard),
|
|
241
436
|
keepGreen: () => healthy(() => fixCheck(rules), { escalations, guard, guardRests, rules }),
|
|
242
|
-
|
|
437
|
+
// Kept until the human sees a review, so a re-review after a risk fix still names it.
|
|
438
|
+
awaitReview: (b) => {
|
|
439
|
+
nitFix = undefined
|
|
440
|
+
return awaitReview(b)
|
|
441
|
+
},
|
|
243
442
|
reviewMissing,
|
|
244
443
|
collecting: feedback.collecting,
|
|
245
444
|
triage: triage(severities),
|
|
@@ -297,8 +496,10 @@ export interface BuildTailOptions extends TailOptions {
|
|
|
297
496
|
readonly feedback?: FeedbackTarget | undefined
|
|
298
497
|
/** Write access for the nit and risk fix turns; absent: the bundled `access` export decides. */
|
|
299
498
|
readonly fixAccess?: ScopeAccess | undefined
|
|
300
|
-
/**
|
|
499
|
+
/** Which round a `walkthrough` reviewer writes; absent: `first`. */
|
|
301
500
|
readonly lap?: t.ReviewRange | undefined
|
|
501
|
+
/** A reviewer turn writes the review as a walkthrough of the code, the lap's notes among its chunks; absent: gtd composes it from the notes alone. */
|
|
502
|
+
readonly walkthrough?: boolean | undefined
|
|
302
503
|
/** What the triaged quality lap left for the review; absent: nothing. */
|
|
303
504
|
readonly report?: LensReport | undefined
|
|
304
505
|
}
|
|
@@ -94,15 +94,16 @@ export const fixQuality = (rules?: string): Promise<void> =>
|
|
|
94
94
|
allowEmpty: true,
|
|
95
95
|
})
|
|
96
96
|
|
|
97
|
-
/** Write `.gtd/REVIEW.md` over everything since `base`, carrying the answers of commit `carry` when given. */
|
|
97
|
+
/** Write `.gtd/REVIEW.md` over everything since `base`, carrying the answers of commit `carry` when given; `extra` joins the prompt. */
|
|
98
98
|
export const reviewing = (
|
|
99
99
|
base: string,
|
|
100
100
|
carry?: string,
|
|
101
101
|
lap: t.ReviewRange = "first",
|
|
102
|
+
extra = "",
|
|
102
103
|
): Promise<void> =>
|
|
103
104
|
t.agentWithSkills(
|
|
104
105
|
"review.reviewing",
|
|
105
|
-
t.buildReviewReviewingPrompt(base, lap, carry, removedAssertions(changesSince(base))),
|
|
106
|
+
t.buildReviewReviewingPrompt(base, lap, carry, removedAssertions(changesSince(base)), extra),
|
|
106
107
|
{
|
|
107
108
|
label: "Reviewing",
|
|
108
109
|
file: REVIEW,
|
|
@@ -30,6 +30,11 @@ import {
|
|
|
30
30
|
summaryPrompt,
|
|
31
31
|
withSkills,
|
|
32
32
|
composedReview,
|
|
33
|
+
titledNotes,
|
|
34
|
+
walkthroughBlock,
|
|
35
|
+
withWalkthrough,
|
|
36
|
+
rankedReview,
|
|
37
|
+
unranked,
|
|
33
38
|
} from "./text.js"
|
|
34
39
|
import { reviewDecisions, steeringFormatFor, unansweredNotes } from "../../steering/index.js"
|
|
35
40
|
import { captureStep, renderText } from "./text.fixture.js"
|
|
@@ -1391,3 +1396,214 @@ describe("composedReview", () => {
|
|
|
1391
1396
|
expect(doc).toContain("## a (2)\n\n2")
|
|
1392
1397
|
})
|
|
1393
1398
|
})
|
|
1399
|
+
|
|
1400
|
+
describe("walkthrough", () => {
|
|
1401
|
+
const empty = { notes: [], fixed: [], noise: [] }
|
|
1402
|
+
const review = [
|
|
1403
|
+
"# Review: abc1234",
|
|
1404
|
+
"",
|
|
1405
|
+
"<!-- base: abc1234 -->",
|
|
1406
|
+
"",
|
|
1407
|
+
"## Subtract",
|
|
1408
|
+
"",
|
|
1409
|
+
"- [ ] ./src/calc.ts#2-2 — subtract(a, b)",
|
|
1410
|
+
"",
|
|
1411
|
+
"## Fixed automatically",
|
|
1412
|
+
"",
|
|
1413
|
+
"- [ ] ./src/calc.ts#2-2 — the reviewer's own chunk",
|
|
1414
|
+
"",
|
|
1415
|
+
].join("\n")
|
|
1416
|
+
|
|
1417
|
+
it("titles notes uniquely, never a summary's, and leaves out answered ones", () => {
|
|
1418
|
+
const notes = [
|
|
1419
|
+
{ title: "Fixed automatically", body: "a" },
|
|
1420
|
+
{ title: "x", body: "b" },
|
|
1421
|
+
{ title: "x", body: "c" },
|
|
1422
|
+
]
|
|
1423
|
+
expect(titledNotes({ ...empty, notes }).map((n) => n.title)).toEqual([
|
|
1424
|
+
"Fixed automatically (2)",
|
|
1425
|
+
"x",
|
|
1426
|
+
"x (2)",
|
|
1427
|
+
])
|
|
1428
|
+
expect(titledNotes({ ...empty, notes, answered: new Set(["x"]) }).map((n) => n.title)).toEqual([
|
|
1429
|
+
"Fixed automatically (2)",
|
|
1430
|
+
"x (2)",
|
|
1431
|
+
])
|
|
1432
|
+
})
|
|
1433
|
+
|
|
1434
|
+
it("lists each note with its exact heading, origin and quoted body", () => {
|
|
1435
|
+
const block = walkthroughBlock([
|
|
1436
|
+
{ title: "No test", body: "Add one.\n\n- [ ] a box", slice: "2", lens: "tests" },
|
|
1437
|
+
])
|
|
1438
|
+
expect(block).toContain(
|
|
1439
|
+
"- `## No test` (slice 2, lens tests):\n\n > Add one.\n >\n > - a box",
|
|
1440
|
+
)
|
|
1441
|
+
expect(block).toContain("its heading exactly as given")
|
|
1442
|
+
})
|
|
1443
|
+
|
|
1444
|
+
it("names the nit fix's commit, and says nothing with nothing to tell", () => {
|
|
1445
|
+
expect(walkthroughBlock([], "0123456789")).toContain(
|
|
1446
|
+
"Commit `0123456` worked on the human's nits",
|
|
1447
|
+
)
|
|
1448
|
+
expect(walkthroughBlock([])).toBe("")
|
|
1449
|
+
})
|
|
1450
|
+
|
|
1451
|
+
it("puts a note's text under its heading and its boxes after the reviewer's pointers", () => {
|
|
1452
|
+
const out = withWalkthrough(review, {
|
|
1453
|
+
...empty,
|
|
1454
|
+
notes: [{ title: "Subtract", body: "It may overflow.", unresolved: true }],
|
|
1455
|
+
})
|
|
1456
|
+
expect(out).toContain(
|
|
1457
|
+
[
|
|
1458
|
+
"## Subtract",
|
|
1459
|
+
"",
|
|
1460
|
+
"_The fixer left this unresolved._",
|
|
1461
|
+
"",
|
|
1462
|
+
"It may overflow.",
|
|
1463
|
+
"",
|
|
1464
|
+
"In the code:",
|
|
1465
|
+
"",
|
|
1466
|
+
"- [ ] ./src/calc.ts#2-2 — subtract(a, b)",
|
|
1467
|
+
"",
|
|
1468
|
+
"Tick exactly one:",
|
|
1469
|
+
"",
|
|
1470
|
+
"- [ ] fix as suggested",
|
|
1471
|
+
"- [ ] won't fix",
|
|
1472
|
+
"- [ ] _your answer_",
|
|
1473
|
+
"",
|
|
1474
|
+
"## Fixed automatically",
|
|
1475
|
+
].join("\n"),
|
|
1476
|
+
)
|
|
1477
|
+
})
|
|
1478
|
+
|
|
1479
|
+
it("appends the fixes and noise, numbered past a title the reviewer took", () => {
|
|
1480
|
+
const out = withWalkthrough(review, {
|
|
1481
|
+
...empty,
|
|
1482
|
+
fixed: [{ title: "a", commit: "0123456789", slice: "1" }],
|
|
1483
|
+
noise: [{ title: "b", p: 0.9 }],
|
|
1484
|
+
})
|
|
1485
|
+
expect(
|
|
1486
|
+
out.endsWith(
|
|
1487
|
+
"## Fixed automatically (2)\n\n- a (0123456, slice 1)\n\n## Dropped as noise\n\n- b (p=0.9)\n",
|
|
1488
|
+
),
|
|
1489
|
+
).toBe(true)
|
|
1490
|
+
})
|
|
1491
|
+
|
|
1492
|
+
it("leaves a review with nothing to add byte for byte, trailing newlines and all", () => {
|
|
1493
|
+
expect(withWalkthrough(review, empty)).toBe(review)
|
|
1494
|
+
expect(withWalkthrough(review.trimEnd(), empty)).toBe(review.trimEnd())
|
|
1495
|
+
expect(withWalkthrough(review, { ...empty, notes: [{ title: "absent", body: "x" }] })).toBe(
|
|
1496
|
+
review,
|
|
1497
|
+
)
|
|
1498
|
+
})
|
|
1499
|
+
|
|
1500
|
+
it("keeps a note ending in a list apart from the pointers, so its answer carries the list", () => {
|
|
1501
|
+
const out = withWalkthrough(review, {
|
|
1502
|
+
...empty,
|
|
1503
|
+
notes: [{ title: "Subtract", body: "Do this:\n\n- step one\n- step two" }],
|
|
1504
|
+
}).replace("- [ ] fix as suggested", "- [x] fix as suggested")
|
|
1505
|
+
const [decision] = reviewDecisions("", out)
|
|
1506
|
+
expect(decision?.body).toContain("step two")
|
|
1507
|
+
expect(decision?.body).not.toContain("./src/calc.ts")
|
|
1508
|
+
})
|
|
1509
|
+
|
|
1510
|
+
it("shows a title holding backticks as one unbroken code span", () => {
|
|
1511
|
+
expect(walkthroughBlock([{ title: "`sub` drifts", body: "x" }])).toContain(
|
|
1512
|
+
"- `` ## `sub` drifts ``:",
|
|
1513
|
+
)
|
|
1514
|
+
})
|
|
1515
|
+
|
|
1516
|
+
it("keeps Risk: out of a finding's chunk", () => {
|
|
1517
|
+
expect(walkthroughBlock([{ title: "x", body: "y" }])).toContain(
|
|
1518
|
+
"never open a note in a finding's chunk with `Risk:`",
|
|
1519
|
+
)
|
|
1520
|
+
})
|
|
1521
|
+
})
|
|
1522
|
+
|
|
1523
|
+
describe("rankedReview", () => {
|
|
1524
|
+
const review = [
|
|
1525
|
+
"# Review: abc1234",
|
|
1526
|
+
"",
|
|
1527
|
+
"<!-- base: abc1234 -->",
|
|
1528
|
+
"",
|
|
1529
|
+
"## Rename",
|
|
1530
|
+
"",
|
|
1531
|
+
"- [ ] ./a.ts#1-1",
|
|
1532
|
+
"",
|
|
1533
|
+
"## Token",
|
|
1534
|
+
"",
|
|
1535
|
+
"_could_",
|
|
1536
|
+
"",
|
|
1537
|
+
"Reads the token.",
|
|
1538
|
+
"",
|
|
1539
|
+
"- [ ] ./a.ts#2-2",
|
|
1540
|
+
"",
|
|
1541
|
+
"## Fixed automatically",
|
|
1542
|
+
"",
|
|
1543
|
+
"- x (abc1234)",
|
|
1544
|
+
"",
|
|
1545
|
+
].join("\n")
|
|
1546
|
+
|
|
1547
|
+
it("puts the ranked chunks first, each led by its tier, and leaves the rest in place", () => {
|
|
1548
|
+
expect(
|
|
1549
|
+
rankedReview(unranked(review), [
|
|
1550
|
+
{ title: "Token", label: "critical: security" },
|
|
1551
|
+
{ title: "Rename", label: "could" },
|
|
1552
|
+
]),
|
|
1553
|
+
).toBe(
|
|
1554
|
+
[
|
|
1555
|
+
"# Review: abc1234",
|
|
1556
|
+
"",
|
|
1557
|
+
"<!-- base: abc1234 -->",
|
|
1558
|
+
"",
|
|
1559
|
+
"## Token",
|
|
1560
|
+
"",
|
|
1561
|
+
"_critical: security_",
|
|
1562
|
+
"",
|
|
1563
|
+
"Reads the token.",
|
|
1564
|
+
"",
|
|
1565
|
+
"- [ ] ./a.ts#2-2",
|
|
1566
|
+
"",
|
|
1567
|
+
"## Rename",
|
|
1568
|
+
"",
|
|
1569
|
+
"_could_",
|
|
1570
|
+
"",
|
|
1571
|
+
"- [ ] ./a.ts#1-1",
|
|
1572
|
+
"",
|
|
1573
|
+
"## Fixed automatically",
|
|
1574
|
+
"",
|
|
1575
|
+
"- x (abc1234)",
|
|
1576
|
+
"",
|
|
1577
|
+
].join("\n"),
|
|
1578
|
+
)
|
|
1579
|
+
})
|
|
1580
|
+
|
|
1581
|
+
it("keeps a setext heading whole", () => {
|
|
1582
|
+
expect(
|
|
1583
|
+
rankedReview("# Review: a\n\nToken\n-----\n\n- [ ] ./a.ts#1\n", [
|
|
1584
|
+
{ title: "Token", label: "could" },
|
|
1585
|
+
]),
|
|
1586
|
+
).toBe("# Review: a\n\nToken\n-----\n\n_could_\n\n- [ ] ./a.ts#1\n")
|
|
1587
|
+
})
|
|
1588
|
+
|
|
1589
|
+
it("keeps a chunk's leading indented code block indented", () => {
|
|
1590
|
+
const code = "# Review: abc1234\n\n## Code\n\n const x = 1\n\n- [ ] ./a.ts#1-1\n"
|
|
1591
|
+
expect(rankedReview(code, [{ title: "Code", label: "could" }])).toContain(
|
|
1592
|
+
"_could_\n\n const x = 1",
|
|
1593
|
+
)
|
|
1594
|
+
})
|
|
1595
|
+
|
|
1596
|
+
it("drops a stale tier with no blank line under the heading, never a longer paragraph", () => {
|
|
1597
|
+
expect(unranked("## A\n_could_\n\nbody\n")).toBe("## A\nbody\n")
|
|
1598
|
+
expect(unranked("## A\n\n_could_\n- [ ] ./a.ts#1\n")).toBe("## A\n\n- [ ] ./a.ts#1\n")
|
|
1599
|
+
const code = "## A\n\n _could_\n"
|
|
1600
|
+
expect(unranked(code)).toBe(code)
|
|
1601
|
+
const prose = "## A\n\n_should: x_\nruns on\n"
|
|
1602
|
+
expect(unranked(prose)).toBe(prose)
|
|
1603
|
+
})
|
|
1604
|
+
|
|
1605
|
+
it("drops a stale tier, so a re-review is ranked afresh", () => {
|
|
1606
|
+
expect(unranked(review)).not.toContain("_could_")
|
|
1607
|
+
expect(unranked(unranked(review))).toBe(unranked(review))
|
|
1608
|
+
})
|
|
1609
|
+
})
|