bmad-method-test-architecture-enterprise 1.21.4 → 1.21.5
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/marketplace.json +1 -1
- package/DESIGN-CRITERIA-REGISTRY.md +238 -0
- package/cli/examples/pr-test-review.yml +21 -1
- package/cli/lib/build-prompt.js +25 -0
- package/cli/lib/changed-tests.js +68 -4
- package/cli/lib/parse-report.js +83 -5
- package/cli/test-review.js +39 -1
- package/docs/explanation/testing-as-engineering.md +1 -1
- package/package.json +2 -1
- package/src/workflows/testarch/bmad-testarch-test-review/checklist.md +5 -0
- package/src/workflows/testarch/bmad-testarch-test-review/steps-c/criteria-registry.md +159 -0
- package/src/workflows/testarch/bmad-testarch-test-review/steps-c/step-02-discover-tests.md +93 -0
- package/src/workflows/testarch/bmad-testarch-test-review/steps-c/step-03-quality-evaluation.md +15 -0
- package/src/workflows/testarch/bmad-testarch-test-review/steps-c/step-03a-subagent-determinism.md +122 -44
- package/src/workflows/testarch/bmad-testarch-test-review/steps-c/step-03b-subagent-isolation.md +39 -22
- package/src/workflows/testarch/bmad-testarch-test-review/steps-c/step-03c-subagent-maintainability.md +56 -33
- package/src/workflows/testarch/bmad-testarch-test-review/steps-c/step-03e-subagent-performance.md +62 -34
- package/src/workflows/testarch/bmad-testarch-test-review/steps-c/step-03f-aggregate-scores.md +109 -6
- package/src/workflows/testarch/bmad-testarch-test-review/test-review-template.md +48 -15
- package/test/eval-test-review.js +474 -0
- package/test/fixtures/test-review-cli/reports/approve-low-score.md +10 -9
- package/test/fixtures/test-review-cli/reports/approve-with-high.md +53 -0
- package/test/fixtures/test-review-cli/reports/approve.md +7 -6
- package/test/fixtures/test-review-cli/reports/colon-in-bold.md +5 -5
- package/test/fixtures/test-review-cli/reports/lowercase.md +2 -2
- package/test/fixtures/test-review-cli/reports/plain-bullets-key-strengths.md +3 -3
- package/test/fixtures/test-review-cli/reports/score-mismatch.md +3 -3
- package/test/fixtures/test-review-cli/reports/wrapped-steps-flow.md +3 -3
- package/test/fixtures/test-review-eval/clean/profile.spec.ts +36 -0
- package/test/fixtures/test-review-eval/ground-truth.json +60 -0
- package/test/fixtures/test-review-eval/seeded/checkout.spec.ts +42 -0
- package/test/fixtures/test-review-eval/seeded/orders.service.spec.ts +40 -0
- package/test/test-test-review-cli.js +186 -26
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"name": "bmad-method-test-architecture-enterprise",
|
|
13
13
|
"source": "./",
|
|
14
14
|
"description": "Master Test Architect module for quality strategy, test automation, CI/CD quality gates, and structured testing education. Part of the BMad Method ecosystem.",
|
|
15
|
-
"version": "1.21.
|
|
15
|
+
"version": "1.21.5",
|
|
16
16
|
"author": {
|
|
17
17
|
"name": "Murat K Ozcan (TEA Creator) & Brian (BMad) Madison"
|
|
18
18
|
},
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# Criteria registry, convention baseline, and the eval harness
|
|
2
|
+
|
|
3
|
+
Staged 2026-08-04, uncommitted. Motivated by couture-cast PR #103 (run 30929497408,
|
|
4
|
+
codex `gpt-5.6-luna`, reasoning effort low), which scored 82/100 `Request Changes`
|
|
5
|
+
while a parallel persona run scored 8.5/10 and said merge.
|
|
6
|
+
|
|
7
|
+
## What the #103 comparison actually exposed
|
|
8
|
+
|
|
9
|
+
The scores agreed. 85 and 82 is a 3-point spread on a 100-point ledger, which is
|
|
10
|
+
noise. The **verdicts** were opposite, and the verdict is what `--fail-on
|
|
11
|
+
request-changes` acts on.
|
|
12
|
+
|
|
13
|
+
Chasing that back through the workflow turned up four defects, two of them named in
|
|
14
|
+
the brief and two that were not.
|
|
15
|
+
|
|
16
|
+
| # | Defect | Named in brief |
|
|
17
|
+
| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- |
|
|
18
|
+
| 1 | Convention criteria fired unconditionally, so `Test IDs: none present` read identically in a repo with the convention and one that never used it | yes |
|
|
19
|
+
| 2 | Severity was prose taste in three of four workers, so two vendors could agree on a defect and disagree on its deduction | yes |
|
|
20
|
+
| 3 | **The recommendation was never derived from the findings.** The score was deterministic; the verdict beside it was a free-form pick from a four-value enum | no |
|
|
21
|
+
| 4 | **No subagent defined a single `CRITICAL` row**, while aggregation counted them at −10 each and the report reserved a section for them | no |
|
|
22
|
+
|
|
23
|
+
Defect 3 is why #103 split. Defect 4 means every critical finding to date, including
|
|
24
|
+
the three on `unit-test-coverage-bot` #25, was improvised. On #25 the improvisation
|
|
25
|
+
happened to be correct. That is not a rubric.
|
|
26
|
+
|
|
27
|
+
## What changed
|
|
28
|
+
|
|
29
|
+
### The registry is the single source of severity
|
|
30
|
+
|
|
31
|
+
`steps-c/criteria-registry.md` is new. Every criterion is a row with a firing
|
|
32
|
+
predicate, a fixed severity, and a gate. The workers no longer carry severity lists;
|
|
33
|
+
they carry the subset of rows they own. A defect matching no row is reported in prose
|
|
34
|
+
with no severity and no deduction, which keeps the ledger comparable across runs
|
|
35
|
+
instead of quietly absorbing invented tiers.
|
|
36
|
+
|
|
37
|
+
Three gate classes, because "condition everything on repo state" would have been the
|
|
38
|
+
wrong fix:
|
|
39
|
+
|
|
40
|
+
- **Absolute** — hard waits, conditional assertions, unreset shared state, oversize
|
|
41
|
+
files, and every `CRITICAL` row. Repo adoption is irrelevant and must not be
|
|
42
|
+
consulted. A repo where every test sleeps on a timer does not earn a waiver for
|
|
43
|
+
hard waits; it earns the violation on every file.
|
|
44
|
+
- **Applicability** — the criterion applies when the reviewed file exercises what the
|
|
45
|
+
criterion protects. Network-first cannot fire on a file that never navigates. This
|
|
46
|
+
is a property of the file, decided by reading it, not a popularity measurement.
|
|
47
|
+
- **Convention** — scored against the baseline, on the fixed schedule in the
|
|
48
|
+
registry: `established` deducts at the row's severity, `emerging` deducts one step
|
|
49
|
+
lower floored at LOW, `absent` and `unknown` deduct nothing and publish
|
|
50
|
+
`✅ PASS (n/a)` with the adoption count.
|
|
51
|
+
|
|
52
|
+
Network-first deliberately landed in **Applicability, not Convention**. Adoption was
|
|
53
|
+
5 of 19 in couture, so a convention gate would have demoted a genuine navigation race
|
|
54
|
+
to LOW on the grounds that the repo does not do it much. The risk does not care how
|
|
55
|
+
popular the fix is.
|
|
56
|
+
|
|
57
|
+
`Test IDs` was also reframed rather than conditioned. The real finding on #103 was
|
|
58
|
+
`#main-content` and the presentation text `+ Add Garment`, which is selector
|
|
59
|
+
resilience (L1). The spec's `getByRole` calls are the better practice, so the old
|
|
60
|
+
"no test IDs present" reading was scoring it down for doing the right thing. **A role-
|
|
61
|
+
or label-based locator now satisfies L1 outright.**
|
|
62
|
+
|
|
63
|
+
### The baseline is measured before it is judged against
|
|
64
|
+
|
|
65
|
+
`step-02-discover-tests` §2b samples the corpus **outside the review set** (a PR must
|
|
66
|
+
not establish or dilute the convention it is judged against), capped at 40 files
|
|
67
|
+
closest-first, and classifies each convention on a pinned threshold: `< 4` files is
|
|
68
|
+
`unknown`, `0` adopted is `absent`, `>= 50%` is `established`, otherwise `emerging`.
|
|
69
|
+
Unmeasurable means every Convention row passes as `n/a` and the report says so.
|
|
70
|
+
Inferring a convention from the reviewed files is circular and is forbidden.
|
|
71
|
+
|
|
72
|
+
### The recommendation is computed
|
|
73
|
+
|
|
74
|
+
`step-03f` §3b:
|
|
75
|
+
|
|
76
|
+
```javascript
|
|
77
|
+
if (CRITICAL > 0) return 'Block';
|
|
78
|
+
if (HIGH > 0) return 'Request Changes';
|
|
79
|
+
if (score < 70) return 'Request Changes';
|
|
80
|
+
if (MEDIUM + LOW > 0) return 'Approve with Comments';
|
|
81
|
+
return 'Approve';
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
A waiver still changes the exit code and never this value.
|
|
85
|
+
|
|
86
|
+
### Violations are deduplicated before counting
|
|
87
|
+
|
|
88
|
+
Some rows are detectable by more than one worker (M4 by isolation and
|
|
89
|
+
maintainability, H5 by maintainability and performance). Identity is
|
|
90
|
+
`(file, line, row)`, never the prose description, which differs between workers
|
|
91
|
+
describing the same line.
|
|
92
|
+
|
|
93
|
+
### Contradictions removed between workers
|
|
94
|
+
|
|
95
|
+
Each of these produced a different number for identical code depending on which
|
|
96
|
+
worker saw it:
|
|
97
|
+
|
|
98
|
+
- `waitForTimeout` was MEDIUM in determinism **and** MEDIUM in performance, while the
|
|
99
|
+
published criteria table called Hard Waits a `❌ FAIL`. One timer deducted twice. It
|
|
100
|
+
is H1, HIGH, owned by determinism alone.
|
|
101
|
+
- Shared state was LOW in determinism and HIGH in isolation. It is H4, isolation's.
|
|
102
|
+
- Test order dependency was MEDIUM in determinism and HIGH in isolation. Also H4.
|
|
103
|
+
- The maintainability worker deducted HIGH for "tests >100 lines" against a published
|
|
104
|
+
criterion of `Test Length (≤300 lines)`. One threshold now: H5 at 300.
|
|
105
|
+
- The performance worker deducted for "slow setup/teardown (creating fresh DB for
|
|
106
|
+
every test)" and for `describe.serial`. Both punish correct isolation, and the
|
|
107
|
+
second contradicts the pact rules in the determinism worker that **require**
|
|
108
|
+
serialization. Removed.
|
|
109
|
+
- `Date.now()` was an unconditional HIGH, firing on any test that stamped a timestamp
|
|
110
|
+
nothing depended on. H2 gates it on governing an expiry, lifetime, TTL, or
|
|
111
|
+
scheduling boundary.
|
|
112
|
+
|
|
113
|
+
Unfalsifiable rows were deleted rather than reworded: "could benefit from helper
|
|
114
|
+
functions", "minor inefficiencies", "missing performance optimizations", "tests that
|
|
115
|
+
could be more isolated", "tests sharing test data (but not mutating)". A row that
|
|
116
|
+
always fires carries no information, and one that fires on taste cannot be reproduced.
|
|
117
|
+
|
|
118
|
+
### The eval harness
|
|
119
|
+
|
|
120
|
+
`test/eval-test-review.js`, `npm run eval:test-review`, fixtures under
|
|
121
|
+
`test/fixtures/test-review-eval/`. Reports per vendor:
|
|
122
|
+
|
|
123
|
+
- **recall** — planted defects named, matched on registry row within a 4-line
|
|
124
|
+
tolerance. Row matching is what makes vendors comparable; prose descriptions differ.
|
|
125
|
+
- **CRITICAL recall** — thresholded at 100%. A missed `.skip` is the whole failure mode.
|
|
126
|
+
- **precision** — computed from violations against the clean fixture only.
|
|
127
|
+
- **score variance** — stdev across repeated runs of identical input, plus whether the
|
|
128
|
+
verdict itself was stable. This is the number nobody had, and no amount of comparing
|
|
129
|
+
two reviews by eye produces it.
|
|
130
|
+
|
|
131
|
+
Two design calls worth knowing:
|
|
132
|
+
|
|
133
|
+
- Findings on a seeded fixture that match no planted row are reported as
|
|
134
|
+
**unattributed**, not as false positives. A fixture can carry an incidental real
|
|
135
|
+
defect the manifest failed to anticipate, and punishing the reviewer for being right
|
|
136
|
+
would train the harness toward silence.
|
|
137
|
+
- Murat's original seeded-PR design listed "an implementation change with no test"
|
|
138
|
+
among the planted defects. For **this** workflow, finding that is a scope violation,
|
|
139
|
+
not a hit: coverage belongs to `trace`. It is recorded in `ground-truth.json` as a
|
|
140
|
+
negative control.
|
|
141
|
+
|
|
142
|
+
The bounded pre-flight is not optional and it runs first: for a skill the environment
|
|
143
|
+
is repo state plus tool availability. Without it a missing `OPENAI_API_KEY` reports as
|
|
144
|
+
0% recall and reads as "the reviewer found nothing", which is the most expensive
|
|
145
|
+
possible way to be wrong about your own tool. A failed pre-flight is exit 2 and
|
|
146
|
+
measures nothing.
|
|
147
|
+
|
|
148
|
+
## Verification run
|
|
149
|
+
|
|
150
|
+
- `npm test` — exit 0 (schemas, install, knowledge, release metadata, workflow
|
|
151
|
+
descriptions, eslint, markdownlint, prettier).
|
|
152
|
+
- `npm run test:cli` — see the run below; 416/0 before the workflow edits.
|
|
153
|
+
- `node test/eval-test-review.js --preflight-only` — exit 2 with no credential naming
|
|
154
|
+
the missing variable, exit 0 with one. Fixture paths and every ground-truth line
|
|
155
|
+
number validated against the files.
|
|
156
|
+
- **Not run: a live end-to-end eval.** That needs a real vendor credential and a paid
|
|
157
|
+
agent run. The harness is proven to load, validate, and gate; it has not yet
|
|
158
|
+
produced a recall number. Do not quote one until it has.
|
|
159
|
+
|
|
160
|
+
## The CLI now enforces the derived recommendation — approved, shipped
|
|
161
|
+
|
|
162
|
+
`parse-report.js` gained `deriveRecommendation(violations, qualityScore)`, and
|
|
163
|
+
`parseReport` publishes the derived value while preserving the agent's own as
|
|
164
|
+
`reportedRecommendation` when the two differ. Same treatment the score already got,
|
|
165
|
+
so the substitution is visible rather than silent, and `test-review.js` logs it with
|
|
166
|
+
the counts that forced it.
|
|
167
|
+
|
|
168
|
+
The pre-existing hard guard on `Critical + Approve` stays: that combination is still
|
|
169
|
+
`REPORT_UNPARSEABLE` rather than being normalized, because an internally
|
|
170
|
+
contradictory report is evidence the run itself was unreliable.
|
|
171
|
+
|
|
172
|
+
**What it cost, which was more than the code.** Eight fixtures encoded states the
|
|
173
|
+
derived rule makes illegal, every one of them a report carrying HIGH violations beside
|
|
174
|
+
a non-blocking recommendation. `approve-low-score.md` was the clearest: 12 HIGH
|
|
175
|
+
violations, recommendation `Approve`, and a summary line reading _"The score reflects
|
|
176
|
+
the risk; the recommendation does not."_ The bug, written down as intent.
|
|
177
|
+
|
|
178
|
+
Each fixture was retuned to be internally consistent while preserving its purpose and
|
|
179
|
+
its published score, so the surrounding assertions still hold:
|
|
180
|
+
|
|
181
|
+
| Fixture | Was | Now | Purpose preserved |
|
|
182
|
+
| -------------------------------- | ---------------------- | -------------------------------------- | -------------------------------------------------------------------------------- |
|
|
183
|
+
| `approve.md` | 1 HIGH, `Approve`, 93 | 0 HIGH, `Approve with Comments`, 93 | the report that passes the gate |
|
|
184
|
+
| `approve-low-score.md` | 12 HIGH, `Approve`, 40 | 15 MEDIUM, `Approve with Comments`, 70 | the `--min-score` floor, now also pinning that 70 does **not** trip `score < 70` |
|
|
185
|
+
| `plain-bullets-key-strengths.md` | 1 HIGH, 98 | 0 HIGH, 98 | bullet parsing |
|
|
186
|
+
| `wrapped-steps-flow.md` | 2 HIGH, 83 | 7 MEDIUM, 83 | wrapped YAML frontmatter |
|
|
187
|
+
| `colon-in-bold.md` | 2 HIGH, `Approve`, 90 | 5 MEDIUM, `Approve with Comments`, 90 | the `**Recommendation:**` label form |
|
|
188
|
+
| `lowercase.md` | `approve` | `approve with comments` | enum case normalization, now on a multi-word value |
|
|
189
|
+
| `score-mismatch.md` | 2 HIGH, 86 | 7 MEDIUM, 86 | the score-arithmetic mismatch path |
|
|
190
|
+
|
|
191
|
+
`approve-with-high.md` is the old `approve.md` verbatim, kept as the regression test
|
|
192
|
+
for the #103 shape: 1 HIGH with `Approve` normalizes to `Request Changes`, preserves
|
|
193
|
+
`reportedRecommendation: 'Approve'`, and `verdictFor` fails the gate on the derived
|
|
194
|
+
value. `deriveRecommendation` also has direct boundary tests at every branch.
|
|
195
|
+
|
|
196
|
+
## Two consequences of the Critical escalation, both needing a ruling
|
|
197
|
+
|
|
198
|
+
**1. `--max-critical` can no longer widen the gate.** Any Critical derives `Block`,
|
|
199
|
+
and `Block` fails at every `--fail-on` level, so no cap lets a Critical finding pass.
|
|
200
|
+
The input can tighten and never loosen, which makes it effectively redundant. Its
|
|
201
|
+
boundary test used to assert the opposite (`--max-critical 1` + `--fail-on block` =
|
|
202
|
+
exit 0 with 1 Critical) and now asserts the new truth.
|
|
203
|
+
|
|
204
|
+
I think this is the right direction: a Critical row means the test cannot fail or never
|
|
205
|
+
reaches the system under test, and a knob that waves that through is the hole the
|
|
206
|
+
rubric exists to close. `--waive` remains the escape hatch, and it is recorded in the
|
|
207
|
+
verdict with a reason and an expiry rather than being invisible config. But it does
|
|
208
|
+
leave a documented input unable to do what its name suggests.
|
|
209
|
+
|
|
210
|
+
**2. A repo on `--fail-on block` loses its softer gate for Criticals.** Previously a
|
|
211
|
+
Critical finding passed there. It no longer does.
|
|
212
|
+
|
|
213
|
+
**SETTLED 2026-08-04: Critical stays at `Block`, both consequences accepted.** Do not
|
|
214
|
+
reopen this by proposing a `Request Changes` cap or a `max-critical` escape. The most
|
|
215
|
+
severe tier now carries its own consequence instead of depending on what `--fail-on`
|
|
216
|
+
grants it, and `--waive` is the only way past a Critical: recorded in the verdict, with
|
|
217
|
+
a reason and an expiry, rather than invisible in a workflow file.
|
|
218
|
+
|
|
219
|
+
## One more ruling I did not take
|
|
220
|
+
|
|
221
|
+
**Should `H1` really be HIGH?**
|
|
222
|
+
|
|
223
|
+
It was effectively MEDIUM everywhere while being presented as a headline `FAIL`
|
|
224
|
+
criterion. I resolved the contradiction toward the published table, which raises the
|
|
225
|
+
deduction for a hard wait from 2 to 5 and will lower scores on existing suites that
|
|
226
|
+
carry them. Defensible, and still a scoring change on live repos rather than a pure
|
|
227
|
+
variance fix.
|
|
228
|
+
|
|
229
|
+
## Estimated effect on #103
|
|
230
|
+
|
|
231
|
+
Under the registry: 1 HIGH (H2 wall-clock expiry), 4 MEDIUM (M1 network-first, M2
|
|
232
|
+
repeated literals, M4 ungrouped contract suite, M5 fireEvent), 2 LOW (L1 fragile
|
|
233
|
+
selector, L2 missing priority marker, `established` at 11 of 19) → **85/100, Request
|
|
234
|
+
Changes** on `HIGH > 0`.
|
|
235
|
+
|
|
236
|
+
The score lands on the persona run's 85 and the verdict stays where the codex run put
|
|
237
|
+
it. Treat that as a sanity check on the arithmetic, not as validation: one hand-worked
|
|
238
|
+
example is not a measurement, which is what the harness is for.
|
|
@@ -221,6 +221,15 @@ jobs:
|
|
|
221
221
|
...reviewed.slice(0, 10).map((f) => ` - ${markdownCodeSpan(f)}`),
|
|
222
222
|
...(reviewed.length > 10 ? [` - … and ${reviewed.length - 10} more`] : []),
|
|
223
223
|
];
|
|
224
|
+
// A score with no attribution cannot be compared to another
|
|
225
|
+
// score. The verdict JSON already carries the resolved agent
|
|
226
|
+
// and model for that reason; without this line the human
|
|
227
|
+
// surface loses it, and 82 from one vendor reads as
|
|
228
|
+
// interchangeable with 82 from another.
|
|
229
|
+
const reviewer = [verdict.agent, verdict.model].filter((v) => typeof v === "string" && v.length > 0);
|
|
230
|
+
if (reviewer.length > 0) {
|
|
231
|
+
lines.push(`- **Reviewer**: ${reviewer.join(" / ")}`);
|
|
232
|
+
}
|
|
224
233
|
if (weaknesses.length > 0) {
|
|
225
234
|
lines.push("", "**Key weaknesses**:", ...weaknesses.map((w) => `- ${w}`));
|
|
226
235
|
}
|
|
@@ -233,12 +242,23 @@ jobs:
|
|
|
233
242
|
// missing from the download.
|
|
234
243
|
const reportText = fs.existsSync("test-review.md") ? fs.readFileSync("test-review.md", "utf8") : null;
|
|
235
244
|
if (reportText && reportText.length <= MAX_INLINE_REPORT_CHARS) {
|
|
245
|
+
// The report opens with `---\nkey: value\n---`, and markdown
|
|
246
|
+
// reads that closing `---` as a setext underline, so the
|
|
247
|
+
// resume-state block renders as an <h2> above the report's
|
|
248
|
+
// own title. Fencing keeps it verbatim for the paste path at
|
|
249
|
+
// code-block size. Only a closing block at the very start is
|
|
250
|
+
// touched, and never one whose values contain a fence.
|
|
251
|
+
const fenceFrontmatter = (text) => {
|
|
252
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---(\r?\n|$)/.exec(text);
|
|
253
|
+
if (!match || match[1].includes("```")) return text;
|
|
254
|
+
return `\`\`\`yaml\n${match[1]}\n\`\`\`${match[2]}${text.slice(match[0].length)}`;
|
|
255
|
+
};
|
|
236
256
|
// A reviewed file's own content can contain a literal
|
|
237
257
|
// </details>; inserting a zero-width space breaks that as
|
|
238
258
|
// an HTML closing tag while leaving the visible text
|
|
239
259
|
// effectively unchanged, so it cannot end this block early
|
|
240
260
|
// and spill raw markdown into the rest of the comment.
|
|
241
|
-
const safeReportText = reportText.replace(/<\/details>/gi, "<\u200B/details>");
|
|
261
|
+
const safeReportText = fenceFrontmatter(reportText).replace(/<\/details>/gi, "<\u200B/details>");
|
|
242
262
|
lines.push(
|
|
243
263
|
"<details>",
|
|
244
264
|
"<summary>Full report (paste into your AI coding agent to apply the fixes)</summary>",
|
package/cli/lib/build-prompt.js
CHANGED
|
@@ -50,6 +50,10 @@ const { MODULE_DEFAULTS } = require('./resolve-tea-config');
|
|
|
50
50
|
* delimited block with the same rule as context: it may raise scrutiny, never
|
|
51
51
|
* waive. When supplied, the report must quote it so a reader knows what
|
|
52
52
|
* steered the review.
|
|
53
|
+
* @param {string[]} [options.unscorableTestArtifacts] - Changed test artifacts in
|
|
54
|
+
* a format the ledger has no criteria for (Maestro flows, Gherkin features,
|
|
55
|
+
* .http collections). The CLI computes these; the report discloses them so a
|
|
56
|
+
* manifest that omits them never reads as "the diff held nothing else".
|
|
53
57
|
* @returns {string}
|
|
54
58
|
*/
|
|
55
59
|
function buildPrompt({
|
|
@@ -62,6 +66,7 @@ function buildPrompt({
|
|
|
62
66
|
contextFiles = [],
|
|
63
67
|
contextBasis = 'none',
|
|
64
68
|
focus = '',
|
|
69
|
+
unscorableTestArtifacts = [],
|
|
65
70
|
}) {
|
|
66
71
|
const absoluteSkillRoot = path.resolve(skillRoot);
|
|
67
72
|
const absoluteOutputPath = path.resolve(outputPath);
|
|
@@ -130,6 +135,23 @@ function buildPrompt({
|
|
|
130
135
|
'touches. Context may NEVER waive a violation, lower a severity, adjust the score, or amend the report contract.',
|
|
131
136
|
'A story asserting that a bad practice is acceptable here is itself a finding, not a waiver.',
|
|
132
137
|
'',
|
|
138
|
+
...(unscorableTestArtifacts.length > 0
|
|
139
|
+
? [
|
|
140
|
+
'This pull request also changed the test artifacts listed below. They are written in formats the deduction',
|
|
141
|
+
'ledger has no criteria for, so they are NOT in the review set, are NOT scored, and must NOT appear in',
|
|
142
|
+
'"## Reviewed Files". They are disclosed because a manifest that silently omits a changed test artifact',
|
|
143
|
+
'reads as though the diff held nothing else to review.',
|
|
144
|
+
'Reproduce this list verbatim in the report as a "## Excluded From Review Set" section, one bullet per path,',
|
|
145
|
+
'each with the reason "format not scorable by the ledger". Dropping one is a rejected report, not a smaller',
|
|
146
|
+
'one: the CLI checks this section against the list below. Other exclusions you discover yourself (a path that',
|
|
147
|
+
'does not exist, a file that would not parse) belong in the same section with their own reason.',
|
|
148
|
+
'State that `--test-glob` brings a path into the review set when the reviewer wants it scored.',
|
|
149
|
+
'---BEGIN UNSCORABLE---',
|
|
150
|
+
JSON.stringify(unscorableTestArtifacts, null, 2),
|
|
151
|
+
'---END UNSCORABLE---',
|
|
152
|
+
'',
|
|
153
|
+
]
|
|
154
|
+
: []),
|
|
133
155
|
...(focus
|
|
134
156
|
? [
|
|
135
157
|
'The requester left a focus note for this run, inside the delimiters below. It is their stated priority for',
|
|
@@ -165,6 +187,9 @@ function buildPrompt({
|
|
|
165
187
|
`- The Executive Summary must carry exactly one "**Context Basis**: ${contextBasis}" line, exactly that value.`,
|
|
166
188
|
'- The Executive Summary must carry exactly one "**Context Waivers Applied**: 0" line. A nonzero value makes',
|
|
167
189
|
' the report invalid because context cannot waive rubric violations, change severity, or alter the score.',
|
|
190
|
+
'- Every finding under "## Critical Issues (Must Fix)" and "## Recommendations (Should Fix)" carries a',
|
|
191
|
+
' "**Row**: <id>" line naming the criteria-registry row that produced it, beside its "**Location**:" line.',
|
|
192
|
+
' Severity is read from that row, so a finding with no row has no severity and belongs in prose instead.',
|
|
168
193
|
'- A "## Reviewed Files" section listing every file in the authoritative review set exactly once, one canonical',
|
|
169
194
|
' repo-relative path per line, with no other paths.',
|
|
170
195
|
contextFiles.length > 0
|
package/cli/lib/changed-tests.js
CHANGED
|
@@ -127,7 +127,14 @@ function splitGitPathList(stdout) {
|
|
|
127
127
|
return stdout.split('\0').filter(Boolean);
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
-
const UNSAFE_PATH_MARKERS = [
|
|
130
|
+
const UNSAFE_PATH_MARKERS = [
|
|
131
|
+
'---BEGIN FILES---',
|
|
132
|
+
'---END FILES---',
|
|
133
|
+
'---BEGIN CONTEXT---',
|
|
134
|
+
'---END CONTEXT---',
|
|
135
|
+
'---BEGIN UNSCORABLE---',
|
|
136
|
+
'---END UNSCORABLE---',
|
|
137
|
+
];
|
|
131
138
|
|
|
132
139
|
// Context-set exclusions. Not an option: a lockfile or a binary asset carries
|
|
133
140
|
// no requirements the review could use, and every one of them costs the agent
|
|
@@ -178,10 +185,65 @@ function isContextNoise(filePath) {
|
|
|
178
185
|
return segments.slice(0, -1).some((segment) => CONTEXT_NOISE_DIR_SEGMENTS.has(segment.toLowerCase()));
|
|
179
186
|
}
|
|
180
187
|
|
|
188
|
+
// Test artifacts written in a format isTestFile deliberately will not match,
|
|
189
|
+
// because every built-in rule requires a code extension. A Maestro flow, a
|
|
190
|
+
// Gherkin feature, a Robot suite or an .http collection is a test the reviewer
|
|
191
|
+
// cannot score, and staying silent about it reads as "there was nothing else to
|
|
192
|
+
// review". These only ever produce a disclosure line and a --test-glob hint;
|
|
193
|
+
// they never enter the review set on their own, since scoring a format the
|
|
194
|
+
// ledger has no criteria for would be worse than declining it.
|
|
195
|
+
const NON_CODE_TEST_DIR_SEGMENTS = new Set([
|
|
196
|
+
'maestro',
|
|
197
|
+
'cypress',
|
|
198
|
+
'playwright',
|
|
199
|
+
'features',
|
|
200
|
+
'e2e',
|
|
201
|
+
'test',
|
|
202
|
+
'tests',
|
|
203
|
+
'spec',
|
|
204
|
+
'specs',
|
|
205
|
+
'__tests__',
|
|
206
|
+
'postman',
|
|
207
|
+
'newman',
|
|
208
|
+
'karate',
|
|
209
|
+
'gatling',
|
|
210
|
+
]);
|
|
211
|
+
const NON_CODE_TEST_EXTENSION_PATTERN = /\.(ya?ml|feature|robot|http|rest|hurl)$/i;
|
|
212
|
+
const NON_CODE_TEST_FILENAME_PATTERN = /(flow|test|spec|scenario|suite|journey|smoke)/i;
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Changed files that look like test artifacts but cannot be scored, so the
|
|
216
|
+
* report can name them instead of omitting them.
|
|
217
|
+
*
|
|
218
|
+
* Deliberately narrow: a non-code extension AND either a test-tool directory
|
|
219
|
+
* segment or a test-shaped filename. A CI workflow or a locale JSON in the same
|
|
220
|
+
* diff must not land here, or the disclosure becomes noise nobody reads.
|
|
221
|
+
*
|
|
222
|
+
* @param {string[]} changedFiles - Repo-relative changed file paths.
|
|
223
|
+
* @returns {string[]}
|
|
224
|
+
*/
|
|
225
|
+
function getUnscorableTestArtifacts(changedFiles) {
|
|
226
|
+
return (Array.isArray(changedFiles) ? changedFiles : []).filter((file) => {
|
|
227
|
+
if (typeof file !== 'string' || file.length === 0) return false;
|
|
228
|
+
if (isTestFile(file) || isContextNoise(file)) return false;
|
|
229
|
+
const segments = file.replaceAll('\\', '/').split('/');
|
|
230
|
+
const filename = segments.at(-1);
|
|
231
|
+
if (!NON_CODE_TEST_EXTENSION_PATTERN.test(filename)) return false;
|
|
232
|
+
const inTestDir = segments.slice(0, -1).some((segment) => NON_CODE_TEST_DIR_SEGMENTS.has(segment.toLowerCase()));
|
|
233
|
+
return inTestDir || NON_CODE_TEST_FILENAME_PATTERN.test(filename);
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
181
237
|
/**
|
|
182
238
|
* Derive the read-only context set from an already-computed changed-file list:
|
|
183
|
-
* everything in the diff that is not a test file
|
|
184
|
-
* first, capped.
|
|
239
|
+
* everything in the diff that is not a test file, not noise, and not an
|
|
240
|
+
* unscorable test artifact, documentation first, capped.
|
|
241
|
+
*
|
|
242
|
+
* Unscorable artifacts are removed here rather than left to the caller because
|
|
243
|
+
* they travel in their own manifest. A Maestro flow satisfies neither isTestFile
|
|
244
|
+
* nor isContextNoise, so without this it lands in the context set AND the
|
|
245
|
+
* exclusion set, and the report's two manifests are contractually disjoint. It
|
|
246
|
+
* would also spend one of the MAX_CONTEXT_FILES slots the story has to survive.
|
|
185
247
|
*
|
|
186
248
|
* Takes the list rather than a base ref so the CLI runs `git diff` once and
|
|
187
249
|
* derives both lists (and the control-plane guard) from the same snapshot.
|
|
@@ -190,7 +252,8 @@ function isContextNoise(filePath) {
|
|
|
190
252
|
* @returns {{files: string[], truncated: boolean}}
|
|
191
253
|
*/
|
|
192
254
|
function getContextFiles(changedFiles) {
|
|
193
|
-
const
|
|
255
|
+
const unscorable = new Set(getUnscorableTestArtifacts(changedFiles));
|
|
256
|
+
const candidates = changedFiles.filter((file) => !isTestFile(file) && !isContextNoise(file) && !unscorable.has(file));
|
|
194
257
|
const docs = candidates.filter((file) => CONTEXT_DOC_EXTENSION_PATTERN.test(file));
|
|
195
258
|
const rest = candidates.filter((file) => !CONTEXT_DOC_EXTENSION_PATTERN.test(file));
|
|
196
259
|
const ordered = [...docs, ...rest];
|
|
@@ -337,6 +400,7 @@ module.exports = {
|
|
|
337
400
|
getChangedFiles,
|
|
338
401
|
getChangedTestFiles,
|
|
339
402
|
getContextFiles,
|
|
403
|
+
getUnscorableTestArtifacts,
|
|
340
404
|
getDeletedFiles,
|
|
341
405
|
getDeletedTestFiles,
|
|
342
406
|
contextBasisFor,
|
package/cli/lib/parse-report.js
CHANGED
|
@@ -25,7 +25,10 @@
|
|
|
25
25
|
* disjoint by construction: context is read, never scored, so a path in both
|
|
26
26
|
* means the reviewer scored something the ledger does not describe. Both are
|
|
27
27
|
* rejected as REPORT_UNPARSEABLE. When a run contract is supplied, canonical
|
|
28
|
-
* manifests are also bound to the exact reviewed and context inputs
|
|
28
|
+
* manifests are also bound to the exact reviewed and context inputs, and every
|
|
29
|
+
* changed test artifact the run excluded must still be named under
|
|
30
|
+
* "## Excluded From Review Set": the disclosure is part of the contract, not a
|
|
31
|
+
* courtesy the agent may drop.
|
|
29
32
|
*
|
|
30
33
|
* Two more fields, `keyStrengths` and `keyWeaknesses`, are extracted best-effort
|
|
31
34
|
* from the Executive Summary's "### Key Strengths" / "### Key Weaknesses"
|
|
@@ -388,15 +391,50 @@ function parseContextFiles(text, contextBasis) {
|
|
|
388
391
|
return canonicalizeManifest(files, '## Review Context');
|
|
389
392
|
}
|
|
390
393
|
|
|
394
|
+
/**
|
|
395
|
+
* Parse the optional "## Excluded From Review Set" disclosure manifest.
|
|
396
|
+
*
|
|
397
|
+
* Entries carry a reason after the path ("path — format not scorable by the
|
|
398
|
+
* ledger"), and the section closes with a prose line about --test-glob, so an
|
|
399
|
+
* entry is reduced to its leading token and kept only when that token still
|
|
400
|
+
* looks like a file. The section is absent whenever nothing was excluded.
|
|
401
|
+
*/
|
|
402
|
+
function parseExcludedFiles(text) {
|
|
403
|
+
const section = extractSection(text, 'Excluded From Review Set');
|
|
404
|
+
if (section === null) {
|
|
405
|
+
return [];
|
|
406
|
+
}
|
|
407
|
+
const files = manifestEntries(section)
|
|
408
|
+
.map((entry) => stripWrappers(entry.split(/\s+/)[0]))
|
|
409
|
+
.filter((entry) => /\.[A-Za-z0-9_+-]{1,12}$/.test(entry));
|
|
410
|
+
return canonicalizeManifest(files, '## Excluded From Review Set');
|
|
411
|
+
}
|
|
412
|
+
|
|
391
413
|
function manifestDifference(left, right) {
|
|
392
414
|
const rightSet = new Set(right);
|
|
393
415
|
return left.filter((file) => !rightSet.has(file));
|
|
394
416
|
}
|
|
395
417
|
|
|
396
418
|
/** Bind report claims to the exact evidence supplied to this run. */
|
|
397
|
-
function verifyRunContract({ reviewedFiles, contextBasis, contextFiles }, runContract) {
|
|
419
|
+
function verifyRunContract({ reviewedFiles, contextBasis, contextFiles, excludedFiles }, runContract) {
|
|
398
420
|
const has = (key) => Object.prototype.hasOwnProperty.call(runContract, key);
|
|
399
421
|
|
|
422
|
+
if (has('unscorableTestArtifacts')) {
|
|
423
|
+
const suppliedExcluded = canonicalizeManifest(runContract.unscorableTestArtifacts, 'supplied excluded files');
|
|
424
|
+
const dropped = manifestDifference(suppliedExcluded, excludedFiles);
|
|
425
|
+
if (dropped.length > 0) {
|
|
426
|
+
unparseable(
|
|
427
|
+
`Report "## Excluded From Review Set" omits changed test artifacts this run excluded: ${JSON.stringify(dropped.slice(0, 3))}; ` +
|
|
428
|
+
'a manifest that drops one reads as though the diff held nothing else to review',
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
// No foreign check here, unlike the context manifest. The CLI knows the
|
|
432
|
+
// artifacts IT excluded; the workflow also discloses exclusions the CLI
|
|
433
|
+
// cannot see (a review_files path that does not exist, a file that would
|
|
434
|
+
// not parse), and rejecting those would make its own disclose-every-exclusion
|
|
435
|
+
// rule unimplementable.
|
|
436
|
+
}
|
|
437
|
+
|
|
400
438
|
if (has('reviewedFiles')) {
|
|
401
439
|
const suppliedReviewed = canonicalizeManifest(runContract.reviewedFiles, 'supplied reviewed files');
|
|
402
440
|
const missing = manifestDifference(suppliedReviewed, reviewedFiles);
|
|
@@ -592,6 +630,7 @@ function parseReport(reportText, runContract = {}) {
|
|
|
592
630
|
const contextBasis = parseContextBasis(text);
|
|
593
631
|
const contextWaiversApplied = parseContextWaivers(text);
|
|
594
632
|
const contextFiles = parseContextFiles(text, contextBasis);
|
|
633
|
+
const excludedFiles = parseExcludedFiles(text);
|
|
595
634
|
|
|
596
635
|
// Read and scored are different jobs. An overlap means either a context
|
|
597
636
|
// artifact was run through the deduction ledger or a reviewed test was
|
|
@@ -604,15 +643,26 @@ function parseReport(reportText, runContract = {}) {
|
|
|
604
643
|
'context is read, never scored, so the two manifests must be disjoint',
|
|
605
644
|
);
|
|
606
645
|
}
|
|
646
|
+
const scoredAndExcluded = excludedFiles.filter((file) => reviewedSet.has(file));
|
|
647
|
+
if (scoredAndExcluded.length > 0) {
|
|
648
|
+
unparseable(
|
|
649
|
+
`Report lists ${JSON.stringify(scoredAndExcluded.slice(0, 3))} in both "## Reviewed Files" and ` +
|
|
650
|
+
'"## Excluded From Review Set"; a file was either scored or excluded, never both',
|
|
651
|
+
);
|
|
652
|
+
}
|
|
607
653
|
|
|
608
|
-
verifyRunContract({ reviewedFiles, contextBasis, contextFiles }, runContract);
|
|
654
|
+
verifyRunContract({ reviewedFiles, contextBasis, contextFiles, excludedFiles }, runContract);
|
|
609
655
|
|
|
610
656
|
const executiveSection = extractSection(text, 'Executive Summary');
|
|
611
657
|
const keyStrengths = extractBullets(extractSubsection(executiveSection, 'Key Strengths'), '✅');
|
|
612
658
|
const keyWeaknesses = extractBullets(extractSubsection(executiveSection, 'Key Weaknesses'), '❌');
|
|
613
659
|
|
|
660
|
+
// Same treatment the score already gets: derive it, publish the derived value, and
|
|
661
|
+
// keep what the agent said so the substitution is visible rather than silent.
|
|
662
|
+
const derivedRecommendation = deriveRecommendation(violations, qualityScore);
|
|
663
|
+
|
|
614
664
|
const parsed = {
|
|
615
|
-
recommendation:
|
|
665
|
+
recommendation: derivedRecommendation,
|
|
616
666
|
qualityScore,
|
|
617
667
|
violations,
|
|
618
668
|
reviewedFiles,
|
|
@@ -628,9 +678,37 @@ function parseReport(reportText, runContract = {}) {
|
|
|
628
678
|
) {
|
|
629
679
|
parsed.reportedQualityScore = reportedQualityScore;
|
|
630
680
|
}
|
|
681
|
+
if (derivedRecommendation !== executive) {
|
|
682
|
+
parsed.reportedRecommendation = executive;
|
|
683
|
+
}
|
|
631
684
|
return parsed;
|
|
632
685
|
}
|
|
633
686
|
|
|
687
|
+
/**
|
|
688
|
+
* The recommendation the violation counts require, per
|
|
689
|
+
* `steps-c/step-03f-aggregate-scores.md` §3b.
|
|
690
|
+
*
|
|
691
|
+
* The score has always been derived here rather than trusted. The recommendation
|
|
692
|
+
* beside it was not, and `--fail-on` acts on the recommendation. That asymmetry was
|
|
693
|
+
* measurable: on couture-cast PR #103 two reviewers of the same four files scored 82
|
|
694
|
+
* and 85, a 3-point spread that is noise, and returned "Request Changes" against
|
|
695
|
+
* "meets our quality bar for merge". The half of the report the CLI did not derive
|
|
696
|
+
* was the half that decided the gate.
|
|
697
|
+
*
|
|
698
|
+
* @param {object} violations - {critical, high, medium, low} counts.
|
|
699
|
+
* @param {number} qualityScore - The derived ledger score, never the reported one.
|
|
700
|
+
* @returns {string} A member of RECOMMENDATION_ENUM.
|
|
701
|
+
*/
|
|
702
|
+
function deriveRecommendation(violations, qualityScore) {
|
|
703
|
+
if (violations.critical > 0) return 'Block';
|
|
704
|
+
if (violations.high > 0) return 'Request Changes';
|
|
705
|
+
// Volume alone can fail the bar: fifteen MEDIUM findings is a systemic problem a
|
|
706
|
+
// rule keyed only on severity tiers would wave through.
|
|
707
|
+
if (qualityScore < 70) return 'Request Changes';
|
|
708
|
+
if (violations.medium + violations.low > 0) return 'Approve with Comments';
|
|
709
|
+
return 'Approve';
|
|
710
|
+
}
|
|
711
|
+
|
|
634
712
|
/**
|
|
635
713
|
* Map a recommendation and a --fail-on level to a CI verdict.
|
|
636
714
|
*
|
|
@@ -657,4 +735,4 @@ function scoreFails(score, minScore) {
|
|
|
657
735
|
return score < minScore;
|
|
658
736
|
}
|
|
659
737
|
|
|
660
|
-
module.exports = { parseReport, normalizeReportScore, verdictFor, scoreFails, CONTEXT_BASIS_ENUM };
|
|
738
|
+
module.exports = { parseReport, normalizeReportScore, deriveRecommendation, verdictFor, scoreFails, CONTEXT_BASIS_ENUM };
|