mandrel 1.62.0 → 1.63.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.
Files changed (27) hide show
  1. package/.agents/scripts/check-action-pinning.js +260 -0
  2. package/.agents/scripts/check-arch-cycles.js +38 -14
  3. package/.agents/scripts/epic-deliver-prepare.js +149 -104
  4. package/.agents/scripts/lib/baseline-snapshot.js +245 -141
  5. package/.agents/scripts/lib/feedback-loop/graduator-core.js +171 -137
  6. package/.agents/scripts/lib/orchestration/code-review.js +206 -168
  7. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/creation.js +71 -5
  8. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/persist.js +16 -2
  9. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/component-drift.js +101 -1
  10. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/crap-drift.js +20 -42
  11. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/maintainability-drift.js +12 -32
  12. package/.agents/scripts/lib/orchestration/lifecycle/trace-logger.js +97 -60
  13. package/.agents/scripts/lib/orchestration/model-attribution.js +73 -45
  14. package/.agents/scripts/lib/orchestration/review-providers/parse-findings.js +97 -49
  15. package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +73 -69
  16. package/.agents/scripts/lib/orchestration/story-close-recovery.js +109 -79
  17. package/.agents/scripts/lib/signals/detectors/common.js +107 -0
  18. package/.agents/scripts/lib/signals/detectors/hotspot.js +12 -18
  19. package/.agents/scripts/lib/signals/detectors/retry.js +3 -40
  20. package/.agents/scripts/lib/signals/detectors/rework.js +3 -40
  21. package/.agents/scripts/lib/story-body/story-body.js +102 -76
  22. package/.agents/scripts/providers/github/blocked-by-add.js +252 -0
  23. package/.agents/scripts/single-story-init.js +16 -3
  24. package/.agents/workflows/audit-architecture.md +9 -0
  25. package/README.md +1 -1
  26. package/docs/CHANGELOG.md +28 -0
  27. package/package.json +1 -1
@@ -4,7 +4,7 @@ import { loadCoverage as defaultLoadCoverage } from '../../../coverage-utils.js'
4
4
  import { calculateCrapForSource } from '../../../crap-engine.js';
5
5
  import { formatNumber } from './_bullet-format.js';
6
6
  import {
7
- createSnapshotStore,
7
+ createDriftDetector,
8
8
  walkComponentRegressions,
9
9
  } from './component-drift.js';
10
10
 
