chore: merge main and regenerate combined catalog metadata

This commit is contained in:
morluto 2026-10-07 18:44:34 +08:00
commit fd552a8c4b
107 changed files with 3225 additions and 422 deletions

View File

@ -1,30 +1,14 @@
name: Documentation site
name: Documentation build
on:
push:
branches: [main]
paths:
- "docs/**"
- "package.json"
- "package-lock.json"
- ".nvmrc"
- ".github/workflows/pages.yml"
pull_request:
branches: [main]
paths:
- "docs/**"
- "package.json"
- "package-lock.json"
- ".nvmrc"
- ".github/workflows/pages.yml"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: pages-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
group: documentation-build-${{ github.ref }}
cancel-in-progress: true
env:
HUSKY: "0"
@ -33,6 +17,7 @@ jobs:
build:
name: Build documentation
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
@ -41,23 +26,3 @@ jobs:
cache: npm
- run: npm ci
- run: npm run docs:build
- uses: actions/upload-pages-artifact@v4
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
with:
path: docs/.vitepress/dist
deploy:
name: Publish documentation
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
needs: build
runs-on: ubuntu-latest
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- uses: actions/configure-pages@v5
- uses: actions/deploy-pages@v4
id: deployment

28
.github/workflows/website-check.yml vendored Normal file
View File

@ -0,0 +1,28 @@
name: Website checks
on:
pull_request:
branches: [main]
paths:
- "website/**"
- "scripts/verify-website.py"
- ".github/workflows/**"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: website-check-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
check:
name: Website checks
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Check out the repository
uses: actions/checkout@v4
- name: Check website links and publishing configuration
run: python3 scripts/verify-website.py

View File

@ -21,6 +21,8 @@ jobs:
steps:
- name: Check out the repository
uses: actions/checkout@v4
- name: Check website links and publishing configuration
run: python3 scripts/verify-website.py
- name: Configure Pages
uses: actions/configure-pages@v5
- name: Upload the website

View File

@ -28,7 +28,8 @@
{
"files": [
"scripts/electron-active-hook*.cjs",
"tests/conformance/readiness/electron/*.js"
"tests/conformance/readiness/electron/*.js",
"website/public/examples/notes-electron/*.js"
],
"rules": {
"no-var-requires": "off"

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

View File

@ -284,7 +284,13 @@ Cross-version matching first prefers exact CIL/signature identity. When that
does not match, it pairs an exact declared type, method name, and raw signature
before trying structural body shape. The exact-signature key uses names only
as part of that full tuple; names alone never select a pair. Duplicate tuples
remain ambiguous rather than being paired by token order.
remain ambiguous rather than being paired by token order. Fields follow the
same exact tiers. The raw signature is exact whether or not REA decoded it, so
undecoded signatures pair by that tuple too, but they never enter structural
rounds. An unmatched member is therefore reported as `unknown`, not added or
removed, when its own signature was not decoded or when the other side has an
unpaired member, one-sided or ambiguous, with the same declared type and name
whose signature was not decoded.
For a matched method, an unavailable or partial body makes body-shape facets
unknown while preserving observed signature differences. Structural identity

View File

@ -669,9 +669,9 @@
"mcp_prompts": 6
},
"digests": {
"tools_sha256": "db85863d0b0947f21a14fcffe1e0e293842c68a209beb4125900d797f1242ccd",
"tools_sha256": "d0b9e5c99d6a2c9bdf065429db901231326a7de5910f2d878cee124891dba57e",
"prompts_sha256": "ef2de8814390b340623c32001610aa2709d90bed51537b6e562ab3abf144ab4d",
"combined_sha256": "7521c178e76e19b8fd552c2ca7e1ed4930dd86d2bf01d035951d974e4f6edab9",
"combined_sha256": "188d726867808cd63764625b93d89e2936af29d9943dbc0e5d22044294521e1d",
"providers_sha256": "a7d885531ee25d42c51385444faec544d34cd7eba6250a7dc75c4faedd4454a3"
}
}

View File

@ -1,5 +1,5 @@
{
"manifest_id": "ecm_7c7f2d7520a5b3ba8f4811c9ca9efcb4a7d9a7d7b68c57b396cc418c9cbea2ee",
"manifest_id": "ecm_4bb7d6acd148fffbd7e696cd56b22ed7a031fcb4b51473170058b4a9e902ee44",
"verifier": {
"id": "rea-managed-conformance",
"version": "1"
@ -13,7 +13,7 @@
"skill_digests": [
{
"skill_id": "reverse-engineer-anything",
"sha256": "acc3d70d97ac210557d5e80c0cd84c236474b7162fddf67753c5a2518b66bb54"
"sha256": "1738df3db4b39d4967e40cc147d24393fd8e1e21fc4f7b7b45da30ce8880c2a9"
}
],
"claims": [

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"

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)

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) ?? "");
}
}

106
scripts/verify-website.py Normal file
View File

@ -0,0 +1,106 @@
"""Check local website references and the sole GitHub Pages publisher."""
from html.parser import HTMLParser
from pathlib import Path
import re
import sys
from urllib.parse import unquote, urlsplit
import xml.etree.ElementTree as ET
class HtmlReferences(HTMLParser):
"""Collect document IDs and references without fetching external URLs."""
def __init__(self):
super().__init__()
self.ids = set()
self.references = []
self.duplicate_ids = []
def handle_starttag(self, tag, attrs):
attributes = dict(attrs)
identifier = attributes.get("id")
if identifier is not None:
if identifier in self.ids:
self.duplicate_ids.append(identifier)
self.ids.add(identifier)
for attribute in ("href", "src"):
reference = attributes.get(attribute)
if reference is not None:
self.references.append(reference)
def check_site(site):
"""Check assets, HTML fragment destinations and SVG syntax."""
pages = {}
errors = []
references = 0
for path in sorted(site.rglob("*.html")):
parser = HtmlReferences()
parser.feed(path.read_text(encoding="utf-8"))
pages[path] = parser
if site / "index.html" not in pages:
errors.append("website/public/index.html is missing")
for path, page in pages.items():
label = path.relative_to(site)
for identifier in page.duplicate_ids:
errors.append(f"{label}: duplicate ID {identifier!r}")
for reference in page.references:
url = urlsplit(reference)
if url.scheme or url.netloc:
continue
references += 1
if url.path.startswith("/"):
errors.append(f"{label}: use a relative site reference: {reference}")
continue
target = (path.parent / unquote(url.path)).resolve() if url.path else path
if not target.is_relative_to(site):
errors.append(f"{label}: reference leaves the website: {reference}")
continue
if target.is_dir():
target /= "index.html"
if not target.is_file():
errors.append(f"{label}: missing local target: {reference}")
elif target in pages and url.fragment and unquote(url.fragment) not in pages[target].ids:
errors.append(f"{label}: missing HTML fragment: {reference}")
for path in sorted(site.rglob("*.svg")):
try:
ET.parse(path)
except ET.ParseError as error:
errors.append(f"{path.relative_to(site)}: invalid SVG XML: {error}")
return len(pages), references, errors
def check_publisher(root):
"""Detect a second Pages action that could replace the website."""
expected = root / ".github/workflows/website-pages.yml"
workflows = root / ".github/workflows"
deploy_action = re.compile(r"^\s*-?\s*uses:\s*['\"]?actions/deploy-pages@", re.MULTILINE)
publishers = [
path
for path in sorted(workflows.iterdir())
if path.suffix in (".yml", ".yaml") and deploy_action.search(path.read_text(encoding="utf-8"))
]
if publishers != [expected]:
actual = ", ".join(str(path.relative_to(root)) for path in publishers) or "none"
return [f"Pages must have one publisher, website-pages.yml; found: {actual}"]
return []
def main():
"""Run the same checks locally, on website PRs and before publishing."""
root = Path(__file__).resolve().parent.parent
pages, references, errors = check_site(root / "website/public")
errors.extend(check_publisher(root))
if errors:
print("Website checks failed:\n" + "\n".join(errors), file=sys.stderr)
return 1
print(f"Verified {pages} HTML pages, {references} local references and SVG XML.")
print("website-pages.yml is the sole GitHub Pages publisher.")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -4,7 +4,7 @@ description: Reverse engineer native, managed, Electron/JavaScript, packaged, fi
metadata:
version: "27"
tool_count: 131
catalog_digest: "7521c178e76e19b8fd552c2ca7e1ed4930dd86d2bf01d035951d974e4f6edab9"
catalog_digest: "188d726867808cd63764625b93d89e2936af29d9943dbc0e5d22044294521e1d"
---
# REA

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,

View File

@ -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,

View File

@ -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,

View File

@ -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;

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";

View File

