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.
- package/.agents/scripts/check-action-pinning.js +260 -0
- package/.agents/scripts/check-arch-cycles.js +38 -14
- package/.agents/scripts/epic-deliver-prepare.js +149 -104
- package/.agents/scripts/lib/baseline-snapshot.js +245 -141
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +171 -137
- package/.agents/scripts/lib/orchestration/code-review.js +206 -168
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/creation.js +71 -5
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/persist.js +16 -2
- package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/component-drift.js +101 -1
- package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/crap-drift.js +20 -42
- package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/maintainability-drift.js +12 -32
- package/.agents/scripts/lib/orchestration/lifecycle/trace-logger.js +97 -60
- package/.agents/scripts/lib/orchestration/model-attribution.js +73 -45
- package/.agents/scripts/lib/orchestration/review-providers/parse-findings.js +97 -49
- package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +73 -69
- package/.agents/scripts/lib/orchestration/story-close-recovery.js +109 -79
- package/.agents/scripts/lib/signals/detectors/common.js +107 -0
- package/.agents/scripts/lib/signals/detectors/hotspot.js +12 -18
- package/.agents/scripts/lib/signals/detectors/retry.js +3 -40
- package/.agents/scripts/lib/signals/detectors/rework.js +3 -40
- package/.agents/scripts/lib/story-body/story-body.js +102 -76
- package/.agents/scripts/providers/github/blocked-by-add.js +252 -0
- package/.agents/scripts/single-story-init.js +16 -3
- package/.agents/workflows/audit-architecture.md +9 -0
- package/README.md +1 -1
- package/docs/CHANGELOG.md +28 -0
- 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
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
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(
|
|
203
|
+
const fileSet = new Set([...files, ...Object.keys(baseline)]);
|
|
226
204
|
for (const relPath of fileSet) {
|
|
227
|
-
const currentMethods =
|
|
205
|
+
const currentMethods = score(relPath);
|
|
228
206
|
if (!currentMethods) continue;
|
|
229
|
-
const fileBaseline =
|
|
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
|
}
|
package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/maintainability-drift.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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 =
|
|
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
|
-
|
|
140
|
-
|
|
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();
|
|
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(
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
|
204
|
-
|
|
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
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
180
|
-
|
|
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
|
-
|
|
56
|
-
if (
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
}
|