@ankhorage/devtools 1.9.5 → 1.10.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/dist/tools/skills/assets/ankhorage-project-structure/references/skill-distribution.md +5 -0
- package/dist/tools/skills/assets/zora-designer/SKILL.md +89 -0
- package/dist/tools/skills/assets/zora-designer/agents/openai.yaml +6 -0
- package/dist/tools/skills/assets/zora-designer/assets/audit-rubric.json +312 -0
- package/dist/tools/skills/assets/zora-designer/references/artifact.md +93 -0
- package/dist/tools/skills/assets/zora-designer/references/audit.md +100 -0
- package/dist/tools/skills/assets/zora-designer/references/workflow.md +137 -0
- package/dist/tools/skills/assets/zora-designer/scripts/audit.mjs +573 -0
- package/dist/tools/skills/assets/zora-designer/scripts/owner-api.mjs +440 -0
- package/dist/tools/skills/assets/zora-designer/scripts/scaffold-template.mjs +222 -0
- package/dist/tools/skills/managed.js +10 -9
- package/dist/tools/skills/selection.d.ts +7 -0
- package/dist/tools/skills/selection.js +82 -0
- package/package.json +1 -1
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
|
|
6
|
+
const RUBRIC_URL = new URL('../assets/audit-rubric.json', import.meta.url);
|
|
7
|
+
const HUMAN_SECTIONS = [
|
|
8
|
+
'Design direction',
|
|
9
|
+
'Resolved decisions and origins',
|
|
10
|
+
'Color system',
|
|
11
|
+
'Typography',
|
|
12
|
+
'Layout, shape, elevation, and motion',
|
|
13
|
+
'Component and interaction states',
|
|
14
|
+
'Screen specifications',
|
|
15
|
+
'Accessibility and validation',
|
|
16
|
+
'Audit summary',
|
|
17
|
+
'Findings and remediation',
|
|
18
|
+
'Risks needing verification',
|
|
19
|
+
'Not assessable',
|
|
20
|
+
'Open decisions',
|
|
21
|
+
'User notes',
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
/*** Load the single canonical audit rubric and verify its invariant total weight. */
|
|
25
|
+
export async function loadAuditRubric() {
|
|
26
|
+
const rubric = JSON.parse(await readFile(RUBRIC_URL, 'utf8'));
|
|
27
|
+
const totalWeight = rubric.rules.reduce((sum, rule) => sum + rule.weight, 0);
|
|
28
|
+
if (totalWeight !== 100) {
|
|
29
|
+
throw new Error(`Canonical zora-designer rubric weight must equal 100; found ${totalWeight}.`);
|
|
30
|
+
}
|
|
31
|
+
return rubric;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/*** Calculate criterion, rule, coverage, confidence, score range, finding impact, and release gates. */
|
|
35
|
+
export async function calculateAudit(input) {
|
|
36
|
+
assertRecord(input, 'Audit input');
|
|
37
|
+
const rubric = await loadAuditRubric();
|
|
38
|
+
const evidence = Array.isArray(input.evidence) ? input.evidence : [];
|
|
39
|
+
const evidenceById = validateEvidence(evidence, rubric.confidenceFactors);
|
|
40
|
+
const assessments = isRecord(input.criteria) ? input.criteria : {};
|
|
41
|
+
const ruleResults = rubric.rules.map((rule) =>
|
|
42
|
+
calculateRule(rule, assessments[rule.id], evidenceById, rubric.statusFactors),
|
|
43
|
+
);
|
|
44
|
+
const totals = calculateTotals(ruleResults);
|
|
45
|
+
const releaseGateCriteria = rubric.releaseGates.map((gate) =>
|
|
46
|
+
calculateReleaseGate(gate, input.releaseGates?.[gate.id], evidenceById),
|
|
47
|
+
);
|
|
48
|
+
const releaseGate = calculateAggregateReleaseGate(releaseGateCriteria);
|
|
49
|
+
const findings = allocateFindingImpacts(
|
|
50
|
+
Array.isArray(input.findings) ? input.findings : [],
|
|
51
|
+
ruleResults,
|
|
52
|
+
totals.applicableWeight,
|
|
53
|
+
evidenceById,
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
status: totals.assessedWeight === totals.applicableWeight ? 'complete' : 'partial',
|
|
58
|
+
score: totals.score,
|
|
59
|
+
coverage: totals.coverage,
|
|
60
|
+
coverageLabel: labelCoverage(totals.coverage),
|
|
61
|
+
provisional: totals.coverage !== null && totals.coverage < 60,
|
|
62
|
+
applicableWeight: totals.applicableWeight,
|
|
63
|
+
assessedWeight: roundDecimal(totals.assessedWeight, 4),
|
|
64
|
+
earnedWeight: roundDecimal(totals.earnedWeight, 4),
|
|
65
|
+
rounding: 'half-up',
|
|
66
|
+
confidence: {
|
|
67
|
+
value: totals.confidence === null ? null : roundDecimal(totals.confidence, 4),
|
|
68
|
+
label: labelConfidence(totals.confidence),
|
|
69
|
+
},
|
|
70
|
+
possibleRange: totals.possibleRange,
|
|
71
|
+
releaseGate,
|
|
72
|
+
releaseGateCriteria,
|
|
73
|
+
ruleResults,
|
|
74
|
+
findings,
|
|
75
|
+
risks: Array.isArray(input.risks)
|
|
76
|
+
? input.risks.map((risk) => ({ ...risk, scoreImpact: 0 }))
|
|
77
|
+
: [],
|
|
78
|
+
passedRules: ruleResults
|
|
79
|
+
.filter((rule) => rule.assessmentFraction === 1 && rule.status === 'pass')
|
|
80
|
+
.map((rule) => rule.rule),
|
|
81
|
+
notAssessable: ruleResults.flatMap((rule) =>
|
|
82
|
+
rule.criteria
|
|
83
|
+
.filter((criterion) => criterion.applicable && criterion.status === 'not-assessable')
|
|
84
|
+
.map((criterion) => ({
|
|
85
|
+
rule: rule.rule,
|
|
86
|
+
criterionId: criterion.id,
|
|
87
|
+
missingEvidence: criterion.reason,
|
|
88
|
+
})),
|
|
89
|
+
),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/*** Serialize one stable configuration or audit artifact using JSON-compatible YAML frontmatter. */
|
|
94
|
+
export function serializeArtifact(input, audit) {
|
|
95
|
+
assertRecord(input, 'Artifact input');
|
|
96
|
+
const documentKind = input.documentKind === 'audit' ? 'audit' : 'configuration';
|
|
97
|
+
const frontmatter = {
|
|
98
|
+
schema: 'zora-designer/v1',
|
|
99
|
+
documentKind,
|
|
100
|
+
status: input.status ?? (documentKind === 'audit' ? 'audited' : 'draft'),
|
|
101
|
+
language: input.language ?? 'en',
|
|
102
|
+
source: input.source ?? { mode: documentKind, inputs: [], evidence: [] },
|
|
103
|
+
config: input.config ?? {},
|
|
104
|
+
derivation: input.derivation ?? {
|
|
105
|
+
provenance: [],
|
|
106
|
+
diagnostics: [],
|
|
107
|
+
assumptions: [],
|
|
108
|
+
unsupported: [],
|
|
109
|
+
ownerRuntimeDrift: [],
|
|
110
|
+
},
|
|
111
|
+
tokens: input.tokens ?? {},
|
|
112
|
+
components: input.components ?? { stateRequirements: [], recipeDecisions: {} },
|
|
113
|
+
screens: input.screens ?? [],
|
|
114
|
+
validation: input.validation ?? {
|
|
115
|
+
scope: documentKind === 'audit' ? 'audit' : 'configuration',
|
|
116
|
+
status: 'not-run',
|
|
117
|
+
gates: [],
|
|
118
|
+
applicationGate: 'not-assessable',
|
|
119
|
+
ownerRuntimeDrift: [],
|
|
120
|
+
blockers: [],
|
|
121
|
+
},
|
|
122
|
+
audit: audit ?? {
|
|
123
|
+
status: 'not-run',
|
|
124
|
+
score: null,
|
|
125
|
+
coverage: null,
|
|
126
|
+
applicableWeight: null,
|
|
127
|
+
assessedWeight: null,
|
|
128
|
+
rounding: 'half-up',
|
|
129
|
+
confidence: { value: null, label: null },
|
|
130
|
+
possibleRange: { lower: null, upper: null },
|
|
131
|
+
releaseGate: 'not-assessable',
|
|
132
|
+
releaseGateCriteria: [],
|
|
133
|
+
ruleResults: [],
|
|
134
|
+
findings: [],
|
|
135
|
+
risks: [],
|
|
136
|
+
passedRules: [],
|
|
137
|
+
notAssessable: [],
|
|
138
|
+
},
|
|
139
|
+
openDecisions: input.openDecisions ?? [],
|
|
140
|
+
};
|
|
141
|
+
const summaries = isRecord(input.summarySections) ? input.summarySections : {};
|
|
142
|
+
const report = HUMAN_SECTIONS.map((section) => {
|
|
143
|
+
const value = section === 'User notes' ? input.userNotes : summaries[section];
|
|
144
|
+
return `## ${section}\n\n${typeof value === 'string' ? value : ''}`;
|
|
145
|
+
}).join('\n\n');
|
|
146
|
+
return `---\n${JSON.stringify(frontmatter, null, 2)}\n---\n\n# ZORA Designer\n\n${report}\n`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/*** Calculate all four canonical criteria and derived values for one weighted rule. */
|
|
150
|
+
function calculateRule(rule, rawAssessments, evidenceById, statusFactors) {
|
|
151
|
+
const assessmentRecord = isRecord(rawAssessments) ? rawAssessments : {};
|
|
152
|
+
const criteria = rule.criteria.map((criterionId) =>
|
|
153
|
+
normalizeCriterion(criterionId, assessmentRecord[criterionId], evidenceById, statusFactors),
|
|
154
|
+
);
|
|
155
|
+
const applicable = criteria.filter((criterion) => criterion.applicable);
|
|
156
|
+
const assessed = applicable.filter((criterion) => criterion.statusFactor !== null);
|
|
157
|
+
const applicableCount = applicable.length;
|
|
158
|
+
const assessedCount = assessed.length;
|
|
159
|
+
const assessmentFraction = applicableCount === 0 ? null : assessedCount / applicableCount;
|
|
160
|
+
const statusFactor =
|
|
161
|
+
assessedCount === 0
|
|
162
|
+
? null
|
|
163
|
+
: assessed.reduce((sum, criterion) => sum + criterion.statusFactor, 0) / assessedCount;
|
|
164
|
+
const criterionWeight = applicableCount === 0 ? 0 : rule.weight / applicableCount;
|
|
165
|
+
const assessedWeight = criterionWeight * assessedCount;
|
|
166
|
+
const earnedWeight =
|
|
167
|
+
criterionWeight * assessed.reduce((sum, criterion) => sum + criterion.statusFactor, 0);
|
|
168
|
+
const confidenceValue =
|
|
169
|
+
assessedCount === 0
|
|
170
|
+
? null
|
|
171
|
+
: assessed.reduce((sum, criterion) => sum + criterion.confidenceFactor, 0) / assessedCount;
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
rule: rule.id,
|
|
175
|
+
name: rule.name,
|
|
176
|
+
weight: rule.weight,
|
|
177
|
+
status: labelRuleStatus(criteria, statusFactor, applicableCount),
|
|
178
|
+
statusFactor: statusFactor === null ? null : roundDecimal(statusFactor, 4),
|
|
179
|
+
applicableCount,
|
|
180
|
+
assessedCount,
|
|
181
|
+
assessmentFraction: assessmentFraction === null ? null : roundDecimal(assessmentFraction, 4),
|
|
182
|
+
confidence: {
|
|
183
|
+
value: confidenceValue === null ? null : roundDecimal(confidenceValue, 4),
|
|
184
|
+
label: labelConfidence(confidenceValue),
|
|
185
|
+
},
|
|
186
|
+
assessedWeight: roundDecimal(assessedWeight, 4),
|
|
187
|
+
earnedWeight: roundDecimal(earnedWeight, 4),
|
|
188
|
+
criteria,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/*** Normalize one criterion and derive confidence only from its essential evidence. */
|
|
193
|
+
function normalizeCriterion(criterionId, rawAssessment, evidenceById, statusFactors) {
|
|
194
|
+
const assessment = isRecord(rawAssessment) ? rawAssessment : {};
|
|
195
|
+
const applicable = assessment.applicable !== false;
|
|
196
|
+
const requestedStatus =
|
|
197
|
+
typeof assessment.status === 'string' ? assessment.status : 'not-assessable';
|
|
198
|
+
const status = applicable ? requestedStatus : 'not-applicable';
|
|
199
|
+
if (!(status in statusFactors)) {
|
|
200
|
+
throw new Error(`Unknown audit criterion status for ${criterionId}: ${status}`);
|
|
201
|
+
}
|
|
202
|
+
if (applicable && status === 'not-applicable') {
|
|
203
|
+
throw new Error(`Applicable criterion ${criterionId} cannot be not-applicable.`);
|
|
204
|
+
}
|
|
205
|
+
const statusFactor = statusFactors[status];
|
|
206
|
+
const evidenceIds = readStringArray(assessment.evidenceIds);
|
|
207
|
+
const essentialEvidenceIds = readStringArray(assessment.essentialEvidenceIds);
|
|
208
|
+
if (statusFactor !== null && essentialEvidenceIds.length === 0) {
|
|
209
|
+
throw new Error(`Assessed criterion ${criterionId} requires essentialEvidenceIds.`);
|
|
210
|
+
}
|
|
211
|
+
if (essentialEvidenceIds.some((evidenceId) => !evidenceIds.includes(evidenceId))) {
|
|
212
|
+
throw new Error(
|
|
213
|
+
`Criterion ${criterionId} essentialEvidenceIds must be a subset of evidenceIds.`,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
for (const evidenceId of [...evidenceIds, ...essentialEvidenceIds]) {
|
|
217
|
+
if (!evidenceById.has(evidenceId)) {
|
|
218
|
+
throw new Error(`Criterion ${criterionId} references unknown evidence: ${evidenceId}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const confidenceFactor =
|
|
222
|
+
statusFactor === null
|
|
223
|
+
? null
|
|
224
|
+
: Math.min(
|
|
225
|
+
...essentialEvidenceIds.map(
|
|
226
|
+
(evidenceId) => evidenceById.get(evidenceId).confidenceFactor,
|
|
227
|
+
),
|
|
228
|
+
);
|
|
229
|
+
return {
|
|
230
|
+
id: criterionId,
|
|
231
|
+
applicable,
|
|
232
|
+
status,
|
|
233
|
+
statusFactor,
|
|
234
|
+
evidenceIds,
|
|
235
|
+
essentialEvidenceIds,
|
|
236
|
+
confidenceFactor,
|
|
237
|
+
reason:
|
|
238
|
+
typeof assessment.reason === 'string' && assessment.reason !== ''
|
|
239
|
+
? assessment.reason
|
|
240
|
+
: status === 'not-assessable'
|
|
241
|
+
? 'Required evidence was not supplied.'
|
|
242
|
+
: '',
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/*** Calculate aggregate score, coverage, range, and confidence from rule results. */
|
|
247
|
+
function calculateTotals(ruleResults) {
|
|
248
|
+
const applicableRules = ruleResults.filter((rule) => rule.applicableCount > 0);
|
|
249
|
+
const applicableWeight = applicableRules.reduce((sum, rule) => sum + rule.weight, 0);
|
|
250
|
+
const assessedWeight = ruleResults.reduce(
|
|
251
|
+
(sum, rule) =>
|
|
252
|
+
sum +
|
|
253
|
+
(rule.applicableCount === 0 ? 0 : (rule.weight / rule.applicableCount) * rule.assessedCount),
|
|
254
|
+
0,
|
|
255
|
+
);
|
|
256
|
+
const earnedWeight = ruleResults.reduce(
|
|
257
|
+
(sum, rule) =>
|
|
258
|
+
sum +
|
|
259
|
+
(rule.applicableCount === 0
|
|
260
|
+
? 0
|
|
261
|
+
: (rule.weight / rule.applicableCount) *
|
|
262
|
+
rule.criteria
|
|
263
|
+
.filter((criterion) => criterion.statusFactor !== null)
|
|
264
|
+
.reduce((criterionSum, criterion) => criterionSum + criterion.statusFactor, 0)),
|
|
265
|
+
0,
|
|
266
|
+
);
|
|
267
|
+
if (applicableWeight === 0) {
|
|
268
|
+
return {
|
|
269
|
+
applicableWeight: 0,
|
|
270
|
+
assessedWeight: 0,
|
|
271
|
+
earnedWeight: 0,
|
|
272
|
+
score: null,
|
|
273
|
+
coverage: null,
|
|
274
|
+
confidence: null,
|
|
275
|
+
possibleRange: { lower: null, upper: null },
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
if (assessedWeight === 0) {
|
|
279
|
+
return {
|
|
280
|
+
applicableWeight,
|
|
281
|
+
assessedWeight,
|
|
282
|
+
earnedWeight,
|
|
283
|
+
score: null,
|
|
284
|
+
coverage: 0,
|
|
285
|
+
confidence: null,
|
|
286
|
+
possibleRange: { lower: 0, upper: 100 },
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
const confidenceNumerator = ruleResults.reduce((sum, rule) => {
|
|
290
|
+
const applicableCount = rule.applicableCount;
|
|
291
|
+
if (applicableCount === 0) return sum;
|
|
292
|
+
const criterionWeight = rule.weight / applicableCount;
|
|
293
|
+
return (
|
|
294
|
+
sum +
|
|
295
|
+
criterionWeight *
|
|
296
|
+
rule.criteria
|
|
297
|
+
.filter((criterion) => criterion.statusFactor !== null)
|
|
298
|
+
.reduce((criterionSum, criterion) => criterionSum + criterion.confidenceFactor, 0)
|
|
299
|
+
);
|
|
300
|
+
}, 0);
|
|
301
|
+
return {
|
|
302
|
+
applicableWeight,
|
|
303
|
+
assessedWeight,
|
|
304
|
+
earnedWeight,
|
|
305
|
+
score: roundHalfUp((100 * earnedWeight) / assessedWeight),
|
|
306
|
+
coverage: roundHalfUp((100 * assessedWeight) / applicableWeight),
|
|
307
|
+
confidence: confidenceNumerator / assessedWeight,
|
|
308
|
+
possibleRange: {
|
|
309
|
+
lower: roundHalfUp((100 * earnedWeight) / applicableWeight),
|
|
310
|
+
upper: roundHalfUp(
|
|
311
|
+
(100 * (earnedWeight + applicableWeight - assessedWeight)) / applicableWeight,
|
|
312
|
+
),
|
|
313
|
+
},
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/*** Validate evidence identities and canonical confidence factors. */
|
|
318
|
+
function validateEvidence(evidence, confidenceFactors) {
|
|
319
|
+
const evidenceById = new Map();
|
|
320
|
+
for (const item of evidence) {
|
|
321
|
+
assertRecord(item, 'Evidence item');
|
|
322
|
+
if (typeof item.id !== 'string' || item.id === '') {
|
|
323
|
+
throw new Error('Every evidence item requires a non-empty id.');
|
|
324
|
+
}
|
|
325
|
+
if (evidenceById.has(item.id)) {
|
|
326
|
+
throw new Error(`Duplicate evidence id: ${item.id}`);
|
|
327
|
+
}
|
|
328
|
+
for (const field of ['kind', 'location', 'observation', 'evidenceLevel', 'reproduction']) {
|
|
329
|
+
if (typeof item[field] !== 'string' || item[field] === '') {
|
|
330
|
+
throw new Error(`Evidence ${item.id} requires a non-empty ${field}.`);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (
|
|
334
|
+
!['measured', 'observed', 'estimated', 'inferred', 'not-assessable'].includes(
|
|
335
|
+
item.evidenceLevel,
|
|
336
|
+
)
|
|
337
|
+
) {
|
|
338
|
+
throw new Error(`Evidence ${item.id} has unknown evidenceLevel: ${item.evidenceLevel}`);
|
|
339
|
+
}
|
|
340
|
+
if (!Array.isArray(item.limitations)) {
|
|
341
|
+
throw new Error(`Evidence ${item.id} requires a limitations list.`);
|
|
342
|
+
}
|
|
343
|
+
if (!(item.confidence in confidenceFactors)) {
|
|
344
|
+
throw new Error(`Evidence ${item.id} has unknown confidence: ${String(item.confidence)}`);
|
|
345
|
+
}
|
|
346
|
+
const canonicalFactor = confidenceFactors[item.confidence];
|
|
347
|
+
if (item.confidenceFactor !== canonicalFactor) {
|
|
348
|
+
throw new Error(
|
|
349
|
+
`Evidence ${item.id} confidenceFactor must be ${canonicalFactor} for ${item.confidence}.`,
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
evidenceById.set(item.id, item);
|
|
353
|
+
}
|
|
354
|
+
return evidenceById;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/*** Derive one canonical release item from all of its subcriteria. */
|
|
358
|
+
function calculateReleaseGate(gate, rawGate, evidenceById) {
|
|
359
|
+
const gateInput = isRecord(rawGate) ? rawGate : {};
|
|
360
|
+
const rawCriteria = isRecord(gateInput.criteria) ? gateInput.criteria : {};
|
|
361
|
+
const criteria = gate.criteria.map((criterionId) => {
|
|
362
|
+
const raw = isRecord(rawCriteria[criterionId]) ? rawCriteria[criterionId] : {};
|
|
363
|
+
const applicable = raw.applicable !== false;
|
|
364
|
+
const status = applicable
|
|
365
|
+
? ['pass', 'fail'].includes(raw.status)
|
|
366
|
+
? raw.status
|
|
367
|
+
: 'not-assessable'
|
|
368
|
+
: 'not-applicable';
|
|
369
|
+
const evidenceIds = readStringArray(raw.evidenceIds);
|
|
370
|
+
if (['pass', 'fail'].includes(status) && evidenceIds.length === 0) {
|
|
371
|
+
throw new Error(`Release criterion ${criterionId} requires evidence for status ${status}.`);
|
|
372
|
+
}
|
|
373
|
+
for (const evidenceId of evidenceIds) {
|
|
374
|
+
if (!evidenceById.has(evidenceId)) {
|
|
375
|
+
throw new Error(
|
|
376
|
+
`Release criterion ${criterionId} references unknown evidence: ${evidenceId}`,
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return {
|
|
381
|
+
id: criterionId,
|
|
382
|
+
applicable,
|
|
383
|
+
status,
|
|
384
|
+
evidenceIds,
|
|
385
|
+
reason: typeof raw.reason === 'string' ? raw.reason : '',
|
|
386
|
+
};
|
|
387
|
+
});
|
|
388
|
+
const applicableCriteria = criteria.filter((criterion) => criterion.applicable);
|
|
389
|
+
const status =
|
|
390
|
+
applicableCriteria.length === 0
|
|
391
|
+
? 'not-applicable'
|
|
392
|
+
: applicableCriteria.some((criterion) => criterion.status === 'fail')
|
|
393
|
+
? 'fail'
|
|
394
|
+
: applicableCriteria.every((criterion) => criterion.status === 'pass')
|
|
395
|
+
? 'pass'
|
|
396
|
+
: 'not-assessable';
|
|
397
|
+
return {
|
|
398
|
+
id: gate.id,
|
|
399
|
+
name: gate.name,
|
|
400
|
+
applicable: applicableCriteria.length > 0,
|
|
401
|
+
status,
|
|
402
|
+
evidenceIds: [...new Set(criteria.flatMap((criterion) => criterion.evidenceIds))].sort(),
|
|
403
|
+
reason: typeof gateInput.reason === 'string' ? gateInput.reason : '',
|
|
404
|
+
criteria,
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/*** Derive the aggregate release gate without allowing the score to override evidence gaps. */
|
|
409
|
+
function calculateAggregateReleaseGate(releaseGates) {
|
|
410
|
+
const applicable = releaseGates.filter((gate) => gate.applicable);
|
|
411
|
+
if (applicable.length === 0) return 'not-assessable';
|
|
412
|
+
if (applicable.some((gate) => gate.status === 'fail')) return 'fail';
|
|
413
|
+
if (applicable.every((gate) => gate.status === 'pass')) return 'pass';
|
|
414
|
+
return 'not-assessable';
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/*** Deduplicate findings by root cause and allocate each criterion loss in stable units. */
|
|
418
|
+
function allocateFindingImpacts(findings, ruleResults, applicableWeight, evidenceById) {
|
|
419
|
+
const deduplicated = [];
|
|
420
|
+
const seenRootCauses = new Set();
|
|
421
|
+
for (const finding of [...findings].sort((left, right) => left.id.localeCompare(right.id))) {
|
|
422
|
+
assertRecord(finding, 'Finding');
|
|
423
|
+
validateFinding(finding, evidenceById);
|
|
424
|
+
const rootCause = typeof finding.rootCause === 'string' ? finding.rootCause : finding.id;
|
|
425
|
+
if (!seenRootCauses.has(rootCause)) {
|
|
426
|
+
seenRootCauses.add(rootCause);
|
|
427
|
+
deduplicated.push({ ...finding, rootCause });
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
const byCriterion = new Map();
|
|
431
|
+
for (const finding of deduplicated) {
|
|
432
|
+
const key = `${finding.rule}:${finding.criterionId}`;
|
|
433
|
+
byCriterion.set(key, [...(byCriterion.get(key) ?? []), finding]);
|
|
434
|
+
}
|
|
435
|
+
const allocations = new Map();
|
|
436
|
+
for (const [key, criterionFindings] of byCriterion) {
|
|
437
|
+
const [ruleId, criterionId] = key.split(':');
|
|
438
|
+
const rule = ruleResults.find((result) => result.rule === ruleId);
|
|
439
|
+
const criterion = rule?.criteria.find((result) => result.id === criterionId);
|
|
440
|
+
if (!rule || !criterion || criterion.statusFactor === null || applicableWeight === 0) {
|
|
441
|
+
throw new Error(`Finding references an unscored criterion: ${key}`);
|
|
442
|
+
}
|
|
443
|
+
if (criterion.statusFactor === 1) {
|
|
444
|
+
throw new Error(`Finding cannot reference a passing criterion: ${key}`);
|
|
445
|
+
}
|
|
446
|
+
const criterionWeight = rule.weight / rule.applicableCount;
|
|
447
|
+
const lossUnits = roundHalfUp(
|
|
448
|
+
((100 * criterionWeight * (1 - criterion.statusFactor)) / applicableWeight) * 10_000,
|
|
449
|
+
);
|
|
450
|
+
const baseUnits = Math.floor(lossUnits / criterionFindings.length);
|
|
451
|
+
const remainder = lossUnits % criterionFindings.length;
|
|
452
|
+
criterionFindings.forEach((finding, index) => {
|
|
453
|
+
allocations.set(finding.id, (baseUnits + (index < remainder ? 1 : 0)) / 10_000);
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
return deduplicated.map((finding) => ({
|
|
457
|
+
...finding,
|
|
458
|
+
scoreImpact: allocations.get(finding.id) ?? 0,
|
|
459
|
+
}));
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/*** Validate the complete finding contract and its evidence references before score allocation. */
|
|
463
|
+
function validateFinding(finding, evidenceById) {
|
|
464
|
+
for (const field of [
|
|
465
|
+
'id',
|
|
466
|
+
'rule',
|
|
467
|
+
'criterionId',
|
|
468
|
+
'severity',
|
|
469
|
+
'location',
|
|
470
|
+
'evidence',
|
|
471
|
+
'evidenceLevel',
|
|
472
|
+
'expected',
|
|
473
|
+
'impact',
|
|
474
|
+
'rootCause',
|
|
475
|
+
'fix',
|
|
476
|
+
'verification',
|
|
477
|
+
'confidence',
|
|
478
|
+
]) {
|
|
479
|
+
if (typeof finding[field] !== 'string' || finding[field] === '') {
|
|
480
|
+
throw new Error(`Finding requires a non-empty ${field}.`);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
if (!Array.isArray(finding.relatedRules)) {
|
|
484
|
+
throw new Error(`Finding ${finding.id} requires a relatedRules list.`);
|
|
485
|
+
}
|
|
486
|
+
const evidenceIds = readStringArray(finding.evidenceIds);
|
|
487
|
+
if (evidenceIds.length === 0) {
|
|
488
|
+
throw new Error(`Finding ${finding.id} requires evidenceIds.`);
|
|
489
|
+
}
|
|
490
|
+
for (const evidenceId of evidenceIds) {
|
|
491
|
+
if (!evidenceById.has(evidenceId)) {
|
|
492
|
+
throw new Error(`Finding ${finding.id} references unknown evidence: ${evidenceId}`);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/*** Label rule status from assessed factors while preserving explicit critical evidence. */
|
|
498
|
+
function labelRuleStatus(criteria, meanFactor, applicableCount) {
|
|
499
|
+
if (applicableCount === 0) return 'not-applicable';
|
|
500
|
+
if (meanFactor === null) return 'not-assessable';
|
|
501
|
+
if (criteria.some((criterion) => criterion.status === 'critical')) return 'critical';
|
|
502
|
+
if (meanFactor === 1) return 'pass';
|
|
503
|
+
if (meanFactor >= 0.75) return 'minor';
|
|
504
|
+
if (meanFactor >= 0.4) return 'major';
|
|
505
|
+
return 'critical';
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/*** Label evidence coverage without conflating it with confidence. */
|
|
509
|
+
function labelCoverage(value) {
|
|
510
|
+
if (value === null) return null;
|
|
511
|
+
if (value >= 85) return 'high';
|
|
512
|
+
if (value >= 60) return 'medium';
|
|
513
|
+
return 'low';
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/*** Label aggregate evidence confidence from the canonical numeric thresholds. */
|
|
517
|
+
function labelConfidence(value) {
|
|
518
|
+
if (value === null) return null;
|
|
519
|
+
if (value >= 0.8) return 'high';
|
|
520
|
+
if (value >= 0.5) return 'medium';
|
|
521
|
+
return 'low';
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/*** Round a nonnegative displayed value half up. */
|
|
525
|
+
function roundHalfUp(value) {
|
|
526
|
+
return Math.floor(value + 0.5);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/*** Round an intermediate serialization value without changing calculation inputs. */
|
|
530
|
+
function roundDecimal(value, digits) {
|
|
531
|
+
const factor = 10 ** digits;
|
|
532
|
+
return Math.round((value + Number.EPSILON) * factor) / factor;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/*** Read a stable string list from optional audit input. */
|
|
536
|
+
function readStringArray(value) {
|
|
537
|
+
return Array.isArray(value) ? value.filter((item) => typeof item === 'string') : [];
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/*** Narrow an unknown value to a non-array record. */
|
|
541
|
+
function isRecord(value) {
|
|
542
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/*** Require an object-shaped input value. */
|
|
546
|
+
function assertRecord(value, label) {
|
|
547
|
+
if (!isRecord(value)) throw new Error(`${label} must be an object.`);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/*** Run catalog inspection or deterministic artifact generation from the command line. */
|
|
551
|
+
async function main() {
|
|
552
|
+
const [command, inputPath, outputPath] = process.argv.slice(2);
|
|
553
|
+
if (command === 'catalog') {
|
|
554
|
+
console.log(JSON.stringify(await loadAuditRubric(), null, 2));
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
if (command === 'audit' && inputPath) {
|
|
558
|
+
const input = JSON.parse(await readFile(inputPath, 'utf8'));
|
|
559
|
+
const audit = await calculateAudit(input.auditInput ?? input);
|
|
560
|
+
const artifact = serializeArtifact({ ...input, documentKind: 'audit' }, audit);
|
|
561
|
+
if (outputPath) await writeFile(outputPath, artifact);
|
|
562
|
+
else console.log(artifact);
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
throw new Error('Usage: audit.mjs catalog | audit.mjs audit <input.json> [output.md]');
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
|
|
569
|
+
main().catch((error) => {
|
|
570
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
571
|
+
process.exitCode = 1;
|
|
572
|
+
});
|
|
573
|
+
}
|