mirror of
https://github.com/caiwx86/small-packages.git
synced 2026-11-01 15:03:56 +08:00
update 2026-09-20 01:22:33
This commit is contained in:
1 parent
42e0cfbec4
commit
deb27aee9d
864 files changed
+180566
-42
No files matched your search
@@ -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`);
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user