@pmelab/gtd 44.0.0 → 45.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +40 -9
- package/dist/chunks/{reserved-DJk_6-ju.mjs → reserved-ulcSkND6.mjs} +142 -5
- package/dist/chunks/{review-X7Jfz0dY.mjs → review-Da2ndnB1.mjs} +177 -7
- package/dist/chunks/{tail-5w5mxGTq.mjs → tail-BW030xz_.mjs} +1 -1
- package/dist/gtd.bundle.mjs +7 -5
- package/dist/testkit.mjs +7 -5
- package/dist/workflow-kit.mjs +22 -10
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +3 -3
- package/dist/workflows/fix.mjs +2 -2
- package/dist/workflows/review.mjs +4 -15
- package/dist/workflows/rollout.mjs +1 -1
- package/dist/workflows/tests.mjs +193 -0
- package/dist/workflows.mjs +533 -40
- package/package.json +6 -1
- package/src/install/ConfigScaffold.ts +2 -1
- package/src/install/Install.ts +5 -4
- package/src/workflow-kit/review.test.ts +80 -2
- package/src/workflow-kit/review.ts +45 -11
- package/src/workflows/feature/review-answers.feature +12 -3
- package/src/workflows/feature/standards.feature +286 -0
- package/src/workflows/index.ts +2 -1
- package/src/workflows/review/review-door.feature +11 -2
- package/src/workflows/review/review.ts +3 -16
- package/src/workflows/shared/learn.test.ts +154 -0
- package/src/workflows/shared/learn.ts +206 -0
- package/src/workflows/shared/lenses.ts +5 -1
- package/src/workflows/shared/options.ts +22 -1
- package/src/workflows/shared/review.ts +29 -2
- package/src/workflows/shared/skills.test.ts +2 -0
- package/src/workflows/shared/skills.ts +3 -0
- package/src/workflows/shared/standards.test.ts +90 -0
- package/src/workflows/shared/standards.ts +156 -0
- package/src/workflows/shared/text.test.ts +58 -1
- package/src/workflows/shared/text.ts +33 -3
- package/src/workflows/shared/vars.ts +10 -0
- package/src/workflows/tests/index.ts +6 -0
- package/src/workflows/tests/tests-options.feature +52 -0
- package/src/workflows/tests/tests.feature +266 -0
- package/src/workflows/tests/tests.test.ts +51 -0
- package/src/workflows/tests/tests.ts +270 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { ANSWER_LEAD, FREE_TEXT_PLACEHOLDER } from "../../steering/index.js"
|
|
2
|
+
|
|
3
|
+
export const STANDARDS = ".gtd/STANDARDS.md"
|
|
4
|
+
|
|
5
|
+
/** A learned standard that contradicts decided ones, waiting on the human. */
|
|
6
|
+
export interface Conflict {
|
|
7
|
+
readonly text: string
|
|
8
|
+
/** The decided standards it contradicts. */
|
|
9
|
+
readonly olds: readonly string[]
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface Standards {
|
|
13
|
+
readonly decided: readonly string[]
|
|
14
|
+
readonly undecided: readonly Conflict[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const UNDECIDED = "Undecided"
|
|
18
|
+
|
|
19
|
+
// The same class as the review's `Gtd-Mark` anchors, so a title reads alike in both.
|
|
20
|
+
export const oneLine = (text: string): string =>
|
|
21
|
+
// eslint-disable-next-line no-control-regex -- the control range IS the point
|
|
22
|
+
text.replace(/[\s\x00-\x1f\x7f-\x9f\u2028\u2029]+/g, " ").trim()
|
|
23
|
+
|
|
24
|
+
const ITEM = /^(?:[-*+]|\d+[.)])\s+(.*)$/
|
|
25
|
+
const CONTRADICTS = /^\s+[-*+]\s+contradicts:\s*(.*)$/i
|
|
26
|
+
|
|
27
|
+
interface Item {
|
|
28
|
+
text: string
|
|
29
|
+
/** Set under `## Undecided`. */
|
|
30
|
+
olds?: string[]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// A formatter wraps long lines: the tail belongs to the last `contradicts:` once there is one.
|
|
34
|
+
const wrapped = (item: Item, line: string): void => {
|
|
35
|
+
const olds = item.olds
|
|
36
|
+
if (olds !== undefined && olds.length > 0) olds[olds.length - 1] += ` ${line.trim()}`
|
|
37
|
+
else item.text += ` ${line.trim()}`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** What tells two standards apart: not the emphasis or spacing a formatter may rewrite. */
|
|
41
|
+
export const standardKey = (text: string): string =>
|
|
42
|
+
oneLine(text.replace(/[*_`\\]/g, "")).toLowerCase()
|
|
43
|
+
|
|
44
|
+
// Each standard once, so every conflict note gets a title of its own.
|
|
45
|
+
const toStandards = (items: readonly Item[]): Standards => {
|
|
46
|
+
const seen = new Set<string>()
|
|
47
|
+
const fresh = (text: string): boolean => {
|
|
48
|
+
const key = standardKey(text)
|
|
49
|
+
if (key === "" || seen.has(key)) return false
|
|
50
|
+
seen.add(key)
|
|
51
|
+
return true
|
|
52
|
+
}
|
|
53
|
+
const decided = items.filter((i) => i.olds === undefined).map((i) => oneLine(i.text))
|
|
54
|
+
const undecided = items.flatMap((i) =>
|
|
55
|
+
i.olds === undefined
|
|
56
|
+
? []
|
|
57
|
+
: [{ text: oneLine(i.text), olds: i.olds.map(oneLine).filter((o) => o !== "") }],
|
|
58
|
+
)
|
|
59
|
+
return { decided: decided.filter(fresh), undecided: undecided.filter((c) => fresh(c.text)) }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Every top-level bullet is one standard, those under `## Undecided` waiting
|
|
64
|
+
* on the human; a hand-edited file may group the rest under any heading.
|
|
65
|
+
*/
|
|
66
|
+
export const parseStandards = (text: string): Standards => {
|
|
67
|
+
const items: Item[] = []
|
|
68
|
+
let pending = false
|
|
69
|
+
let last: Item | undefined
|
|
70
|
+
for (const line of text.split("\n")) {
|
|
71
|
+
const heading = /^(#{1,6})\s+(.*)$/.exec(line)
|
|
72
|
+
const item = ITEM.exec(line)
|
|
73
|
+
const old = CONTRADICTS.exec(line)
|
|
74
|
+
if (heading !== null) {
|
|
75
|
+
pending = heading[1]!.length > 1 && heading[2]!.trim() === UNDECIDED
|
|
76
|
+
last = undefined
|
|
77
|
+
} else if (item !== null) {
|
|
78
|
+
last = pending ? { text: item[1]!, olds: [] } : { text: item[1]! }
|
|
79
|
+
items.push(last)
|
|
80
|
+
} else if (old !== null && last?.olds !== undefined) last.olds.push(old[1]!)
|
|
81
|
+
else if (/^\s+\S/.test(line) && last !== undefined) wrapped(last, line)
|
|
82
|
+
else if (line.trim() !== "") last = undefined
|
|
83
|
+
}
|
|
84
|
+
return toStandards(items)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export const renderStandards = ({ decided, undecided }: Standards): string => {
|
|
88
|
+
const parts = [
|
|
89
|
+
"# Standards",
|
|
90
|
+
"Kept from your review answers for every agent turn. One standard per bullet.",
|
|
91
|
+
...(decided.length === 0 ? [] : [decided.map((s) => `- ${s}`).join("\n")]),
|
|
92
|
+
...(undecided.length === 0
|
|
93
|
+
? []
|
|
94
|
+
: [
|
|
95
|
+
`## ${UNDECIDED}`,
|
|
96
|
+
undecided
|
|
97
|
+
.map((c) => [`- ${c.text}`, ...c.olds.map((o) => ` - contradicts: ${o}`)].join("\n"))
|
|
98
|
+
.join("\n"),
|
|
99
|
+
]),
|
|
100
|
+
]
|
|
101
|
+
return `${parts.join("\n\n")}\n`
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// FNV-1a over the key: a conflict's chunk title must survive markdown parsing and a
|
|
105
|
+
// formatter's rewrite of STANDARDS.md, which its text may not.
|
|
106
|
+
const hash = (raw: string): string => {
|
|
107
|
+
const text = standardKey(raw)
|
|
108
|
+
let h = 0x811c9dc5
|
|
109
|
+
for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 0x01000193)
|
|
110
|
+
return (h >>> 0).toString(16).padStart(8, "0")
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const CONFLICT_TITLE = /^Standard conflict ([0-9a-f]{8})$/
|
|
114
|
+
|
|
115
|
+
export const conflictTitle = (c: Conflict): string => `Standard conflict ${hash(c.text)}`
|
|
116
|
+
|
|
117
|
+
export const conflictFor = (standards: Standards, title: string): Conflict | undefined => {
|
|
118
|
+
const id = CONFLICT_TITLE.exec(title)?.[1]
|
|
119
|
+
return id === undefined ? undefined : standards.undecided.find((c) => hash(c.text) === id)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export const isConflictTitle = (title: string): boolean => CONFLICT_TITLE.test(title)
|
|
123
|
+
|
|
124
|
+
const KEEP_NEW = "keep the new one (drop the old)"
|
|
125
|
+
const KEEP_OLD = "keep the old one (discard the new)"
|
|
126
|
+
|
|
127
|
+
/** A conflict's chunk body; its three boxes read as fix / won't fix / own answer by position. */
|
|
128
|
+
export const conflictBody = (c: Conflict): string =>
|
|
129
|
+
[
|
|
130
|
+
...(c.olds.length === 0
|
|
131
|
+
? [
|
|
132
|
+
`A standard learned from your answers contradicted ones another answer since replaced in \`${STANDARDS}\`. Keep it, discard it, or reword it.`,
|
|
133
|
+
`New: ${c.text}`,
|
|
134
|
+
]
|
|
135
|
+
: [
|
|
136
|
+
`A standard learned from your answers contradicts ${c.olds.length === 1 ? "one" : "some"} already in \`${STANDARDS}\`. Your own answer replaces both with its wording.`,
|
|
137
|
+
`New: ${c.text}`,
|
|
138
|
+
`Old:\n\n${c.olds.map((o) => `- ${o}`).join("\n")}`,
|
|
139
|
+
]),
|
|
140
|
+
`${ANSWER_LEAD}\n\n- [ ] ${KEEP_NEW}\n- [ ] ${KEEP_OLD}\n- [ ] ${FREE_TEXT_PLACEHOLDER}`,
|
|
141
|
+
].join("\n\n")
|
|
142
|
+
|
|
143
|
+
/** What every agent turn is told while `.gtd/STANDARDS.md` holds decided standards. */
|
|
144
|
+
export const standardsBlock = (text: string | undefined): string => {
|
|
145
|
+
const { decided } = parseStandards(text ?? "")
|
|
146
|
+
if (decided.length === 0) return ""
|
|
147
|
+
return `## Standards
|
|
148
|
+
|
|
149
|
+
\`${STANDARDS}\` holds the standards the human set in earlier reviews.
|
|
150
|
+
Follow every one in what you write. Reviewing, never raise a finding a
|
|
151
|
+
standard rules out — a \`Won't fix:\` standard is a finding the human
|
|
152
|
+
rejected, so never raise it or one like it again — and raise breaking a
|
|
153
|
+
standard as a finding.
|
|
154
|
+
|
|
155
|
+
${decided.map((s) => `- ${s}`).join("\n")}`
|
|
156
|
+
}
|
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
withSkills,
|
|
32
32
|
composedReview,
|
|
33
33
|
} from "./text.js"
|
|
34
|
-
import { steeringFormatFor, unansweredNotes } from "../../steering/index.js"
|
|
34
|
+
import { reviewDecisions, steeringFormatFor, unansweredNotes } from "../../steering/index.js"
|
|
35
35
|
import { captureStep, renderText } from "./text.fixture.js"
|
|
36
36
|
|
|
37
37
|
describe("withSkills", () => {
|
|
@@ -68,6 +68,28 @@ describe("agentWithSkills", () => {
|
|
|
68
68
|
expect(request.options).not.toHaveProperty("skills")
|
|
69
69
|
})
|
|
70
70
|
|
|
71
|
+
it("ends every prompt with the kept standards, never the undecided ones", async () => {
|
|
72
|
+
const standards = [
|
|
73
|
+
"# Standards",
|
|
74
|
+
"",
|
|
75
|
+
"- Prefer early returns.",
|
|
76
|
+
"",
|
|
77
|
+
"## Undecided",
|
|
78
|
+
"",
|
|
79
|
+
"- Use classes.",
|
|
80
|
+
" - contradicts: Prefer early returns.",
|
|
81
|
+
].join("\n")
|
|
82
|
+
const request = await captureStep(() => agentWithSkills("name", "do-the-work"), {
|
|
83
|
+
scope: "a",
|
|
84
|
+
skills: { a: [] },
|
|
85
|
+
read: (path) => (path === ".gtd/STANDARDS.md" ? standards : undefined),
|
|
86
|
+
})
|
|
87
|
+
if (request.kind !== "agent") throw new Error("unreachable")
|
|
88
|
+
expect(request.prompt.startsWith("do-the-work\n\n## Standards")).toBe(true)
|
|
89
|
+
expect(request.prompt.endsWith("- Prefer early returns.")).toBe(true)
|
|
90
|
+
expect(request.prompt).not.toContain("Use classes.")
|
|
91
|
+
})
|
|
92
|
+
|
|
71
93
|
it("leaves the prompt bare when the scope resolves to an empty list", async () => {
|
|
72
94
|
const request = await capture(() => agentWithSkills("name", "do-the-work"), { a: [] })
|
|
73
95
|
if (request.kind !== "agent") throw new Error("unreachable")
|
|
@@ -1244,6 +1266,41 @@ describe("buildReviewCollectingPrompt answer boxes", () => {
|
|
|
1244
1266
|
describe("composedReview", () => {
|
|
1245
1267
|
const BASE = "0123456789abcdef0123456789abcdef01234567"
|
|
1246
1268
|
|
|
1269
|
+
describe("standards", () => {
|
|
1270
|
+
const conflict = { text: "Calculators are functions.", olds: ["Calculators are classes."] }
|
|
1271
|
+
const withConflict = composedReview(BASE, {
|
|
1272
|
+
notes: [],
|
|
1273
|
+
fixed: [],
|
|
1274
|
+
noise: [],
|
|
1275
|
+
standards: { added: [], undecided: [conflict] },
|
|
1276
|
+
})
|
|
1277
|
+
|
|
1278
|
+
it("asks about each undecided standard in a note that blocks sign-off", () => {
|
|
1279
|
+
expect(withConflict).toMatch(/^## Standard conflict [0-9a-f]{8}$/m)
|
|
1280
|
+
expect(withConflict).toContain("- [ ] keep the new one (drop the old)")
|
|
1281
|
+
expect(unansweredNotes(withConflict)).toHaveLength(1)
|
|
1282
|
+
})
|
|
1283
|
+
|
|
1284
|
+
it("reads its boxes by position, like a note's", () => {
|
|
1285
|
+
const ticked = withConflict.replace("- [ ] keep the old one", "- [x] keep the old one")
|
|
1286
|
+
expect(unansweredNotes(ticked)).toEqual([])
|
|
1287
|
+
expect(reviewDecisions(withConflict, ticked)).toEqual([
|
|
1288
|
+
expect.objectContaining({ choice: "wontfix" }),
|
|
1289
|
+
])
|
|
1290
|
+
})
|
|
1291
|
+
|
|
1292
|
+
it("lists the standards added", () => {
|
|
1293
|
+
const doc = composedReview(BASE, {
|
|
1294
|
+
notes: [],
|
|
1295
|
+
fixed: [],
|
|
1296
|
+
noise: [],
|
|
1297
|
+
standards: { added: ["Prefer early returns."], undecided: [] },
|
|
1298
|
+
})
|
|
1299
|
+
expect(doc).toContain("## Standards added")
|
|
1300
|
+
expect(doc).toContain("- Prefer early returns.")
|
|
1301
|
+
})
|
|
1302
|
+
})
|
|
1303
|
+
|
|
1247
1304
|
it("is a valid review with unique chunk titles, so every note matches its own chunk", () => {
|
|
1248
1305
|
const doc = composedReview(BASE, {
|
|
1249
1306
|
notes: [
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
agent,
|
|
3
3
|
codeThreads,
|
|
4
|
+
read,
|
|
4
5
|
displayPath,
|
|
5
6
|
env,
|
|
6
7
|
head,
|
|
@@ -19,6 +20,13 @@ import {
|
|
|
19
20
|
FREE_TEXT_PLACEHOLDER,
|
|
20
21
|
} from "../../steering/index.js"
|
|
21
22
|
import { escapeRegExp } from "./registry.js"
|
|
23
|
+
import {
|
|
24
|
+
conflictBody,
|
|
25
|
+
conflictTitle,
|
|
26
|
+
standardsBlock,
|
|
27
|
+
STANDARDS,
|
|
28
|
+
type Conflict,
|
|
29
|
+
} from "./standards.js"
|
|
22
30
|
import { defaultFlagRegistry } from "./vars.js"
|
|
23
31
|
import type { CollectRound, RemovedAssertions, Requested } from "../../workflow-kit/index.js"
|
|
24
32
|
import {
|
|
@@ -57,7 +65,8 @@ export const agentWithSkills = (
|
|
|
57
65
|
options: AgentOptions = {},
|
|
58
66
|
): Promise<void> => {
|
|
59
67
|
const access = accessPreamble(accessFor(name, options.file))
|
|
60
|
-
const
|
|
68
|
+
const standards = standardsBlock(read(STANDARDS))
|
|
69
|
+
const body = [access, prompt, standards].filter((part) => part !== "").join("\n\n")
|
|
61
70
|
return agent(name, withSkills(skillsFor(name).join(", "), body), options)
|
|
62
71
|
}
|
|
63
72
|
|
|
@@ -115,6 +124,8 @@ export interface ComposedReview {
|
|
|
115
124
|
readonly unresolved?: true
|
|
116
125
|
/** A fix turn removed its chunk while renaming another. */
|
|
117
126
|
readonly unclear?: true
|
|
127
|
+
/** Labels for the first two boxes; absent: fix as suggested, won't fix. Read by position, so only the wording changes. */
|
|
128
|
+
readonly answers?: readonly [string, string]
|
|
118
129
|
}[]
|
|
119
130
|
readonly fixed: readonly { readonly title: string; readonly commit: string }[]
|
|
120
131
|
readonly noise: readonly { readonly title: string; readonly p: number }[]
|
|
@@ -124,12 +135,17 @@ export interface ComposedReview {
|
|
|
124
135
|
readonly nitFix?: string | undefined
|
|
125
136
|
/** Titles of notes the human already answered, left out. */
|
|
126
137
|
readonly answered?: ReadonlySet<string> | undefined
|
|
138
|
+
/** Standards added since the review base, and the conflicts still waiting on the human. */
|
|
139
|
+
readonly standards?:
|
|
140
|
+
| { readonly added: readonly string[]; readonly undecided: readonly Conflict[] }
|
|
141
|
+
| undefined
|
|
127
142
|
}
|
|
128
143
|
|
|
129
144
|
const SUMMARY_TITLES = [
|
|
130
145
|
"Weakened scenarios",
|
|
131
146
|
"Fixed from your notes",
|
|
132
147
|
"Fixed automatically",
|
|
148
|
+
"Standards added",
|
|
133
149
|
"Dropped as noise",
|
|
134
150
|
"Nothing to note",
|
|
135
151
|
]
|
|
@@ -156,7 +172,12 @@ export const composedReview = (base: string, report: ComposedReview): string =>
|
|
|
156
172
|
}
|
|
157
173
|
const chunk = (title: string, body: string): string => `## ${title}\n\n${body.trim()}`
|
|
158
174
|
// The lead-in line keeps a body that ends in a list from absorbing the boxes.
|
|
159
|
-
const answerBoxes =
|
|
175
|
+
const answerBoxes = ([fix, wontFix]: readonly [string, string] = [ANSWER_FIX, ANSWER_WONT_FIX]) =>
|
|
176
|
+
`${ANSWER_LEAD}\n\n- [ ] ${fix}\n- [ ] ${wontFix}\n- [ ] ${FREE_TEXT_PLACEHOLDER}`
|
|
177
|
+
const conflicts = (report.standards?.undecided ?? []).map((c) =>
|
|
178
|
+
chunk(unique(conflictTitle(c)), conflictBody(c)),
|
|
179
|
+
)
|
|
180
|
+
const added = report.standards?.added ?? []
|
|
160
181
|
const notes = report.notes
|
|
161
182
|
.map((n) => ({ ...n, title: unique(n.title) }))
|
|
162
183
|
.filter((n) => report.answered?.has(n.title) !== true)
|
|
@@ -180,6 +201,7 @@ export const composedReview = (base: string, report: ComposedReview): string =>
|
|
|
180
201
|
`These scenarios lost an assertion the review base had:\n\n${weakened.join("\n")}`,
|
|
181
202
|
),
|
|
182
203
|
]),
|
|
204
|
+
...conflicts,
|
|
183
205
|
...notes.map((n) =>
|
|
184
206
|
chunk(
|
|
185
207
|
n.title,
|
|
@@ -189,7 +211,7 @@ export const composedReview = (base: string, report: ComposedReview): string =>
|
|
|
189
211
|
: n.unclear === true
|
|
190
212
|
? `_A fix turn removed this while renaming another finding — check whether it was fixed._\n\n${unboxed(n.body)}`
|
|
191
213
|
: unboxed(n.body) || "_No detail given._"
|
|
192
|
-
}\n\n${answerBoxes}`,
|
|
214
|
+
}\n\n${answerBoxes(n.answers)}`,
|
|
193
215
|
),
|
|
194
216
|
),
|
|
195
217
|
...(report.fixed.length === 0
|
|
@@ -200,6 +222,14 @@ export const composedReview = (base: string, report: ComposedReview): string =>
|
|
|
200
222
|
report.fixed.map((f) => `- ${f.title} (${short(f.commit)})`).join("\n"),
|
|
201
223
|
),
|
|
202
224
|
]),
|
|
225
|
+
...(added.length === 0
|
|
226
|
+
? []
|
|
227
|
+
: [
|
|
228
|
+
chunk(
|
|
229
|
+
"Standards added",
|
|
230
|
+
`Learned from your answers and kept in \`${STANDARDS}\`:\n\n${added.map((s) => `- ${s}`).join("\n")}`,
|
|
231
|
+
),
|
|
232
|
+
]),
|
|
203
233
|
...(report.noise.length === 0
|
|
204
234
|
? []
|
|
205
235
|
: [chunk("Dropped as noise", report.noise.map((f) => `- ${f.title} (p=${f.p})`).join("\n"))]),
|
|
@@ -16,6 +16,14 @@ export const testFileGlobs: readonly string[] = [
|
|
|
16
16
|
|
|
17
17
|
export const defaultFlagRegistry = "src/flags/index.ts"
|
|
18
18
|
|
|
19
|
+
/** The `tests` workflow's own process settings: where it looks for skipped tests, and how a skip reads. */
|
|
20
|
+
export const testsDefaults: Readonly<Record<string, string>> = {
|
|
21
|
+
testFiles: testFileGlobs.join(", "),
|
|
22
|
+
// JS/TS runners' `.skip`/`.todo` and `x`-prefixed blocks, and cucumber's skip tags.
|
|
23
|
+
skipPattern:
|
|
24
|
+
"^\\s*(?:(?:describe|context|suite|it|test|specify)\\.(?:skip|todo)\\b|x(?:describe|context|it|test|specify)\\s*\\(|(?:@\\S+\\s+)*@(?:skip|ignore)(?:\\s|$))",
|
|
25
|
+
}
|
|
26
|
+
|
|
19
27
|
/** Process settings. */
|
|
20
28
|
export const defaults: Readonly<Record<string, string>> = {
|
|
21
29
|
reviewBase: "",
|
|
@@ -24,6 +32,8 @@ export const defaults: Readonly<Record<string, string>> = {
|
|
|
24
32
|
reviewNoteActionable: "0.7",
|
|
25
33
|
reviewSeverityMinP: "0.7",
|
|
26
34
|
qualityTriageMinP: "0.7",
|
|
35
|
+
// How sure the judge must be that an answer at the review gate is a reusable standard.
|
|
36
|
+
standardMinP: "0.8",
|
|
27
37
|
refactorFlagPattern: "\\bGTD_FLAG_\\w+",
|
|
28
38
|
seamAllow: [...testFileGlobs, ".gtd/**"].join(", "),
|
|
29
39
|
flagMode: "true",
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
@inmem
|
|
2
|
+
Feature: the tests workflow's skip detection is a workflow option
|
|
3
|
+
|
|
4
|
+
`testFiles` (globs) and `skipPattern` (a per-line regular expression) default
|
|
5
|
+
to JavaScript/TypeScript runners and cucumber tags; another stack sets its
|
|
6
|
+
own on the factory.
|
|
7
|
+
|
|
8
|
+
Background:
|
|
9
|
+
Given a gtd config with:
|
|
10
|
+
"""
|
|
11
|
+
export {
|
|
12
|
+
feature as default,
|
|
13
|
+
feature,
|
|
14
|
+
defaults,
|
|
15
|
+
envDefaults,
|
|
16
|
+
summary,
|
|
17
|
+
steering,
|
|
18
|
+
skills,
|
|
19
|
+
access,
|
|
20
|
+
} from "@pmelab/gtd/workflows/feature"
|
|
21
|
+
import { tests as shippedTests } from "@pmelab/gtd/workflows/tests"
|
|
22
|
+
export const tests = shippedTests({
|
|
23
|
+
testFiles: "tests/test_*.py",
|
|
24
|
+
skipPattern: "@pytest\\.mark\\.skip\\b",
|
|
25
|
+
})
|
|
26
|
+
"""
|
|
27
|
+
And a file "tests/test_calc.py" with:
|
|
28
|
+
"""
|
|
29
|
+
@pytest.mark.skip
|
|
30
|
+
def test_add(): pass
|
|
31
|
+
"""
|
|
32
|
+
And a file "src/calc.test.ts" with:
|
|
33
|
+
"""
|
|
34
|
+
it.skip("adds", () => {})
|
|
35
|
+
"""
|
|
36
|
+
And the working tree is committed as "test: python"
|
|
37
|
+
And step "tests-gate.check" changes nothing
|
|
38
|
+
|
|
39
|
+
Scenario: only the configured files and pattern count as skipped
|
|
40
|
+
Given step "tests.sweeping" is prompted with "tests/test_calc.py:1 — @pytest.mark.skip"
|
|
41
|
+
And step "tests.sweeping" changes nothing
|
|
42
|
+
When gtd starts workflow "tests"
|
|
43
|
+
Then driving the process is refused with "tests/test_calc.py has no section"
|
|
44
|
+
And driving the process is refused without "src/calc.test.ts"
|
|
45
|
+
|
|
46
|
+
Scenario Outline: an unusable skipPattern is refused before any step
|
|
47
|
+
Then starting workflow "tests" with "--var skipPattern=<pattern>" is refused with "<refusal>"
|
|
48
|
+
|
|
49
|
+
Examples:
|
|
50
|
+
| pattern | refusal |
|
|
51
|
+
| .* | skipPattern must not match the empty string |
|
|
52
|
+
| ( | skipPattern is not a valid regular expression |
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
@inmem
|
|
2
|
+
Feature: the tests workflow revives skipped tests or proposes their deletion
|
|
3
|
+
|
|
4
|
+
`gtd --workflow tests` sweeps the repository for skipped tests and test-audit
|
|
5
|
+
findings into `.gtd/TESTS.md`, one section per test file. Each file is
|
|
6
|
+
rewritten in its own turn until nothing in it is skipped and the suite is
|
|
7
|
+
green. A test the agent calls obsolete stays skipped and becomes a proposal in
|
|
8
|
+
`.gtd/RETIRE.md`; the human review asks for one tick on each — delete it, keep
|
|
9
|
+
it skipped, or an own answer. Only a person's tick deletes a test.
|
|
10
|
+
|
|
11
|
+
Background:
|
|
12
|
+
Given a gtd config with:
|
|
13
|
+
"""
|
|
14
|
+
export {
|
|
15
|
+
feature as default,
|
|
16
|
+
feature,
|
|
17
|
+
defaults,
|
|
18
|
+
envDefaults,
|
|
19
|
+
summary,
|
|
20
|
+
steering,
|
|
21
|
+
skills,
|
|
22
|
+
access,
|
|
23
|
+
} from "@pmelab/gtd/workflows/feature"
|
|
24
|
+
export { tests } from "@pmelab/gtd/workflows/tests"
|
|
25
|
+
"""
|
|
26
|
+
And a file "src/calc.ts" with:
|
|
27
|
+
"""
|
|
28
|
+
export const add = (a: number, b: number) => a + b
|
|
29
|
+
"""
|
|
30
|
+
And a file "src/calc.test.ts" with:
|
|
31
|
+
"""
|
|
32
|
+
it.skip("adds", () => expect(add(1, 2)).toBe(3))
|
|
33
|
+
"""
|
|
34
|
+
And a file "src/legacy.test.ts" with:
|
|
35
|
+
"""
|
|
36
|
+
it.skip("calls the v1 api", () => {})
|
|
37
|
+
"""
|
|
38
|
+
And the working tree is committed as "feat: calculator"
|
|
39
|
+
And step "tests-gate.check" changes nothing
|
|
40
|
+
And step "tests.sweeping" writes ".gtd/TESTS.md" with:
|
|
41
|
+
"""
|
|
42
|
+
# Tests
|
|
43
|
+
|
|
44
|
+
## src/calc.test.ts
|
|
45
|
+
|
|
46
|
+
"adds" is skipped.
|
|
47
|
+
|
|
48
|
+
## src/legacy.test.ts
|
|
49
|
+
|
|
50
|
+
"calls the v1 api" is skipped.
|
|
51
|
+
"""
|
|
52
|
+
And step "tests.rewriting", when prompted with "\"adds\" is skipped", writes "src/calc.test.ts" with:
|
|
53
|
+
"""
|
|
54
|
+
it("adds", () => expect(add(1, 2)).toBe(3))
|
|
55
|
+
"""
|
|
56
|
+
And step "tests.rewriting", when prompted with "\"calls the v1 api\" is skipped", writes ".gtd/RETIRE.md" with:
|
|
57
|
+
"""
|
|
58
|
+
# Retire
|
|
59
|
+
|
|
60
|
+
## calls the v1 api
|
|
61
|
+
|
|
62
|
+
src/legacy.test.ts tests the v1 api, which is gone.
|
|
63
|
+
"""
|
|
64
|
+
And step "tests.health.check" changes nothing
|
|
65
|
+
And step "tests.closing" runs its script
|
|
66
|
+
And step "build.review.composing" runs its script
|
|
67
|
+
And step "build.review.closing" runs its script
|
|
68
|
+
|
|
69
|
+
Scenario: the revivable test is unskipped and the obsolete one is proposed for deletion
|
|
70
|
+
When gtd starts workflow "tests"
|
|
71
|
+
And the process is driven
|
|
72
|
+
Then the process rests at "build.review.await-review"
|
|
73
|
+
And the commit subjects end with:
|
|
74
|
+
"""
|
|
75
|
+
gtd(check): tests-gate.check → tests.sweeping
|
|
76
|
+
gtd(agent): tests.sweeping → tests.rewriting
|
|
77
|
+
gtd(agent): tests.rewriting → tests.health.check
|
|
78
|
+
gtd(check): tests.health.check → tests.rewriting
|
|
79
|
+
gtd(agent): tests.rewriting → tests.closing
|
|
80
|
+
gtd(check): tests.closing → build.review.composing
|
|
81
|
+
gtd(check): build.review.composing → build.review.await-review
|
|
82
|
+
"""
|
|
83
|
+
And "src/calc.test.ts" contains "it(\"adds\""
|
|
84
|
+
And "src/legacy.test.ts" contains "it.skip"
|
|
85
|
+
And ".gtd/TESTS.md" does not exist
|
|
86
|
+
And ".gtd/RETIRE.md" does not exist
|
|
87
|
+
And ".gtd/REVIEW.md" contains:
|
|
88
|
+
"""
|
|
89
|
+
## calls the v1 api
|
|
90
|
+
|
|
91
|
+
Delete this skipped test.
|
|
92
|
+
|
|
93
|
+
src/legacy.test.ts tests the v1 api, which is gone.
|
|
94
|
+
|
|
95
|
+
Tick exactly one:
|
|
96
|
+
|
|
97
|
+
- [ ] delete it
|
|
98
|
+
- [ ] keep it skipped
|
|
99
|
+
- [ ] _your answer_
|
|
100
|
+
"""
|
|
101
|
+
And the resting step's content contains "tests removed"
|
|
102
|
+
|
|
103
|
+
Scenario: signing off with an unticked proposal is refused
|
|
104
|
+
When gtd starts workflow "tests"
|
|
105
|
+
And the process is driven
|
|
106
|
+
Then landing is refused with "tick exactly one box"
|
|
107
|
+
|
|
108
|
+
Scenario: a ticked delete it has the test deleted, then the review comes back
|
|
109
|
+
Given step "build.review.fix.nits.fixing" deletes "src/legacy.test.ts"
|
|
110
|
+
And step "build.health.check" changes nothing
|
|
111
|
+
When gtd starts workflow "tests"
|
|
112
|
+
And the process is driven
|
|
113
|
+
And the text "- [ ] delete it" in ".gtd/REVIEW.md" is replaced with "- [x] delete it"
|
|
114
|
+
And gtd lands
|
|
115
|
+
And the process is driven
|
|
116
|
+
Then the process rests at "build.review.await-review"
|
|
117
|
+
And "src/legacy.test.ts" does not exist
|
|
118
|
+
When gtd lands
|
|
119
|
+
And the process is driven
|
|
120
|
+
Then the process rests at "idle"
|
|
121
|
+
|
|
122
|
+
Scenario: a ticked keep it skipped leaves the test skipped and ends the process
|
|
123
|
+
Given judge step "build.review.standards.judge" answers:
|
|
124
|
+
"""
|
|
125
|
+
[{"id":"standard-1","answer":"no","p":0.9}]
|
|
126
|
+
"""
|
|
127
|
+
When gtd starts workflow "tests"
|
|
128
|
+
And the process is driven
|
|
129
|
+
And the text "- [ ] keep it skipped" in ".gtd/REVIEW.md" is replaced with "- [x] keep it skipped"
|
|
130
|
+
And gtd lands
|
|
131
|
+
And the process is driven
|
|
132
|
+
Then the process rests at "idle"
|
|
133
|
+
And "src/legacy.test.ts" contains "it.skip"
|
|
134
|
+
And the commit "gtd(human): build.review.await-review → build.review.standards.judge" has a body containing "Gtd-Mark: wontfix=calls the v1 api"
|
|
135
|
+
|
|
136
|
+
Scenario: a rewrite leaving a test skipped without a proposal is refused
|
|
137
|
+
Given step "tests.rewriting", when prompted with "\"calls the v1 api\" is skipped", writes ".gtd/RETIRE.md" with:
|
|
138
|
+
"""
|
|
139
|
+
# Retire
|
|
140
|
+
"""
|
|
141
|
+
When gtd starts workflow "tests"
|
|
142
|
+
Then driving the process is refused with "src/legacy.test.ts:1 still skipped"
|
|
143
|
+
|
|
144
|
+
Scenario: a rewrite deleting a test file is refused
|
|
145
|
+
Given step "tests.rewriting" deletes "src/legacy.test.ts"
|
|
146
|
+
When gtd starts workflow "tests"
|
|
147
|
+
Then driving the process is refused with "src/legacy.test.ts deleted"
|
|
148
|
+
|
|
149
|
+
Scenario: a sweep deleting a test file is refused
|
|
150
|
+
Given step "tests.sweeping" deletes "src/legacy.test.ts"
|
|
151
|
+
When gtd starts workflow "tests"
|
|
152
|
+
Then driving the process is refused with "src/legacy.test.ts deleted"
|
|
153
|
+
|
|
154
|
+
Scenario: a sweep changing anything but its plan is refused
|
|
155
|
+
Given step "tests.sweeping" writes "src/calc.ts" with:
|
|
156
|
+
"""
|
|
157
|
+
export const add = (a: number, b: number) => b + a
|
|
158
|
+
"""
|
|
159
|
+
When gtd starts workflow "tests"
|
|
160
|
+
Then driving the process is refused with "the sweep changed src/calc.ts"
|
|
161
|
+
|
|
162
|
+
Scenario: a proposal naming its file at the end of a sentence counts
|
|
163
|
+
Given step "tests.rewriting", when prompted with "\"calls the v1 api\" is skipped", writes ".gtd/RETIRE.md" with:
|
|
164
|
+
"""
|
|
165
|
+
# Retire
|
|
166
|
+
|
|
167
|
+
## calls the v1 api
|
|
168
|
+
|
|
169
|
+
The v1 api is gone, so drop it from src/legacy.test.ts.
|
|
170
|
+
"""
|
|
171
|
+
When gtd starts workflow "tests"
|
|
172
|
+
And the process is driven
|
|
173
|
+
Then the process rests at "build.review.await-review"
|
|
174
|
+
|
|
175
|
+
Scenario: a sweep planning a file that is no test file is refused
|
|
176
|
+
Given step "tests.sweeping" writes ".gtd/TESTS.md" with:
|
|
177
|
+
"""
|
|
178
|
+
# Tests
|
|
179
|
+
|
|
180
|
+
## src/calc.ts
|
|
181
|
+
|
|
182
|
+
Nothing is skipped.
|
|
183
|
+
|
|
184
|
+
## src/calc.test.ts
|
|
185
|
+
|
|
186
|
+
"adds" is skipped.
|
|
187
|
+
|
|
188
|
+
## src/legacy.test.ts
|
|
189
|
+
|
|
190
|
+
"calls the v1 api" is skipped.
|
|
191
|
+
"""
|
|
192
|
+
When gtd starts workflow "tests"
|
|
193
|
+
Then driving the process is refused with "is no test file"
|
|
194
|
+
|
|
195
|
+
Scenario: a proposal naming its file as ./path counts
|
|
196
|
+
Given step "tests.rewriting", when prompted with "\"calls the v1 api\" is skipped", writes ".gtd/RETIRE.md" with:
|
|
197
|
+
"""
|
|
198
|
+
# Retire
|
|
199
|
+
|
|
200
|
+
## calls the v1 api
|
|
201
|
+
|
|
202
|
+
./src/legacy.test.ts tests the v1 api, which is gone.
|
|
203
|
+
"""
|
|
204
|
+
When gtd starts workflow "tests"
|
|
205
|
+
And the process is driven
|
|
206
|
+
Then the process rests at "build.review.await-review"
|
|
207
|
+
|
|
208
|
+
Scenario: rewrites that change no code and propose nothing end the process
|
|
209
|
+
Given a file "src/legacy.test.ts" with:
|
|
210
|
+
"""
|
|
211
|
+
it("calls the v2 api", () => {})
|
|
212
|
+
"""
|
|
213
|
+
And a file "src/calc.test.ts" with:
|
|
214
|
+
"""
|
|
215
|
+
it("adds", () => expect(add(1, 2)).toBe(3))
|
|
216
|
+
"""
|
|
217
|
+
And the working tree is committed as "test: nothing skipped"
|
|
218
|
+
And step "tests.rewriting" from now on changes nothing
|
|
219
|
+
When gtd starts workflow "tests"
|
|
220
|
+
And the process is driven
|
|
221
|
+
Then the process rests at "idle"
|
|
222
|
+
|
|
223
|
+
Scenario: one proposal covers one skip, not every skip in its file
|
|
224
|
+
Given a file "src/legacy.test.ts" with:
|
|
225
|
+
"""
|
|
226
|
+
it.skip("calls the v1 api", () => {})
|
|
227
|
+
it.skip("calls the v1 batch api", () => {})
|
|
228
|
+
"""
|
|
229
|
+
And the working tree is committed as "test: another legacy test"
|
|
230
|
+
When gtd starts workflow "tests"
|
|
231
|
+
Then driving the process is refused with "src/legacy.test.ts:2 still skipped"
|
|
232
|
+
|
|
233
|
+
Scenario: a sweep leaving out a file with a skipped test is refused
|
|
234
|
+
Given step "tests.sweeping" writes ".gtd/TESTS.md" with:
|
|
235
|
+
"""
|
|
236
|
+
# Tests
|
|
237
|
+
|
|
238
|
+
## src/calc.test.ts
|
|
239
|
+
|
|
240
|
+
"adds" is skipped.
|
|
241
|
+
"""
|
|
242
|
+
When gtd starts workflow "tests"
|
|
243
|
+
Then driving the process is refused with "src/legacy.test.ts has no section"
|
|
244
|
+
|
|
245
|
+
Scenario: the sweep is told every skipped test, path and line
|
|
246
|
+
Given step "tests.sweeping" is prompted with "src/legacy.test.ts:1 — it.skip(\"calls the v1 api\", () => {})"
|
|
247
|
+
When gtd starts workflow "tests"
|
|
248
|
+
And the process is driven
|
|
249
|
+
Then the process rests at "build.review.await-review"
|
|
250
|
+
|
|
251
|
+
Scenario: a sweep with nothing to do ends the process
|
|
252
|
+
Given a file "src/calc.test.ts" with:
|
|
253
|
+
"""
|
|
254
|
+
it("adds", () => expect(add(1, 2)).toBe(3))
|
|
255
|
+
"""
|
|
256
|
+
And the file "src/legacy.test.ts" is deleted
|
|
257
|
+
And the working tree is committed as "test: revive by hand"
|
|
258
|
+
And step "tests.sweeping" from now on changes nothing
|
|
259
|
+
When gtd starts workflow "tests"
|
|
260
|
+
And the process is driven
|
|
261
|
+
Then the process rests at "idle"
|
|
262
|
+
|
|
263
|
+
Scenario: gtd door tests starts the workflow
|
|
264
|
+
When gtd opens door "tests"
|
|
265
|
+
And the process is driven
|
|
266
|
+
Then the process rests at "build.review.await-review"
|