@ -10,9 +10,63 @@ import { createEvidence } from "../../domain/evidence.js";
import { createEvidenceBundle } from "../../domain/evidenceBundle.js";
import { ok } from "../../domain/result.js";
import { createAnalysisExecution } from "../AnalysisProvider.js";
import { AnalysisSnapshotCache } from "./AnalysisSnapshotCache.js";
import type {
AnalysisOperation,
CapabilityDescriptor,
} from "../AnalysisProvider.js";
import {
AnalysisSnapshotCache,
isSnapshotCacheable,
} from "./AnalysisSnapshotCache.js";
describe("analysis snapshot cache partitioning", () => {
it.each<{
operation: Exclude<AnalysisOperation, "health">;
parameters: Record<string, string>;
cacheable: boolean;
}>([
{ operation: "list_strings", parameters: {}, cacheable: false },
{
operation: "list_strings",
parameters: { document: "fixture" },
cacheable: true,
},
{
operation: "address_name",
parameters: { document: "fixture" },
cacheable: false,
},
{
operation: "address_name",
parameters: { document: "fixture", address: "0x1000" },
cacheable: true,
},
{ operation: "binary_overview", parameters: {}, cacheable: true },
])(
"retains explicit document and cursor requirements for $operation",
({ operation, parameters, cacheable }) => {
const descriptor: CapabilityDescriptor = {
operation,
provider: ANALYSIS_SNAPSHOT_PROVIDER,
available: true,
reason: null,
effects: {
mutatesArtifact: false,
launchesProcess: false,
mayShowUi: false,
mayAccessNetwork: false,
mayWriteFilesystem: false,
changesPermissions: false,
requiresRoot: false,
},
limitations: [],
};
expect(isSnapshotCacheable(operation, descriptor, parameters)).toBe(
cacheable,
);
},
);
it("returns detached data only from the exact provider/profile partition", () => {
const cache = new AnalysisSnapshotCache();
const execution = createAnalysisExecution(

View File

@ -24,7 +24,7 @@ import type {
AnalysisOperation,
CapabilityDescriptor,
} from "../AnalysisProvider.js";
import { GENERATED_MCP_TOOL_CATALOG } from "../../generatedMcpToolCatalog.js";
import { OFFICIAL_TOOL_CONTRACTS } from "../../contracts/officialToolContracts.js";
const STATEFUL_OPERATIONS: ReadonlySet<AnalysisOperation> = new Set([
"health",
@ -46,14 +46,12 @@ const CURSOR_DEFAULT_OPERATIONS: ReadonlySet<AnalysisOperation> = new Set([
]);
const DOCUMENT_SCOPED_OPERATIONS: ReadonlySet<string> = new Set(
GENERATED_MCP_TOOL_CATALOG.filter(({ kind }) => kind === "official-proxy")
.map(({ name }) => name)
.filter(
(name) =>
name !== "current_document" &&
name !== "list_documents" &&
name !== "set_current_document",
),
OFFICIAL_TOOL_CONTRACTS.map(({ name }) => name).filter(
(name) =>
name !== "current_document" &&
name !== "list_documents" &&
name !== "set_current_document",
),
);
/** Whether an operation is immutable and independent of provider UI state. */

View File

@ -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. */

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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;

View File

@ -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 {

View File

@ -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,

View File

@ -11,7 +11,7 @@ import type {
JavaScriptArtifactFile,
JavaScriptArtifactFileSet,
JavaScriptArtifactFileKind,
} from "./JavaScriptArtifactFiles.js";
} from "../../domain/javascript/javascriptArtifactFiles.js";
import { JavaScriptArtifactGraphAccumulator } from "./JavaScriptArtifactGraphAccumulator.js";
import {
artifactObservationEvidence,

View File

@ -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,

View File

@ -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,

View File

@ -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";

View File

@ -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,

View File

@ -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(

View File

@ -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",

View File

@ -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 = (

View File

@ -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,

View File

@ -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[]) =>

View File

@ -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";

View File

@ -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,

View File

@ -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,

View File

@ -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.";

View File

@ -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. */

View File

@ -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,

View File

@ -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";

View File

@ -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";

View File

@ -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,

View File

@ -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;

View File

@ -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);

View File

@ -3,7 +3,8 @@ import { AnyMap, eachMapping } from "@jridgewell/trace-mapping";
import { sanitizeBrowserUrl } from "../domain/browserObservation.js";
import { isUrlLikeModuleSpecifier } from "../domain/webBundleAnalyzerAst.js";
import { analyzeParsedJavaScriptSemantics } from "../domain/javascript/javascriptSemanticAnalysis.js";
import { analyzeParsedJavaScriptReferences } from "../domain/javascript/javascriptSemanticAnalysis.js";
import { traverseJavaScriptAst } from "../domain/javascript/javascriptSemanticTraversal.js";
import { parseJavaScriptSource } from "../domain/javascript/javascriptSourceParser.js";
import { hasValidSourceMapContents } from "../domain/sourceMapContents.js";
import type {
@ -237,42 +238,42 @@ const originalModuleEdges = (
// after an unrecoverable point are missing, so the edges are a subset.
if (parsed.errors.length > 0) incomplete.push(source.source);
let unboundRequires: ReadonlySet<string> | undefined;
t.traverseFast(parsed, (node) => {
const dependency = originalDependency(node);
if (dependency === null) return;
const { kind, specifier } = dependency;
if (kind === "require" && t.isCallExpression(node)) {
unboundRequires ??= new Set(
analyzeParsedJavaScriptSemantics(parsed)
.references.filter(
({ name, role, resolution }) =>
name === "require" &&
role === "read" &&
resolution === "unbound",
traverseJavaScriptAst(parsed, {
enter: (node) => {
const dependency = originalDependency(node);
if (dependency === null) return;
const { kind, specifier } = dependency;
if (kind === "require" && t.isCallExpression(node)) {
unboundRequires ??= new Set(
analyzeParsedJavaScriptReferences(parsed, "require")
.filter(
({ role, resolution }) =>
role === "read" && resolution === "unbound",
)
.map(
({ location }) =>
`${String(location.start.line)}:${String(location.start.column)}`,
),
);
const location = node.callee.loc?.start;
if (
location === undefined ||
!unboundRequires.has(
`${String(location.line)}:${String(location.column)}`,
)
.map(
({ location }) =>
`${String(location.start.line)}:${String(location.start.column)}`,
),
);
const location = node.callee.loc?.start;
if (
location === undefined ||
!unboundRequires.has(
`${String(location.line)}:${String(location.column)}`,
)
)
return;
}
const key = `${source.source}\0${kind}\0${specifier}`;
if (seen.has(key)) return;
seen.add(key);
edges.push({
from_source: source.source,
kind,
specifier,
resolved_source: resolveOriginalSource(specifier, source.source),
});
return;
}
const key = `${source.source}\0${kind}\0${specifier}`;
if (seen.has(key)) return;
seen.add(key);
edges.push({
from_source: source.source,
kind,
specifier,
resolved_source: resolveOriginalSource(specifier, source.source),
});
},
});
}
return { edges, incomplete };

View File

@ -9,26 +9,31 @@ import { CLI_COMMAND_NAMES } from "./cliCommandNames.js";
export { CLI_COMMAND_NAMES } from "./cliCommandNames.js";
const toolCatalog = TOOL_CONTRACTS.map((contract) => ({
name: contract.name,
title: contract.title,
surface: contract.kind,
description: contract.description,
effects: { ...contract.effects },
annotations: contract.annotations,
input_schema: {
type: "object",
...z.toJSONSchema(contract.inputSchema, {
unrepresentable: "any",
}),
},
output_schema: {
type: "object",
...z.toJSONSchema(contract.outputSchema, {
unrepresentable: "any",
}),
},
})).sort((left, right) => left.name.localeCompare(right.name));
const sortedToolContracts = [...TOOL_CONTRACTS].sort((left, right) =>
left.name.localeCompare(right.name),
);
const createToolCatalog = () =>
sortedToolContracts.map((contract) => ({
name: contract.name,
title: contract.title,
surface: contract.kind,
description: contract.description,
effects: { ...contract.effects },
annotations: contract.annotations,
input_schema: {
type: "object",
...z.toJSONSchema(contract.inputSchema, {
unrepresentable: "any",
}),
},
output_schema: {
type: "object",
...z.toJSONSchema(contract.outputSchema, {
unrepresentable: "any",
}),
},
}));
const promptCatalog = PROMPT_CONTRACTS.map((contract) => ({
name: contract.name,
@ -45,26 +50,35 @@ const digest = (value: unknown): string => {
return createHash("sha256").update(encoded).digest("hex");
};
/** Stable, schema-sensitive identity for every public REA surface. */
export const CATALOG_IDENTITY = {
counts: {
cli_commands: CLI_COMMAND_NAMES.length,
mcp_tools: toolCatalog.length,
mcp_prompts: promptCatalog.length,
},
digests: {
tools_sha256: digest(toolCatalog),
const createCatalogDigests = () => {
const tools = createToolCatalog();
return {
tools_sha256: digest(tools),
prompts_sha256: digest(promptCatalog),
combined_sha256: digest({
cli: CLI_COMMAND_NAMES,
tools: toolCatalog,
tools,
prompts: promptCatalog,
}),
} as const;
};
let catalogDigests: ReturnType<typeof createCatalogDigests> | undefined;
/** Stable, schema-sensitive identity; schema digests are computed on first use. */
export const CATALOG_IDENTITY = {
counts: {
cli_commands: CLI_COMMAND_NAMES.length,
mcp_tools: sortedToolContracts.length,
mcp_prompts: promptCatalog.length,
},
tools: toolCatalog.map(({ name, surface, effects, annotations }) => ({
get digests() {
return (catalogDigests ??= createCatalogDigests());
},
tools: sortedToolContracts.map(({ name, kind, effects, annotations }) => ({
name,
surface,
effects,
surface: kind,
effects: { ...effects },
annotations: {
read_only: annotations.readOnlyHint ?? false,
destructive: annotations.destructiveHint ?? false,

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 }>,

View File

@ -126,7 +126,7 @@ export const MANAGED_WORKFLOW_TOOL_CONTRACTS = [
name: "compare_managed_members",
...toolContractMetadata("compare_managed_members"),
description:
"Compare two authenticated inspect_managed_members Evidence records using unique-only decoded-CIL/signature and structural method-shape tiers. Names are reported as observations but are not used as a matching basis; metadata tokens remain build-local coordinates bound to each artifact SHA-256 and MVID, and the tuple digest does not itself remap them.",
"Compare two authenticated inspect_managed_members Evidence records using unique-only tiers: exact CIL/signature, exact declared type, name, and raw signature (decoded or not), and decoded structural method shape. Names alone are never a matching basis; a member whose signature was not decoded, or whose same-named counterpart's was not, stays unknown rather than added or removed. Metadata tokens remain build-local coordinates bound to each artifact SHA-256 and MVID, and the tuple digest does not itself remap them.",
kind: "application",
inputSchema: compareManagedMembersReferenceInputSchema,
outputSchema: comparisonOutputSchema,

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[];
}

View File

@ -317,3 +317,110 @@ describe("keyed archive decoding and graph limits", () => {
);
});
});
describe("AppKit keyed archive connectors", () => {
it("reads outlet and control connectors from NS-prefixed keys", () => {
const parsed = parseInterfaceBuilderRecords({
$archiver: "NSKeyedArchiver",
$objects: [
"$null",
{ $class: { UID: 6 } },
{ $class: { UID: 7 }, identifier: "unrelated-identifier" },
{
$class: { UID: 8 },
NSSource: { UID: 1 },
NSDestination: { UID: 2 },
NSLabel: { UID: 5 },
},
{
$class: { UID: 9 },
NSSource: { UID: 2 },
NSDestination: { UID: 1 },
NSLabel: { UID: 10 },
},
"o_button",
{
$classname: "NSCustomObject",
$classes: ["NSCustomObject", "NSObject"],
},
{ $classname: "NSButton", $classes: ["NSButton", "NSObject"] },
{
$classname: "NSNibOutletConnector",
$classes: ["NSNibOutletConnector", "NSNibConnector", "NSObject"],
},
{
$classname: "NSNibControlConnector",
$classes: ["NSNibControlConnector", "NSNibConnector", "NSObject"],
},
"buttonClicked:",
],
$top: {},
});
expect(parsed.connections).toEqual([
expect.objectContaining({
kind: "outlet",
source_id: "1",
destination_id: "2",
label: "o_button",
}),
expect.objectContaining({
kind: "action",
source_id: "2",
destination_id: "1",
label: "buttonClicked:",
}),
]);
});
it("projects an AppKit action from the control through the selector to its target", () => {
const analysis = buildInterfaceBuilderAnalysis({
targetSha256: hash,
toolVersion: "test",
documents: [
{
relativePath: "MainMenu.nib",
archiveSha256: hash,
documentKind: "nib",
raw: {
$archiver: "NSKeyedArchiver",
$objects: [
"$null",
{ $class: { UID: 4 } },
{ $class: { UID: 5 } },
{
$class: { UID: 6 },
NSSource: { UID: 2 },
NSDestination: { UID: 1 },
NSLabel: "buttonClicked:",
},
{ $classname: "BuildDelegate", $classes: ["NSObject"] },
{ $classname: "NSButton", $classes: ["NSButton", "NSObject"] },
{
$classname: "NSNibControlConnector",
$classes: ["NSNibControlConnector", "NSObject"],
},
],
$top: {},
},
},
],
limits: interfaceBuilderLimitsSchema.parse({}),
});
const byId = new Map(analysis.graph.nodes.map((node) => [node.id, node]));
const action = analysis.graph.nodes.find(({ kind }) => kind === "action");
const from = analysis.graph.edges
.filter(
({ to, relation }) => relation === "target_action" && to === action?.id,
)
.map(({ from: id }) => byId.get(id)?.name);
const to = analysis.graph.edges
.filter(
({ from: id, relation }) =>
relation === "target_action" && id === action?.id,
)
.map(({ to: id }) => (id === null ? null : byId.get(id)?.kind));
expect(from).toEqual(["NSButton"]);
expect(to).toEqual(expect.arrayContaining(["objc_selector", "unknown"]));
});
});

View File

@ -216,8 +216,13 @@ const parseKeyedArchive = (
)
) {
const controlAction = /ControlConnector/iu.test(className);
// AppKit connectors (NSNibOutletConnector, NSNibControlConnector)
// archive NSSource as the outlet owner or sending control and
// NSDestination as the outlet value or action target, so they are read
// in that order without the inversion the unprefixed fields use.
const source = firstObjectReference(
controlAction ? fields.destination : fields.source,
archivedObjectReference(fields.NSSource),
controlAction ? fields.to : fields.from,
controlAction ? fields.target : fields.owner,
);
@ -229,11 +234,13 @@ const parseKeyedArchive = (
source_id: source,
destination_id: firstObjectReference(
controlAction ? fields.source : fields.destination,
archivedObjectReference(fields.NSDestination),
controlAction ? fields.from : fields.to,
controlAction ? fields.owner : fields.target,
),
label: firstString(
fields.label,
fields.NSLabel,
fields.selector,
fields.identifier,
fields.action,
@ -285,6 +292,18 @@ export const firstString = (...values: unknown[]): string | null => {
return null;
};
/**
* The node identity of a resolved archive reference: its authored object ID,
* otherwise its archive UID, which is how the referenced object's node is keyed.
*/
const archivedObjectReference = (value: unknown): string | null => {
const item = record(value);
if (typeof item.archiveUID !== "number") return null;
return (
firstString(item.objectID, item["object-id"]) ?? String(item.archiveUID)
);
};
const firstObjectReference = (...values: unknown[]): string | null => {
for (const value of values) {
const direct = firstString(value);

View File

@ -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;
}

View File

@ -5,6 +5,7 @@ import {
type JavaScriptModuleOrigin,
type JavaScriptSemanticDefinition,
type JavaScriptSemanticIr,
type JavaScriptSemanticReference,
} from "./javascriptSemanticIr.js";
import {
collectSemanticModuleLink,
@ -71,6 +72,16 @@ export const analyzeJavaScriptSemantics = (
: analyzeParsedJavaScriptSemantics(file);
};
/** Recover lexical references, optionally for one name, without evaluating values or provenance. */
export const analyzeParsedJavaScriptReferences = (
file: ParsedJavaScriptSource,
name?: string,
): readonly JavaScriptSemanticReference[] => {
const state = createState(file.program);
collectDefinitions(file.program, state);
return collectSemanticReferences(file.program, state, name);
};
/** Recover semantics from an already parsed JavaScript artifact. */
export const analyzeParsedJavaScriptSemantics = (
file: ParsedJavaScriptSource,

View File

@ -92,16 +92,22 @@ export const collectSemanticModuleLink = (
else if (t.isAssignmentExpression(node)) collectCommonJsExport(node, state);
};
/** Collect bounded lexical identifier references after definitions exist. */
/** Collect lexical references after definitions exist, optionally for one name. */
export const collectSemanticReferences = (
program: t.Program,
state: JavaScriptSemanticAnalysisState,
name?: string,
): JavaScriptSemanticReference[] => {
const output: JavaScriptSemanticReference[] = [];
const seen = new Set<string>();
traverseJavaScriptAst(program, {
enter: (node, parent, readAncestors) => {
if (!t.isIdentifier(node) || parent === null) return;
if (
!t.isIdentifier(node) ||
parent === null ||
(name !== undefined && node.name !== name)
)
return;
const role = semanticIdentifierRole(node, parent, readAncestors());
if (role === null) return;
// A compound assignment or update reads before it writes; emit both so

View File

@ -410,7 +410,7 @@ const BROWSING_CONTEXT_KEYWORDS = new Set([
* `window.open(url, "_blank")` share the callee name. A literal method must
* be an HTTP method; with a computed method, the second literal is a URL
* unless it is provably an fs flag or a browsing-context keyword, or the
* receiver is spelled as a browsing context. XHR normalizes the standard
* receiver is spelled as a browsing context or storage. XHR normalizes the standard
* methods' case; extension methods such as WebDAV `PROPFIND` are
* conventionally uppercase tokens.
*/
@ -422,13 +422,18 @@ const xhrOpenUrl = (
const url = stringValue(urlNode);
if (url === undefined) return undefined;
const method = stringValue(methodNode);
// IndexedDB and Cache Storage `open` take a name and a version, so a
// receiver spelled as storage needs a standard HTTP method literal.
const storage = storageKind(name) !== undefined;
if (method === undefined)
return WINDOW_OPEN_CALL.test(name) ||
return storage ||
WINDOW_OPEN_CALL.test(name) ||
FS_OPEN_FLAGS.has(url) ||
BROWSING_CONTEXT_KEYWORDS.has(url.toLowerCase())
? undefined
: url;
return XHR_METHODS.has(method.toUpperCase()) || /^[A-Z][A-Z-]*$/u.test(method)
return XHR_METHODS.has(method.toUpperCase()) ||
(!storage && /^[A-Z][A-Z-]*$/u.test(method))
? url
: undefined;
};

View File

@ -68,7 +68,7 @@ export const buildComparisonMatching = (
exact_il_signature: methodItems.filter(
({ match }) => match.basis === "exact-il-signature",
).length,
exact_signature: methodItems.filter(
exact_signature: [...methodItems, ...fieldItems].filter(
({ match }) => match.basis === "exact-signature",
).length,
structural_method_shape: methodItems.filter(
@ -92,6 +92,7 @@ export const comparisonLimitations = (
const limitations: string[] = [
"Metadata tokens are build-local coordinates; matched pairs are remaps, not persistent identities.",
"Methods pair by exact CIL/signature, then declared type, name, and signature, then structural shape; names alone are never a matching basis.",
"Fields pair by exact signature, then declared type, name, and signature; an undecoded signature is compared only by its raw bytes, and an unmatched member with one stays unknown.",
];
if (left.coverage.state !== "complete" || right.coverage.state !== "complete")
limitations.push("At least one managed member observation is partial.");

View File

@ -28,6 +28,12 @@ interface ComparisonItemContext {
readonly rightComplete: boolean;
}
/** Declared type and name of each unpaired member, per side, whose signature was not decoded. */
interface UndecodedOneSidedNames {
readonly left: ReadonlySet<string>;
readonly right: ReadonlySet<string>;
}
const methodDimensions = (
left: Method,
right: Method,
@ -113,79 +119,167 @@ const unmatchedComparison = (): UnmatchedComparison => ({
const methodOnlyItem = (
member: OneSided<Method>,
context: ComparisonItemContext,
undecoded: UndecodedOneSidedNames,
): MethodItem => {
if (member.left !== undefined) {
const absenceObserved = context.rightComplete;
const limitations = absenceLimitations(member.left, {
oppositeComplete: context.rightComplete,
oppositeUndecodedNames: undecoded.right,
opposite: "right",
inventory: "method",
});
return {
item_id: `mmc_method_${sha256({ token: member.left.token, side: "left" })}`,
status: absenceObserved ? "removed" : "unknown",
status: limitations.length === 0 ? "removed" : "unknown",
left: methodIdentity(member.left),
right: null,
match: unmatchedComparison(),
dimensions: ["availability"],
evidence_links: [context.leftEvidenceId, context.rightEvidenceId],
limitations: absenceObserved
? []
: [
"unknown-within-incomplete-metadata: The right method inventory is incomplete, so absence was not observed.",
],
limitations,
};
}
const absenceObserved = context.leftComplete;
const limitations = absenceLimitations(member.right, {
oppositeComplete: context.leftComplete,
oppositeUndecodedNames: undecoded.left,
opposite: "left",
inventory: "method",
});
return {
item_id: `mmc_method_${sha256({ token: member.right.token, side: "right" })}`,
status: absenceObserved ? "added" : "unknown",
status: limitations.length === 0 ? "added" : "unknown",
left: null,
right: methodIdentity(member.right),
match: unmatchedComparison(),
dimensions: ["availability"],
evidence_links: [context.leftEvidenceId, context.rightEvidenceId],
limitations: absenceObserved
? []
: [
"unknown-within-incomplete-metadata: The left method inventory is incomplete, so absence was not observed.",
],
limitations,
};
};
/**
* Why a one-sided member's absence from the other inventory was not
* observed: that inventory is incomplete, the member's own signature was not
* decoded, so no shape round could seek a changed counterpart, or the other
* inventory has an unpaired (one-sided or ambiguous) same-named member whose
* signature was not decoded, which may be this member with a changed
* signature.
*/
const absenceLimitations = (
member: Method | Field,
opposite: {
readonly oppositeComplete: boolean;
readonly oppositeUndecodedNames: ReadonlySet<string>;
readonly opposite: "left" | "right";
readonly inventory: "method" | "field";
},
): string[] => [
...(opposite.oppositeComplete
? []
: [
`unknown-within-incomplete-metadata: The ${opposite.opposite} ${opposite.inventory} inventory is incomplete, so absence was not observed.`,
]),
...(member.signature.parse_status === "decoded"
? []
: [
`signature-not-decoded: The member's ${member.signature.parse_status} signature was compared only by exact declared type, name, and raw bytes, so a changed or renamed counterpart could not be sought.`,
]),
...(member.signature.parse_status === "decoded" &&
opposite.oppositeUndecodedNames.has(declaredName(member))
? [
`counterpart-signature-not-decoded: The ${opposite.opposite} ${opposite.inventory} inventory has an unpaired member with the same declared type and name whose signature was not decoded, so it may be this member with a changed signature.`,
]
: []),
];
const declaredName = (member: Method | Field): string =>
JSON.stringify([member.declaring_type, member.name]);
/**
* Declared names of unpaired members whose signature was not decoded: one-sided
* members and candidates of ambiguous groups, which also remain unpaired.
*/
const undecodedOneSidedNames = (members: {
readonly leftOnly: readonly { readonly item: Method | Field }[];
readonly rightOnly: readonly { readonly item: Method | Field }[];
readonly ambiguous: readonly {
readonly left: readonly { readonly item: Method | Field }[];
readonly right: readonly { readonly item: Method | Field }[];
}[];
}): UndecodedOneSidedNames => {
const names = (side: readonly { readonly item: Method | Field }[]) =>
new Set(
side
.filter(({ item }) => item.signature.parse_status !== "decoded")
.map(({ item }) => declaredName(item)),
);
return {
left: names([
...members.leftOnly,
...members.ambiguous.flatMap(({ left }) => left),
]),
right: names([
...members.rightOnly,
...members.ambiguous.flatMap(({ right }) => right),
]),
};
};
const fieldOnlyItem = (
member: OneSided<Field>,
context: ComparisonItemContext,
undecoded: UndecodedOneSidedNames,
): FieldItem => {
if (member.left !== undefined) {
const absenceObserved = context.rightComplete;
const limitations = absenceLimitations(member.left, {
oppositeComplete: context.rightComplete,
oppositeUndecodedNames: undecoded.right,
opposite: "right",
inventory: "field",
});
return {
item_id: `mmc_field_${sha256({ token: member.left.token, side: "left" })}`,
status: absenceObserved ? "removed" : "unknown",
status: limitations.length === 0 ? "removed" : "unknown",
left: fieldIdentity(member.left),
right: null,
match: unmatchedComparison(),
evidence_links: [context.leftEvidenceId, context.rightEvidenceId],
limitations: absenceObserved
? []
: [
"unknown-within-incomplete-metadata: The right field inventory is incomplete, so absence was not observed.",
],
limitations,
};
}
const absenceObserved = context.leftComplete;
const limitations = absenceLimitations(member.right, {
oppositeComplete: context.leftComplete,
oppositeUndecodedNames: undecoded.left,
opposite: "left",
inventory: "field",
});
return {
item_id: `mmc_field_${sha256({ token: member.right.token, side: "right" })}`,
status: absenceObserved ? "added" : "unknown",
status: limitations.length === 0 ? "added" : "unknown",
left: null,
right: fieldIdentity(member.right),
match: unmatchedComparison(),
evidence_links: [context.leftEvidenceId, context.rightEvidenceId],
limitations: absenceObserved
? []
: [
"unknown-within-incomplete-metadata: The left field inventory is incomplete, so absence was not observed.",
],
limitations,
};
};
/**
* Name the identity key an ambiguous group shares. The exact-signature key
* already includes the declared type and name, so names cannot separate it.
*/
const ambiguityLimitation = (
members: "methods" | "fields",
basis: MethodItem["match"]["basis"],
): string =>
basis === "exact-signature"
? `Multiple managed ${members} share the same declared type, name, and raw signature; REA did not guess a token remap.`
: members === "methods"
? "Multiple managed methods share the same non-name identity key; REA did not guess a token remap."
: "Multiple managed fields share the same signature; REA did not guess a token remap from names.";
export const buildMethodItems = (
matches: ManagedMethodMatches,
context: ComparisonItemContext,
@ -248,15 +342,14 @@ export const buildMethodItems = (
},
dimensions: ["availability"],
evidence_links: [context.leftEvidenceId, context.rightEvidenceId],
limitations: [
"Multiple managed methods share the same non-name identity key; REA did not guess a token remap.",
],
limitations: [ambiguityLimitation("methods", ambiguous.basis)],
});
}
const undecoded = undecodedOneSidedNames(matches);
for (const item of matches.leftOnly)
items.push(methodOnlyItem({ left: item.item }, context));
items.push(methodOnlyItem({ left: item.item }, context, undecoded));
for (const item of matches.rightOnly)
items.push(methodOnlyItem({ right: item.item }, context));
items.push(methodOnlyItem({ right: item.item }, context, undecoded));
return items;
};
@ -306,13 +399,12 @@ export const buildFieldItems = (
candidate_right_tokens: ambiguous.right.map(({ item }) => item.token),
},
evidence_links: [context.leftEvidenceId, context.rightEvidenceId],
limitations: [
"Multiple managed fields share the same signature; REA did not guess a token remap from names.",
],
limitations: [ambiguityLimitation("fields", ambiguous.basis)],
});
const undecoded = undecodedOneSidedNames(matches);
for (const item of matches.leftOnly)
items.push(fieldOnlyItem({ left: item.item }, context));
items.push(fieldOnlyItem({ left: item.item }, context, undecoded));
for (const item of matches.rightOnly)
items.push(fieldOnlyItem({ right: item.item }, context));
items.push(fieldOnlyItem({ right: item.item }, context, undecoded));
return items;
};

View File

@ -58,15 +58,13 @@ const keyMethod = (item: Method): Keyed<Method> => ({
item.body.normalized_il_sha256,
])
: null,
signatureKey:
item.signature.parse_status === "decoded"
? stableKey([
"method-signature",
item.declaring_type,
item.name,
item.signature.raw_sha256,
])
: null,
// The raw blob is the exact signature; decoding only feeds shape keys.
signatureKey: stableKey([
"method-signature",
item.declaring_type,
item.name,
item.signature.raw_sha256,
]),
structuralKey:
item.signature.parse_status === "decoded" && item.body.status === "present"
? stableKey([
@ -104,7 +102,14 @@ const keyField = (item: Field): Keyed<Field> => ({
item.signature.parse_status === "decoded"
? stableKey(["field-exact", item.signature.raw_sha256])
: null,
signatureKey: null,
// Only an undecoded field reaches this round: a decoded field with the
// same raw signature already met its counterpart in the exact round.
signatureKey: stableKey([
"field-signature",
item.declaring_type,
item.name,
item.signature.raw_sha256,
]),
structuralKey: stableKey([
"field-structural",
item.signature.kind,
@ -143,7 +148,12 @@ const matchFields = (
left,
right,
exactBasis: "field-signature",
fallbackBases: [],
fallbackBases: [
{
basis: "exact-signature",
key: ({ signatureKey }) => signatureKey,
},
],
});
export type ManagedMethodMatches = ReturnType<typeof matchMethods>;

View File

@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { webPageInspectionSchema } from "./browserObservation.js";
import { analyzeJavaScriptStaticSource } from "./javascript/javascriptStaticAnalysis.js";
import { analyzeCapturedWebBundle } from "./webBundleAnalyzer.js";
import { webBundleAnalysisSchema } from "./webBundleAnalysis.js";
import { createWebTextArtifact } from "./webContentArtifact.js";
@ -208,6 +209,30 @@ describe("web bundle static-analysis parity", () => {
]);
});
it("reads storage open versions as storage rather than endpoints in both analyzers", () => {
const source = `
indexedDB.open(databaseName, "2");
window.indexedDB.open(databaseName, "/v3");
indexedDB.open("APP", "4");
indexedDB.open("GET", "/storage-named-xhr");
`;
expect(
analyzeCapturedWebBundle(inspection(source)).observations.endpoints.map(
({ value }) => value,
),
).toEqual(["/storage-named-xhr"]);
const analysis = analyzeJavaScriptStaticSource(source);
expect(analysis.endpoints.map(({ value }) => value)).toEqual([
"/storage-named-xhr",
]);
expect(analysis.storage.map(({ kind }) => kind)).toEqual([
"indexed-db",
"indexed-db",
"indexed-db",
"indexed-db",
]);
});
it("keeps HTTP-method-named reads of provable keyed collections out of endpoints", () => {
const result = analyzeCapturedWebBundle(
inspection(`
@ -276,6 +301,32 @@ describe("web bundle artifact metadata", () => {
});
});
describe("deep captured web bundle syntax", () => {
it("retains evidence after a parser-admitted deep property chain", () => {
const source = `const value = root${".next".repeat(12_000)};\nimport "./last.js";\nfetch("/after");`;
const result = analyzeCapturedWebBundle(inspection(source));
expect(result.completeness).toMatchObject({
status: "complete",
parsed_scripts: 1,
parse_failures: 0,
});
expect(result.observations.chunks.edges).toContainEqual(
expect.objectContaining({
kind: "static_import",
specifier: "./last.js",
resolved_url: `${origin}/assets/last.js`,
location: expect.objectContaining({ line: 2, column: 0 }),
}),
);
expect(result.observations.endpoints).toContainEqual(
expect.objectContaining({
value: "/after",
location: expect.objectContaining({ line: 3, column: 0 }),
}),
);
});
});
const inspection = (source: string) =>
webPageInspectionSchema.parse({
browser: {

View File

@ -4,6 +4,7 @@ import * as t from "@babel/types";
import { sanitizeEndpointCandidate } from "./browserObservation.js";
import type { WebPageInspection } from "./browserObservation.js";
import type { WebBundleAnalysis } from "./webBundleAnalysis.js";
import { traverseJavaScriptAst } from "./javascript/javascriptSemanticTraversal.js";
import { semanticStaticPropertyName } from "./javascript/javascriptAstValues.js";
import {
calleeName,
@ -61,10 +62,11 @@ export const analyzeScript = (
}
accumulator.parsedScripts += 1;
detectVendorFingerprints(script, accumulator);
t.traverseFast(file, (node) => {
accumulator.visitedNodes += 1;
inspectNode(script, node, accumulator);
return undefined;
traverseJavaScriptAst(file, {
enter: (node) => {
accumulator.visitedNodes += 1;
inspectNode(script, node, accumulator);
},
});
};

View File

@ -47,6 +47,31 @@ describe("native module syntax observations", () => {
});
});
describe("deep native module syntax", () => {
it("retains imports and their locations after a parser-admitted deep property chain", () => {
const source = `const value = root${".next".repeat(12_000)};\nimport "./last.js";\nexport * from "./reexport.js";\nimport("./lazy.js");`;
const result = collectWebModuleImports(source);
expect(result.state).toBe("parsed");
expect(result.diagnostics).toEqual([]);
expect(
result.imports.map(({ kind, specifier }) => [kind, specifier]),
).toEqual([
["static-import", "./last.js"],
["re-export", "./reexport.js"],
["dynamic-import", "./lazy.js"],
]);
for (const item of result.imports)
expect(source.slice(item.start.offset, item.end.offset)).toBe(
item.expression,
);
expect(result.imports[0]?.start).toEqual({
offset: source.indexOf('"./last.js"'),
line: 2,
column: 7,
});
});
});
const script = (
url: string,
kind: "page-script" | "scenario-response",

View File

@ -1,5 +1,6 @@
import { traverseFast, isStringLiteral, isTemplateLiteral } from "@babel/types";
import { isStringLiteral, isTemplateLiteral } from "@babel/types";
import type { Node } from "@babel/types";
import { traverseJavaScriptAst } from "./javascript/javascriptSemanticTraversal.js";
import { parseJavaScriptSource } from "./javascript/javascriptSourceParser.js";
import type { WebModuleImport } from "./webModuleTrace.js";
import type { ExportedWebScript } from "./webScriptExport.js";
@ -51,46 +52,48 @@ export const collectWebModuleImports = (
},
});
};
traverseFast(ast, (node) => {
if (node.type === "ImportDeclaration") {
if (node.importKind === "type" || node.importKind === "typeof") return;
if (
node.specifiers.length > 0 &&
node.specifiers.every(
(s) =>
s.type === "ImportSpecifier" &&
(s.importKind === "type" || s.importKind === "typeof"),
traverseJavaScriptAst(ast, {
enter: (node) => {
if (node.type === "ImportDeclaration") {
if (node.importKind === "type" || node.importKind === "typeof") return;
if (
node.specifiers.length > 0 &&
node.specifiers.every(
(s) =>
s.type === "ImportSpecifier" &&
(s.importKind === "type" || s.importKind === "typeof"),
)
)
)
return;
add("static-import", node.source);
} else if (
node.type === "ExportAllDeclaration" ||
node.type === "ExportNamedDeclaration"
) {
if (
node.exportKind === "type" ||
node.source === null ||
node.source === undefined
)
return;
if (
node.type === "ExportNamedDeclaration" &&
node.specifiers.length > 0 &&
node.specifiers.every(
(s) => s.type === "ExportSpecifier" && s.exportKind === "type",
return;
add("static-import", node.source);
} else if (
node.type === "ExportAllDeclaration" ||
node.type === "ExportNamedDeclaration"
) {
if (
node.exportKind === "type" ||
node.source === null ||
node.source === undefined
)
return;
if (
node.type === "ExportNamedDeclaration" &&
node.specifiers.length > 0 &&
node.specifiers.every(
(s) => s.type === "ExportSpecifier" && s.exportKind === "type",
)
)
return;
add("re-export", node.source);
} else if (node.type === "ImportExpression")
add("dynamic-import", node.source);
else if (
node.type === "CallExpression" &&
node.callee.type === "Import" &&
node.arguments[0] !== undefined
)
return;
add("re-export", node.source);
} else if (node.type === "ImportExpression")
add("dynamic-import", node.source);
else if (
node.type === "CallExpression" &&
node.callee.type === "Import" &&
node.arguments[0] !== undefined
)
add("dynamic-import", node.arguments[0]);
add("dynamic-import", node.arguments[0]);
},
});
imports.sort((a, b) => a.start.offset - b.start.offset);
return {

View File

@ -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));
}
},
);
});

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,

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,

File diff suppressed because one or more lines are too long

View File

@ -123,7 +123,7 @@ export const runLinuxPrivateDisplayProbe: LinuxPrivateDisplayProbeRunner =
cleanup: () => cleanupOwnedProcessGroup(started.ownership),
};
const supervisor = new ProviderProcessSupervisor(launch);
const outcome = await waitForProbeExit(
const outcome = await waitForProbeOutputClose(
supervisor,
options.timeoutMs,
options.signal,
@ -300,7 +300,7 @@ const emptyProcessResult = (
cleanupIncomplete: false,
});
const waitForProbeExit = async (
const waitForProbeOutputClose = async (
supervisor: ProviderProcessSupervisor,
timeoutMs: number,
signal: AbortSignal | undefined,
@ -316,8 +316,8 @@ const waitForProbeExit = async (
try {
return await Promise.race([
supervisor
.waitForExit(Math.max(1, timeoutMs))
.then((exited): "exit" | "timeout" => (exited ? "exit" : "timeout")),
.waitForOutputClose(Math.max(1, timeoutMs))
.then((closed): "exit" | "timeout" => (closed ? "exit" : "timeout")),
aborted,
]);
} finally {

View File

@ -1,5 +1,8 @@
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import { expect, it } from "vitest";
import { runWakaruCommand } from "./WakaruCommand.js";
import { runWakaruCommand, type WakaruLauncher } from "./WakaruCommand.js";
import { RECOVERY_LIMITS } from "./WakaruRelease.js";
it("rejects an exhausted shared execution budget before launching the next command", async () => {
await expect(
@ -36,3 +39,113 @@ it("preserves caller cancellation before acquiring an expired execution budget",
}),
).rejects.toMatchObject({ _tag: "AnalysisCancelledError" });
});
const commandFixture = (
emit: (producer: ReturnType<typeof createProducer>) => void,
) => {
const producer = createProducer();
const launcher: WakaruLauncher = async (options) => {
setImmediate(() => emit(producer));
return {
process: producer,
ownership: {
runId: options.runId,
leaderPid: 12345,
processGroupId: 12345,
expectedParentPid: process.pid,
},
cleanup: async () => ({ cleaned: true, signaled: false }),
};
};
return {
producer,
context: {
command: "/selected/wakaru",
limiter: "/usr/bin/prlimit",
arguments: ["--version"],
cwd: "/tmp/unused",
environment: {},
deadline: Date.now() + 5_000,
launcher,
},
};
};
const createProducer = () =>
Object.assign(new EventEmitter(), {
stdout: new PassThrough(),
stderr: new PassThrough(),
exitCode: null,
signalCode: null,
kill: () => false,
});
it("retains stdout and stderr delivered after launcher exit", async () => {
const { context } = commandFixture((producer) => {
producer.stdout.write("first");
producer.emit("exit", 0, null);
setImmediate(() => {
producer.stdout.end("last");
producer.stderr.end("diagnostic");
producer.emit("close", 0, null);
});
});
await expect(runWakaruCommand(context)).resolves.toMatchObject({
exit_code: 0,
signal: null,
stdout: "firstlast",
stderr: "diagnostic",
});
});
it("checks the diagnostic byte limit against output delivered after exit", async () => {
const { context } = commandFixture((producer) => {
producer.emit("exit", 0, null);
setImmediate(() => {
producer.stderr.end(Buffer.alloc(RECOVERY_LIMITS.reportBytes + 1, 97));
producer.stdout.end();
producer.emit("close", 0, null);
});
});
await expect(runWakaruCommand(context)).rejects.toMatchObject({
_tag: "AnalysisOutputError",
operation: "recover_javascript_sources",
});
});
it("enforces the shared deadline while exited output streams remain open", async () => {
const { context, producer } = commandFixture((process) => {
process.emit("exit", 0, null);
});
try {
await expect(
runWakaruCommand({ ...context, deadline: Date.now() + 50 }),
).rejects.toMatchObject({
_tag: "AnalysisTimeoutError",
operation: "recover_javascript_sources",
});
expect(producer.stderr.listenerCount("data")).toBe(0);
} finally {
producer.stdout.destroy();
producer.stderr.destroy();
}
});
it("honors cancellation while waiting for output after exit", async () => {
const controller = new AbortController();
const { context, producer } = commandFixture((process) => {
process.emit("exit", 0, null);
setImmediate(() => controller.abort());
});
try {
await expect(
runWakaruCommand({ ...context, signal: controller.signal }),
).rejects.toMatchObject({
_tag: "AnalysisCancelledError",
});
expect(producer.stderr.listenerCount("data")).toBe(0);
} finally {
producer.stdout.destroy();
producer.stderr.destroy();
}
});

View File

@ -132,7 +132,7 @@ export const runWakaruCommand = async (context: WakaruCommandContext) => {
);
let failure: unknown;
try {
while (!(await supervisor.waitForExit(100))) {
while (!(await supervisor.waitForOutputClose(100))) {
checkRecoveryDeadline(context.deadline, context.signal);
if (oversized)
throw new AnalysisOutputError(

View File

@ -658,3 +658,52 @@ describe("source-map original artifact language metadata", () => {
}
});
});
describe("deep source-map original syntax", () => {
it("retains valid source maps and native/CommonJS evidence after a parser-admitted deep property chain", async () => {
const source = `const value = root${".next".repeat(12_000)};\nimport "./last.js";\nrequire("./common.js");\nfunction local(require) { require("./shadowed.js"); }`;
const text = JSON.stringify({
version: 3,
names: [],
sources: ["../src/main.js"],
sourcesContent: [source],
mappings: "AAAA",
});
const result = await fetchWebSourceMaps([request], input(), undefined, {
fetch: async () => new Response(text),
});
expect(result).toMatchObject({
status: "included",
items: [
{
status: "included",
limitation: null,
artifact: { text },
original_sources: [{ artifact: { text: source } }],
original_module_edges: [
{
from_source: `${origin}/src/main.js`,
kind: "static_import",
specifier: "./last.js",
resolved_source: `${origin}/src/last.js`,
},
{
from_source: `${origin}/src/main.js`,
kind: "require",
specifier: "./common.js",
resolved_source: `${origin}/src/common.js`,
},
],
mappings: [
{
generated_line: 1,
generated_column: 0,
original_line: 1,
original_column: 0,
},
],
},
],
});
});
});

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 }) => {

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",

View File

@ -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";

View File

@ -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";

View File

@ -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 {

View File

@ -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 = [

View File

@ -77,6 +77,17 @@ const statusCapability = (
});
describe("server and catalog identity", () => {
it("retains all catalog identity fields through transport serialization and detached clones", () => {
const serialized = JSON.parse(JSON.stringify(CATALOG_IDENTITY));
expect(structuredClone(CATALOG_IDENTITY)).toEqual(serialized);
expect(serialized.digests).toEqual(CATALOG_IDENTITY.digests);
expect(serialized.tools).toHaveLength(TOOL_CONTRACTS.length);
const identity = createServerIdentity({
startedAt: "2026-07-13T00:00:00.000Z",
});
expect(JSON.parse(JSON.stringify(identity)).catalog).toEqual(serialized);
});
it("derives package and SDK versions from canonical package metadata", async () => {
const packageJson = JSON.parse(await readFile("package.json", "utf8"));
const packageLock = JSON.parse(await readFile("package-lock.json", "utf8"));

View File

@ -220,3 +220,24 @@ describe("Linux private display selection", () => {
expect(processes).not.toContain(hangingHelperPath);
});
});
describe("private display probe output settlement", () => {
it.skipIf(process.platform === "win32")(
"retains inherited diagnostics written after the launcher exits",
async () => {
const result = await runLinuxPrivateDisplayProbe("direct", {
helperPath: fileURLToPath(
new URL("../../../fixtures/x11ProbeOutput.py", import.meta.url),
),
timeoutMs: 5_000,
});
expect(result).toEqual({
outcome: "exited",
exitCode: 0,
stderr: "firstlast",
stderrBytes: Buffer.byteLength("firstlast"),
cleanupIncomplete: false,
});
},
);
});

View File

@ -251,6 +251,152 @@ describe("managed member comparison uncertainty", () => {
});
});
describe("managed member comparison of undecoded signatures", () => {
it("pairs undecoded signatures by exact declared type, name, and raw bytes", () => {
const undecoded = {
methodSignature: Buffer.from([0x00, 0x00, 0x7f]),
fieldSignature: Buffer.from([0x06, 0x15]),
};
const left = inspect(
buildManagedPeFixture(undecoded),
"/tmp/undecoded-left.dll",
);
const right = inspect(
buildManagedPeFixture(undecoded),
"/tmp/undecoded-right.dll",
);
expect(left.result.coverage.state).toBe("complete");
expect(left.result.methods[0]?.signature.parse_status).toBe("unsupported");
expect(left.result.fields[0]?.signature.parse_status).toBe("unsupported");
const result = compareManagedMembers(left, right);
expect(result.summary).toMatchObject({
unchanged: 2,
added: 0,
removed: 0,
unknown: 0,
});
for (const item of [...result.methods, ...result.fields])
expect(item.match).toMatchObject({
status: "matched",
basis: "exact-signature",
});
});
it("keeps unmatched undecoded members unknown in complete inventories", () => {
const left = inspect(
buildManagedPeFixture({
methodSignature: Buffer.from([0x00, 0x00, 0x7f]),
fieldSignature: Buffer.from([0x06, 0x15]),
}),
"/tmp/undecoded-before.dll",
);
const right = inspect(
buildManagedPeFixture({
methodSignature: Buffer.from([0x00, 0x00, 0x7e]),
fieldSignature: Buffer.from([0x06, 0x7e]),
}),
"/tmp/undecoded-after.dll",
);
expect(right.result.coverage.state).toBe("complete");
const result = compareManagedMembers(left, right);
expect(result.summary).toMatchObject({ added: 0, removed: 0, unknown: 4 });
for (const item of [...result.methods, ...result.fields])
expect(item).toMatchObject({
status: "unknown",
match: { status: "unmatched" },
limitations: [expect.stringContaining("signature-not-decoded")],
});
});
it("keeps a decoded member unknown beside a same-named undecoded counterpart", () => {
const left = inspect(buildManagedPeFixture(), "/tmp/decoded-before.dll");
const right = inspect(
buildManagedPeFixture({
methodSignature: Buffer.from([0x00, 0x00, 0x7f]),
fieldSignature: Buffer.from([0x06, 0x15]),
}),
"/tmp/undecoded-after.dll",
);
expect(left.result.methods[0]?.signature.parse_status).toBe("decoded");
expect(left.result.fields[0]?.signature.parse_status).toBe("decoded");
expect(right.result.coverage.state).toBe("complete");
const result = compareManagedMembers(left, right);
expect(result.summary).toMatchObject({ added: 0, removed: 0, unknown: 4 });
for (const item of [...result.methods, ...result.fields])
expect(item).toMatchObject({
status: "unknown",
match: { status: "unmatched" },
limitations: [
expect.stringMatching(
item.left === null
? /^signature-not-decoded:/
: /^counterpart-signature-not-decoded: The right (method|field) inventory/,
),
],
});
});
});
describe("managed member comparison of ambiguous undecoded signatures", () => {
it("keeps a decoded member unknown beside an ambiguous same-named undecoded group", () => {
const undecoded = { methodSignature: Buffer.from([0x00, 0x00, 0x7f]) };
const decoded = inspect(buildManagedPeFixture(), "/tmp/decoded.dll");
const left = inspect(buildManagedPeFixture(undecoded), "/tmp/left.dll");
const right = inspect(buildManagedPeFixture(undecoded), "/tmp/right.dll");
const [undecodedMethod] = left.result.methods;
const [decodedMethod] = decoded.result.methods;
if (undecodedMethod === undefined || decodedMethod === undefined)
throw new Error("Expected fixture methods");
expect(decodedMethod.name).toBe(undecodedMethod.name);
// One undecoded T.M on the left meets two identical undecoded T.M on the
// right, so that group stays ambiguous; the left also has a decoded T.M.
const result = compareManagedMembers(
{
...left,
result: {
...left.result,
methods: [undecodedMethod, { ...decodedMethod, token: "0x06000002" }],
},
},
{
...right,
result: {
...right.result,
methods: [
undecodedMethod,
{ ...undecodedMethod, token: "0x06000002" },
],
},
},
);
expect(result.methods).toEqual(
expect.arrayContaining([
expect.objectContaining({
status: "unknown",
match: expect.objectContaining({
status: "ambiguous",
basis: "exact-signature",
}),
limitations: [
"Multiple managed methods share the same declared type, name, and raw signature; REA did not guess a token remap.",
],
}),
expect.objectContaining({
status: "unknown",
left: expect.objectContaining({ token: "0x06000002" }),
limitations: [
expect.stringMatching(
/^counterpart-signature-not-decoded: The right method inventory has an unpaired member/u,
),
],
}),
]),
);
expect(result.summary).toMatchObject({ removed: 0, added: 0 });
});
});
const inspect = (bytes: Buffer, path: string) => {
const target = managedPeFixtureTarget(bytes, path);
const result = inspectManagedMembersBytes(bytes, target);

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],

9
tests/fixtures/x11ProbeOutput.py vendored Normal file
View File

@ -0,0 +1,9 @@
import subprocess
import sys
sys.stderr.write("first")
sys.stderr.flush()
subprocess.Popen(
[sys.executable, "-c", "import sys, time; time.sleep(0.05); sys.stderr.write('last'); sys.stderr.flush()"],
stdout=subprocess.DEVNULL,
)

View File

@ -1,6 +1,6 @@
# REA website
An English static website with explanatory figures and a DX-Ball investigation.
An English static website with explanatory figures, worked guides and a DX-Ball investigation.
The public files are in `website/public/`. The site uses HTML, CSS and a small
script for copying code and following the assembly-to-C comparison; it has no
build step or npm dependencies.
@ -20,6 +20,8 @@ Open <http://127.0.0.1:4173/>. Refresh the browser after editing a file.
- `public/index.html`: product introduction and DX-Ball overview.
- `public/showcase/dx-ball/index.html`: sound-pan investigation and project status.
- `public/get-started/index.html`: agent setup, first CLI result and provider guides.
- `public/guides/`: a guide hub and native, JavaScript/Electron and browser examples.
- `public/examples/`: downloadable Electron source and an interactive Notes browser app.
- `public/assets/`: shared styles, interactions, favicon and explanatory figures.
Navigation and assets use relative paths, so the same files work at the local
@ -29,7 +31,18 @@ root and a GitHub Pages project path such as `/rea/`.
Keep the copy direct and specific. Explain the task and the result before listing
tool names. Setup commands and runtime requirements should match the released
package.
package. Keep the core learning path inside the site. Source, issue and evidence
reference links may point to the corresponding repositories.
[evidence/guide-examples.md](evidence/guide-examples.md) records the published
REA package, example digests and observed results behind the guides. The Notes
Electron fixture is for static analysis; Electron is not a prerequisite for
following that example. The separate Notes browser app runs in the local preview
and constructs a CSV download after fetching its JSON data.
Agent terminals show example prompts, not transcripts of previous
investigations. A short cursor animation starts when the prompt enters view and
respects reduced-motion preferences. All prompt text is present without JavaScript.
DX-Ball figures and findings refer to the linked 7 October 2026 checkpoint,
commit `a55dca27ec0a07018c1b2c95ae2be027f7d8c3c4`. Update those links and figures
@ -41,23 +54,47 @@ Evidence. [evidence/dx-ball-sound-pan.md](evidence/dx-ball-sound-pan.md) records
their provenance and the scope of the validation claims. The original executable
and complete private Evidence records are not website assets.
The diagrams are maintained as SVG source. Initial layout references were
The homepage and DX-Ball overview diagrams are maintained as SVG source. Initial layout references were
created with the built-in image generation tool; [figures.md](figures.md) retains
their prompts and the current asset notes. Figures provide an overview;
REA requests, assembly and C remain selectable HTML text. On narrow
screens, the diagrams scroll horizontally and can also be opened at full size.
The worked guides use semantic HTML flows that stack vertically on smaller
screens. The Electron teaching example uses CommonJS, matching its preload
code; the scoped lint override admits `require` only in that example directory.
## GitHub Pages
`.github/workflows/website-pages.yml` prepares and deploys only `website/public`.
It is manually triggered and restricted to `main`; ordinary pushes and pull
requests do not publish the site.
It is the sole Pages publisher, manually triggered and restricted to `main`;
ordinary pushes and pull requests do not publish the site.
Website checks run only for pull requests that change `website/`, the verification
script or workflow definitions. They check local links, HTML fragments, SVG XML
and the single Pages publisher without installing npm dependencies. The same
checks run before each manual deployment. You can also run them locally:
```sh
python3 scripts/verify-website.py
```
`.github/workflows/pages.yml` is a separate, manual-only VitePress build. It
has no Pages artifact upload, deployment job or deployment permissions. This
prevents documentation updates from replacing the public website. The legacy
publishing job has been removed, so enabling this build workflow cannot publish
the old site.
After the site is approved and merged, select **GitHub Actions** under the
repository's **Settings → Pages → Build and deployment**. Then run **Publish REA
website** from the Actions tab on `main`. The workflow uses the `github-pages`
environment and the official Pages actions.
The equivalent CLI command is:
```sh
gh workflow run website-pages.yml --repo morluto/rea --ref main
```
Local development does not change Pages settings or run the deployment workflow.
Any environment protection rules are configured separately when publication is
approved.

View File

@ -0,0 +1,78 @@
# Guide example evidence
The native guide reuses the recorded DX-Ball observations in
[dx-ball-sound-pan.md](dx-ball-sound-pan.md). No new native-provider claim is
introduced by this iteration.
## Notes Electron example
This six-file, source-owned teaching fixture is in
`website/public/examples/notes-electron/`. It is a static analysis target, not
an installed Electron distribution. Its graph was obtained using the released
`rea-agents` 4.1.0 package, Node.js 24.18.0 on Linux x64, on 7 October 2026:
```sh
rea analyze-javascript-application \
/absolute/path/to/website/public/examples/notes-electron --json
```
- Evidence ID: `ev_a7e68cbdbd52ee15a959047774d1164d385e3e5ceb704084395639d97fee093d`.
- Root artifact SHA-256: `dae5292f341b4f0dfd5be0846d4cfa0d4821f3e062e970cc1cd568c18c3fb37c`.
- Four JavaScript files parsed, zero parse failures.
- One exposed API member, one literal IPC channel, one renderer invocation,
one main-process handler, one paired invocation, zero ambiguous invocations.
- The guide's result excerpt comes from `normalized_result.summary.ipc`.
- The `exposes`, `invokes` and `handles` graph edges retain their static
inference authority and source locations. The guide's end-to-end explanation
also uses the visible source in `renderer.js`, `main.js` and `csv.js`; it does
not claim that the complete button-to-file path was observed executing.
The source was formatted before this result was recorded. Re-run analysis
when the example files change.
## Notes browser example
The three-file browser app is in `website/public/examples/notes-web/`.
A dedicated headless Chrome profile opened it at
`http://127.0.0.1:4173/examples/notes-web/`. REA 4.1.0 passively inspected that
existing page with an 8-second window, accessibility text and JSON body shapes.
A separate Playwright controller clicked the button during the capture and
saved the resulting CSV; REA did not perform the click.
- Evidence ID: `ev_7df243651c257e41f525e4225cbca5427467cf1e7b4b5b624ab41ab5717fe9c6`.
- Window: `2026-10-07T09:38:43.492Z` to
`2026-10-07T09:38:51.531Z`.
- One observed `GET` to `/examples/notes-web/notes.json`.
- Status `200`, MIME type `application/json`, script initiator `export.js`.
- Body shape: `/notes` array, `/notes/*/id` number, `/notes/*/title` string.
- The independent browser run downloaded `notes.csv` and displayed
“Exported 2 notes to notes.csv.” CSV construction is explained from source,
not inferred solely from the request capture.
The CLI guide uses a 10-second window to give the reader time to perform the
action. Capture only covers requests after attachment. The example request
path differs under a project prefix such as `/rea/`, while the relative fetch
and export behavior stay the same.
The guide's follow-up CLI commands were also exercised against the real page:
`list-browser-targets` returned its exact page target, and
`inspect-web-page --include-script-sources` returned the loaded `export.js`
source. Discovery Evidence: `ev_31abb33e7707d47625ac909ab9256c705d03a31ee6bcd643fa96730e72469162`. Script capture
Evidence: `ev_aeb777df405c09dc1bbdd4c185a5af2452abaa3cb493683c296c5afa97efbc11`.
Full local outputs and the browser verification harness are under
`/tmp/rea-website-guides/` in the development environment, not website assets.
## Example source digests
| File | SHA-256 |
| -------------------------------------- | ------------------------------------------------------------------ |
| `examples/notes-electron/csv.js` | `6875aeecc1d3e6c1ff5b888a199c26b377d812d5b10e5fb4025e192a206b0018` |
| `examples/notes-electron/index.html` | `a31df9b29eb9d479f913c7806364609019eedd1d19cda386cc24706d5068082d` |
| `examples/notes-electron/main.js` | `cc5b00911bfe428754d94ca40e8c0f354cfaae86f716873423aa5a62b9e0379a` |
| `examples/notes-electron/package.json` | `bfef2a7cba39a989715f56e304ca2578807159f28bdb040b75893bb341caaee7` |
| `examples/notes-electron/preload.js` | `3cb814f2a0822c2fef437ba722374a2eabf6c6795905f41656d9c33fe8d5d9c4` |
| `examples/notes-electron/renderer.js` | `b24aa22ed969c0849dd9da1102935a6b36b96bae46870234bdea10039dd9a5a0` |
| `examples/notes-web/export.js` | `66f5c2501bfca72eb743a1fffbdce9a58d295e69ca66f19562e9b0fa9fc189a6` |
| `examples/notes-web/index.html` | `97bb4729dcd838de8adae7a00eb49df0dbe8a7b73becc1c7d2a6662825013064` |
| `examples/notes-web/notes.json` | `9fc1155e251cfbed09dadca6b195507072fffe3f602564812d644ab27c422282` |

View File

@ -55,3 +55,22 @@ function initializeStepComparisons() {
}
initializeStepComparisons();
function initializeAgentTerminals() {
if (!("IntersectionObserver" in window)) return;
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
entry.target.classList.add("is-visible");
observer.unobserve(entry.target);
});
},
{ threshold: 0.25 },
);
document.querySelectorAll(".agent-terminal").forEach((terminal) => {
observer.observe(terminal);
});
}
initializeAgentTerminals();

View File

@ -707,6 +707,209 @@ pre code {
.small-heading {
margin-top: 1.8rem;
}
.agent-terminal {
margin-block: 1.5rem;
overflow: hidden;
border: 1px solid #303d4a;
border-radius: 6px;
background: #202933;
color: #f1f4f8;
}
.terminal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.65rem 1rem;
border-bottom: 1px solid #384551;
color: #bdc8d4;
font-size: 0.8rem;
}
.terminal-title {
display: flex;
align-items: center;
gap: 0.7rem;
}
.terminal-dots {
display: flex;
gap: 4px;
}
.terminal-dots i {
width: 6px;
height: 6px;
border-radius: 50%;
background: #81909e;
}
.agent-terminal .copy-button {
color: #d6dee7;
}
.agent-terminal .copy-button:hover {
background: #384551;
color: white;
}
.agent-terminal .terminal-prompt {
margin: 0;
padding: 1.4rem;
color: #f1f4f8;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 0.9rem;
line-height: 1.9;
}
.prompt-symbol {
color: #9bbfe5;
margin-right: 0.65rem;
}
.terminal-cursor {
display: inline-block;
width: 0.5em;
height: 1em;
margin-left: 0.4em;
vertical-align: -0.12em;
background: #9bbfe5;
}
.agent-terminal.is-visible .terminal-cursor {
animation: terminal-blink 1s step-end 6;
}
@keyframes terminal-blink {
50% {
opacity: 0;
}
}
.workflow-figure {
margin: 1.7rem 0;
border: 1px solid var(--line);
border-radius: 5px;
padding: 1.4rem;
background: #fafbfc;
}
.figure-title {
margin: 0 0 1.3rem;
font-size: 0.9rem;
font-weight: 600;
color: var(--ink);
}
.flow-steps {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1.9rem;
padding: 0;
margin: 0;
list-style: none;
}
.flow-steps li {
position: relative;
margin: 0;
min-width: 0;
}
.flow-steps li:not(:last-child)::after {
content: "→";
position: absolute;
right: -1.4rem;
top: 2.1rem;
color: #8191a2;
}
.flow-label {
display: block;
margin-bottom: 0.65rem;
color: var(--muted);
font-size: 0.75rem;
}
.flow-node {
display: block;
padding: 0.8rem;
background: white;
border: 1px solid #ced9e5;
border-radius: 4px;
color: var(--accent);
font-size: 0.875rem;
line-height: 1.5;
font-weight: 500;
}
.flow-note {
display: block;
margin-top: 0.65rem;
font-size: 0.8rem;
line-height: 1.55;
color: var(--muted);
}
.flow-result {
margin: 1.3rem 0 0;
padding: 0.9rem 1rem;
border-left: 2px solid var(--accent);
background: #edf2f8;
color: var(--ink);
font-size: 0.9rem;
}
.workflow-figure figcaption {
margin-top: 1rem;
color: var(--muted);
font-size: 0.8rem;
}
.guide-index {
padding-bottom: 3rem;
}
.guide-entry {
display: grid;
grid-template-columns: 160px minmax(0, 1fr);
gap: 2.5rem;
padding-block: 2.5rem;
border-top: 1px solid var(--line);
}
.guide-entry .eyebrow {
margin: 0;
}
.guide-entry h2 {
font-size: 1.75rem;
}
.guide-entry p {
max-width: 660px;
color: var(--muted);
}
.guide-route {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
margin-bottom: 1.25rem;
font-size: 0.85rem;
color: var(--muted);
}
.guide-route span {
padding: 0.2rem 0.55rem;
background: var(--soft);
border-radius: 3px;
color: #414b55;
}
.example-files {
display: flex;
flex-wrap: wrap;
gap: 0.65rem 1.25rem;
padding: 0;
list-style: none;
font-size: 0.9rem;
}
.example-files li + li {
margin: 0;
}
.example-app {
max-width: 720px;
padding-block: 3.5rem;
}
.example-app h1 {
font-size: 3rem;
}
.example-notes {
padding: 0;
list-style: none;
margin-block: 2rem;
}
.example-notes li {
padding-block: 0.8rem;
border-bottom: 1px solid var(--line);
}
.example-status {
margin-block: 1.5rem 1rem;
}
.sr-only {
position: absolute;
width: 1px;
@ -737,6 +940,17 @@ pre code {
.rea-query-pair {
grid-template-columns: 1fr;
}
.flow-steps {
grid-template-columns: 1fr;
gap: 2rem;
}
.flow-steps li:not(:last-child)::after {
content: "↓";
top: auto;
bottom: -1.7rem;
right: auto;
left: 0.85rem;
}
}
@media (max-width: 640px) {
@ -827,6 +1041,13 @@ pre code {
grid-template-columns: 1fr;
gap: 0.25rem;
}
.guide-entry {
grid-template-columns: 1fr;
gap: 0.7rem;
}
.workflow-figure {
padding: 1rem;
}
.step-selector {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
@ -839,6 +1060,12 @@ pre code {
}
}
@media (prefers-reduced-motion: reduce) {
.agent-terminal.is-visible .terminal-cursor {
animation: none;
}
}
@media (max-width: 360px) {
.header-inner {
flex-wrap: wrap;

View File

@ -0,0 +1,11 @@
function toCsv(notes) {
const quote = (value) => `"${String(value).replaceAll('"', '""')}"`;
return (
[
"id,title",
...notes.map((note) => `${note.id},${quote(note.title)}`),
].join("\n") + "\n"
);
}
module.exports = { toCsv };

View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Notes — Electron analysis example</title>
<script src="renderer.js" defer></script>
</head>
<body>
<h1>Notes</h1>
<button id="export" type="button">Export notes</button>
<p id="status" role="status"></p>
</body>
</html>

View File

@ -0,0 +1,19 @@
const { app, BrowserWindow, ipcMain } = require("electron");
const { writeFileSync } = require("node:fs");
const path = require("node:path");
const { toCsv } = require("./csv.js");
const notes = [{ id: 1, title: "First note" }];
ipcMain.handle("notes:export", () => {
const destination = path.join(app.getPath("downloads"), "notes.csv");
writeFileSync(destination, toCsv(notes), "utf8");
return destination;
});
app.whenReady().then(() => {
const window = new BrowserWindow({
webPreferences: { preload: path.join(__dirname, "preload.js") },
});
window.loadFile("index.html");
});

View File

@ -0,0 +1,6 @@
{
"name": "rea-notes-example",
"version": "1.0.0",
"private": true,
"main": "main.js"
}

View File

@ -0,0 +1,5 @@
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("notes", {
exportCsv: () => ipcRenderer.invoke("notes:export"),
});

View File

@ -0,0 +1,4 @@
document.querySelector("#export").addEventListener("click", async () => {
const destination = await window.notes.exportCsv();
document.querySelector("#status").textContent = `Saved to ${destination}`;
});

View File

@ -0,0 +1,24 @@
document.querySelector("#export").addEventListener("click", async () => {
const status = document.querySelector("#status");
try {
const response = await fetch("./notes.json");
if (!response.ok)
throw new Error(`Notes request failed: ${response.status}`);
const { notes } = await response.json();
const quote = (value) => `"${String(value).replaceAll('"', '""')}"`;
const csv =
[
"id,title",
...notes.map((note) => `${note.id},${quote(note.title)}`),
].join("\n") + "\n";
const url = URL.createObjectURL(new Blob([csv], { type: "text/csv" }));
const link = document.createElement("a");
link.href = url;
link.download = "notes.csv";
link.click();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
status.textContent = `Exported ${notes.length} notes to notes.csv.`;
} catch (error) {
status.textContent = error.message;
}
});

View File

@ -0,0 +1,35 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Notes — REA browser example</title>
<link rel="icon" href="../../assets/favicon.svg" type="image/svg+xml" />
<link rel="stylesheet" href="../../assets/styles.css" />
<script src="export.js" defer></script>
</head>
<body>
<main class="container example-app">
<p class="breadcrumb">
<a href="../../guides/browser/">Browser observation guide</a> / Example
</p>
<p class="eyebrow">A small app to inspect</p>
<h1>Notes</h1>
<p class="lede">
Two notes. One CSV export. Observe what happens when you click.
</p>
<ul class="example-notes">
<li>First note</li>
<li>Follow the export</li>
</ul>
<button class="button" type="button" id="export">Export notes</button>
<p class="example-status" id="status" role="status" aria-live="polite">
Ready to export.
</p>
<p class="caption">
This example loads <code>notes.json</code> and creates a CSV in your
browser.
</p>
</main>
</body>
</html>

View File

@ -0,0 +1,6 @@
{
"notes": [
{ "id": 1, "title": "First note" },
{ "id": 2, "title": "Follow the export" }
]
}

Some files were not shown because too many files have changed in this diff Show More