@bounded-systems/conformance-kit 0.6.0 → 0.8.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.
@@ -99,8 +99,16 @@ export function evaluateLikeness({ tokenMap = {}, groups = [], thresholds = DEFA
99
99
  });
100
100
  const collapseCount = categorical.reduce((n, c) => n + c.count, 0);
101
101
 
102
+ // Identical-value pairs (ΔE 0) are intentional ALIASES — two semantic tokens
103
+ // deliberately mapped to one value (e.g. `card`/`white` → one #FFFFFF). The single
104
+ // value is defined once; the alias is by design, NOT redundancy. Only NEAR-but-
105
+ // DISTINCT pairs (0 < ΔE < dupDeltaE) are real redundancy: two almost-the-same
106
+ // colours defined separately, which probably should be one.
107
+ const aliasPairs = near.duplicates.filter((d) => d.identical).length;
108
+ const redundantTokens = near.count - aliasPairs;
109
+
102
110
  const dupIsError = t.dupSeverity === "error";
103
- const errors = (dupIsError ? near.count : 0) + collapseCount;
111
+ const errors = (dupIsError ? redundantTokens : 0) + collapseCount;
104
112
 
105
113
  return {
106
114
  passed: errors === 0,
@@ -108,7 +116,8 @@ export function evaluateLikeness({ tokenMap = {}, groups = [], thresholds = DEFA
108
116
  summary: {
109
117
  tokens: Object.keys(tokenMap).length,
110
118
  nearDuplicates: near.count,
111
- identicalPairs: near.duplicates.filter((d) => d.identical).length,
119
+ identicalPairs: aliasPairs,
120
+ redundantTokens,
112
121
  categoricalGroups: categorical.length,
113
122
  categoricalCollapses: collapseCount,
114
123
  },
@@ -117,7 +126,8 @@ export function evaluateLikeness({ tokenMap = {}, groups = [], thresholds = DEFA
117
126
  // Envelope a future lone `likeness.*` criterion can consume.
118
127
  likeness: {
119
128
  distinctCategoricals: collapseCount === 0,
120
- noRedundantTokens: near.count === 0,
129
+ // Intentional identical-value aliases don't count only near-but-distinct dups.
130
+ noRedundantTokens: redundantTokens === 0,
121
131
  },
122
132
  };
123
133
  }
@@ -156,8 +166,8 @@ async function main() {
156
166
  if (process.env.LIKENESS_REPORT) await writeFile(resolve(process.env.LIKENESS_REPORT), JSON.stringify(report, null, 2) + "\n");
157
167
 
158
168
  const s = report.summary;
159
- const line = `likeness-gate: ${s.tokens} token(s) — ${s.nearDuplicates} near-duplicate(s) (${s.identicalPairs} identical), ${s.categoricalCollapses} categorical collapse(s)`;
160
- const dupLine = (d) => ` · ${d.identical ? "identical" : "near-dup"}: ${d.a} ≈ ${d.b} (${d.aHex}/${d.bHex}) ΔE ${d.deltaE}`;
169
+ const line = `likeness-gate: ${s.tokens} token(s) — ${s.redundantTokens} redundant near-dup(s) + ${s.identicalPairs} intentional alias(es), ${s.categoricalCollapses} categorical collapse(s)`;
170
+ const dupLine = (d) => ` · ${d.identical ? "alias (identical, ok)" : "near-dup"}: ${d.a} ≈ ${d.b} (${d.aHex}/${d.bHex}) ΔE ${d.deltaE}`;
161
171
  const colLine = (c) => ` · collapse: ${c.a} vs ${c.b} (${c.aHex}/${c.bHex}) under ${c.condition} — ΔE ${c.deltaE} < ${c.min}`;
162
172
  if (!report.passed) {
163
173
  console.error(`✗ ${line}`);
@@ -37,6 +37,9 @@
37
37
  //
38
38
  // INPUTS / ENV:
39
39
  // $PAIRING_DECLARED optional pairings.json to UNION in (declared ∪ extracted).
40
+ // --allowlist | $PAIRING_ALLOWLIST=1 CLOSED-WORLD: `declared` is the opt-in
41
+ // allowlist — every extracted pairing must be declared (else an
42
+ // `undeclared` violation) and every declared pairing must pass.
40
43
  // $PAIRING_MATRIX write the Markdown matrix here (else stdout).
41
44
  // $PAIRING_REPORT write the full JSON report here.
42
45
  // $PAIRING_GATE "1" → also run the palette gate over the union and exit 1 on
@@ -197,13 +200,68 @@ export function renderMatrixMarkdown(matrix) {
197
200
  // 4. Full run
198
201
  // ─────────────────────────────────────────────────────────────────────────────
199
202
 
200
- /** Load tokens + stylesheets → extract ∪ declared matrix + palette evaluation. */
201
- export async function runPairingExtractor({ tokens, css = [], declared = null, defaultBackground = null }) {
203
+ /** Resolve a declared spec's pairings to {fg,bg,fgHex,bgHex,kind,name,key}. */
204
+ function resolveDeclared(map, spec) {
205
+ const out = [];
206
+ for (const d of spec.pairings || []) {
207
+ try {
208
+ const fgHex = resolveColor(map, d.fg), bgHex = resolveColor(map, d.bg), kind = d.kind || "text";
209
+ const key = `${toHex(parseHex(fgHex)).toLowerCase()}|${toHex(parseHex(bgHex)).toLowerCase()}|${kind}`;
210
+ out.push({ fg: d.fg, bg: d.bg, fgHex, bgHex, kind, name: d.name, key, source: "declared", confidence: "declared" });
211
+ } catch { /* unresolvable token → skip */ }
212
+ }
213
+ return out;
214
+ }
215
+ const pairKey = (p) => `${toHex(parseHex(p.fgHex)).toLowerCase()}|${toHex(parseHex(p.bgHex)).toLowerCase()}|${p.kind}`;
216
+
217
+ /**
218
+ * Load tokens + stylesheets → extract ∪ declared → matrix + palette evaluation.
219
+ *
220
+ * `allowlist:true` switches to CLOSED-WORLD: the `declared` set is the opt-in
221
+ * allowlist and the gate enforces BOTH directions —
222
+ * 1. every DECLARED pairing must pass contrast, and
223
+ * 2. every pairing the CSS actually produces must be DECLARED; any extracted
224
+ * pairing absent from the allowlist is an `undeclared` violation.
225
+ * The palette envelope is evaluated over the declared (vetted) set, so surface
226
+ * mis-extractions can't poison it — they surface as `undeclared` to either
227
+ * declare (and pass) or fix in the CSS.
228
+ */
229
+ export async function runPairingExtractor({ tokens, css = [], declared = null, defaultBackground = null, allowlist = false }) {
202
230
  const map = typeof tokens === "string" ? await loadTokens(tokens) : tokens;
203
231
  const cssTexts = await Promise.all((Array.isArray(css) ? css : [css]).map((c) => (c.includes("{") ? c : readFile(c, "utf8"))));
204
232
  const rules = cssTexts.flatMap((t) => parseRules(t));
205
233
  const ext = extractPairings(rules, map, { defaultBackground });
206
234
 
235
+ if (allowlist) {
236
+ const spec = declared
237
+ ? (typeof declared === "string" ? JSON.parse(await readFile(declared, "utf8")) : declared)
238
+ : { pairings: [] };
239
+ const declaredPairs = resolveDeclared(map, spec);
240
+ const allowed = new Set(declaredPairs.map((p) => p.key));
241
+ const undeclared = ext.pairings.filter((p) => !allowed.has(pairKey(p)));
242
+ const matrix = buildMatrix(declaredPairs);
243
+ const palette = evaluatePalette({ pairings: declaredPairs.map((p) => ({ ...p })) });
244
+ const failingDeclared = matrix.filter((m) => !m.passed);
245
+ return {
246
+ passed: palette.passed && undeclared.length === 0,
247
+ mode: "allowlist",
248
+ summary: {
249
+ declared: declaredPairs.length,
250
+ extracted: ext.pairings.length,
251
+ undeclared: undeclared.length,
252
+ failingDeclared: failingDeclared.length,
253
+ surfaces: ext.surfaces.length,
254
+ total: declaredPairs.length,
255
+ failing: failingDeclared.length + undeclared.length,
256
+ },
257
+ undeclared,
258
+ defaultSurface: ext.defaultSurface,
259
+ pairings: declaredPairs,
260
+ matrix,
261
+ palette,
262
+ };
263
+ }
264
+
207
265
  // Union declared pairings (resolved) in.
208
266
  const extractedCount = ext.pairings.length;
209
267
  let union = ext.pairings;
@@ -245,7 +303,8 @@ async function main() {
245
303
  console.error("✗ pairing-extractor: usage: pairing-extractor.mjs <tokens.(json|css)> <style1.css> [style2.css …]");
246
304
  process.exit(2);
247
305
  }
248
- const report = await runPairingExtractor({ tokens, css, declared: process.env.PAIRING_DECLARED || null });
306
+ const allowlist = process.argv.includes("--allowlist") || process.env.PAIRING_ALLOWLIST === "1";
307
+ const report = await runPairingExtractor({ tokens, css, declared: process.env.PAIRING_DECLARED || null, allowlist });
249
308
  if (process.env.PAIRING_REPORT) await writeFile(resolve(process.env.PAIRING_REPORT), JSON.stringify(report, null, 2) + "\n");
250
309
 
251
310
  const md = renderMatrixMarkdown(report.matrix);
@@ -253,6 +312,18 @@ async function main() {
253
312
  else console.log(md);
254
313
 
255
314
  const s = report.summary;
315
+ if (allowlist) {
316
+ // Closed-world: fail on undeclared OR failing declared pairings.
317
+ const line = `pairing-extractor [allowlist]: ${s.declared} declared, ${s.undeclared} undeclared, ${s.failingDeclared} failing declared`;
318
+ if (!report.passed) {
319
+ console.error(`✗ ${line}`);
320
+ for (const u of report.undeclared) console.error(` · UNDECLARED: ${u.fg}/${u.bg} [${u.kind}] — declare it or fix the CSS`);
321
+ for (const m of report.matrix) if (!m.passed) console.error(` · FAILS: ${m.fg}/${m.bg} [${m.kind}] WCAG ${m.wcag}:1`);
322
+ process.exit(1);
323
+ }
324
+ console.error(`✓ ${line}`);
325
+ return;
326
+ }
256
327
  const line = `pairing-extractor: ${s.total} pair(s) (${s.extracted} extracted + ${s.declaredAdded} declared) — ${s.failing} failing`;
257
328
  if (process.env.PAIRING_GATE === "1" && !report.passed) {
258
329
  console.error(`✗ ${line}`);
@@ -17,7 +17,11 @@
17
17
  // CONFIG (every member is OPTIONAL — only declared members run):
18
18
  // { "tokens": "brand/tokens/tokens.css", // default token map (path or map)
19
19
  // "palette": { "pairings":[…], "categorical":[…], "thresholds":{…} } | "pairings.json",
20
- // "pairing": { "css":["a.css","b.css"], "declared":"pairings.json", "gate":true },
20
+ // "pairing": { "css":["a.css","b.css"], "declared":"pairings.json", "gate":true,
21
+ // "allowlist":true }, // closed-world: `declared` is the opt-in
22
+ // // allowlist; any pairing the CSS produces that ISN'T declared is a
23
+ // // violation, and declared pairings must pass. Palette envelope is
24
+ // // evaluated over the vetted declared set (no surface false-positives).
21
25
  // "typography":{ "tokens":"…", "body":["body"], "thresholds":{…} } | "typo.json",
22
26
  // "targetSize":{ "targets":[…], "thresholds":{…} } | "targets.json",
23
27
  // "opacity": { "usages":[…], "opacityTokens":{…} } | "usages.json",
@@ -60,9 +64,11 @@ export async function runTokenA11y(config, base = ".") {
60
64
  const r = await runPairingExtractor({
61
65
  tokens: tokens(m), css: (m.css || []).map((c) => rel(base, c)),
62
66
  declared: m.declared ? rel(base, m.declared) : null,
67
+ allowlist: m.allowlist || false,
63
68
  });
64
- // Report-only unless `gate:true`.
65
- return m.gate ? r : { ...r, passed: true, gated: false };
69
+ // Allowlist (closed-world) gates by default — undeclared pairings are
70
+ // violations. Extraction-only mode is report-only unless `gate:true`.
71
+ return (m.allowlist || m.gate) ? r : { ...r, passed: true, gated: false };
66
72
  });
67
73
  }
68
74
  if (config.typography) {
@@ -97,7 +103,9 @@ function memberLine(name, m) {
97
103
  const s = m.summary || {};
98
104
  const tail =
99
105
  name === "palette" ? `${s.pairs} pair(s), ${s.failingPairs} failing, ${s.categoricalCollapses} collapse(s)`
100
- : name === "pairing" ? `${s.total} pair(s), ${s.failing} failing${m.gated === false ? " (report-only)" : ""}`
106
+ : name === "pairing" ? (m.mode === "allowlist"
107
+ ? `${s.declared} declared, ${s.undeclared} undeclared, ${s.failingDeclared} failing`
108
+ : `${s.total} pair(s), ${s.failing} failing${m.gated === false ? " (report-only)" : ""}`)
101
109
  : name === "typography" ? `${s.styles} style(s), ${s.errors} error(s), ${s.warnings} warn(s)`
102
110
  : name === "targetSize" ? `${s.targets} target(s), ${s.belowAA} below AA${m.coverage === "none" ? " (none declared)" : ""}`
103
111
  : name === "opacity" ? `${s.usages} usage(s), ${s.failing} failing`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bounded-systems/conformance-kit",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Standalone, site-agnostic web-conformance toolkit: integrity tooling + build gates + provenance generators, all parameterized so a site vendors one kit instead of duplicating scripts.",
5
5
  "type": "module",
6
6
  "license": "MIT",