249 lines
9.1 KiB
TypeScript
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();
|