@pmelab/gtd 27.5.0 → 27.7.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 +12 -2
- package/dist/chunks/{reserved-DM5W_LAt.mjs → reserved-Df8GeSHw.mjs} +37 -9
- package/dist/chunks/{tail-6gvLXXcg.mjs → tail-DmIfEAI4.mjs} +1 -1
- package/dist/flows.mjs +17 -1
- package/dist/gtd.bundle.mjs +56 -11
- package/dist/testkit.mjs +67 -26
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +61 -10
- package/dist/workflows/fix.mjs +2 -2
- package/dist/workflows/review.mjs +2 -2
- package/dist/workflows/rollout.mjs +1 -1
- package/dist/workflows.mjs +110 -16
- package/package.json +1 -1
- package/src/cli/Cli.ts +2 -2
- package/src/edge/Edge.test.ts +111 -0
- package/src/edge/Edge.ts +15 -3
- package/src/flags/index.test.ts +13 -0
- package/src/flags/index.ts +1 -0
- package/src/flows/runtime.test.ts +42 -0
- package/src/flows/runtime.ts +36 -0
- package/src/replay/Replay.test.ts +43 -0
- package/src/replay/Replay.ts +10 -2
- package/src/replay/Trailers.test.ts +54 -0
- package/src/replay/Trailers.ts +15 -1
- package/src/testkit/EmittedScriptRecognizer.test.ts +41 -0
- package/src/testkit/EmittedScriptRecognizer.ts +16 -7
- package/src/testkit/TestRepo.test.ts +79 -1
- package/src/testkit/TestRepo.ts +17 -6
- package/src/testkit/steps.ts +44 -1
- package/src/testkit/testkit.feature +35 -0
- package/src/workflows/cleanup/cleanup-path.feature +94 -0
- package/src/workflows/cleanup/cleanup.feature +0 -29
- package/src/workflows/feature/fast-path.feature +114 -0
- package/src/workflows/feature/feature.ts +3 -1
- package/src/workflows/feature/one-step-land.feature +108 -0
- package/src/workflows/feature/refactor-first.feature +137 -0
- package/src/workflows/feature/risk-tier.feature +238 -0
- package/src/workflows/feature/seam-rejection.feature +146 -0
- package/src/workflows/feature/seam.feature +0 -67
- package/src/workflows/feature/seam.ts +5 -4
- package/src/workflows/feature/steps.ts +3 -2
- package/src/workflows/feature/tier.test.ts +164 -0
- package/src/workflows/feature/tier.ts +74 -0
- package/src/workflows/shared/options.test.ts +6 -1
- package/src/workflows/shared/text.test.ts +129 -0
- package/src/workflows/shared/text.ts +57 -9
- package/src/workflows/shared/vars.ts +2 -0
- package/src/workflows/shared/workflows.steps.ts +13 -4
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# One SDLC path per file, so a changed path reds exactly one fixture — do not merge.
|
|
2
|
+
@inmem
|
|
3
|
+
Feature: rejected naive passes walk one fixed path through the steer gate to idle
|
|
4
|
+
|
|
5
|
+
With GTD_FLAG_NAIVE_PASS on and GTD_FLAG_RISK_TIER off, three rejected naive
|
|
6
|
+
passes rest at the steer gate, where a human edit restarts the seam and the
|
|
7
|
+
steered rerun lands. The flags are pinned so the per-flag test runs never
|
|
8
|
+
move it off its path.
|
|
9
|
+
|
|
10
|
+
Background:
|
|
11
|
+
Given the shipped workflows, as gtd install scaffolds them
|
|
12
|
+
And an environment variable "GTD_QUALITYREVIEWS" set to ""
|
|
13
|
+
And an environment variable "GTD_FLAG_NAIVE_PASS" set to "1"
|
|
14
|
+
And an environment variable "GTD_FLAG_RISK_TIER" set to "0"
|
|
15
|
+
And a file "src/calc.ts" with:
|
|
16
|
+
"""
|
|
17
|
+
export const add = (a: number, b: number) => a + b
|
|
18
|
+
"""
|
|
19
|
+
And the working tree is committed as "feat: add calculator"
|
|
20
|
+
And a file "TODO.md" with:
|
|
21
|
+
"""
|
|
22
|
+
# Add subtract
|
|
23
|
+
|
|
24
|
+
subtract(a, b) returns the difference.
|
|
25
|
+
"""
|
|
26
|
+
And step "unwind" changes nothing
|
|
27
|
+
And step "start-gate.check" changes nothing
|
|
28
|
+
And step "design.triage" writes ".gtd/REQUIREMENTS.md" with:
|
|
29
|
+
"""
|
|
30
|
+
# Requirements
|
|
31
|
+
|
|
32
|
+
## Concerns
|
|
33
|
+
|
|
34
|
+
### Subtract
|
|
35
|
+
|
|
36
|
+
- `subtract(a, b)` returns `a - b`.
|
|
37
|
+
"""
|
|
38
|
+
And step "design.gate.answer" changes nothing
|
|
39
|
+
|
|
40
|
+
Scenario: three rejects rest at the steer gate, a steered rerun lands and runs to idle
|
|
41
|
+
Given step "seam.naive" writes "src/calc.ts" with:
|
|
42
|
+
"""
|
|
43
|
+
export const add = (a: number, b: number) => b + a
|
|
44
|
+
"""
|
|
45
|
+
And step "implement.health.check" changes nothing
|
|
46
|
+
And step "implement.closing" runs its script
|
|
47
|
+
And step "build.review.reviewing" writes ".gtd/REVIEW.md" with:
|
|
48
|
+
"""
|
|
49
|
+
# Review: abc1234
|
|
50
|
+
|
|
51
|
+
<!-- base: 0000000 -->
|
|
52
|
+
|
|
53
|
+
## calc
|
|
54
|
+
|
|
55
|
+
- [ ] ./src/calc.ts#1-2
|
|
56
|
+
subtract added
|
|
57
|
+
"""
|
|
58
|
+
And step "build.review.closing" runs its script
|
|
59
|
+
When the process is driven
|
|
60
|
+
Then the process rests at "seam.judge"
|
|
61
|
+
|
|
62
|
+
Given step "seam.naive" is prompted with "Your previous attempt was rejected"
|
|
63
|
+
And judge step "seam.judge" answers:
|
|
64
|
+
"""
|
|
65
|
+
[{"id":"shape","answer":"reject","p":0.9}]
|
|
66
|
+
"""
|
|
67
|
+
When the process is driven
|
|
68
|
+
Then the process rests at "seam.steer"
|
|
69
|
+
And the resting step's content contains "Overrule"
|
|
70
|
+
And the resting step's content contains "src/calc.ts"
|
|
71
|
+
|
|
72
|
+
Given a file ".gtd/REQUIREMENTS.md" with:
|
|
73
|
+
"""
|
|
74
|
+
# Requirements
|
|
75
|
+
|
|
76
|
+
## Concerns
|
|
77
|
+
|
|
78
|
+
### Subtract
|
|
79
|
+
|
|
80
|
+
- `subtract(a, b)` returns `a - b`, in the calculator module.
|
|
81
|
+
"""
|
|
82
|
+
And step "seam.naive" from now on changes nothing
|
|
83
|
+
And step "seam.naive" writes "src/calc.ts" with:
|
|
84
|
+
"""
|
|
85
|
+
export const add = (a: number, b: number) => a + b
|
|
86
|
+
export const subtract = (a: number, b: number) => a - b
|
|
87
|
+
"""
|
|
88
|
+
And judge step "seam.judge" answers:
|
|
89
|
+
"""
|
|
90
|
+
[{"id":"shape","answer":"land","p":0.9}]
|
|
91
|
+
"""
|
|
92
|
+
And step "implement.building" writes "src/calc.ts" with:
|
|
93
|
+
"""
|
|
94
|
+
export const add = (a: number, b: number) => a + b
|
|
95
|
+
export const subtract = (a: number, b: number) => a - b
|
|
96
|
+
export const SUBTRACT = true
|
|
97
|
+
"""
|
|
98
|
+
When gtd lands
|
|
99
|
+
And the process is driven
|
|
100
|
+
Then the process rests at "build.review.await-review"
|
|
101
|
+
|
|
102
|
+
Given a file ".gtd/REVIEW.md" with:
|
|
103
|
+
"""
|
|
104
|
+
# Review: abc1234
|
|
105
|
+
|
|
106
|
+
<!-- base: 0000000 -->
|
|
107
|
+
|
|
108
|
+
## calc
|
|
109
|
+
|
|
110
|
+
- [x] ./src/calc.ts#1-2
|
|
111
|
+
subtract added
|
|
112
|
+
"""
|
|
113
|
+
When gtd lands
|
|
114
|
+
And the process is driven
|
|
115
|
+
Then the process rests at "idle"
|
|
116
|
+
And the commit subjects end with:
|
|
117
|
+
"""
|
|
118
|
+
gtd(human): idle → unwind
|
|
119
|
+
gtd(check): unwind → start-gate.check
|
|
120
|
+
gtd(check): start-gate.check → design.triage
|
|
121
|
+
gtd(agent): design.triage → design.gate.answer
|
|
122
|
+
gtd(human): design.gate.answer → seam.naive
|
|
123
|
+
gtd(agent): seam.naive → seam.judge
|
|
124
|
+
gtd(judge): seam.judge → seam.naive
|
|
125
|
+
gtd(agent): seam.naive → seam.judge
|
|
126
|
+
gtd(judge): seam.judge → seam.naive
|
|
127
|
+
gtd(agent): seam.naive → seam.judge
|
|
128
|
+
gtd(judge): seam.judge → seam.steer
|
|
129
|
+
gtd(human): seam.steer → seam.naive
|
|
130
|
+
gtd(agent): seam.naive → seam.judge
|
|
131
|
+
gtd(judge): seam.judge → implement.building
|
|
132
|
+
gtd(agent): implement.building → implement.health.check
|
|
133
|
+
gtd(check): implement.health.check → implement.closing
|
|
134
|
+
gtd(check): implement.closing → build.review.reviewing
|
|
135
|
+
gtd(agent): build.review.reviewing → build.review.await-review
|
|
136
|
+
gtd(human): build.review.await-review → build.review.closing
|
|
137
|
+
gtd(check): build.review.closing → idle
|
|
138
|
+
"""
|
|
139
|
+
And the commit "gtd(human): idle → unwind" has a body containing "Gtd-Var: flag_naive_pass=1"
|
|
140
|
+
And the commit "gtd(human): idle → unwind" has a body containing "Gtd-Var: flag_risk_tier=0"
|
|
141
|
+
And the commit "gtd(agent): seam.naive → seam.judge" has a body containing "Gtd-Kept:"
|
|
142
|
+
And the commit "gtd(judge): seam.judge → seam.naive" has a body containing "\"answer\":\"reject\""
|
|
143
|
+
And the commit "gtd(judge): seam.judge → seam.steer" has a body containing "\"answer\":\"reject\""
|
|
144
|
+
And the commit "gtd(judge): seam.judge → implement.building" has a body containing "\"answer\":\"land\""
|
|
145
|
+
And no commit body contains "Gtd-Approved:"
|
|
146
|
+
And no commit body contains "Gtd-Workflow:"
|
|
@@ -166,73 +166,6 @@ Feature: the naive pass and seam judge replace architecture behind GTD_FLAG_NAIV
|
|
|
166
166
|
And the resting step's content contains "src/calc.ts"
|
|
167
167
|
And the resting step's content does not contain "src/calc*.ts"
|
|
168
168
|
|
|
169
|
-
Scenario: a refactor-first judgment rests at the approval gate, then refactors, then implements
|
|
170
|
-
Given step "seam.naive" writes "src/calc.ts" with:
|
|
171
|
-
"""
|
|
172
|
-
export const add = (a: number, b: number) => a + b
|
|
173
|
-
export const subtract = (a: number, b: number) => a - b
|
|
174
|
-
"""
|
|
175
|
-
And judge step "seam.judge" answers:
|
|
176
|
-
"""
|
|
177
|
-
[{"id":"shape","answer":"refactor-first","p":0.9}]
|
|
178
|
-
"""
|
|
179
|
-
And step "refactor.deriving" writes ".gtd/REFACTOR.md" with:
|
|
180
|
-
"""
|
|
181
|
-
# Behaviour-neutral refactor
|
|
182
|
-
|
|
183
|
-
## Scope (read-only)
|
|
184
|
-
|
|
185
|
-
- `src/calc.ts` # gtd-path-exempt: scenario fixture, not a repo file
|
|
186
|
-
"""
|
|
187
|
-
And step "refactor.building" writes "src/calc.ts" with:
|
|
188
|
-
"""
|
|
189
|
-
export const add = (a: number, b: number) => a + b
|
|
190
|
-
export const op = (a: number, b: number, sign: number) => a + sign * b
|
|
191
|
-
"""
|
|
192
|
-
And step "refactor.health.check" changes nothing
|
|
193
|
-
And step "refactor.closing" runs its script
|
|
194
|
-
When the process is driven
|
|
195
|
-
Then the process rests at "seam.approve"
|
|
196
|
-
And the resting step's content contains "src/calc.ts"
|
|
197
|
-
When gtd lands
|
|
198
|
-
Then the last commit subject is "gtd(human): seam.approve → refactor.deriving"
|
|
199
|
-
And the last commit body contains "Gtd-Approved:"
|
|
200
|
-
When the process is driven
|
|
201
|
-
Then the process rests at "implement.building"
|
|
202
|
-
And the commit subjects end with:
|
|
203
|
-
"""
|
|
204
|
-
gtd(judge): seam.judge → seam.approve
|
|
205
|
-
gtd(human): seam.approve → refactor.deriving
|
|
206
|
-
gtd(check): refactor.deriving → refactor.building
|
|
207
|
-
gtd(agent): refactor.building → refactor.health.check
|
|
208
|
-
gtd(check): refactor.health.check → refactor.closing
|
|
209
|
-
gtd(check): refactor.closing → implement.building
|
|
210
|
-
"""
|
|
211
|
-
|
|
212
|
-
Scenario: three rejects retry the naive pass twice, then rest at the steer gate
|
|
213
|
-
Given step "seam.naive" writes "src/calc.ts" with:
|
|
214
|
-
"""
|
|
215
|
-
export const add = (a: number, b: number) => b + a
|
|
216
|
-
"""
|
|
217
|
-
And judge step "seam.judge" answers:
|
|
218
|
-
"""
|
|
219
|
-
[{"id":"shape","answer":"reject","p":0.9}]
|
|
220
|
-
"""
|
|
221
|
-
When the process is driven
|
|
222
|
-
Then the process rests at "seam.steer"
|
|
223
|
-
And the resting step's content contains "Overrule"
|
|
224
|
-
And the resting step's content contains "src/calc.ts"
|
|
225
|
-
And the commit subjects end with:
|
|
226
|
-
"""
|
|
227
|
-
gtd(human): design.gate.answer → seam.naive
|
|
228
|
-
gtd(agent): seam.naive → seam.judge
|
|
229
|
-
gtd(judge): seam.judge → seam.naive
|
|
230
|
-
gtd(agent): seam.naive → seam.judge
|
|
231
|
-
gtd(judge): seam.judge → seam.naive
|
|
232
|
-
gtd(agent): seam.naive → seam.judge
|
|
233
|
-
gtd(judge): seam.judge → seam.steer
|
|
234
|
-
"""
|
|
235
|
-
|
|
236
169
|
Scenario: an edit at the steer gate runs the naive pass again with a fresh retry budget
|
|
237
170
|
Given step "seam.naive" writes "src/calc.ts" with:
|
|
238
171
|
"""
|
|
@@ -16,6 +16,7 @@ import { coder, FEEDBACK, REQUIREMENTS } from "../shared/steps.js"
|
|
|
16
16
|
import * as t from "../shared/text.js"
|
|
17
17
|
import { defaults } from "../shared/vars.js"
|
|
18
18
|
import { refactorFirst } from "./refactor.js"
|
|
19
|
+
import type { RiskTier } from "./tier.js"
|
|
19
20
|
|
|
20
21
|
/** Naive-pass retries after a `reject`; the next `reject` rests at `seam.steer`. */
|
|
21
22
|
const NAIVE_RETRIES = 2
|
|
@@ -39,9 +40,9 @@ export const naivePass = (rejection?: t.SeamRejection): Promise<void> =>
|
|
|
39
40
|
system: t.builderSystem(),
|
|
40
41
|
})
|
|
41
42
|
|
|
42
|
-
export const implement = (reference: string): Promise<void> =>
|
|
43
|
+
export const implement = (reference: string, tier?: RiskTier): Promise<void> =>
|
|
43
44
|
scope("implement", async () => {
|
|
44
|
-
await t.agentWithSkills("building", t.implementBuildingPrompt(reference), {
|
|
45
|
+
await t.agentWithSkills("building", t.implementBuildingPrompt(reference, tier), {
|
|
45
46
|
label: "Implementing",
|
|
46
47
|
model: coder(),
|
|
47
48
|
system: t.builderSystem(),
|
|
@@ -94,7 +95,7 @@ const decide = (): Promise<Decision> =>
|
|
|
94
95
|
}
|
|
95
96
|
})
|
|
96
97
|
|
|
97
|
-
export const seamBuild = async (): Promise<void> => {
|
|
98
|
+
export const seamBuild = async (tier?: RiskTier): Promise<void> => {
|
|
98
99
|
const { reference, shape } = await decide()
|
|
99
100
|
if (shape !== undefined && !(await refactorFirst(shape, { reference }))) {
|
|
100
101
|
// Unreadable strays were never judged, and no refactor runs: a person reads the diff.
|
|
@@ -106,5 +107,5 @@ export const seamBuild = async (): Promise<void> => {
|
|
|
106
107
|
}),
|
|
107
108
|
)
|
|
108
109
|
}
|
|
109
|
-
await implement(reference)
|
|
110
|
+
await implement(reference, tier)
|
|
110
111
|
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { human, run } from "../../flows/index.js"
|
|
2
2
|
import * as t from "../shared/text.js"
|
|
3
|
+
import { riskTierOn } from "./tier.js"
|
|
3
4
|
import { ARCHITECTURE, coder, FEEDBACK, planner, REQUIREMENTS, REVIEW } from "../shared/steps.js"
|
|
4
5
|
|
|
5
6
|
/** Turn the sketch since `base` into `.gtd/REQUIREMENTS.md`. */
|
|
6
7
|
export const triage = (base: string): Promise<void> =>
|
|
7
|
-
t.agentWithSkills("triage", t.designTriagePrompt(base), {
|
|
8
|
+
t.agentWithSkills("triage", t.designTriagePrompt(base, riskTierOn()), {
|
|
8
9
|
label: "Triaging the change",
|
|
9
10
|
file: REQUIREMENTS,
|
|
10
11
|
mode: "qa",
|
|
@@ -30,7 +31,7 @@ export const decompose = (): Promise<void> =>
|
|
|
30
31
|
|
|
31
32
|
export const answerProductQuestions = (): Promise<void> =>
|
|
32
33
|
human("gate.answer", {
|
|
33
|
-
message: t.designGateAnswerMessage(),
|
|
34
|
+
message: t.designGateAnswerMessage(riskTierOn()),
|
|
34
35
|
label: "Awaiting your product answers",
|
|
35
36
|
file: REQUIREMENTS,
|
|
36
37
|
mode: "qa",
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest"
|
|
2
|
+
import { installContext, type FlowContext } from "../../flows/index.js"
|
|
3
|
+
import { fixtureContext } from "../shared/text.fixture.js"
|
|
4
|
+
import { productionSignals, readRiskTier, recordRisk, riskTierOn } from "./tier.js"
|
|
5
|
+
|
|
6
|
+
afterEach(() => installContext(undefined))
|
|
7
|
+
|
|
8
|
+
const withVars = (vars: Record<string, string>) => installContext(fixtureContext({ vars }))
|
|
9
|
+
|
|
10
|
+
describe("riskTierOn", () => {
|
|
11
|
+
it.each([
|
|
12
|
+
[{ flag_naive_pass: "1", flag_risk_tier: "1" }, true],
|
|
13
|
+
[{ flag_naive_pass: "1", flag_risk_tier: "0" }, false],
|
|
14
|
+
[{ flag_naive_pass: "0", flag_risk_tier: "1" }, false],
|
|
15
|
+
[{ flag_naive_pass: "0", flag_risk_tier: "0" }, false],
|
|
16
|
+
[{ flag_naive_pass: "1" }, false],
|
|
17
|
+
])("%j is %s", (vars, expected) => {
|
|
18
|
+
withVars(vars)
|
|
19
|
+
expect(riskTierOn()).toBe(expected)
|
|
20
|
+
})
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
describe("readRiskTier", () => {
|
|
24
|
+
const doc = (body: string) =>
|
|
25
|
+
`# Requirements\n\n## Risk Tier\n\n${body}\n\n## Answered Questions\n`
|
|
26
|
+
|
|
27
|
+
it.each(["low", "`low`", "**Low** — trivial copy", "LOW - copy", "_low_ — x"])(
|
|
28
|
+
"reads %j as low",
|
|
29
|
+
(body) => expect(readRiskTier(doc(body))).toBe("low"),
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
it.each(["high", "medium", "lower", "", " \n\n"])("reads %j as high", (body) =>
|
|
33
|
+
expect(readRiskTier(doc(body))).toBe("high"),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
it("reads a missing section and undefined as high", () => {
|
|
37
|
+
expect(readRiskTier("# Requirements\n\n## Other\n\nlow\n")).toBe("high")
|
|
38
|
+
expect(readRiskTier(undefined)).toBe("high")
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it("takes the first non-blank line only and stops at the next section", () => {
|
|
42
|
+
expect(readRiskTier("## Risk Tier\n\nhigh — why\nlow\n")).toBe("high")
|
|
43
|
+
expect(readRiskTier("## Risk Tier\n\n## Other\n\nlow\n")).toBe("high")
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe("readRiskTier fences", () => {
|
|
48
|
+
it("ignores a backtick-fenced example above the real section", () => {
|
|
49
|
+
const text = "```\n## Risk Tier\n\nlow\n```\n\n## Risk Tier\n\nhigh\n"
|
|
50
|
+
expect(readRiskTier(text)).toBe("high")
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it("reads a fenced example with no real section as high", () => {
|
|
54
|
+
expect(readRiskTier("```md\n## Risk Tier\n\nlow\n```\n")).toBe("high")
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it("keeps the signals section open across a fence and skips fenced items", () => {
|
|
58
|
+
const signals = "## Production Signals\n- before\n```\n# x\n- fake\n```\n- after\n"
|
|
59
|
+
expect(productionSignals(signals)).toEqual(["before", "after"])
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it("ignores a tilde-fenced example above the real section", () => {
|
|
63
|
+
const text = "~~~\n## Risk Tier\n\nlow\n~~~\n\n## Risk Tier\n\nhigh\n"
|
|
64
|
+
expect(readRiskTier(text)).toBe("high")
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it("does not read a fence opener's info string as the tier", () => {
|
|
68
|
+
expect(readRiskTier("## Risk Tier\n\n```low\nhigh\n```\n")).toBe("high")
|
|
69
|
+
expect(readRiskTier("## Risk Tier\n\n~~~low\nlow\n~~~\n")).toBe("high")
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it("fails closed on an unclosed fence before the real section", () => {
|
|
73
|
+
expect(readRiskTier("```\nstray\n\n## Risk Tier\n\nlow\n")).toBe("high")
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it("does not close a 4-backtick fence with 3 backticks", () => {
|
|
77
|
+
const text = "````\n```\n## Risk Tier\n\nlow\n````\n"
|
|
78
|
+
expect(readRiskTier(text)).toBe("high")
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
describe("readRiskTier fence closers", () => {
|
|
83
|
+
it.each([
|
|
84
|
+
["a tilde line does not close a backtick fence", "```\n~~~\n## Risk Tier\n\nlow\n```\n"],
|
|
85
|
+
["a closer with an info string does not close", "```\n```js\n## Risk Tier\n\nlow\n```\n"],
|
|
86
|
+
])("%s", (_, text) => {
|
|
87
|
+
expect(readRiskTier(text)).toBe("high")
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
describe("productionSignals", () => {
|
|
92
|
+
it("lists top-level items only, skipping blanks and indented lines", () => {
|
|
93
|
+
const text = `## Production Signals
|
|
94
|
+
|
|
95
|
+
- error rate on /pay
|
|
96
|
+
not a signal
|
|
97
|
+
- nested detail
|
|
98
|
+
-
|
|
99
|
+
- spaced out\t signal
|
|
100
|
+
* star item
|
|
101
|
+
|
|
102
|
+
## Answered Questions
|
|
103
|
+
|
|
104
|
+
- not a signal
|
|
105
|
+
`
|
|
106
|
+
expect(productionSignals(text)).toEqual([
|
|
107
|
+
"error rate on /pay",
|
|
108
|
+
"spaced out signal",
|
|
109
|
+
"star item",
|
|
110
|
+
])
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it("skips a fenced Production Signals example", () => {
|
|
114
|
+
const text = "```\n## Production Signals\n- fake\n```\n\n## Production Signals\n- real\n"
|
|
115
|
+
expect(productionSignals(text)).toEqual(["real"])
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it("drops control characters", () => {
|
|
119
|
+
expect(productionSignals("## Production Signals\n- a\u001bb\u0085c\n")).toEqual(["abc"])
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it("is empty for a missing section or undefined", () => {
|
|
123
|
+
expect(productionSignals("## Risk Tier\nlow\n")).toEqual([])
|
|
124
|
+
expect(productionSignals(undefined)).toEqual([])
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
describe("recordRisk", () => {
|
|
129
|
+
const record = (requirements: string | undefined) => {
|
|
130
|
+
const marks: [string, string][] = []
|
|
131
|
+
installContext({
|
|
132
|
+
...fixtureContext({ read: () => requirements }),
|
|
133
|
+
mark: (name: string, value: string) => marks.push([name, value]),
|
|
134
|
+
} as unknown as FlowContext)
|
|
135
|
+
return { tier: recordRisk(), marks }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
it("marks the tier first, then one production-signal each", () => {
|
|
139
|
+
const { tier, marks } = record(
|
|
140
|
+
"## Risk Tier\nhigh — payments\n\n## Production Signals\n- a\n- b\n",
|
|
141
|
+
)
|
|
142
|
+
expect(tier).toBe("high")
|
|
143
|
+
expect(marks).toEqual([
|
|
144
|
+
["risk-tier", "high"],
|
|
145
|
+
["production-signal", "a"],
|
|
146
|
+
["production-signal", "b"],
|
|
147
|
+
])
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it("marks risk-tier=low too", () => {
|
|
151
|
+
const { tier, marks } = record("## Risk Tier\nlow — copy\n\n## Production Signals\n- a\n")
|
|
152
|
+
expect(tier).toBe("low")
|
|
153
|
+
expect(marks).toEqual([
|
|
154
|
+
["risk-tier", "low"],
|
|
155
|
+
["production-signal", "a"],
|
|
156
|
+
])
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it("treats a missing file as high", () => {
|
|
160
|
+
const { tier, marks } = record(undefined)
|
|
161
|
+
expect(tier).toBe("high")
|
|
162
|
+
expect(marks).toEqual([["risk-tier", "high"]])
|
|
163
|
+
})
|
|
164
|
+
})
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { mark, read, vars } from "../../flows/index.js"
|
|
2
|
+
import { REQUIREMENTS } from "../shared/steps.js"
|
|
3
|
+
|
|
4
|
+
export type RiskTier = "low" | "high"
|
|
5
|
+
|
|
6
|
+
/** GTD_FLAG_NAIVE_PASS, GTD_FLAG_RISK_TIER: both pinned at process start, so a mid-process flip never moves a process. */
|
|
7
|
+
export const riskTierOn = (): boolean => vars.flag_naive_pass === "1" && vars.flag_risk_tier === "1"
|
|
8
|
+
|
|
9
|
+
// An unclosed fence runs to end of file, so the tier reads `high` (fail-closed). A backtick
|
|
10
|
+
// opener with a backtick in its info string still counts as a fence: deviates from CommonMark,
|
|
11
|
+
// worst case is `high`.
|
|
12
|
+
const markFenced = (text: string): readonly { line: string; fenced: boolean }[] => {
|
|
13
|
+
let fence: string | undefined // the opener run, e.g. "````"
|
|
14
|
+
return text.split(/\r?\n/).map((line) => {
|
|
15
|
+
const run = /^ {0,3}(`{3,}|~{3,})/.exec(line)?.[1]
|
|
16
|
+
const fenced = fence !== undefined || run !== undefined
|
|
17
|
+
if (fence === undefined) fence = run
|
|
18
|
+
else if (
|
|
19
|
+
run !== undefined &&
|
|
20
|
+
run[0] === fence[0] &&
|
|
21
|
+
run.length >= fence.length &&
|
|
22
|
+
/^ {0,3}(`+|~+)[ \t]*$/.test(line)
|
|
23
|
+
)
|
|
24
|
+
fence = undefined
|
|
25
|
+
return { line, fenced }
|
|
26
|
+
})
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const sectionLines = (
|
|
30
|
+
text: string | undefined,
|
|
31
|
+
title: string,
|
|
32
|
+
): readonly { line: string; fenced: boolean }[] => {
|
|
33
|
+
if (text === undefined) return []
|
|
34
|
+
const heading = new RegExp(`^##[ \\t]+${title}[ \\t]*$`, "i")
|
|
35
|
+
const lines = markFenced(text)
|
|
36
|
+
const start = lines.findIndex((l) => !l.fenced && heading.test(l.line))
|
|
37
|
+
if (start < 0) return []
|
|
38
|
+
const rest = lines.slice(start + 1)
|
|
39
|
+
const end = rest.findIndex((l) => !l.fenced && /^#{1,2}[ \t]/.test(l.line))
|
|
40
|
+
return end < 0 ? rest : rest.slice(0, end)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Anything but a legible `low` is `high`: under-tiering is the risky direction. */
|
|
44
|
+
export const readRiskTier = (requirements: string | undefined): RiskTier => {
|
|
45
|
+
const first = sectionLines(requirements, "Risk Tier").find(
|
|
46
|
+
(l) => !l.fenced && l.line.trim() !== "",
|
|
47
|
+
)?.line
|
|
48
|
+
const word = first?.trim().split(/\s+/)[0]?.replace(/[`*_]/g, "").toLowerCase()
|
|
49
|
+
return word === "low" ? "low" : "high"
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// eslint-disable-next-line no-control-regex -- the control range IS the point
|
|
53
|
+
const CONTROL = /[\x00-\x1f\x7f-\x9f\u2028\u2029]/g
|
|
54
|
+
|
|
55
|
+
const tidy = (text: string): string => text.replace(/\s+/g, " ").replace(CONTROL, "").trim()
|
|
56
|
+
|
|
57
|
+
export const productionSignals = (requirements: string | undefined): readonly string[] => {
|
|
58
|
+
const items: string[] = []
|
|
59
|
+
for (const { line, fenced } of sectionLines(requirements, "Production Signals")) {
|
|
60
|
+
if (fenced) continue
|
|
61
|
+
const top = /^[-*+][ \t]+(.*)$/.exec(line)
|
|
62
|
+
if (top !== null) items.push(top[1]!)
|
|
63
|
+
}
|
|
64
|
+
return items.map(tidy).filter((item) => item !== "")
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The tier is always marked, first, so the last `risk-tier` mark opens the current lap's marks. */
|
|
68
|
+
export const recordRisk = (): RiskTier => {
|
|
69
|
+
const requirements = read(REQUIREMENTS)
|
|
70
|
+
const tier = readRiskTier(requirements)
|
|
71
|
+
mark("risk-tier", tier)
|
|
72
|
+
for (const signal of productionSignals(requirements)) mark("production-signal", signal)
|
|
73
|
+
return tier
|
|
74
|
+
}
|
|
@@ -10,7 +10,7 @@ describe("the shipped workflow factories", () => {
|
|
|
10
10
|
it("take exactly the settings the shipped workflows declare as options", () => {
|
|
11
11
|
expect(Object.keys(ShippedOptions.fields).sort()).toEqual(
|
|
12
12
|
[...Object.keys(defaults), ...Object.keys(envDefaults)]
|
|
13
|
-
.filter((name) => name !== "flag_naive_pass")
|
|
13
|
+
.filter((name) => name !== "flag_naive_pass" && name !== "flag_risk_tier")
|
|
14
14
|
.sort(),
|
|
15
15
|
)
|
|
16
16
|
})
|
|
@@ -24,6 +24,11 @@ describe("the shipped workflow factories", () => {
|
|
|
24
24
|
})
|
|
25
25
|
})
|
|
26
26
|
|
|
27
|
+
it("declare flag_risk_tier off by default; it is no factory option", () => {
|
|
28
|
+
expect(defaults.flag_risk_tier).toBe("0")
|
|
29
|
+
expect(Object.keys(ShippedOptions.fields)).not.toContain("flag_risk_tier")
|
|
30
|
+
})
|
|
31
|
+
|
|
27
32
|
it("lay each option over the shipped setting of its own kind", () => {
|
|
28
33
|
const result = configure(feature({ qualityReviews: "correctness", testCommand: "npm run ci" }))
|
|
29
34
|
expect(result).toMatchObject({
|