fix(javascript): preserve path reasons, module coverage, and property provenance (#5651)

* fix(javascript): preserve selected path host resolution reasons

Classify ELOOP and ENAMETOOLONG while resolving the caller-selected input so the path and host code stay on the invalid-request issue.

Refs #5623

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(javascript): count summary coverage in module units

Use the module population for both examined and total counts. Non-module graph nodes remain in the application coverage section.

Refs #5464

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(javascript): connect direct property reads to return and argument flow

Record the unwrapped value expression and alias the object projection's read occurrence so a direct member read stays in backward provenance.

Refs #5585, #5648

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(javascript): count return wrappers against expression depth

Evaluate the original return expression so erased TypeScript and Flow wrappers stay inside the expression-depth budget. Keep the unwrapped node for identity and value ranges.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(javascript): describe ELOOP as unresolved symlink traversal

A host can return ELOOP for a cycle or for a chain past its traversal limit. Keep the selected path and code without asserting that a cycle exists.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* test(javascript): skip symlink traversal fixtures on Windows

Keep the overlong-name case portable. Windows does not produce ELOOP for these fixtures, matching the other path-failure tests.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(javascript): describe ENAMETOOLONG as a path or component limit

realpath reports that code for a long component and for a long full path. Keep the selected path and host code without claiming only a filename was too long.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* style(javascript): format return-depth and object-read changes

oxfmt rejected the return-depth test and the object-read index. No behavior change.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* test(ida): expect unchanged invalid requests to be nonretryable

The shared projector no longer marks invalid_request retryable. This lookup assertion was not updated with that contract.

Co-authored-by: morluto <morluto@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: morluto <morluto@users.noreply.github.com>
This commit is contained in:
morluto
2026-10-21 03:45:10 +08:00
committed by GitHub
parent b718ad166d
commit 53e0943223
12 changed files with 315 additions and 7 deletions
@@ -343,8 +343,9 @@ export const reconstructJavaScriptArtifact = async (
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.
* Resolve the caller-selected input. A missing, looping, overlong, or
* unreadable selection is a caller or host-resolution failure, not a damaged
* artifact.
*/
const resolveSelectedInput = async (inputPath: string): Promise<string> => {
try {
@@ -366,10 +367,26 @@ const resolveSelectedInput = async (inputPath: string): Promise<string> => {
message: `No file or directory exists at the selected input path (${code}): ${inputPath}`,
},
]);
if (code === "ELOOP" || code === "ENAMETOOLONG")
throw new AnalysisInputError(OPERATION, { cause }, [
{
path: ["input_path"],
reason: "invalid_value",
message: selectedInputResolutionMessage(code, inputPath),
},
]);
throw cause;
}
};
const selectedInputResolutionMessage = (
code: "ELOOP" | "ENAMETOOLONG",
inputPath: string,
): string =>
code === "ENAMETOOLONG"
? `Selected input path exceeds the host path or component length limit (ENAMETOOLONG): ${inputPath}`
: `Selected input path could not be resolved because symbolic-link traversal looped or exceeded the host limit (ELOOP): ${inputPath}`;
/** The selected input's kind, checked against the caller's requested format. */
const resolveFormat = async (
path: string,
@@ -172,6 +172,56 @@ it("continues to project an ordinary member access as a property read", () => {
);
});
it("keeps a directly returned or passed property in backward provenance", () => {
const source = `
const settings = { index: "products" };
function viaLocal() {
const value = settings.index;
return value;
}
function viaMember() {
return settings.index;
}
function viaParen() {
return (settings.index);
}
const arrow = () => settings.index;
function take(value) { return value; }
function added() { return settings.index + "!"; }
const a = viaLocal();
const b = viaMember();
const direct = settings.index;
const c = viaParen();
const d = arrow();
const e = take(settings.index);
const g = take((settings.index));
const kept = settings.index;
const local = take(kept);
const concatenated = added();
`;
const graph = graphFor(source);
const provenance = (binding: string, candidates: boolean) => {
const node = graph.nodes.find(
(item) => item.kind === "binding" && item.label === binding,
);
if (node === undefined) throw new Error(`missing ${binding}`);
const trace = queryJavaScriptSemanticGraph(graph, {
seed: { kind: "semantic-node", node_id: node.node_id },
direction: "backward-provenance",
include_ambiguous_dynamic_edges: candidates,
});
return trace.nodes.some(
(item) => item.kind === "literal" && item.properties.value === "products",
);
};
for (const binding of ["a", "b", "direct", "c", "d", "e", "g", "local"]) {
expect(provenance(binding, false), binding).toBe(true);
expect(provenance(binding, true), `${binding} candidates`).toBe(true);
}
expect(provenance("concatenated", false)).toBe(false);
expect(provenance("concatenated", true)).toBe(false);
});
it("keeps dynamic calls explicit and returns complete deterministic results", () => {
const graph = graphFor(`
const handlers = { ready() { return 1; } };
@@ -60,6 +60,7 @@ import {
projectSemanticFrontiers,
projectSemanticPromises,
projectSemanticReturnValues,
semanticObjectReadOccurrence,
type SemanticFlowProjectionContext,
} from "./JavaScriptSemanticGraphFlowProjection.js";
import {
@@ -828,6 +829,15 @@ const projectCalls = (context: FileContext): void => {
? "resolved"
: "candidate",
});
addRelation(context.state, {
source: semanticObjectReadOccurrence(
context,
argument.valueExpressionLocation,
),
target: argumentNode,
relation: "aliases",
resolution: "resolved",
});
}
}
for (const flow of context.ir.argumentFlows) {
@@ -1,4 +1,5 @@
import type { JavaScriptSemanticGraphNode } from "../../domain/javascript/javascriptSemanticGraphSchemas.js";
import { javaScriptSemanticNodeId } from "../../domain/javascript/javascriptSemanticGraph.js";
import type { JavaScriptSemanticIr } from "../../domain/javascript/javascriptSemanticIr.js";
import { sourceRangesEqual } from "../../domain/javascript/javascriptStaticAnalysisHelpers.js";
import type { JavaScriptSourceRange } from "../../domain/javascript/javascriptStaticAnalysisTypes.js";
@@ -190,6 +191,57 @@ const addPromiseUnknown = (
});
};
interface ObjectReadIndex {
readonly state: SemanticGraphProjectionState;
readonly nodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>;
}
const objectReadIndexes = new WeakMap<JavaScriptSemanticIr, ObjectReadIndex>();
const objectReadRangeKey = (location: JavaScriptSourceRange): string =>
`${String(location.start.line)}:${String(location.start.column)}:${String(location.end.line)}:${String(location.end.column)}`;
/**
* The object projection owns a member read's occurrence. Index it once per
* file so return and argument flow can connect that expression without
* scanning every property read again.
*/
const objectReadOccurrences = (
context: SemanticFlowProjectionContext,
): ReadonlyMap<string, JavaScriptSemanticGraphNode> => {
const cached = objectReadIndexes.get(context.ir);
if (cached !== undefined && cached.state === context.state)
return cached.nodes;
const nodes = new Map<string, JavaScriptSemanticGraphNode>();
for (const operation of context.ir.objectOperations) {
if (operation.kind !== "read") continue;
const node = context.state.nodes.get(
javaScriptSemanticNodeId({
kind: "expression",
identity: {
artifact_sha256: context.file.sha256,
module_path: context.file.path,
source_range: operation.location,
role_key: operation.objectOperationId,
},
}),
);
if (node !== undefined)
nodes.set(objectReadRangeKey(operation.location), node);
}
objectReadIndexes.set(context.ir, { state: context.state, nodes });
return nodes;
};
/** Return the retained read occurrence for one unwrapped member expression. */
export const semanticObjectReadOccurrence = (
context: SemanticFlowProjectionContext,
location: JavaScriptSourceRange | null,
): JavaScriptSemanticGraphNode | undefined =>
location === null
? undefined
: objectReadOccurrences(context).get(objectReadRangeKey(location));
/** Link direct return expressions to their retained semantic references. */
export const projectSemanticReturnValues = (
context: SemanticFlowProjectionContext,
@@ -212,6 +264,15 @@ export const projectSemanticReturnValues = (
? "resolved"
: "candidate",
});
addSemanticGraphRelation(context.state, {
source: semanticObjectReadOccurrence(
context,
site.valueExpressionLocation,
),
target: returnNode,
relation: "aliases",
resolution: "resolved",
});
}
};