feat(ui): add DataTable and Toaster, drop the legacy Table, fix overlay dialogs DataTable replaces the 20-line Table scaffold entirely: columns, sorting, filtering, pagination, selection, bulk actions, comparison layout, sticky first column, custom HTML cells, and a remote source driven by a `request` output rather than a function prop (props travel as HTML attributes, so a function arrives as its own source text). Toaster replaces the hand-rolled status div: tone icons, actions, hover pause/resume and a progress bar. Overlays audit -- Modal and Drawer declared aria-modal="true" but nothing ever moved focus into the panel, so the @keydown handler on their root never ran and closeOnEscape did nothing. Focus, focus restore, a Tab trap and a body scroll lock now live in the reactive runtime, shared by both. ContextMenu placed pointer menus by subtracting a guessed 340x420 from the viewport, which pushed every menu that was not that size away from the pointer; it now positions at the pointer and lets the anchored clamp pull it back once it can be measured. The reactive runtime size budget moves 150k -> 175k to cover anchored overlays, dialog behaviour, the toaster and the DataTable client half. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> @
219 lines
7.5 KiB
JavaScript
219 lines
7.5 KiB
JavaScript
import console from "node:console";
|
|
import { execFileSync } from "node:child_process";
|
|
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync, mkdirSync } from "node:fs";
|
|
import { dirname, extname, join, relative, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import process from "node:process";
|
|
|
|
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
const frameworkVersion = JSON.parse(readFileSync(join(root, "package.json"), "utf8")).version;
|
|
const issues = [];
|
|
const warnings = [];
|
|
const checks = [];
|
|
|
|
function addCheck(name, passed, detail) {
|
|
checks.push({ name, passed, detail });
|
|
if (!passed) issues.push({ severity: "error", code: name, detail });
|
|
}
|
|
|
|
function addWarning(name, detail) {
|
|
warnings.push({ severity: "warning", code: name, detail });
|
|
}
|
|
|
|
function walk(dir, output = []) {
|
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
if (["node_modules", ".git", ".publish", "dist", ".wrnexus"].includes(entry.name)) continue;
|
|
const path = join(dir, entry.name);
|
|
if (entry.isDirectory()) walk(path, output);
|
|
else output.push(path);
|
|
}
|
|
return output;
|
|
}
|
|
|
|
function listGitTrackedFiles() {
|
|
try {
|
|
const output = execFileSync("git", ["ls-files", "-z"], {
|
|
cwd: root,
|
|
encoding: "utf8",
|
|
stdio: ["ignore", "pipe", "ignore"],
|
|
});
|
|
return output
|
|
.split("\0")
|
|
.filter(Boolean)
|
|
.map((path) => join(root, path))
|
|
.filter((path) => existsSync(path) && statSync(path).isFile());
|
|
} catch {
|
|
// Source archives do not contain .git metadata. In that environment the
|
|
// archive itself is the release boundary, so scanning every included file
|
|
// is the correct fallback.
|
|
return walk(root);
|
|
}
|
|
}
|
|
|
|
const allFiles = walk(root);
|
|
const trackedFiles = listGitTrackedFiles();
|
|
const trackedSet = new Set(trackedFiles.map((path) => resolve(path)));
|
|
|
|
const manifests = trackedFiles
|
|
.filter((path) => path.startsWith(join(root, "packages")) && path.endsWith("package.json"))
|
|
.map((path) => ({ path, value: JSON.parse(readFileSync(path, "utf8")) }))
|
|
.filter(({ value }) => value.name?.startsWith("@wrnexus/"));
|
|
addCheck(
|
|
"SEC-PACKAGE-VERSION",
|
|
manifests.every(({ value }) => value.version === frameworkVersion),
|
|
`Every @wrnexus package must use version ${frameworkVersion}.`,
|
|
);
|
|
addCheck(
|
|
"SEC-PRIVATE-PUBLISH",
|
|
manifests.every(({ value }) => value.publishConfig?.access !== "public"),
|
|
"No framework package may opt into public access in a private release.",
|
|
);
|
|
|
|
const secretNames = /(?:^|\/)(?:\.env(?:\..+)?|id_rsa|id_ed25519|.*\.(?:pem|p12|pfx|key))$/i;
|
|
const safeSecretTemplates = /(?:^|\/)\.env(?:\.[^/]+)*\.(?:example|sample|template)$/i;
|
|
function isSecretLike(path) {
|
|
const normalized = relative(root, path).replace(/\\/g, "/");
|
|
return secretNames.test(normalized) && !safeSecretTemplates.test(normalized);
|
|
}
|
|
|
|
const trackedSecretFiles = trackedFiles.filter(isSecretLike);
|
|
addCheck(
|
|
"SEC-NO-TRACKED-SECRET-FILES",
|
|
trackedSecretFiles.length === 0,
|
|
trackedSecretFiles.length > 0
|
|
? `${trackedSecretFiles.map((path) => relative(root, path)).join(", ")}; run: bun run repair:workspace`
|
|
: "No tracked secret-like files found.",
|
|
);
|
|
|
|
const temporaryTypecheckPattern = /(?:^|\/)(?:focus-shims\.d\.ts|tsconfig\.focus\.json)$/i;
|
|
const trackedTypecheckHelpers = trackedFiles.filter((path) =>
|
|
temporaryTypecheckPattern.test(relative(root, path).replace(/\\/g, "/")),
|
|
);
|
|
addCheck(
|
|
"SEC-NO-TRACKED-TYPECHECK-SHIMS",
|
|
trackedTypecheckHelpers.length === 0,
|
|
trackedTypecheckHelpers.length > 0
|
|
? `${trackedTypecheckHelpers.map((path) => relative(root, path)).join(", ")}; run: bun run repair:workspace`
|
|
: "No temporary typecheck helpers are tracked.",
|
|
);
|
|
|
|
const localTypecheckHelpers = allFiles.filter(
|
|
(path) =>
|
|
temporaryTypecheckPattern.test(relative(root, path).replace(/\\/g, "/")) &&
|
|
!trackedSet.has(resolve(path)),
|
|
);
|
|
if (localTypecheckHelpers.length > 0) {
|
|
addWarning(
|
|
"SEC-LOCAL-TYPECHECK-SHIMS",
|
|
`Temporary local typecheck helpers are ignored by the root TypeScript program and can be removed with bun run repair:workspace: ${localTypecheckHelpers
|
|
.map((path) => relative(root, path))
|
|
.join(", ")}`,
|
|
);
|
|
}
|
|
|
|
const localSecretFiles = allFiles.filter(
|
|
(path) => isSecretLike(path) && !trackedSet.has(resolve(path)),
|
|
);
|
|
if (localSecretFiles.length > 0) {
|
|
addWarning(
|
|
"SEC-LOCAL-SECRET-FILES",
|
|
`Ignored or untracked local secret files were not included in the release audit: ${localSecretFiles
|
|
.map((path) => relative(root, path))
|
|
.join(", ")}`,
|
|
);
|
|
}
|
|
|
|
const wrnFiles = trackedFiles.filter((path) => extname(path) === ".wrn");
|
|
const dangerousUrls = [];
|
|
for (const path of wrnFiles) {
|
|
const source = readFileSync(path, "utf8");
|
|
if (
|
|
/\b(?:href|src|action|formaction)\s*=\s*["']\s*(?:javascript:|vbscript:|file:)/i.test(source)
|
|
) {
|
|
dangerousUrls.push(relative(root, path));
|
|
}
|
|
}
|
|
addCheck(
|
|
"SEC-NO-DANGEROUS-STATIC-URLS",
|
|
dangerousUrls.length === 0,
|
|
dangerousUrls.join(", ") || "No dangerous static URLs found.",
|
|
);
|
|
|
|
const productionFiles = trackedFiles.filter((path) =>
|
|
/packages\/(?:security|cache|image|observability|ssr|core)\/src\/.+\.ts$/.test(
|
|
path.replace(/\\/g, "/"),
|
|
),
|
|
);
|
|
const dynamicCode = [];
|
|
for (const path of productionFiles) {
|
|
const source = readFileSync(path, "utf8");
|
|
if (/\beval\s*\(|\bnew\s+Function\s*\(/.test(source)) dynamicCode.push(relative(root, path));
|
|
}
|
|
addCheck(
|
|
"SEC-FOUNDATION-NO-DYNAMIC-CODE",
|
|
dynamicCode.length === 0,
|
|
dynamicCode.join(", ") || "No dynamic code execution in security foundation packages.",
|
|
);
|
|
|
|
const runtimeBudgets = {
|
|
// Raised for the 9.0 release: the reactive runtime took on anchored-overlay
|
|
// clamping, modal dialog focus/scroll behaviour, the toaster and the client
|
|
// half of DataTable. This is a deliberate ceiling, not a rubber stamp --
|
|
// raise it again only alongside a decision about what the runtime should own.
|
|
"reactive-runtime.ts": 175_000,
|
|
"nav-runtime.ts": 25_000,
|
|
"realtime-runtime.ts": 15_000,
|
|
};
|
|
for (const [file, budget] of Object.entries(runtimeBudgets)) {
|
|
const bytes = statSync(join(root, "packages", "csr", "src", file)).size;
|
|
addCheck(
|
|
`PERF-RUNTIME-SIZE-${file.replace(/-runtime\.ts$/, "").toUpperCase()}`,
|
|
bytes <= budget,
|
|
`${file} is ${bytes} bytes (budget ${budget}).`,
|
|
);
|
|
}
|
|
addCheck(
|
|
"PERF-ZERO-JS-ANALYSIS",
|
|
existsSync(join(root, "packages", "compiler", "src", "analysis.ts")),
|
|
"Static/interactive route analysis must exist.",
|
|
);
|
|
addCheck(
|
|
"PERF-BROTLI",
|
|
readFileSync(join(root, "packages", "dev-server", "src", "runtime.ts"), "utf8").includes(
|
|
"brotliCompressSync",
|
|
),
|
|
"Production runtime should prefer Brotli.",
|
|
);
|
|
addCheck(
|
|
"OBS-METRICS",
|
|
existsSync(join(root, "packages", "observability", "src", "metrics.ts")),
|
|
"Metrics registry must exist.",
|
|
);
|
|
addCheck(
|
|
"SUPPLY-SBOM",
|
|
existsSync(join(root, "scripts", "generate-sbom.mjs")),
|
|
"SBOM generator must exist.",
|
|
);
|
|
|
|
const report = {
|
|
schemaVersion: 2,
|
|
frameworkVersion,
|
|
generatedAt: new Date().toISOString(),
|
|
passed: issues.length === 0,
|
|
checks,
|
|
warnings,
|
|
issues,
|
|
};
|
|
if (process.argv.includes("--write")) {
|
|
const reportDir = join(root, ".wrnexus", "reports");
|
|
mkdirSync(reportDir, { recursive: true });
|
|
writeFileSync(
|
|
join(reportDir, `security-performance-${frameworkVersion}.json`),
|
|
JSON.stringify(report, null, 2) + "\n",
|
|
);
|
|
}
|
|
for (const check of checks)
|
|
console.log(`${check.passed ? "ok" : "FAIL"} ${check.name} — ${check.detail}`);
|
|
for (const warning of warnings) console.warn(`warn ${warning.code} — ${warning.detail}`);
|
|
if (issues.length) process.exitCode = 1;
|