mandrel 2.3.0 → 2.5.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/audit-checklists/accessibility.md +29 -0
- package/.agents/audit-checklists/architecture.md +4 -5
- package/.agents/audit-checklists/clean-code.md +10 -0
- package/.agents/audit-checklists/data-model.md +22 -0
- package/.agents/audit-checklists/dependencies.md +11 -2
- package/.agents/audit-checklists/devops.md +4 -0
- package/.agents/audit-checklists/navigability.md +3 -0
- package/.agents/audit-checklists/performance.md +8 -11
- package/.agents/audit-checklists/privacy.md +3 -4
- package/.agents/audit-checklists/quality.md +2 -0
- package/.agents/audit-checklists/security.md +4 -5
- package/.agents/audit-checklists/seo.md +7 -1
- package/.agents/audit-checklists/sre.md +14 -12
- package/.agents/audit-checklists/ux-ui.md +4 -0
- package/.agents/docs/configuration.md +3 -0
- package/.agents/docs/workflows.md +4 -3
- package/.agents/schemas/agentrc.schema.json +17 -0
- package/.agents/schemas/audit-rules.json +134 -19
- package/.agents/schemas/audit-rules.schema.json +6 -2
- package/.agents/scripts/audit-labels-bootstrap.js +4 -4
- package/.agents/scripts/audit-to-stories.js +244 -19
- package/.agents/scripts/lib/audit-suite/checklist-threading.js +26 -3
- package/.agents/scripts/lib/audit-suite/dispatch-checklist.js +132 -0
- package/.agents/scripts/lib/audit-suite/index.js +1 -0
- package/.agents/scripts/lib/audit-suite/selector.js +290 -14
- package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +2 -1
- package/.agents/scripts/lib/audit-to-stories/build-story-body.js +5 -1
- package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +23 -3
- package/.agents/scripts/lib/audit-to-stories/finding-adapter.js +38 -0
- package/.agents/scripts/lib/audit-to-stories/ledger.js +256 -0
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +41 -7
- package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +20 -2
- package/.agents/scripts/lib/command-header.js +1 -1
- package/.agents/scripts/lib/config-settings-schema-delivery.js +21 -0
- package/.agents/scripts/lib/dynamic-workflow/performance-report-contract.js +5 -3
- package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +56 -0
- package/.agents/scripts/lib/findings/route-finding.js +108 -10
- package/.agents/scripts/lib/observability/runtime-friction.js +62 -0
- package/.agents/scripts/lib/orchestration/resolve-stories.js +9 -1
- package/.agents/scripts/lib/orchestration/retro-proposals.js +49 -1
- package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +73 -15
- package/.agents/scripts/lib/orchestration/story-close/phases/local-lens-review.js +81 -1
- package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +1 -0
- package/.agents/scripts/lib/orchestration/ticket-lease.js +78 -10
- package/.agents/scripts/lib/orchestration/ticketing/transition.js +68 -16
- package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +73 -0
- package/.agents/scripts/lib/wave-runner/live-probe.js +93 -12
- package/.agents/scripts/nav-registry-diff.js +449 -0
- package/.agents/scripts/single-story-init.js +189 -51
- package/.agents/scripts/stories-wave-tick.js +39 -2
- package/.agents/workflows/audit-accessibility.md +243 -0
- package/.agents/workflows/audit-architecture.md +89 -71
- package/.agents/workflows/audit-clean-code.md +87 -53
- package/.agents/workflows/audit-data-model.md +198 -0
- package/.agents/workflows/audit-dependencies.md +143 -28
- package/.agents/workflows/audit-devops.md +109 -18
- package/.agents/workflows/audit-documentation.md +25 -53
- package/.agents/workflows/audit-navigability.md +78 -22
- package/.agents/workflows/audit-performance.md +207 -103
- package/.agents/workflows/audit-privacy.md +51 -13
- package/.agents/workflows/audit-quality.md +71 -61
- package/.agents/workflows/audit-security.md +94 -71
- package/.agents/workflows/audit-seo.md +80 -25
- package/.agents/workflows/audit-sre.md +99 -66
- package/.agents/workflows/audit-to-stories.md +44 -5
- package/.agents/workflows/audit-ux-ui.md +71 -17
- package/.agents/workflows/deliver.md +32 -8
- package/.agents/workflows/helpers/audit-dual-path.md +59 -0
- package/.agents/workflows/helpers/audit-self-check.md +70 -0
- package/.agents/workflows/helpers/audit-severity-scale.md +19 -0
- package/.agents/workflows/helpers/deliver-story.md +25 -0
- package/docs/CHANGELOG.md +29 -0
- package/package.json +1 -1
- package/.agents/audit-checklists/lighthouse.md +0 -15
- package/.agents/schemas/audit-results.schema.json +0 -69
- package/.agents/workflows/audit-lighthouse.md +0 -269
|
@@ -36,9 +36,16 @@ import { buildStoryBody } from './lib/audit-to-stories/build-story-body.js';
|
|
|
36
36
|
import { classifyGroupsAgainstGitHub } from './lib/audit-to-stories/dedupe-against-github.js';
|
|
37
37
|
import { withFingerprints } from './lib/audit-to-stories/finding-adapter.js';
|
|
38
38
|
import { groupFindings } from './lib/audit-to-stories/group-findings.js';
|
|
39
|
+
import {
|
|
40
|
+
DEFAULT_LEDGER_PATH,
|
|
41
|
+
readLedger,
|
|
42
|
+
reconcileLedger,
|
|
43
|
+
writeLedger,
|
|
44
|
+
} from './lib/audit-to-stories/ledger.js';
|
|
39
45
|
import { parseAuditReports } from './lib/audit-to-stories/parse-audit-md.js';
|
|
40
46
|
import { buildPlanSeedMarkdown } from './lib/audit-to-stories/seed-from-findings.js';
|
|
41
47
|
import { runAsCli } from './lib/cli-utils.js';
|
|
48
|
+
import { searchSemanticCandidates } from './lib/findings/semantic-issue-search.js';
|
|
42
49
|
import { Logger } from './lib/Logger.js';
|
|
43
50
|
import { parse as parseStoryBody } from './lib/story-body/story-body.js';
|
|
44
51
|
|
|
@@ -78,30 +85,54 @@ function tallyBySeverity(findings) {
|
|
|
78
85
|
return t;
|
|
79
86
|
}
|
|
80
87
|
|
|
81
|
-
async function loadProvider() {
|
|
88
|
+
async function loadProvider({ createProviderImpl, resolveConfigImpl } = {}) {
|
|
82
89
|
// The provider is optional — when missing, the dedupe step emits a
|
|
83
|
-
// create-only classification and the workflow operator is informed.
|
|
90
|
+
// create-only classification and the workflow operator is informed. The
|
|
91
|
+
// `createProviderImpl` / `resolveConfigImpl` seams let a contract test drive
|
|
92
|
+
// this exact adapter (fingerprint + semantic-candidate ports) with an
|
|
93
|
+
// in-memory issue store instead of the live GitHub provider.
|
|
84
94
|
try {
|
|
85
|
-
const
|
|
86
|
-
|
|
95
|
+
const resolveConfig =
|
|
96
|
+
resolveConfigImpl ??
|
|
97
|
+
(await import('./lib/config-resolver.js')).resolveConfig;
|
|
98
|
+
const createProvider =
|
|
99
|
+
createProviderImpl ??
|
|
100
|
+
(await import('./lib/provider-factory.js')).createProvider;
|
|
87
101
|
const config = resolveConfig();
|
|
88
|
-
const provider =
|
|
102
|
+
const provider = createProvider(config ?? {});
|
|
89
103
|
// The existing provider exposes higher-level ticket I/O. The dedupe
|
|
90
|
-
// module
|
|
91
|
-
//
|
|
104
|
+
// module needs `findIssuesByFingerprint(sha)` for the exact-fingerprint
|
|
105
|
+
// pass and — since Story #4626 — a `searchCandidates(finding)` port for
|
|
106
|
+
// the meaning-first Stage-1 pass. Adapt both here so we don't bake
|
|
107
|
+
// provider-shape knowledge into the dedupe module.
|
|
92
108
|
if (typeof provider.searchIssues === 'function') {
|
|
109
|
+
const owner = config?.github?.owner;
|
|
110
|
+
const repo = config?.github?.repo;
|
|
111
|
+
const normalise = (h) => ({
|
|
112
|
+
number: h.number,
|
|
113
|
+
state: (h.state ?? h.state_reason ?? 'open')
|
|
114
|
+
.toString()
|
|
115
|
+
.toLowerCase()
|
|
116
|
+
.includes('closed')
|
|
117
|
+
? 'closed'
|
|
118
|
+
: 'open',
|
|
119
|
+
title: h.title ?? '',
|
|
120
|
+
body: h.body ?? '',
|
|
121
|
+
});
|
|
93
122
|
return {
|
|
94
123
|
async findIssuesByFingerprint(sha) {
|
|
95
|
-
const hits = await provider.searchIssues({
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
124
|
+
const hits = await provider.searchIssues({ query: sha, owner, repo });
|
|
125
|
+
return (hits ?? []).map(normalise);
|
|
126
|
+
},
|
|
127
|
+
async searchCandidates(finding) {
|
|
128
|
+
// Wire the shared semantic search onto the provider's full-text
|
|
129
|
+
// issue search (open + closed) so route-finding's Stage-1 pass runs.
|
|
130
|
+
const search = async (query) => {
|
|
131
|
+
if (!query || query.trim().length === 0) return [];
|
|
132
|
+
const hits = await provider.searchIssues({ query, owner, repo });
|
|
133
|
+
return (hits ?? []).map(normalise);
|
|
134
|
+
};
|
|
135
|
+
return searchSemanticCandidates(finding, { search });
|
|
105
136
|
},
|
|
106
137
|
};
|
|
107
138
|
}
|
|
@@ -149,7 +180,7 @@ function dedupSkippedWarning(reason) {
|
|
|
149
180
|
);
|
|
150
181
|
}
|
|
151
182
|
|
|
152
|
-
async function buildPlan({ glob: pattern, severity, useProvider }) {
|
|
183
|
+
async function buildPlan({ glob: pattern, severity, useProvider, ledger }) {
|
|
153
184
|
const reportPaths = await collectReportPaths(pattern ?? DEFAULT_GLOB);
|
|
154
185
|
if (reportPaths.length === 0) {
|
|
155
186
|
return {
|
|
@@ -188,7 +219,11 @@ async function buildPlan({ glob: pattern, severity, useProvider }) {
|
|
|
188
219
|
if (useProvider) {
|
|
189
220
|
const provider = await loadProvider();
|
|
190
221
|
if (provider) {
|
|
191
|
-
const result = await classifyGroupsAgainstGitHub({
|
|
222
|
+
const result = await classifyGroupsAgainstGitHub({
|
|
223
|
+
groups,
|
|
224
|
+
provider,
|
|
225
|
+
searchCandidates: provider.searchCandidates,
|
|
226
|
+
});
|
|
192
227
|
classifications = result.classifications;
|
|
193
228
|
summary = result.summary;
|
|
194
229
|
dedupApplied = true;
|
|
@@ -205,6 +240,35 @@ async function buildPlan({ glob: pattern, severity, useProvider }) {
|
|
|
205
240
|
Logger.warn(dedupSkippedWarning('disabled'));
|
|
206
241
|
}
|
|
207
242
|
|
|
243
|
+
// Cross-run ledger (Story #4626): fold this scan onto the committed memory,
|
|
244
|
+
// suppress findings a prior run recorded as accepted-risk, and (unless the
|
|
245
|
+
// caller asked not to write) persist the updated ledger. Opt-in — the plain
|
|
246
|
+
// --scan path leaves it untouched so it never mutates a committed file.
|
|
247
|
+
let ledgerSummary;
|
|
248
|
+
if (ledger) {
|
|
249
|
+
const suppressed = reconcileScanLedger({
|
|
250
|
+
ledgerPath: ledger.path ?? DEFAULT_LEDGER_PATH,
|
|
251
|
+
findings: stamped,
|
|
252
|
+
classifications,
|
|
253
|
+
write: ledger.write !== false,
|
|
254
|
+
});
|
|
255
|
+
if (suppressed.size > 0) {
|
|
256
|
+
for (const c of classifications) {
|
|
257
|
+
const findings = c.group?.findings ?? [];
|
|
258
|
+
if (
|
|
259
|
+
findings.length > 0 &&
|
|
260
|
+
findings.every((f) => suppressed.has(f?.fingerprint?.full))
|
|
261
|
+
) {
|
|
262
|
+
c.action = 'skip-accepted-risk';
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
ledgerSummary = {
|
|
267
|
+
path: ledger.path ?? DEFAULT_LEDGER_PATH,
|
|
268
|
+
suppressed: suppressed.size,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
208
272
|
return {
|
|
209
273
|
generatedAt: new Date().toISOString(),
|
|
210
274
|
sourceReports: reportPaths,
|
|
@@ -218,16 +282,157 @@ async function buildPlan({ glob: pattern, severity, useProvider }) {
|
|
|
218
282
|
filtered: filtered.length,
|
|
219
283
|
tally: tallyBySeverity(filtered),
|
|
220
284
|
dedupApplied,
|
|
285
|
+
...(ledgerSummary ? { ledger: ledgerSummary } : {}),
|
|
221
286
|
...summary,
|
|
222
287
|
},
|
|
223
288
|
};
|
|
224
289
|
}
|
|
225
290
|
|
|
291
|
+
/**
|
|
292
|
+
* Fold the current scan onto the committed cross-run ledger and persist it.
|
|
293
|
+
* Returns the set of finding fingerprints the ledger says are accepted-risk
|
|
294
|
+
* (deliberately rejected) so the caller can suppress them.
|
|
295
|
+
*
|
|
296
|
+
* @param {object} params
|
|
297
|
+
* @param {string} params.ledgerPath
|
|
298
|
+
* @param {Array<object>} params.findings — stamped scan findings.
|
|
299
|
+
* @param {Array<{ group?: object, matchedIssues?: Array<{ number: number, state: string }>, matchedFingerprints?: string[] }>} params.classifications
|
|
300
|
+
* @param {boolean} [params.write=true]
|
|
301
|
+
* @returns {Set<string>}
|
|
302
|
+
*/
|
|
303
|
+
function reconcileScanLedger({ ledgerPath, findings, classifications, write }) {
|
|
304
|
+
const prior = readLedger(ledgerPath);
|
|
305
|
+
const issueStates = issueStatesFromClassifications(classifications);
|
|
306
|
+
const { ledger: next } = reconcileLedger({
|
|
307
|
+
ledger: prior,
|
|
308
|
+
findings,
|
|
309
|
+
issueStates,
|
|
310
|
+
});
|
|
311
|
+
if (write !== false) writeLedger(ledgerPath, next);
|
|
312
|
+
return new Set(
|
|
313
|
+
next.entries
|
|
314
|
+
.filter((e) => e.status === 'accepted-risk')
|
|
315
|
+
.map((e) => e.fingerprint),
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Derive a `{ fingerprint → issueState }` map from dedupe classifications so
|
|
321
|
+
* the ledger reconcile sees the live open/closed state of matched Issues.
|
|
322
|
+
* @param {Array<object>} classifications
|
|
323
|
+
* @returns {Record<string, { state: string, number: number|null }>}
|
|
324
|
+
*/
|
|
325
|
+
function issueStatesFromClassifications(classifications) {
|
|
326
|
+
const states = {};
|
|
327
|
+
for (const c of classifications ?? []) {
|
|
328
|
+
const issue = (c.matchedIssues ?? [])[0];
|
|
329
|
+
if (!issue) continue;
|
|
330
|
+
const state = String(issue.state ?? '')
|
|
331
|
+
.toLowerCase()
|
|
332
|
+
.includes('closed')
|
|
333
|
+
? 'closed'
|
|
334
|
+
: 'open';
|
|
335
|
+
for (const fp of c.matchedFingerprints ?? []) {
|
|
336
|
+
states[fp] = { state, number: issue.number ?? null };
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return states;
|
|
340
|
+
}
|
|
341
|
+
|
|
226
342
|
function loadPlan(planPath) {
|
|
227
343
|
if (!planPath) throw new Error('--plan <path> is required');
|
|
228
344
|
return JSON.parse(fs.readFileSync(planPath, 'utf8'));
|
|
229
345
|
}
|
|
230
346
|
|
|
347
|
+
const DEFAULT_SEVERITY_FLOOR = 'high';
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Resolve the unattended-sweep severity floor: an explicit `--severity` wins,
|
|
351
|
+
* else `delivery.auditToStories.severityFloor` from config, else the built-in
|
|
352
|
+
* default (`high`). Reads config defensively so a missing/failed resolve never
|
|
353
|
+
* breaks the run.
|
|
354
|
+
*
|
|
355
|
+
* @param {string|undefined} explicit
|
|
356
|
+
* @returns {Promise<string>}
|
|
357
|
+
*/
|
|
358
|
+
async function resolveSeverityFloor(explicit) {
|
|
359
|
+
if (explicit) return explicit;
|
|
360
|
+
try {
|
|
361
|
+
const { resolveConfig } = await import('./lib/config-resolver.js');
|
|
362
|
+
const config = resolveConfig();
|
|
363
|
+
const floor = config?.delivery?.auditToStories?.severityFloor;
|
|
364
|
+
if (typeof floor === 'string' && floor.length > 0) return floor;
|
|
365
|
+
} catch (_) {
|
|
366
|
+
// fall through to default
|
|
367
|
+
}
|
|
368
|
+
return DEFAULT_SEVERITY_FLOOR;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Unattended `--auto` sweep. No interactive gates: it resolves the severity
|
|
373
|
+
* floor from config, builds the plan (with cross-run ledger reconciliation),
|
|
374
|
+
* and reports a run summary. Under `--dry-run` it performs zero GitHub writes
|
|
375
|
+
* and emits the summary only; otherwise it returns the create-eligible Story
|
|
376
|
+
* payloads for the caller to open. Always resolves — never prompts.
|
|
377
|
+
*
|
|
378
|
+
* @param {object} params
|
|
379
|
+
* @param {string} [params.glob]
|
|
380
|
+
* @param {string} [params.severity] — explicit floor override.
|
|
381
|
+
* @param {boolean} [params.dryRun]
|
|
382
|
+
* @param {boolean} [params.useProvider]
|
|
383
|
+
* @param {string} [params.ledgerPath]
|
|
384
|
+
* @returns {Promise<{ summary: object, stories: Array<object> }>}
|
|
385
|
+
*/
|
|
386
|
+
async function runAuto({ glob, severity, dryRun, useProvider, ledgerPath }) {
|
|
387
|
+
const floor = await resolveSeverityFloor(severity);
|
|
388
|
+
const plan = await buildPlan({
|
|
389
|
+
glob,
|
|
390
|
+
severity: floor,
|
|
391
|
+
useProvider,
|
|
392
|
+
ledger: { path: ledgerPath ?? DEFAULT_LEDGER_PATH, write: !dryRun },
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
const byAction = {
|
|
396
|
+
create: [],
|
|
397
|
+
skipOpen: [],
|
|
398
|
+
skipReoccurring: [],
|
|
399
|
+
suppressed: [],
|
|
400
|
+
};
|
|
401
|
+
for (const c of plan.classifications ?? []) {
|
|
402
|
+
if (c.action === 'create') byAction.create.push(c);
|
|
403
|
+
else if (c.action === 'skip-open') byAction.skipOpen.push(c);
|
|
404
|
+
else if (c.action === 'skip-reoccurring') byAction.skipReoccurring.push(c);
|
|
405
|
+
else if (c.action === 'skip-accepted-risk') byAction.suppressed.push(c);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const eligible = byAction.create.map((c) => c.group);
|
|
409
|
+
const stories = dryRun ? [] : buildAndGateStories(eligible, plan.edges ?? []);
|
|
410
|
+
|
|
411
|
+
const summary = {
|
|
412
|
+
mode: 'auto',
|
|
413
|
+
dryRun: Boolean(dryRun),
|
|
414
|
+
severityFloor: floor,
|
|
415
|
+
sourceReports: plan.sourceReports ?? [],
|
|
416
|
+
totals: {
|
|
417
|
+
findings: plan.summary?.totalFindings ?? 0,
|
|
418
|
+
filtered: plan.summary?.filtered ?? 0,
|
|
419
|
+
groups: (plan.groups ?? []).length,
|
|
420
|
+
create: byAction.create.length,
|
|
421
|
+
skipOpen: byAction.skipOpen.length,
|
|
422
|
+
skipReoccurring: byAction.skipReoccurring.length,
|
|
423
|
+
suppressedByLedger: byAction.suppressed.length,
|
|
424
|
+
},
|
|
425
|
+
// Re-detected open Issues the operator may want a "re-detected" comment on.
|
|
426
|
+
reDetected: byAction.skipOpen
|
|
427
|
+
.flatMap((c) => c.matchedIssues ?? [])
|
|
428
|
+
.map((i) => i.number)
|
|
429
|
+
.filter((n) => typeof n === 'number'),
|
|
430
|
+
ledger: plan.summary?.ledger ?? null,
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
return { summary, stories };
|
|
434
|
+
}
|
|
435
|
+
|
|
231
436
|
/**
|
|
232
437
|
* Build every eligible group into a `{ title, body, labels }` Story object and
|
|
233
438
|
* gate the batch against the inline-contract bar BEFORE any issue is opened.
|
|
@@ -284,6 +489,10 @@ export const __testing = {
|
|
|
284
489
|
loadProvider,
|
|
285
490
|
dedupSkippedWarning,
|
|
286
491
|
buildAndGateStories,
|
|
492
|
+
runAuto,
|
|
493
|
+
resolveSeverityFloor,
|
|
494
|
+
reconcileScanLedger,
|
|
495
|
+
issueStatesFromClassifications,
|
|
287
496
|
};
|
|
288
497
|
|
|
289
498
|
async function main() {
|
|
@@ -291,10 +500,13 @@ async function main() {
|
|
|
291
500
|
args: process.argv.slice(2),
|
|
292
501
|
options: {
|
|
293
502
|
scan: { type: 'boolean' },
|
|
503
|
+
auto: { type: 'boolean' },
|
|
504
|
+
'dry-run': { type: 'boolean' },
|
|
294
505
|
'emit-plan-seed': { type: 'boolean' },
|
|
295
506
|
'emit-stories': { type: 'boolean' },
|
|
296
507
|
glob: { type: 'string' },
|
|
297
508
|
severity: { type: 'string' },
|
|
509
|
+
ledger: { type: 'string' },
|
|
298
510
|
plan: { type: 'string' },
|
|
299
511
|
out: { type: 'string' },
|
|
300
512
|
'no-provider': { type: 'boolean' },
|
|
@@ -303,6 +515,19 @@ async function main() {
|
|
|
303
515
|
strict: false,
|
|
304
516
|
});
|
|
305
517
|
|
|
518
|
+
if (values.auto) {
|
|
519
|
+
const { summary } = await runAuto({
|
|
520
|
+
glob: values.glob,
|
|
521
|
+
severity: values.severity,
|
|
522
|
+
dryRun: values['dry-run'],
|
|
523
|
+
useProvider: !values['no-provider'],
|
|
524
|
+
ledgerPath: values.ledger,
|
|
525
|
+
});
|
|
526
|
+
persist(JSON.stringify(summary, null, 2), values.out);
|
|
527
|
+
if (!values.out) process.stdout.write('\n');
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
|
|
306
531
|
if (values.scan) {
|
|
307
532
|
const plan = await buildPlan({
|
|
308
533
|
glob: values.glob,
|
|
@@ -42,7 +42,11 @@ import { AUDIT_LENSES } from '../audit-to-stories/audit-lenses.js';
|
|
|
42
42
|
import { getPaths, PROJECT_ROOT, resolveConfig } from '../config-resolver.js';
|
|
43
43
|
import { Logger } from '../Logger.js';
|
|
44
44
|
import { estimateTokens } from '../orchestration/context-envelope.js';
|
|
45
|
-
import {
|
|
45
|
+
import {
|
|
46
|
+
changeSetLacksSiblingTest,
|
|
47
|
+
matchesAnyFilePattern,
|
|
48
|
+
resolveLensTier,
|
|
49
|
+
} from './selector.js';
|
|
46
50
|
|
|
47
51
|
/**
|
|
48
52
|
* Hard cap on the assembled checklist payload, in the ≈4-char/token estimate
|
|
@@ -122,6 +126,18 @@ function filePatternsFor(rules, lens) {
|
|
|
122
126
|
return rules?.audits?.[lensKeyFor(lens)]?.triggers?.filePatterns ?? [];
|
|
123
127
|
}
|
|
124
128
|
|
|
129
|
+
/**
|
|
130
|
+
* The full `triggers` block a lens declares in the manifest (or `undefined`).
|
|
131
|
+
* Used to read non-glob trigger fields (e.g. `sourceWithoutSiblingTest`).
|
|
132
|
+
*
|
|
133
|
+
* @param {object} rules parsed `audit-rules.json`.
|
|
134
|
+
* @param {string} lens canonical lens name.
|
|
135
|
+
* @returns {object|undefined}
|
|
136
|
+
*/
|
|
137
|
+
function triggerFor(rules, lens) {
|
|
138
|
+
return rules?.audits?.[lensKeyFor(lens)]?.triggers;
|
|
139
|
+
}
|
|
140
|
+
|
|
125
141
|
/**
|
|
126
142
|
* Normalize a predicted footprint into a clean path list. Accepts a plain
|
|
127
143
|
* `string[]` (the shape the caller derives from a Story's `changes[]` /
|
|
@@ -175,8 +191,15 @@ export function matchLocalLenses({
|
|
|
175
191
|
if (tier !== 'local') continue;
|
|
176
192
|
|
|
177
193
|
const patterns = filePatternsFor(rules, lens);
|
|
178
|
-
|
|
179
|
-
|
|
194
|
+
const fileMatch =
|
|
195
|
+
patterns.length > 0 && matchesAnyFilePattern(patterns, paths);
|
|
196
|
+
// Coverage-gap routing (#4628): thread the quality checklist at write-time
|
|
197
|
+
// on the same sibling-test predicate the selector uses, so a source change
|
|
198
|
+
// that ships without a test is reminded of the quality lens up front.
|
|
199
|
+
const siblingMatch =
|
|
200
|
+
triggerFor(rules, lens)?.sourceWithoutSiblingTest === true &&
|
|
201
|
+
changeSetLacksSiblingTest(paths);
|
|
202
|
+
if (fileMatch || siblingMatch) matched.push(lens);
|
|
180
203
|
}
|
|
181
204
|
return matched;
|
|
182
205
|
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/audit-suite/dispatch-checklist.js — the deliver-dispatch call site for
|
|
3
|
+
* write-time checklist threading (Story #4627, activating Epic #4405's
|
|
4
|
+
* Story #4410 machinery).
|
|
5
|
+
*
|
|
6
|
+
* `checklist-threading.js#buildChecklistPayload` was built and unit-tested but
|
|
7
|
+
* had **zero production call sites**: nothing derived a footprint, assembled
|
|
8
|
+
* the payload, or handed a `checklistPath` to the spawned Story worker. This
|
|
9
|
+
* module is that call site. Given a Story's predicted footprint (its
|
|
10
|
+
* `changes[]` / `references[]` path entries), it assembles the footprint-matched
|
|
11
|
+
* local-lens checklist payload, writes it to the run's temp dir, and returns
|
|
12
|
+
* the `checklistPath` the deliver-story spawn threads into the maker's prompt
|
|
13
|
+
* (the same way the docs digest reaches the worker as `docsDigestPath`).
|
|
14
|
+
*
|
|
15
|
+
* Pure modulo the single file write, which is an injectable seam
|
|
16
|
+
* (`writeFileFn`) so the builder is unit-testable without touching disk. No
|
|
17
|
+
* git, no provider, no network — the footprint is the planner's *prediction*,
|
|
18
|
+
* not a diff, so no repository read is involved.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { buildChecklistPayload } from './checklist-threading.js';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Extract clean path strings from a Story's `changes[]` / `references[]`
|
|
27
|
+
* entries. Accepts both the parsed `{ path, assumption }` PathEntry shape and a
|
|
28
|
+
* bare `string`, dropping empty / non-string entries.
|
|
29
|
+
*
|
|
30
|
+
* @param {unknown} entries
|
|
31
|
+
* @returns {string[]}
|
|
32
|
+
*/
|
|
33
|
+
function footprintFromEntries(entries) {
|
|
34
|
+
if (!Array.isArray(entries)) return [];
|
|
35
|
+
return entries
|
|
36
|
+
.map((entry) => (typeof entry === 'string' ? entry : entry?.path))
|
|
37
|
+
.filter((p) => typeof p === 'string' && p.trim().length > 0)
|
|
38
|
+
.map((p) => p.trim());
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Derive the predicted footprint the checklist matcher reads: the union of a
|
|
43
|
+
* Story's `changes[]` and `references[]` path entries, in that order.
|
|
44
|
+
*
|
|
45
|
+
* Module-local: a detail of {@link buildDispatchChecklist}, exercised through
|
|
46
|
+
* that public entry point (assert the footprint the injected payload builder
|
|
47
|
+
* received) rather than imported directly, so it adds no test-only public
|
|
48
|
+
* export the dead-export ratchet would flag.
|
|
49
|
+
*
|
|
50
|
+
* @param {{ changes?: unknown, references?: unknown }} [args]
|
|
51
|
+
* @returns {string[]}
|
|
52
|
+
*/
|
|
53
|
+
function deriveDispatchFootprint({ changes, references } = {}) {
|
|
54
|
+
return [
|
|
55
|
+
...footprintFromEntries(changes),
|
|
56
|
+
...footprintFromEntries(references),
|
|
57
|
+
];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Default file writer: ensure the parent dir exists, then write the payload.
|
|
62
|
+
*
|
|
63
|
+
* @param {string} filePath
|
|
64
|
+
* @param {string} content
|
|
65
|
+
* @returns {void}
|
|
66
|
+
*/
|
|
67
|
+
function defaultWriteFile(filePath, content) {
|
|
68
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
69
|
+
fs.writeFileSync(filePath, content, 'utf8');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Build the write-time checklist payload for a Story and write it to the run's
|
|
74
|
+
* temp dir, returning the `checklistPath` the deliver-story spawn threads.
|
|
75
|
+
*
|
|
76
|
+
* When the footprint matches no local lens (the common case for a
|
|
77
|
+
* docs-only or infra-only Story), nothing is written and `checklistPath` is
|
|
78
|
+
* `null` — the worker then runs with no write-time checklist, exactly as it
|
|
79
|
+
* does today, and lens-aware coverage still runs maker-blind at Story-scope
|
|
80
|
+
* close.
|
|
81
|
+
*
|
|
82
|
+
* @param {object} args
|
|
83
|
+
* @param {number|string} args.storyId — names the payload file.
|
|
84
|
+
* @param {unknown} [args.changes] — the Story's `changes[]` path entries.
|
|
85
|
+
* @param {unknown} [args.references] — the Story's `references[]` path entries.
|
|
86
|
+
* @param {string} args.runTempDir — the run temp dir the payload is written to.
|
|
87
|
+
* @param {number} [args.tokenBudget] — override the payload cap.
|
|
88
|
+
* @param {typeof buildChecklistPayload} [args.buildPayloadFn] — injectable seam.
|
|
89
|
+
* @param {(filePath: string, content: string) => void} [args.writeFileFn] —
|
|
90
|
+
* injectable write seam (defaults to the on-disk writer).
|
|
91
|
+
* @returns {{
|
|
92
|
+
* checklistPath: string|null,
|
|
93
|
+
* skipped: boolean,
|
|
94
|
+
* matchedLenses: string[],
|
|
95
|
+
* includedLenses: string[],
|
|
96
|
+
* droppedLenses: string[],
|
|
97
|
+
* }}
|
|
98
|
+
*/
|
|
99
|
+
export function buildDispatchChecklist({
|
|
100
|
+
storyId,
|
|
101
|
+
changes,
|
|
102
|
+
references,
|
|
103
|
+
runTempDir,
|
|
104
|
+
tokenBudget,
|
|
105
|
+
buildPayloadFn = buildChecklistPayload,
|
|
106
|
+
writeFileFn = defaultWriteFile,
|
|
107
|
+
}) {
|
|
108
|
+
const footprint = deriveDispatchFootprint({ changes, references });
|
|
109
|
+
const result = buildPayloadFn({
|
|
110
|
+
footprint,
|
|
111
|
+
...(tokenBudget != null ? { tokenBudget } : {}),
|
|
112
|
+
});
|
|
113
|
+
const accounting = {
|
|
114
|
+
matchedLenses: result.matchedLenses,
|
|
115
|
+
includedLenses: result.includedLenses,
|
|
116
|
+
droppedLenses: result.droppedLenses,
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
if (!result.payload || result.includedLenses.length === 0) {
|
|
120
|
+
return { checklistPath: null, skipped: true, ...accounting };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!runTempDir) {
|
|
124
|
+
throw new TypeError(
|
|
125
|
+
'buildDispatchChecklist: runTempDir is required to write a non-empty checklist payload',
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const checklistPath = path.join(runTempDir, `story-${storyId}-checklist.md`);
|
|
130
|
+
writeFileFn(checklistPath, result.payload);
|
|
131
|
+
return { checklistPath, skipped: false, ...accounting };
|
|
132
|
+
}
|