@pmelab/gtd 58.2.0 → 59.0.1
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 +120 -395
- package/dist/chunks/{Schema-B3o6eRNU.mjs → testkit-07Z_ENch.mjs} +13503 -3460
- package/dist/chunks/{wire-C12dahrH.mjs → wire-BTUhRGXj.mjs} +1 -1
- package/dist/flows.mjs +1 -1
- package/dist/gtd.bundle.mjs +484 -497
- package/dist/testkit/gherkin.mjs +145 -65
- package/dist/testkit.mjs +1 -1
- package/dist/wire.mjs +1 -1
- package/package.json +3 -42
- package/skills/authoring/SKILL.md +34 -27
- package/src/cli/Cli.test.ts +148 -71
- package/src/cli/Cli.ts +384 -463
- package/src/cli/Help.test.ts +47 -0
- package/src/cli/Help.ts +70 -0
- package/src/cli/Program.test.ts +127 -33
- package/src/cli/Program.ts +70 -25
- package/src/edge/Edge.ts +4 -4
- package/src/emit/Emit.test.ts +1 -1
- package/src/emit/Emit.ts +1 -1
- package/src/flags/index.test.ts +0 -13
- package/src/flags/index.ts +1 -3
- package/src/flows/factory.ts +3 -1
- package/src/gherkin/index.ts +135 -71
- package/src/gherkin/messages.test.ts +90 -0
- package/src/gherkin/messages.ts +84 -0
- package/src/install/Install.test.ts +14 -11
- package/src/install/Install.ts +38 -15
- package/src/install/index.ts +1 -2
- package/src/replay/Replay.test.ts +1 -1
- package/src/replay/index.ts +1 -1
- package/src/step/planStart.ts +6 -1
- package/src/testkit/TestRepo.test.ts +47 -2
- package/src/testkit/TestRepo.ts +51 -6
- package/src/twins/EmittedScriptRecognizer.ts +5 -8
- package/src/twins/FlowScripts.ts +1 -1
- package/src/wire/Land.ts +1 -1
- package/src/workflow/index.ts +1 -0
- package/src/workflow/load.test.ts +138 -113
- package/src/workflow/load.ts +7 -7
- package/src/workflow/skills.test.ts +5 -5
- package/src/workflow/skills.ts +1 -1
- package/src/workflow/vars.test.ts +30 -3
- package/src/workflow/vars.ts +34 -2
- package/src/workflow-kit/index.ts +1 -1
- package/dist/chunks/reserved-lMYCaapY.mjs +0 -1990
- package/dist/chunks/review-Bfaror38.mjs +0 -427
- package/dist/chunks/tail-Bd7L940p.mjs +0 -355
- package/dist/chunks/testkit-JDtpNKyi.mjs +0 -10410
- package/dist/workflows/cleanup.mjs +0 -36
- package/dist/workflows/feature.mjs +0 -1018
- package/dist/workflows/fix.mjs +0 -41
- package/dist/workflows/review.mjs +0 -45
- package/dist/workflows/rollout.mjs +0 -48
- package/dist/workflows.mjs +0 -40651
- package/src/install/ConfigScaffold.ts +0 -22
- package/src/workflows/cleanup/cleanup-path.feature +0 -94
- package/src/workflows/cleanup/cleanup.feature +0 -315
- package/src/workflows/cleanup/cleanup.test.ts +0 -276
- package/src/workflows/cleanup/cleanup.ts +0 -52
- package/src/workflows/cleanup/index.ts +0 -6
- package/src/workflows/feature/acceptance-criteria-tier.feature +0 -25
- package/src/workflows/feature/acceptance-criteria.feature +0 -389
- package/src/workflows/feature/criteria-coverage.feature +0 -102
- package/src/workflows/feature/criteria.test.ts +0 -147
- package/src/workflows/feature/criteria.ts +0 -267
- package/src/workflows/feature/fast-path.feature +0 -93
- package/src/workflows/feature/feature.test.ts +0 -338
- package/src/workflows/feature/feature.ts +0 -156
- package/src/workflows/feature/green.ts +0 -19
- package/src/workflows/feature/index.ts +0 -18
- package/src/workflows/feature/lens-models.feature +0 -69
- package/src/workflows/feature/lens-review.feature +0 -566
- package/src/workflows/feature/naive-acceptance.feature +0 -147
- package/src/workflows/feature/naive-test.feature +0 -263
- package/src/workflows/feature/one-step-land.feature +0 -93
- package/src/workflows/feature/planning.ts +0 -41
- package/src/workflows/feature/refactor-absorbs.feature +0 -131
- package/src/workflows/feature/refactor-first.feature +0 -158
- package/src/workflows/feature/refactor-lens-models.feature +0 -79
- package/src/workflows/feature/refactor-rebuild.feature +0 -273
- package/src/workflows/feature/refactor-review.feature +0 -1528
- package/src/workflows/feature/refactor.test.ts +0 -1220
- package/src/workflows/feature/refactor.ts +0 -368
- package/src/workflows/feature/release-flag.feature +0 -167
- package/src/workflows/feature/release-flag.test.ts +0 -49
- package/src/workflows/feature/release-flag.ts +0 -46
- package/src/workflows/feature/review-gate.feature +0 -241
- package/src/workflows/feature/reviewer-walkthrough.feature +0 -142
- package/src/workflows/feature/risk-tier.feature +0 -351
- package/src/workflows/feature/seam-rejection.feature +0 -146
- package/src/workflows/feature/seam.feature +0 -297
- package/src/workflows/feature/seam.test.ts +0 -527
- package/src/workflows/feature/seam.ts +0 -426
- package/src/workflows/feature/standards.feature +0 -354
- package/src/workflows/feature/steps.test.ts +0 -65
- package/src/workflows/feature/steps.ts +0 -45
- package/src/workflows/feature/tier-pin.feature +0 -66
- package/src/workflows/feature/tier.test.ts +0 -166
- package/src/workflows/feature/tier.ts +0 -56
- package/src/workflows/fix/fix-lens-origin.feature +0 -114
- package/src/workflows/fix/fix-quality.feature +0 -212
- package/src/workflows/fix/fix.feature +0 -79
- package/src/workflows/fix/fix.test.ts +0 -74
- package/src/workflows/fix/fix.ts +0 -52
- package/src/workflows/fix/index.ts +0 -6
- package/src/workflows/index.ts +0 -47
- package/src/workflows/review/index.ts +0 -6
- package/src/workflows/review/review-door.feature +0 -199
- package/src/workflows/review/review.test.ts +0 -27
- package/src/workflows/review/review.ts +0 -60
- package/src/workflows/rollout/index.ts +0 -6
- package/src/workflows/rollout/rollout.feature +0 -510
- package/src/workflows/rollout/rollout.test.ts +0 -263
- package/src/workflows/rollout/rollout.ts +0 -82
- package/src/workflows/shared/__fixtures__/feature-collecting-prompt.txt +0 -98
- package/src/workflows/shared/access.test.ts +0 -62
- package/src/workflows/shared/access.ts +0 -24
- package/src/workflows/shared/config.fixture.ts +0 -12
- package/src/workflows/shared/fences.ts +0 -24
- package/src/workflows/shared/health.test.ts +0 -148
- package/src/workflows/shared/health.ts +0 -191
- package/src/workflows/shared/learn.test.ts +0 -140
- package/src/workflows/shared/learn.ts +0 -201
- package/src/workflows/shared/lenses.test.ts +0 -39
- package/src/workflows/shared/lenses.ts +0 -294
- package/src/workflows/shared/options.test.ts +0 -157
- package/src/workflows/shared/options.ts +0 -92
- package/src/workflows/shared/progress.feature +0 -124
- package/src/workflows/shared/prose.ts +0 -263
- package/src/workflows/shared/recheck.feature +0 -60
- package/src/workflows/shared/registry.test.ts +0 -345
- package/src/workflows/shared/registry.ts +0 -183
- package/src/workflows/shared/reserved.ts +0 -11
- package/src/workflows/shared/review.test.ts +0 -1062
- package/src/workflows/shared/review.ts +0 -446
- package/src/workflows/shared/skills.test.ts +0 -121
- package/src/workflows/shared/skills.ts +0 -49
- package/src/workflows/shared/standards.test.ts +0 -90
- package/src/workflows/shared/standards.ts +0 -156
- package/src/workflows/shared/steps.test.ts +0 -219
- package/src/workflows/shared/steps.ts +0 -201
- package/src/workflows/shared/tail.test.ts +0 -454
- package/src/workflows/shared/tail.ts +0 -264
- package/src/workflows/shared/text.fixture.ts +0 -128
- package/src/workflows/shared/text.test.ts +0 -1430
- package/src/workflows/shared/text.ts +0 -1700
- package/src/workflows/shared/threads.test.ts +0 -129
- package/src/workflows/shared/vars.ts +0 -47
- package/src/workflows/shared/workflows.steps.ts +0 -54
|
@@ -1,368 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
changes,
|
|
3
|
-
changesSince,
|
|
4
|
-
head,
|
|
5
|
-
matches,
|
|
6
|
-
quote,
|
|
7
|
-
read,
|
|
8
|
-
refuse,
|
|
9
|
-
removeScript,
|
|
10
|
-
run,
|
|
11
|
-
scope,
|
|
12
|
-
vars,
|
|
13
|
-
type Change,
|
|
14
|
-
type DiffShapeResult,
|
|
15
|
-
} from "../../flows/index.js"
|
|
16
|
-
import { lensEntriesOf } from "../../workflow-kit/index.js"
|
|
17
|
-
import { markFenced } from "../shared/fences.js"
|
|
18
|
-
import { healthy, type Guard } from "../shared/health.js"
|
|
19
|
-
import { coder, FEEDBACK, REFACTOR } from "../shared/steps.js"
|
|
20
|
-
import * as t from "../shared/text.js"
|
|
21
|
-
import { defaults, testFileGlobs } from "../shared/vars.js"
|
|
22
|
-
import { buildTail, reUnwind, type FeedbackTarget } from "../shared/review.js"
|
|
23
|
-
import { refactorCollecting } from "./steps.js"
|
|
24
|
-
|
|
25
|
-
export const REFACTOR_PACKAGE = REFACTOR
|
|
26
|
-
|
|
27
|
-
export interface RefactorOptions {
|
|
28
|
-
/** Blank counts as absent: `kept()` reports `""` while the keeping turn is pending. */
|
|
29
|
-
readonly reference?: string | undefined
|
|
30
|
-
/** The refactor note `seam.note` wrote; rides the package as `## Refactor note`. */
|
|
31
|
-
readonly note?: string | undefined
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const isCode = (path: string): boolean => path !== ".gtd" && !path.startsWith(".gtd/")
|
|
35
|
-
|
|
36
|
-
export const requireWithin = (allowed: readonly string[]): void => {
|
|
37
|
-
const outside = changes()
|
|
38
|
-
.map((c) => c.path)
|
|
39
|
-
.filter((path) => isCode(path) && !allowed.includes(path))
|
|
40
|
-
if (outside.length === 0) return
|
|
41
|
-
refuse(
|
|
42
|
-
`gtd land: refactor-scope: the refactor may change only its stray paths — revert these:\n${outside.map((path) => ` - ${path}`).join("\n")}`,
|
|
43
|
-
)
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// A fixed list, not `seamAllow`: narrowing that setting must not let a test file through.
|
|
47
|
-
export const isTestFile = (path: string): boolean =>
|
|
48
|
-
testFileGlobs.some((glob) => matches(path, glob))
|
|
49
|
-
|
|
50
|
-
// The bundled refactor uses `requireTestCallSitesOnly`; this stays a public export.
|
|
51
|
-
export const requireNoTestFiles = (): void => {
|
|
52
|
-
const tests = changes()
|
|
53
|
-
.map((c) => c.path)
|
|
54
|
-
.filter((path) => isCode(path) && isTestFile(path))
|
|
55
|
-
if (tests.length === 0) return
|
|
56
|
-
refuse(
|
|
57
|
-
`gtd land: refactor-tests: the refactor may not change a test file — revert these:\n${tests.map((path) => ` - ${path}`).join("\n")}`,
|
|
58
|
-
)
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// Call sites live in code: a `.feature` file or a snapshot holds only expectations.
|
|
62
|
-
export const holdsCallSites = (path: string): boolean =>
|
|
63
|
-
!path.endsWith(".feature") && !path.endsWith(".snap")
|
|
64
|
-
|
|
65
|
-
// `//` after a `:` (a URL) and `/*` after `*` or `/` (a glob) sit in strings, not comments.
|
|
66
|
-
export const COMMENT = /(?<![*/])\/\*[\s\S]*?\*\/|(?<!:)\/\/.*$/gm
|
|
67
|
-
const ASSERTION = /\b(?:expect|assert\w*)\s*[.(]/g
|
|
68
|
-
const TEST_CASE = /(?<![.\w])x?(?:it|test|describe)\s*[.(]/g
|
|
69
|
-
const MODIFIER =
|
|
70
|
-
/(?<![.\w])(?:it|test|describe)\.(skip|only|todo|fails|skipIf|runIf)\b|(?<![.\w])(x)(?:it|test|describe)\b/g
|
|
71
|
-
|
|
72
|
-
// Modifiers count per kind, so swapping a `.skip` for an `.only` shows.
|
|
73
|
-
const counts = (text: string): string => {
|
|
74
|
-
const live = text.replace(COMMENT, "")
|
|
75
|
-
const modifiers = [...live.matchAll(MODIFIER)].map((m) => m[1] ?? m[2]).sort()
|
|
76
|
-
return JSON.stringify([
|
|
77
|
-
[...live.matchAll(ASSERTION)].length,
|
|
78
|
-
[...live.matchAll(TEST_CASE)].length,
|
|
79
|
-
modifiers,
|
|
80
|
-
])
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const tested = (text: string): boolean => {
|
|
84
|
-
const live = text.replace(COMMENT, "")
|
|
85
|
-
return live.search(ASSERTION) >= 0 || live.search(TEST_CASE) >= 0
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// A call-shape update keeps every live test and assertion, so the counts hold. A changed
|
|
89
|
-
// expected value keeps every count — that is left to the refactor review.
|
|
90
|
-
const callSitesOnly = ({ path, status, before, after }: Change): boolean =>
|
|
91
|
-
status === "modified" &&
|
|
92
|
-
holdsCallSites(path) &&
|
|
93
|
-
before !== undefined &&
|
|
94
|
-
after !== undefined &&
|
|
95
|
-
// A file with no test or assertion is fixture or helper data, never a call site to follow.
|
|
96
|
-
tested(before) &&
|
|
97
|
-
counts(before) === counts(after)
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* The test-file half of the refactor guard: an existing test may follow a changed call shape,
|
|
101
|
-
* nothing more. Returns the test files it admitted, which the scope check then allows.
|
|
102
|
-
*/
|
|
103
|
-
export const requireTestCallSitesOnly = (): readonly string[] => {
|
|
104
|
-
const tests = changes().filter((c) => isCode(c.path) && isTestFile(c.path))
|
|
105
|
-
const refused = tests.filter((c) => !callSitesOnly(c)).map((c) => c.path)
|
|
106
|
-
if (refused.length > 0)
|
|
107
|
-
refuse(
|
|
108
|
-
`gtd land: refactor-tests: the refactor may update an existing test file's call sites only — never add, delete, skip or comment out a test or an assertion, or touch a snapshot, a .feature file or test data; revert these:\n${refused.map((path) => ` - ${path}`).join("\n")}`,
|
|
109
|
-
)
|
|
110
|
-
return tests.map((c) => c.path)
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
const flagExpression = (pattern: string): RegExp => {
|
|
114
|
-
if (pattern.trim() === "") throw new Error("gtd: refactorFlagPattern must not be blank")
|
|
115
|
-
let expression: RegExp
|
|
116
|
-
try {
|
|
117
|
-
expression = new RegExp(pattern, "g")
|
|
118
|
-
} catch (error) {
|
|
119
|
-
throw new Error(`gtd: refactorFlagPattern is not a valid regular expression: ${String(error)}`)
|
|
120
|
-
}
|
|
121
|
-
// An empty match would count the name "" at every character.
|
|
122
|
-
if (expression.test(""))
|
|
123
|
-
throw new Error("gtd: refactorFlagPattern must not match the empty string")
|
|
124
|
-
return expression
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// A zero-length match is never counted: a lookahead-only pattern would count every read
|
|
128
|
-
// under "" and let a flag swap pass. Checked on the strays up front and again at landing.
|
|
129
|
-
const countFlags = (text: string, expression: RegExp, into: Map<string, number>): boolean => {
|
|
130
|
-
let zeroLength = false
|
|
131
|
-
for (const [name] of text.matchAll(expression)) {
|
|
132
|
-
if (name === "") zeroLength = true
|
|
133
|
-
else into.set(name, (into.get(name) ?? 0) + 1)
|
|
134
|
-
}
|
|
135
|
-
return zeroLength
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
const listPaths = (paths: readonly string[]): string =>
|
|
139
|
-
paths.map((path) => ` - ${path}`).join("\n")
|
|
140
|
-
|
|
141
|
-
// Counts per name, so a read moved between files passes.
|
|
142
|
-
export const requireNoNewFlags = (pattern: string, since: string): void => {
|
|
143
|
-
const expression = flagExpression(pattern)
|
|
144
|
-
const before = new Map<string, number>()
|
|
145
|
-
const after = new Map<string, number>()
|
|
146
|
-
const unreadable: string[] = []
|
|
147
|
-
const zeroLength: string[] = []
|
|
148
|
-
for (const { path, status, before: was, after: now } of changesSince(since)) {
|
|
149
|
-
if (!isCode(path)) continue
|
|
150
|
-
// A side absent by status is empty; one absent otherwise could not be read.
|
|
151
|
-
const oldSide = status === "added" ? "" : was
|
|
152
|
-
const newSide = status === "deleted" ? "" : now
|
|
153
|
-
if (oldSide === undefined || newSide === undefined) {
|
|
154
|
-
unreadable.push(path)
|
|
155
|
-
continue
|
|
156
|
-
}
|
|
157
|
-
countFlags(oldSide, expression, before)
|
|
158
|
-
if (countFlags(newSide, expression, after)) zeroLength.push(path)
|
|
159
|
-
}
|
|
160
|
-
if (unreadable.length > 0)
|
|
161
|
-
refuse(`gtd land: refactor-flags: cannot compare flag reads in:\n${listPaths(unreadable)}`)
|
|
162
|
-
if (zeroLength.length > 0)
|
|
163
|
-
refuse(
|
|
164
|
-
`gtd land: refactor-flags: refactorFlagPattern (${pattern}) produced a zero-length match in:\n${listPaths(zeroLength)}`,
|
|
165
|
-
)
|
|
166
|
-
const raised = [...after].filter(([name, count]) => count > (before.get(name) ?? 0))
|
|
167
|
-
if (raised.length === 0) return
|
|
168
|
-
refuse(
|
|
169
|
-
`gtd land: refactor-flags: the refactor may not introduce or add reads of a flag (${pattern}):\n${raised.map(([name, count]) => ` - ${name}: ${before.get(name) ?? 0} → ${count}`).join("\n")}`,
|
|
170
|
-
)
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/** A test-file stray is never refactored: the implement turn still reads it in the naive tip. */
|
|
174
|
-
export const refactorStrays = (shape: DiffShapeResult): readonly string[] =>
|
|
175
|
-
shape.strays.filter((path) => !isTestFile(path))
|
|
176
|
-
|
|
177
|
-
const packageLines = (
|
|
178
|
-
strays: readonly string[],
|
|
179
|
-
pattern: string,
|
|
180
|
-
reference: string | undefined,
|
|
181
|
-
): string[] => [
|
|
182
|
-
"# Behaviour-neutral refactor",
|
|
183
|
-
"",
|
|
184
|
-
"## Scope (read-only)",
|
|
185
|
-
"",
|
|
186
|
-
...strays.map((path) => `- \`${path}\``),
|
|
187
|
-
"",
|
|
188
|
-
"This list is fixed when the package is derived: editing it here widens or narrows nothing.",
|
|
189
|
-
"",
|
|
190
|
-
"## Rules",
|
|
191
|
-
"",
|
|
192
|
-
"- The suite's outcome must not change",
|
|
193
|
-
"- Change only the paths above, plus an existing test file where a changed call shape reaches it: never add or delete a test file, and never add, remove or alter a test or an assertion",
|
|
194
|
-
`- Introduce no flag read and add no second read of an existing one (\`${pattern}\`)`,
|
|
195
|
-
"",
|
|
196
|
-
"## Reference",
|
|
197
|
-
"",
|
|
198
|
-
...(reference === undefined
|
|
199
|
-
? ["None."]
|
|
200
|
-
: [
|
|
201
|
-
`Reading material only, never applied: \`git show ${reference} -- ${strays.map(quote).join(" ")}\``,
|
|
202
|
-
"",
|
|
203
|
-
"Its changes to the existing code above are the seam this refactor exists to land, minus the feature's own behaviour.",
|
|
204
|
-
]),
|
|
205
|
-
"",
|
|
206
|
-
]
|
|
207
|
-
|
|
208
|
-
// Nested under a `##` section (review feedback, the refactor note), so every heading drops a level.
|
|
209
|
-
const demoteHeadings = (text: string): string[] => {
|
|
210
|
-
const marked = markFenced(text.trimEnd())
|
|
211
|
-
const lines = marked.map((m) => m.line)
|
|
212
|
-
const headings = marked.map((m) =>
|
|
213
|
-
m.fenced ? undefined : /^(#{1,6}) /.exec(m.line)?.[1]?.length,
|
|
214
|
-
)
|
|
215
|
-
const shallowest = Math.min(...headings.filter((n): n is number => n !== undefined))
|
|
216
|
-
// Collected headings nest under `## Review feedback`, so the shallowest lands at `###`.
|
|
217
|
-
const shift = 3 - shallowest
|
|
218
|
-
return lines.map((line, i) => {
|
|
219
|
-
const depth = headings[i]
|
|
220
|
-
if (depth === undefined) return line
|
|
221
|
-
// Markdown has no heading past `######`.
|
|
222
|
-
return "#".repeat(Math.min(6, depth + shift)) + line.slice(depth)
|
|
223
|
-
})
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
const deriveScript = (lines: readonly string[]): string =>
|
|
227
|
-
`mkdir -p .gtd
|
|
228
|
-
printf '%s\\n' ${lines.map(quote).join(" ")} > ${quote(REFACTOR_PACKAGE)}
|
|
229
|
-
`
|
|
230
|
-
|
|
231
|
-
interface Plan {
|
|
232
|
-
readonly pattern: string
|
|
233
|
-
readonly strays: readonly string[]
|
|
234
|
-
readonly reference: string | undefined
|
|
235
|
-
readonly note: string | undefined
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
/** Undefined for every shape that is not a readable `refactor-first`; throws on a bad flag pattern. */
|
|
239
|
-
const plan = (shape: DiffShapeResult, options: RefactorOptions): Plan | undefined => {
|
|
240
|
-
if (shape.choice !== "refactor-first" || shape.unreadable.length > 0) return undefined
|
|
241
|
-
const pattern = vars.refactorFlagPattern ?? defaults.refactorFlagPattern!
|
|
242
|
-
const expression = flagExpression(pattern)
|
|
243
|
-
const strays = refactorStrays(shape)
|
|
244
|
-
if (strays.length === 0) return undefined
|
|
245
|
-
const zeroLength = strays.filter((path) => countFlags(read(path) ?? "", expression, new Map()))
|
|
246
|
-
if (zeroLength.length > 0)
|
|
247
|
-
throw new Error(
|
|
248
|
-
`gtd: refactorFlagPattern (${pattern}) produces a zero-length match in:\n${listPaths(zeroLength)}`,
|
|
249
|
-
)
|
|
250
|
-
const reference = options.reference?.trim() ? options.reference : undefined
|
|
251
|
-
const note = options.note?.trim() ? options.note : undefined
|
|
252
|
-
return { pattern, strays, reference, note }
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
// The in-memory `strays`, never the package file: a hand edit of its list changes nothing.
|
|
256
|
-
// Scope and tests look at the turn's own changes: a person's edit at an escalation rest must
|
|
257
|
-
// not wedge every later fix turn. Flags look at the whole range since `entry`.
|
|
258
|
-
const guardOf =
|
|
259
|
-
({ pattern, strays }: Plan, entry: string): Guard =>
|
|
260
|
-
() => {
|
|
261
|
-
requireWithin([...strays, ...requireTestCallSitesOnly()])
|
|
262
|
-
requireNoNewFlags(pattern, entry)
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
/** Derive, build, check and close one refactor lap; `feedback` is the collected review text of the lap before. */
|
|
266
|
-
const refactorLap = async (p: Plan, guard: Guard, feedback?: string): Promise<void> => {
|
|
267
|
-
const { pattern, strays, reference, note } = p
|
|
268
|
-
await run(
|
|
269
|
-
"deriving",
|
|
270
|
-
deriveScript([
|
|
271
|
-
...packageLines(strays, pattern, reference),
|
|
272
|
-
...(note === undefined ? [] : ["## Refactor note", "", ...demoteHeadings(note), ""]),
|
|
273
|
-
...(feedback === undefined
|
|
274
|
-
? []
|
|
275
|
-
: ["## Review feedback", "", ...demoteHeadings(feedback), ""]),
|
|
276
|
-
]),
|
|
277
|
-
{ label: "Deriving the refactor package", file: REFACTOR_PACKAGE },
|
|
278
|
-
)
|
|
279
|
-
await t.agentWithSkills(
|
|
280
|
-
"building",
|
|
281
|
-
t.refactorBuildingPrompt({
|
|
282
|
-
pkg: REFACTOR_PACKAGE,
|
|
283
|
-
strays,
|
|
284
|
-
pattern,
|
|
285
|
-
reference,
|
|
286
|
-
feedback: feedback !== undefined,
|
|
287
|
-
note: note !== undefined,
|
|
288
|
-
}),
|
|
289
|
-
{ label: "Refactoring", model: coder(), system: t.builderSystem() },
|
|
290
|
-
)
|
|
291
|
-
guard()
|
|
292
|
-
await healthy(
|
|
293
|
-
async () => {
|
|
294
|
-
await t.agentWithSkills("fix.suite.fixing", t.refactorFixPrompt(strays, pattern), {
|
|
295
|
-
label: "Fixing the check",
|
|
296
|
-
file: FEEDBACK,
|
|
297
|
-
model: coder(),
|
|
298
|
-
system: t.builderSystem(),
|
|
299
|
-
})
|
|
300
|
-
guard()
|
|
301
|
-
},
|
|
302
|
-
{ describeGuard: guard, rules: t.refactorRules(strays, pattern) },
|
|
303
|
-
)
|
|
304
|
-
await run("closing", removeScript([REFACTOR_PACKAGE]), { label: "Closing out the refactor" })
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
const strayAccess = (strays: readonly string[]) => ({
|
|
308
|
-
write: [...strays, ...testFileGlobs, ".gtd/**"],
|
|
309
|
-
})
|
|
310
|
-
|
|
311
|
-
export const refactorFirst = async (
|
|
312
|
-
shape: DiffShapeResult,
|
|
313
|
-
options: RefactorOptions = {},
|
|
314
|
-
): Promise<boolean> => {
|
|
315
|
-
const p = plan(shape, options)
|
|
316
|
-
if (p === undefined) return false
|
|
317
|
-
return scope({ name: "refactor", access: strayAccess(p.strays) }, async () => {
|
|
318
|
-
await refactorLap(p, guardOf(p, head()))
|
|
319
|
-
return true
|
|
320
|
-
})
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
/**
|
|
324
|
-
* `refactorFirst` plus its review, in the one `refactor` scope so the tail's
|
|
325
|
-
* steps are `refactor.build.*`. A review edit re-runs the refactor, never a
|
|
326
|
-
* re-plan: the collected notes ride the re-derived package. Every round
|
|
327
|
-
* reviews the whole refactor since `entry`, since each lap rebuilds over it.
|
|
328
|
-
*/
|
|
329
|
-
export const reviewedRefactor = async (
|
|
330
|
-
shape: DiffShapeResult,
|
|
331
|
-
options: RefactorOptions = {},
|
|
332
|
-
): Promise<boolean> => {
|
|
333
|
-
const p = plan(shape, options)
|
|
334
|
-
if (p === undefined) return false
|
|
335
|
-
return scope({ name: "refactor", access: strayAccess(p.strays) }, async () => {
|
|
336
|
-
const entry = head()
|
|
337
|
-
const guard = guardOf(p, entry)
|
|
338
|
-
const feedback: FeedbackTarget = {
|
|
339
|
-
file: REFACTOR_PACKAGE,
|
|
340
|
-
collecting: (capture, round) => refactorCollecting(capture, round, p.strays),
|
|
341
|
-
unmatched:
|
|
342
|
-
"gtd land: refactor-feedback: the review feedback changed files but wrote no .gtd/REFACTOR.md — a refactor cannot act on it; revert the change, or `gtd abandon` and restart",
|
|
343
|
-
dropped: t.refactorDroppedRefusal,
|
|
344
|
-
}
|
|
345
|
-
let collected: string | undefined
|
|
346
|
-
for (;;) {
|
|
347
|
-
await refactorLap(p, guard, collected)
|
|
348
|
-
// A person's edit at an escalation rest is never refused, as in the lap.
|
|
349
|
-
const outcome = await buildTail(entry, {
|
|
350
|
-
green: true,
|
|
351
|
-
guard,
|
|
352
|
-
guardRests: false,
|
|
353
|
-
lenses: lensEntriesOf(vars.refactorQualityReviews ?? ""),
|
|
354
|
-
feedback,
|
|
355
|
-
fixAccess: strayAccess(p.strays),
|
|
356
|
-
lensBase: entry,
|
|
357
|
-
rules: t.refactorRules(p.strays, p.pattern),
|
|
358
|
-
lap: "refactor",
|
|
359
|
-
})
|
|
360
|
-
if (outcome.verdict === "signoff") return true
|
|
361
|
-
await reUnwind(outcome)
|
|
362
|
-
// The kit returns `feedback` only once the collecting turn wrote the package.
|
|
363
|
-
collected = read(REFACTOR_PACKAGE)
|
|
364
|
-
if (collected === undefined)
|
|
365
|
-
throw new Error(`gtd: review feedback left no ${REFACTOR_PACKAGE}`)
|
|
366
|
-
}
|
|
367
|
-
})
|
|
368
|
-
}
|
|
@@ -1,167 +0,0 @@
|
|
|
1
|
-
@inmem
|
|
2
|
-
Feature: a release flag only when the requirements ask for one
|
|
3
|
-
|
|
4
|
-
A change ships unflagged unless `.gtd/REQUIREMENTS.md` carries a
|
|
5
|
-
`## Release Flag` section — written by triage, or by the skip path's impact
|
|
6
|
-
turn, only when the sketch asks for a flag, and editable at the design gate.
|
|
7
|
-
Its first line is `new` (a new release flag) or `existing: <NAME>` (behind a
|
|
8
|
-
flag that already exists). Without it no seam judge or refactor runs and the
|
|
9
|
-
naive pass lands as the implementation; with it the seam judge rules on the
|
|
10
|
-
pass.
|
|
11
|
-
|
|
12
|
-
Background:
|
|
13
|
-
Given the shipped workflows, as gtd install scaffolds them
|
|
14
|
-
And an environment variable "GTD_QUALITYREVIEWS" set to ""
|
|
15
|
-
And an environment variable "GTD_FLAG_RISK_TIER" set to "0"
|
|
16
|
-
And a file "src/calc.ts" with:
|
|
17
|
-
"""
|
|
18
|
-
export const add = (a: number, b: number) => a + b
|
|
19
|
-
"""
|
|
20
|
-
And the working tree is committed as "feat: add calculator"
|
|
21
|
-
And step "unwind" changes nothing
|
|
22
|
-
And step "start-gate.check" changes nothing
|
|
23
|
-
And step "seam.naive" writes "src/calc.test.ts" with:
|
|
24
|
-
"""
|
|
25
|
-
it("[AC-1] subtracts", () => {})
|
|
26
|
-
"""
|
|
27
|
-
And step "seam.naive" writes "src/calc.ts" with:
|
|
28
|
-
"""
|
|
29
|
-
export const add = (a: number, b: number) => a + b
|
|
30
|
-
export const subtract = (a: number, b: number) => a - b
|
|
31
|
-
"""
|
|
32
|
-
And step "seam.check" changes nothing
|
|
33
|
-
|
|
34
|
-
Rule: the design path
|
|
35
|
-
|
|
36
|
-
Background:
|
|
37
|
-
Given a file "TODO.md" with:
|
|
38
|
-
"""
|
|
39
|
-
# Add subtract
|
|
40
|
-
|
|
41
|
-
subtract(a, b) returns the difference.
|
|
42
|
-
"""
|
|
43
|
-
And step "design.gate.answer" changes nothing
|
|
44
|
-
|
|
45
|
-
Scenario: with no Release Flag section the naive pass lands unjudged, and no flag is asked for
|
|
46
|
-
Given step "design.triage" writes ".gtd/REQUIREMENTS.md" with:
|
|
47
|
-
"""
|
|
48
|
-
# Requirements
|
|
49
|
-
|
|
50
|
-
## Impact
|
|
51
|
-
|
|
52
|
-
- the calculator module
|
|
53
|
-
|
|
54
|
-
## Acceptance Criteria
|
|
55
|
-
|
|
56
|
-
- AC-1: subtract(a, b) returns a - b.
|
|
57
|
-
"""
|
|
58
|
-
And step "implement.applying" runs its script
|
|
59
|
-
And step "implement.health.check" changes nothing
|
|
60
|
-
And step "implement.closing" runs its script
|
|
61
|
-
When the process is driven
|
|
62
|
-
Then the process rests at "build.review.reviewing"
|
|
63
|
-
And "src/calc.ts" contains "subtract"
|
|
64
|
-
And no commit body contains "seam.judge"
|
|
65
|
-
And the commit subjects end with:
|
|
66
|
-
"""
|
|
67
|
-
gtd(human): design.gate.answer → seam.naive
|
|
68
|
-
gtd(agent): seam.naive → seam.check
|
|
69
|
-
gtd(check): seam.check → implement.applying
|
|
70
|
-
gtd(check): implement.applying → implement.health.check
|
|
71
|
-
gtd(check): implement.health.check → implement.closing
|
|
72
|
-
gtd(check): implement.closing → build.review.reviewing
|
|
73
|
-
"""
|
|
74
|
-
|
|
75
|
-
Scenario: a new release flag asked for sends the naive pass to the seam judge
|
|
76
|
-
Given step "design.triage" writes ".gtd/REQUIREMENTS.md" with:
|
|
77
|
-
"""
|
|
78
|
-
# Requirements
|
|
79
|
-
|
|
80
|
-
## Impact
|
|
81
|
-
|
|
82
|
-
- the calculator module
|
|
83
|
-
|
|
84
|
-
## Release Flag
|
|
85
|
-
|
|
86
|
-
new
|
|
87
|
-
|
|
88
|
-
## Acceptance Criteria
|
|
89
|
-
|
|
90
|
-
- AC-1: subtract(a, b) returns a - b.
|
|
91
|
-
"""
|
|
92
|
-
When the process is driven
|
|
93
|
-
Then the process rests at "seam.judge"
|
|
94
|
-
|
|
95
|
-
Scenario: an existing flag asked for sends the naive pass to the seam judge too
|
|
96
|
-
Given step "design.triage" writes ".gtd/REQUIREMENTS.md" with:
|
|
97
|
-
"""
|
|
98
|
-
# Requirements
|
|
99
|
-
|
|
100
|
-
## Impact
|
|
101
|
-
|
|
102
|
-
- the calculator module
|
|
103
|
-
|
|
104
|
-
## Release Flag
|
|
105
|
-
|
|
106
|
-
existing: GTD_FLAG_BETA
|
|
107
|
-
|
|
108
|
-
## Acceptance Criteria
|
|
109
|
-
|
|
110
|
-
- AC-1: subtract(a, b) returns a - b.
|
|
111
|
-
"""
|
|
112
|
-
When the process is driven
|
|
113
|
-
Then the process rests at "seam.judge"
|
|
114
|
-
|
|
115
|
-
Scenario: a Release Flag section that asks for neither is refused
|
|
116
|
-
Given step "design.triage" writes ".gtd/REQUIREMENTS.md" with:
|
|
117
|
-
"""
|
|
118
|
-
# Requirements
|
|
119
|
-
|
|
120
|
-
## Impact
|
|
121
|
-
|
|
122
|
-
- the calculator module
|
|
123
|
-
|
|
124
|
-
## Release Flag
|
|
125
|
-
|
|
126
|
-
maybe later
|
|
127
|
-
|
|
128
|
-
## Acceptance Criteria
|
|
129
|
-
|
|
130
|
-
- AC-1: subtract(a, b) returns a - b.
|
|
131
|
-
"""
|
|
132
|
-
Then driving the process is refused with "release-flag"
|
|
133
|
-
|
|
134
|
-
Rule: the skip path
|
|
135
|
-
|
|
136
|
-
Background:
|
|
137
|
-
Given a file "TODO.md" with:
|
|
138
|
-
"""
|
|
139
|
-
# Add subtract
|
|
140
|
-
|
|
141
|
-
Ship it behind the existing GTD_FLAG_BETA flag.
|
|
142
|
-
|
|
143
|
-
## Acceptance criteria
|
|
144
|
-
|
|
145
|
-
- subtract(a, b) returns a - b.
|
|
146
|
-
"""
|
|
147
|
-
And judge step "design.clear" answers:
|
|
148
|
-
"""
|
|
149
|
-
[{"id":"clear","answer":"yes","p":0.9}]
|
|
150
|
-
"""
|
|
151
|
-
And step "design.normalise" runs its script
|
|
152
|
-
|
|
153
|
-
Scenario: the impact turn records the flag the sketch asks for, and the seam judge rules
|
|
154
|
-
Given step "design.impact" appends to ".gtd/REQUIREMENTS.md":
|
|
155
|
-
"""
|
|
156
|
-
|
|
157
|
-
## Impact
|
|
158
|
-
|
|
159
|
-
- the calculator module
|
|
160
|
-
|
|
161
|
-
## Release Flag
|
|
162
|
-
|
|
163
|
-
existing: GTD_FLAG_BETA
|
|
164
|
-
"""
|
|
165
|
-
When the process is driven
|
|
166
|
-
Then the process rests at "seam.judge"
|
|
167
|
-
And ".gtd/REQUIREMENTS.md" contains "existing: GTD_FLAG_BETA"
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest"
|
|
2
|
-
import { releaseFlagOf } from "./release-flag.js"
|
|
3
|
-
|
|
4
|
-
const requirements = (section: string): string =>
|
|
5
|
-
`# Requirements\n\n## Impact\n\n- a module\n\n${section}## Acceptance Criteria\n\n- AC-1: a\n`
|
|
6
|
-
|
|
7
|
-
describe("releaseFlagOf", () => {
|
|
8
|
-
it("asks for no flag without the section, or with none", () => {
|
|
9
|
-
expect(releaseFlagOf(requirements(""))).toEqual({ kind: "none" })
|
|
10
|
-
expect(releaseFlagOf(requirements("## Release Flag\n\nnone\n\n"))).toEqual({ kind: "none" })
|
|
11
|
-
})
|
|
12
|
-
|
|
13
|
-
it("reads new and an existing flag's name, backticked or not", () => {
|
|
14
|
-
expect(releaseFlagOf(requirements("## Release Flag\n\nnew\n\n"))).toEqual({ kind: "new" })
|
|
15
|
-
for (const line of [
|
|
16
|
-
"existing: GTD_FLAG_BETA",
|
|
17
|
-
"existing: `GTD_FLAG_BETA`",
|
|
18
|
-
"Existing:GTD_FLAG_BETA",
|
|
19
|
-
])
|
|
20
|
-
expect(releaseFlagOf(requirements(`## Release Flag\n\n${line}\n\n`))).toEqual({
|
|
21
|
-
kind: "existing",
|
|
22
|
-
name: "GTD_FLAG_BETA",
|
|
23
|
-
})
|
|
24
|
-
})
|
|
25
|
-
|
|
26
|
-
it("reads only the first line under the heading, and only a level-2 heading", () => {
|
|
27
|
-
expect(
|
|
28
|
-
releaseFlagOf(requirements("## Release Flag\n\nnew\n\nbecause the sketch says so\n\n")),
|
|
29
|
-
).toEqual({
|
|
30
|
-
kind: "new",
|
|
31
|
-
})
|
|
32
|
-
expect(releaseFlagOf(requirements("### Release Flag\n\nnew\n\n"))).toEqual({ kind: "none" })
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
it("ignores a fenced heading", () => {
|
|
36
|
-
expect(releaseFlagOf(requirements("```\n## Release Flag\n\nnew\n```\n\n"))).toEqual({
|
|
37
|
-
kind: "none",
|
|
38
|
-
})
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
it("names the line it cannot read", () => {
|
|
42
|
-
expect(releaseFlagOf(requirements("## Release Flag\n\nmaybe later\n\n"))).toEqual({
|
|
43
|
-
unreadable: "maybe later",
|
|
44
|
-
})
|
|
45
|
-
expect(releaseFlagOf(requirements("## Release Flag\n\nexisting:\n\n"))).toEqual({
|
|
46
|
-
unreadable: "existing:",
|
|
47
|
-
})
|
|
48
|
-
})
|
|
49
|
-
})
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
import { read, refuse } from "../../flows/index.js"
|
|
2
|
-
import { HEADING, markFenced } from "../shared/fences.js"
|
|
3
|
-
import { REQUIREMENTS } from "../shared/steps.js"
|
|
4
|
-
import type { ReleaseFlag } from "../shared/text.js"
|
|
5
|
-
|
|
6
|
-
const NAME = /^existing:\s*`?([A-Za-z_][\w.-]*)`?$/i
|
|
7
|
-
|
|
8
|
-
const isFlagHeading = (h: RegExpExecArray | null): boolean =>
|
|
9
|
-
h !== null && h[1]!.length === 2 && /^release flag$/i.test(h[2]!.trim())
|
|
10
|
-
|
|
11
|
-
/** The first line under `## Release Flag`, `undefined` without the section. */
|
|
12
|
-
const requestLine = (text: string): string | undefined => {
|
|
13
|
-
const lines = markFenced(text)
|
|
14
|
-
const at = lines.findIndex((l) => !l.fenced && isFlagHeading(HEADING.exec(l.line)))
|
|
15
|
-
if (at === -1) return undefined
|
|
16
|
-
const body = lines.slice(at + 1)
|
|
17
|
-
const end = body.findIndex((l) => !l.fenced && HEADING.test(l.line))
|
|
18
|
-
return (end === -1 ? body : body.slice(0, end)).find((l) => l.line.trim() !== "")?.line.trim()
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/** What `text` asks for, or why it cannot be read. No section asks for no flag. */
|
|
22
|
-
export const releaseFlagOf = (text: string): ReleaseFlag | { readonly unreadable: string } => {
|
|
23
|
-
const line = requestLine(text)
|
|
24
|
-
if (line === undefined || /^none$/i.test(line)) return { kind: "none" }
|
|
25
|
-
if (/^new$/i.test(line)) return { kind: "new" }
|
|
26
|
-
const name = NAME.exec(line)?.[1]
|
|
27
|
-
return name === undefined ? { unreadable: line } : { kind: "existing", name }
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** Refuse `file` when its `## Release Flag` section is neither `none`, `new` nor `existing: <NAME>`. */
|
|
31
|
-
export const requireReleaseFlag = (file: string): void => {
|
|
32
|
-
const request = releaseFlagOf(read(file) ?? "")
|
|
33
|
-
if (!("unreadable" in request)) return
|
|
34
|
-
refuse(
|
|
35
|
-
`gtd land: release-flag: ${file}'s "## Release Flag" reads ${JSON.stringify(request.unreadable)} — its first line must be \`new\`, \`existing: <NAME>\` or \`none\`, or leave the section out for no flag`,
|
|
36
|
-
)
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/** What `.gtd/REQUIREMENTS.md` asks for now; a person's unreadable edit is refused. */
|
|
40
|
-
export const requestedFlag = (): ReleaseFlag => {
|
|
41
|
-
requireReleaseFlag(REQUIREMENTS)
|
|
42
|
-
return releaseFlagOf(read(REQUIREMENTS) ?? "") as ReleaseFlag
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** Whether the change goes behind a release flag: only then does the seam judge rule. */
|
|
46
|
-
export const flagged = (): boolean => requestedFlag().kind !== "none"
|