Files
rea/src/application/javascript/JavaScriptArtifactReconstruction.ts
T
morluto b58adafa4f fix(javascript): align module lookup and source evidence
Preserve CommonJS literal paths and confined ESM URL semantics, including package main fallback, directory intent, Unicode identity, and present opaque modules. Parse extraction inputs at their AST/source owner, retain skipped HTML and source-map coverage, and distinguish endpoint candidates from proven receiver facts. Version structural fingerprints with computed literal contents and propagate them through worker contracts.

Reviewed fixes include #1810, #1840, #2642, #2700, #2702, #2714, #2803, #2960, #2968, #2986, #3035, and #3036.

Validation: 158 resolver tests passed; public CLI/MCP module lookup controls, package-main/Unicode workflows, and six builtin workflows passed in the current compiled lane. Worker schema/fingerprint regressions passed. Large public pdf.js completion and real Electron analysis remain unverified.
2026-10-14 08:46:50 +08:00

393 lines
15 KiB
TypeScript

import { constants } from "node:fs";
import { access, lstat, realpath } from "node:fs/promises";
import { setImmediate } from "node:timers/promises";
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
import {
AnalysisAccessDeniedError,
AnalysisInputError,
AnalysisUnsupportedTargetError,
} from "../../domain/analysisErrorCore.js";
import { createJavaScriptArtifactReader as createReader } from "../../artifacts/javascript/JavaScriptArtifactReader.js";
import type { JavaScriptApplicationGraph } from "../../domain/javascript/javascriptApplicationGraph.js";
import type { JavaScriptSemanticGraph } from "../../domain/javascript/javascriptSemanticGraph.js";
import type { JavaScriptSourceSelection } from "../../domain/javascript/javascriptSourceSelection.js";
import type { ElectronBoundarySummary } from "../../domain/javascript/javascriptApplicationAnalysis.js";
import { parseOwnedJavaScriptApplicationAnalysisSteps } from "../../domain/javascript/javascriptApplicationAnalysis.js";
import { AnalysisError } from "../../domain/analysisErrorBase.js";
import { ArtifactOperationError } from "../../domain/artifactOperationError.js";
import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js";
import type { ArtifactInventoryPartialObservation } from "../../domain/artifactPartialObservation.js";
import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
import { analyzeAndAdoptJavaScriptArtifactFiles } from "./JavaScriptArtifactAnalysis.js";
import { createJavaScriptSourceAnalysis } from "../../artifacts/javascript/JavaScriptSourceAnalysis.js";
import { JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS } from "../../domain/javascript/javascriptAnalysisResourceControls.js";
import { createOwnedJavaScriptApplicationEvidenceCooperatively } from "./JavaScriptApplicationEvidence.js";
import { readJavaScriptArtifactFiles } from "../../artifacts/javascript/JavaScriptArtifactFiles.js";
import { buildImmutableJavaScriptArtifactGraphSteps } from "./JavaScriptArtifactGraphBuilder.js";
import {
javascriptArtifactReconstructionInputSchema,
type JavaScriptArtifactReconstructionInput,
} from "./JavaScriptArtifactReconstructionInput.js";
import { scanCanonicalArtifactInventoryInScope } from "../../artifacts/inventory/scanCanonical.js";
import { summarizeElectronBoundaries } from "./ElectronBoundaryAnalysis.js";
import { createJavaScriptSemanticGraphProjection } from "./JavaScriptSemanticGraphBuilder.js";
import type { ProgressReporter } from "../ProgressReporter.js";
import {
checkpointJavaScriptAnalysis,
completeJavaScriptAnalysisSteps,
} from "./JavaScriptAnalysisControl.js";
/** Application-layer result retaining local diagnostics outside the canonical graph. */
export interface JavaScriptArtifactReconstructionResult {
readonly input_path: string;
readonly format: "asar" | "directory";
readonly root_artifact_sha256: string;
readonly inventory_manifest_id: string;
readonly inventory_graph_sha256: string;
readonly integrity_contradictions: ArtifactInventorySnapshot["integrity_contradictions"];
readonly source_selection?: JavaScriptSourceSelection;
readonly graph: JavaScriptApplicationGraph;
readonly semantic_graph: JavaScriptSemanticGraph;
readonly electron_summary: ElectronBoundarySummary;
readonly statistics: {
readonly relevant_files: number;
readonly nested_asar_containers: number;
readonly text_bytes_read: number;
readonly invalid_utf8_files: number;
readonly parsed_javascript_files: number;
readonly visited_ast_nodes: number;
readonly findings: number;
readonly modules: number;
readonly parse_failures: number;
};
readonly limitations: readonly string[];
}
/** Reconstruct one local ASAR or extracted directory without executing code. */
export const reconstructJavaScriptArtifact = async (
rawInput: unknown,
scope: ArtifactResourceScope,
signal?: AbortSignal,
progress?: ProgressReporter,
): Promise<JavaScriptArtifactReconstructionResult> =>
scope.run(async () => {
const reportPhase = async (
phase: string,
message: string,
): Promise<void> => {
await progress?.report({
phase,
completed: 0,
total: 1,
message,
});
await checkpointJavaScriptAnalysis(signal);
};
const input = javascriptArtifactReconstructionInputSchema.parse(rawInput);
abortIfNeeded(signal);
const path = await resolveSelectedInput(input.input_path);
const format = await resolveFormat(path, input);
const snapshot = await scanCanonicalArtifactInventoryInScope(path, {
resourceScope: scope,
signal,
integrity: { mode: input.integrity_policy },
});
if (snapshot.manifest.root_format !== format)
throw new ArtifactReaderFailure(
"format",
`Artifact inventory classified ${path} as ${snapshot.manifest.root_format}, not ${format}`,
);
const reader = createReader(path, format);
const readerOwner = {
kind: "reader" as const,
reader,
resource: `JavaScript artifact reader for ${path}`,
};
let readerOpen = true;
let reconstructionOutcome:
| {
readonly kind: "completed";
readonly result: JavaScriptArtifactReconstructionResult;
}
| { readonly kind: "failed"; readonly cause: unknown };
try {
await reportPhase(
"read_javascript_artifacts",
"Reading inventoried JavaScript application sources",
);
const files = await readJavaScriptArtifactFiles(
reader,
snapshot,
scope,
signal,
input.source_paths,
);
// Release acquisition before analysis consumes these owned file facts.
const earlyCleanup = await scope.release(readerOwner);
readerOpen = false;
if (earlyCleanup.kind === "failed")
throw ArtifactReaderFailure.withCleanup(
earlyCleanup.cause,
ArtifactReaderFailure.cleanupObservation(
earlyCleanup.cause,
readerOwner.resource,
),
{ kind: "artifact-inventory", inventory: snapshot },
);
await reportPhase(
"parse_javascript_sources",
`Parsing and projecting ${String(files.files.length - (files.source_selection?.unselected_files ?? 0))} selected application source files`,
);
const semanticProjection = createJavaScriptSemanticGraphProjection({
...(input.max_semantic_nodes === undefined
? {}
: { totalNodes: input.max_semantic_nodes }),
...(input.max_semantic_nodes_per_file === undefined
? {}
: { fileNodes: input.max_semantic_nodes_per_file }),
});
const worker = createJavaScriptSourceAnalysis(
{
heapMb:
input.max_heap_mb ?? JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS.heapMb,
timeoutMs:
input.analysis_timeout_ms ??
JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS.timeoutMs,
},
files.files.filter(
(file) => file.kind === "javascript" && file.text.included,
).length,
);
let outcome;
let cleanupFailure: AnalysisError | undefined;
try {
outcome = await analyzeAndAdoptJavaScriptArtifactFiles(
files,
worker,
semanticProjection,
async (file, completed, total) => {
abortIfNeeded(signal);
await progress?.report({
phase: "parse_javascript_source",
completed: 0,
total: 1,
message: `Parsing and projecting ${file.path} (${String(completed + 1)}/${String(total)})`,
});
abortIfNeeded(signal);
},
signal,
);
} finally {
try {
const cleanup = await scope.release({
kind: "javascript-source-analysis",
analysis: worker,
resource: `JavaScript source analyzer for ${path}`,
});
if (cleanup.kind === "failed") throw cleanup.cause;
} catch (cause: unknown) {
if (!(cause instanceof AnalysisError)) throw cause;
cleanupFailure = cause;
}
}
const analysis = outcome.analysis;
let interruption = cleanupFailure ?? outcome.interruption;
// Source facts are owned now. Finish their projection cooperatively so a
// later cancellation can retain Evidence instead of discarding those facts.
const checkpointCompletedAnalysis = async (): Promise<void> => {
await setImmediate();
if (interruption == null && signal?.aborted === true)
interruption = new ArtifactOperationError(OPERATION, "cancelled");
};
const reportCompletedPhase = async (
phase: string,
message: string,
): Promise<void> => {
await checkpointCompletedAnalysis();
if (interruption == null)
await progress?.report({ phase, completed: 0, total: 1, message });
await checkpointCompletedAnalysis();
};
await reportCompletedPhase(
"build_javascript_application_graph",
"Constructing application and Electron boundary relationships",
);
const applicationGraphSteps = buildImmutableJavaScriptArtifactGraphSteps(
snapshot,
files,
analysis,
);
await reportCompletedPhase(
"seal_javascript_application_graph",
"Sealing the validated application graph",
);
const graph = await completeJavaScriptAnalysisSteps(
applicationGraphSteps,
);
await reportCompletedPhase(
"build_javascript_semantic_graph",
"Binding and validating static semantic relationships",
);
const semanticGraphSteps = semanticProjection.finishImmutableSteps(
snapshot.manifest.root_sha256,
graph,
);
await reportCompletedPhase(
"seal_javascript_semantic_graph",
"Validating and sealing the semantic graph",
);
const semanticGraph =
await completeJavaScriptAnalysisSteps(semanticGraphSteps);
await checkpointCompletedAnalysis();
const completed = {
input_path: path,
format,
root_artifact_sha256: snapshot.manifest.root_sha256,
inventory_manifest_id: snapshot.manifest.manifest_id,
inventory_graph_sha256: snapshot.manifest.graph_sha256,
integrity_contradictions: snapshot.integrity_contradictions,
...(files.source_selection === undefined
? {}
: { source_selection: files.source_selection }),
graph,
semantic_graph: semanticGraph,
electron_summary: summarizeElectronBoundaries(
analysis,
new Set(
snapshot.occurrences
.filter(({ entry_kind }) => entry_kind === "directory")
.map(({ logical_path }) => logical_path),
),
),
statistics: {
relevant_files: files.files.length,
nested_asar_containers: files.containers.length,
text_bytes_read: files.text_bytes_read,
invalid_utf8_files: files.invalid_utf8_files,
parsed_javascript_files: analysis.files.filter(
({ javascript }) => javascript !== null,
).length,
visited_ast_nodes: analysis.visited_ast_nodes,
findings: analysis.findings,
modules: analysis.modules,
parse_failures: analysis.parse_failures,
},
limitations: analysis.limitations,
};
if (interruption !== null && interruption !== undefined) {
const { electron_summary: summary, ...application } = completed;
const partial = await completeJavaScriptAnalysisSteps(
parseOwnedJavaScriptApplicationAnalysisSteps({
...application,
summary,
limitations: completed.graph.limitations,
}),
);
const evidence =
await createOwnedJavaScriptApplicationEvidenceCooperatively(
input,
partial,
);
throw interruption.retainPartialObservation(evidence);
}
reconstructionOutcome = { kind: "completed", result: completed };
} catch (cause: unknown) {
reconstructionOutcome = { kind: "failed", cause };
}
if (readerOpen) {
const cleanupAttempt = await scope.release(readerOwner);
if (cleanupAttempt.kind === "failed") {
const cleanup = ArtifactReaderFailure.cleanupObservation(
cleanupAttempt.cause,
readerOwner.resource,
);
const partialObservation: ArtifactInventoryPartialObservation = {
kind: "artifact-inventory",
inventory: snapshot,
};
throw ArtifactReaderFailure.withCleanup(
reconstructionOutcome.kind === "failed"
? reconstructionOutcome.cause
: cleanupAttempt.cause,
cleanup,
partialObservation,
);
}
}
if (reconstructionOutcome.kind === "failed")
throw reconstructionOutcome.cause;
return reconstructionOutcome.result;
});
const OPERATION = "analyze_javascript_application";
/**
* Resolve the caller-selected input. A missing or unreadable selection is a
* caller or host-permission failure, not a damaged artifact.
*/
const resolveSelectedInput = async (inputPath: string): Promise<string> => {
try {
const path = await realpath(inputPath);
await access(path, constants.R_OK);
return path;
} catch (cause: unknown) {
const code =
cause instanceof Error && "code" in cause ? String(cause.code) : "";
if (code === "EACCES" || code === "EPERM")
throw new AnalysisAccessDeniedError(OPERATION, inputPath, code, {
cause,
});
if (code === "ENOENT" || code === "ENOTDIR")
throw new AnalysisInputError(OPERATION, { cause }, [
{
path: ["input_path"],
reason: "invalid_value",
message: `No file or directory exists at the selected input path (${code}): ${inputPath}`,
},
]);
throw cause;
}
};
/** The selected input's kind, checked against the caller's requested format. */
const resolveFormat = async (
path: string,
input: JavaScriptArtifactReconstructionInput,
): Promise<"asar" | "directory"> => {
const metadata = await lstat(path);
const observed = metadata.isDirectory()
? "directory"
: metadata.isFile() && path.toLowerCase().endsWith(".asar")
? "asar"
: undefined;
if (observed === undefined)
throw new AnalysisUnsupportedTargetError(
OPERATION,
input.input_path,
"JavaScript application analysis accepts only a directory or an .asar file",
{
remediationAction:
"Select an extracted application directory, an app bundle, or an .asar file such as Contents/Resources/app.asar.",
},
);
if (input.format !== "auto" && input.format !== observed)
throw new AnalysisInputError(OPERATION, undefined, [
{
path: ["format"],
reason: "invalid_value",
message: `Requested ${input.format} input, but the selected input is ${observed === "asar" ? "an .asar file" : "a directory"}: ${input.input_path}`,
expected: ["auto", observed],
},
]);
return observed;
};
const abortIfNeeded = (signal?: AbortSignal): void => {
if (signal?.aborted === true)
throw new ArtifactReaderFailure(
"cancelled",
"JavaScript artifact reconstruction cancelled",
);
};