chore: merge main and preserve plist and AppKit regressions

This commit is contained in:
morluto committed 2026-10-07 18:02:05 +08:00
commit 071cfd1bb6
66 files changed
+876 -288

No files matched your search

+17 -5
View File
@@ -277,11 +277,23 @@ unchanged sources under `scripts/fixtures/javascript-recovery/`. Its default
REA entrypoint still resolves to `scripts/rea.mjs`. Managed conformance imports
the relocated compiled workflow; owning CI source filters follow the new paths.
This application move preserves the current directory/ASAR reader boundary,
including root/nested byte checks, UTF-8 diagnostics, cancellation and owned
close. Reader selection requires a separately traced increment. Runtime ports
retain their distinct Inspector, passive Electron and active Electron effects;
shipped Electron hooks remain at their existing provider boundary.
`src/artifacts/javascript/` owns directory/ASAR reader construction and relevant
file acquisition, including root/nested byte verification, UTF-8 diagnostics and
nested reader cleanup. Its narrow factory returns the existing `ArtifactReader`;
the application retains input admission, inventory, graph construction and its
original root-reader `finally` close. Pure file facts live in
`src/domain/javascript/javascriptArtifactFiles.ts`, and the shared immutable
inventory snapshot lives in `src/domain/artifactInventorySnapshot.ts`. Inventory
options and integrity policy remain application-owned. Shared stream hashing
lives in `src/artifacts/ArtifactHash.ts`, preserving complete byte counts and the
bounded classification prefix. Acquisition/hash guards reject imports from
application, composition or caller adapters; JavaScript workflows use the factory
instead of importing concrete ASAR/directory reader classes.
ZIP, native-DMG and Mach-O inventory reader selection keep their existing
lifecycles. Runtime ports retain their distinct Inspector, passive Electron and
active Electron effects; shipped Electron hooks remain at their existing
provider boundary.
## Binary application ownership
+1
View File
@@ -5,6 +5,7 @@ export interface ModuleBoundaryViolation {
readonly specifier: string;
readonly target: string;
readonly boundary:
| "artifact-acquisition"
| "pure-layer"
| "application-composition"
| "provider-construction"
+16
View File
@@ -60,6 +60,22 @@ const failedBoundary = (file, target) => {
if (!file.startsWith("src/") || !target.startsWith("src/")) return undefined;
const layer = file.split("/")[1];
const targetLayer = target.split("/")[1];
if (
file.startsWith("src/application/javascript/") &&
/^src\/artifacts\/(?:Asar|Directory)ArtifactReader\.(?:js|ts)$/u.test(
target,
)
)
return "provider-construction";
if (
(file.startsWith("src/artifacts/javascript/") ||
/^src\/artifacts\/ArtifactHash\.(?:js|ts)$/u.test(file)) &&
(["application", "composition", "server", "cli", "main"].includes(
targetLayer,
) ||
/^src\/(?:cli|main)\./u.test(target))
)
return "artifact-acquisition";
if (
PROVIDER_ROOTS.has(layer) &&
/^src\/generatedMcpToolCatalog\.(?:js|ts)$/u.test(target)
+9 -1
View File
@@ -41,6 +41,7 @@ if (!(await compiledRuntimeExists(runtimeFiles))) {
const { createCli } = await import("../dist/cli.js");
const {
renderCliOutputArgumentError,
renderEmptyFilteredCliOutput,
sanitizeCliOutput,
validateCliOutputArguments,
} = await import("../dist/cliOutput.js");
@@ -49,9 +50,16 @@ if (!(await compiledRuntimeExists(runtimeFiles))) {
process.stdout.write(renderCliOutputArgumentError(outputArguments));
process.exitCode = 1;
} else {
let wroteOutput = false;
await createCli().serve(args, {
stdout: (output) => process.stdout.write(sanitizeCliOutput(output)),
stdout: (output) => {
const sanitized = sanitizeCliOutput(output);
if (sanitized.length > 0) wroteOutput = true;
process.stdout.write(sanitized);
},
});
if (!wroteOutput)
process.stdout.write(renderEmptyFilteredCliOutput(args) ?? "");
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ import {
artifactInventoryResultSchema,
type ArtifactInventoryResult,
} from "../domain/artifactGraph.js";
import { abortIfNeeded } from "./ArtifactInventory/hash.js";
import { abortIfNeeded } from "../artifacts/ArtifactHash.js";
import { scanCanonicalArtifactInventory } from "./ArtifactInventory/scanCanonical.js";
import type {
ArtifactIntegrityPolicy,
@@ -1,7 +1,7 @@
import { open } from "node:fs/promises";
import { classifyArtifactContent } from "../ArtifactGraphConstruction.js";
import { ARTIFACT_CLASSIFICATION_PREFIX_BYTES } from "./hash.js";
import { ARTIFACT_CLASSIFICATION_PREFIX_BYTES } from "../../artifacts/ArtifactHash.js";
import type { ArtifactNode } from "../../domain/artifactGraph.js";
import {
hasZipSignature,
@@ -23,7 +23,7 @@ import {
} from "../ArtifactGraphConstruction.js";
import { canonicalDigest } from "../../domain/comparisonSemantics.js";
import { classifyRoot } from "./classify.js";
import { hashReadable, type HashResult } from "./hash.js";
import { hashReadable, type HashResult } from "../../artifacts/ArtifactHash.js";
import { createReader, inventoryLimitations } from "./reader.js";
import {
scanReader,
@@ -22,7 +22,7 @@ import {
STRICT_INTEGRITY_POLICY,
type ArtifactIntegrityPolicy,
} from "./types.js";
import { hashReadable } from "./hash.js";
import { hashReadable } from "../../artifacts/ArtifactHash.js";
export interface PendingIntegrityContradiction {
readonly logicalPath: string;
+1 -18
View File
@@ -1,9 +1,3 @@
import type {
ArtifactInventoryResult,
ArtifactNode,
IntegrityContradiction,
} from "../../domain/artifactGraph.js";
/** Resolved integrity behavior admitted to the artifact scanner. */
export type ArtifactIntegrityPolicy =
| { readonly mode: "fail" }
@@ -19,15 +13,4 @@ export interface ArtifactInventoryOptions {
readonly integrity?: ArtifactIntegrityPolicy | undefined;
}
/** Immutable inventory produced by one complete artifact scan. */
export interface ArtifactInventorySnapshot {
readonly manifest: ArtifactInventoryResult["manifest"];
readonly nodes: readonly ArtifactNode[];
readonly occurrences: ArtifactInventoryResult["occurrences"];
readonly edges: ArtifactInventoryResult["edges"];
readonly provenance: ReadonlyArray<
ArtifactInventoryResult["provenance"][number]
>;
readonly integrity_contradictions: readonly IntegrityContradiction[];
readonly limitations: readonly string[];
}
export type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js";
+6 -8
View File
@@ -278,16 +278,14 @@ const projectNibArchive = (archive: NibArchiveDocument) => {
: className.includes("Control")
? "action"
: className;
const controlAction = className.includes("Control");
const connectionSource = controlAction ? destination : source;
const connectionDestination = controlAction ? source : destination;
if (connectionSource === null) continue;
(connections[String(connectionSource)] ??= []).push({
// NSSource is the outlet owner or the sending control; NSDestination is
// the outlet value or the action target, and a nil target is the first
// responder.
(connections[String(source)] ??= []).push({
type,
"destination-id":
connectionDestination === null ? null : String(connectionDestination),
"destination-id": destination === null ? null : String(destination),
label,
source_id: String(connectionSource),
source_id: String(source),
archive_object_id: String(object.id),
});
}
@@ -2,7 +2,7 @@ import { compareCodePoints } from "../../domain/canonicalOrdering.js";
import type { ElectronIpcFinding } from "../../domain/javascript/electronStaticAnalysisTypes.js";
import type { ElectronBoundarySummary } from "../../domain/javascript/javascriptApplicationAnalysis.js";
import type { JavaScriptArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js";
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
import { resolveArtifactPathByContext } from "./JavaScriptArtifactPathResolution.js";
/** One IPC fact retaining its exact owning artifact for graph projection. */
@@ -1,7 +1,7 @@
import type { ApplicationNode } from "../../domain/javascript/javascriptApplicationGraph.js";
import { sha256Text } from "../../domain/javascript/javascriptStaticAnalysisHelpers.js";
import type { JavaScriptSourceRange } from "../../domain/javascript/javascriptStaticAnalysisTypes.js";
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
import type {
JavaScriptArtifactGraphContext,
JavaScriptArtifactGraphCoverage,
@@ -1,6 +1,6 @@
import type { ApplicationNode } from "../../domain/javascript/javascriptApplicationGraph.js";
import type { ElectronSenderValidationFinding } from "../../domain/javascript/electronStaticAnalysisTypes.js";
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
import {
artifactLocalIdentity,
javascriptAnalysisCoverage,
@@ -1,5 +1,5 @@
import type { ElectronNativeAddonBindingFinding } from "../../domain/javascript/electronStaticAnalysisTypes.js";
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
import {
javascriptAnalysisCoverage,
resolveArtifactPath,
@@ -4,7 +4,7 @@ import type {
ElectronContextBridgeFinding,
ElectronUtilityProcessFinding,
} from "../../domain/javascript/electronStaticAnalysisTypes.js";
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
import {
artifactLocalIdentity,
createElectronRoleNode,
@@ -14,7 +14,7 @@ import { failedJavaScriptStaticAnalysis } from "../../domain/javascript/javascri
import type {
JavaScriptArtifactFile,
JavaScriptArtifactFileSet,
} from "./JavaScriptArtifactFiles.js";
} from "../../domain/javascript/javascriptArtifactFiles.js";
import type {
AnalyzedJavaScriptArtifactFile,
JavaScriptArtifactAnalysis,
@@ -3,7 +3,7 @@ import type {
JavaScriptStaticAnalysis,
} from "../../domain/javascript/javascriptStaticAnalysisTypes.js";
import type { JavaScriptSemanticIr } from "../../domain/javascript/javascriptSemanticIr.js";
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
interface JavaScriptStructuredObservation {
readonly path: string;
@@ -4,7 +4,7 @@ import {
type JavaScriptApplicationGraph,
} from "../../domain/javascript/javascriptApplicationGraph.js";
import type { JavaScriptArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js";
import type { JavaScriptArtifactFileSet } from "./JavaScriptArtifactFiles.js";
import type { JavaScriptArtifactFileSet } from "../../domain/javascript/javascriptArtifactFiles.js";
import { JavaScriptArtifactGraphAccumulator } from "./JavaScriptArtifactGraphAccumulator.js";
import type { JavaScriptArtifactGraphContext } from "./JavaScriptArtifactGraphContext.js";
import {
@@ -1,6 +1,6 @@
import type { ApplicationNode } from "../../domain/javascript/javascriptApplicationGraph.js";
import type { JavaScriptBundlerRegistration } from "../../domain/javascript/javascriptStaticAnalysisTypes.js";
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
import {
addAstContainsEdge,
artifactLocalIdentity,
@@ -11,7 +11,7 @@ import type {
JavaScriptArtifactFile,
JavaScriptArtifactFileSet,
JavaScriptArtifactFileKind,
} from "./JavaScriptArtifactFiles.js";
} from "../../domain/javascript/javascriptArtifactFiles.js";
import { JavaScriptArtifactGraphAccumulator } from "./JavaScriptArtifactGraphAccumulator.js";
import {
artifactObservationEvidence,
@@ -1,6 +1,6 @@
import type { ApplicationNode } from "../../domain/javascript/javascriptApplicationGraph.js";
import type { JavaScriptStaticAnalysis } from "../../domain/javascript/javascriptStaticAnalysisTypes.js";
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
import {
addStaticInferenceEdge,
artifactLocalIdentity,
@@ -5,7 +5,7 @@ import type { JavaScriptArtifactAnalysis } from "./JavaScriptArtifactAnalysisTyp
import type {
JavaScriptArtifactContainer,
JavaScriptArtifactFile,
} from "./JavaScriptArtifactFiles.js";
} from "../../domain/javascript/javascriptArtifactFiles.js";
import type { JavaScriptArtifactGraphAccumulator } from "./JavaScriptArtifactGraphAccumulator.js";
import {
addArtifactContainsEdge,
@@ -4,7 +4,7 @@ import {
resolveArtifactPathByContext,
type ResolveArtifactPathInput,
} from "./JavaScriptArtifactPathResolution.js";
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
import { artifactLocalIdentity } from "./JavaScriptArtifactGraphContext.js";
import { applicationNodeIdentitySchema } from "../../domain/javascript/javascriptApplicationEvidenceSchemas.js";
import { analyzeJavaScriptStaticSource } from "../../domain/javascript/javascriptStaticAnalysis.js";
@@ -1,7 +1,7 @@
import { builtinModules } from "node:module";
import { posix } from "node:path";
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
import {
admitsCanonicalPathSyntax,
hasScheme,
@@ -1,16 +1,12 @@
import { lstat, realpath } from "node:fs/promises";
import { AsarArtifactReader } from "../../artifacts/AsarArtifactReader.js";
import {
ArtifactReaderFailure,
type ArtifactReader,
} from "../../artifacts/ArtifactReader.js";
import { DirectoryArtifactReader } from "../../artifacts/DirectoryArtifactReader.js";
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.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 { ElectronBoundarySummary } from "../../domain/javascript/javascriptApplicationAnalysis.js";
import { analyzeJavaScriptArtifactFiles } from "./JavaScriptArtifactAnalysis.js";
import { readJavaScriptArtifactFiles } from "./JavaScriptArtifactFiles.js";
import { readJavaScriptArtifactFiles } from "../../artifacts/javascript/JavaScriptArtifactFiles.js";
import { buildJavaScriptArtifactGraph } from "./JavaScriptArtifactGraphBuilder.js";
import {
javascriptArtifactReconstructionInputSchema,
@@ -124,14 +120,6 @@ const resolveFormat = async (
return observed;
};
const createReader = (
path: string,
format: "asar" | "directory",
): ArtifactReader =>
format === "asar"
? new AsarArtifactReader(path)
: new DirectoryArtifactReader(path);
const abortIfNeeded = (signal?: AbortSignal): void => {
if (signal?.aborted === true)
throw new ArtifactReaderFailure(
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { resolveArtifactPathByContext } from "./JavaScriptArtifactPathResolution.js";
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
const paths = [
"renderer/index.html",
"assets/app.js",
@@ -1,6 +1,6 @@
import { compareCodePoints } from "../../domain/canonicalOrdering.js";
import type { JavaScriptJsonModuleObservation } from "./JavaScriptArtifactAnalysisTypes.js";
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
/** Parse one approved JSON module without evaluating JavaScript or resolving imports. */
export const analyzeJavaScriptJsonModule = (
@@ -11,7 +11,7 @@ import type {
JavaScriptSemanticIr,
JavaScriptSemanticModuleLink,
} from "../../domain/javascript/javascriptSemanticIr.js";
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
import {
addAstContainsEdge,
artifactLocalIdentity,
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
import { resolveArtifactPathByContext } from "./JavaScriptArtifactPathResolution.js";
const filesFor = (paths: readonly string[]) =>
@@ -2,7 +2,7 @@ import { expect, it } from "vitest";
import { buildJavaScriptSemanticGraph } from "./JavaScriptSemanticGraphBuilder.js";
import type { JavaScriptArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js";
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
import { queryJavaScriptSemanticGraph } from "../../domain/javascript/javascriptSemanticQuery.js";
import { analyzeJavaScriptSemantics } from "../../domain/javascript/javascriptSemanticAnalysis.js";
@@ -14,7 +14,7 @@ import type {
import { sourceRangesEqual as rangesEqual } from "../../domain/javascript/javascriptStaticAnalysisHelpers.js";
import type { JavaScriptApplicationGraph } from "../../domain/javascript/javascriptApplicationGraph.js";
import type { JavaScriptArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js";
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
import {
projectSemanticEvents,
projectSemanticTimers,
@@ -12,7 +12,7 @@ import type {
JavaScriptSemanticGraphUnknown,
} from "../../domain/javascript/javascriptSemanticGraphSchemas.js";
import type { JavaScriptSourceRange } from "../../domain/javascript/javascriptStaticAnalysisTypes.js";
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
import {
inferredSemanticEvidence,
observedSemanticEvidence,
@@ -1,7 +1,7 @@
import type { ApplicationGraphEvidence } from "../../domain/javascript/javascriptApplicationEvidenceSchemas.js";
import type { JavaScriptSemanticGraphNode } from "../../domain/javascript/javascriptSemanticGraph.js";
import type { JavaScriptSourceRange } from "../../domain/javascript/javascriptStaticAnalysisTypes.js";
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
const INFERENCE_LIMITATION =
"This relationship is a conservative static candidate; it does not prove runtime execution or causal flow.";
@@ -4,7 +4,7 @@ import {
type JavaScriptSemanticGraphNode,
} from "../../domain/javascript/javascriptSemanticGraph.js";
import type { JavaScriptSemanticIr } from "../../domain/javascript/javascriptSemanticIr.js";
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
import { observedSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js";
/** Bind domain fingerprint components to exact semantic function nodes. */
@@ -3,7 +3,7 @@ import type { JavaScriptSemanticGraphNode } from "../../domain/javascript/javasc
import type { JavaScriptSemanticIr } from "../../domain/javascript/javascriptSemanticIr.js";
import { sourceRangesEqual } from "../../domain/javascript/javascriptStaticAnalysisHelpers.js";
import type { JavaScriptSourceRange } from "../../domain/javascript/javascriptStaticAnalysisTypes.js";
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
import {
addSemanticGraphNode,
addSemanticGraphRelation,
@@ -2,7 +2,7 @@ import { expect, it } from "vitest";
import { buildJavaScriptSemanticGraph } from "./JavaScriptSemanticGraphBuilder.js";
import type { JavaScriptArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js";
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
import { queryJavaScriptSemanticGraph } from "../../domain/javascript/javascriptSemanticQuery.js";
import { analyzeJavaScriptSemantics } from "../../domain/javascript/javascriptSemanticAnalysis.js";
@@ -2,7 +2,7 @@ import { expect, it } from "vitest";
import { buildJavaScriptSemanticGraph } from "./JavaScriptSemanticGraphBuilder.js";
import type { JavaScriptArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js";
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
import { analyzeJavaScriptSemantics } from "../../domain/javascript/javascriptSemanticAnalysis.js";
import { topLevelBinding } from "../../domain/javascript/javascriptSemanticAnalysis.fixture.js";
@@ -1,9 +1,10 @@
import { createHash } from "node:crypto";
import type { Readable } from "node:stream";
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
import { streamChunkToBuffer } from "../../artifacts/StreamBytes.js";
import { ArtifactReaderFailure } from "./ArtifactReader.js";
import { streamChunkToBuffer } from "./StreamBytes.js";
/** Full stream digest and byte count with bounded classification evidence. */
export type HashResult = {
readonly sha256: string;
readonly bytes: number;
@@ -13,6 +14,7 @@ export type HashResult = {
/** Bounded classification evidence; this is not an executable format size limit. */
export const ARTIFACT_CLASSIFICATION_PREFIX_BYTES = 8_192;
/** Preserve the inventory cancellation reason at a stream boundary. */
export const abortIfNeeded = (signal?: AbortSignal): void => {
if (signal?.aborted === true)
throw new ArtifactReaderFailure(
@@ -21,6 +23,7 @@ export const abortIfNeeded = (signal?: AbortSignal): void => {
);
};
/** Hash every stream byte and retain its bounded classification prefix. */
export const hashReadable = async (
stream: Readable,
signal?: AbortSignal,
+94
View File
@@ -0,0 +1,94 @@
/** Encode a minimal NIBArchive object table for decoder and projection tests. */
export const encodeNibArchiveFixture = (input: {
readonly classes: readonly string[];
readonly objects: readonly {
readonly classIndex: number;
readonly values: Readonly<
Record<string, number | string | boolean | null | { ref: number }>
>;
}[];
}): Buffer => {
const keys = [
...new Set(input.objects.flatMap(({ values }) => Object.keys(values))),
];
const keyIndex = new Map(keys.map((key, index) => [key, index]));
const valueRecords = input.objects.flatMap(({ values }) =>
Object.entries(values).map(([key, value]) =>
Buffer.concat([varint(keyIndex.get(key) ?? 0), encodeValue(value)]),
),
);
let valueStart = 0;
const records = input.objects.map(({ classIndex, values }) => {
const count = Object.keys(values).length;
const result = Buffer.concat([
varint(classIndex),
varint(valueStart),
varint(count),
]);
valueStart += count;
return result;
});
const objects = Buffer.concat(records);
const encodedKeys = Buffer.concat(
keys.map((key) =>
Buffer.concat([varint(Buffer.byteLength(key)), Buffer.from(key)]),
),
);
const values = Buffer.concat(valueRecords);
const classes = Buffer.concat(
input.classes.map((name) => {
const bytes = Buffer.from(name);
return Buffer.concat([varint(bytes.length), varint(0), bytes]);
}),
);
const objectsOffset = 50;
const keysOffset = objectsOffset + objects.length;
const valuesOffset = keysOffset + encodedKeys.length;
const classesOffset = valuesOffset + values.length;
const header = Buffer.alloc(50);
header.write("NIBArchive", 0, "ascii");
header.writeUInt32LE(1, 10);
header.writeUInt32LE(10, 14);
header.writeUInt32LE(input.objects.length, 18);
header.writeUInt32LE(objectsOffset, 22);
header.writeUInt32LE(keys.length, 26);
header.writeUInt32LE(keysOffset, 30);
header.writeUInt32LE(valueStart, 34);
header.writeUInt32LE(valuesOffset, 38);
header.writeUInt32LE(input.classes.length, 42);
header.writeUInt32LE(classesOffset, 46);
return Buffer.concat([header, objects, encodedKeys, values, classes]);
};
const varint = (value: number): Buffer => {
const bytes: number[] = [];
let remaining = value;
do {
let byte = remaining & 0x7f;
remaining >>>= 7;
if (remaining === 0) byte |= 0x80;
bytes.push(byte);
} while (remaining > 0);
return Buffer.from(bytes);
};
const encodeValue = (
value: number | string | boolean | null | { ref: number },
): Buffer => {
if (typeof value === "boolean") return Buffer.from([value ? 4 : 5]);
if (value === null) return Buffer.from([9]);
if (typeof value === "number") {
const bytes = Buffer.alloc(5);
bytes[0] = 2;
bytes.writeInt32LE(value, 1);
return bytes;
}
if (typeof value === "object") {
const bytes = Buffer.alloc(5);
bytes[0] = 10;
bytes.writeUInt32LE(value.ref, 1);
return bytes;
}
const data = Buffer.from(value);
return Buffer.concat([Buffer.from([8]), varint(data.length), data]);
};
+1 -94
View File
@@ -1,100 +1,7 @@
import { describe, expect, it } from "vitest";
import { decodeNibArchive } from "./NibArchive.js";
const encodeArchive = (input: {
readonly classes: readonly string[];
readonly objects: readonly {
readonly classIndex: number;
readonly values: Readonly<
Record<string, number | string | boolean | null | { ref: number }>
>;
}[];
}): Buffer => {
const keys = [
...new Set(input.objects.flatMap(({ values }) => Object.keys(values))),
];
const keyIndex = new Map(keys.map((key, index) => [key, index]));
const valueRecords = input.objects.flatMap(({ values }) =>
Object.entries(values).map(([key, value]) =>
Buffer.concat([varint(keyIndex.get(key) ?? 0), encodeValue(value)]),
),
);
let valueStart = 0;
const records = input.objects.map(({ classIndex, values }) => {
const count = Object.keys(values).length;
const result = Buffer.concat([
varint(classIndex),
varint(valueStart),
varint(count),
]);
valueStart += count;
return result;
});
const objects = Buffer.concat(records);
const encodedKeys = Buffer.concat(
keys.map((key) =>
Buffer.concat([varint(Buffer.byteLength(key)), Buffer.from(key)]),
),
);
const values = Buffer.concat(valueRecords);
const classes = Buffer.concat(
input.classes.map((name) => {
const bytes = Buffer.from(name);
return Buffer.concat([varint(bytes.length), varint(0), bytes]);
}),
);
const objectsOffset = 50;
const keysOffset = objectsOffset + objects.length;
const valuesOffset = keysOffset + encodedKeys.length;
const classesOffset = valuesOffset + values.length;
const header = Buffer.alloc(50);
header.write("NIBArchive", 0, "ascii");
header.writeUInt32LE(1, 10);
header.writeUInt32LE(10, 14);
header.writeUInt32LE(input.objects.length, 18);
header.writeUInt32LE(objectsOffset, 22);
header.writeUInt32LE(keys.length, 26);
header.writeUInt32LE(keysOffset, 30);
header.writeUInt32LE(valueStart, 34);
header.writeUInt32LE(valuesOffset, 38);
header.writeUInt32LE(input.classes.length, 42);
header.writeUInt32LE(classesOffset, 46);
return Buffer.concat([header, objects, encodedKeys, values, classes]);
};
const varint = (value: number): Buffer => {
const bytes: number[] = [];
let remaining = value;
do {
let byte = remaining & 0x7f;
remaining >>>= 7;
if (remaining === 0) byte |= 0x80;
bytes.push(byte);
} while (remaining > 0);
return Buffer.from(bytes);
};
const encodeValue = (
value: number | string | boolean | null | { ref: number },
): Buffer => {
if (typeof value === "boolean") return Buffer.from([value ? 4 : 5]);
if (value === null) return Buffer.from([9]);
if (typeof value === "number") {
const bytes = Buffer.alloc(5);
bytes[0] = 2;
bytes.writeInt32LE(value, 1);
return bytes;
}
if (typeof value === "object") {
const bytes = Buffer.alloc(5);
bytes[0] = 10;
bytes.writeUInt32LE(value.ref, 1);
return bytes;
}
const data = Buffer.from(value);
return Buffer.concat([Buffer.from([8]), varint(data.length), data]);
};
import { encodeNibArchiveFixture as encodeArchive } from "./NibArchive.fixture.js";
describe("NIBArchive decoder", () => {
it("decodes bounded object, key, class, and reference tables", () => {
@@ -3,61 +3,26 @@ import { isAbsolute } from "node:path";
import type { Readable } from "node:stream";
import { compareCodePoints } from "../../domain/canonicalOrdering.js";
import { AsarArtifactReader } from "../../artifacts/AsarArtifactReader.js";
import { AsarArtifactReader } from "../AsarArtifactReader.js";
import {
ArtifactPathRegistry,
normalizeArtifactPath,
} from "../../artifacts/ArtifactPaths.js";
} from "../ArtifactPaths.js";
import {
ArtifactReaderFailure,
type ArtifactEntry,
type ArtifactReader,
} from "../../artifacts/ArtifactReader.js";
import { streamChunkToBuffer } from "../../artifacts/StreamBytes.js";
import { hashReadable } from "../ArtifactInventory/hash.js";
import type { ArtifactInventorySnapshot } from "../ArtifactInventory.js";
} from "../ArtifactReader.js";
import { streamChunkToBuffer } from "../StreamBytes.js";
import { hashReadable } from "../ArtifactHash.js";
import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js";
/** Relevant file categories projected from the complete artifact inventory. */
export type JavaScriptArtifactFileKind =
| "package-json"
| "json"
| "javascript"
| "html"
| "source-map"
| "native-addon";
/** One content-addressed local artifact file and its text availability. */
export interface JavaScriptArtifactFile {
readonly path: string;
readonly container_sha256: string;
readonly sha256: string;
readonly bytes: number;
readonly inventory_artifact_id: string;
readonly kind: JavaScriptArtifactFileKind;
readonly unpacked: boolean;
readonly text:
| { readonly included: true; readonly value: string }
| {
readonly included: false;
readonly reason: "not-applicable" | "invalid-utf8";
};
}
/** One filesystem-backed ASAR nested beneath a directory input. */
export interface JavaScriptArtifactContainer {
readonly path: string;
readonly sha256: string;
readonly bytes: number;
readonly inventory_artifact_id: string;
}
/** Deterministic relevant-file projection plus explicit omissions. */
export interface JavaScriptArtifactFileSet {
readonly files: readonly JavaScriptArtifactFile[];
readonly containers: readonly JavaScriptArtifactContainer[];
readonly text_bytes_read: number;
readonly invalid_utf8_files: number;
}
import type {
JavaScriptArtifactFileKind,
JavaScriptArtifactFile,
JavaScriptArtifactContainer,
JavaScriptArtifactFileSet,
} from "../../domain/javascript/javascriptArtifactFiles.js";
interface ExpectedFile {
readonly path: string;
@@ -0,0 +1,12 @@
import { AsarArtifactReader } from "../AsarArtifactReader.js";
import type { ArtifactReader } from "../ArtifactReader.js";
import { DirectoryArtifactReader } from "../DirectoryArtifactReader.js";
/** Create the reader for an admitted JavaScript artifact format. */
export const createJavaScriptArtifactReader = (
path: string,
format: "asar" | "directory",
): ArtifactReader =>
format === "asar"
? new AsarArtifactReader(path)
: new DirectoryArtifactReader(path);
+39 -5
View File
@@ -18,6 +18,12 @@ export type CliOutputArgumentValidation =
type JsonRecord = Record<string, unknown>;
interface ParsedCliOutputArguments {
readonly filterOutput: boolean;
readonly format: string;
readonly tokenWindow: boolean;
}
const isRecord = (value: unknown): value is JsonRecord =>
typeof value === "object" && value !== null && !Array.isArray(value);
@@ -28,16 +34,16 @@ const validationError = (value: JsonRecord): JsonRecord | undefined => {
return undefined;
};
/** Reject text-window controls that would corrupt a structured document. */
export const validateCliOutputArguments = (
const parseCliOutputArguments = (
arguments_: readonly string[],
): CliOutputArgumentValidation => {
): ParsedCliOutputArguments => {
let format: string = "toon";
let filterOutput = false;
let tokenWindow = false;
for (let index = 0; index < arguments_.length; index += 1) {
const argument = arguments_[index];
// Incur scans builtins across `--` and accepts only the spaced --format form.
// Mirror its effective format so malformed requests still emit complete JSON.
// Incur scans builtins across `--` and accepts only spaced value forms.
// Mirror those effective values at the executable output boundary.
if (argument === "--json") {
format = "json";
continue;
@@ -50,6 +56,13 @@ export const validateCliOutputArguments = (
}
continue;
}
if (argument === "--filter-output") {
if (arguments_[index + 1] !== undefined) {
filterOutput = true;
index += 1;
}
continue;
}
if (argument === "--token-limit" || argument === "--token-offset") {
if (arguments_[index + 1] !== undefined) {
tokenWindow = true;
@@ -63,6 +76,14 @@ export const validateCliOutputArguments = (
)
tokenWindow = true;
}
return { filterOutput, format, tokenWindow };
};
/** Reject text-window controls that would corrupt a structured document. */
export const validateCliOutputArguments = (
arguments_: readonly string[],
): CliOutputArgumentValidation => {
const { format, tokenWindow } = parseCliOutputArguments(arguments_);
if (
tokenWindow &&
(format === "json" || format === "jsonl" || format === "yaml")
@@ -76,6 +97,19 @@ export const validateCliOutputArguments = (
return { ok: true };
};
/** Preserve a complete document when Incur filters the entire result away. */
export const renderEmptyFilteredCliOutput = (
arguments_: readonly string[],
): string | undefined => {
const { filterOutput, format } = parseCliOutputArguments(arguments_);
if (
filterOutput &&
(format === "json" || format === "jsonl" || format === "yaml")
)
return "{}\n";
return undefined;
};
/** Render one complete structured error before command execution. */
export const renderCliOutputArgumentError = (
error: Exclude<CliOutputArgumentValidation, { readonly ok: true }>,
+18
View File
@@ -0,0 +1,18 @@
import type {
ArtifactInventoryResult,
ArtifactNode,
IntegrityContradiction,
} from "./artifactGraph.js";
/** Immutable inventory produced by one complete artifact scan. */
export interface ArtifactInventorySnapshot {
readonly manifest: ArtifactInventoryResult["manifest"];
readonly nodes: readonly ArtifactNode[];
readonly occurrences: ArtifactInventoryResult["occurrences"];
readonly edges: ArtifactInventoryResult["edges"];
readonly provenance: ReadonlyArray<
ArtifactInventoryResult["provenance"][number]
>;
readonly integrity_contradictions: readonly IntegrityContradiction[];
readonly limitations: readonly string[];
}
@@ -0,0 +1,41 @@
/** Relevant file categories projected from the complete artifact inventory. */
export type JavaScriptArtifactFileKind =
| "package-json"
| "json"
| "javascript"
| "html"
| "source-map"
| "native-addon";
/** One content-addressed local artifact file and its text availability. */
export interface JavaScriptArtifactFile {
readonly path: string;
readonly container_sha256: string;
readonly sha256: string;
readonly bytes: number;
readonly inventory_artifact_id: string;
readonly kind: JavaScriptArtifactFileKind;
readonly unpacked: boolean;
readonly text:
| { readonly included: true; readonly value: string }
| {
readonly included: false;
readonly reason: "not-applicable" | "invalid-utf8";
};
}
/** One filesystem-backed ASAR nested beneath a directory input. */
export interface JavaScriptArtifactContainer {
readonly path: string;
readonly sha256: string;
readonly bytes: number;
readonly inventory_artifact_id: string;
}
/** Deterministic relevant-file projection plus explicit omissions. */
export interface JavaScriptArtifactFileSet {
readonly files: readonly JavaScriptArtifactFile[];
readonly containers: readonly JavaScriptArtifactContainer[];
readonly text_bytes_read: number;
readonly invalid_utf8_files: number;
}
@@ -2,6 +2,8 @@ import { createHash } from "node:crypto";
import * as t from "@babel/types";
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
import { compareCodePoints } from "../canonicalOrdering.js";
import { propertyName } from "./javascriptAstValues.js";
import { stringValue } from "./javascriptStaticAnalysisHelpers.js";
@@ -14,9 +16,11 @@ export interface StaticExports {
/** Derive a rename-resistant syntax fingerprint without code execution. */
export const fingerprintJavaScriptAst = (node: t.Node): string => {
const tokens: string[] = [];
t.traverseFast(node, (current) => {
tokens.push(...semanticTokens(current));
return undefined;
traverseJavaScriptAst(node, {
enter: (current) => {
tokens.push(...semanticTokens(current));
return undefined;
},
});
return createHash("sha256").update(JSON.stringify(tokens)).digest("hex");
};
@@ -24,11 +28,13 @@ export const fingerprintJavaScriptAst = (node: t.Node): string => {
/** Collect statically declared CommonJS/bundler export names from one factory. */
export const collectJavaScriptExports = (node: t.Node): StaticExports => {
const exports = new Set<string>();
t.traverseFast(node, (current) => {
if (t.isAssignmentExpression(current))
collectAssignmentExports(current.left, current.right, exports);
if (t.isCallExpression(current)) collectCallExports(current, exports);
return undefined;
traverseJavaScriptAst(node, {
enter: (current) => {
if (t.isAssignmentExpression(current))
collectAssignmentExports(current.left, current.right, exports);
if (t.isCallExpression(current)) collectCallExports(current, exports);
return undefined;
},
});
return {
values: [...exports].sort(compareCodePoints),
@@ -282,6 +282,9 @@ describe("primitive addition values", () => {
['"x" + false', "xfalse"],
['null + "x"', "nullx"],
["3 + 4", 7],
['1 + 2 + "3"', "33"],
['1 + (2 + "3")', "123"],
['(1 + 2) + ("3" + 4)', "334"],
])("recovers %s using primitive coercion", (expression, expected) => {
const ir = analyzeJavaScriptSemantics(`const answer = ${expression};`);
expect(topLevelBinding(ir, "answer").value).toEqual({
@@ -1,7 +1,17 @@
import * as t from "@babel/types";
import { fc, it } from "@fast-check/vitest";
import { describe, expect } from "vitest";
import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js";
import {
analyzeJavaScriptSemantics,
analyzeParsedJavaScriptSemantics,
} from "./javascriptSemanticAnalysis.js";
import { parseJavaScriptSource } from "./javascriptSourceParser.js";
import { analyzeParsedJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
import {
collectJavaScriptExports,
fingerprintJavaScriptAst,
} from "./javascriptAstFingerprint.js";
import type { JavaScriptSemanticValue } from "./javascriptSemanticIr.js";
import {
onlyCallable,
@@ -9,6 +19,45 @@ import {
} from "./javascriptSemanticAnalysis.fixture.js";
describe("JavaScript semantic analysis: rejection 1", () => {
it.each(["left", "right"])(
"retains semantic facts and values from a deeply %s-chained AST",
(direction) => {
// Build an admitted AST directly: Babel's own recursion limit varies
// with parser optimization and must not determine this regression.
const parsed = parseJavaScriptSource("const result = 1; result;");
const declaration = parsed?.program.body[0];
if (parsed === null || !t.isVariableDeclaration(declaration))
throw new TypeError("Expected a parsed variable declaration");
const declarator = declaration.declarations[0];
if (declarator === undefined)
throw new TypeError("Expected a variable declarator");
let expression: t.Expression = t.numericLiteral(1);
for (let index = 1; index < 12000; index += 1)
expression =
direction === "left"
? t.binaryExpression("+", expression, t.numericLiteral(1))
: t.binaryExpression("+", t.numericLiteral(1), expression);
declarator.init = expression;
const staticAnalysis = analyzeParsedJavaScriptStaticSource("", parsed);
expect(staticAnalysis.parse_status).toBe("complete");
expect(staticAnalysis.visited_ast_nodes).toBe(24006);
expect(collectJavaScriptExports(parsed).values).toEqual([]);
expect(fingerprintJavaScriptAst(parsed)).toMatch(/^[a-f0-9]{64}$/u);
const ir = analyzeParsedJavaScriptSemantics(parsed);
expect(ir.coverage.status).toBe("complete");
const binding = topLevelBinding(ir, "result");
expect(binding.value).toEqual({ status: "literal", value: 12000 });
expect(ir.references).toEqual([
expect.objectContaining({
name: "result",
role: "read",
resolution: "resolved",
bindingId: binding.bindingId,
}),
]);
},
);
it("retains complete finite facts and deeply nested static values", () => {
const names = Array.from(
{ length: 280 },
@@ -100,9 +100,9 @@ export const collectSemanticReferences = (
const output: JavaScriptSemanticReference[] = [];
const seen = new Set<string>();
traverseJavaScriptAst(program, {
enter: (node, parent, ancestors) => {
enter: (node, parent, readAncestors) => {
if (!t.isIdentifier(node) || parent === null) return;
const role = semanticIdentifierRole(node, parent, ancestors);
const role = semanticIdentifierRole(node, parent, readAncestors());
if (role === null) return;
// A compound assignment or update reads before it writes; emit both so
// reference consumers see the read they actually execute.
@@ -0,0 +1,53 @@
import * as t from "@babel/types";
import { expect, it } from "vitest";
import { parseJavaScriptSource } from "./javascriptSourceParser.js";
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
it("preserves source-tree enter/exit order and detached ancestor snapshots", () => {
const parsed = parseJavaScriptSource("const answer = first + second;");
if (parsed === null)
throw new TypeError("Expected a parsed JavaScript fixture");
const visits: string[] = [];
const snapshots: (readonly t.Node[])[] = [];
traverseJavaScriptAst(parsed.program, {
enter: (node, parent, readAncestors) => {
visits.push(`enter:${node.type}`);
const ancestors = readAncestors();
expect(ancestors.at(-1) ?? null).toBe(parent);
if (t.isIdentifier(node)) snapshots.push(ancestors);
},
exit: (node) => visits.push(`exit:${node.type}`),
});
expect(visits).toEqual([
"enter:Program",
"enter:VariableDeclaration",
"enter:VariableDeclarator",
"enter:Identifier",
"exit:Identifier",
"enter:BinaryExpression",
"enter:Identifier",
"exit:Identifier",
"enter:Identifier",
"exit:Identifier",
"exit:BinaryExpression",
"exit:VariableDeclarator",
"exit:VariableDeclaration",
"exit:Program",
]);
expect(snapshots.map((nodes) => nodes.map(({ type }) => type))).toEqual([
["Program", "VariableDeclaration", "VariableDeclarator"],
[
"Program",
"VariableDeclaration",
"VariableDeclarator",
"BinaryExpression",
],
[
"Program",
"VariableDeclaration",
"VariableDeclarator",
"BinaryExpression",
],
]);
});
@@ -3,7 +3,8 @@ import * as t from "@babel/types";
/**
* Visit every Babel AST node in deterministic source-tree order.
*
* `ancestors` runs outermost-first and ends with the direct parent. Callers
* `readAncestors` returns a snapshot during `enter`, outermost-first and
* ending with the direct parent. Callers
* need it whenever meaning depends on where a node sits, not just what its
* immediate parent is — a binding inside `catch ({message})` or `({a} = o)`
* has an ObjectProperty parent, so the parent alone cannot say whether the
@@ -15,24 +16,40 @@ export const traverseJavaScriptAst = (
readonly enter: (
node: t.Node,
parent: t.Node | null,
ancestors: readonly t.Node[],
readAncestors: () => readonly t.Node[],
) => void;
readonly exit?: (node: t.Node, parent: t.Node | null) => void;
},
): void => {
const visit = (
node: t.Node,
parent: t.Node | null,
ancestors: readonly t.Node[],
): void => {
visitor.enter(node, parent, ancestors);
for (const child of childNodes(node))
visit(child, node, [...ancestors, node]);
visitor.exit?.(node, parent);
const ancestors: t.Node[] = [];
const enter = (node: t.Node, parent: t.Node | null): TraversalFrame => {
visitor.enter(node, parent, () => [...ancestors]);
ancestors.push(node);
return { node, parent, children: childNodes(node), nextIndex: 0 };
};
visit(root, null, []);
const pending = [enter(root, null)];
while (pending.length > 0) {
const current = pending.at(-1);
if (current === undefined) break;
const child = current.children[current.nextIndex];
if (child !== undefined) {
current.nextIndex += 1;
pending.push(enter(child, current.node));
} else {
visitor.exit?.(current.node, current.parent);
ancestors.pop();
pending.pop();
}
}
};
interface TraversalFrame {
readonly node: t.Node;
readonly parent: t.Node | null;
readonly children: readonly t.Node[];
nextIndex: number;
}
const childNodes = (node: t.Node): t.Node[] => {
const keys: readonly string[] = t.VISITOR_KEYS[node.type] ?? [];
return keys.flatMap((key) => {
@@ -268,12 +268,38 @@ const evaluateAddition = (
node: t.BinaryExpression,
context: EvaluationContext,
): JavaScriptSemanticValue => {
const left = primitiveCandidates(
evaluateExpression(node.left, nestedContext(context)),
);
const right = primitiveCandidates(
evaluateExpression(node.right, nestedContext(context)),
);
const pending: {
readonly node: t.BinaryExpression;
left: JavaScriptSemanticValue | undefined;
}[] = [];
let current: t.Node = node;
while (true) {
if (t.isBinaryExpression(current, { operator: "+" })) {
pending.push({ node: current, left: undefined });
current = current.left;
continue;
}
let value = evaluateExpression(current, nestedContext(context));
while (true) {
const parent = pending.at(-1);
if (parent === undefined) return value;
if (parent.left === undefined) {
parent.left = value;
current = parent.node.right;
break;
}
value = addPrimitiveValues(parent.left, value);
pending.pop();
}
}
};
const addPrimitiveValues = (
leftValue: JavaScriptSemanticValue,
rightValue: JavaScriptSemanticValue,
): JavaScriptSemanticValue => {
const left = primitiveCandidates(leftValue);
const right = primitiveCandidates(rightValue);
if (left === null || right === null)
return { status: "unknown", reason: "Non-primitive addition." };
const values = left.flatMap((leftValue) =>
@@ -1,5 +1,7 @@
import * as t from "@babel/types";
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
import { inspectElectronStaticNode } from "./electronStaticAnalysis.js";
import {
detectVendors,
@@ -56,10 +58,12 @@ const traverseStaticSource = (
file: ParsedJavaScriptSource,
accumulator: AnalysisAccumulator,
): void => {
t.traverseFast(file, (node) => {
accumulator.visitedNodes += 1;
inspectNode(source, node, accumulator);
return undefined;
traverseJavaScriptAst(file, {
enter: (node) => {
accumulator.visitedNodes += 1;
inspectNode(source, node, accumulator);
return undefined;
},
});
};
@@ -1,5 +1,7 @@
import * as t from "@babel/types";
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
import {
collectJavaScriptExports,
fingerprintJavaScriptAst,
@@ -251,13 +253,16 @@ const collectBundlerCallArgumentValues = (
): StaticValues => {
const values: string[] = [];
let unknown = 0;
t.traverseFast(factory, (node) => {
if (!t.isCallExpression(node) && !t.isNewExpression(node)) return undefined;
if (calleeName(node.callee) !== callee) return undefined;
const value = argumentValue(node.arguments[0]);
if (value === undefined) unknown += 1;
else values.push(value);
return undefined;
traverseJavaScriptAst(factory, {
enter: (node) => {
if (!t.isCallExpression(node) && !t.isNewExpression(node))
return undefined;
if (calleeName(node.callee) !== callee) return undefined;
const value = argumentValue(node.arguments[0]);
if (value === undefined) unknown += 1;
else values.push(value);
return undefined;
},
});
return uniqueValues(values, unknown);
};
@@ -0,0 +1,96 @@
import { fc, it } from "@fast-check/vitest";
import { describe, expect } from "vitest";
import {
createDeclaringTypeLookup,
declaringType,
type TypeRange,
} from "./ManagedMemberInspectorCore.js";
const range = (
name: string,
fieldStart: number,
fieldEnd: number,
methodStart = fieldStart,
methodEnd = fieldEnd,
): TypeRange => ({
token: name,
fullName: name,
fieldStart,
fieldEnd,
methodStart,
methodEnd,
});
describe("managed declaring type ownership", () => {
it("resolves field and method boundaries independently, including empty types and gaps", () => {
const ranges = [
range("first", 1, 3, 1, 1),
range("empty", 3, 3, 1, 1),
range("last", 5, 7, 1, 4),
];
const field = createDeclaringTypeLookup(ranges, "field");
const method = createDeclaringTypeLookup(ranges, "method");
expect([0, 1, 2, 3, 4, 5, 6, 7].map(field)).toEqual([
null,
{ token: "first", fullName: "first" },
{ token: "first", fullName: "first" },
null,
null,
{ token: "last", fullName: "last" },
{ token: "last", fullName: "last" },
null,
]);
expect([0, 1, 2, 3, 4].map(method)).toEqual([
null,
{ token: "last", fullName: "last" },
{ token: "last", fullName: "last" },
{ token: "last", fullName: "last" },
null,
]);
});
it("retains the first observed owner for overlapping, reversed, or out-of-order ranges", () => {
const ranges = [
range("first", 5, 9),
range("overlap", 1, 7),
range("reversed", 10, 2),
];
const lookup = createDeclaringTypeLookup(ranges, "method");
expect(lookup(6)).toEqual({ token: "first", fullName: "first" });
expect(lookup(2)).toEqual({ token: "overlap", fullName: "overlap" });
expect(lookup(10)).toBeNull();
expect(createDeclaringTypeLookup([], "method")(1)).toBeNull();
});
it.prop([
fc.array(
fc.record({
fieldStart: fc.integer({ min: 0, max: 30 }),
fieldEnd: fc.integer({ min: 0, max: 30 }),
methodStart: fc.integer({ min: 0, max: 30 }),
methodEnd: fc.integer({ min: 0, max: 30 }),
}),
{ maxLength: 20 },
),
])(
"agrees with observed first-match ownership for arbitrary metadata ranges",
(raw) => {
const ranges = raw.map((value, index) => ({
...value,
token: String(index),
fullName: `Type${String(index)}`,
}));
for (const table of ["field", "method"] as const) {
const lookup = createDeclaringTypeLookup(ranges, table);
for (const row of [
NaN,
-Infinity,
Infinity,
...Array.from({ length: 32 }, (_, i) => i),
])
expect(lookup(row)).toEqual(declaringType(ranges, table, row));
}
},
);
});
+36
View File
@@ -150,6 +150,42 @@ export const declaringType = (
return null;
};
/** Resolve member ownership without rescanning ordered type ranges per row. */
export const createDeclaringTypeLookup = (
ranges: readonly TypeRange[],
table: "field" | "method",
): ((row: number) => ReturnType<typeof declaringType>) => {
const entries = ranges.map((range) => ({
range,
start: table === "field" ? range.fieldStart : range.methodStart,
end: table === "field" ? range.fieldEnd : range.methodEnd,
}));
const ordered = entries.every((entry, index) => {
const previous = entries[index - 1];
return (
entry.start <= entry.end &&
(previous === undefined || previous.end <= entry.start)
);
});
// Malformed metadata can overlap or reverse ranges. Retain the original
// first-match interpretation rather than guessing a different owner.
if (!ordered) return (row) => declaringType(ranges, table, row);
return (row) => {
let first = 0;
let end = entries.length;
while (first < end) {
const middle = first + Math.floor((end - first) / 2);
const entry = entries[middle];
if (entry === undefined) return null;
if (row >= entry.start && row < entry.end)
return { token: entry.range.token, fullName: entry.range.fullName };
if (row < entry.start) end = middle;
else first = middle + 1;
}
return null;
};
};
export const parseTypes = (
bytes: Buffer,
layout: ManagedMetadataLayout,
+5 -3
View File
@@ -1,7 +1,7 @@
import type { ManagedPeLayout } from "./ManagedPeReader.js";
import type { ManagedMetadataLayout } from "./ManagedMetadataLayout.js";
import {
declaringType,
createDeclaringTypeLookup,
signature,
type FieldCore,
type ManagedCallEdge,
@@ -33,6 +33,7 @@ export const parseFields = (
const fields: ManagedField[] = [];
const core = new Map<string, FieldCore>();
const table = layout.table(4);
const declaringType = createDeclaringTypeLookup(ranges, "field");
for (let row = 1; row <= (table?.rowCount ?? 0); row += 1) {
const cursor = metadataRowCursor(bytes, layout, 4, row);
const flags = cursor.readUInt16();
@@ -48,7 +49,7 @@ export const parseFields = (
cursor.readIndex(layout.blobIndexSize),
layout.blob.size,
);
const declared = declaringType(ranges, "field", row);
const declared = declaringType(row);
const token = metadataToken(4, row);
fields.push({
token,
@@ -123,6 +124,7 @@ export const parseMethods = ({
const methods: ManagedMethod[] = [];
const core = new Map<string, MethodCore>();
const table = layout.table(6);
const declaringType = createDeclaringTypeLookup(ranges, "method");
for (let row = 1; row <= (table?.rowCount ?? 0); row += 1) {
const cursor = metadataRowCursor(bytes, layout, 6, row);
const rva = cursor.readUInt32();
@@ -141,7 +143,7 @@ export const parseMethods = ({
layout.blob.size,
);
cursor.readIndex(layout.tableIndexSize(8));
const declared = declaringType(ranges, "method", row);
const declared = declaringType(row);
const token = metadataToken(6, row);
methods.push({
token,
+77
View File
@@ -8,6 +8,7 @@ import { workspaceCliTest } from "../../support/cli/workspaceCliFixture.js";
import {
renderCliOutputArgumentError,
renderEmptyFilteredCliOutput,
sanitizeCliOutput,
validateCliOutputArguments,
} from "../../../src/cliOutput.js";
@@ -140,9 +141,85 @@ describe("CLI output argument and sanitization boundary", () => {
meta: { command: "analyze" },
});
});
it("renders an explicit empty projection for structured filtered output", () => {
for (const format of ["json", "jsonl", "yaml"])
expect(
renderEmptyFilteredCliOutput([
"providers",
"--format",
format,
"--filter-output",
"missing",
]),
).toBe("{}\n");
expect(
renderEmptyFilteredCliOutput(["providers", "--filter-output", "missing"]),
).toBeUndefined();
expect(
renderEmptyFilteredCliOutput(["providers", "--format", "json"]),
).toBeUndefined();
});
});
describe("compiled CLI output boundary", () => {
workspaceCliTest(
"emits valid JSON when an output filter misses the top-level result",
async ({ cli }) => {
const result = await cli.run({
arguments: [
"analyze-javascript-application",
"tests/conformance/readiness/javascript-cli",
"--format",
"json",
"--filter-output",
"summary",
],
});
expect(result).toMatchObject({
exitCode: 0,
stdout: "{}\n",
stderr: "",
json: {},
});
const nested = await cli.run({
arguments: [
"analyze-javascript-application",
"tests/conformance/readiness/javascript-cli",
"--format",
"json",
"--filter-output",
"normalized_result.not_a_field",
],
});
expect(nested).toMatchObject({
exitCode: 0,
stderr: "",
json: { normalized_result: {} },
});
const selected = await cli.run({
arguments: [
"analyze-javascript-application",
"tests/conformance/readiness/javascript-cli",
"--format",
"json",
"--filter-output",
"normalized_result.summary",
],
});
expect(selected).toMatchObject({
exitCode: 0,
stderr: "",
json: {
normalized_result: {
summary: expect.objectContaining({ browser_windows: 0 }),
},
},
});
},
CLI_INTEGRATION_TIMEOUT_MS,
);
workspaceCliTest(
"sanitizes a real missing-argument dispatcher failure",
async ({ cli }) => {
+1 -1
View File
@@ -27,7 +27,7 @@ describe("executable dispatcher", () => {
),
workspace.write(
"dist/cliOutput.js",
"export const sanitizeCliOutput = (output) => output; export const validateCliOutputArguments = () => ({ ok: true }); export const renderCliOutputArgumentError = () => '';\n",
"export const sanitizeCliOutput = (output) => output; export const validateCliOutputArguments = () => ({ ok: true }); export const renderCliOutputArgumentError = () => ''; export const renderEmptyFilteredCliOutput = () => undefined;\n",
),
workspace.write(
"dist/mcpDoctor.js",
@@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest";
import { classifyArtifactContent } from "../../../src/application/ArtifactGraphConstruction.js";
import { classifyRoot } from "../../../src/application/ArtifactInventory/classify.js";
import { ARTIFACT_CLASSIFICATION_PREFIX_BYTES } from "../../../src/application/ArtifactInventory/hash.js";
import { ARTIFACT_CLASSIFICATION_PREFIX_BYTES } from "../../../src/artifacts/ArtifactHash.js";
import { scanCanonicalArtifactInventory } from "../../../src/application/ArtifactInventory/scanCanonical.js";
import { targetFormatSchema } from "../../../src/contracts/toolOutputSchemaPrimitives.js";
import { artifactInventoryResultSchema } from "../../../src/domain/artifactGraph.js";
@@ -8,6 +8,7 @@ import { buildBinary } from "plist";
import { describe, expect, it } from "vitest";
import { analyzeInterfaceBuilderBundle } from "../../../src/application/InterfaceBuilderAnalysis.js";
import { encodeNibArchiveFixture } from "../../../src/artifacts/NibArchive.fixture.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const compile = promisify(execFile);
@@ -71,6 +72,99 @@ describe("compiled Interface Builder bundle reader", () => {
);
});
it("projects compiled AppKit actions from the control to their target", async () => {
const root = await createTestTempDirectory("rea-ib-test-");
const bundle = join(root, "Example.app");
const resources = join(bundle, "Contents", "Resources");
await mkdir(resources, { recursive: true });
// NSNibControlConnector archives the sending control as NSSource and its
// target as NSDestination; a nil target is the first responder.
await writeFile(
join(resources, "Panel.nib"),
encodeNibArchiveFixture({
classes: [
"NSNibExternalObjectPlaceholder",
"NSButton",
"NSNibControlConnector",
"NSString",
"NSMenuItem",
],
objects: [
{ classIndex: 0, values: {} },
{ classIndex: 1, values: {} },
{
classIndex: 2,
values: {
NSSource: { ref: 1 },
NSDestination: { ref: 0 },
NSLabel: { ref: 3 },
},
},
{ classIndex: 3, values: { "NS.bytes": "doOK:" } },
{ classIndex: 4, values: {} },
{
classIndex: 2,
values: {
NSSource: { ref: 4 },
NSDestination: null,
NSLabel: { ref: 6 },
},
},
{ classIndex: 3, values: { "NS.bytes": "arrangeInFront:" } },
],
}),
);
const analysis = await analyzeInterfaceBuilderBundle({
bundlePath: bundle,
targetSha256: "e".repeat(64),
});
const byId = new Map(analysis.graph.nodes.map((node) => [node.id, node]));
const routes = analysis.graph.nodes
.filter(({ kind }) => kind === "action")
.map((action) => ({
action: action.name,
from: analysis.graph.edges
.filter(
({ relation, to }) =>
relation === "target_action" && to === action.id,
)
.map(({ from }) => byId.get(from)?.name),
to: analysis.graph.edges
.filter(
({ relation, from, to }) =>
relation === "target_action" &&
from === action.id &&
(to === null || byId.get(to)?.kind !== "objc_selector"),
)
.map(({ to }) => (to === null ? null : byId.get(to)?.name)),
}))
.sort((left, right) => left.action.localeCompare(right.action));
expect(routes).toEqual([
{ action: "arrangeInFront:", from: ["NSMenuItem"], to: [null] },
{
action: "doOK:",
from: ["NSButton"],
to: ["NSNibExternalObjectPlaceholder"],
},
]);
});
it("honors cancellation during directory traversal", async () => {
const root = await createTestTempDirectory("rea-ib-test-");
const controller = new AbortController();
controller.abort();
await expect(
analyzeInterfaceBuilderBundle({
bundlePath: root,
targetSha256: "c".repeat(64),
signal: controller.signal,
}),
).rejects.toMatchObject({ reason: "cancelled" });
});
});
describe("flat Interface Builder plist values", () => {
it("decodes flat nib archives that hold data and date values", async () => {
const root = await createTestTempDirectory("rea-ib-test-");
const bundle = join(root, "Example.app");
@@ -112,19 +206,6 @@ describe("compiled Interface Builder bundle reader", () => {
);
expect(analysis.graph.nodes.map(({ name }) => name)).toContain("Build");
});
it("honors cancellation during directory traversal", async () => {
const root = await createTestTempDirectory("rea-ib-test-");
const controller = new AbortController();
controller.abort();
await expect(
analyzeInterfaceBuilderBundle({
bundlePath: root,
targetSha256: "c".repeat(64),
signal: controller.signal,
}),
).rejects.toMatchObject({ reason: "cancelled" });
});
});
describe("bounded Interface Builder archive decoding", () => {
@@ -225,7 +306,18 @@ describe("bounded Interface Builder archive decoding", () => {
analysis.graph.nodes.find(({ id }) => id === from)?.kind ===
"control",
);
expect(actionSource).toBeDefined();
const describeNode = (id: string | null) => {
const node = analysis.graph.nodes.find((item) => item.id === id);
return node === undefined ? String(id) : `${node.kind}:${node.name}`;
};
const actionRoutes = analysis.graph.edges
.filter(
({ relation, from, to }) =>
relation === "target_action" &&
(from === action?.id || to === action?.id),
)
.map(({ from, to }) => `${describeNode(from)} -> ${describeNode(to)}`);
expect(actionSource, actionRoutes.join("; ")).toBeDefined();
expect(
analysis.graph.edges.some(
({ from, relation, to }) =>
@@ -5,7 +5,7 @@ import { createPackage } from "@electron/asar";
import { describe, expect, it } from "vitest";
import { scanArtifactInventory } from "../../../src/application/ArtifactInventory.js";
import { readJavaScriptArtifactFiles } from "../../../src/application/javascript/JavaScriptArtifactFiles.js";
import { readJavaScriptArtifactFiles } from "../../../src/artifacts/javascript/JavaScriptArtifactFiles.js";
import { AsarArtifactReader } from "../../../src/artifacts/AsarArtifactReader.js";
import { DirectoryArtifactReader } from "../../../src/artifacts/DirectoryArtifactReader.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
@@ -6,7 +6,7 @@ import { expect, it } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { readJavaScriptArtifactFiles } from "../../../src/application/javascript/JavaScriptArtifactFiles.js";
import { readJavaScriptArtifactFiles } from "../../../src/artifacts/javascript/JavaScriptArtifactFiles.js";
import { reconstructJavaScriptArtifact } from "../../../src/application/javascript/JavaScriptArtifactReconstruction.js";
import { scanArtifactInventory } from "../../../src/application/ArtifactInventory.js";
import {
@@ -8,7 +8,7 @@ import {
resolveArtifactPathByContext,
type ArtifactPathResolution,
} from "../../../src/application/javascript/JavaScriptArtifactPathResolution.js";
import type { JavaScriptArtifactFile } from "../../../src/application/javascript/JavaScriptArtifactFiles.js";
import type { JavaScriptArtifactFile } from "../../../src/domain/javascript/javascriptArtifactFiles.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const execute = promisify(execFile);
const TARGETS = [
+42
View File
@@ -18,6 +18,48 @@ describe("incremental module import boundaries", () => {
["src/contracts/probe.ts", "../android/Provider.js", false],
["src/contracts/probe.ts", "../cli.js", false],
["src/contracts/probe.ts", "../composition/android.js", false],
[
"src/artifacts/javascript/probe.ts",
"../../application/ArtifactInventory.js",
false,
],
[
"src/artifacts/javascript/probe.ts",
"../../composition/android.js",
false,
],
["src/artifacts/javascript/probe.ts", "../../cli.js", false],
[
"src/artifacts/ArtifactHash.ts",
"../application/ArtifactInventory/hash.js",
false,
],
[
"src/artifacts/javascript/probe.ts",
"../../domain/artifactInventorySnapshot.js",
true,
],
["src/artifacts/javascript/probe.ts", "../AsarArtifactReader.js", true],
[
"src/application/javascript/probe.ts",
"../../artifacts/AsarArtifactReader.js",
false,
],
[
"src/application/javascript/probe.ts",
"../../artifacts/DirectoryArtifactReader.js",
false,
],
[
"src/application/javascript/probe.ts",
"../../artifacts/javascript/JavaScriptArtifactReader.js",
true,
],
[
"src/application/ArtifactInventory/probe.ts",
"../../artifacts/AsarArtifactReader.js",
true,
],
["src/domain/probe.test.ts", "../composition/firmware.js", false],
["src/application/probe.ts", "../android/JadxProvider.js", false],
["src/application/probe.ts", "../composition/android.js", false],
+1 -1
View File
@@ -11,7 +11,7 @@
<rect key="frame" x="120" y="400" width="150" height="44"/>
<title key="title" string="Build"/>
<connections>
<action selector="buildTapped:" destination="vc1" id="action1"/>
<action selector="buildTapped:" target="vc1" id="action1"/>
</connections>
</button>
</subviews>