Files
WRNexusJS/scripts/validate-0.6.mjs
T

496 lines
18 KiB
JavaScript

#!/usr/bin/env node
import console from "node:console";
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
import { dirname, join, relative } from "node:path";
import process from "node:process";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { createRequire } from "node:module";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const require = createRequire(import.meta.url);
const failures = [];
const warnings = [];
const passes = [];
const fail = (message) => failures.push(message);
const pass = (message) => passes.push(message);
const readJson = (path) => JSON.parse(readFileSync(path, "utf8"));
function walk(dir, predicate = () => true) {
if (!existsSync(dir)) return [];
const out = [];
for (const name of readdirSync(dir)) {
if (["node_modules", ".git", "dist", ".wrnexus"].includes(name)) continue;
const path = join(dir, name);
const stat = statSync(path);
if (stat.isDirectory()) out.push(...walk(path, predicate));
else if (predicate(path)) out.push(path);
}
return out;
}
const packageDirs = readdirSync(join(root, "packages"))
.filter((name) => existsSync(join(root, "packages", name, "package.json")))
.sort();
const frameworkVersion = readJson(join(root, "package.json")).version;
if (packageDirs.length < 33)
fail(`Expected at least 33 framework packages, found ${packageDirs.length}`);
else pass(`${packageDirs.length} framework packages are present`);
for (const name of packageDirs) {
const manifest = readJson(join(root, "packages", name, "package.json"));
if (manifest.version !== frameworkVersion) {
fail(`packages/${name} is ${manifest.version ?? "unversioned"}; expected ${frameworkVersion}`);
}
}
if (!failures.some((item) => item.startsWith("packages/"))) {
pass(`All framework packages are version ${frameworkVersion}`);
}
for (const [label, path] of [
["root", "package.json"],
["VS Code extension", "editors/vscode/package.json"],
["managed CAPTCHA service", "services/managed-captcha/package.json"],
]) {
const version = readJson(join(root, path)).version;
if (version !== frameworkVersion)
fail(`${label} version is ${version}; expected ${frameworkVersion}`);
else pass(`${label} version is ${frameworkVersion}`);
}
for (const path of [
"packages/store/src/index.ts",
"packages/typecheck/src/index.ts",
"packages/syntax/src/v060.ts",
"packages/compiler/src/client-codegen.ts",
"packages/compiler/src/server-codegen.ts",
"packages/compiler/src/type-codegen.ts",
"packages/compiler/src/store-codegen.ts",
"packages/csr/src/outputs.ts",
"packages/csr/src/server-client.ts",
"packages/ssr/src/store-context.ts",
"packages/ssr/src/rpc.ts",
]) {
if (!existsSync(join(root, path))) fail(`Missing required v0.6 file: ${path}`);
}
const syntaxManifest = readJson(join(root, "packages/syntax/package.json"));
const syntaxIndexSource = readFileSync(join(root, "packages/syntax/src/index.ts"), "utf8");
const compilerCodegenSource = readFileSync(join(root, "packages/compiler/src/codegen.ts"), "utf8");
const publicV060ImportPattern = /(?:\bfrom\s*|\brequire\s*\()\s*["']@wrnexus\/syntax\/v060["']/;
const publicV060Imports = walk(root, (path) => /\.(?:ts|tsx|js|mjs|cjs)$/.test(path)).filter(
(path) => publicV060ImportPattern.test(readFileSync(path, "utf8")),
);
if (syntaxManifest.exports?.["./v060"])
fail("@wrnexus/syntax still exposes the version-specific ./v060 subpath");
if (!syntaxIndexSource.includes("stripRuntimeFunctionModifiers"))
fail("@wrnexus/syntax root does not re-export v0.6 helpers");
if (!compilerCodegenSource.includes('from "@wrnexus/syntax"'))
fail("compiler does not import syntax helpers from @wrnexus/syntax");
for (const path of publicV060Imports)
fail(`${relative(root, path)} imports the forbidden @wrnexus/syntax/v060 subpath`);
if (
!failures.some(
(item) =>
item.includes("syntax/v060") ||
item.includes("root does not re-export") ||
item.includes("compiler does not import syntax helpers"),
)
) {
pass("v0.6 syntax helpers are exposed through @wrnexus/syntax only");
}
const parserSource = readFileSync(join(root, "packages/syntax/src/v060.ts"), "utf8");
for (const marker of [
"parseOutputs",
"parseStructuredImports",
"parseStateDeclarations",
"parseRuntimeFunctions",
"parseStoreLifecycle",
]) {
if (!parserSource.includes(marker)) fail(`v0.6 parser helper missing: ${marker}`);
}
if (
![
"parseOutputs",
"parseStructuredImports",
"parseStateDeclarations",
"parseRuntimeFunctions",
"parseStoreLifecycle",
].some((marker) => !parserSource.includes(marker))
) {
pass("v0.6 parser helpers are present");
}
const uiFiles = walk(join(root, "packages/ui/components"), (path) => path.endsWith(".wrn"));
const forbidden = [
["$emit", /\$emit\s*\(/],
["legacy @event", /^\s*@event\b/m],
["$event", /\$event\b/],
["event.detail", /\bevent\.detail\b/],
["unclassified function", /^\s*(?:async\s+)?function\s+[A-Za-z_$]/m],
];
for (const file of uiFiles) {
const source = readFileSync(file, "utf8");
for (const [label, pattern] of forbidden) {
if (pattern.test(source)) fail(`${relative(root, file)} contains ${label}`);
}
}
if (!failures.some((item) => item.startsWith("packages/ui/components/"))) {
pass(`${uiFiles.length} UI components use classified functions and typed output declarations`);
}
function outputBlocks(source) {
const blocks = [];
for (const match of source.matchAll(/\boutputs\s*\{/g)) {
const brace = source.indexOf("{", match.index);
let depth = 0;
let quote = "";
for (let index = brace; index < source.length; index++) {
const char = source[index];
if (quote) {
if (char === "\\") index++;
else if (char === quote) quote = "";
continue;
}
if (char === '"' || char === "'" || char === "`") quote = char;
else if (char === "{") depth++;
else if (char === "}" && --depth === 0) {
blocks.push(source.slice(brace + 1, index));
break;
}
}
}
return blocks;
}
for (const file of uiFiles) {
const source = readFileSync(file, "utf8");
if (outputBlocks(source).some((block) => /\bunknown\b/.test(block))) {
fail(`${relative(root, file)} contains an untyped output payload contract`);
}
}
if (!failures.some((item) => item.includes("untyped output payload contract"))) {
pass("All UI output payload contracts are concrete and contain no unknown types");
}
for (const [path, markers] of [
["packages/compiler/src/client-codegen.ts", ["RESERVED_BINDINGS", "!parameterNames.has"]],
[
"packages/compiler/src/store-codegen.ts",
[
"const server = context.server",
"persist.migrate",
"persist.validate",
"__wrnexusApplyStoreHotUpdate",
],
],
["packages/compiler/src/server-codegen.ts", ["remotelyReferencedServerFunctions"]],
[
"packages/dev-server/src/pipeline.ts",
["setCompileImportOptions", 'mode: "compatible"', "WRN-IMPORT-IMPLICIT"],
],
["packages/dev-server/src/runtime.ts", ["store-update", "__wrnexusApplyStoreHotUpdate"]],
]) {
const source = readFileSync(join(root, path), "utf8");
for (const marker of markers)
if (!source.includes(marker)) fail(`${path} is missing focused fix marker: ${marker}`);
}
if (!failures.some((item) => item.includes("focused fix marker"))) {
pass("Focused fixes 1-7 are wired into compiler, store, import, RPC, and HMR sources");
}
const storeTypesSource = readFileSync(join(root, "packages/store/src/types.ts"), "utf8");
const storeRuntimeSource = readFileSync(join(root, "packages/store/src/index.ts"), "utf8");
const storeCodegenSource = readFileSync(
join(root, "packages/compiler/src/store-codegen.ts"),
"utf8",
);
for (const [label, condition] of [
[
"separate client/server state generics",
/StoreCombinedState<[\s\S]*?CS extends object[\s\S]*?SS extends object/.test(
storeTypesSource,
) &&
/createClientState\?: \(\) => CS/.test(storeTypesSource) &&
/createServerState\?: \(\) => SS/.test(storeTypesSource),
],
[
"callable action default",
/StoreFunction = \(\.\.\.args: any\[\]\) => any/.test(storeTypesSource) &&
/Record<string, StoreFunction>/.test(storeTypesSource),
],
[
"non-colliding initialization promise",
/readonly whenReady: Promise<void>/.test(storeTypesSource) &&
!/readonly ready: Promise<void>/.test(storeTypesSource) &&
/await instance\.whenReady/.test(storeRuntimeSource),
],
[
"browser store whenReady code generation",
/whenReady: Promise\.resolve\(\)/.test(storeCodegenSource) &&
/core\.whenReady = init\(\)/.test(storeCodegenSource),
],
]) {
if (!condition) fail(`Store type regression: missing ${label}`);
}
if (!failures.some((item) => item.startsWith("Store type regression:"))) {
pass(
"Store client/server state, callable actions, and ready-state collision regressions are guarded",
);
}
const reactiveRuntimeSource = readFileSync(
join(root, "packages/csr/src/reactive-runtime.ts"),
"utf8",
);
const clientCodegenSource = readFileSync(
join(root, "packages/compiler/src/client-codegen.ts"),
"utf8",
);
const syntaxParserSource = readFileSync(join(root, "packages/syntax/src/parser.ts"), "utf8");
const compilerMainSource = readFileSync(join(root, "packages/compiler/src/codegen.ts"), "utf8");
for (const [label, condition] of [
[
"synchronous hydration without a browser module",
reactiveRuntimeSource.includes('if (!moduleUrl || moduleUrl === "__WRNEXUS_CLIENT_MODULE__")'),
],
[
"runtime binding collision guard",
clientCodegenSource.includes("RUNTIME_BINDINGS") &&
clientCodegenSource.includes("!RUNTIME_BINDINGS.has(prop.name)"),
],
[
"unknown lifecycle hook validation",
syntaxParserSource.includes("Unknown lifecycle hook") &&
syntaxParserSource.includes("Unknown store lifecycle hook"),
],
[
"synchronous pages without imported stores",
compilerMainSource.includes('storeBindings.length > 0 ? "async " : ""'),
],
[
"mutable store dispose lifecycle",
storeRuntimeSource.includes('runLifecycle("$dispose", definition.lifecycle?.dispose)'),
],
]) {
if (!condition) fail(`R7 regression: missing ${label}`);
}
const rangeSliderReference = readJson(
join(root, "packages/ui/component-reference.json"),
).components.find((component) => component.name === "RangeSlider");
if (
!rangeSliderReference ||
!["input", "change", "focus", "blur"].every((name) => rangeSliderReference.events.includes(name))
) {
fail("R7 regression: RangeSlider output reference is incomplete");
}
if (!failures.some((item) => item.startsWith("R7 regression:"))) {
pass(
"R7 parser, hydration, codegen, store lifecycle, and component-reference regressions are guarded",
);
}
const uiReference = readJson(join(root, "packages/ui/component-reference.json"));
const uiCatalog = readJson(join(root, "packages/ui/component-catalog.json"));
const declaredUiNames = uiFiles
.map((file) => {
const declaration = /^\s*component\s+([A-Za-z][A-Za-z0-9_]*)\b/m.exec(
readFileSync(file, "utf8"),
);
return declaration?.[1] ?? null;
})
.filter(Boolean)
.sort();
const referenceNames = uiReference.components.map((component) => component.name).sort();
const catalogNames = uiCatalog.components.map((component) => component.name).sort();
if (JSON.stringify(referenceNames) !== JSON.stringify(declaredUiNames)) {
fail("R8 regression: component reference does not match bundled UI declarations");
}
if (JSON.stringify(catalogNames) !== JSON.stringify(declaredUiNames)) {
fail("R8 regression: component catalog does not match bundled UI declarations");
}
try {
const { compileWireFile } = require(join(root, "editors/vscode/src/compiler.cjs"));
for (const file of uiFiles) {
const output = compileWireFile(readFileSync(file, "utf8"), file);
if (!output.includes("${__wireSpreadAttrs(__attrs)}")) {
fail(`R8 regression: ${relative(root, file)} does not forward native attributes`);
}
}
} catch (error) {
fail(`R8 regression: bundled UI compilation failed: ${error.message}`);
}
if (!failures.some((item) => item.startsWith("R8 regression:"))) {
pass(
"R8 UI compilation, readonly-prop diagnostics, native attributes, catalog, and reference are guarded",
);
}
const toggleCountSource = readFileSync(
join(root, "packages/ui/components/ToggleCount.wrn"),
"utf8",
);
for (const [label, condition] of [
[
"peer client-function bindings",
clientCodegenSource.includes("context.functions") &&
clientCodegenSource.includes("context.functions[name](...args)") &&
clientCodegenSource.includes("(...__wrnexusPeerArgs) => context.functions"),
],
[
"drift-free ToggleCount animation scheduling",
toggleCountSource.includes("duration * frame / totalFrames") &&
toggleCountSource.includes("scheduleValueFrame(") &&
toggleCountSource.includes("data-animation-token") &&
!toggleCountSource.includes("setTimeout(\n animateValueFrame"),
],
]) {
if (!condition) fail(`R9 regression: missing ${label}`);
}
if (!failures.some((item) => item.startsWith("R9 regression:"))) {
pass("R9 peer-function binding and drift-free ToggleCount animation are guarded");
}
try {
const { parse, generateTargets } = require(join(root, "editors/vscode/src/compiler.cjs"));
const targets = generateTargets(
parse(`component PeerCalls {
state { value: number = 0 }
functions {
client function increment(): void { value += 1 }
client function run(): void { increment() }
}
view { <button @click='run()'>{value}</button> }
}`),
);
const executable = new Function(
`${targets.browser.replace(/^export\s+/gm, "")}\nreturn { bindClientScope };`,
)();
const state = { value: 0 };
const functions = executable.bindClientScope({
state,
props: {},
output: {},
server: {},
refs: {},
});
functions.run();
if (state.value !== 1) {
fail(`R10 regression: peer client function state was overwritten (${state.value})`);
} else {
pass("R10 peer client functions preserve shared scoped state");
}
} catch (error) {
fail(`R10 regression: peer client function execution failed: ${error.message}`);
}
const compilerTargetTestSource = readFileSync(
join(root, "packages/compiler/test/v060-targets.test.ts"),
"utf8",
);
if (!compilerTargetTestSource.includes('targets.browser.replace(/^export\\s+/gm, "")')) {
fail(
"R11 regression: peer-function executable test does not strip export keywords with a whitespace regex",
);
} else {
pass("R11 peer-function executable regression test uses the correct export-stripping regex");
}
const releaseSource = readFileSync(join(root, "scripts/release.ts"), "utf8");
const referenceGeneratorSource = readFileSync(
join(root, "scripts/generate-ui-component-reference.mjs"),
"utf8",
);
for (const [label, condition] of [
[
"canonical generated-reference Git comparison",
releaseSource.includes('["diff", "--quiet", "--", ...generatedFiles]') &&
releaseSource.includes('["diff", "--name-status", "--", ...generatedFiles]'),
],
[
"component catalog included in the release reference gate",
releaseSource.includes('"packages/ui/component-catalog.json"'),
],
[
"platform-independent component reference ordering",
referenceGeneratorSource.includes("function compareText(left, right)") &&
referenceGeneratorSource.includes("compareText(left.name, right.name)") &&
!referenceGeneratorSource.includes("localeCompare"),
],
[
"line-ending-stable generated reference writes",
referenceGeneratorSource.includes("function normalizeNewlines(source)") &&
referenceGeneratorSource.includes(
"normalizeNewlines(current) === normalizeNewlines(source)",
) &&
referenceGeneratorSource.includes("return false"),
],
]) {
if (!condition) fail(`R13 regression: missing ${label}`);
}
if (!failures.some((item) => item.startsWith("R13 regression:"))) {
const referenceGenerationTest = spawnSync(
process.execPath,
[join(root, "scripts/test-ui-reference-generation.mjs")],
{ encoding: "utf8" },
);
if (referenceGenerationTest.status !== 0) {
fail(
`R13 regression: generated-reference line-ending test failed: ${
referenceGenerationTest.stderr.trim() || referenceGenerationTest.stdout.trim()
}`,
);
} else {
pass(
"R13 release reference gate is canonical, complete, platform independent, and line-ending stable",
);
}
}
const showcaseFiles = walk(join(root, "examples/component-showcase/app"), (path) =>
path.endsWith(".wrn"),
);
for (const file of showcaseFiles) {
const source = readFileSync(file, "utf8");
if (/\b(?:open|defaultOpen)\s*=\s*(?:"true"|'\{true\}'|"\{true\}")/.test(source)) {
fail(`${relative(root, file)} forces an overlay open`);
}
}
if (!failures.some((item) => item.includes("forces an overlay open"))) {
pass(
`${showcaseFiles.length} generated showcase pages contain no forced-open overlay regression`,
);
}
for (const file of walk(root, (path) => path.endsWith(".json"))) {
try {
readJson(file);
} catch (error) {
fail(`Invalid JSON ${relative(root, file)}: ${error.message}`);
}
}
if (!failures.some((item) => item.startsWith("Invalid JSON"))) pass("All JSON files parse");
for (const file of walk(root, (path) => /\.(?:mjs|cjs|js)$/.test(path))) {
const check = spawnSync(process.execPath, ["--check", file], { encoding: "utf8" });
if (check.status !== 0)
fail(`JavaScript syntax failed: ${relative(root, file)}\n${check.stderr.trim()}`);
}
if (!failures.some((item) => item.startsWith("JavaScript syntax failed")))
pass("JavaScript/MJS syntax checks pass");
const bun = spawnSync(process.platform === "win32" ? "bun.exe" : "bun", ["--version"], {
encoding: "utf8",
});
if (bun.status !== 0)
warnings.push("Bun is unavailable; run `bun install` and `bun run check` on the target machine.");
else pass(`Bun ${bun.stdout.trim()} is available`);
console.log("WRNexusJS v0.6 validation");
for (const message of passes) console.log(`PASS ${message}`);
for (const message of warnings) console.warn(`WARN ${message}`);
for (const message of failures) console.error(`FAIL ${message}`);
console.log(`\n${passes.length} passed, ${warnings.length} warnings, ${failures.length} failed`);
process.exitCode = failures.length ? 1 : 0;