ktworkflow-mcp/scripts/draft-live2d-module-migrations.ts

249 lines
9.1 KiB
TypeScript

import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { resolveInsideRoot } from "../src/core/workspace.js";
import { analyzeLive2DMinJsSource } from "../src/tools/deobfuscation.js";
import {
buildLive2DModuleMigrationDecisionDrafts,
Live2DModuleMigrationDraftError,
retargetLive2DFunctionDecisionCatalog,
type Live2DFunctionTargetDecisionCatalog,
type Live2DModuleMigrationDecisionCatalog,
type Live2DModuleMigrationDraftManifest,
} from "../src/tools/live2dModuleMigrations.js";
interface CliOptions {
decisionPath: string;
functionDecisionOutputPath: string | null;
functionDecisionPath: string;
implementationRoot: string;
issueOutputPath: string;
manifestPath: string;
modulePaths: Set<string> | null;
outputPath: string;
refreshExisting: boolean;
reviewEvidence: string[];
sourcePath: string;
}
/** Reads `--name value` arguments into a compact deterministic map. */
function readArgs(argv: string[]): Map<string, string> {
const args = new Map<string, string>();
for (let index = 0; index < argv.length; index += 2) {
const key = argv[index];
const value = argv[index + 1];
if (!key?.startsWith("--") || value === undefined) {
throw new Error(`Invalid Live2D migration draft argument near ${key ?? "<end>"}`);
}
args.set(key.slice(2), value);
}
return args;
}
/** Resolves bounded workspace defaults and the optional comma-separated module selection. */
function parseOptions(argv: string[]): CliOptions {
const args = readArgs(argv);
const reviewEvidence = (args.get("review-evidence") ?? "")
.split(",")
.map((entry) => entry.trim())
.filter(Boolean);
if (reviewEvidence.length === 0) {
throw new Error("--review-evidence is required for Live2D migration drafts");
}
const modulePaths = args.has("modules")
? new Set(
args
.get("modules")!
.split(",")
.map((entry) => entry.trim())
.filter(Boolean),
)
: null;
return {
decisionPath: resolveInsideRoot(
args.get("decisions") ?? "docs/live2d-deobfuscation/module-migration-decisions.json",
),
functionDecisionOutputPath: args.has("function-decisions-output")
? resolveInsideRoot(args.get("function-decisions-output")!)
: null,
functionDecisionPath: resolveInsideRoot(
args.get("function-decisions") ??
"docs/live2d-deobfuscation/function-name-decisions.json",
),
implementationRoot: resolveInsideRoot(
args.get("implementation-root") ??
"Vue/kt-blog-web/src/components/blog/live2d/vendor/cubism2Core",
),
issueOutputPath: resolveInsideRoot(
args.get("issue-output") ??
".kt-workspace/live2d-deobfuscation/module-migration-draft-issues.generated.json",
),
manifestPath: resolveInsideRoot(
args.get("manifest") ?? "docs/live2d-deobfuscation/module-splits.json",
),
modulePaths,
outputPath: resolveInsideRoot(
args.get("output") ??
".kt-workspace/live2d-deobfuscation/module-migration-decisions.draft.generated.json",
),
refreshExisting: args.get("refresh-existing") === "true",
reviewEvidence,
sourcePath: resolveInsideRoot(
args.get("source") ??
"Vue/kt-blog-web/public/live2d/wordpress-moc/live2d.min.js",
),
};
}
/** Parses one typed JSON artifact while leaving semantic validation to the migration builder. */
function readJson<T>(filePath: string): T {
return JSON.parse(readFileSync(filePath, "utf8")) as T;
}
/** Converts one module basename into the existing `Cubism2*Source.spec.ts` convention. */
function getBehaviorTestReference(modulePath: string): { file: string; name: string } {
const basename = path.basename(modulePath, ".ts");
const semanticModuleName = `${basename.charAt(0).toUpperCase()}${basename.slice(1)}`;
return {
file: `Vue/kt-blog-web/src/__tests__/live2d/Cubism2${semanticModuleName}Source.spec.ts`,
name: `preserves reviewed ${modulePath} source behavior through semantic TypeScript`,
};
}
/** Builds and writes a deterministic append-only draft without changing the durable decision catalog. */
function main(): void {
const options = parseOptions(process.argv.slice(2));
const source = readFileSync(options.sourcePath, "utf8");
const sourceAudit = analyzeLive2DMinJsSource(source, {
sourcePath: path.relative(resolveInsideRoot("."), options.sourcePath).replaceAll("\\", "/"),
});
const manifest = readJson<Live2DModuleMigrationDraftManifest>(options.manifestPath);
const current = readJson<Live2DModuleMigrationDecisionCatalog>(options.decisionPath);
const retainedDecisions = options.refreshExisting ? [] : current.decisions;
const completed = new Set(retainedDecisions.map((entry) => entry.functionId));
const selected = manifest.functions.filter(
(entry) =>
entry.ownershipStatus === "owned" &&
!completed.has(entry.functionId) &&
(!options.modulePaths || options.modulePaths.has(entry.ownerModule!)),
);
if (options.modulePaths) {
const selectedModules = new Set(selected.map((entry) => entry.ownerModule));
const missingModules = [...options.modulePaths].filter(
(modulePath) => !selectedModules.has(modulePath),
);
if (missingModules.length > 0) {
throw new Error(`Selected Live2D modules have no pending functions: ${missingModules.join(", ")}`);
}
}
const behaviorTestsByModule = Object.fromEntries(
[...new Set(selected.map((entry) => entry.ownerModule!))].map((modulePath) => [
modulePath,
getBehaviorTestReference(modulePath),
]),
);
let drafts: ReturnType<typeof buildLive2DModuleMigrationDecisionDrafts>;
try {
drafts = buildLive2DModuleMigrationDecisionDrafts({
behaviorTestsByModule,
functionIds: selected.map((entry) => entry.functionId),
implementationRoot: options.implementationRoot,
moduleManifest: manifest,
reviewEvidence: options.reviewEvidence,
source,
sourceFunctions: sourceAudit.functions,
});
} catch (error) {
if (!(error instanceof Live2DModuleMigrationDraftError)) {
throw error;
}
const functions = new Map(manifest.functions.map((entry) => [entry.functionId, entry]));
const issues = error.issues.map((issue) => ({
...issue,
ownerModule: functions.get(issue.functionId)?.ownerModule ?? null,
semanticQualifiedName:
functions.get(issue.functionId)?.semanticQualifiedName ?? null,
sourceOrder: functions.get(issue.functionId)?.sourceOrder ?? null,
}));
const issueCountsByModule = Object.fromEntries(
[...new Set(issues.map((issue) => issue.ownerModule))]
.sort((left, right) => String(left).localeCompare(String(right)))
.map((modulePath) => [
modulePath ?? "<unknown>",
issues.filter((issue) => issue.ownerModule === modulePath).length,
]),
);
mkdirSync(path.dirname(options.issueOutputPath), { recursive: true });
writeFileSync(
options.issueOutputPath,
`${JSON.stringify(
{
issueCountsByModule,
issues,
schemaVersion: "live2d-module-migration-draft-issues/v1",
sourceSha256: sourceAudit.sourceSha256,
summary: {
draftable: selected.length - issues.length,
issueCount: issues.length,
selected: selected.length,
},
},
null,
2,
)}\n`,
"utf8",
);
process.stdout.write(
`${JSON.stringify({
issueCount: issues.length,
issueOutputPath: path
.relative(resolveInsideRoot("."), options.issueOutputPath)
.replaceAll("\\", "/"),
selectedCount: selected.length,
})}\n`,
);
process.exitCode = 2;
return;
}
const sourceOrder = new Map(
manifest.functions.map((entry) => [entry.functionId, entry.sourceOrder]),
);
const catalog: Live2DModuleMigrationDecisionCatalog = {
...current,
decisions: [...retainedDecisions, ...drafts].sort(
(left, right) => sourceOrder.get(left.functionId)! - sourceOrder.get(right.functionId)!,
),
};
mkdirSync(path.dirname(options.outputPath), { recursive: true });
writeFileSync(options.outputPath, `${JSON.stringify(catalog, null, 2)}\n`, "utf8");
let functionDecisionOutputPath: string | null = null;
if (options.functionDecisionOutputPath) {
const functionCatalog = readJson<Live2DFunctionTargetDecisionCatalog>(
options.functionDecisionPath,
);
const retargetedFunctionCatalog = retargetLive2DFunctionDecisionCatalog(
functionCatalog,
catalog.decisions,
);
mkdirSync(path.dirname(options.functionDecisionOutputPath), { recursive: true });
writeFileSync(
options.functionDecisionOutputPath,
`${JSON.stringify(retargetedFunctionCatalog, null, 2)}\n`,
"utf8",
);
functionDecisionOutputPath = path
.relative(resolveInsideRoot("."), options.functionDecisionOutputPath)
.replaceAll("\\", "/");
}
process.stdout.write(
`${JSON.stringify({
draftCount: drafts.length,
functionDecisionOutputPath,
outputPath: path.relative(resolveInsideRoot("."), options.outputPath).replaceAll("\\", "/"),
totalDecisionCount: catalog.decisions.length,
})}\n`,
);
}
main();