diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..af3f481
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,41 @@
+name: CI
+
+on:
+ pull_request:
+ push:
+ branches: [main]
+
+permissions: {}
+
+jobs:
+ verify:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+
+ - name: Enable Corepack
+ run: corepack enable
+
+ - name: Setup Node.js
+ uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
+ with:
+ node-version: 24.15.0
+ cache: pnpm
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
+
+ - name: Check
+ run: pnpm check
+
+ - name: Build
+ run: pnpm build
+
+ - name: Test
+ run: pnpm test
+
+ - name: Pack
+ run: |
+ mkdir -p .artifacts
+ pnpm pack --pack-destination .artifacts
diff --git a/.github/workflows/pkg-pr-new.yml b/.github/workflows/pkg-pr-new.yml
new file mode 100644
index 0000000..d34f654
--- /dev/null
+++ b/.github/workflows/pkg-pr-new.yml
@@ -0,0 +1,50 @@
+name: Publish Package Previews
+
+on:
+ pull_request:
+ branches: [main]
+
+permissions: {}
+
+jobs:
+ publish:
+ if: github.event.pull_request.head.repo.full_name == github.repository
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+
+ - name: Enable Corepack
+ run: corepack enable
+
+ - name: Setup Node.js
+ uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
+ with:
+ node-version: 24.15.0
+ cache: pnpm
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
+
+ - name: Build
+ run: pnpm build
+
+ - name: Publish package preview
+ shell: bash
+ run: |
+ set +e
+ output=$(pnpm exec pkg-pr-new publish --pnpm --previewVersion --no-template '.' 2>&1)
+ status=$?
+ set -e
+ printf '%s\n' "$output"
+
+ if [ "$status" -eq 0 ]; then
+ exit 0
+ fi
+
+ if [[ "$output" == *'There is no workflow defined'* ]]; then
+ echo '::warning title=pkg.pr.new is not enabled::Grant the pkg.pr.new GitHub App access to rstackjs/context to publish preview packages.'
+ exit 0
+ fi
+
+ exit "$status"
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..a0a936d
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+.artifacts
+coverage
+dist
+dist-tests
+node_modules
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 0000000..bdae6d9
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,4 @@
+coverage
+dist
+dist-tests
+pnpm-lock.yaml
diff --git a/.prettierrc.json b/.prettierrc.json
new file mode 100644
index 0000000..5ac85e2
--- /dev/null
+++ b/.prettierrc.json
@@ -0,0 +1,4 @@
+{
+ "printWidth": 100,
+ "singleQuote": true
+}
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..a91bc9a
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,35 @@
+# AGENTS.md
+
+## Stack
+
+- Use the Node.js and pnpm versions declared in `package.json`.
+- TypeScript package built with Rslib.
+- Rsbuild APIs power build-context observation.
+- Rstest runs tests and coverage.
+- Rslint performs lint and type-aware checks.
+
+## Commands
+
+```bash
+corepack enable
+pnpm install
+pnpm check
+pnpm build
+pnpm test
+pnpm test:coverage
+```
+
+## Architecture
+
+- Keep the context runtime independent of the `rstack` CLI package.
+- Keep Rstack-specific command and configuration adapters in `rstackjs/rstack-cli`.
+- Keep Codex and Claude plugin packaging and skills in `rstackjs/agent-skills`.
+- Preserve freshness, completeness, provenance, and evidence axes independently.
+- Missing Rstack producers must degrade to unavailable evidence rather than prevent other tools from
+ working.
+
+## Changes
+
+- Add tests for public API, store-schema, MCP-schema, or evidence-semantic changes.
+- Keep the package API and MCP tool schemas backward compatible unless a breaking change is explicit.
+- Use pkg.pr.new previews for cross-repository Rstack CLI validation.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..235ea7b
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Rstack contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index 2efcde3..acb1d2d 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,34 @@
-# context
-Rstack project context, evidence storage, and MCP runtime.
+# Rstack context
+
+`@rstackjs/context` is the runtime behind the Rstack project-context MCP. It records and queries
+checkout-local build, lint, test, coverage, and Rsdoctor evidence while keeping freshness,
+completeness, and provenance explicit.
+
+Most users install and invoke it through the `rstack` package:
+
+```bash
+rs mcp
+```
+
+The `rstack/context` export re-exports this package for programmatic consumers. Codex and Claude
+Code workflow guidance is distributed separately by
+[`rstackjs/agent-skills`](https://github.com/rstackjs/agent-skills).
+
+## Development
+
+```bash
+corepack enable
+pnpm install
+pnpm check
+pnpm build
+pnpm test
+```
+
+The package uses Rslib for builds, Rstest for tests, and Rslint for lint and type-aware checks.
+Rsbuild remains a public integration surface for build-context observers. See the
+[context engine RFC](./docs/rfc.md) for the runtime architecture and evidence semantics.
+
+Execution coverage evidence is optional and resolved against the package under test, not against
+`@rstackjs/context` itself: install `@rstest/coverage-istanbul` (or another supported Rstest
+coverage provider) as a dependency of the checkout being analyzed for coverage evidence to be
+captured. `@rstackjs/context` does not declare that provider as a peer dependency.
diff --git a/docs/repository-extraction.md b/docs/repository-extraction.md
new file mode 100644
index 0000000..524dd9d
--- /dev/null
+++ b/docs/repository-extraction.md
@@ -0,0 +1,214 @@
+# Rstack Context repository extraction design
+
+## Status
+
+Approved on 2026-08-14.
+
+## Summary
+
+Extract the existing `@rstackjs/context` package from `rstackjs/rstack-cli` into a new public
+`rstackjs/context` repository while preserving the package's focused Git history. Keep Rstack CLI as
+the thin host integration and keep the repository-distributed agent plugin in `rstackjs/agent-skills`.
+
+This is a repository and release-boundary change, not a context-engine redesign. The package name,
+public exports, evidence model, store layout, MCP tools, and agent workflows remain compatible.
+
+## Goals
+
+- Create the public `rstackjs/context` repository.
+- Preserve the commits that changed `packages/context` without importing unrelated Rstack CLI history.
+- Move the context runtime, tests, fixtures, architecture documentation, and package release ownership
+ into that repository.
+- Build with Rslib, test with Rstest, and lint with Rslint.
+- Add pkg.pr.new previews for `@rstackjs/context` pull requests.
+- Keep `rs mcp` as the agent plugin's single runtime entry point.
+- Keep Rstack-specific configuration and command integration in `rstackjs/rstack-cli`.
+- Keep the Codex and Claude plugin bundle and its skills in `rstackjs/agent-skills`.
+
+## Non-goals
+
+- Do not redesign the MCP tool catalog or evidence semantics during extraction.
+- Do not add a daemon, network service, live process registry, or second MCP server.
+- Do not split the context runtime into several packages.
+- Do not embed runtime implementation in the agent plugin.
+- Do not add an artificial Rsbuild application solely to exercise Rsbuild.
+- Do not add npm release machinery to the repository-based agent plugin.
+
+## Repository ownership
+
+### `rstackjs/context`
+
+The new repository owns:
+
+- the `@rstackjs/context` package and public exports;
+- the immutable run, context, snapshot, freshness, completeness, and provenance model;
+- the checkout-local store and workspace resolution;
+- Rsbuild and Rslib build observers;
+- explicit Rslint and Rstest capture and normalized results;
+- optional aggregate execution coverage evidence;
+- Rsdoctor artifact reading, graph normalization, and Agent CLI integration;
+- reachability, product-root, impact, diff, diagnostic, test-result, and composed evidence queries;
+- the MCP server and tool schemas;
+- engine unit tests, integration fixtures, README, and architecture RFC;
+- package CI and pkg.pr.new preview publication.
+
+### `rstackjs/rstack-cli`
+
+Rstack CLI retains:
+
+- the `rs mcp` command and stdio transport;
+- Rstack configuration target selection;
+- generated Rslint and Rstest wrapper configuration paths;
+- the Rstest related-test CLI adapter;
+- Rsbuild and Rslib configuration injection;
+- the `rstack/context` compatibility re-export;
+- CLI adapter and command integration tests;
+- user-facing `rs mcp` and Rstack configuration documentation.
+
+### `rstackjs/agent-skills`
+
+Agent Skills retains:
+
+- the existing Rstack Codex and Claude plugin manifests;
+- the launcher that resolves a workspace-local `rstack` package and invokes `rs mcp`;
+- context workflows, skills, references, and evaluations;
+- installation and real-world evaluation documentation.
+
+It contains no context engine, store, graph, or MCP implementation.
+
+## Runtime architecture
+
+```mermaid
+flowchart LR
+ Agent["Codex or Claude"] --> Plugin["rstack agent plugin"]
+ Plugin -->|"stdio: rs mcp"| CLI["rstack CLI host"]
+ CLI -->|"config adapters"| Context["@rstackjs/context"]
+ Context --> Store[("checkout-local evidence store")]
+ Context --> Doctor["explicit Rsdoctor artifact"]
+ Build["Rsbuild or Rslib process"] -->|"observer records"| Store
+ Lint["explicit Rslint capture"] --> Context
+ Test["explicit Rstest capture"] --> Context
+```
+
+The agent-facing launch path remains unchanged. Moving the runtime package does not introduce a new
+binary or require the agent plugin to understand package roots, monorepo topology, or build process
+discovery.
+
+## Package structure
+
+```text
+rstackjs/context/
+├── .github/workflows/
+│ ├── ci.yml
+│ └── pkg-pr-new.yml
+├── docs/
+│ └── rfc.md
+├── src/
+├── tests/
+├── package.json
+├── pnpm-workspace.yaml
+├── rslib.config.ts
+├── rstest.config.ts
+├── Rslint configuration
+├── tsconfig.json
+├── README.md
+└── LICENSE
+```
+
+The repository is intentionally a single-package workspace. A pnpm catalog keeps the Rstack tool
+versions explicit and reviewable.
+
+## Tooling
+
+- Rslib builds the ESM library and declarations.
+- Rstest runs the package test suite and optional coverage checks.
+- Rslint performs lint and type-aware checks.
+- Rsbuild remains a runtime API because context observers integrate with Rsbuild and Rslib builds
+ through the Rsbuild/Rspack stack.
+- pnpm uses the version declared by the source Rstack CLI repository unless a newer organization
+ convention is required during implementation.
+
+The standalone package must not depend on `rstack` for its own build or checks because `rstack`
+depends on `@rstackjs/context`. Avoiding that development dependency keeps the dependency graph
+acyclic and makes the package independently buildable.
+
+## History migration
+
+Create a minimal default branch in `rstackjs/context`, then create `codex/extract-context`. Generate
+a subtree history rooted at `packages/context` from the current Rstack context branch and merge that
+history into the extraction branch with unrelated histories allowed. Add standalone repository
+scaffolding in later commits.
+
+This preserves package-level commits and blame while excluding unrelated CLI files and commits. The
+extraction remains a normal draft pull request rather than placing not-yet-reviewed runtime code directly
+on the new repository's default branch.
+
+## Public compatibility
+
+- Preserve the npm package name `@rstackjs/context`.
+- Preserve the root and `./mcp` exports.
+- Preserve the current Node engine requirement unless verification proves it can be relaxed.
+- Update package repository, bugs, and homepage metadata for `rstackjs/context`.
+- Keep `rstack/context` as a compatibility re-export.
+- Keep all 15 MCP tool names and current structured result contracts during extraction.
+- Keep the checkout-local store schema and paths compatible.
+
+## Release and integration sequence
+
+1. Create `rstackjs/context` with a minimal default branch.
+2. Open a draft extraction pull request containing preserved package history and standalone tooling.
+3. Publish `@rstackjs/context` previews from that pull request with pkg.pr.new.
+4. Update the existing Rstack CLI draft pull request to remove `packages/context` and consume the
+ preview for cross-repository verification.
+5. Update Agent Skills repository links and evaluation fixtures only where repository ownership
+ changed; its launcher continues to call `rs mcp`.
+6. Evaluate the installed plugin against real Rsbuild, Rslib, and Rstest repositories with graceful
+ degradation when any producer is absent.
+7. Publish a stable `@rstackjs/context` version before the Rstack CLI pull request becomes merge-ready.
+8. Replace the preview dependency with the stable version and keep all coordinated pull requests in
+ draft until their dependency order is satisfied.
+
+## Graceful degradation
+
+The extraction preserves the existing independent evidence lanes:
+
+- an Rsbuild-only project can expose build and Rsdoctor evidence without Rstest or Rslint;
+- an Rslib-only project can expose library build contexts without an application build;
+- Rslint and Rstest captures run only when their explicit tools are called and dependencies exist;
+- missing coverage or its optional provider leaves execution evidence unavailable rather than zero,
+ while the selected tests still run;
+- missing Rsdoctor data leaves artifact queries unavailable without affecting stored snapshots;
+- missing producers never prevent `project_status` from reporting available contexts.
+
+## Verification
+
+The new repository must pass:
+
+- clean install with the declared pnpm version;
+- Rslib build and declaration generation;
+- Rstest unit and integration suites;
+- Rslint and type-aware checks;
+- package packing and import from a clean consumer;
+- MCP protocol smoke tests and the complete tool catalog test;
+- pkg.pr.new preview installation from a clean consumer.
+
+Rstack CLI must pass:
+
+- package build and native build prerequisites;
+- adapter and `rs mcp` integration tests;
+- Rsbuild and Rslib observer injection tests;
+- explicit Rslint and Rstest capture tests;
+- full repository checks required by its `AGENTS.md`.
+
+Agent Skills must pass:
+
+- plugin manifest validation;
+- Codex and Claude skill parity checks;
+- MCP launcher smoke tests using the context preview through Rstack CLI;
+- real-repository evaluations with partial tool availability.
+
+## Rollback
+
+Until the standalone package has a stable release, Rstack CLI can continue using its workspace copy.
+The extraction pull request and preview dependency are independently reversible. No store migration
+or user configuration change is required.
diff --git a/docs/rfc.md b/docs/rfc.md
new file mode 100644
index 0000000..e9e4c1d
--- /dev/null
+++ b/docs/rfc.md
@@ -0,0 +1,705 @@
+# RFC 0001: Rstack context engine
+
+| Field | Value |
+| ------- | --------------------------------------------------------------------------------- |
+| Status | Implemented downstream through Phase 4; Phase 5 resolved without a custom UI |
+| Created | 2026-08-12 |
+| Target | `rstack`, Rspack 2, Rsbuild 2, Rslib 1, Rstest 0.11, Rslint 0.7, and Rsdoctor 2 |
+| Scope | Headless build, lint, test, and artifact-scoped module evidence for coding agents |
+
+## Summary
+
+The Rstack Context Engine turns completed Rsbuild, Rslib, Rslint, and Rstest observations into
+immutable records under the workspace cache. A single `rs mcp` process launched by an agent host
+resolves the workspace root and exposes compact queries over those records. The same MCP also reads
+explicit Rsdoctor artifacts for build analysis and module-level reachability.
+
+This is deliberately a lean, file-based design:
+
+- independent package processes publish records into one workspace store;
+- one root-launched MCP reads every completed record in that store;
+- no coordinator daemon, task-runner integration, package-process discovery service, or development
+ server route is required;
+- Rslint and Rstest execute only through explicit one-shot MCP tools;
+- the repository-based `rstack` plugin in `rstackjs/agent-skills` registers `rs mcp` for Codex and
+ Claude Code and provides six context workflows; and
+- rich build visualization continues to use an existing Rsdoctor report through `report_link`.
+
+The unused-code feature is intentionally artifact-scoped. It identifies modules that are not
+reachable from selected roots in one Rsdoctor module graph. It does not prove that a local symbol or
+export is unused across the repository.
+
+## Motivation
+
+Rstack already provides one CLI for applications, libraries, lint, and tests, but each underlying
+tool observes a different part of development:
+
+- Rsbuild and Rslib know which configurations and environments completed and which assets and chunks
+ they emitted.
+- Rspack supplies the compilation metadata used by those build observers.
+- Rsdoctor provides a richer build artifact, module graph, optimization information, and focused
+ analysis tools.
+- Rslint provides structured diagnostics and optional whole-file fixed output.
+- Rstest provides structured file, case, error, and run results.
+
+Without a shared representation, an agent must run commands repeatedly, parse terminal output, and
+guess which package, build, or source revision an observation describes. That becomes especially
+ambiguous in monorepos where several Rslib packages and one or more Rsbuild applications run at the
+same time.
+
+The context engine gives those independent processes a small rendezvous format and gives agent hosts
+one consistent query surface.
+
+## Goals
+
+- Provide one checkout-local MCP surface for Rstack evidence.
+- Work in Rslib-only, Rsbuild-only, and mixed workspaces.
+- Keep package and build identities independent from the MCP process current working directory.
+- Record completed observations as immutable, schema-versioned files.
+- Keep build, lint, test, and Rsdoctor evidence independently selectable and fresh.
+- Reuse Rsdoctor artifacts and its existing GUI instead of duplicating them.
+- Return bounded structured results with context, snapshot, freshness, completeness, and provenance.
+- Provide task-oriented Codex and Claude Code skills for module reachability, change impact, build
+ analysis, development diagnostics, and snapshot review.
+- Keep context capture from changing the result of the underlying build command.
+
+## Non-goals
+
+- Proving arbitrary local-symbol or export dead code from a bundled artifact.
+- Treating absence from one build graph as repository-wide proof of deletion eligibility.
+- Starting background lint, test, build, watch, or indexing jobs when an MCP client connects.
+- Controlling Rslint or Rstest watch sessions.
+- Depending on Turbo, Nx, or another task runner.
+- Running a coordinator daemon or discovering live package processes.
+- Mounting MCP on an Rsbuild development-server route.
+- Providing a custom context-engine GUI or remote MCP transport in this branch.
+- Applying lint fixes or source edits.
+- Replacing the full Rsdoctor report or general source-analysis tools.
+
+## Architecture
+
+### System overview
+
+```mermaid
+flowchart LR
+ subgraph Processes["Independent Rstack processes"]
+ App["rs dev / rs build"] --> AppObserver["Rsbuild metadata observer"]
+ Library["rs lib"] --> LibObserver["Rslib metadata observer"]
+ Lint["Explicit lint_snapshot"] --> Rslint["One-shot Rslint API"]
+ Test["Explicit test_snapshot"] --> Rstest["One-shot Rstest API"]
+ end
+
+ Store[(".rstack/cache/context-v1
immutable run records")]
+ Artifact["Explicit Rsdoctor artifact"]
+ Report["Existing Rsdoctor report"]
+ MCP["rs mcp
stdio query server"]
+
+ Plugin["rstackjs/agent-skills
one rstack plugin + six context skills"]
+
+ subgraph Hosts["Agent hosts"]
+ Codex["Codex"]
+ Claude["Claude Code"]
+ end
+
+ AppObserver -->|publish| Store
+ LibObserver -->|publish| Store
+ Rslint -->|publish| Store
+ Rstest -->|publish| Store
+ Codex --> Plugin
+ Claude --> Plugin
+ Plugin -->|stdio| MCP
+ MCP -->|read completed records| Store
+ MCP -->|read selected file| Artifact
+ MCP -->|resolve report_link| Report
+ MCP -->|execute on request| Lint
+ MCP -->|execute on request| Test
+```
+
+The build observers are appended to resolved in-memory Rsbuild or Rslib configuration. They record
+completed environment compilations and do not modify the user's stored configuration. The lint and
+test paths are different: `lint_snapshot` and `test_snapshot` are explicit MCP executions that
+publish their results after the one-shot command completes.
+
+### Component responsibilities
+
+| Component | Responsibility |
+| ---------------------- | ------------------------------------------------------------------------------------------------ |
+| Rsbuild/Rslib observer | Publish metadata for each completed build environment and watch generation. |
+| Explicit capture | Run one Rslint or Rstest request and publish its normalized result. |
+| Workspace store | Hold immutable run manifests and context generations shared by independent package processes. |
+| Rsdoctor adapter | Read an explicit artifact, normalize its module graph, and invoke supported Agent CLI queries. |
+| Query layer | Select contexts and snapshots, assess freshness, traverse module graphs, page, and diff results. |
+| MCP server | Expose the query and explicit-capture tools over stdio. |
+| Plugin skills | Select the relevant tools and present their evidence boundaries to the model. |
+
+The runtime is packaged separately from the CLI facade. `@rstackjs/context` owns the store,
+producer adapters, normalized evidence, queries, and MCP implementation. `rstack` owns command and
+configuration integration, exposes the runtime through `rstack/context`, and supplies the Rstack
+config adapter when `rs mcp` runs explicit lint or test captures.
+
+### Monorepo process model
+
+The MCP process does not use its launch directory as a package or build identifier. `rs mcp` starts
+at the host-provided current working directory, walks upward to the nearest workspace manifest, Git
+checkout, or package root, and opens that workspace's store. Build processes perform the same
+workspace resolution from their loaded config path while retaining their own package root.
+
+```mermaid
+flowchart TB
+ subgraph Workspace["One workspace or checkout"]
+ LibA["packages/a
rs lib"]
+ LibB["packages/b
rs lib"]
+ LibC["packages/c
rs lib"]
+ App["apps/web
rs dev"]
+ Store[("workspace store")]
+
+ LibA -->|"run + packageRoot + contexts"| Store
+ LibB -->|"run + packageRoot + contexts"| Store
+ LibC -->|"run + packageRoot + contexts"| Store
+ App -->|"run + packageRoot + contexts"| Store
+ end
+
+ Agent["Agent session at repository root"] -->|stdio| MCP["one rs mcp server"]
+ MCP -->|"all completed runs"| Store
+```
+
+This model has no dependency on the order in which processes start. Package commands may publish
+before or after MCP starts, and multiple MCP readers can inspect the same immutable files. Turbo can
+launch the commands, but the engine neither requires nor reads Turbo's task graph.
+
+Each build context records:
+
+- `contextId`;
+- workspace-relative `packageRoot`;
+- application or library `product`;
+- optional package name and config path; and
+- environment, target, and mode.
+
+For build observations, `contextId` derives from the producer, package root, config path, product,
+environment, command, mode, and target. It therefore distinguishes several builds in one process and
+the same kind of build in different packages. Run IDs distinguish concurrent or repeated processes.
+
+A single Rslib invocation can publish several library environments. A single Rsbuild invocation can
+publish client, server, worker, or other configured environments. A workspace containing only Rslib
+packages works without an Rsbuild application; an Rsbuild-only application works without Rslib; a
+mixed workspace simply contributes both producer types to the same store.
+
+`project_status` is how an agent chooses among those builds: it returns each `runId`, descriptor,
+latest snapshot, and producer-specific freshness. Module analysis then requires that chosen
+`contextId` plus an explicit Rsdoctor `dataFile`. The current adapter does not automatically bind the
+file to the build observation; provenance labels the association `explicit-unverified` and includes
+the latest build observation when one exists.
+
+### Store layout and publication
+
+```text
+.rstack/cache/context-v1/
+└── runs/
+ └── /
+ ├── run.json
+ └── contexts/
+ └── /
+ └── generations/
+ └── -.json
+```
+
+A producer writes an immutable run manifest followed by immutable completed snapshots. Publication
+uses a same-directory temporary file and links it to its final generation name. Readers ignore
+temporary and incomplete records. The cache is disposable; Rsdoctor source artifacts remain in
+their original project-selected location.
+
+```mermaid
+sequenceDiagram
+ participant P as Package process
+ participant S as Workspace store
+ participant M as rs mcp
+ participant A as Agent
+
+ P->>S: publish run.json
+ P->>S: publish completed generation
+ A->>M: project_status
+ M->>S: read manifests and completed generations
+ S-->>M: package contexts and latest snapshots
+ M-->>A: structured workspace status
+```
+
+## Producers and evidence
+
+### Current support matrix
+
+| Producer | Activation | Current evidence |
+| -------------- | ------------------------------------- | ----------------------------------------------------------------------- |
+| Rsbuild/Rspack | `context.enabled: true` on Rstack app | Build status, hash, timing, environment, target, assets, chunks, bounds |
+| Rslib/Rspack | `context.enabled: true` on Rstack lib | The same metadata per generated library environment |
+| Rsdoctor | Explicit `dataFile` in an MCP query | Agent CLI results and normalized artifact module graph |
+| Rslint | Explicit `lint_snapshot` | File diagnostics, totals, input digests, optional fixed-output preview |
+| Rstest | Explicit one-shot `test_snapshot` | File, case, error, status, totals, and optional aggregate execution |
+
+Standalone Rspack processes are not automatically observed. In this implementation, Rspack metadata
+arrives through the Rsbuild-compatible observer used by Rstack's app and library commands.
+
+### Activation
+
+Passive build metadata capture is opt-in:
+
+```ts
+export default define({
+ context: {
+ enabled: true,
+ capture: 'metadata',
+ },
+});
+```
+
+`capture` accepts `off`, `metadata`, or `deep`. The current observer implements metadata capture;
+when `deep` is selected, the snapshot records that the deep facet is unsupported rather than
+inventing deeper evidence. `RSTACK_CONTEXT=1` enables metadata capture for a command and
+`RSTACK_CONTEXT=0` disables it.
+
+### Build metadata
+
+The Rsbuild-compatible observer is appended after Rstack resolves the relevant app or library
+configuration. For every completed environment compile it records:
+
+- producer, command, mode, environment, target, watch state, and first-compile state;
+- duration, compilation hash, error state, and warning state;
+- a bounded asset list with sizes;
+- a bounded chunk list with identifiers, files, and initial state; and
+- dropped asset and chunk counts when metadata was truncated.
+
+The observer catches its own capture failure, reports one warning, and leaves the build result
+unchanged.
+
+### Rsdoctor artifact model
+
+Rsdoctor remains the build-analysis provider. The engine accepts an explicit
+`rsdoctor-data.json`-style file, invokes the supported `@rsdoctor/agent-cli` catalog in-process only
+when requested, and can link an existing HTML report or manifest. It does not require a browser or a
+report server for normal MCP queries.
+
+For reachability, the adapter normalizes the artifact's module graph into stable paths, import
+edges, entry flags, chunk membership, optimizer bounds, and parse issues. Root selection then adds:
+
+- production entries observed in the artifact;
+- mapped `package.json` contract targets for library contexts;
+- side-effect roots; and
+- conservative roots for optimizer bailouts.
+
+Published library analysis carries an open-world bound. A package contract target that cannot be
+mapped to a module is also returned as a bound instead of being silently ignored.
+When the selected context comes only from a non-build producer such as Rstest, an explicit raw
+Rsdoctor artifact can still supply entry and conservative roots. The result reports an unknown
+product and a `product-context-unavailable` bound, so artifact reachability remains available
+without inventing application or library contract semantics.
+
+```mermaid
+flowchart LR
+ Artifact["Explicit Rsdoctor artifact"] --> Normalize["Normalize module graph"]
+ Context["Selected context (product may be unknown)"] --> Roots["Resolve product roots"]
+ Manifest["Library package.json"] --> Roots
+ Normalize --> Roots
+ Roots --> Traverse["Bounded graph traversal"]
+ Traverse --> Candidates["Unreachable module candidates"]
+ Traverse --> Explain["Shortest root path or bound"]
+ Traverse --> Impact["Dependencies or dependents"]
+```
+
+The module queries expose four state axes: production reachability, public-contract status, shipped
+chunk membership, and optimizer retention. They do not infer local-symbol reachability, export use,
+test-only use, or runtime execution.
+
+### Rslint snapshots
+
+`lint_snapshot` creates one Rslint engine, runs either `lintFiles` or `lintText`, normalizes the
+results, closes the engine, and publishes one completed snapshot. File mode defaults to the workspace
+when no patterns are supplied. Text mode records a virtual-input digest.
+
+When `includeFixPreview` is true, the snapshot may contain whole-file fixed output.
+`lint_fix_preview` returns that stored output with the original digest; it never writes the file.
+
+### Rstest snapshots
+
+`test_snapshot` runs Rstest once through its programmatic API. The request can limit files and a test
+name pattern. The resulting snapshot records normalized test files, cases, errors, totals, and the
+run status. An explicit `execution` request also enables Istanbul for that one run and stores bounded
+aggregate statement, function, and branch-arm locations with exact source digests. It does not
+attribute coverage to individual tests. The source-input set remains partial because the adapter
+does not record a complete dependency graph. When the selected package does not install the optional
+Istanbul provider, the requested tests still run and the execution facet is recorded as unavailable.
+The capture result surfaces the provider, availability, and execution-universe completeness so an
+agent sees that limitation without making a second query.
+
+The branch does not attach to an existing watch process, control watch cycles, or keep a resident
+Rstest session.
+
+### Exact-path code evidence
+
+`code_evidence` composes existing immutable records for one checkout-relative source path. It
+selects the newest Rstest and Rslint snapshots whose package root contains the path unless explicit
+snapshot IDs are supplied. An optional line narrows aggregate coverage locations. Both `contextId`
+and `dataFile` are required to add an artifact module axis; no artifact is guessed. When one source
+path has several artifact module variants, an optional `module` selector preserves the exact module
+ID or name returned by the artifact query while `path` continues to select runtime and diagnostic
+evidence.
+
+```mermaid
+flowchart LR
+ Path["Exact source path
and optional line"] --> Join["code_evidence"]
+ Test["Selected Rstest snapshot"] --> Join
+ Lint["Selected Rslint snapshot"] --> Join
+ Artifact["Optional exact-bound
Rsdoctor artifact"] --> Join
+ Join --> Coverage["Aggregate execution
observed / not-observed / unknown"]
+ Join --> Outcome["Exact-path or isolated
related-selection outcome"]
+ Join --> Diagnostics["Exact-path diagnostics"]
+ Join --> Module["Independent module state axes"]
+```
+
+Positive execution requires a positive stored hit and an exact current source digest. A zero-hit
+result becomes `not-observed` only when the instrumented universe is complete and untruncated and
+relevant locations exist. Missing, stale, partial, or truncated evidence stays unknown or
+unavailable. An exact test-file record reports its own outcome. When a capture selected exactly one
+source through `related`, the outcome may instead summarize only the test files returned for that
+isolated selection; its `related-selection` basis distinguishes that run result from source
+execution. Grouped or missing selection remains unknown. Module reachability, shipment, public
+contract, optimizer retention, and runtime coverage remain separate. No exact or isolated
+related-selection record is unknown rather than not-run; not-run requires matching skipped or todo
+records. Exact-path diagnostics are deterministically bounded to 200 items and report their total
+and truncation. Module selection tries the workspace path before a package-relative fallback so
+identical paths in sibling packages remain distinguishable. An explicit `module` selector bypasses
+that path fallback and prevents a hashed or concatenated artifact variant from being silently joined
+to a different module with the same source path.
+
+### Freshness and compatible diffs
+
+Lint and test snapshots record input digests. Query results assess those inputs against the current
+workspace and report `fresh`, `stale`, `partial`, or `unknown` with changed paths where available.
+Build, lint, and test freshness remain independent.
+
+`snapshot_diff` compares only compatible immutable snapshots:
+
+- the schema version must match;
+- the producer must match;
+- the context ID must match; and
+- the capture selection must match; and
+- both snapshots must contain the requested lint-diagnostic or test-result facet.
+
+A compatible result contains added, removed, and changed items plus the independent freshness of
+both sides. Test diffs include file errors, test cases, and unhandled run errors. An incompatible
+result returns ordinary reasons and no inferred comparison.
+
+```mermaid
+flowchart LR
+ Explicit["Explicit lint_snapshot or test_snapshot"] --> Run["One-shot producer"]
+ Run --> Record["Immutable snapshot"]
+ Record --> List["snapshot_list"]
+ List --> Query["diagnostics_list or test_results"]
+ Record --> Diff["snapshot_diff"]
+ Record --> Preview["lint_fix_preview
when captured"]
+```
+
+## MCP contract
+
+### Server lifecycle
+
+The official `rstack` plugin in `rstackjs/agent-skills` registers one local stdio server named
+`rstack` for both Codex and Claude Code. Its Node launcher first
+resolves the workspace-root local `rstack` package and invokes its `rs` binary directly. When that
+package is unavailable from the workspace root, the launcher falls back to `rs mcp` from the MCP
+host `PATH`, inheriting standard input, output, and error and propagating the delegated process
+result.
+
+The plugin runtime launches in the agent session current working directory. `rs mcp` immediately
+resolves the enclosing workspace root, so package identity comes from producer records rather than
+from the MCP launch directory. The fallback also supports installations exposed through `PATH`,
+including package-scoped monorepo tooling. No daemon handshake, port allocation, live-process
+registry, or development-server connection is involved.
+
+The MCP server reads structured content and returns an MCP `resource_link` only for an existing
+Rsdoctor report. It does not currently register MCP resources, resource templates, prompts,
+subscriptions, or a separate Rsdoctor MCP server.
+
+### Tool catalog
+
+The implemented server exposes these 15 tools:
+
+| Tool | Kind | Purpose |
+| ------------------- | ------------------ | --------------------------------------------------------------------- |
+| `project_status` | Query | List package/build contexts and their latest completed observations. |
+| `product_roots` | Query | Resolve roots for one context and explicit Rsdoctor graph. |
+| `unused_candidates` | Query | List artifact-scoped unreachable module candidates. |
+| `dead_code_explain` | Query | Explain one module's reachability, conservative retention, or bounds. |
+| `module_impact` | Query | Traverse dependencies or dependents in one explicit artifact graph. |
+| `code_evidence` | Query | Join bounded exact-path evidence without collapsing independent axes. |
+| `snapshot_list` | Query | Page immutable snapshots by producer or context. |
+| `diagnostics_list` | Query | Page normalized Rslint or Rstest diagnostics. |
+| `test_results` | Query | Page normalized test cases from a completed Rstest snapshot. |
+| `snapshot_diff` | Query | Compare diagnostics or tests from two compatible snapshots. |
+| `lint_fix_preview` | Query | Return stored fixed output without applying it. |
+| `lint_snapshot` | Explicit execution | Run one Rslint capture and publish its snapshot. |
+| `test_snapshot` | Explicit execution | Run one one-shot Rstest capture and publish its snapshot. |
+| `rsdoctor_analyze` | Query | Invoke one supported Agent CLI tool against an explicit data file. |
+| `report_link` | Query | Link an existing Rsdoctor HTML report or manifest. |
+
+Snapshot and diagnostic/test lists use bounded limits and opaque cursors. Module traversal has
+bounded depth and visit counts and reports whether analysis or result paging truncated the answer.
+All artifact tools require an explicit `dataFile`; the server neither starts a build nor guesses
+which Rsdoctor artifact the user intended.
+
+### Module claim vocabulary
+
+The reachability tools use four classifications:
+
+| Classification | Meaning |
+| -------------------------------- | ------------------------------------------------------------------- |
+| `reachable` | A production or contract root has a path to the module. |
+| `preserved-by-conservative-root` | An optimizer/side-effect root has a path to the module. |
+| `unreachable-module-candidate` | No selected root reaches it within the complete traversal. |
+| `insufficient-evidence` | Missing roots or traversal bounds prevent a complete module result. |
+
+An `unreachable-module-candidate` is a request for source and runtime verification, not a deletion
+decision. Export-level and local-symbol conclusions remain outside this branch.
+
+## Agent plugin distribution
+
+The installable host integration lives in
+[`rstackjs/agent-skills`](https://github.com/rstackjs/agent-skills), not in this runtime repository.
+It extends that repository's existing `rstack` plugin rather than creating another plugin or
+marketplace:
+
+```text
+agent-skills/
+├── .codex-plugin/
+├── .claude-plugin/
+├── .agents/plugins/marketplace.json
+├── .mcp.json
+└── skills/
+ ├── analyze-build/
+ ├── assess-change-impact/
+ ├── debug-dev-cycle/
+ ├── explain-dead-code/
+ ├── find-unused-code/
+ └── review-context-change/
+```
+
+The Codex and Claude manifests discover the same root `skills/` directory and `.mcp.json`. The
+launcher uses the workspace-root local `rstack` package when available and otherwise expects `rs`
+on the host `PATH`. The plugin contains no copy of `@rstackjs/context`, compiler graph, store, or MCP
+implementation.
+
+### Skill catalog
+
+| Skill | Typical request | Tool sequence |
+| ----------------------- | --------------------------------- | ------------------------------------------------------------------------------ |
+| `find-unused-code` | "Find unused modules" | `project_status` → `product_roots` → `unused_candidates` → `dead_code_explain` |
+| `explain-dead-code` | "Why is this module included?" | `dead_code_explain` with one context, artifact, and module selector |
+| `assess-change-impact` | "What depends on this module?" | `module_impact` in the dependent direction |
+| `analyze-build` | "Why is this bundle large?" | `project_status` → focused `rsdoctor_analyze` → optional `report_link` |
+| `debug-dev-cycle` | "What lint or tests are failing?" | status/snapshot queries, then an explicit capture only when requested |
+| `review-context-change` | "What changed after this edit?" | `snapshot_list` → `snapshot_diff` → optional `lint_fix_preview` |
+
+The skills keep build-artifact conclusions scoped to the selected context and file. They also keep
+querying separate from execution: `lint_snapshot` and `test_snapshot` run only when fresh results
+are requested, and the preview tool never applies a change.
+
+### Unused-module workflow
+
+```mermaid
+flowchart TD
+ Status["Read project_status"] --> Select["Select explicit context and artifact"]
+ Select --> Roots["Resolve product_roots"]
+ Roots --> Candidates["List unused_candidates"]
+ Candidates --> Explain["Explain strongest candidate"]
+ Explain --> Bounds["Present paths, state axes, bounds, and provenance"]
+ Bounds --> Verify["Recommend source and runtime verification"]
+```
+
+The MCP view returns a bounded `product_roots` sample plus `rootSummary` counts. Unused candidates
+are ordered with project-owned modules first and include complete-result `ownership` counts, so an
+agent does not page through dependency-only results looking for source that is not present. This
+keeps the first agent turn useful even when a production artifact contains thousands of roots or
+candidates.
+
+## Delivery status
+
+```mermaid
+flowchart LR
+ P01["Phase 0/1
foundation, passive build records,
Rsdoctor analysis, report links
implemented"]
+ P2["Phase 2
module artifact reachability
and official plugin workflows
implemented"]
+ P3["Phase 3
explicit one-shot Rslint/Rstest
snapshots and queries
implemented"]
+ P4["Phase 4
compatible diffs, exact-path evidence,
and six skills
implemented"]
+ P5["Phase 5
no custom GUI or remote transport;
reuse headless MCP + Rsdoctor GUI
resolved"]
+
+ P01 --> P2 --> P3 --> P4 --> P5
+```
+
+### Phase 0/1: foundation and build evidence
+
+Implemented downstream:
+
+- workspace/package discovery independent of Turbo or another task runner;
+- immutable run manifests and context generations;
+- deterministic status across independent package processes;
+- opt-in Rsbuild and Rslib metadata observers;
+- the root-resolving `rs mcp` stdio server;
+- explicit Rsdoctor Agent CLI analysis; and
+- optional links to existing Rsdoctor reports.
+
+### Phase 2: module artifact reachability and bundles
+
+Implemented downstream:
+
+- normalized Rsdoctor module graphs;
+- application entries, library contract targets, and conservative roots;
+- bounded root reachability, shortest explanations, and impact traversal;
+- the four module-analysis MCP tools; and
+- one shared Codex and Claude Code integration in `rstackjs/agent-skills`.
+
+The implementation reports module candidates only. Export usage and local-symbol dead-code claims
+were not added.
+
+### Phase 3: explicit development snapshots
+
+Implemented downstream:
+
+- one-shot Rslint snapshots with normalized diagnostics;
+- one-shot Rstest snapshots with normalized file and case results;
+- optional bounded aggregate Istanbul execution evidence;
+- producer-specific freshness; and
+- paginated snapshot, diagnostic, and test-result queries.
+
+Passive lint/test attachment, watch control, resident workers, type-check/timing capture,
+per-test coverage attribution, and related-test graph APIs remain deferred.
+
+### Phase 4: review workflows
+
+Implemented downstream:
+
+- compatibility-checked diagnostic and test diffs;
+- stored lint fixed-output previews without apply;
+- the `code_evidence` exact-path composition query;
+- `debug-dev-cycle` and `review-context-change`; and
+- the complete six-skill set in the official `rstack` plugin.
+
+CI artifact exchange, performance budgets, apply tools, automatic verification, and export-level
+diffs remain deferred.
+
+### Phase 5: presentation decision
+
+Resolved for this branch: no custom GUI, MCP app, report server, or remote transport is needed. The
+headless MCP results cover agent workflows, and `report_link` reuses an existing Rsdoctor report when
+a human needs the richer visualization. A future presentation layer should be considered only after
+a specific workflow cannot be expressed clearly through the current structured tools and report
+link.
+
+### Multi-axis usage evidence
+
+This branch begins adapting Hawk's central idea: collect independent evidence for production
+and non-production targets, then decide reachability only after those fragments are joined. Hawk's
+non-production graph shows what test and development targets import or reach; it is not runtime
+coverage and does not prove that a test or branch executed.
+
+Rstack should preserve the following independent evidence axes:
+
+| Axis | Question answered |
+| ------------------------ | -------------------------------------------------------------------- |
+| Production-reachable | Can a selected product root reach this module or export? |
+| Test-related/imported | Does a selected test target relate to or import it? |
+| Executed/covered | Did runtime coverage observe its statements, functions, or branches? |
+| Shipped/retained | Did the emitted product contain or conservatively retain it? |
+| Public-contract-required | Must a selected library or external contract continue to expose it? |
+
+An absent or unknown result on one axis does not decide another. In particular, `not-covered` must
+never collapse to `dead`. This is an evidence-composition feature, not a security framework or a
+deletion oracle.
+
+Ownership stays upstream where the underlying facts are produced. Rstest owns instrumentation,
+coverage, related-test selection, watch-cycle events, and test-file attribution. Rspack and Rsdoctor
+own production and test module/export graphs plus product roots. Rstack owns immutable snapshots,
+exact identity and freshness joins, and the MCP and skill surfaces that explain the combined
+evidence; it should not duplicate those compiler graphs or test instrumentation.
+
+The lean delivery sequence is:
+
+1. Aggregate existing Istanbul evidence into immutable snapshots and expose it through bounded
+ exact-path composition. This branch implements this step.
+2. Add the existing Rstest related-file CLI output as a distinct static relation when a stable
+ structured seam is available.
+3. Consume upstream Rstest test-file attribution and watch-cycle events when they are available.
+4. Join axes only when workspace, package, context, and exact input or graph digests match; otherwise
+ report the evidence separately with its freshness.
+
+The implemented `code_evidence` query covers aggregate execution, exact-path or isolated
+related-selection outcomes, static related-test selection, diagnostics, and an optional explicit
+artifact module. It does not claim per-test coverage attribution or that a passing related test
+executed the selected source.
+
+## Deferred extensions
+
+The following are potential later work, not part of the implemented contract:
+
+- supported Rsdoctor export-usage and local-binding data;
+- direct standalone Rspack instrumentation;
+- passive Rslint/Rstest sessions and watch-cycle control;
+- static related-test evidence, per-test coverage attribution, and watch-cycle evidence;
+- build, lint, or test subscriptions;
+- CI artifact import/export and performance gates;
+- source mutation and apply/verify flows;
+- retention policies beyond disposable cache cleanup;
+- a coordinator daemon, remote transport, or custom visual surface; and
+- repository-wide claims that combine several artifact graphs automatically.
+
+These additions should continue using the same workspace, context, run, snapshot, and provenance
+identities so the file-based implementation remains the compatibility boundary.
+
+## Validation
+
+The downstream implementation includes focused coverage for:
+
+- workspace discovery and config injection;
+- immutable store publication and record validation;
+- numeric generation ordering and project status;
+- Rsbuild/Rslib metadata capture;
+- Rsdoctor artifact selection, graph normalization, and Agent CLI loading;
+- application and library root resolution;
+- reachability, explanations, and impact traversal;
+- Rslint and Rstest snapshot normalization and freshness;
+- aggregate execution normalization and exact-path code evidence;
+- paging, compatible diffs, and lint previews;
+- the 15-tool MCP catalog and stdio behavior; and
+- both plugin manifests, MCP registration, and six-skill layouts.
+
+The branch's repository verification matrix is `pnpm check`, `pnpm check:spell`, `pnpm build`,
+`pnpm --filter rstack build:native`, and `pnpm test`.
+
+## Acceptance criteria
+
+This lean implementation is complete when:
+
+1. A root-launched agent can enumerate completed contexts from several Rslib packages and Rsbuild
+ applications without knowing their process directories.
+2. Rslib-only, Rsbuild-only, and mixed workspaces publish into the same store format.
+3. An agent can select one context and explicit Rsdoctor artifact before asking for roots,
+ candidates, explanations, impact, or focused build analysis.
+4. Every unused result is presented as an artifact-scoped module candidate with bounds and
+ provenance.
+5. Lint and test captures are explicit one-shot operations whose immutable results can be queried
+ and compared independently from build status.
+6. A lint fixed-output preview can be reviewed without applying it.
+7. Codex and Claude Code expose the same MCP command and the same six task skills.
+8. The normal workflow requires neither a daemon nor a GUI; an existing Rsdoctor report remains
+ available through `report_link`.
+
+## References
+
+- [Astral Hawk architecture](https://github.com/astral-sh/hawk/blob/main/docs/architecture.md)
+- [Model Context Protocol tools](https://modelcontextprotocol.io/specification/2025-11-25/server/tools)
+- [Rspack Stats JSON](https://rspack.rs/api/javascript-api/stats-json)
+- [Rspack tree shaking](https://rspack.rs/guide/optimization/tree-shaking)
+- [Rsbuild plugin hooks](https://rsbuild.rs/plugins/dev/hooks)
+- [Rslib JavaScript API](https://lib.rsbuild.dev/api/javascript-api/instance)
+- [Rslint JavaScript API](https://rslint.rs/guide/js-api)
+- [Rstest JavaScript API](https://rstest.rs/api/javascript-api)
+- [Rsdoctor AI integration](https://rsdoctor.rs/guide/start/ai)
+- [Rsdoctor pull-request preview packages](https://github.com/web-infra-dev/rsdoctor/pull/1900)
+- [Codex plugin packaging](https://developers.openai.com/plugins/build/plugins)
+- [Claude Code plugin reference](https://code.claude.com/docs/en/plugins-reference)
diff --git a/docs/upstream-requests.md b/docs/upstream-requests.md
new file mode 100644
index 0000000..9a630a0
--- /dev/null
+++ b/docs/upstream-requests.md
@@ -0,0 +1,281 @@
+# Upstream and cross-repo follow-ups
+
+This document tracks gaps in upstream (`rstest`, `rsbuild`, `rsdoctor`) public
+APIs that this package currently works around locally, plus open
+cross-repo coordination items with `rstack-cli`. Each entry cites the exact
+local code the request would let us delete or simplify, and the upstream
+surface (or lack of one) that motivates it.
+
+## Upstream API requests
+
+### 1. rstest (`web-infra-dev/rstest`): expose related-test selection publicly
+
+`src/testRun.ts` injects a `resolveRelatedTests: ResolveRelatedTests`
+dependency (`TestCaptureDependencies.resolveRelatedTests`, `src/testRun.ts:111`)
+so that `captureTestSnapshot` can turn a set of changed source files into the
+test files that cover them (`request.related`, validated in
+`validateRelatedSelection`, `src/testRun.ts:280`-`292`, and consumed at
+`src/testRun.ts:380`-`399`). This selection logic is not something this
+package implements itself — it delegates to Rstest's own related-file
+resolver, which is bundled but not exported.
+
+Verified against the installed `@rstest/core@0.11.6`:
+
+- `resolveRelatedTestFiles` (upstream source: `packages/core/src/core/related.ts`)
+ ships only inside the bundled implementation chunks
+ (`dist/related~0.js`, `dist/3374~0.js`); it does not appear in any of the
+ package's public `.d.ts` entry points (`dist/index.d.ts`, `dist/api/index.d.ts`,
+ `dist/browser.d.ts`).
+- The public programmatic API's `RunRstestOptions`
+ (`dist/api/index.d.ts:1193`-`1212`) has no `related` field — only `cwd`,
+ `config`, `inlineConfig`, `files`, and `testNamePattern`.
+- The CLI does expose `--related` / `--findRelatedTests` (see the
+ `relatedFilters?: string[]` field on the CLI-facing result type at
+ `dist/index.d.ts:3388`), so the capability exists, but only inside the CLI
+ entry point that dynamically imports `related.ts` internally — not through
+ any package export a library consumer can import.
+
+**Request:** add `related?: string[]` to `RunRstestOptions` (mirroring
+`--related`/`--findRelatedTests`), or export `resolveRelatedTestFiles` (or an
+equivalent) from `@rstest/core/internal/browser` alongside the already-exported
+`loadCoverageProvider`.
+
+**Payoff:** this package's own `resolveRelatedTests` dependency-injection
+seam (`ResolveRelatedTests` type, `src/testRun.ts:96`-`102`) and the caller
+that has to supply it become unnecessary — related-test resolution could call
+straight into Rstest.
+
+### 2. rsbuild (`web-infra-dev/rsbuild`): export the internal thin stats contract
+
+`src/build.ts`'s `buildMetadataFacet` (`src/build.ts:52`-`140`) calls
+`stats.toJson({ all: false, hash: true, assets: true, chunks: true, errors:
+false, warnings: false })` directly against the Rspack `Stats` instance handed
+to `onAfterEnvironmentCompile`, then walks `assets`/`chunks`/`hash` by hand
+with defensive `typeof` guards.
+
+Verified against the installed `@rsbuild/core@2.1.11`: it has its own internal
+helper, `helpers/stats.ts`, that is not part of the public entry
+(`dist/index.d.ts`'s export list has no `RsbuildStats`, `getRsbuildStats`,
+`getStatsErrors`, or `getStatsWarnings` — confirmed by grepping the full
+public type-export list). The internal module (`dist/helpers/stats.d.ts`)
+exports:
+
+```ts
+export declare const getStatsErrors: ({ errors, children }: RsbuildStats) => Rspack.StatsError[];
+export declare const getStatsWarnings: ({
+ warnings,
+ children,
+}: RsbuildStats) => Rspack.StatsError[];
+export declare function getRsbuildStats(
+ statsInstance: Rspack.Stats | Rspack.MultiStats,
+ compiler: Rspack.Compiler | Rspack.MultiCompiler,
+ logger: Logger,
+ action?: ActionType,
+): RsbuildStats;
+```
+
+Its bundled implementation (`dist/626.js`) builds the `toJson` options from
+the _actual_ compiler (single vs. multi-compiler, via
+`compiler_isMultiCompiler(compiler)`), and `getStatsErrors`/`getStatsWarnings`
+walk `stats.children` to merge per-child errors/warnings when the top-level
+`errors`/`warnings` are absent. `src/build.ts` does none of this today — it
+only reads `json.assets`, `json.chunks`, and `json.hash` off the single
+`stats.toJson(...)` result, so a multi-compiler environment's per-child
+assets/chunks are silently dropped from the metadata facet.
+
+**Request:** re-export `RsbuildStats`, `getRsbuildStats`, `getStatsErrors`,
+and `getStatsWarnings` from `@rsbuild/core`'s public entry (`dist/index.d.ts`),
+the same way `Rspack` itself is already re-exported as a type.
+
+**Payoff:** `buildMetadataFacet` could call `getRsbuildStats` instead of a
+raw `stats.toJson(...)`, inheriting correct multi-compiler `children`
+merging for free instead of this package reimplementing it.
+
+### 3. rsdoctor (`web-infra-dev/rsdoctor`) `agent-cli`
+
+**(a) Validate tool input against each catalog tool's own `inputSchema`
+inside the executor.**
+
+`src/rsdoctor.ts` maintains a hand-rolled JSON Schema subset matcher,
+`matchesJsonSchema` (`src/rsdoctor.ts:186`-`232`, with `matchesSchemaType` at
+`src/rsdoctor.ts:161`-`184`), that duplicates a slice of JSON Schema
+(`type`, `minimum`/`maximum`, `items`, `properties`/`required`,
+`additionalProperties`) purely to validate a tool call's input against the
+`inputSchema` that `getToolCatalog()` already publishes per tool
+(`tool.inputSchema`, loaded at `src/rsdoctor.ts:146`, checked at
+`src/rsdoctor.ts:236` via `getInput`). `createInProcessRsdoctorCliToolExecutor()`
+(also from `@rsdoctor/agent-cli`, used at `src/rsdoctor.ts:153`) does not
+perform this validation itself before executing a tool.
+
+**Request:** have the executor returned by
+`createInProcessRsdoctorCliToolExecutor()` validate the caller's input
+against the tool's own `inputSchema` before running it (or expose a
+`validateToolInput(tool, input)` helper alongside `getToolCatalog()`).
+
+**Payoff:** deletes `matchesJsonSchema`/`matchesSchemaType` and the
+`getInput` validation branch entirely — the package would only need to catch
+and translate the executor's own validation error.
+
+**(b) Document (and converge) a behavioral divergence between the published
+package and the pkg.pr.new preview build this repo's CI depends on.**
+
+`package.json` declares the supported semver dependency (`0.1.1`) while this
+repo's own validation resolves the commit-pinned pkg.pr.new canary through a
+root-only override in `pnpm-workspace.yaml`:
+
+```yaml
+overrides:
+ '@rsdoctor/agent-cli': 'https://pkg.pr.new/@rsdoctor/agent-cli@8926633c'
+```
+
+The split is deliberate (rstack-cli context-plugin-boundary design, "Preview
+dependency policy"): a pkg.pr.new URL must not ship as a transitive
+dependency because `blockExoticSubdeps` consumers reject URL-resolved
+subdependencies, so downstream installs resolve `0.1.1` until a release
+ships. The canary is the head of open PR web-infra-dev/rsdoctor#1924
+(`codex/agent-cli-input-validation`), stacked on #1903; both PRs are mergeable
+with checks green. The
+published `0.1.1` release and that canary disagree on how an _omitted_ artifact section (one whose
+`metadata.summary.status === 'omitted'`, e.g. because the Rsdoctor run used an
+output mode that skips that section) is reported for output-mode-omitted
+data. Published `@rsdoctor/agent-cli@0.1.1` returns `{ ok: true, data: null
+}`. The pkg.pr.new preview build returns a structured failure instead:
+`{ ok: false, error: { code: 'RSDOCTOR_SECTION_UNAVAILABLE', ... } }`. This
+repo's own test, `tests/rsdoctor.test.ts` ("distinguishes collected empty
+data from an omitted artifact section", asserting the `RSDOCTOR_SECTION_UNAVAILABLE`
+shape), only passes against the preview build's new semantics — this
+package's CI cannot ship against the published `0.1.1` release as-is.
+
+**Request:** merge web-infra-dev/rsdoctor#1903 and cut a release of
+`@rsdoctor/agent-cli` that includes the `RSDOCTOR_SECTION_UNAVAILABLE`
+semantics; once it ships, bump the semver dependency and drop the root-only
+override. Until then, downstream consumers on `0.1.1` see `{ ok: true,
+data: null }` for omitted sections, which this package's zero-shape
+detection (`formatRsdoctorAnalysis`) already reports as degraded rather
+than treating as evidence.
+
+## Cross-repo follow-ups (rstack-cli coordination)
+
+### 1. Wrapper-config elimination
+
+Both capture paths in this package write to disk a generated "wrapper"
+config file and pass its path to the underlying tool, rather than passing
+config in memory:
+
+- `src/testRun.ts`: `wrapperConfigPath` (default resolved via
+ `resolveInternalConfigPath(import.meta.dirname, 'rstestConfig.js')`,
+ `src/testRun.ts:338`-`340`) is passed as `runRstest({ config:
+wrapperConfigPath, ... })` (`src/testRun.ts:407`-`416`).
+- `src/lint.ts`: `wrapperConfigPath` (defaulted the same way, resolving
+ `rslintConfig.js`, `src/lint.ts:251`-`252`) is passed as
+ `overrideConfigFile: wrapperConfigPath` (`src/lint.ts:264`-`268`).
+
+Both underlying tools already support passing config without a file on disk:
+
+- `@rstest/core@0.11.6`'s `RunRstestOptions.inlineConfig?: RstestUserConfig`
+ is shallow-merged with any on-disk config, and `config` itself is optional
+ (`dist/api/index.d.ts:1193`-`1212`) — a run can be driven purely by
+ `inlineConfig`. The package also exports `loadConfig` (`dist/index.d.ts:1959`)
+ and `mergeRstestConfig` (`dist/index.d.ts:2145`) for merging config values
+ programmatically.
+- `@rslint/core@0.8.0`'s `RslintOptions.overrideConfigFile?: string | true |
+null` accepts `true` to mean "use only `overrideConfig`, no file, no
+ discovery" (`dist/index.d.ts:301`), paired with `overrideConfig?:
+RslintConfigEntry | RslintConfig | null` (`dist/index.d.ts:295`) for the
+ in-memory config value itself.
+
+In principle this means `rstestConfig.js`/`rslintConfig.js` and the whole
+`resolveInternalConfigPath` file-resolution mechanism
+(`src/source.ts:63`-`71`) could be deleted in favor of passing
+`inlineConfig`/`overrideConfig` directly. The blocker is that the translation
+from a project's `rstack.config.*` file into the producer-specific config
+those wrapper files currently encode is not owned by this package — it lives
+in `rstack-cli`'s `packages/rstack/src/mcp.ts`, which is the caller that
+injects `wrapperConfigPath` into this package's `LintCaptureAdapter`
+(`src/lint.ts:70`-`73`, `{ wrapperConfigPath: string; withConfigTarget:
+ConfigTargetRunner }`) and `TestCaptureDependencies`
+(`src/testRun.ts:104`-`117`, `wrapperConfigPath?: string`).
+
+Concrete migration steps, to be coordinated with `rstack-cli`:
+
+1. In `rstack-cli`, change the `rstack.config.*` → producer-config
+ translation in `packages/rstack/src/mcp.ts` so it produces an in-memory
+ config _value_ (an `RstestUserConfig` for tests, an `RslintConfigEntry`/
+ `RslintConfig` for lint) instead of writing a wrapper `.js` file to disk.
+2. Extend `LintCaptureAdapter` and `TestCaptureDependencies` in this package
+ to accept that value directly — e.g. `overrideConfig?: RslintConfigEntry |
+RslintConfig` alongside (or replacing) `wrapperConfigPath`, and
+ `inlineConfig?: RstestUserConfig` alongside (or replacing) the
+ `TestCaptureDependencies.wrapperConfigPath` equivalent.
+3. In `src/lint.ts`, switch `captureLintSnapshot`'s options construction
+ (`src/lint.ts:264`-`268`) to `overrideConfigFile: true, overrideConfig:
+adapter.overrideConfig` when an in-memory config is supplied, keeping the
+ file-path branch as a fallback for callers that still pass
+ `wrapperConfigPath`.
+4. In `src/testRun.ts`, switch the `runRstest(...)` call
+ (`src/testRun.ts:406`-`416`) to omit `config` and pass
+ `inlineConfig: dependencies.inlineConfig` (merged with the existing
+ coverage `inlineConfig` block at `src/testRun.ts:345`-`360`, via
+ `mergeRstestConfig` if both are present) when supplied.
+5. Once every `rstack-cli` caller passes the in-memory form, delete
+ `resolveInternalConfigPath` (`src/source.ts:63`-`71`) and the bundled
+ `rstestConfig.js`/`rslintConfig.js` wrapper files.
+
+This is sequenced as a joint change: this package's public
+`LintCaptureAdapter`/`TestCaptureDependencies` contract and `rstack-cli`'s
+`mcp.ts` config translation must land together, since neither side can drop
+the file-path form until the other stops relying on it.
+
+### 2. `artifactProducts.ts`: synthesized roots for `product: 'unknown'` — open design question
+
+`resolveArtifactProductRoots` (`src/artifactProducts.ts:5`-`24`) is the entry
+point that `loadAnalysis` (`src/queries.ts:207`-`233`) uses to compute the
+product roots feeding `findUnusedCandidates` and `explainDeadCodeCandidate`
+(`src/queries.ts:410`, `581`) verdicts. For a context whose `product` is
+neither `'application'` nor `'library'` (i.e. `'unknown'`, per
+`ContextDescriptor.product`), it does not skip root resolution — it calls
+`resolveProductRoots` a second time with `product` forced to `'application'`
+and tags the result:
+
+```ts
+const artifactRoots = await resolveProductRoots(
+ workspaceRoot,
+ { ...context, product: 'application' },
+ graph,
+);
+return {
+ ...artifactRoots,
+ product: 'unknown',
+ bounds: ['product-context-unavailable', ...artifactRoots.bounds],
+};
+```
+
+Two consequences follow directly from `resolveProductRoots`
+(`src/products.ts:89`, `105`-`128`):
+
+- The synthesized roots are _application_-shaped: entrypoint/production
+ roots are computed, but the `context.product === 'library'` branch that
+ computes `published-contract` roots (`src/products.ts:106`-`128`) is
+ skipped entirely, because the forced context is `'application'`, never
+ `'library'`.
+- The only signal that this happened is the `'product-context-unavailable'`
+ string prepended to `bounds` — callers that don't inspect `bounds` see a
+ normal-looking root set for a context whose real product is unknown.
+
+This is an unresolved design question, not a bug fix — pending an owner
+decision, the two options are:
+
+- **Option A — keep annotated synthesis (current behavior).** Continue
+ computing application-shaped roots for `'unknown'` contexts and rely on the
+ `'product-context-unavailable'` `bounds` entry for downstream consumers to
+ decide how much to trust the verdict. Simple, but silently wrong for
+ library-shaped code under an unknown context, since it never considers
+ `published-contract` roots.
+- **Option B — degrade to insufficient-evidence.** When `context.product` is
+ `'unknown'`, have `resolveArtifactProductRoots` return an explicit
+ insufficient-evidence result (no synthesized roots) instead of a
+ best-effort application-shaped one, and have `findUnusedCandidates`/
+ `explainDeadCodeCandidate` surface that as a distinct "product context
+ unavailable" outcome rather than a normal verdict annotated with a bounds
+ string. More conservative and legible, but a behavior change for every
+ existing caller in an unknown-product context.
diff --git a/fixtures/context/reachability/application/package.json b/fixtures/context/reachability/application/package.json
new file mode 100644
index 0000000..0b4850b
--- /dev/null
+++ b/fixtures/context/reachability/application/package.json
@@ -0,0 +1,4 @@
+{
+ "name": "fixture-application",
+ "private": true
+}
diff --git a/fixtures/context/reachability/application/rsdoctor-data.json b/fixtures/context/reachability/application/rsdoctor-data.json
new file mode 100644
index 0000000..2d6bbc4
--- /dev/null
+++ b/fixtures/context/reachability/application/rsdoctor-data.json
@@ -0,0 +1,74 @@
+{
+ "data": {
+ "moduleGraph": {
+ "modules": [
+ {
+ "id": 7,
+ "path": "src/cycle-b.ts",
+ "name": "cycle-b"
+ },
+ {
+ "id": "2",
+ "path": "src/live.ts",
+ "name": "live",
+ "chunks": [10]
+ },
+ {
+ "id": 3,
+ "path": "src/legacy.ts",
+ "name": "legacy",
+ "chunks": []
+ },
+ {
+ "id": 1,
+ "path": "src\\index.ts",
+ "webpackId": ".\\src\\index.ts",
+ "name": "index",
+ "chunks": ["shared", 10, "shared"],
+ "isEntry": true
+ },
+ {
+ "id": 4,
+ "path": "src/polyfill.ts",
+ "name": "polyfill",
+ "chunks": ["runtime"],
+ "bailoutReason": {
+ "message": "Top-level side effects"
+ }
+ },
+ {
+ "id": 5,
+ "webpackId": "./src/lazy.ts",
+ "name": "lazy",
+ "bailoutReason": ["retained for import()"]
+ },
+ {
+ "id": 8,
+ "name": "src/cjs.ts",
+ "bailoutReason": {
+ "reason": "CommonJS require()"
+ }
+ },
+ {
+ "id": 6,
+ "path": "src/cycle-a.ts",
+ "name": "cycle-a"
+ },
+ {
+ "id": "2",
+ "path": "src/duplicate-live.ts",
+ "name": "duplicate-live"
+ }
+ ],
+ "dependencies": [
+ { "module": 1, "dependency": 2, "originDependency": 2, "issuer": 7 },
+ { "module": 6, "issuer": 2 },
+ { "module": 6, "dependency": 7, "originDependency": 7 },
+ { "module": 6, "issuer": 7 },
+ { "module": 1, "dependency": 999, "originDependency": 999 },
+ { "module": 1, "dependency": 2, "originDependency": 2 }
+ ],
+ "exports": [{ "schema": "opaque" }]
+ }
+ }
+}
diff --git a/fixtures/context/reachability/library/package.json b/fixtures/context/reachability/library/package.json
new file mode 100644
index 0000000..96da5be
--- /dev/null
+++ b/fixtures/context/reachability/library/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "fixture-library",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.js"
+ },
+ "./feature": "./dist/feature.js",
+ "./generated": "./dist/generated.js"
+ },
+ "main": "./dist/index.js",
+ "module": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "bin": {
+ "fixture-library": "./dist/cli.js"
+ }
+}
diff --git a/fixtures/context/reachability/library/rsdoctor-data.json b/fixtures/context/reachability/library/rsdoctor-data.json
new file mode 100644
index 0000000..67da6ae
--- /dev/null
+++ b/fixtures/context/reachability/library/rsdoctor-data.json
@@ -0,0 +1,40 @@
+{
+ "data": {
+ "moduleGraph": {
+ "modules": [
+ {
+ "id": 13,
+ "path": "/repo/packages/library/src/generated.ts",
+ "name": "generated-source"
+ },
+ {
+ "id": 12,
+ "path": "/repo/packages/library/src/internal.ts",
+ "name": "internal"
+ },
+ {
+ "id": 11,
+ "path": "/repo/packages/library/dist/feature.js",
+ "name": "feature",
+ "chunks": ["feature"],
+ "isEntry": true
+ },
+ {
+ "id": 14,
+ "path": "/repo/packages/library/dist/cli.js",
+ "name": "cli",
+ "chunks": ["cli"]
+ },
+ {
+ "id": 10,
+ "path": "/repo/packages/library/dist/index.js",
+ "name": "index",
+ "chunks": ["index"],
+ "isEntry": true
+ }
+ ],
+ "dependencies": [{ "module": 12, "issuer": 10 }],
+ "exports": []
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..ae22535
--- /dev/null
+++ b/package.json
@@ -0,0 +1,102 @@
+{
+ "name": "@rstackjs/context",
+ "version": "0.6.0",
+ "description": "Rstack project context, evidence storage, and MCP runtime.",
+ "homepage": "https://rstack.rs",
+ "bugs": {
+ "url": "https://github.com/rstackjs/context/issues"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/rstackjs/context.git"
+ },
+ "license": "MIT",
+ "type": "module",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.js"
+ },
+ "./mcp": {
+ "types": "./dist/mcp.d.ts",
+ "default": "./dist/mcp.js"
+ },
+ "./rsbuild": {
+ "types": "./dist/rsbuild.d.ts",
+ "default": "./dist/rsbuild.js"
+ },
+ "./rsdoctor": {
+ "types": "./dist/rsdoctor.d.ts",
+ "default": "./dist/rsdoctor.js"
+ },
+ "./rslib": {
+ "types": "./dist/rslib.d.ts",
+ "default": "./dist/rslib.js"
+ },
+ "./rslint": {
+ "types": "./dist/rslint.d.ts",
+ "default": "./dist/rslint.js"
+ },
+ "./rstack": {
+ "types": "./dist/rstack.d.ts",
+ "default": "./dist/rstack.js"
+ },
+ "./rstest": {
+ "types": "./dist/rstest.d.ts",
+ "default": "./dist/rstest.js"
+ },
+ "./package.json": "./package.json"
+ },
+ "types": "./dist/index.d.ts",
+ "files": [
+ "dist"
+ ],
+ "scripts": {
+ "build": "rslib",
+ "check": "rslint --type-check . && prettier --check .",
+ "dev": "rslib --watch",
+ "format": "prettier --write .",
+ "lint": "rslint --type-check .",
+ "prepare": "pnpm build",
+ "test": "rstest run",
+ "test:coverage": "rstest run --coverage",
+ "test:watch": "rstest"
+ },
+ "dependencies": {
+ "@modelcontextprotocol/sdk": "1.29.0",
+ "@rslint/core": "0.8.0",
+ "@rstest/core": "0.11.6",
+ "zod": "4.4.3"
+ },
+ "devDependencies": {
+ "@rsbuild/core": "2.1.11",
+ "@rsdoctor/agent-cli": "0.1.1",
+ "@rslib/core": "1.0.0-beta.3",
+ "@rstest/coverage-istanbul": "0.11.6",
+ "@types/node": "24.13.3",
+ "pkg-pr-new": "0.0.87",
+ "prettier": "3.9.6",
+ "typescript": "7.0.2"
+ },
+ "peerDependencies": {
+ "@rsdoctor/agent-cli": ">=0.1.1",
+ "@rsbuild/core": "^2.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@rsdoctor/agent-cli": {
+ "optional": true
+ },
+ "@rsbuild/core": {
+ "optional": true
+ }
+ },
+ "engines": {
+ "node": ">=22.12.0",
+ "pnpm": ">=11.0.0"
+ },
+ "packageManager": "pnpm@11.21.0",
+ "publishConfig": {
+ "access": "public",
+ "registry": "https://registry.npmjs.org/"
+ }
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
new file mode 100644
index 0000000..4114386
--- /dev/null
+++ b/pnpm-lock.yaml
@@ -0,0 +1,1704 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: false
+ dedupePeers: true
+ excludeLinksFromLockfile: false
+
+overrides:
+ '@rsdoctor/agent-cli': https://pkg.pr.new/@rsdoctor/agent-cli@8926633c
+
+importers:
+
+ .:
+ dependencies:
+ '@modelcontextprotocol/sdk':
+ specifier: 1.29.0
+ version: 1.29.0(supports-color@7.2.0)(zod@4.4.3)
+ '@rsdoctor/agent-cli':
+ specifier: https://pkg.pr.new/@rsdoctor/agent-cli@8926633c
+ version: https://pkg.pr.new/@rsdoctor/agent-cli@8926633c
+ '@rslint/core':
+ specifier: 0.8.0
+ version: 0.8.0
+ '@rstest/core':
+ specifier: 0.11.6
+ version: 0.11.6
+ zod:
+ specifier: 4.4.3
+ version: 4.4.3
+ devDependencies:
+ '@rsbuild/core':
+ specifier: 2.1.11
+ version: 2.1.11
+ '@rslib/core':
+ specifier: 1.0.0-beta.3
+ version: 1.0.0-beta.3(typescript@7.0.2)
+ '@rstest/coverage-istanbul':
+ specifier: 0.11.6
+ version: 0.11.6(@rstest/core@0.11.6)(supports-color@7.2.0)
+ '@types/node':
+ specifier: 24.13.3
+ version: 24.13.3
+ pkg-pr-new:
+ specifier: 0.0.87
+ version: 0.0.87
+ prettier:
+ specifier: 3.9.6
+ version: 3.9.6
+ typescript:
+ specifier: 7.0.2
+ version: 7.0.2
+
+packages:
+
+ '@ast-grep/napi-darwin-arm64@0.37.0':
+ resolution: {integrity: sha512-QAiIiaAbLvMEg/yBbyKn+p1gX2/FuaC0SMf7D7capm/oG4xGMzdeaQIcSosF4TCxxV+hIH4Bz9e4/u7w6Bnk3Q==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@ast-grep/napi-darwin-x64@0.37.0':
+ resolution: {integrity: sha512-zvcvdgekd4ySV3zUbUp8HF5nk5zqwiMXTuVzTUdl/w08O7JjM6XPOIVT+d2o/MqwM9rsXdzdergY5oY2RdhSPA==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@ast-grep/napi-linux-arm64-gnu@0.37.0':
+ resolution: {integrity: sha512-L7Sj0lXy8X+BqSMgr1LB8cCoWk0rericdeu+dC8/c8zpsav5Oo2IQKY1PmiZ7H8IHoFBbURLf8iklY9wsD+cyA==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@ast-grep/napi-linux-arm64-musl@0.37.0':
+ resolution: {integrity: sha512-LF9sAvYy6es/OdyJDO3RwkX3I82Vkfsng1sqUBcoWC1jVb1wX5YVzHtpQox9JrEhGl+bNp7FYxB4Qba9OdA5GA==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@ast-grep/napi-linux-x64-gnu@0.37.0':
+ resolution: {integrity: sha512-TViz5/klqre6aSmJzswEIjApnGjJzstG/SE8VDWsrftMBMYt2PTu3MeluZVwzSqDao8doT/P+6U11dU05UOgxw==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@ast-grep/napi-linux-x64-musl@0.37.0':
+ resolution: {integrity: sha512-/BcCH33S9E3ovOAEoxYngUNXgb+JLg991sdyiNP2bSoYd30a9RHrG7CYwW6fMgua3ijQ474eV6cq9yZO1bCpXg==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@ast-grep/napi-win32-arm64-msvc@0.37.0':
+ resolution: {integrity: sha512-TjQA4cFoIEW2bgjLkaL9yqT4XWuuLa5MCNd0VCDhGRDMNQ9+rhwi9eLOWRaap3xzT7g+nlbcEHL3AkVCD2+b3A==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@ast-grep/napi-win32-ia32-msvc@0.37.0':
+ resolution: {integrity: sha512-uNmVka8fJCdYsyOlF9aZqQMLTatEYBynjChVTzUfFMDfmZ0bihs/YTqJVbkSm8TZM7CUX82apvn50z/dX5iWRA==}
+ engines: {node: '>= 10'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@ast-grep/napi-win32-x64-msvc@0.37.0':
+ resolution: {integrity: sha512-vCiFOT3hSCQuHHfZ933GAwnPzmL0G04JxQEsBRfqONywyT8bSdDc/ECpAfr3S9VcS4JZ9/F6tkePKW/Om2Dq2g==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [win32]
+
+ '@ast-grep/napi@0.37.0':
+ resolution: {integrity: sha512-Hb4o6h1Pf6yRUAX07DR4JVY7dmQw+RVQMW5/m55GoiAT/VRoKCWBtIUPPOnqDVhbx1Cjfil9b6EDrgJsUAujEQ==}
+ engines: {node: '>= 10'}
+
+ '@emnapi/core@1.11.3':
+ resolution: {integrity: sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==}
+
+ '@emnapi/runtime@1.11.3':
+ resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==}
+
+ '@emnapi/wasi-threads@1.2.3':
+ resolution: {integrity: sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==}
+
+ '@hono/node-server@1.19.17':
+ resolution: {integrity: sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==}
+ engines: {node: '>=18.14.1'}
+ peerDependencies:
+ hono: ^4
+
+ '@jridgewell/resolve-uri@3.1.2':
+ resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
+ engines: {node: '>=6.0.0'}
+
+ '@jridgewell/sourcemap-codec@1.5.5':
+ resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
+
+ '@jridgewell/trace-mapping@0.3.31':
+ resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+
+ '@modelcontextprotocol/sdk@1.29.0':
+ resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@cfworker/json-schema': ^4.1.1
+ zod: ^3.25 || ^4.0
+ peerDependenciesMeta:
+ '@cfworker/json-schema':
+ optional: true
+
+ '@napi-rs/wasm-runtime@1.1.6':
+ resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==}
+ peerDependencies:
+ '@emnapi/core': ^1.7.1
+ '@emnapi/runtime': ^1.7.1
+
+ '@rsbuild/core@2.1.11':
+ resolution: {integrity: sha512-jA/QwZu8wIljp70TjERVoX+vk2cWU+viHpV9EAdKpA6ifu/pFnThEhbV3RFxBvF/9mB3h7ZRUfdDIyknT+9MWA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ hasBin: true
+ peerDependencies:
+ core-js: '>= 3.0.0'
+ peerDependenciesMeta:
+ core-js:
+ optional: true
+
+ '@rsbuild/core@2.1.12':
+ resolution: {integrity: sha512-xRqNHj/svDqeUzXPahmN4BdxEFCU1rxnVdjxyVV7WgsFfH+L3yAoQMJtIXVGhr00IQseDKkc3eonMF3NGrFj2Q==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ hasBin: true
+ peerDependencies:
+ core-js: '>= 3.0.0'
+ peerDependenciesMeta:
+ core-js:
+ optional: true
+
+ '@rsdoctor/agent-cli@https://pkg.pr.new/@rsdoctor/agent-cli@8926633c':
+ resolution: {integrity: sha512-9gRufeY3ZqCIduHiZTzIciG1mC3Pm1aluNN+ZDxKmD2E0JbvAUur0qtSwtJtKsQJbVnb0gaKzWHxxd7MHnC63A==, tarball: https://pkg.pr.new/@rsdoctor/agent-cli@8926633c}
+ version: 0.0.0-preview-8926633
+ engines: {node: '>=22.18.0'}
+ hasBin: true
+
+ '@rslib/core@1.0.0-beta.3':
+ resolution: {integrity: sha512-OtfmaBoGlHo1KYvQ3+B0ZLy3zUsAdoRZfWieaxoJMJ9uKAws2//afFgvUqeSDkkSJDlp8TDdgt4hib+QaFOaGQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ hasBin: true
+ peerDependencies:
+ '@microsoft/api-extractor': ^7
+ typescript: ^5 || ^6 || ^7
+ peerDependenciesMeta:
+ '@microsoft/api-extractor':
+ optional: true
+ typescript:
+ optional: true
+
+ '@rslint/core@0.8.0':
+ resolution: {integrity: sha512-MfMC6lxiXoKPWsYRu9fuAxWA9mCD/I4E5Aa6Sl20a6q5w8r2C12fJM+bceC01AMGYuvWygKHqS3QBJdJHjniRw==}
+ hasBin: true
+ peerDependencies:
+ jiti: ^2.7.0
+ peerDependenciesMeta:
+ jiti:
+ optional: true
+
+ '@rslint/native-darwin-arm64@0.8.0':
+ resolution: {integrity: sha512-Bo6kXL1/TkjVUl6maZ3Sw+JEnZUkgpUD35v06jyBTcjpxlXE6yqFj66NAzB/G2CVu220ADp/FEE7l2Kocrefhg==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@rslint/native-darwin-x64@0.8.0':
+ resolution: {integrity: sha512-F5pabdH7dluxoj7PGuQjPYuoNU+yxnctcJzqrb/CZ122FLP3uJPrbiLufh+ZF9e5ui700rNyb7macJqnHlBV2w==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@rslint/native-linux-arm64-gnu@0.8.0':
+ resolution: {integrity: sha512-SSgSjyaeXI032Wk8bq6VmkLQTWOEqHZH5MWAk3831pd/G2rWr2XcoDi5Wf6w0o2rSCeYzkYbIejOePYwIKT4Lg==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rslint/native-linux-arm64-musl@0.8.0':
+ resolution: {integrity: sha512-50VDZQFAc9kp6rbOUOjyppVjC3d3AdbOJyA6JxlhK3mp8a/8sPIqWG0BlfcN/7ZpwdZrFsQt2Ds+0yMXITfu5A==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@rslint/native-linux-x64-gnu@0.8.0':
+ resolution: {integrity: sha512-wfc/UfnuTBAofwLPK5MLB2Hus1YYnsxP2MVchp380fUvMfQuGFPzz1WOAUY66zqrsrDKpUJsh3V1bx7c8DTlJA==}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rslint/native-linux-x64-musl@0.8.0':
+ resolution: {integrity: sha512-MTYcAMz6IZWb6ZmLo12pakCBA8mS3EW9cOI0jd3At6vs0Yia9YbeOAUiWbIC4Z9yyp75b8ZQCQMRSnY6rDnbaA==}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@rslint/native-win32-arm64-msvc@0.8.0':
+ resolution: {integrity: sha512-hids2jgNWBxZf2mSBLZJxubWRLWlJapEfeJHVokzjpRpBZZNv7O06enoyXSb4Lswff9WaHssIqu1sfZIc9lC2g==}
+ cpu: [arm64]
+ os: [win32]
+
+ '@rslint/native-win32-x64-msvc@0.8.0':
+ resolution: {integrity: sha512-bun7uURKl6NdChwmw/i2mk3Yj9klZQyh1uRbIQG0RDEJ9oTIbU5M3c7K5sd/Rukat0TVCGgbBAzR+YB0DAXPZQ==}
+ cpu: [x64]
+ os: [win32]
+
+ '@rspack/binding-darwin-arm64@2.1.10':
+ resolution: {integrity: sha512-DZlcTpbIb2mjeS1aSG4k01UH33Zj7T+k8ZylPK6HmsKs4JvK4wgpWFC78WVv3p/Aj3MZS6DwtDLwpZ2Ihj/fpg==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@rspack/binding-darwin-x64@2.1.10':
+ resolution: {integrity: sha512-my/0h2LwxCRT6cg3oDDC2e0ZOxQLVajAdIcv0fqnQk5JRNvVuL89PuTutitnSqie1A0/JSL8OQz5XHwmoS3kow==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@rspack/binding-linux-arm64-gnu@2.1.10':
+ resolution: {integrity: sha512-laevn9g+E5PAUEGqiKe6Ju5KApsuQYp+bPI17XS3Lkl8eqL5pS/BmHYU7QMlst4GzV8+wlruVTMh//+st6Vqzg==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rspack/binding-linux-arm64-musl@2.1.10':
+ resolution: {integrity: sha512-V71+Qz5G72+ROZXrJn5zxOszdG1AEbO8pcC/itXXtf4yRR6a3bVHKNKGhipBNxb8eI6cnD/01FH1h3ZG655jLw==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@rspack/binding-linux-ppc64-gnu@2.1.10':
+ resolution: {integrity: sha512-U7HlNzHcDtZ+LYOtOJmtx67kHEybZzUUAaP7aEXjGYO5WTCgh/176sW2UYP0rmZLrgUNFUuzn+B98RLaClNaVg==}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rspack/binding-linux-riscv64-gnu@2.1.10':
+ resolution: {integrity: sha512-GMGTJpy9/ecE+5F5IfxZH4bXv0Wx/b2TiehTlCbTksbL+pKpLHYy0rwGdjWDKbmBkhxMMqPiC7PDnn9LbdnnLA==}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rspack/binding-linux-riscv64-musl@2.1.10':
+ resolution: {integrity: sha512-rkurnAWc04vIbzG1QCrPBWSJadZvaOt1mazFH3EdiJO8VUiu0I1T9zdiwuDOPrd50lOKIZlcTXbd5aaAkWEnvQ==}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [musl]
+
+ '@rspack/binding-linux-s390x-gnu@2.1.10':
+ resolution: {integrity: sha512-X+DyxkriZEAF/wihI7ERDv+CAS0mbMv36aEuQ+vXzTlvS6cSmpou/r29AHbvIF3NlG1UeAbDVlOs9QrMBZjpUQ==}
+ cpu: [s390x]
+ os: [linux]
+ libc: [glibc]
+
+ '@rspack/binding-linux-x64-gnu@2.1.10':
+ resolution: {integrity: sha512-Fat09V6jUuyo9qG7Wyj9cQ31VDfLmokXyBtGqKxY5OvSWHereB7QUub5btbPXHwbp6Iq4aAQyUbbLTzvR1YaBw==}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rspack/binding-linux-x64-musl@2.1.10':
+ resolution: {integrity: sha512-lhHOnIJ4ClpIlA1f1L8aoxEZivYLjnjq5A6jKKz7BKsm+cHK8kqqEm6lO5KqA5xQT0Lonq1o28bmKHEj6JHInw==}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@rspack/binding-wasm32-wasi@2.1.10':
+ resolution: {integrity: sha512-KY5YbWbuvYcoaLXnV+vzZOvGRCeb6jt4EpVpKdph1h1IJjwX/ju15EQ+GOe3iecZEdf0OttQcNVcwBkLkFT9ag==}
+ cpu: [wasm32]
+
+ '@rspack/binding-win32-arm64-msvc@2.1.10':
+ resolution: {integrity: sha512-z4GWzMLofaDGpAt9Z+MlN88LlUBDm+zM6R2GdOOPM6/4g/h3/+47OP7casmSL3AwTGYBEJqogwt08sRSosB6Cg==}
+ cpu: [arm64]
+ os: [win32]
+
+ '@rspack/binding-win32-ia32-msvc@2.1.10':
+ resolution: {integrity: sha512-7qcWdsZ+GuGtzKjqgy7wTN7Dso/ezIY8yhx1r2yIbcczdmXj4FhaEampMDp/25HwtKwIGBBoh6HHSt3JWxpTUg==}
+ cpu: [ia32]
+ os: [win32]
+
+ '@rspack/binding-win32-x64-msvc@2.1.10':
+ resolution: {integrity: sha512-pgp23pLrzfhGnKycxzr7ifP17lAbWZEfnx1bX8gXtYrnpJ66DRNyTKSzxB6sa/HBWjS1L8PX5TjMZ44WfPydqQ==}
+ cpu: [x64]
+ os: [win32]
+
+ '@rspack/binding@2.1.10':
+ resolution: {integrity: sha512-vnu/UP5HnrND15lO9+VeG6eUrbTyycHNQNQ3XEiRiFojuoiGZkIZC3Hbzr8qQH44C6vScPODEPvvIVvLcO2LpQ==}
+
+ '@rspack/core@2.1.10':
+ resolution: {integrity: sha512-YSS2/Xxz8uiG/KXDkqOoA3dTetNo/vysk7bAexQOrU8iuq7JuzDTTAwLKvWZnwmvME8M8m5wcM4YvfIwYmidHA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ peerDependencies:
+ '@module-federation/runtime-tools': ^0.24.1 || ^2.0.0
+ '@swc/helpers': ^0.5.23
+ peerDependenciesMeta:
+ '@module-federation/runtime-tools':
+ optional: true
+ '@swc/helpers':
+ optional: true
+
+ '@rstest/core@0.11.6':
+ resolution: {integrity: sha512-P3wgYGDF3JmhapwN3p4DnbNV9N6+E+dlbp0coT1lAuXN5Po6bHeP/rduGLsTUIX85qWxmJD7ekF7nlOKm9RoOA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ hasBin: true
+ peerDependencies:
+ happy-dom: ^20.8.3
+ jsdom: '>=15.0.0'
+ peerDependenciesMeta:
+ happy-dom:
+ optional: true
+ jsdom:
+ optional: true
+
+ '@rstest/coverage-istanbul@0.11.6':
+ resolution: {integrity: sha512-FvJf9EKLp7jxidaxHSY6HiPlDtQLHAATtG3HdxE3Je3aazp10Je4bjh9vmRDqOquaRIhMZgpLanENS6vA8P+HQ==}
+ peerDependencies:
+ '@rstest/core': ^0.11.0
+
+ '@swc/helpers@0.5.23':
+ resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==}
+
+ '@tybys/wasm-util@0.10.3':
+ resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==}
+
+ '@types/chai@5.2.3':
+ resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
+
+ '@types/deep-eql@4.0.2':
+ resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
+
+ '@types/node@24.13.3':
+ resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==}
+
+ '@typescript/typescript-aix-ppc64@7.0.2':
+ resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==}
+ engines: {node: '>=16.20.0'}
+ cpu: [ppc64]
+ os: [aix]
+
+ '@typescript/typescript-darwin-arm64@7.0.2':
+ resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==}
+ engines: {node: '>=16.20.0'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@typescript/typescript-darwin-x64@7.0.2':
+ resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==}
+ engines: {node: '>=16.20.0'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@typescript/typescript-freebsd-arm64@7.0.2':
+ resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==}
+ engines: {node: '>=16.20.0'}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@typescript/typescript-freebsd-x64@7.0.2':
+ resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==}
+ engines: {node: '>=16.20.0'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@typescript/typescript-linux-arm64@7.0.2':
+ resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==}
+ engines: {node: '>=16.20.0'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@typescript/typescript-linux-arm@7.0.2':
+ resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==}
+ engines: {node: '>=16.20.0'}
+ cpu: [arm]
+ os: [linux]
+
+ '@typescript/typescript-linux-loong64@7.0.2':
+ resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==}
+ engines: {node: '>=16.20.0'}
+ cpu: [loong64]
+ os: [linux]
+
+ '@typescript/typescript-linux-mips64el@7.0.2':
+ resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==}
+ engines: {node: '>=16.20.0'}
+ cpu: [mips64el]
+ os: [linux]
+
+ '@typescript/typescript-linux-ppc64@7.0.2':
+ resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==}
+ engines: {node: '>=16.20.0'}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@typescript/typescript-linux-riscv64@7.0.2':
+ resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==}
+ engines: {node: '>=16.20.0'}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@typescript/typescript-linux-s390x@7.0.2':
+ resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==}
+ engines: {node: '>=16.20.0'}
+ cpu: [s390x]
+ os: [linux]
+
+ '@typescript/typescript-linux-x64@7.0.2':
+ resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==}
+ engines: {node: '>=16.20.0'}
+ cpu: [x64]
+ os: [linux]
+
+ '@typescript/typescript-netbsd-arm64@7.0.2':
+ resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==}
+ engines: {node: '>=16.20.0'}
+ cpu: [arm64]
+ os: [netbsd]
+
+ '@typescript/typescript-netbsd-x64@7.0.2':
+ resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==}
+ engines: {node: '>=16.20.0'}
+ cpu: [x64]
+ os: [netbsd]
+
+ '@typescript/typescript-openbsd-arm64@7.0.2':
+ resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==}
+ engines: {node: '>=16.20.0'}
+ cpu: [arm64]
+ os: [openbsd]
+
+ '@typescript/typescript-openbsd-x64@7.0.2':
+ resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==}
+ engines: {node: '>=16.20.0'}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@typescript/typescript-sunos-x64@7.0.2':
+ resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==}
+ engines: {node: '>=16.20.0'}
+ cpu: [x64]
+ os: [sunos]
+
+ '@typescript/typescript-win32-arm64@7.0.2':
+ resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==}
+ engines: {node: '>=16.20.0'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@typescript/typescript-win32-x64@7.0.2':
+ resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==}
+ engines: {node: '>=16.20.0'}
+ cpu: [x64]
+ os: [win32]
+
+ accepts@2.0.0:
+ resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
+ engines: {node: '>= 0.6'}
+
+ ajv-formats@3.0.1:
+ resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
+
+ ajv@8.20.0:
+ resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}
+
+ assertion-error@2.0.1:
+ resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
+ engines: {node: '>=12'}
+
+ body-parser@2.3.0:
+ resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==}
+ engines: {node: '>=18'}
+
+ bytes@3.1.2:
+ resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
+ engines: {node: '>= 0.8'}
+
+ call-bind-apply-helpers@1.0.2:
+ resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
+ engines: {node: '>= 0.4'}
+
+ call-bound@1.0.4:
+ resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
+ engines: {node: '>= 0.4'}
+
+ content-disposition@1.1.0:
+ resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==}
+ engines: {node: '>=18'}
+
+ content-type@1.0.5:
+ resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==}
+ engines: {node: '>= 0.6'}
+
+ content-type@2.0.0:
+ resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==}
+ engines: {node: '>=18'}
+
+ cookie-signature@1.2.2:
+ resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==}
+ engines: {node: '>=6.6.0'}
+
+ cookie@0.7.2:
+ resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
+ engines: {node: '>= 0.6'}
+
+ cors@2.8.6:
+ resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
+ engines: {node: '>= 0.10'}
+
+ cross-spawn@7.0.6:
+ resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
+ engines: {node: '>= 8'}
+
+ debug@4.4.3:
+ resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
+ engines: {node: '>=6.0'}
+ peerDependencies:
+ supports-color: '*'
+ peerDependenciesMeta:
+ supports-color:
+ optional: true
+
+ depd@2.0.0:
+ resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
+ engines: {node: '>= 0.8'}
+
+ dunder-proto@1.0.1:
+ resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
+ engines: {node: '>= 0.4'}
+
+ ee-first@1.1.1:
+ resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
+
+ encodeurl@2.0.0:
+ resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
+ engines: {node: '>= 0.8'}
+
+ es-define-property@1.0.1:
+ resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
+ engines: {node: '>= 0.4'}
+
+ es-errors@1.3.0:
+ resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
+ engines: {node: '>= 0.4'}
+
+ es-object-atoms@1.1.2:
+ resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
+ engines: {node: '>= 0.4'}
+
+ escape-html@1.0.3:
+ resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
+
+ etag@1.8.1:
+ resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
+ engines: {node: '>= 0.6'}
+
+ eventsource-parser@3.1.1:
+ resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==}
+ engines: {node: '>=18.0.0'}
+
+ eventsource@3.0.7:
+ resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==}
+ engines: {node: '>=18.0.0'}
+
+ express-rate-limit@8.6.2:
+ resolution: {integrity: sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==}
+ engines: {node: '>= 16'}
+ peerDependencies:
+ express: '>= 4.11'
+
+ express@5.2.1:
+ resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
+ engines: {node: '>= 18'}
+
+ fast-deep-equal@3.1.3:
+ resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
+
+ fast-uri@3.1.5:
+ resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==}
+
+ finalhandler@2.1.1:
+ resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
+ engines: {node: '>= 18.0.0'}
+
+ forwarded@0.2.0:
+ resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
+ engines: {node: '>= 0.6'}
+
+ fresh@2.0.0:
+ resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
+ engines: {node: '>= 0.8'}
+
+ function-bind@1.1.2:
+ resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
+
+ get-intrinsic@1.3.0:
+ resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
+ engines: {node: '>= 0.4'}
+
+ get-proto@1.0.1:
+ resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
+ engines: {node: '>= 0.4'}
+
+ gopd@1.2.0:
+ resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
+ engines: {node: '>= 0.4'}
+
+ has-flag@4.0.0:
+ resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
+ engines: {node: '>=8'}
+
+ has-symbols@1.1.0:
+ resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
+ engines: {node: '>= 0.4'}
+
+ hasown@2.0.4:
+ resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
+ engines: {node: '>= 0.4'}
+
+ hono@4.13.1:
+ resolution: {integrity: sha512-kdJoFVv2xmayw6cY09H7AbMJMt8Jn5jdlEdXsP7AGBdF2DIptVlKlOLKXP41yPip4/a3yQPv9gVcJYI8YY04dw==}
+ engines: {node: '>=16.9.0'}
+
+ html-escaper@2.0.2:
+ resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
+
+ http-errors@2.0.1:
+ resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
+ engines: {node: '>= 0.8'}
+
+ iconv-lite@0.7.3:
+ resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==}
+ engines: {node: '>=0.10.0'}
+
+ inherits@2.0.4:
+ resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
+
+ ip-address@10.5.0:
+ resolution: {integrity: sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==}
+ engines: {node: '>= 12'}
+
+ ipaddr.js@1.9.1:
+ resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
+ engines: {node: '>= 0.10'}
+
+ is-promise@4.0.0:
+ resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
+
+ isexe@2.0.0:
+ resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
+
+ istanbul-lib-coverage@3.2.2:
+ resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
+ engines: {node: '>=8'}
+
+ istanbul-lib-report@3.0.1:
+ resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==}
+ engines: {node: '>=10'}
+
+ istanbul-lib-source-maps@5.0.6:
+ resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==}
+ engines: {node: '>=10'}
+
+ istanbul-reports@3.2.0:
+ resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
+ engines: {node: '>=8'}
+
+ jose@6.2.8:
+ resolution: {integrity: sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==}
+
+ json-schema-traverse@1.0.0:
+ resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
+
+ json-schema-typed@8.0.2:
+ resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==}
+
+ make-dir@4.0.0:
+ resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
+ engines: {node: '>=10'}
+
+ math-intrinsics@1.1.0:
+ resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
+ engines: {node: '>= 0.4'}
+
+ media-typer@1.1.1:
+ resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==}
+ engines: {node: '>= 0.8'}
+
+ merge-descriptors@2.0.0:
+ resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==}
+ engines: {node: '>=18'}
+
+ mime-db@1.54.0:
+ resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==}
+ engines: {node: '>= 0.6'}
+
+ mime-types@3.0.2:
+ resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==}
+ engines: {node: '>=18'}
+
+ ms@2.1.3:
+ resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+
+ negotiator@1.0.0:
+ resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
+ engines: {node: '>= 0.6'}
+
+ object-assign@4.1.1:
+ resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
+ engines: {node: '>=0.10.0'}
+
+ object-inspect@1.13.4:
+ resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
+ engines: {node: '>= 0.4'}
+
+ on-finished@2.4.1:
+ resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
+ engines: {node: '>= 0.8'}
+
+ once@1.4.0:
+ resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
+
+ parseurl@1.3.3:
+ resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
+ engines: {node: '>= 0.8'}
+
+ path-key@3.1.1:
+ resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
+ engines: {node: '>=8'}
+
+ path-to-regexp@8.4.2:
+ resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==}
+
+ picomatch@4.0.5:
+ resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==}
+ engines: {node: '>=12'}
+
+ pkce-challenge@5.0.1:
+ resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
+ engines: {node: '>=16.20.0'}
+
+ pkg-pr-new@0.0.87:
+ resolution: {integrity: sha512-nm+30Py1csXWfyMH1ueQyTR11IZGHS5oW8Qok/MxMwjPx9g1jX3wMRrJf8TgEvNo0a0M4i14T0zQsEPWdZfAhg==}
+ hasBin: true
+
+ prettier@3.9.6:
+ resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==}
+ engines: {node: '>=14'}
+ hasBin: true
+
+ proxy-addr@2.0.7:
+ resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
+ engines: {node: '>= 0.10'}
+
+ qs@6.15.3:
+ resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==}
+ engines: {node: '>=0.6'}
+
+ range-parser@1.3.0:
+ resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==}
+ engines: {node: '>= 0.6'}
+
+ raw-body@3.0.2:
+ resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==}
+ engines: {node: '>= 0.10'}
+
+ require-from-string@2.0.2:
+ resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
+ engines: {node: '>=0.10.0'}
+
+ router@2.2.0:
+ resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
+ engines: {node: '>= 18'}
+
+ rsbuild-plugin-dts@1.0.0-beta.3:
+ resolution: {integrity: sha512-Q8x/yyOsy8sNR8sHn0xuZsul7ErT5kXkTmDwCIxQ8esiMCW7QKjfyXDa4kvTxWn7oSQ5NP/O6qZM2vEA07thKw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ peerDependencies:
+ '@microsoft/api-extractor': ^7
+ '@rsbuild/core': ^2.0.0
+ typescript: ^5 || ^6 || ^7
+ peerDependenciesMeta:
+ '@microsoft/api-extractor':
+ optional: true
+ typescript:
+ optional: true
+
+ safer-buffer@2.1.2:
+ resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
+
+ semver@7.8.5:
+ resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
+ engines: {node: '>=10'}
+ hasBin: true
+
+ send@1.2.1:
+ resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==}
+ engines: {node: '>= 18'}
+
+ serve-static@2.2.1:
+ resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
+ engines: {node: '>= 18'}
+
+ setprototypeof@1.2.0:
+ resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
+
+ shebang-command@2.0.0:
+ resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
+ engines: {node: '>=8'}
+
+ shebang-regex@3.0.0:
+ resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
+ engines: {node: '>=8'}
+
+ side-channel-list@1.0.1:
+ resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
+ engines: {node: '>= 0.4'}
+
+ side-channel-map@1.0.1:
+ resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==}
+ engines: {node: '>= 0.4'}
+
+ side-channel-weakmap@1.0.2:
+ resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==}
+ engines: {node: '>= 0.4'}
+
+ side-channel@1.1.1:
+ resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==}
+ engines: {node: '>= 0.4'}
+
+ statuses@2.0.2:
+ resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
+ engines: {node: '>= 0.8'}
+
+ supports-color@7.2.0:
+ resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
+ engines: {node: '>=8'}
+
+ swc-plugin-coverage-instrument@0.0.32:
+ resolution: {integrity: sha512-KsO1xNQdcVb331Rm98Op6uYf36/CYdF1kILQxOddmtCNlRLISpCUqHcnogu+QgELZvG48oie+w0fzS8uktjcEA==}
+
+ toidentifier@1.0.1:
+ resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
+ engines: {node: '>=0.6'}
+
+ tslib@2.8.1:
+ resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
+
+ type-is@2.1.0:
+ resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==}
+ engines: {node: '>= 18'}
+
+ typescript@7.0.2:
+ resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==}
+ engines: {node: '>=16.20.0'}
+ hasBin: true
+
+ undici-types@7.18.2:
+ resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
+
+ unpipe@1.0.0:
+ resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
+ engines: {node: '>= 0.8'}
+
+ vary@1.1.2:
+ resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
+ engines: {node: '>= 0.8'}
+
+ which@2.0.2:
+ resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
+ engines: {node: '>= 8'}
+ hasBin: true
+
+ wrappy@1.0.2:
+ resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
+
+ zod-to-json-schema@3.25.2:
+ resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==}
+ peerDependencies:
+ zod: ^3.25.28 || ^4
+
+ zod@4.4.3:
+ resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
+
+snapshots:
+
+ '@ast-grep/napi-darwin-arm64@0.37.0':
+ optional: true
+
+ '@ast-grep/napi-darwin-x64@0.37.0':
+ optional: true
+
+ '@ast-grep/napi-linux-arm64-gnu@0.37.0':
+ optional: true
+
+ '@ast-grep/napi-linux-arm64-musl@0.37.0':
+ optional: true
+
+ '@ast-grep/napi-linux-x64-gnu@0.37.0':
+ optional: true
+
+ '@ast-grep/napi-linux-x64-musl@0.37.0':
+ optional: true
+
+ '@ast-grep/napi-win32-arm64-msvc@0.37.0':
+ optional: true
+
+ '@ast-grep/napi-win32-ia32-msvc@0.37.0':
+ optional: true
+
+ '@ast-grep/napi-win32-x64-msvc@0.37.0':
+ optional: true
+
+ '@ast-grep/napi@0.37.0':
+ optionalDependencies:
+ '@ast-grep/napi-darwin-arm64': 0.37.0
+ '@ast-grep/napi-darwin-x64': 0.37.0
+ '@ast-grep/napi-linux-arm64-gnu': 0.37.0
+ '@ast-grep/napi-linux-arm64-musl': 0.37.0
+ '@ast-grep/napi-linux-x64-gnu': 0.37.0
+ '@ast-grep/napi-linux-x64-musl': 0.37.0
+ '@ast-grep/napi-win32-arm64-msvc': 0.37.0
+ '@ast-grep/napi-win32-ia32-msvc': 0.37.0
+ '@ast-grep/napi-win32-x64-msvc': 0.37.0
+
+ '@emnapi/core@1.11.3':
+ dependencies:
+ '@emnapi/wasi-threads': 1.2.3
+ tslib: 2.8.1
+ optional: true
+
+ '@emnapi/runtime@1.11.3':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
+ '@emnapi/wasi-threads@1.2.3':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
+ '@hono/node-server@1.19.17(hono@4.13.1)':
+ dependencies:
+ hono: 4.13.1
+
+ '@jridgewell/resolve-uri@3.1.2': {}
+
+ '@jridgewell/sourcemap-codec@1.5.5': {}
+
+ '@jridgewell/trace-mapping@0.3.31':
+ dependencies:
+ '@jridgewell/resolve-uri': 3.1.2
+ '@jridgewell/sourcemap-codec': 1.5.5
+
+ '@modelcontextprotocol/sdk@1.29.0(supports-color@7.2.0)(zod@4.4.3)':
+ dependencies:
+ '@hono/node-server': 1.19.17(hono@4.13.1)
+ ajv: 8.20.0
+ ajv-formats: 3.0.1
+ content-type: 1.0.5
+ cors: 2.8.6
+ cross-spawn: 7.0.6
+ eventsource: 3.0.7
+ eventsource-parser: 3.1.1
+ express: 5.2.1(supports-color@7.2.0)
+ express-rate-limit: 8.6.2(express@5.2.1)(supports-color@7.2.0)
+ hono: 4.13.1
+ jose: 6.2.8
+ json-schema-typed: 8.0.2
+ pkce-challenge: 5.0.1
+ raw-body: 3.0.2
+ zod: 4.4.3
+ zod-to-json-schema: 3.25.2(zod@4.4.3)
+ transitivePeerDependencies:
+ - supports-color
+
+ '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)':
+ dependencies:
+ '@emnapi/core': 1.11.3
+ '@emnapi/runtime': 1.11.3
+ '@tybys/wasm-util': 0.10.3
+ optional: true
+
+ '@rsbuild/core@2.1.11':
+ dependencies:
+ '@rspack/core': 2.1.10(@swc/helpers@0.5.23)
+ '@swc/helpers': 0.5.23
+ transitivePeerDependencies:
+ - '@module-federation/runtime-tools'
+
+ '@rsbuild/core@2.1.12':
+ dependencies:
+ '@rspack/core': 2.1.10(@swc/helpers@0.5.23)
+ '@swc/helpers': 0.5.23
+ transitivePeerDependencies:
+ - '@module-federation/runtime-tools'
+
+ '@rsdoctor/agent-cli@https://pkg.pr.new/@rsdoctor/agent-cli@8926633c': {}
+
+ '@rslib/core@1.0.0-beta.3(typescript@7.0.2)':
+ dependencies:
+ '@rsbuild/core': 2.1.12
+ rsbuild-plugin-dts: 1.0.0-beta.3(@rsbuild/core@2.1.12)(typescript@7.0.2)
+ optionalDependencies:
+ typescript: 7.0.2
+ transitivePeerDependencies:
+ - '@module-federation/runtime-tools'
+ - core-js
+
+ '@rslint/core@0.8.0':
+ dependencies:
+ picomatch: 4.0.5
+ optionalDependencies:
+ '@rslint/native-darwin-arm64': 0.8.0
+ '@rslint/native-darwin-x64': 0.8.0
+ '@rslint/native-linux-arm64-gnu': 0.8.0
+ '@rslint/native-linux-arm64-musl': 0.8.0
+ '@rslint/native-linux-x64-gnu': 0.8.0
+ '@rslint/native-linux-x64-musl': 0.8.0
+ '@rslint/native-win32-arm64-msvc': 0.8.0
+ '@rslint/native-win32-x64-msvc': 0.8.0
+
+ '@rslint/native-darwin-arm64@0.8.0':
+ optional: true
+
+ '@rslint/native-darwin-x64@0.8.0':
+ optional: true
+
+ '@rslint/native-linux-arm64-gnu@0.8.0':
+ optional: true
+
+ '@rslint/native-linux-arm64-musl@0.8.0':
+ optional: true
+
+ '@rslint/native-linux-x64-gnu@0.8.0':
+ optional: true
+
+ '@rslint/native-linux-x64-musl@0.8.0':
+ optional: true
+
+ '@rslint/native-win32-arm64-msvc@0.8.0':
+ optional: true
+
+ '@rslint/native-win32-x64-msvc@0.8.0':
+ optional: true
+
+ '@rspack/binding-darwin-arm64@2.1.10':
+ optional: true
+
+ '@rspack/binding-darwin-x64@2.1.10':
+ optional: true
+
+ '@rspack/binding-linux-arm64-gnu@2.1.10':
+ optional: true
+
+ '@rspack/binding-linux-arm64-musl@2.1.10':
+ optional: true
+
+ '@rspack/binding-linux-ppc64-gnu@2.1.10':
+ optional: true
+
+ '@rspack/binding-linux-riscv64-gnu@2.1.10':
+ optional: true
+
+ '@rspack/binding-linux-riscv64-musl@2.1.10':
+ optional: true
+
+ '@rspack/binding-linux-s390x-gnu@2.1.10':
+ optional: true
+
+ '@rspack/binding-linux-x64-gnu@2.1.10':
+ optional: true
+
+ '@rspack/binding-linux-x64-musl@2.1.10':
+ optional: true
+
+ '@rspack/binding-wasm32-wasi@2.1.10':
+ dependencies:
+ '@emnapi/core': 1.11.3
+ '@emnapi/runtime': 1.11.3
+ '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)
+ optional: true
+
+ '@rspack/binding-win32-arm64-msvc@2.1.10':
+ optional: true
+
+ '@rspack/binding-win32-ia32-msvc@2.1.10':
+ optional: true
+
+ '@rspack/binding-win32-x64-msvc@2.1.10':
+ optional: true
+
+ '@rspack/binding@2.1.10':
+ optionalDependencies:
+ '@rspack/binding-darwin-arm64': 2.1.10
+ '@rspack/binding-darwin-x64': 2.1.10
+ '@rspack/binding-linux-arm64-gnu': 2.1.10
+ '@rspack/binding-linux-arm64-musl': 2.1.10
+ '@rspack/binding-linux-ppc64-gnu': 2.1.10
+ '@rspack/binding-linux-riscv64-gnu': 2.1.10
+ '@rspack/binding-linux-riscv64-musl': 2.1.10
+ '@rspack/binding-linux-s390x-gnu': 2.1.10
+ '@rspack/binding-linux-x64-gnu': 2.1.10
+ '@rspack/binding-linux-x64-musl': 2.1.10
+ '@rspack/binding-wasm32-wasi': 2.1.10
+ '@rspack/binding-win32-arm64-msvc': 2.1.10
+ '@rspack/binding-win32-ia32-msvc': 2.1.10
+ '@rspack/binding-win32-x64-msvc': 2.1.10
+
+ '@rspack/core@2.1.10(@swc/helpers@0.5.23)':
+ dependencies:
+ '@rspack/binding': 2.1.10
+ optionalDependencies:
+ '@swc/helpers': 0.5.23
+
+ '@rstest/core@0.11.6':
+ dependencies:
+ '@rsbuild/core': 2.1.12
+ '@types/chai': 5.2.3
+ transitivePeerDependencies:
+ - '@module-federation/runtime-tools'
+ - core-js
+
+ '@rstest/coverage-istanbul@0.11.6(@rstest/core@0.11.6)(supports-color@7.2.0)':
+ dependencies:
+ '@rstest/core': 0.11.6
+ istanbul-lib-coverage: 3.2.2
+ istanbul-lib-report: 3.0.1
+ istanbul-lib-source-maps: 5.0.6(supports-color@7.2.0)
+ istanbul-reports: 3.2.0
+ swc-plugin-coverage-instrument: 0.0.32
+ transitivePeerDependencies:
+ - supports-color
+
+ '@swc/helpers@0.5.23':
+ dependencies:
+ tslib: 2.8.1
+
+ '@tybys/wasm-util@0.10.3':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
+ '@types/chai@5.2.3':
+ dependencies:
+ '@types/deep-eql': 4.0.2
+ assertion-error: 2.0.1
+
+ '@types/deep-eql@4.0.2': {}
+
+ '@types/node@24.13.3':
+ dependencies:
+ undici-types: 7.18.2
+
+ '@typescript/typescript-aix-ppc64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-darwin-arm64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-darwin-x64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-freebsd-arm64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-freebsd-x64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-linux-arm64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-linux-arm@7.0.2':
+ optional: true
+
+ '@typescript/typescript-linux-loong64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-linux-mips64el@7.0.2':
+ optional: true
+
+ '@typescript/typescript-linux-ppc64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-linux-riscv64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-linux-s390x@7.0.2':
+ optional: true
+
+ '@typescript/typescript-linux-x64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-netbsd-arm64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-netbsd-x64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-openbsd-arm64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-openbsd-x64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-sunos-x64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-win32-arm64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-win32-x64@7.0.2':
+ optional: true
+
+ accepts@2.0.0:
+ dependencies:
+ mime-types: 3.0.2
+ negotiator: 1.0.0
+
+ ajv-formats@3.0.1:
+ dependencies:
+ ajv: 8.20.0
+
+ ajv@8.20.0:
+ dependencies:
+ fast-deep-equal: 3.1.3
+ fast-uri: 3.1.5
+ json-schema-traverse: 1.0.0
+ require-from-string: 2.0.2
+
+ assertion-error@2.0.1: {}
+
+ body-parser@2.3.0(supports-color@7.2.0):
+ dependencies:
+ bytes: 3.1.2
+ content-type: 2.0.0
+ debug: 4.4.3(supports-color@7.2.0)
+ http-errors: 2.0.1
+ iconv-lite: 0.7.3
+ on-finished: 2.4.1
+ qs: 6.15.3
+ raw-body: 3.0.2
+ type-is: 2.1.0
+ transitivePeerDependencies:
+ - supports-color
+
+ bytes@3.1.2: {}
+
+ call-bind-apply-helpers@1.0.2:
+ dependencies:
+ es-errors: 1.3.0
+ function-bind: 1.1.2
+
+ call-bound@1.0.4:
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ get-intrinsic: 1.3.0
+
+ content-disposition@1.1.0: {}
+
+ content-type@1.0.5: {}
+
+ content-type@2.0.0: {}
+
+ cookie-signature@1.2.2: {}
+
+ cookie@0.7.2: {}
+
+ cors@2.8.6:
+ dependencies:
+ object-assign: 4.1.1
+ vary: 1.1.2
+
+ cross-spawn@7.0.6:
+ dependencies:
+ path-key: 3.1.1
+ shebang-command: 2.0.0
+ which: 2.0.2
+
+ debug@4.4.3(supports-color@7.2.0):
+ dependencies:
+ ms: 2.1.3
+ optionalDependencies:
+ supports-color: 7.2.0
+
+ depd@2.0.0: {}
+
+ dunder-proto@1.0.1:
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ es-errors: 1.3.0
+ gopd: 1.2.0
+
+ ee-first@1.1.1: {}
+
+ encodeurl@2.0.0: {}
+
+ es-define-property@1.0.1: {}
+
+ es-errors@1.3.0: {}
+
+ es-object-atoms@1.1.2:
+ dependencies:
+ es-errors: 1.3.0
+
+ escape-html@1.0.3: {}
+
+ etag@1.8.1: {}
+
+ eventsource-parser@3.1.1: {}
+
+ eventsource@3.0.7:
+ dependencies:
+ eventsource-parser: 3.1.1
+
+ express-rate-limit@8.6.2(express@5.2.1)(supports-color@7.2.0):
+ dependencies:
+ debug: 4.4.3(supports-color@7.2.0)
+ express: 5.2.1(supports-color@7.2.0)
+ ip-address: 10.5.0
+ transitivePeerDependencies:
+ - supports-color
+
+ express@5.2.1(supports-color@7.2.0):
+ dependencies:
+ accepts: 2.0.0
+ body-parser: 2.3.0(supports-color@7.2.0)
+ content-disposition: 1.1.0
+ content-type: 1.0.5
+ cookie: 0.7.2
+ cookie-signature: 1.2.2
+ debug: 4.4.3(supports-color@7.2.0)
+ depd: 2.0.0
+ encodeurl: 2.0.0
+ escape-html: 1.0.3
+ etag: 1.8.1
+ finalhandler: 2.1.1(supports-color@7.2.0)
+ fresh: 2.0.0
+ http-errors: 2.0.1
+ merge-descriptors: 2.0.0
+ mime-types: 3.0.2
+ on-finished: 2.4.1
+ once: 1.4.0
+ parseurl: 1.3.3
+ proxy-addr: 2.0.7
+ qs: 6.15.3
+ range-parser: 1.3.0
+ router: 2.2.0(supports-color@7.2.0)
+ send: 1.2.1(supports-color@7.2.0)
+ serve-static: 2.2.1(supports-color@7.2.0)
+ statuses: 2.0.2
+ type-is: 2.1.0
+ vary: 1.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ fast-deep-equal@3.1.3: {}
+
+ fast-uri@3.1.5: {}
+
+ finalhandler@2.1.1(supports-color@7.2.0):
+ dependencies:
+ debug: 4.4.3(supports-color@7.2.0)
+ encodeurl: 2.0.0
+ escape-html: 1.0.3
+ on-finished: 2.4.1
+ parseurl: 1.3.3
+ statuses: 2.0.2
+ transitivePeerDependencies:
+ - supports-color
+
+ forwarded@0.2.0: {}
+
+ fresh@2.0.0: {}
+
+ function-bind@1.1.2: {}
+
+ get-intrinsic@1.3.0:
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ es-define-property: 1.0.1
+ es-errors: 1.3.0
+ es-object-atoms: 1.1.2
+ function-bind: 1.1.2
+ get-proto: 1.0.1
+ gopd: 1.2.0
+ has-symbols: 1.1.0
+ hasown: 2.0.4
+ math-intrinsics: 1.1.0
+
+ get-proto@1.0.1:
+ dependencies:
+ dunder-proto: 1.0.1
+ es-object-atoms: 1.1.2
+
+ gopd@1.2.0: {}
+
+ has-flag@4.0.0: {}
+
+ has-symbols@1.1.0: {}
+
+ hasown@2.0.4:
+ dependencies:
+ function-bind: 1.1.2
+
+ hono@4.13.1: {}
+
+ html-escaper@2.0.2: {}
+
+ http-errors@2.0.1:
+ dependencies:
+ depd: 2.0.0
+ inherits: 2.0.4
+ setprototypeof: 1.2.0
+ statuses: 2.0.2
+ toidentifier: 1.0.1
+
+ iconv-lite@0.7.3:
+ dependencies:
+ safer-buffer: 2.1.2
+
+ inherits@2.0.4: {}
+
+ ip-address@10.5.0: {}
+
+ ipaddr.js@1.9.1: {}
+
+ is-promise@4.0.0: {}
+
+ isexe@2.0.0: {}
+
+ istanbul-lib-coverage@3.2.2: {}
+
+ istanbul-lib-report@3.0.1:
+ dependencies:
+ istanbul-lib-coverage: 3.2.2
+ make-dir: 4.0.0
+ supports-color: 7.2.0
+
+ istanbul-lib-source-maps@5.0.6(supports-color@7.2.0):
+ dependencies:
+ '@jridgewell/trace-mapping': 0.3.31
+ debug: 4.4.3(supports-color@7.2.0)
+ istanbul-lib-coverage: 3.2.2
+ transitivePeerDependencies:
+ - supports-color
+
+ istanbul-reports@3.2.0:
+ dependencies:
+ html-escaper: 2.0.2
+ istanbul-lib-report: 3.0.1
+
+ jose@6.2.8: {}
+
+ json-schema-traverse@1.0.0: {}
+
+ json-schema-typed@8.0.2: {}
+
+ make-dir@4.0.0:
+ dependencies:
+ semver: 7.8.5
+
+ math-intrinsics@1.1.0: {}
+
+ media-typer@1.1.1: {}
+
+ merge-descriptors@2.0.0: {}
+
+ mime-db@1.54.0: {}
+
+ mime-types@3.0.2:
+ dependencies:
+ mime-db: 1.54.0
+
+ ms@2.1.3: {}
+
+ negotiator@1.0.0: {}
+
+ object-assign@4.1.1: {}
+
+ object-inspect@1.13.4: {}
+
+ on-finished@2.4.1:
+ dependencies:
+ ee-first: 1.1.1
+
+ once@1.4.0:
+ dependencies:
+ wrappy: 1.0.2
+
+ parseurl@1.3.3: {}
+
+ path-key@3.1.1: {}
+
+ path-to-regexp@8.4.2: {}
+
+ picomatch@4.0.5: {}
+
+ pkce-challenge@5.0.1: {}
+
+ pkg-pr-new@0.0.87: {}
+
+ prettier@3.9.6: {}
+
+ proxy-addr@2.0.7:
+ dependencies:
+ forwarded: 0.2.0
+ ipaddr.js: 1.9.1
+
+ qs@6.15.3:
+ dependencies:
+ es-define-property: 1.0.1
+ side-channel: 1.1.1
+
+ range-parser@1.3.0: {}
+
+ raw-body@3.0.2:
+ dependencies:
+ bytes: 3.1.2
+ http-errors: 2.0.1
+ iconv-lite: 0.7.3
+ unpipe: 1.0.0
+
+ require-from-string@2.0.2: {}
+
+ router@2.2.0(supports-color@7.2.0):
+ dependencies:
+ debug: 4.4.3(supports-color@7.2.0)
+ depd: 2.0.0
+ is-promise: 4.0.0
+ parseurl: 1.3.3
+ path-to-regexp: 8.4.2
+ transitivePeerDependencies:
+ - supports-color
+
+ rsbuild-plugin-dts@1.0.0-beta.3(@rsbuild/core@2.1.12)(typescript@7.0.2):
+ dependencies:
+ '@ast-grep/napi': 0.37.0
+ '@rsbuild/core': 2.1.12
+ optionalDependencies:
+ typescript: 7.0.2
+
+ safer-buffer@2.1.2: {}
+
+ semver@7.8.5: {}
+
+ send@1.2.1(supports-color@7.2.0):
+ dependencies:
+ debug: 4.4.3(supports-color@7.2.0)
+ encodeurl: 2.0.0
+ escape-html: 1.0.3
+ etag: 1.8.1
+ fresh: 2.0.0
+ http-errors: 2.0.1
+ mime-types: 3.0.2
+ ms: 2.1.3
+ on-finished: 2.4.1
+ range-parser: 1.3.0
+ statuses: 2.0.2
+ transitivePeerDependencies:
+ - supports-color
+
+ serve-static@2.2.1(supports-color@7.2.0):
+ dependencies:
+ encodeurl: 2.0.0
+ escape-html: 1.0.3
+ parseurl: 1.3.3
+ send: 1.2.1(supports-color@7.2.0)
+ transitivePeerDependencies:
+ - supports-color
+
+ setprototypeof@1.2.0: {}
+
+ shebang-command@2.0.0:
+ dependencies:
+ shebang-regex: 3.0.0
+
+ shebang-regex@3.0.0: {}
+
+ side-channel-list@1.0.1:
+ dependencies:
+ es-errors: 1.3.0
+ object-inspect: 1.13.4
+
+ side-channel-map@1.0.1:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ get-intrinsic: 1.3.0
+ object-inspect: 1.13.4
+
+ side-channel-weakmap@1.0.2:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ get-intrinsic: 1.3.0
+ object-inspect: 1.13.4
+ side-channel-map: 1.0.1
+
+ side-channel@1.1.1:
+ dependencies:
+ es-errors: 1.3.0
+ object-inspect: 1.13.4
+ side-channel-list: 1.0.1
+ side-channel-map: 1.0.1
+ side-channel-weakmap: 1.0.2
+
+ statuses@2.0.2: {}
+
+ supports-color@7.2.0:
+ dependencies:
+ has-flag: 4.0.0
+
+ swc-plugin-coverage-instrument@0.0.32: {}
+
+ toidentifier@1.0.1: {}
+
+ tslib@2.8.1: {}
+
+ type-is@2.1.0:
+ dependencies:
+ content-type: 2.0.0
+ media-typer: 1.1.1
+ mime-types: 3.0.2
+
+ typescript@7.0.2:
+ optionalDependencies:
+ '@typescript/typescript-aix-ppc64': 7.0.2
+ '@typescript/typescript-darwin-arm64': 7.0.2
+ '@typescript/typescript-darwin-x64': 7.0.2
+ '@typescript/typescript-freebsd-arm64': 7.0.2
+ '@typescript/typescript-freebsd-x64': 7.0.2
+ '@typescript/typescript-linux-arm': 7.0.2
+ '@typescript/typescript-linux-arm64': 7.0.2
+ '@typescript/typescript-linux-loong64': 7.0.2
+ '@typescript/typescript-linux-mips64el': 7.0.2
+ '@typescript/typescript-linux-ppc64': 7.0.2
+ '@typescript/typescript-linux-riscv64': 7.0.2
+ '@typescript/typescript-linux-s390x': 7.0.2
+ '@typescript/typescript-linux-x64': 7.0.2
+ '@typescript/typescript-netbsd-arm64': 7.0.2
+ '@typescript/typescript-netbsd-x64': 7.0.2
+ '@typescript/typescript-openbsd-arm64': 7.0.2
+ '@typescript/typescript-openbsd-x64': 7.0.2
+ '@typescript/typescript-sunos-x64': 7.0.2
+ '@typescript/typescript-win32-arm64': 7.0.2
+ '@typescript/typescript-win32-x64': 7.0.2
+
+ undici-types@7.18.2: {}
+
+ unpipe@1.0.0: {}
+
+ vary@1.1.2: {}
+
+ which@2.0.2:
+ dependencies:
+ isexe: 2.0.0
+
+ wrappy@1.0.2: {}
+
+ zod-to-json-schema@3.25.2(zod@4.4.3):
+ dependencies:
+ zod: 4.4.3
+
+ zod@4.4.3: {}
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
new file mode 100644
index 0000000..5f7d1fb
--- /dev/null
+++ b/pnpm-workspace.yaml
@@ -0,0 +1,18 @@
+autoInstallPeers: false
+dedupePeers: true
+engineStrict: true
+
+minimumReleaseAge: 1440
+minimumReleaseAgeExclude:
+ - '@rsbuild/*'
+ - '@rslib/*'
+ - '@rspack/*'
+ - 'rsbuild-plugin-*'
+
+# Root-only validation override, deliberately NOT part of the packed manifest: published
+# previews must carry ordinary semver dependencies because blockExoticSubdeps consumers
+# reject URL-resolved subdependencies (rstack-cli context-plugin-boundary design, "Preview
+# dependency policy"). Commit-pinned to the stacked validation head in
+# web-infra-dev/rsdoctor#1924 (on top of #1903).
+overrides:
+ '@rsdoctor/agent-cli': 'https://pkg.pr.new/@rsdoctor/agent-cli@8926633c'
diff --git a/rslib.config.ts b/rslib.config.ts
new file mode 100644
index 0000000..9c5f09d
--- /dev/null
+++ b/rslib.config.ts
@@ -0,0 +1,25 @@
+import { defineConfig } from '@rslib/core';
+import pkgJson from './package.json' with { type: 'json' };
+
+export default defineConfig({
+ dts: true,
+ syntax: 'es2023',
+ source: {
+ entry: {
+ index: './src/index.ts',
+ mcp: './src/mcp.ts',
+ rsbuild: './src/rsbuild.ts',
+ rsdoctor: './src/rsdoctor.ts',
+ rslib: './src/rslib.ts',
+ rslint: './src/rslint.ts',
+ rstack: './src/rstack.ts',
+ rstest: './src/rstest.ts',
+ },
+ define: {
+ RSTACK_CONTEXT_VERSION: JSON.stringify(pkgJson.version),
+ },
+ },
+ output: {
+ minify: false,
+ },
+});
diff --git a/rslint.config.ts b/rslint.config.ts
new file mode 100644
index 0000000..5eb7114
--- /dev/null
+++ b/rslint.config.ts
@@ -0,0 +1,29 @@
+import { defineConfig, globalIgnores, ts } from '@rslint/core';
+
+export default defineConfig([
+ globalIgnores(['coverage/**', 'dist/**', 'dist-tests/**']),
+ ts.configs.recommended,
+ {
+ languageOptions: {
+ parserOptions: {
+ project: ['./tsconfig.json', './tests/tsconfig.json'],
+ },
+ },
+ },
+ {
+ files: ['src/**/*.ts'],
+ rules: {
+ '@typescript-eslint/no-restricted-imports': [
+ 'error',
+ {
+ patterns: [
+ {
+ regex: String.raw`^\.{1,2}/.*\.js$`,
+ message: 'Use the .ts extension for relative imports.',
+ },
+ ],
+ },
+ ],
+ },
+ },
+]);
diff --git a/rstest.config.ts b/rstest.config.ts
new file mode 100644
index 0000000..143ec83
--- /dev/null
+++ b/rstest.config.ts
@@ -0,0 +1,8 @@
+import { defineConfig } from '@rstest/core';
+
+export default defineConfig({
+ include: ['./tests/**/*.test.ts'],
+ source: {
+ tsconfigPath: './tests/tsconfig.json',
+ },
+});
diff --git a/src/analysisModel.ts b/src/analysisModel.ts
new file mode 100644
index 0000000..88e26a1
--- /dev/null
+++ b/src/analysisModel.ts
@@ -0,0 +1,172 @@
+import type { ContextCompleteness, ContextRunStatus } from './model.ts';
+
+type ModuleRef = {
+ id: string;
+ path: string;
+ name: string;
+ chunks: string[];
+};
+
+type ModuleEdge = {
+ from: string;
+ to: string;
+};
+
+type OptimizerBound = 'cjs' | 'dynamic-import' | 'side-effect' | 'unknown-bailout';
+
+type ObservedModule = ModuleRef & {
+ isEntry: boolean;
+ optimizerBound?: OptimizerBound;
+ optimizerReasons?: string[];
+};
+
+type ObservedModuleGraph = {
+ modules: ObservedModule[];
+ edges: ModuleEdge[];
+ exportRowsPresent: boolean;
+ issues: Array<
+ | 'module-graph-missing'
+ | 'module-graph-omitted'
+ | 'artifact-build-mismatch'
+ | 'duplicate-module-id'
+ | 'dangling-edge'
+ >;
+};
+
+type ProductRootKind =
+ 'production-entry' | 'published-contract' | 'side-effect' | 'conservative-runtime';
+
+type ProductRoot = {
+ kind: ProductRootKind;
+ module: ModuleRef;
+ label: string;
+};
+
+type ContractField = 'exports' | 'main' | 'module' | 'types' | 'bin';
+
+type ContractTarget = {
+ field: ContractField;
+ target: string;
+ matchedModuleIds: string[];
+};
+
+type ProductRootSet = {
+ contextId: string;
+ packageRoot: string;
+ product: 'application' | 'library' | 'unknown';
+ roots: ProductRoot[];
+ contractTargets: ContractTarget[];
+ bounds: string[];
+};
+
+type AnalysisProvenance = {
+ contextId: string;
+ dataFile: string;
+ artifactBinding: 'exact' | 'mismatch' | 'explicit-unverified';
+ buildObservation?: {
+ runId: string;
+ snapshotId: string;
+ observedAt: string;
+ status: ContextRunStatus;
+ buildCompleteness?: ContextCompleteness;
+ };
+};
+
+type ModuleState = {
+ productionReachability: 'live' | 'unreachable' | 'unknown';
+ publicContract: 'required' | 'not-required' | 'unknown';
+ shipped: 'yes' | 'unknown';
+ optimizerRetention: 'side-effect' | 'bailout' | 'unknown';
+};
+
+type ModuleCandidate = {
+ subject: ModuleRef & { kind: 'module' };
+ classification: 'unreachable-module-candidate';
+ state: ModuleState;
+ confidence: 'derived' | 'unknown';
+ evidence: string[];
+ bounds: string[];
+};
+
+type ProductRootsResult = {
+ provenance: AnalysisProvenance;
+ graph: {
+ moduleCount: number;
+ edgeCount: number;
+ issues: ObservedModuleGraph['issues'];
+ };
+ product: ProductRootSet;
+};
+
+type UnusedCandidatesResult = {
+ provenance: AnalysisProvenance;
+ roots: {
+ production: number;
+ contract: number;
+ conservative: number;
+ };
+ total: number;
+ returned: number;
+ ownership: {
+ project: number;
+ dependency: number;
+ };
+ analysisTruncated: boolean;
+ resultTruncated: boolean;
+ candidates: ModuleCandidate[];
+ bounds: string[];
+};
+
+type ModulePath = {
+ rootKind: ProductRootKind;
+ modules: ModuleRef[];
+};
+
+type DeadCodeExplanation = {
+ provenance: AnalysisProvenance;
+ subject?: ModuleRef & { kind: 'module' };
+ classification:
+ | 'reachable'
+ | 'unreachable-module-candidate'
+ | 'preserved-by-conservative-root'
+ | 'insufficient-evidence';
+ state: ModuleState;
+ paths: ModulePath[];
+ evidence: string[];
+ analysisTruncated: boolean;
+ bounds: string[];
+};
+
+type ModuleImpactResult = {
+ provenance: AnalysisProvenance;
+ subject?: ModuleRef & { kind: 'module' };
+ direction: 'dependencies' | 'dependents';
+ modules: ModuleRef[];
+ reachedRoots: ProductRoot[];
+ affectedChunks: string[];
+ totalVisited: number;
+ returned: number;
+ truncated: boolean;
+ bounds: string[];
+};
+
+export type {
+ AnalysisProvenance,
+ ContractField,
+ ContractTarget,
+ DeadCodeExplanation,
+ ModuleCandidate,
+ ModuleEdge,
+ ModuleImpactResult,
+ ModulePath,
+ ModuleRef,
+ ModuleState,
+ ObservedModule,
+ ObservedModuleGraph,
+ OptimizerBound,
+ ProductRoot,
+ ProductRootKind,
+ ProductRootSet,
+ ProductRootsResult,
+ UnusedCandidatesResult,
+};
diff --git a/src/artifactProducts.ts b/src/artifactProducts.ts
new file mode 100644
index 0000000..fd8996a
--- /dev/null
+++ b/src/artifactProducts.ts
@@ -0,0 +1,26 @@
+import type { ObservedModuleGraph, ProductRootSet } from './analysisModel.ts';
+import type { ContextDescriptor } from './model.ts';
+import { resolveProductRoots } from './products.ts';
+
+const resolveArtifactProductRoots = async (
+ workspaceRoot: string,
+ context: ContextDescriptor,
+ graph: ObservedModuleGraph,
+): Promise => {
+ if (context.product === 'application' || context.product === 'library') {
+ return resolveProductRoots(workspaceRoot, context, graph);
+ }
+
+ const artifactRoots = await resolveProductRoots(
+ workspaceRoot,
+ { ...context, product: 'application' },
+ graph,
+ );
+ return {
+ ...artifactRoots,
+ product: 'unknown',
+ bounds: ['product-context-unavailable', ...artifactRoots.bounds],
+ };
+};
+
+export { resolveArtifactProductRoots };
diff --git a/src/build.ts b/src/build.ts
new file mode 100644
index 0000000..d827c48
--- /dev/null
+++ b/src/build.ts
@@ -0,0 +1,355 @@
+import { randomUUID } from 'node:crypto';
+import path from 'node:path';
+import type {
+ ConfigParams,
+ EnvironmentContext,
+ OnAfterEnvironmentCompileFn,
+ OnBeforeBuildFn,
+ Rspack,
+ RsbuildConfig,
+ RsbuildPlugin,
+} from '@rsbuild/core';
+import { sha256Hex } from './guards.ts';
+import {
+ contextStoreSchemaVersion,
+ type BuildMetadataFacet,
+ type ContextDescriptor,
+ type ContextInputFile,
+ type ContextRunManifest,
+ type ContextSnapshot,
+ type ContextStoreWriteResult,
+} from './model.ts';
+import { toWorkspacePath } from './paths.ts';
+import { writeContextRunManifest, writeContextSnapshot } from './store.ts';
+import type { ResolvedContextWorkspace } from './workspace.ts';
+
+type BuildContextPluginOptions = {
+ producer: 'rsbuild' | 'rslib';
+ product: 'application' | 'library';
+ capture: 'metadata' | 'deep';
+ workspace: ResolvedContextWorkspace;
+ configPath?: string;
+ params: ConfigParams;
+ variant?: string;
+ inputs?: ContextInputFile[];
+ createRunId?: () => string;
+ now?: () => Date;
+};
+
+const getTarget = (environment: EnvironmentContext): string => environment.config.output.target;
+
+const getDistPath = (workspaceRoot: string, environment: EnvironmentContext): string =>
+ toWorkspacePath(workspaceRoot, environment.distPath) || '.';
+
+const getMode = (params: ConfigParams): string => params.envMode ?? params.env;
+
+const normalizeMetadataPath = (workspaceRoot: string, value: string): string =>
+ path.posix.normalize(
+ path.isAbsolute(value)
+ ? toWorkspacePath(workspaceRoot, value) || '.'
+ : value.split(path.sep).join('/').replaceAll('\\', '/'),
+ );
+
+const buildMetadataFacet = ({
+ options,
+ environment,
+ isFirstCompile,
+ isWatch,
+ stats,
+ time,
+}: {
+ options: BuildContextPluginOptions;
+ environment: EnvironmentContext;
+ isFirstCompile: boolean;
+ isWatch: boolean;
+ stats: NonNullable[0]['stats']>;
+ time: number;
+}): BuildMetadataFacet => {
+ const json = stats.toJson({
+ all: false,
+ hash: true,
+ assets: true,
+ chunks: true,
+ errors: false,
+ warnings: false,
+ }) as Pick;
+ const assets: BuildMetadataFacet['assets'] = [];
+ let droppedAssets = 0;
+ for (const asset of json.assets ?? []) {
+ if (typeof asset.name !== 'string' || typeof asset.size !== 'number') {
+ continue;
+ }
+ const name = normalizeMetadataPath(options.workspace.workspaceRoot, asset.name);
+ if (assets.length < 100) {
+ assets.push({ name, size: asset.size });
+ } else {
+ droppedAssets += 1;
+ }
+ }
+
+ const chunks: BuildMetadataFacet['chunks'] = [];
+ let droppedChunks = 0;
+ for (const chunk of json.chunks ?? []) {
+ if (!Array.isArray(chunk.files)) {
+ continue;
+ }
+ if (chunks.length >= 100) {
+ droppedChunks += 1;
+ continue;
+ }
+
+ const files: string[] = [];
+ for (const file of chunk.files) {
+ if (typeof file !== 'string') {
+ continue;
+ }
+ files.push(normalizeMetadataPath(options.workspace.workspaceRoot, file));
+ }
+
+ chunks.push({
+ ...(typeof chunk.id === 'string' || typeof chunk.id === 'number'
+ ? { id: String(chunk.id) }
+ : {}),
+ files,
+ ...(typeof chunk.initial === 'boolean' ? { initial: chunk.initial } : {}),
+ });
+ }
+
+ return {
+ producer: options.producer,
+ command: options.params.command,
+ mode: getMode(options.params),
+ environment: environment.name,
+ target: [getTarget(environment)],
+ isWatch,
+ isFirstCompile,
+ durationMs: time,
+ ...(typeof json.hash === 'string' ? { hash: json.hash } : {}),
+ hasErrors: stats.hasErrors(),
+ hasWarnings: stats.hasWarnings(),
+ assets,
+ chunks,
+ truncated: {
+ assets: droppedAssets,
+ chunks: droppedChunks,
+ },
+ };
+};
+
+const captureSnapshot = ({
+ options,
+ environment,
+ isFirstCompile,
+ isWatch,
+ stats,
+ time,
+}: Parameters[0] & {
+ options: BuildContextPluginOptions;
+}): Pick => {
+ const deep = options.capture === 'deep' ? 'unsupported' : 'disabled';
+
+ if (stats === undefined) {
+ return {
+ completeness: { build: 'partial', deep },
+ facets: {},
+ status: 'error',
+ };
+ }
+
+ const build = buildMetadataFacet({
+ options,
+ environment,
+ isFirstCompile,
+ isWatch,
+ stats,
+ time,
+ });
+ return {
+ completeness: { build: 'complete', deep },
+ facets: { build },
+ status: build.hasErrors ? 'fail' : 'pass',
+ };
+};
+
+const ensureContextWrite = (result: ContextStoreWriteResult): void => {
+ if (!result.written) {
+ throw result.error;
+ }
+};
+
+const createContextDescriptor = (
+ options: BuildContextPluginOptions,
+ environment: EnvironmentContext,
+): ContextDescriptor => {
+ const packageRoot =
+ toWorkspacePath(options.workspace.workspaceRoot, options.workspace.packageRoot) || '.';
+ const configPath =
+ options.configPath === undefined
+ ? undefined
+ : toWorkspacePath(options.workspace.workspaceRoot, options.configPath) || '.';
+ const mode = getMode(options.params);
+ const target = getTarget(environment);
+ const distPath = getDistPath(options.workspace.workspaceRoot, environment);
+ const identity = [
+ options.producer,
+ packageRoot,
+ configPath ?? '',
+ options.product,
+ environment.name,
+ options.params.command,
+ mode,
+ target,
+ distPath,
+ options.variant ?? '',
+ ].join('\u0000');
+ const contextId = `ctx_${sha256Hex(identity).slice(0, 24)}`;
+
+ return {
+ contextId,
+ packageRoot,
+ product: options.product,
+ ...(options.workspace.packageName === undefined
+ ? {}
+ : { packageName: options.workspace.packageName }),
+ ...(configPath === undefined ? {} : { configPath }),
+ environment: environment.name,
+ target,
+ mode,
+ distPath,
+ ...(options.variant === undefined ? {} : { variant: options.variant }),
+ };
+};
+
+const appendBuildContextPlugin = (
+ config: T,
+ plugin: RsbuildPlugin,
+): T => ({
+ ...config,
+ plugins: [...(config.plugins ?? []), plugin],
+});
+
+const createBuildContextPlugin = (options: BuildContextPluginOptions): RsbuildPlugin => {
+ let runPromise: Promise | undefined;
+ let run: ContextRunManifest | undefined;
+ let descriptorsByEnvironment: ReadonlyMap | undefined;
+ const sequencesByContext = new Map();
+ let warned = false;
+
+ return {
+ name: 'rstack:context-build',
+ setup(api) {
+ const guard =
+ (callback: (...args: Arguments) => Promise | void) =>
+ async (...args: Arguments): Promise => {
+ try {
+ await callback(...args);
+ } catch {
+ if (!warned) {
+ warned = true;
+ try {
+ api.logger.warn('Failed to capture Rstack build context.');
+ } catch {
+ return;
+ }
+ }
+ }
+ };
+
+ const ensureRun: OnBeforeBuildFn = async ({ environments }) => {
+ if (runPromise !== undefined) {
+ await runPromise;
+ return;
+ }
+
+ const now = options.now ?? (() => new Date());
+ const contexts = Object.values(environments)
+ .map((environment) => createContextDescriptor(options, environment))
+ .sort((left, right) => left.environment!.localeCompare(right.environment!));
+ const nextRun: ContextRunManifest = {
+ schemaVersion: contextStoreSchemaVersion,
+ runId: options.createRunId?.() ?? `run_${Date.now()}_${randomUUID()}`,
+ producer: options.producer,
+ command: options.params.command,
+ startedAt: now().toISOString(),
+ contexts,
+ };
+
+ run = nextRun;
+ descriptorsByEnvironment = new Map(
+ contexts.map((context) => [context.environment!, context]),
+ );
+ runPromise = writeContextRunManifest(options.workspace.workspaceRoot, nextRun).then(
+ ensureContextWrite,
+ );
+ await runPromise;
+ };
+
+ const publishSnapshot: OnAfterEnvironmentCompileFn = ({
+ environment,
+ isFirstCompile,
+ isWatch,
+ stats,
+ time,
+ }) => {
+ const capture = captureSnapshot({
+ options,
+ environment,
+ isFirstCompile,
+ isWatch,
+ stats,
+ time,
+ });
+ const environmentName = environment.name;
+ const currentRun = run;
+ const currentRunPromise = runPromise;
+ const currentDescriptorsByEnvironment = descriptorsByEnvironment;
+
+ return (async (): Promise => {
+ if (
+ currentRunPromise === undefined ||
+ currentRun === undefined ||
+ currentDescriptorsByEnvironment === undefined
+ ) {
+ throw new Error('Build context run has not started.');
+ }
+
+ await currentRunPromise;
+ const descriptor = currentDescriptorsByEnvironment.get(environmentName);
+ if (descriptor === undefined) {
+ throw new Error('Build environment is missing from the run manifest.');
+ }
+
+ const sequence = (sequencesByContext.get(descriptor.contextId) ?? 0) + 1;
+ const now = options.now ?? (() => new Date());
+ const snapshot: ContextSnapshot = {
+ schemaVersion: contextStoreSchemaVersion,
+ snapshotId: `snap_${currentRun.runId}_${descriptor.contextId}_${sequence}`,
+ runId: currentRun.runId,
+ contextId: descriptor.contextId,
+ sequence,
+ observedAt: now().toISOString(),
+ ...capture,
+ ...(options.inputs === undefined
+ ? {}
+ : {
+ source: {
+ inputs: options.inputs,
+ inputCompleteness: 'partial',
+ },
+ }),
+ };
+
+ ensureContextWrite(await writeContextSnapshot(options.workspace.workspaceRoot, snapshot));
+ sequencesByContext.set(descriptor.contextId, sequence);
+ })();
+ };
+
+ api.onBeforeBuild(guard(ensureRun));
+ api.onBeforeDevCompile(guard(ensureRun));
+ api.onAfterEnvironmentCompile(guard(publishSnapshot));
+ },
+ };
+};
+
+export { appendBuildContextPlugin, createBuildContextPlugin };
+export type { BuildContextPluginOptions };
diff --git a/src/cache.ts b/src/cache.ts
new file mode 100644
index 0000000..b7775a5
--- /dev/null
+++ b/src/cache.ts
@@ -0,0 +1,33 @@
+import { mkdir, readFile, writeFile } from 'node:fs/promises';
+import path from 'node:path';
+
+const cacheGitignore = '*\n';
+
+type ProjectCacheResult =
+ { status: 'available'; path: string } | { status: 'unavailable'; path: string; error: unknown };
+
+const getProjectCacheDir = (rootPath: string): string => path.join(rootPath, '.rstack', 'cache');
+
+const ensureProjectCacheDir = async (rootPath: string): Promise => {
+ const cachePath = getProjectCacheDir(rootPath);
+ const ignorePath = path.join(cachePath, '.gitignore');
+
+ try {
+ if ((await readFile(ignorePath, 'utf8')) === cacheGitignore) {
+ return { status: 'available', path: cachePath };
+ }
+ } catch {
+ // Create or repair the marker below.
+ }
+
+ try {
+ await mkdir(cachePath, { recursive: true });
+ await writeFile(ignorePath, cacheGitignore);
+ return { status: 'available', path: cachePath };
+ } catch (error) {
+ return { status: 'unavailable', path: cachePath, error };
+ }
+};
+
+export { ensureProjectCacheDir, getProjectCacheDir };
+export type { ProjectCacheResult };
diff --git a/src/codeEvidence.ts b/src/codeEvidence.ts
new file mode 100644
index 0000000..c0fa365
--- /dev/null
+++ b/src/codeEvidence.ts
@@ -0,0 +1,472 @@
+import { readFile } from 'node:fs/promises';
+import path from 'node:path';
+import type { DeadCodeExplanation } from './analysisModel.ts';
+import { sha256Hex } from './guards.ts';
+import { diagnosticsFromStoredSnapshot, type DiagnosticRecord } from './lint.ts';
+import type {
+ ContextFreshness,
+ ContextSnapshot,
+ StoredContextSnapshot,
+ TestExecutionFacet,
+ TestExecutionLocation,
+ TestFacet,
+} from './model.ts';
+import { normalizeModuleSelector, toWorkspacePath } from './paths.ts';
+import { explainAnalysisModule, loadAnalysis } from './queries.ts';
+import { assessSnapshotFreshness } from './source.ts';
+import { readContextSnapshotById, readContextSnapshots } from './store.ts';
+
+type CodeEvidenceQuery = {
+ path: string;
+ line?: number;
+ contextId?: string;
+ dataFile?: string;
+ module?: string;
+ testSnapshotId?: string;
+ lintSnapshotId?: string;
+ maxDepth?: number;
+};
+
+type SnapshotEvidence = {
+ snapshotId: string;
+ contextId: string;
+ observedAt: string;
+ status: ContextSnapshot['status'];
+ completeness: ContextSnapshot['completeness'];
+ freshness: ContextFreshness;
+ packageRoot: string;
+ unreadableInputs?: string[];
+};
+
+type ExecutionCoverageEvidence = {
+ state: 'observed' | 'not-observed' | 'unknown' | 'unavailable';
+ reason?:
+ | 'no-test-snapshot'
+ | 'not-captured'
+ | 'provider-unavailable'
+ | 'path-not-reported'
+ | 'digest-unavailable'
+ | 'digest-mismatch'
+ | 'partial-universe'
+ | 'no-overlapping-locations';
+ relevantLocations: number;
+ observedLocations: number;
+ fileDigest?: string;
+};
+
+type TestOutcomeEvidence = {
+ state: 'failed' | 'passed' | 'not-run' | 'unknown';
+ basis?: 'exact-path' | 'related-selection';
+ reason?: 'no-exact-test-record' | 'related-tests-not-reported';
+ matchingFiles: number;
+ matchingTests: number;
+};
+
+type TestRelationEvidence = {
+ state: 'related' | 'unrelated' | 'unknown' | 'unavailable';
+ reason?: 'no-test-snapshot' | 'not-captured' | 'source-not-selected' | 'selection-not-isolated';
+ testFiles: string[];
+};
+
+type CodeDiagnosticEvidence = {
+ total: number;
+ returned: number;
+ truncated: boolean;
+ items: DiagnosticRecord[];
+};
+
+type CodeEvidenceResult = {
+ path: string;
+ line?: number;
+ executionCoverage: ExecutionCoverageEvidence;
+ testRelation: TestRelationEvidence;
+ testOutcome: TestOutcomeEvidence;
+ diagnostics: CodeDiagnosticEvidence;
+ module?: DeadCodeExplanation;
+ provenance: { test?: SnapshotEvidence; lint?: SnapshotEvidence };
+ bounds: string[];
+};
+
+const normalizeSourcePath = (workspaceRoot: string, value: string): string => {
+ const portable = value.replaceAll('\\', '/');
+ if (portable.length === 0 || path.posix.isAbsolute(portable)) {
+ throw new Error('path must be a non-empty checkout-relative source path.');
+ }
+ const normalized = normalizeModuleSelector(value);
+ const relative = toWorkspacePath(workspaceRoot, normalized);
+ if (relative.length === 0 || relative === '..' || relative.startsWith('../')) {
+ throw new Error('path must be a non-empty checkout-relative source path.');
+ }
+ return relative;
+};
+
+const packageContainsPath = (packageRoot: string, sourcePath: string): boolean => {
+ const normalizedRoot = path.posix.normalize(packageRoot.replaceAll('\\', '/'));
+ return (
+ normalizedRoot === '.' ||
+ sourcePath === normalizedRoot ||
+ sourcePath.startsWith(`${normalizedRoot}/`)
+ );
+};
+
+const lintSnapshotCapturedPath = (stored: StoredContextSnapshot, sourcePath: string): boolean =>
+ stored.snapshot.source?.inputs?.some((input) => input.path === sourcePath) === true ||
+ diagnosticsFromStoredSnapshot(stored).some((diagnostic) => diagnostic.path === sourcePath);
+
+const selectSnapshot = async (
+ workspaceRoot: string,
+ producer: 'rstest' | 'rslint',
+ sourcePath: string,
+ snapshotId: string | undefined,
+): Promise => {
+ if (snapshotId !== undefined) {
+ const selected = await readContextSnapshotById(workspaceRoot, snapshotId);
+ if (selected === undefined || selected.run.producer !== producer) {
+ throw new Error(`${producer === 'rstest' ? 'Rstest' : 'Rslint'} snapshot not found.`);
+ }
+ if (!packageContainsPath(selected.context.packageRoot, sourcePath)) {
+ throw new Error(
+ `Selected ${producer === 'rstest' ? 'Rstest' : 'Rslint'} snapshot package root does not contain the source path.`,
+ );
+ }
+ if (producer === 'rslint' && !lintSnapshotCapturedPath(selected, sourcePath)) {
+ throw new Error('Selected Rslint snapshot did not capture the source path.');
+ }
+ return selected;
+ }
+ return (await readContextSnapshots(workspaceRoot, { producer })).find(
+ (stored) =>
+ packageContainsPath(stored.context.packageRoot, sourcePath) &&
+ (producer !== 'rstest' || stored.snapshot.completeness.test === 'complete') &&
+ stored.snapshot.facets[producer === 'rstest' ? 'test' : 'lint'] !== undefined &&
+ (producer !== 'rslint' || lintSnapshotCapturedPath(stored, sourcePath)),
+ );
+};
+
+const snapshotEvidence = async (
+ workspaceRoot: string,
+ stored: StoredContextSnapshot,
+): Promise => ({
+ snapshotId: stored.snapshot.snapshotId,
+ contextId: stored.snapshot.contextId,
+ observedAt: stored.snapshot.observedAt,
+ status: stored.snapshot.status,
+ completeness: stored.snapshot.completeness,
+ freshness: await assessSnapshotFreshness(workspaceRoot, stored.snapshot),
+ packageRoot: stored.context.packageRoot,
+ ...(stored.snapshot.source?.unreadableInputs === undefined
+ ? {}
+ : { unreadableInputs: [...stored.snapshot.source.unreadableInputs] }),
+});
+
+const locationOverlapsLine = (location: TestExecutionLocation, line: number | undefined): boolean =>
+ line === undefined || (location.start.line <= line && location.end.line >= line);
+
+const readCurrentDigest = async (
+ workspaceRoot: string,
+ sourcePath: string,
+): Promise => {
+ try {
+ return sha256Hex(await readFile(path.resolve(workspaceRoot, sourcePath)));
+ } catch {
+ return undefined;
+ }
+};
+
+const executionCoverage = async (
+ workspaceRoot: string,
+ sourcePath: string,
+ line: number | undefined,
+ stored: StoredContextSnapshot | undefined,
+): Promise => {
+ const empty = { relevantLocations: 0, observedLocations: 0 };
+ if (stored === undefined) return { state: 'unavailable', reason: 'no-test-snapshot', ...empty };
+ const facet = stored.snapshot.facets.execution as unknown as TestExecutionFacet | undefined;
+ if (facet === undefined) return { state: 'unavailable', reason: 'not-captured', ...empty };
+ if (facet.availability !== 'available') {
+ return { state: 'unavailable', reason: 'provider-unavailable', ...empty };
+ }
+ const file = facet.files.find((entry) => entry.path === sourcePath);
+ if (file === undefined) return { state: 'unknown', reason: 'path-not-reported', ...empty };
+ const digest = await readCurrentDigest(workspaceRoot, sourcePath);
+ if (digest === undefined || file.digest === undefined) {
+ return { state: 'unknown', reason: 'digest-unavailable', fileDigest: file.digest, ...empty };
+ }
+ if (digest !== file.digest) {
+ return { state: 'unknown', reason: 'digest-mismatch', fileDigest: file.digest, ...empty };
+ }
+
+ const hits = [
+ ...file.statements
+ .filter(({ location }) => locationOverlapsLine(location, line))
+ .map(({ hits }) => hits),
+ ...file.functions
+ .filter(({ location }) => locationOverlapsLine(location, line))
+ .map(({ hits }) => hits),
+ ...file.branches.flatMap(({ arms }) =>
+ arms.filter(({ location }) => locationOverlapsLine(location, line)).map(({ hits }) => hits),
+ ),
+ ];
+ const observedLocations = hits.filter((value) => value > 0).length;
+ const counts = { relevantLocations: hits.length, observedLocations, fileDigest: file.digest };
+ if (hits.length === 0) {
+ return { state: 'unknown', reason: 'no-overlapping-locations', ...counts };
+ }
+ if (observedLocations > 0) return { state: 'observed', ...counts };
+ if (
+ stored.snapshot.completeness.execution !== 'complete' ||
+ facet.universe.completeness !== 'complete' ||
+ facet.truncated.files > 0 ||
+ facet.truncated.locations > 0
+ ) {
+ return { state: 'unknown', reason: 'partial-universe', ...counts };
+ }
+ return { state: 'not-observed', ...counts };
+};
+
+const testOutcome = (
+ sourcePath: string,
+ stored: StoredContextSnapshot | undefined,
+): TestOutcomeEvidence => {
+ if (stored === undefined) return { state: 'unknown', matchingFiles: 0, matchingTests: 0 };
+ const facet = stored.snapshot.facets.test as unknown as TestFacet | undefined;
+ if (facet === undefined) return { state: 'unknown', matchingFiles: 0, matchingTests: 0 };
+ const files = facet.files.filter((file) => file.path === sourcePath);
+ const tests = facet.files
+ .flatMap((file) => file.tests)
+ .filter((test) => test.path === sourcePath);
+ let basis: TestOutcomeEvidence['basis'] = 'exact-path';
+ let matchingFiles = files;
+ let matchingTests = tests;
+ if (files.length === 0 && tests.length === 0) {
+ const relation = facet.relation;
+ if (
+ relation === undefined ||
+ relation.sources.length !== 1 ||
+ relation.sources[0] !== sourcePath
+ ) {
+ return {
+ state: 'unknown',
+ reason: 'no-exact-test-record',
+ matchingFiles: 0,
+ matchingTests: 0,
+ };
+ }
+ const selectedPaths = new Set(relation.testFiles);
+ matchingFiles = facet.files.filter((file) => selectedPaths.has(file.path));
+ matchingTests = matchingFiles.flatMap((file) => file.tests);
+ basis = 'related-selection';
+ if (relation.testFiles.length > 0 && matchingFiles.length === 0) {
+ return {
+ state: 'unknown',
+ basis,
+ reason: 'related-tests-not-reported',
+ matchingFiles: 0,
+ matchingTests: 0,
+ };
+ }
+ }
+ if (
+ // A run-level unhandled error is global to the snapshot, so it only attributes to this source
+ // when the run was provably isolated to it. An exact test-file record reports its own outcome.
+ (basis === 'related-selection' && facet.unhandledErrors.length > 0) ||
+ matchingFiles.some((file) => file.status === 'fail' || (file.errors?.length ?? 0) > 0) ||
+ matchingTests.some((test) => test.status === 'fail')
+ ) {
+ return {
+ state: 'failed',
+ basis,
+ matchingFiles: matchingFiles.length,
+ matchingTests: matchingTests.length,
+ };
+ }
+ if (
+ matchingFiles.some((file) => file.status === 'pass') ||
+ matchingTests.some((test) => test.status === 'pass')
+ ) {
+ return {
+ state: 'passed',
+ basis,
+ matchingFiles: matchingFiles.length,
+ matchingTests: matchingTests.length,
+ };
+ }
+ return {
+ state: 'not-run',
+ basis,
+ matchingFiles: matchingFiles.length,
+ matchingTests: matchingTests.length,
+ };
+};
+
+const testRelation = (
+ sourcePath: string,
+ stored: StoredContextSnapshot | undefined,
+): TestRelationEvidence => {
+ if (stored === undefined) {
+ return { state: 'unavailable', reason: 'no-test-snapshot', testFiles: [] };
+ }
+ const facet = stored.snapshot.facets.test as unknown as TestFacet | undefined;
+ if (facet?.relation === undefined) {
+ return { state: 'unavailable', reason: 'not-captured', testFiles: [] };
+ }
+ if (!facet.relation.sources.includes(sourcePath)) {
+ return { state: 'unknown', reason: 'source-not-selected', testFiles: [] };
+ }
+ if (facet.relation.sources.length !== 1) {
+ return {
+ state: 'unknown',
+ reason: 'selection-not-isolated',
+ testFiles: [...facet.relation.testFiles],
+ };
+ }
+ return {
+ state: facet.relation.testFiles.length > 0 ? 'related' : 'unrelated',
+ testFiles: [...facet.relation.testFiles],
+ };
+};
+
+const compareDiagnostics = (left: DiagnosticRecord, right: DiagnosticRecord): number =>
+ left.producer.localeCompare(right.producer) ||
+ (left.line ?? 0) - (right.line ?? 0) ||
+ (left.column ?? 0) - (right.column ?? 0) ||
+ left.message.localeCompare(right.message);
+
+const moduleEvidence = async (
+ workspaceRoot: string,
+ query: Required> &
+ Pick & { path: string },
+): Promise => {
+ // The Rsdoctor artifact is read and normalized once per call; every module axis below reuses it.
+ const analysis = await loadAnalysis(workspaceRoot, query);
+ if (query.module !== undefined) {
+ return explainAnalysisModule(analysis, { module: query.module, maxDepth: query.maxDepth });
+ }
+ const { product } = analysis;
+ const packageRelativePath =
+ product.packageRoot === '.'
+ ? query.path
+ : query.path.startsWith(`${product.packageRoot}/`)
+ ? query.path.slice(product.packageRoot.length + 1)
+ : query.path;
+ const insufficientEvidence = (): DeadCodeExplanation => ({
+ provenance: analysis.provenance,
+ classification: 'insufficient-evidence',
+ state: {
+ productionReachability: 'unknown',
+ publicContract: 'unknown',
+ shipped: 'unknown',
+ optimizerRetention: 'unknown',
+ },
+ paths: [],
+ evidence: ['No unique artifact module matched the exact source path.'],
+ analysisTruncated: false,
+ bounds: [...product.bounds, 'source-path-module-match-unavailable'],
+ });
+ try {
+ return explainAnalysisModule(analysis, { module: query.path, maxDepth: query.maxDepth });
+ } catch (error) {
+ if (error instanceof Error && /^Ambiguous module selector:/u.test(error.message)) {
+ return insufficientEvidence();
+ }
+ if (!(error instanceof Error) || !/^Unknown module selector:/u.test(error.message)) throw error;
+ if (packageRelativePath !== query.path) {
+ try {
+ return explainAnalysisModule(analysis, {
+ module: packageRelativePath,
+ maxDepth: query.maxDepth,
+ });
+ } catch (fallbackError) {
+ if (
+ !(fallbackError instanceof Error) ||
+ !/^(?:Unknown|Ambiguous) module selector:/u.test(fallbackError.message)
+ ) {
+ throw fallbackError;
+ }
+ }
+ }
+ return insufficientEvidence();
+ }
+};
+
+const readCodeEvidence = async (
+ workspaceRoot: string,
+ query: CodeEvidenceQuery,
+): Promise => {
+ if ((query.contextId === undefined) !== (query.dataFile === undefined)) {
+ throw new Error('contextId and dataFile must be supplied together.');
+ }
+ if (query.module !== undefined && query.contextId === undefined) {
+ throw new Error('module requires contextId and dataFile.');
+ }
+ if (query.line !== undefined && (!Number.isInteger(query.line) || query.line < 1)) {
+ throw new Error('line must be a positive integer.');
+ }
+ const sourcePath = normalizeSourcePath(workspaceRoot, query.path);
+ const [testSnapshot, lintSnapshot] = await Promise.all([
+ selectSnapshot(workspaceRoot, 'rstest', sourcePath, query.testSnapshotId),
+ selectSnapshot(workspaceRoot, 'rslint', sourcePath, query.lintSnapshotId),
+ ]);
+ const matchingDiagnostics = [
+ ...(lintSnapshot === undefined ? [] : diagnosticsFromStoredSnapshot(lintSnapshot)),
+ ...(testSnapshot === undefined ? [] : diagnosticsFromStoredSnapshot(testSnapshot)),
+ ]
+ .filter((diagnostic) => diagnostic.path === sourcePath)
+ .sort(compareDiagnostics);
+ const diagnosticItems = matchingDiagnostics.slice(0, 200);
+ const diagnostics: CodeDiagnosticEvidence = {
+ total: matchingDiagnostics.length,
+ returned: diagnosticItems.length,
+ truncated: diagnosticItems.length < matchingDiagnostics.length,
+ items: diagnosticItems,
+ };
+ const module =
+ query.contextId === undefined || query.dataFile === undefined
+ ? undefined
+ : await moduleEvidence(workspaceRoot, {
+ path: sourcePath,
+ contextId: query.contextId,
+ dataFile: query.dataFile,
+ module: query.module,
+ maxDepth: query.maxDepth,
+ });
+ const provenance = {
+ ...(testSnapshot === undefined
+ ? {}
+ : { test: await snapshotEvidence(workspaceRoot, testSnapshot) }),
+ ...(lintSnapshot === undefined
+ ? {}
+ : { lint: await snapshotEvidence(workspaceRoot, lintSnapshot) }),
+ };
+ const bounds = [
+ 'aggregate-execution-no-test-attribution',
+ 'test-relation-static-build-graph',
+ 'test-outcome-exact-path-or-isolated-related-selection',
+ 'diagnostics-exact-path-only',
+ ...(module !== undefined && module.provenance.artifactBinding !== 'exact'
+ ? ['artifact-binding-not-exact']
+ : []),
+ ];
+ return {
+ path: sourcePath,
+ ...(query.line === undefined ? {} : { line: query.line }),
+ executionCoverage: await executionCoverage(workspaceRoot, sourcePath, query.line, testSnapshot),
+ testRelation: testRelation(sourcePath, testSnapshot),
+ testOutcome: testOutcome(sourcePath, testSnapshot),
+ diagnostics,
+ ...(module === undefined ? {} : { module }),
+ provenance,
+ bounds,
+ };
+};
+
+export { readCodeEvidence };
+export type {
+ CodeDiagnosticEvidence,
+ CodeEvidenceQuery,
+ CodeEvidenceResult,
+ ExecutionCoverageEvidence,
+ SnapshotEvidence,
+ TestOutcomeEvidence,
+ TestRelationEvidence,
+};
diff --git a/src/config.ts b/src/config.ts
new file mode 100644
index 0000000..593f578
--- /dev/null
+++ b/src/config.ts
@@ -0,0 +1,17 @@
+export type ContextCaptureTier = 'metadata' | 'deep';
+
+export type ContextConfig = {
+ enabled?: boolean;
+ capture?: 'off' | ContextCaptureTier;
+ variant?: string;
+};
+
+export const resolveContextCapture = (
+ config: ContextConfig | undefined,
+ override: string | undefined = process.env.RSTACK_CONTEXT,
+): ContextCaptureTier | 'off' => {
+ if (override === '0' || config?.capture === 'off') return 'off';
+ if (override === '1') return config?.capture === 'deep' ? 'deep' : 'metadata';
+ if (config?.enabled !== true) return 'off';
+ return config.capture ?? 'metadata';
+};
diff --git a/src/diff.ts b/src/diff.ts
new file mode 100644
index 0000000..2773b81
--- /dev/null
+++ b/src/diff.ts
@@ -0,0 +1,235 @@
+import { isDeepStrictEqual } from 'node:util';
+import { lintFacetDiagnostics, type LintFacetDiagnostic } from './lint.ts';
+import {
+ type ContextFreshness,
+ type LintFacet,
+ type StoredContextSnapshot,
+ type TestCaseRecord,
+ type TestErrorRecord,
+ type TestFacet,
+} from './model.ts';
+import { validateLintFacet, validateTestFacet } from './records.ts';
+import { assessSnapshotFreshness } from './source.ts';
+import { readContextSnapshotById } from './store.ts';
+
+type SnapshotDiffKind = 'diagnostics' | 'tests';
+
+type SnapshotDiffRequest = {
+ leftSnapshotId: string;
+ rightSnapshotId: string;
+ kind?: SnapshotDiffKind;
+};
+
+type SnapshotDiffIncompatibilityReason =
+ 'schema-version' | 'producer' | 'context' | 'facet' | 'selection';
+
+type SnapshotDiagnostic = LintFacetDiagnostic;
+
+type SnapshotTestFileError = {
+ kind: 'file-error';
+ project: string;
+ path: string;
+ error: TestErrorRecord;
+};
+
+type SnapshotTestUnhandledError = {
+ kind: 'unhandled-error';
+ error: TestErrorRecord;
+};
+
+type SnapshotDiffItem =
+ SnapshotDiagnostic | SnapshotTestFileError | SnapshotTestUnhandledError | TestCaseRecord;
+
+type SnapshotTestResult = SnapshotTestFileError | SnapshotTestUnhandledError | TestCaseRecord;
+
+type SnapshotDiffResult =
+ | {
+ compatible: false;
+ reasons: SnapshotDiffIncompatibilityReason[];
+ }
+ | {
+ compatible: true;
+ producer: 'rslint' | 'rstest';
+ contextId: string;
+ left: { snapshotId: string; freshness: ContextFreshness };
+ right: { snapshotId: string; freshness: ContextFreshness };
+ added: SnapshotDiffItem[];
+ removed: SnapshotDiffItem[];
+ changed: Array<{ before: SnapshotDiffItem; after: SnapshotDiffItem }>;
+ summary: { added: number; removed: number; changed: number };
+ };
+
+const diagnosticIdentity = (diagnostic: SnapshotDiagnostic): string =>
+ JSON.stringify([
+ diagnostic.path,
+ diagnostic.ruleId === null ? 'message' : 'rule',
+ diagnostic.ruleId ?? diagnostic.message,
+ diagnostic.line,
+ diagnostic.column,
+ ]);
+
+const testIdentity = (result: SnapshotTestResult): string =>
+ 'kind' in result
+ ? result.kind === 'file-error'
+ ? JSON.stringify([result.project, result.path, result.kind, result.error.name])
+ : JSON.stringify([result.kind, result.error.name])
+ : JSON.stringify([result.project, result.path, result.parentNames ?? [], result.name]);
+
+const testResults = (facet: TestFacet): SnapshotTestResult[] => [
+ ...facet.files.flatMap((file) => [
+ ...(file.errors ?? []).map((error) => ({
+ kind: 'file-error' as const,
+ project: file.project,
+ path: file.path,
+ error,
+ })),
+ ...file.tests,
+ ]),
+ ...facet.unhandledErrors.map((error) => ({ kind: 'unhandled-error' as const, error })),
+];
+
+const testResultsEqual = (left: SnapshotTestResult, right: SnapshotTestResult): boolean => {
+ if ('kind' in left || 'kind' in right) return isDeepStrictEqual(left, right);
+ const { durationMs: _leftDurationMs, ...leftResult } = left;
+ const { durationMs: _rightDurationMs, ...rightResult } = right;
+ return isDeepStrictEqual(leftResult, rightResult);
+};
+
+const diffItems = (
+ leftItems: T[],
+ rightItems: T[],
+ identity: (item: T) => string,
+ equal: (left: T, right: T) => boolean = isDeepStrictEqual,
+): Pick, 'added' | 'removed' | 'changed'> => {
+ const leftByIdentity = new Map();
+ const rightByIdentity = new Map();
+ for (const item of leftItems) {
+ const itemIdentity = identity(item);
+ leftByIdentity.set(itemIdentity, [...(leftByIdentity.get(itemIdentity) ?? []), item]);
+ }
+ for (const item of rightItems) {
+ const itemIdentity = identity(item);
+ rightByIdentity.set(itemIdentity, [...(rightByIdentity.get(itemIdentity) ?? []), item]);
+ }
+ const identities = [...new Set([...leftByIdentity.keys(), ...rightByIdentity.keys()])].sort();
+ const added: T[] = [];
+ const removed: T[] = [];
+ const changed: Array<{ before: T; after: T }> = [];
+
+ for (const itemIdentity of identities) {
+ const after = [...(rightByIdentity.get(itemIdentity) ?? [])];
+ const before = (leftByIdentity.get(itemIdentity) ?? []).filter((item) => {
+ const exactIndex = after.findIndex((candidate) => equal(item, candidate));
+ if (exactIndex === -1) return true;
+ after.splice(exactIndex, 1);
+ return false;
+ });
+ const changedCount = Math.min(before.length, after.length);
+ for (let index = 0; index < changedCount; index += 1) {
+ changed.push({ before: before[index], after: after[index] });
+ }
+ removed.push(...before.slice(changedCount));
+ added.push(...after.slice(changedCount));
+ }
+ return { added, removed, changed };
+};
+
+const requestedFacet = (
+ stored: StoredContextSnapshot,
+ kind: SnapshotDiffKind,
+): LintFacet | TestFacet | undefined =>
+ kind === 'diagnostics'
+ ? validateLintFacet(stored.snapshot.facets.lint)
+ : validateTestFacet(stored.snapshot.facets.test);
+
+const diffStoredContextSnapshots = (
+ left: StoredContextSnapshot,
+ right: StoredContextSnapshot,
+ kind: SnapshotDiffKind,
+ leftFreshness: ContextFreshness,
+ rightFreshness: ContextFreshness,
+): SnapshotDiffResult => {
+ const reasons: SnapshotDiffIncompatibilityReason[] = [];
+ if (left.snapshot.schemaVersion !== right.snapshot.schemaVersion) reasons.push('schema-version');
+ if (left.run.producer !== right.run.producer) reasons.push('producer');
+ if (left.snapshot.contextId !== right.snapshot.contextId) reasons.push('context');
+ if (
+ !isDeepStrictEqual(
+ left.snapshot.source?.captureSelection,
+ right.snapshot.source?.captureSelection,
+ )
+ ) {
+ reasons.push('selection');
+ }
+
+ const expectedProducer = kind === 'diagnostics' ? 'rslint' : 'rstest';
+ const leftFacet = requestedFacet(left, kind);
+ const rightFacet = requestedFacet(right, kind);
+ if (
+ left.run.producer !== expectedProducer ||
+ right.run.producer !== expectedProducer ||
+ leftFacet === undefined ||
+ rightFacet === undefined
+ ) {
+ reasons.push('facet');
+ }
+ if (reasons.length > 0) return { compatible: false, reasons: reasons.sort() };
+
+ const items =
+ kind === 'diagnostics'
+ ? diffItems(
+ lintFacetDiagnostics(leftFacet as LintFacet),
+ lintFacetDiagnostics(rightFacet as LintFacet),
+ diagnosticIdentity,
+ )
+ : diffItems(
+ testResults(leftFacet as TestFacet),
+ testResults(rightFacet as TestFacet),
+ testIdentity,
+ testResultsEqual,
+ );
+
+ return {
+ compatible: true,
+ producer: expectedProducer,
+ contextId: left.snapshot.contextId,
+ left: { snapshotId: left.snapshot.snapshotId, freshness: leftFreshness },
+ right: { snapshotId: right.snapshot.snapshotId, freshness: rightFreshness },
+ ...items,
+ summary: {
+ added: items.added.length,
+ removed: items.removed.length,
+ changed: items.changed.length,
+ },
+ };
+};
+
+const diffContextSnapshots = async (
+ workspaceRoot: string,
+ request: SnapshotDiffRequest,
+): Promise => {
+ const [left, right] = await Promise.all([
+ readContextSnapshotById(workspaceRoot, request.leftSnapshotId),
+ readContextSnapshotById(workspaceRoot, request.rightSnapshotId),
+ ]);
+ if (left === undefined) throw new Error(`Snapshot not found: ${request.leftSnapshotId}`);
+ if (right === undefined) throw new Error(`Snapshot not found: ${request.rightSnapshotId}`);
+
+ const kind =
+ request.kind ??
+ (left.run.producer === 'rstest' ? ('tests' as const) : ('diagnostics' as const));
+ const [leftFreshness, rightFreshness] = await Promise.all([
+ assessSnapshotFreshness(workspaceRoot, left.snapshot),
+ assessSnapshotFreshness(workspaceRoot, right.snapshot),
+ ]);
+ return diffStoredContextSnapshots(left, right, kind, leftFreshness, rightFreshness);
+};
+
+export { diffContextSnapshots, diffStoredContextSnapshots };
+export type {
+ SnapshotDiagnostic,
+ SnapshotDiffIncompatibilityReason,
+ SnapshotDiffKind,
+ SnapshotDiffRequest,
+ SnapshotDiffResult,
+};
diff --git a/src/execution.ts b/src/execution.ts
new file mode 100644
index 0000000..8408677
--- /dev/null
+++ b/src/execution.ts
@@ -0,0 +1,549 @@
+import { readFile } from 'node:fs/promises';
+import path from 'node:path';
+import type { TestRunResult } from '@rstest/core/api';
+import type { CoverageMapData } from '@rstest/core/internal/browser';
+import type {
+ TestExecutionBranch,
+ TestExecutionFacet,
+ TestExecutionFile,
+ TestExecutionFunction,
+ TestExecutionLocation,
+ TestExecutionRequestedSelection,
+ TestExecutionStatement,
+} from './model.ts';
+import {
+ isIdentifier,
+ isNonNegativeInteger,
+ isPositiveInteger,
+ isRecordObject,
+ sha256Hex,
+ sha256Pattern,
+} from './guards.ts';
+import { toWorkspacePath } from './paths.ts';
+
+type TestExecutionRequest = {
+ include?: string[];
+ exclude?: string[];
+ allowExternal?: boolean;
+};
+
+const executionBounds = {
+ attribution: 'aggregate-run-only',
+ testAttribution: false,
+ maxFiles: 1000,
+ maxLocationsPerFile: 20_000,
+ maxLocationsTotal: 100_000,
+} as const;
+
+type ExecutionEntry =
+ | { kind: 'statement'; locations: 1; value: TestExecutionStatement }
+ | { kind: 'function'; locations: 2; value: TestExecutionFunction }
+ | { kind: 'branch'; locations: number; value: TestExecutionBranch };
+
+type ExecutionFileCandidate = {
+ path: string;
+ absolutePath: string;
+ structured: boolean;
+ readable: boolean;
+ reportedLocations: number;
+ entries: ExecutionEntry[];
+};
+
+const hasOnlyKeys = (value: Record, keys: readonly string[]): boolean => {
+ const allowed = new Set(keys);
+ return Object.keys(value).every((key) => allowed.has(key));
+};
+
+const normalizeLocation = (value: unknown): TestExecutionLocation | undefined => {
+ if (!isRecordObject(value) || !isRecordObject(value.start) || !isRecordObject(value.end)) {
+ return undefined;
+ }
+ const positions = [value.start, value.end];
+ if (
+ !positions.every(
+ (position) => isPositiveInteger(position.line) && isNonNegativeInteger(position.column),
+ )
+ ) {
+ return undefined;
+ }
+ return {
+ start: { line: value.start.line as number, column: value.start.column as number },
+ end: { line: value.end.line as number, column: value.end.column as number },
+ };
+};
+
+/**
+ * Per-file entry of an Istanbul coverage map as Rstest publishes it: the
+ * `FileCoverage | FileCoverageData` union is a fact of the published type, not something this
+ * module has to infer. The runtime guards below still stand, because persisted snapshots are
+ * re-read from disk and may be arbitrary JSON.
+ */
+type CoverageMapFileEntry = CoverageMapData[string];
+
+const sortedRecordEntries = (value: Record): Array<[string, Value]> =>
+ Object.entries(value).sort(([left], [right]) => left.localeCompare(right));
+
+const unwrapCoverageFile = (
+ value: CoverageMapFileEntry | undefined,
+): Record | undefined => {
+ if (!isRecordObject(value)) return undefined;
+ return isRecordObject(value.data) ? value.data : value;
+};
+
+const normalizeExecutionFile = (
+ workspaceRoot: string,
+ packageRoot: string,
+ mapPath: string,
+ rawValue: CoverageMapFileEntry | undefined,
+): ExecutionFileCandidate => {
+ const data = unwrapCoverageFile(rawValue);
+ const sourcePath =
+ data !== undefined && typeof data.path === 'string' && data.path.length > 0
+ ? data.path
+ : mapPath;
+ const absolutePath = path.isAbsolute(sourcePath)
+ ? sourcePath
+ : path.resolve(packageRoot, sourcePath);
+ const normalizedPath = toWorkspacePath(workspaceRoot, absolutePath);
+ if (
+ data === undefined ||
+ !isRecordObject(data.statementMap) ||
+ !isRecordObject(data.fnMap) ||
+ !isRecordObject(data.branchMap) ||
+ !isRecordObject(data.s) ||
+ !isRecordObject(data.f) ||
+ !isRecordObject(data.b)
+ ) {
+ return {
+ path: normalizedPath,
+ absolutePath,
+ structured: false,
+ readable: false,
+ reportedLocations: 0,
+ entries: [],
+ };
+ }
+
+ const entries: ExecutionEntry[] = [];
+ let reportedLocations = 0;
+ let readable = true;
+ for (const [id, rawLocation] of sortedRecordEntries(data.statementMap)) {
+ reportedLocations += 1;
+ const location = normalizeLocation(rawLocation);
+ const hits = data.s[id];
+ if (location === undefined || !isNonNegativeInteger(hits) || id.length === 0) {
+ readable = false;
+ continue;
+ }
+ entries.push({ kind: 'statement', locations: 1, value: { id, location, hits } });
+ }
+ for (const [id, rawFunction] of sortedRecordEntries(data.fnMap)) {
+ reportedLocations += 2;
+ const hits = data.f[id];
+ if (!isRecordObject(rawFunction)) {
+ readable = false;
+ continue;
+ }
+ const declaration = normalizeLocation(rawFunction.decl);
+ const location = normalizeLocation(rawFunction.loc);
+ if (
+ declaration === undefined ||
+ location === undefined ||
+ typeof rawFunction.name !== 'string' ||
+ !isNonNegativeInteger(hits) ||
+ id.length === 0
+ ) {
+ readable = false;
+ continue;
+ }
+ entries.push({
+ kind: 'function',
+ locations: 2,
+ value: { id, name: rawFunction.name, declaration, location, hits },
+ });
+ }
+ for (const [id, rawBranch] of sortedRecordEntries(data.branchMap)) {
+ const rawArms =
+ isRecordObject(rawBranch) && Array.isArray(rawBranch.locations) ? rawBranch.locations : [];
+ const locations = 1 + rawArms.length;
+ reportedLocations += locations;
+ const rawHits: unknown = data.b[id];
+ if (
+ !isRecordObject(rawBranch) ||
+ typeof rawBranch.type !== 'string' ||
+ !Array.isArray(rawBranch.locations) ||
+ !Array.isArray(rawHits) ||
+ rawHits.length !== rawBranch.locations.length ||
+ id.length === 0
+ ) {
+ readable = false;
+ continue;
+ }
+ const location = normalizeLocation(rawBranch.loc);
+ const arms = rawBranch.locations.map((rawLocation, index) => {
+ const armLocation = normalizeLocation(rawLocation);
+ const hits: unknown = rawHits[index];
+ return armLocation === undefined || !isNonNegativeInteger(hits)
+ ? undefined
+ : { location: armLocation, hits };
+ });
+ if (location === undefined || arms.some((arm) => arm === undefined)) {
+ readable = false;
+ continue;
+ }
+ entries.push({
+ kind: 'branch',
+ locations,
+ value: {
+ id,
+ type: rawBranch.type,
+ location,
+ arms: arms as TestExecutionBranch['arms'],
+ },
+ });
+ }
+
+ return {
+ path: normalizedPath,
+ absolutePath,
+ structured: true,
+ readable,
+ reportedLocations,
+ entries,
+ };
+};
+
+const requestedExecutionSelection = (
+ request: TestExecutionRequest,
+): TestExecutionRequestedSelection => ({
+ ...(request.include === undefined ? {} : { include: [...request.include] }),
+ ...(request.exclude === undefined ? {} : { exclude: [...request.exclude] }),
+ allowExternal: request.allowExternal ?? false,
+});
+
+const withExecutionDigest = (facet: Omit): TestExecutionFacet => ({
+ ...facet,
+ digest: sha256Hex(JSON.stringify(facet)),
+});
+
+const unavailableExecutionFacet = (request: TestExecutionRequest): TestExecutionFacet =>
+ withExecutionDigest({
+ producer: 'rstest',
+ provider: 'istanbul',
+ availability: 'unavailable',
+ requestedSelection: requestedExecutionSelection(request),
+ universe: {
+ reportedFiles: 0,
+ storedFiles: 0,
+ droppedFiles: 0,
+ reportedLocations: 0,
+ storedLocations: 0,
+ droppedLocations: 0,
+ completeness: 'unknown',
+ },
+ truncated: { files: 0, locations: 0 },
+ bounds: executionBounds,
+ files: [],
+ });
+
+const normalizeExecutionFacet = async (
+ workspaceRoot: string,
+ packageRoot: string,
+ request: TestExecutionRequest,
+ coverage: TestRunResult['coverage'],
+): Promise => {
+ if (!isRecordObject(coverage)) return unavailableExecutionFacet(request);
+
+ const candidates = sortedRecordEntries(coverage)
+ .map(([mapPath, value]) => normalizeExecutionFile(workspaceRoot, packageRoot, mapPath, value))
+ .sort(
+ (left, right) =>
+ left.path.localeCompare(right.path) || Number(right.structured) - Number(left.structured),
+ );
+ const files: TestExecutionFile[] = [];
+ const seenPaths = new Set();
+ let readable = true;
+ let storedLocations = 0;
+ let truncatedFiles = 0;
+ let truncatedLocations = 0;
+
+ for (const candidate of candidates) {
+ if (!candidate.structured || seenPaths.has(candidate.path)) {
+ readable = false;
+ continue;
+ }
+ seenPaths.add(candidate.path);
+ readable &&= candidate.readable;
+ if (files.length >= executionBounds.maxFiles) {
+ truncatedFiles += 1;
+ truncatedLocations += candidate.entries.reduce((total, entry) => total + entry.locations, 0);
+ continue;
+ }
+
+ const file: TestExecutionFile = {
+ path: candidate.path,
+ statements: [],
+ functions: [],
+ branches: [],
+ };
+ try {
+ file.digest = sha256Hex(await readFile(candidate.absolutePath));
+ } catch {
+ readable = false;
+ }
+ let fileLocations = 0;
+ for (const entry of candidate.entries) {
+ if (
+ fileLocations + entry.locations > executionBounds.maxLocationsPerFile ||
+ storedLocations + entry.locations > executionBounds.maxLocationsTotal
+ ) {
+ truncatedLocations += entry.locations;
+ continue;
+ }
+ fileLocations += entry.locations;
+ storedLocations += entry.locations;
+ if (entry.kind === 'statement') file.statements.push(entry.value);
+ if (entry.kind === 'function') file.functions.push(entry.value);
+ if (entry.kind === 'branch') file.branches.push(entry.value);
+ }
+ files.push(file);
+ }
+
+ const reportedFiles = candidates.length;
+ const reportedLocations = candidates.reduce(
+ (total, candidate) => total + candidate.reportedLocations,
+ 0,
+ );
+ const droppedFiles = reportedFiles - files.length;
+ const droppedLocations = reportedLocations - storedLocations;
+ const complete =
+ readable &&
+ droppedFiles === 0 &&
+ droppedLocations === 0 &&
+ truncatedFiles === 0 &&
+ truncatedLocations === 0;
+ return withExecutionDigest({
+ producer: 'rstest',
+ provider: 'istanbul',
+ availability: 'available',
+ requestedSelection: requestedExecutionSelection(request),
+ universe: {
+ reportedFiles,
+ storedFiles: files.length,
+ droppedFiles,
+ reportedLocations,
+ storedLocations,
+ droppedLocations,
+ completeness: complete ? 'complete' : 'partial',
+ },
+ truncated: { files: truncatedFiles, locations: truncatedLocations },
+ bounds: executionBounds,
+ files,
+ });
+};
+
+const validateExecutionRequest = (request: TestExecutionRequest | undefined): void => {
+ if (request === undefined) return;
+ for (const key of ['include', 'exclude'] as const) {
+ const patterns = request[key];
+ if (
+ patterns !== undefined &&
+ (!Array.isArray(patterns) || patterns.some((pattern) => typeof pattern !== 'string'))
+ ) {
+ throw new Error(`Execution ${key} must be an array of string patterns.`);
+ }
+ if (patterns !== undefined && patterns.length > 200)
+ throw new Error(`Execution ${key} must contain at most 200 patterns.`);
+ }
+ if (request.allowExternal !== undefined && typeof request.allowExternal !== 'boolean')
+ throw new Error('Execution allowExternal must be a boolean.');
+};
+
+const isExecutionPosition = (value: unknown): boolean =>
+ isRecordObject(value) &&
+ hasOnlyKeys(value, ['line', 'column']) &&
+ isPositiveInteger(value.line) &&
+ isNonNegativeInteger(value.column);
+
+const isExecutionLocation = (value: unknown): boolean =>
+ isRecordObject(value) &&
+ hasOnlyKeys(value, ['start', 'end']) &&
+ isExecutionPosition(value.start) &&
+ isExecutionPosition(value.end);
+
+const isExecutionStatement = (value: unknown): boolean =>
+ isRecordObject(value) &&
+ hasOnlyKeys(value, ['id', 'location', 'hits']) &&
+ isIdentifier(value.id) &&
+ isExecutionLocation(value.location) &&
+ isNonNegativeInteger(value.hits);
+
+const isExecutionFunction = (value: unknown): boolean =>
+ isRecordObject(value) &&
+ hasOnlyKeys(value, ['id', 'name', 'declaration', 'location', 'hits']) &&
+ isIdentifier(value.id) &&
+ typeof value.name === 'string' &&
+ isExecutionLocation(value.declaration) &&
+ isExecutionLocation(value.location) &&
+ isNonNegativeInteger(value.hits);
+
+const isExecutionBranch = (value: unknown): boolean =>
+ isRecordObject(value) &&
+ hasOnlyKeys(value, ['id', 'type', 'location', 'arms']) &&
+ isIdentifier(value.id) &&
+ typeof value.type === 'string' &&
+ isExecutionLocation(value.location) &&
+ Array.isArray(value.arms) &&
+ value.arms.every(
+ (arm) =>
+ isRecordObject(arm) &&
+ hasOnlyKeys(arm, ['location', 'hits']) &&
+ isExecutionLocation(arm.location) &&
+ isNonNegativeInteger(arm.hits),
+ );
+
+const isExecutionFile = (value: unknown): value is TestExecutionFile =>
+ isRecordObject(value) &&
+ hasOnlyKeys(value, ['path', 'digest', 'statements', 'functions', 'branches']) &&
+ isIdentifier(value.path) &&
+ (value.digest === undefined ||
+ (typeof value.digest === 'string' && sha256Pattern.test(value.digest))) &&
+ Array.isArray(value.statements) &&
+ value.statements.every(isExecutionStatement) &&
+ Array.isArray(value.functions) &&
+ value.functions.every(isExecutionFunction) &&
+ Array.isArray(value.branches) &&
+ value.branches.every(isExecutionBranch);
+
+const countExecutionLocations = (file: TestExecutionFile): number => {
+ const { statements, functions, branches } = file;
+ return (
+ statements.length +
+ functions.length * 2 +
+ branches.reduce((total, branch) => total + 1 + branch.arms.length, 0)
+ );
+};
+
+const isRequestedExecutionSelection = (value: unknown): boolean =>
+ isRecordObject(value) &&
+ hasOnlyKeys(value, ['include', 'exclude', 'allowExternal']) &&
+ typeof value.allowExternal === 'boolean' &&
+ (value.include === undefined ||
+ (Array.isArray(value.include) &&
+ value.include.length <= 200 &&
+ value.include.every((entry) => typeof entry === 'string'))) &&
+ (value.exclude === undefined ||
+ (Array.isArray(value.exclude) &&
+ value.exclude.length <= 200 &&
+ value.exclude.every((entry) => typeof entry === 'string')));
+
+const validateExecutionFacet = (value: unknown): TestExecutionFacet | undefined => {
+ if (
+ !isRecordObject(value) ||
+ !hasOnlyKeys(value, [
+ 'producer',
+ 'provider',
+ 'availability',
+ 'requestedSelection',
+ 'digest',
+ 'universe',
+ 'truncated',
+ 'bounds',
+ 'files',
+ ]) ||
+ value.producer !== 'rstest' ||
+ value.provider !== 'istanbul' ||
+ (value.availability !== 'available' && value.availability !== 'unavailable') ||
+ !isRequestedExecutionSelection(value.requestedSelection) ||
+ typeof value.digest !== 'string' ||
+ !sha256Pattern.test(value.digest) ||
+ !isRecordObject(value.universe) ||
+ !hasOnlyKeys(value.universe, [
+ 'reportedFiles',
+ 'storedFiles',
+ 'droppedFiles',
+ 'reportedLocations',
+ 'storedLocations',
+ 'droppedLocations',
+ 'completeness',
+ ]) ||
+ !isNonNegativeInteger(value.universe.reportedFiles) ||
+ !isNonNegativeInteger(value.universe.storedFiles) ||
+ !isNonNegativeInteger(value.universe.droppedFiles) ||
+ !isNonNegativeInteger(value.universe.reportedLocations) ||
+ !isNonNegativeInteger(value.universe.storedLocations) ||
+ !isNonNegativeInteger(value.universe.droppedLocations) ||
+ !['complete', 'partial', 'unknown'].includes(value.universe.completeness as string) ||
+ value.universe.reportedFiles !== value.universe.storedFiles + value.universe.droppedFiles ||
+ value.universe.reportedLocations !==
+ value.universe.storedLocations + value.universe.droppedLocations ||
+ !isRecordObject(value.truncated) ||
+ !hasOnlyKeys(value.truncated, ['files', 'locations']) ||
+ !isNonNegativeInteger(value.truncated.files) ||
+ !isNonNegativeInteger(value.truncated.locations) ||
+ value.truncated.files > value.universe.droppedFiles ||
+ value.truncated.locations > value.universe.droppedLocations ||
+ !isRecordObject(value.bounds) ||
+ !hasOnlyKeys(value.bounds, [
+ 'attribution',
+ 'testAttribution',
+ 'maxFiles',
+ 'maxLocationsPerFile',
+ 'maxLocationsTotal',
+ ]) ||
+ value.bounds.attribution !== 'aggregate-run-only' ||
+ value.bounds.testAttribution !== false ||
+ value.bounds.maxFiles !== executionBounds.maxFiles ||
+ value.bounds.maxLocationsPerFile !== executionBounds.maxLocationsPerFile ||
+ value.bounds.maxLocationsTotal !== executionBounds.maxLocationsTotal ||
+ !Array.isArray(value.files) ||
+ !value.files.every(isExecutionFile) ||
+ value.files.length > executionBounds.maxFiles ||
+ value.files.length !== value.universe.storedFiles ||
+ new Set(value.files.map((file: unknown) => (isExecutionFile(file) ? file.path : undefined)))
+ .size !== value.files.length ||
+ value.files.some(
+ (file: unknown) =>
+ isExecutionFile(file) &&
+ countExecutionLocations(file) > executionBounds.maxLocationsPerFile,
+ ) ||
+ value.universe.storedLocations > executionBounds.maxLocationsTotal ||
+ value.files.reduce(
+ (total: number, file: unknown) =>
+ total + (isExecutionFile(file) ? countExecutionLocations(file) : 0),
+ 0,
+ ) !== value.universe.storedLocations
+ ) {
+ return undefined;
+ }
+
+ const unavailable = value.availability === 'unavailable';
+ const noReportedEvidence =
+ value.universe.reportedFiles === 0 && value.universe.reportedLocations === 0;
+ const complete =
+ value.universe.droppedFiles === 0 &&
+ value.universe.droppedLocations === 0 &&
+ value.truncated.files === 0 &&
+ value.truncated.locations === 0;
+ if (
+ (unavailable &&
+ (!noReportedEvidence ||
+ value.files.length !== 0 ||
+ value.universe.completeness !== 'unknown')) ||
+ (!unavailable && value.universe.completeness === 'unknown') ||
+ (value.universe.completeness === 'complete' &&
+ (!complete || value.files.some((file) => !('digest' in (file as Record)))))
+ ) {
+ return undefined;
+ }
+
+ return value as TestExecutionFacet;
+};
+
+export {
+ normalizeExecutionFacet,
+ unavailableExecutionFacet,
+ validateExecutionFacet,
+ validateExecutionRequest,
+};
+export type { TestExecutionRequest };
diff --git a/src/guards.ts b/src/guards.ts
new file mode 100644
index 0000000..1306c65
--- /dev/null
+++ b/src/guards.ts
@@ -0,0 +1,31 @@
+import { createHash } from 'node:crypto';
+
+const sha256Pattern: RegExp = /^[0-9a-f]{64}$/u;
+
+const isRecordObject = (value: unknown): value is Record =>
+ typeof value === 'object' && value !== null && !Array.isArray(value);
+
+const getNonEmptyString = (value: unknown): string | undefined =>
+ typeof value === 'string' && value.length > 0 ? value : undefined;
+
+const isIdentifier = (value: unknown): value is string =>
+ typeof value === 'string' && value.length > 0;
+
+const isNonNegativeInteger = (value: unknown): value is number =>
+ Number.isSafeInteger(value) && (value as number) >= 0;
+
+const isPositiveInteger = (value: unknown): value is number =>
+ Number.isSafeInteger(value) && (value as number) > 0;
+
+const sha256Hex = (content: string | Buffer): string =>
+ createHash('sha256').update(content).digest('hex');
+
+export {
+ getNonEmptyString,
+ isIdentifier,
+ isNonNegativeInteger,
+ isPositiveInteger,
+ isRecordObject,
+ sha256Hex,
+ sha256Pattern,
+};
diff --git a/src/index.ts b/src/index.ts
new file mode 100644
index 0000000..aa6475e
--- /dev/null
+++ b/src/index.ts
@@ -0,0 +1,100 @@
+export {
+ contextStoreSchemaVersion,
+ type BuildMetadataFacet,
+ type ContextCompleteness,
+ type ContextDescriptor,
+ type ContextFreshness,
+ type ContextInputCompleteness,
+ type ContextInputFile,
+ type ContextProducer,
+ type ContextRunManifest,
+ type ContextRunStatus,
+ type ContextRunStatusEntry,
+ type ContextSnapshot,
+ type ContextSnapshotSource,
+ type ContextStatus,
+ type ContextStoreIssue,
+ type ContextStoreWriteResult,
+ type ContextWorkspaceStatus,
+ type JsonValue,
+ type LintFacet,
+ type LintFileRecord,
+ type LintMessageRecord,
+ type ProjectContextStatus,
+ type ProjectStatus,
+ type StoredContextSnapshot,
+ type TestCaseRecord,
+ type TestErrorRecord,
+ type TestFacet,
+ type TestFileRecord,
+ type TestRelationRecord,
+} from './model.ts';
+export {
+ readContextSnapshotById,
+ readContextSnapshots,
+ readContextWorkspaceStatus,
+ writeContextRunManifest,
+ writeContextSnapshot,
+} from './store.ts';
+export { resolveContextCapture, type ContextCaptureTier, type ContextConfig } from './config.ts';
+export {
+ appendBuildContextPlugin,
+ createBuildContextPlugin,
+ type BuildContextPluginOptions,
+} from './build.ts';
+export { readProjectStatus } from './status.ts';
+export { resolveContextWorkspace, type ResolvedContextWorkspace } from './workspace.ts';
+export {
+ analyzeRsdoctorArtifact,
+ listRsdoctorToolNames,
+ type RsdoctorAnalysisRequest,
+ type RsdoctorAnalysisResult,
+} from './rsdoctor.ts';
+export {
+ assessSnapshotFreshness,
+ createExplicitContextDescriptor,
+ createExplicitRun,
+ recordContextInputFiles,
+} from './source.ts';
+export {
+ captureLintSnapshot,
+ getLintFixPreview,
+ listDiagnostics,
+ type DiagnosticPage,
+ type DiagnosticRecord,
+ type DiagnosticsQuery,
+ type LintCaptureResult,
+ type LintCaptureAdapter,
+ type LintFixPreviewResult,
+ type LintSnapshotRequest,
+ type RslintFactory,
+} from './lint.ts';
+export {
+ captureTestSnapshot,
+ listTestResults,
+ type RelatedTestRequest,
+ type ResolveRelatedTests,
+ type TestCaptureDependencies,
+ type TestCaptureResult,
+ type TestResultPage,
+ type TestResultsQuery,
+ type TestSnapshotRequest,
+} from './testRun.ts';
+export {
+ diffContextSnapshots,
+ diffStoredContextSnapshots,
+ type SnapshotDiagnostic,
+ type SnapshotDiffIncompatibilityReason,
+ type SnapshotDiffKind,
+ type SnapshotDiffRequest,
+ type SnapshotDiffResult,
+} from './diff.ts';
+export { createContextMcpServer, type ContextMcpDependencies } from './mcp.ts';
+export {
+ createRstackContextPlugin,
+ type ContextBuildModifier,
+ type ContextRstackModifierContext,
+ type ContextRstackPlugin,
+ type ContextRstackPluginApi,
+ type ContextRstackPluginOptions,
+} from './rstack.ts';
diff --git a/src/lint.ts b/src/lint.ts
new file mode 100644
index 0000000..3a451d9
--- /dev/null
+++ b/src/lint.ts
@@ -0,0 +1,617 @@
+import { readFile } from 'node:fs/promises';
+import path from 'node:path';
+import type { LintMessage, LintResult, RslintOptions } from '@rslint/core';
+import { sha256Hex } from './guards.ts';
+import {
+ contextStoreSchemaVersion,
+ type ContextFreshness,
+ type ContextRunStatus,
+ type ContextSnapshot,
+ type LintFacet,
+ type LintFileRecord,
+ type LintMessageRecord,
+ type StoredContextSnapshot,
+ type TestErrorRecord,
+ type TestFacet,
+} from './model.ts';
+import { compareStrings } from './order.ts';
+import { decodeCursor, encodeCursor } from './pagination.ts';
+import { toWorkspacePath } from './paths.ts';
+import {
+ assessSnapshotFreshness,
+ createExplicitContextDescriptor,
+ createExplicitRun,
+ resolveExplicitCaptureTarget,
+ resolveInternalConfigPath,
+ type ConfigTargetRunner,
+} from './source.ts';
+import {
+ readContextSnapshotById,
+ readContextSnapshots,
+ writeContextRunManifest,
+ writeContextSnapshot,
+} from './store.ts';
+
+type LintSnapshotRequest =
+ | {
+ mode: 'files';
+ patterns?: string[];
+ includeFixPreview?: boolean;
+ packageRoot?: string;
+ configPath?: string;
+ }
+ | {
+ mode: 'text';
+ code: string;
+ filePath: string;
+ includeFixPreview?: boolean;
+ packageRoot?: string;
+ configPath?: string;
+ };
+
+type LintCaptureResult = {
+ runId: string;
+ contextId: string;
+ snapshotId: string;
+ status: ContextRunStatus;
+ freshness: ContextFreshness;
+ summary: LintFacet['totals'];
+ unreadableInputs?: string[];
+};
+
+type RslintEngine = {
+ lintFiles: (patterns: string | string[]) => Promise;
+ lintText: (code: string, options?: { filePath?: string }) => Promise;
+ close: () => Promise;
+};
+
+type RslintFactory = (options: RslintOptions) => RslintEngine;
+
+type LintCaptureAdapter = {
+ wrapperConfigPath: string;
+ withConfigTarget: ConfigTargetRunner;
+};
+
+type DiagnosticsQuery = {
+ snapshotId?: string;
+ producer?: 'rslint' | 'rstest';
+ pathPrefix?: string;
+ severity?: 'error' | 'warning';
+ ruleId?: string;
+ limit?: number;
+ cursor?: string;
+};
+
+type LintFacetDiagnostic = {
+ path: string;
+ ruleId: string | null;
+ severity: 'error' | 'warning';
+ message: string;
+ line: number;
+ column: number;
+ endLine?: number;
+ endColumn?: number;
+ fixable: boolean;
+};
+
+type DiagnosticRecord = {
+ producer: 'rslint' | 'rstest';
+ path?: string;
+ project?: string;
+ ruleId: string | null;
+ severity: 'error' | 'warning';
+ message: string;
+ line?: number;
+ column?: number;
+ endLine?: number;
+ endColumn?: number;
+ fixable: boolean;
+ name?: string;
+};
+
+type DiagnosticPage = {
+ snapshotId: string;
+ producer: 'rslint' | 'rstest';
+ contextId: string;
+ observedAt: string;
+ completeness: ContextSnapshot['completeness'];
+ freshness: ContextFreshness;
+ total: number;
+ items: DiagnosticRecord[];
+ nextCursor?: string;
+};
+
+type LintFixPreviewResult =
+ | {
+ available: true;
+ snapshotId: string;
+ path: string;
+ beforeDigest: string;
+ fixedOutput: string;
+ }
+ | {
+ available: false;
+ reason: 'not-captured' | 'no-change';
+ snapshotId: string;
+ path: string;
+ };
+
+const defaultLimit = 50;
+const maximumLimit = 200;
+
+const normalizeMessage = (message: LintMessage): LintMessageRecord => ({
+ ruleId: message.ruleId,
+ severity: message.severity,
+ message: message.message,
+ ...(message.messageId === undefined ? {} : { messageId: message.messageId }),
+ line: message.line,
+ column: message.column,
+ ...(message.endLine === undefined ? {} : { endLine: message.endLine }),
+ ...(message.endColumn === undefined ? {} : { endColumn: message.endColumn }),
+ ...(message.fix === undefined ? {} : { fix: message.fix }),
+ ...(message.suggestions === undefined ? {} : { suggestions: message.suggestions }),
+});
+
+const compareMessages = (left: LintMessageRecord, right: LintMessageRecord): number =>
+ left.line - right.line ||
+ left.column - right.column ||
+ compareStrings(left.ruleId ?? '', right.ruleId ?? '') ||
+ compareStrings(left.message, right.message);
+
+const readLintSource = async (filePath: string): Promise => {
+ try {
+ return await readFile(filePath, 'utf8');
+ } catch {
+ return undefined;
+ }
+};
+
+const normalizeResult = async (
+ workspaceRoot: string,
+ result: LintResult,
+ includeFixPreview: boolean,
+ textCode?: string,
+): Promise => {
+ const filePath = toWorkspacePath(workspaceRoot, result.filePath);
+ const source = textCode ?? (await readLintSource(result.filePath));
+ if (source === undefined) return undefined;
+ const fileDigest = sha256Hex(source);
+ const fixedOutput =
+ includeFixPreview && result.output !== undefined && result.output !== source
+ ? result.output
+ : undefined;
+
+ return {
+ path: filePath,
+ digest: fileDigest,
+ errorCount: result.errorCount,
+ warningCount: result.warningCount,
+ fixableErrorCount: result.fixableErrorCount,
+ fixableWarningCount: result.fixableWarningCount,
+ messages: result.messages.map(normalizeMessage).sort(compareMessages),
+ ...(fixedOutput === undefined ? {} : { fixedOutput }),
+ };
+};
+
+const totalsFor = (files: LintFileRecord[]): LintFacet['totals'] => ({
+ files: files.length,
+ errors: files.reduce((total, file) => total + file.errorCount, 0),
+ warnings: files.reduce((total, file) => total + file.warningCount, 0),
+ fixableErrors: files.reduce((total, file) => total + file.fixableErrorCount, 0),
+ fixableWarnings: files.reduce((total, file) => total + file.fixableWarningCount, 0),
+});
+
+const lintCaptureSelection = (
+ workspaceRoot: string,
+ packageRoot: string,
+ request: LintSnapshotRequest,
+): NonNullable['captureSelection'] =>
+ request.mode === 'files'
+ ? {
+ mode: 'files',
+ patterns: [...new Set(request.patterns ?? ['.'])].sort(compareStrings),
+ }
+ : {
+ mode: 'text',
+ filePath: toWorkspacePath(workspaceRoot, path.resolve(packageRoot, request.filePath)),
+ };
+
+const lintInputCompleteness = (
+ workspaceRoot: string,
+ packageRoot: string,
+ patterns: string[],
+ files: LintFileRecord[],
+): 'complete' | 'partial' => {
+ const selectedPaths = new Set(
+ patterns.map((pattern) => toWorkspacePath(workspaceRoot, path.resolve(packageRoot, pattern))),
+ );
+ return selectedPaths.size === files.length && files.every((file) => selectedPaths.has(file.path))
+ ? 'complete'
+ : 'partial';
+};
+
+const ensureWritten = (result: Awaited>): void => {
+ if (!result.written)
+ throw new Error('Could not write the context snapshot.', {
+ cause: result.error,
+ });
+};
+
+const captureLintSnapshot = async (
+ workspaceRoot: string,
+ request: LintSnapshotRequest,
+ createRslint?: RslintFactory,
+ adapter?: LintCaptureAdapter,
+): Promise => {
+ if (adapter?.withConfigTarget === undefined && createRslint === undefined) {
+ throw new Error('Rstack lint capture requires a config adapter.');
+ }
+ const includeFixPreview = request.includeFixPreview ?? false;
+ const target = await resolveExplicitCaptureTarget(workspaceRoot, request);
+ const wrapperConfigPath =
+ adapter?.wrapperConfigPath ?? resolveInternalConfigPath(import.meta.dirname, 'rslintConfig.js');
+ const context = createExplicitContextDescriptor({
+ producer: 'rslint',
+ workspaceRoot,
+ ...target,
+ });
+ const captureSelection = lintCaptureSelection(workspaceRoot, target.packageRoot, request);
+ const run = createExplicitRun({
+ producer: 'rslint',
+ context,
+ command: 'lint',
+ });
+ const options = {
+ cwd: target.packageRoot,
+ overrideConfigFile: wrapperConfigPath,
+ fix: includeFixPreview,
+ } satisfies RslintOptions;
+ const runWrite = await writeContextRunManifest(workspaceRoot, run);
+ if (!runWrite.written) {
+ throw new Error('Could not write the context run.', {
+ cause: runWrite.error,
+ });
+ }
+ let results: LintResult[];
+ try {
+ const withConfigTarget =
+ adapter?.withConfigTarget ?? (async (_configRoot, _configPath, action) => action());
+ results = await withConfigTarget(target.packageRoot, target.configPath, async () => {
+ const engine = createRslint?.(options) ?? new (await import('@rslint/core')).Rslint(options);
+ try {
+ return request.mode === 'files'
+ ? await engine.lintFiles(request.patterns ?? ['.'])
+ : await engine.lintText(request.code, { filePath: request.filePath });
+ } finally {
+ await engine.close();
+ }
+ });
+ if (results.length === 0) {
+ throw new Error(
+ 'Rslint reported no files. Verify that the selected Rstack configuration defines lint with define.lint and that the requested patterns match lint inputs.',
+ );
+ }
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ const file: LintFileRecord = {
+ path:
+ request.mode === 'text'
+ ? toWorkspacePath(workspaceRoot, path.resolve(target.packageRoot, request.filePath)) ||
+ context.packageRoot
+ : context.packageRoot,
+ digest: request.mode === 'text' ? sha256Hex(request.code) : sha256Hex(''),
+ errorCount: 1,
+ warningCount: 0,
+ fixableErrorCount: 0,
+ fixableWarningCount: 0,
+ messages: [
+ {
+ ruleId: null,
+ severity: 2,
+ message,
+ line: 1,
+ column: 1,
+ },
+ ],
+ };
+ const facet: LintFacet = {
+ producer: 'rslint',
+ mode: request.mode,
+ fixPreviewCaptured: false,
+ files: [file],
+ totals: totalsFor([file]),
+ };
+ const snapshot: ContextSnapshot = {
+ schemaVersion: contextStoreSchemaVersion,
+ snapshotId: `snap_${run.runId}_${context.contextId}_0`,
+ runId: run.runId,
+ contextId: context.contextId,
+ sequence: 0,
+ observedAt: new Date().toISOString(),
+ status: 'error',
+ completeness: { lint: 'partial' },
+ facets: { lint: facet },
+ source: { inputs: [], inputCompleteness: 'partial', captureSelection },
+ };
+ ensureWritten(await writeContextSnapshot(workspaceRoot, snapshot));
+ throw error;
+ }
+
+ const normalized = await Promise.all(
+ results.map((result) =>
+ normalizeResult(
+ workspaceRoot,
+ result,
+ includeFixPreview,
+ request.mode === 'text' ? request.code : undefined,
+ ),
+ ),
+ );
+ const files = normalized
+ .flatMap((file) => (file === undefined ? [] : [file]))
+ .sort((left, right) => compareStrings(left.path, right.path));
+ const unreadableInputs = results
+ .flatMap((result, index) =>
+ normalized[index] === undefined ? [toWorkspacePath(workspaceRoot, result.filePath)] : [],
+ )
+ .sort(compareStrings);
+ const totals = totalsFor(files);
+ const facet: LintFacet = {
+ producer: 'rslint',
+ mode: request.mode,
+ fixPreviewCaptured: includeFixPreview,
+ files,
+ totals,
+ };
+ const source: ContextSnapshot['source'] =
+ request.mode === 'text'
+ ? { virtualInputDigest: sha256Hex(request.code), captureSelection }
+ : {
+ inputs: files.map(({ path: filePath, digest: fileDigest }) => ({
+ path: filePath,
+ digest: fileDigest,
+ })),
+ inputCompleteness: lintInputCompleteness(
+ workspaceRoot,
+ target.packageRoot,
+ request.patterns ?? ['.'],
+ files,
+ ),
+ captureSelection,
+ };
+ const status: ContextRunStatus = totals.errors > 0 ? 'fail' : 'pass';
+ const snapshot: ContextSnapshot = {
+ schemaVersion: contextStoreSchemaVersion,
+ snapshotId: `snap_${run.runId}_${context.contextId}_0`,
+ runId: run.runId,
+ contextId: context.contextId,
+ sequence: 0,
+ observedAt: new Date().toISOString(),
+ status,
+ completeness: {
+ lint: unreadableInputs.length === 0 ? 'complete' : 'partial',
+ ...(unreadableInputs.length === 0 ? {} : { source: 'partial' as const }),
+ },
+ facets: { lint: facet },
+ source,
+ };
+
+ ensureWritten(await writeContextSnapshot(workspaceRoot, snapshot));
+
+ return {
+ runId: run.runId,
+ contextId: context.contextId,
+ snapshotId: snapshot.snapshotId,
+ status,
+ freshness: await assessSnapshotFreshness(workspaceRoot, snapshot),
+ summary: totals,
+ ...(unreadableInputs.length === 0 ? {} : { unreadableInputs }),
+ };
+};
+
+const asLintFacet = (stored: StoredContextSnapshot): LintFacet | undefined =>
+ stored.run.producer === 'rslint'
+ ? (stored.snapshot.facets.lint as LintFacet | undefined)
+ : undefined;
+
+const asTestFacet = (stored: StoredContextSnapshot): TestFacet | undefined =>
+ stored.run.producer === 'rstest'
+ ? (stored.snapshot.facets.test as TestFacet | undefined)
+ : undefined;
+
+const lintFacetDiagnostics = (facet: LintFacet): LintFacetDiagnostic[] =>
+ facet.files.flatMap((file) =>
+ file.messages.map((message) => ({
+ path: file.path,
+ ruleId: message.ruleId,
+ severity: message.severity === 2 ? ('error' as const) : ('warning' as const),
+ message: message.message,
+ line: message.line,
+ column: message.column,
+ ...(message.endLine === undefined ? {} : { endLine: message.endLine }),
+ ...(message.endColumn === undefined ? {} : { endColumn: message.endColumn }),
+ fixable: message.fix !== undefined,
+ })),
+ );
+
+const lintDiagnostics = (facet: LintFacet): DiagnosticRecord[] =>
+ lintFacetDiagnostics(facet).map((diagnostic) => ({
+ producer: 'rslint' as const,
+ ...diagnostic,
+ }));
+
+const testDiagnostic = (
+ error: TestErrorRecord,
+ location: { path?: string; project?: string; name?: string },
+): DiagnosticRecord => ({
+ producer: 'rstest',
+ ...location,
+ ruleId: null,
+ severity: 'error',
+ message: error.message,
+ fixable: false,
+});
+
+const testDiagnostics = (facet: TestFacet): DiagnosticRecord[] => [
+ ...facet.files.flatMap((file) => [
+ ...(file.errors ?? []).map((error) =>
+ testDiagnostic(error, {
+ path: file.path,
+ project: file.project,
+ }),
+ ),
+ ...file.tests.flatMap((testCase) =>
+ (testCase.errors ?? []).map((error) =>
+ testDiagnostic(error, {
+ path: testCase.path,
+ project: testCase.project,
+ name: [...(testCase.parentNames ?? []), testCase.name].join(' > '),
+ }),
+ ),
+ ),
+ ]),
+ ...facet.unhandledErrors.map((error) => testDiagnostic(error, {})),
+];
+
+const diagnosticsFromStoredSnapshot = (stored: StoredContextSnapshot): DiagnosticRecord[] => {
+ const lintFacet = asLintFacet(stored);
+ const testFacet = asTestFacet(stored);
+ if (lintFacet !== undefined) return lintDiagnostics(lintFacet);
+ if (testFacet !== undefined) return testDiagnostics(testFacet);
+ return [];
+};
+
+const compareDiagnostics = (left: DiagnosticRecord, right: DiagnosticRecord): number =>
+ compareStrings(left.project ?? '', right.project ?? '') ||
+ compareStrings(left.path ?? '', right.path ?? '') ||
+ (left.line ?? 0) - (right.line ?? 0) ||
+ (left.column ?? 0) - (right.column ?? 0) ||
+ compareStrings(left.ruleId ?? '', right.ruleId ?? '') ||
+ compareStrings(left.name ?? '', right.name ?? '') ||
+ compareStrings(left.message, right.message);
+
+const getLimit = (limit: number | undefined): number => {
+ const resolved = limit ?? defaultLimit;
+ if (!Number.isSafeInteger(resolved) || resolved < 1 || resolved > maximumLimit) {
+ throw new Error('limit must be an integer from 1 to 200.');
+ }
+ return resolved;
+};
+
+const selectDiagnosticSnapshot = async (
+ workspaceRoot: string,
+ query: DiagnosticsQuery,
+): Promise => {
+ const stored =
+ query.snapshotId === undefined
+ ? (
+ await readContextSnapshots(workspaceRoot, {
+ producer: query.producer,
+ })
+ ).find(({ run }) => run.producer === 'rslint' || run.producer === 'rstest')
+ : await readContextSnapshotById(workspaceRoot, query.snapshotId);
+ if (stored === undefined) throw new Error('No matching completed context snapshot was found.');
+ if (query.producer !== undefined && stored.run.producer !== query.producer) {
+ throw new Error('The selected snapshot does not match the requested producer.');
+ }
+ return stored;
+};
+
+const listDiagnostics = async (
+ workspaceRoot: string,
+ query: DiagnosticsQuery = {},
+): Promise => {
+ const stored = await selectDiagnosticSnapshot(workspaceRoot, query);
+ const lintFacet = asLintFacet(stored);
+ const testFacet = asTestFacet(stored);
+ if (lintFacet === undefined && testFacet === undefined) {
+ throw new Error('The selected snapshot has no diagnostics facet.');
+ }
+
+ const items = (lintFacet === undefined ? testDiagnostics(testFacet!) : lintDiagnostics(lintFacet))
+ .filter((item) => query.pathPrefix === undefined || item.path?.startsWith(query.pathPrefix))
+ .filter((item) => query.severity === undefined || item.severity === query.severity)
+ .filter((item) => query.ruleId === undefined || item.ruleId === query.ruleId)
+ .sort(compareDiagnostics);
+ const cursorScope = {
+ surface: 'diagnostics_list',
+ selectedSnapshotId: stored.snapshot.snapshotId,
+ snapshotId: query.snapshotId,
+ producer: query.producer,
+ pathPrefix: query.pathPrefix,
+ severity: query.severity,
+ ruleId: query.ruleId,
+ };
+ const offset = decodeCursor(query.cursor, 'Invalid diagnostics cursor.', cursorScope);
+ const limit = getLimit(query.limit);
+ const page = items.slice(offset, offset + limit);
+ const nextOffset = offset + page.length;
+
+ return {
+ snapshotId: stored.snapshot.snapshotId,
+ producer: lintFacet === undefined ? 'rstest' : 'rslint',
+ contextId: stored.snapshot.contextId,
+ observedAt: stored.snapshot.observedAt,
+ completeness: stored.snapshot.completeness,
+ freshness: await assessSnapshotFreshness(workspaceRoot, stored.snapshot),
+ total: items.length,
+ items: page,
+ ...(nextOffset < items.length ? { nextCursor: encodeCursor(nextOffset, cursorScope) } : {}),
+ };
+};
+
+const getLintFixPreview = async (
+ workspaceRoot: string,
+ snapshotId: string,
+ filePath: string,
+): Promise => {
+ const stored = await readContextSnapshotById(workspaceRoot, snapshotId);
+ if (stored === undefined) throw new Error(`Unknown snapshot: ${snapshotId}`);
+ const facet = asLintFacet(stored);
+ if (facet === undefined) throw new Error('The selected snapshot has no lint facet.');
+ const matches = facet.files.filter(({ path: candidate }) => candidate === filePath);
+ if (matches.length !== 1) throw new Error('The lint snapshot does not contain that exact path.');
+ const file = matches[0];
+ if (!facet.fixPreviewCaptured) {
+ return {
+ available: false,
+ reason: 'not-captured',
+ snapshotId,
+ path: filePath,
+ };
+ }
+ if (file.fixedOutput === undefined) {
+ return {
+ available: false,
+ reason: 'no-change',
+ snapshotId,
+ path: filePath,
+ };
+ }
+ return {
+ available: true,
+ snapshotId,
+ path: filePath,
+ beforeDigest: file.digest,
+ fixedOutput: file.fixedOutput,
+ };
+};
+
+export {
+ captureLintSnapshot,
+ diagnosticsFromStoredSnapshot,
+ getLintFixPreview,
+ lintFacetDiagnostics,
+ listDiagnostics,
+};
+export type {
+ DiagnosticPage,
+ DiagnosticRecord,
+ DiagnosticsQuery,
+ LintCaptureResult,
+ LintCaptureAdapter,
+ LintFacetDiagnostic,
+ LintFixPreviewResult,
+ LintSnapshotRequest,
+ RslintFactory,
+};
diff --git a/src/mcp.ts b/src/mcp.ts
new file mode 100644
index 0000000..01de3e0
--- /dev/null
+++ b/src/mcp.ts
@@ -0,0 +1,967 @@
+// cspell:ignore modelcontextprotocol
+import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
+import type { ContentBlock } from '@modelcontextprotocol/sdk/types.js';
+import { z } from 'zod';
+import { readCodeEvidence, type CodeEvidenceResult } from './codeEvidence.ts';
+import { diffContextSnapshots } from './diff.ts';
+import { isRecordObject } from './guards.ts';
+import {
+ captureLintSnapshot,
+ getLintFixPreview,
+ listDiagnostics,
+ type LintCaptureAdapter,
+ type RslintFactory,
+} from './lint.ts';
+import { decodeCursor, encodeCursor } from './pagination.ts';
+import {
+ explainDeadCodeCandidate,
+ findUnusedCandidates,
+ readProductRoots,
+ traceModuleImpact,
+} from './queries.ts';
+import { validateLintFacet, validateTestFacet } from './records.ts';
+import type { ContextSnapshot } from './model.ts';
+import { analyzeRsdoctorArtifact, listRsdoctorToolNames } from './rsdoctor.ts';
+import { resolveRsdoctorReport } from './report.ts';
+import { assessSnapshotFreshness } from './source.ts';
+import { readProjectStatus } from './status.ts';
+import { readContextSnapshots } from './store.ts';
+import {
+ captureTestSnapshot,
+ listTestResults,
+ type TestCaptureDependencies,
+ type TestSnapshotRequest,
+} from './testRun.ts';
+
+declare const RSTACK_CONTEXT_VERSION: string;
+
+const readOnlyAnnotations = {
+ readOnlyHint: true,
+ destructiveHint: false,
+ openWorldHint: false,
+} as const;
+
+const summarizeBuildFacet = (value: unknown) => {
+ if (
+ !isRecordObject(value) ||
+ typeof value.command !== 'string' ||
+ typeof value.environment !== 'string' ||
+ typeof value.durationMs !== 'number' ||
+ typeof value.hasErrors !== 'boolean' ||
+ typeof value.hasWarnings !== 'boolean' ||
+ !Array.isArray(value.assets) ||
+ !Array.isArray(value.chunks)
+ ) {
+ return undefined;
+ }
+
+ const truncated =
+ isRecordObject(value.truncated) &&
+ typeof value.truncated.assets === 'number' &&
+ Number.isSafeInteger(value.truncated.assets) &&
+ value.truncated.assets >= 0 &&
+ typeof value.truncated.chunks === 'number' &&
+ Number.isSafeInteger(value.truncated.chunks) &&
+ value.truncated.chunks >= 0
+ ? { assets: value.truncated.assets, chunks: value.truncated.chunks }
+ : undefined;
+
+ return {
+ command: value.command,
+ ...(typeof value.mode === 'string' ? { mode: value.mode } : {}),
+ environment: value.environment,
+ environmentCompileDurationMs: value.durationMs,
+ ...(typeof value.hash === 'string' ? { hash: value.hash } : {}),
+ hasErrors: value.hasErrors,
+ hasWarnings: value.hasWarnings,
+ assets: value.assets.length,
+ chunks: value.chunks.length,
+ ...(truncated === undefined
+ ? {}
+ : {
+ total: {
+ assets: value.assets.length + truncated.assets,
+ chunks: value.chunks.length + truncated.chunks,
+ },
+ truncated,
+ }),
+ };
+};
+
+const renderProjectStatus = (status: Awaited>): string =>
+ `Rstack project status: ${status.contexts.length} recorded context ${status.contexts.length === 1 ? 'identity' : 'identities'} (${status.contexts.filter(({ state }) => state === 'ready').length} ready, ${status.contexts.filter(({ state }) => state === 'pending').length} pending); ${status.issues.length} context-store/read issue${status.issues.length === 1 ? '' : 's'}. See structuredContent for compact selection details.`;
+
+const summarizeProjectSnapshot = (snapshot: ContextSnapshot) => {
+ const build = summarizeBuildFacet(snapshot.facets.build);
+ const lint = validateLintFacet(snapshot.facets.lint);
+ const test = validateTestFacet(snapshot.facets.test);
+ return {
+ snapshotId: snapshot.snapshotId,
+ observedAt: snapshot.observedAt,
+ status: snapshot.status,
+ completeness: snapshot.completeness,
+ facets: Object.keys(snapshot.facets).sort(),
+ summary: {
+ ...(build === undefined ? {} : { build }),
+ ...(lint === undefined ? {} : { lint: lint.totals }),
+ ...(test === undefined ? {} : { test: { stats: test.stats, durationMs: test.durationMs } }),
+ },
+ };
+};
+
+const summarizeProjectStatus = (status: Awaited>) => ({
+ schemaVersion: status.schemaVersion,
+ workspaceId: status.workspaceId,
+ contexts: status.contexts.map(
+ ({ runId, producer, context, state, latestSnapshot, latestAttempt, freshness }) => {
+ return {
+ runId,
+ producer,
+ context,
+ state,
+ ...(latestSnapshot === undefined
+ ? {}
+ : {
+ latestSnapshot: summarizeProjectSnapshot(latestSnapshot),
+ freshness,
+ }),
+ ...(latestAttempt === undefined
+ ? {}
+ : { latestAttempt: summarizeProjectSnapshot(latestAttempt) }),
+ };
+ },
+ ),
+ issues: status.issues,
+});
+
+const contextIdInput = z.string().min(1).describe('Context ID returned by project_status.');
+const rsdoctorDataFileInput = z
+ .string()
+ .min(1)
+ .describe('Checkout-relative path to an explicit Rsdoctor JSON artifact.');
+const moduleSelectorInput = z
+ .string()
+ .min(1)
+ .describe('Exact module ID, path, name, or unique path suffix.');
+const paginationCursorInput = z
+ .string()
+ .min(1)
+ .describe(
+ 'Opaque pagination cursor returned by the previous response. Reuse the same filters when continuing.',
+ );
+const packageRootInput = z
+ .string()
+ .min(1)
+ .describe(
+ 'Checkout-relative package directory that must stay inside the checkout; defaults to the checkout root.',
+ );
+const configPathInput = z
+ .string()
+ .min(1)
+ .describe(
+ 'Checkout-relative Rstack config path that must stay inside the checkout; defaults to ordinary discovery in packageRoot.',
+ );
+const rsdoctorToolNameInput = z
+ .enum(listRsdoctorToolNames())
+ .describe('Supported Rsdoctor catalog tool to run.');
+
+const rsdoctorAnalyzeInput = z
+ .object({
+ dataFile: rsdoctorDataFileInput,
+ input: z.record(z.string(), z.unknown()).optional(),
+ toolName: rsdoctorToolNameInput,
+ })
+ .strict();
+
+const productRootsInput = z
+ .object({
+ contextId: contextIdInput,
+ dataFile: rsdoctorDataFileInput,
+ rootLimit: z
+ .number()
+ .int()
+ .min(1)
+ .max(100)
+ .default(50)
+ .describe('Maximum number of representative product roots returned in structuredContent.'),
+ })
+ .strict();
+
+const unusedCandidatesInput = z
+ .object({
+ contextId: contextIdInput,
+ dataFile: rsdoctorDataFileInput,
+ limit: z.number().int().min(1).max(100).optional(),
+ cursor: paginationCursorInput.optional(),
+ })
+ .strict();
+
+const deadCodeExplainInput = z
+ .object({
+ contextId: contextIdInput,
+ dataFile: rsdoctorDataFileInput,
+ module: moduleSelectorInput,
+ maxDepth: z.number().int().min(1).max(32).optional(),
+ })
+ .strict();
+
+const moduleImpactInput = z
+ .object({
+ contextId: contextIdInput,
+ dataFile: rsdoctorDataFileInput,
+ module: moduleSelectorInput,
+ direction: z.enum(['dependencies', 'dependents']).optional(),
+ maxDepth: z.number().int().min(1).max(16).optional(),
+ })
+ .strict();
+
+const codeEvidenceInput = z
+ .object({
+ path: z.string().min(1).describe('Checkout-relative source path to inspect.'),
+ line: z.number().int().min(1).optional(),
+ contextId: contextIdInput
+ .describe(
+ 'Artifact context ID returned by project_status; use only together with dataFile, and omit both for test/lint-only evidence.',
+ )
+ .optional(),
+ dataFile: rsdoctorDataFileInput
+ .describe(
+ 'Explicit checkout-relative Rsdoctor JSON artifact path; use only together with contextId.',
+ )
+ .optional(),
+ module: moduleSelectorInput
+ .describe(
+ 'Optional exact artifact module ID, path, or name to join with path-based test, coverage, and lint evidence.',
+ )
+ .optional(),
+ testSnapshotId: z
+ .string()
+ .min(1)
+ .describe('Explicit completed Rstest snapshot ID; defaults to the newest containing package.')
+ .optional(),
+ lintSnapshotId: z
+ .string()
+ .min(1)
+ .describe('Explicit completed Rslint snapshot ID; defaults to the newest containing package.')
+ .optional(),
+ maxDepth: z.number().int().min(1).max(32).optional(),
+ })
+ .strict();
+
+const reportLinkInput = z.object({ dataFile: rsdoctorDataFileInput }).strict();
+
+const producer = z.enum(['rsbuild', 'rspack', 'rslib', 'rstest', 'rslint', 'rsdoctor']);
+const pageLimit = z.number().int().min(1).max(200).default(50);
+
+const snapshotListInput = z
+ .object({
+ producer: producer.optional(),
+ contextId: contextIdInput.optional(),
+ limit: pageLimit,
+ cursor: paginationCursorInput.optional(),
+ })
+ .strict();
+
+const diagnosticsListInput = z
+ .object({
+ snapshotId: z.string().min(1).optional(),
+ producer: z.enum(['rslint', 'rstest']).optional(),
+ pathPrefix: z.string().optional(),
+ severity: z.enum(['error', 'warning']).optional(),
+ ruleId: z.string().optional(),
+ limit: pageLimit,
+ cursor: paginationCursorInput.optional(),
+ })
+ .strict();
+
+const testResultsInput = z
+ .object({
+ snapshotId: z.string().min(1).optional(),
+ project: z.string().optional(),
+ pathPrefix: z.string().optional(),
+ status: z.enum(['skip', 'pass', 'fail', 'todo']).optional(),
+ limit: pageLimit,
+ cursor: paginationCursorInput.optional(),
+ })
+ .strict();
+
+const snapshotDiffInput = z
+ .object({
+ leftSnapshotId: z.string().min(1),
+ rightSnapshotId: z.string().min(1),
+ kind: z.enum(['diagnostics', 'tests']).optional(),
+ })
+ .strict();
+
+const lintFixPreviewInput = z
+ .object({ snapshotId: z.string().min(1), path: z.string().min(1) })
+ .strict();
+
+const lintSnapshotRequestInput = z.discriminatedUnion('mode', [
+ z
+ .object({
+ mode: z.literal('files'),
+ patterns: z.array(z.string().min(1)).default(['.']),
+ includeFixPreview: z.boolean().default(false),
+ packageRoot: packageRootInput.optional(),
+ configPath: configPathInput.optional(),
+ })
+ .strict(),
+ z
+ .object({
+ mode: z.literal('text'),
+ code: z.string(),
+ filePath: z.string().min(1),
+ includeFixPreview: z.boolean().default(false),
+ packageRoot: packageRootInput.optional(),
+ configPath: configPathInput.optional(),
+ })
+ .strict(),
+]);
+
+const lintSnapshotInput = z
+ .object({
+ mode: z
+ .enum(['files', 'text'])
+ .describe('Lint files selected by patterns, or one text buffer supplied in code.'),
+ patterns: z
+ .array(z.string().min(1))
+ .describe('File patterns used only when mode is files; defaults to ["."].')
+ .optional(),
+ code: z.string().describe('Source text required when mode is text.').optional(),
+ filePath: z
+ .string()
+ .min(1)
+ .describe('Checkout-relative virtual source path required when mode is text.')
+ .optional(),
+ includeFixPreview: z.boolean().optional(),
+ packageRoot: packageRootInput.optional(),
+ configPath: configPathInput.optional(),
+ })
+ .strict();
+
+const testSnapshotInput = z
+ .object({
+ files: z.array(z.string().min(1)).optional(),
+ related: z
+ .array(z.string().min(1))
+ .min(1)
+ .max(200)
+ .describe(
+ 'Source paths resolved from packageRoot; Rstest selects and runs only statically related test files.',
+ )
+ .optional(),
+ testNamePattern: z.string().min(1).optional(),
+ packageRoot: packageRootInput.optional(),
+ configPath: configPathInput.optional(),
+ execution: z
+ .object({
+ include: z.array(z.string()).max(200).optional(),
+ exclude: z.array(z.string()).max(200).optional(),
+ allowExternal: z.boolean().optional(),
+ })
+ .strict()
+ .describe('Explicitly enable aggregate Istanbul execution coverage for this one test run.')
+ .optional(),
+ })
+ .strict();
+
+const toMcpError = (error: unknown) => ({
+ content: [
+ {
+ type: 'text' as const,
+ text: error instanceof Error ? error.message : 'Rstack context request failed.',
+ },
+ ],
+ isError: true,
+});
+
+const formatStructuredResult = (result: unknown): string => {
+ if (!isRecordObject(result)) return 'Rstack result is available in structuredContent.';
+
+ const details: string[] = [];
+ const addDetail = (key: string, value: unknown): void => {
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
+ details.push(`${key}=${String(value)}`);
+ }
+ };
+
+ addDetail('compatible', result.compatible);
+ addDetail('available', result.available);
+ addDetail('status', result.status);
+ addDetail('classification', result.classification);
+ addDetail('total', result.total);
+ addDetail('returned', result.returned);
+ if (result.returned === undefined && Array.isArray(result.items)) {
+ addDetail('returned', result.items.length);
+ }
+ addDetail('totalVisited', result.totalVisited);
+
+ if (isRecordObject(result.ownership)) {
+ addDetail('project', result.ownership.project);
+ addDetail('dependency', result.ownership.dependency);
+ }
+
+ if (isRecordObject(result.graph)) {
+ addDetail('moduleCount', result.graph.moduleCount);
+ addDetail('edgeCount', result.graph.edgeCount);
+ }
+
+ if (isRecordObject(result.summary)) {
+ for (const key of [
+ 'files',
+ 'tests',
+ 'passed',
+ 'failed',
+ 'errors',
+ 'warnings',
+ 'added',
+ 'removed',
+ 'changed',
+ ]) {
+ addDetail(key, result.summary[key]);
+ }
+ }
+
+ if (isRecordObject(result.execution)) {
+ addDetail('executionProvider', result.execution.provider);
+ addDetail('executionAvailability', result.execution.availability);
+ addDetail('executionCompleteness', result.execution.completeness);
+ }
+
+ const firstError =
+ Array.isArray(result.errors) && isRecordObject(result.errors[0])
+ ? result.errors[0]
+ : Array.isArray(result.unhandledErrors) && isRecordObject(result.unhandledErrors[0])
+ ? result.unhandledErrors[0]
+ : undefined;
+ if (firstError !== undefined) {
+ addDetail('firstError', firstError.message);
+ }
+
+ if (Array.isArray(result.unreadableInputs) && result.unreadableInputs.length > 0) {
+ addDetail('unreadableInputs', result.unreadableInputs.length);
+ }
+
+ return details.length === 0
+ ? 'Rstack result is available in structuredContent.'
+ : `Rstack result: ${details.join(', ')}. See structuredContent for complete data.`;
+};
+
+const toStructuredMcpResult = (result: Result) => ({
+ content: [{ type: 'text' as const, text: formatStructuredResult(result) }],
+ structuredContent: result,
+});
+
+const toProductRootsMcpResult = (
+ result: Awaited>,
+ rootLimit: number,
+) => {
+ const rootCounts: Record = {};
+ for (const { kind } of result.product.roots) rootCounts[kind] = (rootCounts[kind] ?? 0) + 1;
+ const roots = result.product.roots.slice(0, rootLimit);
+ const rootSummary = {
+ total: result.product.roots.length,
+ returned: roots.length,
+ truncated: roots.length < result.product.roots.length,
+ byKind: rootCounts,
+ };
+ return {
+ content: [
+ {
+ type: 'text' as const,
+ text: `Rstack roots: modules=${result.graph.moduleCount}, edges=${result.graph.edgeCount}, roots=${rootSummary.returned}/${rootSummary.total}. See structuredContent for bounded root details.`,
+ },
+ ],
+ structuredContent: {
+ ...result,
+ product: { ...result.product, roots },
+ rootSummary,
+ },
+ };
+};
+
+const formatCodeEvidence = (result: CodeEvidenceResult): string => {
+ const diagnostics = result.diagnostics.truncated
+ ? `${result.diagnostics.returned}/${result.diagnostics.total}(truncated)`
+ : String(result.diagnostics.returned);
+ const module =
+ result.module === undefined
+ ? 'not-requested'
+ : `${result.module.classification}(binding=${result.module.provenance.artifactBinding})`;
+ return `Code evidence for ${result.path}: coverage=${result.executionCoverage.state}, relation=${result.testRelation.state}, test=${result.testOutcome.state}, diagnostics=${diagnostics}, module=${module}. See structuredContent for complete data.`;
+};
+
+const isLiteralEmpty = (value: unknown): boolean =>
+ value === '' ||
+ (Array.isArray(value) && value.length === 0) ||
+ (isRecordObject(value) && Object.keys(value).length === 0);
+
+const isRecursivelyZeroShaped = (value: unknown): boolean => {
+ if (value === null || value === '' || value === 0) return true;
+ if (Array.isArray(value)) return value.every(isRecursivelyZeroShaped);
+ return isRecordObject(value) && Object.values(value).every(isRecursivelyZeroShaped);
+};
+
+const findUnavailableSections = (value: unknown): string[] => {
+ if (!isRecordObject(value) || !Array.isArray(value.sectionEvidence)) return [];
+ return value.sectionEvidence
+ .flatMap((section) => {
+ if (
+ !isRecordObject(section) ||
+ typeof section.section !== 'string' ||
+ (section.status !== 'omitted' && section.status !== 'unavailable')
+ ) {
+ return [];
+ }
+ const reason = typeof section.reason === 'string' ? `: ${section.reason}` : '';
+ return [`${section.section} (${section.status}${reason})`];
+ })
+ .sort();
+};
+
+const formatRsdoctorAnalysis = (toolName: string, analysis: unknown, data: unknown): string => {
+ const dataState =
+ data === null
+ ? 'null data'
+ : isLiteralEmpty(data)
+ ? 'literal empty data'
+ : isRecursivelyZeroShaped(data)
+ ? 'recursively zero-shaped data'
+ : 'data present';
+ const unavailableSections = findUnavailableSections(analysis);
+ const sectionEvidence =
+ unavailableSections.length === 0
+ ? ''
+ : ` Unavailable sections: ${unavailableSections.join(', ')}.`;
+ return `Rsdoctor ${toolName} analysis returned ${dataState}.${sectionEvidence}`;
+};
+
+type ContextMcpDependencies = {
+ analyzeRsdoctorArtifact?: typeof analyzeRsdoctorArtifact;
+ captureLintSnapshot?: typeof captureLintSnapshot;
+ captureTestSnapshot?: typeof captureTestSnapshot;
+ createRslint?: RslintFactory;
+ lintCaptureAdapter?: LintCaptureAdapter;
+ testCaptureDependencies?: TestCaptureDependencies;
+ serverVersion?: string;
+};
+
+const listSnapshots = async (workspaceRoot: string, input: z.infer) => {
+ const snapshots = await readContextSnapshots(workspaceRoot, {
+ producer: input.producer,
+ contextId: input.contextId,
+ });
+ const cursorScope = {
+ surface: 'snapshot_list',
+ producer: input.producer,
+ contextId: input.contextId,
+ };
+ const offset = decodeCursor(input.cursor, 'Invalid snapshot cursor.', cursorScope);
+ const selected = snapshots.slice(offset, offset + input.limit);
+ const items = await Promise.all(
+ selected.map(async ({ run, context, snapshot }) => {
+ const lintFacet = validateLintFacet(snapshot.facets.lint);
+ return {
+ snapshotId: snapshot.snapshotId,
+ runId: run.runId,
+ producer: run.producer,
+ context,
+ sequence: snapshot.sequence,
+ observedAt: snapshot.observedAt,
+ status: snapshot.status,
+ completeness: snapshot.completeness,
+ ...(lintFacet === undefined
+ ? {}
+ : {
+ metadata: {
+ lint: {
+ fixPreviewCaptured: lintFacet.fixPreviewCaptured,
+ },
+ },
+ }),
+ freshness: await assessSnapshotFreshness(workspaceRoot, snapshot),
+ };
+ }),
+ );
+ const nextOffset = offset + items.length;
+ return {
+ total: snapshots.length,
+ items,
+ ...(nextOffset < snapshots.length ? { nextCursor: encodeCursor(nextOffset, cursorScope) } : {}),
+ };
+};
+
+const createContextMcpServer = (
+ workspaceRoot: string,
+ dependencies: ContextMcpDependencies = {},
+): McpServer => {
+ const server = new McpServer(
+ {
+ name: 'rstack-context',
+ version: dependencies.serverVersion ?? RSTACK_CONTEXT_VERSION,
+ },
+ {
+ instructions:
+ 'Rstack context evidence is checkout-local and potentially partial. Query tools read completed observations; lint_snapshot and test_snapshot explicitly execute their producer. Artifact-scoped module candidates are never proof that unobserved code is dead.',
+ },
+ );
+
+ server.registerTool(
+ 'project_status',
+ {
+ title: 'Rstack context status',
+ description:
+ 'List all recorded checkout-local Rstack contexts and the latest completed build, lint, or test snapshot for each context.',
+ annotations: readOnlyAnnotations,
+ },
+ async () => {
+ const status = await readProjectStatus(workspaceRoot);
+ return {
+ content: [{ type: 'text', text: renderProjectStatus(status) }],
+ structuredContent: summarizeProjectStatus(status),
+ };
+ },
+ );
+
+ server.registerTool(
+ 'product_roots',
+ {
+ title: 'Resolve product roots',
+ description: 'Return selected roots for one explicit Rsdoctor module graph.',
+ inputSchema: productRootsInput,
+ annotations: readOnlyAnnotations,
+ },
+ async ({ contextId, dataFile, rootLimit }) => {
+ try {
+ const result = await readProductRoots(workspaceRoot, {
+ contextId,
+ dataFile,
+ });
+ return toProductRootsMcpResult(result, rootLimit);
+ } catch (error) {
+ return toMcpError(error);
+ }
+ },
+ );
+
+ server.registerTool(
+ 'unused_candidates',
+ {
+ title: 'Find unused module candidates',
+ description:
+ 'Return unreachable module candidates from one explicit Rsdoctor artifact graph.',
+ inputSchema: unusedCandidatesInput,
+ annotations: readOnlyAnnotations,
+ },
+ async ({ contextId, dataFile, limit, cursor }) => {
+ try {
+ const result = await findUnusedCandidates(workspaceRoot, {
+ contextId,
+ dataFile,
+ limit,
+ cursor,
+ });
+ return toStructuredMcpResult(result);
+ } catch (error) {
+ return toMcpError(error);
+ }
+ },
+ );
+
+ server.registerTool(
+ 'dead_code_explain',
+ {
+ title: 'Explain module reachability',
+ description:
+ 'Explain why one module is reachable, conservatively preserved, or an artifact-scoped candidate.',
+ inputSchema: deadCodeExplainInput,
+ annotations: readOnlyAnnotations,
+ },
+ async ({ contextId, dataFile, module, maxDepth }) => {
+ try {
+ const result = await explainDeadCodeCandidate(workspaceRoot, {
+ contextId,
+ dataFile,
+ module,
+ maxDepth,
+ });
+ return toStructuredMcpResult(result);
+ } catch (error) {
+ return toMcpError(error);
+ }
+ },
+ );
+
+ server.registerTool(
+ 'module_impact',
+ {
+ title: 'Trace module impact',
+ description:
+ 'Trace bounded module dependencies or dependents within one explicit artifact graph.',
+ inputSchema: moduleImpactInput,
+ annotations: readOnlyAnnotations,
+ },
+ async ({ contextId, dataFile, module, direction, maxDepth }) => {
+ try {
+ const result = await traceModuleImpact(workspaceRoot, {
+ contextId,
+ dataFile,
+ module,
+ direction,
+ maxDepth,
+ });
+ return toStructuredMcpResult(result);
+ } catch (error) {
+ return toMcpError(error);
+ }
+ },
+ );
+
+ server.registerTool(
+ 'code_evidence',
+ {
+ title: 'Inspect code evidence',
+ description:
+ 'Join static related-test selection, exact-path test outcome, aggregate execution, diagnostics, and optional explicit artifact module evidence without collapsing their bounds.',
+ inputSchema: codeEvidenceInput,
+ annotations: readOnlyAnnotations,
+ },
+ async (input) => {
+ try {
+ const result = await readCodeEvidence(workspaceRoot, input);
+ return {
+ content: [{ type: 'text', text: formatCodeEvidence(result) }],
+ structuredContent: result,
+ };
+ } catch (error) {
+ return toMcpError(error);
+ }
+ },
+ );
+
+ server.registerTool(
+ 'snapshot_list',
+ {
+ title: 'List context snapshots',
+ description:
+ 'List completed immutable context snapshots newest-first, optionally filtered by producer or context.',
+ inputSchema: snapshotListInput,
+ annotations: readOnlyAnnotations,
+ },
+ async (input) => {
+ try {
+ const result = await listSnapshots(workspaceRoot, input);
+ return toStructuredMcpResult(result);
+ } catch (error) {
+ return toMcpError(error);
+ }
+ },
+ );
+
+ server.registerTool(
+ 'diagnostics_list',
+ {
+ title: 'List snapshot diagnostics',
+ description:
+ 'List deterministic Rslint or Rstest diagnostics, optionally filtered by completed snapshot, producer, path prefix, severity, or rule.',
+ inputSchema: diagnosticsListInput,
+ annotations: readOnlyAnnotations,
+ },
+ async (input) => {
+ try {
+ const result = await listDiagnostics(workspaceRoot, input);
+ return toStructuredMcpResult(result);
+ } catch (error) {
+ return toMcpError(error);
+ }
+ },
+ );
+
+ server.registerTool(
+ 'test_results',
+ {
+ title: 'List snapshot test results',
+ description:
+ 'List deterministic test cases, optionally filtered by completed Rstest snapshot, project, path prefix, or status.',
+ inputSchema: testResultsInput,
+ annotations: readOnlyAnnotations,
+ },
+ async (input) => {
+ try {
+ const result = await listTestResults(workspaceRoot, input);
+ return toStructuredMcpResult(result);
+ } catch (error) {
+ return toMcpError(error);
+ }
+ },
+ );
+
+ server.registerTool(
+ 'snapshot_diff',
+ {
+ title: 'Compare context snapshots',
+ description: 'Compare diagnostics or tests from two compatible immutable snapshots.',
+ inputSchema: snapshotDiffInput,
+ annotations: readOnlyAnnotations,
+ },
+ async (input) => {
+ try {
+ const result = await diffContextSnapshots(workspaceRoot, input);
+ return toStructuredMcpResult(result);
+ } catch (error) {
+ return toMcpError(error);
+ }
+ },
+ );
+
+ server.registerTool(
+ 'lint_fix_preview',
+ {
+ title: 'Read lint fix preview',
+ description: 'Return a fixed-output preview already stored in an immutable lint snapshot.',
+ inputSchema: lintFixPreviewInput,
+ annotations: readOnlyAnnotations,
+ },
+ async ({ snapshotId, path: filePath }) => {
+ try {
+ const result = await getLintFixPreview(workspaceRoot, snapshotId, filePath);
+ return toStructuredMcpResult(result);
+ } catch (error) {
+ return toMcpError(error);
+ }
+ },
+ );
+
+ server.registerTool(
+ 'lint_snapshot',
+ {
+ title: 'Capture lint snapshot',
+ description: 'Run one explicit Rslint capture and store its immutable results.',
+ inputSchema: lintSnapshotInput,
+ annotations: {
+ readOnlyHint: false,
+ destructiveHint: false,
+ openWorldHint: true,
+ },
+ },
+ async (input) => {
+ try {
+ const request = lintSnapshotRequestInput.parse(input);
+ const result = await (dependencies.captureLintSnapshot ?? captureLintSnapshot)(
+ workspaceRoot,
+ request,
+ dependencies.createRslint,
+ dependencies.lintCaptureAdapter,
+ );
+ return toStructuredMcpResult(result);
+ } catch (error) {
+ return toMcpError(error);
+ }
+ },
+ );
+
+ server.registerTool(
+ 'test_snapshot',
+ {
+ title: 'Capture test snapshot',
+ description:
+ 'Run one explicit one-shot Rstest capture for a package with Rstest configured, optionally selected from related source files, and store its immutable results. A host that knows Rstest is not configured returns an error without running unrelated test discovery.',
+ inputSchema: testSnapshotInput,
+ annotations: {
+ readOnlyHint: false,
+ destructiveHint: true,
+ openWorldHint: true,
+ },
+ },
+ async (input) => {
+ try {
+ const result = await (dependencies.captureTestSnapshot ?? captureTestSnapshot)(
+ workspaceRoot,
+ input as TestSnapshotRequest,
+ dependencies.testCaptureDependencies,
+ );
+ return toStructuredMcpResult(result);
+ } catch (error) {
+ return toMcpError(error);
+ }
+ },
+ );
+
+ server.registerTool(
+ 'rsdoctor_analyze',
+ {
+ title: 'Analyze Rsdoctor artifact',
+ description: 'Analyze an explicit Rsdoctor artifact with a catalog tool.',
+ inputSchema: rsdoctorAnalyzeInput,
+ annotations: readOnlyAnnotations,
+ },
+ async ({ dataFile, input, toolName }) => {
+ try {
+ const analysis = await (dependencies.analyzeRsdoctorArtifact ?? analyzeRsdoctorArtifact)(
+ workspaceRoot,
+ {
+ dataFile,
+ input,
+ toolName,
+ },
+ );
+ const analysisData =
+ isRecordObject(analysis.result) && 'data' in analysis.result
+ ? analysis.result.data
+ : analysis.result;
+ return {
+ content: [
+ {
+ type: 'text',
+ text: formatRsdoctorAnalysis(analysis.toolName, analysis, analysisData),
+ },
+ ],
+ structuredContent: analysis,
+ };
+ } catch (error) {
+ return toMcpError(error);
+ }
+ },
+ );
+
+ server.registerTool(
+ 'report_link',
+ {
+ title: 'Link Rsdoctor report',
+ description:
+ 'Return a link to a conventionally named checkout-local Rsdoctor report artifact when present.',
+ inputSchema: reportLinkInput,
+ annotations: readOnlyAnnotations,
+ },
+ async ({ dataFile }) => {
+ try {
+ const report = await resolveRsdoctorReport(workspaceRoot, dataFile);
+ const content: ContentBlock[] = [
+ {
+ type: 'text' as const,
+ text: 'report' in report ? 'Rsdoctor report link is available.' : report.reason,
+ },
+ ];
+
+ if ('report' in report) {
+ content.push({
+ type: 'resource_link' as const,
+ mimeType: report.report.kind === 'html' ? 'text/html' : 'application/json',
+ name: `Rsdoctor ${report.report.kind === 'html' ? 'HTML report' : 'manifest'}`,
+ uri: report.report.uri,
+ });
+ }
+
+ return { content, structuredContent: report };
+ } catch (error) {
+ return toMcpError(error);
+ }
+ },
+ );
+
+ return server;
+};
+
+export { createContextMcpServer };
+export type { ContextMcpDependencies };
diff --git a/src/model.ts b/src/model.ts
new file mode 100644
index 0000000..9f9e748
--- /dev/null
+++ b/src/model.ts
@@ -0,0 +1,333 @@
+const contextStoreSchemaVersion = 1 as const;
+
+type JsonPrimitive = boolean | null | number | string;
+type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
+
+type ContextProducer = 'rsbuild' | 'rspack' | 'rslib' | 'rstest' | 'rslint' | 'rsdoctor';
+type ContextRunStatus = 'queued' | 'running' | 'pass' | 'fail' | 'cancelled' | 'error';
+type ContextCompleteness = 'complete' | 'partial' | 'disabled' | 'unsupported';
+
+type ContextInputFile = { path: string; digest: string };
+type ContextInputCompleteness = 'complete' | 'partial';
+type ContextFreshness = {
+ state: 'fresh' | 'stale' | 'partial' | 'unknown';
+ changedPaths: string[];
+};
+
+type ContextSnapshotSource = {
+ revision?: string;
+ dirtyDigest?: string;
+ inputs?: ContextInputFile[];
+ inputCompleteness?: ContextInputCompleteness;
+ unreadableInputs?: string[];
+ virtualInputDigest?: string;
+ captureSelection?: JsonValue;
+};
+
+type ContextDescriptor = {
+ contextId: string;
+ packageRoot: string;
+ product: string;
+ packageName?: string;
+ configPath?: string;
+ environment?: string;
+ target?: string;
+ mode?: string;
+ variant?: string;
+ distPath?: string;
+};
+
+type BuildMetadataFacet = {
+ producer: 'rsbuild' | 'rslib';
+ command: string;
+ mode?: string;
+ environment: string;
+ target: string[];
+ isWatch: boolean;
+ isFirstCompile: boolean;
+ durationMs: number;
+ hash?: string;
+ hasErrors: boolean;
+ hasWarnings: boolean;
+ assets: Array<{ name: string; size: number }>;
+ chunks: Array<{ id?: string; files: string[]; initial?: boolean }>;
+ truncated: { assets: number; chunks: number };
+};
+
+type LintMessageRecord = {
+ ruleId: string | null;
+ severity: 1 | 2;
+ message: string;
+ messageId?: string;
+ line: number;
+ column: number;
+ endLine?: number;
+ endColumn?: number;
+ fix?: { range: [number, number]; text: string };
+ suggestions?: Array<{
+ messageId?: string;
+ data?: Record;
+ desc: string;
+ fix: { range: [number, number]; text: string };
+ }>;
+};
+
+type LintFileRecord = {
+ path: string;
+ digest: string;
+ errorCount: number;
+ warningCount: number;
+ fixableErrorCount: number;
+ fixableWarningCount: number;
+ messages: LintMessageRecord[];
+ fixedOutput?: string;
+};
+
+type LintFacet = {
+ producer: 'rslint';
+ mode: 'files' | 'text';
+ fixPreviewCaptured: boolean;
+ files: LintFileRecord[];
+ totals: {
+ files: number;
+ errors: number;
+ warnings: number;
+ fixableErrors: number;
+ fixableWarnings: number;
+ };
+};
+
+type TestErrorRecord = {
+ name: string;
+ message: string;
+ stack?: string;
+ diff?: string;
+ actual?: string;
+ expected?: string;
+ retryCount?: number;
+ /** Recursive cause chain reported by Rstest, truncated at a fixed depth on capture. */
+ cause?: TestErrorRecord;
+};
+
+type TestCaseRecord = {
+ project: string;
+ path: string;
+ name: string;
+ parentNames?: string[];
+ status: 'skip' | 'pass' | 'fail' | 'todo';
+ durationMs?: number;
+ errors?: TestErrorRecord[];
+ retryErrors?: TestErrorRecord[];
+ retryCount?: number;
+ /** User-defined task metadata reported by Rstest; stored only when JSON-safe. */
+ meta?: Record;
+};
+
+type TestFileRecord = {
+ project: string;
+ path: string;
+ status: 'skip' | 'pass' | 'fail' | 'todo';
+ durationMs?: number;
+ errors?: TestErrorRecord[];
+ tests: TestCaseRecord[];
+};
+
+type TestRelationRecord = {
+ sources: string[];
+ testFiles: string[];
+};
+
+type TestFacet = {
+ producer: 'rstest';
+ relation?: TestRelationRecord;
+ files: TestFileRecord[];
+ stats: {
+ tests: {
+ total: number;
+ passed: number;
+ failed: number;
+ skipped: number;
+ todo: number;
+ };
+ files: { total: number; failed: number };
+ };
+ durationMs: number;
+ unhandledErrors: TestErrorRecord[];
+};
+
+type TestExecutionPosition = { line: number; column: number };
+
+type TestExecutionLocation = {
+ start: TestExecutionPosition;
+ end: TestExecutionPosition;
+};
+
+type TestExecutionStatement = {
+ id: string;
+ location: TestExecutionLocation;
+ hits: number;
+};
+
+type TestExecutionFunction = {
+ id: string;
+ name: string;
+ declaration: TestExecutionLocation;
+ location: TestExecutionLocation;
+ hits: number;
+};
+
+type TestExecutionBranch = {
+ id: string;
+ type: string;
+ location: TestExecutionLocation;
+ arms: Array<{ location: TestExecutionLocation; hits: number }>;
+};
+
+type TestExecutionFile = {
+ path: string;
+ digest?: string;
+ statements: TestExecutionStatement[];
+ functions: TestExecutionFunction[];
+ branches: TestExecutionBranch[];
+};
+
+type TestExecutionRequestedSelection = {
+ include?: string[];
+ exclude?: string[];
+ allowExternal: boolean;
+};
+
+type TestExecutionFacet = {
+ producer: 'rstest';
+ provider: 'istanbul';
+ availability: 'available' | 'unavailable';
+ requestedSelection: TestExecutionRequestedSelection;
+ digest: string;
+ universe: {
+ reportedFiles: number;
+ storedFiles: number;
+ droppedFiles: number;
+ reportedLocations: number;
+ storedLocations: number;
+ droppedLocations: number;
+ completeness: 'complete' | 'partial' | 'unknown';
+ };
+ truncated: { files: number; locations: number };
+ bounds: {
+ attribution: 'aggregate-run-only';
+ testAttribution: false;
+ maxFiles: 1000;
+ maxLocationsPerFile: 20_000;
+ maxLocationsTotal: 100_000;
+ };
+ files: TestExecutionFile[];
+};
+
+type ContextRunManifest = {
+ schemaVersion: typeof contextStoreSchemaVersion;
+ runId: string;
+ producer: ContextProducer;
+ command: string;
+ startedAt: string;
+ contexts: ContextDescriptor[];
+};
+
+type ContextSnapshot = {
+ schemaVersion: typeof contextStoreSchemaVersion;
+ snapshotId: string;
+ runId: string;
+ contextId: string;
+ sequence: number;
+ observedAt: string;
+ status: ContextRunStatus;
+ completeness: Record;
+ facets: Record;
+ source?: ContextSnapshotSource;
+};
+
+type StoredContextSnapshot = {
+ run: ContextRunManifest;
+ context: ContextDescriptor;
+ snapshot: ContextSnapshot;
+};
+
+type ContextStoreWriteResult =
+ { written: true; path: string } | { written: false; path: string; error: unknown };
+
+type ContextStoreIssue = {
+ code: 'invalid-record' | 'unsupported-schema';
+ path: string;
+};
+
+type ContextStatus = {
+ context: ContextDescriptor;
+ latestSnapshot?: ContextSnapshot;
+};
+
+type ContextRunStatusEntry = {
+ run: ContextRunManifest;
+ contexts: ContextStatus[];
+};
+
+type ContextWorkspaceStatus = {
+ schemaVersion: typeof contextStoreSchemaVersion;
+ runs: ContextRunStatusEntry[];
+ issues: ContextStoreIssue[];
+};
+
+type ProjectContextStatus = {
+ runId: string;
+ producer: ContextProducer;
+ context: ContextDescriptor;
+ state: 'ready' | 'pending';
+ latestSnapshot?: ContextSnapshot;
+ latestAttempt?: ContextSnapshot;
+ freshness?: ContextFreshness;
+};
+
+type ProjectStatus = {
+ schemaVersion: typeof contextStoreSchemaVersion;
+ workspaceId: string;
+ contexts: ProjectContextStatus[];
+ issues: ContextStoreIssue[];
+};
+
+export { contextStoreSchemaVersion };
+export type {
+ BuildMetadataFacet,
+ ContextCompleteness,
+ ContextDescriptor,
+ ContextFreshness,
+ ContextInputCompleteness,
+ ContextInputFile,
+ ContextProducer,
+ ContextRunManifest,
+ ContextRunStatus,
+ ContextRunStatusEntry,
+ ContextSnapshot,
+ ContextSnapshotSource,
+ ContextStatus,
+ ContextStoreIssue,
+ ContextStoreWriteResult,
+ ContextWorkspaceStatus,
+ JsonValue,
+ LintFacet,
+ LintFileRecord,
+ LintMessageRecord,
+ ProjectContextStatus,
+ ProjectStatus,
+ StoredContextSnapshot,
+ TestCaseRecord,
+ TestErrorRecord,
+ TestExecutionBranch,
+ TestExecutionFacet,
+ TestExecutionFile,
+ TestExecutionFunction,
+ TestExecutionLocation,
+ TestExecutionPosition,
+ TestExecutionRequestedSelection,
+ TestExecutionStatement,
+ TestFacet,
+ TestFileRecord,
+ TestRelationRecord,
+};
diff --git a/src/order.ts b/src/order.ts
new file mode 100644
index 0000000..b2f8151
--- /dev/null
+++ b/src/order.ts
@@ -0,0 +1,7 @@
+const compareStrings = (left: string, right: string): number =>
+ left === right ? 0 : left < right ? -1 : 1;
+
+const compareStringsDescending = (left: string, right: string): number =>
+ compareStrings(right, left);
+
+export { compareStrings, compareStringsDescending };
diff --git a/src/pagination.ts b/src/pagination.ts
new file mode 100644
index 0000000..a264ab9
--- /dev/null
+++ b/src/pagination.ts
@@ -0,0 +1,48 @@
+type CursorScope = Readonly>;
+
+const encodeScope = (scope: CursorScope): string =>
+ JSON.stringify(
+ Object.entries(scope)
+ .filter((entry): entry is [string, string] => entry[1] !== undefined)
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)),
+ );
+
+const encodeCursor = (offset: number, scope?: CursorScope): string =>
+ Buffer.from(
+ scope === undefined ? String(offset) : JSON.stringify([1, offset, encodeScope(scope)]),
+ ).toString('base64url');
+
+const decodeCursor = (
+ cursor: string | undefined,
+ errorMessage: string,
+ scope?: CursorScope,
+): number => {
+ if (cursor === undefined) return 0;
+ const value = Buffer.from(cursor, 'base64url').toString('utf8');
+ if (Buffer.from(value).toString('base64url') === cursor) {
+ if (scope === undefined) {
+ const offset = Number(value);
+ if (/^(?:0|[1-9]\d*)$/u.test(value) && Number.isSafeInteger(offset)) return offset;
+ } else {
+ try {
+ const decoded: unknown = JSON.parse(value);
+ if (
+ Array.isArray(decoded) &&
+ decoded.length === 3 &&
+ decoded[0] === 1 &&
+ typeof decoded[1] === 'number' &&
+ Number.isSafeInteger(decoded[1]) &&
+ decoded[1] >= 0 &&
+ decoded[2] === encodeScope(scope)
+ ) {
+ return decoded[1];
+ }
+ } catch {
+ // Fall through to the surface-specific cursor error.
+ }
+ }
+ }
+ throw new Error(errorMessage);
+};
+
+export { decodeCursor, encodeCursor };
diff --git a/src/paths.ts b/src/paths.ts
new file mode 100644
index 0000000..4edb5ee
--- /dev/null
+++ b/src/paths.ts
@@ -0,0 +1,28 @@
+import path from 'node:path';
+
+const toWorkspacePath = (workspaceRoot: string, filePath: string): string =>
+ path.relative(workspaceRoot, path.resolve(workspaceRoot, filePath)).split(path.sep).join('/');
+
+const normalizeModuleSelector = (value: string): string =>
+ path.posix.normalize(value.replaceAll('\\', '/')).replace(/^\.\//u, '');
+
+const resolveContainedPath = (workspaceRoot: string, field: string, value: string): string => {
+ const portable = value.replaceAll('\\', '/');
+ const resolved = path.resolve(workspaceRoot, value);
+ const relative = path.relative(workspaceRoot, resolved);
+ if (
+ portable.length === 0 ||
+ path.isAbsolute(value) ||
+ path.posix.isAbsolute(portable) ||
+ path.isAbsolute(relative) ||
+ relative === '..' ||
+ relative.startsWith(`..${path.sep}`)
+ ) {
+ throw new Error(
+ `${field} must be a non-empty checkout-relative path that stays inside the checkout.`,
+ );
+ }
+ return resolved;
+};
+
+export { normalizeModuleSelector, resolveContainedPath, toWorkspacePath };
diff --git a/src/products.ts b/src/products.ts
new file mode 100644
index 0000000..5a060e4
--- /dev/null
+++ b/src/products.ts
@@ -0,0 +1,182 @@
+import { readFile } from 'node:fs/promises';
+import path from 'node:path';
+import type {
+ ContractField,
+ ContractTarget,
+ ModuleRef,
+ ObservedModule,
+ ObservedModuleGraph,
+ ProductRoot,
+ ProductRootSet,
+} from './analysisModel.ts';
+import { isRecordObject } from './guards.ts';
+import type { ContextDescriptor } from './model.ts';
+import { compareStrings } from './order.ts';
+import { normalizeModuleSelector } from './paths.ts';
+
+const collectStringLeaves = (value: unknown, targets: string[]): void => {
+ if (typeof value === 'string') {
+ targets.push(value);
+ } else if (Array.isArray(value)) {
+ for (const entry of value) collectStringLeaves(entry, targets);
+ } else if (isRecordObject(value)) {
+ for (const entry of Object.values(value)) collectStringLeaves(entry, targets);
+ }
+};
+
+const readContractTargets = async (
+ workspaceRoot: string,
+ packageRoot: string,
+): Promise | undefined> => {
+ let manifest: unknown;
+ try {
+ manifest = JSON.parse(
+ await readFile(path.join(workspaceRoot, packageRoot, 'package.json'), 'utf8'),
+ );
+ } catch {
+ return undefined;
+ }
+ if (!isRecordObject(manifest)) return undefined;
+
+ const pairs: Array<{ field: ContractField; target: string }> = [];
+ for (const field of ['exports', 'bin'] as const) {
+ const targets: string[] = [];
+ collectStringLeaves(manifest[field], targets);
+ pairs.push(...targets.map((target) => ({ field, target })));
+ }
+ for (const field of ['main', 'module', 'types'] as const) {
+ if (typeof manifest[field] === 'string') pairs.push({ field, target: manifest[field] });
+ }
+
+ const unique = new Map(pairs.map((pair) => [`${pair.field}\u0000${pair.target}`, pair]));
+ return [...unique.values()].sort(
+ (left, right) =>
+ compareStrings(left.field, right.field) || compareStrings(left.target, right.target),
+ );
+};
+
+const matchesTarget = (module: ObservedModule, target: string, packageRoot: string): boolean => {
+ const modulePath = normalizeModuleSelector(module.path);
+ const normalizedTarget = normalizeModuleSelector(target);
+ if (modulePath.split('/').includes('node_modules')) return false;
+ const normalizedPackageRoot = normalizeModuleSelector(packageRoot);
+ const scopedTarget =
+ normalizedPackageRoot === '.'
+ ? normalizedTarget
+ : normalizeModuleSelector(`${normalizedPackageRoot}/${normalizedTarget}`);
+ return modulePath === scopedTarget || modulePath.endsWith(`/${scopedTarget}`);
+};
+
+const toModuleRef = ({
+ isEntry: _,
+ optimizerBound: __,
+ optimizerReasons: ___,
+ ...module
+}: ObservedModule): ModuleRef => module;
+
+const addRoot = (roots: ProductRoot[], root: ProductRoot): void => {
+ if (roots.some(({ kind, module }) => kind === root.kind && module.id === root.module.id)) {
+ return;
+ }
+ roots.push(root);
+};
+
+const resolveProductRoots = async (
+ workspaceRoot: string,
+ context: ContextDescriptor,
+ graph: ObservedModuleGraph,
+): Promise => {
+ if (context.product !== 'application' && context.product !== 'library') {
+ throw new Error('Reachability requires an application or library context.');
+ }
+
+ const roots: ProductRoot[] = [];
+ const bounds: string[] = [];
+ const reportedEntries = graph.modules.filter(({ isEntry }) => isEntry);
+ const entryPathById = new Map(
+ reportedEntries.map(({ id, path: modulePath }) => [id, normalizeModuleSelector(modulePath)]),
+ );
+ const nestedEntryIds = new Set(
+ graph.edges
+ .filter(({ from, to }) => {
+ const fromPath = entryPathById.get(from);
+ return fromPath !== undefined && fromPath === entryPathById.get(to);
+ })
+ .map(({ to }) => to),
+ );
+ const entries = reportedEntries.filter(({ id }) => !nestedEntryIds.has(id));
+ for (const module of entries) {
+ addRoot(roots, {
+ kind: 'production-entry',
+ module: toModuleRef(module),
+ label: `entry: ${module.name}`,
+ });
+ }
+ if (entries.length === 0) bounds.push('no-production-entry-roots');
+
+ let contractTargets: ContractTarget[] = [];
+ if (context.product === 'library') {
+ const targets = await readContractTargets(workspaceRoot, context.packageRoot);
+ if (targets === undefined) {
+ bounds.push('package-manifest-unavailable');
+ } else {
+ const modulesById = new Map(graph.modules.map((module) => [module.id, module]));
+ contractTargets = targets.map(({ field, target }) => ({
+ field,
+ target,
+ matchedModuleIds: graph.modules
+ .filter((module) => matchesTarget(module, target, context.packageRoot))
+ .map(({ id }) => id),
+ }));
+ for (const target of contractTargets) {
+ if (target.matchedModuleIds.length === 0) {
+ bounds.push(`unmapped-contract-target:${target.field}:${target.target}`);
+ continue;
+ }
+ if (target.field === 'types') continue;
+ for (const moduleId of target.matchedModuleIds) {
+ const module = modulesById.get(moduleId)!;
+ addRoot(roots, {
+ kind: 'published-contract',
+ module: toModuleRef(module),
+ label: `package.json ${target.field}: ${target.target}`,
+ });
+ }
+ }
+ }
+ bounds.push('published-library-open-world');
+ }
+
+ for (const module of graph.modules) {
+ if (module.optimizerBound === 'side-effect') {
+ addRoot(roots, {
+ kind: 'side-effect',
+ module: toModuleRef(module),
+ label: `side-effect bailout: ${module.name}`,
+ });
+ }
+ }
+ for (const module of graph.modules) {
+ if (module.optimizerBound !== undefined && module.optimizerBound !== 'side-effect') {
+ addRoot(roots, {
+ kind: 'conservative-runtime',
+ module: toModuleRef(module),
+ label: `${module.optimizerBound} bailout: ${module.name}`,
+ });
+ }
+ }
+
+ if (graph.exportRowsPresent) bounds.push('export-usage-schema-unsupported');
+ bounds.push(...graph.issues);
+
+ return {
+ contextId: context.contextId,
+ packageRoot: context.packageRoot,
+ product: context.product,
+ roots,
+ contractTargets,
+ bounds,
+ };
+};
+
+export { resolveProductRoots, toModuleRef };
diff --git a/src/queries.ts b/src/queries.ts
new file mode 100644
index 0000000..f2637d1
--- /dev/null
+++ b/src/queries.ts
@@ -0,0 +1,640 @@
+import type {
+ AnalysisProvenance,
+ DeadCodeExplanation,
+ ModuleCandidate,
+ ModuleImpactResult,
+ ModulePath,
+ ModuleRef,
+ ModuleState,
+ ObservedModule,
+ ObservedModuleGraph,
+ ProductRoot,
+ ProductRootSet,
+ ProductRootsResult,
+ UnusedCandidatesResult,
+} from './analysisModel.ts';
+import { resolveArtifactProductRoots } from './artifactProducts.ts';
+import { getNonEmptyString, isRecordObject } from './guards.ts';
+import type { ContextDescriptor, ContextSnapshot } from './model.ts';
+import { compareStrings } from './order.ts';
+import { decodeCursor, encodeCursor } from './pagination.ts';
+import { normalizeModuleSelector } from './paths.ts';
+import { toModuleRef } from './products.ts';
+import { traceModuleGraph, type TraversalResult } from './reachability.ts';
+import {
+ readRsdoctorArtifact,
+ type RsdoctorArtifactCompilationIdentity,
+ type RsdoctorArtifactMetadata,
+} from './rsdoctor.ts';
+import { normalizeRsdoctorModuleGraph } from './rsdoctorGraph.ts';
+import { readProjectStatus } from './status.ts';
+
+type ArtifactQuery = {
+ contextId: string;
+ dataFile: string;
+};
+
+type UnusedCandidatesQuery = ArtifactQuery & { limit?: number; cursor?: string };
+type PaginatedUnusedCandidatesResult = UnusedCandidatesResult & { nextCursor?: string };
+type ExplanationQuery = ArtifactQuery & { module: string; maxDepth?: number };
+type ImpactQuery = ExplanationQuery & {
+ direction?: 'dependencies' | 'dependents';
+};
+
+type LoadedAnalysis = {
+ provenance: AnalysisProvenance;
+ graph: ObservedModuleGraph;
+ product: ProductRootSet;
+};
+
+type RootTraversals = {
+ production: TraversalResult;
+ contract: TraversalResult;
+ conservative: TraversalResult;
+};
+
+const candidateTraversalOptions = { maxDepth: 32, maxVisited: 20_000 } as const;
+const explanationVisitLimit = 5_000;
+
+const isDependencyModulePath = (modulePath: string): boolean => {
+ const normalized = modulePath.split('\\').join('/');
+ const segments = normalized.split('/');
+ const yarnIndex = segments.indexOf('.yarn');
+ return (
+ segments.includes('node_modules') ||
+ (yarnIndex >= 0 &&
+ ['cache', '__virtual__', 'unplugged'].includes(segments[yarnIndex + 1] ?? ''))
+ );
+};
+
+const getTargets = (value: unknown): string[] =>
+ (Array.isArray(value) ? value : [value])
+ .filter((entry): entry is string => typeof entry === 'string' && entry.length > 0)
+ .sort(compareStrings);
+
+const artifactTargetsIncludeSnapshot = (
+ artifactTarget: unknown,
+ snapshotTarget: unknown,
+): boolean => {
+ const artifactTargets = new Set(getTargets(artifactTarget));
+ return getTargets(snapshotTarget).every((target) => artifactTargets.has(target));
+};
+
+const bindArtifactToSnapshot = (
+ context: ContextDescriptor,
+ snapshot: ContextSnapshot | undefined,
+ metadata: RsdoctorArtifactMetadata | undefined,
+): AnalysisProvenance['artifactBinding'] => {
+ if (snapshot === undefined || metadata === undefined) return 'explicit-unverified';
+ const build = snapshot.facets.build;
+ if (!isRecordObject(build)) return 'explicit-unverified';
+
+ const snapshotHash = getNonEmptyString(build.hash);
+ const snapshotEnvironment = getNonEmptyString(build.environment) ?? context.environment;
+ if (snapshotHash === undefined || snapshotEnvironment === undefined) {
+ return 'explicit-unverified';
+ }
+ if (context.environment !== undefined && context.environment !== snapshotEnvironment) {
+ return 'mismatch';
+ }
+
+ let identity: RsdoctorArtifactCompilationIdentity;
+ let artifactEnvironment: string | undefined;
+ if (metadata.build.compilers !== undefined) {
+ const primaryCompilers = metadata.build.compilers.filter(
+ (compiler) => compiler.name === metadata.build.compiler.name,
+ );
+ if (primaryCompilers.length !== 1) return 'mismatch';
+ const primaryCompiler = primaryCompilers[0]!;
+ identity = primaryCompiler;
+ artifactEnvironment = primaryCompiler.environment ?? primaryCompiler.name;
+ } else {
+ identity = metadata.build;
+ artifactEnvironment =
+ identity.environment ??
+ (metadata.build.compiler.name === snapshotEnvironment
+ ? metadata.build.compiler.name
+ : undefined);
+ }
+
+ if (identity.compilationHash !== undefined && identity.compilationHash !== snapshotHash) {
+ return 'mismatch';
+ }
+ if (identity.environment !== undefined && identity.environment !== snapshotEnvironment) {
+ return 'mismatch';
+ }
+ const snapshotTarget = getTargets(build.target).length > 0 ? build.target : context.target;
+ if (
+ identity.target !== undefined &&
+ getTargets(snapshotTarget).length > 0 &&
+ !artifactTargetsIncludeSnapshot(identity.target, snapshotTarget)
+ ) {
+ return 'mismatch';
+ }
+ if (identity.compilationHash === undefined || artifactEnvironment === undefined) {
+ return 'explicit-unverified';
+ }
+ return artifactEnvironment === snapshotEnvironment ? 'exact' : 'mismatch';
+};
+
+const toSubject = (module: ObservedModule): ModuleCandidate['subject'] => ({
+ kind: 'module',
+ ...toModuleRef(module),
+});
+
+const validateLimit = (limit: number | undefined): number => {
+ const resolved = limit ?? 50;
+ if (!Number.isInteger(resolved) || resolved < 1 || resolved > 100) {
+ throw new Error('limit must be an integer from 1 to 100.');
+ }
+ return resolved;
+};
+
+const validateMaxDepth = (maxDepth: number | undefined, maximum = 16, fallback = 8): number => {
+ const resolved = maxDepth ?? fallback;
+ if (!Number.isInteger(resolved) || resolved < 1 || resolved > maximum) {
+ throw new Error(`maxDepth must be an integer from 1 to ${maximum}.`);
+ }
+ return resolved;
+};
+
+const selectContext = async (
+ workspaceRoot: string,
+ query: ArtifactQuery,
+): Promise<{
+ context: ContextDescriptor;
+ snapshot: ContextSnapshot | undefined;
+ provenance: AnalysisProvenance;
+}> => {
+ const status = await readProjectStatus(workspaceRoot);
+ const matches = status.contexts.filter(({ context }) => context.contextId === query.contextId);
+ if (matches.length === 0) throw new Error(`Unknown context: ${query.contextId}`);
+
+ const ready = matches
+ .filter((entry) => entry.latestSnapshot !== undefined)
+ .sort(
+ (left, right) =>
+ compareStrings(left.latestSnapshot!.observedAt, right.latestSnapshot!.observedAt) ||
+ compareStrings(left.runId, right.runId),
+ );
+ const selected =
+ ready.at(-1) ?? [...matches].sort((a, b) => compareStrings(a.runId, b.runId)).at(-1)!;
+ const snapshot = ready.at(-1)?.latestSnapshot;
+ const buildCompleteness = snapshot?.completeness.build;
+
+ return {
+ context: selected.context,
+ snapshot,
+ provenance: {
+ contextId: query.contextId,
+ dataFile: query.dataFile,
+ artifactBinding: 'explicit-unverified',
+ ...(snapshot === undefined
+ ? {}
+ : {
+ buildObservation: {
+ runId: snapshot.runId,
+ snapshotId: snapshot.snapshotId,
+ observedAt: snapshot.observedAt,
+ status: snapshot.status,
+ ...(buildCompleteness === undefined ? {} : { buildCompleteness }),
+ },
+ }),
+ },
+ };
+};
+
+const loadAnalysis = async (
+ workspaceRoot: string,
+ query: ArtifactQuery,
+): Promise => {
+ const { context, snapshot, provenance } = await selectContext(workspaceRoot, query);
+ const artifact = await readRsdoctorArtifact(workspaceRoot, query.dataFile);
+ const artifactBinding = bindArtifactToSnapshot(context, snapshot, artifact.metadata);
+ const observedGraph = normalizeRsdoctorModuleGraph(artifact);
+ const graph =
+ artifactBinding === 'mismatch'
+ ? {
+ ...observedGraph,
+ issues: [...new Set([...observedGraph.issues, 'artifact-build-mismatch' as const])],
+ }
+ : observedGraph;
+ const graphForProducts =
+ artifactBinding === 'mismatch'
+ ? {
+ modules: [],
+ edges: [],
+ exportRowsPresent: false,
+ issues: graph.issues,
+ }
+ : graph;
+ const product = await resolveArtifactProductRoots(workspaceRoot, context, graphForProducts);
+ return { provenance: { ...provenance, artifactBinding }, graph, product };
+};
+
+const hasAuthoritativeGraph = (graph: ObservedModuleGraph): boolean =>
+ !graph.issues.some((issue) =>
+ ['artifact-build-mismatch', 'module-graph-missing', 'module-graph-omitted'].includes(issue),
+ );
+
+const unavailableGraphEvidence = (graph: ObservedModuleGraph): string =>
+ graph.issues.includes('artifact-build-mismatch')
+ ? 'The artifact graph does not match the selected build snapshot.'
+ : 'The artifact does not contain an available module graph.';
+
+const rootsOfKind = (
+ product: ProductRootSet,
+ family: 'production' | 'contract' | 'conservative',
+): ProductRoot[] =>
+ product.roots.filter(({ kind }) =>
+ family === 'production'
+ ? kind === 'production-entry'
+ : family === 'contract'
+ ? kind === 'published-contract'
+ : kind === 'side-effect' || kind === 'conservative-runtime',
+ );
+
+const traceRootFamilies = (
+ graph: ObservedModuleGraph,
+ product: ProductRootSet,
+ maxDepth: number,
+ maxVisited: number,
+): RootTraversals => ({
+ production: traceModuleGraph(
+ graph,
+ rootsOfKind(product, 'production').map(({ module }) => module.id),
+ 'dependencies',
+ { maxDepth, maxVisited },
+ ),
+ contract: traceModuleGraph(
+ graph,
+ rootsOfKind(product, 'contract').map(({ module }) => module.id),
+ 'dependencies',
+ { maxDepth, maxVisited },
+ ),
+ conservative: traceModuleGraph(
+ graph,
+ rootsOfKind(product, 'conservative').map(({ module }) => module.id),
+ 'dependencies',
+ { maxDepth, maxVisited },
+ ),
+});
+
+const traversalBounds = (product: ProductRootSet, traversals: RootTraversals): string[] => {
+ const bounds = [...product.bounds];
+ if (traversals.production.truncated) bounds.push('production-traversal-truncated');
+ if (traversals.contract.truncated) bounds.push('contract-traversal-truncated');
+ if (traversals.conservative.truncated) bounds.push('conservative-traversal-truncated');
+ return bounds;
+};
+
+const moduleState = (
+ module: ObservedModule,
+ product: ProductRootSet,
+ traversals: RootTraversals,
+): ModuleState => {
+ const productionLive = traversals.production.predecessor.has(module.id);
+ const contractRequired = traversals.contract.predecessor.has(module.id);
+ return {
+ productionReachability: productionLive
+ ? 'live'
+ : traversals.production.truncated || product.bounds.includes('no-production-entry-roots')
+ ? 'unknown'
+ : 'unreachable',
+ publicContract:
+ product.product === 'application'
+ ? 'not-required'
+ : contractRequired
+ ? 'required'
+ : 'unknown',
+ shipped: module.chunks.length > 0 ? 'yes' : 'unknown',
+ optimizerRetention:
+ module.optimizerBound === 'side-effect'
+ ? 'side-effect'
+ : module.optimizerBound === undefined
+ ? 'unknown'
+ : 'bailout',
+ };
+};
+
+const isCandidate = (
+ moduleId: string,
+ product: ProductRootSet,
+ traversals: RootTraversals,
+): boolean =>
+ !product.bounds.includes('no-production-entry-roots') &&
+ !traversals.production.truncated &&
+ !traversals.contract.truncated &&
+ !traversals.conservative.truncated &&
+ !traversals.production.predecessor.has(moduleId) &&
+ !traversals.contract.predecessor.has(moduleId) &&
+ !traversals.conservative.predecessor.has(moduleId);
+
+const resolveModule = (graph: ObservedModuleGraph, selector: string): ObservedModule => {
+ const ambiguous = (matches: ObservedModule[]): never => {
+ const returned = matches.slice(0, 10);
+ const details = returned.map(({ id, path: modulePath }) => `${id} (${modulePath})`).join(', ');
+ const remainder = matches.length - returned.length;
+ throw new Error(
+ `Ambiguous module selector: ${selector}. Matches: ${details}${remainder === 0 ? '' : `, and ${remainder} more`}.`,
+ );
+ };
+ const byId = graph.modules.find(({ id }) => id === selector);
+ if (byId !== undefined) return byId;
+
+ const normalized = normalizeModuleSelector(selector);
+ const exact = graph.modules.filter(
+ (module) =>
+ normalizeModuleSelector(module.path) === normalized ||
+ normalizeModuleSelector(module.name) === normalized,
+ );
+ if (exact.length === 1) return exact[0];
+ if (exact.length > 1) return ambiguous(exact);
+
+ const suffix = graph.modules.filter(({ path: modulePath }) => {
+ const normalizedPath = normalizeModuleSelector(modulePath);
+ return normalizedPath === normalized || normalizedPath.endsWith(`/${normalized}`);
+ });
+ if (suffix.length === 1) return suffix[0];
+ if (suffix.length > 1) return ambiguous(suffix);
+ throw new Error(`Unknown module selector: ${selector}`);
+};
+
+const reconstructPath = (
+ graph: ObservedModuleGraph,
+ traversal: TraversalResult,
+ moduleId: string,
+): ModuleRef[] => {
+ const moduleById = new Map(graph.modules.map((module) => [module.id, module]));
+ const ids: string[] = [];
+ let current: string | undefined = moduleId;
+ while (current !== undefined) {
+ ids.push(current);
+ current = traversal.predecessor.get(current);
+ }
+ return ids.reverse().map((id) => toModuleRef(moduleById.get(id)!));
+};
+
+const shortestRootPath = (
+ graph: ObservedModuleGraph,
+ roots: ProductRoot[],
+ traversal: TraversalResult,
+ moduleId: string,
+): ModulePath | undefined => {
+ if (!traversal.predecessor.has(moduleId)) return undefined;
+ const modules = reconstructPath(graph, traversal, moduleId);
+ const root = roots.find(({ module }) => module.id === modules[0].id);
+ return root === undefined ? undefined : { rootKind: root.kind, modules };
+};
+
+const productRootsFromAnalysis = ({
+ provenance,
+ graph,
+ product,
+}: LoadedAnalysis): ProductRootsResult => ({
+ provenance,
+ graph: {
+ moduleCount: graph.modules.length,
+ edgeCount: graph.edges.length,
+ issues: graph.issues,
+ },
+ product,
+});
+
+const readProductRoots = async (
+ workspaceRoot: string,
+ query: ArtifactQuery,
+): Promise =>
+ productRootsFromAnalysis(await loadAnalysis(workspaceRoot, query));
+
+const findUnusedCandidates = async (
+ workspaceRoot: string,
+ query: UnusedCandidatesQuery,
+): Promise => {
+ const limit = validateLimit(query.limit);
+ const offset = decodeCursor(query.cursor, 'Invalid unused candidates cursor.');
+ const { provenance, graph, product } = await loadAnalysis(workspaceRoot, query);
+ if (!hasAuthoritativeGraph(graph)) {
+ return {
+ provenance,
+ roots: { production: 0, contract: 0, conservative: 0 },
+ total: 0,
+ returned: 0,
+ ownership: { project: 0, dependency: 0 },
+ analysisTruncated: false,
+ resultTruncated: false,
+ candidates: [],
+ bounds: product.bounds,
+ };
+ }
+ const traversals = traceRootFamilies(
+ graph,
+ product,
+ candidateTraversalOptions.maxDepth,
+ candidateTraversalOptions.maxVisited,
+ );
+ const bounds = traversalBounds(product, traversals);
+ const candidates = graph.modules
+ .filter(({ id }) => isCandidate(id, product, traversals))
+ .map((module): ModuleCandidate => ({
+ subject: toSubject(module),
+ classification: 'unreachable-module-candidate',
+ state: moduleState(module, product, traversals),
+ confidence: 'derived',
+ evidence: ['No path from selected roots in this artifact graph.'],
+ bounds,
+ }))
+ .sort(
+ (left, right) =>
+ Number(isDependencyModulePath(left.subject.path)) -
+ Number(isDependencyModulePath(right.subject.path)) ||
+ compareStrings(left.subject.path, right.subject.path) ||
+ compareStrings(left.subject.id, right.subject.id),
+ );
+ const dependencyCandidates = candidates.filter(({ subject }) =>
+ isDependencyModulePath(subject.path),
+ ).length;
+ const returnedCandidates = candidates.slice(offset, offset + limit);
+ const nextOffset = offset + returnedCandidates.length;
+
+ return {
+ provenance,
+ roots: {
+ production: rootsOfKind(product, 'production').length,
+ contract: rootsOfKind(product, 'contract').length,
+ conservative: rootsOfKind(product, 'conservative').length,
+ },
+ total: candidates.length,
+ returned: returnedCandidates.length,
+ ownership: {
+ project: candidates.length - dependencyCandidates,
+ dependency: dependencyCandidates,
+ },
+ analysisTruncated:
+ traversals.production.truncated ||
+ traversals.contract.truncated ||
+ traversals.conservative.truncated,
+ resultTruncated: nextOffset < candidates.length,
+ candidates: returnedCandidates,
+ ...(nextOffset < candidates.length ? { nextCursor: encodeCursor(nextOffset) } : {}),
+ bounds,
+ };
+};
+
+const explainAnalysisModule = (
+ { provenance, graph, product }: LoadedAnalysis,
+ query: Pick,
+): DeadCodeExplanation => {
+ const maxDepth = validateMaxDepth(
+ query.maxDepth,
+ candidateTraversalOptions.maxDepth,
+ candidateTraversalOptions.maxDepth,
+ );
+ if (!hasAuthoritativeGraph(graph)) {
+ return {
+ provenance,
+ classification: 'insufficient-evidence',
+ state: {
+ productionReachability: 'unknown',
+ publicContract: 'unknown',
+ shipped: 'unknown',
+ optimizerRetention: 'unknown',
+ },
+ paths: [],
+ evidence: [unavailableGraphEvidence(graph)],
+ analysisTruncated: false,
+ bounds: product.bounds,
+ };
+ }
+ const module = resolveModule(graph, query.module);
+ const traversals = traceRootFamilies(
+ graph,
+ product,
+ maxDepth,
+ candidateTraversalOptions.maxVisited,
+ );
+ const bounds = traversalBounds(product, traversals);
+ const productionPath = shortestRootPath(
+ graph,
+ rootsOfKind(product, 'production'),
+ traversals.production,
+ module.id,
+ );
+ const contractPath = shortestRootPath(
+ graph,
+ rootsOfKind(product, 'contract'),
+ traversals.contract,
+ module.id,
+ );
+ const conservativePath = shortestRootPath(
+ graph,
+ rootsOfKind(product, 'conservative'),
+ traversals.conservative,
+ module.id,
+ );
+
+ let classification: DeadCodeExplanation['classification'];
+ let paths: ModulePath[];
+ let evidence: string[];
+ if (productionPath !== undefined || contractPath !== undefined) {
+ classification = 'reachable';
+ paths = [productionPath, contractPath].filter((entry) => entry !== undefined);
+ evidence = ['A shortest path from a selected product root exists in this artifact graph.'];
+ } else if (conservativePath !== undefined) {
+ classification = 'preserved-by-conservative-root';
+ paths = [conservativePath];
+ evidence = [
+ 'The module is reachable from a conservative optimizer root in this artifact graph.',
+ ];
+ } else if (isCandidate(module.id, product, traversals)) {
+ classification = 'unreachable-module-candidate';
+ paths = [];
+ evidence = ['No path from selected roots in this artifact graph.'];
+ } else {
+ classification = 'insufficient-evidence';
+ paths = [];
+ evidence = [
+ product.bounds.includes('no-production-entry-roots')
+ ? 'No production entry roots were observed in this artifact graph.'
+ : 'Traversal bounds prevented a complete reachability result for this module.',
+ ];
+ }
+ evidence.push(
+ ...(module.optimizerReasons ?? []).map((reason) => `Rsdoctor optimizer: ${reason}`),
+ );
+
+ return {
+ provenance,
+ subject: toSubject(module),
+ classification,
+ state: moduleState(module, product, traversals),
+ paths,
+ evidence,
+ analysisTruncated:
+ traversals.production.truncated ||
+ traversals.contract.truncated ||
+ traversals.conservative.truncated,
+ bounds,
+ };
+};
+
+const explainDeadCodeCandidate = async (
+ workspaceRoot: string,
+ query: ExplanationQuery,
+): Promise =>
+ explainAnalysisModule(await loadAnalysis(workspaceRoot, query), query);
+
+const traceModuleImpact = async (
+ workspaceRoot: string,
+ query: ImpactQuery,
+): Promise => {
+ const maxDepth = validateMaxDepth(query.maxDepth);
+ const direction = query.direction ?? 'dependents';
+ const { provenance, graph, product } = await loadAnalysis(workspaceRoot, query);
+ if (!hasAuthoritativeGraph(graph)) {
+ return {
+ provenance,
+ direction,
+ modules: [],
+ reachedRoots: [],
+ affectedChunks: [],
+ totalVisited: 0,
+ returned: 0,
+ truncated: false,
+ bounds: product.bounds,
+ };
+ }
+ const module = resolveModule(graph, query.module);
+ const traversal = traceModuleGraph(graph, [module.id], direction, {
+ maxDepth,
+ maxVisited: explanationVisitLimit,
+ });
+ const moduleById = new Map(graph.modules.map((entry) => [entry.id, entry]));
+ const modules = traversal.visited.map((id) => toModuleRef(moduleById.get(id)!));
+ const visited = new Set(traversal.visited);
+ const reachedRoots = product.roots.filter(({ module: root }) => visited.has(root.id));
+ const affectedChunks = [...new Set(modules.flatMap(({ chunks }) => chunks))].sort(compareStrings);
+
+ return {
+ provenance,
+ subject: toSubject(module),
+ direction,
+ modules,
+ reachedRoots,
+ affectedChunks,
+ totalVisited: traversal.visited.length,
+ returned: modules.length,
+ truncated: traversal.truncated,
+ bounds: [...product.bounds, ...(traversal.truncated ? ['impact-traversal-truncated'] : [])],
+ };
+};
+
+export {
+ explainAnalysisModule,
+ explainDeadCodeCandidate,
+ findUnusedCandidates,
+ loadAnalysis,
+ readProductRoots,
+ traceModuleImpact,
+};
+export type { ArtifactQuery, ExplanationQuery, ImpactQuery, LoadedAnalysis, UnusedCandidatesQuery };
diff --git a/src/reachability.ts b/src/reachability.ts
new file mode 100644
index 0000000..7e31327
--- /dev/null
+++ b/src/reachability.ts
@@ -0,0 +1,81 @@
+import type { ObservedModule, ObservedModuleGraph } from './analysisModel.ts';
+import { compareStrings } from './order.ts';
+
+type TraversalOptions = {
+ maxDepth: number;
+ maxVisited: number;
+};
+
+type TraversalResult = {
+ visited: string[];
+ predecessor: ReadonlyMap;
+ depth: ReadonlyMap;
+ truncated: boolean;
+};
+
+const compareModules = (left: ObservedModule, right: ObservedModule): number =>
+ compareStrings(left.path, right.path) ||
+ compareStrings(left.name, right.name) ||
+ compareStrings(left.id, right.id);
+
+const traceModuleGraph = (
+ graph: ObservedModuleGraph,
+ roots: string[],
+ direction: 'dependencies' | 'dependents',
+ options: TraversalOptions,
+): TraversalResult => {
+ const modules = new Map(graph.modules.map((module) => [module.id, module]));
+ const adjacency = new Map>();
+ for (const edge of graph.edges) {
+ const from = direction === 'dependencies' ? edge.from : edge.to;
+ const to = direction === 'dependencies' ? edge.to : edge.from;
+ if (!modules.has(from) || !modules.has(to)) continue;
+ const neighbors = adjacency.get(from) ?? new Set();
+ neighbors.add(to);
+ adjacency.set(from, neighbors);
+ }
+
+ const orderedNeighbors = new Map(
+ [...adjacency].map(([moduleId, neighbors]) => [
+ moduleId,
+ [...neighbors].sort((left, right) => compareModules(modules.get(left)!, modules.get(right)!)),
+ ]),
+ );
+ const orderedRoots = [...new Set(roots)]
+ .filter((moduleId) => modules.has(moduleId))
+ .sort((left, right) => compareModules(modules.get(left)!, modules.get(right)!));
+ const visited: string[] = [];
+ const predecessor = new Map();
+ const depth = new Map();
+ let truncated = false;
+
+ for (const root of orderedRoots) {
+ if (visited.length >= options.maxVisited) {
+ truncated = true;
+ break;
+ }
+ visited.push(root);
+ predecessor.set(root, undefined);
+ depth.set(root, 0);
+ }
+
+ for (let index = 0; index < visited.length; index += 1) {
+ const moduleId = visited[index];
+ const moduleDepth = depth.get(moduleId)!;
+ for (const neighbor of orderedNeighbors.get(moduleId) ?? []) {
+ if (predecessor.has(neighbor)) continue;
+ if (moduleDepth >= options.maxDepth || visited.length >= options.maxVisited) {
+ truncated = true;
+ continue;
+ }
+ visited.push(neighbor);
+ predecessor.set(neighbor, moduleId);
+ depth.set(neighbor, moduleDepth + 1);
+ }
+ }
+
+ return { visited, predecessor, depth, truncated };
+};
+
+export { compareModules, traceModuleGraph };
+export type { TraversalOptions, TraversalResult };
diff --git a/src/records.ts b/src/records.ts
new file mode 100644
index 0000000..99f5a98
--- /dev/null
+++ b/src/records.ts
@@ -0,0 +1,370 @@
+import {
+ contextStoreSchemaVersion,
+ type ContextCompleteness,
+ type ContextDescriptor,
+ type ContextProducer,
+ type ContextRunManifest,
+ type ContextRunStatus,
+ type ContextSnapshot,
+ type LintFacet,
+ type TestFacet,
+} from './model.ts';
+import { validateExecutionFacet } from './execution.ts';
+import {
+ isIdentifier,
+ isNonNegativeInteger,
+ isPositiveInteger,
+ isRecordObject,
+ sha256Pattern,
+} from './guards.ts';
+import { compareStringsDescending } from './order.ts';
+
+const producers = new Set([
+ 'rsbuild',
+ 'rspack',
+ 'rslib',
+ 'rstest',
+ 'rslint',
+ 'rsdoctor',
+]);
+const statuses = new Set([
+ 'queued',
+ 'running',
+ 'pass',
+ 'fail',
+ 'cancelled',
+ 'error',
+]);
+const completenessValues = new Set([
+ 'complete',
+ 'partial',
+ 'disabled',
+ 'unsupported',
+]);
+const testStatuses = new Set(['skip', 'pass', 'fail', 'todo']);
+
+const isRecordPath = (value: unknown): value is string =>
+ typeof value === 'string' && value.length > 0;
+
+const isNonNegativeNumber = (value: unknown): value is number =>
+ typeof value === 'number' && Number.isFinite(value) && value >= 0;
+
+const isOptionalString = (value: unknown): boolean =>
+ value === undefined || typeof value === 'string';
+
+const isStringRecord = (value: unknown): value is Record =>
+ isRecordObject(value) && Object.values(value).every((entry) => typeof entry === 'string');
+
+const isFix = (value: unknown): boolean =>
+ isRecordObject(value) &&
+ Array.isArray(value.range) &&
+ value.range.length === 2 &&
+ value.range.every(isNonNegativeInteger) &&
+ typeof value.text === 'string';
+
+const isLintMessage = (value: unknown): boolean =>
+ isRecordObject(value) &&
+ (value.ruleId === null || typeof value.ruleId === 'string') &&
+ (value.severity === 1 || value.severity === 2) &&
+ typeof value.message === 'string' &&
+ isOptionalString(value.messageId) &&
+ isPositiveInteger(value.line) &&
+ isPositiveInteger(value.column) &&
+ (value.endLine === undefined || isPositiveInteger(value.endLine)) &&
+ (value.endColumn === undefined || isPositiveInteger(value.endColumn)) &&
+ (value.fix === undefined || isFix(value.fix)) &&
+ (value.suggestions === undefined ||
+ (Array.isArray(value.suggestions) &&
+ value.suggestions.every(
+ (suggestion) =>
+ isRecordObject(suggestion) &&
+ isOptionalString(suggestion.messageId) &&
+ (suggestion.data === undefined || isStringRecord(suggestion.data)) &&
+ typeof suggestion.desc === 'string' &&
+ isFix(suggestion.fix),
+ )));
+
+const isLintFile = (value: unknown): boolean =>
+ isRecordObject(value) &&
+ isRecordPath(value.path) &&
+ typeof value.digest === 'string' &&
+ sha256Pattern.test(value.digest) &&
+ isNonNegativeInteger(value.errorCount) &&
+ isNonNegativeInteger(value.warningCount) &&
+ isNonNegativeInteger(value.fixableErrorCount) &&
+ isNonNegativeInteger(value.fixableWarningCount) &&
+ Array.isArray(value.messages) &&
+ value.messages.every(isLintMessage) &&
+ isOptionalString(value.fixedOutput);
+
+const validateLintFacet = (value: unknown): LintFacet | undefined => {
+ if (
+ !isRecordObject(value) ||
+ value.producer !== 'rslint' ||
+ (value.mode !== 'files' && value.mode !== 'text') ||
+ typeof value.fixPreviewCaptured !== 'boolean' ||
+ !Array.isArray(value.files) ||
+ !value.files.every(isLintFile) ||
+ !isRecordObject(value.totals) ||
+ !isNonNegativeInteger(value.totals.files) ||
+ !isNonNegativeInteger(value.totals.errors) ||
+ !isNonNegativeInteger(value.totals.warnings) ||
+ !isNonNegativeInteger(value.totals.fixableErrors) ||
+ !isNonNegativeInteger(value.totals.fixableWarnings)
+ ) {
+ return undefined;
+ }
+ return value as LintFacet;
+};
+
+const isJsonValue = (value: unknown): boolean => {
+ if (
+ value === null ||
+ typeof value === 'boolean' ||
+ typeof value === 'string' ||
+ (typeof value === 'number' && Number.isFinite(value))
+ ) {
+ return true;
+ }
+ if (Array.isArray(value)) return value.every(isJsonValue);
+ return isRecordObject(value) && Object.values(value).every(isJsonValue);
+};
+
+// Mirrors the capture-side truncation in normalizeError: a stored cause chain deeper than this
+// was not produced by this writer, so it is rejected rather than walked without bound.
+const maxTestErrorCauseDepth = 8;
+
+const isTestError = (value: unknown, depth = 0): boolean =>
+ isRecordObject(value) &&
+ typeof value.name === 'string' &&
+ typeof value.message === 'string' &&
+ isOptionalString(value.stack) &&
+ isOptionalString(value.diff) &&
+ isOptionalString(value.actual) &&
+ isOptionalString(value.expected) &&
+ (value.retryCount === undefined || isNonNegativeInteger(value.retryCount)) &&
+ (value.cause === undefined ||
+ (depth < maxTestErrorCauseDepth && isTestError(value.cause, depth + 1)));
+
+const areTestErrors = (value: unknown): boolean =>
+ Array.isArray(value) && value.every((entry) => isTestError(entry));
+
+const isTestMeta = (value: unknown): boolean =>
+ isRecordObject(value) && Object.values(value).every(isJsonValue);
+
+const isTestCase = (value: unknown): boolean =>
+ isRecordObject(value) &&
+ typeof value.project === 'string' &&
+ isRecordPath(value.path) &&
+ typeof value.name === 'string' &&
+ (value.parentNames === undefined ||
+ (Array.isArray(value.parentNames) &&
+ value.parentNames.every((entry) => typeof entry === 'string'))) &&
+ testStatuses.has(value.status as string) &&
+ (value.durationMs === undefined || isNonNegativeNumber(value.durationMs)) &&
+ (value.errors === undefined || areTestErrors(value.errors)) &&
+ (value.retryErrors === undefined || areTestErrors(value.retryErrors)) &&
+ (value.retryCount === undefined || isNonNegativeInteger(value.retryCount)) &&
+ (value.meta === undefined || isTestMeta(value.meta));
+
+const isTestFile = (value: unknown): boolean =>
+ isRecordObject(value) &&
+ typeof value.project === 'string' &&
+ isRecordPath(value.path) &&
+ testStatuses.has(value.status as string) &&
+ (value.durationMs === undefined || isNonNegativeNumber(value.durationMs)) &&
+ (value.errors === undefined || areTestErrors(value.errors)) &&
+ Array.isArray(value.tests) &&
+ value.tests.every(isTestCase);
+
+const isTestRelation = (value: unknown): boolean =>
+ isRecordObject(value) &&
+ Array.isArray(value.sources) &&
+ value.sources.every(isRecordPath) &&
+ Array.isArray(value.testFiles) &&
+ value.testFiles.every(isRecordPath);
+
+const validateTestFacet = (value: unknown): TestFacet | undefined => {
+ if (
+ !isRecordObject(value) ||
+ value.producer !== 'rstest' ||
+ (value.relation !== undefined && !isTestRelation(value.relation)) ||
+ !Array.isArray(value.files) ||
+ !value.files.every(isTestFile) ||
+ !isRecordObject(value.stats) ||
+ !isRecordObject(value.stats.tests) ||
+ !isNonNegativeInteger(value.stats.tests.total) ||
+ !isNonNegativeInteger(value.stats.tests.passed) ||
+ !isNonNegativeInteger(value.stats.tests.failed) ||
+ !isNonNegativeInteger(value.stats.tests.skipped) ||
+ !isNonNegativeInteger(value.stats.tests.todo) ||
+ !isRecordObject(value.stats.files) ||
+ !isNonNegativeInteger(value.stats.files.total) ||
+ !isNonNegativeInteger(value.stats.files.failed) ||
+ !isNonNegativeNumber(value.durationMs) ||
+ !areTestErrors(value.unhandledErrors)
+ ) {
+ return undefined;
+ }
+ return value as TestFacet;
+};
+
+const isSnapshotSource = (value: unknown): boolean => {
+ if (!isRecordObject(value)) return false;
+ const hasInputs = value.inputs !== undefined;
+ const hasCompleteness = value.inputCompleteness !== undefined;
+ return (
+ isOptionalString(value.revision) &&
+ isOptionalString(value.dirtyDigest) &&
+ hasInputs === hasCompleteness &&
+ (!hasInputs ||
+ (Array.isArray(value.inputs) &&
+ value.inputs.every(
+ (input) =>
+ isRecordObject(input) &&
+ isRecordPath(input.path) &&
+ typeof input.digest === 'string' &&
+ sha256Pattern.test(input.digest),
+ ) &&
+ (value.inputCompleteness === 'complete' || value.inputCompleteness === 'partial'))) &&
+ (value.unreadableInputs === undefined ||
+ (value.inputCompleteness === 'partial' &&
+ Array.isArray(value.unreadableInputs) &&
+ value.unreadableInputs.every(isRecordPath))) &&
+ (value.virtualInputDigest === undefined ||
+ (typeof value.virtualInputDigest === 'string' &&
+ sha256Pattern.test(value.virtualInputDigest))) &&
+ (value.captureSelection === undefined || isJsonValue(value.captureSelection))
+ );
+};
+
+const areFacetsValid = (value: Record): boolean =>
+ Object.entries(value).every(([name, facet]) => {
+ const producer = isRecordObject(facet) ? facet.producer : undefined;
+ if (name === 'execution') {
+ return validateExecutionFacet(facet) !== undefined;
+ }
+ if (name === 'lint' || name === 'rslint' || producer === 'rslint') {
+ return validateLintFacet(facet) !== undefined;
+ }
+ if (name === 'test' || name === 'rstest' || producer === 'rstest') {
+ return validateTestFacet(facet) !== undefined;
+ }
+ return isJsonValue(facet);
+ });
+
+const isContextDescriptor = (value: unknown): value is ContextDescriptor =>
+ isRecordObject(value) &&
+ isIdentifier(value.contextId) &&
+ isRecordPath(value.packageRoot) &&
+ typeof value.product === 'string' &&
+ value.product.length > 0 &&
+ (value.packageName === undefined || typeof value.packageName === 'string') &&
+ (value.configPath === undefined || isRecordPath(value.configPath)) &&
+ (value.environment === undefined || typeof value.environment === 'string') &&
+ (value.target === undefined || typeof value.target === 'string') &&
+ (value.mode === undefined || typeof value.mode === 'string');
+
+const validateRunManifest = (value: unknown): ContextRunManifest | undefined => {
+ if (
+ !isRecordObject(value) ||
+ value.schemaVersion !== contextStoreSchemaVersion ||
+ !isIdentifier(value.runId) ||
+ !producers.has(value.producer as ContextProducer) ||
+ typeof value.command !== 'string' ||
+ typeof value.startedAt !== 'string' ||
+ !Array.isArray(value.contexts) ||
+ value.contexts.length === 0 ||
+ !value.contexts.every(isContextDescriptor)
+ ) {
+ return undefined;
+ }
+
+ const contextIds = new Set(value.contexts.map((context) => context.contextId));
+ return contextIds.size === value.contexts.length ? (value as ContextRunManifest) : undefined;
+};
+
+const isCompleteness = (value: unknown): value is Record =>
+ isRecordObject(value) &&
+ Object.values(value).every((entry) => completenessValues.has(entry as ContextCompleteness));
+
+const validateSnapshot = (value: unknown): ContextSnapshot | undefined =>
+ isRecordObject(value) &&
+ value.schemaVersion === contextStoreSchemaVersion &&
+ isIdentifier(value.snapshotId) &&
+ isIdentifier(value.runId) &&
+ isIdentifier(value.contextId) &&
+ Number.isSafeInteger(value.sequence) &&
+ (value.sequence as number) >= 0 &&
+ typeof value.observedAt === 'string' &&
+ statuses.has(value.status as ContextRunStatus) &&
+ isCompleteness(value.completeness) &&
+ isRecordObject(value.facets) &&
+ areFacetsValid(value.facets) &&
+ (value.source === undefined || isSnapshotSource(value.source))
+ ? (value as ContextSnapshot)
+ : undefined;
+
+const getContextSnapshotGenerationFileName = (
+ snapshot: Pick,
+): string => `${snapshot.sequence.toString().padStart(10, '0')}-${snapshot.snapshotId}.json`;
+
+const parseContextSnapshotGenerationFileName = (
+ fileName: string,
+): Pick | undefined => {
+ const separatorIndex = fileName.indexOf('-');
+ if (separatorIndex < 1 || !fileName.endsWith('.json')) {
+ return undefined;
+ }
+
+ const sequenceText = fileName.slice(0, separatorIndex);
+ const sequence = Number(sequenceText);
+ const snapshotId = fileName.slice(separatorIndex + 1, -'.json'.length);
+ if (
+ !Number.isSafeInteger(sequence) ||
+ sequence < 0 ||
+ sequenceText !== sequence.toString().padStart(10, '0') ||
+ snapshotId.length === 0
+ ) {
+ return undefined;
+ }
+ return { sequence, snapshotId };
+};
+
+const compareContextSnapshotGenerationFileNames = (left: string, right: string): number => {
+ const leftGeneration = parseContextSnapshotGenerationFileName(left);
+ const rightGeneration = parseContextSnapshotGenerationFileName(right);
+ if (leftGeneration === undefined || rightGeneration === undefined) {
+ if (leftGeneration !== undefined) {
+ return -1;
+ }
+ if (rightGeneration !== undefined) {
+ return 1;
+ }
+ return compareStringsDescending(left, right);
+ }
+ if (leftGeneration.sequence !== rightGeneration.sequence) {
+ return leftGeneration.sequence > rightGeneration.sequence ? -1 : 1;
+ }
+ return (
+ compareStringsDescending(leftGeneration.snapshotId, rightGeneration.snapshotId) ||
+ compareStringsDescending(left, right)
+ );
+};
+
+const isContextSnapshotGenerationFileName = (
+ fileName: string,
+ snapshot: Pick,
+): boolean => fileName === getContextSnapshotGenerationFileName(snapshot);
+
+export {
+ compareContextSnapshotGenerationFileNames,
+ getContextSnapshotGenerationFileName,
+ isContextSnapshotGenerationFileName,
+ isRecordObject,
+ validateRunManifest,
+ validateSnapshot,
+ validateExecutionFacet,
+ validateLintFacet,
+ validateTestFacet,
+};
diff --git a/src/report.ts b/src/report.ts
new file mode 100644
index 0000000..c864243
--- /dev/null
+++ b/src/report.ts
@@ -0,0 +1,158 @@
+import { readdir, stat } from 'node:fs/promises';
+import path from 'node:path';
+import { pathToFileURL } from 'node:url';
+import { resolveRsdoctorDataFile } from './rsdoctor.ts';
+
+type ReportFileResult =
+ | { kind: 'missing' }
+ | {
+ kind: 'file';
+ path: string;
+ uri: string;
+ };
+
+type RsdoctorReport = {
+ kind: 'html' | 'manifest';
+ path: string;
+ uri: string;
+};
+
+type RsdoctorAnalyzeNextAction = {
+ arguments: {
+ dataFile: string;
+ input: Record;
+ toolName: 'build_summary';
+ };
+ tool: 'rsdoctor_analyze';
+};
+
+type RsdoctorReportResult =
+ | {
+ dataFile: string;
+ report: RsdoctorReport;
+ }
+ | {
+ dataFile: string;
+ nextAction: RsdoctorAnalyzeNextAction;
+ reason: string;
+ };
+
+const createRsdoctorAnalyzeNextAction = (dataFile: string): RsdoctorAnalyzeNextAction => ({
+ arguments: { dataFile, input: {}, toolName: 'build_summary' },
+ tool: 'rsdoctor_analyze',
+});
+
+const resolveReportFile = async (
+ workspaceRoot: string,
+ file: string,
+): Promise => {
+ const resolvedFile = path.resolve(workspaceRoot, file);
+ try {
+ const fileStats = await stat(resolvedFile);
+ if (!fileStats.isFile()) {
+ return { kind: 'missing' };
+ }
+ } catch (error) {
+ if (
+ error instanceof Error &&
+ 'code' in error &&
+ (error.code === 'ENOENT' || error.code === 'ENOTDIR')
+ ) {
+ return { kind: 'missing' };
+ }
+ throw error;
+ }
+
+ return {
+ kind: 'file',
+ path: path.relative(workspaceRoot, resolvedFile).split(path.sep).join('/'),
+ uri: pathToFileURL(resolvedFile).toString(),
+ };
+};
+
+const getSiblingRsdoctorHtmlReports = async (directory: string): Promise => {
+ try {
+ return (await readdir(directory, { withFileTypes: true }))
+ .filter(
+ (entry) =>
+ (entry.isFile() || entry.isSymbolicLink()) &&
+ entry.name.endsWith('.html') &&
+ /(?:^|[-_.])rsdoctor(?:[-_.]|$)/i.test(entry.name),
+ )
+ .map((entry) => entry.name)
+ .sort();
+ } catch {
+ return [];
+ }
+};
+
+const resolveRsdoctorReport = async (
+ workspaceRoot: string,
+ dataFile: string,
+): Promise => {
+ const resolvedDataFile = await resolveRsdoctorDataFile(workspaceRoot, dataFile);
+ const dataDirectory = path.posix.dirname(resolvedDataFile);
+ const conventionalReport = path.posix.join(dataDirectory, 'report-rsdoctor.html');
+
+ const createReport = async (
+ file: string,
+ kind: RsdoctorReport['kind'],
+ ): Promise => {
+ const result = await resolveReportFile(workspaceRoot, file);
+ if (result.kind === 'missing') {
+ return undefined;
+ }
+ return {
+ kind,
+ path: result.path,
+ uri: result.uri,
+ };
+ };
+
+ const conventional = await createReport(conventionalReport, 'html');
+ if (conventional !== undefined) {
+ return {
+ dataFile: resolvedDataFile,
+ report: conventional,
+ };
+ }
+
+ const htmlReports = await getSiblingRsdoctorHtmlReports(
+ path.resolve(workspaceRoot, dataDirectory),
+ );
+ if (htmlReports.length === 1) {
+ const sibling = await createReport(path.posix.join(dataDirectory, htmlReports[0]), 'html');
+ if (sibling !== undefined) {
+ return {
+ dataFile: resolvedDataFile,
+ report: sibling,
+ };
+ }
+ }
+
+ if (htmlReports.length > 1) {
+ return {
+ nextAction: createRsdoctorAnalyzeNextAction(resolvedDataFile),
+ dataFile: resolvedDataFile,
+ reason: 'Multiple sibling HTML reports were found; select one explicitly.',
+ };
+ }
+
+ const manifest = await createReport('.rsdoctor/manifest.json', 'manifest');
+ if (manifest !== undefined) {
+ return {
+ dataFile: resolvedDataFile,
+ report: manifest,
+ };
+ }
+
+ return {
+ nextAction: createRsdoctorAnalyzeNextAction(resolvedDataFile),
+ dataFile: resolvedDataFile,
+ reason:
+ 'No GUI report was found; a GUI report is optional. Use rsdoctor_analyze for static inspection.',
+ };
+};
+
+export { resolveReportFile, resolveRsdoctorReport };
+export type { ReportFileResult, RsdoctorReport, RsdoctorReportResult };
diff --git a/src/rsbuild.ts b/src/rsbuild.ts
new file mode 100644
index 0000000..3bdc40e
--- /dev/null
+++ b/src/rsbuild.ts
@@ -0,0 +1,5 @@
+export {
+ appendBuildContextPlugin,
+ createBuildContextPlugin,
+ type BuildContextPluginOptions,
+} from './build.ts';
diff --git a/src/rsdoctor.ts b/src/rsdoctor.ts
new file mode 100644
index 0000000..e5d9b5b
--- /dev/null
+++ b/src/rsdoctor.ts
@@ -0,0 +1,462 @@
+import { readFile } from 'node:fs/promises';
+import path from 'node:path';
+import { getNonEmptyString, isRecordObject } from './guards.ts';
+import type { JsonValue } from './model.ts';
+
+const supportedToolNames = [
+ 'build_summary',
+ 'bundle_optimize',
+ 'chunks_list',
+ 'errors_list',
+ 'packages_direct_dependencies',
+ 'packages_duplicates',
+ 'packages_similar',
+ 'tree_shaking_retained_modules',
+ 'tree_shaking_side_effects',
+ 'tree_shaking_summary',
+] as const;
+
+const artifactSectionNames = [
+ 'errors',
+ 'configs',
+ 'summary',
+ 'resolver',
+ 'loader',
+ 'moduleGraph',
+ 'chunkGraph',
+ 'moduleCodeMap',
+ 'plugin',
+ 'packageGraph',
+ 'treeShaking',
+ 'otherReports',
+] as const;
+
+const artifactOmissionReasons = [
+ 'not-selected',
+ 'output-mode',
+ 'feature-disabled',
+ 'not-collected',
+] as const;
+
+type RsdoctorToolName = (typeof supportedToolNames)[number];
+type RsdoctorArtifactSectionName = (typeof artifactSectionNames)[number];
+type RsdoctorArtifactOmissionReason = (typeof artifactOmissionReasons)[number];
+
+type RsdoctorSectionEvidence =
+ | { section: RsdoctorArtifactSectionName; status: 'collected' }
+ | {
+ section: RsdoctorArtifactSectionName;
+ status: 'omitted';
+ reason: RsdoctorArtifactOmissionReason;
+ };
+
+type RsdoctorToolDescriptor = {
+ name: RsdoctorToolName;
+ description: string;
+ inputSchema: Record;
+};
+
+type RsdoctorAnalysisRequest = {
+ dataFile: string;
+ toolName: string;
+ input?: Record;
+};
+
+type RsdoctorAnalysisResult = {
+ toolName: string;
+ dataFile: string;
+ result: JsonValue;
+ sectionEvidence: RsdoctorSectionEvidence[];
+ artifactMetadata?: RsdoctorArtifactMetadata;
+};
+
+type RsdoctorArtifactCompilationIdentity = {
+ compilationHash?: string;
+ target?: string | string[];
+ environment?: string;
+};
+
+type RsdoctorArtifactCompilerIdentity = RsdoctorArtifactCompilationIdentity & {
+ name: string;
+ stage?: number;
+};
+
+type RsdoctorArtifactMetadata = {
+ schemaVersion: 1;
+ producer: { name: string; version: string };
+ output: { mode: 'brief' | 'normal' };
+ build: RsdoctorArtifactCompilationIdentity & {
+ id: string;
+ root: string;
+ compiler: { name: string; type?: string; version?: string };
+ compilers?: RsdoctorArtifactCompilerIdentity[];
+ };
+ sections: Record<
+ string,
+ { status: 'collected' } | { status: 'omitted'; reason: RsdoctorArtifactOmissionReason }
+ >;
+};
+
+type RsdoctorArtifact = {
+ path: string;
+ data: Record;
+ metadata?: RsdoctorArtifactMetadata;
+};
+
+type RsdoctorAdapter = {
+ catalog: RsdoctorToolDescriptor[];
+ executor: ReturnType<
+ (typeof import('@rsdoctor/agent-cli'))['createInProcessRsdoctorCliToolExecutor']
+ >;
+};
+
+const isSupportedToolName = (name: string): name is RsdoctorToolName =>
+ supportedToolNames.some((supportedName) => supportedName === name);
+
+const isArtifactOmissionReason = (reason: unknown): reason is RsdoctorArtifactOmissionReason =>
+ artifactOmissionReasons.some((supportedReason) => supportedReason === reason);
+
+const requiredSectionsByTool = {
+ build_summary: ['summary'],
+ bundle_optimize: ['chunkGraph', 'errors', 'packageGraph'],
+ chunks_list: ['chunkGraph'],
+ errors_list: ['errors'],
+ packages_direct_dependencies: ['packageGraph'],
+ packages_duplicates: ['errors'],
+ packages_similar: ['packageGraph'],
+ tree_shaking_retained_modules: ['chunkGraph', 'moduleGraph', 'packageGraph'],
+ tree_shaking_side_effects: ['moduleGraph'],
+ tree_shaking_summary: ['errors'],
+} as const satisfies Record;
+
+let adapterPromise: Promise | undefined;
+
+const loadAdapter = async (): Promise => {
+ const { createInProcessRsdoctorCliToolExecutor, getToolCatalog } =
+ await import('@rsdoctor/agent-cli');
+ const packageCatalog = getToolCatalog();
+ const catalog = supportedToolNames.map((name) => {
+ const tool = packageCatalog.find((entry) => entry.name === name);
+ if (tool === undefined) {
+ throw new Error(`Rsdoctor catalog is missing ${name}.`);
+ }
+
+ return {
+ description: tool.description,
+ inputSchema: tool.inputSchema as Record,
+ name,
+ };
+ });
+
+ return {
+ catalog,
+ executor: createInProcessRsdoctorCliToolExecutor(),
+ };
+};
+
+const getAdapter = (): Promise => (adapterPromise ??= loadAdapter());
+
+const listRsdoctorToolNames = (): RsdoctorToolName[] => [...supportedToolNames];
+
+const matchesSchemaType = (value: unknown, type: unknown): boolean => {
+ if (Array.isArray(type)) {
+ return type.some((entry) => matchesSchemaType(value, entry));
+ }
+
+ switch (type) {
+ case 'array':
+ return Array.isArray(value);
+ case 'boolean':
+ return typeof value === 'boolean';
+ case 'integer':
+ return typeof value === 'number' && Number.isInteger(value);
+ case 'null':
+ return value === null;
+ case 'number':
+ return typeof value === 'number' && Number.isFinite(value);
+ case 'object':
+ return isRecordObject(value);
+ case 'string':
+ return typeof value === 'string';
+ default:
+ return true;
+ }
+};
+
+const matchesJsonSchema = (value: unknown, schema: unknown): boolean => {
+ if (!isRecordObject(schema) || !matchesSchemaType(value, schema.type)) {
+ return false;
+ }
+
+ if (typeof value === 'number') {
+ if (typeof schema.minimum === 'number' && value < schema.minimum) {
+ return false;
+ }
+ if (typeof schema.maximum === 'number' && value > schema.maximum) {
+ return false;
+ }
+ }
+
+ if (Array.isArray(value) && isRecordObject(schema.items)) {
+ return value.every((entry) => matchesJsonSchema(entry, schema.items));
+ }
+
+ if (!isRecordObject(value)) {
+ return true;
+ }
+
+ const properties = isRecordObject(schema.properties) ? schema.properties : {};
+ if (
+ Array.isArray(schema.required) &&
+ schema.required.some((key) => typeof key === 'string' && !(key in value))
+ ) {
+ return false;
+ }
+
+ for (const [key, entry] of Object.entries(value)) {
+ const propertySchema = properties[key];
+ if (propertySchema !== undefined) {
+ if (!matchesJsonSchema(entry, propertySchema)) {
+ return false;
+ }
+ } else if (schema.additionalProperties === false) {
+ return false;
+ } else if (isRecordObject(schema.additionalProperties)) {
+ if (!matchesJsonSchema(entry, schema.additionalProperties)) {
+ return false;
+ }
+ }
+ }
+
+ return true;
+};
+
+const getInput = (input: unknown, tool: RsdoctorToolDescriptor): Record => {
+ const resolvedInput = input === undefined ? {} : input;
+ if (!isRecordObject(resolvedInput) || !matchesJsonSchema(resolvedInput, tool.inputSchema)) {
+ throw new Error('Rsdoctor tool input does not match its schema.');
+ }
+
+ return resolvedInput;
+};
+
+const getCompilationIdentity = (
+ value: Record,
+): RsdoctorArtifactCompilationIdentity | undefined => {
+ const compilationHash = getNonEmptyString(value.compilationHash);
+ const environment = getNonEmptyString(value.environment);
+ const target = getNonEmptyString(value.target);
+ const targets = Array.isArray(value.target)
+ ? value.target.filter((entry): entry is string => getNonEmptyString(entry) !== undefined)
+ : undefined;
+ if (Array.isArray(value.target) && targets?.length !== value.target.length) return undefined;
+ if (value.target !== undefined && target === undefined && targets === undefined) return undefined;
+ if (value.compilationHash !== undefined && compilationHash === undefined) return undefined;
+ if (value.environment !== undefined && environment === undefined) return undefined;
+ return {
+ ...(compilationHash === undefined ? {} : { compilationHash }),
+ ...(target === undefined ? (targets === undefined ? {} : { target: targets }) : { target }),
+ ...(environment === undefined ? {} : { environment }),
+ };
+};
+
+const getArtifactMetadata = (value: unknown): RsdoctorArtifactMetadata | undefined => {
+ if (!isRecordObject(value) || value.schemaVersion !== 1) return undefined;
+ if (
+ !isRecordObject(value.producer) ||
+ !isRecordObject(value.output) ||
+ !isRecordObject(value.build)
+ ) {
+ return undefined;
+ }
+ if (!isRecordObject(value.build.compiler) || !isRecordObject(value.sections)) return undefined;
+
+ const producerName = getNonEmptyString(value.producer.name);
+ const producerVersion = getNonEmptyString(value.producer.version);
+ const mode = value.output.mode;
+ const id = getNonEmptyString(value.build.id);
+ const root = getNonEmptyString(value.build.root);
+ const compilerName = getNonEmptyString(value.build.compiler.name);
+ const compilerType = getNonEmptyString(value.build.compiler.type);
+ const compilerVersion = getNonEmptyString(value.build.compiler.version);
+ const identity = getCompilationIdentity(value.build);
+ if (
+ producerName !== '@rsdoctor/core' ||
+ producerVersion === undefined ||
+ (mode !== 'brief' && mode !== 'normal') ||
+ id === undefined ||
+ root === undefined ||
+ compilerName === undefined ||
+ identity === undefined
+ ) {
+ return undefined;
+ }
+ if (
+ (value.build.compiler.type !== undefined && compilerType === undefined) ||
+ (value.build.compiler.version !== undefined && compilerVersion === undefined)
+ ) {
+ return undefined;
+ }
+
+ const sections: RsdoctorArtifactMetadata['sections'] = {};
+ for (const [name, state] of Object.entries(value.sections)) {
+ if (!isRecordObject(state)) return undefined;
+ if (state.status === 'collected') {
+ sections[name] = { status: 'collected' };
+ } else if (state.status === 'omitted' && isArtifactOmissionReason(state.reason)) {
+ sections[name] = { status: 'omitted', reason: state.reason };
+ } else {
+ return undefined;
+ }
+ }
+ if (artifactSectionNames.some((name) => sections[name] === undefined)) return undefined;
+
+ let compilers: RsdoctorArtifactCompilerIdentity[] | undefined;
+ if (value.build.compilers !== undefined) {
+ if (!Array.isArray(value.build.compilers)) return undefined;
+ compilers = [];
+ for (const entry of value.build.compilers) {
+ if (!isRecordObject(entry)) return undefined;
+ const name = getNonEmptyString(entry.name);
+ const compilerIdentity = getCompilationIdentity(entry);
+ if (
+ name === undefined ||
+ compilerIdentity === undefined ||
+ (entry.stage !== undefined &&
+ (typeof entry.stage !== 'number' || !Number.isFinite(entry.stage)))
+ ) {
+ return undefined;
+ }
+ compilers.push({
+ name,
+ ...(entry.stage === undefined ? {} : { stage: entry.stage }),
+ ...compilerIdentity,
+ });
+ }
+ }
+
+ return {
+ schemaVersion: 1,
+ producer: { name: producerName, version: producerVersion },
+ output: { mode },
+ build: {
+ id,
+ root,
+ compiler: {
+ name: compilerName,
+ ...(compilerType === undefined ? {} : { type: compilerType }),
+ ...(compilerVersion === undefined ? {} : { version: compilerVersion }),
+ },
+ ...identity,
+ ...(compilers === undefined ? {} : { compilers }),
+ },
+ sections,
+ };
+};
+
+const readRsdoctorArtifact = async (
+ workspaceRoot: string,
+ dataFile: string,
+): Promise => {
+ const artifactPath = path.resolve(workspaceRoot, dataFile);
+ let contents: string;
+ try {
+ contents = await readFile(artifactPath, 'utf8');
+ } catch (error) {
+ const cause = error instanceof Error ? ` Cause: ${error.message}` : '';
+ throw new Error(
+ `Rsdoctor data file could not be read at "${artifactPath}". Generate a brief JSON artifact by setting RSDOCTOR_OUTPUT=json for the build.${cause}`,
+ { cause: error },
+ );
+ }
+
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(contents);
+ } catch {
+ throw new Error('Rsdoctor data file must contain valid JSON.');
+ }
+
+ if (!isRecordObject(parsed) || !isRecordObject(parsed.data)) {
+ throw new Error('Rsdoctor data file must contain an object data field.');
+ }
+
+ const metadata = getArtifactMetadata(parsed.metadata);
+ return {
+ path: artifactPath,
+ data: parsed.data,
+ ...(metadata === undefined ? {} : { metadata }),
+ };
+};
+
+const toRelativeWorkspaceFile = (workspaceRoot: string, file: string): string =>
+ path.relative(workspaceRoot, file).split(path.sep).join('/');
+
+const resolveRsdoctorDataFile = async (
+ workspaceRoot: string,
+ dataFile: string,
+): Promise => {
+ const artifact = await readRsdoctorArtifact(workspaceRoot, dataFile);
+ return toRelativeWorkspaceFile(workspaceRoot, artifact.path);
+};
+
+const analyzeRsdoctorArtifact = async (
+ workspaceRoot: string,
+ request: RsdoctorAnalysisRequest,
+): Promise => {
+ if (!isRecordObject(request) || typeof request.toolName !== 'string' || !request.toolName) {
+ throw new Error('Rsdoctor tool name is invalid.');
+ }
+ if (!isSupportedToolName(request.toolName)) {
+ throw new Error('Unknown Rsdoctor tool.');
+ }
+
+ const { catalog, executor } = await getAdapter();
+ const tool = catalog.find(({ name }) => name === request.toolName)!;
+ const input = getInput(request.input, tool);
+ const artifact = await readRsdoctorArtifact(workspaceRoot, request.dataFile);
+
+ let result: unknown;
+ try {
+ result = await executor.execute({
+ dataFile: artifact.path,
+ input,
+ toolName: request.toolName,
+ });
+ } catch (error) {
+ const cause = error instanceof Error ? ` Cause: ${error.message}` : '';
+ throw new Error(`Rsdoctor analysis failed.${cause}`, { cause: error });
+ }
+
+ const metadata = artifact.metadata;
+ return {
+ dataFile: request.dataFile,
+ ...(metadata === undefined ? {} : { artifactMetadata: metadata }),
+ result: result as JsonValue,
+ sectionEvidence:
+ metadata === undefined
+ ? []
+ : requiredSectionsByTool[request.toolName].map((section) => ({
+ section,
+ ...metadata.sections[section],
+ })),
+ toolName: request.toolName,
+ };
+};
+
+export {
+ analyzeRsdoctorArtifact,
+ listRsdoctorToolNames,
+ readRsdoctorArtifact,
+ resolveRsdoctorDataFile,
+};
+export type {
+ RsdoctorAnalysisRequest,
+ RsdoctorAnalysisResult,
+ RsdoctorArtifact,
+ RsdoctorArtifactCompilationIdentity,
+ RsdoctorArtifactMetadata,
+ RsdoctorArtifactOmissionReason,
+ RsdoctorArtifactSectionName,
+ RsdoctorSectionEvidence,
+};
diff --git a/src/rsdoctorGraph.ts b/src/rsdoctorGraph.ts
new file mode 100644
index 0000000..d6a411c
--- /dev/null
+++ b/src/rsdoctorGraph.ts
@@ -0,0 +1,194 @@
+import path from 'node:path';
+import type { ObservedModule, ObservedModuleGraph, OptimizerBound } from './analysisModel.ts';
+import { getNonEmptyString, isRecordObject } from './guards.ts';
+import { compareStrings } from './order.ts';
+import { compareModules } from './reachability.ts';
+import { readRsdoctorArtifact, type RsdoctorArtifact } from './rsdoctor.ts';
+
+const getId = (value: unknown): string | undefined =>
+ typeof value === 'string' && value.length > 0
+ ? value
+ : typeof value === 'number' && Number.isFinite(value)
+ ? String(value)
+ : undefined;
+
+const normalizeModulePath = (value: string): string =>
+ path.posix.normalize(value.replaceAll('\\', '/'));
+
+const stringifyBailoutReason = (value: unknown): string => {
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
+ return String(value);
+ }
+ if (Array.isArray(value)) {
+ return value.map(stringifyBailoutReason).join(' ');
+ }
+ if (!isRecordObject(value)) {
+ return '';
+ }
+
+ const preferredFields = ['reason', 'message', 'description', 'detail', 'title', 'type', 'code'];
+ const preferredText = preferredFields
+ .map((field) => value[field])
+ .filter((entry) => entry !== undefined)
+ .map(stringifyBailoutReason)
+ .filter(Boolean)
+ .join(' ');
+ return preferredText || JSON.stringify(value);
+};
+
+const getOptimizerReasons = (value: unknown): string[] | undefined => {
+ const reasons = [...new Set((Array.isArray(value) ? value : [value]).map(stringifyBailoutReason))]
+ .filter(Boolean)
+ .sort(compareStrings);
+ return reasons.length === 0 ? undefined : reasons;
+};
+
+const getOptimizerBound = (value: unknown): OptimizerBound | undefined => {
+ const reason = stringifyBailoutReason(value).toLowerCase();
+ if (!reason) return undefined;
+ if (
+ reason.includes('cjs') ||
+ reason.includes('commonjs') ||
+ reason.includes('require(') ||
+ reason.includes('require()') ||
+ reason.includes('module.exports') ||
+ reason.includes('exports.')
+ ) {
+ return 'cjs';
+ }
+ if (/side[_ -]?effects?/u.test(reason)) return 'side-effect';
+ if (reason.includes('dynamic import') || reason.includes('import()')) {
+ return 'dynamic-import';
+ }
+ return 'unknown-bailout';
+};
+
+const normalizeModule = (value: unknown): ObservedModule | undefined => {
+ if (!isRecordObject(value)) return undefined;
+ const id = getId(value.id);
+ if (id === undefined) return undefined;
+
+ const rawPath =
+ getNonEmptyString(value.path) ??
+ getNonEmptyString(value.webpackId) ??
+ getNonEmptyString(value.name) ??
+ '';
+ const rawName = getNonEmptyString(value.webpackId) ?? getNonEmptyString(value.name) ?? rawPath;
+ const chunks = Array.isArray(value.chunks)
+ ? [
+ ...new Set(value.chunks.map(getId).filter((entry): entry is string => entry !== undefined)),
+ ].sort(compareStrings)
+ : [];
+ const optimizerReasons = getOptimizerReasons(value.bailoutReason);
+ const optimizerBound = getOptimizerBound(optimizerReasons);
+
+ return {
+ id,
+ path: normalizeModulePath(rawPath),
+ name: rawName.replaceAll('\\', '/'),
+ chunks,
+ isEntry: value.isEntry === true,
+ ...(optimizerBound === undefined ? {} : { optimizerBound }),
+ ...(optimizerReasons === undefined ? {} : { optimizerReasons }),
+ };
+};
+
+const normalizeRsdoctorModuleGraph = (artifact: RsdoctorArtifact): ObservedModuleGraph => {
+ if (artifact.metadata?.sections.moduleGraph?.status === 'omitted') {
+ return {
+ modules: [],
+ edges: [],
+ exportRowsPresent: false,
+ issues: ['module-graph-omitted'],
+ };
+ }
+
+ const moduleGraph = artifact.data.moduleGraph;
+ if (!isRecordObject(moduleGraph)) {
+ return {
+ modules: [],
+ edges: [],
+ exportRowsPresent: false,
+ issues: ['module-graph-missing'],
+ };
+ }
+
+ const issues: ObservedModuleGraph['issues'] = [];
+ const modulesById = new Map();
+ for (const row of Array.isArray(moduleGraph.modules) ? moduleGraph.modules : []) {
+ const module = normalizeModule(row);
+ if (module === undefined) continue;
+ if (modulesById.has(module.id)) {
+ if (!issues.includes('duplicate-module-id')) issues.push('duplicate-module-id');
+ continue;
+ }
+ modulesById.set(module.id, module);
+ }
+
+ for (const row of Array.isArray(moduleGraph.modules) ? moduleGraph.modules : []) {
+ if (!isRecordObject(row) || !Array.isArray(row.modules)) continue;
+ const container = modulesById.get(getId(row.id) ?? '');
+ if (container === undefined || container.chunks.length === 0) continue;
+ for (const childId of row.modules.map(getId)) {
+ if (childId === undefined) continue;
+ const child = modulesById.get(childId);
+ if (child === undefined) continue;
+ modulesById.set(childId, {
+ ...child,
+ chunks: [...new Set([...child.chunks, ...container.chunks])].sort(compareStrings),
+ });
+ }
+ }
+
+ const edgeKeys = new Set();
+ const edges: ObservedModuleGraph['edges'] = [];
+ const addEdge = (from: string | undefined, to: string | undefined): void => {
+ if (from === undefined || to === undefined) return;
+ if (!modulesById.has(from) || !modulesById.has(to)) {
+ if (!issues.includes('dangling-edge')) issues.push('dangling-edge');
+ return;
+ }
+ const key = `${from}\u0000${to}`;
+ if (edgeKeys.has(key)) return;
+ edgeKeys.add(key);
+ edges.push({ from, to });
+ };
+ for (const row of Array.isArray(moduleGraph.dependencies) ? moduleGraph.dependencies : []) {
+ if (!isRecordObject(row)) continue;
+ const usesDependencyShape = Object.hasOwn(row, 'dependency');
+ addEdge(
+ getId(usesDependencyShape ? row.module : row.issuer),
+ getId(usesDependencyShape ? row.dependency : row.module),
+ );
+ }
+
+ for (const row of Array.isArray(moduleGraph.modules) ? moduleGraph.modules : []) {
+ if (!isRecordObject(row)) continue;
+ const moduleId = getId(row.id);
+ for (const importerId of Array.isArray(row.imported) ? row.imported.map(getId) : []) {
+ addEdge(importerId, moduleId);
+ }
+ for (const childId of Array.isArray(row.modules) ? row.modules.map(getId) : []) {
+ addEdge(moduleId, childId);
+ }
+ }
+
+ return {
+ modules: [...modulesById.values()].sort(compareModules),
+ edges: edges.sort(
+ (left, right) => compareStrings(left.from, right.from) || compareStrings(left.to, right.to),
+ ),
+ exportRowsPresent: Array.isArray(moduleGraph.exports) && moduleGraph.exports.length > 0,
+ issues,
+ };
+};
+
+const readRsdoctorModuleGraph = async (
+ workspaceRoot: string,
+ dataFile: string,
+): Promise => {
+ const artifact = await readRsdoctorArtifact(workspaceRoot, dataFile);
+ return normalizeRsdoctorModuleGraph(artifact);
+};
+
+export { normalizeRsdoctorModuleGraph, readRsdoctorModuleGraph };
diff --git a/src/rslib.ts b/src/rslib.ts
new file mode 100644
index 0000000..3bdc40e
--- /dev/null
+++ b/src/rslib.ts
@@ -0,0 +1,5 @@
+export {
+ appendBuildContextPlugin,
+ createBuildContextPlugin,
+ type BuildContextPluginOptions,
+} from './build.ts';
diff --git a/src/rslint.ts b/src/rslint.ts
new file mode 100644
index 0000000..784b003
--- /dev/null
+++ b/src/rslint.ts
@@ -0,0 +1,13 @@
+export {
+ captureLintSnapshot,
+ getLintFixPreview,
+ listDiagnostics,
+ type DiagnosticPage,
+ type DiagnosticRecord,
+ type DiagnosticsQuery,
+ type LintCaptureAdapter,
+ type LintCaptureResult,
+ type LintFixPreviewResult,
+ type LintSnapshotRequest,
+ type RslintFactory,
+} from './lint.ts';
diff --git a/src/rstack.ts b/src/rstack.ts
new file mode 100644
index 0000000..050d642
--- /dev/null
+++ b/src/rstack.ts
@@ -0,0 +1,93 @@
+import type { ConfigParams, RsbuildConfig } from '@rsbuild/core';
+import { appendBuildContextPlugin, createBuildContextPlugin } from './build.ts';
+import { resolveContextCapture, type ContextConfig } from './config.ts';
+import { recordContextInputFiles } from './source.ts';
+import { resolveContextWorkspace } from './workspace.ts';
+
+type ContextRstackPluginOptions = {
+ config?: ContextConfig;
+ configFilePath: string | null;
+ configDependencies: readonly string[];
+ cwd: string;
+};
+
+type ContextRstackModifierContext = Readonly<{ params: ConfigParams }>;
+
+type ContextBuildConfig = {
+ plugins?: RsbuildConfig['plugins'];
+};
+
+type ContextBuildModifier = (
+ config: Config,
+ context: ContextRstackModifierContext,
+) => Config | Promise;
+
+type ContextRstackPluginApi = {
+ modifyConfig(kind: 'app' | 'lib', handler: ContextBuildModifier): void;
+};
+
+type ContextRstackPlugin = {
+ name: 'rstack:context';
+ setup(api: ContextRstackPluginApi): void;
+};
+
+const createRstackContextPlugin = (options: ContextRstackPluginOptions): ContextRstackPlugin => ({
+ name: 'rstack:context',
+ setup(api) {
+ const capture = resolveContextCapture(options.config);
+ if (capture === 'off') return;
+
+ const configPath = options.configFilePath ?? undefined;
+ let commonOptionsPromise:
+ | Promise<{
+ inputs?: Awaited>;
+ workspace: Awaited>;
+ }>
+ | undefined;
+ const resolveCommonOptions = () =>
+ (commonOptionsPromise ??= (async () => {
+ const workspace = await resolveContextWorkspace(configPath ?? options.cwd);
+ const inputs =
+ configPath === undefined
+ ? undefined
+ : await recordContextInputFiles(workspace.workspaceRoot, [
+ ...new Set([configPath, ...options.configDependencies]),
+ ]);
+ return { workspace, inputs };
+ })());
+
+ const register = (
+ kind: 'app' | 'lib',
+ producer: 'rsbuild' | 'rslib',
+ product: 'application' | 'library',
+ ): void => {
+ api.modifyConfig(kind, async (config, { params }) => {
+ const common = await resolveCommonOptions();
+ return appendBuildContextPlugin(
+ config,
+ createBuildContextPlugin({
+ producer,
+ product,
+ capture,
+ ...common,
+ configPath,
+ params,
+ variant: options.config?.variant,
+ }),
+ );
+ });
+ };
+
+ register('app', 'rsbuild', 'application');
+ register('lib', 'rslib', 'library');
+ },
+});
+
+export { createRstackContextPlugin };
+export type {
+ ContextBuildModifier,
+ ContextRstackModifierContext,
+ ContextRstackPlugin,
+ ContextRstackPluginApi,
+ ContextRstackPluginOptions,
+};
diff --git a/src/rstest.ts b/src/rstest.ts
new file mode 100644
index 0000000..935b66b
--- /dev/null
+++ b/src/rstest.ts
@@ -0,0 +1,22 @@
+export { type TestExecutionRequest } from './execution.ts';
+export type {
+ TestExecutionBranch,
+ TestExecutionFacet,
+ TestExecutionFile,
+ TestExecutionFunction,
+ TestExecutionLocation,
+ TestExecutionPosition,
+ TestExecutionRequestedSelection,
+ TestExecutionStatement,
+} from './model.ts';
+export {
+ captureTestSnapshot,
+ listTestResults,
+ type RelatedTestRequest,
+ type ResolveRelatedTests,
+ type TestCaptureDependencies,
+ type TestCaptureResult,
+ type TestResultPage,
+ type TestResultsQuery,
+ type TestSnapshotRequest,
+} from './testRun.ts';
diff --git a/src/source.ts b/src/source.ts
new file mode 100644
index 0000000..49b7de0
--- /dev/null
+++ b/src/source.ts
@@ -0,0 +1,232 @@
+import { randomUUID } from 'node:crypto';
+import { existsSync } from 'node:fs';
+import { access, readFile } from 'node:fs/promises';
+import path from 'node:path';
+import { sha256Hex } from './guards.ts';
+import { resolveContainedPath, toWorkspacePath } from './paths.ts';
+import {
+ contextStoreSchemaVersion,
+ type ContextDescriptor,
+ type ContextFreshness,
+ type ContextInputFile,
+ type ContextRunManifest,
+ type ContextSnapshot,
+} from './model.ts';
+
+type ExplicitContextOptions = {
+ producer: 'rslint' | 'rstest';
+ workspaceRoot: string;
+ packageRoot: string;
+ packageName?: string;
+ configPath?: string;
+};
+
+type ExplicitRunOptions = {
+ producer: 'rslint' | 'rstest';
+ context: ContextDescriptor;
+ command: string;
+ now?: () => Date;
+ createRunId?: () => string;
+};
+
+type ExplicitCaptureTargetRequest = {
+ packageRoot?: string;
+ configPath?: string;
+};
+
+type ExplicitCaptureTarget = {
+ packageRoot: string;
+ packageName?: string;
+ configPath?: string;
+};
+
+type ContextInputEntry = { input: ContextInputFile } | { unreadablePath: string };
+
+type ContextInputRecording = {
+ inputs: ContextInputFile[];
+ unreadablePaths: string[];
+};
+
+type ConfigTargetRunner = (
+ configRoot: string,
+ configPath: string | undefined,
+ action: () => Promise,
+) => Promise;
+
+const rstackConfigFileNames = [
+ 'rstack.config.ts',
+ 'rstack.config.js',
+ 'rstack.config.mts',
+ 'rstack.config.mjs',
+] as const;
+
+const resolveInternalConfigPath = (moduleDirectory: string, fileName: string): string => {
+ const siblingPath = path.join(moduleDirectory, fileName);
+ if (existsSync(siblingPath)) return siblingPath;
+ const parentPath = path.join(moduleDirectory, '..', fileName);
+ if (existsSync(parentPath)) return parentPath;
+ throw new Error(
+ `The bundled wrapper config "${fileName}" is not present next to "${moduleDirectory}". Supply an explicit wrapperConfigPath for this capture.`,
+ );
+};
+
+const readPackageName = async (packageRoot: string): Promise => {
+ try {
+ const packageJson: unknown = JSON.parse(
+ await readFile(path.join(packageRoot, 'package.json'), 'utf8'),
+ );
+ return typeof packageJson === 'object' &&
+ packageJson !== null &&
+ 'name' in packageJson &&
+ typeof packageJson.name === 'string'
+ ? packageJson.name
+ : undefined;
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
+ throw error;
+ }
+};
+
+const findPackageConfig = async (packageRoot: string): Promise => {
+ for (const fileName of rstackConfigFileNames) {
+ const configPath = path.join(packageRoot, fileName);
+ try {
+ await access(configPath);
+ return configPath;
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
+ }
+ }
+ return undefined;
+};
+
+const resolveExplicitCaptureTarget = async (
+ workspaceRoot: string,
+ request: ExplicitCaptureTargetRequest,
+): Promise => {
+ const packageRoot = resolveContainedPath(
+ workspaceRoot,
+ 'packageRoot',
+ request.packageRoot ?? '.',
+ );
+ const configPath =
+ request.configPath === undefined
+ ? await findPackageConfig(packageRoot)
+ : resolveContainedPath(workspaceRoot, 'configPath', request.configPath);
+ const packageName = await readPackageName(packageRoot);
+
+ return {
+ packageRoot,
+ ...(packageName === undefined ? {} : { packageName }),
+ ...(configPath === undefined ? {} : { configPath }),
+ };
+};
+
+const createExplicitContextDescriptor = (options: ExplicitContextOptions): ContextDescriptor => {
+ const packageRoot = toWorkspacePath(options.workspaceRoot, options.packageRoot) || '.';
+ const configPath =
+ options.configPath === undefined
+ ? undefined
+ : toWorkspacePath(options.workspaceRoot, options.configPath) || '.';
+ const identity = [options.producer, packageRoot, configPath ?? ''].join('\u0000');
+
+ return {
+ contextId: `ctx_${sha256Hex(identity).slice(0, 24)}`,
+ packageRoot,
+ product: 'development',
+ ...(options.packageName === undefined ? {} : { packageName: options.packageName }),
+ ...(configPath === undefined ? {} : { configPath }),
+ environment: options.producer === 'rslint' ? 'lint' : 'test',
+ };
+};
+
+const createExplicitRun = (options: ExplicitRunOptions): ContextRunManifest => ({
+ schemaVersion: contextStoreSchemaVersion,
+ runId: options.createRunId?.() ?? `run_${Date.now()}_${randomUUID()}`,
+ producer: options.producer,
+ command: options.command,
+ startedAt: (options.now?.() ?? new Date()).toISOString(),
+ contexts: [options.context],
+});
+
+const collectContextInputFiles = async (
+ workspaceRoot: string,
+ filePaths: string[],
+): Promise => {
+ const entries = await Promise.all(
+ filePaths.map(async (filePath): Promise => {
+ const relativePath = toWorkspacePath(workspaceRoot, filePath) || '.';
+ try {
+ return {
+ input: {
+ path: relativePath,
+ digest: sha256Hex(await readFile(path.resolve(workspaceRoot, relativePath))),
+ },
+ };
+ } catch {
+ return { unreadablePath: relativePath };
+ }
+ }),
+ );
+
+ return {
+ inputs: entries
+ .flatMap((entry) => ('input' in entry ? [entry.input] : []))
+ .sort((left, right) => left.path.localeCompare(right.path)),
+ unreadablePaths: entries
+ .flatMap((entry) => ('unreadablePath' in entry ? [entry.unreadablePath] : []))
+ .sort((left, right) => left.localeCompare(right)),
+ };
+};
+
+const recordContextInputFiles = async (
+ workspaceRoot: string,
+ filePaths: string[],
+): Promise => (await collectContextInputFiles(workspaceRoot, filePaths)).inputs;
+
+const assessSnapshotFreshness = async (
+ workspaceRoot: string,
+ snapshot: ContextSnapshot,
+): Promise => {
+ const source = snapshot.source;
+ if (source?.virtualInputDigest !== undefined || source?.inputs === undefined) {
+ return { state: 'unknown', changedPaths: [] };
+ }
+
+ const changedPaths = (
+ await Promise.all(
+ source.inputs.map(async (input) => {
+ try {
+ const currentDigest = sha256Hex(await readFile(path.resolve(workspaceRoot, input.path)));
+ return currentDigest === input.digest ? undefined : input.path;
+ } catch {
+ return input.path;
+ }
+ }),
+ )
+ )
+ .filter((changedPath): changedPath is string => changedPath !== undefined)
+ .sort((left, right) => left.localeCompare(right));
+
+ if (changedPaths.length > 0) return { state: 'stale', changedPaths };
+ return {
+ state: source.inputCompleteness === 'complete' ? 'fresh' : 'partial',
+ changedPaths: [],
+ };
+};
+
+export {
+ assessSnapshotFreshness,
+ collectContextInputFiles,
+ createExplicitContextDescriptor,
+ createExplicitRun,
+ recordContextInputFiles,
+ resolveExplicitCaptureTarget,
+ resolveInternalConfigPath,
+};
+export type {
+ ConfigTargetRunner,
+ ContextInputRecording,
+ ExplicitContextOptions,
+ ExplicitRunOptions,
+};
diff --git a/src/status.ts b/src/status.ts
new file mode 100644
index 0000000..567b84d
--- /dev/null
+++ b/src/status.ts
@@ -0,0 +1,131 @@
+import { realpath } from 'node:fs/promises';
+import { sha256Hex } from './guards.ts';
+import {
+ type ContextRunManifest,
+ type ContextSnapshot,
+ type ProjectContextStatus,
+ type ProjectStatus,
+} from './model.ts';
+import { compareStrings } from './order.ts';
+import { assessSnapshotFreshness } from './source.ts';
+import { readContextWorkspaceStatus } from './store.ts';
+
+const compareProjectContexts = (
+ left: ProjectContextStatus & { startedAt: string },
+ right: ProjectContextStatus & { startedAt: string },
+): number => {
+ const fields = [
+ [left.context.packageRoot, right.context.packageRoot],
+ [left.context.product, right.context.product],
+ [left.context.environment ?? '', right.context.environment ?? ''],
+ [left.startedAt, right.startedAt],
+ [left.runId, right.runId],
+ ] as const;
+
+ for (const [leftValue, rightValue] of fields) {
+ const result = compareStrings(leftValue, rightValue);
+ if (result !== 0) return result;
+ }
+ return 0;
+};
+
+type SnapshotObservation = { run: ContextRunManifest; snapshot: ContextSnapshot };
+
+const isNewerRun = (current: ContextRunManifest, candidate: ContextRunManifest): boolean =>
+ compareStrings(current.startedAt, candidate.startedAt) < 0 ||
+ (current.startedAt === candidate.startedAt && compareStrings(current.runId, candidate.runId) < 0);
+
+// A run without a snapshot (an aborted build, a capture that only wrote its manifest) must not hide
+// the newest completed snapshot recorded for the same context by an earlier run.
+const newerObservation = (
+ current: SnapshotObservation | undefined,
+ candidate: SnapshotObservation,
+): SnapshotObservation =>
+ current === undefined ||
+ compareStrings(current.snapshot.observedAt, candidate.snapshot.observedAt) < 0 ||
+ (current.snapshot.observedAt === candidate.snapshot.observedAt &&
+ isNewerRun(current.run, candidate.run))
+ ? candidate
+ : current;
+
+const isCompleteSuccessfulObservation = ({ snapshot }: SnapshotObservation): boolean =>
+ snapshot.status === 'pass' && Object.values(snapshot.completeness).includes('complete');
+
+const readProjectStatus = async (workspaceRoot: string): Promise => {
+ const workspace = await readContextWorkspaceStatus(workspaceRoot);
+ const workspacePath = await realpath(workspaceRoot);
+ const workspaceId = `ws_${sha256Hex(workspacePath).slice(0, 24)}`;
+ const currentByContextId = new Map<
+ string,
+ (typeof workspace.runs)[number]['contexts'][number] & {
+ run: (typeof workspace.runs)[number]['run'];
+ observation?: SnapshotObservation;
+ latestAttempt?: SnapshotObservation;
+ }
+ >();
+
+ for (const { run, contexts } of workspace.runs) {
+ for (const contextStatus of contexts) {
+ const current = currentByContextId.get(contextStatus.context.contextId);
+ const candidate =
+ contextStatus.latestSnapshot === undefined
+ ? undefined
+ : { run, snapshot: contextStatus.latestSnapshot };
+ const observation =
+ candidate === undefined || !isCompleteSuccessfulObservation(candidate)
+ ? current?.observation
+ : newerObservation(current?.observation, candidate);
+ const latestAttempt =
+ candidate === undefined
+ ? current?.latestAttempt
+ : newerObservation(current?.latestAttempt, candidate);
+ const newest =
+ current === undefined || isNewerRun(current.run, run)
+ ? { context: contextStatus.context, run }
+ : { context: current.context, run: current.run };
+ currentByContextId.set(contextStatus.context.contextId, {
+ ...newest,
+ ...(observation === undefined ? {} : { observation }),
+ ...(latestAttempt === undefined ? {} : { latestAttempt }),
+ });
+ }
+ }
+
+ const contexts = (
+ await Promise.all(
+ [...currentByContextId.values()].map(
+ async ({ run, context, observation, latestAttempt }) => ({
+ runId: run.runId,
+ producer: run.producer,
+ context,
+ state:
+ observation === undefined && latestAttempt === undefined
+ ? ('pending' as const)
+ : ('ready' as const),
+ ...(observation === undefined
+ ? {}
+ : {
+ latestSnapshot: observation.snapshot,
+ freshness: await assessSnapshotFreshness(workspaceRoot, observation.snapshot),
+ }),
+ ...(latestAttempt === undefined ||
+ latestAttempt.snapshot.snapshotId === observation?.snapshot.snapshotId
+ ? {}
+ : { latestAttempt: latestAttempt.snapshot }),
+ startedAt: run.startedAt,
+ }),
+ ),
+ )
+ )
+ .sort(compareProjectContexts)
+ .map(({ startedAt: _, ...context }) => context satisfies ProjectContextStatus);
+
+ return {
+ schemaVersion: workspace.schemaVersion,
+ workspaceId,
+ contexts,
+ issues: workspace.issues,
+ };
+};
+
+export { readProjectStatus };
diff --git a/src/store.ts b/src/store.ts
new file mode 100644
index 0000000..cea1d23
--- /dev/null
+++ b/src/store.ts
@@ -0,0 +1,344 @@
+import { randomUUID } from 'node:crypto';
+import { link, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
+import path from 'node:path';
+import { ensureProjectCacheDir, getProjectCacheDir } from './cache.ts';
+import {
+ contextStoreSchemaVersion,
+ type ContextDescriptor,
+ type ContextRunManifest,
+ type ContextSnapshot,
+ type ContextProducer,
+ type ContextStoreIssue,
+ type ContextStoreWriteResult,
+ type ContextWorkspaceStatus,
+ type StoredContextSnapshot,
+} from './model.ts';
+import { compareStringsDescending } from './order.ts';
+import {
+ compareContextSnapshotGenerationFileNames,
+ getContextSnapshotGenerationFileName,
+ isContextSnapshotGenerationFileName,
+ isRecordObject,
+ validateRunManifest,
+ validateSnapshot,
+} from './records.ts';
+
+const contextStoreDirectoryName = 'context-v1';
+
+const getContextStoreRoot = (workspaceRoot: string): string =>
+ path.join(getProjectCacheDir(workspaceRoot), contextStoreDirectoryName);
+
+const getRunRoot = (storeRoot: string, runId: string): string =>
+ path.join(storeRoot, 'runs', runId);
+
+const getRunManifestPath = (storeRoot: string, runId: string): string =>
+ path.join(getRunRoot(storeRoot, runId), 'run.json');
+
+const getSnapshotPath = (storeRoot: string, snapshot: ContextSnapshot): string =>
+ path.join(
+ getRunRoot(storeRoot, snapshot.runId),
+ 'contexts',
+ snapshot.contextId,
+ 'generations',
+ getContextSnapshotGenerationFileName(snapshot),
+ );
+
+const serializeRecord = (record: unknown): string => `${JSON.stringify(record)}\n`;
+
+const publishImmutableRecord = async (
+ filePath: string,
+ content: string,
+): Promise => {
+ const temporaryPath = path.join(
+ path.dirname(filePath),
+ `.${path.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`,
+ );
+
+ try {
+ await mkdir(path.dirname(filePath), { recursive: true });
+ await writeFile(temporaryPath, content, { flag: 'wx' });
+ await link(temporaryPath, filePath);
+ return { written: true, path: filePath };
+ } catch (error) {
+ return { written: false, path: filePath, error };
+ } finally {
+ await rm(temporaryPath, { force: true }).catch(() => undefined);
+ }
+};
+
+const unavailableWrite = (workspaceRoot: string, error: unknown): ContextStoreWriteResult => ({
+ written: false,
+ path: getContextStoreRoot(workspaceRoot),
+ error,
+});
+
+const writeContextRunManifest = async (
+ workspaceRoot: string,
+ run: ContextRunManifest,
+): Promise => {
+ if (validateRunManifest(run) === undefined) {
+ return unavailableWrite(workspaceRoot, new Error('Invalid context run manifest.'));
+ }
+
+ try {
+ const cache = await ensureProjectCacheDir(workspaceRoot);
+ if (cache.status === 'unavailable') {
+ return unavailableWrite(workspaceRoot, cache.error);
+ }
+ return publishImmutableRecord(
+ getRunManifestPath(path.join(cache.path, contextStoreDirectoryName), run.runId),
+ serializeRecord(run),
+ );
+ } catch (error) {
+ return unavailableWrite(workspaceRoot, error);
+ }
+};
+
+const writeContextSnapshot = async (
+ workspaceRoot: string,
+ snapshot: ContextSnapshot,
+): Promise => {
+ if (validateSnapshot(snapshot) === undefined) {
+ return unavailableWrite(workspaceRoot, new Error('Invalid context snapshot.'));
+ }
+
+ try {
+ const cache = await ensureProjectCacheDir(workspaceRoot);
+ if (cache.status === 'unavailable') {
+ return unavailableWrite(workspaceRoot, cache.error);
+ }
+ return publishImmutableRecord(
+ getSnapshotPath(path.join(cache.path, contextStoreDirectoryName), snapshot),
+ serializeRecord(snapshot),
+ );
+ } catch (error) {
+ return unavailableWrite(workspaceRoot, error);
+ }
+};
+
+type ReadRecordResult =
+ | { status: 'missing' }
+ | { status: 'issue'; issue: ContextStoreIssue }
+ | { status: 'value'; value: unknown };
+
+const readRecord = async (filePath: string, relativePath: string): Promise => {
+ try {
+ return {
+ status: 'value',
+ value: JSON.parse(await readFile(filePath, 'utf8')) as unknown,
+ };
+ } catch (error) {
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
+ return { status: 'missing' };
+ }
+ return {
+ status: 'issue',
+ issue: { code: 'invalid-record', path: relativePath },
+ };
+ }
+};
+
+const readDirectoryNames = async (directoryPath: string): Promise => {
+ try {
+ const entries = await readdir(directoryPath, { withFileTypes: true });
+ return entries
+ .filter((entry) => entry.isDirectory())
+ .map((entry) => entry.name)
+ .sort();
+ } catch {
+ return [];
+ }
+};
+
+const readLatestSnapshot = async (
+ storeRoot: string,
+ run: ContextRunManifest,
+ context: ContextDescriptor,
+ issues: ContextStoreIssue[],
+): Promise => {
+ const relativeGenerationRoot = path.posix.join(
+ 'runs',
+ run.runId,
+ 'contexts',
+ context.contextId,
+ 'generations',
+ );
+ const generationRoot = path.join(storeRoot, ...relativeGenerationRoot.split('/'));
+ let fileNames: string[];
+ try {
+ fileNames = (await readdir(generationRoot))
+ .filter((fileName) => fileName.endsWith('.json'))
+ .sort(compareContextSnapshotGenerationFileNames);
+ } catch {
+ return undefined;
+ }
+
+ for (const fileName of fileNames) {
+ const relativePath = path.posix.join(relativeGenerationRoot, fileName);
+ const record = await readRecord(path.join(generationRoot, fileName), relativePath);
+ if (record.status === 'issue') {
+ issues.push(record.issue);
+ continue;
+ }
+ const snapshot = record.status === 'value' ? validateSnapshot(record.value) : undefined;
+ if (
+ snapshot === undefined ||
+ snapshot.runId !== run.runId ||
+ snapshot.contextId !== context.contextId ||
+ !isContextSnapshotGenerationFileName(fileName, snapshot)
+ ) {
+ if (record.status === 'value') {
+ issues.push({ code: 'invalid-record', path: relativePath });
+ }
+ continue;
+ }
+ return snapshot;
+ }
+ return undefined;
+};
+
+type ContextSnapshotFilters = {
+ producer?: ContextProducer;
+ contextId?: string;
+};
+
+const readRunSnapshots = async (
+ storeRoot: string,
+ run: ContextRunManifest,
+): Promise => {
+ const snapshots: StoredContextSnapshot[] = [];
+ for (const context of run.contexts) {
+ const generationRoot = path.join(
+ getRunRoot(storeRoot, run.runId),
+ 'contexts',
+ context.contextId,
+ 'generations',
+ );
+ let fileNames: string[];
+ try {
+ fileNames = (await readdir(generationRoot))
+ .filter((fileName) => fileName.endsWith('.json'))
+ .sort(compareContextSnapshotGenerationFileNames);
+ } catch {
+ continue;
+ }
+ for (const fileName of fileNames) {
+ const record = await readRecord(path.join(generationRoot, fileName), fileName);
+ const snapshot = record.status === 'value' ? validateSnapshot(record.value) : undefined;
+ if (
+ snapshot !== undefined &&
+ snapshot.status !== 'queued' &&
+ snapshot.status !== 'running' &&
+ snapshot.runId === run.runId &&
+ snapshot.contextId === context.contextId &&
+ isContextSnapshotGenerationFileName(fileName, snapshot)
+ ) {
+ snapshots.push({ run, context, snapshot });
+ }
+ }
+ }
+ return snapshots;
+};
+
+const compareStoredSnapshots = (
+ left: StoredContextSnapshot,
+ right: StoredContextSnapshot,
+): number =>
+ compareStringsDescending(left.snapshot.observedAt, right.snapshot.observedAt) ||
+ compareStringsDescending(left.run.startedAt, right.run.startedAt) ||
+ compareStringsDescending(left.snapshot.snapshotId, right.snapshot.snapshotId);
+
+const readContextSnapshots = async (
+ workspaceRoot: string,
+ filters: ContextSnapshotFilters = {},
+): Promise => {
+ const storeRoot = getContextStoreRoot(workspaceRoot);
+ const snapshots: StoredContextSnapshot[] = [];
+ for (const runId of await readDirectoryNames(path.join(storeRoot, 'runs'))) {
+ const record = await readRecord(getRunManifestPath(storeRoot, runId), 'run.json');
+ const run = record.status === 'value' ? validateRunManifest(record.value) : undefined;
+ if (
+ run === undefined ||
+ run.runId !== runId ||
+ (filters.producer !== undefined && run.producer !== filters.producer)
+ ) {
+ continue;
+ }
+ const runSnapshots = await readRunSnapshots(storeRoot, run);
+ snapshots.push(
+ ...runSnapshots.filter(
+ ({ context }) => filters.contextId === undefined || context.contextId === filters.contextId,
+ ),
+ );
+ }
+ return snapshots.sort(compareStoredSnapshots);
+};
+
+const readContextSnapshotById = async (
+ workspaceRoot: string,
+ snapshotId: string,
+): Promise =>
+ (await readContextSnapshots(workspaceRoot)).find(
+ ({ snapshot }) => snapshot.snapshotId === snapshotId,
+ );
+
+const readContextWorkspaceStatus = async (
+ workspaceRoot: string,
+): Promise => {
+ const storeRoot = getContextStoreRoot(workspaceRoot);
+ const issues: ContextStoreIssue[] = [];
+ const runs = [];
+
+ for (const runId of await readDirectoryNames(path.join(storeRoot, 'runs'))) {
+ const relativePath = path.posix.join('runs', runId, 'run.json');
+ const record = await readRecord(getRunManifestPath(storeRoot, runId), relativePath);
+ if (record.status === 'issue') {
+ issues.push(record.issue);
+ continue;
+ }
+ if (record.status === 'missing') {
+ continue;
+ }
+ if (!isRecordObject(record.value) || record.value.schemaVersion !== contextStoreSchemaVersion) {
+ issues.push({
+ code: isRecordObject(record.value) ? 'unsupported-schema' : 'invalid-record',
+ path: relativePath,
+ });
+ continue;
+ }
+ const run = validateRunManifest(record.value);
+ if (run === undefined || run.runId !== runId) {
+ issues.push({ code: 'invalid-record', path: relativePath });
+ continue;
+ }
+
+ runs.push({
+ run,
+ contexts: await Promise.all(
+ run.contexts.map(async (context) => {
+ const latestSnapshot = await readLatestSnapshot(storeRoot, run, context, issues);
+ return {
+ context,
+ ...(latestSnapshot === undefined ? {} : { latestSnapshot }),
+ };
+ }),
+ ),
+ });
+ }
+
+ issues.sort((left, right) =>
+ left.path === right.path
+ ? left.code.localeCompare(right.code)
+ : left.path.localeCompare(right.path),
+ );
+ return { schemaVersion: contextStoreSchemaVersion, runs, issues };
+};
+
+export {
+ readContextSnapshotById,
+ readContextSnapshots,
+ readContextWorkspaceStatus,
+ writeContextRunManifest,
+ writeContextSnapshot,
+};
+export type { ContextSnapshotFilters };
diff --git a/src/testRun.ts b/src/testRun.ts
new file mode 100644
index 0000000..8222605
--- /dev/null
+++ b/src/testRun.ts
@@ -0,0 +1,656 @@
+import { randomUUID } from 'node:crypto';
+import path from 'node:path';
+import type { RunRstestOptions, TestRunResult } from '@rstest/core/api';
+import {
+ contextStoreSchemaVersion,
+ type ContextFreshness,
+ type ContextRunStatus,
+ type ContextSnapshot,
+ type JsonValue,
+ type TestCaseRecord,
+ type TestErrorRecord,
+ type TestExecutionFacet,
+ type TestFacet,
+ type TestFileRecord,
+} from './model.ts';
+import {
+ normalizeExecutionFacet,
+ unavailableExecutionFacet,
+ validateExecutionRequest,
+ type TestExecutionRequest,
+} from './execution.ts';
+import { isRecordObject } from './guards.ts';
+import { decodeCursor, encodeCursor } from './pagination.ts';
+import { toWorkspacePath } from './paths.ts';
+import {
+ assessSnapshotFreshness,
+ collectContextInputFiles,
+ createExplicitContextDescriptor,
+ createExplicitRun,
+ resolveExplicitCaptureTarget,
+ resolveInternalConfigPath,
+ type ConfigTargetRunner,
+} from './source.ts';
+import {
+ readContextSnapshotById,
+ readContextSnapshots,
+ writeContextRunManifest,
+ writeContextSnapshot,
+} from './store.ts';
+
+type TestSnapshotRequest = {
+ files?: string[];
+ related?: string[];
+ testNamePattern?: string;
+ packageRoot?: string;
+ configPath?: string;
+ execution?: TestExecutionRequest;
+};
+
+type TestResultsQuery = {
+ snapshotId?: string;
+ project?: string;
+ pathPrefix?: string;
+ status?: TestCaseRecord['status'];
+ limit?: number;
+ cursor?: string;
+};
+
+type TestResultPage = {
+ producer: 'rstest';
+ contextId: string;
+ snapshotId: string;
+ observedAt: string;
+ completeness: ContextSnapshot['completeness'];
+ freshness: ContextFreshness;
+ total: number;
+ items: TestCaseRecord[];
+ nextCursor?: string;
+};
+
+type TestCaptureResult = {
+ runId: string;
+ contextId: string;
+ snapshotId: string;
+ status: ContextRunStatus;
+ freshness: ContextFreshness;
+ summary: Record;
+ execution?: {
+ provider: TestExecutionFacet['provider'];
+ availability: TestExecutionFacet['availability'];
+ completeness: TestExecutionFacet['universe']['completeness'];
+ };
+ errors?: TestCaptureError[];
+ unhandledErrors?: TestErrorRecord[];
+ unreadableInputs?: string[];
+};
+
+type TestCaptureError = TestErrorRecord & {
+ scope: 'file' | 'run';
+ project?: string;
+ path?: string;
+};
+
+type RunRstest = (options?: RunRstestOptions) => Promise;
+
+type RelatedTestRequest = {
+ packageRoot: string;
+ configPath?: string;
+ sources: string[];
+};
+
+type ResolveRelatedTests = (request: RelatedTestRequest) => Promise;
+
+type TestCaptureDependencies = {
+ runRstest?: RunRstest;
+ createRunId?: () => string;
+ createSnapshotId?: () => string;
+ now?: () => Date;
+ wrapperConfigPath?: string;
+ withConfigTarget?: ConfigTargetRunner;
+ resolveRelatedTests?: ResolveRelatedTests;
+ isTestConfigured?: (target: {
+ packageRoot: string;
+ configPath?: string;
+ }) => boolean | Promise;
+ hasCoverageProvider?: (packageRoot: string) => boolean | Promise;
+};
+
+const optionalString = (
+ key: K,
+ value: TestErrorRecord[K] | undefined,
+): Pick | Record =>
+ value === undefined ? {} : ({ [key]: value } as Pick);
+
+// Rstest serializes `cause` chains recursively with its own cycle detection, but the depth is
+// still attacker/author controlled, so capture truncates rather than trusting the producer.
+const maxErrorCauseDepth = 8;
+
+const normalizeError = (
+ error: TestRunResult['unhandledErrors'][number],
+ depth = 0,
+): TestErrorRecord => ({
+ name: error.name,
+ message: error.message,
+ ...optionalString('stack', error.stack),
+ ...optionalString('diff', error.diff),
+ ...optionalString('actual', error.actual),
+ ...optionalString('expected', error.expected),
+ ...(error.retryCount === undefined ? {} : { retryCount: error.retryCount }),
+ ...(error.cause === undefined || depth >= maxErrorCauseDepth
+ ? {}
+ : { cause: normalizeError(error.cause, depth + 1) }),
+});
+
+const normalizeErrors = (
+ errors: ReadonlyArray,
+): TestErrorRecord[] => errors.map((error) => normalizeError(error));
+
+// Task metadata is free-form producer data. It is stored only when the whole value is JSON-safe,
+// and object keys are sorted so a re-capture of identical metadata serializes byte-identically.
+const maxMetaDepth = 8;
+
+const normalizeMetaValue = (value: unknown, depth: number): JsonValue | undefined => {
+ if (value === null || typeof value === 'boolean' || typeof value === 'string') return value;
+ if (typeof value === 'number') return Number.isFinite(value) ? value : undefined;
+ if (depth >= maxMetaDepth) return undefined;
+ if (Array.isArray(value)) {
+ const items: JsonValue[] = [];
+ for (const entry of value) {
+ const item = normalizeMetaValue(entry, depth + 1);
+ if (item === undefined) return undefined;
+ items.push(item);
+ }
+ return items;
+ }
+ if (!isRecordObject(value)) return undefined;
+ const record: Record = {};
+ for (const key of Object.keys(value).sort((left, right) => left.localeCompare(right))) {
+ const entry = normalizeMetaValue(value[key], depth + 1);
+ if (entry === undefined) return undefined;
+ record[key] = entry;
+ }
+ return record;
+};
+
+const optionalTestMeta = (value: unknown): Pick | Record => {
+ if (!isRecordObject(value) || Object.keys(value).length === 0) return {};
+ const normalized = normalizeMetaValue(value, 0);
+ return isRecordObject(normalized) ? { meta: normalized as Record } : {};
+};
+
+const compareTestCases = (left: TestCaseRecord, right: TestCaseRecord): number =>
+ left.project.localeCompare(right.project) ||
+ left.path.localeCompare(right.path) ||
+ (left.parentNames ?? []).join('\u0000').localeCompare((right.parentNames ?? []).join('\u0000')) ||
+ left.name.localeCompare(right.name);
+
+const normalizeTestCase = (
+ workspaceRoot: string,
+ result: TestRunResult['files'][number]['results'][number],
+): TestCaseRecord => ({
+ project: result.project,
+ path: toWorkspacePath(workspaceRoot, result.testPath),
+ name: result.name,
+ ...(result.parentNames === undefined ? {} : { parentNames: [...result.parentNames] }),
+ status: result.status,
+ ...(result.duration === undefined ? {} : { durationMs: result.duration }),
+ ...(result.errors === undefined ? {} : { errors: normalizeErrors(result.errors) }),
+ ...(result.retryErrors === undefined ? {} : { retryErrors: normalizeErrors(result.retryErrors) }),
+ ...(result.retryCount === undefined ? {} : { retryCount: result.retryCount }),
+ ...optionalTestMeta(result.meta),
+});
+
+const normalizeTestFile = (
+ workspaceRoot: string,
+ result: TestRunResult['files'][number],
+): TestFileRecord => ({
+ project: result.project,
+ path: toWorkspacePath(workspaceRoot, result.testPath),
+ status: result.status,
+ ...(result.duration === undefined ? {} : { durationMs: result.duration }),
+ ...(result.errors === undefined ? {} : { errors: normalizeErrors(result.errors) }),
+ tests: result.results
+ .map((testResult) => normalizeTestCase(workspaceRoot, testResult))
+ .sort(compareTestCases),
+});
+
+const normalizeTestFacet = (
+ workspaceRoot: string,
+ result: TestRunResult,
+ relation?: TestFacet['relation'],
+): TestFacet => ({
+ producer: 'rstest',
+ ...(relation === undefined ? {} : { relation }),
+ files: result.files
+ .map((fileResult) => normalizeTestFile(workspaceRoot, fileResult))
+ .sort(
+ (left, right) =>
+ left.project.localeCompare(right.project) || left.path.localeCompare(right.path),
+ ),
+ stats: {
+ tests: { ...result.stats.tests },
+ files: { ...result.stats.files },
+ },
+ durationMs: result.duration.total,
+ unhandledErrors: normalizeErrors(result.unhandledErrors),
+});
+
+const testCaptureSelection = (request: TestSnapshotRequest): JsonValue => ({
+ ...(request.files === undefined
+ ? {}
+ : { files: [...new Set(request.files)].sort((left, right) => left.localeCompare(right)) }),
+ ...(request.related === undefined
+ ? {}
+ : { related: [...new Set(request.related)].sort((left, right) => left.localeCompare(right)) }),
+ ...(request.testNamePattern === undefined ? {} : { testNamePattern: request.testNamePattern }),
+});
+
+const getRunStatus = (result: TestRunResult): ContextRunStatus => {
+ if (result.unhandledErrors.length > 0) return 'error';
+ return result.ok ? 'pass' : 'fail';
+};
+
+const ensureWritten = (result: Awaited>): void => {
+ if (!result.written) throw result.error;
+};
+
+const loadRunRstest = async (): Promise => (await import('@rstest/core/api')).runRstest;
+
+const loadCoverageProviderLoader = async (): Promise<
+ typeof import('@rstest/core/internal/browser').loadCoverageProvider
+> => (await import('@rstest/core/internal/browser')).loadCoverageProvider;
+
+// Detection runs Rstest's own provider loader against the package under test, which is exactly
+// what `addCoveragePlugin` does when coverage is enabled, so this probe cannot disagree with the
+// resolution the run itself will perform. It imports the provider module (not just resolves it),
+// so a provider that fails to evaluate is reported as absent rather than crashing the capture.
+const hasCoverageProvider = async (packageRoot: string): Promise => {
+ try {
+ const loadCoverageProvider = await loadCoverageProviderLoader();
+ await loadCoverageProvider({ provider: 'istanbul' }, packageRoot);
+ return true;
+ } catch {
+ return false;
+ }
+};
+
+const validateRelatedSelection = (request: TestSnapshotRequest): void => {
+ if (request.files !== undefined && request.related !== undefined) {
+ throw new Error('files and related cannot be used together.');
+ }
+ if (
+ request.related !== undefined &&
+ (request.related.length === 0 ||
+ request.related.length > 200 ||
+ request.related.some((source) => source.length === 0))
+ ) {
+ throw new Error('related must contain from 1 to 200 non-empty source paths.');
+ }
+};
+
+const validateTestCaptureWiring = (
+ request: TestSnapshotRequest,
+ dependencies: TestCaptureDependencies,
+): void => {
+ if (request.related !== undefined && dependencies.resolveRelatedTests === undefined) {
+ throw new Error('Rstack test capture requires a related-test resolver.');
+ }
+ if (dependencies.withConfigTarget === undefined && dependencies.runRstest === undefined) {
+ throw new Error('Rstack test capture requires a config adapter.');
+ }
+};
+
+const emptyTestRunResult = (): TestRunResult => ({
+ ok: true,
+ files: [],
+ stats: {
+ tests: { total: 0, passed: 0, failed: 0, skipped: 0, todo: 0 },
+ files: { total: 0, failed: 0 },
+ },
+ unhandledErrors: [],
+ duration: { total: 0 },
+});
+
+const captureTestSnapshot = async (
+ workspaceRoot: string,
+ request: TestSnapshotRequest,
+ dependencies: TestCaptureDependencies = {},
+): Promise => {
+ validateExecutionRequest(request.execution);
+ validateRelatedSelection(request);
+ validateTestCaptureWiring(request, dependencies);
+ const target = await resolveExplicitCaptureTarget(workspaceRoot, request);
+ if (
+ dependencies.isTestConfigured !== undefined &&
+ !(await dependencies.isTestConfigured({
+ packageRoot: target.packageRoot,
+ ...(target.configPath === undefined ? {} : { configPath: target.configPath }),
+ }))
+ ) {
+ const packageRoot = toWorkspacePath(workspaceRoot, target.packageRoot) || '.';
+ throw new Error(
+ `Rstest is not configured for package root "${packageRoot}". packageRoot is checkout-relative; call project_status and use context.packageRoot.`,
+ );
+ }
+ const wrapperConfigPath =
+ dependencies.wrapperConfigPath ??
+ resolveInternalConfigPath(import.meta.dirname, 'rstestConfig.js');
+ const execution = request.execution;
+ const captureExecution =
+ execution !== undefined &&
+ (await (dependencies.hasCoverageProvider ?? hasCoverageProvider)(target.packageRoot));
+ const executionOptions =
+ execution === undefined || !captureExecution
+ ? {}
+ : {
+ inlineConfig: {
+ coverage: {
+ enabled: true,
+ provider: 'istanbul' as const,
+ reporters: [],
+ reportOnFailure: true,
+ ...(execution.include === undefined ? {} : { include: execution.include }),
+ ...(execution.exclude === undefined ? {} : { exclude: execution.exclude }),
+ allowExternal: execution.allowExternal ?? false,
+ },
+ },
+ };
+ const context = createExplicitContextDescriptor({
+ producer: 'rstest',
+ workspaceRoot,
+ ...target,
+ });
+ const captureSelection = testCaptureSelection(request);
+ const now = dependencies.now ?? (() => new Date());
+ const run = createExplicitRun({
+ producer: 'rstest',
+ command: 'test',
+ context,
+ createRunId: dependencies.createRunId,
+ now,
+ });
+ ensureWritten(await writeContextRunManifest(workspaceRoot, run));
+ let result: TestRunResult | undefined;
+ let relation: TestFacet['relation'];
+ try {
+ let selectedFiles = request.files;
+ if (request.related !== undefined && dependencies.resolveRelatedTests !== undefined) {
+ const resolveRelatedTests = dependencies.resolveRelatedTests;
+ const sourceFiles = [
+ ...new Set(request.related.map((source) => path.resolve(target.packageRoot, source))),
+ ];
+ const testFiles = [
+ ...new Set(
+ await resolveRelatedTests({
+ packageRoot: target.packageRoot,
+ configPath: target.configPath,
+ sources: sourceFiles,
+ }),
+ ),
+ ];
+ relation = {
+ sources: sourceFiles.map((source) => toWorkspacePath(workspaceRoot, source)).sort(),
+ testFiles: testFiles.map((file) => toWorkspacePath(workspaceRoot, file)).sort(),
+ };
+ selectedFiles = testFiles.map((file) => toWorkspacePath(target.packageRoot, file));
+ if (selectedFiles.length === 0) result = emptyTestRunResult();
+ }
+
+ if (result === undefined) {
+ const runRstest = dependencies.runRstest ?? (await loadRunRstest());
+ const withConfigTarget =
+ dependencies.withConfigTarget ?? (async (_configRoot, _configPath, action) => action());
+ result = await withConfigTarget(target.packageRoot, target.configPath, () =>
+ runRstest({
+ cwd: target.packageRoot,
+ config: wrapperConfigPath,
+ ...executionOptions,
+ ...(selectedFiles === undefined ? {} : { files: selectedFiles }),
+ ...(request.testNamePattern === undefined
+ ? {}
+ : { testNamePattern: request.testNamePattern }),
+ }),
+ );
+ }
+ } catch (error) {
+ const capturedError: TestErrorRecord =
+ error instanceof Error
+ ? {
+ name: error.name,
+ message: error.message,
+ ...optionalString('stack', error.stack),
+ }
+ : { name: 'Error', message: String(error) };
+ const facet: TestFacet = {
+ producer: 'rstest',
+ files: [],
+ stats: {
+ tests: { total: 0, passed: 0, failed: 0, skipped: 0, todo: 0 },
+ files: { total: 0, failed: 0 },
+ },
+ durationMs: 0,
+ unhandledErrors: [capturedError],
+ };
+ const executionFacet =
+ request.execution === undefined ? undefined : unavailableExecutionFacet(request.execution);
+ const snapshot: ContextSnapshot = {
+ schemaVersion: contextStoreSchemaVersion,
+ snapshotId: dependencies.createSnapshotId?.() ?? `snap_${Date.now()}_${randomUUID()}`,
+ runId: run.runId,
+ contextId: context.contextId,
+ sequence: 0,
+ observedAt: now().toISOString(),
+ status: 'error',
+ completeness: {
+ test: 'partial',
+ ...(executionFacet === undefined ? {} : { execution: 'partial' }),
+ },
+ facets: {
+ test: facet as unknown as JsonValue,
+ ...(executionFacet === undefined
+ ? {}
+ : { execution: executionFacet as unknown as JsonValue }),
+ },
+ source: { inputs: [], inputCompleteness: 'partial', captureSelection },
+ };
+
+ ensureWritten(await writeContextSnapshot(workspaceRoot, snapshot));
+ throw error;
+ }
+ const facet = normalizeTestFacet(workspaceRoot, result, relation);
+ const executionFacet =
+ execution === undefined
+ ? undefined
+ : !captureExecution
+ ? unavailableExecutionFacet(execution)
+ : await normalizeExecutionFacet(
+ workspaceRoot,
+ target.packageRoot,
+ execution,
+ result.coverage,
+ );
+ const recording = await collectContextInputFiles(workspaceRoot, [
+ ...new Set([
+ ...facet.files.map((file) => file.path),
+ ...(facet.relation?.sources ?? []),
+ ...(facet.relation?.testFiles ?? []),
+ ]),
+ ]);
+ const executionInputs =
+ executionFacet?.files.flatMap((file) =>
+ file.digest === undefined ? [] : [{ path: file.path, digest: file.digest }],
+ ) ?? [];
+ const inputs = [
+ ...new Map(
+ [...recording.inputs, ...executionInputs].map((input) => [input.path, input]),
+ ).values(),
+ ].sort((left, right) => left.path.localeCompare(right.path));
+ const unreadableInputs = recording.unreadablePaths.filter(
+ (unreadablePath) => !inputs.some((input) => input.path === unreadablePath),
+ );
+ const inputError =
+ unreadableInputs.length === 0
+ ? undefined
+ : Object.assign(
+ new Error(
+ `Could not read Rstest snapshot inputs: ${unreadableInputs.join(', ')}. Ensure the selected sources and reported test files exist and are readable.`,
+ ),
+ { name: 'TestInputError' },
+ );
+ const persistedFacet: TestFacet =
+ inputError === undefined
+ ? facet
+ : {
+ ...facet,
+ unhandledErrors: [
+ ...facet.unhandledErrors,
+ {
+ name: inputError.name,
+ message: inputError.message,
+ ...optionalString('stack', inputError.stack),
+ },
+ ],
+ };
+ const snapshot: ContextSnapshot = {
+ schemaVersion: contextStoreSchemaVersion,
+ snapshotId: dependencies.createSnapshotId?.() ?? `snap_${Date.now()}_${randomUUID()}`,
+ runId: run.runId,
+ contextId: context.contextId,
+ sequence: 0,
+ observedAt: now().toISOString(),
+ status: inputError === undefined ? getRunStatus(result) : 'error',
+ completeness: {
+ test: inputError === undefined ? 'complete' : 'partial',
+ ...(executionFacet === undefined
+ ? {}
+ : {
+ execution:
+ executionFacet.availability === 'available' &&
+ executionFacet.universe.completeness === 'complete'
+ ? 'complete'
+ : 'partial',
+ }),
+ ...(unreadableInputs.length === 0 ? {} : { source: 'partial' as const }),
+ },
+ facets: {
+ test: persistedFacet as unknown as JsonValue,
+ ...(executionFacet === undefined
+ ? {}
+ : { execution: executionFacet as unknown as JsonValue }),
+ },
+ source: {
+ inputs,
+ inputCompleteness: 'partial',
+ captureSelection,
+ ...(unreadableInputs.length === 0 ? {} : { unreadableInputs }),
+ },
+ };
+
+ ensureWritten(await writeContextSnapshot(workspaceRoot, snapshot));
+ if (inputError !== undefined) throw inputError;
+ const errors: TestCaptureError[] = [
+ ...facet.files.flatMap((file) =>
+ (file.errors ?? []).map((error) => ({
+ ...error,
+ scope: 'file' as const,
+ project: file.project,
+ path: file.path,
+ })),
+ ),
+ ...facet.unhandledErrors.map((error) => ({ ...error, scope: 'run' as const })),
+ ];
+ return {
+ runId: run.runId,
+ contextId: context.contextId,
+ snapshotId: snapshot.snapshotId,
+ status: snapshot.status,
+ freshness: await assessSnapshotFreshness(workspaceRoot, snapshot),
+ summary: {
+ files: facet.stats.files.total,
+ failedFiles: facet.stats.files.failed,
+ tests: facet.stats.tests.total,
+ failedTests: facet.stats.tests.failed,
+ errors: errors.length,
+ unhandledErrors: facet.unhandledErrors.length,
+ },
+ ...(executionFacet === undefined
+ ? {}
+ : {
+ execution: {
+ provider: executionFacet.provider,
+ availability: executionFacet.availability,
+ completeness: executionFacet.universe.completeness,
+ },
+ }),
+ ...(errors.length === 0 ? {} : { errors }),
+ ...(facet.unhandledErrors.length === 0 ? {} : { unhandledErrors: facet.unhandledErrors }),
+ ...(unreadableInputs.length === 0 ? {} : { unreadableInputs }),
+ };
+};
+
+const listTestResults = async (
+ workspaceRoot: string,
+ query: TestResultsQuery,
+): Promise => {
+ const stored =
+ query.snapshotId === undefined
+ ? (await readContextSnapshots(workspaceRoot, { producer: 'rstest' })).find(
+ ({ snapshot }) =>
+ snapshot.completeness.test === 'complete' && snapshot.facets.test !== undefined,
+ )
+ : await readContextSnapshotById(workspaceRoot, query.snapshotId);
+ if (stored === undefined || stored.run.producer !== 'rstest') {
+ throw new Error('Rstest snapshot not found.');
+ }
+ const facet = stored.snapshot.facets.test as unknown as TestFacet | undefined;
+ if (facet?.producer !== 'rstest') throw new Error('Rstest snapshot has no test facet.');
+
+ const items = facet.files
+ .flatMap((file) => file.tests)
+ .filter(
+ (item) =>
+ (query.project === undefined || item.project === query.project) &&
+ (query.pathPrefix === undefined || item.path.startsWith(query.pathPrefix)) &&
+ (query.status === undefined || item.status === query.status),
+ )
+ .sort(compareTestCases);
+ const cursorScope = {
+ surface: 'test_results',
+ selectedSnapshotId: stored.snapshot.snapshotId,
+ snapshotId: query.snapshotId,
+ project: query.project,
+ pathPrefix: query.pathPrefix,
+ status: query.status,
+ };
+ const offset = decodeCursor(query.cursor, 'Invalid test result cursor.', cursorScope);
+ const limit = query.limit ?? 50;
+ if (!Number.isInteger(limit) || limit < 1 || limit > 200) {
+ throw new Error('Test result limit must be an integer from 1 to 200.');
+ }
+ const pageItems = items.slice(offset, offset + limit);
+ const nextOffset = offset + pageItems.length;
+ return {
+ producer: 'rstest',
+ contextId: stored.snapshot.contextId,
+ snapshotId: stored.snapshot.snapshotId,
+ observedAt: stored.snapshot.observedAt,
+ completeness: stored.snapshot.completeness,
+ freshness: await assessSnapshotFreshness(workspaceRoot, stored.snapshot),
+ total: items.length,
+ items: pageItems,
+ ...(nextOffset < items.length ? { nextCursor: encodeCursor(nextOffset, cursorScope) } : {}),
+ };
+};
+
+export { captureTestSnapshot, listTestResults };
+export type {
+ TestCaptureDependencies,
+ TestCaptureError,
+ TestCaptureResult,
+ RelatedTestRequest,
+ ResolveRelatedTests,
+ TestResultPage,
+ TestResultsQuery,
+ TestSnapshotRequest,
+};
diff --git a/src/workspace.ts b/src/workspace.ts
new file mode 100644
index 0000000..2aeb8ef
--- /dev/null
+++ b/src/workspace.ts
@@ -0,0 +1,96 @@
+import { readFile, realpath, stat } from 'node:fs/promises';
+import path from 'node:path';
+
+type ResolvedContextWorkspace = {
+ workspaceRoot: string;
+ packageRoot: string;
+ packageName?: string;
+};
+
+type PackageMetadata = {
+ exists: boolean;
+ isWorkspace: boolean;
+ name?: string;
+};
+
+const pathExists = async (filePath: string): Promise => {
+ try {
+ await stat(filePath);
+ return true;
+ } catch {
+ return false;
+ }
+};
+
+const readPackageMetadata = async (directoryPath: string): Promise => {
+ try {
+ const value = JSON.parse(await readFile(path.join(directoryPath, 'package.json'), 'utf8')) as {
+ name?: unknown;
+ workspaces?: unknown;
+ };
+
+ return {
+ exists: true,
+ isWorkspace:
+ Array.isArray(value.workspaces) ||
+ (typeof value.workspaces === 'object' && value.workspaces !== null),
+ ...(typeof value.name === 'string' ? { name: value.name } : {}),
+ };
+ } catch (error) {
+ if (error instanceof SyntaxError) {
+ return { exists: true, isWorkspace: false };
+ }
+ return { exists: false, isWorkspace: false };
+ }
+};
+
+const hasPnpmWorkspaceManifest = async (directoryPath: string): Promise =>
+ (await pathExists(path.join(directoryPath, 'pnpm-workspace.yaml'))) ||
+ (await pathExists(path.join(directoryPath, 'pnpm-workspace.yml')));
+
+const resolveContextWorkspace = async (startPath: string): Promise => {
+ const canonicalStartPath = await realpath(startPath);
+ const startStats = await stat(canonicalStartPath);
+ const startDirectory = startStats.isDirectory()
+ ? canonicalStartPath
+ : path.dirname(canonicalStartPath);
+ let currentPath = startDirectory;
+ let packageRoot: string | undefined;
+ let packageName: string | undefined;
+ let workspaceRoot: string | undefined;
+ let checkoutRoot: string | undefined;
+
+ while (true) {
+ const packageMetadata = await readPackageMetadata(currentPath);
+ if (packageRoot === undefined && packageMetadata.exists) {
+ packageRoot = currentPath;
+ packageName = packageMetadata.name;
+ }
+ if (
+ workspaceRoot === undefined &&
+ (packageMetadata.isWorkspace || (await hasPnpmWorkspaceManifest(currentPath)))
+ ) {
+ workspaceRoot = currentPath;
+ }
+ if (checkoutRoot === undefined && (await pathExists(path.join(currentPath, '.git')))) {
+ checkoutRoot = currentPath;
+ break;
+ }
+
+ const parentPath = path.dirname(currentPath);
+ if (parentPath === currentPath) {
+ break;
+ }
+ currentPath = parentPath;
+ }
+
+ const resolvedWorkspaceRoot = workspaceRoot ?? checkoutRoot ?? packageRoot ?? startDirectory;
+ return {
+ workspaceRoot: resolvedWorkspaceRoot,
+ packageRoot: packageRoot ?? resolvedWorkspaceRoot,
+ ...(packageName === undefined ? {} : { packageName }),
+ };
+};
+
+export { resolveContextWorkspace };
+export type { ResolvedContextWorkspace };
diff --git a/tests/build.test.ts b/tests/build.test.ts
new file mode 100644
index 0000000..c80918f
--- /dev/null
+++ b/tests/build.test.ts
@@ -0,0 +1,705 @@
+/* rslint-disable @typescript-eslint/no-unsafe-assignment -- Rstest asymmetric matchers are intentionally untyped. */
+import { mkdir, writeFile } from 'node:fs/promises';
+import path from 'node:path';
+import type { ConfigParams, RsbuildConfig } from '@rsbuild/core';
+import { expect, test } from '@rstest/core';
+import {
+ appendBuildContextPlugin,
+ createBuildContextPlugin,
+ readContextWorkspaceStatus,
+ recordContextInputFiles,
+ type BuildMetadataFacet,
+ type ResolvedContextWorkspace,
+} from '../src/index.ts';
+import { withTempWorkspace } from './helpers.ts';
+
+type BeforeHook = (context: { environments: Record }) => Promise | void;
+
+type ObserverHooks = {
+ beforeBuild?: BeforeHook;
+ beforeDevCompile?: BeforeHook;
+ afterEnvironmentCompile?: (context: AfterCompileContext) => Promise | void;
+};
+
+type AfterCompileContext = {
+ environment: unknown;
+ isFirstCompile: boolean;
+ isWatch: boolean;
+ stats?: {
+ hasErrors: () => boolean;
+ hasWarnings: () => boolean;
+ toJson: (options: unknown) => unknown;
+ };
+ time: number;
+};
+
+type ObserverHarness = {
+ hooks: ObserverHooks;
+ warnings: string[];
+};
+
+const getObserverHarness = (
+ plugin: ReturnType,
+): ObserverHarness => {
+ const hooks: ObserverHooks = {};
+ const warnings: string[] = [];
+
+ void plugin.setup?.({
+ logger: {
+ warn: (message: string) => {
+ warnings.push(message);
+ },
+ },
+ onBeforeBuild: (callback: BeforeHook) => {
+ hooks.beforeBuild = callback;
+ },
+ onBeforeDevCompile: (callback: BeforeHook) => {
+ hooks.beforeDevCompile = callback;
+ },
+ onAfterEnvironmentCompile: (
+ callback: (context: AfterCompileContext) => Promise | void,
+ ) => {
+ hooks.afterEnvironmentCompile = callback;
+ },
+ } as never);
+
+ return { hooks, warnings };
+};
+
+const getObserverHooks = (plugin: ReturnType): ObserverHooks =>
+ getObserverHarness(plugin).hooks;
+
+const createEnvironment = (name: string, target: string) => ({
+ config: { output: { target } },
+ distPath: path.resolve('dist'),
+ name,
+});
+
+const createStats = ({
+ hash = 'hash',
+ hasErrors = false,
+ hasWarnings = false,
+ json = {},
+}: {
+ hash?: string;
+ hasErrors?: boolean;
+ hasWarnings?: boolean;
+ json?: Record;
+}) => {
+ const calls: unknown[] = [];
+
+ return {
+ calls,
+ hasErrors: () => hasErrors,
+ hasWarnings: () => hasWarnings,
+ toJson: (options: unknown) => {
+ calls.push(options);
+ return { hash, ...json };
+ },
+ };
+};
+
+const invokeAfter = async (hooks: ObserverHooks, context: AfterCompileContext): Promise => {
+ expect(hooks.afterEnvironmentCompile).toBeDefined();
+ await hooks.afterEnvironmentCompile!(context);
+};
+
+const collectContextId = async ({
+ workspaceRoot,
+ workspace,
+ configPath,
+ producer = 'rsbuild',
+ product = 'application',
+ variant,
+ params = { command: 'build', env: 'production' },
+ environment = 'web',
+ target = 'web',
+ distPath = path.join(workspaceRoot, 'dist'),
+}: {
+ workspaceRoot: string;
+ workspace: ResolvedContextWorkspace;
+ configPath: string;
+ producer?: 'rsbuild' | 'rslib';
+ product?: 'application' | 'library';
+ variant?: string;
+ params?: Pick;
+ environment?: string;
+ target?: string;
+ distPath?: string;
+}): Promise => {
+ const runId = `run_${Math.random().toString(16).slice(2)}`;
+ const plugin = createBuildContextPlugin({
+ producer,
+ product,
+ variant,
+ capture: 'metadata',
+ workspace,
+ configPath,
+ params: params as never,
+ createRunId: () => runId,
+ now: () => new Date('2026-08-12T08:00:00.000Z'),
+ });
+ const hooks = getObserverHooks(plugin);
+
+ await hooks.beforeBuild?.({
+ environments: {
+ [environment]: {
+ ...createEnvironment(environment, target),
+ distPath,
+ },
+ },
+ });
+
+ const status = await readContextWorkspaceStatus(workspaceRoot);
+ const run = status.runs.find((entry) => entry.run.runId === runId)?.run;
+ expect(run).toBeDefined();
+ return run!.contexts[0].contextId;
+};
+
+test('appends one observer without mutating user config or nested Rslib entries', () => {
+ const existingPlugin = { name: 'existing' };
+ const nestedPlugin = { name: 'nested' };
+ const lib = [{ format: 'esm' }, { format: 'cjs' }];
+ const config: {
+ lib: typeof lib;
+ plugins: RsbuildConfig['plugins'];
+ source: { alias: { '@': string } };
+ } = {
+ lib,
+ plugins: [false, [nestedPlugin, null], existingPlugin] as RsbuildConfig['plugins'],
+ source: { alias: { '@': './src' } },
+ };
+ const originalConfigJson = JSON.stringify(config);
+ const observer = { name: 'rstack:context' } as never;
+
+ const appended = appendBuildContextPlugin(config, observer);
+
+ expect(appended).not.toBe(config);
+ expect(appended.plugins).not.toBe(config.plugins);
+ expect(appended.plugins).toEqual([false, [nestedPlugin, null], existingPlugin, observer]);
+ expect(config.plugins).toEqual([false, [nestedPlugin, null], existingPlugin]);
+ expect(JSON.stringify(config)).toBe(originalConfigJson);
+ expect(appended.lib).toBe(lib);
+ expect(appended.lib).toEqual(lib);
+});
+
+test('publishes an aggregate manifest once and advances sequences per environment', async () => {
+ await withTempWorkspace('rstack-build-context-', async (workspaceRoot) => {
+ const plugin = createBuildContextPlugin({
+ producer: 'rslib',
+ product: 'library',
+ capture: 'deep',
+ workspace: {
+ workspaceRoot,
+ packageRoot: path.join(workspaceRoot, 'packages', 'library'),
+ packageName: '@repo/library',
+ },
+ configPath: path.join(workspaceRoot, 'packages', 'library', 'rstack.config.ts'),
+ params: { command: 'build', env: 'production' },
+ createRunId: () => 'run_lifecycle',
+ now: () => new Date('2026-08-12T08:30:00.000Z'),
+ });
+ const { hooks } = getObserverHarness(plugin);
+ const esm = createEnvironment('esm', 'web');
+ const cjs = createEnvironment('cjs', 'node');
+ const environments = { cjs, esm };
+
+ await hooks.beforeBuild?.({ environments });
+ await hooks.beforeDevCompile?.({ environments });
+
+ const esmStats = createStats({
+ hash: 'esm-hash',
+ hasWarnings: true,
+ json: {
+ assets: [{ name: 'dist/esm.js', size: 10 }],
+ chunks: [{ files: ['dist/esm.js'], id: 'esm', initial: true }],
+ },
+ });
+ await invokeAfter(hooks, {
+ environment: esm,
+ isFirstCompile: true,
+ isWatch: true,
+ stats: esmStats,
+ time: 12,
+ });
+ await invokeAfter(hooks, {
+ environment: esm,
+ isFirstCompile: false,
+ isWatch: true,
+ stats: esmStats,
+ time: 13,
+ });
+ await invokeAfter(hooks, {
+ environment: cjs,
+ isFirstCompile: true,
+ isWatch: true,
+ stats: createStats({ hasErrors: true }),
+ time: 14,
+ });
+
+ const status = await readContextWorkspaceStatus(workspaceRoot);
+ expect(status.runs).toHaveLength(1);
+ expect(status.runs[0].run).toMatchObject({
+ command: 'build',
+ contexts: [
+ { environment: 'cjs', product: 'library', target: 'node' },
+ { environment: 'esm', product: 'library', target: 'web' },
+ ],
+ producer: 'rslib',
+ runId: 'run_lifecycle',
+ });
+
+ const snapshots = new Map(
+ status.runs[0].contexts.map(({ context, latestSnapshot }) => [
+ context.environment,
+ latestSnapshot,
+ ]),
+ );
+ expect(snapshots.get('esm')).toMatchObject({
+ sequence: 2,
+ status: 'pass',
+ completeness: { build: 'complete', deep: 'unsupported' },
+ facets: {
+ build: {
+ producer: 'rslib',
+ command: 'build',
+ mode: 'production',
+ environment: 'esm',
+ target: ['web'],
+ isWatch: true,
+ isFirstCompile: false,
+ durationMs: 13,
+ hash: 'esm-hash',
+ hasErrors: false,
+ hasWarnings: true,
+ assets: [{ name: 'dist/esm.js', size: 10 }],
+ chunks: [{ id: 'esm', files: ['dist/esm.js'], initial: true }],
+ truncated: { assets: 0, chunks: 0 },
+ },
+ },
+ });
+ expect(snapshots.get('cjs')).toMatchObject({
+ sequence: 1,
+ status: 'fail',
+ completeness: { build: 'complete', deep: 'unsupported' },
+ });
+ expect(esmStats.calls).toEqual([
+ {
+ all: false,
+ hash: true,
+ assets: true,
+ chunks: true,
+ errors: false,
+ warnings: false,
+ },
+ {
+ all: false,
+ hash: true,
+ assets: true,
+ chunks: true,
+ errors: false,
+ warnings: false,
+ },
+ ]);
+ });
+});
+
+test('bounds metadata rows and distinguishes disabled deep capture from partial builds', async () => {
+ await withTempWorkspace('rstack-build-context-', async (workspaceRoot) => {
+ const plugin = createBuildContextPlugin({
+ producer: 'rsbuild',
+ product: 'application',
+ capture: 'metadata',
+ workspace: {
+ workspaceRoot,
+ packageRoot: path.join(workspaceRoot, 'apps', 'web'),
+ },
+ params: { command: 'dev', env: 'development' },
+ createRunId: () => 'run_bounds',
+ now: () => new Date('2026-08-12T08:45:00.000Z'),
+ });
+ const { hooks } = getObserverHarness(plugin);
+ const environment = createEnvironment('web', 'web');
+ await hooks.beforeDevCompile?.({ environments: { web: environment } });
+
+ await invokeAfter(hooks, {
+ environment,
+ isFirstCompile: true,
+ isWatch: true,
+ stats: createStats({
+ json: {
+ assets: Array.from({ length: 101 }, (_, index) => ({
+ name: path.join(workspaceRoot, 'dist', `asset-${index}.js`),
+ size: index,
+ })),
+ chunks: Array.from({ length: 101 }, (_, index) => ({
+ files: Array.from({ length: 21 }, (_, fileIndex) =>
+ path.join(workspaceRoot, 'dist', `chunk-${index}-${fileIndex}.js`),
+ ),
+ id: `${index}`,
+ })),
+ },
+ }),
+ time: 20,
+ });
+
+ const cappedSnapshot = (await readContextWorkspaceStatus(workspaceRoot)).runs[0].contexts[0]
+ .latestSnapshot!;
+ const build = cappedSnapshot.facets.build as {
+ assets: Array<{ name: string; size: number }>;
+ chunks: Array<{ files: string[] }>;
+ truncated: { assets: number; chunks: number };
+ };
+ expect(build.assets).toHaveLength(100);
+ expect(build.assets[0]).toEqual({ name: 'dist/asset-0.js', size: 0 });
+ expect(build.chunks).toHaveLength(100);
+ expect(build.chunks[0].files).toHaveLength(21);
+ expect(build.chunks[0].files[0]).toBe('dist/chunk-0-0.js');
+ expect(build.chunks[0].files[20]).toBe('dist/chunk-0-20.js');
+ expect(build.truncated).toEqual({ assets: 1, chunks: 1 });
+ expect(JSON.stringify(cappedSnapshot)).not.toContain(workspaceRoot);
+
+ await invokeAfter(hooks, {
+ environment,
+ isFirstCompile: false,
+ isWatch: true,
+ time: 21,
+ });
+
+ const snapshot = (await readContextWorkspaceStatus(workspaceRoot)).runs[0].contexts[0]
+ .latestSnapshot!;
+ expect(snapshot).toMatchObject({
+ sequence: 2,
+ status: 'error',
+ completeness: { build: 'partial', deep: 'disabled' },
+ });
+ });
+});
+
+test('retains bounded valid metadata rows and counts only dropped valid rows', async () => {
+ await withTempWorkspace('rstack-build-context-', async (workspaceRoot) => {
+ const plugin = createBuildContextPlugin({
+ producer: 'rsbuild',
+ product: 'application',
+ capture: 'metadata',
+ workspace: { workspaceRoot, packageRoot: workspaceRoot },
+ params: { command: 'build', env: 'production' },
+ createRunId: () => 'run_high_cardinality',
+ });
+ const { hooks } = getObserverHarness(plugin);
+ const environment = createEnvironment('web', 'web');
+ const assets = Array.from({ length: 206 }, (_, index) =>
+ index % 2 === 0
+ ? { name: `dist/asset-${index / 2}.js`, size: index / 2 }
+ : { name: 123, size: 'invalid' },
+ );
+ const chunks = Array.from({ length: 206 }, (_, index) => {
+ if (index % 2 !== 0) {
+ return { files: 'invalid', id: `invalid-${index}` };
+ }
+
+ const chunkIndex = index / 2;
+ return {
+ files: Array.from({ length: 42 }, (_, fileIndex) =>
+ fileIndex % 2 === 0 ? `dist/chunk-${chunkIndex}-${fileIndex / 2}.js` : { invalid: true },
+ ),
+ id: chunkIndex,
+ initial: chunkIndex % 2 === 0,
+ };
+ });
+ const stats = createStats({ json: { assets, chunks } });
+
+ await hooks.beforeBuild?.({ environments: { web: environment } });
+ await invokeAfter(hooks, {
+ environment,
+ isFirstCompile: true,
+ isWatch: false,
+ stats,
+ time: 1,
+ });
+
+ const build = (await readContextWorkspaceStatus(workspaceRoot)).runs[0].contexts[0]
+ .latestSnapshot!.facets.build as BuildMetadataFacet;
+ expect(build.assets).toEqual(
+ Array.from({ length: 100 }, (_, index) => ({
+ name: `dist/asset-${index}.js`,
+ size: index,
+ })),
+ );
+ expect(build.chunks).toEqual(
+ Array.from({ length: 100 }, (_, chunkIndex) => ({
+ id: String(chunkIndex),
+ files: Array.from(
+ { length: 21 },
+ (_, fileIndex) => `dist/chunk-${chunkIndex}-${fileIndex}.js`,
+ ),
+ initial: chunkIndex % 2 === 0,
+ })),
+ );
+ expect(build.truncated).toEqual({ assets: 3, chunks: 3 });
+ expect(stats.calls).toEqual([
+ {
+ all: false,
+ hash: true,
+ assets: true,
+ chunks: true,
+ errors: false,
+ warnings: false,
+ },
+ ]);
+ });
+});
+
+test('keeps capture failures out of build hooks and warns once per observer', async () => {
+ await withTempWorkspace('rstack-build-context-', async (workspaceRoot) => {
+ const plugin = createBuildContextPlugin({
+ producer: 'rsbuild',
+ product: 'application',
+ capture: 'metadata',
+ workspace: {
+ workspaceRoot,
+ packageRoot: path.join(workspaceRoot, 'app'),
+ },
+ params: { command: 'build', env: 'production' },
+ createRunId: () => 'run_failure',
+ });
+ const harness = getObserverHarness(plugin);
+ const environment = createEnvironment('web', 'web');
+ const brokenStats = {
+ hasErrors: () => false,
+ hasWarnings: () => false,
+ toJson: () => {
+ throw new Error('broken stats');
+ },
+ };
+
+ await harness.hooks.beforeBuild?.({ environments: { web: environment } });
+ await expect(
+ invokeAfter(harness.hooks, {
+ environment,
+ isFirstCompile: true,
+ isWatch: false,
+ stats: brokenStats,
+ time: 1,
+ }),
+ ).resolves.toBeUndefined();
+ await expect(
+ invokeAfter(harness.hooks, {
+ environment,
+ isFirstCompile: false,
+ isWatch: false,
+ stats: brokenStats,
+ time: 1,
+ }),
+ ).resolves.toBeUndefined();
+ expect(harness.warnings).toEqual(['Failed to capture Rstack build context.']);
+ });
+});
+
+test('serializes Stats before awaiting manifest publication', async () => {
+ await withTempWorkspace('rstack-build-context-', async (workspaceRoot) => {
+ const plugin = createBuildContextPlugin({
+ producer: 'rsbuild',
+ product: 'application',
+ capture: 'metadata',
+ workspace: {
+ workspaceRoot,
+ packageRoot: path.join(workspaceRoot, 'app'),
+ },
+ params: { command: 'build', env: 'production' },
+ createRunId: () => 'run_synchronous_stats',
+ });
+ const { hooks } = getObserverHarness(plugin);
+ const environment = createEnvironment('web', 'web');
+ const stats = createStats({ json: { assets: [] } });
+
+ const before = hooks.beforeBuild!({ environments: { web: environment } });
+ const after = hooks.afterEnvironmentCompile!({
+ environment,
+ isFirstCompile: true,
+ isWatch: false,
+ stats,
+ time: 1,
+ });
+
+ const synchronousCallCount = stats.calls.length;
+ await before;
+ await after;
+ expect(synchronousCallCount).toBe(1);
+ });
+});
+
+test('normalizes metadata paths', async () => {
+ await withTempWorkspace('rstack-build-context-', async (workspaceRoot) => {
+ const plugin = createBuildContextPlugin({
+ producer: 'rsbuild',
+ product: 'application',
+ capture: 'metadata',
+ workspace: {
+ workspaceRoot,
+ packageRoot: path.join(workspaceRoot, 'app'),
+ },
+ params: { command: 'build', env: 'production' },
+ createRunId: () => 'run_metadata_paths',
+ });
+ const { hooks } = getObserverHarness(plugin);
+ const environment = createEnvironment('web', 'web');
+
+ await hooks.beforeBuild?.({ environments: { web: environment } });
+ await invokeAfter(hooks, {
+ environment,
+ isFirstCompile: true,
+ isWatch: false,
+ stats: createStats({
+ json: {
+ assets: [
+ { name: 'dist/./asset.js', size: 1 },
+ { name: 'dist//repeated.js', size: 2 },
+ ],
+ chunks: [
+ {
+ files: ['dist/./chunk.js', 'dist//repeated.js'],
+ id: 'web',
+ },
+ ],
+ },
+ }),
+ time: 1,
+ });
+
+ const build = (await readContextWorkspaceStatus(workspaceRoot)).runs[0].contexts[0]
+ .latestSnapshot!.facets.build as {
+ assets: Array<{ name: string; size: number }>;
+ chunks: Array<{ files: string[] }>;
+ };
+ expect(build.assets).toEqual([
+ { name: 'dist/asset.js', size: 1 },
+ { name: 'dist/repeated.js', size: 2 },
+ ]);
+ expect(build.chunks).toEqual([{ files: ['dist/chunk.js', 'dist/repeated.js'], id: 'web' }]);
+ });
+});
+
+test('derives stable IDs from normalized identity inputs and separates every identity field', async () => {
+ await withTempWorkspace('rstack-build-context-', async (workspaceRoot) => {
+ const workspace = {
+ workspaceRoot,
+ packageRoot: path.join(workspaceRoot, 'packages', 'app'),
+ packageName: '@repo/app',
+ };
+ const configPath = path.join(workspaceRoot, 'packages', 'app', 'rstack.config.ts');
+ const base = {
+ workspaceRoot,
+ workspace,
+ configPath,
+ };
+
+ const stable = await collectContextId(base);
+ expect(stable).toMatch(/^ctx_[a-f0-9]{24}$/u);
+ expect(await collectContextId(base)).toBe(stable);
+ await expect(
+ collectContextId({
+ ...base,
+ workspace: { ...workspace, packageRoot: `${workspace.packageRoot}/./` },
+ configPath: `${path.dirname(configPath)}/../app/rstack.config.ts`,
+ }),
+ ).resolves.toBe(stable);
+
+ await expect(collectContextId({ ...base, environment: 'node' })).resolves.not.toBe(stable);
+ await expect(
+ collectContextId({
+ ...base,
+ workspace: {
+ ...workspace,
+ packageRoot: path.join(workspaceRoot, 'packages', 'other'),
+ },
+ }),
+ ).resolves.not.toBe(stable);
+ await expect(
+ collectContextId({
+ ...base,
+ configPath: path.join(workspaceRoot, 'other.config.ts'),
+ }),
+ ).resolves.not.toBe(stable);
+ await expect(
+ collectContextId({ ...base, product: 'library', producer: 'rsbuild' }),
+ ).resolves.not.toBe(stable);
+ await expect(
+ collectContextId({ ...base, product: 'application', producer: 'rslib' }),
+ ).resolves.not.toBe(stable);
+ await expect(
+ collectContextId({
+ ...base,
+ params: { command: 'dev', env: 'production' },
+ }),
+ ).resolves.not.toBe(stable);
+ await expect(
+ collectContextId({
+ ...base,
+ params: { command: 'build', env: 'development' },
+ }),
+ ).resolves.not.toBe(stable);
+ await expect(collectContextId({ ...base, target: 'node' })).resolves.not.toBe(stable);
+ await expect(
+ collectContextId({
+ ...base,
+ distPath: path.join(workspaceRoot, 'dist-firefox'),
+ }),
+ ).resolves.not.toBe(stable);
+ await expect(collectContextId({ ...base, variant: 'firefox_v3' })).resolves.not.toBe(stable);
+ });
+});
+
+test('records explicit build variants, normalized output paths, and partial config inputs', async () => {
+ await withTempWorkspace('rstack-build-context-', async (workspaceRoot) => {
+ const configPath = path.join(workspaceRoot, 'rstack.config.ts');
+ const dependencyPath = path.join(workspaceRoot, 'config', 'shared.ts');
+ await mkdir(path.dirname(dependencyPath), { recursive: true });
+ await writeFile(configPath, 'export {}\n');
+ await writeFile(dependencyPath, 'export const shared = true;\n');
+ const plugin = createBuildContextPlugin({
+ producer: 'rsbuild',
+ product: 'application',
+ capture: 'metadata',
+ variant: 'firefox_v3',
+ workspace: { workspaceRoot, packageRoot: workspaceRoot },
+ configPath,
+ inputs: await recordContextInputFiles(workspaceRoot, [dependencyPath, configPath]),
+ params: { command: 'build', env: 'production' },
+ createRunId: () => 'run_variant_inputs',
+ });
+ const { hooks } = getObserverHarness(plugin);
+ const environment = {
+ ...createEnvironment('web', 'web'),
+ distPath: path.join(workspaceRoot, 'dist', 'firefox'),
+ };
+
+ await hooks.beforeBuild?.({ environments: { web: environment } });
+ await invokeAfter(hooks, {
+ environment,
+ isFirstCompile: true,
+ isWatch: false,
+ stats: createStats({}),
+ time: 1,
+ });
+
+ const status = await readContextWorkspaceStatus(workspaceRoot);
+ expect(status.runs[0].run.contexts[0]).toMatchObject({
+ variant: 'firefox_v3',
+ distPath: 'dist/firefox',
+ });
+ expect(status.runs[0].contexts[0].latestSnapshot!.source).toMatchObject({
+ inputCompleteness: 'partial',
+ inputs: [
+ {
+ path: 'config/shared.ts',
+ digest: expect.stringMatching(/^[0-9a-f]{64}$/u),
+ },
+ {
+ path: 'rstack.config.ts',
+ digest: expect.stringMatching(/^[0-9a-f]{64}$/u),
+ },
+ ],
+ });
+ });
+});
diff --git a/tests/codeEvidence.test.ts b/tests/codeEvidence.test.ts
new file mode 100644
index 0000000..dab040f
--- /dev/null
+++ b/tests/codeEvidence.test.ts
@@ -0,0 +1,913 @@
+import { createHash } from 'node:crypto';
+import { cp, mkdir, readFile, writeFile } from 'node:fs/promises';
+import path from 'node:path';
+import { expect, test } from '@rstest/core';
+import {
+ contextStoreSchemaVersion,
+ writeContextRunManifest,
+ writeContextSnapshot,
+ type ContextDescriptor,
+ type ContextProducer,
+ type ContextRunManifest,
+ type ContextSnapshot,
+ type JsonValue,
+ type LintFacet,
+ type TestFacet,
+} from '../src/index.ts';
+import { readCodeEvidence } from '../src/codeEvidence.ts';
+import type { TestExecutionFacet } from '../src/model.ts';
+import { withTempWorkspace } from './helpers.ts';
+
+const fixtureRoot = path.resolve(
+ import.meta.dirname,
+ '../fixtures/context/reachability/application',
+);
+
+const digest = (value: string): string => createHash('sha256').update(value).digest('hex');
+
+const writeSnapshot = async (
+ workspaceRoot: string,
+ options: {
+ producer: ContextProducer;
+ snapshotId: string;
+ observedAt: string;
+ context?: ContextDescriptor;
+ facets: ContextSnapshot['facets'];
+ completeness: ContextSnapshot['completeness'];
+ source?: ContextSnapshot['source'];
+ status?: ContextSnapshot['status'];
+ },
+): Promise => {
+ const context =
+ options.context ??
+ ({
+ contextId: `ctx_${options.snapshotId}`,
+ packageRoot: '.',
+ product:
+ options.producer === 'rslint' || options.producer === 'rstest'
+ ? 'development'
+ : 'application',
+ ...(options.producer === 'rslint' ? { environment: 'lint' } : {}),
+ ...(options.producer === 'rstest' ? { environment: 'test' } : {}),
+ } satisfies ContextDescriptor);
+ const run = {
+ schemaVersion: contextStoreSchemaVersion,
+ runId: `run_${options.snapshotId}`,
+ producer: options.producer,
+ command:
+ options.producer === 'rstest' ? 'test' : options.producer === 'rslint' ? 'lint' : 'build',
+ startedAt: options.observedAt,
+ contexts: [context],
+ } satisfies ContextRunManifest;
+ expect(await writeContextRunManifest(workspaceRoot, run)).toMatchObject({ written: true });
+ expect(
+ await writeContextSnapshot(workspaceRoot, {
+ schemaVersion: contextStoreSchemaVersion,
+ snapshotId: options.snapshotId,
+ runId: run.runId,
+ contextId: context.contextId,
+ sequence: 0,
+ observedAt: options.observedAt,
+ status: options.status ?? 'pass',
+ completeness: options.completeness,
+ facets: options.facets,
+ ...(options.source === undefined ? {} : { source: options.source }),
+ }),
+ ).toMatchObject({ written: true });
+};
+
+const testFacet = (filePath: string, status: 'pass' | 'fail', message: string): TestFacet => ({
+ producer: 'rstest',
+ files: [
+ {
+ project: 'default',
+ path: filePath,
+ status,
+ ...(status === 'fail' ? { errors: [{ name: 'Error', message }] } : {}),
+ tests: [
+ {
+ project: 'default',
+ path: filePath,
+ name: 'behavior',
+ status,
+ ...(status === 'fail' ? { errors: [{ name: 'Error', message: `${message} case` }] } : {}),
+ },
+ ],
+ },
+ ],
+ stats: {
+ tests: {
+ total: 1,
+ passed: status === 'pass' ? 1 : 0,
+ failed: status === 'fail' ? 1 : 0,
+ skipped: 0,
+ todo: 0,
+ },
+ files: { total: 1, failed: status === 'fail' ? 1 : 0 },
+ },
+ durationMs: 1,
+ unhandledErrors: [],
+});
+
+const executionFacet = (
+ filePath: string,
+ fileDigest: string,
+ hits: number,
+ completeness: 'complete' | 'partial' = 'complete',
+): TestExecutionFacet => ({
+ producer: 'rstest',
+ provider: 'istanbul',
+ availability: 'available',
+ requestedSelection: { allowExternal: false },
+ digest: 'e'.repeat(64),
+ universe: {
+ reportedFiles: 1,
+ storedFiles: 1,
+ droppedFiles: 0,
+ reportedLocations: 5,
+ storedLocations: 5,
+ droppedLocations: 0,
+ completeness,
+ },
+ truncated: { files: 0, locations: 0 },
+ bounds: {
+ attribution: 'aggregate-run-only',
+ testAttribution: false,
+ maxFiles: 1000,
+ maxLocationsPerFile: 20_000,
+ maxLocationsTotal: 100_000,
+ },
+ files: [
+ {
+ path: filePath,
+ digest: fileDigest,
+ statements: [
+ {
+ id: '0',
+ location: { start: { line: 2, column: 0 }, end: { line: 2, column: 10 } },
+ hits,
+ },
+ ],
+ functions: [
+ {
+ id: '0',
+ name: 'value',
+ declaration: { start: { line: 1, column: 0 }, end: { line: 1, column: 5 } },
+ location: { start: { line: 2, column: 0 }, end: { line: 3, column: 1 } },
+ hits,
+ },
+ ],
+ branches: [
+ {
+ id: '0',
+ type: 'if',
+ location: { start: { line: 2, column: 0 }, end: { line: 3, column: 1 } },
+ arms: [
+ {
+ location: { start: { line: 3, column: 0 }, end: { line: 3, column: 1 } },
+ hits,
+ },
+ ],
+ },
+ ],
+ },
+ ],
+});
+
+const lintFacet = (filePath: string): LintFacet => ({
+ producer: 'rslint',
+ mode: 'files',
+ fixPreviewCaptured: false,
+ files: [
+ {
+ path: filePath,
+ digest: 'a'.repeat(64),
+ errorCount: 0,
+ warningCount: 1,
+ fixableErrorCount: 0,
+ fixableWarningCount: 0,
+ messages: [
+ { ruleId: 'no-example', severity: 1, message: 'lint warning', line: 2, column: 1 },
+ ],
+ },
+ ],
+ totals: { files: 1, errors: 0, warnings: 1, fixableErrors: 0, fixableWarnings: 0 },
+});
+
+test('joins newest exact-path execution, test outcome, and diagnostics without inferring related tests', async () => {
+ await withTempWorkspace('rstack-code-evidence-', async (workspaceRoot) => {
+ const source = 'export function value() {\n return 1;\n}\n';
+ const sourcePath = path.join(workspaceRoot, 'src', 'value.ts');
+ await mkdir(path.dirname(sourcePath), { recursive: true });
+ await writeFile(sourcePath, source);
+
+ await writeSnapshot(workspaceRoot, {
+ producer: 'rstest',
+ snapshotId: 'snap_old',
+ observedAt: '2026-08-13T01:00:00.000Z',
+ facets: {
+ test: testFacet('src/value.ts', 'pass', 'old') as unknown as JsonValue,
+ execution: executionFacet('src/value.ts', digest(source), 0) as unknown as JsonValue,
+ },
+ completeness: { test: 'complete', execution: 'complete' },
+ });
+ await writeSnapshot(workspaceRoot, {
+ producer: 'rstest',
+ snapshotId: 'snap_new',
+ observedAt: '2026-08-13T02:00:00.000Z',
+ facets: {
+ test: testFacet('src/value.ts', 'fail', 'test failed') as unknown as JsonValue,
+ execution: executionFacet('src/value.ts', digest(source), 3) as unknown as JsonValue,
+ },
+ completeness: { test: 'complete', execution: 'complete' },
+ });
+ await writeSnapshot(workspaceRoot, {
+ producer: 'rslint',
+ snapshotId: 'snap_lint',
+ observedAt: '2026-08-13T03:00:00.000Z',
+ facets: { lint: lintFacet('src/value.ts') as unknown as JsonValue },
+ completeness: { lint: 'complete' },
+ });
+
+ const result = await readCodeEvidence(workspaceRoot, { path: './src\\value.ts', line: 2 });
+ expect(result).toMatchObject({
+ path: 'src/value.ts',
+ line: 2,
+ executionCoverage: {
+ state: 'observed',
+ relevantLocations: 2,
+ observedLocations: 2,
+ },
+ testOutcome: { state: 'failed', matchingFiles: 1, matchingTests: 1 },
+ diagnostics: {
+ total: 3,
+ returned: 3,
+ truncated: false,
+ items: [
+ { producer: 'rslint', path: 'src/value.ts', message: 'lint warning' },
+ { producer: 'rstest', path: 'src/value.ts', message: 'test failed' },
+ { producer: 'rstest', path: 'src/value.ts', message: 'test failed case' },
+ ],
+ },
+ provenance: {
+ test: { snapshotId: 'snap_new', completeness: { test: 'complete', execution: 'complete' } },
+ lint: { snapshotId: 'snap_lint', completeness: { lint: 'complete' } },
+ },
+ });
+ expect(result.bounds).toContain('test-outcome-exact-path-or-isolated-related-selection');
+ expect(result.bounds).toContain('aggregate-execution-no-test-attribution');
+
+ await expect(
+ readCodeEvidence(workspaceRoot, {
+ path: 'src/value.ts',
+ line: 2,
+ testSnapshotId: 'snap_old',
+ }),
+ ).resolves.toMatchObject({
+ executionCoverage: { state: 'not-observed' },
+ testOutcome: { state: 'passed' },
+ provenance: { test: { snapshotId: 'snap_old' } },
+ });
+ });
+});
+
+test('reports captured related-test evidence independently from test execution', async () => {
+ await withTempWorkspace('rstack-code-evidence-', async (workspaceRoot) => {
+ const facet = testFacet('tests/value.test.ts', 'pass', 'unused');
+ facet.relation = {
+ sources: ['src/value.ts'],
+ testFiles: ['tests/value.test.ts'],
+ };
+ await writeSnapshot(workspaceRoot, {
+ producer: 'rstest',
+ snapshotId: 'snap_related',
+ observedAt: '2026-08-13T01:00:00.000Z',
+ facets: { test: facet as unknown as JsonValue },
+ completeness: { test: 'complete' },
+ });
+ const unrelatedFacet = testFacet('tests/other.test.ts', 'pass', 'unused');
+ unrelatedFacet.relation = { sources: ['src/unrelated.ts'], testFiles: [] };
+ await writeSnapshot(workspaceRoot, {
+ producer: 'rstest',
+ snapshotId: 'snap_unrelated',
+ observedAt: '2026-08-13T02:00:00.000Z',
+ facets: { test: unrelatedFacet as unknown as JsonValue },
+ completeness: { test: 'complete' },
+ });
+ const groupedFacet = testFacet('tests/grouped.test.ts', 'pass', 'unused');
+ groupedFacet.relation = {
+ sources: ['src/value.ts', 'src/other.ts'],
+ testFiles: ['tests/grouped.test.ts'],
+ };
+ await writeSnapshot(workspaceRoot, {
+ producer: 'rstest',
+ snapshotId: 'snap_grouped',
+ observedAt: '2026-08-13T03:00:00.000Z',
+ facets: { test: groupedFacet as unknown as JsonValue },
+ completeness: { test: 'complete' },
+ });
+
+ await expect(
+ readCodeEvidence(workspaceRoot, {
+ path: 'src/value.ts',
+ testSnapshotId: 'snap_related',
+ }),
+ ).resolves.toMatchObject({
+ testRelation: { state: 'related', testFiles: ['tests/value.test.ts'] },
+ testOutcome: {
+ state: 'passed',
+ basis: 'related-selection',
+ matchingFiles: 1,
+ matchingTests: 1,
+ },
+ });
+ await expect(
+ readCodeEvidence(workspaceRoot, {
+ path: 'src/unrelated.ts',
+ testSnapshotId: 'snap_unrelated',
+ }),
+ ).resolves.toMatchObject({
+ testRelation: { state: 'unrelated', testFiles: [] },
+ });
+ await expect(
+ readCodeEvidence(workspaceRoot, {
+ path: 'src/value.ts',
+ testSnapshotId: 'snap_grouped',
+ }),
+ ).resolves.toMatchObject({
+ testRelation: {
+ state: 'unknown',
+ reason: 'selection-not-isolated',
+ testFiles: ['tests/grouped.test.ts'],
+ },
+ });
+ await expect(
+ readCodeEvidence(workspaceRoot, { path: 'src/missing.ts' }),
+ ).resolves.toMatchObject({
+ testRelation: { state: 'unknown', reason: 'source-not-selected', testFiles: [] },
+ });
+ });
+});
+
+test('keeps automatic test evidence complete while explicit selection exposes input errors', async () => {
+ await withTempWorkspace('rstack-code-evidence-', async (workspaceRoot) => {
+ const context = {
+ contextId: 'ctx_test_package',
+ packageRoot: '.',
+ product: 'development',
+ environment: 'test',
+ } as const;
+ const completeFacet = testFacet('tests/value.test.ts', 'pass', 'unused');
+ completeFacet.relation = {
+ sources: ['src/value.ts'],
+ testFiles: ['tests/value.test.ts'],
+ };
+ await writeSnapshot(workspaceRoot, {
+ producer: 'rstest',
+ snapshotId: 'snap_complete',
+ observedAt: '2026-08-13T01:00:00.000Z',
+ context,
+ facets: { test: completeFacet as unknown as JsonValue },
+ completeness: { test: 'complete' },
+ });
+
+ const inputErrorFacet = testFacet('tests/value.test.ts', 'pass', 'unused');
+ inputErrorFacet.relation = {
+ sources: ['src/value.ts'],
+ testFiles: ['tests/value.test.ts'],
+ };
+ inputErrorFacet.unhandledErrors = [
+ {
+ name: 'TestInputError',
+ message: 'Could not read Rstest snapshot inputs: src/value.ts.',
+ },
+ ];
+ await writeSnapshot(workspaceRoot, {
+ producer: 'rstest',
+ snapshotId: 'snap_input_error',
+ observedAt: '2026-08-13T02:00:00.000Z',
+ context,
+ status: 'error',
+ facets: { test: inputErrorFacet as unknown as JsonValue },
+ completeness: { test: 'partial', source: 'partial' },
+ source: {
+ inputs: [],
+ inputCompleteness: 'partial',
+ unreadableInputs: ['src/value.ts'],
+ captureSelection: { related: ['src/value.ts'] },
+ },
+ });
+
+ await expect(readCodeEvidence(workspaceRoot, { path: 'src/value.ts' })).resolves.toMatchObject({
+ provenance: { test: { snapshotId: 'snap_complete', completeness: { test: 'complete' } } },
+ testOutcome: { state: 'passed', basis: 'related-selection' },
+ });
+ await expect(
+ readCodeEvidence(workspaceRoot, {
+ path: 'src/value.ts',
+ testSnapshotId: 'snap_input_error',
+ }),
+ ).resolves.toMatchObject({
+ provenance: {
+ test: {
+ snapshotId: 'snap_input_error',
+ status: 'error',
+ completeness: { test: 'partial', source: 'partial' },
+ unreadableInputs: ['src/value.ts'],
+ },
+ },
+ testOutcome: { state: 'failed', basis: 'related-selection' },
+ });
+ });
+});
+
+test('keeps missing, stale, incomplete, and non-overlapping execution evidence inconclusive', async () => {
+ await withTempWorkspace('rstack-code-evidence-', async (workspaceRoot) => {
+ const sourcePath = path.join(workspaceRoot, 'packages', 'one', 'src', 'value.ts');
+ await mkdir(path.dirname(sourcePath), { recursive: true });
+ await writeFile(sourcePath, 'before');
+ const context = {
+ contextId: 'ctx_test_package',
+ packageRoot: 'packages/one',
+ product: 'development',
+ environment: 'test',
+ } as const;
+ await writeSnapshot(workspaceRoot, {
+ producer: 'rstest',
+ snapshotId: 'snap_partial',
+ observedAt: '2026-08-13T01:00:00.000Z',
+ context,
+ facets: {
+ test: testFacet('packages/one/src/value.ts', 'pass', 'unused') as unknown as JsonValue,
+ execution: executionFacet(
+ 'packages/one/src/value.ts',
+ digest('before'),
+ 0,
+ 'partial',
+ ) as unknown as JsonValue,
+ },
+ completeness: { test: 'complete', execution: 'partial' },
+ });
+
+ const partial = await readCodeEvidence(workspaceRoot, {
+ path: 'packages/one/src/value.ts',
+ line: 2,
+ });
+ expect(partial.executionCoverage).toMatchObject({
+ state: 'unknown',
+ reason: 'partial-universe',
+ });
+
+ await writeFile(sourcePath, 'after');
+ const stale = await readCodeEvidence(workspaceRoot, {
+ path: 'packages/one/src/value.ts',
+ });
+ expect(stale.executionCoverage).toMatchObject({ state: 'unknown', reason: 'digest-mismatch' });
+
+ const absent = await readCodeEvidence(workspaceRoot, { path: 'packages/two/src/value.ts' });
+ expect(absent).toMatchObject({
+ executionCoverage: { state: 'unavailable', reason: 'no-test-snapshot' },
+ testRelation: { state: 'unavailable', reason: 'no-test-snapshot', testFiles: [] },
+ testOutcome: { state: 'unknown' },
+ });
+ await expect(
+ readCodeEvidence(workspaceRoot, {
+ path: 'packages/two/src/value.ts',
+ testSnapshotId: 'snap_partial',
+ }),
+ ).rejects.toThrow('Selected Rstest snapshot package root does not contain the source path.');
+ });
+});
+
+test('distinguishes absent exact test records from matching skipped or todo records', async () => {
+ await withTempWorkspace('rstack-code-evidence-', async (workspaceRoot) => {
+ const facet = testFacet('src/other.test.ts', 'pass', 'unused');
+ facet.files.push({
+ project: 'default',
+ path: 'src/skipped.test.ts',
+ status: 'skip',
+ tests: [
+ { project: 'default', path: 'src/skipped.test.ts', name: 'skipped', status: 'skip' },
+ { project: 'default', path: 'src/skipped.test.ts', name: 'todo', status: 'todo' },
+ ],
+ });
+ await writeSnapshot(workspaceRoot, {
+ producer: 'rstest',
+ snapshotId: 'snap_test_outcomes',
+ observedAt: '2026-08-13T01:00:00.000Z',
+ facets: { test: facet as unknown as JsonValue },
+ completeness: { test: 'complete' },
+ });
+
+ await expect(
+ readCodeEvidence(workspaceRoot, { path: 'src/missing.ts' }),
+ ).resolves.toMatchObject({
+ testOutcome: {
+ state: 'unknown',
+ reason: 'no-exact-test-record',
+ matchingFiles: 0,
+ matchingTests: 0,
+ },
+ });
+ await expect(
+ readCodeEvidence(workspaceRoot, { path: 'src/skipped.test.ts' }),
+ ).resolves.toMatchObject({
+ testOutcome: { state: 'not-run', matchingFiles: 1, matchingTests: 2 },
+ });
+ });
+});
+
+test('attributes run-level unhandled errors to an isolated related selection only', async () => {
+ await withTempWorkspace('rstack-code-evidence-', async (workspaceRoot) => {
+ const facet = testFacet('tests/value.test.ts', 'pass', 'unused');
+ facet.unhandledErrors = [{ name: 'Error', message: 'unhandled rejection in another file' }];
+ facet.relation = { sources: ['src/value.ts'], testFiles: ['tests/value.test.ts'] };
+ await writeSnapshot(workspaceRoot, {
+ producer: 'rstest',
+ snapshotId: 'snap_unhandled',
+ observedAt: '2026-08-13T01:00:00.000Z',
+ facets: { test: facet as unknown as JsonValue },
+ completeness: { test: 'complete' },
+ });
+
+ await expect(
+ readCodeEvidence(workspaceRoot, { path: 'tests/value.test.ts' }),
+ ).resolves.toMatchObject({
+ testOutcome: {
+ state: 'passed',
+ basis: 'exact-path',
+ matchingFiles: 1,
+ matchingTests: 1,
+ },
+ });
+ await expect(readCodeEvidence(workspaceRoot, { path: 'src/value.ts' })).resolves.toMatchObject({
+ testOutcome: {
+ state: 'failed',
+ basis: 'related-selection',
+ matchingFiles: 1,
+ matchingTests: 1,
+ },
+ });
+ });
+});
+
+test('bounds exact-path diagnostics to two hundred records', async () => {
+ await withTempWorkspace('rstack-code-evidence-', async (workspaceRoot) => {
+ const facet = lintFacet('src/noisy.ts');
+ facet.files[0].messages = Array.from({ length: 205 }, (_, index) => ({
+ ruleId: 'no-noise',
+ severity: 1 as const,
+ message: `diagnostic ${String(index).padStart(3, '0')}`,
+ line: index + 1,
+ column: 1,
+ }));
+ facet.files[0].warningCount = 205;
+ facet.totals.warnings = 205;
+ await writeSnapshot(workspaceRoot, {
+ producer: 'rslint',
+ snapshotId: 'snap_noisy',
+ observedAt: '2026-08-13T01:00:00.000Z',
+ facets: { lint: facet as unknown as JsonValue },
+ completeness: { lint: 'complete' },
+ });
+
+ await expect(readCodeEvidence(workspaceRoot, { path: 'src/noisy.ts' })).resolves.toMatchObject({
+ diagnostics: { total: 205, returned: 200, truncated: true },
+ });
+ });
+});
+
+test('selects lint provenance only from snapshots that captured the exact source path', async () => {
+ await withTempWorkspace('rstack-code-evidence-', async (workspaceRoot) => {
+ const relevant = lintFacet('src/value.ts');
+ relevant.files[0].messages = [];
+ relevant.files[0].warningCount = 0;
+ relevant.totals.warnings = 0;
+ await writeSnapshot(workspaceRoot, {
+ producer: 'rslint',
+ snapshotId: 'snap_relevant_lint',
+ observedAt: '2026-08-13T01:00:00.000Z',
+ facets: { lint: relevant as unknown as JsonValue },
+ completeness: { lint: 'complete' },
+ source: {
+ inputs: [{ path: 'src/value.ts', digest: 'a'.repeat(64) }],
+ inputCompleteness: 'complete',
+ },
+ });
+ await writeSnapshot(workspaceRoot, {
+ producer: 'rslint',
+ snapshotId: 'snap_unrelated_lint',
+ observedAt: '2026-08-13T02:00:00.000Z',
+ facets: { lint: lintFacet('src/other.ts') as unknown as JsonValue },
+ completeness: { lint: 'complete' },
+ });
+
+ await expect(readCodeEvidence(workspaceRoot, { path: 'src/value.ts' })).resolves.toMatchObject({
+ diagnostics: { total: 0, items: [] },
+ provenance: { lint: { snapshotId: 'snap_relevant_lint' } },
+ });
+ await expect(
+ readCodeEvidence(workspaceRoot, { path: 'src/missing.ts' }),
+ ).resolves.toMatchObject({
+ diagnostics: { total: 0, items: [] },
+ provenance: {},
+ });
+ await expect(
+ readCodeEvidence(workspaceRoot, {
+ path: 'src/value.ts',
+ lintSnapshotId: 'snap_unrelated_lint',
+ }),
+ ).rejects.toThrow('Selected Rslint snapshot did not capture the source path.');
+ });
+});
+
+test('adds an independent module axis only for an explicit artifact and exposes binding mismatch', async () => {
+ await withTempWorkspace('rstack-code-evidence-', async (workspaceRoot) => {
+ await cp(fixtureRoot, workspaceRoot, { recursive: true });
+ const sourcePath = path.join(workspaceRoot, 'src', 'live.ts');
+ await mkdir(path.dirname(sourcePath), { recursive: true });
+ await writeFile(sourcePath, 'export const live = true;\n');
+ const context = {
+ contextId: 'ctx_app_web',
+ packageRoot: '.',
+ product: 'application',
+ environment: 'web',
+ target: 'web',
+ } as const;
+ await writeSnapshot(workspaceRoot, {
+ producer: 'rsbuild',
+ snapshotId: 'snap_build',
+ observedAt: '2026-08-13T01:00:00.000Z',
+ context,
+ facets: {
+ build: {
+ producer: 'rsbuild',
+ command: 'build',
+ environment: 'web',
+ target: ['web'],
+ isWatch: false,
+ isFirstCompile: true,
+ durationMs: 1,
+ hash: 'expected-hash',
+ hasErrors: false,
+ hasWarnings: false,
+ assets: [],
+ chunks: [],
+ truncated: { assets: 0, chunks: 0 },
+ },
+ },
+ completeness: { build: 'complete' },
+ });
+ const dataFile = path.join(workspaceRoot, 'rsdoctor-data.json');
+ const artifact = JSON.parse(await readFile(dataFile, 'utf8')) as Record;
+ const metadata = {
+ schemaVersion: 1,
+ producer: { name: '@rsdoctor/core', version: '1.6.0' },
+ output: { mode: 'normal' },
+ build: {
+ id: 'build',
+ root: workspaceRoot,
+ compiler: { name: 'web', type: 'rspack', version: '1.7.0' },
+ compilationHash: 'expected-hash',
+ environment: 'web',
+ target: ['web'],
+ },
+ sections: Object.fromEntries(
+ [
+ 'errors',
+ 'configs',
+ 'summary',
+ 'resolver',
+ 'loader',
+ 'moduleGraph',
+ 'chunkGraph',
+ 'moduleCodeMap',
+ 'plugin',
+ 'packageGraph',
+ 'treeShaking',
+ 'otherReports',
+ ].map((section) => [section, { status: 'collected' }]),
+ ),
+ };
+ artifact.metadata = metadata;
+ await writeFile(dataFile, JSON.stringify(artifact));
+
+ const exact = await readCodeEvidence(workspaceRoot, {
+ path: 'src/live.ts',
+ contextId: context.contextId,
+ dataFile: 'rsdoctor-data.json',
+ maxDepth: 4,
+ });
+ expect(exact.module).toMatchObject({
+ provenance: { artifactBinding: 'exact' },
+ subject: { id: '2', path: 'src/live.ts' },
+ state: {
+ productionReachability: 'live',
+ publicContract: 'not-required',
+ shipped: 'yes',
+ },
+ });
+
+ (metadata.build as Record).compilationHash = 'different-hash';
+ artifact.metadata = metadata;
+ await writeFile(dataFile, JSON.stringify(artifact));
+ const mismatch = await readCodeEvidence(workspaceRoot, {
+ path: 'src/live.ts',
+ contextId: context.contextId,
+ dataFile: 'rsdoctor-data.json',
+ });
+ expect(mismatch.module).toMatchObject({
+ provenance: { artifactBinding: 'mismatch' },
+ classification: 'insufficient-evidence',
+ });
+ expect(mismatch.bounds).toContain('artifact-binding-not-exact');
+
+ await expect(
+ readCodeEvidence(workspaceRoot, { path: 'src/live.ts', dataFile: 'rsdoctor-data.json' }),
+ ).rejects.toThrow('contextId and dataFile must be supplied together.');
+ });
+});
+
+test('degrades the module axis for a listed test context instead of failing the whole call', async () => {
+ await withTempWorkspace('rstack-code-evidence-', async (workspaceRoot) => {
+ await cp(fixtureRoot, workspaceRoot, { recursive: true });
+ const source = 'export const live = true;\n';
+ const sourcePath = path.join(workspaceRoot, 'src', 'live.ts');
+ await mkdir(path.dirname(sourcePath), { recursive: true });
+ await writeFile(sourcePath, source);
+ const testContext = {
+ contextId: 'ctx_workspace_test',
+ packageRoot: '.',
+ product: 'development',
+ environment: 'test',
+ } as const;
+ await writeSnapshot(workspaceRoot, {
+ producer: 'rstest',
+ snapshotId: 'snap_workspace_test',
+ observedAt: '2026-08-13T01:00:00.000Z',
+ context: testContext,
+ facets: {
+ test: testFacet('src/live.ts', 'pass', 'unused') as unknown as JsonValue,
+ execution: executionFacet('src/live.ts', digest(source), 1) as unknown as JsonValue,
+ },
+ completeness: { test: 'complete', execution: 'complete' },
+ });
+ await writeSnapshot(workspaceRoot, {
+ producer: 'rslint',
+ snapshotId: 'snap_workspace_lint',
+ observedAt: '2026-08-13T02:00:00.000Z',
+ facets: { lint: lintFacet('src/live.ts') as unknown as JsonValue },
+ completeness: { lint: 'complete' },
+ });
+
+ // A test context is not an application or library product, so the module axis cannot report
+ // reachability. The unrelated coverage, outcome, and diagnostics axes must survive regardless.
+ const evidence = await readCodeEvidence(workspaceRoot, {
+ path: 'src/live.ts',
+ contextId: testContext.contextId,
+ dataFile: 'rsdoctor-data.json',
+ });
+
+ expect(evidence).toMatchObject({
+ executionCoverage: { state: 'observed' },
+ testOutcome: { state: 'passed', basis: 'exact-path' },
+ diagnostics: { total: 1, returned: 1, truncated: false },
+ });
+ expect(evidence.module?.provenance).toMatchObject({
+ contextId: testContext.contextId,
+ dataFile: 'rsdoctor-data.json',
+ });
+ });
+});
+
+test('uses the full workspace path before a package-relative artifact fallback', async () => {
+ await withTempWorkspace('rstack-code-evidence-', async (workspaceRoot) => {
+ await cp(fixtureRoot, workspaceRoot, { recursive: true });
+ const dataFile = path.join(workspaceRoot, 'rsdoctor-data.json');
+ const artifact = JSON.parse(await readFile(dataFile, 'utf8')) as {
+ data: { moduleGraph: { modules: Array> } };
+ };
+ artifact.data.moduleGraph.modules.push(
+ { id: 'pkg-a', path: 'packages/a/src/shared.ts', name: 'shared-a', chunks: ['a'] },
+ { id: 'pkg-b', path: 'packages/b/src/shared.ts', name: 'shared-b', chunks: ['b'] },
+ );
+ await writeFile(dataFile, JSON.stringify(artifact));
+ const context = {
+ contextId: 'ctx_package_a',
+ packageRoot: 'packages/a',
+ product: 'application',
+ } as const;
+ await writeSnapshot(workspaceRoot, {
+ producer: 'rsbuild',
+ snapshotId: 'snap_package_a',
+ observedAt: '2026-08-13T01:00:00.000Z',
+ context,
+ facets: {},
+ completeness: { build: 'partial' },
+ });
+
+ await expect(
+ readCodeEvidence(workspaceRoot, {
+ path: 'packages/a/src/shared.ts',
+ contextId: context.contextId,
+ dataFile: 'rsdoctor-data.json',
+ }),
+ ).resolves.toMatchObject({ module: { subject: { id: 'pkg-a' } } });
+ });
+});
+
+test('preserves execution and diagnostics when the full artifact module path is ambiguous', async () => {
+ await withTempWorkspace('rstack-code-evidence-', async (workspaceRoot) => {
+ await cp(fixtureRoot, workspaceRoot, { recursive: true });
+ const source = 'export const shared = true;\n';
+ const sourcePath = path.join(workspaceRoot, 'packages', 'a', 'src', 'shared.ts');
+ await mkdir(path.dirname(sourcePath), { recursive: true });
+ await writeFile(sourcePath, source);
+ const dataFile = path.join(workspaceRoot, 'rsdoctor-data.json');
+ const artifact = JSON.parse(await readFile(dataFile, 'utf8')) as {
+ data: { moduleGraph: { modules: Array> } };
+ };
+ artifact.data.moduleGraph.modules.push(
+ { id: 'pkg-a-one', path: 'packages/a/src/shared.ts', name: 'shared-one', chunks: ['a'] },
+ { id: 'pkg-a-two', path: 'packages/a/src/shared.ts', name: 'shared-two', chunks: ['a'] },
+ );
+ await writeFile(dataFile, JSON.stringify(artifact));
+
+ const buildContext = {
+ contextId: 'ctx_package_a_build',
+ packageRoot: 'packages/a',
+ product: 'application',
+ } as const;
+ await writeSnapshot(workspaceRoot, {
+ producer: 'rsbuild',
+ snapshotId: 'snap_package_a_build',
+ observedAt: '2026-08-13T01:00:00.000Z',
+ context: buildContext,
+ facets: {},
+ completeness: { build: 'partial' },
+ });
+ const testContext = {
+ contextId: 'ctx_package_a_test',
+ packageRoot: 'packages/a',
+ product: 'development',
+ environment: 'test',
+ } as const;
+ await writeSnapshot(workspaceRoot, {
+ producer: 'rstest',
+ snapshotId: 'snap_package_a_test',
+ observedAt: '2026-08-13T02:00:00.000Z',
+ context: testContext,
+ facets: {
+ test: testFacet('packages/a/src/shared.ts', 'pass', 'unused') as unknown as JsonValue,
+ execution: executionFacet(
+ 'packages/a/src/shared.ts',
+ digest(source),
+ 1,
+ ) as unknown as JsonValue,
+ },
+ completeness: { test: 'complete', execution: 'complete' },
+ });
+ const lintContext = {
+ contextId: 'ctx_package_a_lint',
+ packageRoot: 'packages/a',
+ product: 'development',
+ environment: 'lint',
+ } as const;
+ await writeSnapshot(workspaceRoot, {
+ producer: 'rslint',
+ snapshotId: 'snap_package_a_lint',
+ observedAt: '2026-08-13T03:00:00.000Z',
+ context: lintContext,
+ facets: { lint: lintFacet('packages/a/src/shared.ts') as unknown as JsonValue },
+ completeness: { lint: 'complete' },
+ });
+
+ await expect(
+ readCodeEvidence(workspaceRoot, {
+ path: 'packages/a/src/shared.ts',
+ contextId: buildContext.contextId,
+ dataFile: 'rsdoctor-data.json',
+ }),
+ ).resolves.toMatchObject({
+ executionCoverage: { state: 'observed' },
+ diagnostics: { total: 1, returned: 1, truncated: false },
+ module: {
+ classification: 'insufficient-evidence',
+ evidence: ['No unique artifact module matched the exact source path.'],
+ },
+ });
+
+ await expect(
+ readCodeEvidence(workspaceRoot, {
+ path: 'packages/a/src/shared.ts',
+ contextId: buildContext.contextId,
+ dataFile: 'rsdoctor-data.json',
+ module: 'pkg-a-two',
+ }),
+ ).resolves.toMatchObject({
+ executionCoverage: { state: 'observed' },
+ diagnostics: { total: 1, returned: 1, truncated: false },
+ module: { subject: { id: 'pkg-a-two' } },
+ });
+ });
+});
diff --git a/tests/diff.test.ts b/tests/diff.test.ts
new file mode 100644
index 0000000..e258fd4
--- /dev/null
+++ b/tests/diff.test.ts
@@ -0,0 +1,816 @@
+import { createHash } from 'node:crypto';
+import { mkdtemp, rm, writeFile } from 'node:fs/promises';
+import os from 'node:os';
+import path from 'node:path';
+import { expect, test } from '@rstest/core';
+import {
+ diffContextSnapshots,
+ diffStoredContextSnapshots,
+ type SnapshotDiffKind,
+} from '../src/diff.ts';
+import {
+ contextStoreSchemaVersion,
+ type ContextDescriptor,
+ type ContextFreshness,
+ type ContextProducer,
+ type ContextRunManifest,
+ type ContextSnapshot,
+ type JsonValue,
+ type LintFacet,
+ type StoredContextSnapshot,
+ type TestFacet,
+} from '../src/model.ts';
+import { writeContextRunManifest, writeContextSnapshot } from '../src/store.ts';
+
+const unknownFreshness: ContextFreshness = {
+ state: 'unknown',
+ changedPaths: [],
+};
+
+const emptyLintFacet = (): LintFacet => ({
+ producer: 'rslint',
+ mode: 'files',
+ fixPreviewCaptured: false,
+ files: [],
+ totals: {
+ files: 0,
+ errors: 0,
+ warnings: 0,
+ fixableErrors: 0,
+ fixableWarnings: 0,
+ },
+});
+
+const emptyTestFacet = (): TestFacet => ({
+ producer: 'rstest',
+ files: [],
+ stats: {
+ tests: { total: 0, passed: 0, failed: 0, skipped: 0, todo: 0 },
+ files: { total: 0, failed: 0 },
+ },
+ durationMs: 0,
+ unhandledErrors: [],
+});
+
+const storedSnapshot = ({
+ snapshotId,
+ producer,
+ contextId = 'ctx_app',
+ facet,
+ captureSelection,
+ schemaVersion = contextStoreSchemaVersion,
+}: {
+ snapshotId: string;
+ producer: ContextProducer;
+ contextId?: string;
+ facet?: LintFacet | TestFacet;
+ captureSelection?: JsonValue;
+ schemaVersion?: number;
+}): StoredContextSnapshot => {
+ const context: ContextDescriptor = {
+ contextId,
+ packageRoot: '.',
+ product: 'development',
+ };
+ const run: ContextRunManifest = {
+ schemaVersion: contextStoreSchemaVersion,
+ runId: `run_${snapshotId}`,
+ producer,
+ command: producer,
+ startedAt: '2026-08-12T08:00:00.000Z',
+ contexts: [context],
+ };
+ const snapshot = {
+ schemaVersion,
+ snapshotId,
+ runId: run.runId,
+ contextId,
+ sequence: 0,
+ observedAt: '2026-08-12T08:00:01.000Z',
+ status: 'pass',
+ completeness: {},
+ facets:
+ facet?.producer === 'rslint'
+ ? { lint: facet }
+ : facet?.producer === 'rstest'
+ ? { test: facet }
+ : {},
+ ...(captureSelection === undefined ? {} : { source: { captureSelection } }),
+ } as ContextSnapshot;
+ return { run, context, snapshot };
+};
+
+const compare = (
+ left: StoredContextSnapshot,
+ right: StoredContextSnapshot,
+ kind: SnapshotDiffKind,
+) => diffStoredContextSnapshots(left, right, kind, unknownFreshness, unknownFreshness);
+
+test('returns every applicable incompatibility reason in stable order', () => {
+ const left = storedSnapshot({
+ snapshotId: 'snap_left',
+ producer: 'rslint',
+ contextId: 'ctx_left',
+ facet: emptyLintFacet(),
+ });
+ const right = storedSnapshot({
+ snapshotId: 'snap_right',
+ producer: 'rstest',
+ contextId: 'ctx_right',
+ schemaVersion: 2,
+ });
+
+ expect(compare(left, right, 'diagnostics')).toEqual({
+ compatible: false,
+ reasons: ['context', 'facet', 'producer', 'schema-version'],
+ });
+});
+
+test('identifies the missing side when a requested snapshot does not exist', async () => {
+ const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), 'rstack-context-diff-missing-'));
+ try {
+ const present = storedSnapshot({
+ snapshotId: 'snap_present',
+ producer: 'rslint',
+ facet: emptyLintFacet(),
+ });
+ await writeContextRunManifest(workspaceRoot, present.run);
+ await writeContextSnapshot(workspaceRoot, present.snapshot);
+
+ await expect(
+ diffContextSnapshots(workspaceRoot, {
+ leftSnapshotId: 'snap_missing_left',
+ rightSnapshotId: 'snap_present',
+ }),
+ ).rejects.toThrow('Snapshot not found: snap_missing_left');
+ await expect(
+ diffContextSnapshots(workspaceRoot, {
+ leftSnapshotId: 'snap_present',
+ rightSnapshotId: 'snap_missing_right',
+ }),
+ ).rejects.toThrow('Snapshot not found: snap_missing_right');
+ } finally {
+ await rm(workspaceRoot, { force: true, recursive: true });
+ }
+});
+
+test('rejects snapshots captured with different selections', () => {
+ const left = storedSnapshot({
+ snapshotId: 'snap_left',
+ producer: 'rslint',
+ facet: emptyLintFacet(),
+ captureSelection: { mode: 'files', patterns: ['.'] },
+ });
+ const right = storedSnapshot({
+ snapshotId: 'snap_right',
+ producer: 'rslint',
+ facet: emptyLintFacet(),
+ captureSelection: { mode: 'files', patterns: ['src'] },
+ });
+
+ expect(compare(left, right, 'diagnostics')).toEqual({
+ compatible: false,
+ reasons: ['selection'],
+ });
+});
+
+test('diffs lint diagnostics by location and reports all observable changes', () => {
+ const leftFacet = emptyLintFacet();
+ leftFacet.files = [
+ {
+ path: 'src/a.ts',
+ digest: 'a'.repeat(64),
+ errorCount: 2,
+ warningCount: 0,
+ fixableErrorCount: 0,
+ fixableWarningCount: 0,
+ messages: [
+ {
+ ruleId: 'changed-rule',
+ severity: 2,
+ message: 'before',
+ line: 2,
+ column: 3,
+ endLine: 2,
+ endColumn: 8,
+ },
+ {
+ ruleId: null,
+ severity: 2,
+ message: 'removed',
+ line: 1,
+ column: 1,
+ },
+ ],
+ },
+ ];
+ const rightFacet = emptyLintFacet();
+ rightFacet.files = [
+ {
+ path: 'src/a.ts',
+ digest: 'b'.repeat(64),
+ errorCount: 0,
+ warningCount: 2,
+ fixableErrorCount: 0,
+ fixableWarningCount: 1,
+ messages: [
+ {
+ ruleId: null,
+ severity: 1,
+ message: 'added',
+ line: 1,
+ column: 1,
+ },
+ {
+ ruleId: 'changed-rule',
+ severity: 1,
+ message: 'after',
+ line: 2,
+ column: 3,
+ endLine: 3,
+ endColumn: 4,
+ fix: { range: [2, 3], text: 'fixed' },
+ },
+ ],
+ },
+ ];
+ const left = storedSnapshot({
+ snapshotId: 'snap_left',
+ producer: 'rslint',
+ facet: leftFacet,
+ });
+ const right = storedSnapshot({
+ snapshotId: 'snap_right',
+ producer: 'rslint',
+ facet: rightFacet,
+ });
+
+ const result = compare(left, right, 'diagnostics');
+ expect(result).toEqual({
+ compatible: true,
+ producer: 'rslint',
+ contextId: 'ctx_app',
+ left: { snapshotId: 'snap_left', freshness: unknownFreshness },
+ right: { snapshotId: 'snap_right', freshness: unknownFreshness },
+ added: [
+ {
+ path: 'src/a.ts',
+ ruleId: null,
+ severity: 'warning',
+ message: 'added',
+ line: 1,
+ column: 1,
+ fixable: false,
+ },
+ ],
+ removed: [
+ {
+ path: 'src/a.ts',
+ ruleId: null,
+ severity: 'error',
+ message: 'removed',
+ line: 1,
+ column: 1,
+ fixable: false,
+ },
+ ],
+ changed: [
+ {
+ before: {
+ path: 'src/a.ts',
+ ruleId: 'changed-rule',
+ severity: 'error',
+ message: 'before',
+ line: 2,
+ column: 3,
+ endLine: 2,
+ endColumn: 8,
+ fixable: false,
+ },
+ after: {
+ path: 'src/a.ts',
+ ruleId: 'changed-rule',
+ severity: 'warning',
+ message: 'after',
+ line: 2,
+ column: 3,
+ endLine: 3,
+ endColumn: 4,
+ fixable: true,
+ },
+ },
+ ],
+ summary: { added: 1, removed: 1, changed: 1 },
+ });
+
+ const reversed = compare(right, left, 'diagnostics');
+ expect(reversed.compatible && reversed.added).toEqual(
+ result.compatible ? result.removed : undefined,
+ );
+ expect(reversed.compatible && reversed.removed).toEqual(
+ result.compatible ? result.added : undefined,
+ );
+ expect(reversed.compatible && reversed.changed).toEqual(
+ result.compatible
+ ? result.changed.map(({ before, after }) => ({
+ before: after,
+ after: before,
+ }))
+ : undefined,
+ );
+});
+
+test('preserves duplicate lint diagnostics when only one occurrence is removed', () => {
+ const diagnostic = {
+ ruleId: 'duplicate-rule',
+ severity: 2 as const,
+ message: 'duplicate',
+ line: 1,
+ column: 1,
+ };
+ const leftFacet = emptyLintFacet();
+ leftFacet.files = [
+ {
+ path: 'src/duplicate.ts',
+ digest: 'a'.repeat(64),
+ errorCount: 2,
+ warningCount: 0,
+ fixableErrorCount: 0,
+ fixableWarningCount: 0,
+ messages: [diagnostic, { ...diagnostic }],
+ },
+ ];
+ const rightFacet = emptyLintFacet();
+ rightFacet.files = [
+ {
+ path: 'src/duplicate.ts',
+ digest: 'b'.repeat(64),
+ errorCount: 1,
+ warningCount: 0,
+ fixableErrorCount: 0,
+ fixableWarningCount: 0,
+ messages: [{ ...diagnostic }],
+ },
+ ];
+
+ const result = compare(
+ storedSnapshot({
+ snapshotId: 'snap_left',
+ producer: 'rslint',
+ facet: leftFacet,
+ }),
+ storedSnapshot({
+ snapshotId: 'snap_right',
+ producer: 'rslint',
+ facet: rightFacet,
+ }),
+ 'diagnostics',
+ );
+
+ expect(result).toMatchObject({
+ compatible: true,
+ added: [],
+ removed: [
+ {
+ path: 'src/duplicate.ts',
+ ruleId: 'duplicate-rule',
+ message: 'duplicate',
+ },
+ ],
+ changed: [],
+ summary: { added: 0, removed: 1, changed: 0 },
+ });
+});
+
+test('ignores test observation timing jitter across repeat captures', () => {
+ const testCase = {
+ project: 'unit',
+ path: 'stable.test.ts',
+ parentNames: ['stable suite'],
+ name: 'stays stable',
+ status: 'pass' as const,
+ meta: { owner: 'context' },
+ };
+ const leftFacet = emptyTestFacet();
+ leftFacet.durationMs = 1;
+ leftFacet.files = [
+ {
+ project: 'unit',
+ path: 'stable.test.ts',
+ status: 'pass',
+ durationMs: 0,
+ tests: [{ ...testCase, durationMs: 0 }],
+ },
+ ];
+ const rightFacet = emptyTestFacet();
+ rightFacet.durationMs = 4;
+ rightFacet.files = [
+ {
+ project: 'unit',
+ path: 'stable.test.ts',
+ status: 'pass',
+ durationMs: 3,
+ tests: [{ ...testCase, durationMs: 3 }],
+ },
+ ];
+
+ expect(
+ compare(
+ storedSnapshot({ snapshotId: 'snap_left', producer: 'rstest', facet: leftFacet }),
+ storedSnapshot({ snapshotId: 'snap_right', producer: 'rstest', facet: rightFacet }),
+ 'tests',
+ ),
+ ).toMatchObject({
+ compatible: true,
+ added: [],
+ removed: [],
+ changed: [],
+ summary: { added: 0, removed: 0, changed: 0 },
+ });
+});
+
+test('reports test metadata changes with the original timing context', () => {
+ const leftFacet = emptyTestFacet();
+ leftFacet.files = [
+ {
+ project: 'unit',
+ path: 'metadata.test.ts',
+ status: 'pass',
+ tests: [
+ {
+ project: 'unit',
+ path: 'metadata.test.ts',
+ name: 'tracks ownership',
+ status: 'pass',
+ durationMs: 0,
+ meta: { owner: 'before' },
+ },
+ ],
+ },
+ ];
+ const rightFacet = emptyTestFacet();
+ rightFacet.files = [
+ {
+ project: 'unit',
+ path: 'metadata.test.ts',
+ status: 'pass',
+ tests: [
+ {
+ project: 'unit',
+ path: 'metadata.test.ts',
+ name: 'tracks ownership',
+ status: 'pass',
+ durationMs: 3,
+ meta: { owner: 'after' },
+ },
+ ],
+ },
+ ];
+
+ const result = compare(
+ storedSnapshot({ snapshotId: 'snap_left', producer: 'rstest', facet: leftFacet }),
+ storedSnapshot({ snapshotId: 'snap_right', producer: 'rstest', facet: rightFacet }),
+ 'tests',
+ );
+
+ expect(result).toMatchObject({
+ compatible: true,
+ added: [],
+ removed: [],
+ changed: [
+ {
+ before: { durationMs: 0, meta: { owner: 'before' } },
+ after: { durationMs: 3, meta: { owner: 'after' } },
+ },
+ ],
+ summary: { added: 0, removed: 0, changed: 1 },
+ });
+});
+
+test('diffs project-qualified test results and reports execution changes', () => {
+ const leftFacet = emptyTestFacet();
+ leftFacet.files = [
+ {
+ project: 'alpha',
+ path: 'math.test.ts',
+ status: 'pass',
+ tests: [
+ {
+ project: 'alpha',
+ path: 'math.test.ts',
+ name: 'adds',
+ status: 'pass',
+ },
+ ],
+ },
+ {
+ project: 'beta',
+ path: 'math.test.ts',
+ status: 'pass',
+ tests: [
+ {
+ project: 'beta',
+ path: 'math.test.ts',
+ parentNames: ['math'],
+ name: 'adds',
+ status: 'pass',
+ durationMs: 2,
+ },
+ ],
+ },
+ ];
+ const rightFacet = emptyTestFacet();
+ rightFacet.files = [
+ {
+ project: 'beta',
+ path: 'math.test.ts',
+ status: 'fail',
+ tests: [
+ {
+ project: 'beta',
+ path: 'math.test.ts',
+ parentNames: ['math'],
+ name: 'adds',
+ status: 'fail',
+ durationMs: 7,
+ errors: [{ name: 'AssertionError', message: 'expected 3' }],
+ retryErrors: [{ name: 'AssertionError', message: 'expected 2' }],
+ retryCount: 1,
+ },
+ ],
+ },
+ {
+ project: 'gamma',
+ path: 'math.test.ts',
+ status: 'todo',
+ tests: [
+ {
+ project: 'gamma',
+ path: 'math.test.ts',
+ name: 'adds',
+ status: 'todo',
+ },
+ ],
+ },
+ ];
+
+ const result = compare(
+ storedSnapshot({
+ snapshotId: 'snap_left',
+ producer: 'rstest',
+ facet: leftFacet,
+ }),
+ storedSnapshot({
+ snapshotId: 'snap_right',
+ producer: 'rstest',
+ facet: rightFacet,
+ }),
+ 'tests',
+ );
+
+ expect(result).toMatchObject({
+ compatible: true,
+ producer: 'rstest',
+ added: [{ project: 'gamma', path: 'math.test.ts', name: 'adds', status: 'todo' }],
+ removed: [{ project: 'alpha', path: 'math.test.ts', name: 'adds', status: 'pass' }],
+ changed: [
+ {
+ before: {
+ project: 'beta',
+ path: 'math.test.ts',
+ parentNames: ['math'],
+ name: 'adds',
+ status: 'pass',
+ durationMs: 2,
+ },
+ after: {
+ project: 'beta',
+ path: 'math.test.ts',
+ parentNames: ['math'],
+ name: 'adds',
+ status: 'fail',
+ durationMs: 7,
+ errors: [{ name: 'AssertionError', message: 'expected 3' }],
+ retryErrors: [{ name: 'AssertionError', message: 'expected 2' }],
+ retryCount: 1,
+ },
+ },
+ ],
+ summary: { added: 1, removed: 1, changed: 1 },
+ });
+});
+
+test('includes unhandled Rstest errors in snapshot diffs', () => {
+ const leftFacet = emptyTestFacet();
+ const rightFacet = emptyTestFacet();
+ rightFacet.unhandledErrors = [
+ {
+ name: 'GlobalSetupError',
+ message: 'could not initialize the test environment',
+ stack: 'setup stack',
+ },
+ ];
+
+ const result = compare(
+ storedSnapshot({ snapshotId: 'snap_left', producer: 'rstest', facet: leftFacet }),
+ storedSnapshot({ snapshotId: 'snap_right', producer: 'rstest', facet: rightFacet }),
+ 'tests',
+ );
+
+ expect(result).toMatchObject({
+ compatible: true,
+ added: [
+ {
+ kind: 'unhandled-error',
+ error: {
+ name: 'GlobalSetupError',
+ message: 'could not initialize the test environment',
+ stack: 'setup stack',
+ },
+ },
+ ],
+ removed: [],
+ changed: [],
+ summary: { added: 1, removed: 0, changed: 0 },
+ });
+});
+
+test('preserves duplicate test results when only one occurrence is removed', () => {
+ const duplicate = {
+ project: 'unit',
+ path: 'duplicate.test.ts',
+ parentNames: ['suite'],
+ name: 'duplicate',
+ status: 'pass' as const,
+ };
+ const leftFacet = emptyTestFacet();
+ leftFacet.files = [
+ {
+ project: 'unit',
+ path: 'duplicate.test.ts',
+ status: 'pass',
+ tests: [duplicate, { ...duplicate }],
+ },
+ ];
+ const rightFacet = emptyTestFacet();
+ rightFacet.files = [
+ {
+ project: 'unit',
+ path: 'duplicate.test.ts',
+ status: 'pass',
+ tests: [{ ...duplicate }],
+ },
+ ];
+
+ const result = compare(
+ storedSnapshot({
+ snapshotId: 'snap_left',
+ producer: 'rstest',
+ facet: leftFacet,
+ }),
+ storedSnapshot({
+ snapshotId: 'snap_right',
+ producer: 'rstest',
+ facet: rightFacet,
+ }),
+ 'tests',
+ );
+
+ expect(result).toMatchObject({
+ compatible: true,
+ added: [],
+ removed: [duplicate],
+ changed: [],
+ summary: { added: 0, removed: 1, changed: 0 },
+ });
+});
+
+test('diffs file-level test failures without inventing test cases', () => {
+ const leftFacet = emptyTestFacet();
+ leftFacet.files = [
+ {
+ project: 'unit',
+ path: 'broken.test.ts',
+ status: 'fail',
+ errors: [{ name: 'ImportError', message: 'could not import setup-a' }],
+ tests: [],
+ },
+ ];
+ const rightFacet = emptyTestFacet();
+ rightFacet.files = [
+ {
+ project: 'unit',
+ path: 'broken.test.ts',
+ status: 'fail',
+ errors: [{ name: 'ImportError', message: 'could not import setup-b' }],
+ tests: [],
+ },
+ ];
+
+ const result = compare(
+ storedSnapshot({
+ snapshotId: 'snap_left',
+ producer: 'rstest',
+ facet: leftFacet,
+ }),
+ storedSnapshot({
+ snapshotId: 'snap_right',
+ producer: 'rstest',
+ facet: rightFacet,
+ }),
+ 'tests',
+ );
+
+ expect(result).toMatchObject({
+ compatible: true,
+ added: [],
+ removed: [],
+ changed: [
+ {
+ before: {
+ kind: 'file-error',
+ project: 'unit',
+ path: 'broken.test.ts',
+ error: { name: 'ImportError', message: 'could not import setup-a' },
+ },
+ after: {
+ kind: 'file-error',
+ project: 'unit',
+ path: 'broken.test.ts',
+ error: { name: 'ImportError', message: 'could not import setup-b' },
+ },
+ },
+ ],
+ summary: { added: 0, removed: 0, changed: 1 },
+ });
+});
+
+test('reads immutable snapshots, reports independent freshness, and returns an empty equal diff', async () => {
+ const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), 'rstack-context-diff-'));
+ try {
+ await writeFile(path.join(workspaceRoot, 'input.ts'), 'current');
+ const context: ContextDescriptor = {
+ contextId: 'ctx_lint',
+ packageRoot: '.',
+ product: 'development',
+ environment: 'lint',
+ };
+ const run: ContextRunManifest = {
+ schemaVersion: contextStoreSchemaVersion,
+ runId: 'run_lint',
+ producer: 'rslint',
+ command: 'rs lint',
+ startedAt: '2026-08-12T08:00:00.000Z',
+ contexts: [context],
+ };
+ await writeContextRunManifest(workspaceRoot, run);
+ const source = (content: string) => ({
+ inputs: [
+ {
+ path: 'input.ts',
+ digest: createHash('sha256').update(content).digest('hex'),
+ },
+ ],
+ inputCompleteness: 'complete' as const,
+ });
+ const snapshot = (snapshotId: string, sequence: number, content: string): ContextSnapshot => ({
+ schemaVersion: contextStoreSchemaVersion,
+ snapshotId,
+ runId: run.runId,
+ contextId: context.contextId,
+ sequence,
+ observedAt: `2026-08-12T08:00:0${sequence + 1}.000Z`,
+ status: 'pass',
+ completeness: { lint: 'complete' },
+ facets: { lint: emptyLintFacet() },
+ source: source(content),
+ });
+ await writeContextSnapshot(workspaceRoot, snapshot('snap_fresh', 0, 'current'));
+ await writeContextSnapshot(workspaceRoot, snapshot('snap_stale', 1, 'old'));
+
+ expect(
+ await diffContextSnapshots(workspaceRoot, {
+ leftSnapshotId: 'snap_fresh',
+ rightSnapshotId: 'snap_stale',
+ }),
+ ).toEqual({
+ compatible: true,
+ producer: 'rslint',
+ contextId: 'ctx_lint',
+ left: {
+ snapshotId: 'snap_fresh',
+ freshness: { state: 'fresh', changedPaths: [] },
+ },
+ right: {
+ snapshotId: 'snap_stale',
+ freshness: { state: 'stale', changedPaths: ['input.ts'] },
+ },
+ added: [],
+ removed: [],
+ changed: [],
+ summary: { added: 0, removed: 0, changed: 0 },
+ });
+ } finally {
+ await rm(workspaceRoot, { force: true, recursive: true });
+ }
+});
diff --git a/tests/helpers.ts b/tests/helpers.ts
new file mode 100644
index 0000000..61b7c9c
--- /dev/null
+++ b/tests/helpers.ts
@@ -0,0 +1,18 @@
+import { mkdtemp, rm } from 'node:fs/promises';
+import os from 'node:os';
+import path from 'node:path';
+
+const withTempWorkspace = async (
+ prefix: string,
+ callback: (workspaceRoot: string) => Promise,
+): Promise => {
+ const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), prefix));
+
+ try {
+ await callback(workspaceRoot);
+ } finally {
+ await rm(workspaceRoot, { force: true, recursive: true });
+ }
+};
+
+export { withTempWorkspace };
diff --git a/tests/lint.test.ts b/tests/lint.test.ts
new file mode 100644
index 0000000..b54eade
--- /dev/null
+++ b/tests/lint.test.ts
@@ -0,0 +1,867 @@
+/* rslint-disable @typescript-eslint/no-unsafe-assignment -- Rstest asymmetric matchers are intentionally untyped. */
+import { spawnSync } from 'node:child_process';
+import { mkdir, writeFile } from 'node:fs/promises';
+import path from 'node:path';
+import { pathToFileURL } from 'node:url';
+import type { LintResult, RslintOptions } from '@rslint/core';
+import { beforeEach, expect, test } from '@rstest/core';
+import {
+ captureLintSnapshot,
+ getLintFixPreview,
+ listDiagnostics,
+ type LintCaptureAdapter,
+} from '../src/lint.ts';
+import { contextStoreSchemaVersion, type ContextRunManifest } from '../src/model.ts';
+import { readProjectStatus } from '../src/status.ts';
+import {
+ readContextSnapshotById,
+ readContextSnapshots,
+ writeContextRunManifest,
+ writeContextSnapshot,
+} from '../src/store.ts';
+import { withTempWorkspace } from './helpers.ts';
+
+const mocks = {
+ closeCalls: 0,
+ lintFilesCalls: [] as Array,
+ lintTextCalls: [] as Array<[string, { filePath?: string } | undefined]>,
+ options: [] as RslintOptions[],
+ results: [] as LintResult[],
+ lintError: undefined as Error | undefined,
+};
+
+const createRslint = (options: RslintOptions) => {
+ mocks.options.push(options);
+ return {
+ lintFiles(patterns: string | string[]): Promise {
+ mocks.lintFilesCalls.push(patterns);
+ return mocks.lintError === undefined
+ ? Promise.resolve(mocks.results)
+ : Promise.reject(mocks.lintError);
+ },
+
+ lintText(code: string, options?: { filePath?: string }): Promise {
+ mocks.lintTextCalls.push([code, options]);
+ return mocks.lintError === undefined
+ ? Promise.resolve(mocks.results)
+ : Promise.reject(mocks.lintError);
+ },
+
+ close(): Promise {
+ mocks.closeCalls += 1;
+ return Promise.resolve();
+ },
+ };
+};
+
+const configTargets: Array<{ configRoot: string; configPath: string | undefined }> = [];
+
+const adapter: LintCaptureAdapter = {
+ wrapperConfigPath: path.join(path.sep, 'wrapper', 'rslintConfig.js'),
+ withConfigTarget: (configRoot, configPath, action) => {
+ configTargets.push({ configRoot, configPath });
+ return action();
+ },
+};
+
+beforeEach(() => {
+ configTargets.length = 0;
+ mocks.closeCalls = 0;
+ mocks.lintFilesCalls.length = 0;
+ mocks.lintTextCalls.length = 0;
+ mocks.options.length = 0;
+ mocks.results = [];
+ mocks.lintError = undefined;
+});
+
+test('loading lint queries does not load the Rslint runtime', async () => {
+ await withTempWorkspace('rstack-context-lint-', async (workspaceRoot) => {
+ const markerFile = path.join(workspaceRoot, 'rslint-loaded');
+ const hookFile = path.join(workspaceRoot, 'import-hook.mjs');
+ await writeFile(
+ hookFile,
+ `import { writeFileSync } from 'node:fs';
+import { registerHooks } from 'node:module';
+registerHooks({
+ resolve(specifier, context, nextResolve) {
+ if (specifier === '@rslint/core') {
+ writeFileSync(process.env.RSTACK_RSLINT_LOADED_MARKER, 'loaded');
+ }
+ return nextResolve(specifier, context);
+ },
+});
+`,
+ );
+ const moduleUrl = pathToFileURL(path.resolve('src/lint.ts')).toString();
+ const result = spawnSync(
+ process.execPath,
+ [
+ '--import',
+ pathToFileURL(hookFile).href,
+ '--input-type=module',
+ '--eval',
+ `const { listDiagnostics } = await import(${JSON.stringify(moduleUrl)});
+await listDiagnostics(${JSON.stringify(workspaceRoot)}).catch(() => undefined);`,
+ ],
+ {
+ encoding: 'utf8',
+ env: { ...process.env, RSTACK_RSLINT_LOADED_MARKER: markerFile },
+ },
+ );
+
+ expect(result.stderr).toBe('');
+ expect(result.status).toBe(0);
+ await expect(
+ import('node:fs/promises').then(({ access }) => access(markerFile)),
+ ).rejects.toThrow();
+ });
+});
+
+test('captures an open-ended file snapshot with partial inputs and fail status', async () => {
+ await withTempWorkspace('rstack-context-lint-', async (workspaceRoot) => {
+ const aPath = path.join(workspaceRoot, 'a.ts');
+ const bPath = path.join(workspaceRoot, 'b.ts');
+ await writeFile(aPath, 'const a = 1;\n');
+ await writeFile(bPath, 'const b = 2;\n');
+ mocks.results = [
+ {
+ filePath: bPath,
+ errorCount: 1,
+ warningCount: 0,
+ fixableErrorCount: 1,
+ fixableWarningCount: 0,
+ messages: [
+ {
+ ruleId: 'z-rule',
+ severity: 2,
+ message: 'later',
+ line: 3,
+ column: 1,
+ },
+ {
+ ruleId: 'a-rule',
+ severity: 2,
+ message: 'first',
+ line: 1,
+ column: 2,
+ fix: { range: [0, 1], text: 'x' },
+ },
+ ],
+ },
+ {
+ filePath: aPath,
+ errorCount: 0,
+ warningCount: 1,
+ fixableErrorCount: 0,
+ fixableWarningCount: 0,
+ messages: [
+ {
+ ruleId: null,
+ severity: 1,
+ message: 'warning',
+ line: 1,
+ column: 1,
+ },
+ ],
+ },
+ ];
+
+ const result = await captureLintSnapshot(
+ workspaceRoot,
+ { mode: 'files' },
+ createRslint,
+ adapter,
+ );
+ const stored = await readContextSnapshotById(workspaceRoot, result.snapshotId);
+ const status = await readProjectStatus(workspaceRoot);
+
+ expect(mocks.options).toEqual([
+ {
+ cwd: workspaceRoot,
+ fix: false,
+ overrideConfigFile: expect.stringMatching(/[\\/]rslintConfig\.js$/u),
+ },
+ ]);
+ expect(mocks.lintFilesCalls).toEqual([['.']]);
+ expect(mocks.closeCalls).toBe(1);
+ expect(result).toMatchObject({
+ status: 'fail',
+ freshness: { state: 'partial', changedPaths: [] },
+ summary: {
+ files: 2,
+ errors: 1,
+ warnings: 1,
+ fixableErrors: 1,
+ fixableWarnings: 0,
+ },
+ });
+ expect(stored?.snapshot.source).toMatchObject({
+ captureSelection: { mode: 'files', patterns: ['.'] },
+ inputCompleteness: 'partial',
+ inputs: [{ path: 'a.ts' }, { path: 'b.ts' }],
+ });
+ expect(stored?.snapshot.facets.lint).toMatchObject({
+ producer: 'rslint',
+ mode: 'files',
+ fixPreviewCaptured: false,
+ files: [
+ { path: 'a.ts', messages: [{ message: 'warning' }] },
+ {
+ path: 'b.ts',
+ messages: [{ message: 'first' }, { message: 'later' }],
+ },
+ ],
+ });
+ expect(status.contexts[0]?.context).toEqual({
+ contextId: result.contextId,
+ packageRoot: '.',
+ product: 'development',
+ environment: 'lint',
+ });
+ });
+});
+
+test('captures text without persisting the input and exposes only stored fix output', async () => {
+ await withTempWorkspace('rstack-context-lint-', async (workspaceRoot) => {
+ const code = 'let value = 1;\n';
+ mocks.results = [
+ {
+ filePath: path.join(workspaceRoot, 'src', 'buffer.ts'),
+ errorCount: 0,
+ warningCount: 1,
+ fixableErrorCount: 0,
+ fixableWarningCount: 1,
+ messages: [
+ {
+ ruleId: 'prefer-const',
+ severity: 1,
+ message: 'Use const.',
+ line: 1,
+ column: 1,
+ },
+ ],
+ output: 'const value = 1;\n',
+ },
+ ];
+
+ const result = await captureLintSnapshot(
+ workspaceRoot,
+ {
+ mode: 'text',
+ code,
+ filePath: 'src/buffer.ts',
+ includeFixPreview: true,
+ },
+ createRslint,
+ adapter,
+ );
+ const stored = await readContextSnapshotById(workspaceRoot, result.snapshotId);
+
+ expect(mocks.options[0]).toMatchObject({ cwd: workspaceRoot, fix: true });
+ expect(mocks.lintTextCalls).toEqual([[code, { filePath: 'src/buffer.ts' }]]);
+ expect(result.status).toBe('pass');
+ expect(result.freshness).toEqual({ state: 'unknown', changedPaths: [] });
+ expect(stored?.snapshot.source).toEqual({
+ captureSelection: { mode: 'text', filePath: 'src/buffer.ts' },
+ virtualInputDigest: 'cb9ebc2725b5316484859fdf300212c224086174b0e6e64e16cd2a7f65c90829',
+ });
+ expect(JSON.stringify(stored)).not.toContain(code);
+ await expect(
+ getLintFixPreview(workspaceRoot, result.snapshotId, 'src/buffer.ts'),
+ ).resolves.toEqual({
+ available: true,
+ snapshotId: result.snapshotId,
+ path: 'src/buffer.ts',
+ beforeDigest: 'cb9ebc2725b5316484859fdf300212c224086174b0e6e64e16cd2a7f65c90829',
+ fixedOutput: 'const value = 1;\n',
+ });
+ });
+});
+
+test('paginates and filters diagnostics from one frozen snapshot deterministically', async () => {
+ await withTempWorkspace('rstack-context-lint-', async (workspaceRoot) => {
+ const aPath = path.join(workspaceRoot, 'src', 'a.ts');
+ const bPath = path.join(workspaceRoot, 'src', 'b.ts');
+ await mkdir(path.dirname(aPath), { recursive: true });
+ await writeFile(aPath, 'a');
+ await writeFile(bPath, 'b');
+ mocks.results = [
+ {
+ filePath: bPath,
+ errorCount: 1,
+ warningCount: 0,
+ fixableErrorCount: 0,
+ fixableWarningCount: 0,
+ messages: [{ ruleId: 'b', severity: 2, message: 'b', line: 2, column: 1 }],
+ },
+ {
+ filePath: aPath,
+ errorCount: 1,
+ warningCount: 1,
+ fixableErrorCount: 0,
+ fixableWarningCount: 0,
+ messages: [
+ { ruleId: 'a', severity: 1, message: 'warning', line: 2, column: 1 },
+ { ruleId: 'a', severity: 2, message: 'error', line: 1, column: 1 },
+ ],
+ },
+ ];
+ const capture = await captureLintSnapshot(
+ workspaceRoot,
+ {
+ mode: 'files',
+ patterns: ['src/**/*.ts'],
+ },
+ createRslint,
+ adapter,
+ );
+
+ const first = await listDiagnostics(workspaceRoot, {
+ snapshotId: capture.snapshotId,
+ severity: 'error',
+ pathPrefix: 'src/',
+ limit: 1,
+ });
+ expect(first).toMatchObject({
+ snapshotId: capture.snapshotId,
+ freshness: { state: 'partial', changedPaths: [] },
+ total: 2,
+ items: [{ path: 'src/a.ts', ruleId: 'a', severity: 'error', message: 'error' }],
+ });
+ expect(first.nextCursor).toEqual(expect.any(String));
+ await expect(
+ listDiagnostics(workspaceRoot, {
+ snapshotId: capture.snapshotId,
+ severity: 'warning',
+ pathPrefix: 'src/',
+ limit: 1,
+ cursor: first.nextCursor,
+ }),
+ ).rejects.toThrow('Invalid diagnostics cursor');
+ await expect(
+ listDiagnostics(workspaceRoot, {
+ snapshotId: capture.snapshotId,
+ severity: 'error',
+ pathPrefix: 'src/',
+ limit: 1,
+ cursor: first.nextCursor,
+ }),
+ ).resolves.toMatchObject({
+ snapshotId: capture.snapshotId,
+ total: 2,
+ items: [{ path: 'src/b.ts', ruleId: 'b', severity: 'error', message: 'b' }],
+ });
+ });
+});
+
+test('reports only terminal test failures as current diagnostics', async () => {
+ await withTempWorkspace('rstack-context-lint-', async (workspaceRoot) => {
+ const context = {
+ contextId: 'ctx_test',
+ packageRoot: '.',
+ product: 'development',
+ } as const;
+ const run = {
+ schemaVersion: contextStoreSchemaVersion,
+ runId: 'run_test_diagnostics',
+ producer: 'rstest',
+ command: 'test',
+ startedAt: '2026-08-12T08:00:00.000Z',
+ contexts: [context],
+ } satisfies ContextRunManifest;
+ await writeContextRunManifest(workspaceRoot, run);
+ await writeContextSnapshot(workspaceRoot, {
+ schemaVersion: contextStoreSchemaVersion,
+ snapshotId: 'snap_test_diagnostics',
+ runId: run.runId,
+ contextId: context.contextId,
+ sequence: 0,
+ observedAt: '2026-08-12T08:00:01.000Z',
+ status: 'fail',
+ completeness: { test: 'complete' },
+ facets: {
+ test: {
+ producer: 'rstest',
+ files: [
+ {
+ project: 'unit',
+ path: 'src/recovered.test.ts',
+ status: 'pass',
+ tests: [
+ {
+ project: 'unit',
+ path: 'src/recovered.test.ts',
+ name: 'recovers',
+ status: 'pass',
+ retryErrors: [{ name: 'AssertionError', message: 'recovered attempt' }],
+ retryCount: 1,
+ },
+ ],
+ },
+ {
+ project: 'unit',
+ path: 'src/failing.test.ts',
+ status: 'fail',
+ errors: [{ name: 'Error', message: 'file import failed' }],
+ tests: [
+ {
+ project: 'unit',
+ path: 'src/failing.test.ts',
+ parentNames: ['suite'],
+ name: 'fails',
+ status: 'fail',
+ errors: [{ name: 'AssertionError', message: 'terminal failure' }],
+ retryErrors: [{ name: 'AssertionError', message: 'earlier attempt' }],
+ retryCount: 1,
+ },
+ ],
+ },
+ ],
+ stats: {
+ tests: { total: 2, passed: 1, failed: 1, skipped: 0, todo: 0 },
+ files: { total: 2, failed: 1 },
+ },
+ durationMs: 3,
+ unhandledErrors: [],
+ },
+ },
+ });
+
+ await expect(
+ listDiagnostics(workspaceRoot, { snapshotId: 'snap_test_diagnostics' }),
+ ).resolves.toMatchObject({
+ total: 2,
+ items: [
+ {
+ producer: 'rstest',
+ path: 'src/failing.test.ts',
+ project: 'unit',
+ message: 'file import failed',
+ },
+ {
+ producer: 'rstest',
+ path: 'src/failing.test.ts',
+ project: 'unit',
+ name: 'suite > fails',
+ message: 'terminal failure',
+ },
+ ],
+ });
+ });
+});
+
+test('selects the newest diagnostic snapshot rather than an unrelated newer build', async () => {
+ await withTempWorkspace('rstack-context-lint-', async (workspaceRoot) => {
+ const filePath = path.join(workspaceRoot, 'a.ts');
+ await writeFile(filePath, 'a');
+ mocks.results = [
+ {
+ filePath,
+ errorCount: 1,
+ warningCount: 0,
+ fixableErrorCount: 0,
+ fixableWarningCount: 0,
+ messages: [{ ruleId: 'a', severity: 2, message: 'error', line: 1, column: 1 }],
+ },
+ ];
+ const lintCapture = await captureLintSnapshot(
+ workspaceRoot,
+ { mode: 'files' },
+ createRslint,
+ adapter,
+ );
+ const context = { contextId: 'ctx_build', packageRoot: '.', product: 'application' } as const;
+ const run = {
+ schemaVersion: contextStoreSchemaVersion,
+ runId: 'run_newer_build',
+ producer: 'rsbuild',
+ command: 'build',
+ startedAt: '9999-01-01T00:00:00.000Z',
+ contexts: [context],
+ } satisfies ContextRunManifest;
+ await writeContextRunManifest(workspaceRoot, run);
+ await writeContextSnapshot(workspaceRoot, {
+ schemaVersion: contextStoreSchemaVersion,
+ snapshotId: 'snap_newer_build',
+ runId: run.runId,
+ contextId: context.contextId,
+ sequence: 0,
+ observedAt: '9999-01-01T00:00:00.000Z',
+ status: 'pass',
+ completeness: { build: 'complete' },
+ facets: {},
+ });
+
+ await expect(listDiagnostics(workspaceRoot)).resolves.toMatchObject({
+ snapshotId: lintCapture.snapshotId,
+ total: 1,
+ });
+ });
+});
+
+test('rejects a malformed diagnostics cursor', async () => {
+ await withTempWorkspace('rstack-context-lint-', async (workspaceRoot) => {
+ const filePath = path.join(workspaceRoot, 'a.ts');
+ await writeFile(filePath, 'a');
+ mocks.results = [
+ {
+ filePath,
+ errorCount: 0,
+ warningCount: 0,
+ fixableErrorCount: 0,
+ fixableWarningCount: 0,
+ messages: [],
+ },
+ ];
+ const capture = await captureLintSnapshot(
+ workspaceRoot,
+ { mode: 'files', patterns: ['a.ts'] },
+ createRslint,
+ adapter,
+ );
+
+ await expect(
+ listDiagnostics(workspaceRoot, { snapshotId: capture.snapshotId, cursor: '?' }),
+ ).rejects.toThrow('Invalid diagnostics cursor');
+ });
+});
+
+test('returns snapshot provenance for an empty diagnostics page', async () => {
+ await withTempWorkspace('rstack-context-lint-', async (workspaceRoot) => {
+ const filePath = path.join(workspaceRoot, 'a.ts');
+ await writeFile(filePath, 'a');
+ mocks.results = [
+ {
+ filePath,
+ errorCount: 0,
+ warningCount: 0,
+ fixableErrorCount: 0,
+ fixableWarningCount: 0,
+ messages: [],
+ },
+ ];
+ const capture = await captureLintSnapshot(
+ workspaceRoot,
+ { mode: 'files', patterns: ['a.ts'] },
+ createRslint,
+ adapter,
+ );
+ const stored = await readContextSnapshotById(workspaceRoot, capture.snapshotId);
+
+ await expect(
+ listDiagnostics(workspaceRoot, { snapshotId: capture.snapshotId }),
+ ).resolves.toMatchObject({
+ snapshotId: capture.snapshotId,
+ producer: 'rslint',
+ contextId: capture.contextId,
+ observedAt: stored?.snapshot.observedAt,
+ completeness: { lint: 'complete' },
+ freshness: { state: 'fresh', changedPaths: [] },
+ total: 0,
+ items: [],
+ });
+ });
+});
+
+test('reports preview availability without rerunning or applying Rslint', async () => {
+ await withTempWorkspace('rstack-context-lint-', async (workspaceRoot) => {
+ const filePath = path.join(workspaceRoot, 'a.ts');
+ await writeFile(filePath, 'const a = 1;\n');
+ mocks.results = [
+ {
+ filePath,
+ errorCount: 0,
+ warningCount: 0,
+ fixableErrorCount: 0,
+ fixableWarningCount: 0,
+ messages: [],
+ output: 'const a = 1;\n',
+ },
+ ];
+
+ const notCaptured = await captureLintSnapshot(
+ workspaceRoot,
+ {
+ mode: 'files',
+ },
+ createRslint,
+ adapter,
+ );
+ await expect(getLintFixPreview(workspaceRoot, notCaptured.snapshotId, 'a.ts')).resolves.toEqual(
+ {
+ available: false,
+ reason: 'not-captured',
+ snapshotId: notCaptured.snapshotId,
+ path: 'a.ts',
+ },
+ );
+
+ const noChange = await captureLintSnapshot(
+ workspaceRoot,
+ {
+ mode: 'files',
+ includeFixPreview: true,
+ },
+ createRslint,
+ adapter,
+ );
+ await expect(getLintFixPreview(workspaceRoot, noChange.snapshotId, 'a.ts')).resolves.toEqual({
+ available: false,
+ reason: 'no-change',
+ snapshotId: noChange.snapshotId,
+ path: 'a.ts',
+ });
+ expect(mocks.lintFilesCalls).toHaveLength(2);
+ });
+});
+
+test('separates an unknown snapshot id from a snapshot without a lint facet', async () => {
+ await withTempWorkspace('rstack-context-lint-', async (workspaceRoot) => {
+ const filePath = path.join(workspaceRoot, 'a.ts');
+ await writeFile(filePath, 'const a = 1;\n');
+ mocks.results = [
+ {
+ filePath,
+ errorCount: 0,
+ warningCount: 0,
+ fixableErrorCount: 0,
+ fixableWarningCount: 0,
+ messages: [],
+ },
+ ];
+ await captureLintSnapshot(workspaceRoot, { mode: 'files' }, createRslint, adapter);
+
+ await expect(getLintFixPreview(workspaceRoot, 'snap_missing', 'a.ts')).rejects.toThrow(
+ 'Unknown snapshot: snap_missing',
+ );
+
+ const runId = 'run_build_only';
+ const contextId = 'ctx_build_only';
+ await writeContextRunManifest(workspaceRoot, {
+ schemaVersion: contextStoreSchemaVersion,
+ runId,
+ producer: 'rsbuild',
+ command: 'build',
+ startedAt: '2026-08-14T03:00:00.000Z',
+ contexts: [{ contextId, packageRoot: '.', product: 'application' }],
+ });
+ await writeContextSnapshot(workspaceRoot, {
+ schemaVersion: contextStoreSchemaVersion,
+ snapshotId: 'snap_build_only',
+ runId,
+ contextId,
+ sequence: 0,
+ observedAt: '2026-08-14T03:00:01.000Z',
+ status: 'pass',
+ completeness: { build: 'complete' },
+ facets: {},
+ });
+
+ await expect(getLintFixPreview(workspaceRoot, 'snap_build_only', 'a.ts')).rejects.toThrow(
+ 'The selected snapshot has no lint facet.',
+ );
+ });
+});
+
+test('records unreadable lint inputs as degraded completeness instead of throwing', async () => {
+ await withTempWorkspace('rstack-context-lint-', async (workspaceRoot) => {
+ const presentPath = path.join(workspaceRoot, 'a.ts');
+ const removedPath = path.join(workspaceRoot, 'gone.ts');
+ await writeFile(presentPath, 'const a = 1;\n');
+ mocks.results = [
+ {
+ filePath: removedPath,
+ errorCount: 1,
+ warningCount: 0,
+ fixableErrorCount: 0,
+ fixableWarningCount: 0,
+ messages: [{ ruleId: 'a-rule', severity: 2, message: 'removed', line: 1, column: 1 }],
+ },
+ {
+ filePath: presentPath,
+ errorCount: 0,
+ warningCount: 0,
+ fixableErrorCount: 0,
+ fixableWarningCount: 0,
+ messages: [],
+ },
+ ];
+
+ const capture = await captureLintSnapshot(
+ workspaceRoot,
+ { mode: 'files', patterns: ['a.ts', 'gone.ts'] },
+ createRslint,
+ adapter,
+ );
+
+ expect(capture.unreadableInputs).toEqual(['gone.ts']);
+ expect(capture.summary.files).toBe(1);
+ const stored = await readContextSnapshotById(workspaceRoot, capture.snapshotId);
+ expect(stored?.snapshot.completeness).toEqual({ lint: 'partial', source: 'partial' });
+ expect(stored?.snapshot.source).toMatchObject({
+ inputCompleteness: 'partial',
+ inputs: [{ path: 'a.ts' }],
+ });
+ expect(stored?.snapshot.facets.lint).toMatchObject({
+ producer: 'rslint',
+ files: [{ path: 'a.ts' }],
+ });
+ });
+});
+
+test('records zero reported lint files as an actionable partial error instead of a passing capture', async () => {
+ await withTempWorkspace('rstack-context-lint-', async (workspaceRoot) => {
+ await writeFile(path.join(workspaceRoot, 'a.ts'), 'const a = 1;\n');
+
+ await expect(
+ captureLintSnapshot(workspaceRoot, { mode: 'files' }, createRslint, adapter),
+ ).rejects.toThrow('Rslint reported no files');
+
+ const [stored] = await readContextSnapshots(workspaceRoot, { producer: 'rslint' });
+ expect(stored?.snapshot).toMatchObject({
+ status: 'error',
+ completeness: { lint: 'partial' },
+ facets: {
+ lint: {
+ producer: 'rslint',
+ mode: 'files',
+ files: [
+ {
+ errorCount: 1,
+ messages: [
+ {
+ severity: 2,
+ message: expect.stringContaining('define.lint'),
+ },
+ ],
+ },
+ ],
+ totals: { files: 1, errors: 1 },
+ },
+ },
+ source: { inputs: [], inputCompleteness: 'partial' },
+ });
+ });
+});
+
+test('reports a missing lint config adapter before writing any run manifest', async () => {
+ await withTempWorkspace('rstack-context-lint-', async (workspaceRoot) => {
+ await expect(captureLintSnapshot(workspaceRoot, { mode: 'files' })).rejects.toThrow(
+ 'Rstack lint capture requires a config adapter.',
+ );
+
+ await expect(readProjectStatus(workspaceRoot)).resolves.toMatchObject({ contexts: [] });
+ await expect(readContextSnapshots(workspaceRoot, { producer: 'rslint' })).resolves.toEqual([]);
+ });
+});
+
+test('rejects lint capture targets that escape the checkout', async () => {
+ await withTempWorkspace('rstack-context-lint-', async (workspaceRoot) => {
+ await expect(
+ captureLintSnapshot(
+ workspaceRoot,
+ { mode: 'files', packageRoot: '../../..' },
+ createRslint,
+ adapter,
+ ),
+ ).rejects.toThrow(
+ 'packageRoot must be a non-empty checkout-relative path that stays inside the checkout.',
+ );
+ await expect(
+ captureLintSnapshot(
+ workspaceRoot,
+ { mode: 'files', configPath: '../../evil.config.ts' },
+ createRslint,
+ adapter,
+ ),
+ ).rejects.toThrow(
+ 'configPath must be a non-empty checkout-relative path that stays inside the checkout.',
+ );
+ expect(mocks.options).toEqual([]);
+ await expect(readProjectStatus(workspaceRoot)).resolves.toMatchObject({ contexts: [] });
+ });
+});
+
+test('persists a partial diagnostic snapshot and closes the engine when linting throws', async () => {
+ await withTempWorkspace('rstack-context-lint-', async (workspaceRoot) => {
+ const lintError = new Error('lint failed');
+ mocks.lintError = lintError;
+
+ await expect(
+ captureLintSnapshot(workspaceRoot, { mode: 'files' }, createRslint, adapter),
+ ).rejects.toBe(lintError);
+ expect(mocks.options).toHaveLength(1);
+ expect(mocks.closeCalls).toBe(1);
+
+ const [stored] = await readContextSnapshots(workspaceRoot, {
+ producer: 'rslint',
+ });
+ expect(stored?.snapshot).toMatchObject({
+ status: 'error',
+ completeness: { lint: 'partial' },
+ facets: {
+ lint: {
+ producer: 'rslint',
+ mode: 'files',
+ files: [
+ {
+ errorCount: 1,
+ messages: [{ ruleId: null, severity: 2, message: 'lint failed' }],
+ },
+ ],
+ totals: {
+ files: 1,
+ errors: 1,
+ warnings: 0,
+ fixableErrors: 0,
+ fixableWarnings: 0,
+ },
+ },
+ },
+ source: { inputs: [], inputCompleteness: 'partial' },
+ });
+ await expect(
+ listDiagnostics(workspaceRoot, {
+ snapshotId: stored?.snapshot.snapshotId,
+ }),
+ ).resolves.toMatchObject({
+ producer: 'rslint',
+ contextId: stored?.snapshot.contextId,
+ completeness: { lint: 'partial' },
+ total: 1,
+ items: [{ producer: 'rslint', severity: 'error', message: 'lint failed' }],
+ });
+ });
+});
+
+test('persists a partial diagnostic snapshot when creating the lint engine throws', async () => {
+ await withTempWorkspace('rstack-context-lint-', async (workspaceRoot) => {
+ const factoryError = new Error('Rslint configuration failed');
+
+ await expect(
+ captureLintSnapshot(
+ workspaceRoot,
+ { mode: 'files' },
+ () => {
+ throw factoryError;
+ },
+ adapter,
+ ),
+ ).rejects.toBe(factoryError);
+
+ const [stored] = await readContextSnapshots(workspaceRoot, {
+ producer: 'rslint',
+ });
+ expect(stored?.snapshot).toMatchObject({
+ status: 'error',
+ completeness: { lint: 'partial' },
+ facets: {
+ lint: {
+ totals: { files: 1, errors: 1 },
+ files: [{ messages: [{ message: 'Rslint configuration failed' }] }],
+ },
+ },
+ });
+ expect(mocks.closeCalls).toBe(0);
+ });
+});
diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts
new file mode 100644
index 0000000..e32ab7a
--- /dev/null
+++ b/tests/mcp.test.ts
@@ -0,0 +1,587 @@
+import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
+import os from 'node:os';
+import path from 'node:path';
+import { Client } from '@modelcontextprotocol/sdk/client/index.js';
+import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
+import { expect, test } from '@rstest/core';
+import pkgJson from '../package.json' with { type: 'json' };
+import { createContextMcpServer, type ContextMcpDependencies } from '../src/mcp.ts';
+import { writeContextRunManifest, writeContextSnapshot } from '../src/store.ts';
+
+const toolNames = [
+ 'project_status',
+ 'product_roots',
+ 'unused_candidates',
+ 'dead_code_explain',
+ 'module_impact',
+ 'code_evidence',
+ 'snapshot_list',
+ 'diagnostics_list',
+ 'test_results',
+ 'snapshot_diff',
+ 'lint_fix_preview',
+ 'lint_snapshot',
+ 'test_snapshot',
+ 'rsdoctor_analyze',
+ 'report_link',
+] as const;
+
+const withClient = async (
+ callback: (client: Client, workspaceRoot: string) => Promise,
+ dependencies: ContextMcpDependencies = {},
+): Promise => {
+ const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), 'rstack-context-mcp-'));
+ const server = createContextMcpServer(workspaceRoot, {
+ ...dependencies,
+ serverVersion: pkgJson.version,
+ });
+ const client = new Client({ name: 'context-test-client', version: '1.0.0' });
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
+
+ try {
+ await server.connect(serverTransport);
+ await client.connect(clientTransport);
+ await callback(client, workspaceRoot);
+ } finally {
+ await client.close();
+ await server.close();
+ await rm(workspaceRoot, { force: true, recursive: true });
+ }
+};
+
+test('publishes the complete standalone MCP catalog', async () => {
+ await withClient(async (client) => {
+ const { tools } = await client.listTools();
+
+ expect(client.getServerVersion()).toEqual({
+ name: 'rstack-context',
+ version: pkgJson.version,
+ });
+ expect(tools.map(({ name }) => name)).toEqual(toolNames);
+ expect(tools.every(({ inputSchema }) => inputSchema.type === 'object')).toBe(true);
+ const codeEvidenceSchema = tools.find(({ name }) => name === 'code_evidence')?.inputSchema as {
+ properties?: Record;
+ };
+ expect(codeEvidenceSchema.properties?.contextId?.description).toContain(
+ 'only together with dataFile',
+ );
+ expect(codeEvidenceSchema.properties?.dataFile?.description).toContain(
+ 'only together with contextId',
+ );
+ });
+});
+
+test('reports an empty checkout without requiring optional producers', async () => {
+ await withClient(async (client) => {
+ const result = await client.callTool({ name: 'project_status', arguments: {} });
+
+ expect(result.isError).not.toBe(true);
+ expect(result.structuredContent).toEqual({
+ schemaVersion: 1,
+ workspaceId: expect.stringMatching(/^ws_/),
+ contexts: [],
+ issues: [],
+ });
+ });
+});
+
+test('rejects snapshot cursors reused with different filters', async () => {
+ await withClient(async (client, workspaceRoot) => {
+ const context = { contextId: 'ctx_web', packageRoot: '.', product: 'application' } as const;
+ await writeContextRunManifest(workspaceRoot, {
+ schemaVersion: 1,
+ runId: 'run_lint',
+ producer: 'rslint',
+ command: 'lint',
+ startedAt: '2026-08-14T03:00:00.000Z',
+ contexts: [context],
+ });
+ await writeContextSnapshot(workspaceRoot, {
+ schemaVersion: 1,
+ snapshotId: 'snap_lint',
+ runId: 'run_lint',
+ contextId: context.contextId,
+ sequence: 1,
+ observedAt: '2026-08-14T03:00:01.000Z',
+ status: 'pass',
+ completeness: { lint: 'complete' },
+ facets: {},
+ });
+ await writeContextRunManifest(workspaceRoot, {
+ schemaVersion: 1,
+ runId: 'run_test',
+ producer: 'rstest',
+ command: 'test',
+ startedAt: '2026-08-14T04:00:00.000Z',
+ contexts: [context],
+ });
+ await writeContextSnapshot(workspaceRoot, {
+ schemaVersion: 1,
+ snapshotId: 'snap_test',
+ runId: 'run_test',
+ contextId: context.contextId,
+ sequence: 1,
+ observedAt: '2026-08-14T04:00:01.000Z',
+ status: 'pass',
+ completeness: { test: 'complete' },
+ facets: {},
+ });
+
+ const first = await client.callTool({
+ name: 'snapshot_list',
+ arguments: { limit: 1 },
+ });
+ const cursor = (first.structuredContent as { nextCursor?: string }).nextCursor;
+ expect(cursor).toEqual(expect.any(String));
+
+ const changedFilter = await client.callTool({
+ name: 'snapshot_list',
+ arguments: { producer: 'rslint', limit: 1, cursor },
+ });
+ expect(changedFilter.isError).toBe(true);
+ expect(changedFilter.content).toEqual([
+ { type: 'text', text: expect.stringContaining('Invalid snapshot cursor') },
+ ]);
+ });
+});
+
+test('surfaces requested execution availability in test capture text', async () => {
+ await withClient(
+ async (client) => {
+ const result = await client.callTool({
+ name: 'test_snapshot',
+ arguments: { packageRoot: '.', execution: {} },
+ });
+
+ expect(result.content).toEqual([
+ {
+ type: 'text',
+ text: expect.stringContaining('executionAvailability=unavailable'),
+ },
+ ]);
+ expect(result.structuredContent).toMatchObject({
+ status: 'pass',
+ execution: {
+ provider: 'istanbul',
+ availability: 'unavailable',
+ completeness: 'unknown',
+ },
+ });
+ },
+ {
+ captureTestSnapshot: () =>
+ Promise.resolve({
+ runId: 'run_test',
+ contextId: 'ctx_test',
+ snapshotId: 'snap_test',
+ status: 'pass',
+ freshness: { state: 'fresh', changedPaths: [] },
+ summary: {
+ files: 1,
+ failedFiles: 0,
+ tests: 1,
+ failedTests: 0,
+ errors: 0,
+ unhandledErrors: 0,
+ },
+ execution: {
+ provider: 'istanbul',
+ availability: 'unavailable',
+ completeness: 'unknown',
+ },
+ }),
+ },
+ );
+});
+
+test('captures a real test snapshot through injected capture dependencies', async () => {
+ let captureCount = 0;
+ await withClient(
+ async (client, workspaceRoot) => {
+ await mkdir(path.join(workspaceRoot, 'tests'), { recursive: true });
+ await writeFile(path.join(workspaceRoot, 'tests', 'math.test.ts'), 'test');
+
+ const result = await client.callTool({
+ name: 'test_snapshot',
+ arguments: { files: ['tests/math.test.ts'] },
+ });
+
+ expect(result.isError).not.toBe(true);
+ expect(result.structuredContent).toMatchObject({
+ runId: 'run_mcp_1',
+ snapshotId: 'snap_mcp_1',
+ status: 'pass',
+ summary: { files: 1, tests: 1 },
+ });
+ await expect(
+ client.callTool({ name: 'test_results', arguments: { snapshotId: 'snap_mcp_1' } }),
+ ).resolves.toMatchObject({
+ structuredContent: {
+ snapshotId: 'snap_mcp_1',
+ items: [{ path: 'tests/math.test.ts', name: 'adds' }],
+ },
+ });
+
+ const inputError = await client.callTool({
+ name: 'test_snapshot',
+ arguments: { files: ['tests/gone.test.ts'] },
+ });
+
+ expect(inputError.isError).toBe(true);
+ expect(inputError.content).toEqual([
+ {
+ type: 'text',
+ text: expect.stringContaining(
+ 'Could not read Rstest snapshot inputs: tests/gone.test.ts.',
+ ),
+ },
+ ]);
+ await expect(
+ client.callTool({ name: 'snapshot_list', arguments: {} }),
+ ).resolves.toMatchObject({
+ structuredContent: {
+ items: expect.arrayContaining([
+ expect.objectContaining({
+ snapshotId: 'snap_mcp_2',
+ status: 'error',
+ completeness: { test: 'partial', source: 'partial' },
+ }),
+ ]),
+ },
+ });
+ },
+ {
+ testCaptureDependencies: {
+ wrapperConfigPath: path.join(path.sep, 'wrapper', 'rstestConfig.js'),
+ createRunId: () => {
+ captureCount += 1;
+ return `run_mcp_${captureCount}`;
+ },
+ createSnapshotId: () => `snap_mcp_${captureCount}`,
+ runRstest: (options) => {
+ const testPath = path.join(
+ (options?.cwd as string | undefined) ?? '.',
+ (options?.files as string[] | undefined)?.[0] ?? 'tests/math.test.ts',
+ );
+ return Promise.resolve({
+ ok: true,
+ files: [
+ {
+ project: 'default',
+ testPath,
+ name: path.basename(testPath),
+ status: 'pass',
+ results: [{ project: 'default', testPath, name: 'adds', status: 'pass' }],
+ },
+ ],
+ stats: {
+ tests: { total: 1, passed: 1, failed: 0, skipped: 0, todo: 0 },
+ files: { total: 1, failed: 0 },
+ },
+ unhandledErrors: [],
+ duration: { total: 1 },
+ });
+ },
+ },
+ },
+ );
+});
+
+test('captures a real lint snapshot through an injected capture adapter', async () => {
+ await withClient(
+ async (client, workspaceRoot) => {
+ await writeFile(path.join(workspaceRoot, 'a.ts'), 'const a = 1;\n');
+
+ const result = await client.callTool({
+ name: 'lint_snapshot',
+ arguments: { mode: 'files', patterns: ['a.ts'] },
+ });
+
+ expect(result.isError).not.toBe(true);
+ expect(result.structuredContent).toMatchObject({
+ status: 'fail',
+ summary: { files: 1, errors: 1 },
+ });
+ await expect(
+ client.callTool({ name: 'diagnostics_list', arguments: { producer: 'rslint' } }),
+ ).resolves.toMatchObject({
+ structuredContent: {
+ items: [{ producer: 'rslint', ruleId: 'a-rule', severity: 'error', path: 'a.ts' }],
+ },
+ });
+ },
+ {
+ lintCaptureAdapter: {
+ wrapperConfigPath: path.join(path.sep, 'wrapper', 'rslintConfig.js'),
+ withConfigTarget: (_configRoot, _configPath, action) => action(),
+ },
+ createRslint: (options) => ({
+ lintFiles: () =>
+ Promise.resolve([
+ {
+ filePath: path.join(options.cwd ?? '.', 'a.ts'),
+ errorCount: 1,
+ warningCount: 0,
+ fixableErrorCount: 0,
+ fixableWarningCount: 0,
+ messages: [{ ruleId: 'a-rule', severity: 2, message: 'broken', line: 1, column: 1 }],
+ },
+ ]),
+ lintText: () => Promise.resolve([]),
+ close: () => Promise.resolve(),
+ }),
+ },
+ );
+});
+
+test('rejects capture targets that escape the checkout', async () => {
+ await withClient(
+ async (client) => {
+ const escaped = await client.callTool({
+ name: 'test_snapshot',
+ arguments: { packageRoot: '../../..' },
+ });
+ const escapedConfig = await client.callTool({
+ name: 'lint_snapshot',
+ arguments: { mode: 'files', configPath: '../../evil.config.ts' },
+ });
+
+ expect(escaped.isError).toBe(true);
+ expect(escaped.content).toEqual([
+ {
+ type: 'text',
+ text: expect.stringContaining(
+ 'packageRoot must be a non-empty checkout-relative path that stays inside the checkout.',
+ ),
+ },
+ ]);
+ expect(escapedConfig.isError).toBe(true);
+ expect(escapedConfig.content).toEqual([
+ {
+ type: 'text',
+ text: expect.stringContaining(
+ 'configPath must be a non-empty checkout-relative path that stays inside the checkout.',
+ ),
+ },
+ ]);
+ await expect(
+ client.callTool({ name: 'project_status', arguments: {} }),
+ ).resolves.toMatchObject({ structuredContent: { contexts: [] } });
+ },
+ {
+ testCaptureDependencies: {
+ wrapperConfigPath: path.join(path.sep, 'wrapper', 'rstestConfig.js'),
+ runRstest: () => {
+ throw new Error('must not run');
+ },
+ },
+ lintCaptureAdapter: {
+ wrapperConfigPath: path.join(path.sep, 'wrapper', 'rslintConfig.js'),
+ withConfigTarget: (_configRoot, _configPath, action) => action(),
+ },
+ createRslint: () => {
+ throw new Error('must not run');
+ },
+ },
+ );
+});
+
+test('keeps project status compact while preserving build selection evidence', async () => {
+ await withClient(async (client, workspaceRoot) => {
+ const runId = 'run_build';
+ const contextId = 'ctx_web';
+ expect(
+ await writeContextRunManifest(workspaceRoot, {
+ schemaVersion: 1,
+ runId,
+ producer: 'rsbuild',
+ command: 'build',
+ startedAt: '2026-08-14T03:00:00.000Z',
+ contexts: [
+ {
+ contextId,
+ packageRoot: '.',
+ product: 'application',
+ environment: 'web',
+ mode: 'production',
+ },
+ ],
+ }),
+ ).toMatchObject({ written: true });
+ expect(
+ await writeContextSnapshot(workspaceRoot, {
+ schemaVersion: 1,
+ snapshotId: 'snap_build',
+ runId,
+ contextId,
+ sequence: 1,
+ observedAt: '2026-08-14T03:00:01.000Z',
+ status: 'pass',
+ completeness: { build: 'complete', deep: 'disabled' },
+ facets: {
+ build: {
+ producer: 'rsbuild',
+ command: 'build',
+ mode: 'production',
+ environment: 'web',
+ target: ['web'],
+ isWatch: false,
+ isFirstCompile: true,
+ durationMs: 500,
+ hash: 'build-hash',
+ hasErrors: false,
+ hasWarnings: true,
+ assets: [
+ { name: 'assets/app.js', size: 1234 },
+ ...Array.from({ length: 99 }, (_, index) => ({
+ name: `assets/lazy-${index}.js`,
+ size: index,
+ })),
+ ],
+ chunks: [{ files: ['assets/app.js'], initial: true }],
+ truncated: { assets: 247, chunks: 0 },
+ },
+ },
+ }),
+ ).toMatchObject({ written: true });
+
+ const result = await client.callTool({ name: 'project_status', arguments: {} });
+
+ expect(result.content).toEqual([
+ {
+ type: 'text',
+ text: expect.stringContaining('1 recorded context identity'),
+ },
+ ]);
+ expect(result.structuredContent).toEqual({
+ schemaVersion: 1,
+ workspaceId: expect.stringMatching(/^ws_/),
+ contexts: [
+ {
+ runId,
+ producer: 'rsbuild',
+ context: {
+ contextId,
+ packageRoot: '.',
+ product: 'application',
+ environment: 'web',
+ mode: 'production',
+ },
+ state: 'ready',
+ latestSnapshot: {
+ snapshotId: 'snap_build',
+ observedAt: '2026-08-14T03:00:01.000Z',
+ status: 'pass',
+ completeness: { build: 'complete', deep: 'disabled' },
+ facets: ['build'],
+ summary: {
+ build: {
+ command: 'build',
+ mode: 'production',
+ environment: 'web',
+ environmentCompileDurationMs: 500,
+ hash: 'build-hash',
+ hasErrors: false,
+ hasWarnings: true,
+ assets: 100,
+ chunks: 1,
+ total: { assets: 347, chunks: 1 },
+ truncated: { assets: 247, chunks: 0 },
+ },
+ },
+ },
+ freshness: { state: 'unknown', changedPaths: [] },
+ },
+ ],
+ issues: [],
+ });
+ expect(JSON.stringify(result.structuredContent)).not.toContain('assets/app.js');
+ });
+});
+
+test('keeps complete project status while summarizing a newer failed attempt', async () => {
+ await withClient(async (client, workspaceRoot) => {
+ const context = {
+ contextId: 'ctx_test',
+ packageRoot: '.',
+ product: 'development',
+ environment: 'test',
+ } as const;
+ const completeRun: Parameters[1] = {
+ schemaVersion: 1,
+ runId: 'run_complete',
+ producer: 'rstest',
+ command: 'test',
+ startedAt: '2026-08-14T03:00:00.000Z',
+ contexts: [context],
+ };
+ const errorRun: Parameters[1] = {
+ ...completeRun,
+ runId: 'run_error',
+ startedAt: '2026-08-14T04:00:00.000Z',
+ };
+ expect(await writeContextRunManifest(workspaceRoot, completeRun)).toMatchObject({
+ written: true,
+ });
+ expect(
+ await writeContextSnapshot(workspaceRoot, {
+ schemaVersion: 1,
+ snapshotId: 'snap_complete',
+ runId: completeRun.runId,
+ contextId: context.contextId,
+ sequence: 0,
+ observedAt: '2026-08-14T03:00:01.000Z',
+ status: 'pass',
+ completeness: { test: 'complete' },
+ facets: { summary: { tests: 1, failedTests: 0 } },
+ }),
+ ).toMatchObject({ written: true });
+ expect(await writeContextRunManifest(workspaceRoot, errorRun)).toMatchObject({ written: true });
+ expect(
+ await writeContextSnapshot(workspaceRoot, {
+ schemaVersion: 1,
+ snapshotId: 'snap_error',
+ runId: errorRun.runId,
+ contextId: context.contextId,
+ sequence: 0,
+ observedAt: '2026-08-14T04:00:01.000Z',
+ status: 'error',
+ completeness: { test: 'partial', source: 'partial' },
+ facets: { summary: { tests: 1, failedTests: 0, errors: 1 } },
+ source: {
+ inputs: [],
+ inputCompleteness: 'partial',
+ unreadableInputs: ['src/missing.ts'],
+ },
+ }),
+ ).toMatchObject({ written: true });
+
+ await expect(client.callTool({ name: 'project_status', arguments: {} })).resolves.toMatchObject(
+ {
+ structuredContent: {
+ contexts: [
+ {
+ runId: errorRun.runId,
+ producer: 'rstest',
+ context,
+ state: 'ready',
+ latestSnapshot: {
+ snapshotId: 'snap_complete',
+ status: 'pass',
+ completeness: { test: 'complete' },
+ facets: ['summary'],
+ },
+ latestAttempt: {
+ snapshotId: 'snap_error',
+ status: 'error',
+ completeness: { test: 'partial', source: 'partial' },
+ facets: ['summary'],
+ },
+ },
+ ],
+ issues: [],
+ },
+ },
+ );
+ });
+});
diff --git a/tests/packageExports.test.ts b/tests/packageExports.test.ts
new file mode 100644
index 0000000..9be872c
--- /dev/null
+++ b/tests/packageExports.test.ts
@@ -0,0 +1,56 @@
+import { spawnSync } from 'node:child_process';
+import { readFile } from 'node:fs/promises';
+import path from 'node:path';
+import { expect, test } from '@rstest/core';
+
+type PackageJson = {
+ dependencies?: Record;
+ devDependencies?: Record;
+ exports?: Record;
+ peerDependencies?: Record;
+};
+
+const repositoryRoot = path.resolve(import.meta.dirname, '..');
+
+const readPackageJson = async (): Promise =>
+ JSON.parse(await readFile(path.join(repositoryRoot, 'package.json'), 'utf8')) as PackageJson;
+
+test('publishes focused Context entry points without a Rstack dependency', async () => {
+ const packageJson = await readPackageJson();
+
+ expect(Object.keys(packageJson.exports ?? {}).sort()).toEqual([
+ '.',
+ './mcp',
+ './package.json',
+ './rsbuild',
+ './rsdoctor',
+ './rslib',
+ './rslint',
+ './rstack',
+ './rstest',
+ ]);
+ // Consumers own this optional runtime so a host such as Rstack can pin a preview
+ // directly without turning it into a URL-resolved transitive dependency. This repo's
+ // root-only override still validates against web-infra-dev/rsdoctor#1903.
+ expect(packageJson.dependencies?.['@rsdoctor/agent-cli']).toBeUndefined();
+ expect(packageJson.devDependencies?.['@rsdoctor/agent-cli']).toBe('0.1.1');
+ expect(packageJson.peerDependencies?.['@rsdoctor/agent-cli']).toBe('>=0.1.1');
+ for (const section of [
+ packageJson.dependencies,
+ packageJson.devDependencies,
+ packageJson.peerDependencies,
+ ]) {
+ expect(section?.rstack).toBeUndefined();
+ }
+});
+
+test('loads each focused Context entry point independently', () => {
+ const entryPoints = ['rsbuild', 'rslib', 'rstest', 'rslint', 'rsdoctor', 'mcp', 'rstack'];
+ const script = `await Promise.all(${JSON.stringify(entryPoints)}.map((name) => import('@rstackjs/context/' + name)));`;
+ const result = spawnSync(process.execPath, ['--input-type=module', '--eval', script], {
+ cwd: repositoryRoot,
+ encoding: 'utf8',
+ });
+
+ expect(result.status, result.stderr).toBe(0);
+});
diff --git a/tests/products.test.ts b/tests/products.test.ts
new file mode 100644
index 0000000..82f3c0d
--- /dev/null
+++ b/tests/products.test.ts
@@ -0,0 +1,193 @@
+import path from 'node:path';
+import { expect, test } from '@rstest/core';
+import type { ContextDescriptor } from '../src/model.ts';
+import { resolveProductRoots } from '../src/products.ts';
+import { readRsdoctorModuleGraph } from '../src/rsdoctorGraph.ts';
+
+const fixtureRoot = path.resolve(import.meta.dirname, '../fixtures/context/reachability');
+
+const context = (contextId: string, product: 'application' | 'library'): ContextDescriptor => ({
+ contextId,
+ packageRoot: '.',
+ product,
+});
+
+test('derives application entry and conservative optimizer roots', async () => {
+ const workspaceRoot = path.join(fixtureRoot, 'application');
+ const graph = await readRsdoctorModuleGraph(workspaceRoot, 'rsdoctor-data.json');
+
+ const product = await resolveProductRoots(
+ workspaceRoot,
+ context('ctx_app', 'application'),
+ graph,
+ );
+
+ expect(product.contextId).toBe('ctx_app');
+ expect(product.packageRoot).toBe('.');
+ expect(product.product).toBe('application');
+ expect(product.contractTargets).toEqual([]);
+ expect(product.roots.map(({ kind, module }) => [kind, module.id])).toEqual([
+ ['production-entry', '1'],
+ ['side-effect', '4'],
+ ['conservative-runtime', '8'],
+ ['conservative-runtime', '5'],
+ ]);
+ expect(product.bounds).toEqual([
+ 'export-usage-schema-unsupported',
+ 'duplicate-module-id',
+ 'dangling-edge',
+ ]);
+});
+
+test('filters only same-path entry copies nested under another entry', async () => {
+ const product = await resolveProductRoots(fixtureRoot, context('ctx_app', 'application'), {
+ modules: [
+ {
+ id: 'nested-copy',
+ path: 'src/index.ts',
+ name: 'src/index.ts',
+ chunks: ['index'],
+ isEntry: true,
+ },
+ {
+ id: 'container',
+ path: 'src/index.ts',
+ name: 'src/index.ts|81e5e5370155b3bc',
+ chunks: ['index'],
+ isEntry: true,
+ },
+ {
+ id: 'chunkless-only',
+ path: 'src/standalone.ts',
+ name: 'src/standalone.ts',
+ chunks: [],
+ isEntry: true,
+ },
+ ],
+ edges: [{ from: 'container', to: 'nested-copy' }],
+ exportRowsPresent: false,
+ issues: [],
+ });
+
+ expect(
+ product.roots.filter(({ kind }) => kind === 'production-entry').map(({ module }) => module.id),
+ ).toEqual(['container', 'chunkless-only']);
+});
+
+test('collects library contracts and seeds only exact runtime module matches', async () => {
+ const workspaceRoot = path.join(fixtureRoot, 'library');
+ const graph = await readRsdoctorModuleGraph(workspaceRoot, 'rsdoctor-data.json');
+
+ const product = await resolveProductRoots(
+ workspaceRoot,
+ context('ctx_library', 'library'),
+ graph,
+ );
+
+ expect(product.contractTargets).toEqual([
+ { field: 'bin', target: './dist/cli.js', matchedModuleIds: ['14'] },
+ { field: 'exports', target: './dist/feature.js', matchedModuleIds: ['11'] },
+ { field: 'exports', target: './dist/generated.js', matchedModuleIds: [] },
+ { field: 'exports', target: './dist/index.d.ts', matchedModuleIds: [] },
+ { field: 'exports', target: './dist/index.js', matchedModuleIds: ['10'] },
+ { field: 'main', target: './dist/index.js', matchedModuleIds: ['10'] },
+ { field: 'module', target: './dist/index.js', matchedModuleIds: ['10'] },
+ { field: 'types', target: './dist/index.d.ts', matchedModuleIds: [] },
+ ]);
+ expect(product.roots.map(({ kind, module }) => [kind, module.id])).toEqual([
+ ['production-entry', '11'],
+ ['production-entry', '10'],
+ ['published-contract', '14'],
+ ['published-contract', '11'],
+ ['published-contract', '10'],
+ ]);
+ expect(product.bounds).toEqual([
+ 'unmapped-contract-target:exports:./dist/generated.js',
+ 'unmapped-contract-target:exports:./dist/index.d.ts',
+ 'unmapped-contract-target:types:./dist/index.d.ts',
+ 'published-library-open-world',
+ ]);
+});
+
+test('does not match a library contract to a dependency with the same output suffix', async () => {
+ const workspaceRoot = path.join(fixtureRoot, 'library');
+ const graph = await readRsdoctorModuleGraph(workspaceRoot, 'rsdoctor-data.json');
+ graph.modules.push({
+ id: '99',
+ path: '/repo/node_modules/other-library/dist/index.js',
+ name: 'other-library',
+ chunks: ['other-library'],
+ isEntry: false,
+ });
+
+ const product = await resolveProductRoots(
+ workspaceRoot,
+ context('ctx_library', 'library'),
+ graph,
+ );
+
+ expect(
+ product.contractTargets.find(
+ ({ field, target }) => field === 'exports' && target === './dist/index.js',
+ )?.matchedModuleIds,
+ ).toEqual(['10']);
+ expect(product.roots.some(({ module }) => module.id === '99')).toBe(false);
+});
+
+test('matches a library contract only within the selected monorepo package', async () => {
+ const graph = await readRsdoctorModuleGraph(
+ path.join(fixtureRoot, 'library'),
+ 'rsdoctor-data.json',
+ );
+ graph.modules.push({
+ id: '99',
+ path: '/repo/packages/other-library/dist/index.js',
+ name: 'other-library',
+ chunks: ['other-library'],
+ isEntry: false,
+ });
+
+ const product = await resolveProductRoots(
+ fixtureRoot,
+ { ...context('ctx_library', 'library'), packageRoot: 'library' },
+ graph,
+ );
+
+ expect(
+ product.contractTargets.find(
+ ({ field, target }) => field === 'exports' && target === './dist/index.js',
+ )?.matchedModuleIds,
+ ).toEqual(['10']);
+ expect(product.packageRoot).toBe('library');
+});
+
+test('does not guess generated output to source module mappings', async () => {
+ const workspaceRoot = path.join(fixtureRoot, 'library');
+ const graph = await readRsdoctorModuleGraph(workspaceRoot, 'rsdoctor-data.json');
+ const product = await resolveProductRoots(
+ workspaceRoot,
+ context('ctx_library', 'library'),
+ graph,
+ );
+
+ expect(
+ product.contractTargets.find(({ target }) => target === './dist/generated.js')
+ ?.matchedModuleIds,
+ ).toEqual([]);
+ expect(product.roots.some(({ module }) => module.id === '13')).toBe(false);
+});
+
+test('bounds a library analysis when its package manifest is unavailable', async () => {
+ const workspaceRoot = path.join(fixtureRoot, 'application');
+ const graph = await readRsdoctorModuleGraph(workspaceRoot, 'rsdoctor-data.json');
+
+ const product = await resolveProductRoots(
+ workspaceRoot,
+ { ...context('ctx_library', 'library'), packageRoot: 'missing-package' },
+ graph,
+ );
+
+ expect(product.contractTargets).toEqual([]);
+ expect(product.bounds).toContain('package-manifest-unavailable');
+ expect(product.bounds).toContain('published-library-open-world');
+});
diff --git a/tests/queries.test.ts b/tests/queries.test.ts
new file mode 100644
index 0000000..3def368
--- /dev/null
+++ b/tests/queries.test.ts
@@ -0,0 +1,778 @@
+import { cp, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
+import os from 'node:os';
+import path from 'node:path';
+import { expect, test } from '@rstest/core';
+import {
+ contextStoreSchemaVersion,
+ writeContextRunManifest,
+ writeContextSnapshot,
+ type ContextDescriptor,
+ type ContextRunManifest,
+ type ContextSnapshot,
+} from '../src/index.ts';
+import {
+ explainDeadCodeCandidate,
+ findUnusedCandidates,
+ readProductRoots,
+ traceModuleImpact,
+} from '../src/queries.ts';
+
+const fixtureRoot = path.resolve(import.meta.dirname, '../fixtures/context/reachability');
+
+const withFixtureWorkspace = async (
+ fixture: 'application' | 'library',
+ callback: (workspaceRoot: string) => Promise,
+): Promise => {
+ const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), 'rstack-context-queries-'));
+ await cp(path.join(fixtureRoot, fixture), workspaceRoot, { recursive: true });
+
+ try {
+ await callback(workspaceRoot);
+ } finally {
+ await rm(workspaceRoot, { force: true, recursive: true });
+ }
+};
+
+const recordBuild = async (
+ workspaceRoot: string,
+ context: ContextDescriptor,
+ runId: string,
+ observedAt?: string,
+ buildIdentity?: { hash: string; environment: string; target?: string[] },
+): Promise => {
+ const run = {
+ schemaVersion: contextStoreSchemaVersion,
+ runId,
+ producer: context.product === 'library' ? 'rslib' : 'rsbuild',
+ command: 'build',
+ startedAt: '2026-08-12T04:00:00.000Z',
+ contexts: [context],
+ } satisfies ContextRunManifest;
+ expect(await writeContextRunManifest(workspaceRoot, run)).toMatchObject({
+ written: true,
+ });
+
+ if (observedAt === undefined) return;
+ const facets: ContextSnapshot['facets'] =
+ buildIdentity === undefined
+ ? {}
+ : {
+ build: {
+ producer: context.product === 'library' ? 'rslib' : 'rsbuild',
+ command: 'build',
+ environment: buildIdentity.environment,
+ target: buildIdentity.target ?? [],
+ isWatch: false,
+ isFirstCompile: true,
+ durationMs: 100,
+ hash: buildIdentity.hash,
+ hasErrors: false,
+ hasWarnings: false,
+ assets: [],
+ chunks: [],
+ truncated: { assets: 0, chunks: 0 },
+ },
+ };
+ const snapshot = {
+ schemaVersion: contextStoreSchemaVersion,
+ snapshotId: `snap_${runId}`,
+ runId,
+ contextId: context.contextId,
+ sequence: 1,
+ observedAt,
+ status: 'pass',
+ completeness: { build: 'complete' },
+ facets,
+ } satisfies ContextSnapshot;
+ expect(await writeContextSnapshot(workspaceRoot, snapshot)).toMatchObject({
+ written: true,
+ });
+};
+
+const addArtifactMetadata = async (
+ workspaceRoot: string,
+ build: Record,
+ moduleGraph: { status: 'collected' } | { status: 'omitted'; reason: 'not-selected' } = {
+ status: 'collected',
+ },
+): Promise => {
+ const dataFile = path.join(workspaceRoot, 'rsdoctor-data.json');
+ const artifact = JSON.parse(await readFile(dataFile, 'utf8')) as Record;
+ artifact.metadata = {
+ schemaVersion: 1,
+ producer: { name: '@rsdoctor/core', version: '1.6.0' },
+ output: { mode: 'normal' },
+ build,
+ sections: {
+ errors: { status: 'collected' },
+ configs: { status: 'collected' },
+ summary: { status: 'collected' },
+ resolver: { status: 'collected' },
+ loader: { status: 'collected' },
+ moduleGraph,
+ chunkGraph: { status: 'collected' },
+ moduleCodeMap: { status: 'collected' },
+ plugin: { status: 'collected' },
+ packageGraph: { status: 'collected' },
+ treeShaking: { status: 'collected' },
+ otherReports: { status: 'collected' },
+ },
+ };
+ await writeFile(dataFile, JSON.stringify(artifact));
+};
+
+test('accepts a legacy artifact with explicit-unverified build provenance', async () => {
+ await withFixtureWorkspace('application', async (workspaceRoot) => {
+ const context = {
+ contextId: 'ctx_app',
+ packageRoot: '.',
+ product: 'application',
+ } as const;
+ await recordBuild(workspaceRoot, context, 'run_a', '2026-08-12T04:00:01.000Z');
+
+ const result = await readProductRoots(workspaceRoot, {
+ contextId: context.contextId,
+ dataFile: 'rsdoctor-data.json',
+ });
+
+ expect(result.provenance).toEqual({
+ contextId: context.contextId,
+ dataFile: 'rsdoctor-data.json',
+ artifactBinding: 'explicit-unverified',
+ buildObservation: {
+ runId: 'run_a',
+ snapshotId: 'snap_run_a',
+ observedAt: '2026-08-12T04:00:01.000Z',
+ status: 'pass',
+ buildCompleteness: 'complete',
+ },
+ });
+ expect(result.graph).toEqual({
+ moduleCount: 8,
+ edgeCount: 4,
+ issues: ['duplicate-module-id', 'dangling-edge'],
+ });
+ expect(result.product.roots.map(({ kind, module }) => [kind, module.id])).toEqual([
+ ['production-entry', '1'],
+ ['side-effect', '4'],
+ ['conservative-runtime', '8'],
+ ['conservative-runtime', '5'],
+ ]);
+ });
+});
+
+test('binds v1 artifact metadata to the selected build snapshot on an exact identity match', async () => {
+ await withFixtureWorkspace('application', async (workspaceRoot) => {
+ const context = {
+ contextId: 'ctx_app_web',
+ packageRoot: '.',
+ product: 'application',
+ environment: 'web',
+ target: 'web',
+ } as const;
+ await recordBuild(workspaceRoot, context, 'run_web', '2026-08-12T04:00:01.000Z', {
+ hash: 'compilation-web',
+ environment: 'web',
+ target: ['web'],
+ });
+ await addArtifactMetadata(workspaceRoot, {
+ id: 'rsdoctor-build',
+ root: workspaceRoot,
+ compiler: { name: 'web', type: 'rspack', version: '1.7.0' },
+ compilationHash: 'compilation-web',
+ environment: 'web',
+ target: ['web', 'es2017'],
+ });
+
+ const roots = await readProductRoots(workspaceRoot, {
+ contextId: context.contextId,
+ dataFile: 'rsdoctor-data.json',
+ });
+ const candidates = await findUnusedCandidates(workspaceRoot, {
+ contextId: context.contextId,
+ dataFile: 'rsdoctor-data.json',
+ });
+
+ expect(roots.provenance.artifactBinding).toBe('exact');
+ expect(candidates.total).toBe(1);
+ });
+});
+
+test('does not claim an exact binding for incomplete v1 metadata', async () => {
+ await withFixtureWorkspace('application', async (workspaceRoot) => {
+ const context = {
+ contextId: 'ctx_app_web',
+ packageRoot: '.',
+ product: 'application',
+ environment: 'web',
+ target: 'web',
+ } as const;
+ await recordBuild(workspaceRoot, context, 'run_web', '2026-08-12T04:00:01.000Z', {
+ hash: 'compilation-web',
+ environment: 'web',
+ target: ['web'],
+ });
+ const dataFile = path.join(workspaceRoot, 'rsdoctor-data.json');
+ const artifact = JSON.parse(await readFile(dataFile, 'utf8')) as Record;
+ artifact.metadata = {
+ schemaVersion: 1,
+ producer: { name: '@rsdoctor/core', version: '1.6.0' },
+ output: { mode: 'normal' },
+ build: {
+ id: 'rsdoctor-build',
+ root: workspaceRoot,
+ compiler: { name: 'web', type: 'rspack' },
+ compilationHash: 'compilation-web',
+ environment: 'web',
+ },
+ sections: { moduleGraph: { status: 'collected' } },
+ };
+ await writeFile(dataFile, JSON.stringify(artifact));
+
+ const roots = await readProductRoots(workspaceRoot, {
+ contextId: context.contextId,
+ dataFile: 'rsdoctor-data.json',
+ });
+
+ expect(roots.provenance.artifactBinding).toBe('explicit-unverified');
+ });
+});
+
+test('does not derive graph conclusions from artifact metadata that mismatches the snapshot', async () => {
+ await withFixtureWorkspace('application', async (workspaceRoot) => {
+ const context = {
+ contextId: 'ctx_app_web',
+ packageRoot: '.',
+ product: 'application',
+ environment: 'web',
+ target: 'web',
+ } as const;
+ await recordBuild(workspaceRoot, context, 'run_web', '2026-08-12T04:00:01.000Z', {
+ hash: 'compilation-current',
+ environment: 'web',
+ target: ['web'],
+ });
+ await addArtifactMetadata(workspaceRoot, {
+ id: 'rsdoctor-build',
+ root: workspaceRoot,
+ compiler: { name: 'web', type: 'rspack' },
+ compilationHash: 'compilation-stale',
+ environment: 'web',
+ target: 'web',
+ });
+
+ const roots = await readProductRoots(workspaceRoot, {
+ contextId: context.contextId,
+ dataFile: 'rsdoctor-data.json',
+ });
+ const candidates = await findUnusedCandidates(workspaceRoot, {
+ contextId: context.contextId,
+ dataFile: 'rsdoctor-data.json',
+ });
+ const explanation = await explainDeadCodeCandidate(workspaceRoot, {
+ contextId: context.contextId,
+ dataFile: 'rsdoctor-data.json',
+ module: '3',
+ });
+
+ expect(roots.provenance.artifactBinding).toBe('mismatch');
+ expect(roots.graph.issues).toContain('artifact-build-mismatch');
+ expect(roots.product.roots).toEqual([]);
+ expect(candidates.total).toBe(0);
+ expect(candidates.bounds).toContain('artifact-build-mismatch');
+ expect(explanation.classification).toBe('insufficient-evidence');
+ expect(explanation.state.productionReachability).toBe('unknown');
+
+ const impact = await traceModuleImpact(workspaceRoot, {
+ contextId: context.contextId,
+ dataFile: 'rsdoctor-data.json',
+ module: '3',
+ });
+ expect(impact.modules).toEqual([]);
+ expect(impact.reachedRoots).toEqual([]);
+ expect(impact.bounds).toContain('artifact-build-mismatch');
+ });
+});
+
+test('does not use a module graph that v1 metadata marks as omitted', async () => {
+ await withFixtureWorkspace('application', async (workspaceRoot) => {
+ const context = {
+ contextId: 'ctx_app_web',
+ packageRoot: '.',
+ product: 'application',
+ environment: 'web',
+ target: 'web',
+ } as const;
+ await recordBuild(workspaceRoot, context, 'run_web', '2026-08-12T04:00:01.000Z', {
+ hash: 'compilation-web',
+ environment: 'web',
+ target: ['web'],
+ });
+ await addArtifactMetadata(
+ workspaceRoot,
+ {
+ id: 'rsdoctor-build',
+ root: workspaceRoot,
+ compiler: { name: 'web', type: 'rspack' },
+ compilationHash: 'compilation-web',
+ environment: 'web',
+ target: 'web',
+ },
+ { status: 'omitted', reason: 'not-selected' },
+ );
+
+ const roots = await readProductRoots(workspaceRoot, {
+ contextId: context.contextId,
+ dataFile: 'rsdoctor-data.json',
+ });
+ const candidates = await findUnusedCandidates(workspaceRoot, {
+ contextId: context.contextId,
+ dataFile: 'rsdoctor-data.json',
+ });
+ const explanation = await explainDeadCodeCandidate(workspaceRoot, {
+ contextId: context.contextId,
+ dataFile: 'rsdoctor-data.json',
+ module: 'src/legacy.ts',
+ });
+ const impact = await traceModuleImpact(workspaceRoot, {
+ contextId: context.contextId,
+ dataFile: 'rsdoctor-data.json',
+ module: 'src/legacy.ts',
+ });
+
+ expect(roots.provenance.artifactBinding).toBe('exact');
+ expect(roots.graph).toEqual({
+ moduleCount: 0,
+ edgeCount: 0,
+ issues: ['module-graph-omitted'],
+ });
+ expect(roots.product.roots).toEqual([]);
+ expect(candidates.total).toBe(0);
+ expect(candidates.bounds).toContain('module-graph-omitted');
+ expect(explanation.classification).toBe('insufficient-evidence');
+ expect(explanation.bounds).toContain('module-graph-omitted');
+ expect(impact.modules).toEqual([]);
+ expect(impact.bounds).toContain('module-graph-omitted');
+ });
+});
+
+test('does not bind a primary compiler graph to a different multi-compiler child', async () => {
+ await withFixtureWorkspace('application', async (workspaceRoot) => {
+ const webContext = {
+ contextId: 'ctx_app_web',
+ packageRoot: '.',
+ product: 'application',
+ environment: 'web',
+ target: 'web',
+ } as const;
+ const serverContext = {
+ contextId: 'ctx_app_server',
+ packageRoot: '.',
+ product: 'application',
+ environment: 'server',
+ target: 'node',
+ } as const;
+ await recordBuild(workspaceRoot, webContext, 'run_web', '2026-08-12T04:00:01.000Z', {
+ hash: 'compilation-web',
+ environment: 'web',
+ target: ['web'],
+ });
+ await recordBuild(workspaceRoot, serverContext, 'run_server', '2026-08-12T04:00:02.000Z', {
+ hash: 'compilation-server',
+ environment: 'server',
+ target: ['node'],
+ });
+ await addArtifactMetadata(workspaceRoot, {
+ id: 'rsdoctor-build',
+ root: workspaceRoot,
+ compiler: { name: 'server', type: 'rspack' },
+ compilers: [
+ {
+ name: 'server',
+ environment: 'server',
+ compilationHash: 'compilation-server',
+ target: 'node',
+ },
+ { name: 'web', compilationHash: 'compilation-web', target: ['web'] },
+ ],
+ });
+
+ const webCandidates = await findUnusedCandidates(workspaceRoot, {
+ contextId: webContext.contextId,
+ dataFile: 'rsdoctor-data.json',
+ });
+ const serverCandidates = await findUnusedCandidates(workspaceRoot, {
+ contextId: serverContext.contextId,
+ dataFile: 'rsdoctor-data.json',
+ });
+
+ expect(webCandidates.provenance.artifactBinding).toBe('mismatch');
+ expect(webCandidates.total).toBe(0);
+ expect(webCandidates.bounds).toContain('artifact-build-mismatch');
+ expect(serverCandidates.provenance.artifactBinding).toBe('exact');
+ expect(serverCandidates.total).toBe(1);
+ });
+});
+
+test('returns only artifact-scoped unreachable module candidates', async () => {
+ await withFixtureWorkspace('application', async (workspaceRoot) => {
+ const context = {
+ contextId: 'ctx_app',
+ packageRoot: '.',
+ product: 'application',
+ } as const;
+ await recordBuild(workspaceRoot, context, 'run_app', '2026-08-12T04:00:01.000Z');
+
+ const result = await findUnusedCandidates(workspaceRoot, {
+ contextId: context.contextId,
+ dataFile: 'rsdoctor-data.json',
+ });
+
+ expect(result.roots).toEqual({
+ production: 1,
+ contract: 0,
+ conservative: 3,
+ });
+ expect(result.total).toBe(1);
+ expect(result.returned).toBe(1);
+ expect(result.analysisTruncated).toBe(false);
+ expect(result.resultTruncated).toBe(false);
+ expect(result.candidates).toEqual([
+ {
+ subject: {
+ kind: 'module',
+ id: '3',
+ path: 'src/legacy.ts',
+ name: 'legacy',
+ chunks: [],
+ },
+ classification: 'unreachable-module-candidate',
+ state: {
+ productionReachability: 'unreachable',
+ publicContract: 'not-required',
+ shipped: 'unknown',
+ optimizerRetention: 'unknown',
+ },
+ confidence: 'derived',
+ evidence: ['No path from selected roots in this artifact graph.'],
+ bounds: ['export-usage-schema-unsupported', 'duplicate-module-id', 'dangling-edge'],
+ },
+ ]);
+ });
+});
+
+test('paginates deterministic candidate results separately from analysis truncation', async () => {
+ await withFixtureWorkspace('application', async (workspaceRoot) => {
+ const context = {
+ contextId: 'ctx_app',
+ packageRoot: '.',
+ product: 'application',
+ } as const;
+ await recordBuild(workspaceRoot, context, 'run_app', '2026-08-12T04:00:01.000Z');
+ await writeFile(
+ path.join(workspaceRoot, 'rsdoctor-data.json'),
+ JSON.stringify({
+ data: {
+ moduleGraph: {
+ modules: [
+ { id: 1, path: 'src/index.ts', name: 'index', isEntry: true },
+ { id: 2, path: '.yarn/cache/pkg/unused.js', name: 'dependency-unused' },
+ { id: 3, path: 'src/unused-b.ts', name: 'unused-b' },
+ ],
+ dependencies: [],
+ },
+ },
+ }),
+ );
+
+ const firstPage = await findUnusedCandidates(workspaceRoot, {
+ contextId: context.contextId,
+ dataFile: 'rsdoctor-data.json',
+ limit: 1,
+ });
+
+ expect(firstPage.total).toBe(2);
+ expect(firstPage.returned).toBe(1);
+ expect(firstPage.resultTruncated).toBe(true);
+ expect(firstPage.analysisTruncated).toBe(false);
+ expect(firstPage.ownership).toEqual({ project: 1, dependency: 1 });
+ expect(firstPage.candidates[0]?.subject.id).toBe('3');
+ expect(firstPage.nextCursor).toEqual(expect.any(String));
+ expect(firstPage.nextCursor).not.toBe('1');
+
+ const repeatedFirstPage = await findUnusedCandidates(workspaceRoot, {
+ contextId: context.contextId,
+ dataFile: 'rsdoctor-data.json',
+ limit: 1,
+ });
+ expect(repeatedFirstPage.nextCursor).toBe(firstPage.nextCursor);
+
+ const secondPage = await findUnusedCandidates(workspaceRoot, {
+ contextId: context.contextId,
+ dataFile: 'rsdoctor-data.json',
+ limit: 1,
+ cursor: firstPage.nextCursor,
+ });
+
+ expect(secondPage.total).toBe(2);
+ expect(secondPage.returned).toBe(1);
+ expect(secondPage.resultTruncated).toBe(false);
+ expect(secondPage.analysisTruncated).toBe(false);
+ expect(secondPage.ownership).toEqual({ project: 1, dependency: 1 });
+ expect(secondPage.candidates[0]?.subject.id).toBe('2');
+ expect(secondPage).not.toHaveProperty('nextCursor');
+ });
+});
+
+test('keeps rootless modules unknown instead of deriving unreachable candidates', async () => {
+ await withFixtureWorkspace('application', async (workspaceRoot) => {
+ const context = {
+ contextId: 'ctx_app',
+ packageRoot: '.',
+ product: 'application',
+ } as const;
+ await recordBuild(workspaceRoot, context, 'run_app', '2026-08-12T04:00:01.000Z');
+ await writeFile(
+ path.join(workspaceRoot, 'rsdoctor-data.json'),
+ JSON.stringify({
+ data: {
+ moduleGraph: {
+ modules: [{ id: 2, path: 'src/orphan.ts', name: 'orphan' }],
+ dependencies: [],
+ },
+ },
+ }),
+ );
+
+ const candidates = await findUnusedCandidates(workspaceRoot, {
+ contextId: context.contextId,
+ dataFile: 'rsdoctor-data.json',
+ });
+ const explanation = await explainDeadCodeCandidate(workspaceRoot, {
+ contextId: context.contextId,
+ dataFile: 'rsdoctor-data.json',
+ module: '2',
+ });
+
+ expect(candidates.analysisTruncated).toBe(false);
+ expect(candidates.total).toBe(0);
+ expect(candidates.candidates).toEqual([]);
+ expect(candidates.bounds).toContain('no-production-entry-roots');
+ expect(explanation.classification).toBe('insufficient-evidence');
+ expect(explanation.state.productionReachability).toBe('unknown');
+ expect(explanation.evidence).toEqual([
+ 'No production entry roots were observed in this artifact graph.',
+ ]);
+ });
+});
+
+test('explains reachable, candidate, and conservatively preserved modules', async () => {
+ await withFixtureWorkspace('application', async (workspaceRoot) => {
+ const context = {
+ contextId: 'ctx_app',
+ packageRoot: '.',
+ product: 'application',
+ } as const;
+ await recordBuild(workspaceRoot, context, 'run_app', '2026-08-12T04:00:01.000Z');
+
+ const reachable = await explainDeadCodeCandidate(workspaceRoot, {
+ contextId: context.contextId,
+ dataFile: 'rsdoctor-data.json',
+ module: 'src/live.ts',
+ });
+ const candidate = await explainDeadCodeCandidate(workspaceRoot, {
+ contextId: context.contextId,
+ dataFile: 'rsdoctor-data.json',
+ module: '3',
+ });
+ const preserved = await explainDeadCodeCandidate(workspaceRoot, {
+ contextId: context.contextId,
+ dataFile: 'rsdoctor-data.json',
+ module: 'polyfill',
+ });
+
+ expect(reachable.classification).toBe('reachable');
+ expect(reachable.analysisTruncated).toBe(false);
+ expect(reachable.paths).toEqual([
+ {
+ rootKind: 'production-entry',
+ modules: [
+ {
+ id: '1',
+ path: 'src/index.ts',
+ name: './src/index.ts',
+ chunks: ['10', 'shared'],
+ },
+ { id: '2', path: 'src/live.ts', name: 'live', chunks: ['10'] },
+ ],
+ },
+ ]);
+ expect(candidate.classification).toBe('unreachable-module-candidate');
+ expect(candidate.state.productionReachability).toBe('unreachable');
+ expect(preserved.classification).toBe('preserved-by-conservative-root');
+ expect(preserved.state.optimizerRetention).toBe('side-effect');
+ expect(preserved.evidence).toContain('Rsdoctor optimizer: Top-level side effects');
+ });
+});
+
+test('returns insufficient evidence rather than unreachable when the requested depth truncates', async () => {
+ await withFixtureWorkspace('application', async (workspaceRoot) => {
+ const context = {
+ contextId: 'ctx_app',
+ packageRoot: '.',
+ product: 'application',
+ } as const;
+ await recordBuild(workspaceRoot, context, 'run_app', '2026-08-12T04:00:01.000Z');
+
+ const result = await explainDeadCodeCandidate(workspaceRoot, {
+ contextId: context.contextId,
+ dataFile: 'rsdoctor-data.json',
+ module: 'src/cycle-a.ts',
+ maxDepth: 1,
+ });
+
+ expect(result.classification).toBe('insufficient-evidence');
+ expect(result.analysisTruncated).toBe(true);
+ expect(result.state.productionReachability).toBe('unknown');
+ expect(result.bounds).toContain('production-traversal-truncated');
+ });
+});
+
+test('uses the same default reachability depth for candidates and explanations', async () => {
+ await withFixtureWorkspace('application', async (workspaceRoot) => {
+ const context = {
+ contextId: 'ctx_app',
+ packageRoot: '.',
+ product: 'application',
+ } as const;
+ await recordBuild(workspaceRoot, context, 'run_app', '2026-08-12T04:00:01.000Z');
+ await writeFile(
+ path.join(workspaceRoot, 'rsdoctor-data.json'),
+ JSON.stringify({
+ data: {
+ moduleGraph: {
+ modules: Array.from({ length: 11 }, (_, index) => ({
+ id: index,
+ path: `src/depth-${index}.ts`,
+ name: `depth-${index}`,
+ ...(index === 0 ? { isEntry: true } : {}),
+ })),
+ dependencies: Array.from({ length: 10 }, (_, index) => ({
+ module: index,
+ dependency: index + 1,
+ })),
+ },
+ },
+ }),
+ );
+
+ const result = await explainDeadCodeCandidate(workspaceRoot, {
+ contextId: context.contextId,
+ dataFile: 'rsdoctor-data.json',
+ module: 'src/depth-10.ts',
+ });
+
+ expect(result.classification).toBe('reachable');
+ expect(result.analysisTruncated).toBe(false);
+ });
+});
+
+test('traces artifact-local dependent impact to product roots and emitted chunks', async () => {
+ await withFixtureWorkspace('application', async (workspaceRoot) => {
+ const context = {
+ contextId: 'ctx_app',
+ packageRoot: '.',
+ product: 'application',
+ } as const;
+ await recordBuild(workspaceRoot, context, 'run_app', '2026-08-12T04:00:01.000Z');
+
+ const result = await traceModuleImpact(workspaceRoot, {
+ contextId: context.contextId,
+ dataFile: 'rsdoctor-data.json',
+ module: '2',
+ });
+
+ expect(result.direction).toBe('dependents');
+ expect(result.modules.map(({ id }) => id)).toEqual(['2', '1']);
+ expect(result.reachedRoots.map(({ kind, module }) => [kind, module.id])).toEqual([
+ ['production-entry', '1'],
+ ]);
+ expect(result.affectedChunks).toEqual(['10', 'shared']);
+ expect(result.totalVisited).toBe(2);
+ expect(result.returned).toBe(2);
+ expect(result.truncated).toBe(false);
+ });
+});
+
+test('rejects unknown contexts, ambiguous selectors, and invalid query bounds', async () => {
+ await withFixtureWorkspace('application', async (workspaceRoot) => {
+ const context = {
+ contextId: 'ctx_app',
+ packageRoot: '.',
+ product: 'application',
+ } as const;
+ await recordBuild(workspaceRoot, context, 'run_app', '2026-08-12T04:00:01.000Z');
+
+ await expect(
+ readProductRoots(workspaceRoot, {
+ contextId: 'ctx_missing',
+ dataFile: 'rsdoctor-data.json',
+ }),
+ ).rejects.toThrow('Unknown context: ctx_missing');
+ await expect(
+ explainDeadCodeCandidate(workspaceRoot, {
+ contextId: context.contextId,
+ dataFile: 'rsdoctor-data.json',
+ module: 'cycle',
+ }),
+ ).rejects.toThrow('Unknown module selector: cycle');
+ await expect(
+ findUnusedCandidates(workspaceRoot, {
+ contextId: context.contextId,
+ dataFile: 'rsdoctor-data.json',
+ limit: 0,
+ }),
+ ).rejects.toThrow('limit must be an integer from 1 to 100.');
+ await expect(
+ explainDeadCodeCandidate(workspaceRoot, {
+ contextId: context.contextId,
+ dataFile: 'rsdoctor-data.json',
+ module: '2',
+ maxDepth: 33,
+ }),
+ ).rejects.toThrow('maxDepth must be an integer from 1 to 32.');
+ await expect(
+ traceModuleImpact(workspaceRoot, {
+ contextId: context.contextId,
+ dataFile: 'rsdoctor-data.json',
+ module: '2',
+ maxDepth: 17,
+ }),
+ ).rejects.toThrow('maxDepth must be an integer from 1 to 16.');
+
+ await writeFile(
+ path.join(workspaceRoot, 'rsdoctor-data.json'),
+ JSON.stringify({
+ data: {
+ moduleGraph: {
+ modules: [
+ { id: 1, path: 'src/index.ts', name: 'index', isEntry: true },
+ { id: 2, path: 'src/a/shared.ts', name: 'shared' },
+ { id: 3, path: 'src/b/shared.ts', name: 'shared' },
+ ],
+ dependencies: [],
+ },
+ },
+ }),
+ );
+ await expect(
+ explainDeadCodeCandidate(workspaceRoot, {
+ contextId: context.contextId,
+ dataFile: 'rsdoctor-data.json',
+ module: 'shared',
+ }),
+ ).rejects.toThrow(
+ 'Ambiguous module selector: shared. Matches: 2 (src/a/shared.ts), 3 (src/b/shared.ts).',
+ );
+ });
+});
diff --git a/tests/rawRspackContext.test.ts b/tests/rawRspackContext.test.ts
new file mode 100644
index 0000000..aea1028
--- /dev/null
+++ b/tests/rawRspackContext.test.ts
@@ -0,0 +1,90 @@
+import { cp, mkdtemp, rm } from 'node:fs/promises';
+import os from 'node:os';
+import path from 'node:path';
+import { expect, test } from '@rstest/core';
+import {
+ contextStoreSchemaVersion,
+ writeContextRunManifest,
+ writeContextSnapshot,
+ type ContextDescriptor,
+ type ContextRunManifest,
+ type ContextSnapshot,
+} from '../src/index.ts';
+import { readCodeEvidence } from '../src/codeEvidence.ts';
+import { readProductRoots } from '../src/queries.ts';
+
+const fixtureRoot = path.resolve(
+ import.meta.dirname,
+ '../fixtures/context/reachability/application',
+);
+
+test('uses raw artifact entry roots when only an Rstest context is available', async () => {
+ const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), 'rstack-raw-rspack-context-'));
+ await cp(fixtureRoot, workspaceRoot, { recursive: true });
+
+ try {
+ const context = {
+ contextId: 'ctx_test',
+ packageRoot: '.',
+ product: 'development',
+ environment: 'test',
+ } satisfies ContextDescriptor;
+ const run = {
+ schemaVersion: contextStoreSchemaVersion,
+ runId: 'run_test',
+ producer: 'rstest',
+ command: 'test',
+ startedAt: '2026-08-14T04:00:00.000Z',
+ contexts: [context],
+ } satisfies ContextRunManifest;
+ const snapshot = {
+ schemaVersion: contextStoreSchemaVersion,
+ snapshotId: 'snap_test',
+ runId: run.runId,
+ contextId: context.contextId,
+ sequence: 1,
+ observedAt: '2026-08-14T04:00:01.000Z',
+ status: 'pass',
+ completeness: { test: 'complete' },
+ facets: {
+ test: {
+ producer: 'rstest',
+ files: [],
+ stats: {
+ tests: { total: 0, passed: 0, failed: 0, skipped: 0, todo: 0 },
+ files: { total: 0, failed: 0 },
+ },
+ durationMs: 0,
+ unhandledErrors: [],
+ },
+ },
+ } satisfies ContextSnapshot;
+ expect(await writeContextRunManifest(workspaceRoot, run)).toMatchObject({ written: true });
+ expect(await writeContextSnapshot(workspaceRoot, snapshot)).toMatchObject({ written: true });
+
+ const roots = await readProductRoots(workspaceRoot, {
+ contextId: context.contextId,
+ dataFile: 'rsdoctor-data.json',
+ });
+ expect(roots.product.product).toBe('unknown');
+ expect(roots.product.roots.map(({ kind, module }) => [kind, module.id])).toContainEqual([
+ 'production-entry',
+ '1',
+ ]);
+ expect(roots.product.bounds).toContain('product-context-unavailable');
+
+ const evidence = await readCodeEvidence(workspaceRoot, {
+ path: 'src/live.ts',
+ contextId: context.contextId,
+ dataFile: 'rsdoctor-data.json',
+ });
+ expect(evidence.module).toMatchObject({
+ classification: 'reachable',
+ state: { productionReachability: 'live', publicContract: 'unknown', shipped: 'yes' },
+ bounds: expect.arrayContaining(['product-context-unavailable']),
+ });
+ expect(evidence.bounds).toContain('artifact-binding-not-exact');
+ } finally {
+ await rm(workspaceRoot, { force: true, recursive: true });
+ }
+});
diff --git a/tests/reachability.test.ts b/tests/reachability.test.ts
new file mode 100644
index 0000000..60d7f21
--- /dev/null
+++ b/tests/reachability.test.ts
@@ -0,0 +1,82 @@
+import { expect, test } from '@rstest/core';
+import type { ObservedModuleGraph } from '../src/analysisModel.ts';
+import { traceModuleGraph } from '../src/reachability.ts';
+
+const graph: ObservedModuleGraph = {
+ modules: [
+ {
+ id: 'root',
+ path: 'src/root.ts',
+ name: 'root',
+ chunks: [],
+ isEntry: true,
+ },
+ { id: 'b', path: 'src/b.ts', name: 'b', chunks: [], isEntry: false },
+ { id: 'a', path: 'src/a.ts', name: 'a', chunks: [], isEntry: false },
+ { id: 'end', path: 'src/end.ts', name: 'end', chunks: [], isEntry: false },
+ ],
+ edges: [
+ { from: 'root', to: 'b' },
+ { from: 'root', to: 'a' },
+ { from: 'b', to: 'end' },
+ { from: 'a', to: 'end' },
+ { from: 'end', to: 'root' },
+ ],
+ exportRowsPresent: false,
+ issues: [],
+};
+
+test('traces dependencies breadth-first with deterministic shortest paths through cycles', () => {
+ const result = traceModuleGraph(graph, ['root'], 'dependencies', {
+ maxDepth: 8,
+ maxVisited: 20,
+ });
+
+ expect(result.visited).toEqual(['root', 'a', 'b', 'end']);
+ expect([...result.predecessor]).toEqual([
+ ['root', undefined],
+ ['a', 'root'],
+ ['b', 'root'],
+ ['end', 'a'],
+ ]);
+ expect([...result.depth]).toEqual([
+ ['root', 0],
+ ['a', 1],
+ ['b', 1],
+ ['end', 2],
+ ]);
+ expect(result.truncated).toBe(false);
+});
+
+test('traces dependents by reversing observed issuer edges', () => {
+ const result = traceModuleGraph(graph, ['end'], 'dependents', {
+ maxDepth: 8,
+ maxVisited: 20,
+ });
+
+ expect(result.visited).toEqual(['end', 'a', 'b', 'root']);
+ expect(result.predecessor.get('root')).toBe('a');
+ expect(result.depth.get('root')).toBe(2);
+ expect(result.truncated).toBe(false);
+});
+
+test('marks depth-bounded traversals as truncated when reachable neighbors remain', () => {
+ const result = traceModuleGraph(graph, ['root'], 'dependencies', {
+ maxDepth: 1,
+ maxVisited: 20,
+ });
+
+ expect(result.visited).toEqual(['root', 'a', 'b']);
+ expect(result.visited).not.toContain('end');
+ expect(result.truncated).toBe(true);
+});
+
+test('marks node-bounded traversals as truncated without exceeding the visit limit', () => {
+ const result = traceModuleGraph(graph, ['root'], 'dependencies', {
+ maxDepth: 8,
+ maxVisited: 2,
+ });
+
+ expect(result.visited).toEqual(['root', 'a']);
+ expect(result.truncated).toBe(true);
+});
diff --git a/tests/records.test.ts b/tests/records.test.ts
new file mode 100644
index 0000000..0e840d3
--- /dev/null
+++ b/tests/records.test.ts
@@ -0,0 +1,554 @@
+import { expect, test } from '@rstest/core';
+import { contextStoreSchemaVersion } from '../src/model.ts';
+import {
+ getContextSnapshotGenerationFileName,
+ isContextSnapshotGenerationFileName,
+ validateExecutionFacet,
+ validateLintFacet,
+ validateRunManifest,
+ validateSnapshot,
+ validateTestFacet,
+} from '../src/records.ts';
+
+const context = {
+ contextId: 'ctx_library_esm',
+ packageName: '@repo/library',
+ packageRoot: 'packages/library',
+ configPath: 'packages/library/rstack.config.ts',
+ product: 'library',
+ environment: 'esm',
+};
+
+const run = {
+ schemaVersion: contextStoreSchemaVersion,
+ runId: 'run_library_watch',
+ producer: 'rslib',
+ command: 'build:watch',
+ startedAt: '2026-08-12T05:00:00.000Z',
+ contexts: [context],
+};
+
+const snapshot = {
+ schemaVersion: contextStoreSchemaVersion,
+ snapshotId: 'snap_library_2',
+ runId: run.runId,
+ contextId: context.contextId,
+ sequence: 2,
+ observedAt: '2026-08-12T05:00:02.000Z',
+ status: 'pass',
+ completeness: { build: 'complete' },
+ facets: { summary: { errors: 0, warnings: 1 } },
+};
+
+test('validates complete run manifests', () => {
+ expect(validateRunManifest(run)).toEqual(run);
+});
+
+test('rejects malformed run manifests', () => {
+ const invalidManifests = [
+ null,
+ { ...run, schemaVersion: 2 },
+ { ...run, runId: 42 },
+ { ...run, producer: 'unknown' },
+ { ...run, command: 42 },
+ { ...run, startedAt: 42 },
+ { ...run, contexts: [] },
+ { ...run, contexts: [{ ...context, contextId: 42 }] },
+ { ...run, contexts: [{ ...context, packageRoot: 42 }] },
+ { ...run, contexts: [{ ...context, product: 42 }] },
+ { ...run, contexts: [{ ...context, packageName: 42 }] },
+ ];
+
+ for (const value of invalidManifests) {
+ expect(validateRunManifest(value)).toBeUndefined();
+ }
+});
+
+test('rejects duplicate context IDs in a run manifest', () => {
+ expect(
+ validateRunManifest({
+ ...run,
+ contexts: [context, { ...context, packageRoot: 'packages/other' }],
+ }),
+ ).toBeUndefined();
+});
+
+test('validates complete snapshots', () => {
+ expect(validateSnapshot(snapshot)).toEqual(snapshot);
+});
+
+test('validates lint and test facets with captured source inputs', () => {
+ const lint = {
+ producer: 'rslint',
+ mode: 'files',
+ fixPreviewCaptured: true,
+ files: [
+ {
+ path: 'src/index.ts',
+ digest: 'a'.repeat(64),
+ errorCount: 1,
+ warningCount: 0,
+ fixableErrorCount: 1,
+ fixableWarningCount: 0,
+ messages: [
+ {
+ ruleId: 'no-debugger',
+ severity: 2,
+ message: 'Unexpected debugger statement.',
+ messageId: 'unexpected',
+ line: 1,
+ column: 1,
+ endLine: 1,
+ endColumn: 9,
+ fix: { range: [0, 8], text: '' },
+ suggestions: [
+ {
+ messageId: 'remove',
+ data: { statement: 'debugger' },
+ desc: 'Remove debugger.',
+ fix: { range: [0, 8], text: '' },
+ },
+ ],
+ },
+ ],
+ fixedOutput: '',
+ },
+ ],
+ totals: {
+ files: 1,
+ errors: 1,
+ warnings: 0,
+ fixableErrors: 1,
+ fixableWarnings: 0,
+ },
+ } as const;
+ const testFacet = {
+ producer: 'rstest',
+ relation: {
+ sources: ['src/index.ts'],
+ testFiles: ['src/index.test.ts'],
+ },
+ files: [
+ {
+ project: 'unit',
+ path: 'src/index.test.ts',
+ status: 'fail',
+ durationMs: 4,
+ tests: [
+ {
+ project: 'unit',
+ path: 'src/index.test.ts',
+ name: 'works',
+ parentNames: ['suite'],
+ status: 'fail',
+ durationMs: 3,
+ errors: [{ name: 'AssertionError', message: 'failed', retryCount: 1 }],
+ retryErrors: [{ name: 'AssertionError', message: 'first attempt' }],
+ retryCount: 1,
+ },
+ ],
+ },
+ ],
+ stats: {
+ tests: { total: 1, passed: 0, failed: 1, skipped: 0, todo: 0 },
+ files: { total: 1, failed: 1 },
+ },
+ durationMs: 5,
+ unhandledErrors: [],
+ } as const;
+ const captured = {
+ ...snapshot,
+ source: {
+ inputs: [{ path: 'src/index.ts', digest: 'b'.repeat(64) }],
+ inputCompleteness: 'complete',
+ },
+ facets: { lint, test: testFacet },
+ };
+
+ expect(validateLintFacet(lint)).toEqual(lint);
+ expect(validateTestFacet(testFacet)).toEqual(testFacet);
+ expect(validateSnapshot(captured)).toEqual(captured);
+});
+
+test('validates aggregate Rstest execution facets before generic producer routing', () => {
+ const execution = {
+ producer: 'rstest',
+ provider: 'istanbul',
+ availability: 'available',
+ requestedSelection: { include: ['src/**/*.ts'], allowExternal: false },
+ digest: 'c'.repeat(64),
+ universe: {
+ reportedFiles: 1,
+ storedFiles: 1,
+ droppedFiles: 0,
+ reportedLocations: 6,
+ storedLocations: 6,
+ droppedLocations: 0,
+ completeness: 'complete',
+ },
+ truncated: { files: 0, locations: 0 },
+ bounds: {
+ attribution: 'aggregate-run-only',
+ testAttribution: false,
+ maxFiles: 1000,
+ maxLocationsPerFile: 20_000,
+ maxLocationsTotal: 100_000,
+ },
+ files: [
+ {
+ path: 'src/index.ts',
+ digest: 'd'.repeat(64),
+ statements: [
+ {
+ id: '0',
+ location: {
+ start: { line: 1, column: 0 },
+ end: { line: 1, column: 10 },
+ },
+ hits: 1,
+ },
+ ],
+ functions: [
+ {
+ id: '0',
+ name: 'main',
+ declaration: {
+ start: { line: 1, column: 0 },
+ end: { line: 1, column: 4 },
+ },
+ location: {
+ start: { line: 1, column: 7 },
+ end: { line: 1, column: 10 },
+ },
+ hits: 1,
+ },
+ ],
+ branches: [
+ {
+ id: '0',
+ type: 'if',
+ location: {
+ start: { line: 2, column: 0 },
+ end: { line: 2, column: 10 },
+ },
+ arms: [
+ {
+ location: {
+ start: { line: 2, column: 4 },
+ end: { line: 2, column: 7 },
+ },
+ hits: 0,
+ },
+ {
+ location: {
+ start: { line: 2, column: 8 },
+ end: { line: 2, column: 10 },
+ },
+ hits: 1,
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ } as const;
+
+ expect(validateExecutionFacet(execution)).toEqual(execution);
+ expect(validateSnapshot({ ...snapshot, facets: { execution } })).toEqual({
+ ...snapshot,
+ facets: { execution },
+ });
+ expect(validateExecutionFacet({ ...execution, digest: 'not-a-digest' })).toBeUndefined();
+ expect(
+ validateExecutionFacet({
+ ...execution,
+ bounds: { ...execution.bounds, testAttribution: true },
+ }),
+ ).toBeUndefined();
+ expect(
+ validateSnapshot({
+ ...snapshot,
+ facets: { execution: { ...execution, files: [{ testId: 'owned' }] } },
+ }),
+ ).toBeUndefined();
+ expect(
+ validateExecutionFacet({
+ ...execution,
+ files: Array.from({ length: 1001 }, (_, index) => ({
+ path: `src/${index}.ts`,
+ statements: [],
+ functions: [],
+ branches: [],
+ })),
+ universe: {
+ ...execution.universe,
+ reportedFiles: 1001,
+ storedFiles: 1001,
+ reportedLocations: 0,
+ storedLocations: 0,
+ },
+ }),
+ ).toBeUndefined();
+ expect(
+ validateExecutionFacet({
+ ...execution,
+ files: [
+ {
+ path: 'src/index.ts',
+ statements: Array.from({ length: 20_001 }, (_, index) => ({
+ id: String(index),
+ location: {
+ start: { line: 1, column: 0 },
+ end: { line: 1, column: 1 },
+ },
+ hits: 0,
+ })),
+ functions: [],
+ branches: [],
+ },
+ ],
+ universe: {
+ ...execution.universe,
+ reportedLocations: 20_001,
+ storedLocations: 20_001,
+ },
+ }),
+ ).toBeUndefined();
+ expect(
+ validateExecutionFacet({
+ ...execution,
+ files: Array.from({ length: 6 }, (_, fileIndex) => ({
+ path: `src/${fileIndex}.ts`,
+ statements: Array.from({ length: 20_000 }, (_, index) => ({
+ id: String(index),
+ location: {
+ start: { line: 1, column: 0 },
+ end: { line: 1, column: 1 },
+ },
+ hits: 0,
+ })),
+ functions: [],
+ branches: [],
+ })),
+ universe: {
+ ...execution.universe,
+ reportedFiles: 6,
+ storedFiles: 6,
+ reportedLocations: 120_000,
+ storedLocations: 120_000,
+ },
+ }),
+ ).toBeUndefined();
+});
+
+test('rejects malformed known facets and unpaired source input metadata', () => {
+ const lint = {
+ producer: 'rslint',
+ mode: 'files',
+ fixPreviewCaptured: false,
+ files: [],
+ totals: {
+ files: 0,
+ errors: 0,
+ warnings: 0,
+ fixableErrors: 0,
+ fixableWarnings: 0,
+ },
+ } as const;
+ const testFacet = {
+ producer: 'rstest',
+ files: [],
+ stats: {
+ tests: { total: 0, passed: 0, failed: 0, skipped: 0, todo: 0 },
+ files: { total: 0, failed: 0 },
+ },
+ durationMs: 0,
+ unhandledErrors: [],
+ } as const;
+
+ expect(validateLintFacet({ ...lint, mode: 'watch' })).toBeUndefined();
+ expect(validateLintFacet({ ...lint, files: [{ path: 'a.ts' }] })).toBeUndefined();
+ expect(validateTestFacet({ ...testFacet, durationMs: -1 })).toBeUndefined();
+ expect(
+ validateTestFacet({
+ ...testFacet,
+ relation: { sources: ['src/index.ts'], testFiles: [42] },
+ }),
+ ).toBeUndefined();
+ expect(
+ validateTestFacet({
+ ...testFacet,
+ files: [{ project: 'unit', path: 'a.test.ts', status: 'unknown', tests: [] }],
+ }),
+ ).toBeUndefined();
+ expect(
+ validateTestFacet({
+ ...testFacet,
+ files: [
+ {
+ project: 'unit',
+ path: 'a.test.ts',
+ status: 'fail',
+ errors: [{ name: 'ImportError' }],
+ tests: [],
+ },
+ ],
+ }),
+ ).toBeUndefined();
+ expect(
+ validateSnapshot({
+ ...snapshot,
+ source: { inputs: [{ path: 'src/index.ts', digest: 'not-a-digest' }] },
+ }),
+ ).toBeUndefined();
+ expect(
+ validateSnapshot({
+ ...snapshot,
+ source: { inputCompleteness: 'complete' },
+ }),
+ ).toBeUndefined();
+ expect(
+ validateSnapshot({
+ ...snapshot,
+ source: { captureSelection: { patterns: [Symbol('not-json')] } },
+ }),
+ ).toBeUndefined();
+ expect(
+ validateSnapshot({
+ ...snapshot,
+ facets: { lint: { ...lint, mode: 'watch' } },
+ }),
+ ).toBeUndefined();
+ expect(
+ validateSnapshot({
+ ...snapshot,
+ facets: { test: { ...testFacet, durationMs: -1 } },
+ }),
+ ).toBeUndefined();
+});
+
+test('keeps legacy sources and unknown JSON facets valid', () => {
+ const legacy = {
+ ...snapshot,
+ source: { revision: 'abc123', dirtyDigest: 'dirty' },
+ facets: { future: { producer: 'future', values: [true, null, 1, 'one'] } },
+ };
+
+ expect(validateSnapshot(legacy)).toEqual(legacy);
+});
+
+test('rejects malformed snapshots', () => {
+ const invalidSnapshots = [
+ null,
+ { ...snapshot, schemaVersion: 2 },
+ { ...snapshot, snapshotId: 42 },
+ { ...snapshot, runId: 42 },
+ { ...snapshot, contextId: 42 },
+ { ...snapshot, sequence: -1 },
+ { ...snapshot, sequence: 1.5 },
+ { ...snapshot, observedAt: 42 },
+ { ...snapshot, status: 'unknown' },
+ { ...snapshot, completeness: [] },
+ { ...snapshot, completeness: { build: 'unknown' } },
+ { ...snapshot, facets: [] },
+ {
+ ...snapshot,
+ source: {
+ inputs: [],
+ inputCompleteness: 'partial',
+ unreadableInputs: [42],
+ },
+ },
+ ];
+
+ for (const value of invalidSnapshots) {
+ expect(validateSnapshot(value)).toBeUndefined();
+ }
+});
+
+const causeChain = (depth: number): Record => ({
+ name: `Cause${depth}`,
+ message: `cause ${depth}`,
+ ...(depth === 0 ? {} : { cause: causeChain(depth - 1) }),
+});
+
+const facetWithTestCase = (testCase: Record) => ({
+ producer: 'rstest',
+ files: [
+ {
+ project: 'unit',
+ path: 'src/index.test.ts',
+ status: 'fail',
+ tests: [testCase],
+ },
+ ],
+ stats: {
+ tests: { total: 1, passed: 0, failed: 1, skipped: 0, todo: 0 },
+ files: { total: 1, failed: 1 },
+ },
+ durationMs: 1,
+ unhandledErrors: [],
+});
+
+const baseTestCase = {
+ project: 'unit',
+ path: 'src/index.test.ts',
+ name: 'works',
+ status: 'fail',
+} as const;
+
+test('validates test cases carrying error causes and task metadata', () => {
+ const withFidelity = facetWithTestCase({
+ ...baseTestCase,
+ // Exactly the eight cause links the capture-side cap emits, so writer output always validates.
+ errors: [{ name: 'AssertionError', message: 'outer', cause: causeChain(7) }],
+ meta: { flaky: true, attempts: 2, tags: ['slow'], detail: { note: null } },
+ });
+ expect(validateTestFacet(withFidelity)).toEqual(withFidelity);
+
+ // Snapshots written before these fields existed keep validating: both are optional and the
+ // store is checkout-local, so the schema version does not move.
+ const withoutFidelity = facetWithTestCase({
+ ...baseTestCase,
+ errors: [{ name: 'AssertionError', message: 'outer' }],
+ });
+ expect(validateTestFacet(withoutFidelity)).toEqual(withoutFidelity);
+ expect(validateSnapshot({ ...snapshot, facets: { test: withoutFidelity } })).not.toBeUndefined();
+});
+
+test('rejects unbounded cause chains and metadata that is not JSON-safe', () => {
+ expect(
+ validateTestFacet(
+ facetWithTestCase({
+ ...baseTestCase,
+ errors: [{ name: 'AssertionError', message: 'outer', cause: causeChain(8) }],
+ }),
+ ),
+ ).toBeUndefined();
+ expect(
+ validateTestFacet(
+ facetWithTestCase({
+ ...baseTestCase,
+ errors: [{ name: 'AssertionError', message: 'outer', cause: { message: 'no name' } }],
+ }),
+ ),
+ ).toBeUndefined();
+ expect(
+ validateTestFacet(facetWithTestCase({ ...baseTestCase, meta: { report: () => undefined } })),
+ ).toBeUndefined();
+ expect(
+ validateTestFacet(facetWithTestCase({ ...baseTestCase, meta: ['not', 'a', 'record'] })),
+ ).toBeUndefined();
+ expect(
+ validateTestFacet(facetWithTestCase({ ...baseTestCase, meta: { size: Number.NaN } })),
+ ).toBeUndefined();
+});
+
+test('creates and recognizes canonical snapshot generation file names', () => {
+ expect(getContextSnapshotGenerationFileName(snapshot)).toBe('0000000002-snap_library_2.json');
+ expect(isContextSnapshotGenerationFileName('0000000002-snap_library_2.json', snapshot)).toBe(
+ true,
+ );
+ expect(isContextSnapshotGenerationFileName('2-snap_library_2.json', snapshot)).toBe(false);
+ expect(isContextSnapshotGenerationFileName('0000000002-other.json', snapshot)).toBe(false);
+});
diff --git a/tests/report.test.ts b/tests/report.test.ts
new file mode 100644
index 0000000..d1395a7
--- /dev/null
+++ b/tests/report.test.ts
@@ -0,0 +1,170 @@
+import { mkdir, writeFile } from 'node:fs/promises';
+import path from 'node:path';
+import { pathToFileURL } from 'node:url';
+import { expect, test } from '@rstest/core';
+import { resolveReportFile, resolveRsdoctorReport } from '../src/report.ts';
+import { withTempWorkspace } from './helpers.ts';
+
+const validDataFile = 'artifacts/rsdoctor-data.json';
+
+const writeWorkspaceFile = async (
+ workspaceRoot: string,
+ relativeFile: string,
+ contents: string,
+): Promise => {
+ const filePath = path.join(workspaceRoot, relativeFile);
+ await mkdir(path.dirname(filePath), { recursive: true });
+ await writeFile(filePath, contents);
+ return filePath;
+};
+
+const writeDataFile = async (workspaceRoot: string): Promise => {
+ await writeWorkspaceFile(workspaceRoot, validDataFile, '{"data":{}}');
+};
+
+test('resolves the conventional sibling HTML report before other report candidates', async () => {
+ await withTempWorkspace('rstack-rsdoctor-report-', async (workspaceRoot) => {
+ await writeDataFile(workspaceRoot);
+ const reportPath = await writeWorkspaceFile(
+ workspaceRoot,
+ 'artifacts/report-rsdoctor.html',
+ '',
+ );
+
+ await expect(resolveRsdoctorReport(workspaceRoot, validDataFile)).resolves.toEqual({
+ dataFile: validDataFile,
+ report: {
+ kind: 'html',
+ path: 'artifacts/report-rsdoctor.html',
+ uri: pathToFileURL(reportPath).toString(),
+ },
+ });
+ });
+});
+
+test('returns a typed missing outcome when a report file does not exist', async () => {
+ await withTempWorkspace('rstack-rsdoctor-report-', async (workspaceRoot) => {
+ await expect(
+ resolveReportFile(workspaceRoot, 'artifacts/report-rsdoctor.html'),
+ ).resolves.toEqual({ kind: 'missing' });
+ });
+});
+
+test('resolves one custom sibling HTML report when the conventional report is absent', async () => {
+ await withTempWorkspace('rstack-rsdoctor-report-', async (workspaceRoot) => {
+ await writeDataFile(workspaceRoot);
+ const reportPath = await writeWorkspaceFile(
+ workspaceRoot,
+ 'artifacts/custom-rsdoctor-report.html',
+ '',
+ );
+
+ await expect(resolveRsdoctorReport(workspaceRoot, validDataFile)).resolves.toEqual({
+ dataFile: validDataFile,
+ report: {
+ kind: 'html',
+ path: 'artifacts/custom-rsdoctor-report.html',
+ uri: pathToFileURL(reportPath).toString(),
+ },
+ });
+ });
+});
+
+test('resolves the Rsdoctor default report name ahead of ordinary application HTML', async () => {
+ await withTempWorkspace('rstack-rsdoctor-report-', async (workspaceRoot) => {
+ await writeDataFile(workspaceRoot);
+ const reportPath = await writeWorkspaceFile(
+ workspaceRoot,
+ 'artifacts/rsdoctor-report.html',
+ '',
+ );
+ await writeWorkspaceFile(workspaceRoot, 'artifacts/index.html', '');
+
+ await expect(resolveRsdoctorReport(workspaceRoot, validDataFile)).resolves.toEqual({
+ dataFile: validDataFile,
+ report: {
+ kind: 'html',
+ path: 'artifacts/rsdoctor-report.html',
+ uri: pathToFileURL(reportPath).toString(),
+ },
+ });
+ });
+});
+
+test('ignores an ordinary application HTML file beside the Rsdoctor data artifact', async () => {
+ await withTempWorkspace('rstack-rsdoctor-report-', async (workspaceRoot) => {
+ await writeDataFile(workspaceRoot);
+ await writeWorkspaceFile(
+ workspaceRoot,
+ 'artifacts/index.html',
+ '',
+ );
+
+ await expect(resolveRsdoctorReport(workspaceRoot, validDataFile)).resolves.toEqual({
+ dataFile: validDataFile,
+ nextAction: {
+ arguments: { dataFile: validDataFile, input: {}, toolName: 'build_summary' },
+ tool: 'rsdoctor_analyze',
+ },
+ reason:
+ 'No GUI report was found; a GUI report is optional. Use rsdoctor_analyze for static inspection.',
+ });
+ });
+});
+
+test('resolves the normal workspace .rsdoctor manifest when no sibling HTML report exists', async () => {
+ await withTempWorkspace('rstack-rsdoctor-report-', async (workspaceRoot) => {
+ await writeDataFile(workspaceRoot);
+ const manifestPath = await writeWorkspaceFile(
+ workspaceRoot,
+ '.rsdoctor/manifest.json',
+ '{"version":1}',
+ );
+
+ await expect(resolveRsdoctorReport(workspaceRoot, validDataFile)).resolves.toEqual({
+ dataFile: validDataFile,
+ report: {
+ kind: 'manifest',
+ path: '.rsdoctor/manifest.json',
+ uri: pathToFileURL(manifestPath).toString(),
+ },
+ });
+ });
+});
+
+test('returns a no-report response for ambiguous sibling HTML reports', async () => {
+ await withTempWorkspace('rstack-rsdoctor-report-', async (workspaceRoot) => {
+ await writeDataFile(workspaceRoot);
+ await writeWorkspaceFile(workspaceRoot, 'artifacts/first-rsdoctor.html', '');
+ await writeWorkspaceFile(workspaceRoot, 'artifacts/second-rsdoctor.html', '');
+
+ await expect(resolveRsdoctorReport(workspaceRoot, validDataFile)).resolves.toEqual({
+ dataFile: validDataFile,
+ nextAction: {
+ arguments: { dataFile: validDataFile, input: {}, toolName: 'build_summary' },
+ tool: 'rsdoctor_analyze',
+ },
+ reason: 'Multiple sibling HTML reports were found; select one explicitly.',
+ });
+ });
+});
+
+test('returns a portable next action for a standalone workspace without a GUI report', async () => {
+ await withTempWorkspace('rstack-rsdoctor-report-', async (workspaceRoot) => {
+ await writeDataFile(workspaceRoot);
+
+ const result = await resolveRsdoctorReport(workspaceRoot, validDataFile);
+
+ expect(result).toEqual({
+ dataFile: validDataFile,
+ nextAction: {
+ arguments: { dataFile: validDataFile, input: {}, toolName: 'build_summary' },
+ tool: 'rsdoctor_analyze',
+ },
+ reason:
+ 'No GUI report was found; a GUI report is optional. Use rsdoctor_analyze for static inspection.',
+ });
+ expect(JSON.stringify(result)).not.toContain('pnpm');
+ expect(JSON.stringify(result)).not.toContain('packages/rstack');
+ });
+});
diff --git a/tests/rsdoctor.test.ts b/tests/rsdoctor.test.ts
new file mode 100644
index 0000000..c3a2825
--- /dev/null
+++ b/tests/rsdoctor.test.ts
@@ -0,0 +1,323 @@
+import { spawnSync } from 'node:child_process';
+import { mkdir, writeFile } from 'node:fs/promises';
+import path from 'node:path';
+import { pathToFileURL } from 'node:url';
+import { expect, test } from '@rstest/core';
+import { analyzeRsdoctorArtifact, listRsdoctorToolNames } from '../src/index.ts';
+import { withTempWorkspace } from './helpers.ts';
+
+const validDataFile = 'artifacts/rsdoctor-data.json';
+
+const artifactSectionNames = [
+ 'errors',
+ 'configs',
+ 'summary',
+ 'resolver',
+ 'loader',
+ 'moduleGraph',
+ 'chunkGraph',
+ 'moduleCodeMap',
+ 'plugin',
+ 'packageGraph',
+ 'treeShaking',
+ 'otherReports',
+] as const;
+
+const createArtifactMetadata = (
+ overrides: Record = {},
+) => ({
+ schemaVersion: 1,
+ producer: { name: '@rsdoctor/core', version: '1.2.3' },
+ output: { mode: 'normal' },
+ build: {
+ id: 'test-build',
+ root: '/test/project',
+ compiler: { name: 'rspack' },
+ },
+ sections: Object.fromEntries(
+ artifactSectionNames.map((name) => [name, overrides[name] ?? { status: 'collected' }]),
+ ),
+});
+
+const writeArtifact = async (
+ workspaceRoot: string,
+ dataFile: string,
+ contents: string,
+): Promise => {
+ const artifactPath = path.join(workspaceRoot, dataFile);
+ await mkdir(path.dirname(artifactPath), { recursive: true });
+ await writeFile(artifactPath, contents);
+};
+
+test('lists the complete pinned Rsdoctor tool names', () => {
+ const toolNames = listRsdoctorToolNames();
+
+ expect(toolNames).toEqual([
+ 'build_summary',
+ 'bundle_optimize',
+ 'chunks_list',
+ 'errors_list',
+ 'packages_direct_dependencies',
+ 'packages_duplicates',
+ 'packages_similar',
+ 'tree_shaking_retained_modules',
+ 'tree_shaking_side_effects',
+ 'tree_shaking_summary',
+ ]);
+ expect(new Set(toolNames).size).toBe(toolNames.length);
+});
+
+test('listing the pinned Rsdoctor tools does not load the adapter package', async () => {
+ await withTempWorkspace('rstack-rsdoctor-', async (workspaceRoot) => {
+ const markerFile = path.join(workspaceRoot, 'rsdoctor-loaded');
+ const hookFile = path.join(workspaceRoot, 'import-hook.mjs');
+ await writeFile(
+ hookFile,
+ `import { writeFileSync } from 'node:fs';
+import { registerHooks } from 'node:module';
+registerHooks({
+ resolve(specifier, context, nextResolve) {
+ if (specifier === '@rsdoctor/agent-cli') {
+ writeFileSync(process.env.RSTACK_RSDOCTOR_LOADED_MARKER, 'loaded');
+ }
+ return nextResolve(specifier, context);
+ },
+});
+`,
+ );
+ const moduleUrl = pathToFileURL(path.resolve('src/index.ts')).toString();
+ const result = spawnSync(
+ process.execPath,
+ [
+ '--import',
+ pathToFileURL(hookFile).href,
+ '--input-type=module',
+ '--eval',
+ `const { listRsdoctorToolNames } = await import(${JSON.stringify(moduleUrl)});
+listRsdoctorToolNames();`,
+ ],
+ {
+ encoding: 'utf8',
+ env: { ...process.env, RSTACK_RSDOCTOR_LOADED_MARKER: markerFile },
+ },
+ );
+
+ expect(result.stderr).toBe('');
+ expect(result.status).toBe(0);
+ await expect(
+ import('node:fs/promises').then(({ access }) => access(markerFile)),
+ ).rejects.toThrow();
+ });
+});
+
+test('rejects a missing Rsdoctor artifact', async () => {
+ await withTempWorkspace('rstack-rsdoctor-', async (workspaceRoot) => {
+ const artifactPath = path.resolve(workspaceRoot, validDataFile);
+ await expect(
+ analyzeRsdoctorArtifact(workspaceRoot, {
+ dataFile: validDataFile,
+ toolName: 'build_summary',
+ }),
+ ).rejects.toThrow(
+ `Rsdoctor data file could not be read at "${artifactPath}". Generate a brief JSON artifact by setting RSDOCTOR_OUTPUT=json for the build.`,
+ );
+ });
+});
+
+test('rejects malformed Rsdoctor artifact JSON', async () => {
+ await withTempWorkspace('rstack-rsdoctor-', async (workspaceRoot) => {
+ await writeArtifact(workspaceRoot, validDataFile, '{not-json');
+
+ await expect(
+ analyzeRsdoctorArtifact(workspaceRoot, {
+ dataFile: validDataFile,
+ toolName: 'build_summary',
+ }),
+ ).rejects.toThrow('valid JSON');
+ });
+});
+
+test('rejects a Rsdoctor artifact missing its data envelope', async () => {
+ await withTempWorkspace('rstack-rsdoctor-', async (workspaceRoot) => {
+ await writeArtifact(workspaceRoot, validDataFile, '{}');
+
+ await expect(
+ analyzeRsdoctorArtifact(workspaceRoot, {
+ dataFile: validDataFile,
+ toolName: 'build_summary',
+ }),
+ ).rejects.toThrow('object data field');
+ });
+});
+
+test('rejects a Rsdoctor artifact whose data envelope is not an object', async () => {
+ await withTempWorkspace('rstack-rsdoctor-', async (workspaceRoot) => {
+ await writeArtifact(workspaceRoot, validDataFile, '{"data":[]}');
+
+ await expect(
+ analyzeRsdoctorArtifact(workspaceRoot, {
+ dataFile: validDataFile,
+ toolName: 'build_summary',
+ }),
+ ).rejects.toThrow('object data field');
+ });
+});
+
+test('rejects an unknown Rsdoctor tool name', async () => {
+ await withTempWorkspace('rstack-rsdoctor-', async (workspaceRoot) => {
+ await writeArtifact(workspaceRoot, validDataFile, '{"data":{}}');
+
+ await expect(
+ analyzeRsdoctorArtifact(workspaceRoot, {
+ dataFile: validDataFile,
+ toolName: 'not_a_real_tool',
+ }),
+ ).rejects.toThrow('Unknown Rsdoctor tool');
+ });
+});
+
+test('rejects input that does not match the selected tool schema', async () => {
+ await withTempWorkspace('rstack-rsdoctor-', async (workspaceRoot) => {
+ await writeArtifact(workspaceRoot, validDataFile, '{"data":{}}');
+
+ await expect(
+ analyzeRsdoctorArtifact(workspaceRoot, {
+ dataFile: validDataFile,
+ input: { page: 0 },
+ toolName: 'build_summary',
+ }),
+ ).rejects.toThrow('does not match its schema');
+ });
+});
+
+test('runs a real Rsdoctor catalog tool against a valid artifact fixture', async () => {
+ await withTempWorkspace('rstack-rsdoctor-', async (workspaceRoot) => {
+ await writeArtifact(
+ workspaceRoot,
+ validDataFile,
+ JSON.stringify({ data: { summary: { costs: [{ costs: 12 }] } } }),
+ );
+
+ await expect(
+ analyzeRsdoctorArtifact(workspaceRoot, {
+ dataFile: validDataFile,
+ toolName: 'build_summary',
+ }),
+ ).resolves.toEqual({
+ dataFile: validDataFile,
+ result: {
+ data: { costs: [{ costs: 12 }], totalCost: 12 },
+ description: 'Get build summary with costs (build time analysis).',
+ ok: true,
+ },
+ sectionEvidence: [],
+ toolName: 'build_summary',
+ });
+ });
+});
+
+test('surfaces the underlying cause when the Rsdoctor executor fails', async () => {
+ await withTempWorkspace('rstack-rsdoctor-', async (workspaceRoot) => {
+ await writeArtifact(
+ workspaceRoot,
+ validDataFile,
+ JSON.stringify({ data: { summary: { costs: 'not-a-cost-list' } } }),
+ );
+
+ const failure = await analyzeRsdoctorArtifact(workspaceRoot, {
+ dataFile: validDataFile,
+ toolName: 'build_summary',
+ }).catch((error: unknown) => error);
+
+ expect(failure).toBeInstanceOf(Error);
+ const error = failure as Error;
+ expect(error.cause).toBeInstanceOf(Error);
+ expect(error.message).toBe(
+ `Rsdoctor analysis failed. Cause: ${(error.cause as Error).message}`,
+ );
+ expect(error.message.length).toBeGreaterThan('Rsdoctor analysis failed. Cause: '.length);
+ });
+});
+
+test('maps every Rsdoctor catalog tool to its required artifact sections', async () => {
+ const cases = [
+ ['build_summary', ['summary']],
+ ['bundle_optimize', ['chunkGraph', 'errors', 'packageGraph']],
+ ['chunks_list', ['chunkGraph']],
+ ['errors_list', ['errors']],
+ ['packages_direct_dependencies', ['packageGraph']],
+ ['packages_duplicates', ['errors']],
+ ['packages_similar', ['packageGraph']],
+ ['tree_shaking_retained_modules', ['chunkGraph', 'moduleGraph', 'packageGraph']],
+ ['tree_shaking_side_effects', ['moduleGraph']],
+ ['tree_shaking_summary', ['errors']],
+ ] as const;
+
+ await withTempWorkspace('rstack-rsdoctor-', async (workspaceRoot) => {
+ for (const [toolName, sections] of cases) {
+ const dataFile = `artifacts/${toolName}.json`;
+ await writeArtifact(
+ workspaceRoot,
+ dataFile,
+ JSON.stringify({ data: {}, metadata: createArtifactMetadata() }),
+ );
+
+ const analysis = await analyzeRsdoctorArtifact(workspaceRoot, {
+ dataFile,
+ toolName,
+ });
+
+ expect(analysis.sectionEvidence).toEqual(
+ sections.map((section) => ({ section, status: 'collected' })),
+ );
+ }
+ });
+});
+
+test('distinguishes collected empty data from an omitted artifact section', async () => {
+ await withTempWorkspace('rstack-rsdoctor-', async (workspaceRoot) => {
+ const collectedDataFile = 'artifacts/collected.json';
+ const omittedDataFile = 'artifacts/omitted.json';
+ await writeArtifact(
+ workspaceRoot,
+ collectedDataFile,
+ JSON.stringify({ data: {}, metadata: createArtifactMetadata() }),
+ );
+ await writeArtifact(
+ workspaceRoot,
+ omittedDataFile,
+ JSON.stringify({
+ data: {},
+ metadata: createArtifactMetadata({
+ summary: { status: 'omitted', reason: 'output-mode' },
+ }),
+ }),
+ );
+
+ const collected = await analyzeRsdoctorArtifact(workspaceRoot, {
+ dataFile: collectedDataFile,
+ toolName: 'build_summary',
+ });
+ const omitted = await analyzeRsdoctorArtifact(workspaceRoot, {
+ dataFile: omittedDataFile,
+ toolName: 'build_summary',
+ });
+
+ expect(collected.result).toMatchObject({ ok: true });
+ expect(collected.artifactMetadata).toEqual(createArtifactMetadata());
+ expect(omitted.result).toEqual({
+ ok: false,
+ error: {
+ code: 'RSDOCTOR_SECTION_UNAVAILABLE',
+ message: 'Rsdoctor artifact section "summary" is unavailable (output-mode).',
+ reason: 'output-mode',
+ section: 'summary',
+ status: 'omitted',
+ },
+ });
+ expect(collected.sectionEvidence).toEqual([{ section: 'summary', status: 'collected' }]);
+ expect(omitted.sectionEvidence).toEqual([
+ { reason: 'output-mode', section: 'summary', status: 'omitted' },
+ ]);
+ });
+});
diff --git a/tests/rsdoctorGraph.test.ts b/tests/rsdoctorGraph.test.ts
new file mode 100644
index 0000000..04d6a2c
--- /dev/null
+++ b/tests/rsdoctorGraph.test.ts
@@ -0,0 +1,255 @@
+import { mkdtemp, rm, writeFile } from 'node:fs/promises';
+import os from 'node:os';
+import path from 'node:path';
+import { expect, test } from '@rstest/core';
+import { readRsdoctorModuleGraph } from '../src/rsdoctorGraph.ts';
+
+const applicationWorkspace = path.resolve(
+ import.meta.dirname,
+ '../fixtures/context/reachability/application',
+);
+
+test('normalizes real and compatibility dependency rows without interpreting exports', async () => {
+ const graph = await readRsdoctorModuleGraph(applicationWorkspace, 'rsdoctor-data.json');
+
+ expect(
+ graph.modules.map(({ id, path: modulePath, name, chunks, isEntry, optimizerBound }) => ({
+ id,
+ path: modulePath,
+ name,
+ chunks,
+ isEntry,
+ optimizerBound,
+ })),
+ ).toEqual([
+ {
+ id: '8',
+ path: 'src/cjs.ts',
+ name: 'src/cjs.ts',
+ chunks: [],
+ isEntry: false,
+ optimizerBound: 'cjs',
+ },
+ {
+ id: '6',
+ path: 'src/cycle-a.ts',
+ name: 'cycle-a',
+ chunks: [],
+ isEntry: false,
+ optimizerBound: undefined,
+ },
+ {
+ id: '7',
+ path: 'src/cycle-b.ts',
+ name: 'cycle-b',
+ chunks: [],
+ isEntry: false,
+ optimizerBound: undefined,
+ },
+ {
+ id: '1',
+ path: 'src/index.ts',
+ name: './src/index.ts',
+ chunks: ['10', 'shared'],
+ isEntry: true,
+ optimizerBound: undefined,
+ },
+ {
+ id: '5',
+ path: 'src/lazy.ts',
+ name: './src/lazy.ts',
+ chunks: [],
+ isEntry: false,
+ optimizerBound: 'dynamic-import',
+ },
+ {
+ id: '3',
+ path: 'src/legacy.ts',
+ name: 'legacy',
+ chunks: [],
+ isEntry: false,
+ optimizerBound: undefined,
+ },
+ {
+ id: '2',
+ path: 'src/live.ts',
+ name: 'live',
+ chunks: ['10'],
+ isEntry: false,
+ optimizerBound: undefined,
+ },
+ {
+ id: '4',
+ path: 'src/polyfill.ts',
+ name: 'polyfill',
+ chunks: ['runtime'],
+ isEntry: false,
+ optimizerBound: 'side-effect',
+ },
+ ]);
+ expect(graph.edges).toEqual([
+ { from: '1', to: '2' },
+ { from: '2', to: '6' },
+ { from: '6', to: '7' },
+ { from: '7', to: '6' },
+ ]);
+ expect(graph.exportRowsPresent).toBe(true);
+ expect(graph.issues).toEqual(['duplicate-module-id', 'dangling-edge']);
+});
+
+test('maps an unclassified non-empty bailout to an unknown optimizer bound', async () => {
+ const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), 'rstack-rsdoctor-graph-'));
+ try {
+ await writeFile(
+ path.join(workspaceRoot, 'rsdoctor-data.json'),
+ JSON.stringify({
+ data: {
+ moduleGraph: {
+ modules: [
+ {
+ id: 'module',
+ name: 'module.ts',
+ bailoutReason: {
+ detail: 'optimizer could not classify this module',
+ },
+ },
+ ],
+ },
+ },
+ }),
+ );
+
+ const graph = await readRsdoctorModuleGraph(workspaceRoot, 'rsdoctor-data.json');
+
+ expect(graph.modules[0]?.optimizerBound).toBe('unknown-bailout');
+ expect(graph.modules[0]?.optimizerReasons).toEqual([
+ 'optimizer could not classify this module',
+ ]);
+ } finally {
+ await rm(workspaceRoot, { force: true, recursive: true });
+ }
+});
+
+test('inherits chunk membership from a concatenated module container', async () => {
+ const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), 'rstack-rsdoctor-graph-'));
+ try {
+ await writeFile(
+ path.join(workspaceRoot, 'rsdoctor-data.json'),
+ JSON.stringify({
+ data: {
+ moduleGraph: {
+ modules: [
+ {
+ id: 2,
+ path: '/workspace/src/index.ts',
+ webpackId: 'src/index.ts|hash',
+ chunks: ['1'],
+ isEntry: true,
+ modules: [0, 1],
+ },
+ {
+ id: 1,
+ path: '/workspace/src/index.ts',
+ webpackId: 'src/index.ts',
+ chunks: [],
+ isEntry: true,
+ bailoutReason: ['Statement with side_effects in source code'],
+ concatenationModules: [2],
+ },
+ {
+ id: 0,
+ path: '/workspace/src/index.css',
+ webpackId: 'src/index.css',
+ chunks: [],
+ concatenationModules: [2],
+ },
+ ],
+ dependencies: [],
+ },
+ },
+ }),
+ );
+
+ const graph = await readRsdoctorModuleGraph(workspaceRoot, 'rsdoctor-data.json');
+
+ expect(
+ graph.modules.map(({ id, chunks, optimizerBound, optimizerReasons }) => ({
+ id,
+ chunks,
+ optimizerBound,
+ optimizerReasons,
+ })),
+ ).toEqual([
+ { id: '0', chunks: ['1'], optimizerBound: undefined, optimizerReasons: undefined },
+ {
+ id: '1',
+ chunks: ['1'],
+ optimizerBound: 'side-effect',
+ optimizerReasons: ['Statement with side_effects in source code'],
+ },
+ { id: '2', chunks: ['1'], optimizerBound: undefined, optimizerReasons: undefined },
+ ]);
+ } finally {
+ await rm(workspaceRoot, { force: true, recursive: true });
+ }
+});
+
+test('connects module importers and concatenated children for reachability', async () => {
+ const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), 'rstack-rsdoctor-graph-'));
+ try {
+ await writeFile(
+ path.join(workspaceRoot, 'rsdoctor-data.json'),
+ JSON.stringify({
+ data: {
+ moduleGraph: {
+ modules: [
+ {
+ id: 1,
+ path: '/workspace/src/index.ts',
+ isEntry: true,
+ imported: [],
+ modules: [2],
+ },
+ {
+ id: 2,
+ path: '/workspace/src/feature.ts',
+ imported: [3],
+ },
+ {
+ id: 3,
+ path: '/workspace/src/consumer.ts',
+ imported: [],
+ },
+ ],
+ dependencies: [],
+ },
+ },
+ }),
+ );
+
+ const graph = await readRsdoctorModuleGraph(workspaceRoot, 'rsdoctor-data.json');
+
+ expect(graph.edges).toEqual([
+ { from: '1', to: '2' },
+ { from: '3', to: '2' },
+ ]);
+ } finally {
+ await rm(workspaceRoot, { force: true, recursive: true });
+ }
+});
+
+test('reports a missing module graph as insufficient ordinary artifact data', async () => {
+ const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), 'rstack-rsdoctor-graph-'));
+ try {
+ await writeFile(path.join(workspaceRoot, 'rsdoctor-data.json'), '{"data":{}}');
+
+ await expect(readRsdoctorModuleGraph(workspaceRoot, 'rsdoctor-data.json')).resolves.toEqual({
+ modules: [],
+ edges: [],
+ exportRowsPresent: false,
+ issues: ['module-graph-missing'],
+ });
+ } finally {
+ await rm(workspaceRoot, { force: true, recursive: true });
+ }
+});
diff --git a/tests/rstack.test.ts b/tests/rstack.test.ts
new file mode 100644
index 0000000..ae550fd
--- /dev/null
+++ b/tests/rstack.test.ts
@@ -0,0 +1,88 @@
+import { mkdir, writeFile } from 'node:fs/promises';
+import path from 'node:path';
+import type { ConfigParams, RsbuildConfig } from '@rsbuild/core';
+import { expect, test } from '@rstest/core';
+import { createRstackContextPlugin } from '../src/rstack.ts';
+import { withTempWorkspace as withSharedTempWorkspace } from './helpers.ts';
+
+type BuildConfig = { marker: string; plugins?: RsbuildConfig['plugins'] };
+type Modifier = (
+ config: BuildConfig,
+ context: { params: ConfigParams },
+) => BuildConfig | Promise;
+
+const withTempWorkspace = async (
+ callback: (workspaceRoot: string) => Promise,
+): Promise =>
+ withSharedTempWorkspace('rstack-context-plugin-', async (workspaceRoot) => {
+ await writeFile(
+ path.join(workspaceRoot, 'package.json'),
+ JSON.stringify({ name: 'context-fixture', private: true }),
+ );
+ await callback(workspaceRoot);
+ });
+
+const setupPlugin = (
+ plugin: ReturnType,
+): Map<'app' | 'lib', Modifier> => {
+ const modifiers = new Map<'app' | 'lib', Modifier>();
+ plugin.setup({
+ modifyConfig(kind, handler) {
+ modifiers.set(kind, handler as Modifier);
+ },
+ });
+ return modifiers;
+};
+
+test('registers no build modifiers when Context capture is off', () => {
+ const plugin = createRstackContextPlugin({
+ config: { capture: 'off' },
+ configDependencies: [],
+ configFilePath: null,
+ cwd: '/workspace',
+ });
+
+ expect(plugin.name).toBe('rstack:context');
+ expect(setupPlugin(plugin).size).toBe(0);
+});
+
+test('appends independent application and library observers without mutating config', async () => {
+ await withTempWorkspace(async (workspaceRoot) => {
+ const configFilePath = path.join(workspaceRoot, 'rstack.config.ts');
+ const dependencyPath = path.join(workspaceRoot, 'config', 'shared.ts');
+ await mkdir(path.dirname(dependencyPath), { recursive: true });
+ await writeFile(configFilePath, 'export {}');
+ await writeFile(dependencyPath, 'export {}');
+
+ const plugin = createRstackContextPlugin({
+ config: { enabled: true, variant: 'fixture' },
+ configDependencies: [dependencyPath],
+ configFilePath,
+ cwd: workspaceRoot,
+ });
+ const modifiers = setupPlugin(plugin);
+ const existingPlugin = { name: 'existing', setup() {} };
+ const config: BuildConfig = { marker: 'preserved', plugins: [existingPlugin] };
+ const params = {
+ command: 'build',
+ env: 'production',
+ envMode: 'production',
+ } as ConfigParams;
+
+ const application = await modifiers.get('app')!(config, { params });
+ const library = await modifiers.get('lib')!(config, { params });
+
+ expect([...modifiers.keys()]).toEqual(['app', 'lib']);
+ expect(config.plugins).toEqual([existingPlugin]);
+ expect(application).not.toBe(config);
+ expect(library).not.toBe(config);
+ expect(application).toMatchObject({ marker: 'preserved' });
+ expect(library).toMatchObject({ marker: 'preserved' });
+ expect(
+ application.plugins?.map((entry) => (entry && 'name' in entry ? entry.name : entry)),
+ ).toEqual(['existing', 'rstack:context-build']);
+ expect(
+ library.plugins?.map((entry) => (entry && 'name' in entry ? entry.name : entry)),
+ ).toEqual(['existing', 'rstack:context-build']);
+ });
+});
diff --git a/tests/source.test.ts b/tests/source.test.ts
new file mode 100644
index 0000000..5299db4
--- /dev/null
+++ b/tests/source.test.ts
@@ -0,0 +1,233 @@
+/* rslint-disable @typescript-eslint/no-unsafe-assignment -- Rstest asymmetric matchers are intentionally untyped. */
+import { mkdir, unlink, writeFile } from 'node:fs/promises';
+import path from 'node:path';
+import { expect, test } from '@rstest/core';
+import { contextStoreSchemaVersion, type ContextSnapshot } from '../src/model.ts';
+import { validateRunManifest } from '../src/records.ts';
+import {
+ assessSnapshotFreshness,
+ collectContextInputFiles,
+ createExplicitContextDescriptor,
+ createExplicitRun,
+ recordContextInputFiles,
+ resolveExplicitCaptureTarget,
+ resolveInternalConfigPath,
+} from '../src/source.ts';
+import { withTempWorkspace } from './helpers.ts';
+
+const createSnapshot = (source?: ContextSnapshot['source']): ContextSnapshot => ({
+ schemaVersion: contextStoreSchemaVersion,
+ snapshotId: 'snap_source',
+ runId: 'run_source',
+ contextId: 'ctx_source',
+ sequence: 0,
+ observedAt: '2026-08-12T08:00:00.000Z',
+ status: 'pass',
+ completeness: {},
+ facets: {},
+ ...(source === undefined ? {} : { source }),
+});
+
+test('records sorted SHA-256 inputs and reports complete inputs as fresh', async () => {
+ await withTempWorkspace('rstack-context-source-', async (workspaceRoot) => {
+ await mkdir(path.join(workspaceRoot, 'src'));
+ await writeFile(path.join(workspaceRoot, 'src', 'b.ts'), 'b');
+ await writeFile(path.join(workspaceRoot, 'src', 'a.ts'), 'a');
+
+ const inputs = await recordContextInputFiles(workspaceRoot, [
+ path.join(workspaceRoot, 'src', 'b.ts'),
+ path.join(workspaceRoot, 'src', 'a.ts'),
+ ]);
+
+ expect(inputs).toEqual([
+ {
+ path: 'src/a.ts',
+ digest: 'ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb',
+ },
+ {
+ path: 'src/b.ts',
+ digest: '3e23e8160039594a33894f6564e1b1348bbd7a0088d42c4acb73eeaed59c009d',
+ },
+ ]);
+ await expect(
+ assessSnapshotFreshness(
+ workspaceRoot,
+ createSnapshot({ inputs, inputCompleteness: 'complete' }),
+ ),
+ ).resolves.toEqual({ state: 'fresh', changedPaths: [] });
+ });
+});
+
+test('reports unreadable inputs instead of throwing while recording digests', async () => {
+ await withTempWorkspace('rstack-context-source-', async (workspaceRoot) => {
+ await mkdir(path.join(workspaceRoot, 'src'));
+ await writeFile(path.join(workspaceRoot, 'src', 'present.ts'), 'a');
+
+ await expect(
+ collectContextInputFiles(workspaceRoot, [
+ 'src/missing.ts',
+ 'src/present.ts',
+ 'src/absent.ts',
+ ]),
+ ).resolves.toEqual({
+ inputs: [
+ {
+ path: 'src/present.ts',
+ digest: 'ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb',
+ },
+ ],
+ unreadablePaths: ['src/absent.ts', 'src/missing.ts'],
+ });
+ await expect(
+ recordContextInputFiles(workspaceRoot, ['src/missing.ts', 'src/present.ts']),
+ ).resolves.toEqual([
+ {
+ path: 'src/present.ts',
+ digest: 'ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb',
+ },
+ ]);
+ });
+});
+
+test('rejects capture targets that escape the checkout', async () => {
+ await withTempWorkspace('rstack-context-source-', async (workspaceRoot) => {
+ const containment =
+ 'must be a non-empty checkout-relative path that stays inside the checkout.';
+
+ await expect(
+ resolveExplicitCaptureTarget(workspaceRoot, { packageRoot: '../../..' }),
+ ).rejects.toThrow(`packageRoot ${containment}`);
+ await expect(
+ resolveExplicitCaptureTarget(workspaceRoot, { packageRoot: path.join(workspaceRoot, 'src') }),
+ ).rejects.toThrow(`packageRoot ${containment}`);
+ await expect(
+ resolveExplicitCaptureTarget(workspaceRoot, { configPath: '../../../evil.config.ts' }),
+ ).rejects.toThrow(`configPath ${containment}`);
+ await expect(
+ resolveExplicitCaptureTarget(workspaceRoot, {
+ configPath: path.join(path.sep, 'etc', 'evil.config.ts'),
+ }),
+ ).rejects.toThrow(`configPath ${containment}`);
+ await expect(
+ resolveExplicitCaptureTarget(workspaceRoot, { packageRoot: 'packages/../packages/app' }),
+ ).resolves.toEqual({ packageRoot: path.join(workspaceRoot, 'packages', 'app') });
+ });
+});
+
+test('refuses to hand producers a bundled wrapper config that is absent', () => {
+ expect(() => resolveInternalConfigPath(import.meta.dirname, 'rstestConfig.js')).toThrow(
+ 'The bundled wrapper config "rstestConfig.js" is not present',
+ );
+ expect(resolveInternalConfigPath(import.meta.dirname, 'helpers.ts')).toBe(
+ path.join(import.meta.dirname, 'helpers.ts'),
+ );
+});
+
+test('reports changed and missing inputs as stale in lexical order', async () => {
+ await withTempWorkspace('rstack-context-source-', async (workspaceRoot) => {
+ await mkdir(path.join(workspaceRoot, 'src'));
+ await writeFile(path.join(workspaceRoot, 'src', 'b.ts'), 'b');
+ await writeFile(path.join(workspaceRoot, 'src', 'a.ts'), 'a');
+ const inputs = await recordContextInputFiles(workspaceRoot, ['src/b.ts', 'src/a.ts']);
+
+ await writeFile(path.join(workspaceRoot, 'src', 'b.ts'), 'changed');
+ await unlink(path.join(workspaceRoot, 'src', 'a.ts'));
+
+ await expect(
+ assessSnapshotFreshness(
+ workspaceRoot,
+ createSnapshot({ inputs, inputCompleteness: 'complete' }),
+ ),
+ ).resolves.toEqual({
+ state: 'stale',
+ changedPaths: ['src/a.ts', 'src/b.ts'],
+ });
+ });
+});
+
+test('preserves partial and unknown freshness semantics', async () => {
+ await withTempWorkspace('rstack-context-source-', async (workspaceRoot) => {
+ await writeFile(path.join(workspaceRoot, 'test.ts'), 'test');
+ const inputs = await recordContextInputFiles(workspaceRoot, ['test.ts']);
+
+ await expect(
+ assessSnapshotFreshness(
+ workspaceRoot,
+ createSnapshot({ inputs, inputCompleteness: 'partial' }),
+ ),
+ ).resolves.toEqual({ state: 'partial', changedPaths: [] });
+ await expect(
+ assessSnapshotFreshness(
+ workspaceRoot,
+ createSnapshot({
+ inputs,
+ inputCompleteness: 'complete',
+ virtualInputDigest: 'f'.repeat(64),
+ }),
+ ),
+ ).resolves.toEqual({ state: 'unknown', changedPaths: [] });
+ await expect(assessSnapshotFreshness(workspaceRoot, createSnapshot())).resolves.toEqual({
+ state: 'unknown',
+ changedPaths: [],
+ });
+ });
+});
+
+test('creates stable explicit contexts and unique testable runs', () => {
+ const workspaceRoot = path.join(path.sep, 'workspace');
+ const options = {
+ producer: 'rslint' as const,
+ workspaceRoot,
+ packageRoot: path.join(workspaceRoot, 'packages', 'library'),
+ packageName: '@repo/library',
+ configPath: path.join(workspaceRoot, 'packages', 'library', 'rslint.config.ts'),
+ };
+ const context = createExplicitContextDescriptor(options);
+
+ expect(context).toEqual({
+ contextId: expect.stringMatching(/^ctx_[0-9a-f]{24}$/u),
+ packageRoot: 'packages/library',
+ packageName: '@repo/library',
+ configPath: 'packages/library/rslint.config.ts',
+ product: 'development',
+ environment: 'lint',
+ });
+ expect(createExplicitContextDescriptor(options)).toEqual(context);
+ expect(createExplicitContextDescriptor({ ...options, producer: 'rstest' })).not.toEqual(context);
+ expect(
+ createExplicitRun({
+ producer: 'rslint',
+ context,
+ command: 'lint',
+ createRunId: () => 'run_explicit',
+ now: () => new Date('2026-08-12T08:00:00.000Z'),
+ }),
+ ).toEqual({
+ schemaVersion: contextStoreSchemaVersion,
+ runId: 'run_explicit',
+ producer: 'rslint',
+ command: 'lint',
+ startedAt: '2026-08-12T08:00:00.000Z',
+ contexts: [context],
+ });
+});
+
+test('represents the workspace root as a valid package path', () => {
+ const workspaceRoot = path.join(path.sep, 'workspace');
+ const context = createExplicitContextDescriptor({
+ producer: 'rstest',
+ workspaceRoot,
+ packageRoot: workspaceRoot,
+ configPath: path.join(workspaceRoot, 'rstest.config.ts'),
+ });
+ const run = createExplicitRun({
+ producer: 'rstest',
+ context,
+ command: 'test',
+ createRunId: () => 'run_root',
+ now: () => new Date('2026-08-12T08:00:00.000Z'),
+ });
+
+ expect(context.packageRoot).toBe('.');
+ expect(validateRunManifest(run)).toEqual(run);
+});
diff --git a/tests/status.test.ts b/tests/status.test.ts
new file mode 100644
index 0000000..7031958
--- /dev/null
+++ b/tests/status.test.ts
@@ -0,0 +1,279 @@
+/* rslint-disable @typescript-eslint/no-unsafe-assignment -- Rstest asymmetric matchers are intentionally untyped. */
+import { mkdir } from 'node:fs/promises';
+import path from 'node:path';
+import { expect, test } from '@rstest/core';
+import {
+ contextStoreSchemaVersion,
+ readProjectStatus,
+ writeContextRunManifest,
+ writeContextSnapshot,
+ type ContextDescriptor,
+ type ContextRunManifest,
+ type ContextSnapshot,
+} from '../src/index.ts';
+import { withTempWorkspace } from './helpers.ts';
+
+const createRun = (
+ runId: string,
+ producer: ContextRunManifest['producer'],
+ startedAt: string,
+ context: ContextDescriptor,
+): ContextRunManifest => ({
+ schemaVersion: contextStoreSchemaVersion,
+ runId,
+ producer,
+ command: 'build',
+ startedAt,
+ contexts: [context],
+});
+
+test('returns a stable anonymous status for an empty standalone store', async () => {
+ await withTempWorkspace('rstack-context-status-', async (workspaceRoot) => {
+ const status = await readProjectStatus(workspaceRoot);
+
+ expect(status).toEqual({
+ schemaVersion: contextStoreSchemaVersion,
+ workspaceId: expect.stringMatching(/^ws_[0-9a-f]{24}$/u),
+ contexts: [],
+ issues: [],
+ });
+ expect(JSON.stringify(status)).not.toContain(workspaceRoot);
+ });
+});
+
+test('keeps the newest completed snapshot when a later run recorded none', async () => {
+ await withTempWorkspace('rstack-context-status-', async (workspaceRoot) => {
+ await mkdir(path.join(workspaceRoot, 'packages', 'app'), { recursive: true });
+ const appContext = {
+ contextId: 'ctx_app',
+ packageRoot: 'packages/app',
+ product: 'application',
+ environment: 'web',
+ } as const;
+ const goodRun = createRun('run_good', 'rsbuild', '2026-08-12T04:00:00.000Z', appContext);
+ const abortedRun = createRun('run_aborted', 'rsbuild', '2026-08-12T06:00:00.000Z', appContext);
+ const goodSnapshot = {
+ schemaVersion: contextStoreSchemaVersion,
+ snapshotId: 'snap_good',
+ runId: goodRun.runId,
+ contextId: appContext.contextId,
+ sequence: 1,
+ observedAt: '2026-08-12T04:00:01.000Z',
+ status: 'pass',
+ completeness: { build: 'complete' },
+ facets: { summary: { errors: 0 } },
+ } satisfies ContextSnapshot;
+
+ expect(await writeContextRunManifest(workspaceRoot, goodRun)).toMatchObject({ written: true });
+ expect(await writeContextSnapshot(workspaceRoot, goodSnapshot)).toMatchObject({
+ written: true,
+ });
+ // An aborted build writes its run manifest at onBeforeBuild and never publishes a snapshot.
+ expect(await writeContextRunManifest(workspaceRoot, abortedRun)).toMatchObject({
+ written: true,
+ });
+
+ const masked = await readProjectStatus(workspaceRoot);
+
+ expect(masked.contexts).toEqual([
+ {
+ runId: abortedRun.runId,
+ producer: abortedRun.producer,
+ context: appContext,
+ state: 'ready',
+ latestSnapshot: goodSnapshot,
+ freshness: { state: 'unknown', changedPaths: [] },
+ },
+ ]);
+
+ const laterRun = createRun('run_later', 'rsbuild', '2026-08-12T07:00:00.000Z', appContext);
+ const laterSnapshot = {
+ ...goodSnapshot,
+ snapshotId: 'snap_later',
+ runId: laterRun.runId,
+ observedAt: '2026-08-12T07:00:01.000Z',
+ } satisfies ContextSnapshot;
+ expect(await writeContextRunManifest(workspaceRoot, laterRun)).toMatchObject({ written: true });
+ expect(await writeContextSnapshot(workspaceRoot, laterSnapshot)).toMatchObject({
+ written: true,
+ });
+
+ const refreshed = await readProjectStatus(workspaceRoot);
+
+ expect(refreshed.contexts).toEqual([
+ {
+ runId: laterRun.runId,
+ producer: laterRun.producer,
+ context: appContext,
+ state: 'ready',
+ latestSnapshot: laterSnapshot,
+ freshness: { state: 'unknown', changedPaths: [] },
+ },
+ ]);
+ });
+});
+
+test('keeps complete status evidence while exposing a newer incomplete attempt', async () => {
+ await withTempWorkspace('rstack-context-status-', async (workspaceRoot) => {
+ const context = {
+ contextId: 'ctx_test',
+ packageRoot: '.',
+ product: 'development',
+ environment: 'test',
+ } as const;
+ const completeRun = createRun('run_complete', 'rstest', '2026-08-12T04:00:00.000Z', context);
+ const errorRun = createRun('run_error', 'rstest', '2026-08-12T05:00:00.000Z', context);
+ const completeSnapshot = {
+ schemaVersion: contextStoreSchemaVersion,
+ snapshotId: 'snap_complete',
+ runId: completeRun.runId,
+ contextId: context.contextId,
+ sequence: 0,
+ observedAt: '2026-08-12T04:00:01.000Z',
+ status: 'pass',
+ completeness: { test: 'complete' },
+ facets: { summary: { tests: 1, failedTests: 0 } },
+ } satisfies ContextSnapshot;
+ const errorSnapshot = {
+ ...completeSnapshot,
+ snapshotId: 'snap_error',
+ runId: errorRun.runId,
+ observedAt: '2026-08-12T05:00:01.000Z',
+ status: 'error',
+ completeness: { test: 'partial', source: 'partial' },
+ facets: { summary: { tests: 1, failedTests: 0, errors: 1 } },
+ source: {
+ inputs: [],
+ inputCompleteness: 'partial',
+ unreadableInputs: ['src/missing.ts'],
+ },
+ } satisfies ContextSnapshot;
+
+ expect(await writeContextRunManifest(workspaceRoot, completeRun)).toMatchObject({
+ written: true,
+ });
+ expect(await writeContextSnapshot(workspaceRoot, completeSnapshot)).toMatchObject({
+ written: true,
+ });
+ expect(await writeContextRunManifest(workspaceRoot, errorRun)).toMatchObject({ written: true });
+ expect(await writeContextSnapshot(workspaceRoot, errorSnapshot)).toMatchObject({
+ written: true,
+ });
+
+ await expect(readProjectStatus(workspaceRoot)).resolves.toMatchObject({
+ contexts: [
+ {
+ runId: errorRun.runId,
+ producer: 'rstest',
+ context,
+ state: 'ready',
+ latestSnapshot: completeSnapshot,
+ latestAttempt: errorSnapshot,
+ freshness: { state: 'unknown', changedPaths: [] },
+ },
+ ],
+ issues: [],
+ });
+ });
+});
+
+test('projects only the latest run for each context in deterministic order', async () => {
+ await withTempWorkspace('rstack-context-status-', async (workspaceRoot) => {
+ await mkdir(path.join(workspaceRoot, 'packages', 'app'), {
+ recursive: true,
+ });
+ await mkdir(path.join(workspaceRoot, 'packages', 'library'), {
+ recursive: true,
+ });
+
+ const appContext = {
+ contextId: 'ctx_app',
+ packageRoot: 'packages/app',
+ product: 'application',
+ environment: 'web',
+ } as const;
+ const libraryContext = {
+ contextId: 'ctx_library',
+ packageRoot: 'packages/library',
+ product: 'library',
+ environment: 'esm',
+ } as const;
+ const firstAppRun = createRun('run_app_a', 'rsbuild', '2026-08-12T04:00:00.000Z', appContext);
+ const secondAppRun = createRun('run_app_b', 'rsbuild', '2026-08-12T06:00:00.000Z', appContext);
+ const firstLibraryRun = createRun(
+ 'run_library_a',
+ 'rslib',
+ '2026-08-12T05:00:00.000Z',
+ libraryContext,
+ );
+ const secondLibraryRun = createRun(
+ 'run_library_b',
+ 'rslib',
+ '2026-08-12T05:00:00.000Z',
+ libraryContext,
+ );
+ const appSnapshot = {
+ schemaVersion: contextStoreSchemaVersion,
+ snapshotId: 'snap_app_a',
+ runId: firstAppRun.runId,
+ contextId: appContext.contextId,
+ sequence: 1,
+ observedAt: '2026-08-12T04:00:01.000Z',
+ status: 'pass',
+ completeness: { build: 'complete' },
+ facets: { summary: { errors: 0 } },
+ } satisfies ContextSnapshot;
+ const librarySnapshot = {
+ schemaVersion: contextStoreSchemaVersion,
+ snapshotId: 'snap_library_b',
+ runId: secondLibraryRun.runId,
+ contextId: libraryContext.contextId,
+ sequence: 1,
+ observedAt: '2026-08-12T05:00:01.000Z',
+ status: 'pass',
+ completeness: { build: 'complete' },
+ facets: { summary: { errors: 0 } },
+ } satisfies ContextSnapshot;
+
+ expect(await writeContextRunManifest(workspaceRoot, firstAppRun)).toMatchObject({
+ written: true,
+ });
+ expect(await writeContextRunManifest(workspaceRoot, firstLibraryRun)).toMatchObject({
+ written: true,
+ });
+ expect(await writeContextRunManifest(workspaceRoot, secondLibraryRun)).toMatchObject({
+ written: true,
+ });
+ expect(await writeContextRunManifest(workspaceRoot, secondAppRun)).toMatchObject({
+ written: true,
+ });
+ expect(await writeContextSnapshot(workspaceRoot, appSnapshot)).toMatchObject({
+ written: true,
+ });
+ expect(await writeContextSnapshot(workspaceRoot, librarySnapshot)).toMatchObject({
+ written: true,
+ });
+
+ const status = await readProjectStatus(workspaceRoot);
+
+ expect(status.contexts).toEqual([
+ {
+ runId: secondAppRun.runId,
+ producer: secondAppRun.producer,
+ context: appContext,
+ state: 'ready',
+ latestSnapshot: appSnapshot,
+ freshness: { state: 'unknown', changedPaths: [] },
+ },
+ {
+ runId: secondLibraryRun.runId,
+ producer: secondLibraryRun.producer,
+ context: libraryContext,
+ state: 'ready',
+ latestSnapshot: librarySnapshot,
+ freshness: { state: 'unknown', changedPaths: [] },
+ },
+ ]);
+ expect(JSON.stringify(status)).not.toContain(workspaceRoot);
+ });
+});
diff --git a/tests/store.test.ts b/tests/store.test.ts
new file mode 100644
index 0000000..d55e8b7
--- /dev/null
+++ b/tests/store.test.ts
@@ -0,0 +1,396 @@
+import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
+import path from 'node:path';
+import { expect, test } from '@rstest/core';
+import {
+ contextStoreSchemaVersion,
+ readContextWorkspaceStatus,
+ writeContextRunManifest,
+ writeContextSnapshot,
+ type ContextRunManifest,
+ type ContextSnapshot,
+} from '../src/index.ts';
+import { readContextSnapshotById, readContextSnapshots } from '../src/store.ts';
+import { withTempWorkspace } from './helpers.ts';
+
+const context = {
+ contextId: 'ctx_library_esm',
+ packageName: '@repo/library',
+ packageRoot: 'packages/library',
+ configPath: 'packages/library/rstack.config.ts',
+ product: 'library',
+ environment: 'esm',
+} as const;
+
+const run: ContextRunManifest = {
+ schemaVersion: contextStoreSchemaVersion,
+ runId: 'run_library_watch',
+ producer: 'rslib',
+ command: 'build:watch',
+ startedAt: '2026-08-12T05:00:00.000Z',
+ contexts: [context],
+};
+
+const firstSnapshot: ContextSnapshot = {
+ schemaVersion: contextStoreSchemaVersion,
+ snapshotId: 'snap_library_1',
+ runId: run.runId,
+ contextId: context.contextId,
+ sequence: 1,
+ observedAt: '2026-08-12T05:00:01.000Z',
+ status: 'pass',
+ completeness: { build: 'complete' },
+ facets: { summary: { errors: 0, warnings: 0 } },
+};
+
+const secondSnapshot: ContextSnapshot = {
+ ...firstSnapshot,
+ snapshotId: 'snap_library_2',
+ sequence: 2,
+ observedAt: '2026-08-12T05:00:02.000Z',
+ facets: { summary: { errors: 0, warnings: 1 } },
+};
+
+test('publishes immutable run snapshots and reads the latest context state', async () => {
+ await withTempWorkspace('rstack-context-store-', async (workspaceRoot) => {
+ expect(await writeContextRunManifest(workspaceRoot, run)).toMatchObject({
+ written: true,
+ });
+ expect(await writeContextSnapshot(workspaceRoot, firstSnapshot)).toMatchObject({
+ written: true,
+ });
+ expect(await writeContextSnapshot(workspaceRoot, secondSnapshot)).toMatchObject({
+ written: true,
+ });
+
+ await expect(readContextWorkspaceStatus(workspaceRoot)).resolves.toEqual({
+ schemaVersion: contextStoreSchemaVersion,
+ runs: [
+ {
+ run,
+ contexts: [{ context, latestSnapshot: secondSnapshot }],
+ },
+ ],
+ issues: [],
+ });
+
+ const cacheRoot = path.join(workspaceRoot, '.rstack', 'cache');
+ expect(await readFile(path.join(cacheRoot, '.gitignore'), 'utf8')).toBe('*\n');
+ expect(
+ (await readdir(path.join(cacheRoot, 'context-v1'), { recursive: true })).sort(),
+ ).not.toEqual(expect.arrayContaining([expect.stringMatching(/\.tmp$/u)]));
+ });
+});
+
+test('does not replace an immutable snapshot record', async () => {
+ await withTempWorkspace('rstack-context-store-', async (workspaceRoot) => {
+ await writeContextRunManifest(workspaceRoot, run);
+ expect(await writeContextSnapshot(workspaceRoot, firstSnapshot)).toMatchObject({
+ written: true,
+ });
+
+ const replacement = {
+ ...firstSnapshot,
+ facets: { summary: { errors: 42 } },
+ } satisfies ContextSnapshot;
+ expect(await writeContextSnapshot(workspaceRoot, replacement)).toMatchObject({
+ written: false,
+ });
+
+ const status = await readContextWorkspaceStatus(workspaceRoot);
+ expect(status.runs[0]?.contexts[0]?.latestSnapshot).toEqual(firstSnapshot);
+ });
+});
+
+test('lists every immutable snapshot newest-first and filters or finds exact records', async () => {
+ await withTempWorkspace('rstack-context-store-', async (workspaceRoot) => {
+ const newerRun = {
+ ...run,
+ runId: 'run_library_lint',
+ producer: 'rslint',
+ command: 'lint',
+ startedAt: '2026-08-12T06:00:00.000Z',
+ } satisfies ContextRunManifest;
+ const older = {
+ ...firstSnapshot,
+ snapshotId: 'snap_older',
+ } satisfies ContextSnapshot;
+ const latestInFirstRun = {
+ ...secondSnapshot,
+ snapshotId: 'snap_latest_in_first_run',
+ observedAt: '2026-08-12T07:00:00.000Z',
+ } satisfies ContextSnapshot;
+ const newerRunSnapshot = {
+ ...firstSnapshot,
+ runId: newerRun.runId,
+ snapshotId: 'snap_newer_run',
+ observedAt: '2026-08-12T07:00:00.000Z',
+ } satisfies ContextSnapshot;
+ const pendingSnapshot = {
+ ...secondSnapshot,
+ snapshotId: 'snap_pending',
+ sequence: 3,
+ observedAt: '2026-08-12T08:00:00.000Z',
+ status: 'running',
+ } satisfies ContextSnapshot;
+
+ await writeContextRunManifest(workspaceRoot, run);
+ await writeContextRunManifest(workspaceRoot, newerRun);
+ await writeContextSnapshot(workspaceRoot, older);
+ await writeContextSnapshot(workspaceRoot, latestInFirstRun);
+ await writeContextSnapshot(workspaceRoot, newerRunSnapshot);
+ await writeContextSnapshot(workspaceRoot, pendingSnapshot);
+
+ const snapshots = await readContextSnapshots(workspaceRoot);
+ expect(snapshots).toEqual([
+ { run: newerRun, context, snapshot: newerRunSnapshot },
+ { run, context, snapshot: latestInFirstRun },
+ { run, context, snapshot: older },
+ ]);
+ await expect(
+ readContextSnapshots(workspaceRoot, {
+ producer: 'rslint',
+ contextId: context.contextId,
+ }),
+ ).resolves.toEqual([{ run: newerRun, context, snapshot: newerRunSnapshot }]);
+ await expect(readContextSnapshotById(workspaceRoot, older.snapshotId)).resolves.toEqual({
+ run,
+ context,
+ snapshot: older,
+ });
+ await expect(
+ readContextSnapshotById(workspaceRoot, pendingSnapshot.snapshotId),
+ ).resolves.toBeUndefined();
+ await expect(readContextSnapshotById(workspaceRoot, 'snap_missing')).resolves.toBeUndefined();
+ });
+});
+
+test('reports malformed completed records and ignores temporary files', async () => {
+ await withTempWorkspace('rstack-context-store-', async (workspaceRoot) => {
+ await writeContextRunManifest(workspaceRoot, run);
+ await writeContextSnapshot(workspaceRoot, firstSnapshot);
+ const generationRoot = path.join(
+ workspaceRoot,
+ '.rstack',
+ 'cache',
+ 'context-v1',
+ 'runs',
+ run.runId,
+ 'contexts',
+ context.contextId,
+ 'generations',
+ );
+ await mkdir(generationRoot, { recursive: true });
+ await writeFile(path.join(generationRoot, '0000000002-broken.json'), '{broken');
+ await writeFile(path.join(generationRoot, '.pending.tmp'), '{broken');
+
+ const status = await readContextWorkspaceStatus(workspaceRoot);
+ expect(status.issues).toEqual([
+ {
+ code: 'invalid-record',
+ path: path.posix.join(
+ 'runs',
+ run.runId,
+ 'contexts',
+ context.contextId,
+ 'generations',
+ '0000000002-broken.json',
+ ),
+ },
+ ]);
+ expect(status.runs[0]?.contexts[0]?.latestSnapshot).toEqual(firstSnapshot);
+ });
+});
+
+test('uses the same manifest validation when writing and reading', async () => {
+ await withTempWorkspace('rstack-context-store-', async (workspaceRoot) => {
+ const invalidRun = {
+ ...run,
+ contexts: [context, { ...context, packageRoot: 'packages/other' }],
+ };
+ expect(
+ await writeContextRunManifest(workspaceRoot, invalidRun as ContextRunManifest),
+ ).toMatchObject({ written: false });
+
+ const runRoot = path.join(workspaceRoot, '.rstack', 'cache', 'context-v1', 'runs', run.runId);
+ await mkdir(runRoot, { recursive: true });
+ await writeFile(path.join(runRoot, 'run.json'), JSON.stringify(invalidRun));
+
+ const status = await readContextWorkspaceStatus(workspaceRoot);
+ expect(status.issues).toEqual([
+ {
+ code: 'invalid-record',
+ path: path.posix.join('runs', run.runId, 'run.json'),
+ },
+ ]);
+ });
+});
+
+test('uses the same snapshot validation when writing and reading', async () => {
+ await withTempWorkspace('rstack-context-store-', async (workspaceRoot) => {
+ await writeContextRunManifest(workspaceRoot, run);
+ const invalidSnapshot = { ...firstSnapshot, status: 'unknown' };
+ expect(
+ await writeContextSnapshot(workspaceRoot, invalidSnapshot as ContextSnapshot),
+ ).toMatchObject({ written: false });
+
+ const generationRoot = path.join(
+ workspaceRoot,
+ '.rstack',
+ 'cache',
+ 'context-v1',
+ 'runs',
+ run.runId,
+ 'contexts',
+ context.contextId,
+ 'generations',
+ );
+ const fileName = '0000000001-snap_library_1.json';
+ await mkdir(generationRoot, { recursive: true });
+ await writeFile(path.join(generationRoot, fileName), JSON.stringify(invalidSnapshot));
+
+ const status = await readContextWorkspaceStatus(workspaceRoot);
+ expect(status.issues).toEqual([
+ {
+ code: 'invalid-record',
+ path: path.posix.join(
+ 'runs',
+ run.runId,
+ 'contexts',
+ context.contextId,
+ 'generations',
+ fileName,
+ ),
+ },
+ ]);
+ });
+});
+
+test('rejects snapshot records stored under a non-canonical generation name', async () => {
+ await withTempWorkspace('rstack-context-store-', async (workspaceRoot) => {
+ await writeContextRunManifest(workspaceRoot, run);
+ const generationRoot = path.join(
+ workspaceRoot,
+ '.rstack',
+ 'cache',
+ 'context-v1',
+ 'runs',
+ run.runId,
+ 'contexts',
+ context.contextId,
+ 'generations',
+ );
+ const fileName = '0000000009-snap_library_1.json';
+ await mkdir(generationRoot, { recursive: true });
+ await writeFile(path.join(generationRoot, fileName), JSON.stringify(firstSnapshot));
+
+ const status = await readContextWorkspaceStatus(workspaceRoot);
+ expect(status.issues).toEqual([
+ {
+ code: 'invalid-record',
+ path: path.posix.join(
+ 'runs',
+ run.runId,
+ 'contexts',
+ context.contextId,
+ 'generations',
+ fileName,
+ ),
+ },
+ ]);
+ expect(status.runs[0]?.contexts[0]?.latestSnapshot).toBeUndefined();
+ });
+});
+
+test('stops reading generations after the newest valid snapshot', async () => {
+ await withTempWorkspace('rstack-context-store-', async (workspaceRoot) => {
+ await writeContextRunManifest(workspaceRoot, run);
+ const latestSnapshot = {
+ ...secondSnapshot,
+ snapshotId: 'snap_library_4',
+ sequence: 4,
+ } satisfies ContextSnapshot;
+ await writeContextSnapshot(workspaceRoot, {
+ ...secondSnapshot,
+ snapshotId: 'snap_library_2',
+ sequence: 2,
+ });
+ await writeContextSnapshot(workspaceRoot, {
+ ...secondSnapshot,
+ snapshotId: 'snap_library_3',
+ sequence: 3,
+ });
+ await writeContextSnapshot(workspaceRoot, latestSnapshot);
+
+ const generationRoot = path.join(
+ workspaceRoot,
+ '.rstack',
+ 'cache',
+ 'context-v1',
+ 'runs',
+ run.runId,
+ 'contexts',
+ context.contextId,
+ 'generations',
+ );
+ await writeFile(path.join(generationRoot, '0000000001-broken.json'), '{broken');
+ await writeFile(path.join(generationRoot, '0000000005-broken.json'), '{broken');
+
+ const status = await readContextWorkspaceStatus(workspaceRoot);
+ expect(status.issues).toEqual([
+ {
+ code: 'invalid-record',
+ path: path.posix.join(
+ 'runs',
+ run.runId,
+ 'contexts',
+ context.contextId,
+ 'generations',
+ '0000000005-broken.json',
+ ),
+ },
+ ]);
+ expect(status.runs[0]?.contexts[0]?.latestSnapshot).toEqual(latestSnapshot);
+ });
+});
+
+test('orders generation sequences numerically across the ten-digit boundary', async () => {
+ await withTempWorkspace('rstack-context-store-', async (workspaceRoot) => {
+ await writeContextRunManifest(workspaceRoot, run);
+ const olderSnapshot = {
+ ...secondSnapshot,
+ snapshotId: 'snap_library_old',
+ sequence: 9_999_999_999,
+ } satisfies ContextSnapshot;
+ const newerSnapshot = {
+ ...secondSnapshot,
+ snapshotId: 'snap_library_new',
+ sequence: 10_000_000_000,
+ } satisfies ContextSnapshot;
+ await writeContextSnapshot(workspaceRoot, olderSnapshot);
+ await writeContextSnapshot(workspaceRoot, newerSnapshot);
+
+ const status = await readContextWorkspaceStatus(workspaceRoot);
+ expect(status.runs[0]?.contexts[0]?.latestSnapshot).toEqual(newerSnapshot);
+ });
+});
+
+test('breaks equal generation sequence ties by raw snapshot ID descending', async () => {
+ await withTempWorkspace('rstack-context-store-', async (workspaceRoot) => {
+ await writeContextRunManifest(workspaceRoot, run);
+ const lowerSnapshotId = {
+ ...secondSnapshot,
+ snapshotId: 'snap_Z',
+ sequence: 7,
+ } satisfies ContextSnapshot;
+ const higherSnapshotId = {
+ ...secondSnapshot,
+ snapshotId: 'snap_a',
+ sequence: 7,
+ } satisfies ContextSnapshot;
+ await writeContextSnapshot(workspaceRoot, lowerSnapshotId);
+ await writeContextSnapshot(workspaceRoot, higherSnapshotId);
+
+ const status = await readContextWorkspaceStatus(workspaceRoot);
+ expect(status.runs[0]?.contexts[0]?.latestSnapshot).toEqual(higherSnapshotId);
+ });
+});
diff --git a/tests/testRun.test.ts b/tests/testRun.test.ts
new file mode 100644
index 0000000..54951ca
--- /dev/null
+++ b/tests/testRun.test.ts
@@ -0,0 +1,1664 @@
+/* rslint-disable @typescript-eslint/no-unsafe-assignment -- Rstest asymmetric matchers are intentionally untyped. */
+import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
+import path from 'node:path';
+import type { TestRunResult } from '@rstest/core/api';
+import { expect, test } from '@rstest/core';
+import { listDiagnostics } from '../src/lint.ts';
+import { readProjectStatus } from '../src/status.ts';
+import { validateTestFacet } from '../src/records.ts';
+import { readContextSnapshotById } from '../src/store.ts';
+import {
+ captureTestSnapshot,
+ listTestResults,
+ type TestCaptureDependencies,
+ type TestSnapshotRequest,
+} from '../src/testRun.ts';
+import { withTempWorkspace } from './helpers.ts';
+
+const wrapperConfigPath = path.join(path.sep, 'wrapper', 'rstestConfig.js');
+
+const createResult = (overrides: Partial = {}): TestRunResult => ({
+ ok: true,
+ files: [],
+ stats: {
+ tests: { total: 0, passed: 0, failed: 0, skipped: 0, todo: 0 },
+ files: { total: 0, failed: 0 },
+ },
+ unhandledErrors: [],
+ duration: { total: 0 },
+ ...overrides,
+});
+
+const createDependencies = (
+ result: TestRunResult,
+ calls: unknown[],
+ suffix: string,
+): TestCaptureDependencies => ({
+ hasCoverageProvider: () => true,
+ wrapperConfigPath,
+ runRstest: (options) => {
+ calls.push(options);
+ return Promise.resolve(result);
+ },
+ createRunId: () => `run_${suffix}`,
+ createSnapshotId: () => `snap_${suffix}`,
+ now: () => new Date('2026-08-12T08:00:00.000Z'),
+});
+
+test('keeps the coverage provider out of the published dependency surface', async () => {
+ const packageJson = JSON.parse(
+ await readFile(new URL('../package.json', import.meta.url), 'utf8'),
+ ) as {
+ dependencies?: Record;
+ devDependencies?: Record;
+ peerDependencies?: Record;
+ peerDependenciesMeta?: Record;
+ };
+
+ // The Istanbul provider is resolved from the package under test, so declaring it as a
+ // peer of this package could not satisfy the runtime lookup; it stays a dev-only dependency.
+ expect(packageJson.dependencies?.['@rstest/coverage-istanbul']).toBeUndefined();
+ expect(packageJson.devDependencies?.['@rstest/coverage-istanbul']).toBe('0.11.6');
+ expect(packageJson.peerDependencies?.['@rstest/coverage-istanbul']).toBeUndefined();
+ expect(packageJson.peerDependenciesMeta?.['@rstest/coverage-istanbul']).toBeUndefined();
+
+ // Rsbuild is a type-only integration surface: an optional peer for consumers of the
+ // plugin entry points, never a runtime dependency.
+ expect(packageJson.dependencies?.['@rsbuild/core']).toBeUndefined();
+ expect(packageJson.peerDependencies?.['@rsbuild/core']).toBe('^2.0.0');
+ expect(packageJson.peerDependenciesMeta?.['@rsbuild/core']).toEqual({ optional: true });
+});
+
+test('does not run Rstest when the host reports that tests are not configured', async () => {
+ await withTempWorkspace('rstack-context-test-run-', async (workspaceRoot) => {
+ const calls: unknown[] = [];
+ const dependencies = {
+ ...createDependencies(createResult(), calls, 'not_configured'),
+ isTestConfigured: () => Promise.resolve(false),
+ } satisfies TestCaptureDependencies;
+
+ await expect(captureTestSnapshot(workspaceRoot, {}, dependencies)).rejects.toThrow(
+ 'Rstest is not configured for package root ".". packageRoot is checkout-relative; call project_status and use context.packageRoot.',
+ );
+ expect(calls).toEqual([]);
+ await expect(readProjectStatus(workspaceRoot)).resolves.toMatchObject({ contexts: [] });
+ });
+});
+
+test('captures one passing run with partial source freshness', async () => {
+ await withTempWorkspace('rstack-context-test-run-', async (workspaceRoot) => {
+ const testPath = path.join(workspaceRoot, 'src', 'math.test.ts');
+ await mkdir(path.dirname(testPath), { recursive: true });
+ await writeFile(testPath, 'test');
+ const calls: unknown[] = [];
+ const result = createResult({
+ files: [
+ {
+ project: 'default',
+ testPath,
+ name: 'math.test.ts',
+ status: 'pass',
+ duration: 12,
+ results: [
+ {
+ project: 'default',
+ testPath,
+ name: 'adds',
+ parentNames: ['math'],
+ status: 'pass',
+ duration: 4,
+ },
+ ],
+ },
+ ],
+ stats: {
+ tests: { total: 1, passed: 1, failed: 0, skipped: 0, todo: 0 },
+ files: { total: 1, failed: 0 },
+ },
+ duration: { total: 18 },
+ });
+
+ await expect(
+ captureTestSnapshot(
+ workspaceRoot,
+ { files: ['src/math.test.ts'], testNamePattern: 'adds' },
+ createDependencies(result, calls, 'pass'),
+ ),
+ ).resolves.toEqual({
+ runId: 'run_pass',
+ contextId: expect.stringMatching(/^ctx_[0-9a-f]{24}$/u),
+ snapshotId: 'snap_pass',
+ status: 'pass',
+ freshness: { state: 'partial', changedPaths: [] },
+ summary: {
+ files: 1,
+ failedFiles: 0,
+ tests: 1,
+ failedTests: 0,
+ errors: 0,
+ unhandledErrors: 0,
+ },
+ });
+ expect(calls).toEqual([
+ {
+ cwd: workspaceRoot,
+ config: expect.stringMatching(/rstestConfig\.js$/u),
+ files: ['src/math.test.ts'],
+ testNamePattern: 'adds',
+ },
+ ]);
+
+ const stored = await readContextSnapshotById(workspaceRoot, 'snap_pass');
+ const status = await readProjectStatus(workspaceRoot);
+ expect(stored?.snapshot.facets.execution).toBeUndefined();
+ expect(stored?.snapshot).toMatchObject({
+ status: 'pass',
+ completeness: { test: 'complete' },
+ source: {
+ captureSelection: {
+ files: ['src/math.test.ts'],
+ testNamePattern: 'adds',
+ },
+ inputCompleteness: 'partial',
+ inputs: [
+ {
+ path: 'src/math.test.ts',
+ digest: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08',
+ },
+ ],
+ },
+ facets: {
+ test: {
+ producer: 'rstest',
+ durationMs: 18,
+ unhandledErrors: [],
+ files: [
+ {
+ project: 'default',
+ path: 'src/math.test.ts',
+ status: 'pass',
+ durationMs: 12,
+ tests: [
+ {
+ project: 'default',
+ path: 'src/math.test.ts',
+ name: 'adds',
+ parentNames: ['math'],
+ status: 'pass',
+ durationMs: 4,
+ },
+ ],
+ },
+ ],
+ },
+ },
+ });
+ expect(status.contexts[0]?.context).toEqual({
+ contextId: expect.stringMatching(/^ctx_[0-9a-f]{24}$/u),
+ packageRoot: '.',
+ product: 'development',
+ environment: 'test',
+ });
+
+ await expect(listTestResults(workspaceRoot, {})).resolves.toMatchObject({
+ snapshotId: 'snap_pass',
+ freshness: { state: 'partial', changedPaths: [] },
+ total: 1,
+ items: [{ project: 'default', path: 'src/math.test.ts', name: 'adds' }],
+ });
+ await unlink(testPath);
+ await expect(listTestResults(workspaceRoot, {})).resolves.toMatchObject({
+ snapshotId: 'snap_pass',
+ freshness: { state: 'stale', changedPaths: ['src/math.test.ts'] },
+ });
+ });
+});
+
+test('resolves related source files before running and records the static test relation', async () => {
+ await withTempWorkspace('rstack-context-test-run-', async (workspaceRoot) => {
+ const sourcePath = path.join(workspaceRoot, 'packages', 'app', 'src', 'config.ts');
+ const testPath = path.join(workspaceRoot, 'packages', 'app', 'tests', 'config.test.ts');
+ await mkdir(path.dirname(sourcePath), { recursive: true });
+ await mkdir(path.dirname(testPath), { recursive: true });
+ await writeFile(sourcePath, 'export const value = 1;\n');
+ await writeFile(testPath, 'test');
+ const calls: unknown[] = [];
+ const relatedCalls: unknown[] = [];
+ const result = createResult({
+ files: [
+ {
+ project: 'default',
+ testPath,
+ name: 'config.test.ts',
+ status: 'pass',
+ results: [],
+ },
+ ],
+ stats: {
+ tests: { total: 0, passed: 0, failed: 0, skipped: 0, todo: 0 },
+ files: { total: 1, failed: 0 },
+ },
+ });
+
+ const capture = await captureTestSnapshot(
+ workspaceRoot,
+ { packageRoot: 'packages/app', related: ['src/config.ts'] },
+ {
+ ...createDependencies(result, calls, 'related'),
+ resolveRelatedTests: (request) => {
+ relatedCalls.push(request);
+ return Promise.resolve([testPath]);
+ },
+ },
+ );
+
+ expect(relatedCalls).toEqual([
+ {
+ packageRoot: path.join(workspaceRoot, 'packages/app'),
+ configPath: undefined,
+ sources: [sourcePath],
+ },
+ ]);
+ expect(calls).toEqual([
+ {
+ cwd: path.join(workspaceRoot, 'packages/app'),
+ config: expect.stringMatching(/rstestConfig\.js$/u),
+ files: ['tests/config.test.ts'],
+ },
+ ]);
+ const stored = await readContextSnapshotById(workspaceRoot, capture.snapshotId);
+ expect(stored?.snapshot.source).toEqual({
+ captureSelection: { related: ['src/config.ts'] },
+ inputCompleteness: 'partial',
+ inputs: [
+ {
+ path: 'packages/app/src/config.ts',
+ digest: '5d8f65d2774e206bc9f7a7a4ad39ca2dc563b5c31e46ab57ef4874961237ce29',
+ },
+ {
+ path: 'packages/app/tests/config.test.ts',
+ digest: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08',
+ },
+ ],
+ });
+ expect(stored?.snapshot.facets.test).toMatchObject({
+ relation: {
+ sources: ['packages/app/src/config.ts'],
+ testFiles: ['packages/app/tests/config.test.ts'],
+ },
+ });
+ });
+});
+
+test('persists an actionable error snapshot when a related source file cannot be read', async () => {
+ await withTempWorkspace('rstack-context-test-run-', async (workspaceRoot) => {
+ const testPath = path.join(workspaceRoot, 'tests', 'config.test.ts');
+ await mkdir(path.dirname(testPath), { recursive: true });
+ await writeFile(testPath, 'test');
+ const calls: unknown[] = [];
+ const result = createResult({
+ files: [
+ {
+ project: 'default',
+ testPath,
+ name: 'config.test.ts',
+ status: 'pass',
+ results: [],
+ },
+ ],
+ stats: {
+ tests: { total: 0, passed: 0, failed: 0, skipped: 0, todo: 0 },
+ files: { total: 1, failed: 0 },
+ },
+ });
+
+ await expect(
+ captureTestSnapshot(
+ workspaceRoot,
+ { related: ['src/missing.ts'] },
+ {
+ ...createDependencies(result, calls, 'unreadable'),
+ resolveRelatedTests: () => Promise.resolve([testPath]),
+ },
+ ),
+ ).rejects.toMatchObject({
+ name: 'TestInputError',
+ message:
+ 'Could not read Rstest snapshot inputs: src/missing.ts. Ensure the selected sources and reported test files exist and are readable.',
+ });
+
+ const stored = await readContextSnapshotById(workspaceRoot, 'snap_unreadable');
+ expect(stored?.snapshot).toMatchObject({
+ status: 'error',
+ completeness: { test: 'partial', source: 'partial' },
+ });
+ expect(stored?.snapshot.source).toEqual({
+ captureSelection: { related: ['src/missing.ts'] },
+ inputCompleteness: 'partial',
+ inputs: [
+ {
+ path: 'tests/config.test.ts',
+ digest: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08',
+ },
+ ],
+ unreadableInputs: ['src/missing.ts'],
+ });
+ expect(stored?.snapshot.facets.test).toMatchObject({
+ relation: { sources: ['src/missing.ts'], testFiles: ['tests/config.test.ts'] },
+ unhandledErrors: [
+ {
+ name: 'TestInputError',
+ message:
+ 'Could not read Rstest snapshot inputs: src/missing.ts. Ensure the selected sources and reported test files exist and are readable.',
+ },
+ ],
+ });
+ await expect(
+ listDiagnostics(workspaceRoot, { snapshotId: 'snap_unreadable' }),
+ ).resolves.toMatchObject({
+ snapshotId: 'snap_unreadable',
+ total: 1,
+ items: [
+ {
+ producer: 'rstest',
+ severity: 'error',
+ message:
+ 'Could not read Rstest snapshot inputs: src/missing.ts. Ensure the selected sources and reported test files exist and are readable.',
+ },
+ ],
+ });
+ });
+});
+
+test('keeps the latest complete test results when a newer snapshot has unreadable inputs', async () => {
+ await withTempWorkspace('rstack-context-test-run-', async (workspaceRoot) => {
+ const sourcePath = path.join(workspaceRoot, 'src', 'config.ts');
+ const testPath = path.join(workspaceRoot, 'tests', 'config.test.ts');
+ await mkdir(path.dirname(sourcePath), { recursive: true });
+ await mkdir(path.dirname(testPath), { recursive: true });
+ await writeFile(sourcePath, 'export const value = 1;');
+ await writeFile(testPath, 'test');
+ const result = createResult({
+ files: [
+ {
+ project: 'default',
+ testPath,
+ name: 'config.test.ts',
+ status: 'pass',
+ results: [
+ {
+ project: 'default',
+ testPath,
+ name: 'reads config',
+ parentNames: [],
+ status: 'pass',
+ },
+ ],
+ },
+ ],
+ stats: {
+ tests: { total: 1, passed: 1, failed: 0, skipped: 0, todo: 0 },
+ files: { total: 1, failed: 0 },
+ },
+ });
+ const resolveRelatedTests = () => Promise.resolve([testPath]);
+
+ const complete = await captureTestSnapshot(
+ workspaceRoot,
+ { related: ['src/config.ts'] },
+ {
+ ...createDependencies(result, [], 'complete'),
+ resolveRelatedTests,
+ },
+ );
+ await unlink(sourcePath);
+ await expect(
+ captureTestSnapshot(
+ workspaceRoot,
+ { related: ['src/config.ts'] },
+ {
+ ...createDependencies(result, [], 'incomplete'),
+ resolveRelatedTests,
+ },
+ ),
+ ).rejects.toMatchObject({ name: 'TestInputError' });
+
+ await expect(listTestResults(workspaceRoot, {})).resolves.toMatchObject({
+ snapshotId: complete.snapshotId,
+ total: 1,
+ items: [{ name: 'reads config', status: 'pass' }],
+ });
+ await expect(
+ listTestResults(workspaceRoot, { snapshotId: 'snap_incomplete' }),
+ ).resolves.toMatchObject({
+ snapshotId: 'snap_incomplete',
+ total: 1,
+ items: [{ name: 'reads config', status: 'pass' }],
+ });
+ });
+});
+
+test('reports a missing config adapter before writing any run manifest', async () => {
+ await withTempWorkspace('rstack-context-test-run-', async (workspaceRoot) => {
+ await expect(captureTestSnapshot(workspaceRoot, {}, { wrapperConfigPath })).rejects.toThrow(
+ 'Rstack test capture requires a config adapter.',
+ );
+ await expect(
+ captureTestSnapshot(
+ workspaceRoot,
+ { related: ['src/a.ts'] },
+ {
+ runRstest: () => {
+ throw new Error('must not run');
+ },
+ wrapperConfigPath,
+ },
+ ),
+ ).rejects.toThrow('Rstack test capture requires a related-test resolver.');
+
+ await expect(readProjectStatus(workspaceRoot)).resolves.toMatchObject({ contexts: [] });
+ await expect(listDiagnostics(workspaceRoot)).rejects.toThrow(
+ 'No matching completed context snapshot was found.',
+ );
+ });
+});
+
+test('rejects capture targets that escape the checkout before writing any run manifest', async () => {
+ await withTempWorkspace('rstack-context-test-run-', async (workspaceRoot) => {
+ const calls: unknown[] = [];
+ const dependencies = createDependencies(createResult(), calls, 'escape');
+
+ await expect(
+ captureTestSnapshot(workspaceRoot, { packageRoot: '../../..' }, dependencies),
+ ).rejects.toThrow(
+ 'packageRoot must be a non-empty checkout-relative path that stays inside the checkout.',
+ );
+ await expect(
+ captureTestSnapshot(workspaceRoot, { configPath: '../../evil.config.ts' }, dependencies),
+ ).rejects.toThrow(
+ 'configPath must be a non-empty checkout-relative path that stays inside the checkout.',
+ );
+ expect(calls).toEqual([]);
+ await expect(readProjectStatus(workspaceRoot)).resolves.toMatchObject({ contexts: [] });
+ });
+});
+
+test('rejects files and related source selection together', async () => {
+ await withTempWorkspace('rstack-context-test-run-', async (workspaceRoot) => {
+ const calls: unknown[] = [];
+ const dependencies = createDependencies(createResult(), calls, 'invalid-related');
+ await expect(
+ captureTestSnapshot(
+ workspaceRoot,
+ { files: ['tests/config.test.ts'], related: ['src/config.ts'] },
+ dependencies,
+ ),
+ ).rejects.toThrow('files and related cannot be used together');
+ expect(calls).toEqual([]);
+ });
+});
+
+test('records an empty related selection without falling back to the full test suite', async () => {
+ await withTempWorkspace('rstack-context-test-run-', async (workspaceRoot) => {
+ const sourcePath = path.join(workspaceRoot, 'src', 'unused.ts');
+ await mkdir(path.dirname(sourcePath), { recursive: true });
+ await writeFile(sourcePath, 'export const unused = true;\n');
+ const calls: unknown[] = [];
+
+ const capture = await captureTestSnapshot(
+ workspaceRoot,
+ { related: ['src/unused.ts'] },
+ {
+ ...createDependencies(createResult(), calls, 'unrelated'),
+ resolveRelatedTests: () => Promise.resolve([]),
+ },
+ );
+
+ expect(calls).toEqual([]);
+ expect(capture).toMatchObject({ status: 'pass', summary: { files: 0, tests: 0 } });
+ const stored = await readContextSnapshotById(workspaceRoot, capture.snapshotId);
+ expect(stored?.snapshot.facets.test).toMatchObject({
+ relation: { sources: ['src/unused.ts'], testFiles: [] },
+ files: [],
+ });
+ });
+});
+
+test('captures requested aggregate Istanbul execution evidence', async () => {
+ await withTempWorkspace('rstack-context-test-run-', async (workspaceRoot) => {
+ const testPath = path.join(workspaceRoot, 'src', 'math.test.ts');
+ const sourcePath = path.join(workspaceRoot, 'src', 'math.ts');
+ const classPath = path.join(workspaceRoot, 'src', 'counter.ts');
+ await mkdir(path.dirname(testPath), { recursive: true });
+ await writeFile(testPath, 'test');
+ await writeFile(sourcePath, 'export const add = (a, b) => a + b;');
+ await writeFile(classPath, 'export class Counter {}');
+ const calls: unknown[] = [];
+ const coverage = {
+ [sourcePath]: {
+ path: sourcePath,
+ statementMap: {
+ '1': { start: { line: 2, column: 0 }, end: { line: 2, column: 12 } },
+ '0': { start: { line: 1, column: 0 }, end: { line: 1, column: 36 } },
+ },
+ fnMap: {
+ '0': {
+ name: 'add',
+ decl: {
+ start: { line: 1, column: 13 },
+ end: { line: 1, column: 16 },
+ },
+ loc: {
+ start: { line: 1, column: 19 },
+ end: { line: 1, column: 36 },
+ },
+ },
+ },
+ branchMap: {
+ '0': {
+ type: 'binary-expr',
+ loc: {
+ start: { line: 1, column: 27 },
+ end: { line: 1, column: 32 },
+ },
+ locations: [
+ { start: { line: 1, column: 27 }, end: { line: 1, column: 28 } },
+ { start: { line: 1, column: 31 }, end: { line: 1, column: 32 } },
+ ],
+ },
+ },
+ s: { '0': 2, '1': 0 },
+ f: { '0': 2 },
+ b: { '0': [2, 0] },
+ },
+ [classPath]: {
+ data: {
+ path: classPath,
+ statementMap: {
+ '0': {
+ start: { line: 1, column: 0 },
+ end: { line: 1, column: 23 },
+ },
+ },
+ fnMap: {},
+ branchMap: {},
+ s: { '0': 1 },
+ f: {},
+ b: {},
+ },
+ },
+ } as unknown as NonNullable;
+ const result = createResult({
+ coverage,
+ files: [
+ {
+ project: 'default',
+ testPath,
+ name: 'math.test.ts',
+ status: 'pass',
+ results: [],
+ },
+ ],
+ });
+
+ const capture = await captureTestSnapshot(
+ workspaceRoot,
+ {
+ execution: {
+ include: ['src/**/*.ts'],
+ exclude: ['src/**/*.test.ts'],
+ allowExternal: true,
+ },
+ },
+ createDependencies(result, calls, 'execution'),
+ );
+
+ expect(calls).toEqual([
+ {
+ cwd: workspaceRoot,
+ config: expect.stringMatching(/rstestConfig\.js$/u),
+ inlineConfig: {
+ coverage: {
+ enabled: true,
+ provider: 'istanbul',
+ reporters: [],
+ reportOnFailure: true,
+ include: ['src/**/*.ts'],
+ exclude: ['src/**/*.test.ts'],
+ allowExternal: true,
+ },
+ },
+ },
+ ]);
+ const stored = await readContextSnapshotById(workspaceRoot, capture.snapshotId);
+ expect(stored?.snapshot.completeness).toEqual({
+ test: 'complete',
+ execution: 'complete',
+ });
+ expect(stored?.snapshot.facets.execution).toEqual({
+ producer: 'rstest',
+ provider: 'istanbul',
+ availability: 'available',
+ requestedSelection: {
+ include: ['src/**/*.ts'],
+ exclude: ['src/**/*.test.ts'],
+ allowExternal: true,
+ },
+ digest: expect.stringMatching(/^[0-9a-f]{64}$/u),
+ universe: {
+ reportedFiles: 2,
+ storedFiles: 2,
+ droppedFiles: 0,
+ reportedLocations: 8,
+ storedLocations: 8,
+ droppedLocations: 0,
+ completeness: 'complete',
+ },
+ truncated: { files: 0, locations: 0 },
+ bounds: {
+ attribution: 'aggregate-run-only',
+ testAttribution: false,
+ maxFiles: 1000,
+ maxLocationsPerFile: 20_000,
+ maxLocationsTotal: 100_000,
+ },
+ files: [
+ {
+ path: 'src/counter.ts',
+ digest: '56487e6af58165f47f737975f5cef61ad6dfc7c0c59af4807dcdf993d931570a',
+ statements: [
+ {
+ id: '0',
+ location: {
+ start: { line: 1, column: 0 },
+ end: { line: 1, column: 23 },
+ },
+ hits: 1,
+ },
+ ],
+ functions: [],
+ branches: [],
+ },
+ {
+ path: 'src/math.ts',
+ digest: '5040544f09224c8ba67da55ac47c01b1d1ab917a8d07c0a42f00cf14c570f5a9',
+ statements: [
+ {
+ id: '0',
+ location: {
+ start: { line: 1, column: 0 },
+ end: { line: 1, column: 36 },
+ },
+ hits: 2,
+ },
+ {
+ id: '1',
+ location: {
+ start: { line: 2, column: 0 },
+ end: { line: 2, column: 12 },
+ },
+ hits: 0,
+ },
+ ],
+ functions: [
+ {
+ id: '0',
+ name: 'add',
+ declaration: {
+ start: { line: 1, column: 13 },
+ end: { line: 1, column: 16 },
+ },
+ location: {
+ start: { line: 1, column: 19 },
+ end: { line: 1, column: 36 },
+ },
+ hits: 2,
+ },
+ ],
+ branches: [
+ {
+ id: '0',
+ type: 'binary-expr',
+ location: {
+ start: { line: 1, column: 27 },
+ end: { line: 1, column: 32 },
+ },
+ arms: [
+ {
+ location: {
+ start: { line: 1, column: 27 },
+ end: { line: 1, column: 28 },
+ },
+ hits: 2,
+ },
+ {
+ location: {
+ start: { line: 1, column: 31 },
+ end: { line: 1, column: 32 },
+ },
+ hits: 0,
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ });
+ expect(stored?.snapshot.source?.inputs?.map((input) => input.path)).toEqual([
+ 'src/counter.ts',
+ 'src/math.test.ts',
+ 'src/math.ts',
+ ]);
+ });
+});
+
+test('records requested execution as unavailable when Rstest returns no coverage map', async () => {
+ await withTempWorkspace('rstack-context-test-run-', async (workspaceRoot) => {
+ const calls: unknown[] = [];
+ const capture = await captureTestSnapshot(
+ workspaceRoot,
+ { execution: {} },
+ createDependencies(createResult(), calls, 'execution-unavailable'),
+ );
+
+ expect(calls).toEqual([
+ {
+ cwd: workspaceRoot,
+ config: expect.stringMatching(/rstestConfig\.js$/u),
+ inlineConfig: {
+ coverage: {
+ enabled: true,
+ provider: 'istanbul',
+ reporters: [],
+ reportOnFailure: true,
+ allowExternal: false,
+ },
+ },
+ },
+ ]);
+ await expect(readContextSnapshotById(workspaceRoot, capture.snapshotId)).resolves.toMatchObject(
+ {
+ snapshot: {
+ completeness: { test: 'complete', execution: 'partial' },
+ facets: {
+ execution: {
+ producer: 'rstest',
+ provider: 'istanbul',
+ availability: 'unavailable',
+ requestedSelection: { allowExternal: false },
+ digest: expect.stringMatching(/^[0-9a-f]{64}$/u),
+ universe: {
+ reportedFiles: 0,
+ storedFiles: 0,
+ droppedFiles: 0,
+ reportedLocations: 0,
+ storedLocations: 0,
+ droppedLocations: 0,
+ completeness: 'unknown',
+ },
+ truncated: { files: 0, locations: 0 },
+ bounds: {
+ attribution: 'aggregate-run-only',
+ testAttribution: false,
+ },
+ files: [],
+ },
+ },
+ },
+ },
+ );
+ });
+});
+
+test('runs tests without coverage when the optional Istanbul provider is unavailable', async () => {
+ await withTempWorkspace('rstack-context-test-run-', async (workspaceRoot) => {
+ const testPath = path.join(workspaceRoot, 'src', 'math.test.ts');
+ await mkdir(path.dirname(testPath), { recursive: true });
+ await writeFile(testPath, 'test');
+ const calls: unknown[] = [];
+ const result = createResult({
+ files: [
+ {
+ project: 'default',
+ testPath,
+ name: 'math.test.ts',
+ status: 'pass',
+ results: [],
+ },
+ ],
+ stats: {
+ tests: { total: 1, passed: 1, failed: 0, skipped: 0, todo: 0 },
+ files: { total: 1, failed: 0 },
+ },
+ });
+
+ const capture = await captureTestSnapshot(
+ workspaceRoot,
+ { files: ['src/math.test.ts'], execution: { include: ['src/math.ts'] } },
+ {
+ ...createDependencies(result, calls, 'provider-unavailable'),
+ hasCoverageProvider: () => false,
+ } satisfies TestCaptureDependencies,
+ );
+
+ expect(calls).toEqual([
+ {
+ cwd: workspaceRoot,
+ config: expect.stringMatching(/rstestConfig\.js$/u),
+ files: ['src/math.test.ts'],
+ },
+ ]);
+ expect(capture).toMatchObject({
+ status: 'pass',
+ summary: { files: 1, tests: 1 },
+ execution: {
+ provider: 'istanbul',
+ availability: 'unavailable',
+ completeness: 'unknown',
+ },
+ });
+ await expect(readContextSnapshotById(workspaceRoot, capture.snapshotId)).resolves.toMatchObject(
+ {
+ snapshot: {
+ status: 'pass',
+ completeness: { test: 'complete', execution: 'partial' },
+ facets: {
+ execution: {
+ provider: 'istanbul',
+ availability: 'unavailable',
+ requestedSelection: { include: ['src/math.ts'], allowExternal: false },
+ },
+ },
+ },
+ },
+ );
+ });
+});
+
+test('persists partial execution evidence when a covered source path is unreadable', async () => {
+ await withTempWorkspace('rstack-context-test-run-', async (workspaceRoot) => {
+ const testPath = path.join(workspaceRoot, 'src', 'present.test.ts');
+ const missingPath = path.join(workspaceRoot, 'src', 'missing.ts');
+ await mkdir(path.dirname(testPath), { recursive: true });
+ await writeFile(testPath, 'test');
+ const coverage = {
+ [missingPath]: {
+ path: missingPath,
+ statementMap: {
+ '0': { start: { line: 1, column: 0 }, end: { line: 1, column: 10 } },
+ },
+ fnMap: {},
+ branchMap: {},
+ s: { '0': 1 },
+ f: {},
+ b: {},
+ },
+ } as unknown as NonNullable;
+ const result = createResult({
+ files: [
+ {
+ project: 'default',
+ testPath,
+ name: 'present.test.ts',
+ status: 'pass',
+ results: [],
+ },
+ ],
+ coverage,
+ });
+ const calls: unknown[] = [];
+
+ const capture = await captureTestSnapshot(
+ workspaceRoot,
+ { execution: {} },
+ createDependencies(result, calls, 'execution-unreadable'),
+ );
+ const stored = await readContextSnapshotById(workspaceRoot, capture.snapshotId);
+
+ expect(stored?.snapshot.completeness).toEqual({ test: 'complete', execution: 'partial' });
+ expect(stored?.snapshot.source).toEqual({
+ captureSelection: {},
+ inputCompleteness: 'partial',
+ inputs: [
+ {
+ path: 'src/present.test.ts',
+ digest: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08',
+ },
+ ],
+ });
+ expect(stored?.snapshot.facets.execution).toMatchObject({
+ availability: 'available',
+ universe: { completeness: 'partial' },
+ files: [
+ {
+ path: 'src/missing.ts',
+ statements: [{ id: '0', hits: 1 }],
+ },
+ ],
+ });
+ expect(
+ (stored?.snapshot.facets.execution as { files: Array<{ digest?: string }> }).files[0]?.digest,
+ ).toBeUndefined();
+ });
+});
+
+test('bounds requested execution selectors before invoking Rstest', async () => {
+ await withTempWorkspace('rstack-context-test-run-', async (workspaceRoot) => {
+ let called = false;
+ const dependencies: TestCaptureDependencies = {
+ runRstest: () => {
+ called = true;
+ return Promise.resolve(createResult());
+ },
+ };
+
+ await expect(
+ captureTestSnapshot(
+ workspaceRoot,
+ {
+ execution: {
+ include: Array.from({ length: 201 }, (_, index) => `${index}.ts`),
+ },
+ },
+ dependencies,
+ ),
+ ).rejects.toThrow('Execution include must contain at most 200 patterns.');
+ expect(called).toBe(false);
+ });
+});
+
+test('rejects malformed execution selectors before invoking Rstest', async () => {
+ await withTempWorkspace('rstack-context-test-run-', async (workspaceRoot) => {
+ let called = false;
+ const dependencies: TestCaptureDependencies = {
+ runRstest: () => {
+ called = true;
+ return Promise.resolve(createResult());
+ },
+ };
+
+ await expect(
+ captureTestSnapshot(
+ workspaceRoot,
+ { execution: { include: 'src/**/*.ts' as unknown as string[] } },
+ dependencies,
+ ),
+ ).rejects.toThrow('Execution include must be an array of string patterns.');
+ await expect(
+ captureTestSnapshot(
+ workspaceRoot,
+ { execution: { exclude: [42] as unknown as string[] } },
+ dependencies,
+ ),
+ ).rejects.toThrow('Execution exclude must be an array of string patterns.');
+ await expect(
+ captureTestSnapshot(
+ workspaceRoot,
+ { execution: { allowExternal: 'yes' as unknown as boolean } },
+ dependencies,
+ ),
+ ).rejects.toThrow('Execution allowExternal must be a boolean.');
+ expect(called).toBe(false);
+ });
+});
+
+test('truncates aggregate execution files deterministically', async () => {
+ await withTempWorkspace('rstack-context-test-run-', async (workspaceRoot) => {
+ const coverage: Record = {};
+ const sourceDirectory = path.join(workspaceRoot, 'src');
+ await mkdir(sourceDirectory, { recursive: true });
+ const writes: Array> = [];
+ for (let index = 1000; index >= 0; index -= 1) {
+ const sourcePath = path.join(sourceDirectory, `${index.toString().padStart(4, '0')}.ts`);
+ writes.push(writeFile(sourcePath, 'export {};'));
+ coverage[sourcePath] = {
+ path: sourcePath,
+ statementMap: {},
+ fnMap: {},
+ branchMap: {},
+ s: {},
+ f: {},
+ b: {},
+ };
+ }
+ await Promise.all(writes);
+ const calls: unknown[] = [];
+ const capture = await captureTestSnapshot(
+ workspaceRoot,
+ { execution: {} },
+ createDependencies(
+ createResult({
+ coverage: coverage as NonNullable,
+ }),
+ calls,
+ 'execution-truncated',
+ ),
+ );
+
+ const stored = await readContextSnapshotById(workspaceRoot, capture.snapshotId);
+ expect(stored?.snapshot.completeness.execution).toBe('partial');
+ expect(stored?.snapshot.facets.execution).toMatchObject({
+ availability: 'available',
+ universe: {
+ reportedFiles: 1001,
+ storedFiles: 1000,
+ droppedFiles: 1,
+ completeness: 'partial',
+ },
+ truncated: { files: 1, locations: 0 },
+ });
+ const files = (
+ stored?.snapshot.facets.execution as { files?: Array<{ path: string }> } | undefined
+ )?.files;
+ expect(files).toHaveLength(1000);
+ expect(files?.slice(0, 3)).toEqual([
+ {
+ path: 'src/0000.ts',
+ digest: '2e29cd9a98755c46896f7a2d56524db2d6d96b248e36db46de14c30bf47c8d05',
+ statements: [],
+ functions: [],
+ branches: [],
+ },
+ {
+ path: 'src/0001.ts',
+ digest: '2e29cd9a98755c46896f7a2d56524db2d6d96b248e36db46de14c30bf47c8d05',
+ statements: [],
+ functions: [],
+ branches: [],
+ },
+ {
+ path: 'src/0002.ts',
+ digest: '2e29cd9a98755c46896f7a2d56524db2d6d96b248e36db46de14c30bf47c8d05',
+ statements: [],
+ functions: [],
+ branches: [],
+ },
+ ]);
+ });
+}, 15_000);
+
+test('normalizes failures, retries, skipped and todo cases', async () => {
+ await withTempWorkspace('rstack-context-test-run-', async (workspaceRoot) => {
+ const testPath = path.join(workspaceRoot, 'tests', 'mixed.test.ts');
+ await mkdir(path.dirname(testPath), { recursive: true });
+ await writeFile(testPath, 'test');
+ const calls: unknown[] = [];
+ const result = createResult({
+ ok: false,
+ files: [
+ {
+ project: 'node',
+ testPath,
+ name: 'mixed.test.ts',
+ status: 'fail',
+ errors: [{ name: 'FileError', message: 'file failed' }],
+ results: [
+ {
+ project: 'node',
+ testPath,
+ name: 'eventually fails',
+ status: 'fail',
+ errors: [
+ {
+ name: 'AssertionError',
+ message: 'expected true',
+ stack: 'stack',
+ diff: '- false\n+ true',
+ actual: 'false',
+ expected: 'true',
+ retryCount: 2,
+ },
+ ],
+ retryErrors: [{ name: 'Error', message: 'attempt one' }],
+ retryCount: 2,
+ },
+ { project: 'node', testPath, name: 'skipped', status: 'skip' },
+ { project: 'node', testPath, name: 'later', status: 'todo' },
+ ],
+ },
+ ],
+ stats: {
+ tests: { total: 3, passed: 0, failed: 1, skipped: 1, todo: 1 },
+ files: { total: 1, failed: 1 },
+ },
+ duration: { total: 30 },
+ });
+
+ const capture = await captureTestSnapshot(
+ workspaceRoot,
+ {},
+ createDependencies(result, calls, 'failed'),
+ );
+ expect(capture.status).toBe('fail');
+ const stored = await readContextSnapshotById(workspaceRoot, capture.snapshotId);
+ expect(stored?.snapshot.facets.test).toMatchObject({
+ files: [
+ {
+ status: 'fail',
+ tests: [
+ {
+ name: 'eventually fails',
+ status: 'fail',
+ errors: [
+ {
+ name: 'AssertionError',
+ message: 'expected true',
+ stack: 'stack',
+ diff: '- false\n+ true',
+ actual: 'false',
+ expected: 'true',
+ retryCount: 2,
+ },
+ ],
+ retryErrors: [{ name: 'Error', message: 'attempt one' }],
+ retryCount: 2,
+ },
+ { name: 'later', status: 'todo' },
+ { name: 'skipped', status: 'skip' },
+ ],
+ },
+ ],
+ });
+ });
+});
+
+test('captures file-level failures without inventing test cases', async () => {
+ await withTempWorkspace('rstack-context-test-run-', async (workspaceRoot) => {
+ const testPath = path.join(workspaceRoot, 'tests', 'broken.test.ts');
+ await mkdir(path.dirname(testPath), { recursive: true });
+ await writeFile(testPath, 'invalid');
+ const calls: unknown[] = [];
+ const result = createResult({
+ ok: false,
+ files: [
+ {
+ project: 'node',
+ testPath,
+ name: 'broken.test.ts',
+ status: 'fail',
+ errors: [
+ {
+ name: 'ImportError',
+ message: 'could not import setup module',
+ stack: 'import stack',
+ },
+ ],
+ results: [],
+ },
+ ],
+ stats: {
+ tests: { total: 0, passed: 0, failed: 0, skipped: 0, todo: 0 },
+ files: { total: 1, failed: 1 },
+ },
+ });
+
+ const capture = await captureTestSnapshot(
+ workspaceRoot,
+ {},
+ createDependencies(result, calls, 'file-error'),
+ );
+
+ expect(capture).toMatchObject({
+ status: 'fail',
+ summary: { errors: 1 },
+ errors: [
+ {
+ scope: 'file',
+ project: 'node',
+ path: 'tests/broken.test.ts',
+ name: 'ImportError',
+ message: 'could not import setup module',
+ stack: 'import stack',
+ },
+ ],
+ });
+
+ expect(
+ (await readContextSnapshotById(workspaceRoot, capture.snapshotId))?.snapshot.facets.test,
+ ).toMatchObject({
+ files: [
+ {
+ project: 'node',
+ path: 'tests/broken.test.ts',
+ status: 'fail',
+ errors: [
+ {
+ name: 'ImportError',
+ message: 'could not import setup module',
+ stack: 'import stack',
+ },
+ ],
+ tests: [],
+ },
+ ],
+ });
+ await expect(
+ listDiagnostics(workspaceRoot, { snapshotId: capture.snapshotId }),
+ ).resolves.toMatchObject({
+ total: 1,
+ items: [
+ {
+ producer: 'rstest',
+ project: 'node',
+ path: 'tests/broken.test.ts',
+ severity: 'error',
+ message: 'could not import setup module',
+ },
+ ],
+ });
+ await expect(listTestResults(workspaceRoot, {})).resolves.toEqual({
+ producer: 'rstest',
+ contextId: capture.contextId,
+ snapshotId: capture.snapshotId,
+ observedAt: '2026-08-12T08:00:00.000Z',
+ completeness: { test: 'complete' },
+ freshness: { state: 'partial', changedPaths: [] },
+ total: 0,
+ items: [],
+ });
+ });
+});
+
+test('records unhandled Rstest errors as an error snapshot', async () => {
+ await withTempWorkspace('rstack-context-test-run-', async (workspaceRoot) => {
+ const calls: unknown[] = [];
+ const result = createResult({
+ ok: false,
+ unhandledErrors: [
+ {
+ name: 'ConfigError',
+ message: 'configuration failed',
+ stack: 'config stack',
+ },
+ ],
+ });
+
+ const capture = await captureTestSnapshot(
+ workspaceRoot,
+ {},
+ createDependencies(result, calls, 'error'),
+ );
+ expect(capture).toMatchObject({
+ snapshotId: 'snap_error',
+ status: 'error',
+ freshness: { state: 'partial', changedPaths: [] },
+ summary: { unhandledErrors: 1 },
+ errors: [
+ {
+ scope: 'run',
+ name: 'ConfigError',
+ message: 'configuration failed',
+ stack: 'config stack',
+ },
+ ],
+ unhandledErrors: [
+ {
+ name: 'ConfigError',
+ message: 'configuration failed',
+ stack: 'config stack',
+ },
+ ],
+ });
+ expect(
+ (await readContextSnapshotById(workspaceRoot, capture.snapshotId))?.snapshot.facets,
+ ).toMatchObject({
+ test: {
+ unhandledErrors: [
+ {
+ name: 'ConfigError',
+ message: 'configuration failed',
+ stack: 'config stack',
+ },
+ ],
+ },
+ });
+ });
+});
+
+test('persists a thrown Rstest configuration error as a completed diagnostic snapshot', async () => {
+ await withTempWorkspace('rstack-context-test-run-', async (workspaceRoot) => {
+ const configError = new Error('configuration failed');
+ configError.name = 'ConfigError';
+ const dependencies: TestCaptureDependencies = {
+ wrapperConfigPath,
+ runRstest: async () => {
+ await expect(readProjectStatus(workspaceRoot)).resolves.toMatchObject({
+ contexts: [{ runId: 'run_thrown', state: 'pending' }],
+ });
+ throw configError;
+ },
+ createRunId: () => 'run_thrown',
+ createSnapshotId: () => 'snap_thrown',
+ now: () => new Date('2026-08-12T08:00:00.000Z'),
+ };
+
+ await expect(captureTestSnapshot(workspaceRoot, {}, dependencies)).rejects.toBe(configError);
+
+ const stored = await readContextSnapshotById(workspaceRoot, 'snap_thrown');
+ expect(stored?.snapshot).toMatchObject({
+ runId: 'run_thrown',
+ status: 'error',
+ completeness: { test: 'partial' },
+ facets: {
+ test: {
+ producer: 'rstest',
+ files: [],
+ stats: {
+ tests: { total: 0, passed: 0, failed: 0, skipped: 0, todo: 0 },
+ files: { total: 0, failed: 0 },
+ },
+ durationMs: 0,
+ unhandledErrors: [
+ {
+ name: 'ConfigError',
+ message: 'configuration failed',
+ stack: expect.any(String),
+ },
+ ],
+ },
+ },
+ });
+ await expect(
+ listDiagnostics(workspaceRoot, { snapshotId: 'snap_thrown' }),
+ ).resolves.toMatchObject({
+ snapshotId: 'snap_thrown',
+ total: 1,
+ items: [
+ {
+ producer: 'rstest',
+ severity: 'error',
+ message: 'configuration failed',
+ },
+ ],
+ });
+ });
+});
+
+test('pages project-qualified results in deterministic identity order', async () => {
+ await withTempWorkspace('rstack-context-test-run-', async (workspaceRoot) => {
+ const testPath = path.join(workspaceRoot, 'tests', 'shared.test.ts');
+ await mkdir(path.dirname(testPath), { recursive: true });
+ await writeFile(testPath, 'test');
+ const calls: unknown[] = [];
+ const result = createResult({
+ files: [
+ {
+ project: 'web',
+ testPath,
+ name: 'shared.test.ts',
+ status: 'pass',
+ results: [
+ { project: 'web', testPath, name: 'zeta', status: 'pass' },
+ {
+ project: 'web',
+ testPath,
+ parentNames: ['suite'],
+ name: 'alpha',
+ status: 'skip',
+ },
+ ],
+ },
+ {
+ project: 'node',
+ testPath,
+ name: 'shared.test.ts',
+ status: 'pass',
+ results: [{ project: 'node', testPath, name: 'zeta', status: 'todo' }],
+ },
+ ],
+ stats: {
+ tests: { total: 3, passed: 1, failed: 0, skipped: 1, todo: 1 },
+ files: { total: 2, failed: 0 },
+ },
+ });
+ const capture = await captureTestSnapshot(
+ workspaceRoot,
+ {},
+ createDependencies(result, calls, 'projects'),
+ );
+
+ const first = await listTestResults(workspaceRoot, {
+ snapshotId: capture.snapshotId,
+ pathPrefix: 'tests/',
+ limit: 2,
+ });
+ expect(first).toEqual({
+ producer: 'rstest',
+ contextId: capture.contextId,
+ snapshotId: capture.snapshotId,
+ observedAt: '2026-08-12T08:00:00.000Z',
+ completeness: { test: 'complete' },
+ freshness: { state: 'partial', changedPaths: [] },
+ total: 3,
+ items: [
+ {
+ project: 'node',
+ path: 'tests/shared.test.ts',
+ name: 'zeta',
+ status: 'todo',
+ },
+ {
+ project: 'web',
+ path: 'tests/shared.test.ts',
+ name: 'zeta',
+ status: 'pass',
+ },
+ ],
+ nextCursor: expect.any(String),
+ });
+ await expect(
+ listTestResults(workspaceRoot, {
+ snapshotId: capture.snapshotId,
+ pathPrefix: 'tests/',
+ status: 'pass',
+ limit: 2,
+ cursor: first.nextCursor,
+ }),
+ ).rejects.toThrow('Invalid test result cursor');
+ await expect(
+ listTestResults(workspaceRoot, {
+ snapshotId: capture.snapshotId,
+ pathPrefix: 'tests/',
+ limit: 2,
+ cursor: first.nextCursor,
+ }),
+ ).resolves.toEqual({
+ producer: 'rstest',
+ contextId: capture.contextId,
+ snapshotId: capture.snapshotId,
+ observedAt: '2026-08-12T08:00:00.000Z',
+ completeness: { test: 'complete' },
+ freshness: { state: 'partial', changedPaths: [] },
+ total: 3,
+ items: [
+ {
+ project: 'web',
+ path: 'tests/shared.test.ts',
+ parentNames: ['suite'],
+ name: 'alpha',
+ status: 'skip',
+ },
+ ],
+ });
+ await expect(
+ listTestResults(workspaceRoot, { project: 'web', status: 'skip' }),
+ ).resolves.toMatchObject({
+ total: 1,
+ items: [{ project: 'web', name: 'alpha', status: 'skip' }],
+ });
+ });
+});
+
+const nestedCause = (depth: number): TestRunResult['unhandledErrors'][number] => ({
+ name: `Cause${depth}`,
+ message: `cause ${depth}`,
+ ...(depth === 0 ? {} : { cause: nestedCause(depth - 1) }),
+});
+
+const causeChainLength = (error: unknown): number => {
+ let links = 0;
+ let current = error;
+ while (
+ typeof current === 'object' &&
+ current !== null &&
+ (current as { cause?: unknown }).cause !== undefined
+ ) {
+ links += 1;
+ current = (current as { cause?: unknown }).cause;
+ }
+ return links;
+};
+
+test('carries serialized error causes and task metadata through capture and listing', async () => {
+ await withTempWorkspace('rstack-context-test-run-', async (workspaceRoot) => {
+ const testPath = path.join(workspaceRoot, 'tests', 'fidelity.test.ts');
+ await mkdir(path.dirname(testPath), { recursive: true });
+ await writeFile(testPath, 'test');
+ const calls: unknown[] = [];
+ const result = createResult({
+ ok: false,
+ files: [
+ {
+ project: 'node',
+ testPath,
+ name: 'fidelity.test.ts',
+ status: 'fail',
+ results: [
+ {
+ project: 'node',
+ testPath,
+ name: 'reports a wrapped failure',
+ status: 'fail',
+ errors: [
+ {
+ name: 'AssertionError',
+ message: 'outer',
+ cause: {
+ name: 'TypeError',
+ message: 'middle',
+ cause: { name: 'RangeError', message: 'root' },
+ },
+ },
+ ],
+ // Deliberately unsorted so the stored record proves key normalization.
+ meta: { zeta: [1, { beta: true }], alpha: 'first', nested: { b: null, a: 2 } },
+ },
+ ],
+ },
+ ],
+ stats: {
+ tests: { total: 1, passed: 0, failed: 1, skipped: 0, todo: 0 },
+ files: { total: 1, failed: 1 },
+ },
+ });
+
+ const capture = await captureTestSnapshot(
+ workspaceRoot,
+ {},
+ createDependencies(result, calls, 'fidelity'),
+ );
+ const stored = (await readContextSnapshotById(workspaceRoot, capture.snapshotId))?.snapshot
+ .facets.test;
+
+ // The snapshot only round-trips through the store if the strict validator accepts the
+ // new optional fields, so validation is the assertion that matters here.
+ expect(validateTestFacet(stored)).not.toBeUndefined();
+ expect(stored).toMatchObject({
+ files: [
+ {
+ tests: [
+ {
+ name: 'reports a wrapped failure',
+ errors: [
+ {
+ name: 'AssertionError',
+ message: 'outer',
+ cause: {
+ name: 'TypeError',
+ message: 'middle',
+ cause: { name: 'RangeError', message: 'root' },
+ },
+ },
+ ],
+ meta: { alpha: 'first', nested: { a: 2, b: null }, zeta: [1, { beta: true }] },
+ },
+ ],
+ },
+ ],
+ });
+ const storedMeta = (
+ stored as unknown as { files: Array<{ tests: Array<{ meta: Record }> }> }
+ ).files[0].tests[0].meta;
+ expect(Object.keys(storedMeta)).toEqual(['alpha', 'nested', 'zeta']);
+ expect(Object.keys(storedMeta.nested as Record)).toEqual(['a', 'b']);
+
+ const listed = await listTestResults(workspaceRoot, {});
+ expect(listed.items[0]).toMatchObject({
+ meta: { alpha: 'first' },
+ errors: [{ cause: { cause: { name: 'RangeError' } } }],
+ });
+ });
+});
+
+test('truncates pathological cause chains and drops metadata that is not JSON-safe', async () => {
+ await withTempWorkspace('rstack-context-test-run-', async (workspaceRoot) => {
+ const testPath = path.join(workspaceRoot, 'tests', 'pathological.test.ts');
+ await mkdir(path.dirname(testPath), { recursive: true });
+ await writeFile(testPath, 'test');
+ const calls: unknown[] = [];
+ const result = createResult({
+ ok: false,
+ files: [
+ {
+ project: 'node',
+ testPath,
+ name: 'pathological.test.ts',
+ status: 'fail',
+ results: [
+ {
+ project: 'node',
+ testPath,
+ name: 'deeply wrapped',
+ status: 'fail',
+ errors: [nestedCause(30)],
+ meta: { fn: (() => undefined) as never },
+ },
+ ],
+ },
+ ],
+ stats: {
+ tests: { total: 1, passed: 0, failed: 1, skipped: 0, todo: 0 },
+ files: { total: 1, failed: 1 },
+ },
+ });
+
+ const capture = await captureTestSnapshot(
+ workspaceRoot,
+ {},
+ createDependencies(result, calls, 'pathological'),
+ );
+ const stored = (await readContextSnapshotById(workspaceRoot, capture.snapshotId))?.snapshot
+ .facets.test;
+ expect(validateTestFacet(stored)).not.toBeUndefined();
+ const storedTest = (
+ stored as unknown as {
+ files: Array<{ tests: Array> }>;
+ }
+ ).files[0].tests[0];
+ expect(causeChainLength((storedTest.errors as unknown[])[0])).toBe(8);
+ expect(storedTest.meta).toBeUndefined();
+ });
+});
+
+type AssertNever = T;
+type UnsupportedRequestFields = AssertNever<
+ Extract<
+ keyof TestSnapshotRequest,
+ 'apply' | 'changed' | 'reporter' | 'shard' | 'update' | 'watch'
+ >
+>;
+
+test('keeps unsupported execution controls out of snapshot requests', () => {
+ const unsupportedRequestField = undefined as UnsupportedRequestFields;
+ expect(unsupportedRequestField).toBeUndefined();
+});
diff --git a/tests/tsconfig.json b/tests/tsconfig.json
new file mode 100644
index 0000000..e6d2c6f
--- /dev/null
+++ b/tests/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "../tsconfig.json",
+ "compilerOptions": {
+ "rootDir": "..",
+ "outDir": "../dist-tests",
+ "types": ["node", "@rstest/core/globals"]
+ },
+ "include": ["../src", "."]
+}
diff --git a/tests/workspace.test.ts b/tests/workspace.test.ts
new file mode 100644
index 0000000..d48d049
--- /dev/null
+++ b/tests/workspace.test.ts
@@ -0,0 +1,101 @@
+import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises';
+import os from 'node:os';
+import path from 'node:path';
+import { expect, test } from '@rstest/core';
+import { resolveContextWorkspace } from '../src/workspace.ts';
+
+const withTempDirectory = async (callback: (rootPath: string) => Promise): Promise => {
+ const rootPath = await realpath(
+ await mkdtemp(path.join(os.tmpdir(), 'rstack-context-workspace-')),
+ );
+
+ try {
+ await callback(rootPath);
+ } finally {
+ await rm(rootPath, { force: true, recursive: true });
+ }
+};
+
+test('resolves a package from its config path without using process cwd', async () => {
+ await withTempDirectory(async (workspaceRoot) => {
+ const packageRoot = path.join(workspaceRoot, 'packages', 'library');
+ const configPath = path.join(packageRoot, 'rslib.config.ts');
+ await mkdir(path.join(workspaceRoot, '.git'));
+ await mkdir(packageRoot, { recursive: true });
+ await writeFile(
+ path.join(workspaceRoot, 'pnpm-workspace.yaml'),
+ "packages:\n - 'packages/*'\n",
+ );
+ await writeFile(
+ path.join(packageRoot, 'package.json'),
+ JSON.stringify({ name: '@repo/library' }),
+ );
+ await writeFile(configPath, 'export default {};\n');
+
+ await expect(resolveContextWorkspace(configPath)).resolves.toEqual({
+ workspaceRoot,
+ packageRoot,
+ packageName: '@repo/library',
+ });
+ });
+});
+
+test('falls back to a standalone package root', async () => {
+ await withTempDirectory(async (packageRoot) => {
+ const configPath = path.join(packageRoot, 'rsbuild.config.ts');
+ await writeFile(path.join(packageRoot, 'package.json'), JSON.stringify({ name: 'standalone' }));
+ await writeFile(configPath, 'export default {};\n');
+
+ await expect(resolveContextWorkspace(configPath)).resolves.toEqual({
+ workspaceRoot: packageRoot,
+ packageRoot,
+ packageName: 'standalone',
+ });
+ });
+});
+
+test('uses the checkout root when a nested package has no workspace manifest', async () => {
+ await withTempDirectory(async (workspaceRoot) => {
+ const packageRoot = path.join(workspaceRoot, 'packages', 'library');
+ await mkdir(path.join(workspaceRoot, '.git'));
+ await mkdir(packageRoot, { recursive: true });
+ await writeFile(path.join(packageRoot, 'package.json'), JSON.stringify({ name: 'library' }));
+
+ await expect(resolveContextWorkspace(packageRoot)).resolves.toEqual({
+ workspaceRoot,
+ packageRoot,
+ packageName: 'library',
+ });
+ });
+});
+
+test('stops workspace discovery at the nearest checkout root', async () => {
+ await withTempDirectory(async (ancestorWorkspaceRoot) => {
+ const checkoutRoot = path.join(ancestorWorkspaceRoot, 'checkouts', 'project');
+ const packageRoot = path.join(checkoutRoot, 'packages', 'library');
+ await mkdir(path.join(checkoutRoot, '.git'), { recursive: true });
+ await mkdir(packageRoot, { recursive: true });
+ await writeFile(path.join(ancestorWorkspaceRoot, 'pnpm-workspace.yaml'), 'packages: []\n');
+ await writeFile(path.join(packageRoot, 'package.json'), JSON.stringify({ name: 'library' }));
+
+ await expect(resolveContextWorkspace(packageRoot)).resolves.toEqual({
+ workspaceRoot: checkoutRoot,
+ packageRoot,
+ packageName: 'library',
+ });
+ });
+});
+
+test('uses the start directory when no workspace markers exist', async () => {
+ await withTempDirectory(async (rootPath) => {
+ const sourceDirectory = path.join(rootPath, 'nested');
+ const configPath = path.join(sourceDirectory, 'rspack.config.js');
+ await mkdir(sourceDirectory);
+ await writeFile(configPath, 'export default {};\n');
+
+ await expect(resolveContextWorkspace(configPath)).resolves.toEqual({
+ workspaceRoot: sourceDirectory,
+ packageRoot: sourceDirectory,
+ });
+ });
+});
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..227d39d
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "compilerOptions": {
+ "rootDir": "./src",
+ "outDir": "./dist",
+ "target": "ES2023",
+ "types": ["node"],
+ "lib": ["ESNext"],
+ "declaration": true,
+ "isolatedDeclarations": true,
+ "skipLibCheck": true,
+ "module": "nodenext",
+ "moduleResolution": "nodenext",
+ "rewriteRelativeImportExtensions": true
+ },
+ "include": ["src"]
+}