chore: merge main and regenerate combined catalog metadata
This commit is contained in:
commit
fd552a8c4b
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ export interface ModuleBoundaryViolation {
|
|||
readonly specifier: string;
|
||||
readonly target: string;
|
||||
readonly boundary:
|
||||
| "artifact-acquisition"
|
||||
| "pure-layer"
|
||||
| "application-composition"
|
||||
| "provider-construction"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import {
|
|||
artifactInventoryResultSchema,
|
||||
type ArtifactInventoryResult,
|
||||
} from "../domain/artifactGraph.js";
|
||||
import { abortIfNeeded } from "./ArtifactInventory/hash.js";
|
||||
import { abortIfNeeded } from "../artifacts/ArtifactHash.js";
|
||||
import { scanCanonicalArtifactInventory } from "./ArtifactInventory/scanCanonical.js";
|
||||
import type {
|
||||
ArtifactIntegrityPolicy,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { open } from "node:fs/promises";
|
||||
|
||||
import { classifyArtifactContent } from "../ArtifactGraphConstruction.js";
|
||||
import { ARTIFACT_CLASSIFICATION_PREFIX_BYTES } from "./hash.js";
|
||||
import { ARTIFACT_CLASSIFICATION_PREFIX_BYTES } from "../../artifacts/ArtifactHash.js";
|
||||
import type { ArtifactNode } from "../../domain/artifactGraph.js";
|
||||
import {
|
||||
hasZipSignature,
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import {
|
|||
} from "../ArtifactGraphConstruction.js";
|
||||
import { canonicalDigest } from "../../domain/comparisonSemantics.js";
|
||||
import { classifyRoot } from "./classify.js";
|
||||
import { hashReadable, type HashResult } from "./hash.js";
|
||||
import { hashReadable, type HashResult } from "../../artifacts/ArtifactHash.js";
|
||||
import { createReader, inventoryLimitations } from "./reader.js";
|
||||
import {
|
||||
scanReader,
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import {
|
|||
STRICT_INTEGRITY_POLICY,
|
||||
type ArtifactIntegrityPolicy,
|
||||
} from "./types.js";
|
||||
import { hashReadable } from "./hash.js";
|
||||
import { hashReadable } from "../../artifacts/ArtifactHash.js";
|
||||
|
||||
export interface PendingIntegrityContradiction {
|
||||
readonly logicalPath: string;
|
||||
|
|
|
|||
|
|
@ -1,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";
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { compareCodePoints } from "../../domain/canonicalOrdering.js";
|
|||
import type { ElectronIpcFinding } from "../../domain/javascript/electronStaticAnalysisTypes.js";
|
||||
import type { ElectronBoundarySummary } from "../../domain/javascript/javascriptApplicationAnalysis.js";
|
||||
import type { JavaScriptArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js";
|
||||
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
|
||||
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
|
||||
import { resolveArtifactPathByContext } from "./JavaScriptArtifactPathResolution.js";
|
||||
|
||||
/** One IPC fact retaining its exact owning artifact for graph projection. */
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { ApplicationNode } from "../../domain/javascript/javascriptApplicationGraph.js";
|
||||
import { sha256Text } from "../../domain/javascript/javascriptStaticAnalysisHelpers.js";
|
||||
import type { JavaScriptSourceRange } from "../../domain/javascript/javascriptStaticAnalysisTypes.js";
|
||||
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
|
||||
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
|
||||
import type {
|
||||
JavaScriptArtifactGraphContext,
|
||||
JavaScriptArtifactGraphCoverage,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { ApplicationNode } from "../../domain/javascript/javascriptApplicationGraph.js";
|
||||
import type { ElectronSenderValidationFinding } from "../../domain/javascript/electronStaticAnalysisTypes.js";
|
||||
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
|
||||
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
|
||||
import {
|
||||
artifactLocalIdentity,
|
||||
javascriptAnalysisCoverage,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { ElectronNativeAddonBindingFinding } from "../../domain/javascript/electronStaticAnalysisTypes.js";
|
||||
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
|
||||
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
|
||||
import {
|
||||
javascriptAnalysisCoverage,
|
||||
resolveArtifactPath,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import type {
|
|||
ElectronContextBridgeFinding,
|
||||
ElectronUtilityProcessFinding,
|
||||
} from "../../domain/javascript/electronStaticAnalysisTypes.js";
|
||||
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
|
||||
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
|
||||
import {
|
||||
artifactLocalIdentity,
|
||||
createElectronRoleNode,
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { failedJavaScriptStaticAnalysis } from "../../domain/javascript/javascri
|
|||
import type {
|
||||
JavaScriptArtifactFile,
|
||||
JavaScriptArtifactFileSet,
|
||||
} from "./JavaScriptArtifactFiles.js";
|
||||
} from "../../domain/javascript/javascriptArtifactFiles.js";
|
||||
import type {
|
||||
AnalyzedJavaScriptArtifactFile,
|
||||
JavaScriptArtifactAnalysis,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type {
|
|||
JavaScriptStaticAnalysis,
|
||||
} from "../../domain/javascript/javascriptStaticAnalysisTypes.js";
|
||||
import type { JavaScriptSemanticIr } from "../../domain/javascript/javascriptSemanticIr.js";
|
||||
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
|
||||
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
|
||||
|
||||
interface JavaScriptStructuredObservation {
|
||||
readonly path: string;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import {
|
|||
type JavaScriptApplicationGraph,
|
||||
} from "../../domain/javascript/javascriptApplicationGraph.js";
|
||||
import type { JavaScriptArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js";
|
||||
import type { JavaScriptArtifactFileSet } from "./JavaScriptArtifactFiles.js";
|
||||
import type { JavaScriptArtifactFileSet } from "../../domain/javascript/javascriptArtifactFiles.js";
|
||||
import { JavaScriptArtifactGraphAccumulator } from "./JavaScriptArtifactGraphAccumulator.js";
|
||||
import type { JavaScriptArtifactGraphContext } from "./JavaScriptArtifactGraphContext.js";
|
||||
import {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { ApplicationNode } from "../../domain/javascript/javascriptApplicationGraph.js";
|
||||
import type { JavaScriptBundlerRegistration } from "../../domain/javascript/javascriptStaticAnalysisTypes.js";
|
||||
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
|
||||
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
|
||||
import {
|
||||
addAstContainsEdge,
|
||||
artifactLocalIdentity,
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import type {
|
|||
JavaScriptArtifactFile,
|
||||
JavaScriptArtifactFileSet,
|
||||
JavaScriptArtifactFileKind,
|
||||
} from "./JavaScriptArtifactFiles.js";
|
||||
} from "../../domain/javascript/javascriptArtifactFiles.js";
|
||||
import { JavaScriptArtifactGraphAccumulator } from "./JavaScriptArtifactGraphAccumulator.js";
|
||||
import {
|
||||
artifactObservationEvidence,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { ApplicationNode } from "../../domain/javascript/javascriptApplicationGraph.js";
|
||||
import type { JavaScriptStaticAnalysis } from "../../domain/javascript/javascriptStaticAnalysisTypes.js";
|
||||
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
|
||||
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
|
||||
import {
|
||||
addStaticInferenceEdge,
|
||||
artifactLocalIdentity,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import type { JavaScriptArtifactAnalysis } from "./JavaScriptArtifactAnalysisTyp
|
|||
import type {
|
||||
JavaScriptArtifactContainer,
|
||||
JavaScriptArtifactFile,
|
||||
} from "./JavaScriptArtifactFiles.js";
|
||||
} from "../../domain/javascript/javascriptArtifactFiles.js";
|
||||
import type { JavaScriptArtifactGraphAccumulator } from "./JavaScriptArtifactGraphAccumulator.js";
|
||||
import {
|
||||
addArtifactContainsEdge,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import {
|
|||
resolveArtifactPathByContext,
|
||||
type ResolveArtifactPathInput,
|
||||
} from "./JavaScriptArtifactPathResolution.js";
|
||||
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
|
||||
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
|
||||
import { artifactLocalIdentity } from "./JavaScriptArtifactGraphContext.js";
|
||||
import { applicationNodeIdentitySchema } from "../../domain/javascript/javascriptApplicationEvidenceSchemas.js";
|
||||
import { analyzeJavaScriptStaticSource } from "../../domain/javascript/javascriptStaticAnalysis.js";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { builtinModules } from "node:module";
|
||||
import { posix } from "node:path";
|
||||
|
||||
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
|
||||
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
|
||||
import {
|
||||
admitsCanonicalPathSyntax,
|
||||
hasScheme,
|
||||
|
|
|
|||
|
|
@ -1,16 +1,12 @@
|
|||
import { lstat, realpath } from "node:fs/promises";
|
||||
|
||||
import { AsarArtifactReader } from "../../artifacts/AsarArtifactReader.js";
|
||||
import {
|
||||
ArtifactReaderFailure,
|
||||
type ArtifactReader,
|
||||
} from "../../artifacts/ArtifactReader.js";
|
||||
import { DirectoryArtifactReader } from "../../artifacts/DirectoryArtifactReader.js";
|
||||
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
|
||||
import { createJavaScriptArtifactReader as createReader } from "../../artifacts/javascript/JavaScriptArtifactReader.js";
|
||||
import type { JavaScriptApplicationGraph } from "../../domain/javascript/javascriptApplicationGraph.js";
|
||||
import type { JavaScriptSemanticGraph } from "../../domain/javascript/javascriptSemanticGraph.js";
|
||||
import type { ElectronBoundarySummary } from "../../domain/javascript/javascriptApplicationAnalysis.js";
|
||||
import { analyzeJavaScriptArtifactFiles } from "./JavaScriptArtifactAnalysis.js";
|
||||
import { readJavaScriptArtifactFiles } from "./JavaScriptArtifactFiles.js";
|
||||
import { readJavaScriptArtifactFiles } from "../../artifacts/javascript/JavaScriptArtifactFiles.js";
|
||||
import { buildJavaScriptArtifactGraph } from "./JavaScriptArtifactGraphBuilder.js";
|
||||
import {
|
||||
javascriptArtifactReconstructionInputSchema,
|
||||
|
|
@ -124,14 +120,6 @@ const resolveFormat = async (
|
|||
return observed;
|
||||
};
|
||||
|
||||
const createReader = (
|
||||
path: string,
|
||||
format: "asar" | "directory",
|
||||
): ArtifactReader =>
|
||||
format === "asar"
|
||||
? new AsarArtifactReader(path)
|
||||
: new DirectoryArtifactReader(path);
|
||||
|
||||
const abortIfNeeded = (signal?: AbortSignal): void => {
|
||||
if (signal?.aborted === true)
|
||||
throw new ArtifactReaderFailure(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { resolveArtifactPathByContext } from "./JavaScriptArtifactPathResolution.js";
|
||||
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
|
||||
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
|
||||
const paths = [
|
||||
"renderer/index.html",
|
||||
"assets/app.js",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { compareCodePoints } from "../../domain/canonicalOrdering.js";
|
||||
import type { JavaScriptJsonModuleObservation } from "./JavaScriptArtifactAnalysisTypes.js";
|
||||
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
|
||||
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
|
||||
|
||||
/** Parse one approved JSON module without evaluating JavaScript or resolving imports. */
|
||||
export const analyzeJavaScriptJsonModule = (
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import type {
|
|||
JavaScriptSemanticIr,
|
||||
JavaScriptSemanticModuleLink,
|
||||
} from "../../domain/javascript/javascriptSemanticIr.js";
|
||||
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
|
||||
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
|
||||
import {
|
||||
addAstContainsEdge,
|
||||
artifactLocalIdentity,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
|
||||
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
|
||||
import { resolveArtifactPathByContext } from "./JavaScriptArtifactPathResolution.js";
|
||||
|
||||
const filesFor = (paths: readonly string[]) =>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { expect, it } from "vitest";
|
|||
|
||||
import { buildJavaScriptSemanticGraph } from "./JavaScriptSemanticGraphBuilder.js";
|
||||
import type { JavaScriptArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js";
|
||||
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
|
||||
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
|
||||
import { queryJavaScriptSemanticGraph } from "../../domain/javascript/javascriptSemanticQuery.js";
|
||||
import { analyzeJavaScriptSemantics } from "../../domain/javascript/javascriptSemanticAnalysis.js";
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import type {
|
|||
import { sourceRangesEqual as rangesEqual } from "../../domain/javascript/javascriptStaticAnalysisHelpers.js";
|
||||
import type { JavaScriptApplicationGraph } from "../../domain/javascript/javascriptApplicationGraph.js";
|
||||
import type { JavaScriptArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js";
|
||||
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
|
||||
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
|
||||
import {
|
||||
projectSemanticEvents,
|
||||
projectSemanticTimers,
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import type {
|
|||
JavaScriptSemanticGraphUnknown,
|
||||
} from "../../domain/javascript/javascriptSemanticGraphSchemas.js";
|
||||
import type { JavaScriptSourceRange } from "../../domain/javascript/javascriptStaticAnalysisTypes.js";
|
||||
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
|
||||
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
|
||||
import {
|
||||
inferredSemanticEvidence,
|
||||
observedSemanticEvidence,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { ApplicationGraphEvidence } from "../../domain/javascript/javascriptApplicationEvidenceSchemas.js";
|
||||
import type { JavaScriptSemanticGraphNode } from "../../domain/javascript/javascriptSemanticGraph.js";
|
||||
import type { JavaScriptSourceRange } from "../../domain/javascript/javascriptStaticAnalysisTypes.js";
|
||||
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
|
||||
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
|
||||
|
||||
const INFERENCE_LIMITATION =
|
||||
"This relationship is a conservative static candidate; it does not prove runtime execution or causal flow.";
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import {
|
|||
type JavaScriptSemanticGraphNode,
|
||||
} from "../../domain/javascript/javascriptSemanticGraph.js";
|
||||
import type { JavaScriptSemanticIr } from "../../domain/javascript/javascriptSemanticIr.js";
|
||||
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
|
||||
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
|
||||
import { observedSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js";
|
||||
|
||||
/** Bind domain fingerprint components to exact semantic function nodes. */
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type { JavaScriptSemanticGraphNode } from "../../domain/javascript/javasc
|
|||
import type { JavaScriptSemanticIr } from "../../domain/javascript/javascriptSemanticIr.js";
|
||||
import { sourceRangesEqual } from "../../domain/javascript/javascriptStaticAnalysisHelpers.js";
|
||||
import type { JavaScriptSourceRange } from "../../domain/javascript/javascriptStaticAnalysisTypes.js";
|
||||
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
|
||||
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
|
||||
import {
|
||||
addSemanticGraphNode,
|
||||
addSemanticGraphRelation,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { expect, it } from "vitest";
|
|||
|
||||
import { buildJavaScriptSemanticGraph } from "./JavaScriptSemanticGraphBuilder.js";
|
||||
import type { JavaScriptArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js";
|
||||
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
|
||||
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
|
||||
import { queryJavaScriptSemanticGraph } from "../../domain/javascript/javascriptSemanticQuery.js";
|
||||
import { analyzeJavaScriptSemantics } from "../../domain/javascript/javascriptSemanticAnalysis.js";
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { expect, it } from "vitest";
|
|||
|
||||
import { buildJavaScriptSemanticGraph } from "./JavaScriptSemanticGraphBuilder.js";
|
||||
import type { JavaScriptArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js";
|
||||
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
|
||||
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
|
||||
import { analyzeJavaScriptSemantics } from "../../domain/javascript/javascriptSemanticAnalysis.js";
|
||||
import { topLevelBinding } from "../../domain/javascript/javascriptSemanticAnalysis.fixture.js";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import type { Readable } from "node:stream";
|
||||
|
||||
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
|
||||
import { streamChunkToBuffer } from "../../artifacts/StreamBytes.js";
|
||||
import { ArtifactReaderFailure } from "./ArtifactReader.js";
|
||||
import { streamChunkToBuffer } from "./StreamBytes.js";
|
||||
|
||||
/** Full stream digest and byte count with bounded classification evidence. */
|
||||
export type HashResult = {
|
||||
readonly sha256: string;
|
||||
readonly bytes: number;
|
||||
|
|
@ -13,6 +14,7 @@ export type HashResult = {
|
|||
/** Bounded classification evidence; this is not an executable format size limit. */
|
||||
export const ARTIFACT_CLASSIFICATION_PREFIX_BYTES = 8_192;
|
||||
|
||||
/** Preserve the inventory cancellation reason at a stream boundary. */
|
||||
export const abortIfNeeded = (signal?: AbortSignal): void => {
|
||||
if (signal?.aborted === true)
|
||||
throw new ArtifactReaderFailure(
|
||||
|
|
@ -21,6 +23,7 @@ export const abortIfNeeded = (signal?: AbortSignal): void => {
|
|||
);
|
||||
};
|
||||
|
||||
/** Hash every stream byte and retain its bounded classification prefix. */
|
||||
export const hashReadable = async (
|
||||
stream: Readable,
|
||||
signal?: AbortSignal,
|
||||
|
|
@ -3,61 +3,26 @@ import { isAbsolute } from "node:path";
|
|||
import type { Readable } from "node:stream";
|
||||
|
||||
import { compareCodePoints } from "../../domain/canonicalOrdering.js";
|
||||
import { AsarArtifactReader } from "../../artifacts/AsarArtifactReader.js";
|
||||
import { AsarArtifactReader } from "../AsarArtifactReader.js";
|
||||
import {
|
||||
ArtifactPathRegistry,
|
||||
normalizeArtifactPath,
|
||||
} from "../../artifacts/ArtifactPaths.js";
|
||||
} from "../ArtifactPaths.js";
|
||||
import {
|
||||
ArtifactReaderFailure,
|
||||
type ArtifactEntry,
|
||||
type ArtifactReader,
|
||||
} from "../../artifacts/ArtifactReader.js";
|
||||
import { streamChunkToBuffer } from "../../artifacts/StreamBytes.js";
|
||||
import { hashReadable } from "../ArtifactInventory/hash.js";
|
||||
import type { ArtifactInventorySnapshot } from "../ArtifactInventory.js";
|
||||
} from "../ArtifactReader.js";
|
||||
import { streamChunkToBuffer } from "../StreamBytes.js";
|
||||
import { hashReadable } from "../ArtifactHash.js";
|
||||
import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js";
|
||||
|
||||
/** Relevant file categories projected from the complete artifact inventory. */
|
||||
export type JavaScriptArtifactFileKind =
|
||||
| "package-json"
|
||||
| "json"
|
||||
| "javascript"
|
||||
| "html"
|
||||
| "source-map"
|
||||
| "native-addon";
|
||||
|
||||
/** One content-addressed local artifact file and its text availability. */
|
||||
export interface JavaScriptArtifactFile {
|
||||
readonly path: string;
|
||||
readonly container_sha256: string;
|
||||
readonly sha256: string;
|
||||
readonly bytes: number;
|
||||
readonly inventory_artifact_id: string;
|
||||
readonly kind: JavaScriptArtifactFileKind;
|
||||
readonly unpacked: boolean;
|
||||
readonly text:
|
||||
| { readonly included: true; readonly value: string }
|
||||
| {
|
||||
readonly included: false;
|
||||
readonly reason: "not-applicable" | "invalid-utf8";
|
||||
};
|
||||
}
|
||||
|
||||
/** One filesystem-backed ASAR nested beneath a directory input. */
|
||||
export interface JavaScriptArtifactContainer {
|
||||
readonly path: string;
|
||||
readonly sha256: string;
|
||||
readonly bytes: number;
|
||||
readonly inventory_artifact_id: string;
|
||||
}
|
||||
|
||||
/** Deterministic relevant-file projection plus explicit omissions. */
|
||||
export interface JavaScriptArtifactFileSet {
|
||||
readonly files: readonly JavaScriptArtifactFile[];
|
||||
readonly containers: readonly JavaScriptArtifactContainer[];
|
||||
readonly text_bytes_read: number;
|
||||
readonly invalid_utf8_files: number;
|
||||
}
|
||||
import type {
|
||||
JavaScriptArtifactFileKind,
|
||||
JavaScriptArtifactFile,
|
||||
JavaScriptArtifactContainer,
|
||||
JavaScriptArtifactFileSet,
|
||||
} from "../../domain/javascript/javascriptArtifactFiles.js";
|
||||
|
||||
interface ExpectedFile {
|
||||
readonly path: string;
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import { AsarArtifactReader } from "../AsarArtifactReader.js";
|
||||
import type { ArtifactReader } from "../ArtifactReader.js";
|
||||
import { DirectoryArtifactReader } from "../DirectoryArtifactReader.js";
|
||||
|
||||
/** Create the reader for an admitted JavaScript artifact format. */
|
||||
export const createJavaScriptArtifactReader = (
|
||||
path: string,
|
||||
format: "asar" | "directory",
|
||||
): ArtifactReader =>
|
||||
format === "asar"
|
||||
? new AsarArtifactReader(path)
|
||||
: new DirectoryArtifactReader(path);
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }>,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
@ -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"]));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ describe("executable dispatcher", () => {
|
|||
),
|
||||
workspace.write(
|
||||
"dist/cliOutput.js",
|
||||
"export const sanitizeCliOutput = (output) => output; export const validateCliOutputArguments = () => ({ ok: true }); export const renderCliOutputArgumentError = () => '';\n",
|
||||
"export const sanitizeCliOutput = (output) => output; export const validateCliOutputArguments = () => ({ ok: true }); export const renderCliOutputArgumentError = () => ''; export const renderEmptyFilteredCliOutput = () => undefined;\n",
|
||||
),
|
||||
workspace.write(
|
||||
"dist/mcpDoctor.js",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest";
|
|||
|
||||
import { classifyArtifactContent } from "../../../src/application/ArtifactGraphConstruction.js";
|
||||
import { classifyRoot } from "../../../src/application/ArtifactInventory/classify.js";
|
||||
import { ARTIFACT_CLASSIFICATION_PREFIX_BYTES } from "../../../src/application/ArtifactInventory/hash.js";
|
||||
import { ARTIFACT_CLASSIFICATION_PREFIX_BYTES } from "../../../src/artifacts/ArtifactHash.js";
|
||||
import { scanCanonicalArtifactInventory } from "../../../src/application/ArtifactInventory/scanCanonical.js";
|
||||
import { targetFormatSchema } from "../../../src/contracts/toolOutputSchemaPrimitives.js";
|
||||
import { artifactInventoryResultSchema } from "../../../src/domain/artifactGraph.js";
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { createPackage } from "@electron/asar";
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { scanArtifactInventory } from "../../../src/application/ArtifactInventory.js";
|
||||
import { readJavaScriptArtifactFiles } from "../../../src/application/javascript/JavaScriptArtifactFiles.js";
|
||||
import { readJavaScriptArtifactFiles } from "../../../src/artifacts/javascript/JavaScriptArtifactFiles.js";
|
||||
import { AsarArtifactReader } from "../../../src/artifacts/AsarArtifactReader.js";
|
||||
import { DirectoryArtifactReader } from "../../../src/artifacts/DirectoryArtifactReader.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { expect, it } from "vitest";
|
|||
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
import { readJavaScriptArtifactFiles } from "../../../src/application/javascript/JavaScriptArtifactFiles.js";
|
||||
import { readJavaScriptArtifactFiles } from "../../../src/artifacts/javascript/JavaScriptArtifactFiles.js";
|
||||
import { reconstructJavaScriptArtifact } from "../../../src/application/javascript/JavaScriptArtifactReconstruction.js";
|
||||
import { scanArtifactInventory } from "../../../src/application/ArtifactInventory.js";
|
||||
import {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import {
|
|||
resolveArtifactPathByContext,
|
||||
type ArtifactPathResolution,
|
||||
} from "../../../src/application/javascript/JavaScriptArtifactPathResolution.js";
|
||||
import type { JavaScriptArtifactFile } from "../../../src/application/javascript/JavaScriptArtifactFiles.js";
|
||||
import type { JavaScriptArtifactFile } from "../../../src/domain/javascript/javascriptArtifactFiles.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
const execute = promisify(execFile);
|
||||
const TARGETS = [
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
@ -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>
|
||||
|
|
@ -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");
|
||||
});
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "rea-notes-example",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "main.js"
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
const { contextBridge, ipcRenderer } = require("electron");
|
||||
|
||||
contextBridge.exposeInMainWorld("notes", {
|
||||
exportCsv: () => ipcRenderer.invoke("notes:export"),
|
||||
});
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
document.querySelector("#export").addEventListener("click", async () => {
|
||||
const destination = await window.notes.exportCsv();
|
||||
document.querySelector("#status").textContent = `Saved to ${destination}`;
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
Loading…
Reference in New Issue