mandrel 2.4.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/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/nav-registry-diff.js +449 -0
- 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/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 +16 -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
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/audit-to-stories/ledger.js — Cross-run audit findings ledger.
|
|
3
|
+
*
|
|
4
|
+
* Without a committed memory of what a prior sweep already saw, every
|
|
5
|
+
* `/audit-to-stories` run re-litigates the whole backlog from zero: it cannot
|
|
6
|
+
* tell a brand-new finding from one already filed, an intentionally-rejected
|
|
7
|
+
* finding from an unseen one, or a genuine regression from routine churn. The
|
|
8
|
+
* ledger is that memory. It is a small committed JSON file
|
|
9
|
+
* (`baselines/audit-ledger.json`, the same envelope shape as the arch-cycles
|
|
10
|
+
* baseline — `{ $schema, generatedAt, entries: [] }`) keyed by each finding's
|
|
11
|
+
* shared-helper fingerprint plus a location-based `semanticKey` that survives a
|
|
12
|
+
* reworded title.
|
|
13
|
+
*
|
|
14
|
+
* Each entry carries a lifecycle `status`:
|
|
15
|
+
* - `new` — seen, not yet filed as an Issue.
|
|
16
|
+
* - `filed` — an Issue was opened; re-detections are known, not new.
|
|
17
|
+
* - `fixed` — the tracking Issue closed as completed.
|
|
18
|
+
* - `accepted-risk`— the tracking Issue closed as `not_planned`; the finding
|
|
19
|
+
* is deliberately rejected and is SUPPRESSED on re-detect.
|
|
20
|
+
* - `regressed` — a `fixed` finding re-appeared (closed-completed Issue,
|
|
21
|
+
* finding detected again).
|
|
22
|
+
*
|
|
23
|
+
* `reconcileLedger` folds a fresh scan and the live Issue states onto the prior
|
|
24
|
+
* ledger, returning the next ledger plus a per-finding classification whose
|
|
25
|
+
* `action` (`propose` | `known` | `suppress` | `regressed`) tells the caller
|
|
26
|
+
* whether to open a Story. Pure: filesystem access is confined to the tiny
|
|
27
|
+
* {@link readLedger} / {@link writeLedger} helpers, which take an injectable
|
|
28
|
+
* `fs` so tests never touch disk.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import nodeFs from 'node:fs';
|
|
32
|
+
import nodePath from 'node:path';
|
|
33
|
+
import {
|
|
34
|
+
fingerprintAuditFinding,
|
|
35
|
+
semanticKeyForAuditFinding,
|
|
36
|
+
} from './finding-adapter.js';
|
|
37
|
+
|
|
38
|
+
export const DEFAULT_LEDGER_PATH = 'baselines/audit-ledger.json';
|
|
39
|
+
const LEDGER_SCHEMA_URL =
|
|
40
|
+
'https://mandrel.dev/baselines/audit-ledger.schema.json';
|
|
41
|
+
|
|
42
|
+
// Entry lifecycle states — `new | filed | fixed | accepted-risk | regressed`.
|
|
43
|
+
// The reconcile policy in `decideStatus` is the single source of truth.
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Build an empty ledger envelope (arch-cycles-baseline shape).
|
|
47
|
+
* @param {string} [now] — ISO timestamp to stamp.
|
|
48
|
+
* @returns {{ $schema: string, generatedAt: string, entries: [] }}
|
|
49
|
+
*/
|
|
50
|
+
function createEmptyLedger(now = new Date().toISOString()) {
|
|
51
|
+
return { $schema: LEDGER_SCHEMA_URL, generatedAt: now, entries: [] };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Compute a finding's stable identity: its fingerprint (title-sensitive) and
|
|
56
|
+
* its location-based semantic key (title-insensitive).
|
|
57
|
+
* @param {object} finding — a parsed/stamped audit finding.
|
|
58
|
+
* @returns {{ fingerprint: string, semanticKey: string }}
|
|
59
|
+
*/
|
|
60
|
+
function findingIdentity(finding) {
|
|
61
|
+
return {
|
|
62
|
+
fingerprint: fingerprintAuditFinding(finding).full,
|
|
63
|
+
semanticKey: semanticKeyForAuditFinding(finding),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Read the ledger from disk. Returns an empty ledger when the file is absent
|
|
69
|
+
* or unparseable — a missing memory is an empty memory, never a hard error.
|
|
70
|
+
* @param {string} filePath
|
|
71
|
+
* @param {{ fs?: typeof import('node:fs') }} [deps]
|
|
72
|
+
* @returns {{ $schema?: string, generatedAt?: string, entries: object[] }}
|
|
73
|
+
*/
|
|
74
|
+
export function readLedger(filePath, { fs } = {}) {
|
|
75
|
+
const fsLike = fs ?? nodeFs;
|
|
76
|
+
if (!fsLike || !fsLike.existsSync(filePath)) return createEmptyLedger();
|
|
77
|
+
try {
|
|
78
|
+
const parsed = JSON.parse(fsLike.readFileSync(filePath, 'utf8'));
|
|
79
|
+
if (!parsed || !Array.isArray(parsed.entries)) return createEmptyLedger();
|
|
80
|
+
return parsed;
|
|
81
|
+
} catch (_) {
|
|
82
|
+
return createEmptyLedger();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Persist the ledger to disk with a stable 2-space indent and a trailing
|
|
88
|
+
* newline (so the committed file diffs cleanly).
|
|
89
|
+
* @param {string} filePath
|
|
90
|
+
* @param {object} ledger
|
|
91
|
+
* @param {{ fs?: typeof import('node:fs'), path?: typeof import('node:path') }} [deps]
|
|
92
|
+
*/
|
|
93
|
+
export function writeLedger(filePath, ledger, { fs, path } = {}) {
|
|
94
|
+
const fsLike = fs ?? nodeFs;
|
|
95
|
+
const pathLike = path ?? nodePath;
|
|
96
|
+
if (!fsLike) return;
|
|
97
|
+
fsLike.mkdirSync(pathLike.dirname(filePath), { recursive: true });
|
|
98
|
+
fsLike.writeFileSync(filePath, `${JSON.stringify(ledger, null, 2)}\n`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Index a ledger's entries by fingerprint and by semanticKey for O(1) lookup.
|
|
103
|
+
* @param {{ entries?: object[] }} ledger
|
|
104
|
+
*/
|
|
105
|
+
function indexLedger(ledger) {
|
|
106
|
+
const byFingerprint = new Map();
|
|
107
|
+
const bySemanticKey = new Map();
|
|
108
|
+
for (const entry of ledger?.entries ?? []) {
|
|
109
|
+
if (entry?.fingerprint) byFingerprint.set(entry.fingerprint, entry);
|
|
110
|
+
if (entry?.semanticKey) bySemanticKey.set(entry.semanticKey, entry);
|
|
111
|
+
}
|
|
112
|
+
return { byFingerprint, bySemanticKey };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Resolve the effective Issue state for a finding: an explicit override in
|
|
117
|
+
* `issueStates` (keyed by fingerprint then semanticKey) wins over whatever the
|
|
118
|
+
* prior ledger entry recorded.
|
|
119
|
+
* @param {{ fingerprint: string, semanticKey: string }} id
|
|
120
|
+
* @param {object|null} existing
|
|
121
|
+
* @param {Record<string, { state?: string, stateReason?: string|null, number?: number }>} issueStates
|
|
122
|
+
* @returns {{ state: string, stateReason: string|null, number: number|null }|null}
|
|
123
|
+
*/
|
|
124
|
+
function resolveIssueState(id, existing, issueStates) {
|
|
125
|
+
const override = issueStates[id.fingerprint] ?? issueStates[id.semanticKey];
|
|
126
|
+
const raw = override ?? existing?.issue ?? null;
|
|
127
|
+
if (!raw) return null;
|
|
128
|
+
return {
|
|
129
|
+
state: (raw.state ?? '').toLowerCase(),
|
|
130
|
+
stateReason: raw.stateReason ? String(raw.stateReason).toLowerCase() : null,
|
|
131
|
+
number: typeof raw.number === 'number' ? raw.number : null,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Decide the finding's next status + action from its prior ledger state and
|
|
137
|
+
* the live Issue state. This is the whole reconciliation policy in one place.
|
|
138
|
+
* @param {object|null} existing — prior ledger entry (or null when unseen).
|
|
139
|
+
* @param {{ state: string, stateReason: string|null }|null} issue
|
|
140
|
+
* @returns {{ status: string, action: 'propose'|'known'|'suppress'|'regressed' }}
|
|
141
|
+
*/
|
|
142
|
+
function decideStatus(existing, issue) {
|
|
143
|
+
// A closed Issue is the strongest signal — its close reason drives the verdict.
|
|
144
|
+
if (issue && issue.state === 'closed') {
|
|
145
|
+
if (issue.stateReason === 'not_planned') {
|
|
146
|
+
return { status: 'accepted-risk', action: 'suppress' };
|
|
147
|
+
}
|
|
148
|
+
// Closed as completed (or unspecified) but the finding is in this scan →
|
|
149
|
+
// it came back. That is a regression, not a fresh proposal.
|
|
150
|
+
return { status: 'regressed', action: 'regressed' };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (!existing) return { status: 'new', action: 'propose' };
|
|
154
|
+
|
|
155
|
+
switch (existing.status) {
|
|
156
|
+
case 'accepted-risk':
|
|
157
|
+
return { status: 'accepted-risk', action: 'suppress' };
|
|
158
|
+
case 'filed':
|
|
159
|
+
return { status: 'filed', action: 'known' };
|
|
160
|
+
case 'fixed':
|
|
161
|
+
// Recorded fixed, yet detected again with no closed-Issue evidence →
|
|
162
|
+
// treat as a regression the operator should look at.
|
|
163
|
+
return { status: 'regressed', action: 'regressed' };
|
|
164
|
+
case 'regressed':
|
|
165
|
+
return { status: 'regressed', action: 'regressed' };
|
|
166
|
+
default:
|
|
167
|
+
return { status: 'new', action: 'propose' };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Fold a fresh scan and the live Issue states onto the prior ledger.
|
|
173
|
+
*
|
|
174
|
+
* @param {object} params
|
|
175
|
+
* @param {{ entries?: object[] }} [params.ledger] — prior ledger (default empty).
|
|
176
|
+
* @param {Array<object>} params.findings — parsed/stamped audit findings from this scan.
|
|
177
|
+
* @param {Record<string, { state?: string, stateReason?: string|null, number?: number }>} [params.issueStates]
|
|
178
|
+
* Live Issue state keyed by fingerprint (or semanticKey). Optional — when a
|
|
179
|
+
* prior entry already records the Issue, that is used.
|
|
180
|
+
* @param {string} [params.now] — ISO timestamp for firstSeen/lastSeen stamping.
|
|
181
|
+
* @returns {{
|
|
182
|
+
* ledger: { $schema: string, generatedAt: string, entries: object[] },
|
|
183
|
+
* classifications: Array<{ fingerprint: string, semanticKey: string, status: string, action: string, issue: object|null }>,
|
|
184
|
+
* }}
|
|
185
|
+
*/
|
|
186
|
+
export function reconcileLedger({
|
|
187
|
+
ledger = createEmptyLedger(),
|
|
188
|
+
findings,
|
|
189
|
+
issueStates = {},
|
|
190
|
+
now = new Date().toISOString(),
|
|
191
|
+
} = {}) {
|
|
192
|
+
if (!Array.isArray(findings)) {
|
|
193
|
+
throw new Error('reconcileLedger: findings must be an array');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const { byFingerprint, bySemanticKey } = indexLedger(ledger);
|
|
197
|
+
// Preserve any prior entries NOT touched by this scan (their memory survives).
|
|
198
|
+
const nextByFingerprint = new Map(byFingerprint);
|
|
199
|
+
const classifications = [];
|
|
200
|
+
|
|
201
|
+
for (const finding of findings) {
|
|
202
|
+
const id = findingIdentity(finding);
|
|
203
|
+
const existing =
|
|
204
|
+
byFingerprint.get(id.fingerprint) ??
|
|
205
|
+
(id.semanticKey ? bySemanticKey.get(id.semanticKey) : undefined) ??
|
|
206
|
+
null;
|
|
207
|
+
|
|
208
|
+
const issue = resolveIssueState(id, existing, issueStates);
|
|
209
|
+
const { status, action } = decideStatus(existing, issue);
|
|
210
|
+
|
|
211
|
+
const entry = {
|
|
212
|
+
fingerprint: id.fingerprint,
|
|
213
|
+
semanticKey: id.semanticKey,
|
|
214
|
+
title: finding?.title ?? existing?.title ?? '',
|
|
215
|
+
dimension: finding?.dimension ?? existing?.dimension ?? '',
|
|
216
|
+
primaryFile:
|
|
217
|
+
(Array.isArray(finding?.files) && finding.files[0]) ??
|
|
218
|
+
existing?.primaryFile ??
|
|
219
|
+
'',
|
|
220
|
+
status,
|
|
221
|
+
issue: issue
|
|
222
|
+
? {
|
|
223
|
+
number: issue.number,
|
|
224
|
+
state: issue.state,
|
|
225
|
+
stateReason: issue.stateReason,
|
|
226
|
+
}
|
|
227
|
+
: (existing?.issue ?? null),
|
|
228
|
+
firstSeen: existing?.firstSeen ?? now,
|
|
229
|
+
lastSeen: now,
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
// Re-key under the fresh fingerprint; drop the old entry if it was matched
|
|
233
|
+
// by semanticKey under a now-drifted fingerprint (reworded finding).
|
|
234
|
+
if (existing?.fingerprint && existing.fingerprint !== id.fingerprint) {
|
|
235
|
+
nextByFingerprint.delete(existing.fingerprint);
|
|
236
|
+
}
|
|
237
|
+
nextByFingerprint.set(id.fingerprint, entry);
|
|
238
|
+
|
|
239
|
+
classifications.push({
|
|
240
|
+
fingerprint: id.fingerprint,
|
|
241
|
+
semanticKey: id.semanticKey,
|
|
242
|
+
status,
|
|
243
|
+
action,
|
|
244
|
+
issue: entry.issue,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
ledger: {
|
|
250
|
+
$schema: ledger?.$schema ?? LEDGER_SCHEMA_URL,
|
|
251
|
+
generatedAt: now,
|
|
252
|
+
entries: [...nextByFingerprint.values()],
|
|
253
|
+
},
|
|
254
|
+
classifications,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
@@ -2,11 +2,14 @@
|
|
|
2
2
|
* lib/audit-to-stories/parse-audit-md.js — Parse `audit-*-results.md` reports.
|
|
3
3
|
*
|
|
4
4
|
* Extracts the `## Detailed Findings` section of an audit report and turns
|
|
5
|
-
* every `### <title>` block into a normalised finding record.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* every `### <title>` block into a normalised finding record. Every audit lens
|
|
6
|
+
* report now shares one findings contract (Story #4625): a `Critical | High |
|
|
7
|
+
* Medium | Low` severity scale, a mandated `Location:` path anchor, and a
|
|
8
|
+
* dimension axis. Lenses still label the axes slightly differently — `Severity`
|
|
9
|
+
* vs `Impact`, `Dimension` vs `Category` vs `Type` — so the parser captures
|
|
10
|
+
* every key/value pair the block carries and normalises the two axes the
|
|
11
|
+
* downstream pipeline relies on (severity + dimension), harvesting the
|
|
12
|
+
* `Location:` field into `files[]` ahead of prose scraping.
|
|
10
13
|
*
|
|
11
14
|
* Pure: no filesystem I/O. The caller supplies the report text and source
|
|
12
15
|
* path; the parser returns plain objects.
|
|
@@ -54,7 +57,7 @@ function normaliseSeverity(token) {
|
|
|
54
57
|
}
|
|
55
58
|
|
|
56
59
|
function deriveDimension(fields, fallbackDimension) {
|
|
57
|
-
for (const key of ['dimension', 'category', 'area']) {
|
|
60
|
+
for (const key of ['dimension', 'category', 'area', 'type']) {
|
|
58
61
|
const raw = fields[key];
|
|
59
62
|
if (typeof raw === 'string' && raw.trim().length > 0) {
|
|
60
63
|
return raw
|
|
@@ -66,6 +69,33 @@ function deriveDimension(fields, fallbackDimension) {
|
|
|
66
69
|
return fallbackDimension;
|
|
67
70
|
}
|
|
68
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Harvest the mandated `Location:` field into concrete file paths. Every
|
|
74
|
+
* non-retired lens template now carries a `- **Location:** <path>:<line>`
|
|
75
|
+
* bullet (Story #4625); extracting it here — ahead of the heuristic prose
|
|
76
|
+
* scrape — gives a finding a deterministic identity anchor instead of relying
|
|
77
|
+
* on whatever path text happens to appear in the prose. The value may wrap the
|
|
78
|
+
* path in backticks and/or append a `:line` (or `:line:col`) suffix; both are
|
|
79
|
+
* stripped. Only tokens that look like paths (containing a separator) survive.
|
|
80
|
+
*
|
|
81
|
+
* @param {Record<string, string>} fields
|
|
82
|
+
* @returns {string[]}
|
|
83
|
+
*/
|
|
84
|
+
function deriveLocationFiles(fields) {
|
|
85
|
+
const raw = fields.location;
|
|
86
|
+
if (typeof raw !== 'string' || raw.trim().length === 0) return [];
|
|
87
|
+
const cleaned = raw.replace(/[`[\]]/g, ' ');
|
|
88
|
+
const out = [];
|
|
89
|
+
for (const token of cleaned.split(/[\s,]+/)) {
|
|
90
|
+
if (!token) continue;
|
|
91
|
+
const withoutLine = token.replace(/:\d+(?::\d+)?$/, '');
|
|
92
|
+
if (withoutLine.includes('/') || withoutLine.includes('\\')) {
|
|
93
|
+
out.push(withoutLine);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
69
99
|
function deriveSeverity(fields) {
|
|
70
100
|
for (const key of ['severity', 'impact', 'risk']) {
|
|
71
101
|
const sev = normaliseSeverity(fields[key]);
|
|
@@ -166,7 +196,8 @@ function parseBlockFields(bodyLines) {
|
|
|
166
196
|
* @param {object} params
|
|
167
197
|
* @param {string} params.markdown — full report text.
|
|
168
198
|
* @param {string} params.sourceReport — path used for `sourceReport` field
|
|
169
|
-
* and dimension inference when the report omits a `Dimension:`
|
|
199
|
+
* and dimension inference when the report omits a `Dimension:` / `Category:`
|
|
200
|
+
* / `Type:` line.
|
|
170
201
|
* @returns {Array<{
|
|
171
202
|
* dimension: string,
|
|
172
203
|
* severity: 'critical'|'high'|'medium'|'low'|null,
|
|
@@ -200,6 +231,7 @@ export function parseAuditReport({ markdown, sourceReport }) {
|
|
|
200
231
|
fields['recommendation & rationale'] ?? fields.recommendation ?? '';
|
|
201
232
|
const agentPrompt = fields['agent prompt'] ?? '';
|
|
202
233
|
const fileSet = new Set([
|
|
234
|
+
...deriveLocationFiles(fields),
|
|
203
235
|
...extractFilePaths(currentState),
|
|
204
236
|
...extractFilePaths(recommendation),
|
|
205
237
|
...extractFilePaths(agentPrompt),
|
|
@@ -243,4 +275,6 @@ export const __testing = {
|
|
|
243
275
|
normaliseSeverity,
|
|
244
276
|
extractFilePaths,
|
|
245
277
|
normaliseTitle,
|
|
278
|
+
deriveDimension,
|
|
279
|
+
deriveLocationFiles,
|
|
246
280
|
};
|
|
@@ -17,6 +17,11 @@
|
|
|
17
17
|
* Pure: returns a string. The caller decides where to persist it.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
+
import {
|
|
21
|
+
renderFingerprintFooter,
|
|
22
|
+
renderSemanticKeyFooter,
|
|
23
|
+
} from './finding-adapter.js';
|
|
24
|
+
|
|
20
25
|
const DIMENSION_LABEL = {
|
|
21
26
|
security: 'Security',
|
|
22
27
|
privacy: 'Privacy',
|
|
@@ -24,7 +29,7 @@ const DIMENSION_LABEL = {
|
|
|
24
29
|
'clean-code': 'Clean code',
|
|
25
30
|
dependencies: 'Dependencies',
|
|
26
31
|
devops: 'DevOps',
|
|
27
|
-
|
|
32
|
+
accessibility: 'Accessibility',
|
|
28
33
|
performance: 'Performance',
|
|
29
34
|
seo: 'SEO',
|
|
30
35
|
sre: 'SRE',
|
|
@@ -84,7 +89,20 @@ function formatMVPScope(groups) {
|
|
|
84
89
|
.map((g, idx) => {
|
|
85
90
|
const dims = g.dimensions.join(' / ');
|
|
86
91
|
const file = g.files[0] ? ` (\`${g.files[0]}\`)` : '';
|
|
87
|
-
|
|
92
|
+
// Carry each group's fingerprint (and location-based semantic-key)
|
|
93
|
+
// footer into the seed so a Story authored from it via `/plan` inherits
|
|
94
|
+
// the dedup identity — without this the recommended `/plan --seed-file`
|
|
95
|
+
// path is invisible to the next sweep's dedup (Story #4626). The footers
|
|
96
|
+
// are HTML comments, so they never render in the visible one-pager but
|
|
97
|
+
// stay machine-readable for the dedup probe.
|
|
98
|
+
const findings = Array.isArray(g.findings) ? g.findings : [];
|
|
99
|
+
const footers = [
|
|
100
|
+
renderFingerprintFooter(findings),
|
|
101
|
+
renderSemanticKeyFooter(findings),
|
|
102
|
+
]
|
|
103
|
+
.map((f) => ` ${f}`)
|
|
104
|
+
.join('\n');
|
|
105
|
+
return `${idx + 1}. **${g.title}** — ${dims}${file}\n${footers}`;
|
|
88
106
|
})
|
|
89
107
|
.join('\n');
|
|
90
108
|
}
|
|
@@ -35,7 +35,7 @@ export function applyHeader(content, header) {
|
|
|
35
35
|
/**
|
|
36
36
|
* True when a workflow opts out of slash-command projection via a
|
|
37
37
|
* `command: false` key in its YAML frontmatter (#4482). Used for dual-use
|
|
38
|
-
* lens files (e.g. `audit-security.md
|
|
38
|
+
* lens files (e.g. `audit-security.md`) that stay in
|
|
39
39
|
* the payload as `/deliver` audit-suite prompts but must NOT surface as
|
|
40
40
|
* standalone slash commands because the host ships a native equivalent.
|
|
41
41
|
*
|
|
@@ -303,6 +303,26 @@ const FEEDBACK_LOOP_SCHEMA = {
|
|
|
303
303
|
additionalProperties: false,
|
|
304
304
|
};
|
|
305
305
|
|
|
306
|
+
/**
|
|
307
|
+
* `delivery.auditToStories` — knobs for the `/audit-to-stories` unattended
|
|
308
|
+
* (`--auto`) sweep (Story #4626). `severityFloor` is the minimum severity a
|
|
309
|
+
* finding must meet to be proposed as a Story on an unattended run (default
|
|
310
|
+
* `high`); `autoComment`, when true (default), lets `--auto` post a
|
|
311
|
+
* "re-detected" comment on an already-open matched Issue instead of silently
|
|
312
|
+
* skipping it.
|
|
313
|
+
*/
|
|
314
|
+
const AUDIT_TO_STORIES_SCHEMA = {
|
|
315
|
+
type: 'object',
|
|
316
|
+
properties: {
|
|
317
|
+
severityFloor: {
|
|
318
|
+
type: 'string',
|
|
319
|
+
enum: ['critical', 'high', 'medium', 'low', 'all'],
|
|
320
|
+
},
|
|
321
|
+
autoComment: { type: 'boolean' },
|
|
322
|
+
},
|
|
323
|
+
additionalProperties: false,
|
|
324
|
+
};
|
|
325
|
+
|
|
306
326
|
export const DELIVERY_SCHEMA = {
|
|
307
327
|
type: 'object',
|
|
308
328
|
properties: {
|
|
@@ -318,6 +338,7 @@ export const DELIVERY_SCHEMA = {
|
|
|
318
338
|
refactorStage: REFACTOR_STAGE_SCHEMA,
|
|
319
339
|
acceptanceEval: ACCEPTANCE_EVAL_SCHEMA,
|
|
320
340
|
feedbackLoop: FEEDBACK_LOOP_SCHEMA,
|
|
341
|
+
auditToStories: AUDIT_TO_STORIES_SCHEMA,
|
|
321
342
|
ci: CI_DELIVERY_SCHEMA,
|
|
322
343
|
routing: ROUTING_SCHEMA,
|
|
323
344
|
},
|
|
@@ -12,9 +12,10 @@ import { assertSectionsContract } from './report-contract-core.js';
|
|
|
12
12
|
* contract-tier test assert report conformance against one definition rather
|
|
13
13
|
* than re-deriving headings from prose in two places.
|
|
14
14
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
15
|
+
* The report shape is declared by the lens markdown's Step 4 template. Story
|
|
16
|
+
* #4631 added the mandatory per-finding **Evidence** field (a repro command
|
|
17
|
+
* plus a `measured`/`estimated` tag) so measurement-first evidence is part of
|
|
18
|
+
* the contract both paths must emit, not just prose in the lens.
|
|
18
19
|
*
|
|
19
20
|
* @module dynamic-workflow/performance-report-contract
|
|
20
21
|
*/
|
|
@@ -48,6 +49,7 @@ export const REPORT_TITLE = 'Performance Audit Report';
|
|
|
48
49
|
export const FINDING_FIELDS = Object.freeze([
|
|
49
50
|
'Dimension',
|
|
50
51
|
'Impact',
|
|
52
|
+
'Evidence',
|
|
51
53
|
'Current State',
|
|
52
54
|
'Recommendation & Rationale',
|
|
53
55
|
'Agent Prompt',
|
|
@@ -45,6 +45,12 @@
|
|
|
45
45
|
* - NEVER throw. Every failure path is captured in `errors[]`.
|
|
46
46
|
*/
|
|
47
47
|
|
|
48
|
+
import {
|
|
49
|
+
fingerprintFinding,
|
|
50
|
+
fingerprintFooter,
|
|
51
|
+
semanticKeyFooter,
|
|
52
|
+
semanticKeyFor,
|
|
53
|
+
} from '../findings/route-finding.js';
|
|
48
54
|
import {
|
|
49
55
|
contentFingerprint,
|
|
50
56
|
graduate,
|
|
@@ -121,6 +127,50 @@ export function buildContentMarker(epicId, finding) {
|
|
|
121
127
|
return `<!-- audit-results-followup: epic-${epicId}-${fp} -->`;
|
|
122
128
|
}
|
|
123
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Project a graduator audit finding onto the canonical identity the shared
|
|
132
|
+
* dedup helper (`lib/findings/route-finding.js`) fingerprints over, so a
|
|
133
|
+
* close-time graduator filing and a sweep-time `/audit-to-stories` filing
|
|
134
|
+
* share ONE identity namespace (Story #4626). The graduator's `lens`
|
|
135
|
+
* (`audit-<dimension>`) maps to the identity `area`; its cited `path` is the
|
|
136
|
+
* primary file; its `summary` is the title. Historically the graduator only
|
|
137
|
+
* stamped its own content-hash `audit-results-followup` marker, disjoint from
|
|
138
|
+
* route-finding's `audit-fingerprints` footer — so a `/audit-to-stories`
|
|
139
|
+
* sweep could not recognize a graduator-filed issue and would re-file it.
|
|
140
|
+
*
|
|
141
|
+
* @param {{ lens?: string, path?: string, summary?: string }} finding
|
|
142
|
+
* @returns {{ title: string, area: string, primaryFile: string, severity: string, labels: string[] }}
|
|
143
|
+
*/
|
|
144
|
+
export function toCanonicalFinding(finding) {
|
|
145
|
+
const lens = typeof finding?.lens === 'string' ? finding.lens : '';
|
|
146
|
+
return {
|
|
147
|
+
title: typeof finding?.summary === 'string' ? finding.summary : '',
|
|
148
|
+
area: lens,
|
|
149
|
+
primaryFile: typeof finding?.path === 'string' ? finding.path : '',
|
|
150
|
+
severity: '',
|
|
151
|
+
labels: lens ? [lens] : [],
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Render the canonical `audit-fingerprints` (and location-based
|
|
157
|
+
* `audit-semantic-keys`) footer for a graduator finding, computed off the
|
|
158
|
+
* shared helper's canonical hash. Stamped into every filed follow-up body so
|
|
159
|
+
* the `/audit-to-stories` dedup probe recognizes a graduator-filed issue
|
|
160
|
+
* (Story #4626).
|
|
161
|
+
*
|
|
162
|
+
* @param {{ lens?: string, path?: string, summary?: string }} finding
|
|
163
|
+
* @returns {string}
|
|
164
|
+
*/
|
|
165
|
+
export function canonicalFingerprintFooter(finding) {
|
|
166
|
+
const canonical = toCanonicalFinding(finding);
|
|
167
|
+
const { full } = fingerprintFinding(canonical);
|
|
168
|
+
const key = semanticKeyFor(canonical);
|
|
169
|
+
const lines = [fingerprintFooter(full)];
|
|
170
|
+
if (key) lines.push(semanticKeyFooter(key));
|
|
171
|
+
return lines.join('\n');
|
|
172
|
+
}
|
|
173
|
+
|
|
124
174
|
/**
|
|
125
175
|
* Parse the rendered audit-results markdown into a list of findings.
|
|
126
176
|
* The format produced by `epic-audit.md` Step 4 groups findings under
|
|
@@ -260,6 +310,12 @@ export async function graduateAuditResults(opts = {}) {
|
|
|
260
310
|
finding.summary,
|
|
261
311
|
'',
|
|
262
312
|
`_See Epic #${epicId} for the full audit-results report._`,
|
|
313
|
+
'',
|
|
314
|
+
// Canonical dedup identity so the `/audit-to-stories` sweep-time
|
|
315
|
+
// probe recognizes this close-time filing and never re-files it
|
|
316
|
+
// (Story #4626). The content-hash `audit-results-followup` marker
|
|
317
|
+
// above stays the graduator's own re-file guard.
|
|
318
|
+
canonicalFingerprintFooter(finding),
|
|
263
319
|
].join('\n');
|
|
264
320
|
const labels = [
|
|
265
321
|
'meta::audit-finding',
|