ultra11y 2.29.0 → 2.31.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.opencode/plugins/ultra11y.js +1 -1
- package/README.md +59 -7
- package/dist/cypress-plugin.d.ts +17 -0
- package/dist/cypress-plugin.mjs +82 -0
- package/dist/cypress.d.ts +11 -0
- package/dist/cypress.mjs +170 -0
- package/dist/payload-D0VQYlGS.d.ts +44 -0
- package/dist/playwright.d.ts +35 -0
- package/dist/playwright.mjs +250 -0
- package/docker/runner.mjs +12 -1
- package/package.json +25 -3
- package/scripts/build-extension-icons.mjs +137 -0
- package/scripts/check-artifact-recall.mjs +199 -0
- package/scripts/sync-engine.mjs +126 -52
- package/scripts/ultra11y.mjs +1813 -369
- package/scripts/vendor/wcag-2.2-fr.json +1 -1
- package/scripts/vendor/wcag-2.2-sc-universe.json +1 -1
- package/skills/review-a11y/SKILL.md +1 -1
- package/skills/review-a11y/scripts/ultra11y.mjs +1813 -369
- package/skills/ultra11y/SKILL.md +2 -1
- package/skills/ultra11y/references/ci.md +33 -0
- package/skills/ultra11y/references/devtools.md +7 -0
- package/skills/ultra11y/references/dynamic.md +54 -1
- package/skills/ultra11y/references/e2e.md +42 -11
- package/skills/ultra11y/references/extension.md +52 -0
- package/skills/ultra11y/references/judgment.md +38 -0
- package/skills/ultra11y/references/pages.md +66 -3
- package/skills/ultra11y/scripts/ultra11y.mjs +1813 -369
- package/src/annotate.ts +53 -13
- package/src/check.ts +22 -5
- package/src/cli.ts +426 -32
- package/src/collector.ts +125 -0
- package/src/crawl.ts +43 -0
- package/src/data/wcag-universe.json +1 -1
- package/src/dev.ts +136 -5
- package/src/e2e.ts +13 -2
- package/src/init.ts +11 -2
- package/src/integrations/core.ts +75 -0
- package/src/integrations/cypress-plugin.ts +75 -0
- package/src/integrations/cypress.ts +56 -0
- package/src/integrations/payload.ts +141 -0
- package/src/integrations/playwright.ts +95 -0
- package/src/llm.ts +259 -0
- package/src/pages-report.ts +302 -0
- package/src/pages.ts +25 -4
- package/src/report.ts +48 -18
- package/src/sample.ts +57 -0
- package/src/scan-local.ts +57 -5
- package/src/scan.ts +162 -12
- package/src/snapshot.ts +44 -116
- package/src/types.ts +7 -1
- package/src/vendor/codeindex-engine.d.mts +49 -6
- package/src/vendor/codeindex-engine.mjs +432 -22
- package/src/vendor/engine.meta.json +5 -5
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// src/integrations/playwright.ts
|
|
2
|
+
import { createRequire as createRequire2 } from "module";
|
|
3
|
+
|
|
4
|
+
// src/collector.ts
|
|
5
|
+
var COLLECTED_CSS = [
|
|
6
|
+
"color",
|
|
7
|
+
"backgroundColor",
|
|
8
|
+
"backgroundImage",
|
|
9
|
+
"fontSize",
|
|
10
|
+
"fontWeight",
|
|
11
|
+
"fontStyle",
|
|
12
|
+
"textDecorationLine",
|
|
13
|
+
"textTransform",
|
|
14
|
+
"lineHeight",
|
|
15
|
+
"letterSpacing",
|
|
16
|
+
"wordSpacing",
|
|
17
|
+
"whiteSpace",
|
|
18
|
+
"display",
|
|
19
|
+
"visibility",
|
|
20
|
+
"opacity",
|
|
21
|
+
"position",
|
|
22
|
+
"overflowX",
|
|
23
|
+
"overflowY",
|
|
24
|
+
"outlineStyle",
|
|
25
|
+
"outlineWidth",
|
|
26
|
+
"outlineColor",
|
|
27
|
+
// All four borders + the box shadow: WCAG 1.4.11 asks whether a control's BOUNDARY is
|
|
28
|
+
// perceivable, and a boundary can be drawn by any side, by the fill, by an outline or by a
|
|
29
|
+
// shadow. Collecting only one side would manufacture non-conformities on the other three.
|
|
30
|
+
"borderTopStyle",
|
|
31
|
+
"borderRightStyle",
|
|
32
|
+
"borderBottomStyle",
|
|
33
|
+
"borderLeftStyle",
|
|
34
|
+
"borderTopWidth",
|
|
35
|
+
"borderRightWidth",
|
|
36
|
+
"borderBottomWidth",
|
|
37
|
+
"borderLeftWidth",
|
|
38
|
+
"borderTopColor",
|
|
39
|
+
"borderRightColor",
|
|
40
|
+
"borderBottomColor",
|
|
41
|
+
"borderLeftColor",
|
|
42
|
+
"boxShadow",
|
|
43
|
+
"cursor"
|
|
44
|
+
];
|
|
45
|
+
var COLLECT_MAX_ELEMENTS = 5e3;
|
|
46
|
+
var COLLECT_SNAPSHOT = `(() => {
|
|
47
|
+
const PROPS = ${JSON.stringify(COLLECTED_CSS)};
|
|
48
|
+
const MAX = ${COLLECT_MAX_ELEMENTS};
|
|
49
|
+
const els = document.querySelectorAll('*');
|
|
50
|
+
const styles = [];
|
|
51
|
+
const boxes = [];
|
|
52
|
+
const n = Math.min(els.length, MAX);
|
|
53
|
+
for (let i = 0; i < n; i++) {
|
|
54
|
+
const el = els[i];
|
|
55
|
+
const tag = el.tagName.toLowerCase();
|
|
56
|
+
const cs = getComputedStyle(el);
|
|
57
|
+
const css = {};
|
|
58
|
+
for (const p of PROPS) {
|
|
59
|
+
const v = cs[p];
|
|
60
|
+
if (v !== undefined && v !== null && v !== '') css[p] = String(v);
|
|
61
|
+
}
|
|
62
|
+
styles.push({ i: i, tag: tag, css: css });
|
|
63
|
+
const r = el.getBoundingClientRect();
|
|
64
|
+
boxes.push({ i: i, tag: tag, x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height) });
|
|
65
|
+
}
|
|
66
|
+
// The page's own stylesheets. Some criteria are properties of the STYLESHEET rather than of
|
|
67
|
+
// any element's computed style \u2014 focus styling removed with no replacement (2.4.7), a media
|
|
68
|
+
// query that locks the orientation (1.3.4) \u2014 and this is the only place they exist.
|
|
69
|
+
// A cross-origin sheet throws on .cssRules: COUNT those instead of ignoring them, so a rule
|
|
70
|
+
// reading this digest can tell "nothing there" apart from "I could not look".
|
|
71
|
+
const cssRules = [];
|
|
72
|
+
let unreadable = 0;
|
|
73
|
+
const MAXR = 4000;
|
|
74
|
+
const camel = (p) => p.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
75
|
+
const walk = (list, media) => {
|
|
76
|
+
for (const r of list) {
|
|
77
|
+
if (cssRules.length >= MAXR) return;
|
|
78
|
+
if (typeof r.selectorText === 'string' && r.style) {
|
|
79
|
+
const decls = {};
|
|
80
|
+
for (let i = 0; i < r.style.length; i++) {
|
|
81
|
+
const prop = r.style[i];
|
|
82
|
+
decls[camel(prop)] = r.style.getPropertyValue(prop);
|
|
83
|
+
}
|
|
84
|
+
cssRules.push(media ? { selector: r.selectorText, media: media, decls: decls } : { selector: r.selectorText, decls: decls });
|
|
85
|
+
} else if (r.cssRules) {
|
|
86
|
+
const cond = r.conditionText || (r.media && r.media.mediaText) || media;
|
|
87
|
+
walk(r.cssRules, cond);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
for (const sheet of document.styleSheets) {
|
|
92
|
+
try {
|
|
93
|
+
walk(sheet.cssRules, undefined);
|
|
94
|
+
} catch (e) {
|
|
95
|
+
unreadable++;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const truncated = els.length > MAX;
|
|
100
|
+
return {
|
|
101
|
+
dom: document.documentElement.outerHTML,
|
|
102
|
+
css: { v: 1, rules: cssRules, unreadable: unreadable, truncated: cssRules.length >= MAXR },
|
|
103
|
+
title: document.title,
|
|
104
|
+
lang: document.documentElement.getAttribute('lang') || '',
|
|
105
|
+
url: location.href,
|
|
106
|
+
viewport: { width: window.innerWidth, height: window.innerHeight },
|
|
107
|
+
styles: { v: 1, entries: styles, truncated: truncated },
|
|
108
|
+
boxes: { v: 1, entries: boxes, truncated: truncated }
|
|
109
|
+
};
|
|
110
|
+
})()`;
|
|
111
|
+
|
|
112
|
+
// src/integrations/core.ts
|
|
113
|
+
import { spawnSync } from "child_process";
|
|
114
|
+
import { createRequire } from "module";
|
|
115
|
+
|
|
116
|
+
// src/integrations/payload.ts
|
|
117
|
+
var RANK = { bloquant: 0, majeur: 1, mineur: 2 };
|
|
118
|
+
var THRESHOLD = { blocking: 0, bloquant: 0, major: 1, majeur: 1, minor: 2, mineur: 2 };
|
|
119
|
+
function failingFindings(result, failOn) {
|
|
120
|
+
const max = THRESHOLD[failOn];
|
|
121
|
+
if (max === void 0) throw new Error(`ultra11y: failOn must be blocking|major|minor (got "${failOn}")`);
|
|
122
|
+
return (result.findings ?? []).filter((f) => !f.advisory && (RANK[f.severity] ?? 99) <= max);
|
|
123
|
+
}
|
|
124
|
+
function formatFailure(pageName, failing) {
|
|
125
|
+
const lines = [`ultra11y: ${failing.length} accessibility non-conformity(ies) on "${pageName}":`];
|
|
126
|
+
for (const f of failing.slice(0, 20)) {
|
|
127
|
+
lines.push(` [${f.severity}] ${f.ruleId} (WCAG ${f.criteriaId}) \u2014 ${f.origin?.sourceFile ?? f.file} \u2014 ${f.message}`);
|
|
128
|
+
}
|
|
129
|
+
if (failing.length > 20) lines.push(` \u2026 and ${failing.length - 20} more.`);
|
|
130
|
+
lines.push("Full detail: .ultra11y/pages/ \u2014 re-audit offline with `ultra11y audit`.");
|
|
131
|
+
return lines.join("\n");
|
|
132
|
+
}
|
|
133
|
+
function slugify(url) {
|
|
134
|
+
let path = url;
|
|
135
|
+
try {
|
|
136
|
+
path = new URL(url).pathname;
|
|
137
|
+
try {
|
|
138
|
+
path = decodeURIComponent(path);
|
|
139
|
+
} catch {
|
|
140
|
+
}
|
|
141
|
+
} catch {
|
|
142
|
+
}
|
|
143
|
+
const slug = path.normalize("NFD").replace(new RegExp("\\p{Diacritic}", "gu"), "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
144
|
+
return slug || (path === "/" || path === "" ? "accueil" : "page");
|
|
145
|
+
}
|
|
146
|
+
function buildPayload(collected, url, runner, opts, screenshot) {
|
|
147
|
+
const id = opts.as || slugify(url);
|
|
148
|
+
return {
|
|
149
|
+
meta: {
|
|
150
|
+
v: 1,
|
|
151
|
+
id,
|
|
152
|
+
name: opts.name || collected.title || id,
|
|
153
|
+
url,
|
|
154
|
+
runner,
|
|
155
|
+
viewport: collected.viewport,
|
|
156
|
+
capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
157
|
+
...opts.auth !== void 0 ? { auth: opts.auth } : {},
|
|
158
|
+
...opts.sources ? { sources: opts.sources } : {},
|
|
159
|
+
...opts.notes ? { notes: opts.notes } : {}
|
|
160
|
+
},
|
|
161
|
+
dom: collected.dom,
|
|
162
|
+
styles: collected.styles,
|
|
163
|
+
boxes: collected.boxes,
|
|
164
|
+
css: collected.css,
|
|
165
|
+
...screenshot ? { screenshot } : {}
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
function gate(result, pageName, failOn) {
|
|
169
|
+
const threshold = failOn === void 0 ? "blocking" : failOn;
|
|
170
|
+
if (threshold === false) return;
|
|
171
|
+
const failing = failingFindings(result, threshold);
|
|
172
|
+
if (failing.length) throw new Error(formatFailure(pageName, failing));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// src/integrations/core.ts
|
|
176
|
+
function enginePath() {
|
|
177
|
+
if (process.env.ULTRA11Y) return process.env.ULTRA11Y;
|
|
178
|
+
try {
|
|
179
|
+
return createRequire(import.meta.url).resolve("ultra11y/scripts/ultra11y.mjs");
|
|
180
|
+
} catch {
|
|
181
|
+
return new URL("../scripts/ultra11y.mjs", import.meta.url).pathname;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
function auditSnapshot(payload, engine = enginePath()) {
|
|
185
|
+
const res = spawnSync(process.execPath, [engine, "snapshot", "write", "--json"], {
|
|
186
|
+
input: JSON.stringify(payload),
|
|
187
|
+
encoding: "utf8",
|
|
188
|
+
maxBuffer: 192 * 1024 * 1024
|
|
189
|
+
});
|
|
190
|
+
if (res.error) throw new Error(`ultra11y: could not run the engine at ${engine} \u2014 ${res.error.message}`);
|
|
191
|
+
if (!res.stdout) throw new Error(`ultra11y: the engine produced no output (exit ${res.status})
|
|
192
|
+
${res.stderr || ""}`);
|
|
193
|
+
try {
|
|
194
|
+
return JSON.parse(res.stdout);
|
|
195
|
+
} catch {
|
|
196
|
+
throw new Error(`ultra11y: could not parse the engine output
|
|
197
|
+
${res.stdout.slice(0, 500)}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
function writePagesReport(opts = {}, engine = enginePath()) {
|
|
201
|
+
const args = [engine, "pages", "--in", "-", "--format", "report"];
|
|
202
|
+
if (opts.split !== false) args.push("--split", "page");
|
|
203
|
+
args.push("--out", opts.out ?? "audits/pages");
|
|
204
|
+
if (opts.standard) args.push("--standard", opts.standard);
|
|
205
|
+
if (opts.lang) args.push("--lang", opts.lang);
|
|
206
|
+
const audit = spawnSync(process.execPath, [engine, "audit", ".ultra11y/pages", "--json"], { encoding: "utf8", maxBuffer: 192 * 1024 * 1024 });
|
|
207
|
+
if (audit.error || !audit.stdout) return void 0;
|
|
208
|
+
const res = spawnSync(process.execPath, args, { input: audit.stdout, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
209
|
+
if (res.error || res.status !== 0) return void 0;
|
|
210
|
+
return res.stdout.trim() || void 0;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// src/integrations/playwright.ts
|
|
214
|
+
async function checkA11y(page, opts = {}) {
|
|
215
|
+
const collected = await page.evaluate(COLLECT_SNAPSHOT);
|
|
216
|
+
let shot;
|
|
217
|
+
if (opts.screenshot !== false) {
|
|
218
|
+
try {
|
|
219
|
+
shot = (await page.screenshot({ fullPage: false })).toString("base64");
|
|
220
|
+
} catch {
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
const url = collected.url || page.url();
|
|
224
|
+
const payload = buildPayload(collected, url, "playwright", opts, shot);
|
|
225
|
+
const result = auditSnapshot(payload);
|
|
226
|
+
if (opts.report) writePagesReport(typeof opts.report === "object" ? opts.report : {});
|
|
227
|
+
gate(result, String(payload.meta.name), opts.failOn);
|
|
228
|
+
return result;
|
|
229
|
+
}
|
|
230
|
+
var test = (() => {
|
|
231
|
+
const load = (from) => createRequire2(from)("@playwright/test");
|
|
232
|
+
for (const from of [`${process.cwd()}/package.json`, import.meta.url]) {
|
|
233
|
+
try {
|
|
234
|
+
const base = load(from).test;
|
|
235
|
+
if (!base?.extend) continue;
|
|
236
|
+
return base.extend({
|
|
237
|
+
// biome-ignore lint/suspicious/noExplicitAny: same
|
|
238
|
+
ultra11y: async ({ page }, use) => {
|
|
239
|
+
await use((opts) => checkA11y(page, opts));
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
} catch {
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return void 0;
|
|
246
|
+
})();
|
|
247
|
+
export {
|
|
248
|
+
checkA11y,
|
|
249
|
+
test
|
|
250
|
+
};
|
package/docker/runner.mjs
CHANGED
|
@@ -2,10 +2,21 @@ import { chromium } from "playwright";
|
|
|
2
2
|
import axe from "axe-core";
|
|
3
3
|
const target = process.argv[2];
|
|
4
4
|
const isFile = target.startsWith("/work/");
|
|
5
|
+
const SNAPSHOT = process.env.ULTRA11Y_SNAPSHOT !== "0";
|
|
6
|
+
const COLLECT = "(() => {\n const PROPS = [\"color\",\"backgroundColor\",\"backgroundImage\",\"fontSize\",\"fontWeight\",\"fontStyle\",\"textDecorationLine\",\"textTransform\",\"lineHeight\",\"letterSpacing\",\"wordSpacing\",\"whiteSpace\",\"display\",\"visibility\",\"opacity\",\"position\",\"overflowX\",\"overflowY\",\"outlineStyle\",\"outlineWidth\",\"outlineColor\",\"borderTopStyle\",\"borderRightStyle\",\"borderBottomStyle\",\"borderLeftStyle\",\"borderTopWidth\",\"borderRightWidth\",\"borderBottomWidth\",\"borderLeftWidth\",\"borderTopColor\",\"borderRightColor\",\"borderBottomColor\",\"borderLeftColor\",\"boxShadow\",\"cursor\"];\n const MAX = 5000;\n const els = document.querySelectorAll('*');\n const styles = [];\n const boxes = [];\n const n = Math.min(els.length, MAX);\n for (let i = 0; i < n; i++) {\n const el = els[i];\n const tag = el.tagName.toLowerCase();\n const cs = getComputedStyle(el);\n const css = {};\n for (const p of PROPS) {\n const v = cs[p];\n if (v !== undefined && v !== null && v !== '') css[p] = String(v);\n }\n styles.push({ i: i, tag: tag, css: css });\n const r = el.getBoundingClientRect();\n boxes.push({ i: i, tag: tag, x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height) });\n }\n // The page's own stylesheets. Some criteria are properties of the STYLESHEET rather than of\n // any element's computed style — focus styling removed with no replacement (2.4.7), a media\n // query that locks the orientation (1.3.4) — and this is the only place they exist.\n // A cross-origin sheet throws on .cssRules: COUNT those instead of ignoring them, so a rule\n // reading this digest can tell \"nothing there\" apart from \"I could not look\".\n const cssRules = [];\n let unreadable = 0;\n const MAXR = 4000;\n const camel = (p) => p.replace(/-([a-z])/g, (_, c) => c.toUpperCase());\n const walk = (list, media) => {\n for (const r of list) {\n if (cssRules.length >= MAXR) return;\n if (typeof r.selectorText === 'string' && r.style) {\n const decls = {};\n for (let i = 0; i < r.style.length; i++) {\n const prop = r.style[i];\n decls[camel(prop)] = r.style.getPropertyValue(prop);\n }\n cssRules.push(media ? { selector: r.selectorText, media: media, decls: decls } : { selector: r.selectorText, decls: decls });\n } else if (r.cssRules) {\n const cond = r.conditionText || (r.media && r.media.mediaText) || media;\n walk(r.cssRules, cond);\n }\n }\n };\n for (const sheet of document.styleSheets) {\n try {\n walk(sheet.cssRules, undefined);\n } catch (e) {\n unreadable++;\n }\n }\n\n const truncated = els.length > MAX;\n return {\n dom: document.documentElement.outerHTML,\n css: { v: 1, rules: cssRules, unreadable: unreadable, truncated: cssRules.length >= MAXR },\n title: document.title,\n lang: document.documentElement.getAttribute('lang') || '',\n url: location.href,\n viewport: { width: window.innerWidth, height: window.innerHeight },\n styles: { v: 1, entries: styles, truncated: truncated },\n boxes: { v: 1, entries: boxes, truncated: truncated }\n };\n})()";
|
|
5
7
|
const browser = await chromium.launch({ args: ["--no-sandbox", "--disable-dev-shm-usage"] });
|
|
6
8
|
try {
|
|
7
9
|
const page = await browser.newPage();
|
|
8
10
|
await page.goto(isFile ? "file://" + target : target, { waitUntil: "load", timeout: 45000 });
|
|
11
|
+
// Collected FIRST, on the pristine page: axe's injected source and the 320px reflow
|
|
12
|
+
// resize both come after, and a snapshot must be the page as the browser built it.
|
|
13
|
+
let snapshot;
|
|
14
|
+
if (SNAPSHOT) {
|
|
15
|
+
try {
|
|
16
|
+
snapshot = await page.evaluate(COLLECT);
|
|
17
|
+
try { snapshot.screenshot = (await page.screenshot({ fullPage: false })).toString("base64"); } catch {}
|
|
18
|
+
} catch {}
|
|
19
|
+
}
|
|
9
20
|
await page.addScriptTag({ content: axe.source });
|
|
10
21
|
const axeRes = await page.evaluate(async () => await window.axe.run(document, { resultTypes: ["violations"] }));
|
|
11
22
|
await page.setViewportSize({ width: 320, height: 800 });
|
|
@@ -17,7 +28,7 @@ try {
|
|
|
17
28
|
id: v.id, impact: v.impact, help: v.help, tags: v.tags,
|
|
18
29
|
nodes: v.nodes.slice(0, 10).map((n) => ({ target: n.target, html: (n.html || "").slice(0, 200) })),
|
|
19
30
|
}));
|
|
20
|
-
console.log(JSON.stringify({ url: target, violations, reflow }));
|
|
31
|
+
console.log(JSON.stringify({ url: target, violations, reflow, snapshot }));
|
|
21
32
|
} finally {
|
|
22
33
|
await browser.close();
|
|
23
34
|
}
|
package/package.json
CHANGED
|
@@ -1,15 +1,36 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ultra11y",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.31.0",
|
|
4
4
|
"description": "Audit HTML/CSS/JSX against WCAG 2.2 AA and produce a dated compliance report, or author/review accessible markup (native-HTML-first). A skills.sh agent skill: an install-free static engine (bundled, no npm install) plus the agent's judgment, with check/verify gates against hallucinated non-conformities, and pluggable in-repo standards packs (RGAA France first; Section 508 & EN 301 549 next).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "pnpm@10.33.0",
|
|
7
7
|
"bin": {
|
|
8
8
|
"ultra11y": "scripts/ultra11y.mjs"
|
|
9
9
|
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./.opencode/plugins/ultra11y.js",
|
|
12
|
+
"./playwright": {
|
|
13
|
+
"types": "./dist/playwright.d.ts",
|
|
14
|
+
"import": "./dist/playwright.mjs",
|
|
15
|
+
"default": "./dist/playwright.mjs"
|
|
16
|
+
},
|
|
17
|
+
"./cypress": {
|
|
18
|
+
"types": "./dist/cypress.d.ts",
|
|
19
|
+
"import": "./dist/cypress.mjs",
|
|
20
|
+
"default": "./dist/cypress.mjs"
|
|
21
|
+
},
|
|
22
|
+
"./cypress/plugin": {
|
|
23
|
+
"types": "./dist/cypress-plugin.d.ts",
|
|
24
|
+
"import": "./dist/cypress-plugin.mjs",
|
|
25
|
+
"default": "./dist/cypress-plugin.mjs"
|
|
26
|
+
},
|
|
27
|
+
"./scripts/ultra11y.mjs": "./scripts/ultra11y.mjs",
|
|
28
|
+
"./package.json": "./package.json"
|
|
29
|
+
},
|
|
10
30
|
"files": [
|
|
11
31
|
"scripts",
|
|
12
32
|
"src",
|
|
33
|
+
"dist",
|
|
13
34
|
"skills",
|
|
14
35
|
"docker",
|
|
15
36
|
".claude-plugin",
|
|
@@ -33,7 +54,7 @@
|
|
|
33
54
|
"lint:fix": "biome check --write .",
|
|
34
55
|
"format": "biome format --write .",
|
|
35
56
|
"verify:bundle": "node scripts/verify-skill-bundle.mjs",
|
|
36
|
-
"check:build": "tsup && node scripts/copy-bundle.mjs && git diff --exit-code -- scripts/ultra11y.mjs skills/ultra11y/scripts/ultra11y.mjs skills/review-a11y/scripts/ultra11y.mjs && node scripts/verify-skill-bundle.mjs && node scripts/sync-engine.mjs --check",
|
|
57
|
+
"check:build": "tsup && node scripts/copy-bundle.mjs && git diff --exit-code -- scripts/ultra11y.mjs skills/ultra11y/scripts/ultra11y.mjs skills/review-a11y/scripts/ultra11y.mjs dist && node scripts/build-extension-icons.mjs --check && node scripts/verify-skill-bundle.mjs && node scripts/sync-engine.mjs --check",
|
|
37
58
|
"build:wcag": "node scripts/build-standards.mjs",
|
|
38
59
|
"build:pack:rgaa": "node scripts/build-pack-rgaa.mjs",
|
|
39
60
|
"check:pack:rgaa": "node scripts/build-pack-rgaa.mjs --check",
|
|
@@ -43,7 +64,8 @@
|
|
|
43
64
|
"build:adjudication": "node scripts/build-adjudication.mjs && UPDATE_ADJUDICATION_MD=1 vitest run tests/adjudication-md-sync.test.ts",
|
|
44
65
|
"check:adjudication": "node scripts/build-adjudication.mjs --check",
|
|
45
66
|
"demo": "node scripts/ultra11y.mjs audit tests/fixtures/non-conforming/bad.html --out /tmp/ultra11y-demo --json > /dev/null && node scripts/ultra11y.mjs report --in /tmp/ultra11y-demo/audit-latest.json --out /tmp/ultra11y-demo && node scripts/ultra11y.mjs check --report /tmp/ultra11y-demo/wcag-*.md && node scripts/ultra11y.mjs criteria --list",
|
|
46
|
-
"release": "semantic-release"
|
|
67
|
+
"release": "semantic-release",
|
|
68
|
+
"build:icons": "node scripts/build-extension-icons.mjs"
|
|
47
69
|
},
|
|
48
70
|
"license": "MIT",
|
|
49
71
|
"author": "maxgfr",
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Generates the browser extension's icons — DEV ONLY, never shipped in the engine bundle.
|
|
3
|
+
//
|
|
4
|
+
// Why generate rather than commit a binary somebody drew: an icon is the one asset a reviewer
|
|
5
|
+
// cannot diff. Deriving it from a few lines of code means the shape is reviewable, every size
|
|
6
|
+
// is guaranteed consistent, and `--check` can prove the committed PNGs are exactly what this
|
|
7
|
+
// script produces — the same reproducibility contract the engine bundle is held to.
|
|
8
|
+
//
|
|
9
|
+
// The mark is an eye (the action's own branding icon), drawn as a filled lens with a pupil on
|
|
10
|
+
// the purple the action already declares. Written with a hand-rolled PNG encoder because the
|
|
11
|
+
// repo has no image dependency and is not about to grow one for four small files.
|
|
12
|
+
//
|
|
13
|
+
// node scripts/build-extension-icons.mjs # write
|
|
14
|
+
// node scripts/build-extension-icons.mjs --check # fail if the committed files differ
|
|
15
|
+
import { deflateSync } from "node:zlib";
|
|
16
|
+
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
17
|
+
import { dirname, join } from "node:path";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
|
|
20
|
+
const OUT = join(dirname(fileURLToPath(import.meta.url)), "..", "extension", "icons");
|
|
21
|
+
const SIZES = [16, 32, 48, 128];
|
|
22
|
+
|
|
23
|
+
// action.yml declares `color: purple`; keep the extension recognisably the same tool.
|
|
24
|
+
const BG = [107, 70, 193]; // #6b46c1
|
|
25
|
+
const FG = [255, 255, 255];
|
|
26
|
+
|
|
27
|
+
function crc32(buf) {
|
|
28
|
+
let c = ~0;
|
|
29
|
+
for (const b of buf) {
|
|
30
|
+
c ^= b;
|
|
31
|
+
for (let i = 0; i < 8; i++) c = (c >>> 1) ^ (0xedb88320 & -(c & 1));
|
|
32
|
+
}
|
|
33
|
+
return ~c >>> 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function chunk(type, data) {
|
|
37
|
+
const len = Buffer.alloc(4);
|
|
38
|
+
len.writeUInt32BE(data.length);
|
|
39
|
+
const body = Buffer.concat([Buffer.from(type, "latin1"), data]);
|
|
40
|
+
const crc = Buffer.alloc(4);
|
|
41
|
+
crc.writeUInt32BE(crc32(body));
|
|
42
|
+
return Buffer.concat([len, body, crc]);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** An RGBA pixel buffer → a PNG. Colour type 6, 8-bit, one filter byte per row. */
|
|
46
|
+
function png(size, rgba) {
|
|
47
|
+
const raw = Buffer.alloc(size * (size * 4 + 1));
|
|
48
|
+
for (let y = 0; y < size; y++) {
|
|
49
|
+
raw[y * (size * 4 + 1)] = 0; // filter: none — the images are tiny, filtering buys nothing
|
|
50
|
+
rgba.copy(raw, y * (size * 4 + 1) + 1, y * size * 4, (y + 1) * size * 4);
|
|
51
|
+
}
|
|
52
|
+
const ihdr = Buffer.alloc(13);
|
|
53
|
+
ihdr.writeUInt32BE(size, 0);
|
|
54
|
+
ihdr.writeUInt32BE(size, 4);
|
|
55
|
+
ihdr[8] = 8; // bit depth
|
|
56
|
+
ihdr[9] = 6; // RGBA
|
|
57
|
+
return Buffer.concat([
|
|
58
|
+
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
59
|
+
chunk("IHDR", ihdr),
|
|
60
|
+
// level 9 so the bytes are a function of the pixels alone, not of zlib's default tuning.
|
|
61
|
+
chunk("IDAT", deflateSync(raw, { level: 9 })),
|
|
62
|
+
chunk("IEND", Buffer.alloc(0)),
|
|
63
|
+
]);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The eye mark, supersampled 4× so the curves do not alias at 16px. */
|
|
67
|
+
function draw(size) {
|
|
68
|
+
const S = 4;
|
|
69
|
+
const n = size * S;
|
|
70
|
+
const acc = new Float64Array(size * size * 4);
|
|
71
|
+
const c = n / 2;
|
|
72
|
+
const rOuter = n * 0.47; // the rounded square's inscribed radius
|
|
73
|
+
const corner = n * 0.22;
|
|
74
|
+
const lensW = n * 0.34;
|
|
75
|
+
const lensH = n * 0.2;
|
|
76
|
+
const pupil = n * 0.108;
|
|
77
|
+
|
|
78
|
+
for (let y = 0; y < n; y++) {
|
|
79
|
+
for (let x = 0; x < n; x++) {
|
|
80
|
+
const dx = x - c;
|
|
81
|
+
const dy = y - c;
|
|
82
|
+
// Rounded square (squircle-ish): outside it the pixel is transparent.
|
|
83
|
+
const ax = Math.abs(dx);
|
|
84
|
+
const ay = Math.abs(dy);
|
|
85
|
+
const inSquare =
|
|
86
|
+
ax <= rOuter && ay <= rOuter && (ax <= rOuter - corner || ay <= rOuter - corner || (ax - (rOuter - corner)) ** 2 + (ay - (rOuter - corner)) ** 2 <= corner ** 2);
|
|
87
|
+
if (!inSquare) continue;
|
|
88
|
+
|
|
89
|
+
// The lens: an ellipse. The pupil is a disc at its centre.
|
|
90
|
+
const inLens = (dx / lensW) ** 2 + (dy / lensH) ** 2 <= 1;
|
|
91
|
+
const inPupil = dx * dx + dy * dy <= pupil * pupil;
|
|
92
|
+
const [r, g, b] = inLens && !inPupil ? FG : BG;
|
|
93
|
+
|
|
94
|
+
const i = (Math.floor(y / S) * size + Math.floor(x / S)) * 4;
|
|
95
|
+
acc[i] += r;
|
|
96
|
+
acc[i + 1] += g;
|
|
97
|
+
acc[i + 2] += b;
|
|
98
|
+
acc[i + 3] += 255;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const out = Buffer.alloc(size * size * 4);
|
|
103
|
+
const per = S * S;
|
|
104
|
+
for (let i = 0; i < size * size; i++) {
|
|
105
|
+
const a = acc[i * 4 + 3] / per;
|
|
106
|
+
// Un-premultiply: the accumulator summed colour only over covered samples.
|
|
107
|
+
const cov = acc[i * 4 + 3] / 255 || 1;
|
|
108
|
+
out[i * 4] = Math.round(acc[i * 4] / cov);
|
|
109
|
+
out[i * 4 + 1] = Math.round(acc[i * 4 + 1] / cov);
|
|
110
|
+
out[i * 4 + 2] = Math.round(acc[i * 4 + 2] / cov);
|
|
111
|
+
out[i * 4 + 3] = Math.round(a);
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const check = process.argv.includes("--check");
|
|
117
|
+
mkdirSync(OUT, { recursive: true });
|
|
118
|
+
let stale = [];
|
|
119
|
+
for (const size of SIZES) {
|
|
120
|
+
const file = join(OUT, `icon-${size}.png`);
|
|
121
|
+
const bytes = png(size, draw(size));
|
|
122
|
+
if (check) {
|
|
123
|
+
let current = null;
|
|
124
|
+
try {
|
|
125
|
+
current = readFileSync(file);
|
|
126
|
+
} catch {}
|
|
127
|
+
if (!current || !current.equals(bytes)) stale.push(`icon-${size}.png`);
|
|
128
|
+
} else {
|
|
129
|
+
writeFileSync(file, bytes);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (check && stale.length) {
|
|
133
|
+
console.error(`build-extension-icons: committed icons differ from the source (${stale.join(", ")}). Run \`node scripts/build-extension-icons.mjs\`.`);
|
|
134
|
+
process.exitCode = 1;
|
|
135
|
+
} else {
|
|
136
|
+
console.log(check ? "build-extension-icons: committed icons match the source." : `build-extension-icons: wrote ${SIZES.length} icon(s) to extension/icons/.`);
|
|
137
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Recall guard for regenerated ENGINE-OUTPUT artifacts.
|
|
3
|
+
//
|
|
4
|
+
// The problem this exists to solve, in one sentence: some committed artifacts
|
|
5
|
+
// are this repo's own product and must never move on an engine bump, and some
|
|
6
|
+
// ARE the engine's output and legitimately move on every one. Treating the
|
|
7
|
+
// second kind as a hard red is how an engine pin rots — ultrasec's sat seven
|
|
8
|
+
// releases behind because twelve goldens moved and every nightly re-pin died on
|
|
9
|
+
// them. Treating it as free is worse: an engine that quietly stopped finding a
|
|
10
|
+
// class of bugs would be committed as the new "expected" and pushed to main.
|
|
11
|
+
//
|
|
12
|
+
// So the re-pin regenerates them, and this compares the regenerated artifacts to
|
|
13
|
+
// the committed ones and fails only on LOSS. Enrichment — more findings, more
|
|
14
|
+
// fields, new files — passes. Losing something is a human decision, never an
|
|
15
|
+
// automatic push.
|
|
16
|
+
//
|
|
17
|
+
// What "loss" means, per file type:
|
|
18
|
+
// *.json every array in the structure, keyed by its path; a shorter array is
|
|
19
|
+
// a loss. `["a","b"] -> ["a"]` fails, `-> ["a","b","c"]` passes.
|
|
20
|
+
// *.snap every object key appearing in the text; fewer occurrences of
|
|
21
|
+
// `"ruleId":` means fewer findings, fewer `"related":` means a
|
|
22
|
+
// cross-file link was dropped. Text, because a vitest snapshot is not
|
|
23
|
+
// JSON and hand-parsing it would break on the next vitest release.
|
|
24
|
+
// other line count. Blunt on purpose: a prose report that got shorter is
|
|
25
|
+
// worth a human look, and a file with no finer structure gives
|
|
26
|
+
// nothing better to measure.
|
|
27
|
+
//
|
|
28
|
+
// Keys under FEWER_IS_BETTER are exempt: a shrinking `warnings` or `todo` array
|
|
29
|
+
// is the fix landing, not recall being lost.
|
|
30
|
+
//
|
|
31
|
+
// Usage: node scripts/check-artifact-recall.mjs [--ref HEAD] [--verbose]
|
|
32
|
+
// Exit 0 = nothing lost · 1 = something was lost · 2 = usage/runtime error.
|
|
33
|
+
import { execFileSync } from "node:child_process";
|
|
34
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
35
|
+
import { dirname, join, relative } from "node:path";
|
|
36
|
+
import { fileURLToPath } from "node:url";
|
|
37
|
+
|
|
38
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// The per-repo table. The ONLY part of this file that differs between repos:
|
|
42
|
+
// which committed paths are engine output. Anything not listed here keeps
|
|
43
|
+
// whatever gate it already had — an `orchestrate` snapshot holds the RUNBOOK
|
|
44
|
+
// this skill generates itself, which no engine bump has any business moving.
|
|
45
|
+
const TRACKED = [
|
|
46
|
+
// The graph snapshot is `audit --graph` over the golden fixture: the rules are
|
|
47
|
+
// this repo's, but the cross-file links they fire on come from codeindex, so a
|
|
48
|
+
// newer engine resolves more of them. The `orchestrate` snapshot beside it is
|
|
49
|
+
// the RUNBOOK this skill writes itself — deliberately NOT listed, so it stays
|
|
50
|
+
// a hard red.
|
|
51
|
+
"tests/__snapshots__/golden-graph.test.ts.snap",
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
// Arrays whose shrinking is an improvement, not a regression. Matched as a
|
|
55
|
+
// SUFFIX on the last path segment, case-insensitively: a repo calls them
|
|
56
|
+
// `knownGaps`, `parseErrors` or `unresolvedRefs`, and a guard that only knew the
|
|
57
|
+
// bare words would report every closed gap as a loss — a red on good news is a
|
|
58
|
+
// red people learn to ignore.
|
|
59
|
+
const FEWER_IS_BETTER = ["warning", "error", "todo", "unresolved", "skipped", "failure", "gap", "miss"];
|
|
60
|
+
|
|
61
|
+
const refIdx = process.argv.indexOf("--ref");
|
|
62
|
+
const ref = refIdx === -1 ? "HEAD" : (process.argv[refIdx + 1] ?? "HEAD");
|
|
63
|
+
const verbose = process.argv.includes("--verbose");
|
|
64
|
+
|
|
65
|
+
/** The committed bytes of a path, or null when it is new in this tree. */
|
|
66
|
+
function committed(rel) {
|
|
67
|
+
try {
|
|
68
|
+
return execFileSync("git", ["show", `${ref}:${rel}`], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
69
|
+
} catch {
|
|
70
|
+
return null; // not in the ref: brand new, nothing to lose yet
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Every file under the tracked paths, repo-relative, sorted. */
|
|
75
|
+
function expand(paths) {
|
|
76
|
+
const out = [];
|
|
77
|
+
const walk = (rel) => {
|
|
78
|
+
const abs = join(root, rel);
|
|
79
|
+
if (!existsSync(abs)) return;
|
|
80
|
+
if (statSync(abs).isDirectory()) {
|
|
81
|
+
for (const name of execFileSync("git", ["ls-files", rel], { cwd: root, encoding: "utf8" }).split("\n").filter(Boolean)) {
|
|
82
|
+
out.push(name);
|
|
83
|
+
}
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
out.push(relative(root, abs));
|
|
87
|
+
};
|
|
88
|
+
for (const p of paths) walk(p);
|
|
89
|
+
return [...new Set(out)].sort();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// Measurements. Each returns a Map of "what" -> count.
|
|
94
|
+
|
|
95
|
+
function measureJson(text) {
|
|
96
|
+
const m = new Map();
|
|
97
|
+
let data;
|
|
98
|
+
try {
|
|
99
|
+
data = JSON.parse(text);
|
|
100
|
+
} catch {
|
|
101
|
+
return null; // unparseable: fall back to lines rather than guess
|
|
102
|
+
}
|
|
103
|
+
// ADD, never set: every element of an array shares one path (`cases[].sites`),
|
|
104
|
+
// so overwriting would keep only the last sibling's length and go blind to a
|
|
105
|
+
// loss in any of the others. Summing makes the key mean "sites across all
|
|
106
|
+
// cases", which is the number that must not go down.
|
|
107
|
+
const add = (path, n) => m.set(path, (m.get(path) ?? 0) + n);
|
|
108
|
+
const visit = (node, path) => {
|
|
109
|
+
if (Array.isArray(node)) {
|
|
110
|
+
add(path, node.length);
|
|
111
|
+
node.forEach((v) => visit(v, `${path}[]`));
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (node && typeof node === "object") for (const [k, v] of Object.entries(node)) visit(v, path ? `${path}.${k}` : k);
|
|
115
|
+
};
|
|
116
|
+
visit(data, "");
|
|
117
|
+
return m;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function measureSnapshot(text) {
|
|
121
|
+
const m = new Map();
|
|
122
|
+
for (const [, key] of text.matchAll(/^\s*"([^"]+)":/gm)) m.set(key, (m.get(key) ?? 0) + 1);
|
|
123
|
+
// Keys alone are blind to keyless array elements: a dependency cycle is
|
|
124
|
+
// `["src/a.ts", "src/b.ts"]`, so dropping a whole cycle changes no key count
|
|
125
|
+
// at all. Count bare scalar elements as their own bucket — that is where a
|
|
126
|
+
// path, a cycle or a call site actually lives.
|
|
127
|
+
const bare = [...text.matchAll(/^\s*(?:"(?:[^"\\]|\\.)*"|-?\d+(?:\.\d+)?|true|false|null),?\s*$/gm)].length;
|
|
128
|
+
if (bare) m.set("«scalar elements»", bare);
|
|
129
|
+
return m;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const measure = (rel, text) => {
|
|
133
|
+
if (rel.endsWith(".json")) return measureJson(text) ?? new Map([["lines", text.split("\n").length]]);
|
|
134
|
+
if (rel.endsWith(".snap")) return measureSnapshot(text);
|
|
135
|
+
return new Map([["lines", text.split("\n").length]]);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const exempt = (what) => {
|
|
139
|
+
const last = (what.split(/[.[]/).filter(Boolean).pop() ?? what).toLowerCase().replace(/s$/, "");
|
|
140
|
+
return FEWER_IS_BETTER.some((w) => last.endsWith(w));
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
const files = expand(TRACKED);
|
|
146
|
+
if (files.length === 0) {
|
|
147
|
+
console.error(`check-artifact-recall: none of the tracked paths exist (${TRACKED.join(", ")})`);
|
|
148
|
+
process.exit(2);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const lost = [];
|
|
152
|
+
let gained = 0;
|
|
153
|
+
let added = 0;
|
|
154
|
+
let unchanged = 0;
|
|
155
|
+
|
|
156
|
+
for (const rel of files) {
|
|
157
|
+
const before = committed(rel);
|
|
158
|
+
if (before === null) {
|
|
159
|
+
added++;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
let after;
|
|
163
|
+
try {
|
|
164
|
+
after = readFileSync(join(root, rel), "utf8");
|
|
165
|
+
} catch {
|
|
166
|
+
// A tracked artifact that vanished during regeneration is the loudest
|
|
167
|
+
// possible loss: report it as one rather than skipping the file.
|
|
168
|
+
lost.push({ rel, what: "(the file itself)", before: 1, after: 0 });
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (before === after) {
|
|
172
|
+
unchanged++;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
const b = measure(rel, before);
|
|
176
|
+
const a = measure(rel, after);
|
|
177
|
+
for (const [what, n] of b) {
|
|
178
|
+
if (exempt(what)) continue;
|
|
179
|
+
const now = a.get(what) ?? 0;
|
|
180
|
+
if (now < n) lost.push({ rel, what, before: n, after: now });
|
|
181
|
+
else if (now > n) gained++;
|
|
182
|
+
}
|
|
183
|
+
for (const [what, n] of a) if (!b.has(what) && n > 0) gained++;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const summary = `${files.length} tracked · ${unchanged} unchanged · ${added} new · ${gained} enriched`;
|
|
187
|
+
|
|
188
|
+
if (lost.length) {
|
|
189
|
+
console.error(`check-artifact-recall: RECALL LOST — ${summary}`);
|
|
190
|
+
for (const l of lost) console.error(` ${l.rel}: ${l.what} ${l.before} -> ${l.after}`);
|
|
191
|
+
console.error("");
|
|
192
|
+
console.error("A regenerated artifact that finds LESS than the committed one is a behaviour");
|
|
193
|
+
console.error("change, not a re-pin. Nothing was pushed. Review it by hand and commit it");
|
|
194
|
+
console.error("deliberately if the loss is correct.");
|
|
195
|
+
process.exit(1);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (verbose) for (const rel of files) console.log(` ok ${rel}`);
|
|
199
|
+
console.log(`check-artifact-recall: no recall lost — ${summary}`);
|