release: WRNexusJS 0.6.0
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/cli",
|
||||
"version": "0.5.14",
|
||||
"version": "0.6.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
+609
-4
@@ -28,7 +28,7 @@ import {
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { basename, dirname, join, relative, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { AI_GUIDE, CLAUDE_MD } from "./ai-guide.ts";
|
||||
import { inspectProject, type DoctorCheck } from "./doctor.ts";
|
||||
@@ -70,9 +70,20 @@ interface MigrationCtx {
|
||||
from: string;
|
||||
to: string;
|
||||
dryRun: boolean;
|
||||
explicitImports: boolean;
|
||||
report: MigrationReport;
|
||||
log: (msg: string) => void;
|
||||
}
|
||||
|
||||
export interface MigrationReport {
|
||||
changedAutomatically: string[];
|
||||
needsReview: string[];
|
||||
unresolvedImports: string[];
|
||||
ambiguousFunctions: string[];
|
||||
legacyOutputPayloads: string[];
|
||||
parseFailures: string[];
|
||||
}
|
||||
|
||||
interface Migration {
|
||||
/** Framework version that introduced this change. Runs when `from < version <= to`. */
|
||||
version: string;
|
||||
@@ -181,6 +192,373 @@ export function migrateWrnSource(source: string): string {
|
||||
return formatInlineProps(quoteLegacyDynamicAttributes(source));
|
||||
}
|
||||
|
||||
function findMatching(source: string, open: number, openChar = "{", closeChar = "}"): number {
|
||||
let depth = 0;
|
||||
let quote = "";
|
||||
for (let index = open; 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 === openChar) depth++;
|
||||
else if (char === closeChar && --depth === 0) return index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function convertLegacyEmit(source: string, report: MigrationReport, file: string): string {
|
||||
let output = "";
|
||||
let cursor = 0;
|
||||
while (true) {
|
||||
const start = source.indexOf("$emit(", cursor);
|
||||
if (start < 0) return output + source.slice(cursor);
|
||||
output += source.slice(cursor, start);
|
||||
const end = findMatching(source, start + 5, "(", ")");
|
||||
if (end < 0) {
|
||||
report.needsReview.push(`${file}: unterminated $emit call`);
|
||||
return output + source.slice(start);
|
||||
}
|
||||
const args = source.slice(start + 6, end);
|
||||
const match = args.match(/^\s*(["'])([A-Za-z_$][\w$:-]*)\1\s*(?:,\s*([\s\S]*))?$/);
|
||||
if (!match) {
|
||||
report.needsReview.push(`${file}: dynamic $emit requires manual review`);
|
||||
output += source.slice(start, end + 1);
|
||||
} else {
|
||||
const payload = (match[3] ?? "").trim();
|
||||
output += payload ? `output.${match[2]}(${payload})` : `output.${match[2]}()`;
|
||||
}
|
||||
cursor = end + 1;
|
||||
}
|
||||
}
|
||||
|
||||
function collectLegacyEvents(source: string): { source: string; outputs: string[] } {
|
||||
const outputs: string[] = [];
|
||||
const next = source.replace(
|
||||
/(^|[\s;])@event\s+([A-Za-z_$][\w$]*)\s*=\s*function\b[ \t]*(?:\r?\n)?/gm,
|
||||
(_whole, prefix: string, name: string) => {
|
||||
outputs.push(name);
|
||||
return prefix;
|
||||
},
|
||||
);
|
||||
return { source: next, outputs };
|
||||
}
|
||||
|
||||
function ensureOutputsBlock(
|
||||
source: string,
|
||||
names: string[],
|
||||
report: MigrationReport,
|
||||
file: string,
|
||||
): string {
|
||||
if (!names.length) return source;
|
||||
const unique = [...new Set(names)];
|
||||
const blockMatch = /\boutputs\s*\{/.exec(source);
|
||||
if (blockMatch) {
|
||||
const open = source.indexOf("{", blockMatch.index);
|
||||
const close = findMatching(source, open);
|
||||
if (close < 0) return source;
|
||||
const body = source.slice(open + 1, close);
|
||||
const missing = unique.filter((name) => !new RegExp(`\\b${name}\\s*\\(`).test(body));
|
||||
if (!missing.length) return source;
|
||||
const indent = source.slice(source.lastIndexOf("\n", blockMatch.index) + 1, blockMatch.index);
|
||||
const additions = missing.map((name) => `${indent} ${name}(payload: unknown)`).join("\n");
|
||||
for (const name of missing)
|
||||
report.legacyOutputPayloads.push(`${file}: ${name}(payload: unknown)`);
|
||||
return source.slice(0, close) + `\n${additions}\n${indent}` + source.slice(close);
|
||||
}
|
||||
const rootOpen = source.indexOf("{");
|
||||
if (rootOpen < 0) return source;
|
||||
const lineStart = source.lastIndexOf("\n", rootOpen) + 1;
|
||||
const nonWhitespace = source.slice(lineStart).search(/\S/);
|
||||
const rootIndent = nonWhitespace < 0 ? "" : source.slice(lineStart, lineStart + nonWhitespace);
|
||||
const indent = `${rootIndent} `;
|
||||
const block = `\n${indent}outputs {\n${unique.map((name) => `${indent} ${name}(payload: unknown)`).join("\n")}\n${indent}}\n`;
|
||||
for (const name of unique) report.legacyOutputPayloads.push(`${file}: ${name}(payload: unknown)`);
|
||||
return source.slice(0, rootOpen + 1) + block + source.slice(rootOpen + 1);
|
||||
}
|
||||
|
||||
function classifyLegacyFunctions(source: string, report: MigrationReport, file: string): string {
|
||||
const functionsMatch = /\bfunctions\s*\{/.exec(source);
|
||||
if (!functionsMatch) return source;
|
||||
const open = source.indexOf("{", functionsMatch.index);
|
||||
const close = findMatching(source, open);
|
||||
if (close < 0) return source;
|
||||
const body = source.slice(open + 1, close);
|
||||
let output = "";
|
||||
let cursor = 0;
|
||||
const header =
|
||||
/(^|\n)([ \t]*)(?!client\s|server\s|shared\s)(async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\([^)]*\)(?:\s*:\s*[^{\n]+)?\s*\{/g;
|
||||
for (let match; (match = header.exec(body));) {
|
||||
const bodyOpen = body.indexOf("{", match.index + match[0].length - 1);
|
||||
const bodyClose = findMatching(body, bodyOpen);
|
||||
if (bodyClose < 0) break;
|
||||
const functionBody = body.slice(bodyOpen + 1, bodyClose);
|
||||
const client =
|
||||
/\b(window|document|localStorage|sessionStorage|navigator|HTMLElement|MouseEvent|KeyboardEvent|output\.|refs\.)\b/.test(
|
||||
functionBody,
|
||||
);
|
||||
const server =
|
||||
/\b(ctx|Bun|process|database|db\.|serverService|authService|request|response)\b/.test(
|
||||
functionBody,
|
||||
);
|
||||
let runtime = "";
|
||||
if (client && !server) runtime = "client ";
|
||||
else if (server && !client) runtime = "server ";
|
||||
else if (!client && !server && !/\b(fetch|Date\.now|Math\.random)\b/.test(functionBody))
|
||||
runtime = "shared ";
|
||||
else report.ambiguousFunctions.push(`${file}: ${match[4]}`);
|
||||
output +=
|
||||
body.slice(cursor, match.index) +
|
||||
match[1] +
|
||||
match[2] +
|
||||
runtime +
|
||||
(match[3] ?? "") +
|
||||
`function ${match[4]}`;
|
||||
const consumedHeader =
|
||||
match[0].lastIndexOf(`function ${match[4]}`) + `function ${match[4]}`.length;
|
||||
output += match[0].slice(consumedHeader);
|
||||
cursor = match.index + match[0].length;
|
||||
header.lastIndex = bodyClose + 1;
|
||||
}
|
||||
output += body.slice(cursor);
|
||||
return source.slice(0, open + 1) + output + source.slice(close);
|
||||
}
|
||||
|
||||
export function migrateV060WrnSource(
|
||||
source: string,
|
||||
report: MigrationReport,
|
||||
file = "<inline>",
|
||||
): string {
|
||||
const legacy = collectLegacyEvents(source);
|
||||
let next = ensureOutputsBlock(legacy.source, legacy.outputs, report, file);
|
||||
next = convertLegacyEmit(next, report, file);
|
||||
next = next.replace(/\bevent\.detail\b/g, "payload");
|
||||
next = classifyLegacyFunctions(next, report, file);
|
||||
return migrateWrnSource(next);
|
||||
}
|
||||
|
||||
function updateV060Config(ctx: MigrationCtx): void {
|
||||
const candidates = ["wrnexus.config.ts", "wrnexus.config.js", "wrnexus.config.mjs"];
|
||||
const file = candidates.map((name) => join(ctx.appRoot, name)).find(existsSync);
|
||||
if (!file) return;
|
||||
const current = readFileSync(file, "utf8");
|
||||
if (/\bimports\s*:/.test(current) && /\bcompatibility\s*:/.test(current)) return;
|
||||
const insertion = `\n imports: { mode: "compatible", autoImport: true, aliases: { "@": "./app" } },\n types: { strict: false, noImplicitAny: false, strictNullChecks: true, checkTemplates: true, checkComponentProps: true, generateDeclarations: true, globalTypes: "./app/types/global.d.ts" },\n functions: { legacyDefaultRuntime: "current" },\n stores: { strictMutations: true, persistence: true },\n compatibility: { legacyEmit: true, legacyEventProps: true, legacyComponentDiscovery: true, stringLayouts: true },`;
|
||||
const index = current.lastIndexOf("}");
|
||||
if (index < 0) return;
|
||||
const next =
|
||||
current.slice(0, index).replace(/,?\s*$/, "") + "," + insertion + "\n" + current.slice(index);
|
||||
ctx.log(`~ ${file.slice(ctx.appRoot.length + 1)}: v0.6 compatibility configuration`);
|
||||
if (!ctx.dryRun) writeFileSync(file, next, "utf8");
|
||||
}
|
||||
|
||||
interface V060SymbolIndex {
|
||||
components: Map<string, string[]>;
|
||||
layouts: Map<string, string[]>;
|
||||
ui: Set<string>;
|
||||
}
|
||||
|
||||
function declarationName(file: string): string {
|
||||
const source = readFileSync(file, "utf8");
|
||||
return (
|
||||
/\b(?:component|layout)\s+([A-Za-z_$][\w$]*)\s*\{/.exec(source)?.[1] ?? basename(file, ".wrn")
|
||||
);
|
||||
}
|
||||
|
||||
function addSymbol(index: Map<string, string[]>, name: string, file: string): void {
|
||||
const current = index.get(name) ?? [];
|
||||
current.push(file);
|
||||
index.set(name, current);
|
||||
}
|
||||
|
||||
function loadUiComponentNames(appRoot: string): Set<string> {
|
||||
const candidates = [
|
||||
join(appRoot, "node_modules", "@wrnexus", "ui", "component-reference.json"),
|
||||
join(appRoot, "packages", "ui", "component-reference.json"),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (!existsSync(candidate)) continue;
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(candidate, "utf8")) as {
|
||||
components?: Array<{ name?: string }>;
|
||||
};
|
||||
return new Set(
|
||||
(parsed.components ?? [])
|
||||
.map((entry) => entry.name)
|
||||
.filter((name): name is string => Boolean(name)),
|
||||
);
|
||||
} catch {
|
||||
// Continue to an empty package index. The migration reports unresolved tags.
|
||||
}
|
||||
}
|
||||
return new Set();
|
||||
}
|
||||
|
||||
function buildV060SymbolIndex(appRoot: string): V060SymbolIndex {
|
||||
const components = new Map<string, string[]>();
|
||||
const layouts = new Map<string, string[]>();
|
||||
for (const file of walkProjectFiles(join(appRoot, "app", "components"), ".wrn")) {
|
||||
addSymbol(components, declarationName(file), file);
|
||||
}
|
||||
for (const file of walkProjectFiles(join(appRoot, "app", "layouts"), ".wrn")) {
|
||||
const name = declarationName(file);
|
||||
addSymbol(layouts, name, file);
|
||||
addSymbol(layouts, basename(file, ".wrn"), file);
|
||||
}
|
||||
return { components, layouts, ui: loadUiComponentNames(appRoot) };
|
||||
}
|
||||
|
||||
function importedLocalNames(source: string): Set<string> {
|
||||
const names = new Set<string>();
|
||||
for (const statement of source.matchAll(
|
||||
/^import\s+(?:type\s+)?([\s\S]*?)\s+from\s+["'][^"']+["']\s*;?/gm,
|
||||
)) {
|
||||
const clause = statement[1]!.trim();
|
||||
if (!clause.startsWith("{") && !clause.startsWith("*")) names.add(clause.split(",")[0]!.trim());
|
||||
for (const named of clause.matchAll(
|
||||
/(?:\{|,)\s*(?:type\s+)?([A-Za-z_$][\w$]*)(?:\s+as\s+([A-Za-z_$][\w$]*))?/g,
|
||||
)) {
|
||||
names.add(named[2] ?? named[1]!);
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function importPathFor(appRoot: string, file: string): string {
|
||||
return `@/${relative(join(appRoot, "app"), file).replace(/\\/g, "/")}`;
|
||||
}
|
||||
|
||||
function insertImports(source: string, statements: string[]): string {
|
||||
if (!statements.length) return source;
|
||||
const unique = [...new Set(statements)].filter((statement) => !source.includes(statement));
|
||||
if (!unique.length) return source;
|
||||
const imports = Array.from(
|
||||
source.matchAll(
|
||||
/^import\s+[\s\S]*?(?:;\s*|\n(?=import|\s*(?:page|component|layout|global\s+store|page\s+store)\b))/gm,
|
||||
),
|
||||
);
|
||||
const insertion = imports.length ? imports.at(-1)!.index! + imports.at(-1)![0].length : 0;
|
||||
const prefix = insertion ? "\n" : "";
|
||||
return (
|
||||
source.slice(0, insertion) +
|
||||
prefix +
|
||||
unique.join("\n") +
|
||||
"\n\n" +
|
||||
source.slice(insertion).replace(/^\s*/, "")
|
||||
);
|
||||
}
|
||||
|
||||
function addExplicitImportsToSource(
|
||||
source: string,
|
||||
file: string,
|
||||
appRoot: string,
|
||||
index: V060SymbolIndex,
|
||||
report: MigrationReport,
|
||||
): string {
|
||||
const imported = importedLocalNames(source);
|
||||
const rootName =
|
||||
/\b(?:page|component|layout|global\s+store|page\s+store)\s+([A-Za-z_$][\w$]*)/.exec(
|
||||
source,
|
||||
)?.[1];
|
||||
const applicationImports: string[] = [];
|
||||
const uiImports = new Set<string>();
|
||||
const tags = new Set(
|
||||
Array.from(source.matchAll(/<([A-Z][A-Za-z0-9_$]*)\b/g), (match) => match[1]!),
|
||||
);
|
||||
for (const name of tags) {
|
||||
if (name === rootName || imported.has(name)) continue;
|
||||
const candidates = index.components.get(name) ?? [];
|
||||
if (candidates.length === 1) {
|
||||
applicationImports.push(
|
||||
`import ${name} from ${JSON.stringify(importPathFor(appRoot, candidates[0]!))}`,
|
||||
);
|
||||
imported.add(name);
|
||||
} else if (candidates.length > 1) {
|
||||
report.unresolvedImports.push(
|
||||
`${file}: component '${name}' has ${candidates.length} application matches`,
|
||||
);
|
||||
} else if (index.ui.has(name)) {
|
||||
uiImports.add(name);
|
||||
imported.add(name);
|
||||
} else {
|
||||
report.unresolvedImports.push(`${file}: component '${name}' could not be resolved`);
|
||||
}
|
||||
}
|
||||
if (uiImports.size) {
|
||||
const existingUi = /import\s*\{([\s\S]*?)\}\s*from\s*["']@wrnexus\/ui["']\s*;?/.exec(source);
|
||||
if (existingUi) {
|
||||
const names = new Set([
|
||||
...Array.from(
|
||||
existingUi[1]!.matchAll(/(?:^|,)\s*([A-Za-z_$][\w$]*)/g),
|
||||
(match) => match[1]!,
|
||||
),
|
||||
...uiImports,
|
||||
]);
|
||||
source = source.replace(
|
||||
existingUi[0],
|
||||
`import {\n${[...names]
|
||||
.sort()
|
||||
.map((name) => ` ${name},`)
|
||||
.join("\n")}\n} from "@wrnexus/ui"`,
|
||||
);
|
||||
} else {
|
||||
applicationImports.push(
|
||||
`import {\n${[...uiImports]
|
||||
.sort()
|
||||
.map((name) => ` ${name},`)
|
||||
.join("\n")}\n} from "@wrnexus/ui"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return insertImports(source, applicationImports);
|
||||
}
|
||||
|
||||
function migrateImportedLayout(
|
||||
source: string,
|
||||
file: string,
|
||||
appRoot: string,
|
||||
index: V060SymbolIndex,
|
||||
report: MigrationReport,
|
||||
): string {
|
||||
const match = /\blayout\s*=\s*["']([^"']+)["']/.exec(source);
|
||||
if (!match || match[1] === "none") return source;
|
||||
const candidates = index.layouts.get(match[1]) ?? [];
|
||||
const unique = [...new Set(candidates)];
|
||||
if (unique.length !== 1) {
|
||||
report.unresolvedImports.push(
|
||||
`${file}: layout '${match[1]}' ${unique.length ? "is ambiguous" : "was not found"}`,
|
||||
);
|
||||
return source;
|
||||
}
|
||||
const symbol = declarationName(unique[0]!);
|
||||
const statement = `import ${symbol} from ${JSON.stringify(importPathFor(appRoot, unique[0]!))}`;
|
||||
return insertImports(source.replace(match[0], `layout = ${symbol}`), [statement]);
|
||||
}
|
||||
|
||||
function writeMigrationReport(ctx: MigrationCtx): void {
|
||||
const file = join(ctx.appRoot, ".wrnexus", "migrations", "0.6.0-report.json");
|
||||
ctx.log(`+ .wrnexus/migrations/0.6.0-report.json`);
|
||||
if (ctx.dryRun) return;
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
writeFileSync(
|
||||
file,
|
||||
JSON.stringify(
|
||||
{
|
||||
version: "0.6.0",
|
||||
from: ctx.from,
|
||||
to: ctx.to,
|
||||
appliedAt: new Date().toISOString(),
|
||||
...ctx.report,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Versioned, idempotent upgrade steps. Each MUST be safe to re-run. Source
|
||||
* migrations must preserve semantics and are protected by update backups.
|
||||
@@ -1186,6 +1564,179 @@ const MIGRATIONS: Migration[] = [
|
||||
// Compiler, SSR, dev-server, and CSR runtime changes apply automatically.
|
||||
},
|
||||
},
|
||||
{
|
||||
version: "0.6.0",
|
||||
id: "0.6.0-01-parser-and-language",
|
||||
description:
|
||||
"Enables the v0.6 parser, AST, typed state, imports, stores, outputs, and runtime function grammar.",
|
||||
apply() {},
|
||||
},
|
||||
{
|
||||
version: "0.6.0",
|
||||
id: "0.6.0-02-function-runtime",
|
||||
description:
|
||||
"Classifies unambiguous legacy functions as client, server, or shared while preserving ambiguous behavior.",
|
||||
apply(ctx) {
|
||||
for (const file of walkProjectFiles(join(ctx.appRoot, "app"), ".wrn")) {
|
||||
const before = readFileSync(file, "utf8");
|
||||
const relative = file.slice(ctx.appRoot.length + 1).replace(/\\/g, "/");
|
||||
const after = classifyLegacyFunctions(before, ctx.report, relative);
|
||||
if (after === before) continue;
|
||||
ctx.log(`~ ${relative}: runtime function modifiers`);
|
||||
ctx.report.changedAutomatically.push(relative);
|
||||
if (!ctx.dryRun) writeFileSync(file, after, "utf8");
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
version: "0.6.0",
|
||||
id: "0.6.0-03-typed-outputs",
|
||||
description:
|
||||
"Converts legacy @event declarations to typed outputs with safe unknown payload fallbacks.",
|
||||
apply(ctx) {
|
||||
for (const file of walkProjectFiles(join(ctx.appRoot, "app"), ".wrn")) {
|
||||
const before = readFileSync(file, "utf8");
|
||||
const relative = file.slice(ctx.appRoot.length + 1).replace(/\\/g, "/");
|
||||
const legacy = collectLegacyEvents(before);
|
||||
const after = ensureOutputsBlock(legacy.source, legacy.outputs, ctx.report, relative);
|
||||
if (after === before) continue;
|
||||
ctx.log(`~ ${relative}: typed outputs`);
|
||||
ctx.report.changedAutomatically.push(relative);
|
||||
if (!ctx.dryRun) writeFileSync(file, after, "utf8");
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
version: "0.6.0",
|
||||
id: "0.6.0-04-remove-emit",
|
||||
description:
|
||||
"Migrates static $emit calls and event.detail consumers to output.name(payload) and payload.",
|
||||
apply(ctx) {
|
||||
for (const file of walkProjectFiles(join(ctx.appRoot, "app"), ".wrn")) {
|
||||
const before = readFileSync(file, "utf8");
|
||||
const relative = file.slice(ctx.appRoot.length + 1).replace(/\\/g, "/");
|
||||
const after = convertLegacyEmit(before, ctx.report, relative).replace(
|
||||
/\bevent\.detail\b/g,
|
||||
"payload",
|
||||
);
|
||||
if (after === before) continue;
|
||||
ctx.log(`~ ${relative}: callable outputs`);
|
||||
ctx.report.changedAutomatically.push(relative);
|
||||
if (!ctx.dryRun) writeFileSync(file, after, "utf8");
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
version: "0.6.0",
|
||||
id: "0.6.0-05-explicit-imports",
|
||||
description:
|
||||
"Enables compatible explicit imports and records unresolved symbols without guessing.",
|
||||
apply(ctx) {
|
||||
if (!ctx.explicitImports) return;
|
||||
const index = buildV060SymbolIndex(ctx.appRoot);
|
||||
for (const path of walkProjectFiles(join(ctx.appRoot, "app"), ".wrn")) {
|
||||
const relativeFile = path.slice(ctx.appRoot.length + 1).replace(/\\/g, "/");
|
||||
const before = readFileSync(path, "utf8");
|
||||
const after = addExplicitImportsToSource(
|
||||
before,
|
||||
relativeFile,
|
||||
ctx.appRoot,
|
||||
index,
|
||||
ctx.report,
|
||||
);
|
||||
if (after === before) continue;
|
||||
ctx.log(`~ ${relativeFile}: explicit imports`);
|
||||
if (!ctx.report.changedAutomatically.includes(relativeFile))
|
||||
ctx.report.changedAutomatically.push(relativeFile);
|
||||
if (!ctx.dryRun) writeFileSync(path, after, "utf8");
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
version: "0.6.0",
|
||||
id: "0.6.0-06-layout-imports",
|
||||
description:
|
||||
"Converts safely resolvable string layouts to imported symbols while retaining compatibility.",
|
||||
apply(ctx) {
|
||||
if (!ctx.explicitImports) return;
|
||||
const index = buildV060SymbolIndex(ctx.appRoot);
|
||||
for (const path of walkProjectFiles(join(ctx.appRoot, "app"), ".wrn")) {
|
||||
const relativeFile = path.slice(ctx.appRoot.length + 1).replace(/\\/g, "/");
|
||||
const before = readFileSync(path, "utf8");
|
||||
const after = migrateImportedLayout(before, relativeFile, ctx.appRoot, index, ctx.report);
|
||||
if (after === before) continue;
|
||||
ctx.log(`~ ${relativeFile}: imported layout`);
|
||||
if (!ctx.report.changedAutomatically.includes(relativeFile))
|
||||
ctx.report.changedAutomatically.push(relativeFile);
|
||||
if (!ctx.dryRun) writeFileSync(path, after, "utf8");
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
version: "0.6.0",
|
||||
id: "0.6.0-07-component-contracts",
|
||||
description: "Enables generated component, output, function, and declaration contracts.",
|
||||
apply() {},
|
||||
},
|
||||
{
|
||||
version: "0.6.0",
|
||||
id: "0.6.0-08-store-support",
|
||||
description: "Adds @wrnexus/store and @wrnexus/typecheck to runnable applications.",
|
||||
apply(ctx) {
|
||||
if (!existsSync(join(ctx.appRoot, "app", "pages"))) return;
|
||||
const file = join(ctx.appRoot, "package.json");
|
||||
const pkg = JSON.parse(readFileSync(file, "utf8")) as Record<string, any>;
|
||||
const dependencies = (pkg.dependencies ??= {});
|
||||
const added: string[] = [];
|
||||
for (const name of ["@wrnexus/store", "@wrnexus/typecheck"]) {
|
||||
if (dependencies[name] === `^${ctx.to}`) continue;
|
||||
dependencies[name] = `^${ctx.to}`;
|
||||
added.push(name);
|
||||
}
|
||||
if (added.length) ctx.log(`+ dependencies: ${added.join(", ")}`);
|
||||
if (!ctx.dryRun && added.length)
|
||||
writeFileSync(file, JSON.stringify(pkg, null, 2) + "\n", "utf8");
|
||||
},
|
||||
},
|
||||
{
|
||||
version: "0.6.0",
|
||||
id: "0.6.0-09-type-config",
|
||||
description:
|
||||
"Adds compatible imports, type checking, stores, and legacy behavior configuration.",
|
||||
apply(ctx) {
|
||||
updateV060Config(ctx);
|
||||
const types = join(ctx.appRoot, "app", "types");
|
||||
if (!existsSync(types)) {
|
||||
ctx.log("+ app/types/global.d.ts");
|
||||
if (!ctx.dryRun) {
|
||||
mkdirSync(types, { recursive: true });
|
||||
writeFileSync(
|
||||
join(types, "global.d.ts"),
|
||||
"// Application-wide ambient WRNexusJS types.\nexport {};\n",
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
version: "0.6.0",
|
||||
id: "0.6.0-10-showcase-and-ui",
|
||||
description: "Finalizes v0.6 source normalization and writes the detailed migration report.",
|
||||
apply(ctx) {
|
||||
for (const file of walkProjectFiles(join(ctx.appRoot, "app"), ".wrn")) {
|
||||
const before = readFileSync(file, "utf8");
|
||||
const relative = file.slice(ctx.appRoot.length + 1).replace(/\\/g, "/");
|
||||
const after = migrateWrnSource(before);
|
||||
if (after === before) continue;
|
||||
ctx.log(`~ ${relative}: final v0.6 normalization`);
|
||||
if (!ctx.report.changedAutomatically.includes(relative))
|
||||
ctx.report.changedAutomatically.push(relative);
|
||||
if (!ctx.dryRun) writeFileSync(file, after, "utf8");
|
||||
}
|
||||
writeMigrationReport(ctx);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/** Release tooling uses this to require an explicit migration entry per version. */
|
||||
@@ -1291,7 +1842,12 @@ function backupProjectFiles(appRoot: string, from: string, target: string): stri
|
||||
}
|
||||
|
||||
/** Update one app without marking success until install and verification pass. */
|
||||
export function updateApp(appRoot: string, target: string, dryRun: boolean): UpdatedApp | null {
|
||||
export function updateApp(
|
||||
appRoot: string,
|
||||
target: string,
|
||||
dryRun: boolean,
|
||||
options: { explicitImports?: boolean; report?: MigrationReport } = {},
|
||||
): UpdatedApp | null {
|
||||
const pkgPath = join(appRoot, "package.json");
|
||||
if (!existsSync(pkgPath)) {
|
||||
console.log(` ⚠ ${appRoot}: no package.json — skipped`);
|
||||
@@ -1300,6 +1856,16 @@ export function updateApp(appRoot: string, target: string, dryRun: boolean): Upd
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as Record<string, unknown>;
|
||||
const from = appVersion(appRoot, pkg);
|
||||
const log = (m: string) => console.log(` ${m}`);
|
||||
const report =
|
||||
options.report ??
|
||||
({
|
||||
changedAutomatically: [],
|
||||
needsReview: [],
|
||||
unresolvedImports: [],
|
||||
ambiguousFunctions: [],
|
||||
legacyOutputPayloads: [],
|
||||
parseFailures: [],
|
||||
} satisfies MigrationReport);
|
||||
console.log(` ▸ ${pkg.name ?? appRoot} (${from} → ${target})`);
|
||||
|
||||
if (!dryRun) log(`backup: ${backupProjectFiles(appRoot, from, target)}`);
|
||||
@@ -1312,7 +1878,15 @@ export function updateApp(appRoot: string, target: string, dryRun: boolean): Upd
|
||||
|
||||
for (const m of MIGRATIONS) {
|
||||
if (cmp(m.version, from) > 0 && cmp(m.version, target) <= 0) {
|
||||
m.apply({ appRoot, from, to: target, dryRun, log });
|
||||
m.apply({
|
||||
appRoot,
|
||||
from,
|
||||
to: target,
|
||||
dryRun,
|
||||
explicitImports: options.explicitImports ?? false,
|
||||
report,
|
||||
log,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1383,6 +1957,8 @@ export async function runUpdate(dir: string, args: string[]): Promise<void> {
|
||||
const root = resolve(dir);
|
||||
const dryRun = args.includes("--dry-run");
|
||||
const verify = !args.includes("--no-verify");
|
||||
const explicitImports = args.includes("--explicit-imports");
|
||||
const reportRequested = args.includes("--report");
|
||||
const versionArg = args.find((a) => a.startsWith("--version="))?.split("=")[1];
|
||||
const target =
|
||||
versionArg ?? (args.includes("--latest") ? latestPublished() : null) ?? cliVersion();
|
||||
@@ -1417,7 +1993,36 @@ export async function runUpdate(dir: string, args: string[]): Promise<void> {
|
||||
// Workspace → update the root manifest + every app; else just this app.
|
||||
const apps = await workspaceApps(root);
|
||||
const targets = apps ? [root, ...apps] : [root];
|
||||
const updated = targets.map((t) => updateApp(t, target, dryRun)).filter(Boolean) as UpdatedApp[];
|
||||
const reports = new Map<string, MigrationReport>();
|
||||
const updated = targets
|
||||
.map((targetRoot) => {
|
||||
const report: MigrationReport = {
|
||||
changedAutomatically: [],
|
||||
needsReview: [],
|
||||
unresolvedImports: [],
|
||||
ambiguousFunctions: [],
|
||||
legacyOutputPayloads: [],
|
||||
parseFailures: [],
|
||||
};
|
||||
reports.set(targetRoot, report);
|
||||
return updateApp(targetRoot, target, dryRun, { explicitImports, report });
|
||||
})
|
||||
.filter(Boolean) as UpdatedApp[];
|
||||
|
||||
if (reportRequested || dryRun) {
|
||||
for (const [targetRoot, report] of reports) {
|
||||
console.log(`
|
||||
Migration report: ${targetRoot}`);
|
||||
console.log(` Changed automatically: ${new Set(report.changedAutomatically).size}`);
|
||||
console.log(` Needs review: ${report.needsReview.length}`);
|
||||
console.log(` Unresolved imports: ${report.unresolvedImports.length}`);
|
||||
console.log(` Ambiguous function runtime: ${report.ambiguousFunctions.length}`);
|
||||
console.log(
|
||||
` Legacy output payloads using unknown: ${report.legacyOutputPayloads.length}`,
|
||||
);
|
||||
console.log(` Parse failures: ${report.parseFailures.length}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
console.log(`\n Dry run — no files written. Re-run without --dry-run to apply.\n`);
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { migrateV060WrnSource } from "../src/update.ts";
|
||||
|
||||
const report = () => ({
|
||||
changedAutomatically: [],
|
||||
needsReview: [],
|
||||
unresolvedImports: [],
|
||||
ambiguousFunctions: [],
|
||||
legacyOutputPayloads: [],
|
||||
parseFailures: [],
|
||||
});
|
||||
|
||||
describe("v0.6 source migration", () => {
|
||||
test("converts legacy outputs and remains idempotent", () => {
|
||||
const input = `component Demo {
|
||||
props {
|
||||
@event confirm = function
|
||||
}
|
||||
functions {
|
||||
function confirm() {
|
||||
$emit("confirm", { ok: true })
|
||||
}
|
||||
}
|
||||
view { <button @click='confirm()'>Confirm</button> }
|
||||
}`;
|
||||
const first = migrateV060WrnSource(input, report(), "Demo.wrn");
|
||||
const second = migrateV060WrnSource(first, report(), "Demo.wrn");
|
||||
expect(first).toContain("outputs {");
|
||||
expect(first).toContain("confirm(payload: unknown)");
|
||||
expect(first).toContain("output.confirm({ ok: true })");
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user