mandrel 1.69.0 → 1.70.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/docs/workflows.md +1 -1
- package/.agents/scripts/agents-update-preflight.js +235 -0
- package/.agents/scripts/apply-quality-bootstrap.js +79 -0
- package/.agents/scripts/audit-labels-bootstrap.js +52 -30
- package/.agents/scripts/audit-to-stories.js +54 -0
- package/.agents/scripts/bootstrap.js +13 -3
- package/.agents/scripts/generate-config-docs.js +189 -94
- package/.agents/scripts/lib/audit-suite/findings.js +0 -4
- package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +99 -0
- package/.agents/scripts/lib/audit-to-stories/build-story-body.js +13 -5
- package/.agents/scripts/lib/baseline-snapshot.js +163 -4
- package/.agents/scripts/lib/baselines/refresh-service.js +0 -4
- package/.agents/scripts/lib/config/baselines.js +0 -20
- package/.agents/scripts/lib/config/temp-paths.js +0 -31
- package/.agents/scripts/lib/crap-utils.js +281 -0
- package/.agents/scripts/lib/orchestration/dispatch-engine.js +0 -2
- package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/composition.js +0 -84
- package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/signals.js +3 -4
- package/.agents/scripts/lib/orchestration/lifecycle/trace-logger.js +0 -4
- package/.agents/scripts/lib/orchestration/retro/phases/compose-body.js +101 -70
- package/.agents/scripts/lib/orchestration/spec-renderer.js +42 -14
- package/.agents/scripts/lib/orchestration/ticket-lease.js +3 -0
- package/.agents/scripts/lib/story-body/story-body.js +110 -65
- package/.agents/scripts/lib/test-tiers.js +13 -7
- package/.agents/scripts/lib/wave-runner/tick.js +177 -53
- package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +226 -0
- package/.agents/scripts/providers/github/issues.js +48 -0
- package/.agents/scripts/providers/github.js +1 -0
- package/.agents/workflows/agents-update.md +205 -28
- package/README.md +20 -0
- package/docs/CHANGELOG.md +32 -0
- package/lib/cli/registry.js +49 -6
- package/lib/cli/update.js +335 -332
- package/package.json +16 -11
|
@@ -141,6 +141,92 @@ function resolveNode(schema, node) {
|
|
|
141
141
|
return { node, refName: null };
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
/**
|
|
145
|
+
* Render the "Type" cell for an `array`-typed node by inspecting its `items`
|
|
146
|
+
* schema. Mirrors the original inline ladder exactly: a `$ref` item renders
|
|
147
|
+
* `array<RefName>`, an enum item renders `array<enum>`, a typed item renders
|
|
148
|
+
* `array<type>`, and anything else collapses to a bare `array`.
|
|
149
|
+
*
|
|
150
|
+
* @param {object} flat Flattened array node.
|
|
151
|
+
* @returns {string}
|
|
152
|
+
*/
|
|
153
|
+
function renderArrayType(flat) {
|
|
154
|
+
const items = flat.items;
|
|
155
|
+
if (items && typeof items === 'object') {
|
|
156
|
+
if (items.$ref) {
|
|
157
|
+
const refName = items.$ref.startsWith('#/$defs/')
|
|
158
|
+
? items.$ref.slice('#/$defs/'.length)
|
|
159
|
+
: items.$ref;
|
|
160
|
+
return `\`array<${refName}>\``;
|
|
161
|
+
}
|
|
162
|
+
if (Array.isArray(items.enum)) {
|
|
163
|
+
return `\`array<enum>\``;
|
|
164
|
+
}
|
|
165
|
+
if (typeof items.type === 'string') {
|
|
166
|
+
return `\`array<${items.type}>\``;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return '`array`';
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Render the "Type" cell for an `object`-typed node — `object<map>` when it
|
|
174
|
+
* carries an `additionalProperties` schema (the map form), `object`
|
|
175
|
+
* otherwise.
|
|
176
|
+
*
|
|
177
|
+
* @param {object} flat Flattened object node.
|
|
178
|
+
* @returns {string}
|
|
179
|
+
*/
|
|
180
|
+
function renderObjectType(flat) {
|
|
181
|
+
if (
|
|
182
|
+
flat.additionalProperties &&
|
|
183
|
+
typeof flat.additionalProperties === 'object'
|
|
184
|
+
) {
|
|
185
|
+
return '`object<map>`';
|
|
186
|
+
}
|
|
187
|
+
return '`object`';
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Ordered dispatch table for the "Type" cell. Each rule pairs a `when(flat)`
|
|
192
|
+
* predicate with a `render(flat)` producer; {@link renderType} walks the
|
|
193
|
+
* table once and returns the first match, so a new schema shape becomes a new
|
|
194
|
+
* row here rather than another nested branch.
|
|
195
|
+
*
|
|
196
|
+
* Order is load-bearing — `oneOf` and `enum` are matched before the plain
|
|
197
|
+
* `type` rules, exactly as the original ladder short-circuited.
|
|
198
|
+
*
|
|
199
|
+
* @type {Array<{ when: (flat: object) => boolean, render: (flat: object) => string }>}
|
|
200
|
+
*/
|
|
201
|
+
const TYPE_RULES = [
|
|
202
|
+
// The only oneOf in the schema is `listOrExtenderOfStrings`.
|
|
203
|
+
{
|
|
204
|
+
when: (flat) => Array.isArray(flat.oneOf),
|
|
205
|
+
render: () => '`string[]` or `{ append?, prepend? }`',
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
when: (flat) => Array.isArray(flat.enum),
|
|
209
|
+
render: (flat) =>
|
|
210
|
+
flat.enum.map((v) => `\`${JSON.stringify(v)}\``).join(' \\| '),
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
when: (flat) => Array.isArray(flat.type),
|
|
214
|
+
render: (flat) => flat.type.map((t) => `\`${t}\``).join(' \\| '),
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
when: (flat) => flat.type === 'array',
|
|
218
|
+
render: renderArrayType,
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
when: (flat) => flat.type === 'object',
|
|
222
|
+
render: renderObjectType,
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
when: (flat) => typeof flat.type === 'string',
|
|
226
|
+
render: (flat) => `\`${flat.type}\``,
|
|
227
|
+
},
|
|
228
|
+
];
|
|
229
|
+
|
|
144
230
|
/**
|
|
145
231
|
* Render the "Type" cell for a schema node. The agentrc schema uses a few
|
|
146
232
|
* recurring shapes — string, integer, number, boolean, array, object,
|
|
@@ -155,51 +241,8 @@ function resolveNode(schema, node) {
|
|
|
155
241
|
function renderType(schema, node) {
|
|
156
242
|
if (!node || typeof node !== 'object') return '?';
|
|
157
243
|
const flat = flattenAllOf(schema, node);
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
// The only oneOf in the schema is `listOrExtenderOfStrings`.
|
|
161
|
-
return '`string[]` or `{ append?, prepend? }`';
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
if (Array.isArray(flat.enum)) {
|
|
165
|
-
return flat.enum.map((v) => `\`${JSON.stringify(v)}\``).join(' \\| ');
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
const type = flat.type;
|
|
169
|
-
if (Array.isArray(type)) {
|
|
170
|
-
return type.map((t) => `\`${t}\``).join(' \\| ');
|
|
171
|
-
}
|
|
172
|
-
if (typeof type === 'string') {
|
|
173
|
-
if (type === 'array') {
|
|
174
|
-
const items = flat.items;
|
|
175
|
-
if (items && typeof items === 'object') {
|
|
176
|
-
if (items.$ref) {
|
|
177
|
-
const refName = items.$ref.startsWith('#/$defs/')
|
|
178
|
-
? items.$ref.slice('#/$defs/'.length)
|
|
179
|
-
: items.$ref;
|
|
180
|
-
return `\`array<${refName}>\``;
|
|
181
|
-
}
|
|
182
|
-
if (Array.isArray(items.enum)) {
|
|
183
|
-
return `\`array<enum>\``;
|
|
184
|
-
}
|
|
185
|
-
if (typeof items.type === 'string') {
|
|
186
|
-
return `\`array<${items.type}>\``;
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
return '`array`';
|
|
190
|
-
}
|
|
191
|
-
if (type === 'object') {
|
|
192
|
-
if (
|
|
193
|
-
flat.additionalProperties &&
|
|
194
|
-
typeof flat.additionalProperties === 'object'
|
|
195
|
-
) {
|
|
196
|
-
return '`object<map>`';
|
|
197
|
-
}
|
|
198
|
-
return '`object`';
|
|
199
|
-
}
|
|
200
|
-
return `\`${type}\``;
|
|
201
|
-
}
|
|
202
|
-
return '?';
|
|
244
|
+
const rule = TYPE_RULES.find((r) => r.when(flat));
|
|
245
|
+
return rule ? rule.render(flat) : '?';
|
|
203
246
|
}
|
|
204
247
|
|
|
205
248
|
/**
|
|
@@ -233,6 +276,94 @@ function escapeCell(text) {
|
|
|
233
276
|
return String(text).replace(/\r?\n/g, ' ').replace(/\|/g, '\\|');
|
|
234
277
|
}
|
|
235
278
|
|
|
279
|
+
/**
|
|
280
|
+
* Emit the rows for a nested-object property: a header row carrying the
|
|
281
|
+
* parent's description followed by the recursively-flattened child rows.
|
|
282
|
+
* Returns `null` when `flat` is not a properties-bearing object, so the
|
|
283
|
+
* caller can fall through to the next row shape.
|
|
284
|
+
*
|
|
285
|
+
* @param {object} schema
|
|
286
|
+
* @param {{flat: object, keyPath: string, pathParts: string[], propName: string, isRequired: boolean, description: string}} ctx
|
|
287
|
+
* @returns {Array<object> | null}
|
|
288
|
+
*/
|
|
289
|
+
function nestedObjectRows(schema, ctx) {
|
|
290
|
+
const { flat, keyPath, pathParts, propName, isRequired, description } = ctx;
|
|
291
|
+
if (flat.type !== 'object' || !flat.properties) return null;
|
|
292
|
+
const childRequired = new Set(
|
|
293
|
+
Array.isArray(flat.required) ? flat.required : [],
|
|
294
|
+
);
|
|
295
|
+
return [
|
|
296
|
+
{
|
|
297
|
+
key: keyPath,
|
|
298
|
+
required: isRequired ? 'Yes' : 'No',
|
|
299
|
+
type: '`object`',
|
|
300
|
+
def: renderDefault(flat.default),
|
|
301
|
+
description: description || 'Nested configuration block.',
|
|
302
|
+
},
|
|
303
|
+
...flattenObject(schema, flat, [...pathParts, propName], childRequired),
|
|
304
|
+
];
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Emit the single `[]`-suffixed row for an array-of-objects property,
|
|
309
|
+
* describing the item shape in the Description cell. Returns `null` when the
|
|
310
|
+
* property is not an array whose items are a properties-bearing object.
|
|
311
|
+
*
|
|
312
|
+
* @param {object} schema
|
|
313
|
+
* @param {{flat: object, keyPath: string, isRequired: boolean, description: string}} ctx
|
|
314
|
+
* @returns {Array<object> | null}
|
|
315
|
+
*/
|
|
316
|
+
function arrayOfObjectsRows(schema, ctx) {
|
|
317
|
+
const { flat, keyPath, isRequired, description } = ctx;
|
|
318
|
+
if (flat.type !== 'array' || !flat.items) return null;
|
|
319
|
+
const { node: itemNode, refName } = resolveNode(schema, flat.items);
|
|
320
|
+
if (!itemNode || itemNode.type !== 'object' || !itemNode.properties) {
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
const itemKeys = Object.keys(itemNode.properties).join(', ');
|
|
324
|
+
const suffix = refName ? ` (\`${refName}\`)` : '';
|
|
325
|
+
const desc =
|
|
326
|
+
(description ? `${description} ` : '') +
|
|
327
|
+
`Each item${suffix} has: ${itemKeys}.`;
|
|
328
|
+
return [
|
|
329
|
+
{
|
|
330
|
+
key: `${keyPath}[]`,
|
|
331
|
+
required: isRequired ? 'Yes' : 'No',
|
|
332
|
+
type: renderType(schema, flat),
|
|
333
|
+
def: renderDefault(flat.default),
|
|
334
|
+
description: desc,
|
|
335
|
+
},
|
|
336
|
+
];
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Emit the leaf (scalar / non-recursed) row for a property. Always matches —
|
|
341
|
+
* it is the fallthrough shape when neither the nested-object nor the
|
|
342
|
+
* array-of-objects builder applied.
|
|
343
|
+
*
|
|
344
|
+
* @param {object} schema
|
|
345
|
+
* @param {{flat: object, keyPath: string, isRequired: boolean, description: string}} ctx
|
|
346
|
+
* @returns {Array<object>}
|
|
347
|
+
*/
|
|
348
|
+
function leafRow(schema, ctx) {
|
|
349
|
+
const { flat, keyPath, isRequired, description } = ctx;
|
|
350
|
+
return [
|
|
351
|
+
{
|
|
352
|
+
key: keyPath,
|
|
353
|
+
required: isRequired ? 'Yes' : 'No',
|
|
354
|
+
type: renderType(schema, flat),
|
|
355
|
+
def: renderDefault(flat.default),
|
|
356
|
+
description: description || '—',
|
|
357
|
+
},
|
|
358
|
+
];
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Ordered row-shape builders for one property. The loop in flattenObject
|
|
362
|
+
// returns the first builder that yields rows (non-null), matching the
|
|
363
|
+
// original if/continue ladder: nested-object first, array-of-objects next,
|
|
364
|
+
// scalar leaf as the always-matching fallthrough.
|
|
365
|
+
const ROW_BUILDERS = [nestedObjectRows, arrayOfObjectsRows, leafRow];
|
|
366
|
+
|
|
236
367
|
/**
|
|
237
368
|
* Flatten one object-typed schema node into table rows. Recurses into
|
|
238
369
|
* nested `object` properties (resolving `$ref`s along the way) so dot-paths
|
|
@@ -260,57 +391,21 @@ function flattenObject(schema, node, pathParts, required) {
|
|
|
260
391
|
for (const [propName, rawChild] of Object.entries(properties)) {
|
|
261
392
|
const { node: child } = resolveNode(schema, rawChild);
|
|
262
393
|
const flat = flattenAllOf(schema, child);
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
description: description || 'Nested configuration block.',
|
|
277
|
-
});
|
|
278
|
-
const childRequired = new Set(
|
|
279
|
-
Array.isArray(flat.required) ? flat.required : [],
|
|
280
|
-
);
|
|
281
|
-
rows.push(
|
|
282
|
-
...flattenObject(schema, flat, [...pathParts, propName], childRequired),
|
|
283
|
-
);
|
|
284
|
-
continue;
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
// Array-of-objects: emit one row and describe the item shape.
|
|
288
|
-
if (flat.type === 'array' && flat.items) {
|
|
289
|
-
const { node: itemNode, refName } = resolveNode(schema, flat.items);
|
|
290
|
-
if (itemNode && itemNode.type === 'object' && itemNode.properties) {
|
|
291
|
-
const itemKeys = Object.keys(itemNode.properties).join(', ');
|
|
292
|
-
const suffix = refName ? ` (\`${refName}\`)` : '';
|
|
293
|
-
const desc =
|
|
294
|
-
(description ? `${description} ` : '') +
|
|
295
|
-
`Each item${suffix} has: ${itemKeys}.`;
|
|
296
|
-
rows.push({
|
|
297
|
-
key: `${keyPath}[]`,
|
|
298
|
-
required: isRequired ? 'Yes' : 'No',
|
|
299
|
-
type: renderType(schema, flat),
|
|
300
|
-
def: renderDefault(flat.default),
|
|
301
|
-
description: desc,
|
|
302
|
-
});
|
|
303
|
-
continue;
|
|
394
|
+
const ctx = {
|
|
395
|
+
flat,
|
|
396
|
+
keyPath: [...pathParts, propName].join('.'),
|
|
397
|
+
pathParts,
|
|
398
|
+
propName,
|
|
399
|
+
isRequired: required.has(propName) || localRequired.has(propName),
|
|
400
|
+
description: flat.description || rawChild.description || '',
|
|
401
|
+
};
|
|
402
|
+
for (const build of ROW_BUILDERS) {
|
|
403
|
+
const built = build(schema, ctx);
|
|
404
|
+
if (built !== null) {
|
|
405
|
+
rows.push(...built);
|
|
406
|
+
break;
|
|
304
407
|
}
|
|
305
408
|
}
|
|
306
|
-
|
|
307
|
-
rows.push({
|
|
308
|
-
key: keyPath,
|
|
309
|
-
required: isRequired ? 'Yes' : 'No',
|
|
310
|
-
type: renderType(schema, flat),
|
|
311
|
-
def: renderDefault(flat.default),
|
|
312
|
-
description: description || '—',
|
|
313
|
-
});
|
|
314
409
|
}
|
|
315
410
|
|
|
316
411
|
return rows;
|
|
@@ -17,8 +17,6 @@
|
|
|
17
17
|
|
|
18
18
|
import { groupRows, resolveComponents } from '../baselines/components.js';
|
|
19
19
|
|
|
20
|
-
const SEVERITIES = Object.freeze(['critical', 'high', 'medium', 'low']);
|
|
21
|
-
|
|
22
20
|
/**
|
|
23
21
|
* Pure: count findings into a {critical,high,medium,low} histogram. Findings
|
|
24
22
|
* with severities outside that set are ignored, keeping the rendered summary
|
|
@@ -37,8 +35,6 @@ export function aggregateSummary(findings) {
|
|
|
37
35
|
return summary;
|
|
38
36
|
}
|
|
39
37
|
|
|
40
|
-
export const KNOWN_SEVERITIES = SEVERITIES;
|
|
41
|
-
|
|
42
38
|
/**
|
|
43
39
|
* Resolve the per-component rollup map for an envelope. When the envelope
|
|
44
40
|
* already carries a `rollup` block (every writer-produced baseline does), we
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/audit-to-stories/audit-lenses.js — SSOT for the canonical audit lens
|
|
3
|
+
* taxonomy.
|
|
4
|
+
*
|
|
5
|
+
* The `/audit-<lens>` workflows each emit a report named
|
|
6
|
+
* `audit-<lens>-results.md`. The `audit::<lens>` GitHub label set is keyed off
|
|
7
|
+
* exactly these lens names — never off a finding's fine-grained `dimension`
|
|
8
|
+
* text. `build-story-body.js` derives a group's `audit::<lens>` labels from
|
|
9
|
+
* each finding's `sourceReport` basename via {@link lensFromSourceReport}, and
|
|
10
|
+
* `audit-labels-bootstrap.js` creates one `audit::<lens>` label per entry in
|
|
11
|
+
* {@link AUDIT_LENSES}. Centralising the list here keeps the label producer
|
|
12
|
+
* (bootstrap) and the label deriver (story-body) from drifting apart.
|
|
13
|
+
*
|
|
14
|
+
* Pure: no I/O.
|
|
15
|
+
*
|
|
16
|
+
* @see Story #4195 — junk `audit::<dimension>` label derivation.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The canonical lens names, one per `/audit-<lens>` workflow under
|
|
21
|
+
* `.agents/workflows/` (excluding the `audit-to-stories` and `audit-fan-out`
|
|
22
|
+
* meta-workflows, which produce no `audit-<lens>-results.md` of their own).
|
|
23
|
+
* Adding a new `audit-*` workflow MUST add its lens name here.
|
|
24
|
+
*
|
|
25
|
+
* @type {ReadonlyArray<string>}
|
|
26
|
+
*/
|
|
27
|
+
export const AUDIT_LENSES = Object.freeze([
|
|
28
|
+
'architecture',
|
|
29
|
+
'clean-code',
|
|
30
|
+
'dependencies',
|
|
31
|
+
'devops',
|
|
32
|
+
'documentation',
|
|
33
|
+
'lighthouse',
|
|
34
|
+
'navigability',
|
|
35
|
+
'performance',
|
|
36
|
+
'privacy',
|
|
37
|
+
'quality',
|
|
38
|
+
'security',
|
|
39
|
+
'seo',
|
|
40
|
+
'sre',
|
|
41
|
+
'ux-ui',
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
/** O(1) membership set for {@link isCanonicalLens}. */
|
|
45
|
+
const LENS_SET = new Set(AUDIT_LENSES);
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {string} lens
|
|
49
|
+
* @returns {boolean} true when `lens` is one of the canonical {@link AUDIT_LENSES}.
|
|
50
|
+
*/
|
|
51
|
+
export function isCanonicalLens(lens) {
|
|
52
|
+
return LENS_SET.has(lens);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Derive the canonical lens name from an `audit-<lens>-results.md` source
|
|
57
|
+
* report path. The derivation is basename-only (the directory — typically
|
|
58
|
+
* `temp/audits/` — is irrelevant) and matches the exact
|
|
59
|
+
* `audit-<lens>-results.md` shape the audit workflows emit. Backslash and
|
|
60
|
+
* forward-slash separators are both handled so a Windows-authored path
|
|
61
|
+
* resolves identically.
|
|
62
|
+
*
|
|
63
|
+
* Returns `null` when the path does not match the expected shape or the
|
|
64
|
+
* extracted lens is not one of the canonical {@link AUDIT_LENSES} — callers
|
|
65
|
+
* drop a `null` so a stray report name can never mint a junk `audit::*` label.
|
|
66
|
+
*
|
|
67
|
+
* @param {unknown} sourceReport — e.g. `temp/audits/audit-clean-code-results.md`.
|
|
68
|
+
* @returns {string|null} the canonical lens (`clean-code`) or `null`.
|
|
69
|
+
*/
|
|
70
|
+
export function lensFromSourceReport(sourceReport) {
|
|
71
|
+
if (typeof sourceReport !== 'string' || sourceReport.length === 0) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
const normalised = sourceReport.replace(/\\/g, '/');
|
|
75
|
+
const base = normalised.slice(normalised.lastIndexOf('/') + 1);
|
|
76
|
+
const match = base.match(/^audit-(.+)-results\.md$/);
|
|
77
|
+
if (!match) return null;
|
|
78
|
+
const lens = match[1];
|
|
79
|
+
return isCanonicalLens(lens) ? lens : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Derive the deduped, sorted set of canonical `audit::<lens>` labels for a
|
|
84
|
+
* collection of findings, keyed off each finding's `sourceReport` basename.
|
|
85
|
+
* Findings whose `sourceReport` does not resolve to a canonical lens
|
|
86
|
+
* contribute no label (never a junk one). Multi-lens groups (findings from
|
|
87
|
+
* more than one report) yield one label per distinct lens.
|
|
88
|
+
*
|
|
89
|
+
* @param {Array<{ sourceReport?: unknown }>} findings
|
|
90
|
+
* @returns {string[]} sorted `audit::<lens>` labels.
|
|
91
|
+
*/
|
|
92
|
+
export function auditLabelsForFindings(findings) {
|
|
93
|
+
const lenses = new Set();
|
|
94
|
+
for (const finding of findings ?? []) {
|
|
95
|
+
const lens = lensFromSourceReport(finding?.sourceReport);
|
|
96
|
+
if (lens) lenses.add(lens);
|
|
97
|
+
}
|
|
98
|
+
return [...lenses].sort().map((lens) => `audit::${lens}`);
|
|
99
|
+
}
|
|
@@ -6,8 +6,10 @@
|
|
|
6
6
|
* criteria #8: Title (caller), Summary, Acceptance Criteria, Agent
|
|
7
7
|
* Prompts, Context block, fingerprint footer.
|
|
8
8
|
*
|
|
9
|
-
* Pure: returns { title, body, labels }. Labels
|
|
10
|
-
* `audit::<
|
|
9
|
+
* Pure: returns { title, body, labels }. Labels carry one canonical
|
|
10
|
+
* `audit::<lens>` per distinct source report represented in the merge
|
|
11
|
+
* (derived from each finding's `sourceReport` basename, NEVER from the
|
|
12
|
+
* fine-grained `dimension` text — see Story #4195), plus the standard
|
|
11
13
|
* `type::story`, `agent::ready`, and (when any finding is Critical)
|
|
12
14
|
* `risk::high`.
|
|
13
15
|
*
|
|
@@ -19,6 +21,7 @@
|
|
|
19
21
|
*/
|
|
20
22
|
|
|
21
23
|
import { serialize } from '../story-body/story-body.js';
|
|
24
|
+
import { auditLabelsForFindings } from './audit-lenses.js';
|
|
22
25
|
import { renderFingerprintFooter } from './finding-adapter.js';
|
|
23
26
|
|
|
24
27
|
const STATIC_LABELS = Object.freeze(['type::story', 'agent::ready']);
|
|
@@ -71,9 +74,14 @@ function contextLinksFromGroup(group) {
|
|
|
71
74
|
}
|
|
72
75
|
|
|
73
76
|
function labelsForGroup(group) {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
+
// Derive `audit::<lens>` from each finding's `sourceReport` basename
|
|
78
|
+
// (`audit-<lens>-results.md` → `audit::<lens>`), NOT from the finding's
|
|
79
|
+
// fine-grained `dimension` text. The dimension is free-form prose
|
|
80
|
+
// ("stale-description", "dry", "efficiency (cpu)") and minting
|
|
81
|
+
// `audit::<dimension>` from it produced non-existent labels; only the 14
|
|
82
|
+
// canonical lens labels are valid. Multi-lens groups carry one label per
|
|
83
|
+
// distinct source report. See Story #4195.
|
|
84
|
+
const auditLabels = auditLabelsForFindings(group.findings ?? []);
|
|
77
85
|
const labels = [...STATIC_LABELS, ...auditLabels];
|
|
78
86
|
const hasCritical = (group.findings ?? []).some(
|
|
79
87
|
(f) => f.severity === 'critical',
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
resolveEscomplexVersion,
|
|
21
21
|
resolveTsTranspilerVersion,
|
|
22
22
|
scanAndScore,
|
|
23
|
+
scanAndScoreCombined,
|
|
23
24
|
} from './crap-utils.js';
|
|
24
25
|
import { ensureEpicBranchRef as defaultEnsureEpicBranchRef } from './git-branch-lifecycle.js';
|
|
25
26
|
import { calculateAll, scanDirectory } from './maintainability-utils.js';
|
|
@@ -562,6 +563,126 @@ function crapDirsMatchMi({
|
|
|
562
563
|
);
|
|
563
564
|
}
|
|
564
565
|
|
|
566
|
+
/**
|
|
567
|
+
* Config-only sibling of `crapDirsMatchMi` (Story #4192): decide — before any
|
|
568
|
+
* tree scan — whether the MI and CRAP passes target the same dirs with the
|
|
569
|
+
* same ignore globs. When true, `regenerateMainFromTree` collapses the two
|
|
570
|
+
* escomplex passes into a single combined `analyzeOnce` scan; when false it
|
|
571
|
+
* falls back to the independent two-pass path. Pure array compare, no scan
|
|
572
|
+
* list required.
|
|
573
|
+
*/
|
|
574
|
+
function crapConfigMatchesMi({
|
|
575
|
+
crapTargetDirs,
|
|
576
|
+
crapIgnoreGlobs,
|
|
577
|
+
miTargetDirs,
|
|
578
|
+
miIgnoreGlobs,
|
|
579
|
+
}) {
|
|
580
|
+
return (
|
|
581
|
+
crapTargetDirs.length === miTargetDirs.length &&
|
|
582
|
+
crapTargetDirs.every((d, i) => d === miTargetDirs[i]) &&
|
|
583
|
+
crapIgnoreGlobs.length === miIgnoreGlobs.length &&
|
|
584
|
+
crapIgnoreGlobs.every((g, i) => g === miIgnoreGlobs[i])
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Run the combined MI + CRAP single-pass scan once and project it into the
|
|
590
|
+
* `{ calculateAllFn, scanDirectoryFn, scanAndScoreFn, loadCoverageFn }`
|
|
591
|
+
* injection seams that `regenerateMaintainability` / `regenerateCrap` already
|
|
592
|
+
* consume. Returns `null` when the combined path is NOT eligible (either
|
|
593
|
+
* baseline unconfigured, dirs/globs differ, or coverage is required but
|
|
594
|
+
* missing) — the caller then falls back to the two independent passes.
|
|
595
|
+
*
|
|
596
|
+
* Eligibility deliberately requires coverage to be present under
|
|
597
|
+
* `requireCoverage`: when coverage is missing, the two-pass path takes a
|
|
598
|
+
* dedicated `no-coverage` short-circuit for CRAP (no scan at all), and
|
|
599
|
+
* reproducing that precise envelope is cleaner by deferring to the existing
|
|
600
|
+
* `regenerateCrap` branch than by routing through the combined scanner.
|
|
601
|
+
*
|
|
602
|
+
* Story #4192 — collapses the duplicate escomplex AST parse on the full-tree
|
|
603
|
+
* baseline path (2 parses/file → 1 parse/file). Byte-for-byte equivalent to
|
|
604
|
+
* the two-pass path: the combined scan returns the same MI score map and the
|
|
605
|
+
* same CRAP rows the separate passes would, and the downstream projection +
|
|
606
|
+
* writer logic is shared verbatim.
|
|
607
|
+
*
|
|
608
|
+
* @returns {Promise<null | {
|
|
609
|
+
* calculateAllFn: () => Promise<Record<string, number>>,
|
|
610
|
+
* scanDirectoryFn: (dir: string, list: string[]) => string[],
|
|
611
|
+
* scanAndScoreFn: () => Promise<{ rows: Array<object> }>,
|
|
612
|
+
* loadCoverageFn: () => object,
|
|
613
|
+
* }>}
|
|
614
|
+
*/
|
|
615
|
+
async function buildCombinedScanSeams({
|
|
616
|
+
cwd,
|
|
617
|
+
baselines,
|
|
618
|
+
quality,
|
|
619
|
+
loadCoverageFn,
|
|
620
|
+
scanAndScoreCombinedFn,
|
|
621
|
+
}) {
|
|
622
|
+
const miPath = baselines?.maintainability?.path;
|
|
623
|
+
const crapPath = baselines?.crap?.path;
|
|
624
|
+
if (
|
|
625
|
+
typeof miPath !== 'string' ||
|
|
626
|
+
miPath.length === 0 ||
|
|
627
|
+
typeof crapPath !== 'string' ||
|
|
628
|
+
crapPath.length === 0
|
|
629
|
+
) {
|
|
630
|
+
return null; // both baselines must be configured to combine
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const miTargetDirs = quality?.maintainability?.targetDirs ?? [];
|
|
634
|
+
const miIgnoreGlobs = quality?.maintainability?.ignoreGlobs ?? [];
|
|
635
|
+
const crapCfg = quality?.crap ?? {};
|
|
636
|
+
const crapTargetDirs = Array.isArray(crapCfg.targetDirs)
|
|
637
|
+
? crapCfg.targetDirs
|
|
638
|
+
: [];
|
|
639
|
+
const crapIgnoreGlobs = Array.isArray(crapCfg.ignoreGlobs)
|
|
640
|
+
? crapCfg.ignoreGlobs
|
|
641
|
+
: [];
|
|
642
|
+
|
|
643
|
+
if (
|
|
644
|
+
!crapConfigMatchesMi({
|
|
645
|
+
crapTargetDirs,
|
|
646
|
+
crapIgnoreGlobs,
|
|
647
|
+
miTargetDirs,
|
|
648
|
+
miIgnoreGlobs,
|
|
649
|
+
})
|
|
650
|
+
) {
|
|
651
|
+
return null; // dirs/globs differ → two-pass fallback
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const requireCoverage = crapCfg.requireCoverage !== false;
|
|
655
|
+
const coveragePath = crapCfg.coveragePath ?? 'coverage/coverage-final.json';
|
|
656
|
+
const coverageAbs = path.isAbsolute(coveragePath)
|
|
657
|
+
? coveragePath
|
|
658
|
+
: path.resolve(cwd, coveragePath);
|
|
659
|
+
const coverage = loadCoverageFn(coverageAbs);
|
|
660
|
+
|
|
661
|
+
if (!coverage && requireCoverage) {
|
|
662
|
+
return null; // no coverage → let the two-pass CRAP short-circuit run
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const { miScores, crap } = await scanAndScoreCombinedFn({
|
|
666
|
+
targetDirs: miTargetDirs,
|
|
667
|
+
coverage,
|
|
668
|
+
requireCoverage,
|
|
669
|
+
cwd,
|
|
670
|
+
ignoreGlobs: miIgnoreGlobs,
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
return {
|
|
674
|
+
// MI consumes the precomputed score map directly; the scanDirectory seam
|
|
675
|
+
// is short-circuited to a no-op list (the combined scan already walked
|
|
676
|
+
// the tree), so regenerateMaintainability's projection runs unchanged.
|
|
677
|
+
calculateAllFn: async () => miScores,
|
|
678
|
+
scanDirectoryFn: (_dir, list = []) => list,
|
|
679
|
+
// CRAP consumes the precomputed scan result; coverage is already loaded
|
|
680
|
+
// so its loadCoverage seam returns the same object without re-reading.
|
|
681
|
+
scanAndScoreFn: async () => crap,
|
|
682
|
+
loadCoverageFn: () => coverage,
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
|
|
565
686
|
/**
|
|
566
687
|
* Regenerate the CRAP baseline from a fresh tree scan + coverage map.
|
|
567
688
|
* Returns `null` when no CRAP baseline path is configured. Story #4075 —
|
|
@@ -675,6 +796,7 @@ export async function regenerateMainFromTree({
|
|
|
675
796
|
scanDirectoryFn = scanDirectory,
|
|
676
797
|
calculateAllFn = calculateAll,
|
|
677
798
|
scanAndScoreFn = scanAndScore,
|
|
799
|
+
scanAndScoreCombinedFn = scanAndScoreCombined,
|
|
678
800
|
loadCoverageFn = loadCoverage,
|
|
679
801
|
resolveEscomplexVersionFn = resolveEscomplexVersion,
|
|
680
802
|
resolveTsTranspilerVersionFn = resolveTsTranspilerVersion,
|
|
@@ -689,12 +811,49 @@ export async function regenerateMainFromTree({
|
|
|
689
811
|
const files = [];
|
|
690
812
|
let didChange = false;
|
|
691
813
|
|
|
814
|
+
// Story #4192 — when MI and CRAP target the same dirs/globs (and coverage
|
|
815
|
+
// is present), collapse the two escomplex passes into a single combined
|
|
816
|
+
// `analyzeOnce` scan. The combined scan is projected into the same scan
|
|
817
|
+
// seams the two passes consume, so the MI/CRAP envelope projection + writer
|
|
818
|
+
// logic below runs byte-for-byte identically either way. When the combined
|
|
819
|
+
// path is not eligible, `combined` is `null` and the original two
|
|
820
|
+
// independent passes run.
|
|
821
|
+
//
|
|
822
|
+
// The combined path is an internal optimization of the *production-default*
|
|
823
|
+
// scan seams. A caller that injects its own `calculateAllFn` or
|
|
824
|
+
// `scanAndScoreFn` is explicitly opting into the two independent passes
|
|
825
|
+
// (the DI contract: "use my seam"), so the combined path defers to those
|
|
826
|
+
// injected seams rather than silently bypassing them. In production neither
|
|
827
|
+
// seam is overridden, so the optimization is always taken.
|
|
828
|
+
const usingDefaultScanSeams =
|
|
829
|
+
calculateAllFn === calculateAll && scanAndScoreFn === scanAndScore;
|
|
830
|
+
const combined = usingDefaultScanSeams
|
|
831
|
+
? await buildCombinedScanSeams({
|
|
832
|
+
cwd,
|
|
833
|
+
baselines,
|
|
834
|
+
quality,
|
|
835
|
+
loadCoverageFn,
|
|
836
|
+
scanAndScoreCombinedFn,
|
|
837
|
+
})
|
|
838
|
+
: null;
|
|
839
|
+
|
|
840
|
+
const miCalculateAllFn = combined ? combined.calculateAllFn : calculateAllFn;
|
|
841
|
+
const miScanDirectoryFn = combined
|
|
842
|
+
? combined.scanDirectoryFn
|
|
843
|
+
: scanDirectoryFn;
|
|
844
|
+
const crapScanAndScoreFn = combined
|
|
845
|
+
? combined.scanAndScoreFn
|
|
846
|
+
: scanAndScoreFn;
|
|
847
|
+
const crapLoadCoverageFn = combined
|
|
848
|
+
? combined.loadCoverageFn
|
|
849
|
+
: loadCoverageFn;
|
|
850
|
+
|
|
692
851
|
const miScan = await regenerateMaintainability({
|
|
693
852
|
cwd,
|
|
694
853
|
baselines,
|
|
695
854
|
quality,
|
|
696
|
-
scanDirectoryFn,
|
|
697
|
-
calculateAllFn,
|
|
855
|
+
scanDirectoryFn: miScanDirectoryFn,
|
|
856
|
+
calculateAllFn: miCalculateAllFn,
|
|
698
857
|
writeFn,
|
|
699
858
|
writeFileFn,
|
|
700
859
|
loadPriorFn,
|
|
@@ -711,8 +870,8 @@ export async function regenerateMainFromTree({
|
|
|
711
870
|
quality,
|
|
712
871
|
logger,
|
|
713
872
|
miScan,
|
|
714
|
-
scanAndScoreFn,
|
|
715
|
-
loadCoverageFn,
|
|
873
|
+
scanAndScoreFn: crapScanAndScoreFn,
|
|
874
|
+
loadCoverageFn: crapLoadCoverageFn,
|
|
716
875
|
resolveEscomplexVersionFn,
|
|
717
876
|
resolveTsTranspilerVersionFn,
|
|
718
877
|
writeFn,
|
|
@@ -739,7 +739,3 @@ function validateOptions({ kind, scopeFiles, fullScope, writePath }) {
|
|
|
739
739
|
);
|
|
740
740
|
}
|
|
741
741
|
}
|
|
742
|
-
|
|
743
|
-
// Exposed for the lint/test invariant (Task #2208) so the guard can list
|
|
744
|
-
// every kind the service is contracted to dispatch without re-deriving it.
|
|
745
|
-
export const REFRESH_SERVICE_SUPPORTED_KINDS = SUPPORTED_KINDS;
|