mandrel 2.55.0 → 2.56.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/docs/agentrc-reference.json +4 -0
- package/.agents/docs/configuration.md +3 -0
- package/.agents/rules/ci-remediation.md +39 -21
- package/.agents/schemas/agentrc.schema.json +19 -0
- package/.agents/scripts/audit-to-stories.js +222 -75
- package/.agents/scripts/file-ci-gap.js +306 -0
- package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +25 -1
- package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +40 -52
- package/.agents/scripts/lib/audit-to-stories/finding-adapter.js +5 -1
- package/.agents/scripts/lib/audit-to-stories/issue-corpus.js +162 -0
- package/.agents/scripts/lib/audit-to-stories/issues-file.js +121 -0
- package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +1 -1
- package/.agents/scripts/lib/audit-to-stories/ledger-record.js +126 -0
- package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +11 -0
- package/.agents/scripts/lib/config-settings-schema.js +33 -0
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +53 -13
- package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +71 -25
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +18 -25
- package/.agents/scripts/lib/{audit-to-stories/ledger.js → findings/audit-ledger.js} +131 -24
- package/.agents/scripts/lib/findings/route-finding.js +38 -0
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/github/framework-repo.js +148 -2
- package/.agents/scripts/lib/label-constants.js +6 -1
- package/.agents/scripts/lib/observability/source-classifier.js +1 -0
- package/.agents/scripts/lib/orchestration/ci-gap-intake.js +605 -0
- package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +13 -8
- package/.agents/scripts/lib/orchestration/plan-persist/audit-provenance.js +197 -0
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +15 -2
- package/.agents/scripts/lib/orchestration/run-epilogue.js +4 -4
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +32 -20
- package/.agents/scripts/pr-watch-with-update.js +3 -2
- package/.agents/workflows/audit-to-stories.md +63 -27
- package/.agents/workflows/helpers/deliver-story-reference.md +19 -4
- package/.agents/workflows/helpers/plan-reference.md +23 -0
- package/.agents/workflows/mandrel-plan.md +6 -6
- package/docs/CHANGELOG.md +10 -0
- package/package.json +1 -1
|
@@ -22,7 +22,10 @@
|
|
|
22
22
|
* Routing correctness: a routed item already knows its source
|
|
23
23
|
* (`framework` / `consumer`), so we file each source bucket with its own
|
|
24
24
|
* constant classifier and thread the graduator's per-run filing cap across
|
|
25
|
-
* the two buckets.
|
|
25
|
+
* the two buckets. Which repository a source routes to is decided once, in
|
|
26
|
+
* `github/framework-repo.js` — the silent consumer-repo fallback that used
|
|
27
|
+
* to live here (and mis-filed framework work into the consumer's tracker)
|
|
28
|
+
* is gone from the whole path, not just from this module. The `meta::<framework-gap|consumer-improvement>` +
|
|
26
29
|
* `friction::<category>` labels are lifted verbatim from the routed item.
|
|
27
30
|
*
|
|
28
31
|
* Behind the `delivery.feedbackLoop.retroProposals` toggle (default ON,
|
|
@@ -30,7 +33,10 @@
|
|
|
30
33
|
* failure path is captured in `errors[]`.
|
|
31
34
|
*/
|
|
32
35
|
|
|
33
|
-
import {
|
|
36
|
+
import {
|
|
37
|
+
DEFAULT_FRAMEWORK_REPO,
|
|
38
|
+
parseRepoSlug,
|
|
39
|
+
} from '../github/framework-repo.js';
|
|
34
40
|
import { META_LABELS } from '../label-constants.js';
|
|
35
41
|
import {
|
|
36
42
|
contentFingerprint,
|
|
@@ -218,6 +224,8 @@ function toFinding(item, source, index) {
|
|
|
218
224
|
* @param {{owner: string, repo: string}} opts.currentRepo — the repo the
|
|
219
225
|
* retro is running inside (the consumer's own repo); the cross-repo guard's
|
|
220
226
|
* anchor.
|
|
227
|
+
* @param {{owner: string, repo: string}} [opts.platformRepo] — the shared
|
|
228
|
+
* platform/infra bucket, forwarded to the walk's routing SSOT.
|
|
221
229
|
* @param {{owner: string, repo: string}} [opts.frameworkRepo] — where
|
|
222
230
|
* framework-tagged proposals route.
|
|
223
231
|
* @param {{ framework?: object[], consumer?: object[] }} [opts.routedProposals]
|
|
@@ -239,6 +247,7 @@ export async function graduateRetroProposals({
|
|
|
239
247
|
config,
|
|
240
248
|
currentRepo,
|
|
241
249
|
frameworkRepo,
|
|
250
|
+
platformRepo,
|
|
242
251
|
routedProposals,
|
|
243
252
|
ghPath,
|
|
244
253
|
spawnImpl,
|
|
@@ -294,6 +303,7 @@ export async function graduateRetroProposals({
|
|
|
294
303
|
config,
|
|
295
304
|
currentRepo,
|
|
296
305
|
frameworkRepo,
|
|
306
|
+
platformRepo,
|
|
297
307
|
// Each bucket's source is known — a constant classifier routes the
|
|
298
308
|
// whole bucket to the correct repo and stamps the correct label.
|
|
299
309
|
classifier: () => source,
|
|
@@ -380,22 +390,6 @@ export function enrichRoutedProposalsWithFilings(routedProposals, filed) {
|
|
|
380
390
|
};
|
|
381
391
|
}
|
|
382
392
|
|
|
383
|
-
/**
|
|
384
|
-
* Parse an `"<owner>/<repo>"` slug into `{ owner, repo }`, or `null` when
|
|
385
|
-
* the slug is empty / malformed.
|
|
386
|
-
*
|
|
387
|
-
* @param {string|null|undefined} slug
|
|
388
|
-
* @returns {{ owner: string, repo: string } | null}
|
|
389
|
-
*/
|
|
390
|
-
function parseRepoSlug(slug) {
|
|
391
|
-
if (typeof slug !== 'string') return null;
|
|
392
|
-
const parts = slug.split('/');
|
|
393
|
-
if (parts.length !== 2) return null;
|
|
394
|
-
const [owner, repo] = parts;
|
|
395
|
-
if (!owner || !repo) return null;
|
|
396
|
-
return { owner, repo };
|
|
397
|
-
}
|
|
398
|
-
|
|
399
393
|
/**
|
|
400
394
|
* Orchestrating seam invoked by the retro post-and-mirror phase: gate the
|
|
401
395
|
* toggle, file the routed proposals, and return the routed proposals
|
|
@@ -452,13 +446,12 @@ export async function fileRetroProposals({
|
|
|
452
446
|
);
|
|
453
447
|
return passthrough('no-current-repo');
|
|
454
448
|
}
|
|
455
|
-
//
|
|
456
|
-
//
|
|
457
|
-
//
|
|
458
|
-
//
|
|
459
|
-
//
|
|
460
|
-
//
|
|
461
|
-
// because consumer === framework here).
|
|
449
|
+
// An unconfigured framework slug falls back to the Mandrel mirror
|
|
450
|
+
// constant, NEVER to the consumer's own repo: the retired consumer-repo
|
|
451
|
+
// fallback silently auto-filed framework-tagged proposals into the
|
|
452
|
+
// consumer's tracker while the retro body rendered them under "framework
|
|
453
|
+
// repo" (masked in this repo only because consumer === framework here).
|
|
454
|
+
// `github/framework-repo.js` is the SSOT for that rule now.
|
|
462
455
|
const frameworkRepoObj =
|
|
463
456
|
parseRepoSlug(frameworkRepo) ?? parseRepoSlug(DEFAULT_FRAMEWORK_REPO);
|
|
464
457
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* lib/audit-
|
|
2
|
+
* lib/findings/audit-ledger.js — Cross-run audit findings ledger.
|
|
3
3
|
*
|
|
4
4
|
* Without a committed memory of what a prior sweep already saw, every
|
|
5
5
|
* `/audit-to-stories` run re-litigates the whole backlog from zero: it cannot
|
|
@@ -30,10 +30,7 @@
|
|
|
30
30
|
|
|
31
31
|
import nodeFs from 'node:fs';
|
|
32
32
|
import nodePath from 'node:path';
|
|
33
|
-
import {
|
|
34
|
-
fingerprintAuditFinding,
|
|
35
|
-
semanticKeyForAuditFinding,
|
|
36
|
-
} from './finding-adapter.js';
|
|
33
|
+
import { fingerprintFinding, semanticKeyFor } from './route-finding.js';
|
|
37
34
|
|
|
38
35
|
export const DEFAULT_LEDGER_PATH = 'baselines/audit-ledger.json';
|
|
39
36
|
const LEDGER_SCHEMA_URL =
|
|
@@ -54,13 +51,24 @@ function createEmptyLedger(now = new Date().toISOString()) {
|
|
|
54
51
|
/**
|
|
55
52
|
* Compute a finding's stable identity: its fingerprint (title-sensitive) and
|
|
56
53
|
* its location-based semantic key (title-insensitive).
|
|
54
|
+
*
|
|
55
|
+
* The projection onto the canonical identity is the **caller's**, injected as
|
|
56
|
+
* `toCanonical`. That is what lets this module live beside `route-finding.js`
|
|
57
|
+
* in the shared findings layer: the audit pipeline's own adapter
|
|
58
|
+
* (`lib/audit-to-stories/finding-adapter.js`) imports *from* here, so importing
|
|
59
|
+
* it back would close a `findings → audit-to-stories → findings` cycle that
|
|
60
|
+
* `check-arch-cycles` rightly refuses. A caller that already holds canonical
|
|
61
|
+
* findings passes nothing.
|
|
62
|
+
*
|
|
57
63
|
* @param {object} finding — a parsed/stamped audit finding.
|
|
64
|
+
* @param {(finding: object) => object} [toCanonical]
|
|
58
65
|
* @returns {{ fingerprint: string, semanticKey: string }}
|
|
59
66
|
*/
|
|
60
|
-
function findingIdentity(finding) {
|
|
67
|
+
function findingIdentity(finding, toCanonical) {
|
|
68
|
+
const canonical = toCanonical ? toCanonical(finding) : finding;
|
|
61
69
|
return {
|
|
62
|
-
fingerprint:
|
|
63
|
-
semanticKey:
|
|
70
|
+
fingerprint: fingerprintFinding(canonical).full,
|
|
71
|
+
semanticKey: semanticKeyFor(canonical),
|
|
64
72
|
};
|
|
65
73
|
}
|
|
66
74
|
|
|
@@ -132,6 +140,19 @@ function resolveIssueState(id, existing, issueStates) {
|
|
|
132
140
|
};
|
|
133
141
|
}
|
|
134
142
|
|
|
143
|
+
/**
|
|
144
|
+
* The four verdicts the policy can reach, spelled once. Deduping them keeps
|
|
145
|
+
* `decideStatus` readable as the decision table it is, rather than eight
|
|
146
|
+
* near-identical object literals.
|
|
147
|
+
* @type {Record<string, { status: string, action: string }>}
|
|
148
|
+
*/
|
|
149
|
+
const VERDICT = Object.freeze({
|
|
150
|
+
filed: Object.freeze({ status: 'filed', action: 'known' }),
|
|
151
|
+
propose: Object.freeze({ status: 'new', action: 'propose' }),
|
|
152
|
+
suppress: Object.freeze({ status: 'accepted-risk', action: 'suppress' }),
|
|
153
|
+
regressed: Object.freeze({ status: 'regressed', action: 'regressed' }),
|
|
154
|
+
});
|
|
155
|
+
|
|
135
156
|
/**
|
|
136
157
|
* Decide the finding's next status + action from its prior ledger state and
|
|
137
158
|
* the live Issue state. This is the whole reconciliation policy in one place.
|
|
@@ -140,31 +161,41 @@ function resolveIssueState(id, existing, issueStates) {
|
|
|
140
161
|
* @returns {{ status: string, action: 'propose'|'known'|'suppress'|'regressed' }}
|
|
141
162
|
*/
|
|
142
163
|
function decideStatus(existing, issue) {
|
|
143
|
-
// A closed Issue is the strongest signal — its close reason drives the
|
|
164
|
+
// A closed Issue is the strongest signal — its close reason drives the
|
|
165
|
+
// verdict, and it is read FIRST so a recorded `filed` can never outrank it.
|
|
144
166
|
if (issue && issue.state === 'closed') {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
return { status: 'regressed', action: 'regressed' };
|
|
167
|
+
return issue.stateReason === 'not_planned'
|
|
168
|
+
? VERDICT.suppress
|
|
169
|
+
: // Closed as completed (or unspecified) but the finding is in this scan
|
|
170
|
+
// → it came back. That is a regression, not a fresh proposal.
|
|
171
|
+
VERDICT.regressed;
|
|
151
172
|
}
|
|
152
173
|
|
|
153
|
-
|
|
174
|
+
// An OPEN tracking Issue means the finding has been filed, whatever the prior
|
|
175
|
+
// entry said — including when there is no prior entry at all. Until Story
|
|
176
|
+
// #5305 nothing in the package ever assigned `filed`, so this fell through to
|
|
177
|
+
// `new`/`propose` on every run and the `filed` arm below was unreachable in
|
|
178
|
+
// production: the ledger suppressed nothing, and only the GitHub-search dedup
|
|
179
|
+
// stopped a sweep re-filing what it had already filed. Reading it before the
|
|
180
|
+
// `!existing` guard is what makes a record pass correct on its FIRST run
|
|
181
|
+
// rather than its second.
|
|
182
|
+
const unseen = issue?.state === 'open' ? VERDICT.filed : VERDICT.propose;
|
|
183
|
+
if (!existing) return unseen;
|
|
154
184
|
|
|
155
185
|
switch (existing.status) {
|
|
156
186
|
case 'accepted-risk':
|
|
157
|
-
return
|
|
187
|
+
return VERDICT.suppress;
|
|
158
188
|
case 'filed':
|
|
159
|
-
return
|
|
189
|
+
return VERDICT.filed;
|
|
190
|
+
// Recorded fixed, yet detected again with no closed-Issue evidence → treat
|
|
191
|
+
// as a regression the operator should look at. An open Issue does not
|
|
192
|
+
// soften that: the finding came back either way.
|
|
160
193
|
case 'fixed':
|
|
161
|
-
|
|
162
|
-
// treat as a regression the operator should look at.
|
|
163
|
-
return { status: 'regressed', action: 'regressed' };
|
|
194
|
+
return VERDICT.regressed;
|
|
164
195
|
case 'regressed':
|
|
165
|
-
return
|
|
196
|
+
return VERDICT.regressed;
|
|
166
197
|
default:
|
|
167
|
-
return
|
|
198
|
+
return unseen;
|
|
168
199
|
}
|
|
169
200
|
}
|
|
170
201
|
|
|
@@ -178,6 +209,8 @@ function decideStatus(existing, issue) {
|
|
|
178
209
|
* Live Issue state keyed by fingerprint (or semanticKey). Optional — when a
|
|
179
210
|
* prior entry already records the Issue, that is used.
|
|
180
211
|
* @param {string} [params.now] — ISO timestamp for firstSeen/lastSeen stamping.
|
|
212
|
+
* @param {(finding: object) => object} [params.toCanonical] — projection onto
|
|
213
|
+
* the canonical identity; omit when `findings` are already canonical.
|
|
181
214
|
* @returns {{
|
|
182
215
|
* ledger: { $schema: string, generatedAt: string, entries: object[] },
|
|
183
216
|
* classifications: Array<{ fingerprint: string, semanticKey: string, status: string, action: string, issue: object|null }>,
|
|
@@ -188,6 +221,7 @@ export function reconcileLedger({
|
|
|
188
221
|
findings,
|
|
189
222
|
issueStates = {},
|
|
190
223
|
now = new Date().toISOString(),
|
|
224
|
+
toCanonical,
|
|
191
225
|
} = {}) {
|
|
192
226
|
if (!Array.isArray(findings)) {
|
|
193
227
|
throw new Error('reconcileLedger: findings must be an array');
|
|
@@ -199,7 +233,7 @@ export function reconcileLedger({
|
|
|
199
233
|
const classifications = [];
|
|
200
234
|
|
|
201
235
|
for (const finding of findings) {
|
|
202
|
-
const id = findingIdentity(finding);
|
|
236
|
+
const id = findingIdentity(finding, toCanonical);
|
|
203
237
|
const existing =
|
|
204
238
|
byFingerprint.get(id.fingerprint) ??
|
|
205
239
|
(id.semanticKey ? bySemanticKey.get(id.semanticKey) : undefined) ??
|
|
@@ -254,3 +288,76 @@ export function reconcileLedger({
|
|
|
254
288
|
classifications,
|
|
255
289
|
};
|
|
256
290
|
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Record a set of already-known identities as filed against one Issue.
|
|
294
|
+
*
|
|
295
|
+
* The finding-shaped {@link reconcileLedger} cannot serve this caller:
|
|
296
|
+
* `plan-persist` never sees findings. It holds the provenance identities it
|
|
297
|
+
* stamped on a Story body — fingerprints and semantic keys, as strings — plus
|
|
298
|
+
* the issue number it just created. That is enough to record the filing, and
|
|
299
|
+
* demanding a finding it does not have would be the reason the recommended
|
|
300
|
+
* planning path never reached this ledger at all.
|
|
301
|
+
*
|
|
302
|
+
* An identity already carrying a **closed** Issue is left exactly as it is: a
|
|
303
|
+
* finding whose tracking Issue was closed `not_planned` is `accepted-risk` and
|
|
304
|
+
* must stay suppressed, and one closed as completed is a `regressed` the
|
|
305
|
+
* operator still needs to see. A fresh filing never overwrites either verdict.
|
|
306
|
+
*
|
|
307
|
+
* @param {object} params
|
|
308
|
+
* @param {{ entries?: object[] }} [params.ledger] — prior ledger (default empty).
|
|
309
|
+
* @param {Array<{ fingerprint: string, semanticKey?: string, title?: string, dimension?: string, primaryFile?: string }>} params.identities
|
|
310
|
+
* @param {{ number: number }} params.issue — the Issue these identities were filed as.
|
|
311
|
+
* @param {string} [params.now]
|
|
312
|
+
* @returns {{ ledger: object, recorded: number, skipped: number }}
|
|
313
|
+
*/
|
|
314
|
+
export function recordFiledIdentities({
|
|
315
|
+
ledger = createEmptyLedger(),
|
|
316
|
+
identities,
|
|
317
|
+
issue,
|
|
318
|
+
now = new Date().toISOString(),
|
|
319
|
+
} = {}) {
|
|
320
|
+
if (!Array.isArray(identities)) {
|
|
321
|
+
throw new Error('recordFiledIdentities: identities must be an array');
|
|
322
|
+
}
|
|
323
|
+
if (!issue || typeof issue.number !== 'number') {
|
|
324
|
+
throw new Error('recordFiledIdentities: issue.number must be a number');
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const { byFingerprint } = indexLedger(ledger);
|
|
328
|
+
const next = new Map(byFingerprint);
|
|
329
|
+
let recorded = 0;
|
|
330
|
+
let skipped = 0;
|
|
331
|
+
|
|
332
|
+
for (const identity of identities) {
|
|
333
|
+
const fingerprint = identity?.fingerprint;
|
|
334
|
+
if (typeof fingerprint !== 'string' || fingerprint.length === 0) continue;
|
|
335
|
+
const existing = byFingerprint.get(fingerprint) ?? null;
|
|
336
|
+
if (existing?.issue && existing.issue.state === 'closed') {
|
|
337
|
+
skipped += 1;
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
next.set(fingerprint, {
|
|
341
|
+
fingerprint,
|
|
342
|
+
semanticKey: identity.semanticKey ?? existing?.semanticKey ?? '',
|
|
343
|
+
title: identity.title ?? existing?.title ?? '',
|
|
344
|
+
dimension: identity.dimension ?? existing?.dimension ?? '',
|
|
345
|
+
primaryFile: identity.primaryFile ?? existing?.primaryFile ?? '',
|
|
346
|
+
status: 'filed',
|
|
347
|
+
issue: { number: issue.number, state: 'open', stateReason: null },
|
|
348
|
+
firstSeen: existing?.firstSeen ?? now,
|
|
349
|
+
lastSeen: now,
|
|
350
|
+
});
|
|
351
|
+
recorded += 1;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return {
|
|
355
|
+
ledger: {
|
|
356
|
+
$schema: ledger?.$schema ?? LEDGER_SCHEMA_URL,
|
|
357
|
+
generatedAt: now,
|
|
358
|
+
entries: [...next.values()],
|
|
359
|
+
},
|
|
360
|
+
recorded,
|
|
361
|
+
skipped,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
@@ -36,6 +36,7 @@ import { fingerprintSeverity } from './severity.js';
|
|
|
36
36
|
const SEP = '␟'; // unit separator — keeps fingerprint fields unambiguous
|
|
37
37
|
const MARKER = 'audit-fingerprints:';
|
|
38
38
|
const SEMANTIC_MARKER = 'audit-semantic-keys:';
|
|
39
|
+
const LABEL_MARKER = 'audit-labels:';
|
|
39
40
|
export const SHA1_RE = /^[0-9a-f]{40}$/;
|
|
40
41
|
// A semantic key round-trips through a comma-joined footer, so it must not
|
|
41
42
|
// carry a comma or a `>` (which would truncate the HTML comment). Both are
|
|
@@ -175,6 +176,43 @@ export function parseSemanticKeyFooter(body) {
|
|
|
175
176
|
);
|
|
176
177
|
}
|
|
177
178
|
|
|
179
|
+
/**
|
|
180
|
+
* Render the machine-readable audit-label footer
|
|
181
|
+
* (`<!-- audit-labels: audit::x,audit::y -->`).
|
|
182
|
+
*
|
|
183
|
+
* The dedup corpus is listed by `audit::*` label, so a Story carrying none is
|
|
184
|
+
* absent from the pool an indexed run matches against — and with an index in
|
|
185
|
+
* play the exact lookup is answered locally and never reaches the provider, so
|
|
186
|
+
* a fingerprint footer alone cannot rescue it. Carrying the labels through the
|
|
187
|
+
* seed is what lets the planning path stamp them without the authoring agent
|
|
188
|
+
* being asked to notice them (Story #5307).
|
|
189
|
+
*
|
|
190
|
+
* @param {string | string[]} labels
|
|
191
|
+
* @returns {string}
|
|
192
|
+
*/
|
|
193
|
+
export function auditLabelFooter(labels) {
|
|
194
|
+
const list = (Array.isArray(labels) ? labels : [labels])
|
|
195
|
+
.filter((l) => typeof l === 'string' && l.startsWith('audit::'))
|
|
196
|
+
.map((l) => l.replace(/[,>]/g, ' ').trim())
|
|
197
|
+
.filter((l) => l.length > 0);
|
|
198
|
+
if (list.length === 0) return '';
|
|
199
|
+
return `<!-- ${LABEL_MARKER} ${[...new Set(list)].sort().join(',')} -->`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Read every `audit-labels` footer out of a body, de-duplicated.
|
|
204
|
+
*
|
|
205
|
+
* @param {string} body
|
|
206
|
+
* @returns {string[]}
|
|
207
|
+
*/
|
|
208
|
+
export function parseAuditLabelFooter(body) {
|
|
209
|
+
return parseAllFooterValues(
|
|
210
|
+
body,
|
|
211
|
+
/<!--\s*audit-labels:\s*([^>]*?)\s*-->/g,
|
|
212
|
+
(s) => s.startsWith('audit::'),
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
178
216
|
/**
|
|
179
217
|
* Render the machine-readable fingerprint footer for one or more shas.
|
|
180
218
|
*
|