From ec394a2bdfbc1474851f4849611d6d308d8e60a0 Mon Sep 17 00:00:00 2001 From: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:05:00 +0800 Subject: [PATCH] feat(javascript): add filtered retained module reference views Expose filtered canonical module identities, retained source-range counts and mixed support through the existing shared CLI/MCP analysis view. Preserve full-item access, source selection, provenance and partial coverage while allocating only the selected page. Verify exact facts and CLI/MCP identity on shipped Obsidian, record complete-task BPE/resource measurements and reuse owned-process supervision for optional tokenizer cleanup. Fixes #4092 Part of #4027; decision #4091. Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> --- .../references/javascript-applications.md | 30 + docs/javascript-application-workflows.md | 42 +- ...vascript-module-references-2026-10-15.json | 691 ++++++++++++++++++ ...javascript-module-references-2026-10-15.md | 123 ++++ package.json | 1 + scripts/fixtures/tokenizer-family-wrapper.py | 36 + .../verify/javascript/module-references.mjs | 415 +++++++++++ src/contracts/analysisViewToolContracts.ts | 20 +- src/domain/analysisView/analysisView.test.ts | 296 ++++++++ src/domain/analysisView/analysisView.ts | 21 + .../analysisView/javascriptApplicationView.ts | 147 +++- .../javascriptModuleReferences.ts | 61 ++ tests/boundary/cli/analysisViewCli.test.ts | 36 + tests/boundary/mcp/analysisViewMcp.test.ts | 60 ++ 14 files changed, 1950 insertions(+), 29 deletions(-) create mode 100644 docs/verification/javascript-module-references-2026-10-15.json create mode 100644 docs/verification/javascript-module-references-2026-10-15.md create mode 100755 scripts/fixtures/tokenizer-family-wrapper.py create mode 100644 scripts/verify/javascript/module-references.mjs create mode 100644 src/domain/analysisView/javascriptModuleReferences.ts diff --git a/.agents/skills/reverse-engineer-anything/references/javascript-applications.md b/.agents/skills/reverse-engineer-anything/references/javascript-applications.md index 29b4718fef..2200bcd9a6 100644 --- a/.agents/skills/reverse-engineer-anything/references/javascript-applications.md +++ b/.agents/skills/reverse-engineer-anything/references/javascript-applications.md @@ -94,6 +94,36 @@ recorded observations, identity, hashes, exports and source ranges; it does not include every child syntax observation, related edge or original source file. An absent item fact is not evidence of absence from the whole application. +For canonical identities and retained source-range counts, use a typed reference +page instead of reading every full item: + +```json +{ + "source": { + "kind": "retained-evidence", + "evidence_id": "ev_ACTUAL_PARENT_ID" + }, + "view": { + "kind": "javascript", + "collection": "modules", + "projection": "references", + "where": { "path": "main.js", "kind": "javascript-module" }, + "offset": 0, + "limit": 16 + } +} +``` + +Filters match exact retained strings; omit a filter to include all values, use +`path: null` for unavailable paths, and `path: ""` for a recorded empty path. +Rows preserve canonical `identity` and distinct observation support states in +`basis`. `retained_source_range_count` counts retained source-range observations, +not runtime imports or unique reference sites. Use a row's `node_id` with the item +view above for exact locations and full supporting observations. Follow +`coverage.next_offset`; page exhaustion describes matching retained rows only. +Check `source_coverage` for source selection, partial/unknown graph coverage and +unknown counts before drawing a negative conclusion. + ## Trace only the remaining question Use `trace_application_feature` on the parent application Evidence for one diff --git a/docs/javascript-application-workflows.md b/docs/javascript-application-workflows.md index 7b581e8ae2..ef5f12c67a 100644 --- a/docs/javascript-application-workflows.md +++ b/docs/javascript-application-workflows.md @@ -6,7 +6,7 @@ are `inspect_analysis_view`, `trace_application_feature`, `trace_javascript_semantics`, `compare_application_versions`, `compare_source_to_bundle`, and `compare_javascript_export_shapes`; their CLI equivalents use the same names with hyphens. `inspect_analysis_view` projects a -summary, module page, or one module from already completed application +summary, module page, typed module references, or one module from already completed application Evidence; it does not walk relationships. `inspect_analysis_view` also projects retained `inspect_binary_layout` @@ -40,6 +40,46 @@ combine its fields with the fact's location. Semantic trace results include the canonical context subset referenced by their returned facts, so those facts remain self-contained in the response. +## Retained module facts + +For canonical identities and retained source-range counts, query the existing +`inspect_analysis_view` tool with a typed module-reference projection: + +```json +{ + "source": { + "kind": "retained-evidence", + "evidence_id": "ev_ACTUAL_PARENT_ID" + }, + "view": { + "kind": "javascript", + "collection": "modules", + "projection": "references", + "where": { "path": "main.js", "kind": "javascript-module" }, + "offset": 0, + "limit": 16 + } +} +``` + +Replace the parent ID with the actual analysis summary's `parent_evidence_id`. +Filters match exact retained paths and kinds. Omission includes all values; +`path: null` selects an unavailable path and `path: ""` selects a recorded empty +path. Multiple identities can share a path. The default offset is 0 and limit +is 64; follow `coverage.next_offset` for later matching rows in retained order. + +Each row contains its `node_id`, kind, path, canonical identity, retained +source-range observation count, and distinct supporting authority/state/confidence +combinations in `basis`. These counts do not prove unique reference sites or +runtime loading. Use the existing module item view with the row's `node_id` for +exact source ranges and complete observations. The result retains parent/query +Evidence identity, artifact identity, limitations and unknowns. `source_coverage` +keeps selected/unselected sources, graph coverage and unknown counts separate +from page exhaustion; an exhausted or empty page cannot prove absence from an +incompletely analyzed application. CLI `inspect-analysis-view` reads the same +request from a JSON file; portable input uses `source.kind: "inline"` with the +complete parent Evidence. + ## Feature tracing Select one literal seed kind: node ID, route, string, API, IPC channel, module, diff --git a/docs/verification/javascript-module-references-2026-10-15.json b/docs/verification/javascript-module-references-2026-10-15.json new file mode 100644 index 0000000000..210c3f39e6 --- /dev/null +++ b/docs/verification/javascript-module-references-2026-10-15.json @@ -0,0 +1,691 @@ +{ + "base_revision": "c9f9b83c6f5878f1d5b102ab206a2ce214749258", + "source_sha256": { + "scripts/verify/javascript/module-references.mjs": "9d5f0b8b35ed2ebb4591f9dc3baea0fc116a0c3fd7d8e5ce30ec476c84671ed1", + "src/contracts/analysisViewToolContracts.ts": "f7c64b3472e7ec1090f9d47ede864b8c9630d506d896454a800747f2c90a8960", + "src/domain/analysisView/analysisView.ts": "854b03988791c19642b01d17813b379b51d8edc10d2190a3c236c9a1b2feb365", + "src/domain/analysisView/javascriptApplicationView.ts": "f2c41ad15fdc9f891412c9161a832b60baf3dae55e3c4d60159098d07f7d1ef1", + "src/domain/analysisView/javascriptModuleReferences.ts": "34c7a7ff466e9c49d43f1b11b72f878041de61a1bc09086ec190a5833b454806", + "scripts/fixtures/tokenizer-family-wrapper.py": "2e9b7092ee539ae1c6a477492f05bb6007d4612dadfff9b90051d621de43049b" + }, + "verification": { + "scope": "Completed module-fact reads from identical retained Evidence; no hosted-model performance claim", + "artifact": { + "path": "/tmp/rea-codemode-experiment-20261015/artifacts/obsidian-1.14.4.asar", + "sha256": "146ef8470d8cbb4a6db209bdc19bf2bad13350444186c0fd93c31087b142b92f" + }, + "parent_evidence_id": "ev_73ef06e3b9b944f54c5257f7960279127d8518d2a73cd8618bd045dbe44e6b5a", + "source_selection": { + "requested_paths": ["package.json", "main.js"], + "unselected_files": 36 + }, + "source_coverage": { + "application": { + "status": "partial", + "truncated": false, + "omitted_count": null, + "limits": [] + }, + "semantic": { + "status": "partial", + "truncated": false, + "omitted_nodes": null, + "omitted_relations": null, + "limits": [], + "families": [ + { + "family": "boundary", + "status": "partial", + "retained_relations": 14, + "omitted_relations": null, + "unknown_count": 31 + }, + { + "family": "call-flow", + "status": "partial", + "retained_relations": 845, + "omitted_relations": null, + "unknown_count": 1278 + }, + { + "family": "child-process", + "status": "partial", + "retained_relations": 0, + "omitted_relations": null, + "unknown_count": 31 + }, + { + "family": "closure", + "status": "partial", + "retained_relations": 1187, + "omitted_relations": null, + "unknown_count": 31 + }, + { + "family": "configuration", + "status": "partial", + "retained_relations": 0, + "omitted_relations": null, + "unknown_count": 31 + }, + { + "family": "data-flow", + "status": "partial", + "retained_relations": 9388, + "omitted_relations": null, + "unknown_count": 1135 + }, + { + "family": "event", + "status": "partial", + "retained_relations": 127, + "omitted_relations": null, + "unknown_count": 89 + }, + { + "family": "object-flow", + "status": "partial", + "retained_relations": 1462, + "omitted_relations": null, + "unknown_count": 2292 + }, + { + "family": "promise-ownership", + "status": "partial", + "retained_relations": 80, + "omitted_relations": null, + "unknown_count": 98 + }, + { + "family": "request", + "status": "partial", + "retained_relations": 0, + "omitted_relations": null, + "unknown_count": 31 + }, + { + "family": "resource-lifecycle", + "status": "partial", + "retained_relations": 4, + "omitted_relations": null, + "unknown_count": 31 + }, + { + "family": "timer", + "status": "partial", + "retained_relations": 8, + "omitted_relations": null, + "unknown_count": 31 + } + ] + }, + "application_unknowns": 36, + "semantic_unknowns": 5109 + }, + "where": { + "path": "main.js", + "kind": "javascript-module" + }, + "preparation_ms": 20978.249922, + "samples": [ + { + "round": 0, + "mode": "legacy", + "elapsed_ms": 363.02500200000213, + "calls": 24, + "structured_reply_bytes": 325201, + "facts_sha256": "d3be5e91d2ec3fd9a95851cfbbdef9c168bb4c7153f78810b8b235e005401299", + "matching_modules": 23, + "coverage": { + "status": "page", + "examined": 55, + "total": 55, + "next_offset": null, + "exhausted": true + } + }, + { + "round": 0, + "mode": "references", + "elapsed_ms": 14.895039999999426, + "calls": 1, + "structured_reply_bytes": 22158, + "facts_sha256": "d3be5e91d2ec3fd9a95851cfbbdef9c168bb4c7153f78810b8b235e005401299", + "matching_modules": 23, + "coverage": { + "status": "page", + "examined": 23, + "total": 23, + "next_offset": null, + "exhausted": true + } + }, + { + "round": 1, + "mode": "references", + "elapsed_ms": 12.951290000000881, + "calls": 1, + "structured_reply_bytes": 22158, + "facts_sha256": "d3be5e91d2ec3fd9a95851cfbbdef9c168bb4c7153f78810b8b235e005401299", + "matching_modules": 23, + "coverage": { + "status": "page", + "examined": 23, + "total": 23, + "next_offset": null, + "exhausted": true + } + }, + { + "round": 1, + "mode": "legacy", + "elapsed_ms": 232.7533779999976, + "calls": 24, + "structured_reply_bytes": 325201, + "facts_sha256": "d3be5e91d2ec3fd9a95851cfbbdef9c168bb4c7153f78810b8b235e005401299", + "matching_modules": 23, + "coverage": { + "status": "page", + "examined": 55, + "total": 55, + "next_offset": null, + "exhausted": true + } + }, + { + "round": 2, + "mode": "legacy", + "elapsed_ms": 235.16672899999685, + "calls": 24, + "structured_reply_bytes": 325201, + "facts_sha256": "d3be5e91d2ec3fd9a95851cfbbdef9c168bb4c7153f78810b8b235e005401299", + "matching_modules": 23, + "coverage": { + "status": "page", + "examined": 55, + "total": 55, + "next_offset": null, + "exhausted": true + } + }, + { + "round": 2, + "mode": "references", + "elapsed_ms": 10.566158999998152, + "calls": 1, + "structured_reply_bytes": 22158, + "facts_sha256": "d3be5e91d2ec3fd9a95851cfbbdef9c168bb4c7153f78810b8b235e005401299", + "matching_modules": 23, + "coverage": { + "status": "page", + "examined": 23, + "total": 23, + "next_offset": null, + "exhausted": true + } + } + ], + "token_costs": { + "tokenizer": "cl100k_base", + "tiktoken_version": "0.12.0", + "costs": { + "legacy": { + "selected_catalog": 7977, + "full_catalog": 195446, + "common_request": 53, + "common_summary": 2675, + "requests": 2836, + "structured_replies": 71761, + "selected_catalog_task_total": 85302, + "full_catalog_task_total": 272771 + }, + "references": { + "selected_catalog": 7977, + "full_catalog": 195446, + "common_request": 53, + "common_summary": 2675, + "requests": 102, + "structured_replies": 5741, + "selected_catalog_task_total": 16548, + "full_catalog_task_total": 204017 + } + } + }, + "cli_parity": { + "evidence_id": "ev_b3586dfdf7d4f01157f8f2978326aaa7103987110f2df4fa5bf3331ff2898749", + "facts_sha256": "d3be5e91d2ec3fd9a95851cfbbdef9c168bb4c7153f78810b8b235e005401299", + "exact_cli_mcp_view_parity": true + }, + "conditions": "First query is after shared artifact analysis; subsequent queries share one MCP session. Legacy item reads are serial; a concurrent legacy baseline is unmeasured. Filesystem/build warmness uncontrolled. Token costs count one post-change catalog, preparation request/summary and each request/structured reply once; client rendering, replay, reasoning, final answer and model latency are unmeasured. Preparation and parity/tokenization overhead are outside per-query elapsed times; external family-resource receipts include the complete verifier and children." + }, + "resources": { + "scope": "Complete verifier, MCP/analysis descendants, temporary export, CLI parity and tokenizer; cgroup memory includes charged file cache and is not summed RSS", + "elapsed_seconds": 35.797, + "cpu_seconds": 35.197074, + "peak_cgroup_bytes": 1938313216, + "memory_max_bytes": 8589934592, + "memory_early_stop_bytes": 6442450944, + "cpu_quota_percent": 100, + "payload_exit_code": 0, + "payload_cleanup_verified": true, + "events_at_payload_exit": { + "memory.events": { + "low": 0, + "high": 0, + "max": 0, + "oom": 0, + "oom_kill": 0, + "oom_group_kill": 0 + }, + "pids.events": { + "max": 0 + } + }, + "observer_sources_sha256": { + "resource-run.py": "7728862171415e7dee2c11af2ead2b9a54265fccaf82a7113956d36b29065e33", + "resource_payload.py": "6f089e645459eba80c5d73e39c21606c78173a44daf49fabc3b88c4631ce51c6" + } + }, + "prior_attempts": [ + { + "scope": "Initial verifier incorrectly requested full MCP delivery; completed analysis exceeded existing response admission", + "elapsed_seconds": 21.971, + "cpu_seconds": 21.78546, + "peak_cgroup_bytes": 1080860672, + "outcome": "resource_constraint transport; verifier corrected to summary-first; no performance credit" + }, + { + "scope": "Interim successful parity run before eliminating verifier assertion serialization and counting the bootstrap request", + "elapsed_seconds": 31.335, + "cpu_seconds": 31.07044, + "peak_cgroup_bytes": 1652293632, + "outcome": "parity passed; superseded measurement, not pooled with final samples" + }, + { + "scope": "Successful three-pair probe before adding the shared analyze_javascript_application schema to selected-catalog accounting", + "preparation_ms": 18317.110148, + "samples": [ + { + "round": 0, + "mode": "legacy", + "elapsed_ms": 313.59318599999824, + "calls": 24, + "structured_reply_bytes": 325201, + "facts_sha256": "d3be5e91d2ec3fd9a95851cfbbdef9c168bb4c7153f78810b8b235e005401299", + "matching_modules": 23, + "coverage": { + "status": "page", + "examined": 55, + "total": 55, + "next_offset": null, + "exhausted": true + } + }, + { + "round": 0, + "mode": "references", + "elapsed_ms": 13.595478999999614, + "calls": 1, + "structured_reply_bytes": 22158, + "facts_sha256": "d3be5e91d2ec3fd9a95851cfbbdef9c168bb4c7153f78810b8b235e005401299", + "matching_modules": 23, + "coverage": { + "status": "page", + "examined": 23, + "total": 23, + "next_offset": null, + "exhausted": true + } + }, + { + "round": 1, + "mode": "references", + "elapsed_ms": 11.230690000000322, + "calls": 1, + "structured_reply_bytes": 22158, + "facts_sha256": "d3be5e91d2ec3fd9a95851cfbbdef9c168bb4c7153f78810b8b235e005401299", + "matching_modules": 23, + "coverage": { + "status": "page", + "examined": 23, + "total": 23, + "next_offset": null, + "exhausted": true + } + }, + { + "round": 1, + "mode": "legacy", + "elapsed_ms": 206.19418099999893, + "calls": 24, + "structured_reply_bytes": 325201, + "facts_sha256": "d3be5e91d2ec3fd9a95851cfbbdef9c168bb4c7153f78810b8b235e005401299", + "matching_modules": 23, + "coverage": { + "status": "page", + "examined": 55, + "total": 55, + "next_offset": null, + "exhausted": true + } + }, + { + "round": 2, + "mode": "legacy", + "elapsed_ms": 242.2037199999977, + "calls": 24, + "structured_reply_bytes": 325201, + "facts_sha256": "d3be5e91d2ec3fd9a95851cfbbdef9c168bb4c7153f78810b8b235e005401299", + "matching_modules": 23, + "coverage": { + "status": "page", + "examined": 55, + "total": 55, + "next_offset": null, + "exhausted": true + } + }, + { + "round": 2, + "mode": "references", + "elapsed_ms": 13.734700000000885, + "calls": 1, + "structured_reply_bytes": 22158, + "facts_sha256": "d3be5e91d2ec3fd9a95851cfbbdef9c168bb4c7153f78810b8b235e005401299", + "matching_modules": 23, + "coverage": { + "status": "page", + "examined": 23, + "total": 23, + "next_offset": null, + "exhausted": true + } + } + ], + "elapsed_seconds": 31.037, + "cpu_seconds": 30.786998, + "peak_cgroup_bytes": 1886543872, + "outcome": "Facts and CLI/MCP parity passed. Selected-catalog totals omitted the common analysis-tool schema; superseded, not pooled. Shared-host timing differed substantially from the final run." + }, + { + "scope": "Completed three-pair probe with full token accounting before the review-added tokenizer deadline", + "preparation_ms": 66696.731143, + "samples": [ + { + "round": 0, + "mode": "legacy", + "elapsed_ms": 1114.080965000001, + "calls": 24, + "structured_reply_bytes": 325201, + "facts_sha256": "d3be5e91d2ec3fd9a95851cfbbdef9c168bb4c7153f78810b8b235e005401299", + "matching_modules": 23, + "coverage": { + "status": "page", + "examined": 55, + "total": 55, + "next_offset": null, + "exhausted": true + } + }, + { + "round": 0, + "mode": "references", + "elapsed_ms": 46.572758999987855, + "calls": 1, + "structured_reply_bytes": 22158, + "facts_sha256": "d3be5e91d2ec3fd9a95851cfbbdef9c168bb4c7153f78810b8b235e005401299", + "matching_modules": 23, + "coverage": { + "status": "page", + "examined": 23, + "total": 23, + "next_offset": null, + "exhausted": true + } + }, + { + "round": 1, + "mode": "references", + "elapsed_ms": 37.09324900001229, + "calls": 1, + "structured_reply_bytes": 22158, + "facts_sha256": "d3be5e91d2ec3fd9a95851cfbbdef9c168bb4c7153f78810b8b235e005401299", + "matching_modules": 23, + "coverage": { + "status": "page", + "examined": 23, + "total": 23, + "next_offset": null, + "exhausted": true + } + }, + { + "round": 1, + "mode": "legacy", + "elapsed_ms": 675.7124039999908, + "calls": 24, + "structured_reply_bytes": 325201, + "facts_sha256": "d3be5e91d2ec3fd9a95851cfbbdef9c168bb4c7153f78810b8b235e005401299", + "matching_modules": 23, + "coverage": { + "status": "page", + "examined": 55, + "total": 55, + "next_offset": null, + "exhausted": true + } + }, + { + "round": 2, + "mode": "legacy", + "elapsed_ms": 777.2660329999926, + "calls": 24, + "structured_reply_bytes": 325201, + "facts_sha256": "d3be5e91d2ec3fd9a95851cfbbdef9c168bb4c7153f78810b8b235e005401299", + "matching_modules": 23, + "coverage": { + "status": "page", + "examined": 55, + "total": 55, + "next_offset": null, + "exhausted": true + } + }, + { + "round": 2, + "mode": "references", + "elapsed_ms": 36.31178899999941, + "calls": 1, + "structured_reply_bytes": 22158, + "facts_sha256": "d3be5e91d2ec3fd9a95851cfbbdef9c168bb4c7153f78810b8b235e005401299", + "matching_modules": 23, + "coverage": { + "status": "page", + "examined": 23, + "total": 23, + "next_offset": null, + "exhausted": true + } + } + ], + "elapsed_seconds": 108.372, + "cpu_seconds": 81.551676, + "peak_cgroup_bytes": 1884962816, + "outcome": "All facts/costs and CLI/MCP parity passed. Superseded execution source after adding a tokenizer deadline; retained separately, not pooled." + }, + { + "scope": "Successful data comparison with direct-child tokenizer timeout before adopting shared owned-process supervision", + "preparation_ms": 19051.368892000002, + "samples": [ + { + "round": 0, + "mode": "legacy", + "elapsed_ms": 330.7313439999998, + "calls": 24, + "structured_reply_bytes": 325201, + "facts_sha256": "d3be5e91d2ec3fd9a95851cfbbdef9c168bb4c7153f78810b8b235e005401299", + "matching_modules": 23, + "coverage": { + "status": "page", + "examined": 55, + "total": 55, + "next_offset": null, + "exhausted": true + } + }, + { + "round": 0, + "mode": "references", + "elapsed_ms": 13.62184999999954, + "calls": 1, + "structured_reply_bytes": 22158, + "facts_sha256": "d3be5e91d2ec3fd9a95851cfbbdef9c168bb4c7153f78810b8b235e005401299", + "matching_modules": 23, + "coverage": { + "status": "page", + "examined": 23, + "total": 23, + "next_offset": null, + "exhausted": true + } + }, + { + "round": 1, + "mode": "references", + "elapsed_ms": 12.475999999998749, + "calls": 1, + "structured_reply_bytes": 22158, + "facts_sha256": "d3be5e91d2ec3fd9a95851cfbbdef9c168bb4c7153f78810b8b235e005401299", + "matching_modules": 23, + "coverage": { + "status": "page", + "examined": 23, + "total": 23, + "next_offset": null, + "exhausted": true + } + }, + { + "round": 1, + "mode": "legacy", + "elapsed_ms": 187.18667300000016, + "calls": 24, + "structured_reply_bytes": 325201, + "facts_sha256": "d3be5e91d2ec3fd9a95851cfbbdef9c168bb4c7153f78810b8b235e005401299", + "matching_modules": 23, + "coverage": { + "status": "page", + "examined": 55, + "total": 55, + "next_offset": null, + "exhausted": true + } + }, + { + "round": 2, + "mode": "legacy", + "elapsed_ms": 293.4882039999975, + "calls": 24, + "structured_reply_bytes": 325201, + "facts_sha256": "d3be5e91d2ec3fd9a95851cfbbdef9c168bb4c7153f78810b8b235e005401299", + "matching_modules": 23, + "coverage": { + "status": "page", + "examined": 55, + "total": 55, + "next_offset": null, + "exhausted": true + } + }, + { + "round": 2, + "mode": "references", + "elapsed_ms": 8.146389999998064, + "calls": 1, + "structured_reply_bytes": 22158, + "facts_sha256": "d3be5e91d2ec3fd9a95851cfbbdef9c168bb4c7153f78810b8b235e005401299", + "matching_modules": 23, + "coverage": { + "status": "page", + "examined": 23, + "total": 23, + "next_offset": null, + "exhausted": true + } + } + ], + "elapsed_seconds": 30.742, + "cpu_seconds": 30.425218, + "peak_cgroup_bytes": 1592938496, + "outcome": "Fact and cost parity passed. That harness only killed its immediate tokenizer child; superseded by process-family supervision and detached-helper verification." + } + ], + "prior_tokenizer_timeout_verifications": [ + { + "artifact_sha256": "146ef8470d8cbb4a6db209bdc19bf2bad13350444186c0fd93c31087b142b92f", + "scenario": "Real Obsidian references plus CLI parity, then a deliberately stalled tokenizer", + "injected_tokenizer_source": "#!/bin/sh\nexec /bin/sleep 30\n", + "timeout_ms": 20, + "observed_error_code": "ETIMEDOUT", + "expected_payload_exit_code": 1, + "payload_cleanup_verified": true, + "remaining_owned_temporary_files": 0, + "elapsed_seconds": 31.258, + "cpu_seconds": 31.00025, + "peak_cgroup_bytes": 1644077056, + "events_at_payload_exit": { + "memory.events": { + "low": 0, + "high": 0, + "max": 0, + "oom": 0, + "oom_kill": 0, + "oom_group_kill": 0 + }, + "pids.events": { + "max": 0 + } + }, + "scope": "Robustness check; no efficiency credit", + "limitation": "Immediate child only; did not test detached descendants and is superseded by the owned-family check." + } + ], + "tokenizer_timeout_verification": { + "artifact_sha256": "146ef8470d8cbb4a6db209bdc19bf2bad13350444186c0fd93c31087b142b92f", + "scenario": "Real Obsidian references and CLI parity, then an owned tokenizer wrapper with a detached SIGTERM-ignoring helper", + "fixture": "scripts/fixtures/tokenizer-family-wrapper.py", + "fixture_sha256": "2e9b7092ee539ae1c6a477492f05bb6007d4612dadfff9b90051d621de43049b", + "timeout_ms": 2000, + "observed_failure_type": "OwnedCommandFailure", + "observed_reason": "timeout", + "cleanup_failure": null, + "expected_payload_exit_code": 1, + "payload_cleanup_verified": true, + "remaining_owned_temporary_files": 0, + "helper_observation": { + "pid": 2871108, + "group": 2871108, + "parent": 2871107, + "run_id": "rea-module-token-f139b74a-dd83-498b-b663-e765104159ec", + "detached": true, + "ignores_sigterm": true + }, + "elapsed_seconds": 55.08, + "cpu_seconds": 45.245603, + "peak_cgroup_bytes": 1563475968, + "events_at_payload_exit": { + "memory.events": { + "low": 0, + "high": 0, + "max": 0, + "oom": 0, + "oom_kill": 0, + "oom_group_kill": 0 + }, + "pids.events": { + "max": 0 + } + }, + "scope": "Robustness check; no efficiency credit. PIDs are observations; signaling authority comes from the shared process owner." + } +} diff --git a/docs/verification/javascript-module-references-2026-10-15.md b/docs/verification/javascript-module-references-2026-10-15.md new file mode 100644 index 0000000000..64f80479af --- /dev/null +++ b/docs/verification/javascript-module-references-2026-10-15.md @@ -0,0 +1,123 @@ +# Retained JavaScript module-reference verification + +Tracks #4092 under #4027 and the decision in #4091. This verifies an existing +tool's data projection, without a code-mode host or SDK. [Machine-readable +results](javascript-module-references-2026-10-15.json) pin the base revision, +executed source hashes, samples, tokenizer and resource observations. + +## Real application and question + +Official Obsidian 1.14.4 ASAR, 27,477,966 bytes, SHA-256 +`146ef8470d8cbb4a6db209bdc19bf2bad13350444186c0fd93c31087b142b92f`. +The [official gzip](https://github.com/obsidianmd/obsidian-releases/releases/download/v1.14.4/obsidian-1.14.4.asar.gz) +has SHA-256 `d1ed428c363968774f0f3906e67d8a53a058ece0b6cdd924b07865977b2ada21`. +Select `package.json` and `main.js`; the other 36 inventoried files remain +explicitly unselected. No target code is executed. + +Question: retrieve every retained `javascript-module` identity at exact path +`main.js`, its retained source-range observation count, and distinct support +states. Compare the existing module page plus full item reads with one filtered +typed reference view. All six completed query samples use one identical retained +parent. All 23 selected rows, identities, counts and support states match. Public +CLI inline input and MCP retained input produce the exact same view and Evidence +ID. This establishes producer-fact consistency, not independent extractor +correctness or proof of runtime loading. + +## Completed read costs + +| Measurement | Existing page + items | Typed reference view | +| ------------------------------------------------------------- | --------------------: | -------------------: | +| Calls per query task | 24 | 1 | +| Structured reply bytes | 325,201 | 22,158 | +| First query in its arm, after preparation | 363.03 ms | 14.90 ms | +| Subsequent same-session query times | 232.75 / 235.17 ms | 12.95 / 10.57 ms | +| Request + structured-reply BPE tokens | 74,597 | 5,843 | +| With selected tool catalog + shared bootstrap request/summary | 85,302 | 16,548 | +| With complete tool catalog + shared bootstrap request/summary | 272,771 | 204,017 | + +Order alternates: existing/reference, reference/existing, existing/reference. +Times include verifier-side fact normalization and equality checks; byte/token +accounting follows the timed reads. The initial artifact analysis and summary +took 20.978 seconds and is shared preparation, not a query-speed improvement. +The retained source remains partial; unknown counts and source selection are +preserved independently of exhausted pages. No query cache was introduced. + +Tokens are `cl100k_base` counts with tiktoken 0.12.0 over serialized values, not +hosted-model usage. Catalog projections contain name/description/inputSchema; +both workflows use the same post-change catalog. Each request/reply is counted +once. Output schemas, host-specific rendering/replay, reasoning, final answers, +agent success rates and model latency are unmeasured. This is a completed +data-retrieval comparison; it does not establish a whole-agent speedup or a +before/after catalog-size improvement. Legacy item reads are serial; a concurrent +legacy baseline was not measured. Selected discovery includes both the +analysis and view tool schemas. Builds/filesystem are warm or uncontrolled, +so the first query is not an OS-cold analysis measurement. Shared-host scheduling +was uncontrolled: an earlier successful probe had substantially higher wall/CPU +times. Its samples are retained separately; neither run establishes a portable +latency guarantee. + +The complete final verifier scope used 35.797 seconds elapsed, 35.197 seconds +CPU and 1,938,313,216 bytes peak cgroup memory. This includes shared analysis, +both query arms, temporary export, CLI parity and tokenization; CPU/memory were +not isolated by arm. Cgroup memory includes charged file cache and is not summed +process RSS. The whole scope had one CPU, an 8 GiB hard memory cap, 6 GiB early +stop and no swap. Payload cleanup was verified; no OOM/capacity event occurred. +An earlier full-response transport failure and interim probes are recorded +separately in the JSON and earn no performance credit. + +A review follow-up exercised a stalled tokenizer after real Obsidian reference +reads and CLI parity. The committed POSIX fault fixture forks a helper into a +separate process group, ignores SIGTERM and stalls. With a caller-selected +2,000 ms deadline, the shared owned-command supervisor reported +`OwnedCommandFailure` with reason `timeout` and no cleanup failure. The verifier +failed as expected, its complete owned scope was cleaned, and its selected +temporary root was empty. The separate robustness run used 55.080 s wall / +45.246 s CPU / 1,563,475,968 B peak cgroup memory, with cleanup verified and no +OOM. It earns no efficiency credit. An earlier 20 ms immediate-child timeout +probe did not establish descendant cleanup; it remains a separate prior attempt. +The detached-helper scenario was executed on Linux, not Windows or macOS. + +A deterministic CI regression also asserts two retained +observations at the same source range, excludes an unavailable location, and +checks agreement with the full item view; the count deliberately measures +observations rather than unique source sites. + +## Reproduce + +Use the pinned Node/npm toolchain and `npm ci`, then `npm run build:cached`. +Download/decompress the versioned application outside the checkout and check its +digest. Run with an operator-selected absolute artifact path: + +```bash +node scripts/verify/javascript/module-references.mjs \ + --artifact /absolute/path/obsidian-1.14.4.asar \ + --source-path package.json --source-path main.js \ + --path main.js --kind javascript-module --rounds 3 --cli-parity \ + --tokenizer-python /absolute/path/to/tiktoken-venv/bin/python +``` + +The optional Python interpreter needs tiktoken 0.12.0; omit that flag to skip +token accounting. It has a 60-second wall-time deadline, configurable with the +positive integer `--tokenizer-timeout-ms`. The tokenizer uses the existing +owned-command supervisor, with native identity checks and inherited run-token +sweeping for descendants. Inputs and temporary Evidence use the existing private +runtime root. Cleanup attempts preserve the primary error; incomplete command +cleanup retains its owner and private files for recovery. `--mode legacy` or +`--mode references` runs one arm. Fresh runs +reanalyze the artifact and can produce different parent identities/resource +coverage; the verifier requires equal facts within its paired run. The measured +run additionally used the preserved [8 GiB cgroup observer](https://github.com/morluto/rea/blob/b3bdabab694af1f9801eff7caf75001b92314a58/experiments/analysis-efficiency/resource-run.py) +and its adjacent `resource_payload.py`; observer hashes are in the JSON. +No application, complete bundle, raw observations or ambient environment is +archived. Temporary exported/inline Evidence files are removed after successful +owned-command cleanup. + +For the Linux descendant-timeout check, create an operator-selected private +directory and set `REA_TOKENIZER_PROBE_RECORD` to a file within it. Run the same +real-artifact command with `--mode references --rounds 1`, replace the tokenizer +with the absolute path to `scripts/fixtures/tokenizer-family-wrapper.py`, and add +`--tokenizer-timeout-ms 2000`. Expect a timeout failure. The explicit marker +confirms the detached helper started; the resource observer must separately +verify the complete owned scope is empty, and the selected temporary root must +contain no verifier files. The recorded PID is an observation, not authority to +signal that process. diff --git a/package.json b/package.json index d637db7fea..904a3806f4 100644 --- a/package.json +++ b/package.json @@ -213,6 +213,7 @@ "verify:javascript-runtime": "npm run build:cached && node scripts/verify/javascript/runtime-observation.mjs", "verify:javascript:digests": "npm run build:cached && node scripts/verify/javascript/digests.mjs", "verify:javascript:output": "npm run build:cached && node scripts/verify/javascript/output.mjs", + "verify:javascript:module-references": "npm run build:cached && node scripts/verify/javascript/module-references.mjs", "verify:application-workflows": "npm run build:cached && node scripts/verify/javascript/application-workflows.mjs", "verify:managed": "npm run build:cached && node scripts/verify/managed/conformance.mjs", "verify:firmware": "npm run build:cached && node scripts/verify/firmware/analysis.mjs", diff --git a/scripts/fixtures/tokenizer-family-wrapper.py b/scripts/fixtures/tokenizer-family-wrapper.py new file mode 100755 index 0000000000..8a92578f78 --- /dev/null +++ b/scripts/fixtures/tokenizer-family-wrapper.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""POSIX fault fixture for the optional module-reference token counter. + +Use as --tokenizer-python with an explicit REA_TOKENIZER_PROBE_RECORD path. +It deliberately ignores the counter arguments, forks a detached helper that +ignores SIGTERM, and stalls. The production owned-command supervisor must +terminate the verified family. Recorded PIDs are observations, not authority +to signal a process; verification also checks the owned scope after exit. +""" + +import json +import os +from pathlib import Path +import signal +import time + + +def main(): + record = Path(os.environ["REA_TOKENIZER_PROBE_RECORD"]) + child = os.fork() + if child == 0: + os.setsid() + signal.signal(signal.SIGTERM, signal.SIG_IGN) + record.write_text(json.dumps({ + "pid": os.getpid(), + "group": os.getpgrp(), + "parent": os.getppid(), + "run_id": os.environ.get("REA_PROCESS_RUN_ID"), + "detached": True, + "ignores_sigterm": True, + }) + "\n", encoding="utf-8") + time.sleep(30) + + +if __name__ == "__main__": + main() diff --git a/scripts/verify/javascript/module-references.mjs b/scripts/verify/javascript/module-references.mjs new file mode 100644 index 0000000000..7772cecf72 --- /dev/null +++ b/scripts/verify/javascript/module-references.mjs @@ -0,0 +1,415 @@ +#!/usr/bin/env node + +import assert from "node:assert/strict"; +import { createHash, randomUUID } from "node:crypto"; +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { performance } from "node:perf_hooks"; +import { fileURLToPath } from "node:url"; +import { parseArgs } from "node:util"; +import { Client } from "@modelcontextprotocol/client"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; +import { artifactCliEvidence } from "../../lib/artifact-e2e.mjs"; +import { closeVerifierResources } from "../../lib/verifier-cleanup.mjs"; +import { PrivateRuntimeRoot } from "../../../dist/process/PrivateRuntimeRoot.js"; +import { + OwnedCommandFailure, + runOwnedCommand, +} from "../../../dist/process/OwnedCommand.js"; + +const { values } = parseArgs({ + options: { + artifact: { type: "string" }, + "source-path": { type: "string", multiple: true }, + path: { type: "string" }, + kind: { type: "string" }, + rounds: { type: "string", default: "3" }, + mode: { type: "string", default: "parity" }, + "cli-parity": { type: "boolean", default: false }, + "tokenizer-python": { type: "string" }, + "tokenizer-timeout-ms": { type: "string", default: "60000" }, + }, + strict: true, +}); +assert.ok(values.artifact?.startsWith("/"), "--artifact must be absolute"); +assert.ok(["parity", "legacy", "references"].includes(values.mode)); +const rounds = Number(values.rounds); +assert.ok(Number.isSafeInteger(rounds) && rounds > 0); +const tokenizerTimeoutMs = Number(values["tokenizer-timeout-ms"]); +assert.ok( + Number.isSafeInteger(tokenizerTimeoutMs) && tokenizerTimeoutMs > 0, + "--tokenizer-timeout-ms must be a positive safe integer", +); +const where = { + ...(values.path === undefined ? {} : { path: values.path }), + ...(values.kind === undefined ? {} : { kind: values.kind }), +}; +const transport = new StdioClientTransport({ + command: process.execPath, + args: [fileURLToPath(new URL("../../rea.mjs", import.meta.url)), "mcp"], + env: { + PATH: process.env.PATH ?? "/usr/bin:/bin", + REA_LOG_LEVEL: "silent", + REA_ANALYSIS_PROVIDER: "auto", + }, + stderr: "pipe", +}); +const client = new Client({ + name: "javascript-module-reference-verifier", + version: "1", +}); +const serialize = (value) => JSON.stringify(value); +const bytes = (value) => Buffer.byteLength(serialize(value)); +const digest = (value) => + createHash("sha256").update(serialize(value)).digest("hex"); +const support = (observations) => [ + ...new Map( + observations.map(({ evidence }) => { + const basis = { + authority: evidence.authority, + state: evidence.state, + confidence: evidence.confidence, + }; + return [serialize(basis), basis]; + }), + ).values(), +]; +const normalize = (rows) => + rows.map((row) => ({ + ...row, + basis: [...row.basis].sort((a, b) => + serialize(a).localeCompare(serialize(b)), + ), + })); +let temporary; +let primaryError; +let retainTemporary = false; +try { + await client.connect(transport); + transport.stderr?.resume(); + const tools = (await client.listTools()).tools; + const relevant = tools.filter(({ name }) => + ["analyze_javascript_application", "inspect_analysis_view"].includes(name), + ); + assert.equal(relevant.length, 2); + const call = async (name, args, transcript) => { + const response = await client.callTool( + { name, arguments: args }, + { timeout: 360_000 }, + ); + if (response.isError) throw new Error(serialize(response)); + const evidence = response.structuredContent; + assert.ok(evidence?.evidence_id && evidence.normalized_result); + transcript?.push({ + request: { name, arguments: args }, + response: evidence, + }); + return evidence; + }; + const preparationStart = performance.now(); + const preparationRequest = { + input_path: values.artifact, + detail: "summary", + ...(values["source-path"] === undefined + ? {} + : { source_paths: values["source-path"] }), + }; + const summary = await call( + "analyze_javascript_application", + preparationRequest, + ); + const source = { + kind: "retained-evidence", + evidence_id: summary.normalized_result.parent_evidence_id, + }; + const preparationMs = performance.now() - preparationStart; + const samples = []; + const costInputs = {}; + let canonical; + for (let round = 0; round < rounds; round += 1) { + const modes = + values.mode === "parity" + ? round % 2 === 0 + ? ["legacy", "references"] + : ["references", "legacy"] + : [values.mode]; + for (const mode of modes) { + const transcript = []; + const started = performance.now(); + let rows; + let coverage; + if (mode === "legacy") { + const page = await call( + "inspect_analysis_view", + { + source, + view: { + kind: "page", + collection: "modules", + offset: 0, + limit: Number.MAX_SAFE_INTEGER, + }, + }, + transcript, + ); + assert.equal(page.normalized_result.coverage.exhausted, true); + rows = []; + for (const row of page.normalized_result.items) { + if (where.path !== undefined && row.path !== where.path) continue; + if (where.kind !== undefined && row.kind !== where.kind) continue; + const itemEvidence = await call( + "inspect_analysis_view", + { + source, + view: { + kind: "item", + collection: "modules", + selector: { node_id: row.node_id }, + }, + }, + transcript, + ); + const item = itemEvidence.normalized_result.item; + rows.push({ + node_id: item.node_id, + kind: item.kind, + path: item.path, + identity: item.identity, + retained_source_range_count: item.source_ranges.length, + basis: support(item.observations), + }); + } + coverage = page.normalized_result.coverage; + } else { + const references = await call( + "inspect_analysis_view", + { + source, + view: { + kind: "javascript", + collection: "modules", + projection: "references", + where, + offset: 0, + limit: Number.MAX_SAFE_INTEGER, + }, + }, + transcript, + ); + const result = references.normalized_result; + rows = result.rows; + coverage = result.coverage; + assert.equal(coverage.exhausted, true); + assert.deepEqual( + result.source_coverage.application, + summary.normalized_result.summary.coverage.application, + ); + assert.deepEqual( + result.source_coverage.source_selection, + summary.normalized_result.summary.source_selection, + ); + assert.equal( + result.source_coverage.semantic.status, + summary.normalized_result.summary.coverage.semantic.status, + ); + assert.equal( + result.source_coverage.application_unknown_count, + summary.normalized_result.summary.coverage.application_unknowns, + ); + assert.equal( + result.source_coverage.semantic_unknown_count, + summary.normalized_result.summary.coverage.semantic_unknowns, + ); + } + assert.ok( + rows.length > 0, + "No matching modules; select a representative nonempty task", + ); + const normalized = normalize(rows); + canonical ??= normalized; + assert.deepEqual( + normalized, + canonical, + "Module facts changed across workflows/rounds", + ); + samples.push({ + round, + mode, + elapsed_ms: performance.now() - started, + calls: transcript.length, + structured_reply_bytes: transcript.reduce( + (total, entry) => total + bytes(entry.response), + 0, + ), + facts_sha256: digest(normalized), + matching_modules: rows.length, + coverage, + }); + costInputs[mode] ??= { + catalog: relevant.map(({ name, description, inputSchema }) => ({ + name, + description, + inputSchema, + })), + full_catalog: tools.map(({ name, description, inputSchema }) => ({ + name, + description, + inputSchema, + })), + common_summary: summary, + common_request: { + name: "analyze_javascript_application", + arguments: preparationRequest, + }, + transcript, + }; + } + } + let cliParity = null; + if (values["cli-parity"]) { + temporary = await PrivateRuntimeRoot.create({ + prefix: "rea-module-reference-verifier-", + }); + const bundlePath = join(temporary.path, "bundle.json"); + const exported = await client.callTool({ + name: "export_evidence_bundle", + arguments: { path: bundlePath }, + }); + if (exported.isError) throw new Error(serialize(exported)); + const bundle = JSON.parse(await readFile(bundlePath, "utf8")); + const parent = bundle.records.find( + ({ evidence_id }) => evidence_id === source.evidence_id, + ); + assert.ok(parent, "Export omitted the retained analysis parent"); + const request = join(temporary.path, "view.json"); + await writeFile( + request, + serialize({ + source: { kind: "inline", evidence: parent }, + view: { + kind: "javascript", + collection: "modules", + projection: "references", + where, + offset: 0, + limit: Number.MAX_SAFE_INTEGER, + }, + }), + ); + const cli = await artifactCliEvidence("inspect-analysis-view", request); + assert.deepEqual(normalize(cli.normalized_result.rows), canonical); + assert.equal(cli.normalized_result.parent_evidence_id, parent.evidence_id); + const retained = await call("inspect_analysis_view", { + source, + view: cli.normalized_result.view, + }); + assert.deepEqual(cli.normalized_result, retained.normalized_result); + assert.equal(cli.evidence_id, retained.evidence_id); + cliParity = { + evidence_id: cli.evidence_id, + facts_sha256: digest(canonical), + exact_cli_mcp_view_parity: true, + }; + } + let tokenCosts = null; + if (values["tokenizer-python"]) { + temporary ??= await PrivateRuntimeRoot.create({ + prefix: "rea-module-reference-verifier-", + }); + const input = join(temporary.path, "token-input.json"); + await writeFile(input, serialize(costInputs)); + let result; + try { + result = await runOwnedCommand( + { + command: values["tokenizer-python"], + arguments: [ + "-c", + ` +import json, sys, tiktoken +with open(sys.argv[1], encoding='utf-8') as source: + data=json.load(source) +enc=tiktoken.get_encoding('cl100k_base') +def count(value): + return len(enc.encode(json.dumps(value,ensure_ascii=False,separators=(',',':')),disallowed_special=())) +out={} +for mode, task in data.items(): + replies=sum(count(entry['response']) for entry in task['transcript']) + requests=sum(count(entry['request']) for entry in task['transcript']) + common=count(task['common_summary']) + common_request=count(task['common_request']) + selected=count(task['catalog']) + full=count(task['full_catalog']) + out[mode]=dict(selected_catalog=selected,full_catalog=full,common_request=common_request,common_summary=common,requests=requests,structured_replies=replies,selected_catalog_task_total=selected+common_request+common+requests+replies,full_catalog_task_total=full+common_request+common+requests+replies) +print(json.dumps(dict(tokenizer='cl100k_base',tiktoken_version=tiktoken.__version__,costs=out))) +`, + input, + ], + cwd: temporary.path, + runId: `rea-module-token-${randomUUID()}`, + // An interpreter wrapper may exec another command. The shared owner + // still verifies native identities and the exact inherited run token. + expectedCommand: null, + sweepTokenOwnedProcesses: true, + }, + { timeoutMs: tokenizerTimeoutMs, diagnosticBytes: 1024 * 1024 }, + ); + } catch (cause) { + // An incomplete cleanup carries its supervisor and identity resources in + // the error. Preserve its private files for that owner instead of erasing + // recovery data while a command may still be using it. + retainTemporary = + cause instanceof OwnedCommandFailure && + cause.cleanupOwner !== undefined; + throw new Error( + "Token accounting failed; inspect the owned-command outcome and cleanup status.", + { cause }, + ); + } + assert.notEqual( + result.stdout.invalidUtf8, + true, + "Tokenizer output must be UTF-8 JSON", + ); + tokenCosts = JSON.parse(result.stdout.text); + } + process.stdout.write( + `${serialize({ + scope: + "Completed module-fact reads from identical retained Evidence; no hosted-model performance claim", + artifact: { + path: values.artifact, + sha256: summary.normalized_result.artifact.sha256, + }, + parent_evidence_id: source.evidence_id, + source_selection: summary.normalized_result.summary.source_selection, + source_coverage: summary.normalized_result.summary.coverage, + where, + preparation_ms: preparationMs, + samples, + token_costs: tokenCosts, + cli_parity: cliParity, + conditions: + "First query is after shared artifact analysis; subsequent queries share one MCP session. Legacy item reads are serial; a concurrent legacy baseline is unmeasured. Filesystem/build warmness uncontrolled. Token costs count one post-change catalog, preparation request/summary and each request/structured reply once; client rendering, replay, reasoning, final answer and model latency are unmeasured. Preparation and parity/tokenization overhead are outside per-query elapsed times; external family-resource receipts include the complete verifier and children.", + })}\n`, + ); +} catch (cause) { + primaryError = cause; + throw cause; +} finally { + await closeVerifierResources( + [ + () => client.close(), + () => transport.close(), + async () => { + if (temporary === undefined) return; + if (retainTemporary) + throw new Error( + `Command cleanup is incomplete; retaining its private input/Evidence files at ${temporary.path}.`, + ); + await temporary.close(); + }, + ], + primaryError, + ); +} diff --git a/src/contracts/analysisViewToolContracts.ts b/src/contracts/analysisViewToolContracts.ts index 035e291cf1..a7e2bb18e4 100644 --- a/src/contracts/analysisViewToolContracts.ts +++ b/src/contracts/analysisViewToolContracts.ts @@ -16,7 +16,7 @@ export const ANALYSIS_VIEW_TOOL_CONTRACTS = [ ...toolContractMetadata("inspect_analysis_view"), kind: "application", description: - 'Inspect selected views of completed binary-layout, JavaScript application, native analyze_function, inspect_artifact, inventory_artifact, inspect_wasm_artifact, inspect_macho, decode_interface_builder, inspect_evm_interface, inspect_managed_artifact, inspect_managed_members, inspect_android_package, inspect_dart_aot or inspect_web_network_capture Evidence without repeating analysis. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. For a retained record use source: {kind: "retained-evidence", evidence_id: ""}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: }. For layout, JavaScript, inventory, WASM, Mach-O, Interface Builder, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions; occurrence logical_paths matches exact recorded strings (omitted means all available, [] means none). source_complete distinguishes complete inventories from partial source records; page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. Mach-O views use kind macho and facet entrypoints/architectures/build_metadata/load_commands/dependencies/imports/exports/segments/provenance; source_exhaustive false means unreturned rows may exist. Interface Builder views use kind interface-builder and collection documents/nodes/edges/coverage; nodes and edges accept an exact document relative_path. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Managed artifact summaries preserve complete identity/classification, metadata status, module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed pages use kind managed and collection references/resources/attributes, retaining source coverage and build-local token scope; page exhaustion does not establish complete metadata. Managed member summaries keep identity, selection with unmatched names, coverage and per-collection counts; managed-members pages use collection types/exported_types/fields/methods/member_refs/call_edges/field_accesses/user_strings and carry the source selection and coverage. Android package summaries retain all declarations, engine identity and counts without decoded manifest text. Android manifest pages use kind android, facet manifest, offset and limit in UTF-16 code units; source_status preserves partial decoded coverage even when the retained text page is exhausted. Dart AOT summary preserves target/ABI/library identity, snapshot headers and pool lower bounds. String pages use kind dart-aot, collection package_uris/dart_uris/class_like_tokens; source coverage remains partial when the producer says partial. Native function Evidence requires kind native: view: {kind: "native", facet: "procedure"}. Facets select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native API and high-pcode. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.', + 'Inspect selected views of completed binary-layout, JavaScript application, native analyze_function, inspect_artifact, inventory_artifact, inspect_wasm_artifact, inspect_macho, decode_interface_builder, inspect_evm_interface, inspect_managed_artifact, inspect_managed_members, inspect_android_package, inspect_dart_aot or inspect_web_network_capture Evidence without repeating analysis. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. For a retained record use source: {kind: "retained-evidence", evidence_id: ""}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: }. For layout, JavaScript, inventory, WASM, Mach-O, Interface Builder, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Typed module references use view {kind: "javascript", collection: "modules", projection: "references", where: {path: "main.js", kind: "javascript-module"}, offset: 0, limit: 64}; where matches exact retained strings (null selects unavailable paths). Rows include canonical identity, retained source-range count and observation support states. Source coverage and selection remain explicit; exhausting matching rows does not prove runtime loading or complete analysis. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions; occurrence logical_paths matches exact recorded strings (omitted means all available, [] means none). source_complete distinguishes complete inventories from partial source records; page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. Mach-O views use kind macho and facet entrypoints/architectures/build_metadata/load_commands/dependencies/imports/exports/segments/provenance; source_exhaustive false means unreturned rows may exist. Interface Builder views use kind interface-builder and collection documents/nodes/edges/coverage; nodes and edges accept an exact document relative_path. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Managed artifact summaries preserve complete identity/classification, metadata status, module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed pages use kind managed and collection references/resources/attributes, retaining source coverage and build-local token scope; page exhaustion does not establish complete metadata. Managed member summaries keep identity, selection with unmatched names, coverage and per-collection counts; managed-members pages use collection types/exported_types/fields/methods/member_refs/call_edges/field_accesses/user_strings and carry the source selection and coverage. Android package summaries retain all declarations, engine identity and counts without decoded manifest text. Android manifest pages use kind android, facet manifest, offset and limit in UTF-16 code units; source_status preserves partial decoded coverage even when the retained text page is exhausted. Dart AOT summary preserves target/ABI/library identity, snapshot headers and pool lower bounds. String pages use kind dart-aot, collection package_uris/dart_uris/class_like_tokens; source coverage remains partial when the producer says partial. Native function Evidence requires kind native: view: {kind: "native", facet: "procedure"}. Facets select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native API and high-pcode. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.', inputSchema: inspectAnalysisViewInputSchema, outputSchema: evidenceResultOf(analysisViewResultSchema), examples: [ @@ -94,6 +94,24 @@ export const ANALYSIS_VIEW_TOOL_CONTRACTS = [ }, }, }, + { + title: + "Read typed module reference facts from retained application Evidence", + input: { + source: { + kind: "retained-evidence", + evidence_id: RETAINED_EXAMPLE_ID, + }, + view: { + kind: "javascript", + collection: "modules", + projection: "references", + where: { path: "main.js", kind: "javascript-module" }, + offset: 0, + limit: 64, + }, + }, + }, ], }, ] as const satisfies readonly ToolContract[]; diff --git a/src/domain/analysisView/analysisView.test.ts b/src/domain/analysisView/analysisView.test.ts index 7d456cbbce..87d1930f19 100644 --- a/src/domain/analysisView/analysisView.test.ts +++ b/src/domain/analysisView/analysisView.test.ts @@ -481,6 +481,240 @@ it("projects JavaScript summary and preserves selected module observations", () }); }); +it("projects typed module facts without source bodies and keeps source coverage independent of paging", () => { + const analysis = analysisViewJavaScriptAnalysisWithSource(); + const parent = javascriptParent(analysis); + const input = inspectAnalysisViewInputSchema.parse({ + source: { kind: "retained-evidence", evidence_id: parent.evidenceId }, + view: { + kind: "javascript", + collection: "modules", + projection: "references", + where: { path: "renderer.js" }, + limit: 1, + }, + }); + const projected = projectAnalysisView(parent, input.view); + if (!projected.ok) throw projected.error; + if (projected.value.kind !== "javascript") + throw new Error("expected JavaScript view"); + const node = analysis.graph.nodes[0]; + if (!node) throw new Error("missing module"); + expect(projected.value.rows).toEqual([ + { + node_id: node.node_id, + kind: node.kind, + path: "renderer.js", + identity: node.identity, + retained_source_range_count: 0, + basis: [ + { authority: "artifact-bytes", state: "observed", confidence: "exact" }, + ], + }, + ]); + expect(projected.value.coverage).toMatchObject({ total: 1, exhausted: true }); + expect(projected.value.source_coverage).toMatchObject({ + application: analysis.graph.coverage, + semantic: { status: analysis.semantic_graph.coverage.status }, + }); + expect(JSON.stringify(projected.value.rows)).not.toMatch( + /secret|should not leak|observations/, + ); + const row = projected.value.rows[0]; + if (!row) throw new Error("missing reference"); + const retainedIdentity = structuredClone(node.identity); + if (row.identity.strategy !== "content-digest") + throw new Error("expected digest identity"); + row.identity.sha256 = "e".repeat(64); + expect(node.identity).toEqual(retainedIdentity); + const empty = projectAnalysisView(parent, { + ...input.view, + kind: "javascript", + collection: "modules", + projection: "references", + where: { kind: "javascript-module" }, + offset: 0, + limit: 1, + }); + if (!empty.ok) throw empty.error; + expect(empty.value).toMatchObject({ + rows: [], + coverage: { total: 0, exhausted: true }, + }); +}); + +it("counts retained range observations including repeats and skips unavailable locations", () => { + const analysis = analysisViewJavaScriptAnalysis(); + const original = analysis.graph.nodes[0]; + const observed = original?.observations[0]; + if (!original || !observed) throw new Error("missing module observation"); + const range = { + kind: "source-range", + source: "renderer.js", + start: { line: 1, column: 0 }, + end: { line: 1, column: 8 }, + }; + const node = createJavaScriptApplicationNode({ + kind: original.kind, + identity: original.identity, + observations: [ + ...["first retained observation", "second retained observation"].map( + (label) => ({ + label, + properties: {}, + evidence: { + ...observed.evidence, + location: { available: true, value: range }, + }, + }), + ), + { + label: "unavailable location", + properties: {}, + evidence: { + ...observed.evidence, + authority: "unknown", + state: "unavailable", + confidence: "unknown", + location: { + available: false, + reason: "not-observed", + detail: "Location was not recorded.", + }, + limitations: ["Location was not recorded."], + }, + }, + ], + }); + const { graph_id: _graphId, ...application } = analysis.graph; + const graph = createJavaScriptApplicationGraph({ + ...application, + nodes: [node], + root_node_ids: [node.node_id], + }); + const parent = javascriptParent( + analysisViewBindJavaScriptGraphs(analysis, graph), + ); + const references = projectAnalysisView(parent, { + kind: "javascript", + collection: "modules", + projection: "references", + where: {}, + offset: 0, + limit: 1, + }); + const item = projectAnalysisView(parent, { + kind: "item", + collection: "modules", + selector: { node_id: node.node_id }, + }); + if (!references.ok) throw references.error; + if (!item.ok) throw item.error; + expect(references.value).toMatchObject({ + rows: [{ node_id: node.node_id, retained_source_range_count: 2 }], + }); + expect(item.value).toMatchObject({ item: { source_ranges: [range, range] } }); +}); + +it("validates typed module selectors without coercing null or empty paths", () => { + const source = { + kind: "retained-evidence", + evidence_id: javascriptParent().evidenceId, + }; + const view = { + kind: "javascript", + collection: "modules", + projection: "references", + }; + for (const path of [null, "", "renderer.js"]) + expect( + inspectAnalysisViewInputSchema.safeParse({ + source, + view: { ...view, where: { path } }, + }).success, + ).toBe(true); + for (const where of [ + { path: 1 }, + { kind: "electron-main" }, + { glob: "*.js" }, + ]) + expect( + inspectAnalysisViewInputSchema.safeParse({ + source, + view: { ...view, where }, + }).success, + ).toBe(false); + expect( + projectAnalysisView(layoutParent(), { + ...view, + kind: "javascript", + collection: "modules", + projection: "references", + where: {}, + offset: 0, + limit: 1, + }).ok, + ).toBe(false); +}); + +it("keeps mixed observation support distinct instead of promoting a module to observed", () => { + const analysis = analysisViewJavaScriptAnalysis(); + const original = analysis.graph.nodes[0]; + const observed = original?.observations[0]; + if (!original || !observed) throw new Error("missing module observation"); + const inferred = { + ...observed.evidence, + authority: "static-relationship-inference" as const, + state: "inferred" as const, + confidence: "low" as const, + limitations: ["Static candidate; runtime loading is unknown."], + }; + const node = createJavaScriptApplicationNode({ + kind: original.kind, + identity: original.identity, + observations: [ + { + label: observed.label, + properties: observed.properties, + evidence: observed.evidence, + }, + { label: "candidate one", properties: {}, evidence: inferred }, + { label: "candidate two", properties: {}, evidence: inferred }, + ], + }); + const { graph_id: _graphId, ...application } = analysis.graph; + const graph = createJavaScriptApplicationGraph({ + ...application, + nodes: [node], + root_node_ids: [node.node_id], + }); + const result = projectAnalysisView( + javascriptParent(analysisViewBindJavaScriptGraphs(analysis, graph)), + { + kind: "javascript", + collection: "modules", + projection: "references", + where: {}, + offset: 0, + limit: 1, + }, + ); + if (!result.ok) throw result.error; + if (result.value.kind !== "javascript") + throw new Error("expected references"); + expect(result.value.rows[0]?.basis).toHaveLength(2); + expect(result.value.rows[0]?.basis).toEqual( + expect.arrayContaining([ + { authority: "artifact-bytes", state: "observed", confidence: "exact" }, + { + authority: "static-relationship-inference", + state: "inferred", + confidence: "low", + }, + ]), + ); +}); + it("rejects ambiguous JavaScript module paths with candidate node ids", () => { const analysis = analysisViewJavaScriptAnalysis(); const first = analysis.graph.nodes[0]; @@ -535,6 +769,28 @@ it("rejects ambiguous JavaScript module paths with candidate node ids", () => { }, ], }); + const view = { + kind: "javascript", + collection: "modules", + projection: "references", + where: { path: "renderer.js" }, + offset: 0, + limit: 1, + } as const; + const ids: string[] = []; + for (const offset of [0, 1, 2, Number.MAX_SAFE_INTEGER]) { + const page = projectAnalysisView(parent, { ...view, offset }); + if (!page.ok) throw page.error; + if (page.value.kind !== "javascript") + throw new Error("expected references"); + ids.push(...page.value.rows.map(({ node_id }) => node_id)); + expect(page.value.coverage).toMatchObject({ + total: 2, + next_offset: offset === 0 ? 1 : null, + exhausted: offset !== 0, + }); + } + expect(ids).toEqual(graph.nodes.map(({ node_id }) => node_id)); }); it("pages actual modules, including unknown and empty paths, without unrelated roles", () => { @@ -644,6 +900,24 @@ it("pages actual modules, including unknown and empty paths, without unrelated r collection: "modules", selector: { path: "" }, }); + for (const [path, kind] of [ + [null, "javascript-module"], + ["", "source-module"], + ] as const) { + const references = projectAnalysisView(parent, { + kind: "javascript", + collection: "modules", + projection: "references", + where: { path }, + offset: 0, + limit: 1, + }); + if (!references.ok) throw references.error; + expect(references.value).toMatchObject({ + rows: [{ path, kind }], + coverage: { total: 1, exhausted: true }, + }); + } expect(emptyPath.ok).toBe(true); const role = nodes.find((node) => node.kind === "electron-renderer"); if (role === undefined) throw new Error("missing role"); @@ -875,6 +1149,22 @@ it.each(["partial", "unknown"] as const)( }); for (const view of [ { kind: "page", collection: "modules", offset: 0, limit: 10 }, + { + kind: "javascript", + collection: "modules", + projection: "references", + where: {}, + offset: 0, + limit: 10, + }, + { + kind: "javascript", + collection: "modules", + projection: "references", + where: { path: "not-retained.js" }, + offset: 0, + limit: 10, + }, { kind: "item", collection: "modules", @@ -884,6 +1174,12 @@ it.each(["partial", "unknown"] as const)( const result = projectAnalysisView(parent, view); if (!result.ok) throw result.error; expect(result.value.coverage.exhausted).toBe(true); + if (result.value.kind === "javascript") + expect(result.value.source_coverage).toMatchObject({ + application: { status, omitted_count: null }, + semantic: { status: "unknown" }, + application_unknown_count: 1, + }); expect(result.value.unknowns).toEqual([ "1 application graph unknowns remain in the parent Evidence.", ...messages.slice(0, 65), diff --git a/src/domain/analysisView/analysisView.ts b/src/domain/analysisView/analysisView.ts index ce56b38296..bdac388063 100644 --- a/src/domain/analysisView/analysisView.ts +++ b/src/domain/analysisView/analysisView.ts @@ -1,4 +1,9 @@ import { z } from "zod"; +import { + javascriptModuleReferencesRequestSchema, + javascriptModuleReferenceSchema, + javascriptModuleSourceCoverageSchema, +} from "./javascriptModuleReferences.js"; import { javaScriptSourceSelectionSchema } from "../javascript/javascriptSourceSelection.js"; import { @@ -216,6 +221,7 @@ export const analysisViewRequestSchema = z.discriminatedUnion("kind", [ facetViewSchema, itemViewSchema, pageViewSchema, + javascriptModuleReferencesRequestSchema, nativeViewSchema, inventoryViewSchema, wasmViewRequestSchema, @@ -379,6 +385,16 @@ export const javascriptApplicationSummaryViewResultSchema = z.strictObject({ parent_operation: z.literal("analyze_javascript_application"), }); +/** Typed reference page; full item observations remain available separately. */ +export const javascriptModuleReferencesViewResultSchema = z.strictObject({ + ...viewResultBase, + kind: z.literal("javascript"), + view: javascriptModuleReferencesRequestSchema, + parent_operation: z.literal("analyze_javascript_application"), + rows: z.array(javascriptModuleReferenceSchema), + source_coverage: javascriptModuleSourceCoverageSchema, +}); + /** Exact summary carrier for artifact inspection's optional delivery contract. */ export const artifactInventorySummaryViewResultSchema = z.strictObject({ ...summaryViewResultBase, @@ -527,6 +543,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [ items: z.array(jsonValueSchema), ...viewResultBase, }), + javascriptModuleReferencesViewResultSchema, z.strictObject({ kind: z.literal("native"), view: nativeViewSchema, @@ -647,6 +664,10 @@ export type UnsignedAnalysisView = | Omit, "view_digest"> | Omit, "view_digest"> | Omit, "view_digest"> + | Omit< + Extract, + "view_digest" + > | Omit< Extract, "view_digest" diff --git a/src/domain/analysisView/javascriptApplicationView.ts b/src/domain/analysisView/javascriptApplicationView.ts index 7af6645c90..f83db3c826 100644 --- a/src/domain/analysisView/javascriptApplicationView.ts +++ b/src/domain/analysisView/javascriptApplicationView.ts @@ -7,6 +7,7 @@ import { jsonObjectSchema, type JsonValue } from "../jsonValue.js"; import type { JavaScriptApplicationAnalysisResult } from "../javascript/javascriptApplicationAnalysis.js"; import type { ApplicationNode } from "../javascript/javascriptApplicationGraphSchemas.js"; import { err, ok, type Result } from "../result.js"; +import type { JavaScriptModuleReference } from "./javascriptModuleReferences.js"; import { boundedViewMessages, completeWithinViewCoverage as completeCoverage, @@ -50,20 +51,52 @@ export const javascriptModulePath = (node: ApplicationNode): string | null => { return null; }; -const moduleEntries = ( - nodes: readonly ApplicationNode[], -): readonly { - readonly node: ApplicationNode; +interface ModuleEntry { + readonly node: ApplicationNode & { + readonly kind: JavaScriptModuleReference["kind"]; + }; readonly path: string | null; -}[] => - nodes - .filter( - (node) => - node.kind === "javascript-asset" || - node.kind === "javascript-module" || - node.kind === "source-module", - ) - .map((node) => ({ node, path: javascriptModulePath(node) })); +} + +const isModuleNode = (node: ApplicationNode): node is ModuleEntry["node"] => + node.kind === "javascript-asset" || + node.kind === "javascript-module" || + node.kind === "source-module"; + +/** One owner for the module kinds and path semantics of every module view. */ +function* moduleEntries( + nodes: readonly ApplicationNode[], +): Generator { + for (const node of nodes) { + if (isModuleNode(node)) yield { node, path: javascriptModulePath(node) }; + } +} + +/** Scan retained order once; materialize only the requested matching page. */ +const modulePage = ( + nodes: readonly ApplicationNode[], + view: { + readonly offset: number; + readonly limit: number; + readonly where?: Extract< + AnalysisViewRequest, + { kind: "javascript" } + >["where"]; + }, +): { entries: ModuleEntry[]; total: number } => { + const entries: ModuleEntry[] = []; + let total = 0; + for (const entry of moduleEntries(nodes)) { + if (view.where?.kind !== undefined && entry.node.kind !== view.where.kind) + continue; + if (view.where?.path !== undefined && entry.path !== view.where.path) + continue; + if (total >= view.offset && entries.length < view.limit) + entries.push(entry); + total += 1; + } + return { entries, total }; +}; const exportNames = (node: ApplicationNode): readonly string[] => { const names = new Set(); @@ -128,6 +161,35 @@ const moduleItem = (node: ApplicationNode): JsonValue => ({ })), }); +const moduleReference = ({ + node, + path, +}: ModuleEntry): JavaScriptModuleReference => { + let ranges = 0; + const support = new Map(); + for (const { evidence } of node.observations) { + if ( + evidence.location.available && + evidence.location.value.kind === "source-range" + ) + ranges += 1; + const { authority, state, confidence } = evidence; + support.set(`${authority}:${state}:${confidence}`, { + authority, + state, + confidence, + }); + } + return { + node_id: node.node_id, + kind: node.kind, + path, + identity: node.identity, + retained_source_range_count: ranges, + basis: [...support.values()], + }; +}; + const applicationUnknownCount = ( analysis: JavaScriptApplicationAnalysisResult, ): number => @@ -190,7 +252,7 @@ const parentFields = ( }; const selectModule = ( - modules: ReturnType, + modules: readonly ModuleEntry[], selector: Extract["selector"], ): Result => { if ("node_id" in selector) { @@ -249,15 +311,45 @@ export const projectJavaScriptApplicationView = ( analysis: JavaScriptApplicationAnalysisResult, view: Exclude, ): Result => { - if (view.kind !== "summary" && view.kind !== "item" && view.kind !== "page") + if ( + view.kind !== "summary" && + view.kind !== "item" && + view.kind !== "page" && + view.kind !== "javascript" + ) return err( jsIncompatible( - "JavaScript application Evidence supports summary, module item or module page views.", + "JavaScript application Evidence supports summary, module item, module page or typed JavaScript reference views.", ), ); const shared = parentFields(parent, analysis); - const modules = moduleEntries(analysis.graph.nodes); - if (view.kind === "summary") + if (view.kind === "javascript") { + const { entries, total } = modulePage(analysis.graph.nodes, view); + const rows = entries.map(moduleReference); + const { families: _families, ...semantic } = + analysis.semantic_graph.coverage; + return ok({ + ...shared, + kind: "javascript", + view, + rows, + source_coverage: { + ...(analysis.source_selection === undefined + ? {} + : { source_selection: analysis.source_selection }), + application: analysis.graph.coverage, + semantic, + application_unknown_count: applicationUnknownCount(analysis), + semantic_unknown_count: analysis.semantic_graph.unknowns.length, + }, + coverage: pageCoverage(view.offset, rows.length, total), + }); + } + if (view.kind === "summary") { + const moduleCount = analysis.graph.nodes.reduce( + (count, node) => count + Number(isModuleNode(node)), + 0, + ); return ok({ kind: "summary", view, @@ -288,9 +380,10 @@ export const projectJavaScriptApplicationView = ( }), limitation_count: analysis.limitations.length, }, - coverage: completeCoverage(modules.length, analysis.graph.nodes.length), + coverage: completeCoverage(moduleCount, analysis.graph.nodes.length), ...shared, }); + } if (view.kind === "item") { if (view.collection !== "modules") return err( @@ -298,6 +391,7 @@ export const projectJavaScriptApplicationView = ( "Section and symbol collections apply to inspect_binary_layout Evidence.", ), ); + const modules = [...moduleEntries(analysis.graph.nodes)]; const selected = selectModule(modules, view.selector); if (!selected.ok) return selected; return ok({ @@ -314,18 +408,17 @@ export const projectJavaScriptApplicationView = ( "Section and symbol collections apply to inspect_binary_layout Evidence.", ), ); - const items = modules - .slice(view.offset, view.offset + view.limit) - .map(({ node, path }) => ({ - node_id: node.node_id, - kind: node.kind, - path, - })); + const { entries, total } = modulePage(analysis.graph.nodes, view); + const items = entries.map(({ node, path }) => ({ + node_id: node.node_id, + kind: node.kind, + path, + })); return ok({ kind: "page", view, items, - coverage: pageCoverage(view.offset, items.length, modules.length), + coverage: pageCoverage(view.offset, items.length, total), ...shared, }); }; diff --git a/src/domain/analysisView/javascriptModuleReferences.ts b/src/domain/analysisView/javascriptModuleReferences.ts new file mode 100644 index 0000000000..48a8305418 --- /dev/null +++ b/src/domain/analysisView/javascriptModuleReferences.ts @@ -0,0 +1,61 @@ +import { z } from "zod"; +import { prefixedDigestSchema } from "../digests.js"; +import { + applicationCoverageSchema, + applicationGraphEvidenceSchema, + applicationNodeIdentitySchema, +} from "../javascript/javascriptApplicationEvidenceSchemas.js"; +import { javaScriptSemanticGraphInputSchema } from "../javascript/javascriptSemanticGraphSchemas.js"; +import { javaScriptSourceSelectionSchema } from "../javascript/javascriptSourceSelection.js"; + +const moduleKindSchema = z.enum([ + "javascript-asset", + "javascript-module", + "source-module", +]); + +/** Exact filters over retained owner facts; paths are not filesystem lookups. */ +export const javascriptModuleReferencesRequestSchema = z.strictObject({ + kind: z.literal("javascript"), + collection: z.literal("modules"), + projection: z.literal("references"), + where: z + .strictObject({ + path: z.string().nullable().optional(), + kind: moduleKindSchema.optional(), + }) + .default({}), + offset: z.number().int().nonnegative().default(0), + limit: z.number().int().positive().default(64), +}); + +/** Unique support states, without promoting mixed observations to one fact. */ +const basisSchema = z.strictObject({ + authority: applicationGraphEvidenceSchema.shape.authority, + state: applicationGraphEvidenceSchema.shape.state, + confidence: applicationGraphEvidenceSchema.shape.confidence, +}); + +/** Reusable facts rather than task-specific dependency interpretations. */ +export const javascriptModuleReferenceSchema = z.strictObject({ + node_id: prefixedDigestSchema("jag_node"), + kind: moduleKindSchema, + path: z.string().nullable(), + identity: applicationNodeIdentitySchema, + retained_source_range_count: z.number().int().nonnegative(), + basis: z.array(basisSchema).min(1), +}); + +export const javascriptModuleSourceCoverageSchema = z.strictObject({ + source_selection: javaScriptSourceSelectionSchema.exactOptional(), + application: applicationCoverageSchema, + semantic: javaScriptSemanticGraphInputSchema.shape.coverage.omit({ + families: true, + }), + application_unknown_count: z.number().int().nonnegative(), + semantic_unknown_count: z.number().int().nonnegative(), +}); + +export type JavaScriptModuleReference = z.infer< + typeof javascriptModuleReferenceSchema +>; diff --git a/tests/boundary/cli/analysisViewCli.test.ts b/tests/boundary/cli/analysisViewCli.test.ts index d4b1f32224..f9402ad47e 100644 --- a/tests/boundary/cli/analysisViewCli.test.ts +++ b/tests/boundary/cli/analysisViewCli.test.ts @@ -1,4 +1,5 @@ import { apiSurfaceHar } from "../../fixtures/historicalHar.js"; +import { inspectAnalysisView } from "../../../src/application/analysisView/AnalysisViewService.js"; import { createHash } from "node:crypto"; import { mkdir, unlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; @@ -14,12 +15,47 @@ import { analysisViewCaptureFixture, analysisViewCaptureEvidence, analysisViewObjectDenseCapture, + analysisViewJavaScriptEvidence, + analysisViewJavaScriptAnalysisWithSource, } from "../../fixtures/analysisView.js"; import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js"; import { ghidraFunctionDossier } from "../../../src/domain/ghidraValues.fixture.js"; import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js"; import { inspectEvidenceBundle } from "../../../src/application/investigation/InspectEvidenceBundle.js"; +cliTest( + "reads typed module references through the public CLI with canonical Evidence", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-module-reference-cli-"); + const request = join(root, "request.json"); + const parent = analysisViewJavaScriptEvidence( + analysisViewJavaScriptAnalysisWithSource(), + ); + const input = { + source: { kind: "inline", evidence: parent }, + view: { + kind: "javascript", + collection: "modules", + projection: "references", + where: { path: "renderer.js" }, + limit: 1, + }, + }; + const expected = inspectAnalysisView(input); + if (!expected.ok) throw expected.error; + await writeFile(request, JSON.stringify(input)); + const response = await cli.run({ + arguments: ["inspect-analysis-view", request, "--json"], + }); + expect(response.exitCode).toBe(0); + const evidence = parseEvidence(response.json); + expect(evidence.normalized_result).toEqual( + expected.value.normalized_result, + ); + expect(evidence.evidence_id).toBe(expected.value.evidence_id); + expect(evidence.evidence_links).toEqual([parent.evidence_id]); + }, +); cliTest( "projects opted-in query names through compiled CLI without query values", async ({ cli }) => { diff --git a/tests/boundary/mcp/analysisViewMcp.test.ts b/tests/boundary/mcp/analysisViewMcp.test.ts index 74815bc610..3fbe191bd9 100644 --- a/tests/boundary/mcp/analysisViewMcp.test.ts +++ b/tests/boundary/mcp/analysisViewMcp.test.ts @@ -70,6 +70,66 @@ const connect = async ( return { client, session }; }; +it("advertises typed module references and preserves portable/retained Evidence parity", async () => { + const { client, session } = await connect(); + const parent = analysisViewJavaScriptEvidence( + analysisViewJavaScriptAnalysisWithSource(), + ); + const recorded = session.recordEvidence(parent); + if (!recorded.ok) throw recorded.error; + const { ajv, inputSchema, outputSchema } = await advertisedMcpToolSchemas( + client, + "inspect_analysis_view", + ); + const view = { + kind: "javascript", + collection: "modules", + projection: "references", + where: { path: "renderer.js" }, + limit: 1, + }; + const outputs = []; + for (const source of [ + { kind: "inline", evidence: parent }, + { kind: "retained-evidence", evidence_id: parent.evidence_id }, + ]) { + expect(ajv.validate(inputSchema, { source, view })).toBe(true); + expect( + ajv.validate(inputSchema, { + source, + view: { ...view, where: { glob: "*" } }, + }), + ).toBe(false); + const response = await client.callTool({ + name: "inspect_analysis_view", + arguments: { source, view }, + }); + expect(response.isError).not.toBe(true); + expect( + ajv.validate(outputSchema, response.structuredContent), + JSON.stringify(ajv.errors), + ).toBe(true); + const evidence = parseEvidence(response.structuredContent); + expect(evidence).toMatchObject({ + evidence_links: [parent.evidence_id], + normalized_result: { + kind: "javascript", + rows: [ + { + path: "renderer.js", + identity: expect.any(Object), + basis: expect.any(Array), + }, + ], + coverage: { exhausted: true }, + }, + }); + expect(session.evidenceById(evidence.evidence_id)).toEqual(evidence); + outputs.push(evidence.normalized_result); + } + expect(outputs[0]).toEqual(outputs[1]); +}); + it("projects opted-in query names identically from retained and portable MCP Evidence", async () => { const { client, session } = await connect(); const capture = analysisViewCaptureFixture();