mandrel 2.21.0 → 2.22.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/.agents/README.md +1 -1
- package/.agents/agents/story-worker.md +5 -0
- package/.agents/instructions.md +14 -17
- package/.agents/rules/git-conventions.md +1 -1
- package/.agents/rules/known-tooling-behavior.md +114 -0
- package/.agents/scripts/check-context-budget.js +134 -2
- package/.agents/scripts/lib/audit-suite/selector.js +275 -162
- package/.agents/scripts/lib/config/temp-paths.js +51 -7
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +604 -57
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +72 -21
- package/.agents/scripts/lib/label-constants.js +12 -1
- package/.agents/scripts/lib/observability/runtime-friction.js +13 -1
- package/.agents/scripts/lib/observability/signals-writer.js +133 -14
- package/.agents/scripts/lib/observability/source-classifier.js +131 -1
- package/.agents/scripts/lib/orchestration/code-review.js +12 -0
- package/.agents/scripts/lib/orchestration/complexity-gate.js +51 -46
- package/.agents/scripts/lib/orchestration/resolve-stories.js +17 -14
- package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
- package/.agents/scripts/lib/orchestration/review-providers/degraded-gates.js +222 -0
- package/.agents/scripts/lib/orchestration/review-providers/findings-renderer.js +18 -3
- package/.agents/scripts/lib/orchestration/review-providers/native.js +82 -126
- package/.agents/scripts/lib/orchestration/review-providers/review-provider-factory.js +10 -0
- package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +300 -0
- package/.agents/scripts/lib/orchestration/run-epilogue.js +51 -1
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +18 -8
- package/.agents/scripts/lib/orchestration/single-story-close/phases/review-outcome.js +66 -0
- package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +5 -1
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +305 -10
- package/.agents/scripts/lib/story-body/story-body.js +248 -174
- package/.agents/scripts/resolve-stories.js +52 -33
- package/.agents/scripts/single-story-confirm-merge.js +5 -7
- package/.agents/workflows/helpers/deliver-digest.md +8 -6
- package/.agents/workflows/helpers/deliver-reference.md +15 -12
- package/.agents/workflows/helpers/deliver-story-reference.md +23 -21
- package/.agents/workflows/helpers/deliver-story.md +2 -2
- package/.agents/workflows/helpers/plan-reference.md +5 -4
- package/docs/CHANGELOG.md +20 -0
- package/package.json +1 -1
|
@@ -31,15 +31,16 @@
|
|
|
31
31
|
* cardinality is deliberately not an axis (Story #4764).
|
|
32
32
|
* 4. **Deliver re-derives.** `/deliver` computes the route from the fetched
|
|
33
33
|
* Story body via the **same** shape function at dispatch
|
|
34
|
-
* ({@link resolveStoryDispatchMode}) and
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
34
|
+
* ({@link resolveStoryDispatchMode}) and **reports** it, while the
|
|
35
|
+
* dispatch *mode* answers a different question: may the engine run in the
|
|
36
|
+
* router's own session? Only a **single-Story run** may (Story #4736) —
|
|
37
|
+
* sub-agent isolation buys nothing when there is no concurrent sibling to
|
|
38
|
+
* isolate from. Shape cannot grant that session (Story #4829): a lite body
|
|
39
|
+
* makes work cheap, it does not conjure a second session for a sibling to
|
|
40
|
+
* run in. The `route::lite` label is a **human-visible hint only**, never
|
|
41
|
+
* the control signal: a lost label or an unread marker can no longer
|
|
42
|
+
* misroute delivery. Either way every `single-story-close.js` gate runs
|
|
43
|
+
* unchanged.
|
|
43
44
|
*
|
|
44
45
|
* The shape taxonomy is deliberately the one `review-depth.js` already
|
|
45
46
|
* applies to the landed diff at close (`deriveChangeLevel` over the
|
|
@@ -864,27 +865,34 @@ function routeForReporting(body, opts) {
|
|
|
864
865
|
/**
|
|
865
866
|
* Decide how `/deliver` executes a Story.
|
|
866
867
|
*
|
|
867
|
-
*
|
|
868
|
-
*
|
|
869
|
-
*
|
|
870
|
-
*
|
|
871
|
-
*
|
|
872
|
-
*
|
|
873
|
-
*
|
|
874
|
-
*
|
|
875
|
-
*
|
|
876
|
-
*
|
|
877
|
-
*
|
|
878
|
-
*
|
|
879
|
-
*
|
|
880
|
-
*
|
|
881
|
-
*
|
|
882
|
-
*
|
|
883
|
-
*
|
|
884
|
-
*
|
|
885
|
-
*
|
|
886
|
-
*
|
|
887
|
-
*
|
|
868
|
+
* **`inline` names one indivisible resource: the router's own session.** Two
|
|
869
|
+
* Stories cannot both own it, so exactly one premise can grant it —
|
|
870
|
+
* **run topology (Story #4736)**: a run resolving a *single* Story executes
|
|
871
|
+
* inline whatever its shape, because sub-agent isolation is load-bearing only
|
|
872
|
+
* for CONCURRENT dispatch (two workers sharing a checkout race on worktrees and
|
|
873
|
+
* branch refs) and a one-Story run has no sibling to race. It therefore pays
|
|
874
|
+
* the spawn premium (a boot is a cache WRITE at full rate, where an inline
|
|
875
|
+
* continuation is a cache read at ~10%; ~$1.43/M vs ~$1.07/M on comparable
|
|
876
|
+
* bench work) for nothing. That is a fact about the run, not about the work, so
|
|
877
|
+
* the shape gate's `enabled` switch — which governs *shape derivation* — does
|
|
878
|
+
* not reach it.
|
|
879
|
+
*
|
|
880
|
+
* **Shape cannot grant it (Story #4829).** The shape read used to return
|
|
881
|
+
* `inline` for any lite-shaped body in a multi-Story run, inheriting no
|
|
882
|
+
* topology guard. Measured twice on 2026-07-29: a two-Story and a three-Story
|
|
883
|
+
* run came back `inline` for *every* Story while `stories-wave-tick.js`
|
|
884
|
+
* reported the whole set ready under a concurrency cap of five — a router
|
|
885
|
+
* following both signals literally runs several engines over one session and
|
|
886
|
+
* one checkout, the precise hazard the sub-agent path exists to prevent. Both
|
|
887
|
+
* runs were completed only by an operator overriding the verdict by hand, which
|
|
888
|
+
* is an invariant held by judgment rather than by code. So this function has
|
|
889
|
+
* exactly **one** `inline` exit, guarded by the topology premise; every path
|
|
890
|
+
* below it returns `subagent`, and the derived shape is carried on `route` for
|
|
891
|
+
* reporting only. A lite shape makes the work cheap — it does not conjure a
|
|
892
|
+
* second session for a sibling to run in.
|
|
893
|
+
*
|
|
894
|
+
* The label is read only to report hint consistency in `reasons`; it never
|
|
895
|
+
* routes on either premise (Story #4722 AC-4/AC-5).
|
|
888
896
|
*
|
|
889
897
|
* Inline execution removes model-side fan-out only — it changes **where** the
|
|
890
898
|
* engine runs, never **what** runs. Every deterministic
|
|
@@ -901,7 +909,8 @@ function routeForReporting(body, opts) {
|
|
|
901
909
|
* selectSensitivePathClassesFn?: Function,
|
|
902
910
|
* }} [args] `storyCount` is the number of Stories the invoking `/deliver` run
|
|
903
911
|
* resolved. Omitted (or not a positive integer) means "unknown run size",
|
|
904
|
-
* which
|
|
912
|
+
* which cannot be shown sibling-free and therefore dispatches as a sub-agent
|
|
913
|
+
* — never an assumed 1.
|
|
905
914
|
* @returns {{ mode: 'inline'|'subagent', reasons: string[], route: ReturnType<typeof deriveStoryShape>|null }}
|
|
906
915
|
*/
|
|
907
916
|
export function resolveStoryDispatchMode({
|
|
@@ -920,6 +929,8 @@ export function resolveStoryDispatchMode({
|
|
|
920
929
|
? `the ${LITE_ROUTE_LABEL} label is present (hint only — the derived shape is the control signal)`
|
|
921
930
|
: `the ${LITE_ROUTE_LABEL} label is absent (hint only — the derived shape is the control signal)`;
|
|
922
931
|
|
|
932
|
+
// The ONLY `inline` exit in this function, and the guard is the whole
|
|
933
|
+
// contract: an inline verdict must mean the engine can actually run inline.
|
|
923
934
|
if (storyCount === 1) {
|
|
924
935
|
return {
|
|
925
936
|
mode: 'inline',
|
|
@@ -956,23 +967,17 @@ export function resolveStoryDispatchMode({
|
|
|
956
967
|
};
|
|
957
968
|
}
|
|
958
969
|
|
|
970
|
+
// Past the topology guard a sibling may be dispatched concurrently, so the
|
|
971
|
+
// router's session is not available to anyone. The shape is still derived and
|
|
972
|
+
// returned on `route` — ceremony and reporting read it — but it decides
|
|
973
|
+
// nothing here: both shapes dispatch as a sub-agent.
|
|
959
974
|
const route = deriveStoryRouteFromBody(body, {
|
|
960
975
|
injectedRules,
|
|
961
976
|
selectSensitivePathClassesFn,
|
|
962
977
|
});
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
hintNote,
|
|
969
|
-
],
|
|
970
|
-
route,
|
|
971
|
-
};
|
|
972
|
-
}
|
|
973
|
-
return {
|
|
974
|
-
mode: 'subagent',
|
|
975
|
-
reasons: [`full-shaped Story — ${route.reasons[0]}`, hintNote],
|
|
976
|
-
route,
|
|
977
|
-
};
|
|
978
|
+
const shapeNote =
|
|
979
|
+
route.route === 'lite'
|
|
980
|
+
? `lite-shaped Story in a multi-Story run — the shape is inline-eligible but the router's session is not: a concurrent sibling would have to share it, racing worktrees and branch refs; sub-agent dispatch (${route.reasons[0]})`
|
|
981
|
+
: `full-shaped Story — ${route.reasons[0]}`;
|
|
982
|
+
return { mode: 'subagent', reasons: [shapeNote, hintNote], route };
|
|
978
983
|
}
|
|
@@ -317,21 +317,24 @@ export function buildStoriesEnvelope({
|
|
|
317
317
|
const inSetDone = sorted.filter(isSatisfiedBlocker).map((s) => s.id);
|
|
318
318
|
return {
|
|
319
319
|
kind: 'stories',
|
|
320
|
-
// `dispatchMode` (Story #4722): the resolver
|
|
321
|
-
// execution mode
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
// `route::lite` label is a human-visible hint only, never the control
|
|
326
|
-
// signal: a lost label cannot misroute delivery. Model-side fan-out
|
|
327
|
-
// only; close gates are untouched.
|
|
320
|
+
// `dispatchMode` (Story #4722, #4736, #4829): the resolver reports the
|
|
321
|
+
// per-Story execution mode so `/deliver` reads one field — `inline` (run
|
|
322
|
+
// deliver-story in the router's own session: no story-worker /
|
|
323
|
+
// acceptance-critic sub-agent boots) or `subagent` (the conservative
|
|
324
|
+
// default). Model-side fan-out only; close gates are untouched.
|
|
328
325
|
//
|
|
329
|
-
// `storyCount`
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
//
|
|
326
|
+
// `storyCount` is the premise that decides it, and it is this call site's
|
|
327
|
+
// load-bearing argument: `inline` names the router's ONE session, so it is
|
|
328
|
+
// granted only to a run resolving exactly ONE Story, which has no
|
|
329
|
+
// concurrent sibling to share that session with. Passing the resolved set
|
|
330
|
+
// size here is therefore what makes the envelope self-consistent with the
|
|
331
|
+
// ready set `stories-wave-tick.js` computes from the same `dag`: a set of
|
|
332
|
+
// more than one can never come back with a Story claiming the session
|
|
333
|
+
// (Story #4829 — it previously could, whenever the body was lite-shaped).
|
|
334
|
+
// It is the resolved set size, NOT the undelivered remainder, so the mode
|
|
335
|
+
// a caller reads for a given `--ids` list never changes as siblings land
|
|
336
|
+
// mid-run. The `route::lite` label is a human-visible hint only, never the
|
|
337
|
+
// control signal.
|
|
335
338
|
stories: sorted.map(({ id, title, body, url, labels, state }) => ({
|
|
336
339
|
id,
|
|
337
340
|
title,
|
|
Binary file
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* review-providers/degraded-gates.js — the review's "a gate did not run"
|
|
3
|
+
* channel (Story #4839).
|
|
4
|
+
*
|
|
5
|
+
* ## The defect this closes
|
|
6
|
+
*
|
|
7
|
+
* Story #4699 correctly decided that a tool which **could not execute** is an
|
|
8
|
+
* operational degradation, not a code finding: it is routed to friction
|
|
9
|
+
* telemetry so severity tiers keep reflecting code findings only. What #4699
|
|
10
|
+
* did not add was any *other* channel, so the review's own verdict became
|
|
11
|
+
* unable to distinguish "lint ran and found nothing" from "lint never ran" —
|
|
12
|
+
* both rendered `✅ No findings` with an all-zero severity tally, and the close
|
|
13
|
+
* pipeline read the second as the first. A gate that reports success when it
|
|
14
|
+
* did not run is worse than no gate, because it is trusted.
|
|
15
|
+
*
|
|
16
|
+
* This module is that missing channel. A degradation travels **beside** the
|
|
17
|
+
* `Finding[]`, never inside it:
|
|
18
|
+
*
|
|
19
|
+
* - it never becomes a `Finding`, so `countBySeverity` is untouched and no
|
|
20
|
+
* execution failure can appear as a critical / high / medium (or even a
|
|
21
|
+
* suggestion) — #4699's intent survives intact;
|
|
22
|
+
* - it is rendered as its own section in the structured comment, and it
|
|
23
|
+
* suppresses the false `✅ No findings` claim;
|
|
24
|
+
* - it is carried on the `runCodeReview` envelope as `degraded` /
|
|
25
|
+
* `degradations[]`, so the close pipeline sees it too.
|
|
26
|
+
*
|
|
27
|
+
* ## Report, not block — and why
|
|
28
|
+
*
|
|
29
|
+
* A degraded gate is reported loudly and does **not** halt the close. The close
|
|
30
|
+
* pipeline already runs the canonical `npm run lint` as a hard
|
|
31
|
+
* close-validation gate *before* the review phase; the review's scoped lint is
|
|
32
|
+
* a second, narrower read of the same surface. Failing a merge because a
|
|
33
|
+
* *secondary* read of an already-gated surface could not start would block
|
|
34
|
+
* delivery on an operational condition the hard gate has already covered. What
|
|
35
|
+
* was actually broken was the silence, so the fix is to make the silence
|
|
36
|
+
* impossible: every surface that reads the review outcome now states the
|
|
37
|
+
* degradation explicitly. Escalating to a block is a one-line change here if
|
|
38
|
+
* the operator posture ever needs it.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @typedef {object} GateDegradation
|
|
43
|
+
* @property {string} tool Emitter (e.g. `native-review-lint`).
|
|
44
|
+
* @property {string} gate Gate that degraded (e.g. `scoped-lint`).
|
|
45
|
+
* @property {string} surface Sub-surface that could not run (e.g. `markdownlint`).
|
|
46
|
+
* @property {string} reason Machine-readable reason code.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Pure: keep only well-formed degradation records. A misbehaving provider must
|
|
51
|
+
* not be able to corrupt the rendered comment or the envelope.
|
|
52
|
+
*
|
|
53
|
+
* @param {unknown} input
|
|
54
|
+
* @returns {GateDegradation[]}
|
|
55
|
+
*/
|
|
56
|
+
export function normalizeDegradations(input) {
|
|
57
|
+
if (!Array.isArray(input)) return [];
|
|
58
|
+
const out = [];
|
|
59
|
+
for (const d of input) {
|
|
60
|
+
if (!d || typeof d !== 'object') continue;
|
|
61
|
+
const surface = typeof d.surface === 'string' ? d.surface : null;
|
|
62
|
+
const reason = typeof d.reason === 'string' ? d.reason : null;
|
|
63
|
+
if (surface === null || reason === null) continue;
|
|
64
|
+
out.push({
|
|
65
|
+
tool: typeof d.tool === 'string' ? d.tool : 'unknown',
|
|
66
|
+
gate: typeof d.gate === 'string' ? d.gate : 'unknown',
|
|
67
|
+
surface,
|
|
68
|
+
reason,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Feature-detect a provider's degradation channel, mirroring how
|
|
76
|
+
* `getPromptMessages` is feature-detected. Providers predating this contract
|
|
77
|
+
* carry no `getDegradations`, so the empty array keeps their output byte-stable;
|
|
78
|
+
* a throw degrades to empty (observability must never fail a review).
|
|
79
|
+
*
|
|
80
|
+
* MUST be called **after** `runReview` — a provider records its degradations
|
|
81
|
+
* during the run.
|
|
82
|
+
*
|
|
83
|
+
* @param {{ getDegradations?: Function }} reviewProvider
|
|
84
|
+
* @param {{ warn?: Function }} [logger]
|
|
85
|
+
* @returns {Promise<GateDegradation[]>}
|
|
86
|
+
*/
|
|
87
|
+
export async function collectProviderDegradations(reviewProvider, logger) {
|
|
88
|
+
if (typeof reviewProvider?.getDegradations !== 'function') return [];
|
|
89
|
+
try {
|
|
90
|
+
return normalizeDegradations(await reviewProvider.getDegradations());
|
|
91
|
+
} catch (err) {
|
|
92
|
+
logger?.warn?.(
|
|
93
|
+
`[code-review] getDegradations threw; treating as none. ${
|
|
94
|
+
err?.message ?? err
|
|
95
|
+
}`,
|
|
96
|
+
);
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Pure: one-line operator-facing summary of the degraded gates, for progress
|
|
103
|
+
* output and comment tallies. Empty input renders `none` so the field is always
|
|
104
|
+
* present — a missing degradation line must never be read as "no degradation".
|
|
105
|
+
*
|
|
106
|
+
* @param {ReadonlyArray<GateDegradation>} degradations
|
|
107
|
+
* @returns {string}
|
|
108
|
+
*/
|
|
109
|
+
export function summarizeDegradations(degradations) {
|
|
110
|
+
const rows = normalizeDegradations(degradations);
|
|
111
|
+
if (rows.length === 0) return 'none';
|
|
112
|
+
return rows.map((d) => `${d.gate}/${d.surface} (${d.reason})`).join(', ');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Pure: the `{ degraded, degradations }` pair every outcome envelope carries, so
|
|
117
|
+
* a caller adds the channel by spreading one helper rather than restating the
|
|
118
|
+
* derivation (and cannot ship `degradations` without `degraded`).
|
|
119
|
+
*
|
|
120
|
+
* @param {unknown} degradations
|
|
121
|
+
* @returns {{ degraded: boolean, degradations: GateDegradation[] }}
|
|
122
|
+
*/
|
|
123
|
+
export function degradationEnvelope(degradations) {
|
|
124
|
+
const rows = normalizeDegradations(degradations);
|
|
125
|
+
return { degraded: rows.length > 0, degradations: rows };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Merge the degraded-gate records of every inline chain entry that carries the
|
|
130
|
+
* channel. A provider predating the contract contributes nothing; a throw is
|
|
131
|
+
* logged and skipped, because a chain must never lose a "this gate did not run"
|
|
132
|
+
* signal *or* fail a review over reporting one.
|
|
133
|
+
*
|
|
134
|
+
* @param {ReadonlyArray<{ name: string, provider: { getDegradations?: Function } }>} entries
|
|
135
|
+
* @param {{ warn?: Function }} [logger]
|
|
136
|
+
* @returns {Promise<GateDegradation[]>}
|
|
137
|
+
*/
|
|
138
|
+
export async function mergeChainDegradations(entries, logger) {
|
|
139
|
+
const merged = [];
|
|
140
|
+
for (const entry of entries) {
|
|
141
|
+
if (typeof entry.provider?.getDegradations !== 'function') continue;
|
|
142
|
+
try {
|
|
143
|
+
merged.push(
|
|
144
|
+
...normalizeDegradations(await entry.provider.getDegradations()),
|
|
145
|
+
);
|
|
146
|
+
} catch (err) {
|
|
147
|
+
logger?.warn?.(
|
|
148
|
+
`[code-review] Inline provider "${entry.name}" getDegradations threw; skipping. ${
|
|
149
|
+
err?.message ?? err
|
|
150
|
+
}`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return merged;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Pure: the header field naming how many gates did not run. Empty when the
|
|
159
|
+
* review was healthy, so a healthy body stays byte-identical to pre-#4839.
|
|
160
|
+
*
|
|
161
|
+
* @param {ReadonlyArray<GateDegradation>} degraded Already normalized.
|
|
162
|
+
* @returns {string[]}
|
|
163
|
+
*/
|
|
164
|
+
export function renderDegradedHeaderLines(degraded) {
|
|
165
|
+
if (degraded.length === 0) return [];
|
|
166
|
+
return [`**Degraded gates**: ${degraded.length} (did not run)`];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Pure: the "nothing surfaced" block. This is the exact sentence the defect
|
|
171
|
+
* turned into a lie — with a degraded gate present, an all-zero tally is not a
|
|
172
|
+
* clean verdict and must not read like one.
|
|
173
|
+
*
|
|
174
|
+
* @param {ReadonlyArray<GateDegradation>} degraded Already normalized.
|
|
175
|
+
* @returns {string[]}
|
|
176
|
+
*/
|
|
177
|
+
export function renderNoFindingsBlock(degraded) {
|
|
178
|
+
if (degraded.length === 0) {
|
|
179
|
+
return [
|
|
180
|
+
'### ✅ No findings',
|
|
181
|
+
'',
|
|
182
|
+
'No issues surfaced by the review provider.',
|
|
183
|
+
];
|
|
184
|
+
}
|
|
185
|
+
return [
|
|
186
|
+
`### ⚠️ No findings — ${degraded.length} gate(s) did not run`,
|
|
187
|
+
'',
|
|
188
|
+
'The gates that ran surfaced no issues. This review does **not** vouch ' +
|
|
189
|
+
'for the degraded surface(s) listed above.',
|
|
190
|
+
];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Pure: render the "Degraded Gates" section of the structured comment. Returns
|
|
195
|
+
* an empty array when nothing degraded, so a healthy review's body stays
|
|
196
|
+
* byte-identical to the pre-#4839 output.
|
|
197
|
+
*
|
|
198
|
+
* @param {ReadonlyArray<GateDegradation>} degradations
|
|
199
|
+
* @returns {string[]} markdown lines
|
|
200
|
+
*/
|
|
201
|
+
export function renderDegradedGatesSection(degradations) {
|
|
202
|
+
const rows = normalizeDegradations(degradations);
|
|
203
|
+
if (rows.length === 0) return [];
|
|
204
|
+
const lines = [
|
|
205
|
+
`### ⚠️ Degraded Gates (${rows.length})`,
|
|
206
|
+
'',
|
|
207
|
+
'The following review gate(s) **did not run**. Their surface is',
|
|
208
|
+
'unreviewed — an all-zero finding tally below does not vouch for it.',
|
|
209
|
+
'',
|
|
210
|
+
];
|
|
211
|
+
for (const d of rows) {
|
|
212
|
+
lines.push(
|
|
213
|
+
`- \`${d.gate}\` → \`${d.surface}\` could not execute — ${d.reason} (emitter: \`${d.tool}\`).`,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
lines.push('');
|
|
217
|
+
lines.push(
|
|
218
|
+
'Verify with the canonical `npm run lint` before trusting this review.',
|
|
219
|
+
);
|
|
220
|
+
lines.push('');
|
|
221
|
+
return lines;
|
|
222
|
+
}
|
|
@@ -15,6 +15,13 @@
|
|
|
15
15
|
* @typedef {import('./types.js').Severity} Severity
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
+
import {
|
|
19
|
+
normalizeDegradations,
|
|
20
|
+
renderDegradedGatesSection,
|
|
21
|
+
renderDegradedHeaderLines,
|
|
22
|
+
renderNoFindingsBlock,
|
|
23
|
+
} from './degraded-gates.js';
|
|
24
|
+
|
|
18
25
|
/**
|
|
19
26
|
* Canonical severity ordering. The render output always lists the
|
|
20
27
|
* severity-tier counts in this order and emits the per-finding sections
|
|
@@ -115,6 +122,12 @@ export function renderManualPromptsSection(messages) {
|
|
|
115
122
|
* manual-prompt provider output; rendered as a trailing section when
|
|
116
123
|
* non-empty.
|
|
117
124
|
*
|
|
125
|
+
* Story #4839 — an optional `degradations` field names review gates that could
|
|
126
|
+
* not execute. They are **not** findings and never enter `countBySeverity`; they
|
|
127
|
+
* render as their own section and suppress the unqualified "no findings" claim,
|
|
128
|
+
* because a review that could not run a gate has not established that the
|
|
129
|
+
* gate's surface is clean.
|
|
130
|
+
*
|
|
118
131
|
* @param {{
|
|
119
132
|
* ticketId: number,
|
|
120
133
|
* baseRef: string,
|
|
@@ -122,6 +135,7 @@ export function renderManualPromptsSection(messages) {
|
|
|
122
135
|
* findings: ReadonlyArray<Finding>,
|
|
123
136
|
* provider?: string,
|
|
124
137
|
* promptMessages?: ReadonlyArray<string>,
|
|
138
|
+
* degradations?: ReadonlyArray<object>,
|
|
125
139
|
* }} input
|
|
126
140
|
* @returns {string}
|
|
127
141
|
*/
|
|
@@ -131,6 +145,7 @@ export function renderFindings(input) {
|
|
|
131
145
|
const counts = countBySeverity(findings);
|
|
132
146
|
const totalKnown =
|
|
133
147
|
counts.critical + counts.high + counts.medium + counts.suggestion;
|
|
148
|
+
const degraded = normalizeDegradations(input.degradations);
|
|
134
149
|
|
|
135
150
|
const providerLine = provider
|
|
136
151
|
? `**Provider**: \`${provider}\``
|
|
@@ -142,6 +157,7 @@ export function renderFindings(input) {
|
|
|
142
157
|
`**Comparison**: \`${baseRef}\` … \`${headRef}\``,
|
|
143
158
|
providerLine,
|
|
144
159
|
`**Findings**: ${totalKnown}`,
|
|
160
|
+
...renderDegradedHeaderLines(degraded),
|
|
145
161
|
'',
|
|
146
162
|
'### 📦 Severity Tier Counts',
|
|
147
163
|
'',
|
|
@@ -150,12 +166,11 @@ export function renderFindings(input) {
|
|
|
150
166
|
return `- ${meta.emoji} ${meta.label}: ${counts[sev]}`;
|
|
151
167
|
}),
|
|
152
168
|
'',
|
|
169
|
+
...renderDegradedGatesSection(degraded),
|
|
153
170
|
];
|
|
154
171
|
|
|
155
172
|
if (totalKnown === 0) {
|
|
156
|
-
lines.push(
|
|
157
|
-
lines.push('');
|
|
158
|
-
lines.push('No issues surfaced by the review provider.');
|
|
173
|
+
lines.push(...renderNoFindingsBlock(degraded));
|
|
159
174
|
} else {
|
|
160
175
|
for (const sev of SEVERITY_ORDER) {
|
|
161
176
|
const tierFindings = findings.filter((f) => f && f.severity === sev);
|