update 2026-09-20 01:22:33

This commit is contained in:
action committed 2026-09-20 01:22:33 +08:00
1 parent 42e0cfbec4
commit deb27aee9d
864 files changed
+180566 -42

No files matched your search

+27
View File
@@ -0,0 +1,27 @@
/* Build the stylesheet for a gate to measure. One copy.
*
* The stylesheet is GENERATED (build-css.sh concatenates styles/), so a gate must build it and
* never measure a stale copy left over from the last run. Three tools said that in three ways and
* had already drifted three ways — two used mkdtemp and one a fixed name under a shared /tmp, two
* silenced build-css.sh and one did not, and only css-dup passed --dev. The fixed-name variant is
* also what once forced a `name` parameter on this API — callers invented 'cascade-export.css'
* purely to avoid colliding with each other. mkdtemp makes the name a non-question, so do not
* re-add the knob. */
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { execFileSync } from 'node:child_process';
export { ROOT } from './root.mjs';
import { ROOT } from './root.mjs';
/* -> absolute path of a freshly built cascade.css. `dev` keeps comments: css-dup matches @mirror
* markers, which the squeeze strips. stdio: 'inherit' keeps build-css.sh's "N bytes -> path" line,
* which is worth having in a CI log and harmless locally. */
export function buildCss({ dev = false } = {}) {
const dir = mkdtempSync(join(process.env.RUNNER_TEMP || tmpdir(), 'fs-css-'));
const out = join(dir, 'cascade.css');
execFileSync(join(ROOT, 'luci-theme-footstrap/build-css.sh'),
dev ? [out, '--dev'] : [out], { stdio: 'inherit' });
return out;
}
@@ -0,0 +1,125 @@
/* `innerHTML` and its two relatives, held by a rule instead of by review.
*
* The theme parses HTML from a string in four places and every one is safe today: three are `= ''`
* clears and the fourth concatenates an icon builder with literals. Nothing checked that. The whole
* chrome is built from `E()` and `document.createElement`, so a fifth site written the ordinary way
* — `link.innerHTML = someLabel` — would be a script sink on a page fed by menu.d entries that any
* package may ship, and it would read exactly like the four that are fine.
*
* eslint-plugin-no-unsanitized is the off-the-shelf answer and is not taken: it is a dependency in
* a repository whose devDependencies are the argument for having a build at all, and its escape
* hatch is a comment pragma rather than a named builder. This rule takes the same shape as the
* codebase's other invariants — a small allowlist, stated once.
*
* SAFE is a string expression whose every leaf is a literal, a call to one of the two icon builders,
* or a const bound to one of those. Anything else — a bare identifier, a property read, a template
* with an interpolation, a function argument — fails and has to become `E()`/`textContent`, or be
* added to BUILDERS with the reason it cannot carry attacker text.
*/
/* Two kinds of builder, and the difference is what happens to the argument.
*
* MARKUP takes markup and wraps it — svgIcon('<circle …/>') returns an <svg> around exactly what it
* was handed — so its arguments are checked like any other operand.
*
* LOOKUP takes a NAME and returns markup the theme wrote: iconSvg()/iconFor() map a menu node's
* name onto one of a closed set of paths, with a `|| ICONS._default` fallback, and never
* interpolate the name into the result. Their arguments are unchecked on purpose — the name comes
* from a menu.d entry any package may ship, and being unable to reach the output is the property
* that makes the call safe. A builder added here must have that property. */
const MARKUP_BUILDERS = new Set([ 'svgIcon' ]);
const LOOKUP_BUILDERS = new Set([ 'iconSvg', 'iconFor' ]);
const SINK_PROPS = new Set([ 'innerHTML', 'outerHTML' ]);
function calleeName(node) {
if (node.type === 'Identifier') return node.name;
if (node.type === 'MemberExpression' && !node.computed && node.property.type === 'Identifier')
return node.property.name;
return null;
}
export function makeRule() {
return {
meta: {
type: 'problem',
docs: { description: 'parse HTML from a string only out of literals and named builders' },
schema: [],
messages: {
sink: 'HTML sink `{{ sink }}` fed a value this rule cannot prove is literal. Build the '
+ 'node with E() or set textContent; if it is markup the theme owns, name its '
+ 'builder in tools/lib/eslint-no-unsanitized.mjs.',
banned: '`{{ sink }}` writes into the parser and has no safe form here.',
},
},
create(context) {
const src = context.sourceCode ?? context.getSourceCode();
/* `seen` breaks the cycle a self-referential const would otherwise spin in */
function safe(node, seen = new Set()) {
if (!node || seen.has(node)) return false;
seen.add(node);
switch (node.type) {
case 'Literal':
return typeof node.value === 'string' || node.value === null;
case 'TemplateLiteral':
return node.expressions.every((e) => safe(e, seen));
case 'BinaryExpression':
return node.operator === '+' && safe(node.left, seen) && safe(node.right, seen);
case 'ConditionalExpression':
return safe(node.consequent, seen) && safe(node.alternate, seen);
case 'LogicalExpression':
return safe(node.left, seen) && safe(node.right, seen);
case 'CallExpression': {
const fn = calleeName(node.callee);
if (LOOKUP_BUILDERS.has(fn)) return true;
return MARKUP_BUILDERS.has(fn) && node.arguments.every((a) => safe(a, seen));
}
case 'Identifier': {
/* a const whose single write is itself safe — the `chevron` shape. Walked up the
* scope chain by name: getScope(node) answers for the node's own scope, and the
* binding is usually one or more scopes above the sink. */
let scope = src.getScope(node), v = null;
while (scope && !v) {
v = scope.variables.find((x) => x.name === node.name) ?? null;
scope = scope.upper;
}
if (!v || v.defs.length !== 1) return false;
const def = v.defs[0];
if (def.type !== 'Variable' || def.parent.kind !== 'const') return false;
if (v.references.some((r) => r.isWrite() && r.identifier !== def.name)) return false;
return safe(def.node.init, seen);
}
default:
return false;
}
}
return {
AssignmentExpression(node) {
const left = node.left;
if (left.type !== 'MemberExpression' || left.computed) return;
if (left.property.type !== 'Identifier' || !SINK_PROPS.has(left.property.name)) return;
if (safe(node.right)) return;
context.report({ node, messageId: 'sink', data: { sink: left.property.name } });
},
CallExpression(node) {
const name = calleeName(node.callee);
if (name === 'insertAdjacentHTML') {
if (safe(node.arguments[1])) return;
context.report({ node, messageId: 'sink', data: { sink: name } });
}
/* document.write cannot be made safe on a page LuCI has already parsed: it
* reopens the document. There is no allowlist for it. */
if ((name === 'write' || name === 'writeln')
&& node.callee.type === 'MemberExpression'
&& node.callee.object.type === 'Identifier'
&& node.callee.object.name === 'document')
context.report({ node, messageId: 'banned', data: { sink: `document.${name}` } });
},
};
},
};
}
export const plugin = { rules: { 'no-unsanitized-html': makeRule() } };
@@ -0,0 +1,58 @@
/* Static half of the "does the page render" check in tools/smoke.mjs: does every `alias.name(`
* reference one module makes into another actually exist in what the other exports?
*
* The shape it holds against: fs-appearance.js called `axes.currentContentWidth()` while
* fs-axes.js's `return baseclass.extend({ … })` carried no such key. Nothing ran that branch until
* a router did, `undefined(v)` threw inside the Appearance tab's one try/catch, and the whole
* "Footstrap" section vanished from System with a single console line — `npm run smoke` never
* built that page (it only evaluated modules) and `npm run check` stayed green end to end. This
* catches the same shape in milliseconds, with no browser, by reading source text the way
* `tools/axes.mjs` already reads the axis factories.
*
* Deliberately narrow, and safe to be narrow: a MISS here (an export built dynamically, e.g.
* `exported[key] = fn`) makes the check see fewer exports than there are, which can only produce a
* false failure — never a false pass — and every module this is pointed at lists its exports as a
* literal, comma-separated identifier list. It is a second, cheap line behind the render check in
* tools/smoke.mjs, not a replacement for it: a name can exist and still be wired to the wrong
* value, which only rendering the page catches. */
function stripComments(src) {
return src.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/\/\/[^\n]*/g, ' ');
}
/* The identifiers inside the module's own `return baseclass.extend({ … });` — the LAST such call
* in the file, which is where every one of these modules places it. Comments are stripped first so
* a word inside one ("the storage wrappers") cannot be mistaken for an export. Both shapes the
* theme uses are covered: a bare identifier (`currentTint, applyTint`) exports the name itself, and
* a `key: value` pair (not used today, but cheap to hold) exports the key. */
export function exportedKeys(src) {
const clean = stripComments(src);
const m = clean.match(/return\s+baseclass\.extend\(\{([\s\S]*)\}\)\s*;?\s*$/);
if (!m) return null;
const body = m[1];
const keys = new Set();
for (const km of body.matchAll(/([A-Za-z_$][A-Za-z0-9_$]*)/g)) keys.add(km[1]);
return keys;
}
/* Every `alias.name` reference in `src` — a call (`axes.currentContentWidth()`) or a bare
* reference handed to another function (`bump(axes.applyContentWidth)`), because the second shape
* fails the same way one call later: `bump()` invokes whatever it was given, undefined included. */
export function referencedNames(src, alias) {
const clean = stripComments(src);
const re = new RegExp(`\\b${alias}\\.([A-Za-z_$][A-Za-z0-9_$]*)`, 'g');
const names = new Set();
for (const m of clean.matchAll(re)) names.add(m[1]);
return names;
}
/* The check itself: every name `callerSrc` reaches for on `alias` must be a key `calleeSrc`
* exports. Returns `null` (not an empty array) when the callee's export shape was not recognised,
* so a caller can tell "nothing missing" from "could not check" and never turns the second into a
* silent pass. */
export function missingExports(callerSrc, alias, calleeSrc) {
const exported = exportedKeys(calleeSrc);
if (!exported) return null;
const referenced = referencedNames(callerSrc, alias);
return [...referenced].filter((n) => !exported.has(n)).sort();
}
@@ -0,0 +1,93 @@
/* Shared harness for the two playwright gates that render docs/gallery.html: a11y-gallery.mjs (axe,
* WCAG 2.2 AA) and export-tier.mjs (the outbound --*-color-* contract). Nothing here ships.
*
* Both gates repeated the same ~20 lines — serve the gallery on an ephemeral port, stamp the
* Appearance axes onto :root — and the stamping is what mattered: each copy restated rules that
* already live in fs-prefs.js and head.ut's pre-paint, including the load-bearing one:
*
* set --fs-tint-h BEFORE the data-tint attribute
*
* If the tint gains a second custom property, or accent joins the sweep, a forgotten copy goes on
* testing the OLD shape and passing. One copy. */
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { join, extname } from 'node:path';
export { ROOT } from './root.mjs';
import { ROOT } from './root.mjs';
/* buildCss lives in lib/css.mjs — it is not a gallery concern, and devkit-build.mjs was importing
* it from here while having nothing to do with the gallery. */
/* Serve docs/gallery.html + the freshly-built stylesheet on an ephemeral port. */
export async function serveGallery(cssPath) {
const FILES = {
'/gallery.html': join(ROOT, 'docs/gallery.html'),
'/cascade.css': cssPath,
};
const TYPES = { '.html': 'text/html', '.css': 'text/css' };
const server = createServer(async (req, res) => {
const path = req.url.split('?')[0];
const file = FILES[path === '/' ? '/gallery.html' : path];
if (!file) { res.writeHead(404).end(); return; }
res.writeHead(200, { 'content-type': TYPES[extname(file)] || 'text/plain' });
res.end(await readFile(file));
});
await new Promise((r) => server.listen(0, '127.0.0.1', r));
return {
base: `http://127.0.0.1:${server.address().port}/gallery.html`,
close: () => server.close(),
};
}
/* Stamp one point of the Appearance matrix onto :root, exactly the way the theme does.
*
* The one copy. It must stay in step with applyMode/applyPalette/colorAxis in fs-prefs.js and
* head.ut's pre-paint, or these gates go on proving something that is no longer true.
*
* `tint`/`accent`: null (or 0) is off, which CLEARS both attribute and custom property — an
* untinted router must cost exactly the palette it already had, so "off" is not hue 0.
*
* Only the HUE half of a colour axis is swept, and that is the honest scope: a hue rotation keeps
* the palette's lightness and chroma, so it is a contract these gates can hold the theme to across
* the whole wheel. A hex axis is a colour the ADMIN chose against no palette at all — no value of it
* could pass or fail, which is why the theme derives readable ink over it in CSS and the Appearance
* page reports the ratio instead of a gate asserting one. */
export async function applyAppearance(page, { mode = 'light', palette = 'footstrap', tint = null, accent = null } = {}) {
await page.evaluate(([m, p, t, a]) => {
const root = document.documentElement;
root.setAttribute('data-darkmode', m === 'dark' ? 'true' : 'false');
if (p && p !== 'footstrap') root.setAttribute('data-palette', p);
else root.removeAttribute('data-palette');
/* colour axes in hue mode: the custom property FIRST, then the attribute that switches the
* rotation on. The other order paints one frame with the previous hue — the theme's own
* ordering rule. The attribute's VALUE says which mode is in effect, so 'hue' is not
* decoration: :root[data-tint="hex"] means something else entirely. */
const hue = (val, attr, prop) => {
if (!val) { root.removeAttribute(attr); root.style.removeProperty(prop); return; }
root.style.setProperty(prop, String(val));
root.setAttribute(attr, 'hue');
};
hue(t, 'data-tint', '--fs-tint-h');
hue(a, 'data-accent', '--fs-accent-h');
}, [mode, palette, tint, accent]);
await page.waitForTimeout(150);
}
/* The palette x mode grid both gates sweep. The TINT list is a PARAMETER, not shared: axe takes
* the two extremes, export-tier walks the wheel at 60°. That difference is deliberate and argued
* in each caller; the scaffolding around it is not. */
export function matrix(tints = [null]) {
return [
{ palette: 'footstrap', mode: 'light' },
{ palette: 'footstrap', mode: 'dark' },
{ palette: 'hicontrast', mode: 'light' },
{ palette: 'hicontrast', mode: 'dark' },
{ palette: 'bootstrap', mode: 'light' },
{ palette: 'bootstrap', mode: 'dark' },
{ palette: '2020', mode: 'light' },
{ palette: '2020', mode: 'dark' },
{ palette: 'forum', mode: 'light' },
{ palette: 'forum', mode: 'dark' },
].flatMap((c) => tints.map((tint) => ({ ...c, tint })));
}
@@ -0,0 +1,82 @@
/* The page-module map, read from the one file that declares it.
*
* `menu-footstrap-common.js` maps a `body[data-page]` value to the module for that page and loads it
* there. Two tools need the list — the gate that proves map and modules agree, and the size budget,
* which must not count a page module in what a cold page downloads — and a list parsed in two places
* is a list that will disagree in one.
*/
import { readFileSync, existsSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
export const RESOURCES = resolve(dirname(fileURLToPath(import.meta.url)),
'../../luci-theme-footstrap/htdocs/luci-static/resources');
export const LOADER = 'menu-footstrap-common.js';
/* -> Map(data-page -> module name). Throws rather than returning an empty map: every caller reads
* "no page modules" as a fact about the theme, so a parse that quietly found none lies to both. */
export function pageModules() {
const src = readFileSync(join(RESOURCES, LOADER), 'utf8');
const block = /const PAGE_MODULES = \{([^}]*)\}/.exec(src);
if (!block) throw new Error(`no PAGE_MODULES map in ${LOADER}`);
const map = new Map();
for (const m of block[1].matchAll(/'([a-z0-9-]+)'\s*:\s*'([a-z0-9-]+)'/g)) map.set(m[1], m[2]);
if (!map.size) throw new Error(`PAGE_MODULES in ${LOADER} is empty`);
return map;
}
/* Which modules a page downloads before it can show anything, DERIVED rather than listed.
*
* The page-module map answers a narrower question than the size budget needs: it names the two
* modules the loader fetches per page, but a module reached only from one of THOSE is just as
* absent from a cold visit, and the budget was counting it. `fs-version.js` is the standing
* example — 281 B required by `fs-appearance` alone, downloaded on no cold page, and paid for on
* every one of them in the number the gate printed.
*
* So: start at the two `L.require(…)` calls the footer emits and walk the directive prologues.
* `luci.js` resolves a prologue when the module is FETCHED, so everything the walk does not reach
* is fetched only when something else asks for it. The prologue shape is the one
* `tools/minify-js.mjs` parses, and the same constraint applies — the scan stops at the first
* string literal that is neither `'use strict'` nor a `'require …'`. */
const FOOTER = resolve(RESOURCES, '../../../ucode/template/themes/footstrap/partials/footer.ut');
export function entryModules() {
const src = readFileSync(FOOTER, 'utf8');
const names = [ ...src.matchAll(/L\.require\('([a-z0-9-]+)'\)/g) ].map((m) => m[1]);
if (!names.length) throw new Error(`no L.require() entry point in ${FOOTER}`);
return names;
}
function ours(name) {
return existsSync(join(RESOURCES, name + '.js'));
}
function requiresOf(name) {
let src;
try { src = readFileSync(join(RESOURCES, name + '.js'), 'utf8'); }
catch (e) { return []; } /* a stock luci-base class, not one of ours */
const out = [];
for (const line of src.split('\n')) {
const m = /^\s*'(?:use strict|require\s+(\S+?)(?:\s+as\s+\S+)?)'\s*;?\s*$/.exec(line);
if (m) { if (m[1]) out.push(m[1]); continue; }
if (line.trim() === '' || line.trim().startsWith('/*') || line.trim().startsWith('*')
|| line.trim().startsWith('//')) continue;
break; /* the prologue ends at the first real statement */
}
return out;
}
/* -> Set of OUR module names a cold page fetches. A stock luci-base class reached along the way
* (`baseclass`, `dom`, `ui`, `rpc`) is not the theme's to weigh — luci-base loads ui, rpc and form
* unconditionally on every page regardless of what this theme asks for. */
export function coldModules() {
const seen = new Set();
const queue = entryModules();
while (queue.length) {
const name = queue.shift();
if (seen.has(name) || !ours(name)) continue;
seen.add(name);
for (const dep of requiresOf(name)) queue.push(dep);
}
return seen;
}
@@ -0,0 +1,223 @@
/* Which pages are worth measuring, and why most of them are not.
*
* Walking every leaf of the router's menu is 37 pages on a bare OpenWrt and 146 across four
* containers, each at six widths — honest, and mostly repetition: the theme does not know what a
* page is ABOUT, only what it is MADE OF. Two pages built from the same parts cannot answer
* differently, so the sweep is over SHAPES rather than over pages. */
/* Runs INSIDE the page. The flags are the things the gates measure or the theme reshapes; two pages
* with the same set exercise the same code in `fs-select`, `fs-fit` and the stylesheet. Order is
* fixed by construction (the list below), so the signature is stable across runs and machines. */
export const SHAPE_PROBE = function () {
const view = document.getElementById('view') || document.body;
const has = (sel) => view.querySelector(sel) !== null;
const count = (sel) => view.querySelectorAll(sel).length;
const tables = [ ...view.querySelectorAll('.table, table') ];
const cols = tables.reduce((n, t) => {
const head = t.querySelector('.tr, tr');
return Math.max(n, head ? head.children.length : 0);
}, 0);
const rows = tables.reduce((n, t) => Math.max(n, t.querySelectorAll('.tr, tr').length), 0);
const flags = [];
if (has('.table.fs-dt')) flags.push('data-table');
if (has('.cbi-section-table')) flags.push('config-table');
if (cols > 6) flags.push('wide-table');
if (rows > 30) flags.push('long-table');
if (has('.cbi-map, .cbi-section')) flags.push('form');
if (has('.cbi-tabmenu, [data-tab]')) flags.push('tabs');
if (has('select')) flags.push('select');
if (has('.cbi-dropdown')) flags.push('dropdown');
if (has('textarea')) flags.push('textarea');
if (has('.ace_editor, .CodeMirror, .cm-editor')) flags.push('editor');
if (has('input[type="file"]')) flags.push('file');
if (has('svg')) flags.push('svg');
if (has('iframe')) flags.push('iframe');
if (has('.cbi-progressbar')) flags.push('progressbar');
if (has('.fs-card, .fs-ovl-sys, .fs-ovl-mem')) flags.push('cards');
if (has('.cbi-page-actions')) flags.push('actions');
if (count('*') > 1500) flags.push('heavy');
return flags.join('+') || 'bare';
};
/* The pages that keep their seat whatever they are made of: every one is a place a defect actually
* reached a user, and a shape-mate standing in for it would be a bet that the defect was about the
* shape rather than about the page. */
export const PINNED = [
'/admin/status/overview', /* the poll's own page, and the theme's busiest */
'/admin/status/processes', /* the wide data table; the arrival bug was reported here */
'/admin/network/dhcp', /* leases: the table that cards, plus its own form */
'/admin/network/wireless', /* assoclist: the widest table the stock menu has */
'/admin/network/diagnostics', /* #11's shredded column and the sub-24px button row */
'/admin/system/system', /* the Appearance panel hangs off this one */
'/admin/system/flash', /* file inputs and the modal-heavy path */
'/admin/status/iptables' /* long pre-formatted output, the nested-scroll case of #12 */
];
/* -> { picked: [path], dropped: Map(path -> representative), shapes: Map(path -> shape) }
*
* `keep` is the set that may not be sampled away (the baseline's paths, and PINNED). Order is the
* menu's, so the choice of representative is deterministic rather than whichever page answered
* first. */
export function representatives(shapes, keep = []) {
const mustKeep = new Set(keep);
const seen = new Map(); /* shape -> the page that represents it */
const picked = [], dropped = new Map();
for (const [ path, shape ] of shapes) {
if (mustKeep.has(path)) { picked.push(path); if (!seen.has(shape)) seen.set(shape, path); continue; }
if (!seen.has(shape)) { seen.set(shape, path); picked.push(path); continue; }
dropped.set(path, seen.get(shape));
}
return { picked, dropped, shapes };
}
/* Visit each page once and read its shape. One load, one settle — a fifth of what the measuring
* pass costs, which is what makes the reduction worth doing at all. */
/* Shapes already read from this router, shared between the gates that ask for them.
*
* Classifying is one load and a 1200ms settle PER PAGE OF THE MENU, and `spa-parity` and
* `live-audit` both do it, one after the other, against the same routers in the same CI job — the
* same walk twice, for an answer that cannot differ between them. With `FS_SHAPES` naming a
* directory, the first gate writes what it read and the second reads it back.
*
* Per PAGE rather than per run, because the two gates ask about slightly different lists (spa-parity
* drops its origin page), and a whole-list key would miss on that alone.
*
* A shape describes the page AS THIS BUILD RENDERS IT — `SHAPE_PROBE` looks for `.fs-card` among
* others — so the cache is only correct while the theme under the router does not change. That is
* why it is off unless asked for: CI points it at a directory inside the runner's own temp, new for
* every run. Never point it at a path that outlives an install. */
const CACHE_DIR = process.env.FS_SHAPES || '';
function cacheFile(base) {
const key = base.replace(/[^A-Za-z0-9]+/g, '-').replace(/^-|-$/g, '');
return `${CACHE_DIR}/shapes-${key}.json`;
}
async function readCache(base) {
if (!CACHE_DIR) return new Map();
try {
const fs = await import('node:fs');
return new Map(Object.entries(JSON.parse(fs.readFileSync(cacheFile(base), 'utf8'))));
} catch (e) { return new Map(); } /* no file, bad file: classify from scratch */
}
async function writeCache(base, shapes) {
if (!CACHE_DIR) return;
try {
const fs = await import('node:fs');
fs.mkdirSync(CACHE_DIR, { recursive: true });
const merged = { ...Object.fromEntries(await readCache(base)), ...Object.fromEntries(shapes) };
fs.writeFileSync(cacheFile(base), JSON.stringify(merged));
} catch (e) { /* a cache that cannot be written is a slow run, not a failed one */ }
}
/* HOW LONG A PAGE MAY TAKE TO ANSWER THE PROBE.
*
* `page.evaluate()` has no timeout of its own, at all: Playwright hands the expression to the
* renderer and waits for the renderer, so a page whose main thread has stopped turning is not slow
* — it is silent, forever. Measured (task liveslice): `/admin/system/filemanager` pinned the
* renderer at ~105% CPU, `classify()` never came back, and the `parity` and `audit` slices of the
* `live` job were cancelled by their own `timeout-minutes: 45` four runs in a row without printing
* one line about which page they were on.
*
* The probe is a handful of querySelectorAll calls over a page that has ALREADY settled for
* `settle` ms: 4-5 ms measured on a healthy build (`fs-fit.js@76b3c7a`; a whole page costs 1.3 s,
* and 1.2 s of that is the settle), against not returning at all on the frozen one (`@13e9864`,
* still alive after 12 minutes). 10 s is ~2000x the healthy cost — nothing that is merely slow gets
* up there — and it keeps the worst case affordable: the widest menu measured is 96 paths
* (owrt2512b, third-party fixtures installed), so even every one of them freezing costs the walk
* 16 minutes rather than the whole 45-minute slice. */
export const PROBE_DEADLINE_MS = 10000;
const FROZEN = Symbol('probe deadline');
/* Playwright's timeouts do not cover evaluate(); this is that timeout. The loser of the race is
* left pending on purpose — `Promise.race` keeps it handled, and the page it belongs to is closed
* immediately after. */
function withDeadline(promise, ms) {
let timer;
return Promise.race([
promise.finally(() => clearTimeout(timer)),
new Promise((_, reject) => { timer = setTimeout(() => reject(FROZEN), ms); })
]);
}
/* `frozen` collects the pages that loaded and then stopped answering — see reportFrozen(). `id` is
* what the finding calls this router; `base` is a URL with a port owlab picked, so a gate that has
* a stand id should pass it. */
export async function classify(page, base, paths, { settle = 1200, timeout = 20000, frozen = [], id = base } = {}) {
const shapes = new Map();
const cached = await readCache(base);
const fresh = new Map();
/* THE SHAPE WALK GETS ITS OWN PAGE, so that a page which freezes can be thrown away.
*
* Measured: once the renderer's main thread stops, that page never comes back — every later
* goto() on it burns its full 20 s timeout and every evaluate() throws `Execution context was
* destroyed` at once, so one frozen page silently costs the whole rest of the menu. Closing it
* and opening another in the SAME context costs 0.5 s and the walk goes on; the session cookie
* and the gate's route seal live on the context, not on the page, so the new one is logged in.
*
* The caller's page is deliberately not the one navigated here: it carries the console listeners
* and the login that the gate's own sweep needs, and must not be the thing that gets discarded. */
const ctx = page.context();
let probe = await ctx.newPage();
try {
for (const path of paths) {
if (cached.has(path)) { shapes.set(path, cached.get(path)); continue; }
try { await probe.goto(base + path, { waitUntil: 'domcontentloaded', timeout }); }
catch (e) { continue; } /* a page that will not load is measured by nobody */
await probe.waitForTimeout(settle);
try {
const shape = await withDeadline(probe.evaluate(SHAPE_PROBE), PROBE_DEADLINE_MS);
shapes.set(path, shape);
fresh.set(path, shape);
}
catch (e) {
if (e !== FROZEN) continue; /* context died under us: leave it out rather than guess */
/* Printed as it happens, not held to the end: this is the one finding whose whole
* point is that the log used to say nothing while the run was still going. */
frozen.push(`${id} ${path}`);
process.stdout.write(` FINDING ${id} ${path} loaded and then stopped answering — `
+ `no reply to the shape probe in ${PROBE_DEADLINE_MS / 1000}s, where a page that `
+ 'is alive answers in ~5ms\n');
await probe.close().catch(() => {});
probe = await ctx.newPage();
}
}
}
finally { await probe.close().catch(() => {}); }
if (fresh.size) await writeCache(base, fresh);
return shapes;
}
/* The frozen pages of a run, as the gate's own closing report. Returns how many there were, so the
* caller can make its exit status say so.
*
* This is NOT the same thing as a page that would not open — that one is the runner or the stand,
* and this walk already passes over it in silence. A page that answered its own load and then went
* quiet has a pinned main thread, which is the theme's own defect (task liveslice: `fs-fit.js`
* between `76b3c7a` and `13e9864`) and is measured by nobody after it. */
export function reportFrozen(frozen) {
if (!frozen.length) return 0;
process.stderr.write(`\n${frozen.length} page(s) never answered the shape probe:\n`);
for (const f of frozen) process.stderr.write(` ${f}\n`);
process.stderr.write('\nThe page loaded, so this is not the runner: its main thread stopped'
+ ' turning. Open the page\nwith a profiler attached, or bisect the theme against it — and'
+ ' note that every gate walking\nthis menu measured nothing on it.\n');
return frozen.length;
}
/* One line per shape, so the run says what it stood in for rather than quietly not measuring it. */
export function reportReduction(id, picked, dropped, shapes) {
const bySample = new Map();
for (const [ path, rep ] of dropped) {
if (!bySample.has(rep)) bySample.set(rep, []);
bySample.get(rep).push(path);
}
process.stdout.write(`${id}: ${picked.length} of ${shapes.size} page(s) measured — `
+ `${dropped.size} share a shape with one that is\n`);
for (const [ rep, list ] of bySample)
process.stdout.write(` ${shapes.get(rep)}\n ${rep} stands in for ${list.join(', ')}\n`);
}
+30
View File
@@ -0,0 +1,30 @@
/* The repo root, and the reads every gate does through it: `read`, `filesIn`, `readAll`. One copy.
*
* `join(dirname(fileURLToPath(import.meta.url)), '..')` written out per tool is three levels of
* truth about where the repo is, all free to drift the moment a tool moves between `tools/` and
* `tools/lib/` — at which point the boilerplate resolves one directory off and every read fails
* with ENOENT pointing at a path nobody wrote.
*
* Two callers stay on their own walk on purpose: mirror.mjs takes a MIXED list of files and
* directories, and minify-js.mjs takes roots outside the repo (it runs over the staged payload). */
import { readFileSync, readdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
/* lib/ is one level below tools/, which is one below the repo root. */
export const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
/* read a repo-relative path as text */
export const read = (p) => readFileSync(join(ROOT, p), 'utf8');
/* Every file under a repo-relative dir whose name ends in `ext`, as repo-relative paths.
* `recursive: true` yields directories as well as files, and a directory never ends in an
* extension — so the extension filter is what excludes them, not a separate stat(). */
export const filesIn = (dir, ext) => readdirSync(join(ROOT, dir), { recursive: true })
.filter((f) => f.endsWith(ext))
.map((f) => join(dir, f));
/* Every such file's text, newline-joined — a whole tree as ONE string. That is what a gate wants
* when the question is "does the theme, anywhere, do X": a named file goes stale the moment the
* code moves to a sibling module, which is the failure chrome-fence.mjs's own header records. */
export const readAll = (dir, ext) => filesIn(dir, ext).map(read).join('\n');
+192
View File
@@ -0,0 +1,192 @@
/* The live half of the gates: one place that knows how to reach a running router, log into LuCI and
* enumerate its pages.
*
* Every static gate here measures a FILE. The bugs that reached users measured a PAGE — a column
* shredded to one character per line (#11), a submenu title clipped (#22), a doubled scrollbar in
* Firefox (#12), a third-party app's tabs laid out wrong (#36, #33, #8), a client navigation that
* painted less than a full load (upstream review). None can be seen in a stylesheet; all are one
* query away on a live page.
*
* The routers are owlab's — `owlab status -json` names each one and the port it answers on, so
* nothing here hard-codes a port or a container name. Nothing here boots anything either: a gate
* that starts and stops containers by itself is a gate nobody runs locally. */
import { execFileSync } from 'node:child_process';
/* The routers a gate runs on by default: the three OpenWrt lines the theme supports. What changes
* what the theme is measured against is the package manager (apk on 25.12+, opkg on 24.10) and the
* luci-base the router carries; the snapshot box tracks luci-base's master, so an upstream change
* fails here before it reaches a user. `--all` widens to every running OpenWrt router (the -b/-c/-d
* twins); a gate that takes `--only` must honour `--all` too.
*
* ImmortalWrt is not a gate target at all, since 0.14.13: same luci-base, different brand and app
* set, never the leg that caught something first, and its two legs produced 1685 `noname` findings
* of their own app set on the 0.14.13 release run with nothing to read in them. A running `imm*`
* router is ignored, and `--only imm2512` is refused by name rather than measured. */
export const CORE = [ 'owrt2512', 'owrt2410', 'owrtsnap' ];
/* Every RUNNING owlab router, newest release first, or an empty array when owlab is absent — the
* caller decides whether that is a failure (a gate) or a reason to skip (a local convenience).
* With no `only` and no `all`, the CORE pair above; if none of it is running, everything that is,
* with a line saying so — a gate that silently measured a different set than it claims is worse
* than a slow one. */
export function stands(only, { all = false } = {}) {
let out;
try {
out = execFileSync('owlab', [ 'status', '-json' ], { encoding: 'utf8', stdio: [ 'ignore', 'pipe', 'ignore' ] });
} catch (e) {
return [];
}
let parsed;
try { parsed = JSON.parse(out); } catch (e) { return []; }
const wanted = (only || '').split(',').map((s) => s.trim()).filter(Boolean);
const foreign = (parsed.routers || []).filter((r) => r.distro !== 'openwrt' && wanted.includes(r.id));
if (foreign.length) {
console.error(`${foreign.map((r) => r.id).join(', ')}: not a gate target — the gates measure OpenWrt `
+ 'routers only (tools/lib/stands.mjs).');
process.exit(2);
}
const running = (parsed.routers || [])
.filter((r) => r.state === 'running' && r.http_port && r.distro === 'openwrt')
.map((r) => ({
id: r.id,
base: `http://localhost:${r.http_port}/cgi-bin/luci`,
release: r.release,
distro: r.distro,
pkg: r.package_manager,
}));
if (wanted.length) return running.filter((r) => wanted.includes(r.id));
if (all) return running;
const core = running.filter((r) => CORE.includes(r.id));
if (core.length) return core;
if (running.length)
process.stderr.write(`(none of ${CORE.join(', ')} is running — measuring `
+ `${running.map((r) => r.id).join(', ')} instead)\n`);
return running;
}
/* Cut the browser off from everything except the router under test.
*
* A live gate measures what THIS theme renders. Anything a page fetches from the open internet is
* somebody else's code reaching somebody else's host, and it can only add noise: `luci-app-ssclash`
* asks GitHub for the latest mihomo release on two of its pages, and on a CI runner — whose egress
* IP is shared and rate-limited — that answers 403. Nine console findings across three routers, on
* a release that had nothing to do with any of them, none of it reproducible off the runner.
*
* Blocking is better than filtering the message afterwards, and better than dropping the app from
* the stands: the app stays (its lazily-inserted Ace stylesheets are the one adversary that can
* invert the cascade layer order — owlab.yaml), the request simply never leaves. The gates stop
* depending on the network, on a rate limit, and on how fast github.com answers today.
*
* `abort`, not a fake 200: an invented response is a lie the page then renders. The abort still
* writes `net::ERR_FAILED` to the console, which live-audit's own network-noise filter drops.
*
* The router's own origin passes, and so do data:/blob: — a stylesheet or an icon the page builds
* for itself is not a fetch. */
export async function sealToRouter(ctx, base) {
const host = new URL(base).host;
/* The predicate form, never a catch-all glob: a glob route hands EVERY request to a JS callback,
* including the hundreds the router itself serves, and each round trip through the client costs
* time and disables the browser's own handling. A predicate is evaluated by Playwright, so a request to
* the router is never intercepted at all — only the handful going elsewhere reach the handler.
* The glob version pushed CI's live job from 17 minutes past its 45-minute limit. */
await ctx.route(
(url) => url.host !== host && url.protocol !== 'data:' && url.protocol !== 'blob:',
(route) => route.abort());
}
/* LuCI answers an unauthenticated request with the login form, not a 403 page — so every live gate
* has to log in before it can measure anything. owlab's routers are root with an empty password
* (`owlab status` prints it); a hardware router is not what these gates run against. */
export async function login(page, base) {
await page.goto(base, { waitUntil: 'domcontentloaded' });
if (await page.$('input[name="luci_password"]')) {
await page.fill('input[name="luci_username"]', 'root');
await Promise.all([
page.waitForNavigation({ waitUntil: 'domcontentloaded' }),
page.press('input[name="luci_password"]', 'Enter'),
]);
}
}
/* Every LEAF of the router's own menu tree, as LuCI itself resolves it — not a list of paths kept
* in the repo, which would go stale the moment an app is installed or a release moves a page.
*
* `L.ui.menu.load()` returns the tree ALREADY rooted at `admin`, so the walk starts with an empty
* path: seeding it with the root's name produces `/admin/admin/...` and a sweep of 404s that looks
* like a clean run. */
export async function menuPaths(page, opts = {}) {
/* `L.ui` is only there once ui.js has been required by something on the page, and how soon that
* happens differs between release lines — reading it straight after the login redirect crashed
* the 24.10 leg with "Cannot read properties of undefined (reading 'menu')" while 25.12 was fine.
* Wait for the runtime, then ask for the module by name rather than hoping somebody else did. */
await page.waitForFunction(() => window.L && typeof window.L.require === 'function', null, { timeout: 20000 });
/* Only the leaves that are PAGES. A dispatcher tree carries far more than the menu shows: on a
* router with openclash and justclash installed, 105 of its 169 leaves are `call` nodes — RPC
* endpoints an app registers for its own JS — plus `function` nodes and the untitled plumbing.
* None renders a page, so a gate that measured layout on one measured an empty `#view` and spent
* two thirds of its wall clock proving that.
*
* `view` and `template` are the two that paint (plus `cbi` on an old enough app), and a node the
* menu does not title is not a page a user can reach. `{ all: true }` returns the raw walk, for a
* caller that wants the dispatcher rather than the interface. */
return page.evaluate(async (all) => {
const ui = await L.require('ui');
const tree = await ui.menu.load();
const RENDERS = { view: 1, template: 1, cbi: 1 };
const out = [];
const walk = (node, path) => {
for (const name of Object.keys(node.children || {})) {
const child = node.children[name];
const p = path.concat(name);
if (child.children && Object.keys(child.children).length) { walk(child, p); continue; }
const type = (child.action && child.action.type) || '';
if (all || (RENDERS[type] && child.title)) out.push('/' + p.join('/'));
}
};
walk(tree, []);
return out;
}, !!opts.all);
}
/* Pages a sweep must not open twice: they end the session or the router, and the second visit
* measures a login form or a dead container. Named by path fragment, because the menu labels are
* translated and the paths are not. */
export const DESTRUCTIVE = /\/(logout|reboot|flash|backup|shutdown)(\/|$)/;
/* The literal clone owlab boots beside `owrt2512`, `owrt2410` and `owrtsnap` (`owlab.yaml`, task
* sweepspeed) — same distro, release and package manager, its own container answering its own
* ubus — for exactly this: a sweep can treat the pair as one logical stand with twice the
* concurrency, without changing which release lines it covers. Auto-paired only when the caller did
* not already name the twin explicitly: `--only owrt2512,owrt2512b` means "measure both,
* separately", and this must not silently halve that into "measure the union once." Returns a Map
* keyed by the BASE stand's id; empty when owlab has no `-b` set running, so a caller with the old
* three-stand lab degrades to exactly today's behaviour. */
export function pairStands(list) {
let out;
try {
out = execFileSync('owlab', [ 'status', '-json' ], { encoding: 'utf8', stdio: [ 'ignore', 'pipe', 'ignore' ] });
} catch (e) { return new Map(); }
let parsed;
try { parsed = JSON.parse(out); } catch (e) { return new Map(); }
const running = (parsed.routers || []).filter((r) => r.state === 'running' && r.http_port);
const byId = new Map(running.map((r) => [ r.id, r ]));
const explicit = new Set(list.map((s) => s.id));
const pairs = new Map();
for (const s of list) {
const twinId = s.id + 'b';
if (explicit.has(twinId)) continue;
const t = byId.get(twinId);
if (t) pairs.set(s.id, { id: t.id, base: `http://localhost:${t.http_port}/cgi-bin/luci`,
release: t.release, distro: t.distro, pkg: t.package_manager });
}
return pairs;
}
/* No stand, no verdict — and a gate that quietly reports success on zero routers is worse than one
* that fails, because it looks the same as a clean run in a log. */
export function requireStands(list, name) {
if (list.length) return list;
console.error(`${name}: no owlab router is running, so nothing was checked.`);
console.error('Start one with `owlab up` (see docs/development.md) and run this again.');
process.exit(2);
}
+41
View File
@@ -0,0 +1,41 @@
/* The --*-color-* export tier, parsed out of styles/02-tokens.css — the one place it is defined.
*
* A gate that hand-writes its own cross-product of families x levels checks the list, not the
* contract: one such list knew --text-color-highest and the other did not, so that name was
* defined, shipped, read by apps and inspected by nothing — setting --text-color-highest to #808080
* (~3.95:1 on a light --fs-bg, a real AA failure) passed with "export tier: OK — 1820 checks".
* Derived here and consumed by both, so a level added to the tokens is measured on the next run.
*
* Names come back WITHOUT the leading `--`: that is the shape devkit.src.html renders
* (`'var(--' + name + ')'`), and export-tier.mjs adds it back. */
/* Families in the order the devkit renders them. The regex is the gate on what counts as the tier:
* --fs-* is private and deliberately not surfaced — see the token file's own header. */
const FAMILY_ORDER = ['background', 'border', 'text', 'primary', 'success', 'warn', 'error'];
const LEVEL_RANK = { highest: 0, high: 1, medium: 2, low: 3 };
/* Returns [{ family, levels: ['text-color-high', …] strongest first, ink: 'on-…-color' | null }].
* A family with no ink is one apps do not fill with — background, border, text. */
export function parseExportTier(css) {
const found = new Set();
for (const m of css.matchAll(
/--((?:background|border|text|primary|success|warn|error)-color-(?:highest|high|medium|low))\s*:/g))
found.add(m[1]);
const groups = {};
for (const name of found) {
const fam = name.split('-color-')[0];
(groups[fam] ||= { family: fam, levels: [], ink: null }).levels.push(name);
}
for (const m of css.matchAll(/--(on-(?:primary|success|warn|error)-color)\s*:/g)) {
const fam = m[1].replace(/^on-/, '').replace(/-color$/, '');
if (groups[fam]) groups[fam].ink = m[1];
}
const lvl = (n) => LEVEL_RANK[n.split('-color-')[1]] ?? 9;
return FAMILY_ORDER.filter((f) => groups[f]).map((f) => ({
...groups[f],
levels: groups[f].levels.sort((a, b) => lvl(a) - lvl(b)),
}));
}
@@ -0,0 +1,68 @@
/* Extract the inline <script> bodies from a ucode .ut template so ESLint can lint them.
*
* The browser JS inside the templates is otherwise the only JS in the theme that nothing checks:
* eslint runs over `htdocs/**` and jsmin minifies the same tree, while a .ut is copied to the
* router verbatim. That leaves the pre-paint in partials/head.ut — the most load-bearing script in
* the theme, stamping :root before the first frame — outside every gate, with a wrong frame nobody
* reports as its only failure symptom. */
/* <script> with no `src`. `[^>]*` covers attributes we do use (`data-fs-shell` is on <style>, but
* a future <script> attribute must not silently drop the block from the lint). */
const SCRIPT = /<script(?![^>]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/g;
const INTERPOLATED = /\{\{|\{%/;
/* A block the linter cannot see must not think. Anything beyond assignments — a keyword that
* branches, loops, declares or calls back — belongs in a pure block that IS linted. */
const LOGIC = /\b(?:if|else|for|while|do|switch|try|catch|function|return|=>)\b|=>/;
export function extractScripts(text) {
const out = [];
for (const m of text.matchAll(SCRIPT)) {
const body = m[1];
if (!body.trim()) continue;
const start = m.index + m[0].indexOf(body);
const before = text.slice(0, start);
const line = before.split('\n').length;
const col = start - (before.lastIndexOf('\n') + 1);
out.push({ body, start, line, col, interpolated: INTERPOLATED.test(body) });
}
return out;
}
/* An interpolated block is exempt from the lint, so it has to earn the exemption: one statement,
* no control flow. `window.__fsSD={…};` passes; a branch on a server value does not. */
export function assertDataOnly(block, filename) {
const src = block.body.trim();
if (LOGIC.test(src))
throw new Error(
`${filename}:${block.line}: an interpolated <script> is not linted (it is not JS until ` +
`rendered), so it must be DATA ONLY — hand the server value to a pure-JS block instead ` +
`(see head.ut's window.__fsSD).`
);
if (src.split(';').filter((s) => s.trim()).length > 1)
throw new Error(
`${filename}:${block.line}: an interpolated <script> must be a SINGLE data statement; ` +
`this one has several. Move the logic into a pure-JS block, which the linter can see.`
);
}
export const utProcessor = {
meta: { name: 'ut-inline-script', version: '1.0.0' },
supportsAutofix: false,
preprocess(text, filename) {
const out = [];
let i = 0;
for (const block of extractScripts(text)) {
if (block.interpolated) { assertDataOnly(block, filename); continue; }
/* pad to the body's exact offset: line/column then need no remapping */
out.push({
text: '\n'.repeat(block.line - 1) + ' '.repeat(block.col) + block.body,
filename: `${i++}.js`,
});
}
return out;
},
postprocess(messages) {
return messages.flat();
},
};