@pmelab/gtd 28.2.0 → 29.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 +13 -4
- package/dist/chunks/{reserved-DeBS4Ymh.mjs → reserved-DoHUII2D.mjs} +206 -121
- package/dist/chunks/{tail-B2SER2Ih.mjs → tail-C6YBG5zh.mjs} +19 -12
- package/dist/testkit.mjs +2 -1
- package/dist/workflow-kit.mjs +51 -21
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +247 -96
- package/dist/workflows/fix.mjs +2 -2
- package/dist/workflows/review.mjs +2 -2
- package/dist/workflows/rollout.mjs +1 -1
- package/dist/workflows.mjs +528 -262
- package/package.json +1 -1
- package/src/testkit/TestRepo.ts +3 -0
- package/src/testkit/index.ts +1 -1
- package/src/testkit/steps.ts +44 -0
- package/src/workflow-kit/review.test.ts +366 -8
- package/src/workflow-kit/review.ts +109 -26
- package/src/workflows/feature/feature.test.ts +313 -1
- package/src/workflows/feature/feature.ts +48 -43
- package/src/workflows/feature/one-step-land.feature +4 -0
- package/src/workflows/feature/refactor-first.feature +54 -24
- package/src/workflows/feature/refactor-review.feature +1706 -0
- package/src/workflows/feature/refactor.test.ts +592 -2
- package/src/workflows/feature/refactor.ts +160 -42
- package/src/workflows/feature/review.test.ts +322 -5
- package/src/workflows/feature/review.ts +77 -10
- package/src/workflows/feature/seam-rejection.feature +4 -0
- package/src/workflows/feature/seam.feature +3 -4
- package/src/workflows/feature/seam.test.ts +196 -1
- package/src/workflows/feature/seam.ts +23 -9
- package/src/workflows/feature/steps.test.ts +54 -2
- package/src/workflows/feature/steps.ts +45 -9
- package/src/workflows/feature/tier.test.ts +4 -0
- package/src/workflows/feature/tier.ts +2 -20
- package/src/workflows/shared/__fixtures__/feature-collecting-prompt.txt +97 -0
- package/src/workflows/shared/access.test.ts +31 -1
- package/src/workflows/shared/access.ts +7 -1
- package/src/workflows/shared/fences.ts +19 -0
- package/src/workflows/shared/health.ts +32 -12
- package/src/workflows/shared/options.test.ts +18 -0
- package/src/workflows/shared/options.ts +1 -0
- package/src/workflows/shared/prose.ts +4 -2
- package/src/workflows/shared/skills.test.ts +20 -1
- package/src/workflows/shared/skills.ts +43 -32
- package/src/workflows/shared/steps.ts +33 -19
- package/src/workflows/shared/tail.test.ts +84 -1
- package/src/workflows/shared/tail.ts +30 -12
- package/src/workflows/shared/text.test.ts +266 -3
- package/src/workflows/shared/text.ts +183 -69
- package/src/workflows/shared/vars.ts +12 -2
package/package.json
CHANGED
package/src/testkit/TestRepo.ts
CHANGED
|
@@ -72,6 +72,8 @@ export interface TestRepo {
|
|
|
72
72
|
subjects(): readonly string[]
|
|
73
73
|
/** Every commit's full message, subject and trailers, oldest first. */
|
|
74
74
|
messages(): readonly string[]
|
|
75
|
+
/** Every commit's full hash, oldest first. */
|
|
76
|
+
hashes(): readonly string[]
|
|
75
77
|
}
|
|
76
78
|
|
|
77
79
|
const REGISTRY = Symbol.for("@pmelab/gtd/testkit:modules")
|
|
@@ -286,6 +288,7 @@ export const testRepo = (options: TestRepoOptions): TestRepo => {
|
|
|
286
288
|
},
|
|
287
289
|
subjects: () => repo.commitHistory().map(({ message }) => message.split("\n")[0] ?? ""),
|
|
288
290
|
messages: () => repo.commitHistory().map(({ message }) => message),
|
|
291
|
+
hashes: () => repo.commitHistory().map(({ hash }) => hash),
|
|
289
292
|
}
|
|
290
293
|
return self
|
|
291
294
|
}
|
package/src/testkit/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { runsItsScript, testRepo, type TestRepo } from "./TestRepo.js"
|
|
1
|
+
export { runsItsScript, testRepo, type Actor, type TestRepo } from "./TestRepo.js"
|
|
2
2
|
export { InMemRepo } from "./InMemRepo.js"
|
|
3
3
|
export { makeInMemoryWorkspaceOps, testLayers, type ScriptedCommand } from "./Layers.js"
|
|
4
4
|
export { makeCapturingCliIo } from "./cliIo.js"
|
package/src/testkit/steps.ts
CHANGED
|
@@ -121,6 +121,11 @@ Given("step {string} from now on changes nothing", (world: TestkitWorld, step: s
|
|
|
121
121
|
world.actions.set(step, [() => undefined])
|
|
122
122
|
})
|
|
123
123
|
|
|
124
|
+
// Lets a later visit rest at a step an earlier one had scripted.
|
|
125
|
+
Given("step {string} is no longer scripted", (world: TestkitWorld, step: string) => {
|
|
126
|
+
world.actions.delete(step)
|
|
127
|
+
})
|
|
128
|
+
|
|
124
129
|
Given(
|
|
125
130
|
"step {string} writes {string} with:",
|
|
126
131
|
(world: TestkitWorld, step: string, path: string, content: string) => {
|
|
@@ -141,6 +146,17 @@ Given(
|
|
|
141
146
|
},
|
|
142
147
|
)
|
|
143
148
|
|
|
149
|
+
// A closing step deletes the file before its collecting turn runs, so this edit
|
|
150
|
+
// leaves a closed turn changing nothing while still answering an in-gate one.
|
|
151
|
+
Given(
|
|
152
|
+
"step {string} rewrites {string} with:",
|
|
153
|
+
(world: TestkitWorld, step: string, path: string, content: string) => {
|
|
154
|
+
world.act(step, (_, repo) => {
|
|
155
|
+
if (repo.read(path) !== undefined) repo.write(path, withNewline(content))
|
|
156
|
+
})
|
|
157
|
+
},
|
|
158
|
+
)
|
|
159
|
+
|
|
144
160
|
Given("step {string} runs its script", (world: TestkitWorld, step: string) => {
|
|
145
161
|
world.act(step, runsItsScript)
|
|
146
162
|
})
|
|
@@ -202,6 +218,13 @@ Then(
|
|
|
202
218
|
},
|
|
203
219
|
)
|
|
204
220
|
|
|
221
|
+
Then(
|
|
222
|
+
"driving the process is refused without {string}",
|
|
223
|
+
async (world: TestkitWorld, message: string) => {
|
|
224
|
+
await assert.rejects(world.drive(), (error: Error) => !error.message.includes(message))
|
|
225
|
+
},
|
|
226
|
+
)
|
|
227
|
+
|
|
205
228
|
Then("the process rests at {string}", async (world: TestkitWorld, step: string) => {
|
|
206
229
|
assert.strictEqual((await world.repo().next()).state, step)
|
|
207
230
|
})
|
|
@@ -211,6 +234,17 @@ Then("the resting step's content contains {string}", async (world: TestkitWorld,
|
|
|
211
234
|
assert.ok(content.includes(text), content)
|
|
212
235
|
})
|
|
213
236
|
|
|
237
|
+
Then(
|
|
238
|
+
"the resting step's content contains the hash of the commit {string}",
|
|
239
|
+
async (world: TestkitWorld, subject: string) => {
|
|
240
|
+
const repo = world.repo()
|
|
241
|
+
const hash = repo.hashes()[repo.subjects().lastIndexOf(subject)]
|
|
242
|
+
assert.ok(hash !== undefined, `no commit "${subject}"`)
|
|
243
|
+
const { content } = await repo.next()
|
|
244
|
+
assert.ok(content.includes(hash), content)
|
|
245
|
+
},
|
|
246
|
+
)
|
|
247
|
+
|
|
214
248
|
Then(
|
|
215
249
|
"the resting step's content does not contain {string}",
|
|
216
250
|
async (world: TestkitWorld, text: string) => {
|
|
@@ -250,6 +284,16 @@ Then(
|
|
|
250
284
|
},
|
|
251
285
|
)
|
|
252
286
|
|
|
287
|
+
Then(
|
|
288
|
+
"the output lines of gtd {string} start with:",
|
|
289
|
+
async (world: TestkitWorld, list: string, prefixes: string) => {
|
|
290
|
+
const out = (await outputOf(world, list)).split("\n").filter((line) => line !== "")
|
|
291
|
+
const expected = prefixes.split("\n").filter((line) => line.trim() !== "")
|
|
292
|
+
assert.strictEqual(out.length, expected.length, out.join("\n"))
|
|
293
|
+
out.forEach((line, i) => assert.ok(line.startsWith(expected[i]!), out.join("\n")))
|
|
294
|
+
},
|
|
295
|
+
)
|
|
296
|
+
|
|
253
297
|
Then("{string} contains {string}", (world: TestkitWorld, path: string, text: string) => {
|
|
254
298
|
assert.ok(world.repo().read(path)?.includes(text), `${path}: ${world.repo().read(path)}`)
|
|
255
299
|
})
|
|
@@ -1,13 +1,30 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, vi } from "vitest"
|
|
2
2
|
|
|
3
|
-
const state = vi.hoisted(() => ({
|
|
3
|
+
const state = vi.hoisted(() => ({
|
|
4
|
+
feedback: undefined as string | undefined,
|
|
5
|
+
head: "h1",
|
|
6
|
+
pending: [] as unknown[],
|
|
7
|
+
files: {} as Record<string, string | undefined>,
|
|
8
|
+
since: [] as { path: string; status: string; before?: string; after?: string }[],
|
|
9
|
+
sinceAfter: undefined as
|
|
10
|
+
| { path: string; status: string; before?: string; after?: string }[]
|
|
11
|
+
| undefined,
|
|
12
|
+
agentThread: false,
|
|
13
|
+
humanThread: false,
|
|
14
|
+
}))
|
|
4
15
|
|
|
5
16
|
vi.mock("../flows/index.js", () => ({
|
|
6
|
-
changes: () =>
|
|
7
|
-
changesSince: () =>
|
|
8
|
-
|
|
17
|
+
changes: () => state.pending,
|
|
18
|
+
changesSince: (commit: string) => {
|
|
19
|
+
const list = commit === "h2" && state.sinceAfter ? state.sinceAfter : state.since
|
|
20
|
+
return Object.assign([...list], {
|
|
21
|
+
paths: list.map((c) => c.path),
|
|
22
|
+
get: (path: string) => list.find((c) => c.path === path),
|
|
23
|
+
})
|
|
24
|
+
},
|
|
25
|
+
hasThreadFor: (who: string) => (who === "agent" ? state.agentThread : state.humanThread),
|
|
9
26
|
head: () => state.head,
|
|
10
|
-
read: () => state.feedback,
|
|
27
|
+
read: (path: string) => (path === "f.md" ? state.feedback : state.files[path]),
|
|
11
28
|
refuse: (m: string) => {
|
|
12
29
|
throw new Error(m)
|
|
13
30
|
},
|
|
@@ -15,12 +32,20 @@ vi.mock("../flows/index.js", () => ({
|
|
|
15
32
|
requireThreadsClosed: () => {},
|
|
16
33
|
}))
|
|
17
34
|
|
|
18
|
-
import {
|
|
35
|
+
import {
|
|
36
|
+
review,
|
|
37
|
+
reviewToHandOff,
|
|
38
|
+
routeNotes,
|
|
39
|
+
type HandOffPolicy,
|
|
40
|
+
type Requested,
|
|
41
|
+
type ReviewPolicy,
|
|
42
|
+
type Verdict,
|
|
43
|
+
} from "./review.js"
|
|
19
44
|
import type { ReviewNote } from "../steering/index.js"
|
|
20
45
|
|
|
21
46
|
const note = (id: string) => ({ id }) as unknown as ReviewNote
|
|
22
47
|
|
|
23
|
-
const setup = (verdicts: Record<string, Verdict
|
|
48
|
+
const setup = (verdicts: Record<string, Verdict>, silent = false, dropped = false) => {
|
|
24
49
|
const calls: string[] = []
|
|
25
50
|
const deferred: string[][] = []
|
|
26
51
|
const collected: ReviewNote[][] = []
|
|
@@ -33,7 +58,7 @@ const setup = (verdicts: Record<string, Verdict>) => {
|
|
|
33
58
|
keepGreen: async () => void calls.push("green"),
|
|
34
59
|
collecting: async () => {
|
|
35
60
|
calls.push("collect")
|
|
36
|
-
state.feedback = "x"
|
|
61
|
+
if (!silent) state.feedback = "x"
|
|
37
62
|
},
|
|
38
63
|
captures: {
|
|
39
64
|
editNotes: (_r: string, edits: readonly ReviewNote[]) => {
|
|
@@ -41,6 +66,12 @@ const setup = (verdicts: Record<string, Verdict>) => {
|
|
|
41
66
|
return "cap"
|
|
42
67
|
},
|
|
43
68
|
},
|
|
69
|
+
refusals: dropped
|
|
70
|
+
? {
|
|
71
|
+
dropped: (r: Requested) =>
|
|
72
|
+
`dropped: ${r.notes.map((n) => n.id).join(",")} / ${r.paths.join(",")}`,
|
|
73
|
+
}
|
|
74
|
+
: {},
|
|
44
75
|
defer: async (ns: readonly ReviewNote[]) => {
|
|
45
76
|
calls.push("defer")
|
|
46
77
|
deferred.push(ns.map((n) => n.id))
|
|
@@ -57,6 +88,13 @@ const setup = (verdicts: Record<string, Verdict>) => {
|
|
|
57
88
|
|
|
58
89
|
beforeEach(() => {
|
|
59
90
|
state.feedback = undefined
|
|
91
|
+
state.pending = []
|
|
92
|
+
state.files = {}
|
|
93
|
+
state.since = []
|
|
94
|
+
state.sinceAfter = undefined
|
|
95
|
+
state.head = "h1"
|
|
96
|
+
state.agentThread = false
|
|
97
|
+
state.humanThread = false
|
|
60
98
|
})
|
|
61
99
|
|
|
62
100
|
describe("routeNotes defer", () => {
|
|
@@ -82,3 +120,323 @@ describe("routeNotes defer", () => {
|
|
|
82
120
|
expect(t.calls).toEqual(["nits", "green", "defer", "close", "collect"])
|
|
83
121
|
})
|
|
84
122
|
})
|
|
123
|
+
|
|
124
|
+
describe("routeNotes dropped", () => {
|
|
125
|
+
it("an edit note whose collecting turn writes nothing is refused naming the note", async () => {
|
|
126
|
+
const t = setup({ a: "edit" }, true, true)
|
|
127
|
+
await expect(routeNotes(t.p, [note("a")], t.r)).rejects.toThrow("dropped: a / ")
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it("signs off when no dropped refusal is set", async () => {
|
|
131
|
+
const t = setup({ a: "edit" }, true)
|
|
132
|
+
expect(await routeNotes(t.p, [note("a")], t.r)).toEqual({ verdict: "signoff" })
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it("a collecting turn that changed something else is unmatched, not dropped", async () => {
|
|
136
|
+
const t = setup({ a: "edit" }, true, true)
|
|
137
|
+
Object.assign(t.p.refusals, { unmatched: "unmatched" })
|
|
138
|
+
state.pending = [{ path: "x" }]
|
|
139
|
+
await expect(routeNotes(t.p, [note("a")], t.r)).rejects.toThrow("unmatched")
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it("a praise-only round never collects, so signs off", async () => {
|
|
143
|
+
const t = setup({ a: "praise" }, true, true)
|
|
144
|
+
expect(await routeNotes(t.p, [note("a")], t.r)).toEqual({ verdict: "signoff" })
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
const doc = (...body: string[]): string =>
|
|
149
|
+
[
|
|
150
|
+
"# Review: abc1234",
|
|
151
|
+
"",
|
|
152
|
+
"<!-- base: 0000000000000000000000000000000000000000 -->",
|
|
153
|
+
"",
|
|
154
|
+
"## calc",
|
|
155
|
+
"",
|
|
156
|
+
...body,
|
|
157
|
+
"",
|
|
158
|
+
].join("\n")
|
|
159
|
+
|
|
160
|
+
const POINTER = "- [ ] ./a.ts#1-1 does x"
|
|
161
|
+
const thread = (...entries: string[]): string =>
|
|
162
|
+
["[^t]:", ...entries.map((e) => ` - ${e}`)].join("\n")
|
|
163
|
+
const oneShot = (id: string, text: string): string => `[^${id}]:\n ${text}`
|
|
164
|
+
|
|
165
|
+
/** Drives `review` over one round: `before` is the file as reviewed, `after` what the human left. */
|
|
166
|
+
const driveRound = async (
|
|
167
|
+
before: string,
|
|
168
|
+
after: string,
|
|
169
|
+
opts: {
|
|
170
|
+
verdicts?: Record<string, Verdict>
|
|
171
|
+
inGate?: string
|
|
172
|
+
handEdit?: boolean
|
|
173
|
+
silent?: boolean
|
|
174
|
+
/** Nothing but thread edits lands after the in-gate collecting turn. */
|
|
175
|
+
settled?: boolean
|
|
176
|
+
noDropped?: boolean
|
|
177
|
+
} = {},
|
|
178
|
+
) => {
|
|
179
|
+
const triaged: string[][] = []
|
|
180
|
+
const captured: string[] = []
|
|
181
|
+
const editRanges: (string | undefined)[] = []
|
|
182
|
+
const dropped: Requested[] = []
|
|
183
|
+
const rounds: string[] = []
|
|
184
|
+
let awaits = 0
|
|
185
|
+
const p = {
|
|
186
|
+
file: "r.md",
|
|
187
|
+
feedback: "f.md",
|
|
188
|
+
isCode: (path: string) => path.endsWith(".ts"),
|
|
189
|
+
reviewing: async () => void (state.files["r.md"] = before),
|
|
190
|
+
fixRisks: async () => {},
|
|
191
|
+
keepGreen: async () => {},
|
|
192
|
+
awaitReview: async () => {
|
|
193
|
+
awaits++
|
|
194
|
+
if (awaits > 2 || (awaits > 1 && opts.inGate === undefined)) throw new Error("stop")
|
|
195
|
+
if (opts.inGate !== undefined && awaits === 1) {
|
|
196
|
+
state.agentThread = true
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
state.humanThread = false
|
|
200
|
+
state.files["r.md"] = after
|
|
201
|
+
state.since = [{ path: "r.md", status: "modified", before, after }]
|
|
202
|
+
if (opts.handEdit)
|
|
203
|
+
state.since.push({ path: "a.ts", status: "modified", before: "a", after: "b" })
|
|
204
|
+
},
|
|
205
|
+
reviewMissing: async () => {},
|
|
206
|
+
collecting: async (_capture: string, round: string) => {
|
|
207
|
+
rounds.push(round)
|
|
208
|
+
if (opts.inGate !== undefined && state.agentThread) {
|
|
209
|
+
state.agentThread = false
|
|
210
|
+
state.humanThread = true
|
|
211
|
+
state.files["r.md"] = opts.inGate
|
|
212
|
+
state.head = "h2"
|
|
213
|
+
if (opts.settled) state.sinceAfter = []
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
if (!opts.silent) state.feedback = "x"
|
|
217
|
+
},
|
|
218
|
+
triage: async (ns: readonly ReviewNote[]) => {
|
|
219
|
+
triaged.push(ns.map((n) => n.id))
|
|
220
|
+
return new Map(Object.entries(opts.verdicts ?? {}))
|
|
221
|
+
},
|
|
222
|
+
answerQuestions: async () => {},
|
|
223
|
+
fixNits: async () => {},
|
|
224
|
+
defer: async () => {},
|
|
225
|
+
close: async () => {},
|
|
226
|
+
captures: {
|
|
227
|
+
edits: (_round: string, from?: string) => (
|
|
228
|
+
captured.push("edits"),
|
|
229
|
+
editRanges.push(from),
|
|
230
|
+
"edits"
|
|
231
|
+
),
|
|
232
|
+
notes: () => (captured.push("notes"), "notes"),
|
|
233
|
+
editNotes: () => (captured.push("editNotes"), "editNotes"),
|
|
234
|
+
},
|
|
235
|
+
refusals: {
|
|
236
|
+
deleted: "deleted",
|
|
237
|
+
unmatched: "unmatched",
|
|
238
|
+
...(opts.noDropped
|
|
239
|
+
? {}
|
|
240
|
+
: {
|
|
241
|
+
dropped: (r: Requested) => {
|
|
242
|
+
dropped.push(r)
|
|
243
|
+
return "dropped"
|
|
244
|
+
},
|
|
245
|
+
}),
|
|
246
|
+
},
|
|
247
|
+
} as unknown as ReviewPolicy
|
|
248
|
+
const result = await review("base", p).catch((e: Error) => e)
|
|
249
|
+
return { result, triaged, dropped, captured, rounds, editRanges }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
describe("a one-shot footnote is a note, a thread footnote is not", () => {
|
|
253
|
+
const base = doc(`${POINTER}[^t]`, "", thread("H: why?"))
|
|
254
|
+
const replied = doc(`${POINTER}[^t]`, "", thread("H: why?", "A: because"))
|
|
255
|
+
|
|
256
|
+
it("a one-shot footnote added after an in-gate collecting turn routes through triage", async () => {
|
|
257
|
+
const withFootnote = doc(
|
|
258
|
+
`${POINTER}[^t][^n1]`,
|
|
259
|
+
"",
|
|
260
|
+
thread("H: why?", "A: because"),
|
|
261
|
+
"",
|
|
262
|
+
oneShot("n1", "please rename this"),
|
|
263
|
+
)
|
|
264
|
+
const r = await driveRound(base, withFootnote, {
|
|
265
|
+
inGate: replied,
|
|
266
|
+
verdicts: { "note-1": "praise" },
|
|
267
|
+
})
|
|
268
|
+
expect(r.triaged).toHaveLength(1)
|
|
269
|
+
expect(r.triaged[0]).toHaveLength(1)
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
it("only a thread reply after an in-gate collecting turn still settles", async () => {
|
|
273
|
+
const r = await driveRound(base, replied, { inGate: replied })
|
|
274
|
+
expect(r.triaged).toEqual([])
|
|
275
|
+
expect(r.result).toEqual({ verdict: "signoff" })
|
|
276
|
+
})
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
describe("the dropped refusal lists edit notes only", () => {
|
|
280
|
+
const before = doc(POINTER, "- [ ] ./a.ts#2-2 other")
|
|
281
|
+
const after = doc(`${POINTER} nice`, "- [ ] ./a.ts#2-2 other rename it")
|
|
282
|
+
|
|
283
|
+
it("a hand-edit round names the edited paths and no notes", async () => {
|
|
284
|
+
const r = await driveRound(before, after, { handEdit: true, silent: true })
|
|
285
|
+
expect(r.result).toBeInstanceOf(Error)
|
|
286
|
+
expect(r.dropped).toEqual([{ notes: [], paths: ["a.ts"] }])
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
it('a round with unparsed notes collects as "notes", a triaged one as "triaged"', async () => {
|
|
290
|
+
const unparsed = await driveRound(before, `${before}\n\nstray prose`, { silent: true })
|
|
291
|
+
expect(unparsed.rounds).toEqual(["notes"])
|
|
292
|
+
const praise = doc(`${POINTER}[^n1]`, "- [ ] ./a.ts#2-2 other", "", oneShot("n1", "rename it"))
|
|
293
|
+
const triaged = await driveRound(before, praise, {
|
|
294
|
+
verdicts: { "note-1": "edit" },
|
|
295
|
+
silent: true,
|
|
296
|
+
})
|
|
297
|
+
expect(triaged.rounds).toEqual(["triaged"])
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it("a notes-only round names the notes judged edit, footnotes included", async () => {
|
|
301
|
+
const praise = doc(
|
|
302
|
+
`${POINTER}[^n1]`,
|
|
303
|
+
"- [ ] ./a.ts#2-2 other[^n2]",
|
|
304
|
+
"",
|
|
305
|
+
oneShot("n1", "lovely"),
|
|
306
|
+
"",
|
|
307
|
+
oneShot("n2", "rename it"),
|
|
308
|
+
)
|
|
309
|
+
const r = await driveRound(before, praise, {
|
|
310
|
+
verdicts: { "note-1": "praise", "note-2": "edit" },
|
|
311
|
+
silent: true,
|
|
312
|
+
})
|
|
313
|
+
expect(r.result).toBeInstanceOf(Error)
|
|
314
|
+
expect(r.triaged[0]).toHaveLength(2)
|
|
315
|
+
expect(r.dropped).toHaveLength(1)
|
|
316
|
+
expect(r.dropped[0]!.paths).toEqual([])
|
|
317
|
+
expect(r.dropped[0]!.notes).toHaveLength(1)
|
|
318
|
+
expect(r.dropped[0]!.notes[0]!.anchor).toContain("[^n2]")
|
|
319
|
+
})
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
describe("a settled round with an unfolded change", () => {
|
|
323
|
+
const base = doc(`${POINTER}[^t]`, "", thread("H: why?"))
|
|
324
|
+
const replied = doc(`${POINTER}[^t]`, "", thread("H: why?", "A: because"))
|
|
325
|
+
const withNote = (text: string) =>
|
|
326
|
+
doc(`${POINTER} ${text}[^t]`, "", thread("H: why?", "A: because", "H: ok"))
|
|
327
|
+
|
|
328
|
+
it("an edit note since reviewed with an empty collecting turn refuses naming the note", async () => {
|
|
329
|
+
const r = await driveRound(base, withNote("rename it"), {
|
|
330
|
+
inGate: replied,
|
|
331
|
+
settled: true,
|
|
332
|
+
silent: true,
|
|
333
|
+
verdicts: { "note-1": "edit" },
|
|
334
|
+
})
|
|
335
|
+
expect(r.result).toBeInstanceOf(Error)
|
|
336
|
+
expect(r.triaged).toHaveLength(1)
|
|
337
|
+
expect(r.captured).toEqual(["edits", "editNotes"])
|
|
338
|
+
expect(r.rounds).toEqual(["reply", "triaged"])
|
|
339
|
+
expect(r.dropped).toHaveLength(1)
|
|
340
|
+
expect(r.dropped[0]!.notes).toHaveLength(1)
|
|
341
|
+
expect(r.dropped[0]!.paths).toEqual([])
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
it("a code hand-edit since reviewed names the path", async () => {
|
|
345
|
+
const r = await driveRound(base, replied, {
|
|
346
|
+
inGate: replied,
|
|
347
|
+
settled: true,
|
|
348
|
+
silent: true,
|
|
349
|
+
handEdit: true,
|
|
350
|
+
})
|
|
351
|
+
expect(r.result).toBeInstanceOf(Error)
|
|
352
|
+
expect(r.triaged).toEqual([])
|
|
353
|
+
expect(r.captured).toEqual(["edits", "edits"])
|
|
354
|
+
expect(r.rounds).toEqual(["reply", "edits"])
|
|
355
|
+
expect(r.dropped).toEqual([{ notes: [], paths: ["a.ts"] }])
|
|
356
|
+
// the in-gate reply capture has no range; the settled round's starts at `reviewed`
|
|
357
|
+
expect(r.editRanges).toEqual([undefined, "h1"])
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
it("a nit-only note is fixed without a collecting turn or a refusal", async () => {
|
|
361
|
+
const r = await driveRound(base, withNote("typo"), {
|
|
362
|
+
inGate: replied,
|
|
363
|
+
settled: true,
|
|
364
|
+
silent: true,
|
|
365
|
+
verdicts: { "note-1": "nit" },
|
|
366
|
+
})
|
|
367
|
+
expect(r.rounds).toEqual(["reply"])
|
|
368
|
+
expect(r.dropped).toEqual([])
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
it("a closed collecting turn that writes the feedback file turns the round into feedback", async () => {
|
|
372
|
+
const r = await driveRound(base, withNote("rename it"), {
|
|
373
|
+
inGate: replied,
|
|
374
|
+
settled: true,
|
|
375
|
+
verdicts: { "note-1": "edit" },
|
|
376
|
+
})
|
|
377
|
+
expect(r.result).toMatchObject({ verdict: "feedback" })
|
|
378
|
+
expect(r.dropped).toEqual([])
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
it("praise-only notes sign off", async () => {
|
|
382
|
+
const r = await driveRound(base, withNote("lovely"), {
|
|
383
|
+
inGate: replied,
|
|
384
|
+
settled: true,
|
|
385
|
+
verdicts: { "note-1": "praise" },
|
|
386
|
+
})
|
|
387
|
+
expect(r.triaged).toHaveLength(1)
|
|
388
|
+
expect(r.result).toEqual({ verdict: "signoff" })
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
it("only thread replies sign off without triage", async () => {
|
|
392
|
+
const r = await driveRound(
|
|
393
|
+
base,
|
|
394
|
+
doc(`${POINTER}[^t]`, "", thread("H: why?", "A: because", "H: ok")),
|
|
395
|
+
{
|
|
396
|
+
inGate: replied,
|
|
397
|
+
settled: true,
|
|
398
|
+
},
|
|
399
|
+
)
|
|
400
|
+
expect(r.triaged).toEqual([])
|
|
401
|
+
expect(r.result).toEqual({ verdict: "signoff" })
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
it("without a dropped refusal an edit note signs off unjudged", async () => {
|
|
405
|
+
const r = await driveRound(base, withNote("rename it"), {
|
|
406
|
+
inGate: replied,
|
|
407
|
+
settled: true,
|
|
408
|
+
noDropped: true,
|
|
409
|
+
verdicts: { "note-1": "edit" },
|
|
410
|
+
})
|
|
411
|
+
expect(r.triaged).toEqual([])
|
|
412
|
+
expect(r.result).toEqual({ verdict: "signoff" })
|
|
413
|
+
})
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
describe("reviewToHandOff", () => {
|
|
417
|
+
it("hands off a footnote-only edit instead of closing as a sign-off", async () => {
|
|
418
|
+
const before = doc(POINTER)
|
|
419
|
+
const after = doc(`${POINTER}[^n1]`, "", oneShot("n1", "rename it"))
|
|
420
|
+
const calls: string[] = []
|
|
421
|
+
let awaits = 0
|
|
422
|
+
const p = {
|
|
423
|
+
file: "r.md",
|
|
424
|
+
reviewing: async () => void (state.files["r.md"] = before),
|
|
425
|
+
fixRisks: async () => {},
|
|
426
|
+
keepGreen: async () => {},
|
|
427
|
+
awaitReview: async () => {
|
|
428
|
+
if (++awaits > 1) throw new Error("stop")
|
|
429
|
+
state.files["r.md"] = after
|
|
430
|
+
state.since = [{ path: "r.md", status: "modified", before, after }]
|
|
431
|
+
},
|
|
432
|
+
reviewMissing: async () => {},
|
|
433
|
+
close: async () => void calls.push("close"),
|
|
434
|
+
handOff: async () => void calls.push("handOff"),
|
|
435
|
+
deleted: "deleted",
|
|
436
|
+
threadsPending: "pending",
|
|
437
|
+
unparsed: "unparsed",
|
|
438
|
+
} as unknown as HandOffPolicy
|
|
439
|
+
await reviewToHandOff("base", p)
|
|
440
|
+
expect(calls).toEqual(["handOff"])
|
|
441
|
+
})
|
|
442
|
+
})
|