ktworkflow-mcp/scripts/audit-live2d-module-splits.ts

598 lines
21 KiB
JavaScript

#!/usr/bin/env node
import { createHash } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
analyzeLive2DMinJsSource,
buildCallResolutionReviewCatalog,
buildFunctionNameReviewCatalog,
buildLegacyFunctionRenameCatalog,
type Live2DCallResolutionDecisionCatalog,
type Live2DFunctionNameDecisionCatalog,
} from "../src/tools/deobfuscation.js";
import { analyzeLive2DBootstrapGraph } from "../src/tools/live2dBootstrapGraph.js";
import { findMissingLive2DImplementationTargets } from "../src/tools/live2dImplementationTargets.js";
import {
buildLive2DModuleReviewPack,
type Live2DModuleReviewPackManifest,
} from "../src/tools/live2dModuleReviewPack.js";
import {
buildLive2DModuleGraphAnalysis,
buildLive2DModuleSplitCatalog,
type Live2DModuleGraphAnalysis,
type Live2DModuleCouplingEdge,
type Live2DModuleSplitCatalog,
validateLive2DModuleSplitCatalog,
} from "../src/tools/live2dModuleSplits.js";
import {
auditLive2DModuleMigrations,
type Live2DModuleMigrationDecisionCatalog,
} from "../src/tools/live2dModuleMigrations.js";
import { analyzeLive2DSourceBindings } from "../src/tools/live2dSourceBindings.js";
interface CliOptions {
bindingOutputPath: string;
bootstrapOutputPath: string;
callDecisionPath: string;
draftOutputPath: string;
functionDecisionPath: string;
finalOutputPath: string | null;
graphOutputPath: string;
implementationRoot: string;
migrationDecisionPath: string;
reviewResultPaths: string[];
reviewJsonOutputPath: string;
reviewMarkdownOutputPath: string;
sourcePath: string;
}
interface Live2DModuleOwnerReviewRow {
decision: "accept-owner" | "change-owner" | "omitted-unreachable";
evidence: string[];
functionId: string;
ownerModule: string | null;
reason: string;
reviewStatus: "reviewed";
}
interface Live2DModuleOwnerReviewResult {
findings: Array<{ severity: "Critical" | "Important" | "Minor" } & Record<string, unknown>>;
functionIdsSha256: string;
partitionId: string;
reviewedRows: Live2DModuleOwnerReviewRow[];
rowCount: number;
rowsSha256: string;
schemaVersion: 1 | "live2d-module-owner-review-result/v1";
sourceSha256: string;
summary: unknown;
}
const dirname = path.dirname(fileURLToPath(import.meta.url));
const workspaceRoot = path.resolve(
process.env.KT_WORKSPACE_ROOT || path.join(dirname, "../../.."),
);
const generatedRoot =
".kt-workspace/deobfuscate/blog-live2d-cubism2-minjs/live2d.min";
/** Resolves a CLI path against the shared KT workspace root. */
function resolveWorkspacePath(value: string): string {
return path.isAbsolute(value) ? value : path.resolve(workspaceRoot, value);
}
/** Parses the bounded source/catalog/output surface for the module-split audit. */
function parseArgs(argv: string[]): CliOptions {
const args = new Map<string, string>();
for (let index = 0; index < argv.length; index += 1) {
const item = argv[index];
if (!item.startsWith("--")) {
continue;
}
const [key, inlineValue] = item.slice(2).split("=", 2);
if (inlineValue !== undefined) {
args.set(key, inlineValue);
continue;
}
const next = argv[index + 1];
if (next && !next.startsWith("--")) {
args.set(key, next);
index += 1;
}
}
return {
bindingOutputPath: resolveWorkspacePath(
args.get("binding-output") ?? `${generatedRoot}/module-source-bindings.generated.json`,
),
bootstrapOutputPath: resolveWorkspacePath(
args.get("bootstrap-output") ?? `${generatedRoot}/module-bootstrap.generated.json`,
),
callDecisionPath: resolveWorkspacePath(
args.get("call-decisions") ?? "docs/live2d-deobfuscation/call-resolutions.json",
),
draftOutputPath: resolveWorkspacePath(
args.get("draft-output") ?? `${generatedRoot}/module-splits.draft.generated.json`,
),
functionDecisionPath: resolveWorkspacePath(
args.get("function-decisions") ??
"docs/live2d-deobfuscation/function-name-decisions.json",
),
finalOutputPath: args.has("final-output")
? resolveWorkspacePath(args.get("final-output")!)
: null,
graphOutputPath: resolveWorkspacePath(
args.get("graph-output") ?? `${generatedRoot}/module-graph.generated.json`,
),
implementationRoot: resolveWorkspacePath(
args.get("implementation-root") ??
"Vue/kt-blog-web/src/components/blog/live2d/vendor/cubism2Core",
),
migrationDecisionPath: resolveWorkspacePath(
args.get("migration-decisions") ??
"docs/live2d-deobfuscation/module-migration-decisions.json",
),
reviewJsonOutputPath: resolveWorkspacePath(
args.get("review-json-output") ?? `${generatedRoot}/module-review-pack.generated.json`,
),
reviewResultPaths: ["a", "b", "c"].map((suffix) =>
resolveWorkspacePath(
args.get(`review-${suffix}`) ??
`.kt-workspace/subagents/results/2026-07-15-live2d-task4-review-${suffix}.json`,
),
),
reviewMarkdownOutputPath: resolveWorkspacePath(
args.get("review-markdown-output") ??
".kt-workspace/subagents/2026-07-15-live2d-task4-module-owner-source-pack.md",
),
sourcePath: resolveWorkspacePath(
args.get("source") ??
"Vue/kt-blog-web/public/live2d/wordpress-moc/live2d.min.js",
),
};
}
/** Reads a typed JSON catalog while leaving semantic validation to the builders. */
function readJson<T>(filePath: string): T {
return JSON.parse(readFileSync(filePath, "utf8")) as T;
}
/** Writes deterministic pretty JSON with one trailing LF. */
function writeJson(filePath: string, value: unknown): void {
mkdirSync(path.dirname(filePath), { recursive: true });
writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
/** Writes a generated UTF-8 text artifact after creating its evidence directory. */
function writeText(filePath: string, value: string): void {
mkdirSync(path.dirname(filePath), { recursive: true });
writeFileSync(filePath, value.endsWith("\n") ? value : `${value}\n`, "utf8");
}
/** Adapts only bootstrap prototype ownership into structural function clusters. */
function buildBootstrapStructuralCouplingEdges(
bootstrapAnalysis: ReturnType<typeof analyzeLive2DBootstrapGraph>,
): Live2DModuleCouplingEdge[] {
return bootstrapAnalysis.couplingEdges
.filter((edge) => edge.kind === "prototype-owner")
.map((edge) => ({
couplingKey: edge.couplingKey,
functionIds: edge.functionIds,
kind: "prototype-owner" as const,
}));
}
/** Produces a stable SHA-256 for a generated text payload. */
function sha256(value: string): string {
return createHash("sha256").update(value).digest("hex");
}
/** Validates the three disjoint reviewer outputs and indexes their 627 decisions. */
function validateModuleOwnerReviews(
reviewPack: Live2DModuleReviewPackManifest,
results: readonly Live2DModuleOwnerReviewResult[],
): Map<string, Live2DModuleOwnerReviewRow & { partitionId: string }> {
if (results.length !== reviewPack.partitions.length) {
throw new Error("Live2D module review result partition count mismatch");
}
const decisions = new Map<
string,
Live2DModuleOwnerReviewRow & { partitionId: string }
>();
for (const partition of reviewPack.partitions) {
const result = results.find(
(candidate) => candidate.partitionId === partition.partitionId,
);
if (!result) {
throw new Error(`Missing Live2D module review result: ${partition.partitionId}`);
}
if (
(result.schemaVersion !== 1 &&
result.schemaVersion !== "live2d-module-owner-review-result/v1") ||
result.sourceSha256 !== reviewPack.sourceSha256 ||
result.rowCount !== partition.rowCount ||
result.functionIdsSha256 !== partition.functionIdsSha256 ||
result.rowsSha256 !== partition.rowsSha256 ||
result.reviewedRows.length !== partition.rows.length
) {
throw new Error(`Live2D module review seals mismatch: ${partition.partitionId}`);
}
const expectedById = new Map(
partition.rows.map((row) => [row.functionId, row]),
);
for (const row of result.reviewedRows) {
const expected = expectedById.get(row.functionId);
if (!expected || decisions.has(row.functionId)) {
throw new Error(`Duplicate or foreign Live2D module review row: ${row.functionId}`);
}
if (
row.reviewStatus !== "reviewed" ||
row.reason.trim().length === 0 ||
row.evidence.length === 0
) {
throw new Error(`Incomplete Live2D module review row: ${row.functionId}`);
}
if (expected.ownerCandidate === null && expected.reachability !== "reachable") {
if (row.decision !== "omitted-unreachable" || row.ownerModule !== null) {
throw new Error(`Reachability mismatch in module review: ${row.functionId}`);
}
} else if (row.ownerModule === null || row.decision === "omitted-unreachable") {
throw new Error(`Reachable function lacks module owner: ${row.functionId}`);
} else if (
row.decision === "accept-owner" &&
row.ownerModule !== expected.ownerCandidate
) {
throw new Error(`Accepted owner differs from candidate: ${row.functionId}`);
} else if (
row.decision === "change-owner" &&
row.ownerModule === expected.ownerCandidate
) {
throw new Error(`Changed owner still equals candidate: ${row.functionId}`);
}
decisions.set(row.functionId, {
...row,
partitionId: partition.partitionId,
});
}
}
if (decisions.size !== reviewPack.summary.functionCount) {
throw new Error(`Live2D module review coverage is ${decisions.size}/627`);
}
return decisions;
}
/** Rebuilds module dependency rows after applying the reviewed owner decisions. */
function buildReviewedModules(
functions: Array<
Live2DModuleSplitCatalog["functions"][number] & {
ownerReview: Live2DModuleOwnerReviewRow & { partitionId: string };
}
>,
graph: Live2DModuleGraphAnalysis,
): Array<Record<string, unknown>> {
const functionById = new Map(functions.map((entry) => [entry.functionId, entry]));
const modules = new Map<
string,
{
clusterIds: Set<string>;
couplingKeys: Set<string>;
couplingModules: Set<string>;
dependencyModules: Set<string>;
implementationFunctionIds: string[];
ownedFunctionIds: string[];
}
>();
for (const entry of functions) {
const modulePaths = new Set([
...(entry.ownerModule ? [entry.ownerModule] : []),
...entry.implementationModules,
]);
for (const modulePath of modulePaths) {
const module = modules.get(modulePath) ?? {
clusterIds: new Set<string>(),
couplingKeys: new Set<string>(),
couplingModules: new Set<string>(),
dependencyModules: new Set<string>(),
implementationFunctionIds: [],
ownedFunctionIds: [],
};
module.clusterIds.add(entry.clusterId);
if (entry.implementationModules.includes(modulePath)) {
module.implementationFunctionIds.push(entry.functionId);
}
if (entry.ownerModule === modulePath) {
module.ownedFunctionIds.push(entry.functionId);
}
modules.set(modulePath, module);
}
if (entry.ownerModule) {
for (const implementationModule of entry.implementationModules) {
if (implementationModule !== entry.ownerModule) {
modules
.get(entry.ownerModule)!
.dependencyModules.add(implementationModule);
}
}
}
}
for (const edge of graph.callEdges) {
const caller = functionById.get(edge.callerFunctionId)?.ownerModule;
const target = functionById.get(edge.targetFunctionId)?.ownerModule;
if (caller && target && caller !== target) {
modules.get(caller)!.dependencyModules.add(target);
}
}
for (const edge of graph.couplingEdges) {
const owners = [
...new Set(
edge.functionIds
.map((functionId) => functionById.get(functionId)?.ownerModule)
.filter((owner): owner is string => owner !== null && owner !== undefined),
),
];
for (const owner of owners) {
const module = modules.get(owner)!;
module.couplingKeys.add(edge.couplingKey);
for (const peer of owners) {
if (peer !== owner) {
module.couplingModules.add(peer);
}
}
}
}
return [...modules.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.map(([modulePath, module]) => ({
clusterIds: [...module.clusterIds].sort(),
couplingKeys: [...module.couplingKeys].sort(),
couplingModules: [...module.couplingModules].sort(),
dependencyModules: [...module.dependencyModules].sort(),
implementationFunctionIds: module.implementationFunctionIds.sort(
(left, right) =>
functionById.get(left)!.sourceOrder - functionById.get(right)!.sourceOrder,
),
ownedFunctionIds: module.ownedFunctionIds.sort(
(left, right) =>
functionById.get(left)!.sourceOrder - functionById.get(right)!.sourceOrder,
),
modulePath,
}));
}
/** Builds the durable reviewed split manifest with ownership and initialization evidence. */
function buildReviewedModuleSplitManifest(
reviewPack: Live2DModuleReviewPackManifest,
draft: Live2DModuleSplitCatalog,
graph: Live2DModuleGraphAnalysis,
bindingAnalysis: ReturnType<typeof analyzeLive2DSourceBindings>,
bootstrapAnalysis: ReturnType<typeof analyzeLive2DBootstrapGraph>,
results: readonly Live2DModuleOwnerReviewResult[],
): Record<string, unknown> {
const decisions = validateModuleOwnerReviews(reviewPack, results);
const functions = draft.functions.map((entry) => {
const ownerReview = decisions.get(entry.functionId)!;
const ownerModule = ownerReview.ownerModule;
return {
...entry,
ownerModule,
ownerReview,
ownershipStatus:
ownerModule === null ? ("omitted-unreachable" as const) : ("owned" as const),
};
});
const criticalOrImportant = results.flatMap((result) => result.findings).filter(
(finding) =>
finding.severity === "Critical" || finding.severity === "Important",
);
if (criticalOrImportant.length > 0) {
throw new Error("Live2D module owner review has blocking findings");
}
const modules = buildReviewedModules(functions, graph);
if (modules.length !== draft.modules.length) {
throw new Error(
`Live2D reviewed manifest lost implementation modules: ${modules.length}/${draft.modules.length}`,
);
}
return {
bootstrap: bootstrapAnalysis,
functions,
graphSummary: graph.summary,
modules,
reviews: results.map((result) => ({
findings: result.findings,
functionIdsSha256: result.functionIdsSha256,
partitionId: result.partitionId,
rowCount: result.rowCount,
rowsSha256: result.rowsSha256,
summary: result.summary,
})),
schemaVersion: "live2d-module-splits/v1",
sharedImplementations: draft.sharedImplementations,
sourceBindings: bindingAnalysis,
sourcePath: reviewPack.sourcePath,
sourceSha256: reviewPack.sourceSha256,
summary: {
blockingFindingCount: criticalOrImportant.length,
functionCount: functions.length,
moduleCount: modules.length,
ownerModuleCount: new Set(
functions.map((entry) => entry.ownerModule).filter(Boolean),
).size,
omittedFunctionCount: functions.filter((entry) => entry.ownerModule === null)
.length,
ownedFunctionCount: functions.filter((entry) => entry.ownerModule !== null)
.length,
reviewedFunctionCount: decisions.size,
sharedImplementationCount: draft.sharedImplementations.length,
},
};
}
/** Adds source-sealed semantic migration state to every reviewed owner decision. */
function attachModuleMigrationState(
manifest: Record<string, unknown>,
source: string,
sourceFunctions: ReturnType<typeof analyzeLive2DMinJsSource>["functions"],
options: CliOptions,
): Record<string, unknown> {
const migrationAudit = auditLive2DModuleMigrations({
decisions: readJson<Live2DModuleMigrationDecisionCatalog>(options.migrationDecisionPath),
implementationRoot: options.implementationRoot,
moduleManifest: manifest as unknown as Parameters<
typeof auditLive2DModuleMigrations
>[0]["moduleManifest"],
source,
sourceFunctions,
workspaceRoot,
});
const migrationByFunctionId = new Map(
migrationAudit.functions.map((entry) => [entry.functionId, entry.migrationStatus]),
);
const functions = (manifest.functions as Array<Record<string, unknown>>).map((entry) => ({
...entry,
migrationStatus: migrationByFunctionId.get(String(entry.functionId)),
}));
return {
...manifest,
functions,
schemaVersion: "live2d-module-splits/v2",
summary: {
...(manifest.summary as Record<string, unknown>),
migratedFunctionCount: migrationAudit.summary.migrated,
omittedMigrationFunctionCount: migrationAudit.summary.omittedUnreachable,
pendingMigrationFunctionCount: migrationAudit.summary.pending,
},
};
}
/** Generates all source-sealed Task 4 audits and the exhaustive three-way review pack. */
function main(): void {
const options = parseArgs(process.argv.slice(2));
const source = readFileSync(options.sourcePath, "utf8");
const sourceLabel = path
.relative(workspaceRoot, options.sourcePath)
.replaceAll("\\", "/");
const sourceAudit = analyzeLive2DMinJsSource(source, {
sourcePath: sourceLabel,
});
const functionCatalog = buildFunctionNameReviewCatalog(
sourceAudit,
buildLegacyFunctionRenameCatalog(sourceAudit, []),
readJson<Live2DFunctionNameDecisionCatalog>(options.functionDecisionPath),
);
const implementationRoot = path.resolve(options.implementationRoot);
const implementationRootPrefix = `${implementationRoot}${path.sep}`;
const missingImplementationTargets = findMissingLive2DImplementationTargets(
functionCatalog.functions,
(modulePath) => {
const implementationPath = path.resolve(implementationRoot, modulePath);
if (!implementationPath.startsWith(implementationRootPrefix)) {
return null;
}
return readFileSync(implementationPath, "utf8");
},
);
if (missingImplementationTargets.length > 0) {
throw new Error(
`Live2D reviewed implementation targets are missing: ${missingImplementationTargets
.map((entry) => `${entry.functionId} -> ${entry.targetSymbol} (${entry.reason})`)
.join(", ")}`,
);
}
const callCatalog = buildCallResolutionReviewCatalog(
sourceAudit,
functionCatalog,
readJson<Live2DCallResolutionDecisionCatalog>(options.callDecisionPath),
);
const bindingAnalysis = analyzeLive2DSourceBindings(
source,
sourceAudit,
functionCatalog,
);
const bootstrapAnalysis = analyzeLive2DBootstrapGraph(
source,
sourceAudit,
functionCatalog,
callCatalog,
);
const graph = buildLive2DModuleGraphAnalysis(
functionCatalog,
callCatalog,
[
...bindingAnalysis.couplingEdges,
...buildBootstrapStructuralCouplingEdges(bootstrapAnalysis),
],
);
const draft = buildLive2DModuleSplitCatalog(functionCatalog, graph);
const validation = validateLive2DModuleSplitCatalog(
functionCatalog,
graph,
draft,
);
const reviewPack = buildLive2DModuleReviewPack(
functionCatalog,
graph,
draft,
);
writeJson(options.bindingOutputPath, bindingAnalysis);
writeJson(options.bootstrapOutputPath, bootstrapAnalysis);
writeJson(options.graphOutputPath, graph);
writeJson(options.draftOutputPath, draft);
writeText(options.reviewJsonOutputPath, reviewPack.json);
writeText(options.reviewMarkdownOutputPath, reviewPack.markdown);
let finalOutput: { path: string; sha256: string } | null = null;
if (options.finalOutputPath) {
const reviewedManifest = attachModuleMigrationState(
buildReviewedModuleSplitManifest(
reviewPack.manifest,
draft,
graph,
bindingAnalysis,
bootstrapAnalysis,
options.reviewResultPaths.map((filePath) =>
readJson<Live2DModuleOwnerReviewResult>(filePath),
),
),
source,
sourceAudit.functions,
options,
);
const reviewedJson = `${JSON.stringify(reviewedManifest, null, 2)}\n`;
writeText(options.finalOutputPath, reviewedJson);
finalOutput = {
path: options.finalOutputPath,
sha256: sha256(reviewedJson),
};
}
console.log(
JSON.stringify(
{
bindingSummary: bindingAnalysis.summary,
bootstrapSummary: bootstrapAnalysis.summary,
graphSummary: graph.summary,
moduleValidation: validation,
outputs: {
binding: options.bindingOutputPath,
bootstrap: options.bootstrapOutputPath,
draft: options.draftOutputPath,
graph: options.graphOutputPath,
reviewJson: options.reviewJsonOutputPath,
reviewMarkdown: options.reviewMarkdownOutputPath,
reviewedManifest: finalOutput,
},
reviewPack: {
jsonSha256: reviewPack.jsonSha256,
markdownSha256: reviewPack.markdownSha256,
summary: reviewPack.manifest.summary,
},
sourceSha256: sourceAudit.sourceSha256,
},
null,
2,
),
);
}
main();