feat(javascript): retain queryable static route inventories (#6019)

This commit is contained in:
gensokyo-dev
2026-10-22 16:54:14 +08:00
committed by GitHub
parent 933af60eba
commit 7d0efef62d
67 changed files with 74345 additions and 231 deletions
@@ -59,6 +59,20 @@ in the same session. A returned `error.details.partial_observation` reference
can be supplied directly to `inspect_analysis_view`. If the SDK cancels locally, retrieve its recorded ID from
the Evidence bundle before inspecting the partial analysis.
## Inventory route candidates without returning the application graph
When the advertised schema supports `view`, use `detail: "summary"` with
`view: {kind: "javascript-routes", collection: "routes", fields: ["path", "confidence"], limit: 20}`.
The same view works with `analyze_web_bundle` on already selected captured sources.
The complete source Evidence is retained; use its `parent_evidence_id` with
`inspect_analysis_view` for `summary`, `routes`, `occurrences`, `sources`, or
`unknowns`. Filter and batch IDs before requesting more fields, and follow
`coverage.next_offset`. CLI selections require `--evidence_output` to keep the
complete record. Static patterns, inferred deployment bases, heuristic system
classification, and unavailable sources never establish route reachability or
the complete application route denominator. See the
[route inventory guide](https://github.com/morluto/rea/blob/main/docs/javascript-route-inventory.md).
## Read a module without repeating analysis
Use the actual returned parent ID, on the same connection, as `source.evidence_id`.
+7
View File
@@ -22,6 +22,13 @@ Inspect native SAZ or HAR 1.2 offline with REA, or connect Everywhere's separate
- `compare_web_captures` / `rea compare-web-captures` compares passive captures or exact-step-aligned browser scenarios. Scenario results commit reproducible literal normalization and expose alignment failures plus artifact-level action, screenshot, DOM, accessibility, URL, history, storage, and event differences. Missing or truncated evidence is never treated as equivalence.
For a compact route query, select `detail: "summary"` and a
`view: {kind: "javascript-routes", collection: "routes", limit: 20}`. The
shared [route inventory](javascript-route-inventory.md) preserves source IDs,
precise locations, partial coverage, and the complete parent Evidence for
subsequent filtered pages. Static candidates never establish complete application
coverage or reachability.
For `analyze_web_bundle`, each `webmcp_declarations` entry's
`schema_property_names` is a source-derived projection of statically known own
property keys, not schema validation. `schema_property_names_coverage` reports
+102
View File
@@ -0,0 +1,102 @@
# Static JavaScript route inventory
`analyze_javascript_application` and `analyze_web_bundle` retain a shared
`route_inventory` alongside their existing graph and endpoint observations.
The inventory answers which route patterns the supplied code suggests, where
each pattern came from, and which parts remain unknown. It never requests a
route, executes scripts, expands a sitemap, or establishes reachability.
Select a compact view in either MCP analysis call:
```json
{
"detail": "summary",
"view": {
"kind": "javascript-routes",
"collection": "routes",
"where": { "path_prefix": "/reports" },
"fields": ["path", "classification", "confidence"],
"limit": 20
}
}
```
Keep the original analysis inputs in that call. For the CLI, pass the same
selection as `--view '<JSON>' --detail summary --evidence_output routes.json`.
The complete Evidence bundle is written without overwriting existing files.
Unselected delivery retains the existing complete result. A web-bundle summary
requires an explicit route view; application summaries also support the existing
general summary when `view` is omitted.
Use the returned `normalized_result.parent_evidence_id` with
`inspect_analysis_view` on the same connection. The CLI's exported bundle can be
reopened with `inspect-evidence-bundle`; its `records` contain the complete
Evidence. To use `inspect_analysis_view`'s `evidence-file` source, extract the
selected parent record into a separate local Evidence JSON file (that source
accepts one Evidence record, not a bundle). Views do not reopen source files or
reconnect to a browser. Historical Evidence without `route_inventory` is
unmeasured, rather than an empty route set.
Collections are `summary`, `routes`, `occurrences`, `sources`, and `unknowns`.
All row collections support exact batch `where.ids`, field selection, `offset`,
and `limit`. An explicit empty filter array matches nothing. Routes also support
exact `paths`, literal `path_prefix`, `classifications`, `mechanisms`, and
`min_confidence`; occurrences support `route_ids`, `source_ids`, and `mechanisms`;
sources support `keys` and `statuses`; unknowns support `source_ids` and `kinds`.
Follow `coverage.next_offset` until `coverage.exhausted`; a page may stop early
at the 1 MiB serialized view budget. Oversized indivisible facts return a
resource error carrying the retained parent Evidence ID.
Pattern IDs bind the exact path. Source IDs bind the source key, path, original
URL, content digest and container digest. Occurrences identify a detector mechanism at a source location; one location
may support multiple mechanisms. `source_count` counts distinct sources. Lookup IDs use 128 bits of SHA-256;
full content and inventory digests remain SHA-256. Field selection always keeps
row identity and necessary provenance. The page's deduplicated `sources` table
contains the source descriptors referenced by returned rows. Retrieve all
occurrences by route ID when independent supporting evidence matters. Source
ranges use one-based lines and zero-based UTF-16 columns with exclusive ends;
filename-only discoveries have no invented AST range.
The shared parser handles literal router calls, nested route objects, typed
`_$` trees with explicitly inferred property-name fallbacks, Next App Router
page paths, and navigation targets. It follows stable lexical bindings and
keeps unresolved parents, mutations, spreads, and dynamic fields explicit.
A selected route-builder root owns its configuration even when mutation prevents
trusting its value. Ordinary wrapper keys do not become route ancestors. Missing
outer ancestors of unbound typed subtrees remain unknown rather than invented.
Generic Next runtime path constants require a top-level application segment
supported by structural or navigation candidates across the supplied sources.
This limits framework filesystem noise, while leaving unsupported candidates
unknown. The parser shares the existing AST traversal and lexical index.
[Next dynamic segments](https://nextjs.org/docs/app/api-reference/file-conventions/dynamic-routes)
map to `:id`, `:slug+`, or `:slug*`; groups and parallel slots do not contribute
URL segments. Intercepted routes remain unresolved. A prefix before `/_next/`
is an **inferred** routing base: [assetPrefix](https://nextjs.org/docs/app/api-reference/config/next-config-js/assetPrefix)
can differ from the routing base. Every affected occurrence keeps
`unprefixed_path`, `inferred_base_path`, and low confidence. `system` classification
is explicitly a path-name heuristic; `page` uses page-file or structured/typed configuration support. Name-only
`route`/`addRoute` calls remain `unknown` because they can also register server APIs.
Known capture omissions remain inline in `summary.reported_capture_omissions`
and queryable as `unknowns` of kind `capture-omission`. Each keeps a stable gap ID,
its script-record/script-source unit, and the reported count (including `null`
for unknown). They do not invent missing script identities or source ranges.
Counts describe deduplicated patterns in the provided code sources. They are
not a denominator for complete application or capture coverage. In particular,
server-only, lazy, inaccessible, or unselected sources may be missing.
`coverage.application_routes` always remains `unknown`; parsing and candidate
enumeration have their own completeness. Static work and fact admission share
the existing 8 MiB per-source transfer ceiling, and the root inventory has an
8 MiB admission budget. Exhaustion preserves a prefix with explicit capacity;
the serialization owner also admits route facts within the actual space left by
legacy facts. `result-bytes` capacity preserves an earlier work-capacity reason;
if even that envelope cannot fit, the optional route facet remains unmeasured.
These are accounting bounds, not a claim that total process memory is 8 MiB.
Projection only reduces delivery, not analysis work. Retained immutable Evidence
can reuse validated tables through a weak cache with the snapshot's lifetime;
subject identity is checked on every query. No live state or source content is
cached as current. Use existing source selection and resource controls when
they match the investigation, keeping their partial coverage visible.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,228 @@
# Static JavaScript route inventory verification
Issue [#4772](https://github.com/morluto/rea/issues/4772). Qualified runtime
`b49ee40bf330b7bf1c5180b14dcc104c1c613a8e`, tree `0e5f8fe2feae1796c340c07c26e0e28e5d4328d8`,
integrates main `c8ed32853881b08aa83459d4da55a28a4e09b0c8`. Recorded at
`2026-10-22T08:30:58.269Z`. Subsequent verification-only
commits change these reports. The [JSON record](javascript-route-inventory-2026-10-21.json)
pins original inputs, exact commands, profiles, failed controls and historical revisions.
Current-head GitHub CI and automated review must be checked separately.
The latest main contracts, selected environment and ASAR admission remain shared
by CLI and MCP. The staged-code hook runs lint fixes before formatting, resolving the
producing order behind the earlier CI format failure.
## Current implementation and executed checks
Shared source analysis produces stable route, occurrence, source and gap facts.
CLI and MCP use the same retained Evidence views, with filtering, selected fields,
batch identities and pagination; complete source Evidence remains accessible.
Coverage and explicit unknowns survive compact delivery and source deletion.
Selected CLI delivery requires a complete Evidence export; MCP retention is
checked before analysis. Immutable snapshots use weak identity caching with
parent identity checks; live target state is not cached.
Route scanning reuses semantic bindings, mutation, literal-field, reference and
truthiness owners. Navigation data ownership excludes query/search/hash values,
callee/projection/predicate edges and definitely inactive branches while keeping
independent lexical routes. Registration patterns preserve optional syntax and
initializer ranges. Unrecognized array/string methods do not become navigation.
Rest-copy exclusions, unresolved projections, mutations and cycles retain their
appropriate independent facts or unknowns. Next page classification shares one
owner, including private folders, encoded public underscores and unresolved
interception context. Source ranges use one-based lines, zero-based UTF-16
columns and exclusive ends.
Source/fact and inventory budgets are 8 MiB; optional facts fit the actual
remaining combined-result budget. Selected pages include the sealing digest
within their 1 MiB bound. Cancellation preserves earlier facts and Evidence;
worker cleanup and capacity failures retain typed outcomes.
The current integration uses main's canonical Evidence publication/cleanup owner
and deferred MCP runtime/catalog. CLI delivery explicitly produces JsonValue and
projects the two publication receipt scalars without cloning complete Evidence.
The canonical web-bundle state and coordinate owners replace duplicate types.
The coordinator parser-import boundary remains covered.
**3847 source checks and 116 compiled public checks passed**,
including 245 scanner regressions, actual full/compact SDK profiles, both JSON
Schema dialects, source selection/deletion, retained reads, cancellation,
recovery, real workers and CLI/browser adapters. The current public selection additionally covers ASAR admission, selected
environment, CLI resource closing and Electron file scope; earlier startup/
publication checks remain pinned to their recorded runtime.
Selected-path identity runs separately with its original 30-second deadline.
Typecheck/lint passed; documentation checks are recorded in JSON after generation.
The main-baseline service-worker deadline correction [#6186](https://github.com/morluto/rea/pull/6186)
is included; it retains the original stalled-handshake assertions and test guard.
A valid review case exposed overwritten query fields hiding independent route
initializers: seven corrected regressions failed before the fix, while182 tests
passed, including four new effective/uncertain-data controls. Ownership now
consults the existing cached effective-field reader. A known final slot or method
excludes discarded references; unknown spreads/computed keys remain conservative.
Full/compact SDK reads after source deletion and both browser adapters retain
the independent route. The public SDK assertion requires the exact expected count
and every expected path. The record preserves failed validation and a corrected
initial test-harness field name; no invalid harness failure is counted as a bug.
A second review case exposed selected member query data becoming page routes.
Six direct/computed/optional, alias, nested and array-member cases failed while 189
controls passed. The existing static member-chain helper now lives in the shared
AST owner; both require consumers use it and its old export is removed. Optional
admission checks charge each path step before retention. Navigation source
selection reuses canonical possible-value sources at each read and one selected-slot
walk shared with copied initializers, without claiming whole receivers or callee
namespaces. Current full/compact retained SDK queries and both browser adapters
exclude the selected query object while preserving its independent sibling.
A previous runtime's public batch hit two original 30-second schema deadlines.
That failed attempt, successful isolated rerun and their combined resource cost
remain pinned in JSON. Current execution runs schema validity alone, 18 other
suites together and selected-path identity alone, retaining original assertions
and deadlines.
Five prototype/own-property cases failed before the latest fix while 198 passed.
The field owner now reuses the existing AST prototype-setter predicate. Setters
retain unresolved inheritance but cannot overwrite computed/shorthand own keys;
setter data references are not discarded as own-key writes. Direct, member and
destructured query selection share this owner. Full/compact historical SDK reads
and both browser adapters exclude the prototype-shaped query payload.
The runtime retains main's integer-schema allocation optimization and integrates
the latest main contracts, shared keyed ordering and retained-parent parser. The
only merge conflict was the analysis-view description: the complete upstream
text plus the exact route-view sentence were retained. Current catalog changes
and fresh client/schema checks are recorded separately in JSON.
A later review found that lifetime identity eligibility retained fields proved
replaced before a query read. Eight cases failed while 206 controls passed. Both
member reads and direct identifiers now use canonical value-flow sources or
binding snapshots for their read site. Unknown order keeps possible sources;
known replacements exclude stale ones. Binding-once deduplication is removed
because separate reads may select different values; syntax-cycle and resource
bounds remain. Both SDK profiles after deletion and both browser adapters preserve
the independent route through member and direct-variable replacements, while
actual replacement payloads and earlier/possible query data remain excluded.
A further review reproduced four failures through conditional aliases, unknown
array members and nested receiver aliases, while 215 controls passed. The shared
mutation producer now distinguishes proved receiver identity from possible
aliasing. Canonical read-time selection requires both identity and ordering
before discarding original values or superseded prefixes. Deferred reference
paths retain this proof and assigned-value provenance; deduplication includes
certainty. Direct writes through the read's own alias still prove replacement.
Both public SDK profiles after deletion and both browser adapters cover a
distinct uncertain-alias payload without claiming the entire receiver.
CI also caught our integration regression in the unrelated Dart ABI advertisement:
lint removed a slash escape, making the pattern invalid in JavaScript `v` mode.
The ABI schema now encodes the slash as a hex code point, consistent with existing
portable patterns and stable under lint fixes. The original advertised-pattern
client test is included in this qualification. The prior inference that escaped
and unescaped character classes were equivalent across clients is withdrawn;
no Dart-provider execution is claimed. The interrupted superseded local run and
failed CI are retained in the JSON record.
Two later review cases reproduced eleven failures while 222 controls passed:
possible query fields cleared by spreads became pages, and selected member
configurations claimed unrelated siblings. The selected-slot walk now uses the
canonical possible object/array reference owners; exact fields still prove
positive destinations. Configuration ownership preserves member and copied
initializer paths through the same walk. Conditional-container selection reuses
existing value-edge/truthiness rules. Normal paths advance offsets; exceptional
path expansion is charged before allocation and node/path visits bound cycles.
This also exposed a canonical computed-key overwrite defect: an independent
semantic-analysis regression failed with unknown coverage before the owner fix
and retains its original literal afterward. Earlier unknown computed values are
excluded by later definite slots; a getter paired with only a later setter stays
possible. Accessor and conditional-container controls pass. Both SDK profiles
after source deletion and both browser adapters exclude a distinct possible-query
payload and preserve the selected configuration's independent sibling with exact
counts. Unsupported projected configurations keep explicit gaps. Actual SDK
tool definitions remain structurally identical to the preceding qualified runtime.
The next review reproduced two growing getter projections exhausting the 8 MiB
route budget and hiding an independent route. An existing direct reader golden
also retained overwritten computed-key alternatives; the earlier selected test
matrix had omitted that producer suite. Three cases failed and 241 controls
passed, including a finite repeated-getter control. The source-order expectations
now match the shared owner's definite overwrite rule.
A subsequent review showed that suffix lengths are insufficient recursion proof:
two finite getter chains were marked cyclic, leaking query data as page routes;
243 controls passed. That inference is withdrawn. Selected-value traversal now
uses value/lookup/finish frames: evaluate each canonical returned-reference path,
clear its unfinished receiver/key, then resume the caller's member path. Scoped
visited maps preserve independent result sinks. The suffix-length heuristic and
suffix concatenation have been removed. Three finite/nested cases pass alongside
genuine direct/mutual recursive getters and finite repeated selections. Both
retained SDK profiles after deletion and both browser adapters exclude distinct
finite-getter query payloads while retaining exact independent routes and actual
cyclic gaps.
Current source qualification covers **all 133 JavaScript domain suites plus eight
related suites**. It ran on the exact staged source tree; commit hooks left that
tree identical, so its source tests were not needlessly repeated after commit.
New build-dependent public and real-artifact workflows ran on the frozen runtime.
## Real production inputs and oracle
Every one of the 67 saved production scripts was rehashed and freshly analyzed
through production isolated workers, with one semantic projection node, 512 MiB
worker old-space and the existing 15-second source deadline. Application sources
were read as data. No source facts were reused for this qualification.
| Homepage capture | Scripts | Bytes | Completed | Route checkpoints | Rooted patterns |
| ---------------- | ------: | --------: | --------: | ----------------: | --------------: |
| nextjs-org | 29 | 1,533,098 | 28 | 28 | 0 |
| shadcn-ui | 38 | 2,465,078 | 36 | 37 | 18 |
The full task cost **207.348 s / 313.817 CPU s / 502,669,312 bytes peak family RSS**. The previous pinned runtime `972c5ad86` full-worker run
took 277.184 s/409.684 CPU s. These are individual observations, not a timing
distribution. Earlier paired source elapsed observations
remain pinned to their measured revisions in JSON. There is no controlled causal ablation or repeated timing
study, so neither the source correction nor the main integration is assigned
a performance cause. Configured deadlines and partial/unavailable
facts are explicit. Zero supported rooted Next patterns does not establish zero
application routes. Lazy/server-only code, reachability and the full application
route denominator remain unknown.
Independent direct-byte inspection of the
[86,218-byte shadcn production chunk](https://ui.shadcn.com/_next/static/immutable/chunks/0ftd4gqztonqp.js)
finds exactly 18 quoted `/docs` patterns, including 12 direct href properties.
SHA-256: `45c6ead1e08cc578464e7a0c6b2e6764d7af83f99039e291f288a971b988b3fc`.
Every fresh SDK and browser verifier requires the entire sorted 18-pattern oracle,
original source identity, provenance and complete retained Evidence.
An owned Chromium 156.0.8078.4 compiled this source via
Runtime.compileScript/persistScript on a blank local page without running it.
Actual CLI and official SDK analyze_web_bundle calls passively captured it via
CDP and returned all 18 patterns, original URL and SHA-256. Source parsing was
complete, enumeration partial and application coverage unknown. Owned process
and temporary-profile cleanup succeeded. Cost: **10.379 s / 13.481 CPU s / 2,091,114,496 bytes peak family RSS**.
## Equivalent SDK task costs
Both fresh sessions list `/docs` patterns with confidence, mechanism and source
provenance, preserve complete Evidence, perform three retained follow-up reads
and export Evidence. They assert the same complete parent Evidence identity.
Measurements include the instrumentation and use full input advertisements.
| Delivery | Used declarations + all calls/replies, tokenx | Entire catalog + all calls/replies, tokenx | Task resources |
| -------- | --------------------------------------------: | -----------------------------------------: | ------------------------------------------------------------- |
| Complete | 1,934,534 | 3,035,567 | 13.785 s / 18.104 CPU s / 1,356,595,200 bytes peak family RSS |
| Selected | 58,902 | 1,159,935 | 12.243 s / 15.909 CPU s / 1,303,572,480 bytes peak family RSS |
Tokenx counts actual advertised schemas, arguments and full SDK replies, including
exports and follow-ups; it estimates tokens rather than actual model usage.
Initialization, JSON-RPC envelopes and separate token-count instrumentation costs
remain unmeasured. Processes/sessions are fresh with warm OS caches; there is no
filesystem-cache flush, repeated statistical timing study or claim of stable
latency/analysis speedup. Default compact-advertisement costs are not measured.
CPU is payload process-family wait-rusage delta and RSS samples all descendant
tasks every 20 ms. Heavy tasks run sequentially on two CPUs with an 8 GiB family
RSS/420-second guard. No qualification guard fired.
The JSON preserves prior failures, source-owner corrections and their exact
before/after revisions. Historical CI/review does not establish the current head.
Private issue samples, complete application routes, live route execution,
external cloud clients and Windows/macOS provider execution remain unverified.
+2 -2
View File
@@ -321,8 +321,8 @@
},
"lint-staged": {
"*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}": [
"oxfmt",
"oxlint --fix"
"oxlint --fix",
"oxfmt"
],
"*.{json,md,yml,yaml}": "oxfmt --no-error-on-unmatched-pattern"
},
@@ -3,6 +3,10 @@ import {
type AsarHeaderAdmission,
} from "../../config/asarHeaderAdmission.js";
import { constants } from "node:fs";
import {
buildJavaScriptRouteInventorySteps,
type JavaScriptRouteInventory,
} from "../../domain/javascript/javascriptRouteInventory.js";
import { access, lstat, realpath } from "node:fs/promises";
import { setImmediate } from "node:timers/promises";
@@ -48,6 +52,7 @@ import {
/** Application-layer result retaining local diagnostics outside the canonical graph. */
export interface JavaScriptArtifactReconstructionResult {
readonly route_inventory?: JavaScriptRouteInventory;
readonly input_path: string;
readonly format: "asar" | "directory";
readonly root_artifact_sha256: string;
@@ -265,6 +270,48 @@ export const reconstructJavaScriptArtifact = async (
await completeJavaScriptAnalysisSteps(semanticGraphSteps);
await checkpointCompletedAnalysis();
const completed = {
route_inventory: await completeJavaScriptAnalysisSteps(
buildJavaScriptRouteInventorySteps([
...analysis.files
.filter(({ file }) => file.kind === "javascript")
.map(({ file, javascript, analysis_failure }) => ({
key: file.path,
path: file.path,
url: null,
sha256: file.sha256,
container_sha256: file.container_sha256,
status:
javascript === null || javascript.parse_status === "failed"
? ("unavailable" as const)
: javascript.parse_error_count > 0
? ("partial" as const)
: ("complete" as const),
reason: !file.text.included
? file.text.reason
: (analysis_failure?.reason ??
(javascript === null
? "Source analysis is unavailable"
: javascript.parse_status === "failed"
? "Script parsing failed"
: javascript.parse_error_count > 0
? "Script parsing recovered from errors"
: null)),
facts: javascript?.routes ?? null,
})),
...files.unavailable_relevant_files
.filter((file) => file.kind === "javascript")
.map((file) => ({
key: file.path,
path: file.path,
url: null,
sha256: file.sha256,
container_sha256: file.container_sha256,
status: "unavailable" as const,
reason: "Inventoried JavaScript source bytes are unavailable",
facts: null,
})),
]),
),
input_path: path,
format,
root_artifact_sha256: snapshot.manifest.root_sha256,
@@ -303,6 +350,7 @@ export const reconstructJavaScriptArtifact = async (
},
limitations: analysis.limitations,
};
await checkpointCompletedAnalysis();
if (interruption !== null && interruption !== undefined) {
const { electron_summary: summary, ...application } = completed;
const partial = await completeJavaScriptAnalysisSteps(
@@ -1,4 +1,5 @@
import { z } from "zod";
import { JAVASCRIPT_STATIC_FACT_BYTES } from "../domain/javascript/javascriptAnalysisResourceControls.js";
import { digestSchema } from "../domain/digests.js";
import { WEB_RUNTIME_LIMITS } from "../domain/webRuntime.js";
import { webPageInspectionSchema } from "../domain/browserObservationSchemas.js";
@@ -16,7 +17,7 @@ export const WEB_BUNDLE_WORKER_LIMITS = {
totalTimeoutMs: DEFAULT_WEB_BUNDLE_TOTAL_TIMEOUT_MS,
protocolBytes: 64 * 1024,
sourceBytes: 8 * WEB_RUNTIME_LIMITS.sourceBytes,
resultBytes: 8 * 1024 * 1024,
resultBytes: JAVASCRIPT_STATIC_FACT_BYTES,
} as const;
const includedSourceSchema = z.strictObject({
@@ -1,3 +1,4 @@
import { analyzeCapturedWebScript } from "../domain/webBundleAnalyzer.js";
import { createHash, randomUUID } from "node:crypto";
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { once } from "node:events";
@@ -216,6 +217,43 @@ it("bounds and binds per-script worker jobs, then reuses one worker for the next
status: "analyzed",
script_key: nextScript.script_key,
});
// The legacy result fits, but the additional route facet does not. Actual
// worker admission must retain the legacy facts and a partial route prefix.
const routesScript = makeScript(
"createBrowserRouter([{path:'/reports',children:[{path:'settings'},{path:'history'}]}]);",
);
const full = analyzeCapturedWebScript(routesScript);
if (full.status !== "analyzed") throw new Error("Expected analysis");
const { route_facts: _routeFacts, ...existing } = full;
const offered =
Buffer.byteLength(JSON.stringify(existing)) +
Buffer.byteLength(',"route_facts":') +
700;
expect(Buffer.byteLength(JSON.stringify(full))).toBeGreaterThan(offered);
const routesId = randomUUID(),
routesInput = sourceDescriptor(routesScript);
await writeSource(root, routesId, routesInput.bytes);
await send(worker, {
kind: "analyze",
id: routesId,
source: routesInput.descriptor,
result_budget_bytes: offered,
});
expect(await server.next()).toMatchObject({ kind: "phase", id: routesId });
const routesComplete = await server.next();
if (routesComplete.kind !== "complete")
throw new Error(JSON.stringify(routesComplete));
const selectedBytes = await readFile(join(root, `${routesId}.result`));
expect(selectedBytes.length).toBeLessThanOrEqual(offered);
const selected = JSON.parse(selectedBytes.toString("utf8"));
expect(selected.observations).toEqual(full.observations);
expect(selected.route_facts.capacity).toMatchObject({
kind: "result-bytes",
limit_bytes: 700,
});
expect(selected.route_facts.candidates.length).toBeGreaterThan(0);
expect(selected.route_facts.candidates.length).toBeLessThan(3);
} finally {
if (worker !== undefined) {
worker.stdin.end();
@@ -1,3 +1,6 @@
import { fitJavaScriptRouteSourceFacts } from "../domain/javascript/javascriptRouteResultBudget.js";
import { jsonValueByteLength } from "../domain/jsonValueByteLength.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { createHash } from "node:crypto";
import { open } from "node:fs/promises";
import { join } from "node:path";
@@ -168,6 +171,21 @@ const analyzeJob = async (
throw new WebBundleWorkerOutputError(
"Web bundle analyzer did not produce a completed per-script result",
);
if (result.route_facts != null) {
const { route_facts, ...existing } = result;
const available =
request.result_budget_bytes -
jsonValueByteLength(
existing as JsonValue,
request.result_budget_bytes,
) -
Buffer.byteLength(',"route_facts":');
const admitted = fitJavaScriptRouteSourceFacts(route_facts, available);
result =
admitted === undefined
? existing
: { ...existing, route_facts: admitted };
}
const resultPath = join(root, `${request.id}.result`);
const file = await open(resultPath, "wx", 0o600);
const hash = createHash("sha256");
+5 -2
View File
@@ -104,7 +104,7 @@ export const deliverCliAnalysisEvidence = async (
export const publishCliAnalysisEvidence = async (
value: JsonValue,
input: CliEvidenceDelivery,
) => {
): Promise<JsonValue> => {
const { operation, signal, detail, evidenceOutput } = input;
if (!isEvidence(value)) return value;
const complete = parseEvidence(value);
@@ -146,7 +146,10 @@ export const publishCliAnalysisEvidence = async (
return detail === "summary"
? {
summary: published.value.delivered,
evidence_bundle: published.value.evidenceBundle,
evidence_bundle: {
path: published.value.evidenceBundle.path,
bytes: published.value.evidenceBundle.bytes,
},
}
: published.value.delivered;
};
+20 -6
View File
@@ -1,3 +1,9 @@
import {
parseJavaScriptRouteDelivery,
javascriptRouteDeliveryOptions,
} from "./javascriptRouteDelivery.js";
import { deliverCliAnalysisEvidence } from "./analysisEvidenceDelivery.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { Cli, z } from "incur";
import { browserContext, browserCliError } from "../cliBrowserContext.js";
@@ -157,6 +163,7 @@ const registerBundleAnalysis = (
}),
options: z.object({
...browserScopeOptions,
...javascriptRouteDeliveryOptions,
observationMs: observationDuration(500),
fetchSourceMaps: z
.boolean()
@@ -172,6 +179,11 @@ const registerBundleAnalysis = (
run: ({ args, options }) =>
withCommandCancellation((signal) =>
logCliCommand(logger, "analyze-web-bundle", async () => {
const delivery = parseJavaScriptRouteDelivery(
"analyze_web_bundle",
options,
);
if (!delivery.ok) return projectAnalysisError(delivery.error);
const context = browserContext();
const input = {
cdp_endpoint: args.endpoint,
@@ -205,17 +217,19 @@ const registerBundleAnalysis = (
input,
),
);
return await finishAnalysisCommand(
() => analyzeWebBundle(context.provider, parsed.data, { signal }),
{
return deliverCliAnalysisEvidence({
...delivery.value,
run: () =>
analyzeWebBundle(context.provider, parsed.data, { signal }),
service: {
close: async () => {
await context.provider.close?.();
},
},
context.provider.identity().id,
"analyze_web_bundle",
providerId: context.provider.identity().id,
operation: "analyze_web_bundle",
signal,
);
});
}),
),
});
+50 -39
View File
@@ -1,3 +1,4 @@
import { parseJavaScriptRouteDelivery } from "./javascriptRouteDelivery.js";
import { isAbsolute, resolve, sep } from "node:path";
import { Cli, z } from "incur";
@@ -312,45 +313,55 @@ const registerJavaScriptApplicationCommand = (
options: javascriptApplicationOptions,
run: ({ args, options }) =>
withCommandCancellation((signal) =>
logCliCommand(logger, CLI_COMMANDS.analyzeJavaScriptApplication, () =>
runCliJavaScriptApplicationAnalysis(
{
// An empty path names no file; leave it for input validation.
input_path:
args.path === "" ? args.path : absoluteCallerPath(args.path),
format: options.artifactFormat,
integrity_policy: options.integrityPolicy,
...(options.maxSemanticNodes === undefined
? {}
: { max_semantic_nodes: options.maxSemanticNodes }),
...(options.maxSemanticNodesPerFile === undefined
? {}
: {
max_semantic_nodes_per_file:
options.maxSemanticNodesPerFile,
}),
...(options.maxSemanticUnknowns === undefined
? {}
: { max_semantic_unknowns: options.maxSemanticUnknowns }),
...(options.maxSemanticUnknownsPerFile === undefined
? {}
: {
max_semantic_unknowns_per_file:
options.maxSemanticUnknownsPerFile,
}),
...(options.sourcePath === undefined
? {}
: { source_paths: options.sourcePath }),
...(options.maxHeapMb === undefined
? {}
: { max_heap_mb: options.maxHeapMb }),
...(options.analysisTimeoutMs === undefined
? {}
: { analysis_timeout_ms: options.analysisTimeoutMs }),
},
environment,
signal,
),
logCliCommand(
logger,
CLI_COMMANDS.analyzeJavaScriptApplication,
async () => {
const delivery = parseJavaScriptRouteDelivery(
"analyze_javascript_application",
options,
);
if (!delivery.ok) return projectAnalysisError(delivery.error);
return runCliJavaScriptApplicationAnalysis(
{
// An empty path names no file; leave it for input validation.
input_path:
args.path === "" ? args.path : absoluteCallerPath(args.path),
format: options.artifactFormat,
integrity_policy: options.integrityPolicy,
...(options.maxSemanticNodes === undefined
? {}
: { max_semantic_nodes: options.maxSemanticNodes }),
...(options.maxSemanticNodesPerFile === undefined
? {}
: {
max_semantic_nodes_per_file:
options.maxSemanticNodesPerFile,
}),
...(options.maxSemanticUnknowns === undefined
? {}
: { max_semantic_unknowns: options.maxSemanticUnknowns }),
...(options.maxSemanticUnknownsPerFile === undefined
? {}
: {
max_semantic_unknowns_per_file:
options.maxSemanticUnknownsPerFile,
}),
...(options.sourcePath === undefined
? {}
: { source_paths: options.sourcePath }),
...(options.maxHeapMb === undefined
? {}
: { max_heap_mb: options.maxHeapMb }),
...(options.analysisTimeoutMs === undefined
? {}
: { analysis_timeout_ms: options.analysisTimeoutMs }),
},
environment,
signal,
delivery.value,
);
},
),
),
});
+16 -1
View File
@@ -1,3 +1,5 @@
import type { AnalysisViewRequest } from "../domain/analysisView/analysisView.js";
import { publishCliAnalysisEvidence } from "./analysisEvidenceDelivery.js";
import { JAVASCRIPT_APPLICATION_PROVIDER } from "../application/InvestigationProviders.js";
import { createProgressReporter } from "../application/ProgressReporter.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
@@ -48,6 +50,11 @@ export const runCliJavaScriptApplicationAnalysis = async (
input: unknown,
environment: Readonly<Record<string, string | undefined>>,
signal?: AbortSignal,
delivery?: {
readonly detail: "complete" | "summary";
readonly evidenceOutput: string | undefined;
readonly view?: AnalysisViewRequest;
},
): Promise<JsonValue> => {
const { analyzeJavaScriptApplication } =
await import("../application/javascript/JavaScriptApplicationService.js");
@@ -87,7 +94,15 @@ export const runCliJavaScriptApplicationAnalysis = async (
),
);
}
return result.ok ? result.value : cliError(result.error);
const value = result.ok ? result.value : cliError(result.error);
return delivery === undefined
? value
: publishCliAnalysisEvidence(value, {
...delivery,
providerId: JAVASCRIPT_APPLICATION_PROVIDER.id,
operation: "analyze_javascript_application",
signal: signal ?? new AbortController().signal,
});
};
const cliError = (
+63
View File
@@ -0,0 +1,63 @@
import { z } from "incur";
import {
javaScriptRoutesDeliverySchema,
webRoutesDeliverySchema,
} from "../domain/analysisView/javascriptRoutesView.js";
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
import { err, ok } from "../domain/result.js";
import {
analysisEvidenceDeliveryOptions,
cliEvidenceDeliveryInputError,
} from "./analysisEvidenceDelivery.js";
export const javascriptRouteDeliveryOptions = {
...analysisEvidenceDeliveryOptions,
view: z
.string()
.optional()
.describe(
"JSON javascript-routes view; requires --detail summary and --evidence_output",
),
};
/** Validate delivery before opening artifacts or attaching a browser. */
export const parseJavaScriptRouteDelivery = (
operation: string,
options: {
detail: "complete" | "summary";
evidence_output?: string | undefined;
view?: string | undefined;
},
) => {
let view: unknown;
try {
view = options.view === undefined ? undefined : JSON.parse(options.view);
} catch (cause) {
return err(
new AnalysisInputError(operation, { cause }, [
{
path: ["view"],
reason: "invalid_format",
message: "view must be valid JSON",
},
]),
);
}
const raw = {
detail: options.detail,
...(view === undefined ? {} : { view }),
};
const schema =
operation === "analyze_web_bundle"
? webRoutesDeliverySchema
: javaScriptRoutesDeliverySchema;
const parsed = schema.safeParse(raw);
if (!parsed.success)
return err(
analysisInputErrorFromIssues(operation, parsed.error.issues, raw),
);
const value = { ...parsed.data, evidenceOutput: options.evidence_output };
const invalid = cliEvidenceDeliveryInputError({ ...value, operation });
return invalid === undefined ? ok(value) : err(invalid);
};
+2
View File
@@ -1,3 +1,4 @@
import { javascriptRouteDeliveryOptions } from "./cli/javascriptRouteDelivery.js";
import { z } from "incur";
import { webRuntimeCommandTimeoutSchema } from "./domain/browserDeadline.js";
import { javaScriptAnalysisResourceControlsSchema } from "./domain/javascript/javascriptAnalysisResourceControls.js";
@@ -72,6 +73,7 @@ export const electronPageInspectionOptions = z.object({
});
export const javascriptApplicationOptions = z.object({
...javascriptRouteDeliveryOptions,
maxSemanticNodes:
javaScriptAnalysisResourceControlsSchema.shape.max_semantic_nodes,
maxSemanticNodesPerFile:
File diff suppressed because one or more lines are too long
+17 -3
View File
@@ -1,3 +1,9 @@
import { z } from "zod";
import {
javaScriptRoutesCompleteDeliveryShape,
webRoutesSummaryDeliveryShape,
} from "../domain/analysisView/javascriptRoutesView.js";
import { javaScriptRoutesViewResultSchema } from "../domain/analysisView/analysisView.js";
import type { ToolContract } from "./toolContractTypes.js";
import {
inspectWebPageInputSchema,
@@ -35,7 +41,15 @@ import { evidenceResultOf } from "./toolOutputSchemaPrimitives.js";
const listOutputSchema = evidenceResultOf(browserTargetListSchema);
const inspectionOutputSchema = evidenceResultOf(webPageInspectionSchema);
const bundleOutputSchema = evidenceResultOf(webBundleAnalysisSchema);
export const analyzeWebBundleRequestSchema = z
.discriminatedUnion("detail", [
analyzeWebBundleInputSchema.extend(javaScriptRoutesCompleteDeliveryShape),
analyzeWebBundleInputSchema.extend(webRoutesSummaryDeliveryShape),
])
.meta({ "x-rea-root-discriminator": "detail" });
const bundleOutputSchema = evidenceResultOf(
z.union([webBundleAnalysisSchema, javaScriptRoutesViewResultSchema]),
);
const observationSessionOutputSchema = evidenceResultOf(
webObservationSessionSchema,
);
@@ -174,9 +188,9 @@ export const BROWSER_TOOL_CONTRACTS = [
name: "analyze_web_bundle",
...toolContractMetadata("analyze_web_bundle"),
description:
"Capture JavaScript source from one selected CDP page and statically derive a chunk graph, route and endpoint candidates, vendor fingerprints, page-declared WebMCP metadata, and optionally fetch source maps from the selected target origin or explicitly requested origins. JavaScript is parsed but never executed.",
"Capture JavaScript source from one selected CDP page and statically derive a chunk graph, route and endpoint candidates, vendor fingerprints, page-declared WebMCP metadata, and optionally fetch source maps from the selected target origin or explicitly requested origins. JavaScript is parsed but never executed. Select detail summary and an explicit javascript-routes view for filtered route patterns, occurrences, sources, unknowns or summary counts; complete Evidence remains retained for later views. Static candidates do not establish reachability or a complete application route denominator.",
kind: "browser-provider",
inputSchema: analyzeWebBundleInputSchema,
inputSchema: analyzeWebBundleRequestSchema,
outputSchema: bundleOutputSchema,
examples: [
{
+102 -1
View File
@@ -380,12 +380,113 @@ const flattenRootObjectUnion = (
};
};
/** Preserve opt-in discriminator relationships without a root union. */
const presentDiscriminatedRoot = (
root: Record<string, unknown>,
): Record<string, unknown> => {
const { "x-rea-root-discriminator": discriminator, ...canonical } = root;
const flattened = flattenRootObjectUnion(canonical);
if (typeof discriminator !== "string") return flattened;
const alternatives = canonical.anyOf ?? canonical.oneOf;
if (!Array.isArray(alternatives) || alternatives.length !== 2)
throw new TypeError(
"A presented root discriminator requires two object alternatives",
);
const branches = alternatives.map((alternative) => {
const branch = objectBranch(canonical, alternative);
if (branch === undefined || branch.additionalProperties !== false)
throw new TypeError(
"A presented root discriminator requires strict objects",
);
return branch;
});
const requiredBranch = branches.find((branch) =>
requiredProperties(branch).includes(discriminator),
);
const fallback = branches.find(
(branch) => !requiredProperties(branch).includes(discriminator),
);
const literal = (
branch: Readonly<Record<string, unknown>> | undefined,
): unknown => {
const properties = branch?.properties;
const schema = isObject(properties) ? properties[discriminator] : undefined;
return isObject(schema) ? schema.const : undefined;
};
const selected = literal(requiredBranch),
otherwise = literal(fallback);
if (
requiredBranch === undefined ||
fallback === undefined ||
typeof selected !== "string" ||
typeof otherwise !== "string" ||
selected === otherwise
)
throw new TypeError(
"A presented root discriminator requires distinct literals and one default alternative",
);
const properties = Object.fromEntries(
Object.entries(
isObject(flattened.properties) ? flattened.properties : {},
).map(([key, schema]) => {
if (!isObject(schema) || !Array.isArray(schema.anyOf))
return [key, schema];
const possible = schema.anyOf.filter(
(alternative) =>
alternative !== false &&
!(
isObject(alternative) &&
isObject(alternative.not) &&
Object.keys(alternative.not).length === 0
),
);
return [key, possible.length === 1 ? possible[0] : schema];
}),
);
const commonRequired = new Set(requiredProperties(flattened));
const constraints = (branch: Readonly<Record<string, unknown>>) => {
const own = isObject(branch.properties) ? branch.properties : {};
const differences = Object.fromEntries(
Object.entries(properties).flatMap(([key, schema]) => {
if (key === discriminator || isDeepStrictEqual(schema, own[key]))
return [];
return [[key, Object.hasOwn(own, key) ? own[key] : false]];
}),
);
const required = requiredProperties(branch).filter(
(key) => !commonRequired.has(key),
);
return {
...(Object.keys(differences).length === 0
? {}
: { properties: differences }),
...(required.length === 0 ? {} : { required }),
};
};
// The canonical branch predicates supply validation; omit generic group
// guidance that would describe the forbidden never-valued property as optional.
const { description: _description, ...presentation } = flattened;
return {
...presentation,
properties,
...(canonical.description === undefined
? {}
: { description: canonical.description }),
if: {
properties: { [discriminator]: { const: selected } },
required: [discriminator],
},
then: constraints(requiredBranch),
else: constraints(fallback),
};
};
/** Describe canonical input fields and present the root as one object. */
export const presentInputJsonSchema = (
root: Record<string, unknown>,
describeProperty: (property: string) => string | undefined,
): Record<string, unknown> => {
const flattened = flattenRootObjectUnion(root);
const flattened = presentDiscriminatedRoot(root);
const described = describeSchema(flattened, flattened, describeProperty);
if (!isObject(described))
return normalizeAdvertisedSchema(flattened).schema as Record<
@@ -1,3 +1,4 @@
import { z } from "zod";
import type { ToolContract } from "../toolContractTypes.js";
import { toolContractMetadata } from "../toolEffects.js";
import { electronFuseOutputSchemas } from "../toolOutputSchemaGroups.js";
@@ -13,11 +14,15 @@ import {
electronActiveObservationInputSchema,
electronActiveObservationResultSchema,
} from "../../domain/javascript/electronActiveObservation.js";
import { javascriptApplicationSummaryViewResultSchema } from "../../domain/analysisView/analysisView.js";
import {
analysisDetailOutputSchemaOf,
analysisDetailSchema,
} from "../analysisDetail.js";
javaScriptRoutesCompleteDeliveryShape,
javaScriptRoutesSummaryDeliveryShape,
} from "../../domain/analysisView/javascriptRoutesView.js";
import {
javaScriptRoutesViewResultSchema,
javascriptApplicationSummaryViewResultSchema,
} from "../../domain/analysisView/analysisView.js";
import { analysisDetailOutputSchemaOf } from "../analysisDetail.js";
import {
analyzeJavaScriptApplicationInputSchema,
javascriptApplicationAnalysisResultSchema,
@@ -31,13 +36,22 @@ import { JAVASCRIPT_RUNTIME_RECONCILIATION_EXAMPLE } from "./javascriptRuntimeRe
const listOutputSchema = evidenceResultOf(electronTargetListSchema);
const inspectionOutputSchema = evidenceResultOf(electronPageInspectionSchema);
/** Caller-selected delivery of a completed JavaScript application analysis. */
export const analyzeJavaScriptApplicationRequestSchema =
analyzeJavaScriptApplicationInputSchema.extend({
detail: analysisDetailSchema,
});
export const analyzeJavaScriptApplicationRequestSchema = z
.discriminatedUnion("detail", [
analyzeJavaScriptApplicationInputSchema.extend(
javaScriptRoutesCompleteDeliveryShape,
),
analyzeJavaScriptApplicationInputSchema.extend(
javaScriptRoutesSummaryDeliveryShape,
),
])
.meta({ "x-rea-root-discriminator": "detail" });
const applicationOutputSchema = analysisDetailOutputSchemaOf(
javascriptApplicationAnalysisResultSchema,
javascriptApplicationSummaryViewResultSchema,
z.union([
javascriptApplicationSummaryViewResultSchema,
javaScriptRoutesViewResultSchema,
]),
);
const reconciliationOutputSchema = evidenceResultOf(
javascriptRuntimeReconciliationResultSchema,
@@ -92,7 +106,7 @@ export const ELECTRON_TOOL_CONTRACTS = [
name: "analyze_javascript_application",
...toolContractMetadata("analyze_javascript_application"),
description:
"Reconstruct one local ASAR or extracted JavaScript application as an inline application graph without executing it. Returns recovered graph nodes, edges, semantic relations, integrity contradictions, limitations, and coverage. Select source_paths to acquire and analyze only exact inventoried file paths; the complete artifact inventory and identity remain intact, and unselected sources remain explicit unknowns. Dependencies are not implicitly analyzed. Select detail summary to retain the complete analysis Evidence in this session and receive only its summary view, then inspect selected modules or pages with inspect_analysis_view. Summary detail reduces delivery, whereas source_paths bounds source analysis. Integrity mismatches fail by default; record-and-continue retains observed bytes as untrusted and marks graph coverage partial. Timeout recovery: returned provider_timeout uses the per-source analysis_timeout_ms budget. A host-only 'timed out awaiting tools/call' needs a longer client tool deadline, not a larger analysis_timeout_ms. Never repeat the unchanged call to test whether that timeout persists; report the host setting if configuration is not editable (Codex: mcp_servers.<server>.tool_timeout_sec, seconds).",
"Reconstruct one local ASAR or extracted JavaScript application as an inline application graph without executing it. Returns recovered graph nodes, edges, semantic relations, integrity contradictions, limitations, and coverage. Select source_paths to acquire and analyze only exact inventoried file paths; the complete artifact inventory and identity remain intact, and unselected sources remain explicit unknowns. Dependencies are not implicitly analyzed. Select detail summary to retain the complete analysis Evidence in this session and receive only its summary view, then inspect selected modules or pages with inspect_analysis_view. Select a javascript-routes view with summary detail to query deduplicated static patterns, source-linked occurrences, source status or interpretation gaps with filters, fields and paging. Static routes do not establish reachability or the full application route set. Summary detail reduces delivery, whereas source_paths bounds source analysis. Integrity mismatches fail by default; record-and-continue retains observed bytes as untrusted and marks graph coverage partial. Timeout recovery: returned provider_timeout uses the per-source analysis_timeout_ms budget. A host-only 'timed out awaiting tools/call' needs a longer client tool deadline, not a larger analysis_timeout_ms. Never repeat the unchanged call to test whether that timeout persists; report the host setting if configuration is not editable (Codex: mcp_servers.<server>.tool_timeout_sec, seconds).",
kind: "electron-provider",
inputSchema: analyzeJavaScriptApplicationRequestSchema,
outputSchema: applicationOutputSchema,
@@ -0,0 +1,92 @@
import { Ajv2020 } from "ajv/dist/2020.js";
import { Ajv } from "ajv";
import { describe, expect, it } from "vitest";
import { toolContract } from "./toolContracts.js";
import { toolInputSchemaWithMetadata } from "./toolSchemaMetadata.js";
const view = { kind: "javascript-routes", collection: "routes" };
const cases = [
["analyze_javascript_application", { input_path: "/tmp/application" }],
[
"analyze_web_bundle",
{
cdp_endpoint: "http://127.0.0.1:9222",
target_id: "page",
allowed_origins: ["https://example.test"],
},
],
] as const;
describe.each(cases)("%s delivery contract", (name, base) => {
it.each(["draft-2020-12", "draft-07"] as const)(
"advertises the canonical combinations in %s",
(target) => {
const contract = toolContract(name);
const projected = toolInputSchemaWithMetadata(contract)[
"~standard"
].jsonSchema.input({ target });
const ajv =
target === "draft-2020-12"
? new Ajv2020({ strict: false, validateFormats: false })
: new Ajv({ strict: false, validateFormats: false });
expect(ajv.validateSchema(projected)).toBe(true);
const validate = ajv.compile(projected);
expect(projected).toMatchObject({ type: "object" });
expect(projected).not.toHaveProperty("anyOf");
expect(projected).not.toHaveProperty("oneOf");
expect(projected).not.toHaveProperty("allOf");
expect(projected).not.toHaveProperty("x-rea-root-discriminator");
const inputs = [
[{}, true],
[{ detail: "complete" }, true],
[{ detail: "summary", view }, true],
[{ detail: "summary" }, name === "analyze_javascript_application"],
[{ view }, false],
[{ detail: "complete", view }, false],
[{ detail: "summary", view: { ...view, kind: "other" } }, false],
[{ detail: "summary", view, unexpected: true }, false],
] as const;
for (const [delivery, valid] of inputs) {
const input = { ...base, ...delivery };
expect(
contract.inputSchema.safeParse(input).success,
JSON.stringify(input),
).toBe(valid);
expect(
validate(input),
JSON.stringify({ input, errors: validate.errors }),
).toBe(valid);
}
},
);
});
it.each([{ detail: "complete" }, { detail: "summary", view }])(
"preserves web deadline validation through $detail delivery",
(delivery) => {
const contract = toolContract("analyze_web_bundle");
const base = cases[1][1];
const invalid = contract.inputSchema.safeParse({
...base,
...delivery,
analysis_timeout_ms: 40_000,
total_analysis_timeout_ms: 30_000,
});
expect(invalid.success).toBe(false);
if (!invalid.success)
expect(invalid.error.issues).toEqual(
expect.arrayContaining([
expect.objectContaining({ path: ["analysis_timeout_ms"] }),
]),
);
const deadlines = {
command_timeout_ms: 2_000,
analysis_timeout_ms: 10_000,
total_analysis_timeout_ms: 20_000,
source_map_fetch_timeout_ms: 3_000,
};
expect(
contract.inputSchema.parse({ ...base, ...delivery, ...deadlines }),
).toMatchObject({ ...delivery, ...deadlines });
},
);
+35
View File
@@ -7,6 +7,14 @@ import {
parseAnalysisViewResult,
analysisViewResultInputError,
} from "./analysisViewParent.js";
import {
javaScriptRoutesViewRequestSchema,
javaScriptRoutesViewItemsSchema,
javaScriptRoutesViewSummarySchema,
javaScriptRoutesSourceReferenceSchema,
projectJavaScriptRoutesView,
} from "./javascriptRoutesView.js";
import { javaScriptRouteInventorySchema } from "../javascript/javascriptRouteInventory.js";
import {
sourceToBundleViewRequestSchema,
sourceToBundleViewItemsSchema,
@@ -308,6 +316,7 @@ const nativeViewSchema = z.strictObject({
/** Caller-selected projection of already completed analysis Evidence. */
export const analysisViewRequestSchema = z.discriminatedUnion("kind", [
javaScriptRoutesViewRequestSchema,
nativeFunctionMatchingViewRequestSchema,
sourceToBundleViewRequestSchema,
summaryViewSchema,
@@ -431,6 +440,7 @@ const viewResultBase = {
"inspect_with_objdump",
"inspect_desktop_application",
"analyze_javascript_application",
"analyze_web_bundle",
"analyze_function",
"inspect_artifact",
"inventory_artifact",
@@ -638,6 +648,26 @@ export const dartAotSummaryViewResultSchema = z.strictObject({
summary: dartAotSummarySchema,
});
export const javaScriptRoutesViewResultSchema = z.strictObject({
...viewResultBase,
artifact: artifactSchema.nullable(),
kind: z.literal("javascript-routes"),
parent_operation: z.enum([
"analyze_javascript_application",
"analyze_web_bundle",
]),
view: javaScriptRoutesViewRequestSchema,
inventory_id: javaScriptRouteInventorySchema.shape.inventory_id,
source_manifest_sha256: digestSchema,
source_coverage: javaScriptRouteInventorySchema.shape.coverage,
inventory_capacity: javaScriptRouteInventorySchema.shape.capacity,
capture_url: z.string().nullable(),
items: javaScriptRoutesViewItemsSchema,
sources: z.array(javaScriptRoutesSourceReferenceSchema),
summary: javaScriptRoutesViewSummarySchema.nullable(),
byte_limited: z.boolean(),
});
export const gpuCodeObjectsSummaryViewResultSchema = z.strictObject({
...summaryViewResultBase,
parent_operation: z.literal("inspect_gpu_code_objects"),
@@ -710,6 +740,7 @@ export const sourceToBundlePageViewResultSchema = z.strictObject({
/** Projected facts for one selected view of retained analysis Evidence. */
export const analysisViewResultSchema = z.discriminatedUnion("kind", [
javaScriptRoutesViewResultSchema,
sourceToBundlePageViewResultSchema,
z.strictObject({
...viewResultBase,
@@ -1116,6 +1147,10 @@ export const projectAnalysisView = (
},
]),
);
if (view.kind === "javascript-routes") {
const projected = projectJavaScriptRoutesView(parent, view);
return projected.ok ? ok(sealAnalysisView(projected.value)) : projected;
}
if (isGroupingView(view)) {
const grouped = projectGroupingView(parent, view, accumulators);
return grouped.ok ? ok(sealAnalysisView(grouped.value)) : grouped;
@@ -0,0 +1,318 @@
import { expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "../javascript/javascriptStaticAnalysis.js";
import { completeSemanticSteps } from "../javascript/javascriptSemanticTraversal.js";
import { buildJavaScriptRouteInventorySteps } from "../javascript/javascriptRouteInventory.js";
import { freezeJsonSnapshot } from "../immutableJson.js";
import {
projectAnalysisView,
analysisViewRequestSchema,
type AnalysisViewParent,
} from "./analysisView.js";
const inventory = (
text = `createBrowserRouter([{path:'/reports',children:[{path:':id'},{path:'settings'}]}]);router.push('/login');`,
) =>
completeSemanticSteps(
buildJavaScriptRouteInventorySteps([
{
key: "routes.js",
path: "routes.js",
url: null,
sha256: "a".repeat(64),
container_sha256: null,
status: "complete",
reason: null,
facts: analyzeJavaScriptStaticSource(text).routes!,
},
]),
);
const parent = (): AnalysisViewParent => ({
evidenceId: `ev_${"b".repeat(64)}`,
operation: "analyze_javascript_application",
limitations: [],
normalizedResult: {
input_path: "/gone/app",
root_artifact_sha256: "c".repeat(64),
route_inventory: inventory(),
},
});
const query = (
source: AnalysisViewParent,
selection: Record<string, unknown>,
) => {
const result = projectAnalysisView(
source,
analysisViewRequestSchema.parse({
kind: "javascript-routes",
...selection,
}),
);
if (!result.ok) throw result.error;
if (result.value.kind !== "javascript-routes") throw new Error("Wrong view");
return result.value;
};
it("returns selected facts with inline provenance and a deduplicated source reference", () => {
const result = query(parent(), {
collection: "routes",
fields: ["path"],
where: { path_prefix: "/reports" },
limit: 2,
});
expect(result.items).toHaveLength(2);
expect(result.sources).toHaveLength(1);
expect(result.items[0]).toMatchObject({
path: "/reports",
provenance: {
source_id: result.sources[0]!.id,
mechanism: "nested-route-object",
},
});
expect(result.items[0]).not.toHaveProperty("classification");
expect(result.coverage).toMatchObject({
total: 3,
next_offset: 2,
exhausted: false,
});
expect(result.source_coverage.application_routes).toBe("unknown");
expect(result.parent_evidence_id).toBe(parent().evidenceId);
});
it("supports batch IDs, explicit empty selections and stable page order", () => {
const source = parent(),
all = query(source, { collection: "routes" });
const ids = all.items.map((row) => row.id);
expect(
query(source, { collection: "routes", where: { ids: [...ids].reverse() } })
.items,
).toEqual(all.items);
expect(
query(source, { collection: "routes", where: { ids: [] } }).items,
).toEqual([]);
const next = query(source, { collection: "routes", offset: 1, limit: 1 });
expect(next.items).toEqual(all.items.slice(1, 2));
expect(
query(source, { collection: "routes", fields: [] }).items[0],
).not.toHaveProperty("path");
});
it("joins route occurrences and sources without reopening source paths", () => {
const source = parent(),
routes = query(source, {
collection: "routes",
where: { paths: ["/reports/settings"] },
});
const occurrences = query(source, {
collection: "occurrences",
where: { route_ids: [routes.items[0]!.id] },
fields: ["segments"],
});
expect(occurrences.items).toHaveLength(1);
expect(occurrences.items[0]).toMatchObject({
route_id: routes.items[0]!.id,
segments: [{ value: "/reports" }, { value: "settings" }],
});
const sources = query(source, {
collection: "sources",
where: {
ids: occurrences.sources.map((row) => row.id),
statuses: ["complete"],
},
fields: ["path"],
});
expect(sources.items[0]).toMatchObject({
path: "routes.js",
status: "complete",
sha256: "a".repeat(64),
});
});
it("keeps source gaps separate from pattern counts and preserves partial coverage", () => {
const source = {
...parent(),
normalizedResult: {
input_path: "/gone/app",
root_artifact_sha256: "c".repeat(64),
route_inventory: inventory(
`const routes={reports:{settings:{_$:'settings'}}};`,
),
},
};
const gaps = query(source, {
collection: "unknowns",
where: { kinds: ["interpretation"] },
});
expect(gaps.items.length).toBeGreaterThan(0);
expect(gaps.source_coverage.candidate_enumeration).toBe("partial");
const summary = query(source, { collection: "summary" });
expect(summary.summary!.statistics.route_patterns).toBe(1);
expect(summary.summary!.gaps).toBeGreaterThan(0);
expect(summary.items).toEqual([]);
});
it("accepts captured-page Evidence with an explicitly absent local artifact", () => {
const result = query(
{
...parent(),
operation: "analyze_web_bundle",
subject: null,
normalizedResult: {
capture: { target_url: "https://example.test/" },
route_inventory: inventory(),
},
},
{ collection: "routes" },
);
expect(result.artifact).toBeNull();
expect(result.capture_url).toBe("https://example.test/");
});
it("checks subject identity again after warming an immutable parent", () => {
const source = parent();
const sealed = {
...source,
sealedResult: true,
normalizedResult: freezeJsonSnapshot({
input_path: "/gone/app",
root_artifact_sha256: "c".repeat(64),
route_inventory: inventory(),
}),
};
query(sealed, { collection: "summary" });
const result = projectAnalysisView(
{
...sealed,
subject: {
name: "app",
local_path: "/different",
digest: { sha256: "c".repeat(64) },
format: "javascript-bundle",
architecture: null,
},
},
analysisViewRequestSchema.parse({
kind: "javascript-routes",
collection: "summary",
}),
);
expect(result.ok).toBe(false);
});
it("distinguishes historical unmeasured routes from an empty measured inventory", () => {
const result = projectAnalysisView(
{
...parent(),
normalizedResult: {
input_path: "/gone/app",
root_artifact_sha256: "c".repeat(64),
},
},
analysisViewRequestSchema.parse({
kind: "javascript-routes",
collection: "routes",
}),
);
expect(result.ok).toBe(false);
expect(
query(
{
...parent(),
normalizedResult: {
input_path: "/gone/app",
root_artifact_sha256: "c".repeat(64),
route_inventory: inventory(""),
},
},
{ collection: "routes" },
).coverage.exhausted,
).toBe(true);
});
it("bounds serialized pages before cloning large selected fields and preserves a continuation", () => {
const text = ["a", "b", "c", "d", "e", "f"]
.map((key) => `router.push('/${key}${key.repeat(190000)}');`)
.join("\n");
const source = {
...parent(),
normalizedResult: {
input_path: "/gone/app",
root_artifact_sha256: "c".repeat(64),
route_inventory: inventory(text),
},
};
const first = query(source, {
collection: "routes",
fields: ["path"],
limit: 100,
});
// Each route is ~190KiB; the 1MiB view contract still applies when all facts fit.
expect(Buffer.byteLength(JSON.stringify(first))).toBeLessThanOrEqual(
1024 * 1024,
);
expect(first.items.length).toBeGreaterThan(0);
expect(first.byte_limited).toBe(true);
expect(first.coverage.next_offset).toBe(first.items.length);
});
it("returns a typed retained-parent resource error when even a summary identity exceeds the page budget", () => {
const source = {
...parent(),
operation: "analyze_web_bundle",
normalizedResult: {
capture: {
target_url: "https://example.test/" + "a".repeat(1024 * 1024),
},
route_inventory: inventory(),
},
};
const result = projectAnalysisView(
source,
analysisViewRequestSchema.parse({
kind: "javascript-routes",
collection: "summary",
}),
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected a resource error");
expect(result.error.message).toContain("cannot fit");
});
it.each(["summary", "routes"] as const)(
"includes the sealed digest in the %s byte ceiling",
(collection) => {
const source = {
...parent(),
operation: "analyze_web_bundle",
normalizedResult: {
capture: { target_url: "https://example.test/" },
route_inventory: inventory("route('/only');"),
},
};
const selection =
collection === "summary"
? { collection }
: { collection, fields: ["path"], limit: 1 };
const bytes = Buffer.byteLength(JSON.stringify(query(source, selection)));
source.normalizedResult.capture.target_url += "a".repeat(
1024 * 1024 - bytes + 1,
);
const result = projectAnalysisView(
source,
analysisViewRequestSchema.parse({
kind: "javascript-routes",
...selection,
}),
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected a sealed-page resource error");
expect(result.error.message).toContain("cannot fit");
},
);
it("admits a summary that fits exactly after sealing", () => {
const source = {
...parent(),
operation: "analyze_web_bundle",
normalizedResult: {
capture: { target_url: "https://example.test/" },
route_inventory: inventory("route('/only');"),
},
};
const selection = { collection: "summary" };
const bytes = Buffer.byteLength(JSON.stringify(query(source, selection)));
source.normalizedResult.capture.target_url += "a".repeat(1024 * 1024 - bytes);
expect(Buffer.byteLength(JSON.stringify(query(source, selection)))).toBe(
1024 * 1024,
);
});
@@ -0,0 +1,667 @@
import { javascriptSourceRangeSchema } from "../javascript/javascriptSourceCoordinates.js";
import { z } from "zod";
import { digestSchema } from "../digests.js";
import { isImmutableJsonSnapshot } from "../immutableJson.js";
import { err, ok, type Result } from "../result.js";
import type { AnalysisError } from "../analysisErrorBase.js";
import { AnalysisResourceConstraintError } from "../analysisErrorCore.js";
import type { JsonValue } from "../jsonValue.js";
import { jsonValueByteLength } from "../jsonValueByteLength.js";
import {
javaScriptRouteInventorySchema,
javaScriptInventoryRouteSchema,
javaScriptRouteOccurrenceSchema,
javaScriptRouteInventorySourceSchema,
javaScriptRouteGapSchema,
javaScriptRouteCaptureOmissionSchema,
type JavaScriptRouteInventory,
type JavaScriptInventoryRoute,
type JavaScriptRouteOccurrence,
type JavaScriptRouteInventorySource,
} from "../javascript/javascriptRouteInventory.js";
import { javaScriptRouteMechanismSchema } from "../javascript/javascriptRouteFacts.js";
import type {
AnalysisViewParent,
AnalysisViewRequest,
UnsignedAnalysisView,
} from "./analysisView.js";
import { analysisViewInputError } from "./analysisViewErrors.js";
import {
boundedViewMessages,
completeWithinViewCoverage,
pageViewCoverage,
} from "./analysisViewCoverage.js";
const identity = { kind: z.literal("javascript-routes") };
const paging = {
offset: z.int().nonnegative().default(0),
limit: z.int().positive().default(64),
};
const ids = z.array(z.string()).exactOptional();
export const javaScriptRoutesViewRequestSchema = z.discriminatedUnion(
"collection",
[
z.strictObject({ ...identity, collection: z.literal("summary") }),
z.strictObject({
...identity,
collection: z.literal("routes"),
where: z
.strictObject({
ids,
paths: z.array(z.string()).exactOptional(),
path_prefix: z
.string()
.exactOptional()
.describe("Exact string prefix; no URL/path normalization."),
classifications: z
.array(javaScriptInventoryRouteSchema.shape.classification)
.exactOptional(),
mechanisms: z.array(javaScriptRouteMechanismSchema).exactOptional(),
min_confidence:
javaScriptInventoryRouteSchema.shape.confidence.exactOptional(),
})
.default({}),
fields: z.array(javaScriptInventoryRouteSchema.keyof()).exactOptional(),
...paging,
}),
z.strictObject({
...identity,
collection: z.literal("occurrences"),
where: z
.strictObject({
ids,
route_ids: z.array(z.string()).exactOptional(),
source_ids: z.array(z.string()).exactOptional(),
mechanisms: z.array(javaScriptRouteMechanismSchema).exactOptional(),
})
.default({}),
fields: z.array(javaScriptRouteOccurrenceSchema.keyof()).exactOptional(),
...paging,
}),
z.strictObject({
...identity,
collection: z.literal("sources"),
where: z
.strictObject({
ids,
keys: z.array(z.string()).exactOptional(),
statuses: z
.array(javaScriptRouteInventorySourceSchema.shape.status)
.exactOptional(),
})
.default({}),
fields: z
.array(javaScriptRouteInventorySourceSchema.keyof())
.exactOptional(),
...paging,
}),
z.strictObject({
...identity,
collection: z.literal("unknowns"),
where: z
.strictObject({
ids,
source_ids: z.array(z.string()).exactOptional(),
kinds: z.array(javaScriptRouteGapSchema.shape.kind).exactOptional(),
})
.default({}),
fields: z.array(javaScriptRouteGapSchema.keyof()).exactOptional(),
...paging,
}),
],
);
/** Select retained delivery explicitly; complete delivery never silently drops facts. */
export const javaScriptRoutesCompleteDeliveryShape = {
// Optional input participates in discriminator dispatch before its default.
detail: z.literal("complete").optional().default("complete"),
view: z.never().exactOptional(),
};
export const javaScriptRoutesSummaryDeliveryShape = {
detail: z.literal("summary"),
view: javaScriptRoutesViewRequestSchema.exactOptional(),
};
export const webRoutesSummaryDeliveryShape = {
detail: z.literal("summary"),
view: javaScriptRoutesViewRequestSchema,
};
export const javaScriptRoutesDeliverySchema = z.discriminatedUnion("detail", [
z.strictObject(javaScriptRoutesCompleteDeliveryShape),
z.strictObject(javaScriptRoutesSummaryDeliveryShape),
]);
export const webRoutesDeliverySchema = z.discriminatedUnion("detail", [
z.strictObject(javaScriptRoutesCompleteDeliveryShape),
z.strictObject(webRoutesSummaryDeliveryShape),
]);
export const javaScriptRouteProvenanceSchema = z.strictObject({
source_id: javaScriptRouteInventorySourceSchema.shape.id,
occurrence_id: javaScriptRouteOccurrenceSchema.shape.id.nullable(),
source_range: javascriptSourceRangeSchema.nullable(),
mechanism: javaScriptRouteMechanismSchema.nullable(),
derivation: javaScriptRouteOccurrenceSchema.shape.derivation.nullable(),
inferred_base_path: z.string().nullable(),
});
export const javaScriptRoutesViewItemsSchema = z.union([
z.array(
javaScriptInventoryRouteSchema
.partial()
.required({ id: true })
.extend({ provenance: javaScriptRouteProvenanceSchema }),
),
z.array(
javaScriptRouteOccurrenceSchema
.partial()
.required({ id: true, route_id: true, source_id: true })
.extend({ provenance: javaScriptRouteProvenanceSchema }),
),
z.array(
javaScriptRouteInventorySourceSchema
.partial()
.required({ id: true, key: true, sha256: true, status: true }),
),
z.array(
javaScriptRouteGapSchema
.partial()
.required({ id: true, source_id: true, kind: true })
.extend({ provenance: javaScriptRouteProvenanceSchema.nullable() }),
),
]);
export const javaScriptRoutesViewSummarySchema = z.strictObject({
reported_capture_omissions: z.array(
z.strictObject({
id: javaScriptRouteGapSchema.shape.id,
reason: z.string(),
...javaScriptRouteCaptureOmissionSchema.shape,
}),
),
statistics: javaScriptRouteInventorySchema.shape.statistics,
gaps: z.int().nonnegative(),
source_status_counts: z.strictObject({
complete: z.int().nonnegative(),
partial: z.int().nonnegative(),
unavailable: z.int().nonnegative(),
unknown: z.int().nonnegative(),
}),
patterns_by_classification: z.strictObject({
page: z.int().nonnegative(),
system: z.int().nonnegative(),
unknown: z.int().nonnegative(),
}),
});
export const javaScriptRoutesSourceReferenceSchema =
javaScriptRouteInventorySourceSchema;
type Request = z.output<typeof javaScriptRoutesViewRequestSchema>;
type Gap = z.infer<typeof javaScriptRouteGapSchema>;
interface Table<T extends { id: string }> {
readonly rows: readonly T[];
readonly byId: ReadonlyMap<string, { row: T; index: number }>;
}
const table = <T extends { id: string }>(rows: readonly T[]): Table<T> => ({
rows,
byId: new Map(rows.map((row, index) => [row.id, { row, index }])),
});
interface Checked {
readonly inventory: JavaScriptRouteInventory;
readonly routes: Table<JavaScriptInventoryRoute>;
readonly occurrences: Table<JavaScriptRouteOccurrence>;
readonly sources: Table<JavaScriptRouteInventorySource>;
readonly gaps: Table<Gap>;
readonly summary: z.infer<typeof javaScriptRoutesViewSummarySchema>;
}
const checkedRoutes = new WeakMap<object, Checked>();
const inputError = (message: string): AnalysisError =>
analysisViewInputError([
{
path: ["source", "normalized_result", "route_inventory"],
reason: "invalid_value",
message,
},
]);
const read = (parent: AnalysisViewParent): Result<Checked, AnalysisError> => {
const raw = parent.normalizedResult;
if (raw === null || typeof raw !== "object" || Array.isArray(raw))
return err(inputError("Parent result is not an analysis record."));
const reusable = parent.sealedResult === true && isImmutableJsonSnapshot(raw);
const retained = reusable ? checkedRoutes.get(raw) : undefined;
if (retained !== undefined) return ok(retained);
const value = (raw as Record<string, unknown>).route_inventory;
if (value === undefined)
return err(
inputError(
"This parent has no measured route inventory; route absence remains unknown.",
),
);
const parsed = javaScriptRouteInventorySchema.safeParse(value);
if (!parsed.success)
return err(
inputError(
`Parent route inventory failed validation (${parsed.error.issues.length} issues); the complete source remains in parent Evidence ${parent.evidenceId}.`,
),
);
const inventory = parsed.data;
const source_status_counts = {
complete: 0,
partial: 0,
unavailable: 0,
unknown: 0,
},
patterns_by_classification = { page: 0, system: 0, unknown: 0 };
for (const source of inventory.sources) source_status_counts[source.status]++;
for (const route of inventory.routes)
patterns_by_classification[route.classification]++;
const checked: Checked = {
inventory,
routes: table(inventory.routes),
occurrences: table(inventory.occurrences),
sources: table(inventory.sources),
gaps: table(inventory.unknowns),
summary: {
reported_capture_omissions: inventory.unknowns.flatMap((gap) =>
gap.capture_omission === undefined
? []
: [{ id: gap.id, reason: gap.reason, ...gap.capture_omission }],
),
statistics: inventory.statistics,
gaps: inventory.unknowns.length,
source_status_counts,
patterns_by_classification,
},
};
if (reusable) checkedRoutes.set(raw, checked);
return ok(checked);
};
const candidates = <T extends { id: string }>(
rows: Table<T>,
selected: readonly string[] | undefined,
): readonly T[] =>
selected === undefined
? rows.rows
: [...new Set(selected)]
.flatMap((id) => {
const entry = rows.byId.get(id);
return entry === undefined ? [] : [entry];
})
.sort((a, b) => a.index - b.index)
.map((entry) => entry.row);
const choose = (
row: Record<string, JsonValue>,
fields: readonly string[],
): Record<string, JsonValue> =>
Object.fromEntries(
[...new Set(fields)]
.filter((field) => Object.hasOwn(row, field))
.map((field) => [field, row[field]!]),
);
const matches = (
values: ReadonlySet<string> | undefined,
value: string | null,
): boolean => values === undefined || (value !== null && values.has(value));
const selectedSet = (values: readonly string[] | undefined) =>
values === undefined ? undefined : new Set(values);
const ranks = { low: 0, medium: 1, high: 2 };
const PAGE_BYTES = 1024 * 1024;
// Sealing appends this field to a nonempty JSON object after projection.
const DIGEST_BYTES = jsonValueByteLength({ view_digest: "0".repeat(64) }) - 1;
const PROJECTION_BYTES = PAGE_BYTES - DIGEST_BYTES;
const appIdentity = z.object({
input_path: z.string().min(1),
root_artifact_sha256: digestSchema,
});
const captureIdentity = z.object({
capture: z.object({ target_url: z.string() }),
});
/** Historical projection: no source file reads, browser connection or reanalysis. */
export const projectJavaScriptRoutesView = (
parent: AnalysisViewParent,
selected: AnalysisViewRequest,
): Result<UnsignedAnalysisView, AnalysisError> => {
if (selected.kind !== "javascript-routes")
return err(inputError("Select a javascript-routes view."));
const operation = parent.operation;
if (
operation !== "analyze_javascript_application" &&
operation !== "analyze_web_bundle"
)
return err(
inputError(
"Route views require JavaScript application or captured web-bundle Evidence.",
),
);
let artifact = parent.artifact ?? null,
capture_url: string | null = null;
if (operation === "analyze_javascript_application") {
const identity = appIdentity.safeParse(parent.normalizedResult);
if (!identity.success)
return err(
inputError(
"Parent application artifact identity is missing or malformed.",
),
);
artifact = {
path: identity.data.input_path,
sha256: identity.data.root_artifact_sha256,
};
if (
parent.subject != null &&
(parent.subject.local_path !== artifact.path ||
parent.subject.digest.sha256 !== artifact.sha256)
)
return err(
inputError(
"Parent subject does not match its application artifact identity.",
),
);
} else {
const identity = captureIdentity.safeParse(parent.normalizedResult);
if (!identity.success)
return err(
inputError("Parent captured-page identity is missing or malformed."),
);
capture_url = identity.data.capture.target_url;
}
const parsed = read(parent);
if (!parsed.ok) return parsed;
const data = parsed.value,
inventory = data.inventory,
view: Request = selected;
const shared = {
kind: "javascript-routes" as const,
view,
parent_evidence_id: parent.evidenceId,
parent_operation: operation as
| "analyze_javascript_application"
| "analyze_web_bundle",
parent_digest: parent.evidenceId.slice(3),
artifact,
capture_url,
inventory_id: inventory.inventory_id,
source_manifest_sha256: inventory.source_manifest_sha256,
source_coverage: { ...inventory.coverage },
inventory_capacity:
inventory.capacity === null ? null : { ...inventory.capacity },
limitations: boundedViewMessages(
[...parent.limitations, ...inventory.limitations],
parent.evidenceId,
"limitations",
),
unknowns: [
"The complete application route set, reachability and runtime permissions remain unknown.",
...(inventory.coverage.candidate_enumeration === "partial"
? [
"Some source/routing facts are incomplete; inspect sources and unknowns without reanalyzing.",
]
: []),
],
};
const tooLarge = (): Result<never, AnalysisError> =>
err(
new AnalysisResourceConstraintError(
"inspect_analysis_view",
"transport",
"The requested route page cannot fit the analysis-view byte budget; select fewer fields or a smaller selection. The complete parent is retained.",
{
parent_evidence_id: parent.evidenceId,
limit_bytes: PAGE_BYTES,
collection: view.collection,
},
),
);
if (view.collection === "summary") {
const summary = {
...shared,
items: [],
sources: [],
summary: {
...data.summary,
statistics: { ...data.summary.statistics },
source_status_counts: { ...data.summary.source_status_counts },
patterns_by_classification: {
...data.summary.patterns_by_classification,
},
},
byte_limited: false,
coverage: completeWithinViewCoverage(
inventory.routes.length,
inventory.routes.length,
),
};
return jsonValueByteLength(summary, PROJECTION_BYTES) > PROJECTION_BYTES
? tooLarge()
: ok(summary);
}
const page = view;
const proof = (
occurrence: JavaScriptRouteOccurrence,
): z.infer<typeof javaScriptRouteProvenanceSchema> => ({
source_id: occurrence.source_id,
occurrence_id: occurrence.id,
source_range: occurrence.source_range,
mechanism: occurrence.mechanism,
derivation: occurrence.derivation,
inferred_base_path: occurrence.inferred_base_path,
});
const deliver = <T extends { id: string }>(
source: Table<T>,
ids: readonly string[] | undefined,
predicate: (row: T) => boolean,
project: (row: T) => {
item: Record<string, JsonValue>;
sources: readonly string[];
},
): Result<UnsignedAnalysisView, AnalysisError> => {
const rows = candidates(source, ids).filter(predicate),
items: Record<string, JsonValue>[] = [],
refs = new Map<string, JavaScriptRouteInventorySource>();
// Count the fixed envelope once. Counter widths and status text reserve the
// largest representation, so growing a page never rescans earlier sources.
let bytes =
DIGEST_BYTES +
jsonValueByteLength(
{
...shared,
items: [],
sources: [],
summary: null,
byte_limited: false,
coverage: {
status: "complete-within-view",
examined: Number.MAX_SAFE_INTEGER,
total: Number.MAX_SAFE_INTEGER,
next_offset: Number.MAX_SAFE_INTEGER,
exhausted: false,
},
},
PROJECTION_BYTES,
),
byte_limited = false;
if (bytes > PAGE_BYTES) return tooLarge();
for (
let index = page.offset;
index < rows.length && items.length < page.limit;
index++
) {
const projected = project(rows[index]!),
additions: JavaScriptRouteInventorySource[] = [];
let addedBytes =
jsonValueByteLength(projected.item, PAGE_BYTES - bytes) +
(items.length > 0 ? 1 : 0);
for (const id of new Set(projected.sources)) {
if (refs.has(id)) continue;
const entry = data.sources.byId.get(id);
if (entry !== undefined) {
addedBytes +=
jsonValueByteLength(
entry.row,
Math.max(0, PAGE_BYTES - bytes - addedBytes),
) + (refs.size + additions.length > 0 ? 1 : 0);
additions.push(entry.row);
}
}
if (bytes + addedBytes > PAGE_BYTES) {
if (items.length === 0) return tooLarge();
byte_limited = true;
break;
}
bytes += addedBytes;
items.push(projected.item);
for (const row of additions) refs.set(row.id, row);
}
const result = {
...shared,
items: javaScriptRoutesViewItemsSchema.parse(items),
sources: [...refs.values()],
summary: null,
byte_limited,
coverage: pageViewCoverage(page.offset, items.length, rows.length),
};
return jsonValueByteLength({ ...result, items }, PROJECTION_BYTES) >
PROJECTION_BYTES
? tooLarge()
: ok(result);
};
if (page.collection === "routes") {
const where = page.where,
filters = {
paths: selectedSet(where.paths),
classifications: selectedSet(where.classifications),
mechanisms: selectedSet(where.mechanisms),
},
fields = page.fields ?? [
"path",
"classification",
"classification_basis",
"confidence",
"mechanisms",
"occurrence_count",
"source_count",
];
return deliver(
data.routes,
where.ids,
(row) =>
matches(filters.paths, row.path) &&
(where.path_prefix === undefined ||
row.path.startsWith(where.path_prefix)) &&
matches(filters.classifications, row.classification) &&
(where.mechanisms === undefined ||
row.mechanisms.some((mechanism) =>
filters.mechanisms!.has(mechanism),
)) &&
(where.min_confidence === undefined ||
ranks[row.confidence] >= ranks[where.min_confidence]),
(row) => {
const occurrence = data.occurrences.byId.get(
row.primary_occurrence_id,
)!.row;
return {
item: {
...choose(row, fields),
id: row.id,
provenance: proof(occurrence),
},
sources: [occurrence.source_id],
};
},
);
}
if (page.collection === "occurrences") {
const where = page.where,
filters = {
route_ids: selectedSet(where.route_ids),
source_ids: selectedSet(where.source_ids),
mechanisms: selectedSet(where.mechanisms),
},
fields = page.fields ?? [
"mechanism",
"confidence",
"derivation",
"origin",
"source_range",
"unprefixed_path",
"inferred_base_path",
];
return deliver(
data.occurrences,
where.ids,
(row) =>
matches(filters.route_ids, row.route_id) &&
matches(filters.source_ids, row.source_id) &&
matches(filters.mechanisms, row.mechanism),
(row) => ({
item: {
...choose(row, fields),
id: row.id,
route_id: row.route_id,
source_id: row.source_id,
provenance: proof(row),
},
sources: [row.source_id],
}),
);
}
if (page.collection === "sources") {
const where = page.where,
filters = {
keys: selectedSet(where.keys),
statuses: selectedSet(where.statuses),
},
fields = page.fields ?? [
"path",
"url",
"container_sha256",
"reason",
"base_path",
"reported_route_capacity",
];
return deliver(
data.sources,
where.ids,
(row) =>
matches(filters.keys, row.key) && matches(filters.statuses, row.status),
(row) => ({
item: {
...choose(row, fields),
id: row.id,
key: row.key,
sha256: row.sha256,
status: row.status,
},
sources: [],
}),
);
}
const where = page.where,
filters = {
source_ids: selectedSet(where.source_ids),
kinds: selectedSet(where.kinds),
},
fields = page.fields ?? ["source_range", "reason"];
return deliver(
data.gaps,
where.ids,
(row) =>
matches(filters.source_ids, row.source_id) &&
matches(filters.kinds, row.kind),
(row) => ({
item: {
...choose(row, fields),
id: row.id,
source_id: row.source_id,
kind: row.kind,
provenance:
row.source_id === null
? null
: {
source_id: row.source_id,
occurrence_id: null,
source_range: row.source_range,
mechanism: null,
derivation: null,
inferred_base_path: null,
},
},
sources: row.source_id === null ? [] : [row.source_id],
}),
);
};
+1 -1
View File
@@ -32,7 +32,7 @@ const abiSelectorSchema = z
},
{ message: ABI_SELECTOR_MESSAGE, abort: true },
)
.regex(/^[^\/]*$/u, ABI_SELECTOR_MESSAGE)
.regex(/^[^\x2f]*$/u, ABI_SELECTOR_MESSAGE)
.meta({ maxLength: ABI_SELECTOR_MAX_CHARACTERS });
/**
@@ -1,4 +1,7 @@
import { z } from "zod";
/** Complete static checkpoint / per-script result transport ceiling. */
export const JAVASCRIPT_STATIC_FACT_BYTES = 8 * 1024 * 1024;
import type { JsonValue } from "../jsonValue.js";
import type { ApplicationCoverage } from "./javascriptApplicationEvidenceSchemas.js";
@@ -1,4 +1,5 @@
import { z } from "zod";
import { javaScriptRouteInventorySchema } from "./javascriptRouteInventory.js";
import { canBorrowParsedJsonSnapshot } from "../immutableJson.js";
import {
@@ -87,6 +88,7 @@ const applicationAnalysisResultShape = z.strictObject({
statistics: reconstructionStatisticsSchema,
limitations: z.array(z.string().min(1)),
semantic_graph: javaScriptSemanticGraphSchema,
route_inventory: javaScriptRouteInventorySchema.exactOptional(),
});
type BindingIssue = {
@@ -2,6 +2,7 @@ import * as t from "@babel/types";
import { expect, it } from "vitest";
import {
semanticMemberChain,
semanticStaticPropertyKey,
staticObjectProperty,
staticObjectPropertyNames,
@@ -256,3 +257,27 @@ it("omits prototype setters copied by literal object spread", () => {
staticObjectPropertyNames(t.objectExpression([t.spreadElement(source)])),
).toEqual({ status: "complete", names: ["visible"] });
});
it("shares static member paths and stops before unadmitted path expansion", () => {
const root = t.identifier("registry");
const selected = t.optionalMemberExpression(
t.memberExpression(
t.tsAsExpression(root, t.tsUnknownKeyword()),
t.stringLiteral("query"),
true,
),
t.identifier("nested"),
false,
true,
);
expect(semanticMemberChain(selected)).toEqual({
root,
members: ["query", "nested"],
});
expect(
semanticMemberChain(t.memberExpression(root, t.identifier("key"), true)),
).toBeUndefined();
let admitted = 0;
expect(semanticMemberChain(selected, () => ++admitted < 2)).toBeUndefined();
expect(admitted).toBe(2);
});
+68 -11
View File
@@ -10,6 +10,20 @@ export const runtimeFunctionParameters = (
? node.params.slice(1)
: node.params;
/** One erased syntax edge, without walking a potentially nested wrapper chain. */
export const javaScriptExpressionWrapperTarget = (
node: t.Node,
): t.Node | undefined =>
t.isParenthesizedExpression(node) ||
t.isTSAsExpression(node) ||
t.isTSTypeAssertion(node) ||
t.isTSNonNullExpression(node) ||
t.isTSSatisfiesExpression(node) ||
t.isTSInstantiationExpression(node) ||
t.isTypeCastExpression(node)
? node.expression
: undefined;
/**
* Remove wrappers that affect TypeScript or Flow checking but not runtime
* expression identity. The returned depth lets bounded evaluators account for
@@ -21,15 +35,9 @@ export const unwrapJavaScriptExpression = (
): { readonly node: t.Node; readonly depth: number } => {
let current = node;
let depth = 0;
while (
t.isParenthesizedExpression(current) ||
t.isTSAsExpression(current) ||
t.isTSTypeAssertion(current) ||
t.isTSNonNullExpression(current) ||
t.isTSSatisfiesExpression(current) ||
t.isTSInstantiationExpression(current) ||
t.isTypeCastExpression(current)
) {
for (;;) {
const target = javaScriptExpressionWrapperTarget(current);
if (target === undefined) break;
// Optional-chain grouping affects execution even when expression identity
// is transparent. Keep every grouping boundary available to that consumer.
if (
@@ -38,7 +46,7 @@ export const unwrapJavaScriptExpression = (
current.extra?.parenthesized === true)
)
break;
current = current.expression;
current = target;
depth += 1;
}
return { node: current, depth };
@@ -118,6 +126,26 @@ export const semanticStaticPropertyKey = (
return !computed && t.isIdentifier(property) ? property.name : null;
};
/** Determine value truthiness from syntax, without executing source or resolving bindings. */
export const staticJavaScriptTruthiness = (
node: t.Node,
): boolean | undefined => {
const value = unwrapJavaScriptExpression(node).node;
if (t.isBooleanLiteral(value)) return value.value;
if (t.isNullLiteral(value)) return false;
if (t.isStringLiteral(value)) return value.value.length > 0;
if (t.isNumericLiteral(value))
return value.value !== 0 && !Number.isNaN(value.value);
if (
t.isObjectExpression(value) ||
t.isArrayExpression(value) ||
t.isFunctionExpression(value) ||
t.isArrowFunctionExpression(value)
)
return true;
return undefined;
};
/** Syntax whose value cannot contribute own properties to an object spread. */
export const isNullishJavaScriptSyntax = (node: t.Node): boolean => {
node = unwrapJavaScriptExpression(node).node;
@@ -140,7 +168,8 @@ type StaticObjectMember =
const UNKNOWN_STATIC_OBJECT_MEMBER: StaticObjectMember = { kind: "unknown" };
const isObjectPrototypeSetter = (property: t.ObjectProperty): boolean =>
/** Object-literal prototype setters are never ordinary own-property writes. */
export const isObjectPrototypeSetter = (property: t.ObjectProperty): boolean =>
!property.computed &&
!property.shorthand &&
semanticStaticPropertyKey(property.key, false) === "__proto__";
@@ -362,3 +391,31 @@ export function* semanticWriteTargets(
*/
export const javascriptDisplayText = (value: string): string =>
value === "" ? '""' : value;
/** Strip a static member chain, returning its root expression and member path. */
export const semanticMemberChain = (
node: t.Node | null | undefined,
admit?: (node: t.Node) => boolean,
):
| { readonly root: t.Node | null | undefined; readonly members: string[] }
| undefined => {
const members: string[] = [];
let current = node;
while (current !== null && current !== undefined) {
current = unwrapJavaScriptExpression(current).node;
if (admit?.(current) === false) return undefined;
if (
!t.isMemberExpression(current) &&
!t.isOptionalMemberExpression(current)
)
break;
const member = semanticStaticPropertyKey(
current.property,
current.computed,
);
if (member === null || !t.isNode(current.object)) return undefined;
members.push(member);
current = current.object;
}
return { root: current, members: members.reverse() };
};
@@ -3,13 +3,13 @@ import * as t from "@babel/types";
import { javascriptBootstrapAt } from "./javascriptBootstrap.js";
import { proveJavaScriptBootstrapIntegrity } from "./javascriptBootstrapIntegrity.js";
import {
semanticMemberChain,
semanticStaticPropertyKey,
unwrapJavaScriptExpression,
} from "./javascriptAstValues.js";
import { collectJavaScriptIdentityWritesSteps } from "./javascriptElectronMemberWrites.js";
import { createSemanticModuleInterop } from "./javascriptModuleInterop.js";
import {
semanticMemberChain,
semanticRequireOrigin,
type JavaScriptRequireOrigin,
} from "./javascriptSemanticRequireOrigin.js";
@@ -0,0 +1,60 @@
import { z } from "zod";
import { javascriptSourceRangeSchema } from "./javascriptSourceCoordinates.js";
export const javaScriptRouteMechanismSchema = z.enum([
"router-call",
"nested-route-object",
"typed-route-tree",
"next-app-chunk",
"navigation-target",
"route-string-constant",
]);
export const javaScriptRouteSegmentSchema = z.strictObject({
value: z.string(),
location: javascriptSourceRangeSchema,
origin: z.enum(["literal", "property-name", "next-app-path"]),
});
export const javaScriptRouteCandidateSchema = z.strictObject({
path: z.string().startsWith("/"),
mechanism: javaScriptRouteMechanismSchema,
confidence: z.enum(["low", "medium", "high"]),
derivation: z.enum(["literal", "joined", "inferred"]),
location: javascriptSourceRangeSchema,
segments: z.array(javaScriptRouteSegmentSchema),
});
export const javaScriptRouteWorkCapacitySchema = z.strictObject({
kind: z.literal("route-work-or-facts"),
limit_bytes: z.int().nonnegative(),
accounted_fact_bytes: z.int().nonnegative(),
accounted_work_bytes: z.int().nonnegative(),
});
export const javaScriptRouteSourceFactsSchema = z.strictObject({
candidates: z.array(javaScriptRouteCandidateSchema),
unresolved: z.array(
z.strictObject({
mechanism: javaScriptRouteMechanismSchema,
location: javascriptSourceRangeSchema,
reason: z.string(),
}),
),
capacity: z
.discriminatedUnion("kind", [
javaScriptRouteWorkCapacitySchema,
z.strictObject({
kind: z.literal("result-bytes"),
limit_bytes: z.int().nonnegative(),
preceding_work_capacity: javaScriptRouteWorkCapacitySchema.nullable(),
}),
])
.nullable(),
});
export type JavaScriptRouteCandidate = z.infer<
typeof javaScriptRouteCandidateSchema
>;
export type JavaScriptRouteSegment = z.infer<
typeof javaScriptRouteSegmentSchema
>;
export type JavaScriptRouteSourceFacts = z.infer<
typeof javaScriptRouteSourceFactsSchema
>;
@@ -0,0 +1,357 @@
import { expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
import { completeSemanticSteps } from "./javascriptSemanticTraversal.js";
import {
buildJavaScriptRouteInventorySteps,
javaScriptRouteInventorySchema,
type JavaScriptRouteInventoryInput,
} from "./javascriptRouteInventory.js";
const source = (
key: string,
text: string,
url: string | null = null,
): JavaScriptRouteInventoryInput => ({
key,
path: url === null ? key : null,
url,
sha256: "a".repeat(64),
container_sha256: null,
status: "complete",
reason: null,
facts: analyzeJavaScriptStaticSource(text).routes!,
});
const build = (inputs: JavaScriptRouteInventoryInput[], bytes?: number) =>
javaScriptRouteInventorySchema.parse(
completeSemanticSteps(buildJavaScriptRouteInventorySteps(inputs, bytes)),
);
it("coalesces exact patterns while retaining independent occurrences and stable identities", () => {
const inputs = [
source("one.js", `router.push('/reports');`),
source(
"two.js",
`createBrowserRouter([{path:'/reports',children:[{path:'settings'}]}]);`,
),
];
const result = build(inputs),
repeated = build([...inputs].reverse());
expect(result.routes.map(({ path }) => path)).toEqual([
"/reports",
"/reports/settings",
]);
expect(result.routes[0]).toMatchObject({
occurrence_count: 3,
source_count: 2,
});
expect(result.routes.map(({ id }) => id)).toEqual(
repeated.routes.map(({ id }) => id),
);
expect(result.source_manifest_sha256).toBe(repeated.source_manifest_sha256);
expect(result.inventory_id).toBe(repeated.inventory_id);
expect(result.coverage.application_routes).toBe("unknown");
});
it("chooses deterministic primary provenance when sources have equal confidence", () => {
const inputs = [
source("one.js", `router.push('/reports');`),
source("two.js", `router.push('/reports');`),
];
expect(build(inputs)).toEqual(build([...inputs].reverse()));
});
it("binds ordinary constants to application segments across sources and excludes framework filesystem noise", () => {
const result = build([
source("navigation.js", `router.push('/reports');`),
source(
"runtime.js",
`globalThis.TURBOPACK=[];const routes=['/reports/:id','/ROOT/node_modules/next/process/','/_not-found'];`,
),
]);
expect(result.routes.map(({ path }) => path)).toEqual([
"/reports",
"/reports/:id",
]);
expect(result.statistics.unanchored_literals_excluded).toBe(2);
});
it("retains inferred Next script bases and the unprefixed path", () => {
const result = build([
source(
"script",
`router.push('/planner');`,
"https://example.test/app/_next/static/chunks/123.js",
),
]);
expect(result.routes.map(({ path }) => path)).toEqual(["/app/planner"]);
expect(result.occurrences[0]).toMatchObject({
unprefixed_path: "/planner",
inferred_base_path: "/app",
confidence: "low",
});
expect(result.sources[0]!.url).toBe(
"https://example.test/app/_next/static/chunks/123.js",
);
expect(result.unknowns.some(({ kind }) => kind === "inferred-base")).toBe(
true,
);
});
it("reports missing source facts as partial and preserves filename-only discoveries without fake AST ranges", () => {
const result = build([
{
...source("app/reports/[id]/page.js", ""),
status: "unavailable",
reason: "source was not selected",
facts: null,
},
]);
expect(result.coverage).toMatchObject({
source_parsing: "partial",
candidate_enumeration: "partial",
application_routes: "unknown",
});
expect(result.routes[0]!.path).toBe("/reports/:id");
expect(result.occurrences[0]).toMatchObject({
origin: "source-path",
source_range: null,
});
});
it("does not interpret the URL authority or file URL prefix as an App Router directory or routing base", () => {
const result = build([
source("script", `router.push('/reports');`, "https://app/reports/page.js"),
]);
expect(result.routes.map(({ path }) => path)).toEqual(["/reports"]);
const local = build([
source("file", `router.push('/reports');`, "file:///tmp/_next/123.js"),
]);
expect(local.sources[0]!.base_path).toBeNull();
expect(local.routes[0]!.path).toBe("/reports");
});
it("rejects forged aggregate counts and detached provenance", () => {
const result = build([source("one.js", `router.push('/reports');`)]);
expect(
javaScriptRouteInventorySchema.safeParse({
...result,
routes: result.routes.map((route) => ({
...route,
occurrence_count: 99,
})),
}).success,
).toBe(false);
expect(
javaScriptRouteInventorySchema.safeParse({ ...result, sources: [] })
.success,
).toBe(false);
});
it("retains a bounded prefix with explicit capacity and counts only retained facts", () => {
const inputs = [
source(
"many.js",
Array.from(
{ length: 100 },
(_, index) => `router.push('/reports/${index}');`,
).join("\n"),
),
];
const result = build(inputs, 8192);
expect(result.routes.length).toBeGreaterThan(0);
expect(result.routes.length).toBeLessThan(100);
expect(result.coverage.candidate_enumeration).toBe("partial");
expect(result.capacity?.limit_bytes).toBe(8192);
expect(Buffer.byteLength(JSON.stringify(result))).toBeLessThanOrEqual(8192);
expect(() => build(inputs, 1)).toThrow(RangeError);
});
it("anchors generic constants before applying an inferred asset base", () => {
const result = build([
source(
"prefixed.js",
`globalThis.TURBOPACK=[];createBrowserRouter([{path:'/reports'}]);const paths=['/reports/:id','/noise'];`,
"https://example.test/app/_next/static/chunks/one.js",
),
]);
expect(result.routes.map((row) => row.path)).toEqual([
"/app/reports",
"/app/reports/:id",
]);
expect(result.statistics.unanchored_literals_excluded).toBe(1);
});
it("classifies typed page configuration while generic server route calls remain unknown", () => {
const result = build([
source(
"typed.js",
`defineRoutes({reports:{_$:'reports',settings:{_$:'settings'}}});app.route('/api/users');`,
),
]);
expect(
result.routes
.filter((row) => row.classification === "page")
.map((row) => row.path),
).toEqual(["/reports", "/reports/settings"]);
expect(result.routes.find((row) => row.path === "/api/users")).toMatchObject({
classification: "unknown",
classification_basis: "unknown",
});
});
it("keeps TypeScript page filename provenance separate from syntax ranges", () => {
const result = build([
source(
"app/(account)/reports/[id]/page.tsx",
"export default function Page() { return null; }",
),
]);
expect(result.routes).toHaveLength(1);
expect(result.routes[0]).toMatchObject({
path: "/reports/:id",
classification: "page",
classification_basis: "next-app-page",
});
expect(result.occurrences[0]).toMatchObject({
origin: "source-path",
source_range: null,
});
});
it("coalesces inferred deployment roots without rewriting explicit trailing slashes", () => {
const result = build([
source(
"root-call",
"router.push('/');",
"https://example.test/app/_next/static/chunks/runtime.js",
),
source(
"root-page",
"",
"https://example.test/app/_next/static/chunks/app/page.tsx",
),
source("explicit.js", "router.push('/app');router.push('/app/');"),
]);
expect(result.routes.map(({ path }) => path)).toEqual(["/app", "/app/"]);
const root = result.routes.find(({ path }) => path === "/app")!;
expect(root.source_count).toBe(3);
const page = result.occurrences.find(({ origin }) => origin === "source-url");
expect(page).toMatchObject({
route_id: root.id,
unprefixed_path: "/",
inferred_base_path: "/app",
source_range: null,
});
expect(result.unknowns.some(({ kind }) => kind === "inferred-base")).toBe(
true,
);
});
it("keeps enumeration partial when a recognized navigation target is unresolved", () => {
const result = build([source("navigation.js", "router.navigate(target);")]);
expect(result.routes).toEqual([]);
expect(result.coverage.candidate_enumeration).toBe("partial");
expect(result.unknowns).toEqual([
expect.objectContaining({
kind: "interpretation",
source_id: result.sources[0]!.id,
}),
]);
});
it.each(["/app/reports", "/reports"])(
"matches based and unbased constants against application segments for %s",
(anchor) => {
const result = build([
source(
"mixed.js",
`globalThis.TURBOPACK=[];router.push('${anchor}');const paths=['/reports/settings','/app/reports/history','/noise','/app/noise','/ROOT/node_modules/framework'];`,
"https://example.test/app/_next/static/chunks/one.js",
),
]);
expect(result.routes.map(({ path }) => path)).toEqual([
"/app/reports",
"/app/reports/history",
"/app/reports/settings",
]);
expect(result.occurrences).toEqual(
expect.arrayContaining([
expect.objectContaining({
unprefixed_path: "/reports/settings",
inferred_base_path: "/app",
}),
expect.objectContaining({
unprefixed_path: "/app/reports/history",
inferred_base_path: null,
}),
]),
);
},
);
it("keeps filename and module-derived anchors application-relative when a deployment base matches an application segment", () => {
const result = build([
source(
"page",
"",
"https://example.test/app/_next/static/chunks/app/app/page-abc.js",
),
source(
"root-runtime",
"globalThis.TURBOPACK=[];const paths=['/app/settings','/noise'];",
"https://example.test/_next/static/chunks/root.js",
),
source(
"module-runtime",
"globalThis.TURBOPACK=[];const modulePath='app/app/page.js';",
"https://example.test/app/_next/static/chunks/runtime.js",
),
]);
expect(result.routes.map(({ path }) => path)).toEqual([
"/app/app",
"/app/settings",
]);
const page = result.routes.find(({ path }) => path === "/app/app")!;
expect(page.source_count).toBe(2);
for (const origin of ["source-url", "source-syntax"])
expect(result.occurrences).toEqual(
expect.arrayContaining([
expect.objectContaining({
route_id: page.id,
origin,
unprefixed_path: "/app",
inferred_base_path: "/app",
}),
]),
);
expect(result.statistics.unanchored_literals_excluded).toBe(1);
});
it("reports generated typed configurations as partial even without retained candidates", () => {
const result = build([
source("generated.js", "defineRoutes({reports:buildRoutes()});"),
]);
expect(result.routes).toEqual([]);
expect(result.coverage.candidate_enumeration).toBe("partial");
expect(result.unknowns).toHaveLength(1);
expect(result.unknowns[0]!.kind).toBe("interpretation");
});
it.each([
["app/feed/(.)photo/page.tsx", null],
[
"page.js",
"https://example.test/_next/static/chunks/app/feed/(.)photo/page-123.js",
],
] as const)(
"retains intercepted filename metadata as partial: %s",
(path, url) => {
const result = build([
source(path, "export default function Photo(){return null;}", url),
]);
expect(result.routes).toEqual([]);
expect(result.coverage.candidate_enumeration).toBe("partial");
expect(result.unknowns).toEqual(
expect.arrayContaining([
expect.objectContaining({
kind: "interpretation",
reason: expect.stringContaining("intercepted"),
source_range: null,
}),
]),
);
},
);
@@ -0,0 +1,702 @@
import { javascriptSourceRangeSchema } from "./javascriptSourceCoordinates.js";
import { digestCanonicalValue } from "../canonicalDigest.js";
import { z } from "zod";
import { jsonValueByteLength } from "../jsonValueByteLength.js";
import type { JsonValue } from "../jsonValue.js";
import { digestSchema } from "../digests.js";
import { JAVASCRIPT_STATIC_FACT_BYTES } from "./javascriptAnalysisResourceControls.js";
import {
javaScriptRouteCandidateSchema,
javaScriptRouteMechanismSchema,
javaScriptRouteSegmentSchema,
javaScriptRouteSourceFactsSchema,
type JavaScriptRouteCandidate,
type JavaScriptRouteSourceFacts,
} from "./javascriptRouteFacts.js";
import {
classifyNextAppRoutePath,
nextScriptBasePath,
} from "./javascriptRoutePaths.js";
const sourceStatus = z.enum(["complete", "partial", "unavailable", "unknown"]);
const confidence = javaScriptRouteCandidateSchema.shape.confidence;
const sourceShape = {
id: z.string().regex(/^jss:[a-f0-9]{32}$/u),
key: z.string(),
path: z.string().nullable(),
url: z.string().nullable(),
sha256: digestSchema.nullable(),
container_sha256: digestSchema.nullable(),
status: sourceStatus,
reported_route_capacity: javaScriptRouteSourceFactsSchema.shape.capacity,
reason: z.string().nullable(),
base_path: z
.strictObject({
value: z.string(),
basis: z.literal("next-script-url"),
status: z.literal("inferred"),
})
.nullable(),
};
export const javaScriptRouteInventorySourceSchema = z.strictObject(sourceShape);
export const javaScriptRouteOccurrenceSchema = z.strictObject({
id: z.string().regex(/^jso:[a-f0-9]{32}$/u),
route_id: z.string().regex(/^jsr:[a-f0-9]{32}$/u),
source_id: sourceShape.id,
mechanism: javaScriptRouteMechanismSchema,
confidence,
derivation: javaScriptRouteCandidateSchema.shape.derivation,
source_range: javascriptSourceRangeSchema.nullable(),
origin: z.enum(["source-syntax", "source-path", "source-url"]),
unprefixed_path: z.string(),
inferred_base_path: z.string().nullable(),
segments: z.array(javaScriptRouteSegmentSchema),
});
export const javaScriptInventoryRouteSchema = z.strictObject({
id: z.string().regex(/^jsr:[a-f0-9]{32}$/u),
path: z.string().startsWith("/"),
classification: z.enum(["page", "system", "unknown"]),
classification_basis: z.enum([
"next-app-page",
"router-configuration",
"path-name-heuristic",
"unknown",
]),
confidence,
mechanisms: z.array(javaScriptRouteMechanismSchema),
occurrence_count: z.int().nonnegative(),
source_count: z.int().nonnegative(),
primary_occurrence_id: javaScriptRouteOccurrenceSchema.shape.id,
});
export const javaScriptRouteCaptureOmissionSchema = z.strictObject({
unit: z.enum(["script-records", "script-sources"]),
count: z.int().nonnegative().nullable(),
});
export const javaScriptRouteGapSchema = z.strictObject({
id: z.string().regex(/^jsg:[a-f0-9]{32}$/u),
source_id: sourceShape.id.nullable(),
kind: z.enum([
"source-unavailable",
"interpretation",
"inferred-base",
"capacity",
"capture-omission",
]),
source_range: javascriptSourceRangeSchema.nullable(),
reason: z.string(),
capture_omission: javaScriptRouteCaptureOmissionSchema.exactOptional(),
});
const capacitySchema = z.strictObject({
limit_bytes: z.int().nonnegative(),
accounted_bytes: z.int().nonnegative(),
});
const inventoryShape = z.strictObject({
inventory_id: z.string().regex(/^jsri:[a-f0-9]{64}$/u),
source_manifest_sha256: digestSchema,
sources: z.array(javaScriptRouteInventorySourceSchema),
routes: z.array(javaScriptInventoryRouteSchema),
occurrences: z.array(javaScriptRouteOccurrenceSchema),
unknowns: z.array(javaScriptRouteGapSchema),
coverage: z.strictObject({
scope: z.literal("provided-code-sources"),
source_parsing: z.enum(["complete", "partial", "unknown"]),
candidate_enumeration: z.enum(["complete", "partial"]),
application_routes: z.literal("unknown"),
}),
statistics: z.strictObject({
sources_seen: z.int().nonnegative(),
sources_retained: z.int().nonnegative(),
route_patterns: z.int().nonnegative(),
occurrences: z.int().nonnegative(),
unanchored_literals_excluded: z.int().nonnegative(),
}),
capacity: capacitySchema.nullable(),
limitations: z.array(z.string()),
});
export const javaScriptRouteInventorySchema = inventoryShape.superRefine(
(value, context) => {
const sources = new Set<string>(),
routes = new Map<
string,
z.infer<typeof javaScriptInventoryRouteSchema>
>(),
occurrences = new Map<
string,
z.infer<typeof javaScriptRouteOccurrenceSchema>
>();
for (const source of value.sources) {
if (
sources.has(source.id) ||
source.id !==
lookupId("jss", [
source.key,
source.path,
source.url,
source.sha256,
source.container_sha256,
])
)
context.addIssue({
code: "custom",
path: ["sources"],
message:
"Route source identity must be unique and bind its source/content fields",
});
sources.add(source.id);
}
for (const route of value.routes) {
if (routes.has(route.id) || route.id !== routeId(route.path))
context.addIssue({
code: "custom",
path: ["routes"],
message: "Route identity must uniquely bind its exact pattern",
});
routes.set(route.id, route);
}
const byRoute = new Map<string, typeof value.occurrences>();
for (const occurrence of value.occurrences) {
if (
occurrences.has(occurrence.id) ||
!sources.has(occurrence.source_id) ||
!routes.has(occurrence.route_id) ||
occurrence.id !==
lookupId("jso", [
occurrence.source_id,
routes.get(occurrence.route_id)?.path ?? null,
occurrence.mechanism,
occurrence.origin,
occurrence.source_range,
occurrence.segments,
])
)
context.addIssue({
code: "custom",
path: ["occurrences"],
message:
"Route occurrences require unique identities and retained source/route owners",
});
occurrences.set(occurrence.id, occurrence);
let rows = byRoute.get(occurrence.route_id);
if (rows === undefined) {
rows = [];
byRoute.set(occurrence.route_id, rows);
}
rows.push(occurrence);
}
for (const route of value.routes) {
const rows = byRoute.get(route.id) ?? [],
primary = occurrences.get(route.primary_occurrence_id);
if (
rows.length !== route.occurrence_count ||
new Set(rows.map((row) => row.source_id)).size !== route.source_count ||
primary?.route_id !== route.id ||
rows.some((row) => !route.mechanisms.includes(row.mechanism)) ||
new Set(rows.map((row) => row.mechanism)).size !==
new Set(route.mechanisms).size ||
new Set(route.mechanisms).size !== route.mechanisms.length ||
route.confidence !== primary?.confidence ||
rows.some((row) => ranks[row.confidence] > ranks[route.confidence]) ||
route.classification !==
classify(route.path, route.mechanisms).classification ||
route.classification_basis !==
classify(route.path, route.mechanisms).classification_basis
)
context.addIssue({
code: "custom",
path: ["routes"],
message:
"Route aggregates and primary provenance must match their occurrences",
});
}
if (
value.statistics.sources_retained !== value.sources.length ||
value.statistics.route_patterns !== value.routes.length ||
value.statistics.occurrences !== value.occurrences.length
)
context.addIssue({
code: "custom",
path: ["statistics"],
message: "Route statistics must count retained facts",
});
const gaps = new Set<string>();
for (const gap of value.unknowns) {
if (
gaps.has(gap.id) ||
gap.id !== routeGapId(gap) ||
(gap.source_id !== null && !sources.has(gap.source_id))
)
context.addIssue({
code: "custom",
path: ["unknowns"],
message:
"An attributed route gap requires a unique content-bound identity and retained source",
});
if (
(gap.kind === "capture-omission") !==
(gap.capture_omission !== undefined) ||
(gap.kind === "capture-omission" &&
(gap.source_id !== null || gap.source_range !== null))
)
context.addIssue({
code: "custom",
path: ["unknowns"],
message:
"Capture omissions keep capture-level counts and have no invented source or range.",
});
gaps.add(gap.id);
}
const { inventory_id, ...body } = value;
if (inventory_id !== `jsri:${hash(["javascript-routes-v1", body])}`)
context.addIssue({
code: "custom",
path: ["inventory_id"],
message:
"Inventory identity must bind all retained facts and coverage.",
});
},
);
export type JavaScriptRouteInventory = z.infer<
typeof javaScriptRouteInventorySchema
>;
export type JavaScriptInventoryRoute = z.infer<
typeof javaScriptInventoryRouteSchema
>;
export type JavaScriptRouteOccurrence = z.infer<
typeof javaScriptRouteOccurrenceSchema
>;
export type JavaScriptRouteInventorySource = z.infer<
typeof javaScriptRouteInventorySourceSchema
>;
export interface JavaScriptRouteInventoryInput {
readonly key: string;
readonly path: string | null;
readonly url: string | null;
readonly sha256: string | null;
readonly container_sha256: string | null;
readonly status: z.infer<typeof sourceStatus>;
readonly reason: string | null;
readonly facts: JavaScriptRouteSourceFacts | null;
}
const hash = (value: JsonValue): string =>
digestCanonicalValue(value, "JavaScript route fact");
const lookupId = (prefix: string, value: JsonValue): string =>
`${prefix}:${hash(value).slice(0, 32)}`;
const routeGapId = (
gap: Omit<z.infer<typeof javaScriptRouteGapSchema>, "id">,
): string =>
lookupId("jsg", [
gap.source_id,
gap.kind,
gap.source_range,
gap.reason,
...(gap.capture_omission === undefined ? [] : [gap.capture_omission]),
]);
export type JavaScriptRouteCaptureOmission = NonNullable<
z.infer<typeof javaScriptRouteGapSchema>["capture_omission"]
> & { readonly reason: string };
const routeId = (path: string): string => lookupId("jsr", path);
const order = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
const ranks = { low: 0, medium: 1, high: 2 } as const;
const classify = (
path: string,
mechanisms: readonly string[],
): Pick<
JavaScriptInventoryRoute,
"classification" | "classification_basis"
> => {
if (
/(?:^|\/)(?:auth|login|logout|signin|signup|billing|callback|callbacks|oauth|sso)(?:\/|$)/iu.test(
path,
)
)
return {
classification: "system",
classification_basis: "path-name-heuristic",
};
if (mechanisms.includes("next-app-chunk"))
return { classification: "page", classification_basis: "next-app-page" };
if (
mechanisms.includes("nested-route-object") ||
mechanisms.includes("typed-route-tree")
)
return {
classification: "page",
classification_basis: "router-configuration",
};
return { classification: "unknown", classification_basis: "unknown" };
};
const INVENTORY_LIMITATIONS = [
"Routes are static candidates from the provided code sources. Reachability, permissions, server-only routes and the complete application route set are unverified.",
"Pattern IDs are exact path identities within this inventory. Occurrence and source IDs retain source/content identity and precise syntax ranges; metadata-only discoveries have no AST range.",
"System classification is a path-name heuristic. Asset URL prefixes can differ from routing bases; inferred prefixes retain the original unprefixed path.",
"Unbound path strings require a top-level segment supported by structural routes or navigation candidates. Unknown or missing source facts do not establish route absence.",
] as const;
/** Join only owned syntax facts and source metadata; never fetch or reinterpret source text. */
export function* buildJavaScriptRouteInventorySteps(
inputs: readonly JavaScriptRouteInventoryInput[],
byteBudget = JAVASCRIPT_STATIC_FACT_BYTES,
captureOmissions: readonly JavaScriptRouteCaptureOmission[] = [],
): Generator<void, JavaScriptRouteInventory> {
const sources: JavaScriptRouteInventorySource[] = [],
occurrences: JavaScriptRouteOccurrence[] = [],
unknowns: JavaScriptRouteInventory["unknowns"] = [];
const routes = new Map<string, JavaScriptInventoryRoute>(),
sourceIdsByRoute = new Map<string, Set<string>>(),
seenOccurrences = new Set<string>(),
seenGaps = new Set<string>();
const contexts: {
input: JavaScriptRouteInventoryInput;
source: JavaScriptRouteInventorySource;
metadataPath: string | undefined;
}[] = [];
// Reserve a conservative envelope before admitting rows. It covers the four
// arrays, digests, fixed limitations and every counter at its widest encoding.
const headerBytes = jsonValueByteLength({
inventory_id: `jsri:${"f".repeat(64)}`,
source_manifest_sha256: "f".repeat(64),
sources: [],
routes: [],
occurrences: [],
unknowns: [],
capacity: {
limit_bytes: Number.MAX_SAFE_INTEGER,
accounted_bytes: Number.MAX_SAFE_INTEGER,
},
coverage: {
scope: "provided-code-sources",
source_parsing: "complete",
candidate_enumeration: "complete",
application_routes: "unknown",
},
statistics: Object.fromEntries(
[
"sources_seen",
"sources_retained",
"route_patterns",
"occurrences",
"unanchored_literals_excluded",
].map((key) => [key, Number.MAX_SAFE_INTEGER]),
),
limitations: [...INVENTORY_LIMITATIONS],
});
if (!Number.isSafeInteger(byteBudget) || byteBudget < headerBytes)
throw new RangeError(
`Route inventory budget must fit its ${headerBytes}-byte envelope.`,
);
let accounted = headerBytes,
capacity: JavaScriptRouteInventory["capacity"] = null,
partial = false,
unanchored = 0;
const reserve = (value: JsonValue): boolean => {
if (capacity !== null) return false;
const bytes = jsonValueByteLength(value, byteBudget - accounted) + 1;
if (bytes > byteBudget - accounted) {
capacity = { limit_bytes: byteBudget, accounted_bytes: accounted };
partial = true;
return false;
}
accounted += bytes;
return true;
};
const retainGap = (
body: Omit<z.infer<typeof javaScriptRouteGapSchema>, "id">,
): void => {
const value = { id: routeGapId(body), ...body };
if (!seenGaps.has(value.id) && reserve(value)) {
unknowns.push(value);
seenGaps.add(value.id);
}
};
const gap = (
source: JavaScriptRouteInventorySource,
kind: z.infer<typeof javaScriptRouteGapSchema>["kind"],
reason: string,
source_range: z.infer<typeof javascriptSourceRangeSchema> | null = null,
): void => retainGap({ source_id: source.id, kind, source_range, reason });
for (const { unit, count, reason } of captureOmissions) {
yield;
partial = true;
retainGap({
source_id: null,
kind: "capture-omission",
source_range: null,
reason,
capture_omission: { unit, count },
});
}
const manifestIds: string[] = [];
for (const input of inputs) {
yield;
const id = lookupId("jss", [
input.key,
input.path,
input.url,
input.sha256,
input.container_sha256,
]);
manifestIds.push(id);
const prefix =
input.url === null ? undefined : nextScriptBasePath(input.url);
const source: JavaScriptRouteInventorySource = {
id,
key: input.key,
path: input.path,
url: input.url,
sha256: input.sha256,
container_sha256: input.container_sha256,
status: input.status,
reported_route_capacity: input.facts?.capacity ?? null,
reason: input.reason,
base_path: prefix
? { value: prefix, basis: "next-script-url", status: "inferred" }
: null,
};
if (!reserve(source)) continue;
sources.push(source);
const metadata = classifyNextAppRoutePath(input.url ?? input.path ?? "");
const metadataPath = metadata?.kind === "known" ? metadata.path : undefined;
contexts.push({ input, source, metadataPath });
if (metadata?.kind === "unknown") {
partial = true;
gap(source, "interpretation", metadata.reason);
}
if (input.status !== "complete" || input.facts === null) {
partial = true;
gap(
source,
"source-unavailable",
input.reason ?? "Route syntax was not fully measured for this source.",
);
}
if (source.base_path !== null)
gap(
source,
"inferred-base",
"A Next script URL prefix is an inferred routing base; assetPrefix may differ from the deployed route base.",
);
if (input.facts?.capacity !== null && input.facts?.capacity !== undefined) {
partial = true;
gap(
source,
"capacity",
input.facts.capacity.kind === "result-bytes"
? `Route facts exceeded the ${input.facts.capacity.limit_bytes}-byte remaining serialized-result budget; additional candidates remain unknown.`
: `Source route work/facts reached ${input.facts.capacity.limit_bytes} accounted bytes; additional candidates remain unknown.`,
);
}
for (const unknown of input.facts?.unresolved ?? []) {
partial = true;
gap(source, "interpretation", unknown.reason, unknown.location);
}
}
const prefixPath = (
path: string,
source: JavaScriptRouteInventorySource,
applicationRelative: boolean,
): string => {
const base = source.base_path?.value;
return base === undefined ||
(!applicationRelative && (path === base || path.startsWith(`${base}/`)))
? path
: path === "/"
? base
: `${base}${path}`;
};
// Anchors and constants share application segments even when one source
// already includes its inferred deployment base. Output paths stay exact.
const applicationRoot = (
path: string,
source: JavaScriptRouteInventorySource,
applicationRelative: boolean,
): string => {
const base = source.base_path?.value;
const start =
!applicationRelative &&
base !== undefined &&
(path === base || path.startsWith(`${base}/`))
? base.length + 1
: 1;
const end = path.indexOf("/", start);
return path.slice(start, end < 0 ? undefined : end);
};
const roots = new Set<string>();
for (const { input, source, metadataPath } of contexts) {
yield;
if (metadataPath !== undefined) {
const root = applicationRoot(metadataPath, source, true);
if (root) roots.add(root);
}
for (const candidate of input.facts?.candidates ?? [])
if (candidate.mechanism !== "route-string-constant") {
const root = applicationRoot(
candidate.path,
source,
candidate.mechanism === "next-app-chunk",
);
if (root) roots.add(root);
}
}
const append = (
source: JavaScriptRouteInventorySource,
candidate: Omit<JavaScriptRouteCandidate, "location"> & {
location: z.infer<typeof javascriptSourceRangeSchema> | null;
},
origin: JavaScriptRouteOccurrence["origin"],
): void => {
if (capacity !== null) return;
// Next module paths are already application-relative. Other source
// destinations may include the deployment base themselves.
const applicationRelative = candidate.mechanism === "next-app-chunk";
const path = prefixPath(candidate.path, source, applicationRelative);
if (
candidate.mechanism === "route-string-constant" &&
!roots.has(applicationRoot(candidate.path, source, applicationRelative))
) {
unanchored++;
return;
}
const route_id = routeId(path),
inferredBase = path === candidate.path ? null : source.base_path!.value;
const identity: JsonValue = [
source.id,
path,
candidate.mechanism,
origin,
candidate.location,
candidate.segments,
];
const id = lookupId("jso", identity);
if (seenOccurrences.has(id)) return;
const occurrence: JavaScriptRouteOccurrence = {
id,
route_id,
source_id: source.id,
mechanism: candidate.mechanism,
confidence: inferredBase === null ? candidate.confidence : "low",
derivation: inferredBase === null ? candidate.derivation : "inferred",
source_range: origin === "source-syntax" ? candidate.location : null,
origin,
unprefixed_path: candidate.path,
inferred_base_path: inferredBase,
segments: origin === "source-syntax" ? candidate.segments : [],
};
let route = routes.get(path);
if (route === undefined) {
route = {
id: route_id,
path,
...classify(path, [candidate.mechanism]),
confidence: occurrence.confidence,
mechanisms: [],
occurrence_count: 0,
source_count: 0,
primary_occurrence_id: id,
};
if (
!reserve({
...route,
classification_basis: "router-configuration",
confidence: "medium",
mechanisms: javaScriptRouteMechanismSchema.options,
occurrence_count: Number.MAX_SAFE_INTEGER,
source_count: Number.MAX_SAFE_INTEGER,
})
)
return;
}
if (!reserve(occurrence)) return;
if (!routes.has(path)) {
routes.set(path, route);
sourceIdsByRoute.set(route_id, new Set());
}
occurrences.push(occurrence);
seenOccurrences.add(id);
route.occurrence_count++;
const routeSources = sourceIdsByRoute.get(route_id)!;
routeSources.add(source.id);
route.source_count = routeSources.size;
if (!route.mechanisms.includes(candidate.mechanism)) {
route.mechanisms.push(candidate.mechanism);
Object.assign(route, classify(path, route.mechanisms));
}
if (
ranks[occurrence.confidence] > ranks[route.confidence] ||
(ranks[occurrence.confidence] === ranks[route.confidence] &&
id < route.primary_occurrence_id)
) {
route.confidence = occurrence.confidence;
route.primary_occurrence_id = id;
}
};
for (const { input, source, metadataPath } of contexts) {
yield;
if (metadataPath !== undefined)
append(
source,
{
path: metadataPath,
mechanism: "next-app-chunk",
confidence: "medium",
derivation: "joined",
location: null,
segments: [],
},
source.url === null ? "source-path" : "source-url",
);
for (const candidate of input.facts?.candidates ?? []) {
yield;
append(source, candidate, "source-syntax");
if (capacity !== null) break;
}
}
sources.sort((a, b) => order(a.key, b.key) || order(a.id, b.id));
occurrences.sort((a, b) => order(a.id, b.id));
const items = [...routes.values()].sort((a, b) => order(a.path, b.path));
for (const route of items) route.mechanisms.sort(order);
unknowns.sort(
(a, b) =>
order(a.source_id ?? "", b.source_id ?? "") ||
order(a.kind, b.kind) ||
(a.source_range?.start.line ?? 0) - (b.source_range?.start.line ?? 0) ||
(a.source_range?.start.column ?? 0) -
(b.source_range?.start.column ?? 0) ||
(a.source_range?.end.line ?? 0) - (b.source_range?.end.line ?? 0) ||
(a.source_range?.end.column ?? 0) - (b.source_range?.end.column ?? 0) ||
order(a.reason, b.reason),
);
const source_manifest_sha256 = hash(manifestIds.sort(order));
const result: Omit<JavaScriptRouteInventory, "inventory_id"> = {
source_manifest_sha256,
sources,
routes: items,
occurrences,
unknowns,
capacity,
coverage: {
scope: "provided-code-sources",
source_parsing:
sources.length === 0
? "unknown"
: sources.some((source) => source.status !== "complete") ||
capacity !== null
? "partial"
: "complete",
candidate_enumeration: partial ? "partial" : "complete",
application_routes: "unknown",
},
statistics: {
sources_seen: inputs.length,
sources_retained: sources.length,
route_patterns: items.length,
occurrences: occurrences.length,
unanchored_literals_excluded: unanchored,
},
limitations: [...INVENTORY_LIMITATIONS],
};
return {
inventory_id: `jsri:${hash(["javascript-routes-v1", result])}`,
...result,
};
}
@@ -0,0 +1,92 @@
/** URL route syntax, independent of filesystem path normalization. */
export const routePath = (
value: string,
options: { relative?: boolean; navigation?: boolean } = {},
): string | undefined => {
const end = options.navigation ? value.search(/[?#]/u) : -1;
const path = end < 0 ? value : value.slice(0, end);
if (!path || /[\u0000-\u0020\u007f\\]/u.test(path) || path.startsWith("//"))
return;
if (!path.startsWith("/") && !options.relative) return;
if (/^[a-z][a-z0-9+.-]*:/iu.test(path)) return;
return path.startsWith("/") ? path : `/${path}`;
};
export type NextAppRoutePath =
| { readonly kind: "known"; readonly path: string }
| { readonly kind: "unknown"; readonly reason: string };
/** Classify a page filename without treating missing routing context as absence. */
export const classifyNextAppRoutePath = (
raw: string,
): NextAppRoutePath | undefined => {
if (raw.includes("\\")) return;
let value = raw;
if (raw.includes("://")) {
try {
value = new URL(raw).pathname;
} catch {
return;
}
}
// A deployment prefix may itself contain an `app` segment. Only the chunk
// path following Next's asset boundary can describe an App Router module.
const assetRoot = value.indexOf("/_next/");
if (assetRoot >= 0) value = value.slice(assetRoot + "/_next/".length);
const match =
/(?:^|\/)app\/(?:(.*?)\/)?page(?:-([^/?#]+)\.js|\.(?:[cm]?jsx?|tsx?)|)(?:[?#].*)?$/u.exec(
value,
);
if (!match) return;
if (match[2] !== undefined && !/(?:^|\/)static\/chunks\/app\//u.test(value))
return;
const encodedSegments = (match[1] ?? "").split("/");
// A private folder excludes the whole page, including intercepted ancestors.
// Test before decoding: %5F names are public URLs.
if (encodedSegments.some((segment) => segment.startsWith("_"))) return;
const segments: string[] = [];
for (const encoded of encodedSegments) {
let segment: string;
try {
segment = decodeURIComponent(encoded);
} catch {
return;
}
if (/^\([^)]*\)$/u.test(segment) || segment.startsWith("@")) continue;
// Intercepted routes need a parent routing context that a filename does not establish.
if (segment.startsWith("("))
return {
kind: "unknown",
reason:
"An intercepted Next page route requires parent routing context unavailable from its filename.",
};
if (segment.includes("/") || segment === "." || segment === "..") return;
const optional = /^\[\[\.\.\.([^\]]+)\]\]$/u.exec(segment);
const catchAll = /^\[\.\.\.([^\]]+)\]$/u.exec(segment);
const parameter = /^\[([^\]]+)\]$/u.exec(segment);
segments.push(
optional
? `:${optional[1]}*`
: catchAll
? `:${catchAll[1]}+`
: parameter
? `:${parameter[1]}`
: segment,
);
}
const path = routePath(`/${segments.filter(Boolean).join("/")}`);
return path === undefined ? undefined : { kind: "known", path };
};
export const nextScriptBasePath = (source: string): string | undefined => {
let path = source;
if (/^https?:\/\//iu.test(source)) {
try {
path = new URL(source).pathname;
} catch {
return;
}
} else if (!source.startsWith("/")) return;
const at = path.indexOf("/_next/");
return at < 0 ? undefined : path.slice(0, at);
};
@@ -0,0 +1,63 @@
import { expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
import { javaScriptRouteSourceFactsSchema } from "./javascriptRouteFacts.js";
import { fitJavaScriptRouteSourceFacts } from "./javascriptRouteResultBudget.js";
const facts = () =>
analyzeJavaScriptStaticSource(
`createBrowserRouter([{path:'/reports',children:[{path:'settings'},{path:'history'}]}]);`,
).routes!;
it("borrows complete source facts when the actual enclosing result has room", () => {
const input = facts();
expect(
fitJavaScriptRouteSourceFacts(
input,
Buffer.byteLength(JSON.stringify(input)),
),
).toBe(input);
});
it("preserves a bounded prefix with exact locations and explicit result capacity", () => {
const input = facts(),
original = JSON.stringify(input),
selected = fitJavaScriptRouteSourceFacts(input, 700)!;
expect(selected.candidates.length).toBeGreaterThan(0);
expect(selected.candidates.length).toBeLessThan(input.candidates.length);
expect(selected.candidates).toEqual(
input.candidates.slice(0, selected.candidates.length),
);
expect(selected.capacity).toEqual({
kind: "result-bytes",
limit_bytes: 700,
preceding_work_capacity: null,
});
expect(Buffer.byteLength(JSON.stringify(selected))).toBeLessThanOrEqual(700);
expect(JSON.stringify(input)).toBe(original);
expect(javaScriptRouteSourceFactsSchema.safeParse(selected).success).toBe(
true,
);
});
it("preserves earlier work capacity and uncertainty when transport admission also truncates", () => {
const input = facts();
input.capacity = {
kind: "route-work-or-facts",
limit_bytes: 4096,
accounted_fact_bytes: 2000,
accounted_work_bytes: 2000,
};
input.unresolved.push({
mechanism: "nested-route-object",
location: input.candidates[0]!.location,
reason: 'Unknown parent: "é💫"',
});
const selected = fitJavaScriptRouteSourceFacts(input, 800)!;
expect(selected.unresolved).toEqual(input.unresolved);
expect(selected.capacity).toMatchObject({
kind: "result-bytes",
preceding_work_capacity: input.capacity,
});
expect(Buffer.byteLength(JSON.stringify(selected))).toBeLessThanOrEqual(800);
});
it("omits an unmeasured facet when even its capacity envelope cannot fit", () => {
expect(fitJavaScriptRouteSourceFacts(facts(), 0)).toBeUndefined();
expect(fitJavaScriptRouteSourceFacts(facts(), 1)).toBeUndefined();
});
@@ -0,0 +1,51 @@
import { jsonValueByteLength } from "../jsonValueByteLength.js";
import type { JsonValue } from "../jsonValue.js";
import type { JavaScriptRouteSourceFacts } from "./javascriptRouteFacts.js";
/**
* Admit this optional facet inside the enclosing result's actual remaining
* serialized bytes. The serialization owner supplies the space after all
* existing facts and this member's key/colon/comma; no worker protocol lives here.
* Omission means even a capacity envelope cannot fit, never an empty measurement.
*/
export const fitJavaScriptRouteSourceFacts = (
facts: JavaScriptRouteSourceFacts,
availableBytes: number,
): JavaScriptRouteSourceFacts | undefined => {
if (availableBytes <= 0) return undefined;
if (jsonValueByteLength(facts, availableBytes) <= availableBytes)
return facts;
const result: JavaScriptRouteSourceFacts = {
candidates: [],
unresolved: [],
capacity: {
kind: "result-bytes",
limit_bytes: availableBytes,
preceding_work_capacity:
facts.capacity === null
? null
: facts.capacity.kind === "route-work-or-facts"
? facts.capacity
: facts.capacity.preceding_work_capacity,
},
};
let bytes = jsonValueByteLength(result, availableBytes);
if (bytes > availableBytes) return undefined;
const append = <T extends JsonValue>(
source: readonly T[],
output: T[],
): void => {
for (const fact of source) {
const added =
jsonValueByteLength(fact, availableBytes - bytes) +
(output.length === 0 ? 0 : 1);
if (bytes + added > availableBytes) break;
output.push(fact);
bytes += added;
}
};
// Preserve uncertainty before route candidates when a prefix must be returned.
append(facts.unresolved, result.unresolved);
append(facts.candidates, result.candidates);
return result;
};
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -282,6 +282,8 @@ export function* collectSemanticMemberMutationsSteps(
readonly mutation?: t.Node;
readonly originAt: t.Node | undefined;
readonly widenedGetterResult?: true;
readonly definiteReceiver: boolean;
readonly assignedSources?: readonly SemanticIterationSource[];
}[] = [];
const iterableReferences: IterableReference[] = [];
const iterableReferenceKeys = new WeakMap<t.Node, Set<string>>();
@@ -298,6 +300,8 @@ export function* collectSemanticMemberMutationsSteps(
reference.originAt?.end,
reference.iterationOnly,
reference.widenedGetterResult === true,
reference.definiteReceiver === true,
reference.assignedSources?.[0]?.target?.start,
reference.requiredSources?.map((source) => [
source.node.start,
source.node.end,
@@ -335,16 +339,19 @@ export function* collectSemanticMemberMutationsSteps(
ownerInitializer?: t.Node,
widenedGetterResult = false,
assignedSources?: readonly SemanticIterationSource[],
definiteReceiver = true,
): void => {
const pending: (ReferencedValue & {
readonly bindings: ReadonlySet<string>;
readonly originAt: t.Node | undefined;
readonly definiteReceiver: boolean;
})[] = [
{
node,
path,
bindings,
originAt,
definiteReceiver,
...(ownerInitializer === undefined ? {} : { ownerInitializer }),
},
];
@@ -525,7 +532,7 @@ export function* collectSemanticMemberMutationsSteps(
current.originAt?.start ?? -1
}\u0001${current.originAt?.end ?? -1}\u0001${
current.ownerInitializer?.start ?? -1
}\u0001${current.ownerInitializer?.end ?? -1}\u0001${widenedGetterResult}\u0001${assignedSources?.[0]?.target?.start ?? -1}`;
}\u0001${current.ownerInitializer?.end ?? -1}\u0001${widenedGetterResult}\u0001${current.definiteReceiver}\u0001${assignedSources?.[0]?.target?.start ?? -1}`;
let covering = coveringPaths.get(pathState);
if (covering === undefined) {
covering = new SemanticPropertyPathCoverage();
@@ -574,6 +581,9 @@ export function* collectSemanticMemberMutationsSteps(
});
else
binding.memberWrites.push({
receiverIdentity: current.definiteReceiver
? "definite"
: "possible",
path,
node: mutation,
receiverInitializers,
@@ -588,6 +598,11 @@ export function* collectSemanticMemberMutationsSteps(
node: initializer.node,
path: [...initializer.projection, ...path],
bindings: nested,
definiteReceiver:
current.definiteReceiver &&
origins.initializers.length +
origins.referenceInitializers.length ===
1,
originAt: initializer.node,
});
}
@@ -602,6 +617,15 @@ export function* collectSemanticMemberMutationsSteps(
initializer,
path: copiedPath,
bindings: nested,
definiteReceiver:
current.definiteReceiver &&
origins.initializers.length +
origins.referenceInitializers.length ===
1 &&
copiedPath.every(
(key) => key !== null && typeof key !== "object",
),
...(assignedSources === undefined ? {} : { assignedSources }),
effect,
...(widenedGetterResult ? { widenedGetterResult: true } : {}),
originAt: initializer.node,
@@ -612,23 +636,27 @@ export function* collectSemanticMemberMutationsSteps(
t.isMemberExpression(expression) ||
t.isOptionalMemberExpression(expression)
) {
const key = semanticStaticPropertyKey(
expression.property,
expression.computed,
);
pending.push({
node: expression.object,
path: [
semanticStaticPropertyKey(expression.property, expression.computed),
...current.path,
],
path: [key, ...current.path],
bindings: current.bindings,
definiteReceiver: current.definiteReceiver && key !== null,
originAt: current.originAt,
});
} else {
for (const value of effect === "escape"
? escapedReferenceLeaves(
expression,
current.path,
widenedGetterResult,
)
: expandedValues(expression, current.path, effect)) {
const references =
effect === "escape"
? escapedReferenceLeaves(
expression,
current.path,
widenedGetterResult,
)
: expandedValues(expression, current.path, effect);
for (const value of references) {
if (
value.escapes &&
(effect !== "escape" ||
@@ -651,6 +679,8 @@ export function* collectSemanticMemberMutationsSteps(
path: value.path,
fallbackPath: value.iterableFallbackPath,
bindings: current.bindings,
definiteReceiver: false,
...(assignedSources === undefined ? {} : { assignedSources }),
effect,
originAt: current.originAt,
...(widenedGetterResult ? { widenedGetterResult: true } : {}),
@@ -660,6 +690,21 @@ export function* collectSemanticMemberMutationsSteps(
pending.push({
...value,
bindings: current.bindings,
definiteReceiver:
current.definiteReceiver &&
references.length === 1 &&
value.inherited !== true &&
value.escapes !== true &&
value.widenedGetterResult !== true &&
value.iterableFallbackPath === undefined &&
value.ownerInitializer === undefined &&
!t.isAwaitExpression(expression) &&
current.path.every(
(key) => key !== null && typeof key !== "object",
) &&
value.path.every(
(key) => key !== null && typeof key !== "object",
),
originAt: current.originAt,
});
}
@@ -896,6 +941,10 @@ export function* collectSemanticMemberMutationsSteps(
path: [null, ...reference.path],
fallbackPath: [],
bindings: reference.bindings,
definiteReceiver: false,
...(reference.assignedSources === undefined
? {}
: { assignedSources: reference.assignedSources }),
effect: reference.effect,
...(reference.widenedGetterResult
? { widenedGetterResult: true }
@@ -917,6 +966,10 @@ export function* collectSemanticMemberMutationsSteps(
path: reference.path,
fallbackPath: projection,
bindings: reference.bindings,
definiteReceiver: false,
...(reference.assignedSources === undefined
? {}
: { assignedSources: reference.assignedSources }),
effect: reference.effect,
...(reference.widenedGetterResult
? { widenedGetterResult: true }
@@ -936,6 +989,8 @@ export function* collectSemanticMemberMutationsSteps(
reference.originAt,
undefined,
reference.widenedGetterResult === true,
reference.assignedSources,
reference.definiteReceiver,
);
}
for (const [index, reference] of [...iterableReferences].entries()) {
@@ -962,6 +1017,8 @@ export function* collectSemanticMemberMutationsSteps(
reference.originAt,
undefined,
reference.widenedGetterResult === true,
reference.assignedSources,
reference.definiteReceiver === true,
);
}
// Native Map lookup methods do not expose their receiver to user code.
@@ -1002,6 +1059,8 @@ interface IterableReference extends ReferencedValue {
readonly mutation?: t.Node;
readonly originAt: t.Node | undefined;
readonly iterationOnly?: boolean;
readonly definiteReceiver?: boolean;
readonly assignedSources?: readonly SemanticIterationSource[];
readonly requiredSources?: NonNullable<
SemanticIterationSource["requiredSources"]
>;
@@ -40,20 +40,14 @@ it("resolves exact keys with source-order overrides, spreads and accessors", ()
const fixture = objectAndState(
`const o = { a: 1, ...base, b: 2, a: 3, [dynamic]: 4, __proto__: proto, ...{ b: 5 }, get c() { return 6; }, c: 7 };`,
);
// Expectations match the full source-order scan these exact keys replace.
// Later definite own fields exclude earlier computed-key alternatives.
expect(spelled(fixture, "a")).toEqual([
["{ b: 5 }", '["a"]'],
["4", "[]"],
["3", "[]"],
]);
expect(spelled(fixture, "b")).toEqual([
["{ b: 5 }", '["b"]'],
["4", "[]"],
]);
expect(spelled(fixture, "c")).toEqual([
["7", "[]"],
["4", "[]"],
]);
expect(spelled(fixture, "b")).toEqual([["{ b: 5 }", '["b"]']]);
expect(spelled(fixture, "c")).toEqual([["7", "[]"]]);
expect(spelled(fixture, "missing")).toEqual([
["{ b: 5 }", '["missing"]'],
["proto", '["missing"]'],
@@ -690,6 +690,19 @@ class ObjectReferenceWalk {
property: t.ObjectMethod | t.ObjectProperty,
name: string | null,
): void {
// An earlier unknown computed key cannot replace a later definite slot.
// Spreads obey the same overwrite proof; paired named accessors below
// retain their separate getter/setter semantics.
if (
name === null &&
this.#key != null &&
typeof this.#key !== "object" &&
(t.isObjectMethod(property, { kind: "get" })
? this.#data.has(String(this.#key)) ||
this.#getters.has(String(this.#key))
: this.#overwritten.has(String(this.#key)))
)
return;
if (
name !== null &&
!semanticPropertyPathKeyMatches(this.#key ?? null, name)
@@ -234,6 +234,7 @@ const projectedSourceFactsAtRead = (
return (
capture !== undefined &&
receiverAt !== undefined &&
replacement.receiverIdentity === "definite" &&
t.isAssignmentExpression(replacement.node, { operator: "=" }) &&
samePrefix(replacement, effect) &&
semanticTopLevelEffectFollowsCapture(
@@ -290,6 +291,7 @@ const projectedSourceFactsAtRead = (
| undefined;
for (const effect of writes) {
if (
effect.receiverIdentity === "definite" &&
t.isAssignmentExpression(effect.node, { operator: "=" }) &&
effect.path.every(
(key, index) =>
@@ -22,6 +22,7 @@ import {
} from "./javascriptSemanticState.js";
import {
semanticStaticPropertyKey,
semanticMemberChain,
unwrapJavaScriptExpression,
} from "./javascriptAstValues.js";
import { semanticPropertyPathKeyMatches } from "./javascriptSemanticPropertyPaths.js";
@@ -1354,32 +1355,6 @@ export const semanticGlobalProcessPathOrigin = (
};
};
/** Strip a static member chain, returning its root expression and member path. */
export const semanticMemberChain = (
node: t.Node | null | undefined,
):
| { readonly root: t.Node | null | undefined; readonly members: string[] }
| undefined => {
const members: string[] = [];
let current = node;
while (current !== null && current !== undefined) {
current = unwrapJavaScriptExpression(current).node;
if (
!t.isMemberExpression(current) &&
!t.isOptionalMemberExpression(current)
)
break;
const member = semanticStaticPropertyKey(
current.property,
current.computed,
);
if (member === null || !t.isNode(current.object)) return undefined;
members.push(member);
current = current.object;
}
return { root: current, members: members.reverse() };
};
/** Recover an unshadowed literal require origin and its exact member path. */
export const semanticRequireOrigin = (
node: t.Node | null | undefined,
@@ -5,6 +5,22 @@ import * as t from "@babel/types";
import { parseJavaScriptSource } from "./javascriptSourceParser.js";
import { indexJavaScriptBindingsWithReceiversSteps } from "./javascriptSemanticAnalysis.js";
import { semanticProjectedSourcesAtRead } from "./javascriptSemanticProjectedSources.js";
import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js";
import { onlyBinding } from "./javascriptSemanticAnalysis.fixture.js";
it("excludes overwritten unknown-key sources from member mutations", () => {
const result = analyzeJavaScriptSemantics(`
const independent={child:{tag:'retained'}};
const selected={child:{tag:'separate'}};
const registry={[key]:independent,query:selected};
registry.query.child.tag='changed';
const observed=independent.child.tag;
`);
expect(onlyBinding(result, "observed").value).toEqual({
status: "literal",
value: "retained",
});
});
const shapes = [
{
@@ -3,6 +3,7 @@ import type { SemanticIterationSource } from "./javascriptSemanticIterationSourc
import * as t from "@babel/types";
import {
semanticStaticPropertyKey,
staticJavaScriptTruthiness,
unwrapJavaScriptExpression,
} from "./javascriptAstValues.js";
import {
@@ -60,6 +61,8 @@ export interface JavaScriptSemanticMemberEffect {
readonly assignedSources?: readonly SemanticIterationSource[];
/** Binding values whose receiver identities were selected for this effect. */
readonly receiverInitializers?: readonly JavaScriptSemanticInitializer[];
/** Proof that the write selects this captured receiver, not just a possible alias. */
readonly receiverIdentity?: "definite" | "possible";
/** Capture of this binding used while following an aliased effect receiver. */
readonly receiverAt?: t.Node;
}
@@ -412,7 +415,7 @@ const semanticWithRequireLookup = (
? "captures"
: "unknown";
}
const truthiness = semanticWithStaticTruthiness(entry);
const truthiness = staticJavaScriptTruthiness(entry);
return truthiness === true
? "skips"
: truthiness === false
@@ -493,23 +496,6 @@ const finalSemanticWithStringProperty = (
return undefined;
};
const semanticWithStaticTruthiness = (node: t.Node): boolean | undefined => {
const value = unwrapJavaScriptExpression(node).node;
if (t.isBooleanLiteral(value)) return value.value;
if (t.isNullLiteral(value)) return false;
if (t.isStringLiteral(value)) return value.value.length > 0;
if (t.isNumericLiteral(value))
return value.value !== 0 && !Number.isNaN(value.value);
if (
t.isObjectExpression(value) ||
t.isArrayExpression(value) ||
t.isFunctionExpression(value) ||
t.isArrowFunctionExpression(value)
)
return true;
return undefined;
};
const isSemanticWithPrimitive = (node: t.Node): boolean =>
t.isNullLiteral(node) ||
t.isBooleanLiteral(node) ||
@@ -39,4 +39,7 @@ export const javascriptSourceRangeSchema = z
path: ["end"],
message: "Source range end must not precede its start",
});
});
})
.describe(
"One-based lines, zero-based UTF-16 columns; start inclusive, end exclusive.",
);
@@ -1,5 +1,7 @@
import { uniqueSortedBy } from "../canonicalOrdering.js";
import * as t from "@babel/types";
import { JavaScriptRouteScan } from "./javascriptRouteScan.js";
import type { JavaScriptRouteSourceFacts } from "./javascriptRouteFacts.js";
import { RETURNED_REFERENCE_WIDENING_LIMITATION } from "./javascriptSemanticCoverage.js";
import { collectElectronWrappedCallPaths } from "./electronWrappedCallPaths.js";
import {
@@ -26,12 +28,12 @@ import {
} from "./javascriptStaticAnalysisHelpers.js";
import {
addReference,
addEndpoint,
addSourceMapDirectives,
finishPartialNetworkEndpoints,
finishModuleRequireCalls,
finishSqliteStorageCalls,
inspectCall,
inspectRouteProperty,
inspectRoleProperty,
inspectRpcNode,
finishRpcScanSteps,
@@ -112,7 +114,13 @@ export function* analyzeParsedJavaScriptStaticSourceSteps(
rpcScan: createJavaScriptRpcScan(source, receivers.rpcClientRoots, index),
expressionShapes: createSemanticExpressionShapeResolver(bindings),
};
yield* traverseStaticSourceSteps(file, findings, bindings);
const routeScan = yield* JavaScriptRouteScan.createSteps(
file,
index,
(fact) => addEndpoint(findings, { ...fact, kind: "route" }),
);
yield* traverseStaticSourceSteps(file, findings, bindings, routeScan);
const routes = yield* routeScan.finishSteps();
yield* finishElectronBrowserWindowsSteps(findings, index);
if (accumulator.pendingModuleRequireCalls.length > 0) {
yield* index.indexMemberMutationFactsSteps();
@@ -134,17 +142,19 @@ export function* analyzeParsedJavaScriptStaticSourceSteps(
electronIdentity,
);
addSourceMapDirectives(source, file.comments ?? [], accumulator);
return finalizeStaticAnalysis(source, file, accumulator, bindings);
return finalizeStaticAnalysis(source, file, accumulator, bindings, routes);
}
function* traverseStaticSourceSteps(
file: ParsedJavaScriptSource,
findings: JavaScriptFindingContext,
bindings: JavaScriptSemanticAnalysisState,
routeScan: JavaScriptRouteScan,
): Generator<void, void> {
yield* traverseJavaScriptAstSteps(file, {
enter: (node) => {
enter: (node, parent) => {
findings.accumulator.visitedNodes += 1;
routeScan.inspect(node, parent);
inspectNode(node, findings, bindings);
return undefined;
},
@@ -157,6 +167,7 @@ const finalizeStaticAnalysis = (
file: ParsedJavaScriptSource,
accumulator: AnalysisAccumulator,
bindings: JavaScriptSemanticAnalysisState,
routes: JavaScriptRouteSourceFacts,
): JavaScriptStaticAnalysis => {
const parserErrors = file.errors.length;
const limitations = [
@@ -216,6 +227,7 @@ const finalizeStaticAnalysis = (
accumulator.endpoints,
accumulator.moduleRangeIndex,
),
routes,
storage: finalizeLocatedFindings(
accumulator.storage,
accumulator.moduleRangeIndex,
@@ -319,7 +331,6 @@ const inspectNode = (
specifier: stringValue(node.source),
});
if (t.isObjectProperty(node)) {
inspectRouteProperty(node, findings);
inspectRoleProperty(node, findings);
}
inspectRpcNode(node, findings);
@@ -9,7 +9,6 @@ import {
addLocatedFinding,
} from "./javascriptStaticAnalysisFindings.js";
import {
STATIC_ROUTE_CALL_NAMES,
argumentNode,
argumentValue,
calleeName,
@@ -442,16 +441,6 @@ const inspectEndpointCall = (
context: FindingContext,
bindings: JavaScriptSemanticAnalysisState,
): void => {
const route = STATIC_ROUTE_CALL_NAMES.some(
(candidate) => name === candidate || name.endsWith(`.${candidate}`),
);
if (route && first !== undefined)
addEndpoint(context, {
node,
kind: "route",
value: first,
mechanism: `call:${name}`,
});
if (isKnownNetworkRequest(node, bindings)) {
const target = argumentNode(node.arguments[0]);
if (target === undefined) return;
@@ -747,21 +736,6 @@ export function* finishRpcScanSteps(
}
}
export const inspectRouteProperty = (
node: t.ObjectProperty,
context: FindingContext,
): void => {
const key = semanticStaticPropertyName(node.key, node.computed);
const value = stringValue(node.value);
if ((key === "path" || key === "route") && value !== undefined)
addEndpoint(context, {
node,
kind: "route",
value,
mechanism: `property:${key}`,
});
};
export const inspectRoleProperty = (
node: t.ObjectProperty,
context: FindingContext,
@@ -896,7 +870,10 @@ export const addReference = (
});
};
const addEndpoint = (context: FindingContext, input: EndpointInput): void =>
export const addEndpoint = (
context: FindingContext,
input: EndpointInput,
): void =>
addLocatedFinding(context, {
collection: context.accumulator.endpoints,
key: `endpoint\0${input.kind}\0${input.interpretation ?? "literal"}\0${input.resolution ?? "exact"}\0${input.value}`,
@@ -1,4 +1,5 @@
import type { ElectronStaticFindings } from "./electronStaticAnalysisTypes.js";
import type { JavaScriptRouteSourceFacts } from "./javascriptRouteFacts.js";
/** Token semantics shared by bundle producers and worker admission. */
export const JAVASCRIPT_AST_FINGERPRINT_ALGORITHM = "babel-ast-v3";
@@ -121,6 +122,8 @@ export interface JavaScriptStaticAnalysis {
readonly visited_ast_nodes: number;
readonly references: readonly JavaScriptStaticReference[];
readonly endpoints: readonly JavaScriptStaticEndpoint[];
/** Missing in historical analyses; absence means unmeasured route coverage. */
readonly routes?: JavaScriptRouteSourceFacts;
readonly storage: readonly JavaScriptStaticStorage[];
readonly bundler_registrations: readonly JavaScriptBundlerRegistration[];
readonly role_paths: readonly JavaScriptRolePath[];
+2
View File
@@ -1,4 +1,5 @@
import { z } from "zod";
import { javaScriptRouteInventorySchema } from "./javascript/javascriptRouteInventory.js";
import {
MAX_BROWSER_DEADLINE_MS,
@@ -362,6 +363,7 @@ export type WebSourceMaps = z.infer<typeof webSourceMapsSchema>;
/** Provider-neutral result of JavaScript bundle reverse engineering. */
export const webBundleAnalysisSchema = z.object({
route_inventory: javaScriptRouteInventorySchema.exactOptional(),
capture: z.object({
target_url: z.string(),
scripts_observed: z.int().min(0),
+54
View File
@@ -1,3 +1,7 @@
import {
projectAnalysisView,
analysisViewRequestSchema,
} from "./analysisView/analysisView.js";
import { describe, expect, it } from "vitest";
import { createWebPageInspection } from "../../tests/support/browser/webPageInspection.js";
@@ -1982,3 +1986,53 @@ it("retains distinct WebMCP descriptions and names while deduplicating equivalen
),
).toBe(true);
});
it("keeps known capture omissions in compact route summaries and queryable gap facts", () => {
const captured = inspection("createBrowserRouter([{path:'/reports'}]);");
captured.completeness.excluded.push(
{ section: "scripts", reason: "resource_budget_exhausted", count: 3 },
{
section: "script_sources",
reason: "resource_budget_exhausted",
count: null,
},
);
const report = analyzeCapturedWebBundle(captured),
parent = {
evidenceId: `ev_${"a".repeat(64)}`,
operation: "analyze_web_bundle",
normalizedResult: report,
limitations: report.limitations,
subject: null,
};
const summary = projectAnalysisView(
parent,
analysisViewRequestSchema.parse({
kind: "javascript-routes",
collection: "summary",
}),
);
if (!summary.ok) throw summary.error;
if (summary.value.kind !== "javascript-routes") throw new Error("Wrong view");
expect(summary.value.source_coverage.candidate_enumeration).toBe("partial");
expect(summary.value.summary!.reported_capture_omissions).toEqual(
expect.arrayContaining([
expect.objectContaining({ unit: "script-records", count: 3 }),
expect.objectContaining({ unit: "script-sources", count: null }),
]),
);
const gaps = projectAnalysisView(
parent,
analysisViewRequestSchema.parse({
kind: "javascript-routes",
collection: "unknowns",
where: { kinds: ["capture-omission"] },
fields: ["capture_omission", "reason"],
}),
);
if (!gaps.ok) throw gaps.error;
if (gaps.value.kind !== "javascript-routes") throw new Error("Wrong view");
expect(gaps.value.items).toHaveLength(2);
for (const row of gaps.value.items)
expect(row).toMatchObject({ source_id: null, provenance: null });
});
+2 -1
View File
@@ -17,7 +17,7 @@ export const analyzeCapturedWebScript = (
script: IncludedScript,
): WebBundleScriptAnalysis => {
const accumulator = createWebBundleAnalysisAccumulator();
analyzeScript(script, accumulator);
const routeFacts = analyzeScript(script, accumulator);
const failed = accumulator.failedScripts.length > 0;
const recovered = accumulator.recoveredScripts.length > 0;
return webBundleScriptAnalysisSchema.parse({
@@ -26,6 +26,7 @@ export const analyzeCapturedWebScript = (
parse_status: failed ? "failed" : recovered ? "recovered" : "parsed",
visited_ast_nodes: accumulator.visitedNodes,
widened_return_references: accumulator.widenedScripts.length > 0,
route_facts: routeFacts,
observations: {
edges: accumulator.edges,
routes: accumulator.routes,
+21 -40
View File
@@ -3,6 +3,8 @@ import type {
IncludedScript,
} from "./webBundleAnalyzerState.js";
import * as t from "@babel/types";
import { JavaScriptRouteScan } from "./javascript/javascriptRouteScan.js";
import type { JavaScriptRouteSourceFacts } from "./javascript/javascriptRouteFacts.js";
import { sanitizeEndpointCandidate } from "./browserObservation.js";
import { compositeKey } from "./unicodeCodePointOrder.js";
@@ -21,6 +23,7 @@ import {
unwrapJavaScriptExpression,
} from "./javascript/javascriptAstValues.js";
import {
STATIC_ROUTE_CALL_NAMES,
calleeName,
endpointArgument,
stringValue,
@@ -43,11 +46,11 @@ type Inference = WebBundleAnalysis["inferences"][number];
export const analyzeScript = (
script: IncludedScript,
accumulator: AnalysisAccumulator,
): void => {
): JavaScriptRouteSourceFacts | null => {
const file = parseJavaScriptSource(script.source.artifact.text);
if (file === null) {
accumulator.failedScripts.push(script.script_key);
return;
return null;
}
accumulator.parsedScripts += 1;
if ((file.errors?.length ?? 0) > 0)
@@ -58,6 +61,18 @@ export const analyzeScript = (
);
completeSemanticSteps(index.indexAmbientWritesSteps());
const { receivers } = index;
const routeScan = completeSemanticSteps(
JavaScriptRouteScan.createSteps(file, index, ({ node, value, mechanism }) =>
addFinding({
collection: accumulator.routes,
script,
rawValue: value,
mechanism,
node,
accumulator,
}),
),
);
const openReceiverFacts = receivers.openReceivers;
let mutationFactsIndexed = false;
const isMapLookup = (
@@ -77,8 +92,9 @@ export const analyzeScript = (
index,
);
traverseJavaScriptAst(file, {
enter: (node) => {
enter: (node, parent) => {
accumulator.visitedNodes += 1;
routeScan.inspect(node, parent);
inspectNode(script, node, accumulator, openReceiverFacts, isMapLookup);
const endpoint = rpc.inspect(node);
if (endpoint !== undefined)
@@ -104,6 +120,7 @@ export const analyzeScript = (
});
if (index.bindings.returnedReferenceWidenings.size > 0)
accumulator.widenedScripts.push(script.script_key);
return completeSemanticSteps(routeScan.finishSteps());
};
const inspectNode = (
@@ -145,7 +162,6 @@ const inspectNode = (
accumulator,
});
}
if (t.isObjectProperty(node)) inspectRouteProperty(script, node, accumulator);
if (
t.isCallExpression(node) ||
t.isOptionalCallExpression(node) ||
@@ -180,19 +196,10 @@ const inspectCall = (
addEdge({ script, specifier, kind: "worker_import", node, accumulator });
}
if (
routeCallNames.some(
STATIC_ROUTE_CALL_NAMES.some(
(candidate) => name === candidate || name.endsWith(`.${candidate}`),
)
) {
if (first !== undefined)
addFinding({
collection: accumulator.routes,
script,
rawValue: first,
mechanism: `call:${name}`,
node,
accumulator,
});
addRouteFrameworkInference({
script,
name,
@@ -250,24 +257,6 @@ const isWebMcpRegistrationCallee = (callee: t.Node): boolean => {
);
};
const inspectRouteProperty = (
script: IncludedScript,
node: t.ObjectProperty,
accumulator: AnalysisAccumulator,
): void => {
const key = semanticStaticPropertyName(node.key, node.computed);
const route = stringValue(node.value);
if ((key === "path" || key === "route") && route !== undefined)
addFinding({
collection: accumulator.routes,
script,
rawValue: route,
mechanism: `property:${key}`,
node,
accumulator,
});
};
interface AddEdgeContext {
readonly script: IncludedScript;
readonly specifier: string;
@@ -484,14 +473,6 @@ const basis = (script: IncludedScript, detector: string) => ({
detector,
});
const routeCallNames = [
"route",
"addRoute",
"useRoutes",
"createBrowserRouter",
"createHashRouter",
] as const;
const vendorDetectors: readonly {
readonly value: string;
readonly detector: string;
+61
View File
@@ -1,3 +1,5 @@
import { buildJavaScriptRouteInventorySteps } from "./javascript/javascriptRouteInventory.js";
import { completeSemanticSteps } from "./javascript/javascriptSemanticTraversal.js";
import { RETURNED_REFERENCE_WIDENING_LIMITATION } from "./javascript/javascriptSemanticCoverage.js";
import type { WebPageInspection } from "./browserObservationSchemas.js";
import {
@@ -101,6 +103,65 @@ export const buildCapturedWebBundleAnalysis = (
omittedSourceScripts !== undefined ||
omittedSourceMaps !== undefined;
return webBundleAnalysisSchema.parse({
route_inventory: completeSemanticSteps(
buildJavaScriptRouteInventorySteps(
inspection.scripts.items.map((script) => {
const result = byKey.get(script.script_key);
const analyzed = result?.status === "analyzed" ? result : undefined;
return {
key: script.script_key,
path: null,
url: script.url,
container_sha256: null,
sha256: script.source.included
? script.source.artifact.sha256
: null,
status:
analyzed === undefined || analyzed.parse_status === "failed"
? ("unavailable" as const)
: analyzed.parse_status === "recovered"
? ("partial" as const)
: ("complete" as const),
reason: !script.source.included
? script.source.reason
: result?.status === "unavailable"
? result.reason
: analyzed === undefined
? "No route syntax result was returned"
: analyzed.parse_status === "failed"
? "Script parsing failed"
: analyzed.parse_status === "recovered"
? "Script parsing recovered from errors"
: null,
facts: analyzed?.route_facts ?? null,
};
}),
undefined,
[
...(omittedScripts === undefined
? []
: [
{
unit: "script-records" as const,
count: omittedScripts,
reason: captureBudgetReason("script records", omittedScripts),
},
]),
...(omittedSourceScripts === undefined
? []
: [
{
unit: "script-sources" as const,
count: omittedSourceScripts,
reason: captureBudgetReason(
"script source artifacts",
omittedSourceScripts,
),
},
]),
],
),
),
capture: buildCaptureObservation(
inspection,
sourceScripts,
+2
View File
@@ -1,6 +1,7 @@
import { z } from "zod";
import { webBundleAnalysisSchema } from "./webBundleAnalysis.js";
import { javaScriptRouteSourceFactsSchema } from "./javascript/javascriptRouteFacts.js";
const observationsSchema = z.strictObject({
edges: webBundleAnalysisSchema.shape.observations.shape.chunks.shape.edges,
@@ -19,6 +20,7 @@ export const webBundleScriptAnalysisSchema = z
parse_status: z.enum(["parsed", "recovered", "failed"]),
visited_ast_nodes: z.number().int().min(0),
widened_return_references: z.boolean(),
route_facts: javaScriptRouteSourceFactsSchema.nullable().optional(),
observations: observationsSchema,
inferences: webBundleAnalysisSchema.shape.inferences,
}),
@@ -4,6 +4,7 @@ import { jsonValueSchema } from "../../domain/jsonValue.js";
import { digestSchema } from "../../domain/digests.js";
import {
JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS,
JAVASCRIPT_STATIC_FACT_BYTES,
JAVASCRIPT_SEMANTIC_ITEM_MAXIMUM,
} from "../../domain/javascript/javascriptAnalysisResourceControls.js";
import { javaScriptAnalysisTransferDescriptorSchema } from "./JavaScriptAnalysisTransfer.js";
@@ -11,7 +12,7 @@ import { javaScriptAnalysisTransferDescriptorSchema } from "./JavaScriptAnalysis
/** Independent worker execution budget; source length is not an admission rule. */
export const JAVASCRIPT_ANALYSIS_WORKER_DEFAULTS = {
...JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS,
checkpointBytes: 8 * 1024 * 1024,
checkpointBytes: JAVASCRIPT_STATIC_FACT_BYTES,
protocolBytes: 64 * 1024,
} as const;
@@ -2,6 +2,7 @@ import { javaScriptSemanticUnknownOmissionsSchema } from "../../domain/javascrip
import { z } from "zod";
import { digestSchema } from "../../domain/digests.js";
import type { JavaScriptStaticAnalysis } from "../../domain/javascript/javascriptStaticAnalysisTypes.js";
import { javaScriptRouteSourceFactsSchema } from "../../domain/javascript/javascriptRouteFacts.js";
import { JAVASCRIPT_AST_FINGERPRINT_ALGORITHM } from "../../domain/javascript/javascriptStaticAnalysisTypes.js";
import type {
JavaScriptSemanticProperty,
@@ -308,6 +309,7 @@ const referenceShape = {
export const javaScriptWorkerStaticAnalysisSchema = z
.strictObject({
parse_status: z.enum(["complete", "partial", "failed"]),
routes: javaScriptRouteSourceFactsSchema.exactOptional(),
parse_error_count: count,
visited_ast_nodes: count,
references: z.array(
@@ -1,3 +1,6 @@
import { fitJavaScriptRouteSourceFacts } from "../../domain/javascript/javascriptRouteResultBudget.js";
import { jsonValueByteLength } from "../../domain/jsonValueByteLength.js";
import type { JsonValue } from "../../domain/jsonValue.js";
import type { JavaScriptSemanticProjectionCapacity } from "../../domain/javascript/javascriptSemanticFileProjection.js";
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
@@ -117,7 +120,24 @@ const analyzeJob = async (
{
phase: async (phase) => send({ kind: "phase", id: request.id, phase }),
checkpoint: async (facts) => {
const value = javaScriptWorkerStaticAnalysisSchema.parse(facts);
let value = javaScriptWorkerStaticAnalysisSchema.parse(facts);
if (value.routes !== undefined) {
const { routes, ...existing } = value;
// This is schema-checked, producer-owned JSON. Interface optional
// members can only make the size estimate conservative.
const available =
DEFAULTS.checkpointBytes -
jsonValueByteLength(
existing as JsonValue,
DEFAULTS.checkpointBytes,
) -
Buffer.byteLength(',"routes":');
const admitted = fitJavaScriptRouteSourceFacts(routes, available);
value =
admitted === undefined
? existing
: { ...existing, routes: admitted };
}
const text = JSON.stringify(value);
const bytes = Buffer.byteLength(text);
if (bytes > DEFAULTS.checkpointBytes)
+4 -4
View File
@@ -139,8 +139,8 @@ export const runDeferred = async (
// A transport published after shutdown begins is still ours to close.
await serving;
const ownedHandle = handle;
const handleResults = Promise.allSettled([
...(ownedHandle === undefined
const handleResults = Promise.allSettled(
ownedHandle === undefined
? []
: [
Promise.resolve()
@@ -148,8 +148,8 @@ export const runDeferred = async (
.then(() => {
handle = undefined;
}),
]),
]);
],
);
// Servers published before the serving handle settles join this owner;
// closeAll seals further publication before taking its snapshot.
const [handles, instances] = await Promise.all([
+1
View File
@@ -1045,6 +1045,7 @@ const registerObservationTools = ({
registerWebScriptTool(server, { ...common, artifactResources });
registerBrowserTools(server, {
...common,
evidenceById,
browser: options.browserObservation,
loadFailure: options.optionalProviderLoadFailures?.browserObservation,
});
+24 -3
View File
@@ -1,3 +1,8 @@
import type { EvidenceLookup } from "../application/EvidenceInputResolver.js";
import {
projectRetainedAnalysis,
requireAnalysisViewRetention,
} from "../application/analysisView/AnalysisViewService.js";
import type { ToolResultDelivery } from "./toolResult.js";
import type { EvidenceMcpServer } from "./EvidenceMcpServer.js";
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
@@ -37,6 +42,7 @@ interface BrowserToolRegistration {
readonly logger: Logger;
readonly loadFailure?: OptionalProviderLoadFailure | undefined;
readonly browser: BrowserObservationPort | undefined;
readonly evidenceById?: EvidenceLookup | undefined;
readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined;
readonly withAdmittedAnalysis?: WithAdmittedAnalysis;
}
@@ -96,11 +102,26 @@ export const registerBrowserTools = (
registration,
analyzeContract,
{ input, context },
(parsed, { signal, progress }) =>
analyzeWebBundle(options.browser, parsed, {
async ({ detail, view, ...request }, { signal, progress }) => {
const retention = {
recordEvidence: options.recordEvidence,
evidenceById: options.evidenceById,
};
if (detail === "summary") {
const ready = requireAnalysisViewRetention(
analyzeContract.name,
retention,
);
if (!ready.ok) return ready;
}
const analyzed = await analyzeWebBundle(options.browser, request, {
signal,
progress,
}),
});
return analyzed.ok && view !== undefined
? projectRetainedAnalysis(analyzed.value, retention, view)
: analyzed;
},
),
);
server.registerTool(
+17 -3
View File
@@ -1,6 +1,9 @@
import type { ToolResultDelivery } from "./toolResult.js";
import type { EvidenceMcpServer } from "./EvidenceMcpServer.js";
import { summarizeRetainedAnalysis } from "../application/analysisView/AnalysisViewService.js";
import {
projectRetainedAnalysis,
requireAnalysisViewRetention,
} from "../application/analysisView/AnalysisViewService.js";
import type { EvidenceLookup } from "../application/EvidenceInputResolver.js";
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
import {
@@ -113,14 +116,25 @@ export const registerElectronTools = (
analyzeContract,
undefined,
{ input, context },
async ({ detail, ...request }, { signal, progress }) => {
async ({ detail, view, ...request }, { signal, progress }) => {
if (detail === "summary") {
const ready = requireAnalysisViewRetention(
analyzeContract.name,
options,
);
if (!ready.ok) return ready;
}
const analyzed = await analyzeJavaScriptApplicationValidated(
request,
options.artifactResources,
{ signal, progress, environment: options.environment },
);
return detail === "summary" && analyzed.ok
? summarizeRetainedAnalysis(analyzed.value, options)
? projectRetainedAnalysis(
analyzed.value,
options,
view ?? { kind: "summary" },
)
: analyzed;
},
),
@@ -1,3 +1,4 @@
import { webBundleAnalysisSchema } from "../../../src/domain/webBundleAnalysis.js";
import { expect } from "vitest";
import { toolContract } from "../../../src/contracts/toolContracts.js";
@@ -39,7 +40,8 @@ const inspectDeclarations = (document: unknown) => {
const parsedEvidence = parseEvidence(parsed);
return {
evidence: parsedEvidence,
declarations: parsed.normalized_result.observations.webmcp_declarations,
declarations: webBundleAnalysisSchema.parse(parsed.normalized_result)
.observations.webmcp_declarations,
};
};
@@ -0,0 +1,473 @@
import { createHash } from "node:crypto";
import { expect, it } from "vitest";
import { readFile, writeFile, rm, mkdir } from "node:fs/promises";
import { join } from "node:path";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
import { cliTest } from "../support/cli/cliFixture.js";
import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js";
import { parseEvidence } from "../../src/domain/evidence.js";
import { javaScriptRoutesViewResultSchema } from "../../src/domain/analysisView/analysisView.js";
const routeViewFromEvidence = (evidence: unknown) =>
javaScriptRoutesViewResultSchema.parse(
parseEvidence(evidence).normalized_result,
);
const interceptedSource = "export default function Photo(){return null;}";
const independentPaths = [
"/checks/independent",
"/checks/projected",
"/checks/rest",
"/checks/inactive-default",
"/checks/inactive-branch",
"/checks/overwritten",
"/checks/member",
"/checks/config-sibling",
"/checks/optional/:id?",
];
const fixture = async () => {
const path = await createTestTempDirectory("rea-javascript-routes-");
await writeFile(
join(path, "routes.js"),
`globalThis.TURBOPACK=[];globalThis.TURBOPACK.push([1,function module(){const routes=defineRoutes({reports:{_$:'reports',settings:{_$:'settings'},history:{}}});createBrowserRouter([{path:'/account',children:[{path:':id'}]}]);router.push('/reports/' + selectedId);router.push({pathname:'/reports/' + selectedId});router.navigate(['/reports/',selectedId]);const mixed={children:[{path:'/reports/stale-ordinary',element:View},{_$:'/reports/stale-typed'}]};mixed.children[1]._$='/reports/changed';createBrowserRouter(mixed);router.push({pathname:'/reports/static-object',query:{href:'/query-data',path:'/download/file',component:'preview'}});router.addRoute('admin',{path:'settings',component:View});const aliasQuery={path:'/download/alias',component:'preview',href:'/alias-query-data',module:'app/fake/page.tsx'};const target={pathname:'/reports/aliased',query:aliasQuery};router.push(target);const independent={path:'/checks/independent',component:View};router.push({pathname:'/reports/aliased',query:independent ? {ok:1}:{ok:2}});createBrowserRouter([{path:'/reports/stale-computed',[computedKey]:'/unrelated',children:[{path:'settings'}]}]);const registry={route:{path:'/checks/projected',component:View},query:{path:'/download/projected-query',component:Preview}};const {query:payload}=registry;router.push({pathname:'/reports/aliased',query:payload});const pattern='/checks/optional/:id?';createBrowserRouter([{path:pattern}]);const restRegistry={query:{path:'/download/rest-query',component:Preview},route:{path:'/checks/rest',component:View}};const {route,...restPayload}=restRegistry;router.push({pathname:'/reports/aliased',query:restPayload});const fallback={path:'/checks/inactive-default',component:View};const {query:actualQuery=fallback}={query:{ok:1}};router.push({pathname:'/reports/aliased',query:actualQuery});const cache=[];cache.push('/checks/array-data');text.replace('/checks/string-data','');const inactiveBranch={path:'/checks/inactive-branch',component:View};router.push({pathname:'/reports/aliased',query:true?{}:inactiveBranch});const overwritten={path:'/checks/overwritten',component:View};router.push({pathname:'/reports/aliased',query:{item:overwritten,item:0}});const memberRegistry={query:{path:'/download/member-query',component:Preview},route:{path:'/checks/member',component:View}};router.push({pathname:'/reports/aliased',query:memberRegistry.query});const protoPayload={path:'/download/proto-query',component:Preview};router.push({pathname:'/reports/aliased',query:{['__proto__']:protoPayload,__proto__:null}});const replacedRegistry={query:independent};replacedRegistry.query={ok:1};router.push({pathname:'/reports/aliased',query:replacedRegistry.query});let replacedDirect=independent;replacedDirect={ok:1};router.push({pathname:'/reports/aliased',query:replacedDirect});const uncertainPayload={path:'/download/uncertain-query',component:Preview};const uncertainRegistry={query:uncertainPayload};const otherRegistry={query:{ok:1}};const uncertainAlias=flag?uncertainRegistry:otherRegistry;uncertainAlias.query={ok:1};router.push({pathname:'/reports/aliased',query:uncertainRegistry.query});const possiblePayload={path:'/download/possible-query',component:Preview};const possibleRegistry={query:possiblePayload,...unknown};router.push({pathname:'/reports/aliased',query:possibleRegistry.query});const cyclicRegistry={get a(){return this.a.b}};router.push({pathname:'/reports/aliased',query:cyclicRegistry.a});const finiteRegistry={get a(){return this},get b(){return this.a.a},path:'/download/finite-query',component:Preview};router.push({pathname:'/reports/aliased',query:finiteRegistry.a.b});const configurationRegistry={children:[{path:'/checks/config-sibling',component:View}],routes:[]};createBrowserRouter(configurationRegistry.routes);const helper='app/reports/helper/page-helpers.js';const extra='/reports/help';}]);`,
);
const intercepted = join(path, "app/feed/(.)photo");
await mkdir(intercepted, { recursive: true });
await writeFile(join(intercepted, "page.tsx"), interceptedSource);
return path;
};
cliTest(
"exports complete route Evidence and queries it after source removal",
async ({ cli }) => {
const path = await fixture(),
output = join(
await createTestTempDirectory("rea-route-evidence-"),
"routes.json",
);
const response = await cli.run({
arguments: [
"analyze-javascript-application",
path,
"--detail",
"summary",
"--view",
JSON.stringify({
kind: "javascript-routes",
collection: "routes",
fields: ["path"],
where: { path_prefix: "/reports" },
limit: 2,
}),
"--evidence_output",
output,
"--json",
],
});
expect(response.exitCode, response.stderr).toBe(0);
const envelope = response.json as {
summary: unknown;
evidence_bundle: { path: string };
};
const view = routeViewFromEvidence(envelope.summary);
expect(view.items).toHaveLength(2);
expect(view.coverage).toMatchObject({ total: 6, next_offset: 2 });
expect(view.sources[0]!.sha256).toMatch(/^[a-f0-9]{64}$/u);
await rm(path, { recursive: true });
const bundle = JSON.parse(await readFile(output, "utf8")) as {
records: { evidence_id: string }[];
};
const record = bundle.records.find(
(record) => record.evidence_id === view.parent_evidence_id,
);
expect(record).toBeDefined();
const evidenceFile = join(
await createTestTempDirectory("rea-route-record-"),
"evidence.json",
);
await writeFile(evidenceFile, JSON.stringify(record));
const repeated = await cli.run({
arguments: [
"inspect-analysis-view",
JSON.stringify({
source: {
kind: "evidence-file",
path: evidenceFile,
},
view: {
kind: "javascript-routes",
collection: "routes",
where: { path_prefix: "/reports" },
offset: 2,
limit: 2,
},
}),
"--json",
],
});
expect(repeated.exitCode, repeated.stdout + repeated.stderr).toBe(0);
expect(routeViewFromEvidence(repeated.json).items).toHaveLength(2);
},
);
cliTest(
"rejects invalid route delivery before accessing the application",
async ({ cli }) => {
for (const args of [
["--view", "{"],
[
"--detail",
"summary",
"--view",
'{"kind":"javascript-routes","collection":"routes"}',
],
]) {
const response = await cli.run({
arguments: [
"analyze-javascript-application",
"/not-opened",
...args,
"--json",
],
});
expect(response.exitCode).not.toBe(0);
expect(response.json).toMatchObject({ code: "invalid_request" });
}
},
);
it.each(["full", "compact"] as const)(
"serves retained route batches through the %s production SDK schemas",
async (profile) => {
const path = await fixture(),
{ call } = await connectLocalToolsMcp({
REA_MCP_INPUT_SCHEMA_PROFILE: profile,
});
for (const source_paths of [[], [""], [1]]) {
const invalid = await call("analyze_javascript_application", {
input_path: path,
source_paths,
});
expect(invalid.isError).toBe(true);
expect(JSON.stringify(invalid.content)).toContain("source_paths");
}
const deliveryView = { kind: "javascript-routes", collection: "routes" };
for (const delivery of [
{ view: deliveryView },
{ detail: "complete", view: deliveryView },
]) {
const invalid = await call("analyze_javascript_application", {
input_path: "/not-opened",
...delivery,
});
expect(invalid.isError).toBe(true);
}
const invalidBrowser = await call("analyze_web_bundle", {
cdp_endpoint: "http://127.0.0.1:1",
target_id: "not-opened",
allowed_origins: ["https://example.test"],
detail: "summary",
});
expect(invalidBrowser.isError).toBe(true);
const response = await call("analyze_javascript_application", {
input_path: path,
detail: "summary",
view: {
kind: "javascript-routes",
collection: "routes",
where: { path_prefix: "/account" },
},
});
expect(response.isError).not.toBe(true);
const selected = routeViewFromEvidence(response.structuredContent);
expect(selected.items).toHaveLength(2);
await rm(path, { recursive: true });
const occurrences = await call("inspect_analysis_view", {
source: {
kind: "retained-evidence",
evidence_id: selected.parent_evidence_id,
},
view: {
kind: "javascript-routes",
collection: "occurrences",
where: { route_ids: selected.items.map((row) => row.id) },
fields: ["segments"],
},
});
expect(occurrences.isError).not.toBe(true);
const result = routeViewFromEvidence(occurrences.structuredContent);
expect(result.items).toHaveLength(2);
expect(result.sources).toHaveLength(1);
expect(result.source_coverage.application_routes).toBe("unknown");
const dataPaths = await call("inspect_analysis_view", {
source: {
kind: "retained-evidence",
evidence_id: selected.parent_evidence_id,
},
view: {
kind: "javascript-routes",
collection: "routes",
where: {
paths: [
"/download/file",
"/query-data",
"/download/alias",
"/alias-query-data",
"/fake",
"/download/projected-query",
"/download/member-query",
"/download/proto-query",
"/download/uncertain-query",
"/download/possible-query",
"/download/finite-query",
"/checks/optional/:id",
"/download/rest-query",
"/checks/array-data",
"/checks/string-data",
],
},
},
});
expect(dataPaths.isError).not.toBe(true);
expect(routeViewFromEvidence(dataPaths.structuredContent).items).toEqual(
[],
);
const independent = await call("inspect_analysis_view", {
source: {
kind: "retained-evidence",
evidence_id: selected.parent_evidence_id,
},
view: {
kind: "javascript-routes",
collection: "routes",
where: {
paths: independentPaths,
},
fields: ["path"],
},
});
expect(independent.isError).not.toBe(true);
const independentItems = routeViewFromEvidence(
independent.structuredContent,
).items;
expect(independentItems).toHaveLength(independentPaths.length);
expect(independentItems).toEqual(
expect.arrayContaining(
independentPaths.map((path) => expect.objectContaining({ path })),
),
);
const unknowns = await call("inspect_analysis_view", {
source: {
kind: "retained-evidence",
evidence_id: selected.parent_evidence_id,
},
view: {
kind: "javascript-routes",
collection: "unknowns",
where: { kinds: ["interpretation"] },
},
});
expect(unknowns.isError).not.toBe(true);
const gaps = routeViewFromEvidence(unknowns.structuredContent);
expect(gaps.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
reason: expect.stringContaining("intercepted"),
}),
expect.objectContaining({ reason: expect.stringMatching(/cyclic/i) }),
]),
);
expect(gaps.source_coverage.candidate_enumeration).toBe("partial");
expect(
gaps.sources.some(
({ sha256 }) =>
sha256 ===
createHash("sha256").update(interceptedSource).digest("hex"),
),
).toBe(true);
},
);
cliTest(
"projects captured route facts through both browser adapters",
async ({ cli }) => {
const { startFakeCdpBrowser } =
await import("../fixtures/fakeCdpBrowser.js");
const browser = await startFakeCdpBrowser({
commandResult: ({ method }) =>
method === "Debugger.getScriptSource"
? {
scriptSource: `const panels=[{path:'panel',element:Panel}];const discarded={path:'/discarded',children:panels,children:[]};const secondary=[{path:'/reports',children:[{children:panels}]}];createBrowserRouter([{path:'/reports',children:[{path:'settings'}],element:jsx(Panel,{routes:secondary})}]);const query={path:'/download/web-data',component:'preview',href:'/web-query-data',module:'app/web-fake/page.tsx'};const target={pathname:'/reports/aliased-web',query};router.push(target);const independent={path:'/checks/independent-web',component:View};router.push({pathname:'/reports/aliased-web',query:!!independent});const registry={route:{path:'/checks/projected-web',component:View},query:{path:'/download/projected-web-query',component:Preview}};const {query:payload}=registry;router.push({pathname:'/reports/aliased-web',query:payload});route(('/checks/optional-web/:id?'));const restRegistry={query:{path:'/download/rest-web-query',component:Preview},route:{path:'/checks/rest-web',component:View}};const {route:excluded,...restPayload}=restRegistry;router.push({pathname:'/reports/aliased-web',query:restPayload});const fallback={path:'/checks/inactive-default-web',component:View};const {query:actualQuery=fallback}={query:{ok:1}};router.push({pathname:'/reports/aliased-web',query:actualQuery});const cache=[];cache.push('/checks/array-data-web');text.replace('/checks/string-data-web','');const inactiveBranch={path:'/checks/inactive-branch-web',component:View};router.push({pathname:'/reports/aliased-web',query:true?{}:inactiveBranch});const overwritten={path:'/checks/overwritten-web',component:View};router.push({pathname:'/reports/aliased-web',query:{item:overwritten,item:0}});const memberRegistry={query:{path:'/download/member-query-web',component:Preview},route:{path:'/checks/member-web',component:View}};router.push({pathname:'/reports/aliased-web',query:memberRegistry.query});const protoPayload={path:'/download/proto-query-web',component:Preview};router.push({pathname:'/reports/aliased-web',query:{['__proto__']:protoPayload,__proto__:null}});const replacedRegistry={query:independent};replacedRegistry.query={ok:1};router.push({pathname:'/reports/aliased-web',query:replacedRegistry.query});let replacedDirect=independent;replacedDirect={ok:1};router.push({pathname:'/reports/aliased-web',query:replacedDirect});const uncertainPayload={path:'/download/uncertain-query-web',component:Preview};const uncertainRegistry={query:uncertainPayload};const otherRegistry={query:{ok:1}};const uncertainAlias=flag?uncertainRegistry:otherRegistry;uncertainAlias.query={ok:1};router.push({pathname:'/reports/aliased-web',query:uncertainRegistry.query});const possiblePayload={path:'/download/possible-query-web',component:Preview};const possibleRegistry={query:possiblePayload,...unknown};router.push({pathname:'/reports/aliased-web',query:possibleRegistry.query});const cyclicRegistry={get a(){return this.a.b}};router.push({pathname:'/reports/aliased-web',query:cyclicRegistry.a});const finiteRegistry={get a(){return this},get b(){return this.a.a},path:'/download/finite-query-web',component:Preview};router.push({pathname:'/reports/aliased-web',query:finiteRegistry.a.b});const configurationRegistry={children:[{path:'/checks/config-sibling-web',component:View}],routes:[]};createBrowserRouter(configurationRegistry.routes);`,
}
: undefined,
});
try {
const view = {
kind: "javascript-routes",
collection: "routes",
where: {
paths: [
"/reports/settings",
"/reports/panel",
"/reports/aliased-web",
"/checks/independent-web",
"/download/web-data",
"/web-query-data",
"/web-fake",
"/checks/projected-web",
"/checks/optional-web/:id?",
"/checks/optional-web/:id",
"/download/projected-web-query",
"/checks/rest-web",
"/download/rest-web-query",
"/checks/inactive-default-web",
"/checks/array-data-web",
"/checks/string-data-web",
"/checks/inactive-branch-web",
"/checks/overwritten-web",
"/checks/member-web",
"/checks/config-sibling-web",
"/download/member-query-web",
"/download/proto-query-web",
"/download/uncertain-query-web",
"/download/possible-query-web",
"/download/finite-query-web",
],
},
};
const output = join(
await createTestTempDirectory("rea-browser-route-evidence-"),
"routes.json",
);
const response = await cli.run({
arguments: [
"analyze-web-bundle",
browser.endpoint,
"allowed-page",
"--allowed-origins",
browser.allowedOrigin,
"--observation-ms",
"0",
"--detail",
"summary",
"--view",
JSON.stringify(view),
"--evidence_output",
output,
"--json",
],
});
expect(response.exitCode, response.stderr).toBe(0);
const envelope = response.json as { summary: unknown };
const selected = routeViewFromEvidence(envelope.summary);
expect(selected.items).toHaveLength(12);
expect(selected.items).toEqual(
expect.arrayContaining([
expect.objectContaining({ path: "/reports/aliased-web" }),
expect.objectContaining({ path: "/checks/independent-web" }),
expect.objectContaining({ path: "/checks/projected-web" }),
expect.objectContaining({ path: "/checks/rest-web" }),
expect.objectContaining({ path: "/checks/inactive-default-web" }),
expect.objectContaining({ path: "/checks/inactive-branch-web" }),
expect.objectContaining({ path: "/checks/overwritten-web" }),
expect.objectContaining({ path: "/checks/member-web" }),
expect.objectContaining({ path: "/checks/config-sibling-web" }),
expect.objectContaining({ path: "/checks/optional-web/:id?" }),
]),
);
expect(selected.artifact).toBeNull();
const { call } = await connectLocalToolsMcp();
const observed = await call("analyze_web_bundle", {
cdp_endpoint: browser.endpoint,
target_id: "allowed-page",
allowed_origins: [browser.allowedOrigin],
observation_ms: 0,
detail: "summary",
view,
});
expect(observed.isError).not.toBe(true);
const result = routeViewFromEvidence(observed.structuredContent);
expect(result.items).toHaveLength(12);
expect(result.items).toEqual(
expect.arrayContaining([
expect.objectContaining({ path: "/reports/aliased-web" }),
expect.objectContaining({ path: "/checks/independent-web" }),
expect.objectContaining({ path: "/checks/projected-web" }),
expect.objectContaining({ path: "/checks/rest-web" }),
expect.objectContaining({ path: "/checks/inactive-default-web" }),
expect.objectContaining({ path: "/checks/inactive-branch-web" }),
expect.objectContaining({ path: "/checks/overwritten-web" }),
expect.objectContaining({ path: "/checks/member-web" }),
expect.objectContaining({ path: "/checks/config-sibling-web" }),
expect.objectContaining({ path: "/checks/optional-web/:id?" }),
]),
);
const retained = await call("inspect_analysis_view", {
source: {
kind: "retained-evidence",
evidence_id: result.parent_evidence_id,
},
view: { kind: "javascript-routes", collection: "sources" },
});
expect(retained.isError).not.toBe(true);
expect(
routeViewFromEvidence(retained.structuredContent).items.length,
).toBeGreaterThan(0);
} finally {
await browser.close();
}
},
120000,
);
it("keeps a large source's partial route facts queryable through SDK delivery", async () => {
const path = await createTestTempDirectory("rea-large-route-result-");
const text = Array.from(
{ length: 12000 },
(_, index) => `route('/reports/${index}/${"x".repeat(96)}');`,
).join("\n");
await writeFile(join(path, "routes.js"), text);
const { call } = await connectLocalToolsMcp({
NODE_OPTIONS: "--max-old-space-size=3072",
});
const response = await call("analyze_javascript_application", {
input_path: path,
max_semantic_nodes: 1,
max_semantic_unknowns: 8,
max_heap_mb: 512,
analysis_timeout_ms: 30000,
detail: "summary",
view: {
kind: "javascript-routes",
collection: "routes",
fields: ["path"],
limit: 2,
},
});
expect(response.isError).not.toBe(true);
const selected = routeViewFromEvidence(response.structuredContent);
expect(selected.items).toHaveLength(2);
expect(selected.source_coverage.candidate_enumeration).toBe("partial");
expect(selected.sources[0]).toMatchObject({
status: "complete",
sha256: createHash("sha256").update(text).digest("hex"),
reported_route_capacity: { kind: "result-bytes" },
});
expect(Buffer.byteLength(JSON.stringify(selected))).toBeLessThanOrEqual(
1024 * 1024,
);
const unknowns = await call("inspect_analysis_view", {
source: {
kind: "retained-evidence",
evidence_id: selected.parent_evidence_id,
},
view: {
kind: "javascript-routes",
collection: "unknowns",
where: { kinds: ["capacity"] },
fields: ["reason"],
},
});
expect(unknowns.isError).not.toBe(true);
expect(
routeViewFromEvidence(unknowns.structuredContent).items.length,
).toBeGreaterThan(0);
}, 60000);
@@ -0,0 +1,52 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it, onTestFinished, vi } from "vitest";
import * as routeInventory from "../../../../src/domain/javascript/javascriptRouteInventory.js";
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
import { reconstructJavaScriptArtifact } from "../../../support/javascriptApplicationScope.js";
it("retains completed route Evidence when cancellation arrives during inventory finalization", async () => {
const root = await createTestTempDirectory("rea-route-finalization-");
await writeFile(join(root, "routes.js"), "route('/reports');");
const controller = new AbortController();
const original = routeInventory.buildJavaScriptRouteInventorySteps;
let interrupted = false;
const hook = vi
.spyOn(routeInventory, "buildJavaScriptRouteInventorySteps")
.mockImplementation(function* (...args) {
const steps: Iterator<void, routeInventory.JavaScriptRouteInventory> =
original(...args);
try {
for (;;) {
const step = steps.next();
if (step.done) return step.value;
// Inject cancellation at the real domain finalization boundary,
// after source analysis and its preceding cancellation checkpoint.
if (!interrupted) {
interrupted = true;
controller.abort();
}
yield;
}
} finally {
steps.return?.();
}
});
onTestFinished(() => hook.mockRestore());
await expect(
reconstructJavaScriptArtifact({ input_path: root }, controller.signal),
).rejects.toMatchObject({
reason: "cancelled",
partialObservation: {
operation: "analyze_javascript_application",
normalized_result: {
route_inventory: {
routes: expect.arrayContaining([
expect.objectContaining({ path: "/reports" }),
]),
},
},
},
});
expect(interrupted).toBe(true);
});
@@ -742,3 +742,69 @@ require("node:readline").createInterface({ input: process.stdin }).once("line",
}
},
);
it("retains legacy routes and a bounded route-fact prefix inside one checkpoint budget", async () => {
const count = 12000;
const text = Array.from(
{ length: count },
(_, index) => `route('/reports/${index}/${"x".repeat(96)}');`,
).join("\n");
const worker = new JavaScriptAnalysisWorker({
heapMb: 512,
timeoutMs: 30000,
});
try {
const analyzed = await worker.analyze(source("routes.js", text), {
nodes: 1,
unknowns: 8,
});
const facts = analyzed.ok
? analyzed.value.javascript
: analyzed.error.javascript;
expect(
facts,
analyzed.ok ? undefined : analyzed.error.error.message,
).not.toBeNull();
expect(facts!.endpoints).toHaveLength(count);
expect(facts!.routes!.candidates.length).toBeGreaterThan(0);
expect(facts!.routes!.candidates.length).toBeLessThan(count);
expect(facts!.routes!.capacity).toMatchObject({ kind: "result-bytes" });
expect(Buffer.byteLength(JSON.stringify(facts))).toBeLessThanOrEqual(
8 * 1024 * 1024,
);
} finally {
await worker.close();
}
}, 60000);
it("keeps wrapped route ancestry, navigation and unknown named parents in the real worker", async () => {
const worker = new JavaScriptAnalysisWorker({
heapMb: 128,
timeoutMs: 30000,
});
try {
const analyzed = await worker.analyze(
source(
"routes.ts",
"const detail={_$:'detail'};defineRoutes({reports:{_$:'reports',detail:(detail as typeof detail)}});router.push(({pathname:'/users'} as const));router.push(({pathname:'/settings'} satisfies {pathname:string}));router.addRoute('admin',({path:'settings',component:View} as const));router.addRoute('admin',{path:'relative',children:[{path:'/public',component:View},{path:'relative'}]});defineRoutes({generated:buildRoutes()});",
),
{ nodes: 1, unknowns: 8 },
);
if (!analyzed.ok) throw analyzed.error.error;
expect(
analyzed.value.javascript.routes!.candidates.map(({ path }) => path),
).toEqual([
"/users",
"/settings",
"/reports",
"/reports/detail",
"/public",
]);
expect(analyzed.value.javascript.routes!.unresolved).toHaveLength(3);
expect(analyzed.value.javascript.routes!.unresolved[0]!.reason).toContain(
"parent",
);
} finally {
await worker.close();
}
});
@@ -31,6 +31,8 @@ const createSelectionClient = () => {
env: {
PATH: process.env.PATH ?? "",
NODE_OPTIONS: "--max-old-space-size=512 --max-semi-space-size=8",
// Exact advertised constraints require the complete schema profile.
REA_MCP_INPUT_SCHEMA_PROFILE: "full",
},
stderr: "pipe",
});