494 lines
18 KiB
JavaScript
494 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();
|
|
if (packageDirs.length !== 33) fail(`Expected 33 framework packages, found ${packageDirs.length}`);
|
|
else pass("33 framework packages are present");
|
|
|
|
for (const name of packageDirs) {
|
|
const manifest = readJson(join(root, "packages", name, "package.json"));
|
|
if (manifest.version !== "0.6.0")
|
|
fail(`packages/${name} is ${manifest.version ?? "unversioned"}`);
|
|
}
|
|
if (!failures.some((item) => item.startsWith("packages/")))
|
|
pass("All framework packages are version 0.6.0");
|
|
|
|
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 !== "0.6.0") fail(`${label} version is ${version}`);
|
|
else pass(`${label} version is 0.6.0`);
|
|
}
|
|
|
|
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",
|
|
"UPGRADE-0.6.0.md",
|
|
"ROLLBACK-0.6.0.md",
|
|
"RELEASE_NOTES-0.6.0.md",
|
|
]) {
|
|
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("const scopedContext = { ...context, functions }") &&
|
|
clientCodegenSource.includes("handler(scopedContext, ...args)"),
|
|
],
|
|
[
|
|
"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;
|