#!/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(); 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 ] [--output ] [--formatted-source ] [--internal-root-source ] [--catalog-output ] [--review-catalog-output ] [--decisions ] [--call-decisions ] [--call-review-catalog-output ] [--review-packet ] 默认输入: ${defaultSourcePath} 默认输出: ${defaultOutputPath} 函数名目录: ${defaultCatalogOutputPath} 函数名复审骨架: ${defaultReviewCatalogOutputPath} 长期复审决策: ${defaultDecisionCatalogPath} 调用复审骨架: ${defaultCallReviewCatalogOutputPath} 长期调用决策: ${defaultCallDecisionCatalogPath} 可选 Context Packet 门禁: --review-packet 校验函数表的 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 = {}; 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; entries: number; pending: number; pendingOwnerCounts: Record; 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); }