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"] +}