feat(javascript): analyze selected application sources

Analyze exact selected sources in first-request order before unrelated
static scopes, preserving full artifact identity and byte verification.
Retain selection in CLI/MCP Evidence and summaries, with explicit unknowns
and partial coverage for unselected sources. Keep default full analysis.

Fixes #1848

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-12 04:07:14 +08:00
1 parent 0d8b728d24
commit 85c1d2587a
24 files changed
+790 -18

No files matched your search

@@ -18,6 +18,7 @@ import { streamChunkToBuffer } from "../StreamBytes.js";
import { hashReadable } from "../ArtifactHash.js";
import type { ArtifactOccurrence } from "../../domain/artifactGraph.js";
import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js";
import { AnalysisInputError } from "../../domain/analysisErrorCore.js";
import type {
JavaScriptArtifactFileKind,
@@ -47,6 +48,7 @@ interface ReadContext {
readonly files: JavaScriptArtifactFile[];
readonly containers: JavaScriptArtifactContainer[];
readonly signal: AbortSignal | undefined;
readonly selectedPaths: ReadonlyMap<string, number> | undefined;
textBytes: number;
invalidUtf8: number;
}
@@ -57,13 +59,49 @@ interface ReadTextInput {
readonly expected: ExpectedFile;
}
/** Read all relevant textual entries through an already-inventoried reader. */
/** Verify inventoried entries; acquire text only inside the caller's scope. */
export const readJavaScriptArtifactFiles = async (
reader: ArtifactReader,
snapshot: ArtifactInventorySnapshot,
resources: ArtifactResourceScope,
signal?: AbortSignal,
sourcePaths?: readonly string[],
): Promise<JavaScriptArtifactFileSet> => {
const inventory = expectedInventory(snapshot);
const expected = inventory.files;
const selectedPaths =
sourcePaths === undefined
? undefined
: new Map([...new Set(sourcePaths)].map((path, index) => [path, index]));
const issues = (sourcePaths ?? []).flatMap((path, index) =>
expected.has(path) || inventory.unavailableFiles.has(path)
? []
: [
{
path: ["source_paths", index],
reason: "invalid_value" as const,
message:
"Select an exact relevant file path from the artifact inventory; no globs or path aliases.",
},
],
);
if (issues.length > 0)
throw new AnalysisInputError(
"analyze_javascript_application",
undefined,
issues,
);
const unavailablePath = sourcePaths?.find((path) =>
inventory.unavailableFiles.has(path),
);
if (unavailablePath !== undefined)
throw new ArtifactReaderFailure(
"unavailable",
`Selected inventoried source bytes are unavailable: ${unavailablePath}`,
{
partialObservation: { kind: "artifact-inventory", inventory: snapshot },
},
);
if (reader instanceof AsarArtifactReader) {
const root = snapshot.nodes.find(
({ artifact_id }) => artifact_id === snapshot.manifest.root_artifact_id,
@@ -79,8 +117,6 @@ export const readJavaScriptArtifactFiles = async (
signal,
);
}
const inventory = expectedInventory(snapshot);
const expected = inventory.files;
const context: ReadContext = {
resources,
expected,
@@ -89,13 +125,17 @@ export const readJavaScriptArtifactFiles = async (
files: [],
containers: [],
signal,
selectedPaths,
textBytes: 0,
invalidUtf8: 0,
};
await visitReader(reader, "", snapshot.manifest.root_sha256, context);
const files = context.files.sort((left, right) =>
compareUnicodeCodePoints(left.path, right.path),
);
const files = context.files.sort((left, right) => {
const leftPriority = selectedPaths?.get(left.path) ?? Infinity;
const rightPriority = selectedPaths?.get(right.path) ?? Infinity;
if (leftPriority !== rightPriority) return leftPriority - rightPriority;
return compareUnicodeCodePoints(left.path, right.path);
});
assertExpectedFilesWereVisited(expected, files);
return {
files,
@@ -104,6 +144,17 @@ export const readJavaScriptArtifactFiles = async (
),
text_bytes_read: context.textBytes,
invalid_utf8_files: context.invalidUtf8,
...(sourcePaths === undefined
? {}
: {
source_selection: {
requested_paths: [...sourcePaths],
unselected_files: files.filter(
({ text }) =>
!text.included && text.reason === "outside-analysis-scope",
).length,
},
}),
};
};
@@ -251,6 +302,19 @@ const readText = async (
context: ReadContext,
input: ReadTextInput,
): Promise<JavaScriptArtifactFile["text"]> => {
if (
context.selectedPaths !== undefined &&
!context.selectedPaths.has(input.expected.path)
) {
// Keep the inventory/acquisition identity check without allocating or
// decoding an unrelated source's text.
await verifyEntryBytes(
await input.reader.open(input.entry, context.signal),
input.expected,
context.signal,
);
return { included: false, reason: "outside-analysis-scope" };
}
if (input.expected.kind === "native-addon") {
await verifyEntryBytes(
await input.reader.open(input.entry, context.signal),
@@ -311,16 +375,23 @@ const expectedInventory = (
): {
readonly files: ReadonlyMap<string, ExpectedFile>;
readonly containers: ReadonlyMap<string, ExpectedContainer>;
readonly unavailableFiles: ReadonlySet<string>;
} => {
const nodes = new Map(snapshot.nodes.map((node) => [node.artifact_id, node]));
const files = new Map<string, ExpectedFile>();
const containers = new Map<string, ExpectedContainer>();
const unavailableFiles = new Set<string>();
for (const occurrence of snapshot.occurrences) {
if (occurrence.logical_path === ".") continue;
const isNestedAsar =
occurrence.entry_kind === "file" &&
occurrence.logical_path.toLowerCase().endsWith(".asar");
if (occurrence.artifact_id === null) {
if (
occurrence.entry_kind === "file" &&
relevantKind(occurrence.logical_path) !== undefined
)
unavailableFiles.add(occurrence.logical_path);
if (isNestedAsar)
containers.set(occurrence.logical_path, {
hash_status: occurrence.hash_status,
@@ -358,7 +429,7 @@ const expectedInventory = (
kind,
});
}
return { files, containers };
return { files, containers, unavailableFiles };
};
const readAll = async (