analyze_javascript_application ran several long phases with no event-loop turn, so MCP ping and cancellation waited: each file's parse, analysis and semantic projection; building every application node and relationship before the application graph's first sealing step; and whole-graph schema parses inside both immutable graph factories. - Each file runs as steps (parse, static analysis, semantic analysis, then each projection pass), driven by the existing time-sliced checkpoint. - Application graph input construction yields between phases, per module link and per file of findings. - The pipeline transfers its privately built graph inputs to new sealing steps that validate the graph's own fields synchronously and elements, content checks and the digest in steps. The public copying factories keep their synchronous capture. Results are unchanged: graph IDs and the complete result digest match main on five inputs including Obsidian's 23 MB obsidian.asar. On the issue's 1,200-function fixture the longest event-loop block falls from about 290 ms to 90 ms, and ping after abort from up to 286 ms to at most 56 ms. On obsidian.asar it falls from 10.9 s to 5.4 s; the rest is one analyzer stage of a single very large file. Refs #1462 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: N0zoM1z0 <161784452+N0zoM1z0@users.noreply.github.com>
456 lines
14 KiB
TypeScript
456 lines
14 KiB
TypeScript
import { posix } from "node:path";
|
|
|
|
import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js";
|
|
import type { ApplicationNode } from "../../domain/javascript/javascriptApplicationGraphSchemas.js";
|
|
import {
|
|
completeApplicationCoverage,
|
|
partialApplicationCoverage,
|
|
} from "../../domain/javascript/javascriptApplicationEvidenceSchemas.js";
|
|
import {
|
|
semanticCoverageResourceLimits,
|
|
semanticResourceLimitCoverage,
|
|
} from "../../domain/javascript/javascriptSemanticCoverage.js";
|
|
import { semanticResourceLimitReason } from "../../domain/javascript/javascriptSemanticResourceLimits.js";
|
|
import type {
|
|
JavaScriptModuleOrigin,
|
|
JavaScriptSemanticModuleLink,
|
|
} from "../../domain/javascript/javascriptSemanticIr.js";
|
|
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
|
|
import {
|
|
addAstContainsEdge,
|
|
artifactLocalIdentity,
|
|
type JavaScriptArtifactGraphContext,
|
|
type JavaScriptArtifactGraphCoverage,
|
|
} from "./JavaScriptArtifactGraphContext.js";
|
|
import {
|
|
astObservationEvidence,
|
|
staticInferenceEvidence,
|
|
} from "./JavaScriptArtifactGraphEvidence.js";
|
|
import {
|
|
resolveArtifactPathByContext,
|
|
type ArtifactPathResolution,
|
|
} from "./JavaScriptArtifactPathResolution.js";
|
|
import { projectJavaScriptExportReturnShapes } from "../../domain/javascript/javascriptExportReturnShapeProjection.js";
|
|
|
|
import type { JavaScriptModuleSemanticIr } from "./JavaScriptArtifactAnalysisTypes.js";
|
|
|
|
interface SemanticAnalysis {
|
|
readonly ir: JavaScriptModuleSemanticIr;
|
|
}
|
|
|
|
interface RelationshipInput {
|
|
readonly context: JavaScriptArtifactGraphContext;
|
|
readonly file: JavaScriptArtifactFile;
|
|
readonly semantic: SemanticAnalysis;
|
|
readonly source: ApplicationNode;
|
|
readonly link: JavaScriptSemanticModuleLink;
|
|
}
|
|
|
|
type ResolvedModuleTarget = {
|
|
readonly node: ApplicationNode;
|
|
readonly limitations: readonly string[];
|
|
} & (
|
|
| {
|
|
readonly path: string;
|
|
readonly file: JavaScriptArtifactFile;
|
|
readonly status: "resolved";
|
|
}
|
|
| {
|
|
readonly path: null;
|
|
readonly file: null;
|
|
readonly status: Exclude<
|
|
ArtifactPathResolution["resolution_status"],
|
|
"resolved"
|
|
>;
|
|
}
|
|
);
|
|
|
|
/** Create one path-scoped source-module identity for every analyzed source file. */
|
|
export const addJavaScriptSourceModules = (
|
|
context: JavaScriptArtifactGraphContext,
|
|
): void => {
|
|
for (const analyzed of context.analysis.files) {
|
|
const { file, semantic } = analyzed;
|
|
const asset = context.assetNodes.get(file.path);
|
|
const program = semantic?.ir.scopes.find(({ kind }) => kind === "program");
|
|
if (asset === undefined || semantic === null || program === undefined)
|
|
continue;
|
|
const exports = semantic.ir.moduleLinks
|
|
.flatMap(({ exportedName }) =>
|
|
exportedName === null ? [] : [exportedName],
|
|
)
|
|
.sort(compareUnicodeCodePoints);
|
|
const retainedExports = [...new Set(exports)];
|
|
const source = context.accumulator.addNode({
|
|
kind: "javascript-module",
|
|
identity: artifactLocalIdentity(
|
|
file.sha256,
|
|
"source-module",
|
|
`${file.container_sha256}:${file.path}`,
|
|
),
|
|
observations: [
|
|
{
|
|
label: file.path,
|
|
properties: {
|
|
semantic_role: "source-module",
|
|
path: file.path,
|
|
container_sha256: file.container_sha256,
|
|
logical_module_key: file.path,
|
|
module_format: moduleFormat(file.path, semantic.ir),
|
|
import_relationships: semantic.ir.moduleLinks.filter(
|
|
({ kind }) => kind === "import" || kind === "require",
|
|
).length,
|
|
export_relationships: exports.length,
|
|
export_names: retainedExports,
|
|
omitted_export_names: 0,
|
|
},
|
|
evidence: astObservationEvidence({
|
|
sha256: file.sha256,
|
|
path: file.path,
|
|
range: program.location,
|
|
operation: "recover-source-module",
|
|
coverage: semanticCoverage(semantic),
|
|
limitations: semantic.ir.limitations,
|
|
}),
|
|
},
|
|
],
|
|
});
|
|
context.sourceModuleNodes.set(file.path, source);
|
|
addAstContainsEdge(context, {
|
|
source: asset,
|
|
target: source,
|
|
file,
|
|
range: program.location,
|
|
coverage: semanticCoverage(semantic),
|
|
properties: { logical_module_key: file.path },
|
|
});
|
|
}
|
|
};
|
|
|
|
/** Relationship compositions the application graph cannot represent, for disclosure. */
|
|
export interface JavaScriptModuleRelationshipOmissions {
|
|
/** Import specifiers that resolved back to the importing module itself. */
|
|
readonly selfImports: number;
|
|
}
|
|
|
|
type RelationshipOmissionCounter = { selfImports: number };
|
|
|
|
/** Compose bounded CommonJS and ESM binding relationships across artifact files. */
|
|
export function* addJavaScriptModuleRelationshipsSteps(
|
|
context: JavaScriptArtifactGraphContext,
|
|
): Generator<void, JavaScriptModuleRelationshipOmissions> {
|
|
const omissions = { selfImports: 0 };
|
|
for (const analyzed of context.analysis.files) {
|
|
const { file, semantic } = analyzed;
|
|
const source = context.sourceModuleNodes.get(file.path);
|
|
if (semantic === null || source === undefined) continue;
|
|
for (const link of semantic.ir.moduleLinks) {
|
|
// One module can export thousands of bindings, each with return shapes.
|
|
yield;
|
|
const input = { context, file, semantic, source, link };
|
|
if (isExportLink(link)) addExportRelationship(input, omissions);
|
|
else if (link.specifier !== null)
|
|
addImportRelationship(input, source, omissions, {
|
|
specifier: link.specifier,
|
|
importedPath: link.importedName === null ? [] : [link.importedName],
|
|
});
|
|
}
|
|
}
|
|
return omissions;
|
|
}
|
|
|
|
const addExportRelationship = (
|
|
input: RelationshipInput,
|
|
omissions: RelationshipOmissionCounter,
|
|
): void => {
|
|
const { context, file, semantic, source, link } = input;
|
|
if (link.exportedName === null) return;
|
|
const baseCoverage = semanticCoverage(semantic);
|
|
const returnShapes = projectJavaScriptExportReturnShapes({
|
|
ir: semantic.ir,
|
|
link,
|
|
modulePath: file.path,
|
|
baseCoverage,
|
|
});
|
|
const exported = context.accumulator.addNode({
|
|
kind: "javascript-module",
|
|
identity: artifactLocalIdentity(
|
|
file.sha256,
|
|
"module-export",
|
|
`${file.container_sha256}:${file.path}:${link.exportedName}`,
|
|
),
|
|
observations: [
|
|
{
|
|
label: `${file.path}:${link.exportedName}`,
|
|
properties: {
|
|
semantic_role: "export-binding",
|
|
module_path: file.path,
|
|
relationship_kind: link.kind,
|
|
exported_name: link.exportedName,
|
|
local_name: link.localName,
|
|
imported_name: link.importedName,
|
|
declared_specifier: link.specifier,
|
|
},
|
|
evidence: astObservationEvidence({
|
|
sha256: file.sha256,
|
|
path: file.path,
|
|
range: link.location,
|
|
operation: "recover-module-export",
|
|
coverage: baseCoverage,
|
|
limitations: semantic.ir.limitations,
|
|
}),
|
|
},
|
|
...(returnShapes === null
|
|
? []
|
|
: [
|
|
{
|
|
label: `${file.path}:${link.exportedName}:return-shapes`,
|
|
properties: returnShapes.properties,
|
|
evidence: staticInferenceEvidence({
|
|
sha256: file.sha256,
|
|
path: file.path,
|
|
range: returnShapes.range,
|
|
operation: "recover-export-return-shapes",
|
|
coverage: returnShapes.coverage,
|
|
limitations: returnShapes.limitations,
|
|
}),
|
|
},
|
|
]),
|
|
],
|
|
});
|
|
context.accumulator.addEdge({
|
|
source_node_id: source.node_id,
|
|
target_node_id: exported.node_id,
|
|
relation: "exposes",
|
|
properties: relationshipProperties(link, null),
|
|
evidence: relationshipEvidence(input, "expose-module-export", []),
|
|
});
|
|
const origin = moduleOriginForExport(semantic.ir, link);
|
|
if (origin !== null)
|
|
addImportRelationship(input, exported, omissions, origin);
|
|
};
|
|
|
|
const addImportRelationship = (
|
|
input: RelationshipInput,
|
|
source: ApplicationNode,
|
|
omissions: RelationshipOmissionCounter,
|
|
origin: JavaScriptModuleOrigin,
|
|
): void => {
|
|
const target = resolveModuleTarget(input, origin.specifier);
|
|
// A specifier that resolves back to the importing module itself (a literal
|
|
// self-import, self-require, or artifact-confined path alias)
|
|
// cannot become an edge: the application graph forbids self-referential
|
|
// edges, and emitting one fails result validation for the whole analysis.
|
|
if (target.node.node_id === source.node_id) {
|
|
omissions.selfImports += 1;
|
|
return;
|
|
}
|
|
input.context.accumulator.addEdge({
|
|
source_node_id: source.node_id,
|
|
target_node_id: target.node.node_id,
|
|
relation: "imports",
|
|
properties: relationshipProperties(input.link, target, origin),
|
|
evidence: relationshipEvidence(
|
|
input,
|
|
"resolve-semantic-module-relationship",
|
|
target.limitations,
|
|
target.status === "resolved"
|
|
? "high"
|
|
: target.status === "external"
|
|
? "medium"
|
|
: "low",
|
|
),
|
|
});
|
|
};
|
|
|
|
const resolveModuleTarget = (
|
|
input: RelationshipInput,
|
|
specifier: string,
|
|
): ResolvedModuleTarget => {
|
|
const resolution = resolveArtifactPathByContext({
|
|
declaredPath: specifier,
|
|
sourcePath: input.file.path,
|
|
context: "module-specifier",
|
|
files: input.context.filesByPath,
|
|
moduleKind:
|
|
input.link.kind === "require" || input.link.kind === "commonjs-export"
|
|
? "require"
|
|
: "import",
|
|
});
|
|
if (resolution.resolution_status !== "resolved")
|
|
return {
|
|
node: unresolvedModuleNode(
|
|
input,
|
|
specifier,
|
|
resolution.resolution_status,
|
|
),
|
|
path: null,
|
|
file: null,
|
|
status: resolution.resolution_status,
|
|
limitations: resolution.limitations,
|
|
};
|
|
const path = resolution.resolved_path;
|
|
const file = input.context.filesByPath.get(path);
|
|
const node =
|
|
input.context.sourceModuleNodes.get(path) ??
|
|
input.context.fileNodes.get(path);
|
|
if (file === undefined || node === undefined)
|
|
throw new TypeError(
|
|
`Resolved artifact path ${path} is absent from its graph context.`,
|
|
);
|
|
return {
|
|
node,
|
|
path,
|
|
file,
|
|
status: "resolved",
|
|
limitations: resolution.limitations,
|
|
};
|
|
};
|
|
|
|
const unresolvedModuleNode = (
|
|
input: RelationshipInput,
|
|
specifier: string,
|
|
status: Exclude<ResolvedModuleTarget["status"], "resolved">,
|
|
): ApplicationNode =>
|
|
input.context.accumulator.addNode({
|
|
kind: "javascript-module",
|
|
identity: artifactLocalIdentity(
|
|
input.file.sha256,
|
|
"unresolved-semantic-module",
|
|
`${status}:${specifier}`,
|
|
),
|
|
observations: [
|
|
{
|
|
label: specifier,
|
|
properties: {
|
|
semantic_role: "module-reference",
|
|
declared_specifier: specifier,
|
|
resolution_status: status,
|
|
},
|
|
evidence: staticInferenceEvidence({
|
|
sha256: input.file.sha256,
|
|
path: input.file.path,
|
|
range: input.link.location,
|
|
operation: "retain-unresolved-semantic-module",
|
|
coverage: semanticCoverage(input.semantic),
|
|
confidence: "low",
|
|
limitations: input.semantic.ir.limitations,
|
|
}),
|
|
},
|
|
],
|
|
});
|
|
|
|
const moduleOriginForExport = (
|
|
ir: JavaScriptModuleSemanticIr,
|
|
link: JavaScriptSemanticModuleLink,
|
|
): JavaScriptModuleOrigin | null => {
|
|
if (link.specifier !== null)
|
|
return {
|
|
specifier: link.specifier,
|
|
importedPath: link.importedName === null ? [] : [link.importedName],
|
|
};
|
|
if (link.localName === null) return null;
|
|
const program = ir.scopes.find(({ kind }) => kind === "program");
|
|
const binding = ir.bindings.find(
|
|
({ name, scopeId }) =>
|
|
name === link.localName && scopeId === program?.scopeId,
|
|
);
|
|
return binding?.provenance.status === "module" &&
|
|
binding.provenance.origins.length === 1
|
|
? (binding.provenance.origins[0] ?? null)
|
|
: null;
|
|
};
|
|
|
|
const relationshipProperties = (
|
|
link: JavaScriptSemanticModuleLink,
|
|
target: ResolvedModuleTarget | null,
|
|
origin?: JavaScriptModuleOrigin,
|
|
) => ({
|
|
module_link_kind: link.kind,
|
|
specifier: origin?.specifier ?? link.specifier,
|
|
imported_name: origin?.importedPath.at(-1) ?? link.importedName,
|
|
imported_path: origin?.importedPath ?? [],
|
|
local_name: link.localName,
|
|
exported_name: link.exportedName,
|
|
resolved_path: target?.path ?? null,
|
|
resolution_status: target?.status ?? null,
|
|
target_file_kind: target?.file?.kind ?? null,
|
|
target_json_status:
|
|
target?.file?.kind === "json" ? jsonStatus(target.file) : null,
|
|
});
|
|
|
|
const relationshipEvidence = (
|
|
input: RelationshipInput,
|
|
operation: string,
|
|
limitations: readonly string[],
|
|
confidence: "high" | "medium" | "low" = "high",
|
|
) =>
|
|
staticInferenceEvidence({
|
|
sha256: input.file.sha256,
|
|
path: input.file.path,
|
|
range: input.link.location,
|
|
operation,
|
|
coverage: semanticCoverage(input.semantic),
|
|
confidence,
|
|
limitations: [
|
|
...input.semantic.ir.limitations,
|
|
...semanticCoverageResourceLimits(input.semantic.ir.coverage).map(
|
|
semanticResourceLimitReason,
|
|
),
|
|
...limitations,
|
|
],
|
|
});
|
|
|
|
const semanticCoverage = (
|
|
semantic: SemanticAnalysis,
|
|
): JavaScriptArtifactGraphCoverage => {
|
|
const resourceLimits = semanticCoverageResourceLimits(semantic.ir.coverage);
|
|
if (semantic.ir.coverage.status === "complete" && resourceLimits.length === 0)
|
|
return completeApplicationCoverage();
|
|
return partialApplicationCoverage(
|
|
semanticResourceLimitCoverage(resourceLimits),
|
|
semantic.ir.coverage.omittedCount,
|
|
);
|
|
};
|
|
|
|
const moduleFormat = (
|
|
path: string,
|
|
ir: JavaScriptModuleSemanticIr,
|
|
): "commonjs" | "esm" | "mixed" | "unknown" => {
|
|
const extension = posix.extname(path).toLowerCase();
|
|
if (extension === ".mjs") return "esm";
|
|
if (extension === ".cjs") return "commonjs";
|
|
const esm = ir.moduleLinks.some(({ kind }) =>
|
|
["import", "export", "re-export"].includes(kind),
|
|
);
|
|
const commonJs = ir.moduleLinks.some(({ kind }) =>
|
|
["require", "commonjs-export"].includes(kind),
|
|
);
|
|
return esm && commonJs
|
|
? "mixed"
|
|
: esm
|
|
? "esm"
|
|
: commonJs
|
|
? "commonjs"
|
|
: "unknown";
|
|
};
|
|
|
|
const jsonStatus = (
|
|
file: JavaScriptArtifactFile,
|
|
): "included" | "invalid" | "unavailable" => {
|
|
if (!file.text.included) return "unavailable";
|
|
try {
|
|
JSON.parse(file.text.value);
|
|
return "included";
|
|
} catch (cause: unknown) {
|
|
// Invalid JSON is classified without further detail.
|
|
void cause;
|
|
return "invalid";
|
|
}
|
|
};
|
|
|
|
const isExportLink = (link: JavaScriptSemanticModuleLink): boolean =>
|
|
link.kind === "export" ||
|
|
link.kind === "re-export" ||
|
|
link.kind === "commonjs-export";
|