feat: release design system engine, studio and component contracts
This commit is contained in:
1 parent
ff1065fb27
commit
3943b946d6
693 files changed
+175546
-3894
No files matched your search
@@ -0,0 +1,86 @@
|
||||
import { readFileSync, readdirSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createHash } from "node:crypto";
|
||||
import console from "node:console";
|
||||
import { COMPONENT_CONTRACTS } from "../packages/design-system/src/contracts.ts";
|
||||
|
||||
// Source inventory only: these observations do not certify rendered behavior.
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const read = (path) => readFileSync(resolve(root, path), "utf8");
|
||||
const names = (source, expression) =>
|
||||
[...new Set([...source.matchAll(expression)].map((m) => m[1]))].sort();
|
||||
const reference = JSON.parse(read("packages/ui/component-reference.json"));
|
||||
const catalog = JSON.parse(read("packages/ui/component-catalog.json"));
|
||||
const components = readdirSync(resolve(root, "packages/ui/components"))
|
||||
.filter((f) => f.endsWith(".wrn"))
|
||||
.sort()
|
||||
.map((file) => {
|
||||
const path = `packages/ui/components/${file}`;
|
||||
const source = read(path);
|
||||
const name = /^\s*component\s+(\w+)/m.exec(source)?.[1];
|
||||
if (!name) throw new Error(`Missing component declaration: ${path}`);
|
||||
const entry = reference.components.find((c) => c.name === name);
|
||||
return {
|
||||
name,
|
||||
source: path,
|
||||
sha256: createHash("sha256").update(source).digest("hex"),
|
||||
category: catalog.components.find((c) => c.name === name)?.category ?? "uncatalogued",
|
||||
inReference: Boolean(entry),
|
||||
inCatalog: catalog.components.some((c) => c.name === name),
|
||||
props: entry?.props?.map((p) => p.name) ?? [],
|
||||
tokenReferences: names(source, /var\(\s*--wrn-([\w-]+)/g),
|
||||
dataAttributes: names(source, /\b(data-[\w-]+)\s*=/g),
|
||||
slots: names(source, /<slot\b[^>]*\bname="([^"]+)"/g),
|
||||
hasDefaultSlot: /<slot(?:\s|>)/.test(source),
|
||||
hasLocalStyle: /\bstyle\s*\{/.test(source),
|
||||
hasContractPartHooks: /\bdata-wrn-part\s*=/.test(source),
|
||||
literalGeometryDeclarations: (
|
||||
source.match(
|
||||
/\b(?:border-radius|padding(?:-inline|-block)?|box-shadow|transform)\s*:\s*(?!var\()[^;{}]+;/g,
|
||||
) ?? []
|
||||
).length,
|
||||
status:
|
||||
COMPONENT_CONTRACTS[name]?.source === path ? "recipe-supported" : "legacy-uncontracted",
|
||||
contractVersion: COMPONENT_CONTRACTS[name]?.contractVersion ?? null,
|
||||
regions: COMPONENT_CONTRACTS[name]?.regions ?? {},
|
||||
};
|
||||
});
|
||||
const actual = new Set(components.map((c) => c.name));
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
methodology:
|
||||
"Static observations from current working tree, including uncommitted files. Props copied from generated reference; not a parser or visual/behavior certification. Literal declaration count is a heuristic and includes legitimate structural values.",
|
||||
componentCount: components.length,
|
||||
referenceCount: reference.components.length,
|
||||
catalogCount: catalog.components.length,
|
||||
referenceOnly: reference.components.filter((c) => !actual.has(c.name)).map((c) => c.name),
|
||||
catalogOnly: catalog.components.filter((c) => !actual.has(c.name)).map((c) => c.name),
|
||||
components,
|
||||
};
|
||||
const directory = resolve(root, "docs/design-system");
|
||||
mkdirSync(directory, { recursive: true });
|
||||
writeFileSync(
|
||||
resolve(directory, "component-coverage.json"),
|
||||
`${JSON.stringify(report, null, 2)}\n`,
|
||||
);
|
||||
const rows = components.map(
|
||||
(c) =>
|
||||
`| ${c.name} | ${c.category} | ${c.tokenReferences.length} | ${c.hasLocalStyle ? "yes" : "no"} | ${c.hasContractPartHooks ? "yes" : "no"} | ${c.inReference && c.inCatalog ? "matched" : "gap"} | ${c.status} |`,
|
||||
);
|
||||
writeFileSync(
|
||||
resolve(directory, "component-coverage.md"),
|
||||
`# Design-system source coverage\n\nRegenerate with \`node scripts/audit-design-system.mjs\`. This inventories the working tree, including uncommitted components. It does not certify visual, accessibility or runtime behavior.\n\nSources: **${components.length}**; reference entries: **${report.referenceCount}**; catalog entries: **${report.catalogCount}**. Versioned contracts cover all 104 components. Provider, native and content-owned regions retain explicit styling limits in the JSON report. Status reports hook/recipe support, not accessibility certification. Token counts are source-local references, not inherited/global coverage. Part hooks refer specifically to the versioned \`data-wrn-part\` convention.\n\n| Component | Category | Tokens | Local style | Part hooks | Metadata | Status |\n| --- | --- | ---: | --- | --- | --- | --- |\n${rows.join("\n")}\n`,
|
||||
);
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
components: report.componentCount,
|
||||
reference: report.referenceCount,
|
||||
catalog: report.catalogCount,
|
||||
metadataGaps: components.filter((c) => !c.inReference || !c.inCatalog).map((c) => c.name),
|
||||
localStyles: components.filter((c) => c.hasLocalStyle).length,
|
||||
partHooks: components.filter((c) => c.hasContractPartHooks).length,
|
||||
referenceOnly: report.referenceOnly,
|
||||
catalogOnly: report.catalogOnly,
|
||||
}),
|
||||
);
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isIndependentReleaseVersion } from "./lib/package-version.mjs";
|
||||
import console from "node:console";
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
@@ -112,7 +113,7 @@ for (const directory of packageDirectories) {
|
||||
if (hasHelperKit) helperPackageCount++;
|
||||
componentCount += components.length;
|
||||
|
||||
if (!/^0\.8\.\d+$/.test(manifest.version))
|
||||
if (!isIndependentReleaseVersion(manifest.version))
|
||||
failures.push(`${manifest.name}: invalid independent version ${manifest.version}`);
|
||||
if (!existsSync(entry)) failures.push(`${manifest.name}: missing public entry ${main}`);
|
||||
if (!existsSync(join(packageRoot, "README.md")))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, symlinkSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { generateApplicationTypesWithPlugins } from "../packages/cli/src/types.ts";
|
||||
|
||||
@@ -20,6 +20,9 @@ try {
|
||||
return !/(?:^|\/)(?:dist|node_modules|\.wrnexus(?:-[^/]*)?)(?:\/|$)/.test(relative);
|
||||
},
|
||||
});
|
||||
// The scratch config must resolve the same declared dependencies as the example.
|
||||
if (existsSync(join(source, "node_modules")))
|
||||
symlinkSync(join(source, "node_modules"), join(scratch, "node_modules"), "junction");
|
||||
await generateApplicationTypesWithPlugins(scratch);
|
||||
|
||||
const stale = generated.filter((relative) => {
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
/** Copied into an isolated tarball consumer by prepare-design-release.ts. Package imports only. */
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdirSync, writeFileSync, existsSync, readFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { CORE_DESIGN_SYSTEMS, compileDesignSystem } from "@wrnexus/design-system";
|
||||
import { createDesignPackage, readDesignPackage } from "@wrnexus/design-system/package";
|
||||
import { StudioSession } from "@wrnexus/design-studio";
|
||||
import { planDesignUpgrade, applyDesignUpgrade } from "@wrnexus/design-studio/upgrades";
|
||||
import { createStudioHandler } from "@wrnexus/design-studio/server";
|
||||
import { createFrameworkMcpServer } from "@wrnexus/mcp";
|
||||
import { uiComponentNames } from "@wrnexus/ui/registry";
|
||||
import { resolveThemeConfig } from "@wrnexus/styles/theme";
|
||||
import schema from "@wrnexus/design-system/schema.json";
|
||||
|
||||
assert.equal(uiComponentNames().length, 104);
|
||||
assert.ok(schema);
|
||||
const system = structuredClone(CORE_DESIGN_SYSTEMS.square);
|
||||
const original = await createDesignPackage(system);
|
||||
const next = structuredClone(system);
|
||||
next.version = "1.1.0";
|
||||
next.tokens["color.primary"]!.value = "#abcdef";
|
||||
const upgraded = await createDesignPackage(next);
|
||||
const source = JSON.stringify(upgraded);
|
||||
const sessions: StudioSession[] = [];
|
||||
const applications = [];
|
||||
for (const name of ["application-one", "application-two"]) {
|
||||
const root = resolve(name);
|
||||
mkdirSync(root, { recursive: true });
|
||||
writeFileSync(join(root, "wrn.config.ts"), "export default { existing: true };");
|
||||
const storage = {
|
||||
value: "",
|
||||
getItem() {
|
||||
return this.value || null;
|
||||
},
|
||||
setItem(_key: string, value: string) {
|
||||
this.value = value;
|
||||
},
|
||||
};
|
||||
const session = new StudioSession(system, storage);
|
||||
sessions.push(session);
|
||||
session.setLocks(["/tokens/shape.control"]);
|
||||
const review = await planDesignUpgrade(session, source);
|
||||
assert.equal(review.canApply, true);
|
||||
await applyDesignUpgrade(session, source, review.baseRevision);
|
||||
assert.equal(session.valid.version, "1.1.0");
|
||||
session.undo();
|
||||
assert.equal(session.valid.version, "1.0.0");
|
||||
session.redo();
|
||||
session.edit("{unfinished");
|
||||
const restored = new StudioSession(undefined, storage);
|
||||
assert.equal(restored.restore(), true);
|
||||
assert.equal(restored.source, "{unfinished");
|
||||
restored.undo();
|
||||
assert.equal(restored.valid.version, "1.1.0");
|
||||
const server = createFrameworkMcpServer(root, { design: true, designWrite: true });
|
||||
let draft = (await server.call("wrnexus_design_create", {
|
||||
baseRevision: "none",
|
||||
source: JSON.stringify(system),
|
||||
})) as { revision: string };
|
||||
const migration = (await server.call("wrnexus_design_upgrade_plan", { source })) as {
|
||||
canApply: boolean;
|
||||
};
|
||||
assert.equal(migration.canApply, true);
|
||||
draft = (await server.call("wrnexus_design_upgrade", {
|
||||
baseRevision: draft.revision,
|
||||
source,
|
||||
})) as typeof draft;
|
||||
const page = {
|
||||
id: "account",
|
||||
title: "Account settings",
|
||||
components: [
|
||||
{ component: "Input", props: { id: "name", label: "Display name" } },
|
||||
{ component: "Button", props: { label: "Save profile", variant: "default" } },
|
||||
],
|
||||
};
|
||||
draft = (await server.call("wrnexus_design_page", {
|
||||
baseRevision: draft.revision,
|
||||
page,
|
||||
})) as typeof draft;
|
||||
const preview = (await server.call("wrnexus_design_preview", { pageId: "account" })) as {
|
||||
html: string;
|
||||
};
|
||||
assert.ok(preview.html.includes('data-wrn-component="Button"'));
|
||||
assert.ok(preview.html.includes("Save profile"));
|
||||
const pinned = (await server.call("wrnexus_design_package")) as { package: typeof upgraded };
|
||||
assert.equal(pinned.package.integrity.digest, upgraded.integrity.digest);
|
||||
await server.call("wrnexus_design_apply", {
|
||||
baseRevision: draft.revision,
|
||||
baseApplicationRevision: "none",
|
||||
});
|
||||
const state = (await server.call("wrnexus_design_inspect")) as {
|
||||
application: { revision: string };
|
||||
};
|
||||
assert.notEqual(state.application.revision, "none");
|
||||
assert.equal(
|
||||
readFileSync(join(root, "wrn.config.ts"), "utf8"),
|
||||
"export default { existing: true };",
|
||||
);
|
||||
applications.push({
|
||||
name,
|
||||
digest: pinned.package.integrity.digest,
|
||||
applied: state.application.revision,
|
||||
});
|
||||
}
|
||||
assert.equal(applications[0]!.digest, applications[1]!.digest);
|
||||
assert.equal(applications[0]!.applied, applications[1]!.applied);
|
||||
assert.equal(
|
||||
(await readDesignPackage(JSON.stringify(original))).package.integrity.digest,
|
||||
original.integrity.digest,
|
||||
);
|
||||
for (const system of Object.values(CORE_DESIGN_SYSTEMS)) {
|
||||
assert.ok(compileDesignSystem(system).css);
|
||||
assert.ok(resolveThemeConfig({ designSystem: { system } }).recipeCss);
|
||||
}
|
||||
const handler = await createStudioHandler({ applicationRoot: resolve("application-one") });
|
||||
assert.equal((await handler(new Request("http://127.0.0.1:4320/"))).status, 200);
|
||||
const client = await handler(new Request("http://127.0.0.1:4320/studio.js"));
|
||||
assert.equal(client.status, 200);
|
||||
assert.ok((await client.text()).length > 1000);
|
||||
const req = await handler(
|
||||
new Request("http://127.0.0.1:4320/api/preview", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ system: CORE_DESIGN_SYSTEMS.angular, mode: "dark" }),
|
||||
}),
|
||||
);
|
||||
assert.equal(req.status, 200);
|
||||
const result = await req.json();
|
||||
assert.ok(
|
||||
(await (await handler(new Request("http://127.0.0.1:4320" + result.url))).text()).includes(
|
||||
'data-wrn-component="Button"',
|
||||
),
|
||||
);
|
||||
assert.equal(existsSync(resolve("node_modules/@wrnexus/design-studio/src")), false);
|
||||
assert.equal(
|
||||
existsSync(resolve("node_modules/@wrnexus/design-studio/assets/studio-client.js")),
|
||||
true,
|
||||
);
|
||||
writeFileSync(
|
||||
"browser-probe.ts",
|
||||
'import {StudioSession} from "@wrnexus/design-studio"; import {createDesignPackage} from "@wrnexus/design-system/package"; import {planDesignUpgrade} from "@wrnexus/design-studio/upgrades"; console.log(new StudioSession(),createDesignPackage,planDesignUpgrade);',
|
||||
);
|
||||
const browser = await Bun.build({ entrypoints: [resolve("browser-probe.ts")], target: "browser" });
|
||||
assert.equal(browser.success, true, browser.logs.join("\n"));
|
||||
assert.ok(browser.outputs.length);
|
||||
const defaultServer = createFrameworkMcpServer(resolve("application-one"));
|
||||
assert.equal(defaultServer.tools().length, 8);
|
||||
const stdio = resolve("node_modules/@wrnexus/mcp/dist/stdio.js");
|
||||
const child = Bun.spawn(
|
||||
[process.execPath, stdio, "--root=" + resolve("application-one"), "--design"],
|
||||
{ stdin: "pipe", stdout: "pipe", stderr: "pipe" },
|
||||
);
|
||||
child.stdin.write(
|
||||
JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "tools/call",
|
||||
params: { name: "wrnexus_design_context", arguments: { components: ["Button"] } },
|
||||
}) + "\n",
|
||||
);
|
||||
child.stdin.end();
|
||||
const protocol = JSON.parse((await new Response(child.stdout).text()).trim());
|
||||
assert.equal(await child.exited, 0);
|
||||
assert.ok(protocol.result.structuredContent.value.components.Button);
|
||||
const cli = Bun.spawn(
|
||||
[
|
||||
process.execPath,
|
||||
resolve("node_modules/@wrnexus/design-studio/dist/cli.js"),
|
||||
"--app-root",
|
||||
resolve("application-one"),
|
||||
],
|
||||
{ stdout: "pipe", stderr: "pipe", env: { ...process.env, DESIGN_STUDIO_PORT: "0" } },
|
||||
);
|
||||
let startupTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
const startup = await Promise.race([
|
||||
cli.stdout.getReader().read(),
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
startupTimer = setTimeout(
|
||||
() => reject(Error("Installed Studio CLI startup timed out.")),
|
||||
10000,
|
||||
);
|
||||
}),
|
||||
]);
|
||||
const line = new TextDecoder().decode(startup.value);
|
||||
const address = /http:\/\/127\.0\.0\.1:\d+\//.exec(line)?.[0];
|
||||
assert.ok(address, line);
|
||||
assert.equal((await fetch(address)).status, 200);
|
||||
} finally {
|
||||
if (startupTimer) clearTimeout(startupTimer);
|
||||
cli.kill();
|
||||
await cli.exited;
|
||||
}
|
||||
writeFileSync(
|
||||
process.env.WRNEXUS_DESIGN_REPORT!,
|
||||
JSON.stringify(
|
||||
{
|
||||
packagingVerified: false,
|
||||
published: false,
|
||||
applications,
|
||||
checks: [
|
||||
"104 shipped component sources",
|
||||
"JSON schema asset",
|
||||
"browser-safe root and upgrades bundle",
|
||||
"prebuilt Studio client without src files",
|
||||
"real composed and agent SSR previews",
|
||||
"shared pin and CSS consistency across two applications",
|
||||
"invalid draft reload and undo/redo",
|
||||
"reviewed package upgrades and MCP operations",
|
||||
"unchanged default MCP tools and installed stdio protocol",
|
||||
"existing application configuration preserved",
|
||||
"installed Studio CLI starts without source files",
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
"Installed tarball consumers passed for two applications, Studio browser/server assets and MCP.",
|
||||
);
|
||||
@@ -0,0 +1,2 @@
|
||||
import { runDesignStudio } from "../packages/design-studio/src/cli.ts";
|
||||
await runDesignStudio();
|
||||
@@ -0,0 +1,113 @@
|
||||
/** Reproducible Phase 6 local agent evaluation. No provider calls, routes or deployments. */
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { resolve, join } from "node:path";
|
||||
import { createFrameworkMcpServer } from "../packages/mcp/src/index.ts";
|
||||
import { CORE_DESIGN_SYSTEMS } from "../packages/design-system/src/index.ts";
|
||||
import { compileWrnFile } from "../packages/compiler/src/index.ts";
|
||||
import { checkDesignConformance } from "../packages/design-studio/src/conformance.ts";
|
||||
import type { AgentPage } from "../packages/design-studio/src/agent-pages.ts";
|
||||
const output = resolve("output/design-system-phase-6");
|
||||
mkdirSync(output, { recursive: true });
|
||||
const report: Array<Record<string, unknown>> = [];
|
||||
for (const [name, system] of Object.entries(CORE_DESIGN_SYSTEMS)) {
|
||||
const directory = join(output, name);
|
||||
mkdirSync(directory, { recursive: true });
|
||||
writeFileSync(join(directory, "package.json"), "{}");
|
||||
const server = createFrameworkMcpServer(directory, { design: true, designWrite: true });
|
||||
let draft = (await server.call("wrnexus_design_inspect")) as { revision: string };
|
||||
draft = (await server.call("wrnexus_design_create", {
|
||||
baseRevision: draft.revision,
|
||||
source: JSON.stringify(system),
|
||||
})) as typeof draft;
|
||||
const context = await server.call("wrnexus_design_context", {
|
||||
components: ["PageHeader", "Input", "Button", "Alert"],
|
||||
});
|
||||
writeFileSync(join(directory, "agent-context.json"), JSON.stringify(context, null, 2));
|
||||
for (const id of ["account", "support"]) {
|
||||
const page: AgentPage = {
|
||||
id,
|
||||
title: id === "account" ? "Account settings" : "Contact support",
|
||||
components: [
|
||||
{
|
||||
component: "PageHeader",
|
||||
props: { title: id === "account" ? "Your profile" : "How can we help?" },
|
||||
},
|
||||
{
|
||||
component: "Input",
|
||||
props: { id: id + "-name", label: "Your name", placeholder: "Enter your name" },
|
||||
},
|
||||
{
|
||||
component: "Input",
|
||||
props: {
|
||||
id: id + "-email",
|
||||
label: "Email",
|
||||
type: "email",
|
||||
placeholder: "you@example.com",
|
||||
},
|
||||
},
|
||||
{
|
||||
component: "Alert",
|
||||
props: {
|
||||
title: "Design preview",
|
||||
description: "This page uses your selected design system.",
|
||||
},
|
||||
},
|
||||
{
|
||||
component: "Button",
|
||||
props: { label: id === "account" ? "Save profile" : "Send request", variant: "default" },
|
||||
},
|
||||
],
|
||||
};
|
||||
draft = (await server.call("wrnexus_design_page", {
|
||||
baseRevision: draft.revision,
|
||||
page,
|
||||
})) as typeof draft;
|
||||
for (const mode of ["light", "dark"]) {
|
||||
const preview = (await server.call("wrnexus_design_preview", { pageId: id, mode })) as {
|
||||
html: string;
|
||||
};
|
||||
writeFileSync(join(directory, `${id}-${mode}.html`), preview.html);
|
||||
}
|
||||
}
|
||||
const exported = (await server.call("wrnexus_design_export")) as {
|
||||
pages: Record<string, string>;
|
||||
artifacts: Record<string, string>;
|
||||
};
|
||||
for (const [filename, source] of Object.entries(exported.pages)) {
|
||||
writeFileSync(join(directory, filename), source);
|
||||
const conformance = checkDesignConformance(source);
|
||||
const compiled = compileWrnFile(source);
|
||||
if (!conformance.ok || !compiled) throw Error("Agent evaluation failed.");
|
||||
report.push({
|
||||
system: name,
|
||||
page: filename,
|
||||
compiled: true,
|
||||
conformance,
|
||||
modes: ["light", "dark"],
|
||||
preview: "Trusted shipped SSR components; static CSP-protected HTML.",
|
||||
});
|
||||
}
|
||||
for (const [name, body] of Object.entries(exported.artifacts))
|
||||
writeFileSync(join(directory, name + ".txt"), body);
|
||||
}
|
||||
writeFileSync(
|
||||
join(output, "report.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
passed: true,
|
||||
pages: report.length,
|
||||
previews: report.length * 2,
|
||||
evaluations: report,
|
||||
limits: [
|
||||
"No LLM call. This exercises the agent tool protocol deterministically.",
|
||||
"Static checks and SSR previews do not certify all accessibility, dynamic behavior or visual quality.",
|
||||
"Generated pages and design artifacts require explicit application integration.",
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
`Agent evaluation passed: ${report.length} pages and ${report.length * 2} previews. ${output}`,
|
||||
);
|
||||
@@ -81,6 +81,9 @@ export function validateStagedManifest(stage: string, manifest: Record<string, u
|
||||
...manifestTargets(manifest.types),
|
||||
...manifestTargets(manifest.bin),
|
||||
...manifestTargets(manifest.exports),
|
||||
...((manifest.wrnexus as any)?.clientRuntimes ?? []).flatMap((runtime: { entry?: string }) =>
|
||||
manifestTargets(runtime.entry),
|
||||
),
|
||||
];
|
||||
for (const target of new Set(targets)) {
|
||||
if (!target.startsWith("./")) throw new Error(`${name} has unsafe package target ${target}.`);
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/** Package versions are independent of the framework release line. Release candidates use stable SemVer triples. */
|
||||
export function isIndependentReleaseVersion(value) {
|
||||
return (
|
||||
typeof value === "string" && /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/.test(value)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
/** Local release preparation only. Never authenticates to or publishes to a registry. */
|
||||
import {
|
||||
existsSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
rmSync,
|
||||
} from "node:fs";
|
||||
import { basename, dirname, join, resolve, sep } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { validateAndHashStage } from "./lib/package-integrity.ts";
|
||||
const root = resolve(import.meta.dir, "..");
|
||||
const output = join(root, "output", "design-system-phase-7");
|
||||
const stage = join(root, ".publish-design");
|
||||
const mode = process.argv[2] ?? "plan";
|
||||
const all = new Map(
|
||||
readdirSync(join(root, "packages")).flatMap((name) => {
|
||||
const file = join(root, "packages", name, "package.json");
|
||||
if (!existsSync(file)) return [];
|
||||
const manifest = JSON.parse(readFileSync(file, "utf8"));
|
||||
return [[manifest.name, { directory: name, ...manifest }]];
|
||||
}),
|
||||
);
|
||||
const names = new Set<string>();
|
||||
function visit(name: string) {
|
||||
if (names.has(name)) return;
|
||||
const pkg = all.get(name);
|
||||
if (!pkg) throw Error("Missing workspace package " + name);
|
||||
names.add(name);
|
||||
for (const dependency of Object.keys({
|
||||
...pkg.dependencies,
|
||||
...pkg.optionalDependencies,
|
||||
...pkg.peerDependencies,
|
||||
}))
|
||||
if (dependency.startsWith("@wrnexus/")) visit(dependency);
|
||||
}
|
||||
for (const name of ["@wrnexus/design-system", "@wrnexus/design-studio", "@wrnexus/mcp"])
|
||||
visit(name);
|
||||
mkdirSync(output, { recursive: true });
|
||||
const plan = {
|
||||
formatVersion: 1,
|
||||
stageDirectory: stage,
|
||||
packages: [...names].map((name) => ({ name, version: all.get(name).version })),
|
||||
schemaVersion: 1,
|
||||
contractVersion: 1,
|
||||
designPackageVersion: 1,
|
||||
registryAvailability: "not-checked",
|
||||
published: false,
|
||||
notes: [
|
||||
"Source versions remain unchanged. Confirm unused release versions before publication.",
|
||||
"Use the existing version/release process for production; this command only stages and packs locally.",
|
||||
"Production-wide API/visual/type gates must pass separately before publication.",
|
||||
],
|
||||
};
|
||||
writeFileSync(join(output, "release-plan.json"), JSON.stringify(plan, null, 2));
|
||||
function run(command: string, args: string[], cwd = root) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
maxBuffer: 20 * 1024 * 1024,
|
||||
env: { ...process.env, WRNEXUS_DESIGN_REPORT: join(output, "consumer-report.json") },
|
||||
});
|
||||
if (result.status !== 0)
|
||||
throw Error(
|
||||
`${basename(command)} failed: ${result.error?.message ?? result.stderr ?? result.stdout}`,
|
||||
);
|
||||
return result.stdout;
|
||||
}
|
||||
if (mode === "plan") {
|
||||
console.log(JSON.stringify(plan, null, 2));
|
||||
} else if (mode === "stage") {
|
||||
console.log(
|
||||
run(process.execPath, [
|
||||
"run",
|
||||
"scripts/publish-packages.ts",
|
||||
"design-system",
|
||||
"design-studio",
|
||||
"mcp",
|
||||
"--with-dependencies",
|
||||
"--stage-root=.publish-design",
|
||||
]),
|
||||
);
|
||||
} else if (mode === "check") {
|
||||
writeFileSync(
|
||||
join(output, "consumer-report.json"),
|
||||
JSON.stringify({ packagingVerified: false, published: false, status: "running" }, null, 2),
|
||||
);
|
||||
const integrity = JSON.parse(readFileSync(join(stage, "PACKAGE-INTEGRITY.json"), "utf8"));
|
||||
if (
|
||||
integrity.packages.length !== names.size ||
|
||||
integrity.packages.some(
|
||||
(pkg: { name: string; version: string }) =>
|
||||
!names.has(pkg.name) || pkg.version !== all.get(pkg.name)?.version,
|
||||
)
|
||||
)
|
||||
throw Error("Staged closure differs from release plan. Restage all design packages.");
|
||||
const node = Bun.which("node");
|
||||
if (!node) throw Error("Node.js with npm is required to verify tarballs.");
|
||||
const npm = join(dirname(node), "node_modules", "npm", "bin", "npm-cli.js");
|
||||
if (!existsSync(npm)) throw Error("Unable to locate npm CLI.");
|
||||
const tarballs = join(output, "tarballs");
|
||||
mkdirSync(tarballs, { recursive: true });
|
||||
const dependencies: Record<string, string> = {};
|
||||
const packed = [];
|
||||
for (const pkg of integrity.packages) {
|
||||
const folder = join(stage, pkg.name.slice("@wrnexus/".length));
|
||||
const manifest = JSON.parse(readFileSync(join(folder, "package.json"), "utf8"));
|
||||
const actual = validateAndHashStage(folder, manifest);
|
||||
if (JSON.stringify(actual) !== JSON.stringify(pkg))
|
||||
throw Error("Staged integrity mismatch for " + pkg.name);
|
||||
const files = JSON.parse(
|
||||
run(node, [
|
||||
npm,
|
||||
"pack",
|
||||
folder,
|
||||
"--pack-destination",
|
||||
tarballs,
|
||||
"--json",
|
||||
"--ignore-scripts",
|
||||
]),
|
||||
);
|
||||
dependencies[pkg.name] = "file:" + join(tarballs, files[0].filename).replace(/\\/g, "/");
|
||||
packed.push({
|
||||
name: pkg.name,
|
||||
version: manifest.version,
|
||||
filename: files[0].filename,
|
||||
integrity: files[0].integrity,
|
||||
});
|
||||
}
|
||||
writeFileSync(join(output, "tarballs.json"), JSON.stringify(packed, null, 2));
|
||||
const consumer = mkdtempSync(join(tmpdir(), "wrnexus-design-release-"));
|
||||
try {
|
||||
writeFileSync(
|
||||
join(consumer, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "design-release-consumer",
|
||||
private: true,
|
||||
type: "module",
|
||||
dependencies,
|
||||
}),
|
||||
);
|
||||
console.log(
|
||||
run(node, [npm, "install", "--ignore-scripts", "--no-audit", "--no-fund"], consumer),
|
||||
);
|
||||
writeFileSync(
|
||||
join(consumer, "probe.ts"),
|
||||
readFileSync(join(root, "scripts", "design-release-consumer.ts"), "utf8"),
|
||||
);
|
||||
console.log(run(process.execPath, ["probe.ts"], consumer));
|
||||
writeFileSync(
|
||||
join(consumer, "types.ts"),
|
||||
'import {CORE_DESIGN_SYSTEMS} from "@wrnexus/design-system"; import {createDesignPackage} from "@wrnexus/design-system/package"; import {StudioSession} from "@wrnexus/design-studio"; import {planDesignUpgrade} from "@wrnexus/design-studio/upgrades"; import {createFrameworkMcpServer} from "@wrnexus/mcp"; const session = new StudioSession(CORE_DESIGN_SYSTEMS.square); const artifact = await createDesignPackage(session.valid); await planDesignUpgrade(session, JSON.stringify(artifact)); createFrameworkMcpServer(".", {design:true,designWrite:true}); const recovery: string | undefined = session.recoverySource; console.log(recovery);',
|
||||
);
|
||||
writeFileSync(
|
||||
join(consumer, "tsconfig.json"),
|
||||
JSON.stringify({
|
||||
compilerOptions: {
|
||||
noEmit: true,
|
||||
module: "ESNext",
|
||||
moduleResolution: "bundler",
|
||||
target: "ESNext",
|
||||
skipLibCheck: true,
|
||||
types: [],
|
||||
lib: ["ESNext", "DOM"],
|
||||
},
|
||||
files: ["types.ts"],
|
||||
}),
|
||||
);
|
||||
run(
|
||||
node,
|
||||
[join(root, "node_modules", "typescript", "bin", "tsc"), "--project", "tsconfig.json"],
|
||||
consumer,
|
||||
);
|
||||
console.log("Installed package declarations passed consumer type checking.");
|
||||
} finally {
|
||||
// The path comes exclusively from mkdtemp; verify its workspace-independent boundary before removal.
|
||||
if (
|
||||
!resolve(consumer).startsWith(resolve(tmpdir()) + sep) ||
|
||||
!basename(consumer).startsWith("wrnexus-design-release-")
|
||||
)
|
||||
console.error("Skipped cleanup: invalid consumer boundary.");
|
||||
else if (process.argv.includes("--keep-consumer")) {
|
||||
writeFileSync(
|
||||
join(output, "consumer-path.json"),
|
||||
JSON.stringify({ directory: consumer }, null, 2),
|
||||
);
|
||||
console.log("Retained installed consumer for local QA: " + consumer);
|
||||
} else rmSync(consumer, { recursive: true, force: true });
|
||||
}
|
||||
const solo = mkdtempSync(join(tmpdir(), "wrnexus-design-release-"));
|
||||
try {
|
||||
writeFileSync(
|
||||
join(solo, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "mcp-only-release-consumer",
|
||||
private: true,
|
||||
type: "module",
|
||||
dependencies: { "@wrnexus/mcp": dependencies["@wrnexus/mcp"] },
|
||||
}),
|
||||
);
|
||||
run(node, [npm, "install", "--ignore-scripts", "--no-audit", "--no-fund"], solo);
|
||||
writeFileSync(
|
||||
join(solo, "probe.ts"),
|
||||
'import assert from "node:assert/strict"; import {createFrameworkMcpServer} from "@wrnexus/mcp"; import {existsSync} from "node:fs"; assert.equal(existsSync("node_modules/@wrnexus/design-studio"),false); assert.equal(createFrameworkMcpServer(".").tools().length,8); await assert.rejects(createFrameworkMcpServer(".",{design:true}).call("wrnexus_design_context"),/WRN-MCP-DESIGN-INSTALL/); console.log("MCP-only install keeps default APIs without requiring Studio.");',
|
||||
);
|
||||
console.log(run(process.execPath, ["probe.ts"], solo));
|
||||
} finally {
|
||||
if (
|
||||
resolve(solo).startsWith(resolve(tmpdir()) + sep) &&
|
||||
basename(solo).startsWith("wrnexus-design-release-")
|
||||
)
|
||||
rmSync(solo, { recursive: true, force: true });
|
||||
}
|
||||
const reportPath = join(output, "consumer-report.json");
|
||||
const report = JSON.parse(readFileSync(reportPath, "utf8"));
|
||||
report.packagingVerified = true;
|
||||
report.checkedAt = new Date().toISOString();
|
||||
report.checks.push(
|
||||
"installed declaration consumer type check",
|
||||
"MCP-only installation without optional Studio peer",
|
||||
);
|
||||
writeFileSync(reportPath, JSON.stringify(report, null, 2));
|
||||
console.log("Local tarballs verified. No registry publication performed.");
|
||||
} else throw Error("Usage: bun scripts/prepare-design-release.ts plan | stage | check");
|
||||
@@ -0,0 +1,46 @@
|
||||
import { catalogFixtureDocument } from "../packages/ui/test/design-system-catalog-fixture.ts";
|
||||
import { CORE_DESIGN_SYSTEMS } from "../packages/design-system/src/index.ts";
|
||||
import { resolveThemeConfig, renderThemeCss } from "../packages/styles/src/theme.ts";
|
||||
import { designFixtureDocument } from "../packages/ui/test/design-system-fixture.ts";
|
||||
import { getComponentControllerRuntime } from "../packages/csr/src/index.ts";
|
||||
|
||||
const server = Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: Number(process.env.DESIGN_PREVIEW_PORT ?? 4318),
|
||||
async fetch(request) {
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname === "/__wrnexus/controllers.js")
|
||||
return new Response(getComponentControllerRuntime(), {
|
||||
headers: { "content-type": "text/javascript" },
|
||||
});
|
||||
const name = url.searchParams.get("system") ?? "square";
|
||||
const system = Object.hasOwn(CORE_DESIGN_SYSTEMS, name)
|
||||
? CORE_DESIGN_SYSTEMS[name as keyof typeof CORE_DESIGN_SYSTEMS]
|
||||
: undefined;
|
||||
if (!system) return new Response("Unknown design system", { status: 404 });
|
||||
if (url.pathname === "/theme.css")
|
||||
return new Response(renderThemeCss(resolveThemeConfig({ designSystem: { system } })), {
|
||||
headers: { "content-type": "text/css" },
|
||||
});
|
||||
if (url.pathname === "/catalog")
|
||||
return new Response(
|
||||
await catalogFixtureDocument(
|
||||
system,
|
||||
url.searchParams.get("mode") === "dark" ? "dark" : "light",
|
||||
Math.max(0, Math.min(6, Number(url.searchParams.get("page")) || 0)),
|
||||
),
|
||||
{ headers: { "content-type": "text/html; charset=utf-8" } },
|
||||
);
|
||||
if (url.pathname !== "/") return new Response("Not found", { status: 404 });
|
||||
const mode = url.searchParams.get("mode") === "dark" ? "dark" : "light";
|
||||
return new Response(
|
||||
await designFixtureDocument(
|
||||
system,
|
||||
mode,
|
||||
url.searchParams.get("delivery") === "linked" ? "linked" : "inline",
|
||||
),
|
||||
{ headers: { "content-type": "text/html; charset=utf-8" } },
|
||||
);
|
||||
},
|
||||
});
|
||||
console.log(`Design-system preview: ${server.url}`);
|
||||
@@ -24,14 +24,23 @@ import {
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { basename, dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import process from "node:process";
|
||||
import { validateAndHashStage, type StagedPackageIntegrity } from "./lib/package-integrity.ts";
|
||||
|
||||
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const packagesDir = join(repoRoot, "packages");
|
||||
const stageRoot = join(repoRoot, ".publish");
|
||||
const stageArgument =
|
||||
process.argv.find((arg) => arg.startsWith("--stage-root="))?.slice(13) ?? ".publish";
|
||||
const stageRoot = resolve(repoRoot, stageArgument);
|
||||
if (
|
||||
dirname(stageRoot) !== resolve(repoRoot) ||
|
||||
!/^\.publish(?:-[a-z0-9-]+)?$/.test(basename(stageRoot))
|
||||
)
|
||||
throw Error(
|
||||
"Stage root must be a .publish or .publish-NAME directory directly inside this workspace.",
|
||||
);
|
||||
const REGISTRY = process.env.WRNEXUS_NPM_REGISTRY ?? "https://registry.npmjs.org/";
|
||||
// First release is published PRIVATE ("restricted") so we can test before the
|
||||
// world sees it; flip to public with `npm access public @wrnexus/<name>` (or set
|
||||
@@ -44,6 +53,7 @@ const HOMEPAGE = "https://wrnexusjs.dev";
|
||||
|
||||
/** Extra (non-code) files each package must ship, relative to the package root. */
|
||||
const ASSETS: Record<string, string[]> = {
|
||||
"@wrnexus/design-studio": ["assets"],
|
||||
"@wrnexus/ui": [
|
||||
"components",
|
||||
"styles",
|
||||
@@ -97,7 +107,11 @@ function topoSort(pkgs: Pkg[]): Pkg[] {
|
||||
const visit = (p: Pkg) => {
|
||||
if (seen.has(p.name)) return;
|
||||
seen.add(p.name);
|
||||
for (const dep of Object.keys(p.manifest.dependencies ?? {})) {
|
||||
for (const dep of Object.keys({
|
||||
...p.manifest.dependencies,
|
||||
...p.manifest.optionalDependencies,
|
||||
...p.manifest.peerDependencies,
|
||||
})) {
|
||||
const d = byName.get(dep);
|
||||
if (d) visit(d);
|
||||
}
|
||||
@@ -121,6 +135,7 @@ function entriesOf(dir: string, m: Record<string, any>): Record<string, string>
|
||||
map[rel] = join(dir, v.replace(/^\.\//, "")).replace(/\\/g, "/");
|
||||
};
|
||||
add(m.main);
|
||||
for (const runtime of m.wrnexus?.clientRuntimes ?? []) add(runtime.entry);
|
||||
if (m.bin) Object.values(m.bin).forEach(add);
|
||||
if (m.exports) for (const v of Object.values(m.exports)) add(v);
|
||||
if (m.wrnexus?.plugin) {
|
||||
@@ -205,6 +220,8 @@ function publishManifest(
|
||||
out.exports = { ".": { types: out.types, import: out.main } };
|
||||
}
|
||||
|
||||
if (m.peerDependenciesMeta) out.peerDependenciesMeta = m.peerDependenciesMeta;
|
||||
|
||||
if (m.bin) {
|
||||
out.bin = {};
|
||||
for (const [k, v] of Object.entries(m.bin)) out.bin[k] = js(v as string);
|
||||
@@ -223,6 +240,11 @@ function publishManifest(
|
||||
|
||||
if (m.wrnexus) {
|
||||
out.wrnexus = structuredClone(m.wrnexus);
|
||||
for (const runtime of out.wrnexus.clientRuntimes ?? []) {
|
||||
if (typeof runtime.entry === "string" && runtime.entry.endsWith(".ts")) {
|
||||
runtime.entry = js(runtime.entry);
|
||||
}
|
||||
}
|
||||
const plugin = out.wrnexus?.plugin?.plugin;
|
||||
if (typeof plugin === "string" && plugin.endsWith(".ts")) {
|
||||
out.wrnexus.plugin.plugin = js(plugin);
|
||||
@@ -237,7 +259,9 @@ function publishManifest(
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const filter = process.argv.slice(2);
|
||||
const filter = process.argv
|
||||
.slice(2)
|
||||
.filter((arg) => !arg.startsWith("--stage-root=") && arg !== "--with-dependencies");
|
||||
const allPackages = readPackages([]);
|
||||
const workspaceVersions = new Map(
|
||||
allPackages.map((pkg) => [pkg.name, String(pkg.manifest.version)]),
|
||||
@@ -247,6 +271,22 @@ async function main() {
|
||||
(pkg) => filter.includes(pkg.dir.split(/[\\/]/).pop()!) || filter.includes(pkg.name),
|
||||
)
|
||||
: allPackages;
|
||||
if (process.argv.includes("--with-dependencies")) {
|
||||
const visited = new Set(selected.map((pkg) => pkg.name));
|
||||
for (let index = 0; index < selected.length; index++) {
|
||||
for (const name of Object.keys({
|
||||
...selected[index]!.manifest.dependencies,
|
||||
...selected[index]!.manifest.optionalDependencies,
|
||||
...selected[index]!.manifest.peerDependencies,
|
||||
})) {
|
||||
if (!name.startsWith("@wrnexus/") || visited.has(name)) continue;
|
||||
const dependency = allPackages.find((pkg) => pkg.name === name);
|
||||
if (!dependency) throw Error(`Missing workspace dependency ${name}.`);
|
||||
visited.add(name);
|
||||
selected.push(dependency);
|
||||
}
|
||||
}
|
||||
}
|
||||
const pkgs = topoSort(selected);
|
||||
if (!pkgs.length) {
|
||||
console.error("No @wrnexus packages matched.");
|
||||
@@ -265,7 +305,7 @@ async function main() {
|
||||
entry: entries,
|
||||
outDir: join(p.dir, "dist"),
|
||||
format: ["esm"],
|
||||
dts: true,
|
||||
dts: { compilerOptions: { ignoreDeprecations: "6.0" } },
|
||||
external: [
|
||||
/^@wrnexus\//,
|
||||
/^bun:/,
|
||||
@@ -307,9 +347,20 @@ async function main() {
|
||||
for (const a of packageAssets(p.manifest)) {
|
||||
if (existsSync(join(p.dir, a))) cpSync(join(p.dir, a), join(stage, a), { recursive: true });
|
||||
}
|
||||
if (p.name === "@wrnexus/design-studio") {
|
||||
const browser = await Bun.build({
|
||||
entrypoints: [join(p.dir, "src", "client.ts")],
|
||||
target: "browser",
|
||||
minify: true,
|
||||
});
|
||||
if (!browser.success || browser.outputs.length !== 1)
|
||||
throw Error("Studio browser asset build failed.");
|
||||
mkdirSync(join(stage, "assets"), { recursive: true });
|
||||
writeFileSync(join(stage, "assets", "studio-client.js"), await browser.outputs[0]!.text());
|
||||
}
|
||||
const manifest = publishManifest(p.manifest, p.manifest.version, workspaceVersions);
|
||||
writeFileSync(join(stage, "package.json"), JSON.stringify(manifest, null, 2) + "\n");
|
||||
if (p.manifest.wrnexus && !manifest.wrnexus?.plugin?.plugin) {
|
||||
if (p.manifest.wrnexus?.plugin && !manifest.wrnexus?.plugin?.plugin) {
|
||||
throw new Error(`${p.name} lost its wrnexus.plugin metadata while staging.`);
|
||||
}
|
||||
for (const asset of packageAssets(p.manifest)) {
|
||||
@@ -347,11 +398,13 @@ async function main() {
|
||||
);
|
||||
|
||||
console.log(
|
||||
`\n✓ Staged and verified ${order.length} package(s) under .publish/ (access: ${ACCESS})`,
|
||||
`\n✓ Staged and verified ${order.length} package(s) under ${basename(stageRoot)}/ (access: ${ACCESS})`,
|
||||
);
|
||||
console.log("Publish order (deps first):");
|
||||
for (const n of order) {
|
||||
console.log(` npm publish .publish/${n.replace("@wrnexus/", "")} --access ${ACCESS}`);
|
||||
console.log(
|
||||
` npm publish ${basename(stageRoot)}/${n.replace("@wrnexus/", "")} --access ${ACCESS}`,
|
||||
);
|
||||
}
|
||||
if (ACCESS === "restricted") {
|
||||
console.log("\nAfter testing, flip every package to public with:");
|
||||
|
||||
@@ -0,0 +1,562 @@
|
||||
/** Repeatable catalog hook/contract generation. Never rewrites behavior or existing attributes. */
|
||||
import { readFileSync, readdirSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parse } from "../packages/compiler/src/index.ts";
|
||||
import { CORE_COMPONENT_CONTRACTS } from "../packages/design-system/src/core-contracts.ts";
|
||||
import { CATALOG_PART_PROPERTIES } from "../packages/design-system/src/contract-properties.ts";
|
||||
|
||||
const root = resolve(fileURLToPath(new URL("..", import.meta.url)));
|
||||
const apply = process.argv.includes("--write");
|
||||
const directory = join(root, "packages/ui/components");
|
||||
type ElementNode = {
|
||||
type: "element";
|
||||
tag: string;
|
||||
attrs: Array<{ name: string; value: string; event?: boolean }>;
|
||||
children: unknown[];
|
||||
};
|
||||
type Profile = keyof typeof CATALOG_PART_PROPERTIES;
|
||||
const providerParts: Record<string, Record<string, string>> = {
|
||||
Chart: {
|
||||
canvas: "Chart.js draws canvas pixels; use the existing chart options/data API.",
|
||||
accessible: "Runtime-generated accessible data table is owned by the chart adapter.",
|
||||
},
|
||||
DrawflowCanvas: {
|
||||
viewport:
|
||||
"Drawflow owns generated graph DOM and its styles. Use the existing provider options and CSS integration.",
|
||||
},
|
||||
Map: { embeddedFrame: "Embedded map document is cross-origin and owned by the map provider." },
|
||||
};
|
||||
const familyNames: Record<string, string[]> = {
|
||||
forms: [
|
||||
"AdvancedSelect",
|
||||
"ColorPicker",
|
||||
"ComboBox",
|
||||
"DatePicker",
|
||||
"FileInput",
|
||||
"FileUploadProgress",
|
||||
"InputGroup",
|
||||
"InputNumber",
|
||||
"PinInput",
|
||||
"RangeSlider",
|
||||
"SearchBox",
|
||||
"StrongPassword",
|
||||
"TimePicker",
|
||||
"TogglePassword",
|
||||
],
|
||||
navigation: [
|
||||
"BackToTop",
|
||||
"Breadcrumb",
|
||||
"Dropdown",
|
||||
"Footer",
|
||||
"Link",
|
||||
"MegaMenu",
|
||||
"Navbar",
|
||||
"Pagination",
|
||||
"PreferenceSwitcher",
|
||||
"Scrollspy",
|
||||
"Sidebar",
|
||||
"TextLink",
|
||||
],
|
||||
layout: [
|
||||
"AuthSplitLayout",
|
||||
"CTASection",
|
||||
"FeatureGrid",
|
||||
"Grid",
|
||||
"Hero",
|
||||
"HeroActions",
|
||||
"LayoutSplitter",
|
||||
"Marquee",
|
||||
"MarketingSectionHeader",
|
||||
"MetricGrid",
|
||||
"PublicPageShell",
|
||||
"Section",
|
||||
"SectionHeader",
|
||||
"SplitHero",
|
||||
],
|
||||
typography: ["Typography", "Kbd", "Blockquote"],
|
||||
media: ["Avatar", "AvatarGroup", "DeviceFrame", "Image"],
|
||||
providers: ["Chart", "DrawflowCanvas", "Map"],
|
||||
editors: ["FlowCanvas", "WysiwygEditor"],
|
||||
feedback: ["Skeleton", "Spinner", "Tooltip", "Progress", "Toaster", "LegendIndicator", "Rating"],
|
||||
};
|
||||
const family = (name: string) =>
|
||||
Object.entries(familyNames).find(([, names]) => names.includes(name))?.[0] ?? "collections";
|
||||
const camel = (value: string) =>
|
||||
value
|
||||
.replace(/[-_]([a-z0-9])/g, (_, letter: string) => letter.toUpperCase())
|
||||
.replace(/[^a-zA-Z0-9]/g, "");
|
||||
const hooks = /^data-wrn-(?:owner|part|component|axis-[\w-]+)$/;
|
||||
function stripHooks(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(stripHooks);
|
||||
if (value && typeof value === "object")
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, entry]) => [
|
||||
key,
|
||||
key === "attrs" && Array.isArray(entry)
|
||||
? entry.filter((attr) => !hooks.test(attr.name))
|
||||
: stripHooks(entry),
|
||||
]),
|
||||
);
|
||||
return value;
|
||||
}
|
||||
function elements(
|
||||
value: unknown,
|
||||
depth = 0,
|
||||
output: Array<{ node: ElementNode; root: boolean }> = [],
|
||||
): typeof output {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) elements(item, depth, output);
|
||||
return output;
|
||||
}
|
||||
if (!value || typeof value !== "object") return output;
|
||||
const record = value as Record<string, unknown>;
|
||||
if (record.type === "element") {
|
||||
const node = value as ElementNode;
|
||||
output.push({ node, root: depth === 0 });
|
||||
elements(node.children, depth + 1, output);
|
||||
} else
|
||||
for (const [key, entry] of Object.entries(record))
|
||||
if (key !== "attrs") elements(entry, depth, output);
|
||||
return output;
|
||||
}
|
||||
/** Scan tags without treating > inside quotes or brace expressions as an end marker. */
|
||||
function openingTags(
|
||||
source: string,
|
||||
start: number,
|
||||
end: number,
|
||||
): Array<{ start: number; end: number; tag: string; raw: string }> {
|
||||
const result = [];
|
||||
for (let index = start; index < end; index++) {
|
||||
if (source.startsWith("<!--", index)) {
|
||||
index = source.indexOf("-->", index) + 2;
|
||||
continue;
|
||||
}
|
||||
if (source[index] !== "<" || !/[A-Za-z]/.test(source[index + 1] ?? "")) continue;
|
||||
const match = /^<([A-Za-z][\w.-]*)\b/.exec(source.slice(index));
|
||||
if (!match) continue;
|
||||
let quote = "",
|
||||
braces = 0,
|
||||
finish = index + match[0].length;
|
||||
for (; finish < end; finish++) {
|
||||
const char = source[finish]!;
|
||||
if (quote) {
|
||||
if (char === "\\") {
|
||||
finish++;
|
||||
continue;
|
||||
}
|
||||
if (char === quote) quote = "";
|
||||
continue;
|
||||
}
|
||||
if (char === '"' || char === "'" || char === "`") {
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
if (char === "{") braces++;
|
||||
else if (char === "}") braces--;
|
||||
else if (char === ">" && braces === 0) break;
|
||||
}
|
||||
if (finish >= end) throw Error("Unclosed view tag");
|
||||
result.push({
|
||||
start: index,
|
||||
end: finish + 1,
|
||||
tag: match[1]!,
|
||||
raw: source.slice(index, finish + 1),
|
||||
});
|
||||
index = finish;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
const catalog: Record<string, unknown> = {};
|
||||
const snapshots: Record<string, string> = {};
|
||||
const pendingWrites: Array<{ path: string; source: string }> = [];
|
||||
for (const file of readdirSync(directory)
|
||||
.filter((file) => file.endsWith(".wrn"))
|
||||
.sort()) {
|
||||
const path = `packages/ui/components/${file}`,
|
||||
current = readFileSync(join(root, path), "utf8");
|
||||
const nameMatch = /\bcomponent\s+(\w+)/.exec(current);
|
||||
if (nameMatch && Object.hasOwn(CORE_COMPONENT_CONTRACTS, nameMatch[1]!)) continue;
|
||||
// Remove only this generator's attributes before regenerating; leave all original bytes intact.
|
||||
const source = current.replace(
|
||||
/ data-wrn-(?:owner|part|component|axis-[\w-]+)=(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')/g,
|
||||
"",
|
||||
);
|
||||
const ast = parse(source);
|
||||
const name = ast.name;
|
||||
if (!name || Object.hasOwn(CORE_COMPONENT_CONTRACTS, name)) continue;
|
||||
const view = /\bview\s*\{/.exec(source),
|
||||
style = /^\s*style\s*\{/m.exec(source.slice((view?.index ?? 0) + (view?.[0].length ?? 0)));
|
||||
if (!view || !style) throw Error(`${name}: missing view/style boundary`);
|
||||
const start = view.index + view[0].length,
|
||||
end = start + style.index;
|
||||
const nodes = elements(ast.view),
|
||||
tags = openingTags(source, start, end);
|
||||
if (tags.length !== nodes.length || tags.some((tag, index) => tag.tag !== nodes[index]?.node.tag))
|
||||
throw Error(`${name}: scanner/parser tag mismatch (${tags.length}/${nodes.length})`);
|
||||
const props = ast.props ?? [];
|
||||
const axes: Record<string, { default: string; values: string[] }> = {};
|
||||
for (const prop of props)
|
||||
if (
|
||||
[
|
||||
"variant",
|
||||
"size",
|
||||
"color",
|
||||
"radius",
|
||||
"shadow",
|
||||
"pill",
|
||||
"shape",
|
||||
"rounded",
|
||||
"bordered",
|
||||
"flush",
|
||||
"compact",
|
||||
"dense",
|
||||
"separated",
|
||||
].includes(prop.name) &&
|
||||
prop.default !== undefined
|
||||
) {
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(prop.default);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (typeof value !== "string" && typeof value !== "boolean") continue;
|
||||
const values = new Set([String(value)]);
|
||||
for (const match of source.matchAll(
|
||||
new RegExp(
|
||||
`(?:wrn-component--${prop.name}-|wrn-[a-z-]+--${prop.name}-)([a-z0-9-]+)|data-${prop.name}=["']([a-z0-9-]+)["']`,
|
||||
"g",
|
||||
),
|
||||
))
|
||||
values.add(match[1] ?? match[2]!);
|
||||
if (typeof value === "boolean") {
|
||||
values.add("false");
|
||||
values.add("true");
|
||||
}
|
||||
axes[prop.name] = { default: String(value), values: [...values].sort() };
|
||||
}
|
||||
const rootClass = nodes
|
||||
.filter((item) => item.root)
|
||||
.flatMap((item) =>
|
||||
item.node.attrs.filter((attr) => attr.name === "class").map((attr) => attr.value),
|
||||
)
|
||||
.join(" ");
|
||||
const baseClass =
|
||||
(rootClass.match(/\bwrn-(?!component\b|visually\b)[\w-]+/g) ?? []).find(
|
||||
(value) => !value.includes("--color-") && !value.includes("--size-"),
|
||||
) ?? "";
|
||||
const stem = baseClass.replace(/^wrn-next--|^wrn-/, "");
|
||||
const parts: Record<
|
||||
string,
|
||||
{
|
||||
elements: string[];
|
||||
states: Record<string, string>;
|
||||
profile: Profile;
|
||||
ownership: "framework" | "provider" | "content" | "native";
|
||||
role: string;
|
||||
properties: readonly string[];
|
||||
shapeBinding: boolean;
|
||||
explicitAxes: Record<string, string>;
|
||||
}
|
||||
> = {};
|
||||
const regions: Record<string, { ownership: string; parts: string[]; limitations: string }> = {};
|
||||
const edits: Array<{ position: number; text: string }> = [];
|
||||
for (let index = 0; index < nodes.length; index++) {
|
||||
const { node, isRoot } = { node: nodes[index]!.node, isRoot: nodes[index]!.root };
|
||||
const tag = tags[index]!;
|
||||
if (
|
||||
node.tag[0] !== node.tag[0]?.toLowerCase() ||
|
||||
[
|
||||
"slot",
|
||||
"path",
|
||||
"line",
|
||||
"rect",
|
||||
"circle",
|
||||
"polyline",
|
||||
"polygon",
|
||||
"defs",
|
||||
"stop",
|
||||
"linearGradient",
|
||||
"source",
|
||||
"option",
|
||||
"script",
|
||||
"style",
|
||||
].includes(node.tag)
|
||||
)
|
||||
continue;
|
||||
const attributes = Object.fromEntries(
|
||||
node.attrs.filter((attr) => !attr.event).map((attr) => [attr.name, attr.value]),
|
||||
);
|
||||
const classes = (attributes.class ?? "").match(/\bwrn-[\w-]+/g) ?? [];
|
||||
const bem = classes.find((value) => value.includes("__"));
|
||||
const flat = classes.find(
|
||||
(value) =>
|
||||
baseClass && value.startsWith(baseClass + "-") && !value.startsWith(baseClass + "--"),
|
||||
);
|
||||
let suffix =
|
||||
bem?.split("__")[1]?.replace(new RegExp(`^${stem}-`), "") ??
|
||||
flat?.slice(baseClass.length + 1);
|
||||
if (suffix?.includes("--")) suffix = suffix.split("--")[0];
|
||||
let part = isRoot
|
||||
? "root"
|
||||
: suffix
|
||||
? camel(suffix)
|
||||
: node.tag === "canvas"
|
||||
? "canvas"
|
||||
: node.tag === "iframe"
|
||||
? "embeddedFrame"
|
||||
: node.tag === "svg"
|
||||
? "vector"
|
||||
: ["input", "textarea", "select"].includes(node.tag)
|
||||
? "control"
|
||||
: node.tag === "button"
|
||||
? attributes["aria-expanded"] !== undefined
|
||||
? "trigger"
|
||||
: "button"
|
||||
: node.tag === "a"
|
||||
? "link"
|
||||
: /^h[1-6]$/.test(node.tag)
|
||||
? "title"
|
||||
: node.tag === "label"
|
||||
? "label"
|
||||
: undefined;
|
||||
if (Object.hasOwn(attributes, "data-drawflow-host")) part = "viewport";
|
||||
if (!part) continue;
|
||||
const interactive =
|
||||
["button", "input", "select", "textarea", "a"].includes(node.tag) ||
|
||||
Object.hasOwn(attributes, "tabindex");
|
||||
let profile: Profile = isRoot
|
||||
? family(name) === "layout"
|
||||
? "layout"
|
||||
: ["typography", "feedback"].includes(family(name))
|
||||
? "text"
|
||||
: "layout"
|
||||
: /^h[1-6]$/.test(node.tag) ||
|
||||
/^(title|label|description|text|copy|caption|heading|eyebrow|help|hint|error|summary|value|meta|name|breadcrumb)/i.test(
|
||||
part,
|
||||
)
|
||||
? "text"
|
||||
: interactive
|
||||
? "control"
|
||||
: /icon|spinner|indicator|dot|badgeMark|arrow|progressFill|track|gridPattern|decoration/i.test(
|
||||
part,
|
||||
)
|
||||
? "decoration"
|
||||
: /panel|surface|popover|menu|dialog|item|node$|card|tile|frame|content$|body$/i.test(
|
||||
part,
|
||||
)
|
||||
? "surface"
|
||||
: "layout";
|
||||
if (["img", "picture", "video"].includes(node.tag)) profile = "media";
|
||||
// Keep quantitative and provider-rendered geometry outside CSS recipes.
|
||||
let ownership: "framework" | "provider" | "content" | "native" = "framework";
|
||||
if (
|
||||
["canvas", "iframe", "svg"].includes(node.tag) ||
|
||||
attributes.type === "hidden" ||
|
||||
/sr-only|visually-hidden/.test(attributes.class ?? "")
|
||||
)
|
||||
profile = "managed";
|
||||
if (["canvas", "svg"].includes(node.tag))
|
||||
regions[part] = {
|
||||
ownership: "framework",
|
||||
parts: [part],
|
||||
limitations:
|
||||
"Rendered pixels and vector geometry are owned by the existing renderer; recipes style surrounding parts only.",
|
||||
};
|
||||
if (providerParts[name]?.[part]) {
|
||||
profile = "managed";
|
||||
ownership = "provider";
|
||||
regions[part] = { ownership, parts: [part], limitations: providerParts[name]![part]! };
|
||||
}
|
||||
if (node.tag === "iframe") {
|
||||
profile = "managed";
|
||||
ownership = "provider";
|
||||
regions[part] = {
|
||||
ownership,
|
||||
parts: [part],
|
||||
limitations: "Cross-origin embedded content cannot be styled through this contract.",
|
||||
};
|
||||
}
|
||||
if (
|
||||
["range", "color", "file", "date", "time"].includes(attributes.type ?? "") ||
|
||||
node.tag === "select"
|
||||
) {
|
||||
ownership = "native";
|
||||
profile = "text";
|
||||
regions[part] = {
|
||||
ownership,
|
||||
parts: [part],
|
||||
limitations:
|
||||
"Only the outer native control is styled; picker dialogs, option popups, tracks and thumbs remain native or existing component CSS.",
|
||||
};
|
||||
}
|
||||
if (Object.hasOwn(attributes, "contenteditable")) {
|
||||
ownership = "content";
|
||||
profile = "text";
|
||||
regions[part] = {
|
||||
ownership,
|
||||
parts: [part],
|
||||
limitations:
|
||||
"Styles apply to the editor container, never rewrite the user-authored document or formatting.",
|
||||
};
|
||||
}
|
||||
// A root frame is a surface only if its own base selector declares geometry.
|
||||
if (isRoot && baseClass) {
|
||||
const escaped = baseClass;
|
||||
const cssBlock = new RegExp(`\\.${escaped}\\s*\\{([^}]*)\\}`, "g");
|
||||
const ownCss = [...source.slice(end).matchAll(cssBlock)].map((match) => match[1]).join(" ");
|
||||
if (
|
||||
/border(?:-radius)?\s*:|box-shadow\s*:/.test(ownCss) &&
|
||||
!["layout", "typography", "feedback"].includes(family(name))
|
||||
)
|
||||
profile = "surface";
|
||||
if (/border-radius\s*:/.test(ownCss) && ["Skeleton", "Tooltip", "Progress"].includes(name))
|
||||
profile = "surface";
|
||||
}
|
||||
if (
|
||||
node.tag === "canvas" ||
|
||||
node.tag === "iframe" ||
|
||||
node.tag === "svg" ||
|
||||
attributes.type === "hidden" ||
|
||||
/sr-only|visually-hidden/.test(attributes.class ?? "")
|
||||
)
|
||||
profile = "managed";
|
||||
const states: Record<string, string> = { hover: ":hover" };
|
||||
if (interactive) Object.assign(states, { focusVisible: ":focus-visible", active: ":active" });
|
||||
if (
|
||||
["input", "button", "select", "textarea"].includes(node.tag) ||
|
||||
Object.hasOwn(attributes, "aria-disabled")
|
||||
)
|
||||
states.disabled = ':is(:disabled,[aria-disabled="true"])';
|
||||
if (Object.hasOwn(attributes, "aria-invalid") || Object.hasOwn(attributes, "data-invalid"))
|
||||
states.invalid = ':is([aria-invalid="true"],[data-invalid="true"])';
|
||||
if (Object.hasOwn(attributes, "readonly") || Object.hasOwn(attributes, "aria-readonly"))
|
||||
states.readonly = ':is([readonly],[aria-readonly="true"])';
|
||||
if (["checkbox", "radio"].includes(attributes.type ?? "")) {
|
||||
states.checked = ":checked";
|
||||
states.unchecked = ":not(:checked)";
|
||||
if (attributes.type === "checkbox") states.indeterminate = ":indeterminate";
|
||||
}
|
||||
if (Object.hasOwn(attributes, "aria-expanded") || Object.hasOwn(attributes, "data-open"))
|
||||
states.open = ':is([aria-expanded="true"],[data-open="true"])';
|
||||
if (
|
||||
Object.hasOwn(attributes, "aria-selected") ||
|
||||
Object.hasOwn(attributes, "data-selected") ||
|
||||
Object.hasOwn(attributes, "aria-current") ||
|
||||
Object.hasOwn(attributes, "data-active")
|
||||
)
|
||||
states.selected =
|
||||
':is([aria-selected="true"],[data-selected="true"],[aria-current="page"],[data-active="true"])';
|
||||
const explicitAxes: Record<string, string> = {};
|
||||
const axisAttrs = Object.keys(axes).map((axis) => {
|
||||
const original = attributes[`data-${axis}`];
|
||||
if (original && original !== `{${axis}}` && original.includes("{"))
|
||||
explicitAxes[axis] = original;
|
||||
const quote = original
|
||||
? (new RegExp(`\\bdata-${axis}\\s*=\\s*(["'])`).exec(tag.raw)?.[1] ?? '"')
|
||||
: '"';
|
||||
return `data-wrn-axis-${axis}=${quote}${original ?? `{${axis}}`}${quote}`;
|
||||
});
|
||||
const previous = parts[part];
|
||||
// Mixed structural roles sharing a part are conservatively limited to their intersection.
|
||||
const properties = previous
|
||||
? previous.properties.filter((property) =>
|
||||
(CATALOG_PART_PROPERTIES[profile] as readonly string[]).includes(property),
|
||||
)
|
||||
: CATALOG_PART_PROPERTIES[profile];
|
||||
parts[part] = {
|
||||
elements: [...new Set([...(previous?.elements ?? []), node.tag])],
|
||||
states: { ...previous?.states, ...states },
|
||||
profile: previous?.profile === profile ? profile : previous ? "layout" : profile,
|
||||
ownership: previous?.ownership ?? ownership,
|
||||
role:
|
||||
node.tag === "kbd" || node.tag === "code" || node.tag === "pre"
|
||||
? "code"
|
||||
: profile === "managed"
|
||||
? "managed"
|
||||
: profile,
|
||||
properties,
|
||||
shapeBinding:
|
||||
previous?.shapeBinding || !!attributes["data-shape"] || !!attributes["data-rounded"],
|
||||
explicitAxes: { ...previous?.explicitAxes, ...explicitAxes },
|
||||
};
|
||||
// Existing core hooks are never rewritten; generator is idempotent.
|
||||
if (attributes["data-wrn-part"] !== undefined) {
|
||||
if (attributes["data-wrn-part"] !== part || attributes["data-wrn-owner"] !== name)
|
||||
throw Error(`${name}: conflicting existing hook`);
|
||||
continue;
|
||||
}
|
||||
const attrs = ` data-wrn-owner="${name}" data-wrn-part="${part}"${isRoot ? ` data-wrn-component="${name}"` : ""}${axisAttrs.length ? " " + axisAttrs.join(" ") : ""}`;
|
||||
edits.push({ position: tag.end - (tag.raw.endsWith("/>") ? 2 : 1), text: attrs });
|
||||
}
|
||||
let output = source;
|
||||
for (const edit of edits.sort((a, b) => b.position - a.position))
|
||||
output = output.slice(0, edit.position) + edit.text + output.slice(edit.position);
|
||||
if (JSON.stringify(stripHooks(ast)) !== JSON.stringify(stripHooks(parse(output))))
|
||||
throw Error(`${name}: non-hook AST changed`);
|
||||
snapshots[path] = source;
|
||||
catalog[name] = {
|
||||
contractVersion: 1,
|
||||
component: name,
|
||||
source: path,
|
||||
family: family(name),
|
||||
support: "recipe-supported",
|
||||
axes,
|
||||
parts,
|
||||
slots: [
|
||||
...new Set(
|
||||
nodes
|
||||
.filter(({ node }) => node.tag === "slot")
|
||||
.map(({ node }) => node.attrs.find((attr) => attr.name === "name")?.value ?? "default"),
|
||||
),
|
||||
],
|
||||
tokens: [
|
||||
...new Set([...source.matchAll(/var\(\s*--wrn-([\w-]+)/g)].map((match) => match[1])),
|
||||
].sort(),
|
||||
regions,
|
||||
constraints: [
|
||||
"Only declared outer parts are styled. Existing events, markup, slots and state transitions are unchanged.",
|
||||
"Geometry, text scale and semantic color choices supplied by instance props retain precedence.",
|
||||
],
|
||||
fixtures: [
|
||||
"baseline",
|
||||
"square",
|
||||
"soft",
|
||||
"angular",
|
||||
"light",
|
||||
"dark",
|
||||
"narrow",
|
||||
"interaction-regression",
|
||||
],
|
||||
compatibility: { engine: 1, componentContract: 1 },
|
||||
};
|
||||
if (output !== current) pendingWrites.push({ path, source: output });
|
||||
}
|
||||
let serialized = JSON.stringify(
|
||||
catalog,
|
||||
(key, value) => {
|
||||
if (key === "properties" && Array.isArray(value))
|
||||
for (const [profile, properties] of Object.entries(CATALOG_PART_PROPERTIES))
|
||||
if (JSON.stringify(value) === JSON.stringify(properties)) return `__PROFILE_${profile}__`;
|
||||
return value;
|
||||
},
|
||||
2,
|
||||
);
|
||||
serialized = serialized.replace(/"__PROFILE_(\w+)__"/g, "CATALOG_PART_PROPERTIES.$1");
|
||||
const generated = `// Generated by scripts/rollout-design-contracts.ts. Review contract boundaries when changing markup.\nimport { CATALOG_PART_PROPERTIES } from './contract-properties.ts';\nexport const CATALOG_COMPONENT_CONTRACTS = ${serialized} as const;\n`;
|
||||
if (apply) {
|
||||
mkdirSync(join(root, ".tmp-tests"), { recursive: true });
|
||||
const snapshotPath = join(root, ".tmp-tests/design-phase-3-before.json");
|
||||
// Keep the first pre-migration snapshot for independent regression verification.
|
||||
try {
|
||||
readFileSync(snapshotPath);
|
||||
} catch {
|
||||
writeFileSync(snapshotPath, JSON.stringify(snapshots));
|
||||
}
|
||||
for (const item of pendingWrites) writeFileSync(join(root, item.path), item.source);
|
||||
writeFileSync(join(root, "packages/design-system/src/catalog-contracts.ts"), generated);
|
||||
}
|
||||
console.log(
|
||||
`${apply ? "Wrote" : "Audited"} ${Object.keys(catalog).length} catalog contracts; every source passed non-hook AST preservation.`,
|
||||
);
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isIndependentReleaseVersion } from "./lib/package-version.mjs";
|
||||
import console from "node:console";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
@@ -79,8 +80,8 @@ const manifests = trackedFiles
|
||||
.filter(({ value }) => value.name?.startsWith("@wrnexus/"));
|
||||
addCheck(
|
||||
"SEC-PACKAGE-VERSION",
|
||||
manifests.every(({ value }) => /^0\.8\.\d+$/.test(value.version)),
|
||||
"Every @wrnexus package must use a valid independent 0.8 version.",
|
||||
manifests.every(({ value }) => isIndependentReleaseVersion(value.version)),
|
||||
"Every @wrnexus package must use a valid independent release version.",
|
||||
);
|
||||
addCheck(
|
||||
"SEC-PRIVATE-PUBLISH",
|
||||
|
||||
@@ -32,6 +32,20 @@ try {
|
||||
throw new Error("Safe stage did not produce deterministic SHA-256 records.");
|
||||
}
|
||||
|
||||
const runtimeManifest = {
|
||||
...manifest,
|
||||
wrnexus: { clientRuntimes: [{ id: "chartjs", entry: "./dist/chart-runtime.js" }] },
|
||||
};
|
||||
let missingRuntimeRejected = false;
|
||||
try {
|
||||
validateAndHashStage(safe, runtimeManifest);
|
||||
} catch (error) {
|
||||
missingRuntimeRejected = String(error).includes("chart-runtime.js");
|
||||
}
|
||||
if (!missingRuntimeRejected) throw new Error("Missing client runtime was not rejected.");
|
||||
writeFileSync(join(safe, "dist", "chart-runtime.js"), "export {};\n");
|
||||
validateAndHashStage(safe, runtimeManifest);
|
||||
|
||||
const forbidden = join(root, "forbidden");
|
||||
mkdirSync(forbidden);
|
||||
writeFileSync(join(forbidden, ".env"), "TOKEN=secret\n");
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import console from "node:console";
|
||||
import assert from "node:assert/strict";
|
||||
import { isIndependentReleaseVersion } from "./lib/package-version.mjs";
|
||||
for (const version of ["0.1.0", "0.1.51", "0.8.117", "1.0.0"])
|
||||
assert.equal(isIndependentReleaseVersion(version), true, version);
|
||||
for (const version of [
|
||||
"0.01.0",
|
||||
"01.0.0",
|
||||
"0.8",
|
||||
"1.2.3.4",
|
||||
"1.2.3-beta",
|
||||
"workspace:*",
|
||||
"garbage",
|
||||
null,
|
||||
])
|
||||
assert.equal(isIndependentReleaseVersion(version), false, String(version));
|
||||
console.log("Independent release version policy probes passed.");
|
||||
@@ -86,6 +86,13 @@ try {
|
||||
dependencies[name] = viewer.devDependencies[name];
|
||||
}
|
||||
}
|
||||
// The all-package probe imports the optional React adapter explicitly.
|
||||
// Ordinary framework consumers still do not need React installed.
|
||||
if (stagedPackageNames.has("@wrnexus/react")) {
|
||||
const adapter = JSON.parse(readFileSync(join(root, "packages/react/package.json"), "utf8"));
|
||||
dependencies.react = adapter.peerDependencies.react;
|
||||
dependencies["react-dom"] = adapter.peerDependencies["react-dom"];
|
||||
}
|
||||
writeFileSync(
|
||||
join(consumer, "package.json"),
|
||||
JSON.stringify(
|
||||
@@ -130,6 +137,30 @@ try {
|
||||
throw new Error(`Staged package import probe failed:\n${probe.stderr || probe.stdout}`);
|
||||
if (probe.stdout) process.stdout.write(probe.stdout);
|
||||
|
||||
if (stagedPackageNames.has("@wrnexus/ui")) {
|
||||
writeFileSync(
|
||||
join(consumer, "ui-runtime-probe.mjs"),
|
||||
`import { existsSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
const root = resolve("node_modules/@wrnexus/ui");
|
||||
const manifest = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8"));
|
||||
for (const runtime of manifest.wrnexus?.clientRuntimes ?? []) {
|
||||
const entry = resolve(root, runtime.entry);
|
||||
if (!existsSync(entry) || entry.endsWith(".ts")) throw new Error("Missing compiled UI runtime: " + runtime.id);
|
||||
if (runtime.bundle === false) continue;
|
||||
const result = await Bun.build({ entrypoints: [entry], target: "browser" });
|
||||
if (!result.success) throw new AggregateError(result.logs, "UI browser runtime failed: " + runtime.id);
|
||||
}
|
||||
if (!existsSync(resolve(root, "components/Chart.wrn"))) throw new Error("Missing Chart component");
|
||||
console.log("Published UI component and browser runtimes passed.");
|
||||
`,
|
||||
);
|
||||
const ui = spawnSync(bun, ["ui-runtime-probe.mjs"], { cwd: consumer, encoding: "utf8" });
|
||||
if (ui.status !== 0)
|
||||
throw new Error(`Staged UI runtime probe failed:\n${ui.stderr || ui.stdout}`);
|
||||
if (ui.stdout) process.stdout.write(ui.stdout);
|
||||
}
|
||||
|
||||
if (stagedPackageNames.has("@wrnexus/viewer")) {
|
||||
writeFileSync(
|
||||
join(consumer, "viewer-probe.mjs"),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isIndependentReleaseVersion } from "./lib/package-version.mjs";
|
||||
import console from "node:console";
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
@@ -59,8 +60,8 @@ check(
|
||||
);
|
||||
check("root version is a 0.8 release", /^0\.8\.\d+$/.test(releaseVersion));
|
||||
check(
|
||||
"all framework packages use valid independent 0.8 versions",
|
||||
packageManifests.every((manifest) => /^0\.8\.\d+$/.test(manifest.version)),
|
||||
"all framework packages use valid independent release versions",
|
||||
packageManifests.every((manifest) => isIndependentReleaseVersion(manifest.version)),
|
||||
);
|
||||
const bunLockPackages = [
|
||||
...text("bun.lock").matchAll(/^ {4}"packages\/[^"\r\n]+": \{\r?\n([\s\S]*?)^ {4}\},?$/gm),
|
||||
|
||||
Reference in new issue
Block a user