release: WRNexusJS 0.6.0

This commit is contained in:
2026-08-01 01:09:58 +05:30
parent 3e565e8d03
commit 687d345882
502 changed files with 33038 additions and 11358 deletions
+609 -4
View File
@@ -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`);