rename web/ to admin/, along with the web-named build and cli identifiers
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env node
|
||||
// The set of npm packages whose bytes reach admin/dist must be exactly the set
|
||||
// recorded in licenses/dependency-identity.txt (milestone-14 ruling 3).
|
||||
//
|
||||
// The shipped build carries no sourcemaps, so this makes a second build with
|
||||
// them into its own directory: the `sources` list of each chunk names the
|
||||
// modules that went into it, and the artifact `npm run build` produced stays
|
||||
// untouched. Runs from admin/ as `npm run assert-bundled`, on a laptop exactly as
|
||||
// on the runner.
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { bundledPackages, comparePackages, formatDiff, recordedPackages } from "./bundledPackages.mjs";
|
||||
|
||||
const webRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const outDir = "dist-sourcemap";
|
||||
const identityFile = join(webRoot, "..", "licenses", "dependency-identity.txt");
|
||||
|
||||
function fail(message) {
|
||||
process.stderr.write(`${message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function mapFiles(relativeDir) {
|
||||
const absolute = join(webRoot, relativeDir);
|
||||
let entries;
|
||||
try {
|
||||
entries = readdirSync(absolute, { withFileTypes: true });
|
||||
} catch (err) {
|
||||
fail(`assert-bundled: cannot read ${relativeDir}: ${err.message}`);
|
||||
}
|
||||
const found = [];
|
||||
for (const entry of entries) {
|
||||
const child = `${relativeDir}/${entry.name}`;
|
||||
if (entry.isDirectory()) {
|
||||
found.push(...mapFiles(child));
|
||||
} else if (entry.isFile() && entry.name.endsWith(".map")) {
|
||||
found.push(child);
|
||||
}
|
||||
}
|
||||
return found.sort();
|
||||
}
|
||||
|
||||
// The binary npm ci installed, never `npx`: npx silently downloads a package it
|
||||
// cannot find locally, so a wrong working directory would turn a licence check
|
||||
// into an unpinned fetch from the network.
|
||||
try {
|
||||
execFileSync(
|
||||
join(webRoot, "node_modules", ".bin", "vite"),
|
||||
["build", "--sourcemap", "--outDir", outDir, "--emptyOutDir"],
|
||||
{
|
||||
cwd: webRoot,
|
||||
stdio: ["ignore", "ignore", "inherit"],
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
fail(`assert-bundled: the sourcemap build failed: ${err.message}`);
|
||||
}
|
||||
|
||||
const maps = mapFiles(outDir);
|
||||
if (maps.length === 0) fail("assert-bundled: the sourcemap build produced no .map files; this check cannot run blind");
|
||||
|
||||
const sourceLists = maps.map((path) => {
|
||||
const raw = readFileSync(join(webRoot, path), "utf8");
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
fail(`assert-bundled: ${path} is not JSON: ${err.message}`);
|
||||
}
|
||||
return Array.isArray(parsed.sources) ? parsed.sources : [];
|
||||
});
|
||||
|
||||
const bundled = bundledPackages(sourceLists);
|
||||
|
||||
let identity;
|
||||
try {
|
||||
identity = readFileSync(identityFile, "utf8");
|
||||
} catch (err) {
|
||||
fail(`assert-bundled: cannot read licenses/dependency-identity.txt: ${err.message}`);
|
||||
}
|
||||
|
||||
const recorded = recordedPackages(identity);
|
||||
if (recorded === null) {
|
||||
fail("assert-bundled: licenses/dependency-identity.txt has no '[npm packages bundled into admin/dist]' section");
|
||||
}
|
||||
if (recorded.length === 0) {
|
||||
fail("assert-bundled: the '[npm packages bundled into admin/dist]' section is empty");
|
||||
}
|
||||
|
||||
const { added, removed } = comparePackages(recorded, bundled);
|
||||
if (added.length !== 0 || removed.length !== 0) {
|
||||
process.stderr.write(`${formatDiff(recorded, bundled)}\n\n`);
|
||||
fail(
|
||||
[
|
||||
"the set of npm packages in admin/dist has changed (-recorded +current).",
|
||||
"Work out what the change means for licenses/inventory.zon first, then record",
|
||||
"the new list in that section of licenses/dependency-identity.txt.",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
process.stdout.write(`admin/dist bundles exactly the ${bundled.length} recorded packages:\n`);
|
||||
for (const name of bundled) process.stdout.write(`${name}\n`);
|
||||
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env node
|
||||
// Every rule in the built stylesheet must sit inside a cascade layer
|
||||
// (milestone 23). Unlayered author CSS outranks every layer whatever its
|
||||
// selector says, so a single unlayered rule silently beats the StyleX atomic
|
||||
// rules it was written to sit under. That failure renders wrong and passes
|
||||
// every other gate: no test asserts computed style, and the bundler is happy.
|
||||
// It also checks the layer ORDER, which is the invariant that actually matters:
|
||||
// a later layer beats an earlier one, so `reset` has to be declared first.
|
||||
//
|
||||
// What it does not catch, so nobody reads more into a pass than is there: an
|
||||
// unlayered rule that sets only custom properties is allowed, because StyleX
|
||||
// emits its token `:root` block exactly that way and this cannot tell that
|
||||
// block from an override of it; a declaration value containing `@layer` or a
|
||||
// brace inside a string blinds the stripper; and with several stylesheets it
|
||||
// judges each alone, not their load order in the document.
|
||||
//
|
||||
// This check runs from admin/ as part of `npm run build`.
|
||||
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const distDir = join(dirname(dirname(fileURLToPath(import.meta.url))), "dist", "assets");
|
||||
|
||||
const sheets = readdirSync(distDir).filter((name) => name.endsWith(".css"));
|
||||
if (sheets.length === 0) {
|
||||
console.error("assert-css-layers: no stylesheet in dist/assets — did the build emit one?");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// At-rules that describe a resource or a name rather than styling an element.
|
||||
// They carry no cascade priority against a layer, so being outside one is
|
||||
// correct, and StyleX emits `@property` for its custom properties.
|
||||
const unlayerable = String.raw`@(?:layer|property|keyframes|font-face|counter-style|charset|import)`;
|
||||
|
||||
/** Strip comments, then every balanced block and statement the rule above allows. */
|
||||
function outsideLayers(css) {
|
||||
let rest = css.replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
for (;;) {
|
||||
const at = rest.search(new RegExp(`${unlayerable}[^{;]*\\{`));
|
||||
if (at === -1) break;
|
||||
let depth = 0;
|
||||
let end = rest.indexOf("{", at);
|
||||
for (let i = end; i < rest.length; i += 1) {
|
||||
if (rest[i] === "{") depth += 1;
|
||||
else if (rest[i] === "}") {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
end = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
rest = rest.slice(0, at) + rest.slice(end + 1);
|
||||
}
|
||||
return rest.replace(new RegExp(`${unlayerable}[^;{}]*;`, "g"), "");
|
||||
}
|
||||
|
||||
/**
|
||||
* A rule that only sets custom properties styles nothing on its own — StyleX
|
||||
* emits its token `:root` block that way, ahead of its layers, and a variable
|
||||
* is consumed through `var()` rather than competing with a layered rule.
|
||||
*/
|
||||
function stylesSomething(body) {
|
||||
return body
|
||||
.split(";")
|
||||
.map((declaration) => declaration.trim())
|
||||
.some((declaration) => declaration.length > 0 && !declaration.startsWith("--"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Layer names in the order their position is fixed, which is where each name is
|
||||
* first mentioned — a later block under an already-named layer does not move it.
|
||||
*/
|
||||
function layerOrder(css) {
|
||||
const seen = [];
|
||||
for (const [, names] of css.replace(/\/\*[\s\S]*?\*\//g, "").matchAll(/@layer\s+([^{;]+)[{;]/g)) {
|
||||
for (const name of names.split(",")) {
|
||||
const trimmed = name.trim();
|
||||
if (trimmed.length > 0 && !seen.includes(trimmed)) seen.push(trimmed);
|
||||
}
|
||||
}
|
||||
return seen;
|
||||
}
|
||||
|
||||
let failed = false;
|
||||
for (const sheet of sheets) {
|
||||
const css = readFileSync(join(distDir, sheet), "utf8");
|
||||
|
||||
// Order is the whole point: a later layer wins, so the reset has to be first.
|
||||
const order = layerOrder(css);
|
||||
if (order.length > 0 && order[0] !== "reset") {
|
||||
console.error(
|
||||
`assert-css-layers: ${sheet} declares layers in the order ${order.join(", ")} — ` +
|
||||
`'reset' must come first or it outranks the StyleX rules written against it.`,
|
||||
);
|
||||
failed = true;
|
||||
}
|
||||
|
||||
const leftover = outsideLayers(css);
|
||||
for (const [, selector, body] of leftover.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
|
||||
if (!stylesSomething(body)) continue;
|
||||
console.error(
|
||||
`assert-css-layers: ${sheet} styles elements outside every @layer:\n` +
|
||||
` ${selector.trim().slice(0, 80)} { ${body.trim().slice(0, 60)} … }`,
|
||||
);
|
||||
failed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
console.error("Wrap it in a layer declared before StyleX's, as admin/src/styles.css does.");
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(
|
||||
`every rule in ${sheets.length === 1 ? "the stylesheet" : `${sheets.length} stylesheets`} sits inside a cascade layer`,
|
||||
);
|
||||
@@ -0,0 +1,77 @@
|
||||
// The decisions behind `npm run assert-bundled`, kept separate from the script
|
||||
// that does the I/O so they can be unit-tested (milestone-14 deviation 24).
|
||||
//
|
||||
// The licence inventory has to cover every package whose bytes ship, and the
|
||||
// lockfile does not answer that question: it lists what could be reached, not
|
||||
// what rollup kept. Several packages of the non-dev closure are recorded as
|
||||
// tree-shaken away, and if application code starts importing one of them, no
|
||||
// lockfile, no version and no dependency set changes — only the bundle does. So
|
||||
// the bundle is what this reads.
|
||||
|
||||
const sectionHeading = "[npm packages bundled into admin/dist]";
|
||||
|
||||
// A sourcemap `sources` entry for a dependency ends in
|
||||
// `node_modules/<name>/<file>` or `node_modules/@<scope>/<name>/<file>`. Only
|
||||
// the last `node_modules/` matters: a nested dependency's path carries two.
|
||||
export function packageFromSource(source) {
|
||||
const marker = "node_modules/";
|
||||
const at = source.lastIndexOf(marker);
|
||||
if (at === -1) return null;
|
||||
const rest = source.slice(at + marker.length);
|
||||
const parts = rest.split("/");
|
||||
if (parts.length === 0 || parts[0] === "") return null;
|
||||
if (parts[0].startsWith("@")) {
|
||||
if (parts.length < 2 || parts[1] === "") return null;
|
||||
return `${parts[0]}/${parts[1]}`;
|
||||
}
|
||||
return parts[0];
|
||||
}
|
||||
|
||||
/// The sorted, deduplicated package set of a list of sourcemap `sources` arrays.
|
||||
export function bundledPackages(sourceLists) {
|
||||
const found = new Set();
|
||||
for (const sources of sourceLists) {
|
||||
for (const source of sources) {
|
||||
const name = packageFromSource(source);
|
||||
if (name !== null) found.add(name);
|
||||
}
|
||||
}
|
||||
return [...found].sort();
|
||||
}
|
||||
|
||||
/// The recorded section of `licenses/dependency-identity.txt`: every non-blank
|
||||
/// line after the heading, up to the next `[section]`.
|
||||
export function recordedPackages(text) {
|
||||
const recorded = new Set();
|
||||
let grabbing = false;
|
||||
for (const raw of text.split("\n")) {
|
||||
const line = raw.trim();
|
||||
if (!grabbing) {
|
||||
if (line === sectionHeading) grabbing = true;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("[")) break;
|
||||
if (line !== "") recorded.add(line);
|
||||
}
|
||||
return grabbing ? [...recorded].sort() : null;
|
||||
}
|
||||
|
||||
/// What changed, in the two directions that mean different things: a package
|
||||
/// that started shipping needs a licence decision, and one that stopped needs
|
||||
/// the record corrected.
|
||||
export function comparePackages(recorded, bundled) {
|
||||
const inBundle = new Set(bundled);
|
||||
const inRecord = new Set(recorded);
|
||||
return {
|
||||
added: bundled.filter((name) => !inRecord.has(name)),
|
||||
removed: recorded.filter((name) => !inBundle.has(name)),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatDiff(recorded, bundled) {
|
||||
const { added, removed } = comparePackages(recorded, bundled);
|
||||
const lines = [];
|
||||
for (const name of removed) lines.push(`-${name}`);
|
||||
for (const name of added) lines.push(`+${name}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
bundledPackages,
|
||||
comparePackages,
|
||||
formatDiff,
|
||||
packageFromSource,
|
||||
recordedPackages,
|
||||
} from "./bundledPackages.mjs";
|
||||
|
||||
describe("packageFromSource", () => {
|
||||
it("reads a plain package name", () => {
|
||||
expect(packageFromSource("../../node_modules/react-dom/client.js")).toBe("react-dom");
|
||||
});
|
||||
|
||||
it("keeps the scope of a scoped package", () => {
|
||||
expect(packageFromSource("../../node_modules/@tanstack/react-query/build/index.js")).toBe(
|
||||
"@tanstack/react-query",
|
||||
);
|
||||
});
|
||||
|
||||
it("takes the last node_modules, so a nested dependency is named correctly", () => {
|
||||
expect(packageFromSource("node_modules/vite/node_modules/@scope/inner/x.js")).toBe("@scope/inner");
|
||||
});
|
||||
|
||||
it("ignores application sources", () => {
|
||||
expect(packageFromSource("src/lib/api.ts")).toBeNull();
|
||||
expect(packageFromSource("../src/main.tsx")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("bundledPackages", () => {
|
||||
it("sorts and deduplicates across every map", () => {
|
||||
const packages = bundledPackages([
|
||||
["node_modules/react/index.js", "src/main.tsx", "node_modules/react/jsx-runtime.js"],
|
||||
["node_modules/@tanstack/react-router/x.js", "node_modules/react/index.js"],
|
||||
]);
|
||||
expect(packages).toEqual(["@tanstack/react-router", "react"]);
|
||||
});
|
||||
|
||||
it("returns an empty set when nothing came from node_modules", () => {
|
||||
expect(bundledPackages([["src/main.tsx"]])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recordedPackages", () => {
|
||||
const identity = [
|
||||
"[some earlier section]",
|
||||
"ignored",
|
||||
"",
|
||||
"[npm packages bundled into admin/dist]",
|
||||
"react",
|
||||
"@tanstack/react-query",
|
||||
"",
|
||||
"react-dom",
|
||||
"",
|
||||
"[a later section]",
|
||||
"not-a-package",
|
||||
].join("\n");
|
||||
|
||||
it("reads only its own section, sorted and deduplicated", () => {
|
||||
expect(recordedPackages(identity)).toEqual(["@tanstack/react-query", "react", "react-dom"]);
|
||||
});
|
||||
|
||||
it("distinguishes a missing section from an empty one", () => {
|
||||
expect(recordedPackages("[other]\nx\n")).toBeNull();
|
||||
expect(recordedPackages("[npm packages bundled into admin/dist]\n\n[next]\n")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("comparePackages", () => {
|
||||
it("reports both directions", () => {
|
||||
const { added, removed } = comparePackages(["a", "b"], ["b", "c"]);
|
||||
expect(added).toEqual(["c"]);
|
||||
expect(removed).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("reports nothing when the sets match", () => {
|
||||
expect(comparePackages(["a", "b"], ["a", "b"])).toEqual({ added: [], removed: [] });
|
||||
expect(formatDiff(["a"], ["a"])).toBe("");
|
||||
});
|
||||
|
||||
it("formats a diff the way the failure prints it", () => {
|
||||
expect(formatDiff(["a", "b"], ["b", "c"])).toBe("-a\n+c");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env node
|
||||
// Freshness stamp for admin/dist (milestone-15 ruling 5). A stale dist has
|
||||
// already shipped a crashing settings page once. Write mode runs from admin/ as
|
||||
// part of `npm run build`; check mode runs from the repository root as a
|
||||
// build.zig system command. Every path resolves from this file's own location
|
||||
// so both working directories hash the same set.
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { readdirSync, readFileSync, writeFileSync, statSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const webRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const distDir = join(webRoot, "dist");
|
||||
const stampFile = join(distDir, ".src-hash");
|
||||
const stampRelative = "admin/dist/.src-hash";
|
||||
|
||||
const inputDirs = ["src", "public"];
|
||||
const inputFiles = [
|
||||
"index.html",
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"vite.config.ts",
|
||||
"tsconfig.json",
|
||||
"tsconfig.app.json",
|
||||
"tsconfig.node.json",
|
||||
];
|
||||
|
||||
const staleMessage = "admin/dist is stale: rebuild the frontend (npm run build)";
|
||||
|
||||
function fail(message) {
|
||||
process.stderr.write(`${message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function walk(relativeDir) {
|
||||
const absolute = join(webRoot, relativeDir);
|
||||
let entries;
|
||||
try {
|
||||
entries = readdirSync(absolute, { withFileTypes: true });
|
||||
} catch (err) {
|
||||
fail(`stamp-dist: cannot read admin/${relativeDir}: ${err.message}`);
|
||||
}
|
||||
const found = [];
|
||||
for (const entry of entries) {
|
||||
const child = `${relativeDir}/${entry.name}`;
|
||||
if (entry.isDirectory()) {
|
||||
found.push(...walk(child));
|
||||
} else if (entry.isFile()) {
|
||||
found.push(child);
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
function inputSet() {
|
||||
const paths = [...inputFiles, ...inputDirs.flatMap(walk)];
|
||||
for (const path of inputFiles) {
|
||||
try {
|
||||
if (!statSync(join(webRoot, path)).isFile()) fail(`stamp-dist: admin/${path} is not a file`);
|
||||
} catch (err) {
|
||||
fail(`stamp-dist: cannot stat admin/${path}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
// Sorted by path so the digest does not depend on directory order.
|
||||
return paths.sort();
|
||||
}
|
||||
|
||||
function digest() {
|
||||
const hash = createHash("sha256");
|
||||
for (const path of inputSet()) {
|
||||
hash.update(path);
|
||||
hash.update("\0");
|
||||
hash.update(readFileSync(join(webRoot, path)));
|
||||
hash.update("\0");
|
||||
}
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
const check = process.argv.includes("--check");
|
||||
const computed = digest();
|
||||
|
||||
if (check) {
|
||||
let recorded;
|
||||
try {
|
||||
recorded = readFileSync(stampFile, "utf8").trim();
|
||||
} catch {
|
||||
fail(staleMessage);
|
||||
}
|
||||
if (recorded !== computed) fail(staleMessage);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
try {
|
||||
writeFileSync(stampFile, `${computed}\n`);
|
||||
} catch (err) {
|
||||
fail(`stamp-dist: cannot write ${stampRelative}: ${err.message}`);
|
||||
}
|
||||
process.stdout.write(`${stampRelative} ${computed}\n`);
|
||||
Reference in New Issue
Block a user