@ankhorage/devtools 1.10.13 → 1.10.15
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/zora-designer/SKILL.md +2 -2
- package/dist/tools/skills/assets/zora-designer/references/artifact.md +1 -1
- package/dist/tools/skills/assets/zora-designer/references/audit.md +2 -2
- package/dist/tools/skills/assets/zora-designer/references/screens.md +1 -1
- package/dist/tools/skills/assets/zora-designer/references/workflow.md +2 -2
- package/dist/tools/skills/assets/zora-designer/scripts/{audit.mjs → audit.ts} +280 -49
- package/dist/tools/skills/assets/zora-designer/scripts/{generate-template-catalog.mjs → generate-template-catalog.ts} +14 -5
- package/dist/tools/skills/assets/zora-designer/scripts/{owner-api.mjs → owner-api.ts} +356 -54
- package/dist/tools/skills/assets/zora-designer/scripts/{scaffold-template.mjs → scaffold-template.ts} +27 -21
- package/dist/tools/workflows/files/release.yml +35 -0
- package/dist/tools/workflows/files/renovate.yml +2 -2
- package/package.json +1 -1
|
@@ -34,7 +34,7 @@ once.
|
|
|
34
34
|
From the target repository, run:
|
|
35
35
|
|
|
36
36
|
```text
|
|
37
|
-
bun .agents/skills/zora-designer/scripts/owner-api.
|
|
37
|
+
bun .agents/skills/zora-designer/scripts/owner-api.ts inspect
|
|
38
38
|
```
|
|
39
39
|
|
|
40
40
|
Use its installed owner output for categories, recommendations, harmonies, tone pairs, navigation
|
|
@@ -76,7 +76,7 @@ under `assets/images/`; rebuild text, controls, icons, surfaces, and layout with
|
|
|
76
76
|
Scaffold only a reviewed, release-valid manifest:
|
|
77
77
|
|
|
78
78
|
```text
|
|
79
|
-
bun .agents/skills/zora-designer/scripts/scaffold-template.
|
|
79
|
+
bun .agents/skills/zora-designer/scripts/scaffold-template.ts scaffold-input.json
|
|
80
80
|
```
|
|
81
81
|
|
|
82
82
|
The helper creates the template directory and regenerates discovery from the filesystem. Do not add
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
`zora-designer.md` is the deterministic design/audit decision record. It is never runtime theme or
|
|
4
4
|
manifest authority. The serializer and its ordered frontmatter/section skeleton live in
|
|
5
|
-
[audit.
|
|
5
|
+
[audit.ts](../scripts/audit.ts); do not maintain another template.
|
|
6
6
|
|
|
7
7
|
## Document lifecycle
|
|
8
8
|
|
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
The canonical rubric, weights, criterion keys, status factors, confidence factors, and release-gate
|
|
4
4
|
inventory live in [audit-rubric.json](../assets/audit-rubric.json). The calculation implementation
|
|
5
|
-
in [audit.
|
|
5
|
+
in [audit.ts](../scripts/audit.ts) loads that file. Do not reproduce either list or the arithmetic
|
|
6
6
|
in prompts, references, tests, or application code.
|
|
7
7
|
|
|
8
8
|
Inspect the current catalog when needed:
|
|
9
9
|
|
|
10
10
|
```text
|
|
11
|
-
bun .agents/skills/zora-designer/scripts/audit.
|
|
11
|
+
bun .agents/skills/zora-designer/scripts/audit.ts catalog
|
|
12
12
|
```
|
|
13
13
|
|
|
14
14
|
## Evidence intake
|
|
@@ -56,7 +56,7 @@ model to improvise:
|
|
|
56
56
|
Inspect the skill-owned design rules before rendering:
|
|
57
57
|
|
|
58
58
|
```text
|
|
59
|
-
bun .agents/skills/zora-designer/scripts/audit.
|
|
59
|
+
bun .agents/skills/zora-designer/scripts/audit.ts catalog
|
|
60
60
|
```
|
|
61
61
|
|
|
62
62
|
Apply every relevant `generation` rule from that catalog. The rubric belongs to this skill; owner
|
|
@@ -10,7 +10,7 @@ Read repository instructions, an existing manifest or `zora-designer.md`, instal
|
|
|
10
10
|
configuration, and relevant screens. Run:
|
|
11
11
|
|
|
12
12
|
```text
|
|
13
|
-
bun .agents/skills/zora-designer/scripts/owner-api.
|
|
13
|
+
bun .agents/skills/zora-designer/scripts/owner-api.ts inspect
|
|
14
14
|
```
|
|
15
15
|
|
|
16
16
|
The output is the choice source. Do not use remembered categories, color options, tone pairs,
|
|
@@ -58,7 +58,7 @@ screen brief. Changing the screen list invalidates navigator confirmation.
|
|
|
58
58
|
Provide the resolved `category`, theme overrides, navigator, screens, and region decisions to:
|
|
59
59
|
|
|
60
60
|
```text
|
|
61
|
-
bun .agents/skills/zora-designer/scripts/owner-api.
|
|
61
|
+
bun .agents/skills/zora-designer/scripts/owner-api.ts compose design-input.json
|
|
62
62
|
```
|
|
63
63
|
|
|
64
64
|
Use owner-returned theme configuration, generated roles, computed Surface themes, diagnostics,
|
|
@@ -21,9 +21,112 @@ const HUMAN_SECTIONS = [
|
|
|
21
21
|
'User notes',
|
|
22
22
|
];
|
|
23
23
|
|
|
24
|
+
interface AuditRubric {
|
|
25
|
+
confidenceFactors: Record<string, number>;
|
|
26
|
+
releaseGates: ReleaseGateDefinition[];
|
|
27
|
+
rules: RuleDefinition[];
|
|
28
|
+
statusFactors: Record<string, number | null>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface RuleDefinition {
|
|
32
|
+
criteria: string[];
|
|
33
|
+
id: string;
|
|
34
|
+
name: string;
|
|
35
|
+
weight: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface ReleaseGateDefinition {
|
|
39
|
+
criteria: string[];
|
|
40
|
+
id: string;
|
|
41
|
+
name: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface EvidenceItem extends Record<string, unknown> {
|
|
45
|
+
confidence: string;
|
|
46
|
+
confidenceFactor: number;
|
|
47
|
+
evidenceLevel: string;
|
|
48
|
+
id: string;
|
|
49
|
+
kind: string;
|
|
50
|
+
limitations: unknown[];
|
|
51
|
+
location: string;
|
|
52
|
+
observation: string;
|
|
53
|
+
reproduction: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface CriterionResult {
|
|
57
|
+
applicable: boolean;
|
|
58
|
+
confidenceFactor: number | null;
|
|
59
|
+
essentialEvidenceIds: string[];
|
|
60
|
+
evidenceIds: string[];
|
|
61
|
+
id: string;
|
|
62
|
+
reason: string;
|
|
63
|
+
status: string;
|
|
64
|
+
statusFactor: number | null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface RuleResult {
|
|
68
|
+
applicableCount: number;
|
|
69
|
+
assessedCount: number;
|
|
70
|
+
assessedWeight: number;
|
|
71
|
+
assessmentFraction: number | null;
|
|
72
|
+
confidence: { label: string | null; value: number | null };
|
|
73
|
+
criteria: CriterionResult[];
|
|
74
|
+
earnedWeight: number;
|
|
75
|
+
name: string;
|
|
76
|
+
rule: string;
|
|
77
|
+
status: string;
|
|
78
|
+
statusFactor: number | null;
|
|
79
|
+
weight: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface ReleaseGateResult {
|
|
83
|
+
applicable: boolean;
|
|
84
|
+
criteria: {
|
|
85
|
+
applicable: boolean;
|
|
86
|
+
evidenceIds: string[];
|
|
87
|
+
id: string;
|
|
88
|
+
reason: string;
|
|
89
|
+
status: string;
|
|
90
|
+
}[];
|
|
91
|
+
evidenceIds: string[];
|
|
92
|
+
id: string;
|
|
93
|
+
name: string;
|
|
94
|
+
reason: string;
|
|
95
|
+
status: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface Finding extends Record<string, unknown> {
|
|
99
|
+
confidence: string;
|
|
100
|
+
criterionId: string;
|
|
101
|
+
evidence: string;
|
|
102
|
+
evidenceIds: string[];
|
|
103
|
+
evidenceLevel: string;
|
|
104
|
+
expected: string;
|
|
105
|
+
fix: string;
|
|
106
|
+
id: string;
|
|
107
|
+
impact: string;
|
|
108
|
+
location: string;
|
|
109
|
+
relatedRules: unknown[];
|
|
110
|
+
rootCause: string;
|
|
111
|
+
rule: string;
|
|
112
|
+
severity: string;
|
|
113
|
+
verification: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
interface AuditTotals {
|
|
117
|
+
applicableWeight: number;
|
|
118
|
+
assessedWeight: number;
|
|
119
|
+
confidence: number | null;
|
|
120
|
+
earnedWeight: number;
|
|
121
|
+
possibleRange: { lower: number | null; upper: number | null };
|
|
122
|
+
score: number | null;
|
|
123
|
+
coverage: number | null;
|
|
124
|
+
}
|
|
125
|
+
|
|
24
126
|
/*** 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'));
|
|
127
|
+
export async function loadAuditRubric(): Promise<AuditRubric> {
|
|
128
|
+
const rubric: unknown = JSON.parse(await readFile(RUBRIC_URL, 'utf8'));
|
|
129
|
+
assertAuditRubric(rubric);
|
|
27
130
|
const totalWeight = rubric.rules.reduce((sum, rule) => sum + rule.weight, 0);
|
|
28
131
|
if (totalWeight !== 100) {
|
|
29
132
|
throw new Error(`Canonical zora-designer rubric weight must equal 100; found ${totalWeight}.`);
|
|
@@ -32,7 +135,7 @@ export async function loadAuditRubric() {
|
|
|
32
135
|
}
|
|
33
136
|
|
|
34
137
|
/*** Calculate criterion, rule, coverage, confidence, score range, finding impact, and release gates. */
|
|
35
|
-
export async function calculateAudit(input) {
|
|
138
|
+
export async function calculateAudit(input: unknown) {
|
|
36
139
|
assertRecord(input, 'Audit input');
|
|
37
140
|
const rubric = await loadAuditRubric();
|
|
38
141
|
const evidence = Array.isArray(input.evidence) ? input.evidence : [];
|
|
@@ -42,8 +145,9 @@ export async function calculateAudit(input) {
|
|
|
42
145
|
calculateRule(rule, assessments[rule.id], evidenceById, rubric.statusFactors),
|
|
43
146
|
);
|
|
44
147
|
const totals = calculateTotals(ruleResults);
|
|
148
|
+
const releaseGateInputs = isRecord(input.releaseGates) ? input.releaseGates : {};
|
|
45
149
|
const releaseGateCriteria = rubric.releaseGates.map((gate) =>
|
|
46
|
-
calculateReleaseGate(gate,
|
|
150
|
+
calculateReleaseGate(gate, releaseGateInputs[gate.id], evidenceById),
|
|
47
151
|
);
|
|
48
152
|
const releaseGate = calculateAggregateReleaseGate(releaseGateCriteria);
|
|
49
153
|
const findings = allocateFindingImpacts(
|
|
@@ -72,9 +176,7 @@ export async function calculateAudit(input) {
|
|
|
72
176
|
releaseGateCriteria,
|
|
73
177
|
ruleResults,
|
|
74
178
|
findings,
|
|
75
|
-
risks: Array.isArray(input.risks)
|
|
76
|
-
? input.risks.map((risk) => ({ ...risk, scoreImpact: 0 }))
|
|
77
|
-
: [],
|
|
179
|
+
risks: Array.isArray(input.risks) ? input.risks.map(normalizeRisk) : [],
|
|
78
180
|
passedRules: ruleResults
|
|
79
181
|
.filter((rule) => rule.assessmentFraction === 1 && rule.status === 'pass')
|
|
80
182
|
.map((rule) => rule.rule),
|
|
@@ -91,7 +193,7 @@ export async function calculateAudit(input) {
|
|
|
91
193
|
}
|
|
92
194
|
|
|
93
195
|
/*** Serialize one stable configuration or audit artifact using JSON-compatible YAML frontmatter. */
|
|
94
|
-
export function serializeArtifact(input, audit) {
|
|
196
|
+
export function serializeArtifact(input: unknown, audit: unknown) {
|
|
95
197
|
assertRecord(input, 'Artifact input');
|
|
96
198
|
const documentKind = input.documentKind === 'audit' ? 'audit' : 'configuration';
|
|
97
199
|
const frontmatter = {
|
|
@@ -147,13 +249,18 @@ export function serializeArtifact(input, audit) {
|
|
|
147
249
|
}
|
|
148
250
|
|
|
149
251
|
/*** Calculate all four canonical criteria and derived values for one weighted rule. */
|
|
150
|
-
function calculateRule(
|
|
252
|
+
function calculateRule(
|
|
253
|
+
rule: RuleDefinition,
|
|
254
|
+
rawAssessments: unknown,
|
|
255
|
+
evidenceById: Map<string, EvidenceItem>,
|
|
256
|
+
statusFactors: Record<string, number | null>,
|
|
257
|
+
): RuleResult {
|
|
151
258
|
const assessmentRecord = isRecord(rawAssessments) ? rawAssessments : {};
|
|
152
259
|
const criteria = rule.criteria.map((criterionId) =>
|
|
153
260
|
normalizeCriterion(criterionId, assessmentRecord[criterionId], evidenceById, statusFactors),
|
|
154
261
|
);
|
|
155
262
|
const applicable = criteria.filter((criterion) => criterion.applicable);
|
|
156
|
-
const assessed = applicable.filter(
|
|
263
|
+
const assessed = applicable.filter(isAssessedCriterion);
|
|
157
264
|
const applicableCount = applicable.length;
|
|
158
265
|
const assessedCount = assessed.length;
|
|
159
266
|
const assessmentFraction = applicableCount === 0 ? null : assessedCount / applicableCount;
|
|
@@ -190,7 +297,12 @@ function calculateRule(rule, rawAssessments, evidenceById, statusFactors) {
|
|
|
190
297
|
}
|
|
191
298
|
|
|
192
299
|
/*** Normalize one criterion and derive confidence only from its essential evidence. */
|
|
193
|
-
function normalizeCriterion(
|
|
300
|
+
function normalizeCriterion(
|
|
301
|
+
criterionId: string,
|
|
302
|
+
rawAssessment: unknown,
|
|
303
|
+
evidenceById: Map<string, EvidenceItem>,
|
|
304
|
+
statusFactors: Record<string, number | null>,
|
|
305
|
+
): CriterionResult {
|
|
194
306
|
const assessment = isRecord(rawAssessment) ? rawAssessment : {};
|
|
195
307
|
const applicable = assessment.applicable !== false;
|
|
196
308
|
const requestedStatus =
|
|
@@ -223,7 +335,7 @@ function normalizeCriterion(criterionId, rawAssessment, evidenceById, statusFact
|
|
|
223
335
|
? null
|
|
224
336
|
: Math.min(
|
|
225
337
|
...essentialEvidenceIds.map(
|
|
226
|
-
(evidenceId) => evidenceById.get(evidenceId)
|
|
338
|
+
(evidenceId) => evidenceById.get(evidenceId)?.confidenceFactor ?? 0,
|
|
227
339
|
),
|
|
228
340
|
);
|
|
229
341
|
return {
|
|
@@ -244,7 +356,7 @@ function normalizeCriterion(criterionId, rawAssessment, evidenceById, statusFact
|
|
|
244
356
|
}
|
|
245
357
|
|
|
246
358
|
/*** Calculate aggregate score, coverage, range, and confidence from rule results. */
|
|
247
|
-
function calculateTotals(ruleResults) {
|
|
359
|
+
function calculateTotals(ruleResults: RuleResult[]): AuditTotals {
|
|
248
360
|
const applicableRules = ruleResults.filter((rule) => rule.applicableCount > 0);
|
|
249
361
|
const applicableWeight = applicableRules.reduce((sum, rule) => sum + rule.weight, 0);
|
|
250
362
|
const assessedWeight = ruleResults.reduce(
|
|
@@ -260,7 +372,7 @@ function calculateTotals(ruleResults) {
|
|
|
260
372
|
? 0
|
|
261
373
|
: (rule.weight / rule.applicableCount) *
|
|
262
374
|
rule.criteria
|
|
263
|
-
.filter(
|
|
375
|
+
.filter(isAssessedCriterion)
|
|
264
376
|
.reduce((criterionSum, criterion) => criterionSum + criterion.statusFactor, 0)),
|
|
265
377
|
0,
|
|
266
378
|
);
|
|
@@ -287,14 +399,14 @@ function calculateTotals(ruleResults) {
|
|
|
287
399
|
};
|
|
288
400
|
}
|
|
289
401
|
const confidenceNumerator = ruleResults.reduce((sum, rule) => {
|
|
290
|
-
const applicableCount = rule
|
|
402
|
+
const { applicableCount } = rule;
|
|
291
403
|
if (applicableCount === 0) return sum;
|
|
292
404
|
const criterionWeight = rule.weight / applicableCount;
|
|
293
405
|
return (
|
|
294
406
|
sum +
|
|
295
407
|
criterionWeight *
|
|
296
408
|
rule.criteria
|
|
297
|
-
.filter(
|
|
409
|
+
.filter(isAssessedCriterion)
|
|
298
410
|
.reduce((criterionSum, criterion) => criterionSum + criterion.confidenceFactor, 0)
|
|
299
411
|
);
|
|
300
412
|
}, 0);
|
|
@@ -315,10 +427,14 @@ function calculateTotals(ruleResults) {
|
|
|
315
427
|
}
|
|
316
428
|
|
|
317
429
|
/*** Validate evidence identities and canonical confidence factors. */
|
|
318
|
-
function validateEvidence(
|
|
319
|
-
|
|
430
|
+
function validateEvidence(
|
|
431
|
+
evidence: unknown[],
|
|
432
|
+
confidenceFactors: Record<string, number>,
|
|
433
|
+
): Map<string, EvidenceItem> {
|
|
434
|
+
const evidenceById = new Map<string, EvidenceItem>();
|
|
320
435
|
for (const item of evidence) {
|
|
321
436
|
assertRecord(item, 'Evidence item');
|
|
437
|
+
assertEvidenceItem(item);
|
|
322
438
|
if (typeof item.id !== 'string' || item.id === '') {
|
|
323
439
|
throw new Error('Every evidence item requires a non-empty id.');
|
|
324
440
|
}
|
|
@@ -355,17 +471,17 @@ function validateEvidence(evidence, confidenceFactors) {
|
|
|
355
471
|
}
|
|
356
472
|
|
|
357
473
|
/*** Derive one canonical release item from all of its subcriteria. */
|
|
358
|
-
function calculateReleaseGate(
|
|
474
|
+
function calculateReleaseGate(
|
|
475
|
+
gate: ReleaseGateDefinition,
|
|
476
|
+
rawGate: unknown,
|
|
477
|
+
evidenceById: Map<string, EvidenceItem>,
|
|
478
|
+
): ReleaseGateResult {
|
|
359
479
|
const gateInput = isRecord(rawGate) ? rawGate : {};
|
|
360
480
|
const rawCriteria = isRecord(gateInput.criteria) ? gateInput.criteria : {};
|
|
361
481
|
const criteria = gate.criteria.map((criterionId) => {
|
|
362
482
|
const raw = isRecord(rawCriteria[criterionId]) ? rawCriteria[criterionId] : {};
|
|
363
483
|
const applicable = raw.applicable !== false;
|
|
364
|
-
const status = applicable
|
|
365
|
-
? ['pass', 'fail'].includes(raw.status)
|
|
366
|
-
? raw.status
|
|
367
|
-
: 'not-assessable'
|
|
368
|
-
: 'not-applicable';
|
|
484
|
+
const status = applicable ? readReleaseCriterionStatus(raw.status) : 'not-applicable';
|
|
369
485
|
const evidenceIds = readStringArray(raw.evidenceIds);
|
|
370
486
|
if (['pass', 'fail'].includes(status) && evidenceIds.length === 0) {
|
|
371
487
|
throw new Error(`Release criterion ${criterionId} requires evidence for status ${status}.`);
|
|
@@ -406,7 +522,7 @@ function calculateReleaseGate(gate, rawGate, evidenceById) {
|
|
|
406
522
|
}
|
|
407
523
|
|
|
408
524
|
/*** Derive the aggregate release gate without allowing the score to override evidence gaps. */
|
|
409
|
-
function calculateAggregateReleaseGate(releaseGates) {
|
|
525
|
+
function calculateAggregateReleaseGate(releaseGates: ReleaseGateResult[]): string {
|
|
410
526
|
const applicable = releaseGates.filter((gate) => gate.applicable);
|
|
411
527
|
if (applicable.length === 0) return 'not-assessable';
|
|
412
528
|
if (applicable.some((gate) => gate.status === 'fail')) return 'fail';
|
|
@@ -415,29 +531,44 @@ function calculateAggregateReleaseGate(releaseGates) {
|
|
|
415
531
|
}
|
|
416
532
|
|
|
417
533
|
/*** Deduplicate findings by root cause and allocate each criterion loss in stable units. */
|
|
418
|
-
function allocateFindingImpacts(
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
534
|
+
function allocateFindingImpacts(
|
|
535
|
+
findings: unknown[],
|
|
536
|
+
ruleResults: RuleResult[],
|
|
537
|
+
applicableWeight: number,
|
|
538
|
+
evidenceById: Map<string, EvidenceItem>,
|
|
539
|
+
) {
|
|
540
|
+
const normalizedFindings = findings.map((finding) => {
|
|
422
541
|
assertRecord(finding, 'Finding');
|
|
423
542
|
validateFinding(finding, evidenceById);
|
|
543
|
+
return finding;
|
|
544
|
+
});
|
|
545
|
+
const deduplicated: Finding[] = [];
|
|
546
|
+
const seenRootCauses = new Set<string>();
|
|
547
|
+
for (const finding of normalizedFindings.sort((left, right) => left.id.localeCompare(right.id))) {
|
|
424
548
|
const rootCause = typeof finding.rootCause === 'string' ? finding.rootCause : finding.id;
|
|
425
549
|
if (!seenRootCauses.has(rootCause)) {
|
|
426
550
|
seenRootCauses.add(rootCause);
|
|
427
551
|
deduplicated.push({ ...finding, rootCause });
|
|
428
552
|
}
|
|
429
553
|
}
|
|
430
|
-
const byCriterion = new Map();
|
|
554
|
+
const byCriterion = new Map<string, Finding[]>();
|
|
431
555
|
for (const finding of deduplicated) {
|
|
432
556
|
const key = `${finding.rule}:${finding.criterionId}`;
|
|
433
557
|
byCriterion.set(key, [...(byCriterion.get(key) ?? []), finding]);
|
|
434
558
|
}
|
|
435
|
-
const allocations = new Map();
|
|
559
|
+
const allocations = new Map<string, number>();
|
|
436
560
|
for (const [key, criterionFindings] of byCriterion) {
|
|
437
561
|
const [ruleId, criterionId] = key.split(':');
|
|
438
562
|
const rule = ruleResults.find((result) => result.rule === ruleId);
|
|
439
|
-
|
|
440
|
-
|
|
563
|
+
if (!rule) {
|
|
564
|
+
throw new Error(`Finding references an unscored criterion: ${key}`);
|
|
565
|
+
}
|
|
566
|
+
const criterion = rule.criteria.find((result) => result.id === criterionId);
|
|
567
|
+
if (
|
|
568
|
+
criterion?.statusFactor === undefined ||
|
|
569
|
+
criterion.statusFactor === null ||
|
|
570
|
+
applicableWeight === 0
|
|
571
|
+
) {
|
|
441
572
|
throw new Error(`Finding references an unscored criterion: ${key}`);
|
|
442
573
|
}
|
|
443
574
|
if (criterion.statusFactor === 1) {
|
|
@@ -460,7 +591,10 @@ function allocateFindingImpacts(findings, ruleResults, applicableWeight, evidenc
|
|
|
460
591
|
}
|
|
461
592
|
|
|
462
593
|
/*** Validate the complete finding contract and its evidence references before score allocation. */
|
|
463
|
-
function validateFinding(
|
|
594
|
+
function validateFinding(
|
|
595
|
+
finding: Record<string, unknown>,
|
|
596
|
+
evidenceById: Map<string, EvidenceItem>,
|
|
597
|
+
): asserts finding is Finding {
|
|
464
598
|
for (const field of [
|
|
465
599
|
'id',
|
|
466
600
|
'rule',
|
|
@@ -476,26 +610,32 @@ function validateFinding(finding, evidenceById) {
|
|
|
476
610
|
'verification',
|
|
477
611
|
'confidence',
|
|
478
612
|
]) {
|
|
479
|
-
|
|
613
|
+
const fieldValue = finding[field];
|
|
614
|
+
if (typeof fieldValue !== 'string' || fieldValue === '') {
|
|
480
615
|
throw new Error(`Finding requires a non-empty ${field}.`);
|
|
481
616
|
}
|
|
482
617
|
}
|
|
618
|
+
const findingId = typeof finding.id === 'string' ? finding.id : 'unknown finding';
|
|
483
619
|
if (!Array.isArray(finding.relatedRules)) {
|
|
484
|
-
throw new Error(`Finding ${
|
|
620
|
+
throw new Error(`Finding ${findingId} requires a relatedRules list.`);
|
|
485
621
|
}
|
|
486
622
|
const evidenceIds = readStringArray(finding.evidenceIds);
|
|
487
623
|
if (evidenceIds.length === 0) {
|
|
488
|
-
throw new Error(`Finding ${
|
|
624
|
+
throw new Error(`Finding ${findingId} requires evidenceIds.`);
|
|
489
625
|
}
|
|
490
626
|
for (const evidenceId of evidenceIds) {
|
|
491
627
|
if (!evidenceById.has(evidenceId)) {
|
|
492
|
-
throw new Error(`Finding ${
|
|
628
|
+
throw new Error(`Finding ${findingId} references unknown evidence: ${evidenceId}`);
|
|
493
629
|
}
|
|
494
630
|
}
|
|
495
631
|
}
|
|
496
632
|
|
|
497
633
|
/*** Label rule status from assessed factors while preserving explicit critical evidence. */
|
|
498
|
-
function labelRuleStatus(
|
|
634
|
+
function labelRuleStatus(
|
|
635
|
+
criteria: CriterionResult[],
|
|
636
|
+
meanFactor: number | null,
|
|
637
|
+
applicableCount: number,
|
|
638
|
+
): string {
|
|
499
639
|
if (applicableCount === 0) return 'not-applicable';
|
|
500
640
|
if (meanFactor === null) return 'not-assessable';
|
|
501
641
|
if (criteria.some((criterion) => criterion.status === 'critical')) return 'critical';
|
|
@@ -506,7 +646,7 @@ function labelRuleStatus(criteria, meanFactor, applicableCount) {
|
|
|
506
646
|
}
|
|
507
647
|
|
|
508
648
|
/*** Label evidence coverage without conflating it with confidence. */
|
|
509
|
-
function labelCoverage(value) {
|
|
649
|
+
function labelCoverage(value: number | null): string | null {
|
|
510
650
|
if (value === null) return null;
|
|
511
651
|
if (value >= 85) return 'high';
|
|
512
652
|
if (value >= 60) return 'medium';
|
|
@@ -514,7 +654,7 @@ function labelCoverage(value) {
|
|
|
514
654
|
}
|
|
515
655
|
|
|
516
656
|
/*** Label aggregate evidence confidence from the canonical numeric thresholds. */
|
|
517
|
-
function labelConfidence(value) {
|
|
657
|
+
function labelConfidence(value: number | null): string | null {
|
|
518
658
|
if (value === null) return null;
|
|
519
659
|
if (value >= 0.8) return 'high';
|
|
520
660
|
if (value >= 0.5) return 'medium';
|
|
@@ -522,28 +662,118 @@ function labelConfidence(value) {
|
|
|
522
662
|
}
|
|
523
663
|
|
|
524
664
|
/*** Round a nonnegative displayed value half up. */
|
|
525
|
-
function roundHalfUp(value) {
|
|
665
|
+
function roundHalfUp(value: number): number {
|
|
526
666
|
return Math.floor(value + 0.5);
|
|
527
667
|
}
|
|
528
668
|
|
|
529
669
|
/*** Round an intermediate serialization value without changing calculation inputs. */
|
|
530
|
-
function roundDecimal(value, digits) {
|
|
670
|
+
function roundDecimal(value: number, digits: number): number {
|
|
531
671
|
const factor = 10 ** digits;
|
|
532
672
|
return Math.round((value + Number.EPSILON) * factor) / factor;
|
|
533
673
|
}
|
|
534
674
|
|
|
675
|
+
/*** Return whether a criterion carries the numeric factors required for scoring. */
|
|
676
|
+
function isAssessedCriterion(
|
|
677
|
+
criterion: CriterionResult,
|
|
678
|
+
): criterion is CriterionResult & { confidenceFactor: number; statusFactor: number } {
|
|
679
|
+
return criterion.statusFactor !== null && criterion.confidenceFactor !== null;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/*** Normalize one risk while preserving its supplied descriptive fields. */
|
|
683
|
+
function normalizeRisk(value: unknown): Record<string, unknown> {
|
|
684
|
+
assertRecord(value, 'Risk');
|
|
685
|
+
return { ...value, scoreImpact: 0 };
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/*** Read the only evidence-backed release criterion statuses. */
|
|
689
|
+
function readReleaseCriterionStatus(value: unknown): string {
|
|
690
|
+
return value === 'pass' || value === 'fail' ? value : 'not-assessable';
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/*** Validate the canonical rubric structure loaded from the packaged JSON asset. */
|
|
694
|
+
function assertAuditRubric(value: unknown): asserts value is AuditRubric {
|
|
695
|
+
assertRecord(value, 'Audit rubric');
|
|
696
|
+
if (!isNumberRecord(value.confidenceFactors) || !isNullableNumberRecord(value.statusFactors)) {
|
|
697
|
+
throw new Error('Audit rubric factors must be numeric records.');
|
|
698
|
+
}
|
|
699
|
+
if (!Array.isArray(value.rules) || !Array.isArray(value.releaseGates)) {
|
|
700
|
+
throw new Error('Audit rubric requires rules and releaseGates arrays.');
|
|
701
|
+
}
|
|
702
|
+
for (const rule of value.rules) {
|
|
703
|
+
assertRecord(rule, 'Audit rule');
|
|
704
|
+
if (
|
|
705
|
+
typeof rule.id !== 'string' ||
|
|
706
|
+
typeof rule.name !== 'string' ||
|
|
707
|
+
typeof rule.weight !== 'number' ||
|
|
708
|
+
!isStringArray(rule.criteria)
|
|
709
|
+
) {
|
|
710
|
+
throw new Error('Every audit rule requires id, name, weight, and criteria.');
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
for (const gate of value.releaseGates) {
|
|
714
|
+
assertRecord(gate, 'Release gate');
|
|
715
|
+
if (
|
|
716
|
+
typeof gate.id !== 'string' ||
|
|
717
|
+
typeof gate.name !== 'string' ||
|
|
718
|
+
!isStringArray(gate.criteria)
|
|
719
|
+
) {
|
|
720
|
+
throw new Error('Every release gate requires id, name, and criteria.');
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/*** Validate one evidence record before semantic checks use its required fields. */
|
|
726
|
+
function assertEvidenceItem(value: Record<string, unknown>): asserts value is EvidenceItem {
|
|
727
|
+
const stringFields = [
|
|
728
|
+
'confidence',
|
|
729
|
+
'evidenceLevel',
|
|
730
|
+
'id',
|
|
731
|
+
'kind',
|
|
732
|
+
'location',
|
|
733
|
+
'observation',
|
|
734
|
+
'reproduction',
|
|
735
|
+
];
|
|
736
|
+
if (
|
|
737
|
+
stringFields.some((field) => typeof value[field] !== 'string') ||
|
|
738
|
+
typeof value.confidenceFactor !== 'number' ||
|
|
739
|
+
!Array.isArray(value.limitations)
|
|
740
|
+
) {
|
|
741
|
+
throw new Error('Evidence item has an invalid canonical shape.');
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/*** Return whether every record value is a finite number. */
|
|
746
|
+
function isNumberRecord(value: unknown): value is Record<string, number> {
|
|
747
|
+
return isRecord(value) && Object.values(value).every((item) => typeof item === 'number');
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/*** Return whether every record value is numeric or explicitly unassessed. */
|
|
751
|
+
function isNullableNumberRecord(value: unknown): value is Record<string, number | null> {
|
|
752
|
+
return (
|
|
753
|
+
isRecord(value) &&
|
|
754
|
+
Object.values(value).every((item) => item === null || typeof item === 'number')
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/*** Return whether an unknown value contains only strings. */
|
|
759
|
+
function isStringArray(value: unknown): value is string[] {
|
|
760
|
+
return Array.isArray(value) && value.every((item) => typeof item === 'string');
|
|
761
|
+
}
|
|
762
|
+
|
|
535
763
|
/*** Read a stable string list from optional audit input. */
|
|
536
|
-
function readStringArray(value) {
|
|
537
|
-
return Array.isArray(value)
|
|
764
|
+
function readStringArray(value: unknown): string[] {
|
|
765
|
+
return Array.isArray(value)
|
|
766
|
+
? value.filter((item): item is string => typeof item === 'string')
|
|
767
|
+
: [];
|
|
538
768
|
}
|
|
539
769
|
|
|
540
770
|
/*** Narrow an unknown value to a non-array record. */
|
|
541
|
-
function isRecord(value) {
|
|
771
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
542
772
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
543
773
|
}
|
|
544
774
|
|
|
545
775
|
/*** Require an object-shaped input value. */
|
|
546
|
-
function assertRecord(value, label) {
|
|
776
|
+
function assertRecord(value: unknown, label: string): asserts value is Record<string, unknown> {
|
|
547
777
|
if (!isRecord(value)) throw new Error(`${label} must be an object.`);
|
|
548
778
|
}
|
|
549
779
|
|
|
@@ -555,14 +785,15 @@ async function main() {
|
|
|
555
785
|
return;
|
|
556
786
|
}
|
|
557
787
|
if (command === 'audit' && inputPath) {
|
|
558
|
-
const input = JSON.parse(await readFile(inputPath, 'utf8'));
|
|
788
|
+
const input: unknown = JSON.parse(await readFile(inputPath, 'utf8'));
|
|
789
|
+
assertRecord(input, 'Audit artifact input');
|
|
559
790
|
const audit = await calculateAudit(input.auditInput ?? input);
|
|
560
791
|
const artifact = serializeArtifact({ ...input, documentKind: 'audit' }, audit);
|
|
561
792
|
if (outputPath) await writeFile(outputPath, artifact);
|
|
562
793
|
else console.log(artifact);
|
|
563
794
|
return;
|
|
564
795
|
}
|
|
565
|
-
throw new Error('Usage: audit.
|
|
796
|
+
throw new Error('Usage: audit.ts catalog | audit.ts audit <input.json> [output.md]');
|
|
566
797
|
}
|
|
567
798
|
|
|
568
799
|
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
|
|
@@ -3,16 +3,25 @@
|
|
|
3
3
|
import { access, readdir, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { join, resolve } from 'node:path';
|
|
5
5
|
|
|
6
|
-
import { loadContractsApi } from './owner-api.
|
|
6
|
+
import { loadContractsApi } from './owner-api.ts';
|
|
7
7
|
|
|
8
8
|
const CATEGORY_ROOT = 'src/templates/categories';
|
|
9
9
|
|
|
10
|
+
interface TemplateDefinitionSource {
|
|
11
|
+
category: string;
|
|
12
|
+
categoryDirectory: string;
|
|
13
|
+
slug: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
10
16
|
/*** Regenerate the portable template catalog from canonical template directories. */
|
|
11
|
-
export async function generateTemplateCatalog(
|
|
17
|
+
export async function generateTemplateCatalog(
|
|
18
|
+
targetDirectory = process.cwd(),
|
|
19
|
+
appCategories?: readonly string[],
|
|
20
|
+
): Promise<{ outputPath: string; templateCount: number }> {
|
|
12
21
|
const root = resolve(targetDirectory);
|
|
13
22
|
const canonicalAppCategories = appCategories ?? (await loadContractsApi(root)).APP_CATEGORIES;
|
|
14
23
|
const categoriesRoot = join(root, CATEGORY_ROOT);
|
|
15
|
-
const definitions = [];
|
|
24
|
+
const definitions: TemplateDefinitionSource[] = [];
|
|
16
25
|
|
|
17
26
|
for (const categoryEntry of await readDirectories(categoriesRoot)) {
|
|
18
27
|
const category = categoryEntry.name.replaceAll('-', '_');
|
|
@@ -65,14 +74,14 @@ export const TEMPLATE_DEFINITIONS: readonly TemplateDefinition[] = ${definitions
|
|
|
65
74
|
}
|
|
66
75
|
|
|
67
76
|
/*** Read child directories in stable lexical order. */
|
|
68
|
-
async function readDirectories(directory) {
|
|
77
|
+
async function readDirectories(directory: string) {
|
|
69
78
|
return (await readdir(directory, { withFileTypes: true }))
|
|
70
79
|
.filter((entry) => entry.isDirectory())
|
|
71
80
|
.sort((left, right) => left.name.localeCompare(right.name));
|
|
72
81
|
}
|
|
73
82
|
|
|
74
83
|
/*** Return whether a filesystem path exists. */
|
|
75
|
-
async function pathExists(filePath) {
|
|
84
|
+
async function pathExists(filePath: string): Promise<boolean> {
|
|
76
85
|
try {
|
|
77
86
|
await access(filePath);
|
|
78
87
|
return true;
|