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.
Files changed (33) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/DESIGN-CRITERIA-REGISTRY.md +238 -0
  3. package/cli/examples/pr-test-review.yml +21 -1
  4. package/cli/lib/build-prompt.js +25 -0
  5. package/cli/lib/changed-tests.js +68 -4
  6. package/cli/lib/parse-report.js +83 -5
  7. package/cli/test-review.js +39 -1
  8. package/docs/explanation/testing-as-engineering.md +1 -1
  9. package/package.json +2 -1
  10. package/src/workflows/testarch/bmad-testarch-test-review/checklist.md +5 -0
  11. package/src/workflows/testarch/bmad-testarch-test-review/steps-c/criteria-registry.md +159 -0
  12. package/src/workflows/testarch/bmad-testarch-test-review/steps-c/step-02-discover-tests.md +93 -0
  13. package/src/workflows/testarch/bmad-testarch-test-review/steps-c/step-03-quality-evaluation.md +15 -0
  14. package/src/workflows/testarch/bmad-testarch-test-review/steps-c/step-03a-subagent-determinism.md +122 -44
  15. package/src/workflows/testarch/bmad-testarch-test-review/steps-c/step-03b-subagent-isolation.md +39 -22
  16. package/src/workflows/testarch/bmad-testarch-test-review/steps-c/step-03c-subagent-maintainability.md +56 -33
  17. package/src/workflows/testarch/bmad-testarch-test-review/steps-c/step-03e-subagent-performance.md +62 -34
  18. package/src/workflows/testarch/bmad-testarch-test-review/steps-c/step-03f-aggregate-scores.md +109 -6
  19. package/src/workflows/testarch/bmad-testarch-test-review/test-review-template.md +48 -15
  20. package/test/eval-test-review.js +474 -0
  21. package/test/fixtures/test-review-cli/reports/approve-low-score.md +10 -9
  22. package/test/fixtures/test-review-cli/reports/approve-with-high.md +53 -0
  23. package/test/fixtures/test-review-cli/reports/approve.md +7 -6
  24. package/test/fixtures/test-review-cli/reports/colon-in-bold.md +5 -5
  25. package/test/fixtures/test-review-cli/reports/lowercase.md +2 -2
  26. package/test/fixtures/test-review-cli/reports/plain-bullets-key-strengths.md +3 -3
  27. package/test/fixtures/test-review-cli/reports/score-mismatch.md +3 -3
  28. package/test/fixtures/test-review-cli/reports/wrapped-steps-flow.md +3 -3
  29. package/test/fixtures/test-review-eval/clean/profile.spec.ts +36 -0
  30. package/test/fixtures/test-review-eval/ground-truth.json +60 -0
  31. package/test/fixtures/test-review-eval/seeded/checkout.spec.ts +42 -0
  32. package/test/fixtures/test-review-eval/seeded/orders.service.spec.ts +40 -0
  33. 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.4",
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>",
@@ -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
@@ -127,7 +127,14 @@ function splitGitPathList(stdout) {
127
127
  return stdout.split('\0').filter(Boolean);
128
128
  }
129
129
 
130
- const UNSAFE_PATH_MARKERS = ['---BEGIN FILES---', '---END FILES---', '---BEGIN CONTEXT---', '---END CONTEXT---'];
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 and not noise, documentation
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 candidates = changedFiles.filter((file) => !isTestFile(file) && !isContextNoise(file));
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,
@@ -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: executive,
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 };