@bounded-systems/conformance-kit 0.2.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.
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env node
2
+ // Baseline-availability gate — turns "our CSS is interoperable" into a
3
+ // CONTINUOUSLY-ENFORCED member of the conformance contract. It maps a project's
4
+ // SHIPPED CSS to web-features Baseline data (via stylelint-plugin-use-baseline —
5
+ // headless, no browser) and FAILS CLOSED (exit 1) when the site-wide status is
6
+ // below a configurable target (default: widely). The machine-readable result is
7
+ // exactly the shape lone's conformance() model consumes for `compatibility.baseline`
8
+ // (`{ status, fallbackTested }`), so a clean run lets a site honestly assert that
9
+ // criterion — and a regression to a newer/limited feature turns CI red.
10
+ //
11
+ // node gates/baseline-gate.mjs [cssGlob] # build gate (exit 1 below target)
12
+ //
13
+ // HONEST, NOT ASPIRATIONAL: the gate reports the MEASURED status whatever it is.
14
+ // The site-wide status is the WORST feature used (a feature guarded behind an
15
+ // `@supports` query is a tested fallback and does not count against it):
16
+ // • 0 features below "widely" -> "widely"
17
+ // • some below "widely" but none below "newly" -> "newly"
18
+ // • any feature below "newly" -> "limited"
19
+ //
20
+ // Config-driven; NOTHING about any one site is hard-coded:
21
+ // argv[2] / $BASELINE_CSS glob of CSS to scan (default: "dist/**/*.css")
22
+ // $BASELINE_TARGET lowest acceptable status (widely|newly, default: widely)
23
+ // $BASELINE_REPORT path to write the JSON report (default: none)
24
+ //
25
+ // The pure classify/threshold functions are exported for unit testing.
26
+ import { writeFile } from "node:fs/promises";
27
+ import { resolve } from "node:path";
28
+
29
+ // ── Pure core (unit-testable) ────────────────────────────────────────────────
30
+
31
+ export const STATUS_ORDER = ["limited", "newly", "widely"]; // worst → best
32
+
33
+ /** Site-wide status from the two stylelint passes (counts of features below each bar). */
34
+ export function classify(belowWidely, belowNewly) {
35
+ if (belowWidely === 0) return "widely";
36
+ return belowNewly > 0 ? "limited" : "newly";
37
+ }
38
+
39
+ /** Whether `status` is at or above the `target` bar. */
40
+ export function meetsTarget(status, target) {
41
+ return STATUS_ORDER.indexOf(status) >= STATUS_ORDER.indexOf(target);
42
+ }
43
+
44
+ /** Build the report from a measured status. Pure: (status, target, offenders) → report. */
45
+ export function evaluateBaseline(status, target = "widely", offenders = []) {
46
+ return {
47
+ passed: meetsTarget(status, target),
48
+ target,
49
+ status,
50
+ offenders,
51
+ // The envelope lone's conformance() consumes for `compatibility.baseline`.
52
+ baseline: { status, fallbackTested: false },
53
+ };
54
+ }
55
+
56
+ // ── Impure runner (stylelint; deterministic, no browser/network) ─────────────
57
+
58
+ async function violationsAt(files, available) {
59
+ const stylelint = (await import("stylelint")).default;
60
+ const res = await stylelint.lint({
61
+ files,
62
+ config: {
63
+ plugins: ["stylelint-plugin-use-baseline"],
64
+ rules: { "plugin/use-baseline": [true, { available }] },
65
+ },
66
+ });
67
+ const feats = [];
68
+ for (const r of res.results) for (const w of r.warnings) feats.push(w.text.replace(/\s+plugin\/use-baseline$/, ""));
69
+ return feats;
70
+ }
71
+
72
+ /** Scan → classify → evaluate → report. Exposed for programmatic use and the test. */
73
+ export async function runBaselineGate({ css, target = "widely" }) {
74
+ const belowWidely = await violationsAt(css, "widely");
75
+ let status = "widely";
76
+ if (belowWidely.length > 0) {
77
+ const belowNewly = await violationsAt(css, "newly");
78
+ status = classify(belowWidely.length, belowNewly.length);
79
+ }
80
+ return evaluateBaseline(status, target, belowWidely);
81
+ }
82
+
83
+ // ── CLI ──────────────────────────────────────────────────────────────────────
84
+
85
+ async function main() {
86
+ const css = (process.argv[2] && !process.argv[2].startsWith("--") ? process.argv[2] : process.env.BASELINE_CSS || "dist/**/*.css");
87
+ const target = (process.env.BASELINE_TARGET || "widely").trim();
88
+ if (target !== "widely" && target !== "newly") {
89
+ console.error(`✗ baseline-gate: $BASELINE_TARGET must be "widely" or "newly" (got "${target}")`);
90
+ process.exit(2);
91
+ }
92
+
93
+ const report = await runBaselineGate({ css: resolve(process.cwd(), css), target });
94
+ if (process.env.BASELINE_REPORT) {
95
+ await writeFile(resolve(process.env.BASELINE_REPORT), JSON.stringify(report, null, 2) + "\n");
96
+ }
97
+
98
+ const line = `baseline-gate: shipped CSS is Baseline "${report.status}" (${report.offenders.length} feature(s) below widely) · target "${target}"`;
99
+ if (!report.passed) {
100
+ console.error(`✗ ${line}`);
101
+ for (const o of report.offenders) console.error(` · ${o}`);
102
+ console.error(` guard newer features behind an @supports feature query (the tested fallback), or lower $BASELINE_TARGET.`);
103
+ process.exit(1);
104
+ }
105
+ console.log(`✓ ${line}`);
106
+ for (const o of report.offenders) console.log(` · ${o}`);
107
+ }
108
+
109
+ // Only run the CLI when invoked directly (not when imported by a test).
110
+ if (import.meta.url === `file://${process.argv[1]}`) {
111
+ main().catch((e) => { console.error("✗ baseline-gate: error —", e.stack || e.message); process.exit(1); });
112
+ }
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env node
2
+ // CommonMark assertion runner — pins a site's markdown renderer so its
3
+ // markdown→HTML behaviour can't silently drift, and proves it never emits unsafe
4
+ // raw HTML.
5
+ //
6
+ // node gates/commonmark-runner.mjs <renderer.mjs> [fixtures.json]
7
+ //
8
+ // The RENDERER IS AN INPUT: a site supplies the module exporting its markdown→HTML
9
+ // function (the export name defaults to `renderMarkdown`, override with
10
+ // $COMMONMARK_RENDER_EXPORT). The runner does two things:
11
+ // 1. CONFORMANCE — for the constructs the renderer supports, assert it produces
12
+ // the expected HTML. Drift from these snapshots fails the build.
13
+ // 2. SAFETY — feed it hostile raw HTML and assert every tag is ESCAPED, never
14
+ // passed through (the safe deviation from CommonMark, which passes HTML blocks).
15
+ //
16
+ // The fixtures default to a small, safe CommonMark SUBSET (headings, emphasis, code
17
+ // spans, links, tight bullet lists, HTML-escaping; hr as HTML5 void <hr>;
18
+ // single-block blockquote without inner <p>). A site whose renderer differs supplies
19
+ // its own fixtures JSON: { "conformance": {md: html}, "subset": {md: html},
20
+ // "hostile": [md], "allowedTags": [tag], "dangerous": "regex-source" }.
21
+ import { readFile } from "node:fs/promises";
22
+ import { resolve, isAbsolute } from "node:path";
23
+ import { pathToFileURL } from "node:url";
24
+
25
+ const rendererPath = process.argv[2] || process.env.COMMONMARK_RENDERER;
26
+ const fixturesPath = process.argv[3] || process.env.COMMONMARK_FIXTURES;
27
+ const exportName = process.env.COMMONMARK_RENDER_EXPORT || "renderMarkdown";
28
+ if (!rendererPath) { console.error("usage: commonmark-runner <renderer.mjs> [fixtures.json]"); process.exit(2); }
29
+
30
+ const abs = isAbsolute(rendererPath) ? rendererPath : resolve(rendererPath);
31
+ const mod = await import(pathToFileURL(abs).href);
32
+ const renderMarkdown = mod[exportName] || mod.default;
33
+ if (typeof renderMarkdown !== "function") {
34
+ console.error(`✗ commonmark-runner: ${rendererPath} has no "${exportName}" (or default) function export`);
35
+ process.exit(2);
36
+ }
37
+
38
+ // ---- default fixtures: a safe CommonMark subset ---------------------------------
39
+ const DEFAULTS = {
40
+ conformance: {
41
+ "## A heading": "<h2>A heading</h2>",
42
+ "### Sub heading": "<h3>Sub heading</h3>",
43
+ "This is *italic* and **bold** text.": "<p>This is <em>italic</em> and <strong>bold</strong> text.</p>",
44
+ "Use the `build.mjs` file.": "<p>Use the <code>build.mjs</code> file.</p>",
45
+ "See [the site](https://example.com).": '<p>See <a href="https://example.com">the site</a>.</p>',
46
+ "- one\n- two\n- three": "<ul><li>one</li><li>two</li><li>three</li></ul>",
47
+ "* a\n* b": "<ul><li>a</li><li>b</li></ul>",
48
+ "Just a plain paragraph\nwith a soft break.": "<p>Just a plain paragraph with a soft break.</p>",
49
+ "Tom & Jerry < > test.": "<p>Tom &amp; Jerry &lt; &gt; test.</p>",
50
+ },
51
+ subset: {
52
+ "---": "<hr>",
53
+ "> quoted line\n> second line": "<blockquote>quoted line second line</blockquote>",
54
+ },
55
+ hostile: [
56
+ '<div onclick="x">hi</div> and <script>alert(1)</script>',
57
+ "An <img src=x onerror=alert(1)> inline.",
58
+ "<a href=javascript:alert(1)>x</a>",
59
+ "<iframe src=//evil></iframe>",
60
+ ],
61
+ allowedTags: ["p", "h2", "h3", "ul", "li", "blockquote", "hr", "code", "a", "strong", "em"],
62
+ dangerous: "\\b(onclick|onerror|onload)\\b|javascript:",
63
+ };
64
+
65
+ const fx = fixturesPath
66
+ ? { ...DEFAULTS, ...JSON.parse(await readFile(resolve(fixturesPath), "utf8")) }
67
+ : DEFAULTS;
68
+
69
+ const CONFORMANCE = fx.conformance;
70
+ const SUBSET = fx.subset || {};
71
+ const HOSTILE = fx.hostile || [];
72
+ const ALLOWED_TAGS = new Set(fx.allowedTags || []);
73
+ const DANGEROUS = new RegExp(fx.dangerous || DEFAULTS.dangerous, "i");
74
+
75
+ let fails = 0;
76
+ const fail = (m) => { console.error(` ✗ ${m}`); fails++; };
77
+
78
+ // ---- 1. CONFORMANCE snapshots: input → exact expected HTML ----------------------
79
+ for (const [md, want] of [...Object.entries(CONFORMANCE), ...Object.entries(SUBSET)]) {
80
+ const got = renderMarkdown(md);
81
+ if (got !== want) fail(`snapshot drift for ${JSON.stringify(md)}\n want: ${want}\n got: ${got}`);
82
+ }
83
+
84
+ // ---- 2. SAFETY: raw HTML must be escaped, never passed through ------------------
85
+ for (const md of HOSTILE) {
86
+ const out = renderMarkdown(md);
87
+ for (const t of out.matchAll(/<\/?([a-zA-Z][a-zA-Z0-9]*)\b/g)) {
88
+ if (!ALLOWED_TAGS.has(t[1].toLowerCase())) fail(`unsafe raw HTML leaked through renderer: <${t[1]}> from ${JSON.stringify(md)}`);
89
+ }
90
+ if (DANGEROUS.test(out) && !/&lt;/.test(out)) fail(`dangerous attribute/URL not neutralised: ${JSON.stringify(md)} → ${out}`);
91
+ }
92
+
93
+ console.log("");
94
+ if (fails) {
95
+ console.error(`✗ commonmark-runner: ${fails} failure(s) — the renderer drifted or leaked raw HTML.`);
96
+ process.exit(1);
97
+ }
98
+ console.log(`✓ commonmark-runner: pinned ${Object.keys(CONFORMANCE).length} CommonMark + ${Object.keys(SUBSET).length} subset construct(s); ${HOSTILE.length} hostile input(s) fully escaped.`);
@@ -0,0 +1,389 @@
1
+ // gates/conformance/conformance.mjs
2
+ //
3
+ // Zero-dependency Node port of lone's conformance AGGREGATOR
4
+ // (`jsr:@bounded-systems/lone@^0.4`, `src/standard/conformance.ts`). Folds (a)
5
+ // lone's static DOM findings and (b) supplied external evidence into a single typed
6
+ // report. The contract that matters: the strong COMPACT_CLAIM is emitted ONLY when
7
+ // every gating (tier-1 `required`) criterion is `met`. Anything less yields an
8
+ // honest partial summary that names what is clean, what is unmet, and what was never
9
+ // assessed. Absent external evidence → `not-assessed`, never silently `met`.
10
+ //
11
+ // See ./web-build.mjs for why the model is mirrored in Node rather than imported.
12
+
13
+ import {
14
+ COMPACT_CLAIM,
15
+ CRITERIA,
16
+ CWV_THRESHOLDS,
17
+ parseExternalEvidence,
18
+ STANDARD_NAME,
19
+ STANDARD_VERSION,
20
+ } from "./web-build.mjs";
21
+
22
+ /**
23
+ * Whether a criterion gates the headline COMPACT_CLAIM. Only the tier-1 required
24
+ * criteria do — tier-2/tier-3/cognitive criteria are reported and summarised but
25
+ * NEVER widen the compact claim. Criteria with no explicit `tier` are tier-1.
26
+ */
27
+ function gatesCompactClaim(c) {
28
+ return c.required && (c.tier ?? 1) === 1;
29
+ }
30
+
31
+ /** Whether lone could not even read the subtree. */
32
+ const INVALID_SUBJECT_CODE = "LONE_ENGINE_INVALID_SUBJECT";
33
+
34
+ const met = (detail) => ({ status: "met", detail });
35
+ const unmet = (detail) => ({ status: "unmet", detail });
36
+ const notAssessed = (detail) => ({ status: "not-assessed", detail });
37
+
38
+ /** The OpenSSF Scorecard score at/above which `integrity.scorecard` is `met`. */
39
+ const SCORECARD_THRESHOLD = 7.0;
40
+
41
+ // Per-criterion evaluator for external evidence. Returns `not-assessed` when the
42
+ // relevant evidence field is absent; otherwise checks shape/thresholds.
43
+ const EXTERNAL_EVALUATORS = {
44
+ "html.validator-clean": (e) => {
45
+ const v = e.htmlValidator;
46
+ if (!v) return notAssessed("no Nu HTML Checker report supplied");
47
+ return v.errors === 0 ? met("0 validator errors") : unmet(`${v.errors} validator error(s)`);
48
+ },
49
+ "a11y.axe-serious-critical": (e) => {
50
+ const v = e.axe;
51
+ if (!v) return notAssessed("no axe-core scan supplied");
52
+ const bad = v.serious + v.critical;
53
+ return bad === 0
54
+ ? met("0 serious/critical violations")
55
+ : unmet(`${v.critical} critical, ${v.serious} serious violation(s)`);
56
+ },
57
+ "a11y.wcag22-aa-manual": (e) => {
58
+ const v = e.manualA11y;
59
+ if (!v) return notAssessed("no manual WCAG 2.2 AA audit supplied");
60
+ const ok = v.wcag22AA && v.keyboardTested && v.screenReaderTested && v.completeFlows;
61
+ if (!ok) {
62
+ const gaps = [];
63
+ if (!v.wcag22AA) gaps.push("AA not attested");
64
+ if (!v.keyboardTested) gaps.push("keyboard not tested");
65
+ if (!v.screenReaderTested) gaps.push("screen reader not tested");
66
+ if (!v.completeFlows) gaps.push("flows incomplete");
67
+ return unmet(gaps.join(", "));
68
+ }
69
+ // Attested clean — but a self-attested manual audit never gates the claim.
70
+ if (!v.verifiedBy) {
71
+ return notAssessed("manual AA audit self-attested; independent verification required (set manualA11y.verifiedBy)");
72
+ }
73
+ return met(`manual AA audit attested across complete flows, verified by ${v.verifiedBy}`);
74
+ },
75
+ "a11y.wcag22-aaa-selected": (e) => {
76
+ const v = e.wcag22AAA;
77
+ if (!v) return notAssessed("no AAA attestation supplied (optional)");
78
+ return v.met
79
+ ? met(`selected AAA met (${v.criteria.length} criteria)`)
80
+ : unmet("selected AAA not met");
81
+ },
82
+ "security.asvs": (e) => {
83
+ const v = e.asvs;
84
+ if (!v || v.achievedLevel == null) return notAssessed("no OWASP ASVS attestation supplied");
85
+ if (v.achievedLevel < v.targetLevel) return unmet(`ASVS Level ${v.achievedLevel} below target L${v.targetLevel}`);
86
+ // Level reached — but a self-graded ASVS attestation never gates the claim.
87
+ if (!v.verifiedBy) {
88
+ return notAssessed(`ASVS L${v.achievedLevel} self-attested; independent verification required (set asvs.verifiedBy)`);
89
+ }
90
+ return met(`ASVS ${v.version} Level ${v.achievedLevel} (target L${v.targetLevel}), verified by ${v.verifiedBy}`);
91
+ },
92
+ "security.no-critical-vulns": (e) => {
93
+ const v = e.vulns;
94
+ if (!v) return notAssessed("no vulnerability report supplied");
95
+ return v.knownCriticalOrHighVulns === 0
96
+ ? met("0 known critical/high vulns")
97
+ : unmet(`${v.knownCriticalOrHighVulns} known critical/high vuln(s)`);
98
+ },
99
+ "security.hsts-preload": (e) => {
100
+ const v = e.hstsPreload;
101
+ if (!v) return notAssessed("no HSTS preload status supplied");
102
+ return v.preloaded
103
+ ? met("origin is on the HSTS preload list")
104
+ : unmet("origin is not on the HSTS preload list");
105
+ },
106
+ "performance.core-web-vitals": (e) => {
107
+ const samples = e.coreWebVitals;
108
+ if (!samples || samples.length === 0) {
109
+ return notAssessed("no Core Web Vitals field data supplied");
110
+ }
111
+ const factors = new Set(samples.map((s) => s.formFactor));
112
+ const missing = ["mobile", "desktop"].filter((f) => !factors.has(f));
113
+ if (missing.length > 0) return unmet(`missing ${missing.join(" + ")} field data`);
114
+ const failures = [];
115
+ for (const s of samples) {
116
+ if (s.percentile < CWV_THRESHOLDS.percentile) failures.push(`${s.formFactor} below p${CWV_THRESHOLDS.percentile}`);
117
+ if (s.lcpMs > CWV_THRESHOLDS.lcpMs) failures.push(`${s.formFactor} LCP ${s.lcpMs}ms`);
118
+ if (s.inpMs > CWV_THRESHOLDS.inpMs) failures.push(`${s.formFactor} INP ${s.inpMs}ms`);
119
+ if (s.cls > CWV_THRESHOLDS.cls) failures.push(`${s.formFactor} CLS ${s.cls}`);
120
+ }
121
+ return failures.length === 0
122
+ ? met("LCP/INP/CLS within thresholds at p75, mobile + desktop")
123
+ : unmet(failures.join("; "));
124
+ },
125
+ "compatibility.baseline": (e) => {
126
+ const v = e.baseline;
127
+ if (!v) return notAssessed("no Baseline result supplied");
128
+ if (v.status === "widely") return met("Baseline Widely Available");
129
+ return v.fallbackTested
130
+ ? met(`Baseline ${v.status}, with a tested fallback`)
131
+ : unmet(`Baseline ${v.status} and no tested fallback`);
132
+ },
133
+ "reliability.runtime": (e) => {
134
+ const v = e.reliability;
135
+ if (!v) return notAssessed("no runtime reliability report supplied");
136
+ const gaps = [];
137
+ if (v.uncaughtErrors !== 0) gaps.push(`${v.uncaughtErrors} uncaught error(s)`);
138
+ if (v.brokenInternalLinks !== 0) gaps.push(`${v.brokenInternalLinks} broken link(s)`);
139
+ if (!v.e2eCriticalJourneys) gaps.push("critical journeys not e2e-covered");
140
+ return gaps.length === 0
141
+ ? met("no runtime errors, no broken links, critical journeys e2e-covered")
142
+ : unmet(gaps.join(", "));
143
+ },
144
+
145
+ // ── Tier-2 ────────────────────────────────────────────────────────────────
146
+ "semantic.jsonld-shacl": (e) => {
147
+ const v = e.jsonLdShacl;
148
+ if (!v) return notAssessed("no JSON-LD/SHACL report supplied");
149
+ return v.conforms && v.blocks === 0
150
+ ? met("JSON-LD 1.1 conforms to SHACL shapes (0 violating blocks)")
151
+ : unmet(v.conforms ? `${v.blocks} SHACL-violating block(s)` : `SHACL does not conform (${v.blocks} block(s))`);
152
+ },
153
+ "seo.technical": (e) => {
154
+ const v = e.seoTechnical;
155
+ if (!v) return notAssessed("no technical-SEO report supplied");
156
+ const gaps = [];
157
+ if (!v.canonicalOk) gaps.push("canonical issues");
158
+ if (!v.titlesUnique) gaps.push("non-unique titles");
159
+ if (!v.robotsRfc9309Ok) gaps.push("robots.txt not RFC 9309-valid");
160
+ if (!v.sitemapResolves) gaps.push("sitemap does not resolve");
161
+ if (v.brokenInternalLinks !== 0) gaps.push(`${v.brokenInternalLinks} broken internal link(s)`);
162
+ return gaps.length === 0
163
+ ? met("canonical/titles/robots/sitemap clean, 0 broken internal links")
164
+ : unmet(gaps.join(", "));
165
+ },
166
+ "semantic.commonmark": (e) => {
167
+ const v = e.commonMark;
168
+ if (!v) return notAssessed("no CommonMark report supplied");
169
+ return v.conforms ? met("Markdown conforms to CommonMark") : unmet("Markdown does not conform to CommonMark");
170
+ },
171
+ "semantic.ai-readability": (e) => {
172
+ const v = e.aiReadability;
173
+ if (!v) return notAssessed("no AI-readability report supplied (optional)");
174
+ const gaps = [];
175
+ if (!v.llmsTxtPresent) gaps.push("llms.txt missing");
176
+ if (!v.linksResolve) gaps.push("llms.txt links do not resolve");
177
+ if (!v.markdownSiblings) gaps.push("no Markdown siblings");
178
+ return gaps.length === 0
179
+ ? met("llms.txt present, links resolve, Markdown siblings exposed")
180
+ : unmet(gaps.join(", "));
181
+ },
182
+ "semantic.openapi": (e) => {
183
+ const v = e.openApi;
184
+ if (!v) return notAssessed("no OpenAPI report supplied (only applies if an API is published)");
185
+ const gaps = [];
186
+ if (!v.openapiValid) gaps.push("OpenAPI document invalid");
187
+ if (!v.responsesMatchSchemas) gaps.push("responses diverge from schemas");
188
+ return gaps.length === 0
189
+ ? met("OpenAPI 3.2 valid; responses match JSON Schema 2020-12")
190
+ : unmet(gaps.join(", "));
191
+ },
192
+ "semantic.feeds": (e) => {
193
+ const v = e.feeds;
194
+ if (!v) return notAssessed("no feed report supplied (optional)");
195
+ return v.atomValid ? met("Atom feed valid (RFC 4287)") : unmet("Atom feed invalid");
196
+ },
197
+
198
+ // ── Tier-3 ──────────────────────────────────────────────────────────────────
199
+ "integrity.slsa-provenance": (e) => {
200
+ const v = e.slsaProvenance;
201
+ if (!v) return notAssessed("no SLSA/in-toto provenance supplied");
202
+ const gaps = [];
203
+ if (!v.present) gaps.push("not present");
204
+ if (!v.signed) gaps.push("not signed");
205
+ if (!v.verified) gaps.push("not verified");
206
+ return gaps.length === 0
207
+ ? met("SLSA/in-toto provenance present, signed, and verified")
208
+ : unmet(gaps.join(", "));
209
+ },
210
+ "integrity.slsa-level": (e) => {
211
+ const v = e.slsaLevel;
212
+ if (!v) return notAssessed("no SLSA build level supplied");
213
+ return v.level >= v.target
214
+ ? met(`SLSA build Level ${v.level} (target L${v.target})`)
215
+ : unmet(`SLSA build Level ${v.level} below target L${v.target}`);
216
+ },
217
+ "integrity.scorecard": (e) => {
218
+ const v = e.scorecard;
219
+ if (!v) return notAssessed("no OpenSSF Scorecard result supplied");
220
+ return v.score >= SCORECARD_THRESHOLD
221
+ ? met(`OpenSSF Scorecard ${v.score.toFixed(1)} (≥ ${SCORECARD_THRESHOLD})`)
222
+ : unmet(`OpenSSF Scorecard ${v.score.toFixed(1)} below ${SCORECARD_THRESHOLD}`);
223
+ },
224
+ "integrity.reproducible-build": (e) => {
225
+ const v = e.reproducibleBuild;
226
+ if (!v) return notAssessed("no reproducible-build report supplied");
227
+ return v.reproducible ? met("build is byte-reproducible") : unmet("build is not reproducible");
228
+ },
229
+ "integrity.sbom": (e) => {
230
+ const v = e.sbom;
231
+ if (!v) return notAssessed("no SPDX SBOM supplied");
232
+ const gaps = [];
233
+ if (!v.present) gaps.push("not present");
234
+ if (!v.valid) gaps.push("not valid");
235
+ if (!v.complete) gaps.push("incomplete");
236
+ if (!v.signed) gaps.push("not signed");
237
+ return gaps.length === 0
238
+ ? met("SPDX SBOM present, valid, complete, and signed")
239
+ : unmet(gaps.join(", "));
240
+ },
241
+ "integrity.content-digests": (e) => {
242
+ const v = e.contentDigests;
243
+ if (!v) return notAssessed("no content-digest report supplied (optional)");
244
+ return v.reprDigestHeaders ? met("Repr-Digest headers present (RFC 9530)") : unmet("no Repr-Digest headers");
245
+ },
246
+ "integrity.signed-release-manifest": (e) => {
247
+ const v = e.signedReleaseManifest;
248
+ if (!v) return notAssessed("no release-manifest report supplied");
249
+ const gaps = [];
250
+ if (!v.present) gaps.push("not present");
251
+ if (!v.signed) gaps.push("not signed");
252
+ return gaps.length === 0 ? met("release manifest present and signed") : unmet(gaps.join(", "));
253
+ },
254
+ "integrity.ipfs-cid": (e) => {
255
+ const v = e.ipfsCid;
256
+ if (!v) return notAssessed("no IPFS CID report supplied (optional)");
257
+ return v.cidRecorded ? met("IPFS CID recorded") : unmet("no IPFS CID recorded");
258
+ },
259
+ "integrity.http-rfc9110": (e) => {
260
+ const v = e.httpRfc9110;
261
+ if (!v) return notAssessed("no RFC 9110 HTTP report supplied (optional)");
262
+ return v.conforms ? met("HTTP semantics conform to RFC 9110") : unmet("HTTP semantics do not conform to RFC 9110");
263
+ },
264
+
265
+ // ── Cognitive ─────────────────────────────────────────────────────────────
266
+ "cognitive.coga-usability-testing": (e) => {
267
+ const v = e.cogaUsability;
268
+ if (!v) return notAssessed("no COGA usability testing supplied (optional)");
269
+ const gaps = [];
270
+ if (!v.conducted) gaps.push("not conducted");
271
+ if (!v.withCognitiveDisabilities) gaps.push("not tested with people with cognitive disabilities");
272
+ if (!v.criticalTasksPassed) gaps.push("critical tasks failed");
273
+ return gaps.length === 0
274
+ ? met("COGA usability testing conducted; critical tasks passed")
275
+ : unmet(gaps.join(", "));
276
+ },
277
+ };
278
+
279
+ /** Findings whose code starts with any of the criterion's prefixes. */
280
+ function matchFindings(findings, prefixes) {
281
+ return findings.filter((f) => prefixes.some((p) => f.code.startsWith(p)));
282
+ }
283
+
284
+ function evaluateLone(c, findings, subjectInvalid) {
285
+ if (subjectInvalid) {
286
+ return { ...c, status: "not-assessed", detail: "subject is not a DOM element; lone could not assess it", findings: [] };
287
+ }
288
+ const matched = matchFindings(findings, c.loneCodes ?? []);
289
+ const errors = matched.filter((f) => f.severity === "error");
290
+ if (errors.length === 0) {
291
+ const note = matched.length === 0 ? "no findings" : `${matched.length} non-error finding(s)`;
292
+ return { ...c, status: "met", detail: `lone static checks clean (${note})`, findings: matched };
293
+ }
294
+ return { ...c, status: "unmet", detail: `${errors.length} error-severity finding(s)`, findings: matched };
295
+ }
296
+
297
+ /**
298
+ * Aggregate lone findings + external evidence into a conformance report.
299
+ *
300
+ * @param {{ findings?: Array<{code:string, severity:string}> }} lone Output of
301
+ * lone's `validate()`/`BlessResult` (anything with `findings`).
302
+ * @param {object} [evidence] Typed external evidence. Validated for shape (throws on
303
+ * a malformed envelope). Absent fields → `not-assessed`.
304
+ * @returns {ConformanceReport}
305
+ */
306
+ export function conformance(lone, evidence) {
307
+ const parsed = parseExternalEvidence(evidence ?? {});
308
+ const findings = lone?.findings ?? [];
309
+ const subjectInvalid = findings.some((f) => f.code === INVALID_SUBJECT_CODE);
310
+
311
+ const results = CRITERIA.map((c) => {
312
+ if (c.evidence === "lone") {
313
+ if (c.pendingValidators) {
314
+ return {
315
+ ...c,
316
+ status: "not-assessed",
317
+ detail: `${c.label} is lone-measurable in principle, but its DOM validators are not yet implemented; reported but non-gating`,
318
+ findings: [],
319
+ };
320
+ }
321
+ return evaluateLone(c, findings, subjectInvalid);
322
+ }
323
+ const evaluator = EXTERNAL_EVALUATORS[c.id];
324
+ const verdict = evaluator ? evaluator(parsed) : notAssessed("no evaluator registered");
325
+ return { ...c, status: verdict.status, detail: verdict.detail };
326
+ });
327
+
328
+ const summary = {
329
+ met: results.filter((r) => r.status === "met").length,
330
+ unmet: results.filter((r) => r.status === "unmet").length,
331
+ notAssessed: results.filter((r) => r.status === "not-assessed").length,
332
+ total: results.length,
333
+ };
334
+
335
+ // The compact claim is gated on the TIER-1 required set ONLY.
336
+ const gating = results.filter((r) => gatesCompactClaim(r));
337
+ const conformant = gating.every((r) => r.status === "met");
338
+
339
+ return {
340
+ standard: STANDARD_NAME,
341
+ version: STANDARD_VERSION,
342
+ results,
343
+ summary,
344
+ areaSummaries: buildAreaSummaries(results),
345
+ conformant,
346
+ claim: conformant ? COMPACT_CLAIM : partialSummary(results),
347
+ };
348
+ }
349
+
350
+ /** Roll up results per area into an honest, non-overclaiming one-liner each. */
351
+ function buildAreaSummaries(results) {
352
+ const order = [];
353
+ for (const r of results) if (!order.includes(r.area)) order.push(r.area);
354
+ return order.map((area) => {
355
+ const inArea = results.filter((r) => r.area === area);
356
+ const met = inArea.filter((r) => r.status === "met").length;
357
+ const unmet = inArea.filter((r) => r.status === "unmet").length;
358
+ const notAssessed = inArea.filter((r) => r.status === "not-assessed").length;
359
+ const total = inArea.length;
360
+ const tail = [];
361
+ if (unmet > 0) tail.push(`${unmet} unmet`);
362
+ if (notAssessed > 0) tail.push(`${notAssessed} not assessed`);
363
+ const suffix = tail.length > 0 ? ` (${tail.join(", ")})` : "";
364
+ return { area, met, unmet, notAssessed, total, summary: `${area}: ${met}/${total} met${suffix}` };
365
+ });
366
+ }
367
+
368
+ /** Build an honest partial summary naming what is clean, unmet, and unassessed. */
369
+ function partialSummary(results) {
370
+ const parts = [];
371
+ const loneResults = results.filter(
372
+ (r) => r.evidence === "lone" && gatesCompactClaim(r) && !r.pendingValidators,
373
+ );
374
+ const loneNotAssessed = loneResults.some((r) => r.status === "not-assessed");
375
+ const loneUnmet = loneResults.filter((r) => r.status === "unmet");
376
+ if (loneNotAssessed) {
377
+ parts.push("DOM not assessed (invalid subject)");
378
+ } else if (loneUnmet.length === 0) {
379
+ parts.push("automated DOM checks clean");
380
+ } else {
381
+ parts.push(`automated DOM checks found issues in ${loneUnmet.map((r) => r.label).join(", ")}`);
382
+ }
383
+ const gating = results.filter((r) => gatesCompactClaim(r) && r.evidence === "external");
384
+ const unmet = gating.filter((r) => r.status === "unmet");
385
+ const unassessed = gating.filter((r) => r.status === "not-assessed");
386
+ for (const r of unmet) parts.push(`${r.label} unmet`);
387
+ for (const r of unassessed) parts.push(`${r.label} not supplied`);
388
+ return `Partial conformance: ${parts.join("; ")}.`;
389
+ }