@pmelab/gtd 18.1.0 → 19.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 +28 -30
- package/dist/gtd.bundle.mjs +338 -460
- package/package.json +2 -1
- package/skills/authoring/SKILL.md +6 -7
- package/src/flows/helpers.ts +4 -2
- package/src/flows/scripts.test.ts +28 -1
- package/src/flows/scripts.ts +3 -0
- package/src/workflows/access.test.ts +0 -1
- package/src/workflows/access.ts +0 -1
- package/src/workflows/health.test.ts +21 -1
- package/src/workflows/health.ts +47 -8
- package/src/workflows/packages.test.ts +91 -0
- package/src/workflows/packages.ts +96 -76
- package/src/workflows/planning.ts +5 -49
- package/src/workflows/prose.ts +1 -9
- package/src/workflows/review.test.ts +88 -1
- package/src/workflows/review.ts +41 -16
- package/src/workflows/scenarios.test.ts +256 -0
- package/src/workflows/scenarios.ts +137 -0
- package/src/workflows/skills.test.ts +0 -4
- package/src/workflows/skills.ts +0 -2
- package/src/workflows/steps.test.ts +0 -10
- package/src/workflows/steps.ts +2 -20
- package/src/workflows/text.test.ts +60 -0
- package/src/workflows/text.ts +117 -95
- package/src/workflows/unified.ts +8 -4
- package/src/workflows/vars.ts +1 -2
- package/src/workflows/diff.test.ts +0 -115
- package/src/workflows/diff.ts +0 -306
package/src/workflows/prose.ts
CHANGED
|
@@ -77,8 +77,7 @@ export const architectPersona = `You are the technical planning voice in gtd's b
|
|
|
77
77
|
once product concerns are settled, reading them cold, no carried
|
|
78
78
|
conversation. Work out the *how* per concern — structure, data models,
|
|
79
79
|
tech-stack choices, error handling — raise the technical open
|
|
80
|
-
questions, then
|
|
81
|
-
judgement call: the grouping is already decided.`
|
|
80
|
+
questions, then pin interfaces and tests in the architecture document.`
|
|
82
81
|
|
|
83
82
|
export const reviewerPersona = `You are the independent reviewing mind in gtd's build pipeline —
|
|
84
83
|
deliberately separate from whoever wrote the code, with no attachment
|
|
@@ -89,13 +88,6 @@ and, when asked, fix the small nits the human flagged and the risks
|
|
|
89
88
|
you marked yourself, each in one batch.
|
|
90
89
|
Beyond that you never fix or build anything yourself.`
|
|
91
90
|
|
|
92
|
-
export const specReviewerPersona = `You are the adversarial spec-conformance checker in gtd's build
|
|
93
|
-
pipeline, checking one freshly-built package against its spec. Verify
|
|
94
|
-
only: tasks done, criteria met, code sound and consistent with the
|
|
95
|
-
codebase. Write feedback only when something is genuinely wrong —
|
|
96
|
-
otherwise write nothing; a clean turn IS the approval. Never fix what
|
|
97
|
-
you find — naming it precisely enough for a fix turn is the whole job.`
|
|
98
|
-
|
|
99
91
|
export const builderPersona = `You are the TDD implementer in gtd's build pipeline: build one
|
|
100
92
|
package's declared scope end to end, tests first, and come back to fix
|
|
101
93
|
things when redirected — a failing check, or reviewer feedback. Stay
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it } from "vitest"
|
|
2
2
|
import { installContext, type Change, type JudgeAnswer, type StepRequest } from "../flows/index.js"
|
|
3
|
-
import { review, type ReviewOutcome } from "./review.js"
|
|
3
|
+
import { fixQualityFindings, review, type ReviewOutcome } from "./review.js"
|
|
4
4
|
import { unified } from "./index.js"
|
|
5
5
|
import { fixtureContext } from "./text.fixture.js"
|
|
6
6
|
|
|
@@ -331,3 +331,90 @@ describe("the default quality lenses", () => {
|
|
|
331
331
|
])
|
|
332
332
|
})
|
|
333
333
|
})
|
|
334
|
+
|
|
335
|
+
describe("scenario wording in review's fix turns", () => {
|
|
336
|
+
const frozen = { at: "h1", texts: { "e2e/a.feature": "Given a" } }
|
|
337
|
+
const risky = doc(["Risk: drops the carry", "sub"])
|
|
338
|
+
|
|
339
|
+
it("a green-keeping fix after a risk fix that rewrites a scenario stops at review.scenario-wording", async () => {
|
|
340
|
+
const log: string[] = []
|
|
341
|
+
let red = false
|
|
342
|
+
let drifted = false
|
|
343
|
+
installContext(
|
|
344
|
+
fixtureContext(
|
|
345
|
+
{},
|
|
346
|
+
{
|
|
347
|
+
step: (request: StepRequest) => {
|
|
348
|
+
if (request.kind === "restart") return Promise.resolve()
|
|
349
|
+
log.push(request.name)
|
|
350
|
+
if (request.name === "health.check") red = !red && !drifted
|
|
351
|
+
if (request.name === "fix") drifted = true
|
|
352
|
+
if (request.name === "scenario-wording") throw new Stop()
|
|
353
|
+
return Promise.resolve()
|
|
354
|
+
},
|
|
355
|
+
pushScope: () => undefined,
|
|
356
|
+
popScope: () => undefined,
|
|
357
|
+
read: (path) =>
|
|
358
|
+
path === REVIEW
|
|
359
|
+
? risky
|
|
360
|
+
: path === "e2e/a.feature"
|
|
361
|
+
? drifted
|
|
362
|
+
? "Given b"
|
|
363
|
+
: "Given a"
|
|
364
|
+
: undefined,
|
|
365
|
+
changes: (): readonly Change[] =>
|
|
366
|
+
red
|
|
367
|
+
? [{ path: ".gtd/FEEDBACK.md", status: "added", before: undefined, after: "red" }]
|
|
368
|
+
: [],
|
|
369
|
+
matches: (path, pattern) => path === pattern,
|
|
370
|
+
head: () => "h2",
|
|
371
|
+
start: () => "h0",
|
|
372
|
+
},
|
|
373
|
+
),
|
|
374
|
+
)
|
|
375
|
+
await review("base", undefined, frozen).catch((e: unknown) => {
|
|
376
|
+
if (!(e instanceof Stop)) throw e
|
|
377
|
+
})
|
|
378
|
+
expect(log.slice(-2)).toEqual(["fix", "scenario-wording"])
|
|
379
|
+
})
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
describe("the quality fix loop", () => {
|
|
383
|
+
it("a fix that resolves QUALITY.md and rewrites a scenario ends after the wording gate", async () => {
|
|
384
|
+
const QUALITY = ".gtd/QUALITY.md"
|
|
385
|
+
const log: string[] = []
|
|
386
|
+
const files = new Map([
|
|
387
|
+
[QUALITY, "- finding"],
|
|
388
|
+
["e2e/a.feature", "Given a"],
|
|
389
|
+
])
|
|
390
|
+
let last: readonly Change[] = []
|
|
391
|
+
installContext(
|
|
392
|
+
fixtureContext(
|
|
393
|
+
{},
|
|
394
|
+
{
|
|
395
|
+
step: (request: StepRequest) => {
|
|
396
|
+
if (request.kind === "restart") return Promise.resolve()
|
|
397
|
+
log.push(request.name)
|
|
398
|
+
last = []
|
|
399
|
+
if (request.name === "fix.quality.fixing") {
|
|
400
|
+
files.delete(QUALITY)
|
|
401
|
+
files.set("e2e/a.feature", "Given b")
|
|
402
|
+
last = [{ path: QUALITY, status: "deleted", before: "- finding", after: undefined }]
|
|
403
|
+
}
|
|
404
|
+
return Promise.resolve()
|
|
405
|
+
},
|
|
406
|
+
pushScope: () => undefined,
|
|
407
|
+
popScope: () => undefined,
|
|
408
|
+
read: (path) => files.get(path),
|
|
409
|
+
changes: () => last,
|
|
410
|
+
matches: (path, pattern) => path === pattern,
|
|
411
|
+
head: () => "h2",
|
|
412
|
+
start: () => "h0",
|
|
413
|
+
},
|
|
414
|
+
),
|
|
415
|
+
)
|
|
416
|
+
const frozen = { at: "h1", texts: { "e2e/a.feature": "Given a" } }
|
|
417
|
+
expect(await fixQualityFindings({ rounds: 0 }, frozen)).toBe(true)
|
|
418
|
+
expect(log).toEqual(["fix.quality.fixing", "scenario-wording"])
|
|
419
|
+
})
|
|
420
|
+
})
|
package/src/workflows/review.ts
CHANGED
|
@@ -19,8 +19,11 @@ import {
|
|
|
19
19
|
} from "../flows/index.js"
|
|
20
20
|
import { reviewNotes, reviewRisks, stripCodeThreads, type ReviewNote } from "../steering/index.js"
|
|
21
21
|
import { escalation, FIX_CAP, healthy, type EscalationCount } from "./health.js"
|
|
22
|
+
import type { BuiltPlan } from "./packages.js"
|
|
23
|
+
import { guarded, type FrozenScenarios } from "./scenarios.js"
|
|
22
24
|
import {
|
|
23
25
|
answerReviewQuestions,
|
|
26
|
+
ARCHITECTURE,
|
|
24
27
|
awaitReview,
|
|
25
28
|
collecting,
|
|
26
29
|
fix,
|
|
@@ -54,10 +57,14 @@ export const qualityLap = async (): Promise<"clean" | "findings"> => {
|
|
|
54
57
|
}
|
|
55
58
|
|
|
56
59
|
/** Resolves `true` once `.gtd/QUALITY.md` is resolved, `false` when the fix cap escalated instead. */
|
|
57
|
-
export const fixQualityFindings = async (
|
|
60
|
+
export const fixQualityFindings = async (
|
|
61
|
+
escalations: EscalationCount,
|
|
62
|
+
frozen?: FrozenScenarios,
|
|
63
|
+
): Promise<boolean> => {
|
|
58
64
|
for (let turns = 0; turns < FIX_CAP; turns++) {
|
|
59
|
-
await fixQuality()
|
|
60
|
-
|
|
65
|
+
await guarded(frozen, fixQuality)()
|
|
66
|
+
// Not `changes()`: a wording gate after the fix would be the last step.
|
|
67
|
+
if (read(QUALITY) === undefined) return true
|
|
61
68
|
}
|
|
62
69
|
await escalation(escalations)
|
|
63
70
|
return false
|
|
@@ -203,6 +210,7 @@ type Finish =
|
|
|
203
210
|
interface Round {
|
|
204
211
|
readonly round: string
|
|
205
212
|
readonly escalations: EscalationCount
|
|
213
|
+
readonly frozen: FrozenScenarios | undefined
|
|
206
214
|
readonly close: () => Promise<void>
|
|
207
215
|
readonly outcome: (verdict: "signoff" | "feedback") => ReviewOutcome
|
|
208
216
|
readonly unfolded: () => Promise<Finish>
|
|
@@ -221,8 +229,8 @@ const routeNotes = async (notes: readonly ReviewNote[], r: Round): Promise<Finis
|
|
|
221
229
|
answeredAt = head()
|
|
222
230
|
}
|
|
223
231
|
if (nits.length > 0) {
|
|
224
|
-
await fixNits(nits)
|
|
225
|
-
await healthy(fix, { escalations: r.escalations })
|
|
232
|
+
await guarded(r.frozen, () => fixNits(nits), "review")()
|
|
233
|
+
await healthy(guarded(r.frozen, fix, "review"), { escalations: r.escalations })
|
|
226
234
|
}
|
|
227
235
|
if (edits.length > 0) {
|
|
228
236
|
await r.close()
|
|
@@ -239,6 +247,7 @@ const finish = async (
|
|
|
239
247
|
reviewed: string,
|
|
240
248
|
collectedAt: string | undefined,
|
|
241
249
|
escalations: EscalationCount,
|
|
250
|
+
frozen: FrozenScenarios | undefined,
|
|
242
251
|
): Promise<Finish> => {
|
|
243
252
|
// Everything the human did is read before any agent turn runs, so a nit fix
|
|
244
253
|
// is never counted as a hand-edit.
|
|
@@ -268,7 +277,7 @@ const finish = async (
|
|
|
268
277
|
if (edited.length > 0) return collectWith(t.reviewEditsCapture(round))
|
|
269
278
|
const notes = reviewNotes(baselineText, record)
|
|
270
279
|
if (notes.length === 0) return collectWith(t.reviewNotesCapture(round))
|
|
271
|
-
return routeNotes(notes, { round, escalations, close, outcome, unfolded })
|
|
280
|
+
return routeNotes(notes, { round, escalations, frozen, close, outcome, unfolded })
|
|
272
281
|
}
|
|
273
282
|
|
|
274
283
|
/** Write the review; if it marks risks, fix them, keep green, and write it again — once, so the re-review's own risks reach the human unfixed. */
|
|
@@ -276,12 +285,13 @@ const reviewOnce = async (
|
|
|
276
285
|
base: string,
|
|
277
286
|
carry: string | undefined,
|
|
278
287
|
escalations: EscalationCount,
|
|
288
|
+
frozen: FrozenScenarios | undefined,
|
|
279
289
|
): Promise<void> => {
|
|
280
290
|
await reviewing(base, carry)
|
|
281
291
|
const risks = reviewRisks(read(REVIEW) ?? "")
|
|
282
292
|
if (risks.length === 0) return
|
|
283
|
-
await fixRisks(risks)
|
|
284
|
-
await healthy(fix, { escalations })
|
|
293
|
+
await guarded(frozen, () => fixRisks(risks), "review")()
|
|
294
|
+
await healthy(guarded(frozen, fix, "review"), { escalations })
|
|
285
295
|
await reviewing(base, carry)
|
|
286
296
|
}
|
|
287
297
|
|
|
@@ -294,17 +304,18 @@ const reviewOnce = async (
|
|
|
294
304
|
export const review = async (
|
|
295
305
|
base: string,
|
|
296
306
|
escalations: EscalationCount = { rounds: 0 },
|
|
307
|
+
frozen?: FrozenScenarios,
|
|
297
308
|
): Promise<ReviewOutcome> => {
|
|
298
309
|
let carry: string | undefined
|
|
299
310
|
for (;;) {
|
|
300
|
-
await reviewOnce(base, carry, escalations)
|
|
311
|
+
await reviewOnce(base, carry, escalations, frozen)
|
|
301
312
|
carry = undefined
|
|
302
313
|
let reviewed = head()
|
|
303
314
|
let collectedAt: string | undefined
|
|
304
315
|
for (;;) {
|
|
305
316
|
collectedAt = await converse(base, collectedAt)
|
|
306
317
|
if (collectedAt === "missing") break
|
|
307
|
-
const result = await finish(reviewed, collectedAt, escalations)
|
|
318
|
+
const result = await finish(reviewed, collectedAt, escalations, frozen)
|
|
308
319
|
if (!("next" in result)) return result
|
|
309
320
|
if (result.next === "rereview") {
|
|
310
321
|
carry = result.carry
|
|
@@ -316,22 +327,36 @@ export const review = async (
|
|
|
316
327
|
}
|
|
317
328
|
|
|
318
329
|
/** The build tail: fix (when entered red), keep green, the quality lap, then human review since `base`. */
|
|
319
|
-
export const buildTail = (
|
|
330
|
+
export const buildTail = (
|
|
331
|
+
fixFirst: boolean,
|
|
332
|
+
base: string,
|
|
333
|
+
built?: BuiltPlan,
|
|
334
|
+
): Promise<ReviewOutcome> =>
|
|
320
335
|
scope("build", async () => {
|
|
336
|
+
const frozen = built?.frozen
|
|
321
337
|
const escalations: EscalationCount = { rounds: 0 }
|
|
338
|
+
const guardedFix = guarded(frozen, () => fix())
|
|
322
339
|
let redFirst = fixFirst
|
|
340
|
+
// fixFirst's own fix + healthy below already is the full run.
|
|
341
|
+
if (!fixFirst) {
|
|
342
|
+
await healthy(
|
|
343
|
+
guarded(frozen, () => fix(built)),
|
|
344
|
+
{ escalations, sweepOnGreen: [ARCHITECTURE] },
|
|
345
|
+
)
|
|
346
|
+
}
|
|
323
347
|
// The lap runs once a tail: after its findings are fixed, review follows.
|
|
324
348
|
let lapped = false
|
|
325
349
|
for (;;) {
|
|
326
350
|
if (redFirst) {
|
|
327
|
-
await
|
|
328
|
-
await healthy(
|
|
351
|
+
await guardedFix()
|
|
352
|
+
await healthy(guardedFix, { fixesSoFar: 1, escalations })
|
|
329
353
|
redFirst = false
|
|
330
354
|
}
|
|
331
355
|
const lap = lapped ? "clean" : await qualityLap()
|
|
332
356
|
lapped = true
|
|
333
|
-
if (lap === "clean") return review(base, escalations)
|
|
334
|
-
if (await fixQualityFindings(escalations))
|
|
335
|
-
|
|
357
|
+
if (lap === "clean") return review(base, escalations, frozen)
|
|
358
|
+
if (await fixQualityFindings(escalations, frozen)) {
|
|
359
|
+
await healthy(guardedFix, { escalations })
|
|
360
|
+
} else redFirst = true
|
|
336
361
|
}
|
|
337
362
|
})
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest"
|
|
2
|
+
import { installContext, type StepRequest } from "../flows/index.js"
|
|
3
|
+
import {
|
|
4
|
+
driftedScenarios,
|
|
5
|
+
freezeScenarios,
|
|
6
|
+
guarded,
|
|
7
|
+
holdWording,
|
|
8
|
+
scenarioText,
|
|
9
|
+
} from "./scenarios.js"
|
|
10
|
+
import { fixtureContext, renderText } from "./text.fixture.js"
|
|
11
|
+
|
|
12
|
+
afterEach(() => installContext(undefined))
|
|
13
|
+
|
|
14
|
+
const tree = (files: Record<string, string>) => ({
|
|
15
|
+
read: (path: string) => files[path],
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
describe("scenarioText", () => {
|
|
19
|
+
it("ignores comments, tags, blank lines and indentation", () => {
|
|
20
|
+
const loose = [
|
|
21
|
+
"@wip @slow",
|
|
22
|
+
"Feature: x",
|
|
23
|
+
"",
|
|
24
|
+
" # a comment",
|
|
25
|
+
" @tag",
|
|
26
|
+
" Scenario: y",
|
|
27
|
+
" Given a thing",
|
|
28
|
+
].join("\n")
|
|
29
|
+
const tight = "Feature: x\nScenario: y\nGiven a thing"
|
|
30
|
+
expect(scenarioText(loose)).toBe(scenarioText(tight))
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it("keeps changed wording", () => {
|
|
34
|
+
expect(scenarioText("Given a")).not.toBe(scenarioText("Given b"))
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it.each([
|
|
38
|
+
["a # line", "# Review: abc"],
|
|
39
|
+
["an @ line", "@mention"],
|
|
40
|
+
["a blank line", ""],
|
|
41
|
+
])("keeps %s inside a doc string", (_, line) => {
|
|
42
|
+
const doc = (body: string[]) =>
|
|
43
|
+
["Given a file with:", ' """', ...body.map((l) => (l === "" ? "" : ` ${l}`)), ' """'].join(
|
|
44
|
+
"\n",
|
|
45
|
+
)
|
|
46
|
+
expect(scenarioText(doc(["top", line, "end"]))).not.toBe(scenarioText(doc(["top", "end"])))
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it("keeps indentation inside a doc string", () => {
|
|
50
|
+
const doc = (line: string) => ["Given a file with:", ' """', ` ${line}`, ' """'].join("\n")
|
|
51
|
+
expect(scenarioText(doc(" nested"))).not.toBe(scenarioText(doc("nested")))
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it("ignores a doc string re-indented together with its delimiters", () => {
|
|
55
|
+
const at = (indent: string) =>
|
|
56
|
+
[
|
|
57
|
+
"Given a file with:",
|
|
58
|
+
`${indent}\`\`\``,
|
|
59
|
+
`${indent}# Review`,
|
|
60
|
+
`${indent} - x`,
|
|
61
|
+
`${indent}\`\`\``,
|
|
62
|
+
].join("\n")
|
|
63
|
+
expect(scenarioText(at(" "))).toBe(scenarioText(at(" ")))
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
describe("freezeScenarios", () => {
|
|
68
|
+
it("snapshots wording at head, nothing frozen is undefined", () => {
|
|
69
|
+
const files = { "a.feature": "@t\nGiven a" }
|
|
70
|
+
renderText(() => {
|
|
71
|
+
expect(freezeScenarios([])).toBeUndefined()
|
|
72
|
+
})
|
|
73
|
+
installContext(fixtureContext({ head: "h1" }, tree(files)))
|
|
74
|
+
expect(freezeScenarios(["a.feature"])).toEqual({
|
|
75
|
+
at: "h1",
|
|
76
|
+
texts: { "a.feature": scenarioText("Given a") },
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it("merges over previous, a new path overriding an old one", () => {
|
|
81
|
+
installContext(
|
|
82
|
+
fixtureContext({ head: "h2" }, tree({ "b.feature": "Given b", "a.feature": "Given a2" })),
|
|
83
|
+
)
|
|
84
|
+
const merged = freezeScenarios(["a.feature", "b.feature"], {
|
|
85
|
+
at: "h1",
|
|
86
|
+
texts: { "a.feature": "Given a", "c.feature": "Given c" },
|
|
87
|
+
})
|
|
88
|
+
expect(merged).toEqual({
|
|
89
|
+
at: "h2",
|
|
90
|
+
texts: {
|
|
91
|
+
"a.feature": "Given a2",
|
|
92
|
+
"b.feature": "Given b",
|
|
93
|
+
"c.feature": "Given c",
|
|
94
|
+
},
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it("keeps previous untouched when no paths are given", () => {
|
|
99
|
+
const previous = { at: "h1", texts: { "a.feature": "Given a" } }
|
|
100
|
+
installContext(fixtureContext({ head: "h2" }, tree({})))
|
|
101
|
+
expect(freezeScenarios([], previous)).toBe(previous)
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
describe("driftedScenarios", () => {
|
|
106
|
+
const frozen = { at: "h1", texts: { "a.feature": "Given a", "b.feature": "Given b" } }
|
|
107
|
+
|
|
108
|
+
it("flags edited and deleted files, ignores comment-only edits and other paths", () => {
|
|
109
|
+
installContext(
|
|
110
|
+
fixtureContext({}, tree({ "a.feature": "# note\nGiven a2", "other.feature": "Given z" })),
|
|
111
|
+
)
|
|
112
|
+
expect(driftedScenarios(frozen)).toEqual(["a.feature", "b.feature"])
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it("is empty while wording is unchanged", () => {
|
|
116
|
+
installContext(
|
|
117
|
+
fixtureContext({}, tree({ "a.feature": " @x\n Given a", "b.feature": "Given b" })),
|
|
118
|
+
)
|
|
119
|
+
expect(driftedScenarios(frozen)).toEqual([])
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
describe("holdWording", () => {
|
|
124
|
+
const run = async (
|
|
125
|
+
files: () => Record<string, string>,
|
|
126
|
+
frozen: Parameters<typeof holdWording>[0],
|
|
127
|
+
head = "h2",
|
|
128
|
+
): Promise<StepRequest[]> => {
|
|
129
|
+
const steps: StepRequest[] = []
|
|
130
|
+
installContext(
|
|
131
|
+
fixtureContext(
|
|
132
|
+
{ head },
|
|
133
|
+
{
|
|
134
|
+
read: (path) => files()[path],
|
|
135
|
+
step: (request) => {
|
|
136
|
+
steps.push(request)
|
|
137
|
+
return Promise.resolve()
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
),
|
|
141
|
+
)
|
|
142
|
+
await holdWording(frozen)
|
|
143
|
+
return steps
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
it("does nothing without a freeze or without drift", async () => {
|
|
147
|
+
expect(await run(() => ({}), undefined)).toEqual([])
|
|
148
|
+
const frozen = { at: "h1", texts: { "a.feature": "Given a" } }
|
|
149
|
+
expect(await run(() => ({ "a.feature": "Given a" }), frozen)).toEqual([])
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it("rests at scenario-wording with the removed and added lines and the restore command", async () => {
|
|
153
|
+
const frozen = { at: "h1", texts: { "my dir/a.feature": "Given a\nThen ok" } }
|
|
154
|
+
const steps = await run(() => ({ "my dir/a.feature": "Given a\nThen better" }), frozen)
|
|
155
|
+
const [step] = steps
|
|
156
|
+
if (step?.kind !== "human") throw new Error("expected a human step")
|
|
157
|
+
expect(step.name).toBe("scenario-wording")
|
|
158
|
+
expect(step.options.acceptClean).toBe(true)
|
|
159
|
+
expect(step.options.base).toBe("h1")
|
|
160
|
+
const message = step.options.message ?? ""
|
|
161
|
+
expect(message).toContain("- Then ok")
|
|
162
|
+
expect(message).toContain("+ Then better")
|
|
163
|
+
expect(message).toContain("git checkout h1 -- 'my dir/a.feature'")
|
|
164
|
+
expect(message).toContain("partial accept")
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
const shownDiff = async (before: string, after: string): Promise<string[]> => {
|
|
168
|
+
const frozen = { at: "h1", texts: { "a.feature": before } }
|
|
169
|
+
const [step] = await run(() => ({ "a.feature": after }), frozen)
|
|
170
|
+
if (step?.kind !== "human") throw new Error("expected a human step")
|
|
171
|
+
return (step.options.message ?? "").split("\n").filter((l) => /^[-+] /.test(l))
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
it("shows a moved step as one removal and one addition of it, in place", async () => {
|
|
175
|
+
expect(await shownDiff("Given a\nWhen b\nThen c", "When b\nGiven a\nThen c")).toEqual([
|
|
176
|
+
"- Given a",
|
|
177
|
+
"+ Given a",
|
|
178
|
+
])
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it("shows a blank line added inside a doc string", async () => {
|
|
182
|
+
expect(await shownDiff('"""\na\nb\n"""', '"""\na\n\nb\n"""')).toEqual(["+ "])
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it("shows a repeated step as its added copy", async () => {
|
|
186
|
+
expect(await shownDiff("Given a\nThen c", "Given a\nGiven a\nThen c")).toEqual(["+ Given a"])
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it("accepts still-differing files by moving the snapshot to head", async () => {
|
|
190
|
+
const frozen = { at: "h1", texts: { "a.feature": "Given a", "b.feature": "Given b" } }
|
|
191
|
+
await run(() => ({ "a.feature": "Given a2", "b.feature": "Given b2" }), frozen)
|
|
192
|
+
expect(frozen).toEqual({
|
|
193
|
+
at: "h2",
|
|
194
|
+
texts: { "a.feature": "Given a2", "b.feature": "Given b2" },
|
|
195
|
+
})
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it("rejects restored files, keeping the original wording frozen", async () => {
|
|
199
|
+
const frozen = { at: "h1", texts: { "a.feature": "Given a", "b.feature": "Given b" } }
|
|
200
|
+
let files: Record<string, string> = { "a.feature": "Given a2", "b.feature": "Given b2" }
|
|
201
|
+
installContext(
|
|
202
|
+
fixtureContext(
|
|
203
|
+
{ head: "h2" },
|
|
204
|
+
{
|
|
205
|
+
read: (path) => files[path],
|
|
206
|
+
step: () => {
|
|
207
|
+
files = { "a.feature": "Given a", "b.feature": "Given b2" }
|
|
208
|
+
return Promise.resolve()
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
),
|
|
212
|
+
)
|
|
213
|
+
await holdWording(frozen)
|
|
214
|
+
expect(frozen.texts).toEqual({ "a.feature": "Given a", "b.feature": "Given b2" })
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it("drops an accepted deletion from the snapshot", async () => {
|
|
218
|
+
const frozen = { at: "h1", texts: { "a.feature": "Given a" } }
|
|
219
|
+
await run(() => ({}), frozen)
|
|
220
|
+
expect(frozen.texts).toEqual({})
|
|
221
|
+
})
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
describe("guarded", () => {
|
|
225
|
+
it("runs the turn, then holds wording", async () => {
|
|
226
|
+
const order: string[] = []
|
|
227
|
+
const frozen = { at: "h1", texts: { "a.feature": "Given a" } }
|
|
228
|
+
installContext(
|
|
229
|
+
fixtureContext(
|
|
230
|
+
{ head: "h2" },
|
|
231
|
+
{
|
|
232
|
+
read: () => "Given changed",
|
|
233
|
+
step: (request) => {
|
|
234
|
+
order.push(request.kind)
|
|
235
|
+
return Promise.resolve()
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
),
|
|
239
|
+
)
|
|
240
|
+
await guarded(frozen, () => {
|
|
241
|
+
order.push("turn")
|
|
242
|
+
return Promise.resolve()
|
|
243
|
+
})()
|
|
244
|
+
expect(order).toEqual(["turn", "human"])
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it("is the bare turn without a freeze", async () => {
|
|
248
|
+
const order: string[] = []
|
|
249
|
+
installContext(fixtureContext())
|
|
250
|
+
await guarded(undefined, () => {
|
|
251
|
+
order.push("turn")
|
|
252
|
+
return Promise.resolve()
|
|
253
|
+
})()
|
|
254
|
+
expect(order).toEqual(["turn"])
|
|
255
|
+
})
|
|
256
|
+
})
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { head, human, read, scope } from "../flows/index.js"
|
|
2
|
+
import * as t from "./text.js"
|
|
3
|
+
|
|
4
|
+
/** Mutable: an accepted change moves the snapshot forward. */
|
|
5
|
+
export interface FrozenScenarios {
|
|
6
|
+
at: string
|
|
7
|
+
texts: Record<string, string>
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const DOC_STRING = /^(\s*)("""|```)/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Gherkin wording only: lines trimmed; blank, `#` comment and `@` tag lines
|
|
14
|
+
* dropped — except inside a doc string, whose content is test input: kept
|
|
15
|
+
* whole, indented relative to its opening delimiter (as Gherkin reads it).
|
|
16
|
+
*/
|
|
17
|
+
export const scenarioText = (source: string): string => {
|
|
18
|
+
const out: string[] = []
|
|
19
|
+
let doc: { readonly indent: number; readonly fence: string } | undefined
|
|
20
|
+
for (const line of source.split("\n")) {
|
|
21
|
+
const delimiter = DOC_STRING.exec(line)
|
|
22
|
+
if (doc === undefined && delimiter !== null) {
|
|
23
|
+
doc = { indent: delimiter[1]!.length, fence: delimiter[2]! }
|
|
24
|
+
out.push(line.trim())
|
|
25
|
+
} else if (doc !== undefined && line.trim().startsWith(doc.fence)) {
|
|
26
|
+
doc = undefined
|
|
27
|
+
out.push(line.trim())
|
|
28
|
+
} else if (doc !== undefined) {
|
|
29
|
+
const lead = line.length - line.trimStart().length
|
|
30
|
+
out.push(line.slice(Math.min(lead, doc.indent)).trimEnd())
|
|
31
|
+
} else {
|
|
32
|
+
const trimmed = line.trim()
|
|
33
|
+
if (trimmed !== "" && !trimmed.startsWith("#") && !trimmed.startsWith("@")) out.push(trimmed)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return out.join("\n")
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const textOf = (path: string): string | undefined => {
|
|
40
|
+
const source = read(path)
|
|
41
|
+
return source === undefined ? undefined : scenarioText(source)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Snapshot `.feature` paths at `head()`, merged over `previous`; `undefined` when nothing is frozen. */
|
|
45
|
+
export const freezeScenarios = (
|
|
46
|
+
paths: readonly string[],
|
|
47
|
+
previous?: FrozenScenarios,
|
|
48
|
+
): FrozenScenarios | undefined => {
|
|
49
|
+
if (paths.length === 0) return previous
|
|
50
|
+
const texts: Record<string, string> = { ...previous?.texts }
|
|
51
|
+
for (const path of paths) {
|
|
52
|
+
const text = textOf(path)
|
|
53
|
+
if (text !== undefined) texts[path] = text
|
|
54
|
+
}
|
|
55
|
+
return Object.keys(texts).length === 0 ? previous : { at: head(), texts }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Paths whose wording differs from the snapshot (a deleted file counts). */
|
|
59
|
+
export const driftedScenarios = (frozen: FrozenScenarios): readonly string[] =>
|
|
60
|
+
Object.entries(frozen.texts)
|
|
61
|
+
.filter(([path, text]) => textOf(path) !== text)
|
|
62
|
+
.map(([path]) => path)
|
|
63
|
+
|
|
64
|
+
// Blank lines survive only inside doc strings, where they are wording.
|
|
65
|
+
const lines = (text: string | undefined): string[] =>
|
|
66
|
+
text === undefined || text === "" ? [] : text.split("\n")
|
|
67
|
+
|
|
68
|
+
/** `table[i][j]`: the longest common subsequence of `a[i..]` and `b[j..]`. */
|
|
69
|
+
const lcsTable = (a: readonly string[], b: readonly string[]): number[][] => {
|
|
70
|
+
const table = Array.from({ length: a.length + 1 }, () => Array<number>(b.length + 1).fill(0))
|
|
71
|
+
for (let i = a.length - 1; i >= 0; i--) {
|
|
72
|
+
for (let j = b.length - 1; j >= 0; j--) {
|
|
73
|
+
table[i]![j] =
|
|
74
|
+
a[i] === b[j] ? table[i + 1]![j + 1]! + 1 : Math.max(table[i + 1]![j]!, table[i]![j + 1]!)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return table
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Changed lines in file order, `- ` / `+ `-prefixed, so a moved or repeated step still shows. */
|
|
81
|
+
const lineDiff = (a: readonly string[], b: readonly string[]): string[] => {
|
|
82
|
+
const table = lcsTable(a, b)
|
|
83
|
+
const out: string[] = []
|
|
84
|
+
let i = 0
|
|
85
|
+
let j = 0
|
|
86
|
+
while (i < a.length && j < b.length) {
|
|
87
|
+
if (a[i] === b[j]) {
|
|
88
|
+
i++
|
|
89
|
+
j++
|
|
90
|
+
} else if (table[i + 1]![j]! >= table[i]![j + 1]!) out.push(`- ${a[i++]}`)
|
|
91
|
+
else out.push(`+ ${b[j++]}`)
|
|
92
|
+
}
|
|
93
|
+
return [...out, ...a.slice(i).map((l) => `- ${l}`), ...b.slice(j).map((l) => `+ ${l}`)]
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export const wordingDrift = (frozen: FrozenScenarios, path: string): t.WordingDrift => ({
|
|
97
|
+
path,
|
|
98
|
+
lines: lineDiff(lines(frozen.texts[path]), lines(textOf(path))),
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
/** After a turn: stop at `scenario-wording` while wording drifted; accept moves the snapshot. */
|
|
102
|
+
export const holdWording = async (frozen: FrozenScenarios | undefined): Promise<void> => {
|
|
103
|
+
if (frozen === undefined) return
|
|
104
|
+
const drifted = driftedScenarios(frozen)
|
|
105
|
+
if (drifted.length === 0) return
|
|
106
|
+
await human("scenario-wording", {
|
|
107
|
+
message: t.scenarioWordingMessage(
|
|
108
|
+
frozen.at,
|
|
109
|
+
drifted.map((path) => wordingDrift(frozen, path)),
|
|
110
|
+
),
|
|
111
|
+
label: "Scenario wording changed",
|
|
112
|
+
acceptClean: true,
|
|
113
|
+
base: frozen.at,
|
|
114
|
+
})
|
|
115
|
+
// Still differing is accepted; restored is rejected and stays frozen as it was.
|
|
116
|
+
const accepted = driftedScenarios(frozen)
|
|
117
|
+
if (accepted.length === 0) return
|
|
118
|
+
for (const path of accepted) {
|
|
119
|
+
const text = textOf(path)
|
|
120
|
+
if (text === undefined) delete frozen.texts[path]
|
|
121
|
+
else frozen.texts[path] = text
|
|
122
|
+
}
|
|
123
|
+
frozen.at = head()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** `turn`, then `holdWording`; `within` names the scope the gate rests in. */
|
|
127
|
+
export const guarded =
|
|
128
|
+
(
|
|
129
|
+
frozen: FrozenScenarios | undefined,
|
|
130
|
+
turn: () => Promise<void>,
|
|
131
|
+
within?: string,
|
|
132
|
+
): (() => Promise<void>) =>
|
|
133
|
+
async () => {
|
|
134
|
+
await turn()
|
|
135
|
+
if (within === undefined) await holdWording(frozen)
|
|
136
|
+
else await scope(within, () => holdWording(frozen))
|
|
137
|
+
}
|
|
@@ -18,8 +18,6 @@ describe("the bundled workflow's skills export", () => {
|
|
|
18
18
|
"architecture.decompose",
|
|
19
19
|
"packages.item",
|
|
20
20
|
"packages.item.fix.suite",
|
|
21
|
-
"packages.item.fix.spec",
|
|
22
|
-
"packages.item.spec",
|
|
23
21
|
"packages.item.health",
|
|
24
22
|
"build",
|
|
25
23
|
"build.health",
|
|
@@ -50,8 +48,6 @@ describe("the bundled workflow's skills export", () => {
|
|
|
50
48
|
"architecture.decompose": ["incremental-implementation", "planning-and-task-breakdown"],
|
|
51
49
|
"packages.item": ["test-driven-development", "incremental-implementation"],
|
|
52
50
|
"packages.item.fix.suite": ["debugging-and-error-recovery"],
|
|
53
|
-
"packages.item.fix.spec": ["incremental-implementation", "code-simplification"],
|
|
54
|
-
"packages.item.spec": ["code-review-and-quality", "spec-driven-development"],
|
|
55
51
|
"packages.item.health": ["debugging-and-error-recovery"],
|
|
56
52
|
build: ["debugging-and-error-recovery"],
|
|
57
53
|
"build.health": ["debugging-and-error-recovery"],
|