ktworkflow-mcp/scripts/audit-live2d-minjs.ts

391 lines
14 KiB
JavaScript

#!/usr/bin/env node
import { existsSync, 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,
type Live2DFunctionReviewPacketValidation,
type LegacyFunctionRenameEvidence,
validateLive2DFunctionReviewPacket,
} from "../src/tools/deobfuscation.js";
interface CliOptions {
callDecisionCatalogPath: string;
callReviewCatalogOutputPath: string;
catalogOutputPath: string;
decisionCatalogPath: string;
formattedSourcePath: string;
internalRootSourcePath: string;
outputPath: string;
renameMapPaths: string[];
reviewCatalogOutputPath: string;
reviewPacketPath: string | null;
sourcePath: string;
}
const dirname = path.dirname(fileURLToPath(import.meta.url));
const workspaceRoot = path.resolve(
process.env.KT_WORKSPACE_ROOT || path.join(dirname, "../../.."),
);
const defaultSourcePath =
"Vue/kt-blog-web/public/live2d/wordpress-moc/live2d.min.js";
const defaultOutputPath =
".kt-workspace/deobfuscate/blog-live2d-cubism2-minjs/live2d.min/source-structure-audit.json";
const defaultFormattedSourcePath =
".kt-workspace/deobfuscate/blog-live2d-cubism2-minjs/live2d.min/formatted.js";
const defaultCatalogOutputPath =
".kt-workspace/deobfuscate/blog-live2d-cubism2-minjs/live2d.min/function-name-catalog.json";
const defaultReviewCatalogOutputPath =
".kt-workspace/deobfuscate/blog-live2d-cubism2-minjs/live2d.min/function-name-review-catalog.generated.json";
const defaultDecisionCatalogPath =
"docs/live2d-deobfuscation/function-name-decisions.json";
const defaultCallDecisionCatalogPath =
"docs/live2d-deobfuscation/call-resolutions.json";
const defaultCallReviewCatalogOutputPath =
".kt-workspace/deobfuscate/blog-live2d-cubism2-minjs/live2d.min/call-review-catalog.generated.json";
const defaultInternalRootSourcePath =
".kt-workspace/deobfuscate/blog-live2d-cubism2-minjs/live2d.min/core-only-pass1.js";
const defaultPublicRenameMapPath =
".kt-workspace/deobfuscate/blog-live2d-cubism2-minjs/live2d.min/renames-public-roots.json";
const defaultInternalRenameMapPath =
".kt-workspace/deobfuscate/blog-live2d-cubism2-minjs/live2d.min/renames-internal-roots.json";
/**
* Resolves a CLI path against the KT workspace so the command behaves identically from local and NAS checkouts.
* @param value Absolute path or KT-workspace-relative path.
* @returns Normalized absolute path.
*/
function resolveWorkspacePath(value: string): string {
return path.isAbsolute(value) ? value : path.resolve(workspaceRoot, value);
}
/**
* Parses the bounded source/output CLI surface for the reproducible Live2D source audit.
* @param argv Command-line arguments after the executable and script path.
* @returns Resolved source and generated-evidence paths.
*/
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) {
if (key === "review-packet" && inlineValue.trim().length === 0) {
throw new Error("--review-packet requires a workspace-relative or absolute path");
}
args.set(key, inlineValue);
continue;
}
const next = argv[index + 1];
if (key === "review-packet" && (!next || next.startsWith("--"))) {
throw new Error("--review-packet requires a workspace-relative or absolute path");
}
if (next && !next.startsWith("--")) {
args.set(key, next);
index += 1;
}
}
if (args.has("help") || args.has("h")) {
printHelp();
process.exit(0);
}
return {
callDecisionCatalogPath: resolveWorkspacePath(
args.get("call-decisions") ?? defaultCallDecisionCatalogPath,
),
callReviewCatalogOutputPath: resolveWorkspacePath(
args.get("call-review-catalog-output") ??
defaultCallReviewCatalogOutputPath,
),
catalogOutputPath: resolveWorkspacePath(
args.get("catalog-output") ?? defaultCatalogOutputPath,
),
decisionCatalogPath: resolveWorkspacePath(
args.get("decisions") ?? defaultDecisionCatalogPath,
),
formattedSourcePath: resolveWorkspacePath(
args.get("formatted-source") ?? defaultFormattedSourcePath,
),
internalRootSourcePath: resolveWorkspacePath(
args.get("internal-root-source") ?? defaultInternalRootSourcePath,
),
outputPath: resolveWorkspacePath(args.get("output") ?? defaultOutputPath),
renameMapPaths: [
resolveWorkspacePath(defaultPublicRenameMapPath),
resolveWorkspacePath(defaultInternalRenameMapPath),
],
reviewCatalogOutputPath: resolveWorkspacePath(
args.get("review-catalog-output") ?? defaultReviewCatalogOutputPath,
),
reviewPacketPath: args.has("review-packet")
? resolveWorkspacePath(args.get("review-packet") ?? "")
: null,
sourcePath: resolveWorkspacePath(args.get("source") ?? defaultSourcePath),
};
}
/**
* Prints the stable command contract without exposing generated evidence outside `.kt-workspace` by default.
*/
function printHelp(): void {
console.log(`KT Live2D min.js 源码结构审计
用法:
pnpm run audit-live2d-minjs -- [--source <path>] [--output <path>] [--formatted-source <path>] [--internal-root-source <path>] [--catalog-output <path>] [--review-catalog-output <path>] [--decisions <path>] [--call-decisions <path>] [--call-review-catalog-output <path>] [--review-packet <path>]
默认输入:
${defaultSourcePath}
默认输出:
${defaultOutputPath}
函数名目录:
${defaultCatalogOutputPath}
函数名复审骨架:
${defaultReviewCatalogOutputPath}
长期复审决策:
${defaultDecisionCatalogPath}
调用复审骨架:
${defaultCallReviewCatalogOutputPath}
长期调用决策:
${defaultCallDecisionCatalogPath}
可选 Context Packet 门禁:
--review-packet <path> 校验函数表的 order/name/byte span/line span/SyntaxKind/functionId/snippetSha256
`);
}
/**
* Reads the compact durable function decision catalog; semantic validation runs in the catalog builder.
* @param decisionCatalogPath Versioned JSON path under `docs/live2d-deobfuscation`.
* @returns Parsed reviewed decision catalog.
*/
function readFunctionNameDecisions(
decisionCatalogPath: string,
): Live2DFunctionNameDecisionCatalog {
const parsed = JSON.parse(
readFileSync(decisionCatalogPath, "utf8"),
) as Live2DFunctionNameDecisionCatalog;
if (parsed.schemaVersion !== 1 || !Array.isArray(parsed.decisions)) {
throw new Error(`Invalid Live2D function decision catalog: ${decisionCatalogPath}`);
}
return parsed;
}
/**
* Reads the compact durable call decision catalog; source/caller/target validation runs in the catalog builder.
* @param decisionCatalogPath Versioned JSON path under `docs/live2d-deobfuscation`.
* @returns Parsed reviewed-only call decision catalog.
*/
function readCallResolutionDecisions(
decisionCatalogPath: string,
): Live2DCallResolutionDecisionCatalog {
const parsed = JSON.parse(
readFileSync(decisionCatalogPath, "utf8"),
) as Live2DCallResolutionDecisionCatalog;
if (parsed.schemaVersion !== 1 || !Array.isArray(parsed.decisions)) {
throw new Error(`Invalid Live2D call decision catalog: ${decisionCatalogPath}`);
}
return parsed;
}
/**
* Reads one historical direct `symbol@offset -> semanticName` map and rejects nested/ambiguous schemas.
* @param renameMapPath Rename evidence file created by the initial public/internal root passes.
* @returns Typed evidence source for raw-source function ID migration.
*/
function readLegacyRenameMap(
renameMapPath: string,
checkpointPath: string,
): LegacyFunctionRenameEvidence {
const parsed = JSON.parse(readFileSync(renameMapPath, "utf8")) as unknown;
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
throw new Error(`Legacy rename map must be an object: ${renameMapPath}`);
}
const renames: Record<string, string> = {};
for (const [key, value] of Object.entries(parsed)) {
if (typeof value !== "string") {
throw new Error(`Legacy rename map values must be strings: ${renameMapPath}#${key}`);
}
renames[key] = value;
}
return {
checkpointAudit: analyzeLive2DMinJsSource(
readFileSync(checkpointPath, "utf8"),
{
allowCoreOnly: true,
sourcePath: path.relative(workspaceRoot, checkpointPath).replaceAll("\\", "/"),
},
),
evidencePath: path.relative(workspaceRoot, renameMapPath).replaceAll("\\", "/"),
renames,
};
}
/**
* Reads the immutable min.js source, writes the AST-derived inventory/call graph, and prints a bounded summary.
*/
function main(): void {
const options = parseArgs(process.argv.slice(2));
if (options.reviewPacketPath) {
const requiredPacketEvidencePaths = [
options.decisionCatalogPath,
options.formattedSourcePath,
options.internalRootSourcePath,
...options.renameMapPaths,
];
const missingPacketEvidencePaths = requiredPacketEvidencePaths.filter(
(requiredPath) => !existsSync(requiredPath),
);
if (missingPacketEvidencePaths.length > 0) {
throw new Error(
`Live2D review-packet validation requires current decision/catalog evidence: ${missingPacketEvidencePaths.join(", ")}`,
);
}
}
const source = readFileSync(options.sourcePath, "utf8");
const audit = analyzeLive2DMinJsSource(source, {
sourcePath: path.relative(workspaceRoot, options.sourcePath).replaceAll("\\", "/"),
});
mkdirSync(path.dirname(options.outputPath), { recursive: true });
writeFileSync(options.outputPath, `${JSON.stringify(audit, null, 2)}\n`, "utf8");
let functionCatalogSummary: {
candidate: number;
candidateOwnerCounts: Record<string, number>;
entries: number;
pending: number;
pendingOwnerCounts: Record<string, number>;
reviewed: number;
skipped: number;
total: number;
} | null = null;
let callCatalogSummary: {
decisions: number;
pending: number;
reviewed: number;
total: number;
} | null = null;
let reviewPacketValidation: Live2DFunctionReviewPacketValidation | null = null;
if (
existsSync(options.formattedSourcePath) &&
existsSync(options.internalRootSourcePath) &&
options.renameMapPaths.every(existsSync)
) {
const functionCatalog = buildLegacyFunctionRenameCatalog(
audit,
[
readLegacyRenameMap(
options.renameMapPaths[0],
options.formattedSourcePath,
),
readLegacyRenameMap(
options.renameMapPaths[1],
options.internalRootSourcePath,
),
],
);
mkdirSync(path.dirname(options.catalogOutputPath), { recursive: true });
writeFileSync(
options.catalogOutputPath,
`${JSON.stringify(functionCatalog, null, 2)}\n`,
"utf8",
);
const reviewCatalog = buildFunctionNameReviewCatalog(
audit,
functionCatalog,
existsSync(options.decisionCatalogPath)
? readFunctionNameDecisions(options.decisionCatalogPath)
: undefined,
);
mkdirSync(path.dirname(options.reviewCatalogOutputPath), { recursive: true });
writeFileSync(
options.reviewCatalogOutputPath,
`${JSON.stringify(reviewCatalog, null, 2)}\n`,
"utf8",
);
const callDecisionCatalog = existsSync(options.callDecisionCatalogPath)
? readCallResolutionDecisions(options.callDecisionCatalogPath)
: undefined;
const callReviewCatalog = buildCallResolutionReviewCatalog(
audit,
reviewCatalog,
callDecisionCatalog,
);
mkdirSync(path.dirname(options.callReviewCatalogOutputPath), {
recursive: true,
});
writeFileSync(
options.callReviewCatalogOutputPath,
`${JSON.stringify(callReviewCatalog, null, 2)}\n`,
"utf8",
);
callCatalogSummary = {
decisions: callDecisionCatalog?.decisions.length ?? 0,
pending: callReviewCatalog.summary.pending,
reviewed: callReviewCatalog.summary.reviewed,
total: callReviewCatalog.summary.total,
};
if (options.reviewPacketPath) {
reviewPacketValidation = validateLive2DFunctionReviewPacket(
readFileSync(options.reviewPacketPath, "utf8"),
reviewCatalog,
source,
);
}
functionCatalogSummary = {
candidate: reviewCatalog.summary.candidate,
candidateOwnerCounts: reviewCatalog.summary.candidateOwnerCounts,
entries: functionCatalog.entries.length,
pending: reviewCatalog.summary.pending,
pendingOwnerCounts: reviewCatalog.summary.pendingOwnerCounts,
reviewed: reviewCatalog.summary.reviewed,
skipped: functionCatalog.skipped.length,
total: reviewCatalog.summary.total,
};
}
if (options.reviewPacketPath && reviewPacketValidation === null) {
throw new Error(
"Live2D review-packet validation requires the formatted source, internal root source, and rename maps",
);
}
console.log(
JSON.stringify(
{
core: audit.core,
callCatalog: callCatalogSummary,
functionCatalog: functionCatalogSummary,
ok: audit.summary.parseDiagnosticCount === 0,
outputPath: options.outputPath,
reviewPacket: reviewPacketValidation,
sourcePath: options.sourcePath,
sourceSha256: audit.sourceSha256,
summary: audit.summary,
},
null,
2,
),
);
}
try {
main();
} catch (error) {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
}