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.
Files changed (57) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.opencode/plugins/ultra11y.js +1 -1
  5. package/README.md +59 -7
  6. package/dist/cypress-plugin.d.ts +17 -0
  7. package/dist/cypress-plugin.mjs +82 -0
  8. package/dist/cypress.d.ts +11 -0
  9. package/dist/cypress.mjs +170 -0
  10. package/dist/payload-D0VQYlGS.d.ts +44 -0
  11. package/dist/playwright.d.ts +35 -0
  12. package/dist/playwright.mjs +250 -0
  13. package/docker/runner.mjs +12 -1
  14. package/package.json +25 -3
  15. package/scripts/build-extension-icons.mjs +137 -0
  16. package/scripts/check-artifact-recall.mjs +199 -0
  17. package/scripts/sync-engine.mjs +126 -52
  18. package/scripts/ultra11y.mjs +1813 -369
  19. package/scripts/vendor/wcag-2.2-fr.json +1 -1
  20. package/scripts/vendor/wcag-2.2-sc-universe.json +1 -1
  21. package/skills/review-a11y/SKILL.md +1 -1
  22. package/skills/review-a11y/scripts/ultra11y.mjs +1813 -369
  23. package/skills/ultra11y/SKILL.md +2 -1
  24. package/skills/ultra11y/references/ci.md +33 -0
  25. package/skills/ultra11y/references/devtools.md +7 -0
  26. package/skills/ultra11y/references/dynamic.md +54 -1
  27. package/skills/ultra11y/references/e2e.md +42 -11
  28. package/skills/ultra11y/references/extension.md +52 -0
  29. package/skills/ultra11y/references/judgment.md +38 -0
  30. package/skills/ultra11y/references/pages.md +66 -3
  31. package/skills/ultra11y/scripts/ultra11y.mjs +1813 -369
  32. package/src/annotate.ts +53 -13
  33. package/src/check.ts +22 -5
  34. package/src/cli.ts +426 -32
  35. package/src/collector.ts +125 -0
  36. package/src/crawl.ts +43 -0
  37. package/src/data/wcag-universe.json +1 -1
  38. package/src/dev.ts +136 -5
  39. package/src/e2e.ts +13 -2
  40. package/src/init.ts +11 -2
  41. package/src/integrations/core.ts +75 -0
  42. package/src/integrations/cypress-plugin.ts +75 -0
  43. package/src/integrations/cypress.ts +56 -0
  44. package/src/integrations/payload.ts +141 -0
  45. package/src/integrations/playwright.ts +95 -0
  46. package/src/llm.ts +259 -0
  47. package/src/pages-report.ts +302 -0
  48. package/src/pages.ts +25 -4
  49. package/src/report.ts +48 -18
  50. package/src/sample.ts +57 -0
  51. package/src/scan-local.ts +57 -5
  52. package/src/scan.ts +162 -12
  53. package/src/snapshot.ts +44 -116
  54. package/src/types.ts +7 -1
  55. package/src/vendor/codeindex-engine.d.mts +49 -6
  56. package/src/vendor/codeindex-engine.mjs +432 -22
  57. 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.29.0",
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}`);