@@ -117,16 +117,7 @@ export function createCrapDriftDetector(opts = {}) {
117
117
  const ceiling = Number.isFinite(opts.ceiling)
118
118
  ? opts.ceiling
119
119
  : DEFAULT_CEILING;
120
- const baselineDir = opts.baselineDir ?? '.agents/state';
121
- const baselinePath = path.join(cwd, baselineDir, BASELINE_FILENAME);
122
120
  const logger = opts.logger ?? null;
123
- const store = createSnapshotStore({
124
- fs,
125
- baselinePath,
126
- metadata: { ceiling, threshold },
127
- });
128
-
129
- let baseline = null;
130
121
 
131
122
  function methodKey(method, startLine) {
132
123
  return `${method}@${startLine}`;
@@ -187,46 +178,33 @@ export function createCrapDriftDetector(opts = {}) {
187
178
  }
188
179
  }
189
180
 
190
- return {
191
- get baselinePath() {
192
- return baselinePath;
193
- },
194
-
195
- captureBaseline() {
196
- const coverageMap = readCoverageMap();
197
- const snapshot = {};
198
- for (const f of files) {
199
- const methods = scoreFile(f, coverageMap);
200
- if (!methods) continue;
201
- snapshot[f] = {};
202
- for (const [key, row] of Object.entries(methods)) {
203
- snapshot[f][key] = row.crap;
204
- }
181
+ return createDriftDetector({
182
+ fs,
183
+ cwd,
184
+ files: opts.files,
185
+ baselineDir: opts.baselineDir,
186
+ baselineFilename: BASELINE_FILENAME,
187
+ metadata: { ceiling, threshold },
188
+ beforeScore: readCoverageMap,
189
+ scoreFile,
190
+ captureScore: (methods) => {
191
+ const persisted = {};
192
+ for (const [key, row] of Object.entries(methods)) {
193
+ persisted[key] = row.crap;
205
194
  }
206
- baseline = snapshot;
207
- store.persist(snapshot);
208
- return snapshot;
209
- },
210
-
211
- loadBaseline() {
212
- baseline = store.load();
213
- return baseline;
195
+ return persisted;
214
196
  },
215
-
216
- async detect() {
217
- if (!baseline) return [];
218
- const coverageMap = readCoverageMap();
197
+ async detect({ baseline, scoreFile: score }) {
219
198
  const bullets = [];
220
- const base = baseline;
221
199
  // Union of files watched and files present in baseline, so methods that
222
200
  // appeared since the snapshot (new files) are still checked, and methods
223
201
  // that disappeared from baseline-only files don't get us stuck iterating
224
202
  // anything spurious.
225
- const fileSet = new Set([...files, ...Object.keys(base)]);
203
+ const fileSet = new Set([...files, ...Object.keys(baseline)]);
226
204
  for (const relPath of fileSet) {
227
- const currentMethods = scoreFile(relPath, coverageMap);
205
+ const currentMethods = score(relPath);
228
206
  if (!currentMethods) continue;
229
- const fileBaseline = base[relPath] ?? {};
207
+ const fileBaseline = baseline[relPath] ?? {};
230
208
  for (const [key, row] of Object.entries(currentMethods)) {
231
209
  const prev = fileBaseline[key];
232
210
  const crossed =
@@ -245,5 +223,5 @@ export function createCrapDriftDetector(opts = {}) {
245
223
  }
246
224
  return bullets;
247
225
  },
248
- };
226
+ });
249
227
  }
@@ -4,7 +4,7 @@ import path from 'node:path';
4
4
  import { calculateForSource } from '../../../maintainability-engine.js';
5
5
  import { formatNumber } from './_bullet-format.js';
6
6
  import {
7
- createSnapshotStore,
7
+ createDriftDetector,
8
8
  walkComponentRegressions,
9
9
  } from './component-drift.js';
10
10
 
@@ -75,16 +75,10 @@ export function detectComponentRegressions(params = {}) {
75
75
  export function createMaintainabilityDriftDetector(opts = {}) {
76
76
  const fs = opts.fs ?? nodeFs;
77
77
  const cwd = opts.cwd ?? process.cwd();
78
- const files = Array.isArray(opts.files) ? [...opts.files] : [];
79
78
  const calculate = opts.calculate ?? calculateForSource;
80
79
  const threshold = Number.isFinite(opts.threshold)
81
80
  ? opts.threshold
82
81
  : DEFAULT_THRESHOLD;
83
- const baselineDir = opts.baselineDir ?? '.agents/state';
84
- const baselinePath = path.join(cwd, baselineDir, BASELINE_FILENAME);
85
- const store = createSnapshotStore({ fs, baselinePath });
86
-
87
- let baseline = null;
88
82
 
89
83
  function scoreFile(relPath) {
90
84
  try {
@@ -97,32 +91,18 @@ export function createMaintainabilityDriftDetector(opts = {}) {
97
91
  }
98
92
  }
99
93
 
100
- return {
101
- get baselinePath() {
102
- return baselinePath;
103
- },
104
-
105
- captureBaseline() {
106
- const snapshot = {};
107
- for (const f of files) {
108
- const s = scoreFile(f);
109
- if (s != null) snapshot[f] = s;
110
- }
111
- baseline = snapshot;
112
- store.persist(snapshot);
113
- return snapshot;
114
- },
115
-
116
- loadBaseline() {
117
- baseline = store.load();
118
- return baseline;
119
- },
120
-
121
- async detect() {
122
- if (!baseline) return [];
94
+ return createDriftDetector({
95
+ fs,
96
+ cwd,
97
+ files: opts.files,
98
+ baselineDir: opts.baselineDir,
99
+ baselineFilename: BASELINE_FILENAME,
100
+ scoreFile,
101
+ captureScore: (score) => score,
102
+ async detect({ baseline, scoreFile: score }) {
123
103
  const bullets = [];
124
104
  for (const [relPath, baseScore] of Object.entries(baseline)) {
125
- const current = scoreFile(relPath);
105
+ const current = score(relPath);
126
106
  if (current == null) continue;
127
107
  const drop = baseScore - current;
128
108
  if (drop >= threshold) {
@@ -133,5 +113,5 @@ export function createMaintainabilityDriftDetector(opts = {}) {
133
113
  }
134
114
  return bullets;
135
115
  },
136
- };
116
+ });
137
117
  }
@@ -136,17 +136,57 @@ export function parseLedger(text) {
136
136
  * - Failed: N
137
137
  * - …
138
138
  */
139
- export function render(ledger, opts = {}) {
140
- const records = Array.isArray(ledger) ? ledger : parseLedger(ledger);
139
+ /**
140
+ * Index the ledger records into the two maps `render` needs: the `emitted`
141
+ * record per seqId and the terminal (`completed`/`failed`) record per seqId.
142
+ * Story #4075 — extracted from `render` so the orchestrating body stays flat.
143
+ */
144
+ function indexLedgerRecords(records) {
141
145
  const emittedBySeq = new Map();
142
- const terminalBySeq = new Map(); // seqId -> 'completed' | 'failed' record
146
+ const terminalBySeq = new Map();
143
147
  for (const rec of records) {
144
148
  if (!rec || typeof rec !== 'object') continue;
145
149
  if (rec.kind === 'emitted') emittedBySeq.set(rec.seqId, rec);
146
150
  else if (rec.kind === 'completed' || rec.kind === 'failed')
147
151
  terminalBySeq.set(rec.seqId, rec);
148
152
  }
153
+ return { emittedBySeq, terminalBySeq };
154
+ }
149
155
 
156
+ /**
157
+ * Compute the `(durationMs)` / `(pending)` chunk for one emitted event,
158
+ * given its terminal record (or undefined when still in flight).
159
+ */
160
+ export function formatDurationChunk(emit, terminal) {
161
+ if (!terminal) return '(pending)';
162
+ const start = new Date(emit.ts).getTime();
163
+ const end = new Date(terminal.ts).getTime();
164
+ if (Number.isFinite(start) && Number.isFinite(end) && end >= start) {
165
+ return `(${end - start}ms)`;
166
+ }
167
+ return '';
168
+ }
169
+
170
+ /**
171
+ * Render a single per-event line for the phase section.
172
+ */
173
+ function formatEventLine(emit, terminal) {
174
+ const failedMarker =
175
+ terminal && terminal.kind === 'failed' ? ' ⚠️ FAILED' : '';
176
+ const parts = [
177
+ formatClock(emit.ts),
178
+ emit.event,
179
+ formatDurationChunk(emit, terminal),
180
+ summarizePayload(emit.payload),
181
+ ].filter(Boolean);
182
+ return parts.join(' ') + failedMarker;
183
+ }
184
+
185
+ /**
186
+ * Group emitted events into ordered phase buckets, each carrying its
187
+ * rendered per-event lines. Phase order is first-seen by ascending seqId.
188
+ */
189
+ function buildPhaseLines(emittedBySeq, terminalBySeq) {
150
190
  const phaseOrder = [];
151
191
  const phaseLines = new Map();
152
192
  for (const emit of [...emittedBySeq.values()].sort(
@@ -157,79 +197,76 @@ export function render(ledger, opts = {}) {
157
197
  phaseLines.set(phase, []);
158
198
  phaseOrder.push(phase);
159
199
  }
200
+ phaseLines
201
+ .get(phase)
202
+ .push(formatEventLine(emit, terminalBySeq.get(emit.seqId)));
203
+ }
204
+ return { phaseOrder, phaseLines };
205
+ }
206
+
207
+ /**
208
+ * Compute the wall-clock span (`maxEnd - minStart`) of a single phase, or
209
+ * `null` when no finite span can be derived.
210
+ */
211
+ function computePhaseSpanMs(phase, emittedBySeq, terminalBySeq) {
212
+ let minStart = Infinity;
213
+ let maxEnd = -Infinity;
214
+ for (const emit of emittedBySeq.values()) {
215
+ if (phaseFor(emit.event) !== phase) continue;
216
+ const start = new Date(emit.ts).getTime();
217
+ if (Number.isFinite(start) && start < minStart) minStart = start;
160
218
  const terminal = terminalBySeq.get(emit.seqId);
161
- let durationMs = '';
162
219
  if (terminal) {
163
- const start = new Date(emit.ts).getTime();
164
220
  const end = new Date(terminal.ts).getTime();
165
- if (Number.isFinite(start) && Number.isFinite(end) && end >= start) {
166
- durationMs = `(${end - start}ms)`;
167
- }
168
- } else {
169
- durationMs = '(pending)';
221
+ if (Number.isFinite(end) && end > maxEnd) maxEnd = end;
170
222
  }
171
- const summary = summarizePayload(emit.payload);
172
- const failedMarker =
173
- terminal && terminal.kind === 'failed' ? ' ⚠️ FAILED' : '';
174
- const parts = [
175
- formatClock(emit.ts),
176
- emit.event,
177
- durationMs,
178
- summary,
179
- ].filter(Boolean);
180
- phaseLines.get(phase).push(parts.join(' ') + failedMarker);
181
223
  }
224
+ return Number.isFinite(minStart) && Number.isFinite(maxEnd)
225
+ ? maxEnd - minStart
226
+ : null;
227
+ }
182
228
 
183
- const lines = [];
184
- const epicId = opts.epicId ? `epic ${opts.epicId}` : 'epic';
185
- lines.push(`# Lifecycle — ${epicId}`);
186
- lines.push('');
187
- for (const phase of phaseOrder) {
188
- lines.push(`## ${phase}`);
189
- lines.push('');
190
- for (const l of phaseLines.get(phase)) {
191
- lines.push(l);
192
- }
193
- lines.push('');
194
- }
195
- // Summary block
229
+ /**
230
+ * Build the trailing `## Summary` block lines.
231
+ */
232
+ function buildSummaryLines(phaseOrder, emittedBySeq, terminalBySeq) {
196
233
  const totalEvents = emittedBySeq.size;
197
234
  const failedCount = [...terminalBySeq.values()].filter(
198
235
  (r) => r.kind === 'failed',
199
236
  ).length;
200
- const completedCount = totalEvents - failedCount;
201
237
  const phaseDurations = [];
202
238
  for (const phase of phaseOrder) {
203
- const seqIds = [...emittedBySeq.values()]
204
- .filter((e) => phaseFor(e.event) === phase)
205
- .map((e) => e.seqId);
206
- if (seqIds.length === 0) continue;
207
- let minStart = Infinity;
208
- let maxEnd = -Infinity;
209
- for (const sid of seqIds) {
210
- const e = emittedBySeq.get(sid);
211
- const t = terminalBySeq.get(sid);
212
- const start = new Date(e.ts).getTime();
213
- if (Number.isFinite(start) && start < minStart) minStart = start;
214
- if (t) {
215
- const end = new Date(t.ts).getTime();
216
- if (Number.isFinite(end) && end > maxEnd) maxEnd = end;
217
- }
218
- }
219
- if (Number.isFinite(minStart) && Number.isFinite(maxEnd)) {
220
- phaseDurations.push(` - ${phase}: ${maxEnd - minStart}ms`);
221
- }
239
+ const spanMs = computePhaseSpanMs(phase, emittedBySeq, terminalBySeq);
240
+ if (spanMs !== null) phaseDurations.push(` - ${phase}: ${spanMs}ms`);
222
241
  }
223
- lines.push('## Summary');
224
- lines.push('');
225
- lines.push(`- Events: ${totalEvents}`);
226
- lines.push(`- Completed: ${completedCount}`);
227
- lines.push(`- Failed: ${failedCount}`);
242
+ const lines = [
243
+ '## Summary',
244
+ '',
245
+ `- Events: ${totalEvents}`,
246
+ `- Completed: ${totalEvents - failedCount}`,
247
+ `- Failed: ${failedCount}`,
248
+ ];
228
249
  if (phaseDurations.length > 0) {
229
- lines.push('- Phase durations:');
230
- for (const pd of phaseDurations) lines.push(pd);
250
+ lines.push('- Phase durations:', ...phaseDurations);
231
251
  }
232
252
  lines.push('');
253
+ return lines;
254
+ }
255
+
256
+ export function render(ledger, opts = {}) {
257
+ const records = Array.isArray(ledger) ? ledger : parseLedger(ledger);
258
+ const { emittedBySeq, terminalBySeq } = indexLedgerRecords(records);
259
+ const { phaseOrder, phaseLines } = buildPhaseLines(
260
+ emittedBySeq,
261
+ terminalBySeq,
262
+ );
263
+
264
+ const epicId = opts.epicId ? `epic ${opts.epicId}` : 'epic';
265
+ const lines = [`# Lifecycle — ${epicId}`, ''];
266
+ for (const phase of phaseOrder) {
267
+ lines.push(`## ${phase}`, '', ...phaseLines.get(phase), '');
268
+ }
269
+ lines.push(...buildSummaryLines(phaseOrder, emittedBySeq, terminalBySeq));
233
270
  return lines.join('\n');
234
271
  }
235
272
 
@@ -151,6 +151,76 @@ const VALID_SOURCES = new Set(['sdk-metadata', 'env', 'unknown']);
151
151
  const ISO_8601_RE =
152
152
  /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
153
153
 
154
+ /**
155
+ * Per-field validators for {@link validateModelAttributionPayload}. Each
156
+ * returns an array of error strings for its field (empty when the field is
157
+ * valid), so the top-level validator collapses to a flat-map over the field
158
+ * list — no per-field branching in the orchestrating body. Story #4075
159
+ * (CLI-orchestration CC reduction): keeps each validator pure and
160
+ * single-responsibility.
161
+ */
162
+ function validateKind(payload) {
163
+ return payload.kind === MODEL_ATTRIBUTION_TYPE
164
+ ? []
165
+ : [`kind must be "${MODEL_ATTRIBUTION_TYPE}"`];
166
+ }
167
+
168
+ function validateTicketId(payload) {
169
+ return Number.isInteger(payload.ticketId) && payload.ticketId > 0
170
+ ? []
171
+ : ['ticketId must be a positive integer'];
172
+ }
173
+
174
+ function validateModel(payload) {
175
+ const { model } = payload;
176
+ if (!model || typeof model !== 'object' || Array.isArray(model)) {
177
+ return ['model must be an object'];
178
+ }
179
+ const errors = [];
180
+ if (typeof model.id !== 'string' || model.id.length === 0) {
181
+ errors.push('model.id must be a non-empty string');
182
+ }
183
+ if (
184
+ model.family !== undefined &&
185
+ (typeof model.family !== 'string' || model.family.length === 0)
186
+ ) {
187
+ errors.push('model.family, when present, must be a non-empty string');
188
+ }
189
+ return errors;
190
+ }
191
+
192
+ function validateSource(payload) {
193
+ return typeof payload.source === 'string' && VALID_SOURCES.has(payload.source)
194
+ ? []
195
+ : [`source must be one of: ${[...VALID_SOURCES].join(', ')}`];
196
+ }
197
+
198
+ function validateRecordedAt(payload) {
199
+ return typeof payload.recordedAt === 'string' &&
200
+ ISO_8601_RE.test(payload.recordedAt)
201
+ ? []
202
+ : ['recordedAt must be an ISO-8601 timestamp string'];
203
+ }
204
+
205
+ function validateSdkMetadata(payload) {
206
+ const { sdkMetadata } = payload;
207
+ if (sdkMetadata === undefined) return [];
208
+ const valid =
209
+ sdkMetadata !== null &&
210
+ typeof sdkMetadata === 'object' &&
211
+ !Array.isArray(sdkMetadata);
212
+ return valid ? [] : ['sdkMetadata, when present, must be an object'];
213
+ }
214
+
215
+ const PAYLOAD_FIELD_VALIDATORS = Object.freeze([
216
+ validateKind,
217
+ validateTicketId,
218
+ validateModel,
219
+ validateSource,
220
+ validateRecordedAt,
221
+ validateSdkMetadata,
222
+ ]);
223
+
154
224
  /**
155
225
  * Hand-rolled validator matching
156
226
  * `.agents/schemas/model-attribution.schema.json`. Returns
@@ -168,7 +238,6 @@ const ISO_8601_RE =
168
238
  * @returns {{ ok: true } | { ok: false, errors: string[] }}
169
239
  */
170
240
  export function validateModelAttributionPayload(payload) {
171
- const errors = [];
172
241
  if (
173
242
  payload === null ||
174
243
  typeof payload !== 'object' ||
@@ -176,50 +245,9 @@ export function validateModelAttributionPayload(payload) {
176
245
  ) {
177
246
  return { ok: false, errors: ['payload must be a plain object'] };
178
247
  }
179
- if (payload.kind !== MODEL_ATTRIBUTION_TYPE) {
180
- errors.push(`kind must be "${MODEL_ATTRIBUTION_TYPE}"`);
181
- }
182
- if (!Number.isInteger(payload.ticketId) || payload.ticketId <= 0) {
183
- errors.push('ticketId must be a positive integer');
184
- }
185
- if (
186
- !payload.model ||
187
- typeof payload.model !== 'object' ||
188
- Array.isArray(payload.model)
189
- ) {
190
- errors.push('model must be an object');
191
- } else {
192
- if (typeof payload.model.id !== 'string' || payload.model.id.length === 0) {
193
- errors.push('model.id must be a non-empty string');
194
- }
195
- if (
196
- payload.model.family !== undefined &&
197
- (typeof payload.model.family !== 'string' ||
198
- payload.model.family.length === 0)
199
- ) {
200
- errors.push('model.family, when present, must be a non-empty string');
201
- }
202
- }
203
- if (
204
- typeof payload.source !== 'string' ||
205
- !VALID_SOURCES.has(payload.source)
206
- ) {
207
- errors.push(`source must be one of: ${[...VALID_SOURCES].join(', ')}`);
208
- }
209
- if (
210
- typeof payload.recordedAt !== 'string' ||
211
- !ISO_8601_RE.test(payload.recordedAt)
212
- ) {
213
- errors.push('recordedAt must be an ISO-8601 timestamp string');
214
- }
215
- if (
216
- payload.sdkMetadata !== undefined &&
217
- (payload.sdkMetadata === null ||
218
- typeof payload.sdkMetadata !== 'object' ||
219
- Array.isArray(payload.sdkMetadata))
220
- ) {
221
- errors.push('sdkMetadata, when present, must be an object');
222
- }
248
+ const errors = PAYLOAD_FIELD_VALIDATORS.flatMap((validate) =>
249
+ validate(payload),
250
+ );
223
251
  return errors.length ? { ok: false, errors } : { ok: true };
224
252
  }
225
253
 
@@ -24,10 +24,102 @@
24
24
  * this value (security-review defaults to `'security'`); when
25
25
  * omitted, `category` is only set when present (codex behavior).
26
26
  *
27
+ * Story #4074 — the parser body was a CC-30 ternary thicket. The
28
+ * per-field branching now lives in three small, independently-testable
29
+ * pure helpers (`unwrapEnvelope`, `coerceString`, `buildFinding`), so the
30
+ * orchestration body collapses to: unwrap → `Array.isArray` guard →
31
+ * `map(buildFinding).filter(Boolean)`.
32
+ *
27
33
  * @typedef {import('./types.js').Finding} Finding
28
34
  * @typedef {import('./types.js').Severity} Severity
29
35
  */
30
36
 
37
+ /**
38
+ * Unwrap up to two layers of envelope around a findings array.
39
+ *
40
+ * Accepts a bare array unchanged, an object with a `findings` array, or
41
+ * either shape nested one level deep under a `result` / `data` key. The
42
+ * second pass resolves `{ result: { findings: [] } }`-style
43
+ * double-envelopes. Anything that does not resolve to an array is
44
+ * returned as-is for the caller's `Array.isArray` guard to reject.
45
+ *
46
+ * @param {unknown} parsed
47
+ * @returns {unknown}
48
+ */
49
+ export function unwrapEnvelope(parsed) {
50
+ let value = parsed;
51
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
52
+ if (Array.isArray(value.findings)) value = value.findings;
53
+ else if (value.result !== undefined) value = value.result;
54
+ else if (value.data !== undefined) value = value.data;
55
+ }
56
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
57
+ if (Array.isArray(value.findings)) value = value.findings;
58
+ }
59
+ return value;
60
+ }
61
+
62
+ /**
63
+ * Coerce a value to a non-empty trimmed string, or null.
64
+ *
65
+ * Returns the trimmed string when `value` is a string with
66
+ * non-whitespace content; otherwise null.
67
+ *
68
+ * @param {unknown} value
69
+ * @returns {string | null}
70
+ */
71
+ export function coerceString(value) {
72
+ if (typeof value !== 'string') return null;
73
+ const trimmed = value.trim();
74
+ return trimmed.length > 0 ? trimmed : null;
75
+ }
76
+
77
+ /**
78
+ * Build a single `Finding` from a raw entry, or null when the entry is
79
+ * unusable (not an object, or missing a title/body).
80
+ *
81
+ * `title` is trimmed; `body` falls back to a non-empty `message` alias
82
+ * and is preserved verbatim (untrimmed) to match the historical
83
+ * behavior. `category` is set from the entry when present, else from
84
+ * `defaultCategory` when supplied. `file` / `line` are included only
85
+ * when present and well-formed.
86
+ *
87
+ * @param {unknown} entry
88
+ * @param {{
89
+ * mapSeverity: (raw: unknown) => Severity,
90
+ * defaultCategory?: string,
91
+ * }} options
92
+ * @returns {Finding | null}
93
+ */
94
+ export function buildFinding(entry, { mapSeverity, defaultCategory }) {
95
+ if (!entry || typeof entry !== 'object') return null;
96
+
97
+ const title = coerceString(entry.title);
98
+ const body = coerceString(entry.body)
99
+ ? entry.body
100
+ : coerceString(entry.message)
101
+ ? entry.message
102
+ : null;
103
+ if (!title || !body) return null;
104
+
105
+ /** @type {Finding} */
106
+ const finding = { severity: mapSeverity(entry.severity), title, body };
107
+
108
+ const category =
109
+ typeof entry.category === 'string' && entry.category.length > 0
110
+ ? entry.category
111
+ : defaultCategory;
112
+ if (category !== undefined) finding.category = category;
113
+
114
+ if (typeof entry.file === 'string' && entry.file.length > 0) {
115
+ finding.file = entry.file;
116
+ }
117
+ if (Number.isInteger(entry.line) && entry.line > 0) {
118
+ finding.line = entry.line;
119
+ }
120
+ return finding;
121
+ }
122
+
31
123
  /**
32
124
  * Parse a provider's raw stdout into `Finding[]`.
33
125
  *
@@ -52,54 +144,10 @@ export function parseProviderFindings(rawStdout, options) {
52
144
  throw new Error(`${errorPrefix}: ${err?.message ?? err}`);
53
145
  }
54
146
 
55
- // Unwrap a single layer of envelope when present.
56
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
57
- if (Array.isArray(parsed.findings)) parsed = parsed.findings;
58
- else if (parsed.result !== undefined) parsed = parsed.result;
59
- else if (parsed.data !== undefined) parsed = parsed.data;
60
- }
61
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
62
- if (Array.isArray(parsed.findings)) parsed = parsed.findings;
63
- }
64
-
65
- if (!Array.isArray(parsed)) return [];
147
+ const unwrapped = unwrapEnvelope(parsed);
148
+ if (!Array.isArray(unwrapped)) return [];
66
149
 
67
- /** @type {Finding[]} */
68
- const findings = [];
69
- for (const entry of parsed) {
70
- if (!entry || typeof entry !== 'object') continue;
71
- const title =
72
- typeof entry.title === 'string' && entry.title.trim().length > 0
73
- ? entry.title.trim()
74
- : null;
75
- const body =
76
- typeof entry.body === 'string' && entry.body.trim().length > 0
77
- ? entry.body
78
- : typeof entry.message === 'string' && entry.message.trim().length > 0
79
- ? entry.message
80
- : null;
81
- if (!title || !body) continue;
82
-
83
- /** @type {Finding} */
84
- const finding = {
85
- severity: mapSeverity(entry.severity),
86
- title,
87
- body,
88
- };
89
- const category =
90
- typeof entry.category === 'string' && entry.category.length > 0
91
- ? entry.category
92
- : defaultCategory;
93
- if (category !== undefined) {
94
- finding.category = category;
95
- }
96
- if (typeof entry.file === 'string' && entry.file.length > 0) {
97
- finding.file = entry.file;
98
- }
99
- if (Number.isInteger(entry.line) && entry.line > 0) {
100
- finding.line = entry.line;
101
- }
102
- findings.push(finding);
103
- }
104
- return findings;
150
+ return unwrapped
151
+ .map((entry) => buildFinding(entry, { mapSeverity, defaultCategory }))
152
+ .filter(Boolean);
105
153
  }