From c07288855b40e493314a7c613f466faf413c0adb Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 13 Aug 2026 05:04:47 +0000 Subject: [PATCH 01/46] refactor(context): extract context runtime package --- LICENSE | 21 + README.md | 15 + .../reachability/application/package.json | 4 + .../application/rsdoctor-data.json | 74 ++ .../context/reachability/library/package.json | 17 + .../reachability/library/rsdoctor-data.json | 40 + package.json | 61 + rslib.config.ts | 19 + rstest.config.ts | 8 + src/analysisModel.ts | 170 +++ src/build.ts | 361 ++++++ src/cache.ts | 33 + src/codeEvidence.ts | 394 ++++++ src/config.ts | 17 + src/diff.ts | 229 ++++ src/execution.ts | 551 +++++++++ src/index.ts | 89 ++ src/lint.ts | 550 +++++++++ src/mcp.ts | 858 +++++++++++++ src/model.ts | 319 +++++ src/products.ts | 174 +++ src/queries.ts | 647 ++++++++++ src/reachability.ts | 83 ++ src/records.ts | 356 ++++++ src/report.ts | 151 +++ src/rsdoctor.ts | 460 +++++++ src/rsdoctorGraph.ts | 187 +++ src/source.ts | 198 +++ src/status.ts | 85 ++ src/store.ts | 346 ++++++ src/testRun.ts | 405 +++++++ src/workspace.ts | 96 ++ tests/build.test.ts | 716 +++++++++++ tests/codeEvidence.test.ts | 640 ++++++++++ tests/diff.test.ts | 652 ++++++++++ tests/lint.test.ts | 651 ++++++++++ tests/products.test.ts | 158 +++ tests/queries.test.ts | 757 ++++++++++++ tests/reachability.test.ts | 82 ++ tests/records.test.ts | 452 +++++++ tests/report.test.ts | 140 +++ tests/rsdoctor.test.ts | 301 +++++ tests/rsdoctorGraph.test.ts | 211 ++++ tests/source.test.ts | 176 +++ tests/status.test.ts | 152 +++ tests/store.test.ts | 408 +++++++ tests/testRun.test.ts | 1061 +++++++++++++++++ tests/tsconfig.json | 9 + tests/workspace.test.ts | 99 ++ tsconfig.json | 16 + 50 files changed, 13699 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 fixtures/context/reachability/application/package.json create mode 100644 fixtures/context/reachability/application/rsdoctor-data.json create mode 100644 fixtures/context/reachability/library/package.json create mode 100644 fixtures/context/reachability/library/rsdoctor-data.json create mode 100644 package.json create mode 100644 rslib.config.ts create mode 100644 rstest.config.ts create mode 100644 src/analysisModel.ts create mode 100644 src/build.ts create mode 100644 src/cache.ts create mode 100644 src/codeEvidence.ts create mode 100644 src/config.ts create mode 100644 src/diff.ts create mode 100644 src/execution.ts create mode 100644 src/index.ts create mode 100644 src/lint.ts create mode 100644 src/mcp.ts create mode 100644 src/model.ts create mode 100644 src/products.ts create mode 100644 src/queries.ts create mode 100644 src/reachability.ts create mode 100644 src/records.ts create mode 100644 src/report.ts create mode 100644 src/rsdoctor.ts create mode 100644 src/rsdoctorGraph.ts create mode 100644 src/source.ts create mode 100644 src/status.ts create mode 100644 src/store.ts create mode 100644 src/testRun.ts create mode 100644 src/workspace.ts create mode 100644 tests/build.test.ts create mode 100644 tests/codeEvidence.test.ts create mode 100644 tests/diff.test.ts create mode 100644 tests/lint.test.ts create mode 100644 tests/products.test.ts create mode 100644 tests/queries.test.ts create mode 100644 tests/reachability.test.ts create mode 100644 tests/records.test.ts create mode 100644 tests/report.test.ts create mode 100644 tests/rsdoctor.test.ts create mode 100644 tests/rsdoctorGraph.test.ts create mode 100644 tests/source.test.ts create mode 100644 tests/status.test.ts create mode 100644 tests/store.test.ts create mode 100644 tests/testRun.test.ts create mode 100644 tests/tsconfig.json create mode 100644 tests/workspace.test.ts create mode 100644 tsconfig.json 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 new file mode 100644 index 0000000..e56d91e --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# 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). 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..ef25ab3 --- /dev/null +++ b/package.json @@ -0,0 +1,61 @@ +{ + "name": "@rstackjs/context", + "version": "0.5.0-alpha.1", + "description": "Rstack project context, evidence storage, and MCP runtime.", + "homepage": "https://rstack.rs", + "bugs": { + "url": "https://github.com/rstackjs/rstack-cli/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/rstackjs/rstack-cli.git", + "directory": "packages/context" + }, + "license": "MIT", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./mcp": { + "types": "./dist/mcp.d.ts", + "default": "./dist/mcp.js" + }, + "./package.json": "./package.json" + }, + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "rslib", + "dev": "rslib --watch", + "test": "rstest" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "catalog:", + "@rsbuild/core": "catalog:", + "@rsdoctor/agent-cli": "catalog:", + "@rslint/core": "catalog:", + "@rstest/core": "catalog:", + "zod": "catalog:" + }, + "devDependencies": { + "@rslib/core": "catalog:", + "@rstest/coverage-istanbul": "catalog:", + "@types/node": "catalog:", + "fast-json-stable-stringify": "catalog:", + "typescript": "catalog:" + }, + "peerDependencies": { + "@rstest/coverage-istanbul": "0.11.6" + }, + "engines": { + "node": ">=22.12.0" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + } +} diff --git a/rslib.config.ts b/rslib.config.ts new file mode 100644 index 0000000..409d776 --- /dev/null +++ b/rslib.config.ts @@ -0,0 +1,19 @@ +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', + }, + define: { + RSTACK_CONTEXT_VERSION: JSON.stringify(pkgJson.version), + }, + }, + output: { + minify: false, + }, +}); 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..1579ac4 --- /dev/null +++ b/src/analysisModel.ts @@ -0,0 +1,170 @@ +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'; + 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: 'queued' | 'running' | 'pass' | 'fail' | 'cancelled' | 'error'; + buildCompleteness?: 'complete' | 'partial' | 'disabled' | 'unsupported'; + }; +}; + +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/build.ts b/src/build.ts new file mode 100644 index 0000000..9fd1445 --- /dev/null +++ b/src/build.ts @@ -0,0 +1,361 @@ +import { createHash, randomUUID } from 'node:crypto'; +import path from 'node:path'; +import type { + ConfigParams, + EnvironmentContext, + OnAfterEnvironmentCompileFn, + OnBeforeBuildFn, + RsbuildConfig, + RsbuildPlugin, +} from '@rsbuild/core'; +import { + contextStoreSchemaVersion, + type BuildMetadataFacet, + type ContextDescriptor, + type ContextInputFile, + type ContextRunManifest, + type ContextSnapshot, + type ContextStoreWriteResult, +} from './model.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 normalizeWorkspacePath = (workspaceRoot: string, value: string): string => + path.relative(workspaceRoot, value).split(path.sep).join('/') || '.'; + +const getTarget = (environment: EnvironmentContext): string => environment.config.output.target; + +const getDistPath = (workspaceRoot: string, environment: EnvironmentContext): string => + normalizeWorkspacePath(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) + ? normalizeWorkspacePath(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 { + assets?: Array<{ name?: unknown; size?: unknown }>; + chunks?: Array<{ files?: unknown; id?: unknown; initial?: unknown }>; + hash?: unknown; + }; + 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 = normalizeWorkspacePath( + options.workspace.workspaceRoot, + options.workspace.packageRoot, + ); + const configPath = + options.configPath === undefined + ? undefined + : normalizeWorkspacePath(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_${createHash('sha256').update(identity).digest('hex').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..cb215c8 --- /dev/null +++ b/src/codeEvidence.ts @@ -0,0 +1,394 @@ +import { createHash } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import type { DeadCodeExplanation } from './analysisModel.ts'; +import { diagnosticsFromStoredSnapshot, type DiagnosticRecord } from './lint.ts'; +import type { + ContextFreshness, + ContextSnapshot, + StoredContextSnapshot, + TestExecutionFacet, + TestExecutionLocation, + TestFacet, +} from './model.ts'; +import { explainDeadCodeCandidate, readProductRoots } 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; +}; + +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'; + reason?: 'no-exact-test-record'; + matchingFiles: number; + matchingTests: number; +}; + +type CodeDiagnosticEvidence = { + total: number; + returned: number; + truncated: boolean; + items: DiagnosticRecord[]; +}; + +type CodeEvidenceResult = { + path: string; + line?: number; + executionCoverage: ExecutionCoverageEvidence; + 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 = path.posix.normalize(portable).replace(/^\.\//u, ''); + const relative = path + .relative(workspaceRoot, path.resolve(workspaceRoot, normalized)) + .split(path.sep) + .join('/'); + 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 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.`, + ); + } + return selected; + } + return (await readContextSnapshots(workspaceRoot, { producer })).find( + ({ context, snapshot }) => + packageContainsPath(context.packageRoot, sourcePath) && + snapshot.facets[producer === 'rstest' ? 'test' : 'lint'] !== undefined, + ); +}; + +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, +}); + +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 createHash('sha256') + .update(await readFile(path.resolve(workspaceRoot, sourcePath))) + .digest('hex'); + } 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); + if (files.length === 0 && tests.length === 0) { + return { + state: 'unknown', + reason: 'no-exact-test-record', + matchingFiles: 0, + matchingTests: 0, + }; + } + if ( + files.some((file) => file.status === 'fail') || + tests.some((test) => test.status === 'fail') + ) { + return { state: 'failed', matchingFiles: files.length, matchingTests: tests.length }; + } + if ( + files.some((file) => file.status === 'pass') || + tests.some((test) => test.status === 'pass') + ) { + return { state: 'passed', matchingFiles: files.length, matchingTests: tests.length }; + } + return { state: 'not-run', matchingFiles: files.length, matchingTests: tests.length }; +}; + +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 => { + if (query.module !== undefined) { + return explainDeadCodeCandidate(workspaceRoot, { + contextId: query.contextId, + dataFile: query.dataFile, + module: query.module, + maxDepth: query.maxDepth, + }); + } + const roots = await readProductRoots(workspaceRoot, query); + const packageRelativePath = + roots.product.packageRoot === '.' + ? query.path + : query.path.startsWith(`${roots.product.packageRoot}/`) + ? query.path.slice(roots.product.packageRoot.length + 1) + : query.path; + const insufficientEvidence = (): DeadCodeExplanation => ({ + provenance: roots.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: [...roots.product.bounds, 'source-path-module-match-unavailable'], + }); + try { + return await explainDeadCodeCandidate(workspaceRoot, { + contextId: query.contextId, + dataFile: query.dataFile, + 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 await explainDeadCodeCandidate(workspaceRoot, { + contextId: query.contextId, + dataFile: query.dataFile, + 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-outcome-exact-path-only', + '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), + testOutcome: testOutcome(sourcePath, testSnapshot), + diagnostics, + ...(module === undefined ? {} : { module }), + provenance, + bounds, + }; +}; + +export { readCodeEvidence }; +export type { + CodeDiagnosticEvidence, + CodeEvidenceQuery, + CodeEvidenceResult, + ExecutionCoverageEvidence, + SnapshotEvidence, + TestOutcomeEvidence, +}; 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..654e357 --- /dev/null +++ b/src/diff.ts @@ -0,0 +1,229 @@ +import { isDeepStrictEqual } from 'node:util'; +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'; + +type SnapshotDiagnostic = { + path: string; + ruleId: string | null; + severity: 'error' | 'warning'; + message: string; + line: number; + column: number; + endLine?: number; + endColumn?: number; + fixable: boolean; +}; + +type SnapshotTestFileError = { + kind: 'file-error'; + project: string; + path: string; + error: TestErrorRecord; +}; + +type SnapshotDiffItem = SnapshotDiagnostic | SnapshotTestFileError | 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: SnapshotTestFileError | TestCaseRecord): string => + 'kind' in result + ? JSON.stringify([result.project, result.path, result.kind, result.error.name]) + : JSON.stringify([result.project, result.path, result.parentNames ?? [], result.name]); + +const lintDiagnostics = (facet: LintFacet): SnapshotDiagnostic[] => + 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 testResults = (facet: TestFacet): Array => + facet.files.flatMap((file) => [ + ...(file.errors ?? []).map((error) => ({ + kind: 'file-error' as const, + project: file.project, + path: file.path, + error, + })), + ...file.tests, + ]); + +const diffItems = ( + leftItems: T[], + rightItems: T[], + identity: (item: T) => string, +): 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) => isDeepStrictEqual(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'); + + 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( + lintDiagnostics(leftFacet as LintFacet), + lintDiagnostics(rightFacet as LintFacet), + diagnosticIdentity, + ) + : diffItems( + testResults(leftFacet as TestFacet), + testResults(rightFacet as TestFacet), + testIdentity, + ); + + 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..93b8a65 --- /dev/null +++ b/src/execution.ts @@ -0,0 +1,551 @@ +import { createHash } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import type { TestRunResult } from '@rstest/core/api'; +import type { + TestExecutionBranch, + TestExecutionFacet, + TestExecutionFile, + TestExecutionFunction, + TestExecutionLocation, + TestExecutionRequestedSelection, + TestExecutionStatement, +} from './model.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 sha256Pattern = /^[0-9a-f]{64}$/u; + +const isRecordObject = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +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 hasOnlyKeys = (value: Record, keys: readonly string[]): boolean => { + const allowed = new Set(keys); + return Object.keys(value).every((key) => allowed.has(key)); +}; + +const toWorkspacePath = (workspaceRoot: string, filePath: string): string => + path.relative(workspaceRoot, path.resolve(workspaceRoot, filePath)).split(path.sep).join('/'); + +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 }, + }; +}; + +const sortedRecordEntries = (value: Record): Array<[string, unknown]> => + Object.entries(value).sort(([left], [right]) => left.localeCompare(right)); + +const unwrapCoverageFile = (value: unknown): Record | undefined => { + if (!isRecordObject(value)) return undefined; + return isRecordObject(value.data) ? value.data : value; +}; + +const normalizeExecutionFile = ( + workspaceRoot: string, + packageRoot: string, + mapPath: string, + rawValue: unknown, +): 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 = 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 = 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: createHash('sha256').update(JSON.stringify(facet)).digest('hex'), +}); + +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 = createHash('sha256') + .update(await readFile(candidate.absolutePath)) + .digest('hex'); + } 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): boolean => + 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: Record): number => { + const statements = file.statements as unknown[]; + const functions = file.functions as unknown[]; + const branches = file.branches as Array<{ arms: unknown[] }>; + 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 as number) + (value.universe.droppedFiles as number) || + value.universe.reportedLocations !== + (value.universe.storedLocations as number) + (value.universe.droppedLocations as number) || + !isRecordObject(value.truncated) || + !hasOnlyKeys(value.truncated, ['files', 'locations']) || + !isNonNegativeInteger(value.truncated.files) || + !isNonNegativeInteger(value.truncated.locations) || + value.truncated.files > (value.universe.droppedFiles as number) || + value.truncated.locations > (value.universe.droppedLocations as number) || + !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) => (file as { path: string }).path)).size !== + value.files.length || + value.files.some( + (file) => + countExecutionLocations(file as Record) > + executionBounds.maxLocationsPerFile, + ) || + value.universe.storedLocations > executionBounds.maxLocationsTotal || + value.files.reduce( + (total, file) => total + countExecutionLocations(file as Record), + 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/index.ts b/src/index.ts new file mode 100644 index 0000000..42da441 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,89 @@ +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, +} 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 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'; diff --git a/src/lint.ts b/src/lint.ts new file mode 100644 index 0000000..13c5f71 --- /dev/null +++ b/src/lint.ts @@ -0,0 +1,550 @@ +import { createHash } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import type { LintMessage, LintResult, RslintOptions } from '@rslint/core'; +import { + contextStoreSchemaVersion, + type ContextFreshness, + type ContextRunStatus, + type ContextSnapshot, + type LintFacet, + type LintFileRecord, + type LintMessageRecord, + type StoredContextSnapshot, + type TestErrorRecord, + type TestFacet, +} from './model.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']; +}; + +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 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 digest = (content: string | Buffer): string => + createHash('sha256').update(content).digest('hex'); + +const toWorkspacePath = (workspaceRoot: string, filePath: string): string => + path + .relative(path.resolve(workspaceRoot), path.resolve(workspaceRoot, filePath)) + .split(path.sep) + .join('/'); + +const compareStrings = (left: string, right: string): number => + left === right ? 0 : left < right ? -1 : 1; + +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 normalizeResult = async ( + workspaceRoot: string, + result: LintResult, + includeFixPreview: boolean, + textCode?: string, +): Promise => { + const filePath = toWorkspacePath(workspaceRoot, result.filePath); + const source = textCode ?? (await readFile(result.filePath, 'utf8')); + const fileDigest = digest(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 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 => { + 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 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) => { + if (createRslint === undefined) { + throw new Error('Rstack lint capture requires a config adapter.'); + } + return 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(); + } + }); + } 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' ? digest(request.code) : digest(''), + 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' }, + }; + ensureWritten(await writeContextSnapshot(workspaceRoot, snapshot)); + throw error; + } + + const files = ( + await Promise.all( + results.map((result) => + normalizeResult( + workspaceRoot, + result, + includeFixPreview, + request.mode === 'text' ? request.code : undefined, + ), + ), + ) + ).sort((left, right) => compareStrings(left.path, right.path)); + const totals = totalsFor(files); + const facet: LintFacet = { + producer: 'rslint', + mode: request.mode, + fixPreviewCaptured: includeFixPreview, + files, + totals, + }; + const source: ContextSnapshot['source'] = + request.mode === 'text' + ? { virtualInputDigest: digest(request.code) } + : { + inputs: files.map(({ path: filePath, digest: fileDigest }) => ({ + path: filePath, + digest: fileDigest, + })), + inputCompleteness: 'complete', + }; + 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: 'complete' }, + 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, + }; +}; + +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 lintDiagnostics = (facet: LintFacet): DiagnosticRecord[] => + facet.files.flatMap((file) => + file.messages.map((message) => ({ + producer: 'rslint' as const, + 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 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 decodeCursor = (cursor: string | undefined): number => { + if (cursor === undefined) return 0; + const decoded = Buffer.from(cursor, 'base64url').toString('utf8'); + const offset = Number(decoded); + if ( + !/^(?:0|[1-9]\d*)$/u.test(decoded) || + Buffer.from(decoded).toString('base64url') !== cursor || + !Number.isSafeInteger(offset) + ) { + throw new Error('Invalid diagnostics cursor.'); + } + return offset; +}; + +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 offset = decodeCursor(query.cursor); + 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: Buffer.from(String(nextOffset)).toString('base64url') } + : {}), + }; +}; + +const getLintFixPreview = async ( + workspaceRoot: string, + snapshotId: string, + filePath: string, +): Promise => { + const stored = await readContextSnapshotById(workspaceRoot, snapshotId); + const facet = stored === undefined ? undefined : 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, listDiagnostics }; +export type { + DiagnosticPage, + DiagnosticRecord, + DiagnosticsQuery, + LintCaptureResult, + LintCaptureAdapter, + LintFixPreviewResult, + LintSnapshotRequest, + RslintFactory, +}; diff --git a/src/mcp.ts b/src/mcp.ts new file mode 100644 index 0000000..b2b616b --- /dev/null +++ b/src/mcp.ts @@ -0,0 +1,858 @@ +// 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 { captureLintSnapshot, getLintFixPreview, listDiagnostics } from './lint.ts'; +import { + explainDeadCodeCandidate, + findUnusedCandidates, + readProductRoots, + traceModuleImpact, +} from './queries.ts'; +import { validateLintFacet } from './records.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 TestSnapshotRequest } from './testRun.ts'; + +declare const RSTACK_CONTEXT_VERSION: string; + +const renderProjectStatus = (status: Awaited>): string => + `Rstack project status: ${status.contexts.length} current context${status.contexts.length === 1 ? '' : 's'} (${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 details.`; + +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; defaults to the checkout root.'); +const configPathInput = z + .string() + .min(1) + .describe('Checkout-relative Rstack config path; 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.optional(), + dataFile: rsdoctorDataFileInput.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(), + 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 isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const formatStructuredResult = (result: unknown): string => { + if (!isRecord(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 (isRecord(result.ownership)) { + addDetail('project', result.ownership.project); + addDetail('dependency', result.ownership.dependency); + } + + if (isRecord(result.graph)) { + addDetail('moduleCount', result.graph.moduleCount); + addDetail('edgeCount', result.graph.edgeCount); + } + + if (isRecord(result.summary)) { + for (const key of [ + 'files', + 'tests', + 'passed', + 'failed', + 'errors', + 'warnings', + 'added', + 'removed', + 'changed', + ]) { + addDetail(key, result.summary[key]); + } + } + + 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 = Object.fromEntries( + result.product.roots.map(({ kind }) => kind).map((kind) => [kind, 0]), + ) as 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}, 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) || + (isRecord(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 isRecord(value) && Object.values(value).every(isRecursivelyZeroShaped); +}; + +const findUnavailableSections = (value: unknown): string[] => { + if (!isRecord(value) || !Array.isArray(value.sectionEvidence)) return []; + return value.sectionEvidence + .flatMap((section) => { + if ( + !isRecord(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; + serverVersion?: string; +}; + +const readOnlyAnnotations = { + readOnlyHint: true, + destructiveHint: false, + openWorldHint: false, +} as const; + +const decodeSnapshotCursor = (cursor: string | undefined): number => { + if (cursor === undefined) return 0; + const value = Buffer.from(cursor, 'base64url').toString('utf8'); + if (!/^(?:0|[1-9]\d*)$/u.test(value) || Buffer.from(value).toString('base64url') !== cursor) { + throw new Error('Invalid snapshot cursor.'); + } + return Number(value); +}; + +const listSnapshots = async (workspaceRoot: string, input: z.infer) => { + const snapshots = await readContextSnapshots(workspaceRoot, { + producer: input.producer, + contextId: input.contextId, + }); + const offset = decodeSnapshotCursor(input.cursor); + 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: Buffer.from(String(nextOffset)).toString('base64url') } + : {}), + }; +}; + +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: { + readOnlyHint: true, + destructiveHint: false, + openWorldHint: false, + }, + }, + async () => { + const status = await readProjectStatus(workspaceRoot); + return { + content: [{ type: 'text', text: renderProjectStatus(status) }], + structuredContent: status, + }; + }, + ); + + server.registerTool( + 'product_roots', + { + title: 'Resolve product roots', + description: 'Return selected roots for one explicit Rsdoctor module graph.', + inputSchema: productRootsInput, + annotations: { + readOnlyHint: true, + destructiveHint: false, + openWorldHint: false, + }, + }, + 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: { + readOnlyHint: true, + destructiveHint: false, + openWorldHint: false, + }, + }, + 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: { + readOnlyHint: true, + destructiveHint: false, + openWorldHint: false, + }, + }, + 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: { + readOnlyHint: true, + destructiveHint: false, + openWorldHint: false, + }, + }, + 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 exact-path aggregate execution, test outcome, 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, + ); + return toStructuredMcpResult(result); + } catch (error) { + return toMcpError(error); + } + }, + ); + + server.registerTool( + 'test_snapshot', + { + title: 'Capture test snapshot', + description: 'Run one explicit one-shot Rstest capture and store its immutable results.', + inputSchema: testSnapshotInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + openWorldHint: true, + }, + }, + async (input) => { + try { + const result = await (dependencies.captureTestSnapshot ?? captureTestSnapshot)( + workspaceRoot, + input as TestSnapshotRequest, + ); + 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: { + readOnlyHint: true, + destructiveHint: false, + openWorldHint: false, + }, + }, + async ({ dataFile, input, toolName }) => { + try { + const analysis = await (dependencies.analyzeRsdoctorArtifact ?? analyzeRsdoctorArtifact)( + workspaceRoot, + { + dataFile, + input, + toolName, + }, + ); + const analysisData = + typeof analysis.result === 'object' && + analysis.result !== null && + !Array.isArray(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 an explicit checkout-local Rsdoctor report artifact when present.', + inputSchema: reportLinkInput, + annotations: { + readOnlyHint: true, + destructiveHint: false, + openWorldHint: false, + }, + }, + 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..6764ec6 --- /dev/null +++ b/src/model.ts @@ -0,0 +1,319 @@ +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; + virtualInputDigest?: string; +}; + +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; +}; + +type TestCaseRecord = { + project: string; + path: string; + name: string; + parentNames?: string[]; + status: 'skip' | 'pass' | 'fail' | 'todo'; + durationMs?: number; + errors?: TestErrorRecord[]; + retryErrors?: TestErrorRecord[]; + retryCount?: number; +}; + +type TestFileRecord = { + project: string; + path: string; + status: 'skip' | 'pass' | 'fail' | 'todo'; + durationMs?: number; + errors?: TestErrorRecord[]; + tests: TestCaseRecord[]; +}; + +type TestFacet = { + producer: 'rstest'; + 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; + 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, +}; diff --git a/src/products.ts b/src/products.ts new file mode 100644 index 0000000..4961683 --- /dev/null +++ b/src/products.ts @@ -0,0 +1,174 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import type { + ContractField, + ContractTarget, + ObservedModule, + ObservedModuleGraph, + ProductRoot, + ProductRootSet, +} from './analysisModel.ts'; +import type { ContextDescriptor } from './model.ts'; + +const isObject = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const compareStrings = (left: string, right: string): number => + left === right ? 0 : left < right ? -1 : 1; + +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 (isObject(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 (!isObject(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 normalizeTarget = (value: string): string => + path.posix.normalize(value.replaceAll('\\', '/')).replace(/^\.\//u, ''); + +const matchesTarget = (module: ObservedModule, target: string, packageRoot: string): boolean => { + const modulePath = normalizeTarget(module.path); + const normalizedTarget = normalizeTarget(target); + if (modulePath.split('/').includes('node_modules')) return false; + const normalizedPackageRoot = normalizeTarget(packageRoot); + const scopedTarget = + normalizedPackageRoot === '.' + ? normalizedTarget + : normalizeTarget(`${normalizedPackageRoot}/${normalizedTarget}`); + return modulePath === scopedTarget || modulePath.endsWith(`/${scopedTarget}`); +}; + +const toRootModule = ({ + isEntry: _, + optimizerBound: __, + optimizerReasons: ___, + ...module +}: ObservedModule) => 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 entries = graph.modules.filter(({ isEntry }) => isEntry); + for (const module of entries) { + addRoot(roots, { + kind: 'production-entry', + module: toRootModule(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 { + 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 = graph.modules.find(({ id }) => id === moduleId)!; + addRoot(roots, { + kind: 'published-contract', + module: toRootModule(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: toRootModule(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: toRootModule(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 }; diff --git a/src/queries.ts b/src/queries.ts new file mode 100644 index 0000000..c6f8ce2 --- /dev/null +++ b/src/queries.ts @@ -0,0 +1,647 @@ +import path from 'node:path'; +import type { + AnalysisProvenance, + DeadCodeExplanation, + ModuleCandidate, + ModuleImpactResult, + ModulePath, + ModuleRef, + ModuleState, + ObservedModule, + ObservedModuleGraph, + ProductRoot, + ProductRootSet, + ProductRootsResult, + UnusedCandidatesResult, +} from './analysisModel.ts'; +import type { ContextDescriptor, ContextSnapshot } from './model.ts'; +import { resolveProductRoots } 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 compareStrings = (left: string, right: string): number => + left === right ? 0 : left < right ? -1 : 1; + +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 isObject = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const getString = (value: unknown): string | undefined => + typeof value === 'string' && value.length > 0 ? value : undefined; + +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 (!isObject(build)) return 'explicit-unverified'; + + const snapshotHash = getString(build.hash); + const snapshotEnvironment = getString(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 matchingCompilers = metadata.build.compilers.filter( + (compiler) => + compiler.environment === snapshotEnvironment || compiler.name === snapshotEnvironment, + ); + if (matchingCompilers.length !== 1) return 'mismatch'; + identity = matchingCompilers[0]!; + artifactEnvironment = identity.environment ?? matchingCompilers[0]!.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 toModuleRef = ({ + isEntry: _, + optimizerBound: __, + optimizerReasons: ___, + ...module +}: ObservedModule): ModuleRef => module; + +const toSubject = (module: ObservedModule): ModuleCandidate['subject'] => ({ + kind: 'module', + ...toModuleRef(module), +}); + +const normalizeSelector = (value: string): string => + path.posix.normalize(value.replaceAll('\\', '/')).replace(/^\.\//u, ''); + +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 decodeUnusedCandidatesCursor = (cursor: string | undefined): number => { + if (cursor === undefined) return 0; + const value = Buffer.from(cursor, 'base64url').toString('utf8'); + const offset = Number(value); + if ( + !/^(?:0|[1-9]\d*)$/u.test(value) || + Buffer.from(value).toString('base64url') !== cursor || + !Number.isSafeInteger(offset) + ) { + throw new Error('Invalid unused candidates cursor.'); + } + return offset; +}; + +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 resolveProductRoots(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 byId = graph.modules.find(({ id }) => id === selector); + if (byId !== undefined) return byId; + + const normalized = normalizeSelector(selector); + const exact = graph.modules.filter( + (module) => + normalizeSelector(module.path) === normalized || + normalizeSelector(module.name) === normalized, + ); + if (exact.length === 1) return exact[0]!; + if (exact.length > 1) throw new Error(`Ambiguous module selector: ${selector}`); + + const suffix = graph.modules.filter(({ path: modulePath }) => { + const normalizedPath = normalizeSelector(modulePath); + return normalizedPath === normalized || normalizedPath.endsWith(`/${normalized}`); + }); + if (suffix.length === 1) return suffix[0]!; + if (suffix.length > 1) throw new Error(`Ambiguous module selector: ${selector}`); + 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 readProductRoots = async ( + workspaceRoot: string, + query: ArtifactQuery, +): Promise => { + const { provenance, graph, product } = await loadAnalysis(workspaceRoot, query); + return { + provenance, + graph: { + moduleCount: graph.modules.length, + edgeCount: graph.edges.length, + issues: graph.issues, + }, + product, + }; +}; + +const findUnusedCandidates = async ( + workspaceRoot: string, + query: UnusedCandidatesQuery, +): Promise => { + const limit = validateLimit(query.limit); + const offset = decodeUnusedCandidatesCursor(query.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: Buffer.from(String(nextOffset)).toString('base64url') } + : {}), + bounds, + }; +}; + +const explainDeadCodeCandidate = async ( + workspaceRoot: string, + query: ExplanationQuery, +): Promise => { + const maxDepth = validateMaxDepth( + query.maxDepth, + candidateTraversalOptions.maxDepth, + candidateTraversalOptions.maxDepth, + ); + const { provenance, graph, product } = await loadAnalysis(workspaceRoot, query); + 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 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 { explainDeadCodeCandidate, findUnusedCandidates, readProductRoots, traceModuleImpact }; +export type { ArtifactQuery, ExplanationQuery, ImpactQuery, UnusedCandidatesQuery }; diff --git a/src/reachability.ts b/src/reachability.ts new file mode 100644 index 0000000..a03a51b --- /dev/null +++ b/src/reachability.ts @@ -0,0 +1,83 @@ +import type { ObservedModule, ObservedModuleGraph } from './analysisModel.ts'; + +type TraversalOptions = { + maxDepth: number; + maxVisited: number; +}; + +type TraversalResult = { + visited: string[]; + predecessor: ReadonlyMap; + depth: ReadonlyMap; + truncated: boolean; +}; + +const compareStrings = (left: string, right: string): number => + left === right ? 0 : left < right ? -1 : 1; + +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 { traceModuleGraph }; +export type { TraversalOptions, TraversalResult }; diff --git a/src/records.ts b/src/records.ts new file mode 100644 index 0000000..991402c --- /dev/null +++ b/src/records.ts @@ -0,0 +1,356 @@ +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'; + +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 sha256Pattern = /^[0-9a-f]{64}$/u; + +const isRecordObject = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const isIdentifier = (value: unknown): value is string => + typeof value === 'string' && value.length > 0; + +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 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 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 isTestError = (value: unknown): 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)); + +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 || + (Array.isArray(value.errors) && value.errors.every(isTestError))) && + (value.retryErrors === undefined || + (Array.isArray(value.retryErrors) && value.retryErrors.every(isTestError))) && + (value.retryCount === undefined || isNonNegativeInteger(value.retryCount)); + +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 || + (Array.isArray(value.errors) && value.errors.every(isTestError))) && + Array.isArray(value.tests) && + value.tests.every(isTestCase); + +const validateTestFacet = (value: unknown): TestFacet | undefined => { + if ( + !isRecordObject(value) || + value.producer !== 'rstest' || + !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) || + !Array.isArray(value.unhandledErrors) || + !value.unhandledErrors.every(isTestError) + ) { + return undefined; + } + return value as TestFacet; +}; + +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); +}; + +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.virtualInputDigest === undefined || + (typeof value.virtualInputDigest === 'string' && + sha256Pattern.test(value.virtualInputDigest))) + ); +}; + +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 compareDescending = (left: string, right: string): number => + left === right ? 0 : left > right ? -1 : 1; + +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 compareDescending(left, right); + } + if (leftGeneration.sequence !== rightGeneration.sequence) { + return leftGeneration.sequence > rightGeneration.sequence ? -1 : 1; + } + return ( + compareDescending(leftGeneration.snapshotId, rightGeneration.snapshotId) || + compareDescending(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..4e442dc --- /dev/null +++ b/src/report.ts @@ -0,0 +1,151 @@ +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 getSiblingHtmlReports = async (directory: string): Promise => { + try { + return (await readdir(directory, { withFileTypes: true })) + .filter((entry) => (entry.isFile() || entry.isSymbolicLink()) && entry.name.endsWith('.html')) + .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 getSiblingHtmlReports(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/rsdoctor.ts b/src/rsdoctor.ts new file mode 100644 index 0000000..7c4a900 --- /dev/null +++ b/src/rsdoctor.ts @@ -0,0 +1,460 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +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[]; +}; + +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 isObject = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +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 isObject(value); + case 'string': + return typeof value === 'string'; + default: + return true; + } +}; + +const matchesJsonSchema = (value: unknown, schema: unknown): boolean => { + if (!isObject(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) && isObject(schema.items)) { + return value.every((entry) => matchesJsonSchema(entry, schema.items)); + } + + if (!isObject(value)) { + return true; + } + + const properties = isObject(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 (isObject(schema.additionalProperties)) { + if (!matchesJsonSchema(entry, schema.additionalProperties)) { + return false; + } + } + } + + return true; +}; + +const getInput = (input: unknown, tool: RsdoctorToolDescriptor): Record => { + const resolvedInput = input === undefined ? {} : input; + if (!isObject(resolvedInput) || !matchesJsonSchema(resolvedInput, tool.inputSchema)) { + throw new Error('Rsdoctor tool input does not match its schema.'); + } + + return resolvedInput; +}; + +const getOptionalString = (value: unknown): string | undefined => + typeof value === 'string' && value.length > 0 ? value : undefined; + +const getCompilationIdentity = ( + value: Record, +): RsdoctorArtifactCompilationIdentity | undefined => { + const compilationHash = getOptionalString(value.compilationHash); + const environment = getOptionalString(value.environment); + const target = getOptionalString(value.target); + const targets = Array.isArray(value.target) + ? value.target.filter((entry): entry is string => getOptionalString(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 (!isObject(value) || value.schemaVersion !== 1) return undefined; + if (!isObject(value.producer) || !isObject(value.output) || !isObject(value.build)) { + return undefined; + } + if (!isObject(value.build.compiler) || !isObject(value.sections)) return undefined; + + const producerName = getOptionalString(value.producer.name); + const producerVersion = getOptionalString(value.producer.version); + const mode = value.output.mode; + const id = getOptionalString(value.build.id); + const root = getOptionalString(value.build.root); + const compilerName = getOptionalString(value.build.compiler.name); + const compilerType = getOptionalString(value.build.compiler.type); + const compilerVersion = getOptionalString(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 (!isObject(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 (!isObject(entry)) return undefined; + const name = getOptionalString(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 (!isObject(parsed) || !isObject(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 (!isObject(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 { + throw new Error('Rsdoctor analysis failed.'); + } + + const metadata = artifact.metadata; + return { + dataFile: request.dataFile, + 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..84a1dbc --- /dev/null +++ b/src/rsdoctorGraph.ts @@ -0,0 +1,187 @@ +import path from 'node:path'; +import type { ObservedModule, ObservedModuleGraph, OptimizerBound } from './analysisModel.ts'; +import { readRsdoctorArtifact, type RsdoctorArtifact } from './rsdoctor.ts'; + +const isObject = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const compareStrings = (left: string, right: string): number => + left === right ? 0 : left < right ? -1 : 1; + +const getString = (value: unknown): string | undefined => + typeof value === 'string' && value.length > 0 ? value : undefined; + +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 (!isObject(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 (!isObject(value)) return undefined; + const id = getId(value.id); + if (id === undefined) return undefined; + + const rawPath = + getString(value.path) ?? getString(value.webpackId) ?? getString(value.name) ?? ''; + const rawName = getString(value.webpackId) ?? getString(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 compareModules = (left: ObservedModule, right: ObservedModule): number => + compareStrings(left.path, right.path) || + compareStrings(left.name, right.name) || + compareStrings(left.id, right.id); + +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 (!isObject(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 (!isObject(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'] = []; + for (const row of Array.isArray(moduleGraph.dependencies) ? moduleGraph.dependencies : []) { + if (!isObject(row)) continue; + const usesDependencyShape = Object.hasOwn(row, 'dependency'); + const from = getId(usesDependencyShape ? row.module : row.issuer); + const to = getId(usesDependencyShape ? row.dependency : row.module); + if (from === undefined || to === undefined) continue; + if (!modulesById.has(from) || !modulesById.has(to)) { + if (!issues.includes('dangling-edge')) issues.push('dangling-edge'); + continue; + } + const key = `${from}\u0000${to}`; + if (edgeKeys.has(key)) continue; + edgeKeys.add(key); + edges.push({ from, to }); + } + + 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/source.ts b/src/source.ts new file mode 100644 index 0000000..a25b79a --- /dev/null +++ b/src/source.ts @@ -0,0 +1,198 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { existsSync } from 'node:fs'; +import { access, readFile } from 'node:fs/promises'; +import path from 'node:path'; +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 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 toWorkspacePath = (workspaceRoot: string, filePath: string): string => { + const relativePath = path + .relative(path.resolve(workspaceRoot), path.resolve(workspaceRoot, filePath)) + .split(path.sep) + .join('/'); + return relativePath || '.'; +}; + +const digest = (content: string | Buffer): string => + createHash('sha256').update(content).digest('hex'); + +const resolveInternalConfigPath = (moduleDirectory: string, fileName: string): string => { + const siblingPath = path.join(moduleDirectory, fileName); + return existsSync(siblingPath) ? siblingPath : path.join(moduleDirectory, '..', fileName); +}; + +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 = path.resolve(workspaceRoot, request.packageRoot ?? '.'); + const configPath = + request.configPath === undefined + ? await findPackageConfig(packageRoot) + : path.resolve(workspaceRoot, 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_${digest(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 recordContextInputFiles = async ( + workspaceRoot: string, + filePaths: string[], +): Promise => + Promise.all( + filePaths.map(async (filePath) => { + const relativePath = toWorkspacePath(workspaceRoot, filePath); + return { + path: relativePath, + digest: digest(await readFile(path.resolve(workspaceRoot, relativePath))), + }; + }), + ).then((inputs) => inputs.sort((left, right) => left.path.localeCompare(right.path))); + +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 = digest(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, + createExplicitContextDescriptor, + createExplicitRun, + recordContextInputFiles, + resolveExplicitCaptureTarget, + resolveInternalConfigPath, +}; +export type { ConfigTargetRunner, ExplicitContextOptions, ExplicitRunOptions }; diff --git a/src/status.ts b/src/status.ts new file mode 100644 index 0000000..765a37c --- /dev/null +++ b/src/status.ts @@ -0,0 +1,85 @@ +import { createHash } from 'node:crypto'; +import { realpath } from 'node:fs/promises'; +import { type ProjectContextStatus, type ProjectStatus } from './model.ts'; +import { assessSnapshotFreshness } from './source.ts'; +import { readContextWorkspaceStatus } from './store.ts'; + +const compareStrings = (left: string, right: string): number => + left === right ? 0 : left < right ? -1 : 1; + +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; +}; + +const readProjectStatus = async (workspaceRoot: string): Promise => { + const workspace = await readContextWorkspaceStatus(workspaceRoot); + const workspacePath = await realpath(workspaceRoot); + const workspaceId = `ws_${createHash('sha256').update(workspacePath).digest('hex').slice(0, 24)}`; + const currentByContextId = new Map< + string, + (typeof workspace.runs)[number]['contexts'][number] & { + run: (typeof workspace.runs)[number]['run']; + } + >(); + + for (const { run, contexts } of workspace.runs) { + for (const contextStatus of contexts) { + const current = currentByContextId.get(contextStatus.context.contextId); + if ( + current === undefined || + compareStrings(current.run.startedAt, run.startedAt) < 0 || + (current.run.startedAt === run.startedAt && + compareStrings(current.run.runId, run.runId) < 0) + ) { + currentByContextId.set(contextStatus.context.contextId, { + ...contextStatus, + run, + }); + } + } + } + + const contexts = ( + await Promise.all( + [...currentByContextId.values()].map(async ({ run, context, latestSnapshot }) => ({ + runId: run.runId, + producer: run.producer, + context, + state: latestSnapshot === undefined ? ('pending' as const) : ('ready' as const), + ...(latestSnapshot === undefined + ? {} + : { + latestSnapshot, + freshness: await assessSnapshotFreshness(workspaceRoot, latestSnapshot), + }), + 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..5cdfe13 --- /dev/null +++ b/src/store.ts @@ -0,0 +1,346 @@ +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 { + 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 => + compareDescending(left.snapshot.observedAt, right.snapshot.observedAt) || + compareDescending(left.run.startedAt, right.run.startedAt) || + compareDescending(left.snapshot.snapshotId, right.snapshot.snapshotId); + +const compareDescending = (left: string, right: string): number => + left === right ? 0 : left > right ? -1 : 1; + +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..0d0f706 --- /dev/null +++ b/src/testRun.ts @@ -0,0 +1,405 @@ +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 TestFacet, + type TestFileRecord, +} from './model.ts'; +import { + normalizeExecutionFacet, + unavailableExecutionFacet, + validateExecutionRequest, + type TestExecutionRequest, +} from './execution.ts'; +import { + assessSnapshotFreshness, + createExplicitContextDescriptor, + createExplicitRun, + recordContextInputFiles, + resolveExplicitCaptureTarget, + resolveInternalConfigPath, + type ConfigTargetRunner, +} from './source.ts'; +import { + readContextSnapshotById, + readContextSnapshots, + writeContextRunManifest, + writeContextSnapshot, +} from './store.ts'; + +type TestSnapshotRequest = { + files?: 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; +}; + +type RunRstest = (options?: RunRstestOptions) => Promise; + +type TestCaptureDependencies = { + runRstest?: RunRstest; + createRunId?: () => string; + createSnapshotId?: () => string; + now?: () => Date; + wrapperConfigPath?: string; + withConfigTarget?: ConfigTargetRunner; +}; + +const toWorkspacePath = (workspaceRoot: string, filePath: string): string => + path.relative(workspaceRoot, path.resolve(workspaceRoot, filePath)).split(path.sep).join('/'); + +const optionalString = ( + key: K, + value: TestErrorRecord[K] | undefined, +): Pick | Record => + value === undefined ? {} : ({ [key]: value } as Pick); + +const normalizeError = (error: TestRunResult['unhandledErrors'][number]): 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 }), +}); + +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: result.errors.map(normalizeError) }), + ...(result.retryErrors === undefined + ? {} + : { retryErrors: result.retryErrors.map(normalizeError) }), + ...(result.retryCount === undefined ? {} : { retryCount: result.retryCount }), +}); + +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: result.errors.map(normalizeError) }), + tests: result.results + .map((testResult) => normalizeTestCase(workspaceRoot, testResult)) + .sort(compareTestCases), +}); + +const normalizeTestFacet = (workspaceRoot: string, result: TestRunResult): TestFacet => ({ + producer: 'rstest', + 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: result.unhandledErrors.map(normalizeError), +}); + +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 captureTestSnapshot = async ( + workspaceRoot: string, + request: TestSnapshotRequest, + dependencies: TestCaptureDependencies = {}, +): Promise => { + validateExecutionRequest(request.execution); + const target = await resolveExplicitCaptureTarget(workspaceRoot, request); + const wrapperConfigPath = + dependencies.wrapperConfigPath ?? + resolveInternalConfigPath(import.meta.dirname, 'rstestConfig.js'); + const context = createExplicitContextDescriptor({ + producer: 'rstest', + workspaceRoot, + ...target, + }); + 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; + try { + const runRstest = dependencies.runRstest ?? (await loadRunRstest()); + const withConfigTarget = + dependencies.withConfigTarget ?? + (async (_configRoot, _configPath, action) => { + if (dependencies.runRstest === undefined) { + throw new Error('Rstack test capture requires a config adapter.'); + } + return action(); + }); + result = await withConfigTarget(target.packageRoot, target.configPath, () => + runRstest({ + cwd: target.packageRoot, + config: wrapperConfigPath, + ...(request.execution === undefined + ? {} + : { + inlineConfig: { + coverage: { + enabled: true, + provider: 'istanbul' as const, + reporters: [], + reportOnFailure: true, + ...(request.execution.include === undefined + ? {} + : { include: request.execution.include }), + ...(request.execution.exclude === undefined + ? {} + : { exclude: request.execution.exclude }), + allowExternal: request.execution.allowExternal ?? false, + }, + }, + }), + ...(request.files === undefined ? {} : { files: request.files }), + ...(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' }, + }; + + ensureWritten(await writeContextSnapshot(workspaceRoot, snapshot)); + throw error; + } + const facet = normalizeTestFacet(workspaceRoot, result); + const executionFacet = + request.execution === undefined + ? undefined + : await normalizeExecutionFacet( + workspaceRoot, + target.packageRoot, + request.execution, + result.coverage, + ); + const testInputs = await recordContextInputFiles(workspaceRoot, [ + ...new Set(facet.files.map((file) => file.path)), + ]); + const executionInputs = + executionFacet?.files.flatMap((file) => + file.digest === undefined ? [] : [{ path: file.path, digest: file.digest }], + ) ?? []; + const inputs = [ + ...new Map([...testInputs, ...executionInputs].map((input) => [input.path, input])).values(), + ].sort((left, right) => left.path.localeCompare(right.path)); + const snapshot: ContextSnapshot = { + schemaVersion: contextStoreSchemaVersion, + snapshotId: dependencies.createSnapshotId?.() ?? `snap_${Date.now()}_${randomUUID()}`, + runId: run.runId, + contextId: context.contextId, + sequence: 0, + observedAt: now().toISOString(), + status: getRunStatus(result), + completeness: { + test: 'complete', + ...(executionFacet === undefined + ? {} + : { + execution: + executionFacet.availability === 'available' && + executionFacet.universe.completeness === 'complete' + ? 'complete' + : 'partial', + }), + }, + facets: { + test: facet as unknown as JsonValue, + ...(executionFacet === undefined + ? {} + : { execution: executionFacet as unknown as JsonValue }), + }, + source: { inputs, inputCompleteness: 'partial' }, + }; + + ensureWritten(await writeContextSnapshot(workspaceRoot, snapshot)); + 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, + unhandledErrors: facet.unhandledErrors.length, + }, + }; +}; + +const decodeCursor = (cursor: string | undefined): number => { + if (cursor === undefined) return 0; + const value = Buffer.from(cursor, 'base64url').toString('utf8'); + if (!/^(?:0|[1-9]\d*)$/u.test(value)) throw new Error('Invalid test result cursor.'); + return Number(value); +}; + +const encodeCursor = (offset: number): string => Buffer.from(String(offset)).toString('base64url'); + +const listTestResults = async ( + workspaceRoot: string, + query: TestResultsQuery, +): Promise => { + const stored = + query.snapshotId === undefined + ? (await readContextSnapshots(workspaceRoot, { producer: 'rstest' })).find( + ({ snapshot }) => 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 offset = decodeCursor(query.cursor); + 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) } : {}), + }; +}; + +export { captureTestSnapshot, listTestResults }; +export type { + TestCaptureDependencies, + TestCaptureResult, + 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..33386e6 --- /dev/null +++ b/tests/build.test.ts @@ -0,0 +1,716 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +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'; + +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 withTempWorkspace = async ( + callback: (workspaceRoot: string) => Promise, +): Promise => { + const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), 'rstack-build-context-')); + + try { + await callback(workspaceRoot); + } finally { + await rm(workspaceRoot, { force: true, recursive: true }); + } +}; + +const getObserverHarness = ( + plugin: ReturnType, +): ObserverHarness => { + const hooks: ObserverHooks = {}; + const warnings: string[] = []; + + 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(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(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(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(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(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(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(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(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..ba29631 --- /dev/null +++ b/tests/codeEvidence.test.ts @@ -0,0 +1,640 @@ +import { createHash } from 'node:crypto'; +import { cp, mkdir, 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 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'; + +const fixtureRoot = path.resolve( + import.meta.dirname, + '../fixtures/context/reachability/application', +); + +const digest = (value: string): string => createHash('sha256').update(value).digest('hex'); + +const withTempWorkspace = async ( + callback: (workspaceRoot: string) => Promise, +): Promise => { + const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), 'rstack-code-evidence-')); + try { + await callback(workspaceRoot); + } finally { + await rm(workspaceRoot, { recursive: true, force: true }); + } +}; + +const writeSnapshot = async ( + workspaceRoot: string, + options: { + producer: ContextProducer; + snapshotId: string; + observedAt: string; + context?: ContextDescriptor; + facets: ContextSnapshot['facets']; + completeness: ContextSnapshot['completeness']; + }, +): 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: 'pass', + completeness: options.completeness, + facets: options.facets, + }), + ).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(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-only'); + 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('keeps missing, stale, incomplete, and non-overlapping execution evidence inconclusive', async () => { + await withTempWorkspace(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' }, + 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(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('bounds exact-path diagnostics to two hundred records', async () => { + await withTempWorkspace(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('adds an independent module axis only for an explicit artifact and exposes binding mismatch', async () => { + await withTempWorkspace(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('uses the full workspace path before a package-relative artifact fallback', async () => { + await withTempWorkspace(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(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..e4c8f8e --- /dev/null +++ b/tests/diff.test.ts @@ -0,0 +1,652 @@ +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 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, + schemaVersion = contextStoreSchemaVersion, +}: { + snapshotId: string; + producer: ContextProducer; + contextId?: string; + facet?: LintFacet | TestFacet; + 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 } + : {}, + } 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('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('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('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/lint.test.ts b/tests/lint.test.ts new file mode 100644 index 0000000..73a3b23 --- /dev/null +++ b/tests/lint.test.ts @@ -0,0 +1,651 @@ +import { spawnSync } from 'node:child_process'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +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 } 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'; + +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 { + async lintFiles(patterns: string | string[]): Promise { + mocks.lintFilesCalls.push(patterns); + if (mocks.lintError !== undefined) throw mocks.lintError; + return mocks.results; + }, + + async lintText(code: string, options?: { filePath?: string }): Promise { + mocks.lintTextCalls.push([code, options]); + if (mocks.lintError !== undefined) throw mocks.lintError; + return mocks.results; + }, + + async close(): Promise { + mocks.closeCalls += 1; + }, + }; +}; + +beforeEach(() => { + mocks.closeCalls = 0; + mocks.lintFilesCalls.length = 0; + mocks.lintTextCalls.length = 0; + mocks.options.length = 0; + mocks.results = []; + mocks.lintError = undefined; +}); + +const withTempWorkspace = async ( + callback: (workspaceRoot: string) => Promise, +): Promise => { + const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), 'rstack-context-lint-')); + try { + await callback(workspaceRoot); + } finally { + await rm(workspaceRoot, { force: true, recursive: true }); + } +}; + +test('loading lint queries does not load the Rslint runtime', async () => { + await withTempWorkspace(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', + hookFile, + '--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 a deterministic file snapshot with complete inputs and fail status', async () => { + await withTempWorkspace(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); + 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: 'fresh', changedPaths: [] }, + summary: { + files: 2, + errors: 1, + warnings: 1, + fixableErrors: 1, + fixableWarnings: 0, + }, + }); + expect(stored?.snapshot.source).toMatchObject({ + inputCompleteness: 'complete', + 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(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, + ); + 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({ + 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(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, + ); + + const first = await listDiagnostics(workspaceRoot, { + snapshotId: capture.snapshotId, + severity: 'error', + pathPrefix: 'src/', + limit: 1, + }); + expect(first).toMatchObject({ + snapshotId: capture.snapshotId, + freshness: { state: 'fresh', 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: '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(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(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); + 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(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' }, createRslint); + + 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(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' }, createRslint); + 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(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, + ); + 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, + ); + 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('persists a partial diagnostic snapshot and closes the engine when linting throws', async () => { + await withTempWorkspace(async (workspaceRoot) => { + const lintError = new Error('lint failed'); + mocks.lintError = lintError; + + await expect(captureLintSnapshot(workspaceRoot, { mode: 'files' }, createRslint)).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(async (workspaceRoot) => { + const factoryError = new Error('Rslint configuration failed'); + + await expect( + captureLintSnapshot(workspaceRoot, { mode: 'files' }, () => { + throw factoryError; + }), + ).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/products.test.ts b/tests/products.test.ts new file mode 100644 index 0000000..ad77b68 --- /dev/null +++ b/tests/products.test.ts @@ -0,0 +1,158 @@ +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('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..78f9638 --- /dev/null +++ b/tests/queries.test.ts @@ -0,0 +1,757 @@ +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('selects the matching multi-compiler child by environment name', 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: 'multi-compiler', type: 'rspack' }, + compilers: [ + { + name: 'server', + environment: 'server', + compilationHash: 'compilation-server', + target: 'node', + }, + { name: 'web', compilationHash: 'compilation-web', target: ['web'] }, + ], + }); + + const candidates = await findUnusedCandidates(workspaceRoot, { + contextId: context.contextId, + dataFile: 'rsdoctor-data.json', + }); + + expect(candidates.provenance.artifactBinding).toBe('exact'); + expect(candidates.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'); + }); +}); 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..043da68 --- /dev/null +++ b/tests/records.test.ts @@ -0,0 +1,452 @@ +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', + 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, + 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, + 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: [] }, + ]; + + for (const value of invalidSnapshots) { + expect(validateSnapshot(value)).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..a89ea03 --- /dev/null +++ b/tests/report.test.ts @@ -0,0 +1,140 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { expect, test } from '@rstest/core'; +import { resolveReportFile, resolveRsdoctorReport } from '../src/report.ts'; + +const validDataFile = 'artifacts/rsdoctor-data.json'; + +const withTempWorkspace = async ( + callback: (workspaceRoot: string) => Promise, +): Promise => { + const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), 'rstack-rsdoctor-report-')); + + try { + await callback(workspaceRoot); + } finally { + await rm(workspaceRoot, { force: true, recursive: true }); + } +}; + +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(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(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(async (workspaceRoot) => { + await writeDataFile(workspaceRoot); + const reportPath = await writeWorkspaceFile( + workspaceRoot, + 'artifacts/custom-report.html', + '', + ); + + await expect(resolveRsdoctorReport(workspaceRoot, validDataFile)).resolves.toEqual({ + dataFile: validDataFile, + report: { + kind: 'html', + path: 'artifacts/custom-report.html', + uri: pathToFileURL(reportPath).toString(), + }, + }); + }); +}); + +test('resolves the normal workspace .rsdoctor manifest when no sibling HTML report exists', async () => { + await withTempWorkspace(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(async (workspaceRoot) => { + await writeDataFile(workspaceRoot); + await writeWorkspaceFile(workspaceRoot, 'artifacts/first.html', ''); + await writeWorkspaceFile(workspaceRoot, 'artifacts/second.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(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..2c1f539 --- /dev/null +++ b/tests/rsdoctor.test.ts @@ -0,0 +1,301 @@ +import { spawnSync } from 'node:child_process'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { expect, test } from '@rstest/core'; +import { analyzeRsdoctorArtifact, listRsdoctorToolNames } from '../src/index.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 withTempWorkspace = async ( + callback: (workspaceRoot: string) => Promise, +): Promise => { + const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), 'rstack-rsdoctor-')); + + try { + await callback(workspaceRoot); + } finally { + await rm(workspaceRoot, { force: true, recursive: true }); + } +}; + +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(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', + hookFile, + '--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(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(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(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(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(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(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(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('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(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(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).toEqual(omitted.result); + 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..d6584f3 --- /dev/null +++ b/tests/rsdoctorGraph.test.ts @@ -0,0 +1,211 @@ +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('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/source.test.ts b/tests/source.test.ts new file mode 100644 index 0000000..9bfec11 --- /dev/null +++ b/tests/source.test.ts @@ -0,0 +1,176 @@ +import { mkdir, mkdtemp, rm, unlink, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +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, + createExplicitContextDescriptor, + createExplicitRun, + recordContextInputFiles, +} from '../src/source.ts'; + +const withTempWorkspace = async ( + callback: (workspaceRoot: string) => Promise, +): Promise => { + const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), 'rstack-context-source-')); + + try { + await callback(workspaceRoot); + } finally { + await rm(workspaceRoot, { force: true, recursive: true }); + } +}; + +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(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 changed and missing inputs as stale in lexical order', async () => { + await withTempWorkspace(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(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..852dd66 --- /dev/null +++ b/tests/status.test.ts @@ -0,0 +1,152 @@ +import { mkdir, mkdtemp, rm } from 'node:fs/promises'; +import os from 'node:os'; +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'; + +const withTempWorkspace = async ( + callback: (workspaceRoot: string) => Promise, +): Promise => { + const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), 'rstack-context-status-')); + + try { + await callback(workspaceRoot); + } finally { + await rm(workspaceRoot, { force: true, recursive: true }); + } +}; + +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(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('projects only the latest run for each context in deterministic order', async () => { + await withTempWorkspace(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: 'pending', + }, + { + 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..01ecf67 --- /dev/null +++ b/tests/store.test.ts @@ -0,0 +1,408 @@ +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +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'; + +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 } }, +}; + +const withTempWorkspace = async ( + callback: (workspaceRoot: string) => Promise, +): Promise => { + const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), 'rstack-context-store-')); + + try { + await callback(workspaceRoot); + } finally { + await rm(workspaceRoot, { force: true, recursive: true }); + } +}; + +test('publishes immutable run snapshots and reads the latest context state', async () => { + await withTempWorkspace(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(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(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(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(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(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(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(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(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(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..27d148c --- /dev/null +++ b/tests/testRun.test.ts @@ -0,0 +1,1061 @@ +import { mkdir, mkdtemp, readFile, rm, unlink, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +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 { readContextSnapshotById } from '../src/store.ts'; +import { + captureTestSnapshot, + listTestResults, + type TestCaptureDependencies, + type TestSnapshotRequest, +} from '../src/testRun.ts'; + +const withTempWorkspace = async ( + callback: (workspaceRoot: string) => Promise, +): Promise => { + const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), 'rstack-context-test-run-')); + + try { + await callback(workspaceRoot); + } finally { + await rm(workspaceRoot, { force: true, recursive: true }); + } +}; + +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 => ({ + runRstest: async (options) => { + calls.push(options); + return result; + }, + createRunId: () => `run_${suffix}`, + createSnapshotId: () => `snap_${suffix}`, + now: () => new Date('2026-08-12T08:00:00.000Z'), +}); + +test('publishes the Istanbul provider as an exact required peer', async () => { + const packageJson = JSON.parse( + await readFile(new URL('../package.json', import.meta.url), 'utf8'), + ) as { + dependencies?: Record; + devDependencies?: Record; + peerDependencies?: Record; + peerDependenciesMeta?: Record; + }; + + expect(packageJson.dependencies?.['@rstest/coverage-istanbul']).toBeUndefined(); + expect(packageJson.devDependencies?.['@rstest/coverage-istanbul']).toBe('catalog:'); + expect(packageJson.peerDependencies?.['@rstest/coverage-istanbul']).toBe('0.11.6'); + expect(packageJson.peerDependenciesMeta?.['@rstest/coverage-istanbul']).toBeUndefined(); +}); + +test('captures one passing run with partial source freshness', async () => { + await withTempWorkspace(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, + 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: { + 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('captures requested aggregate Istanbul execution evidence', async () => { + await withTempWorkspace(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(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('persists partial execution evidence when a covered source path is unreadable', async () => { + await withTempWorkspace(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({ + 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(async (workspaceRoot) => { + let called = false; + const dependencies: TestCaptureDependencies = { + runRstest: async () => { + called = true; + return 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(async (workspaceRoot) => { + let called = false; + const dependencies: TestCaptureDependencies = { + runRstest: async () => { + called = true; + return 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(async (workspaceRoot) => { + const coverage: Record = {}; + for (let index = 1000; index >= 0; index -= 1) { + const sourcePath = path.join(workspaceRoot, 'src', `${index.toString().padStart(4, '0')}.ts`); + await mkdir(path.dirname(sourcePath), { recursive: true }); + await writeFile(sourcePath, 'export {};'); + coverage[sourcePath] = { + path: sourcePath, + statementMap: {}, + fnMap: {}, + branchMap: {}, + s: {}, + f: {}, + b: {}, + }; + } + 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: [], + }, + ]); + }); +}); + +test('normalizes failures, retries, skipped and todo cases', async () => { + await withTempWorkspace(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(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( + (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(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 }, + }); + 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(async (workspaceRoot) => { + const configError = new Error('configuration failed'); + configError.name = 'ConfigError'; + const dependencies: TestCaptureDependencies = { + 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(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: 'Mg', + }); + 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' }], + }); + }); +}); + +type AssertNever = T; +type UnsupportedRequestFields = AssertNever< + Extract< + keyof TestSnapshotRequest, + 'apply' | 'changed' | 'related' | '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..caf0fec --- /dev/null +++ b/tests/workspace.test.ts @@ -0,0 +1,99 @@ +import { mkdir, mkdtemp, 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 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"] +} From 9e6ccd8c3a63cec8b46c62399dacc806a8411c19 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 13 Aug 2026 05:14:21 +0000 Subject: [PATCH 02/46] docs(context): use official Rstack agent plugin --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e56d91e..1770001 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Rstack Context +# 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, From ca0661c32ae252b71acd01fd3ddd03552d955048 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 13 Aug 2026 05:48:49 +0000 Subject: [PATCH 03/46] Merge remote-tracking branch 'origin/main' into codex/rstack-mcp-observability # Conflicts: # packages/rstack/src/cli/commands.ts # packages/rstack/src/config.ts # pnpm-lock.yaml # pnpm-workspace.yaml --- package.json | 2 +- src/diff.ts | 2 +- src/execution.ts | 34 ++++++++++++++++------------------ src/lint.ts | 2 +- src/queries.ts | 8 ++++---- src/reachability.ts | 2 +- tests/build.test.ts | 27 ++++++++++++++------------- tests/codeEvidence.test.ts | 4 ++-- tests/lint.test.ts | 18 +++++++++++------- tests/source.test.ts | 1 + tests/status.test.ts | 1 + tests/testRun.test.ts | 13 +++++++------ 12 files changed, 60 insertions(+), 54 deletions(-) diff --git a/package.json b/package.json index ef25ab3..efec989 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rstackjs/context", - "version": "0.5.0-alpha.1", + "version": "0.6.0", "description": "Rstack project context, evidence storage, and MCP runtime.", "homepage": "https://rstack.rs", "bugs": { diff --git a/src/diff.ts b/src/diff.ts index 654e357..4e67c17 100644 --- a/src/diff.ts +++ b/src/diff.ts @@ -129,7 +129,7 @@ const diffItems = ( }); const changedCount = Math.min(before.length, after.length); for (let index = 0; index < changedCount; index += 1) { - changed.push({ before: before[index]!, after: after[index]! }); + changed.push({ before: before[index], after: after[index] }); } removed.push(...before.slice(changedCount)); added.push(...after.slice(changedCount)); diff --git a/src/execution.ts b/src/execution.ts index 93b8a65..2f0b1cf 100644 --- a/src/execution.ts +++ b/src/execution.ts @@ -165,7 +165,7 @@ const normalizeExecutionFile = ( isRecordObject(rawBranch) && Array.isArray(rawBranch.locations) ? rawBranch.locations : []; const locations = 1 + rawArms.length; reportedLocations += locations; - const rawHits = data.b[id]; + const rawHits: unknown = data.b[id]; if ( !isRecordObject(rawBranch) || typeof rawBranch.type !== 'string' || @@ -180,7 +180,7 @@ const normalizeExecutionFile = ( const location = normalizeLocation(rawBranch.loc); const arms = rawBranch.locations.map((rawLocation, index) => { const armLocation = normalizeLocation(rawLocation); - const hits = rawHits[index]; + const hits: unknown = rawHits[index]; return armLocation === undefined || !isNonNegativeInteger(hits) ? undefined : { location: armLocation, hits }; @@ -402,7 +402,7 @@ const isExecutionBranch = (value: unknown): boolean => isNonNegativeInteger(arm.hits), ); -const isExecutionFile = (value: unknown): boolean => +const isExecutionFile = (value: unknown): value is TestExecutionFile => isRecordObject(value) && hasOnlyKeys(value, ['path', 'digest', 'statements', 'functions', 'branches']) && isIdentifier(value.path) && @@ -415,10 +415,8 @@ const isExecutionFile = (value: unknown): boolean => Array.isArray(value.branches) && value.branches.every(isExecutionBranch); -const countExecutionLocations = (file: Record): number => { - const statements = file.statements as unknown[]; - const functions = file.functions as unknown[]; - const branches = file.branches as Array<{ arms: unknown[] }>; +const countExecutionLocations = (file: TestExecutionFile): number => { + const { statements, functions, branches } = file; return ( statements.length + functions.length * 2 + @@ -476,16 +474,15 @@ const validateExecutionFacet = (value: unknown): TestExecutionFacet | undefined !isNonNegativeInteger(value.universe.storedLocations) || !isNonNegativeInteger(value.universe.droppedLocations) || !['complete', 'partial', 'unknown'].includes(value.universe.completeness as string) || - value.universe.reportedFiles !== - (value.universe.storedFiles as number) + (value.universe.droppedFiles as number) || + value.universe.reportedFiles !== value.universe.storedFiles + value.universe.droppedFiles || value.universe.reportedLocations !== - (value.universe.storedLocations as number) + (value.universe.droppedLocations as number) || + 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 as number) || - value.truncated.locations > (value.universe.droppedLocations as number) || + value.truncated.files > value.universe.droppedFiles || + value.truncated.locations > value.universe.droppedLocations || !isRecordObject(value.bounds) || !hasOnlyKeys(value.bounds, [ 'attribution', @@ -503,16 +500,17 @@ const validateExecutionFacet = (value: unknown): TestExecutionFacet | undefined !value.files.every(isExecutionFile) || value.files.length > executionBounds.maxFiles || value.files.length !== value.universe.storedFiles || - new Set(value.files.map((file) => (file as { path: string }).path)).size !== - value.files.length || + new Set(value.files.map((file: unknown) => (isExecutionFile(file) ? file.path : undefined))) + .size !== value.files.length || value.files.some( - (file) => - countExecutionLocations(file as Record) > - executionBounds.maxLocationsPerFile, + (file: unknown) => + isExecutionFile(file) && + countExecutionLocations(file) > executionBounds.maxLocationsPerFile, ) || value.universe.storedLocations > executionBounds.maxLocationsTotal || value.files.reduce( - (total, file) => total + countExecutionLocations(file as Record), + (total: number, file: unknown) => + total + (isExecutionFile(file) ? countExecutionLocations(file) : 0), 0, ) !== value.universe.storedLocations ) { diff --git a/src/lint.ts b/src/lint.ts index 13c5f71..13624d5 100644 --- a/src/lint.ts +++ b/src/lint.ts @@ -511,7 +511,7 @@ const getLintFixPreview = async ( 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]!; + const file = matches[0]; if (!facet.fixPreviewCaptured) { return { available: false, diff --git a/src/queries.ts b/src/queries.ts index c6f8ce2..6ddafe8 100644 --- a/src/queries.ts +++ b/src/queries.ts @@ -112,7 +112,7 @@ const bindArtifactToSnapshot = ( ); if (matchingCompilers.length !== 1) return 'mismatch'; identity = matchingCompilers[0]!; - artifactEnvironment = identity.environment ?? matchingCompilers[0]!.name; + artifactEnvironment = identity.environment ?? matchingCompilers[0].name; } else { identity = metadata.build; artifactEnvironment = @@ -369,14 +369,14 @@ const resolveModule = (graph: ObservedModuleGraph, selector: string): ObservedMo normalizeSelector(module.path) === normalized || normalizeSelector(module.name) === normalized, ); - if (exact.length === 1) return exact[0]!; + if (exact.length === 1) return exact[0]; if (exact.length > 1) throw new Error(`Ambiguous module selector: ${selector}`); const suffix = graph.modules.filter(({ path: modulePath }) => { const normalizedPath = normalizeSelector(modulePath); return normalizedPath === normalized || normalizedPath.endsWith(`/${normalized}`); }); - if (suffix.length === 1) return suffix[0]!; + if (suffix.length === 1) return suffix[0]; if (suffix.length > 1) throw new Error(`Ambiguous module selector: ${selector}`); throw new Error(`Unknown module selector: ${selector}`); }; @@ -404,7 +404,7 @@ const shortestRootPath = ( ): 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); + const root = roots.find(({ module }) => module.id === modules[0].id); return root === undefined ? undefined : { rootKind: root.kind, modules }; }; diff --git a/src/reachability.ts b/src/reachability.ts index a03a51b..8e9bab0 100644 --- a/src/reachability.ts +++ b/src/reachability.ts @@ -62,7 +62,7 @@ const traceModuleGraph = ( } for (let index = 0; index < visited.length; index += 1) { - const moduleId = visited[index]!; + const moduleId = visited[index]; const moduleDepth = depth.get(moduleId)!; for (const neighbor of orderedNeighbors.get(moduleId) ?? []) { if (predecessor.has(neighbor)) continue; diff --git a/tests/build.test.ts b/tests/build.test.ts index 33386e6..6da0905 100644 --- a/tests/build.test.ts +++ b/tests/build.test.ts @@ -1,3 +1,4 @@ +/* rslint-disable @typescript-eslint/no-unsafe-assignment -- Rstest asymmetric matchers are intentionally untyped. */ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; @@ -55,7 +56,7 @@ const getObserverHarness = ( const hooks: ObserverHooks = {}; const warnings: string[] = []; - plugin.setup?.({ + void plugin.setup?.({ logger: { warn: (message: string) => { warnings.push(message); @@ -164,7 +165,7 @@ const collectContextId = async ({ const status = await readContextWorkspaceStatus(workspaceRoot); const run = status.runs.find((entry) => entry.run.runId === runId)?.run; expect(run).toBeDefined(); - return run!.contexts[0]!.contextId; + return run!.contexts[0].contextId; }; test('appends one observer without mutating user config or nested Rslib entries', () => { @@ -250,7 +251,7 @@ test('publishes an aggregate manifest once and advances sequences per environmen const status = await readContextWorkspaceStatus(workspaceRoot); expect(status.runs).toHaveLength(1); - expect(status.runs[0]!.run).toMatchObject({ + expect(status.runs[0].run).toMatchObject({ command: 'build', contexts: [ { environment: 'cjs', product: 'library', target: 'node' }, @@ -261,7 +262,7 @@ test('publishes an aggregate manifest once and advances sequences per environmen }); const snapshots = new Map( - status.runs[0]!.contexts.map(({ context, latestSnapshot }) => [ + status.runs[0].contexts.map(({ context, latestSnapshot }) => [ context.environment, latestSnapshot, ]), @@ -354,7 +355,7 @@ test('bounds metadata rows and distinguishes disabled deep capture from partial time: 20, }); - const cappedSnapshot = (await readContextWorkspaceStatus(workspaceRoot)).runs[0]!.contexts[0]! + const cappedSnapshot = (await readContextWorkspaceStatus(workspaceRoot)).runs[0].contexts[0] .latestSnapshot!; const build = cappedSnapshot.facets.build as { assets: Array<{ name: string; size: number }>; @@ -364,9 +365,9 @@ test('bounds metadata rows and distinguishes disabled deep capture from partial 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.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); @@ -377,7 +378,7 @@ test('bounds metadata rows and distinguishes disabled deep capture from partial time: 21, }); - const snapshot = (await readContextWorkspaceStatus(workspaceRoot)).runs[0]!.contexts[0]! + const snapshot = (await readContextWorkspaceStatus(workspaceRoot)).runs[0].contexts[0] .latestSnapshot!; expect(snapshot).toMatchObject({ sequence: 2, @@ -429,7 +430,7 @@ test('retains bounded valid metadata rows and counts only dropped valid rows', a time: 1, }); - const build = (await readContextWorkspaceStatus(workspaceRoot)).runs[0]!.contexts[0]! + const build = (await readContextWorkspaceStatus(workspaceRoot)).runs[0].contexts[0] .latestSnapshot!.facets.build as BuildMetadataFacet; expect(build.assets).toEqual( Array.from({ length: 100 }, (_, index) => ({ @@ -578,7 +579,7 @@ test('normalizes metadata paths', async () => { time: 1, }); - const build = (await readContextWorkspaceStatus(workspaceRoot)).runs[0]!.contexts[0]! + const build = (await readContextWorkspaceStatus(workspaceRoot)).runs[0].contexts[0] .latestSnapshot!.facets.build as { assets: Array<{ name: string; size: number }>; chunks: Array<{ files: string[] }>; @@ -695,11 +696,11 @@ test('records explicit build variants, normalized output paths, and partial conf }); const status = await readContextWorkspaceStatus(workspaceRoot); - expect(status.runs[0]!.run.contexts[0]).toMatchObject({ + expect(status.runs[0].run.contexts[0]).toMatchObject({ variant: 'firefox_v3', distPath: 'dist/firefox', }); - expect(status.runs[0]!.contexts[0]!.latestSnapshot!.source).toMatchObject({ + expect(status.runs[0].contexts[0].latestSnapshot!.source).toMatchObject({ inputCompleteness: 'partial', inputs: [ { diff --git a/tests/codeEvidence.test.ts b/tests/codeEvidence.test.ts index ba29631..a7e42e3 100644 --- a/tests/codeEvidence.test.ts +++ b/tests/codeEvidence.test.ts @@ -377,14 +377,14 @@ test('distinguishes absent exact test records from matching skipped or todo reco test('bounds exact-path diagnostics to two hundred records', async () => { await withTempWorkspace(async (workspaceRoot) => { const facet = lintFacet('src/noisy.ts'); - facet.files[0]!.messages = Array.from({ length: 205 }, (_, index) => ({ + 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.files[0].warningCount = 205; facet.totals.warnings = 205; await writeSnapshot(workspaceRoot, { producer: 'rslint', diff --git a/tests/lint.test.ts b/tests/lint.test.ts index 73a3b23..b7c2ffd 100644 --- a/tests/lint.test.ts +++ b/tests/lint.test.ts @@ -1,3 +1,4 @@ +/* rslint-disable @typescript-eslint/no-unsafe-assignment -- Rstest asymmetric matchers are intentionally untyped. */ import { spawnSync } from 'node:child_process'; import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import os from 'node:os'; @@ -27,20 +28,23 @@ const mocks = { const createRslint = (options: RslintOptions) => { mocks.options.push(options); return { - async lintFiles(patterns: string | string[]): Promise { + lintFiles(patterns: string | string[]): Promise { mocks.lintFilesCalls.push(patterns); - if (mocks.lintError !== undefined) throw mocks.lintError; - return mocks.results; + return mocks.lintError === undefined + ? Promise.resolve(mocks.results) + : Promise.reject(mocks.lintError); }, - async lintText(code: string, options?: { filePath?: string }): Promise { + lintText(code: string, options?: { filePath?: string }): Promise { mocks.lintTextCalls.push([code, options]); - if (mocks.lintError !== undefined) throw mocks.lintError; - return mocks.results; + return mocks.lintError === undefined + ? Promise.resolve(mocks.results) + : Promise.reject(mocks.lintError); }, - async close(): Promise { + close(): Promise { mocks.closeCalls += 1; + return Promise.resolve(); }, }; }; diff --git a/tests/source.test.ts b/tests/source.test.ts index 9bfec11..1a1739e 100644 --- a/tests/source.test.ts +++ b/tests/source.test.ts @@ -1,3 +1,4 @@ +/* rslint-disable @typescript-eslint/no-unsafe-assignment -- Rstest asymmetric matchers are intentionally untyped. */ import { mkdir, mkdtemp, rm, unlink, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; diff --git a/tests/status.test.ts b/tests/status.test.ts index 852dd66..423e715 100644 --- a/tests/status.test.ts +++ b/tests/status.test.ts @@ -1,3 +1,4 @@ +/* rslint-disable @typescript-eslint/no-unsafe-assignment -- Rstest asymmetric matchers are intentionally untyped. */ import { mkdir, mkdtemp, rm } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; diff --git a/tests/testRun.test.ts b/tests/testRun.test.ts index 27d148c..abdecad 100644 --- a/tests/testRun.test.ts +++ b/tests/testRun.test.ts @@ -1,3 +1,4 @@ +/* rslint-disable @typescript-eslint/no-unsafe-assignment -- Rstest asymmetric matchers are intentionally untyped. */ import { mkdir, mkdtemp, readFile, rm, unlink, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; @@ -42,9 +43,9 @@ const createDependencies = ( calls: unknown[], suffix: string, ): TestCaptureDependencies => ({ - runRstest: async (options) => { + runRstest: (options) => { calls.push(options); - return result; + return Promise.resolve(result); }, createRunId: () => `run_${suffix}`, createSnapshotId: () => `snap_${suffix}`, @@ -547,9 +548,9 @@ test('bounds requested execution selectors before invoking Rstest', async () => await withTempWorkspace(async (workspaceRoot) => { let called = false; const dependencies: TestCaptureDependencies = { - runRstest: async () => { + runRstest: () => { called = true; - return createResult(); + return Promise.resolve(createResult()); }, }; @@ -572,9 +573,9 @@ test('rejects malformed execution selectors before invoking Rstest', async () => await withTempWorkspace(async (workspaceRoot) => { let called = false; const dependencies: TestCaptureDependencies = { - runRstest: async () => { + runRstest: () => { called = true; - return createResult(); + return Promise.resolve(createResult()); }, }; From b5d8c7015f76d61605e8194ec6d21abb90c89bf1 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 13 Aug 2026 05:59:38 +0000 Subject: [PATCH 04/46] fix(context): keep MCP protocol output clean --- src/mcp.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mcp.ts b/src/mcp.ts index b2b616b..e7aa481 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -751,7 +751,7 @@ const createContextMcpServer = ( inputSchema: testSnapshotInput, annotations: { readOnlyHint: false, - destructiveHint: false, + destructiveHint: true, openWorldHint: true, }, }, From f0608ee4eb582a4ee009aeeb47fdab3c961f0e48 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 13 Aug 2026 06:03:23 +0000 Subject: [PATCH 05/46] test(context): canonicalize temporary roots --- tests/workspace.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/workspace.test.ts b/tests/workspace.test.ts index caf0fec..d48d049 100644 --- a/tests/workspace.test.ts +++ b/tests/workspace.test.ts @@ -1,11 +1,13 @@ -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +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 mkdtemp(path.join(os.tmpdir(), 'rstack-context-workspace-')); + const rootPath = await realpath( + await mkdtemp(path.join(os.tmpdir(), 'rstack-context-workspace-')), + ); try { await callback(rootPath); From 59465fed9163c40c633b186ef4edf9bb068b6731 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 13 Aug 2026 06:11:28 +0000 Subject: [PATCH 06/46] test: support context checks on Windows --- tests/lint.test.ts | 4 ++-- tests/rsdoctor.test.ts | 2 +- tests/testRun.test.ts | 9 ++++++--- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/tests/lint.test.ts b/tests/lint.test.ts index b7c2ffd..4207ba6 100644 --- a/tests/lint.test.ts +++ b/tests/lint.test.ts @@ -92,7 +92,7 @@ registerHooks({ process.execPath, [ '--import', - hookFile, + pathToFileURL(hookFile).href, '--input-type=module', '--eval', `const { listDiagnostics } = await import(${JSON.stringify(moduleUrl)}); @@ -169,7 +169,7 @@ test('captures a deterministic file snapshot with complete inputs and fail statu { cwd: workspaceRoot, fix: false, - overrideConfigFile: expect.stringMatching(/\/rslintConfig\.js$/u), + overrideConfigFile: expect.stringMatching(/[\\/]rslintConfig\.js$/u), }, ]); expect(mocks.lintFilesCalls).toEqual([['.']]); diff --git a/tests/rsdoctor.test.ts b/tests/rsdoctor.test.ts index 2c1f539..cdc88ad 100644 --- a/tests/rsdoctor.test.ts +++ b/tests/rsdoctor.test.ts @@ -102,7 +102,7 @@ registerHooks({ process.execPath, [ '--import', - hookFile, + pathToFileURL(hookFile).href, '--input-type=module', '--eval', `const { listRsdoctorToolNames } = await import(${JSON.stringify(moduleUrl)}); diff --git a/tests/testRun.test.ts b/tests/testRun.test.ts index abdecad..bf0bed6 100644 --- a/tests/testRun.test.ts +++ b/tests/testRun.test.ts @@ -607,10 +607,12 @@ test('rejects malformed execution selectors before invoking Rstest', async () => test('truncates aggregate execution files deterministically', async () => { await withTempWorkspace(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(workspaceRoot, 'src', `${index.toString().padStart(4, '0')}.ts`); - await mkdir(path.dirname(sourcePath), { recursive: true }); - await writeFile(sourcePath, 'export {};'); + const sourcePath = path.join(sourceDirectory, `${index.toString().padStart(4, '0')}.ts`); + writes.push(writeFile(sourcePath, 'export {};')); coverage[sourcePath] = { path: sourcePath, statementMap: {}, @@ -621,6 +623,7 @@ test('truncates aggregate execution files deterministically', async () => { b: {}, }; } + await Promise.all(writes); const calls: unknown[] = []; const capture = await captureTestSnapshot( workspaceRoot, From e5fe9034b4763a24643aeacd8478ceef9a162564 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 13 Aug 2026 06:16:13 +0000 Subject: [PATCH 07/46] test: allow Windows coverage fixture time --- tests/testRun.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/testRun.test.ts b/tests/testRun.test.ts index bf0bed6..fff9ed0 100644 --- a/tests/testRun.test.ts +++ b/tests/testRun.test.ts @@ -677,7 +677,7 @@ test('truncates aggregate execution files deterministically', async () => { }, ]); }); -}); +}, 15_000); test('normalizes failures, retries, skipped and todo cases', async () => { await withTempWorkspace(async (workspaceRoot) => { From 8201b62e6994078d81a97e3cbdc1242d8f631fd8 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 13 Aug 2026 06:41:19 +0000 Subject: [PATCH 08/46] fix(context): compare complete snapshot evidence --- src/diff.ts | 37 ++++++++++++++++++++++----- src/lint.ts | 21 +++++++++++++-- src/model.ts | 1 + src/records.ts | 3 ++- src/testRun.ts | 12 +++++++-- tests/diff.test.ts | 59 +++++++++++++++++++++++++++++++++++++++++++ tests/lint.test.ts | 2 ++ tests/records.test.ts | 6 +++++ tests/testRun.test.ts | 5 ++++ 9 files changed, 134 insertions(+), 12 deletions(-) diff --git a/src/diff.ts b/src/diff.ts index 4e67c17..bef1bf1 100644 --- a/src/diff.ts +++ b/src/diff.ts @@ -19,7 +19,8 @@ type SnapshotDiffRequest = { kind?: SnapshotDiffKind; }; -type SnapshotDiffIncompatibilityReason = 'schema-version' | 'producer' | 'context' | 'facet'; +type SnapshotDiffIncompatibilityReason = + 'schema-version' | 'producer' | 'context' | 'facet' | 'selection'; type SnapshotDiagnostic = { path: string; @@ -40,7 +41,13 @@ type SnapshotTestFileError = { error: TestErrorRecord; }; -type SnapshotDiffItem = SnapshotDiagnostic | SnapshotTestFileError | TestCaseRecord; +type SnapshotTestUnhandledError = { + kind: 'unhandled-error'; + error: TestErrorRecord; +}; + +type SnapshotDiffItem = + SnapshotDiagnostic | SnapshotTestFileError | SnapshotTestUnhandledError | TestCaseRecord; type SnapshotDiffResult = | { @@ -68,9 +75,13 @@ const diagnosticIdentity = (diagnostic: SnapshotDiagnostic): string => diagnostic.column, ]); -const testIdentity = (result: SnapshotTestFileError | TestCaseRecord): string => +const testIdentity = ( + result: SnapshotTestFileError | SnapshotTestUnhandledError | TestCaseRecord, +): string => 'kind' in result - ? JSON.stringify([result.project, result.path, result.kind, result.error.name]) + ? 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 lintDiagnostics = (facet: LintFacet): SnapshotDiagnostic[] => @@ -88,8 +99,10 @@ const lintDiagnostics = (facet: LintFacet): SnapshotDiagnostic[] => })), ); -const testResults = (facet: TestFacet): Array => - facet.files.flatMap((file) => [ +const testResults = ( + facet: TestFacet, +): Array => [ + ...facet.files.flatMap((file) => [ ...(file.errors ?? []).map((error) => ({ kind: 'file-error' as const, project: file.project, @@ -97,7 +110,9 @@ const testResults = (facet: TestFacet): Array ({ kind: 'unhandled-error' as const, error })), +]; const diffItems = ( leftItems: T[], @@ -156,6 +171,14 @@ const diffStoredContextSnapshots = ( 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); diff --git a/src/lint.ts b/src/lint.ts index 13624d5..3cde79f 100644 --- a/src/lint.ts +++ b/src/lint.ts @@ -188,6 +188,21 @@ const totalsFor = (files: LintFileRecord[]): LintFacet['totals'] => ({ 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 ensureWritten = (result: Awaited>): void => { if (!result.written) throw new Error('Could not write the context snapshot.', { @@ -210,6 +225,7 @@ const captureLintSnapshot = async ( workspaceRoot, ...target, }); + const captureSelection = lintCaptureSelection(workspaceRoot, target.packageRoot, request); const run = createExplicitRun({ producer: 'rslint', context, @@ -286,7 +302,7 @@ const captureLintSnapshot = async ( status: 'error', completeness: { lint: 'partial' }, facets: { lint: facet }, - source: { inputs: [], inputCompleteness: 'partial' }, + source: { inputs: [], inputCompleteness: 'partial', captureSelection }, }; ensureWritten(await writeContextSnapshot(workspaceRoot, snapshot)); throw error; @@ -314,13 +330,14 @@ const captureLintSnapshot = async ( }; const source: ContextSnapshot['source'] = request.mode === 'text' - ? { virtualInputDigest: digest(request.code) } + ? { virtualInputDigest: digest(request.code), captureSelection } : { inputs: files.map(({ path: filePath, digest: fileDigest }) => ({ path: filePath, digest: fileDigest, })), inputCompleteness: 'complete', + captureSelection, }; const status: ContextRunStatus = totals.errors > 0 ? 'fail' : 'pass'; const snapshot: ContextSnapshot = { diff --git a/src/model.ts b/src/model.ts index 6764ec6..520d096 100644 --- a/src/model.ts +++ b/src/model.ts @@ -20,6 +20,7 @@ type ContextSnapshotSource = { inputs?: ContextInputFile[]; inputCompleteness?: ContextInputCompleteness; virtualInputDigest?: string; + captureSelection?: JsonValue; }; type ContextDescriptor = { diff --git a/src/records.ts b/src/records.ts index 991402c..5dd985f 100644 --- a/src/records.ts +++ b/src/records.ts @@ -217,7 +217,8 @@ const isSnapshotSource = (value: unknown): boolean => { (value.inputCompleteness === 'complete' || value.inputCompleteness === 'partial'))) && (value.virtualInputDigest === undefined || (typeof value.virtualInputDigest === 'string' && - sha256Pattern.test(value.virtualInputDigest))) + sha256Pattern.test(value.virtualInputDigest))) && + (value.captureSelection === undefined || isJsonValue(value.captureSelection)) ); }; diff --git a/src/testRun.ts b/src/testRun.ts index 0d0f706..58bf96c 100644 --- a/src/testRun.ts +++ b/src/testRun.ts @@ -155,6 +155,13 @@ const normalizeTestFacet = (workspaceRoot: string, result: TestRunResult): TestF unhandledErrors: result.unhandledErrors.map(normalizeError), }); +const testCaptureSelection = (request: TestSnapshotRequest): JsonValue => ({ + ...(request.files === undefined + ? {} + : { files: [...new Set(request.files)].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'; @@ -181,6 +188,7 @@ const captureTestSnapshot = async ( workspaceRoot, ...target, }); + const captureSelection = testCaptureSelection(request); const now = dependencies.now ?? (() => new Date()); const run = createExplicitRun({ producer: 'rstest', @@ -269,7 +277,7 @@ const captureTestSnapshot = async ( ? {} : { execution: executionFacet as unknown as JsonValue }), }, - source: { inputs: [], inputCompleteness: 'partial' }, + source: { inputs: [], inputCompleteness: 'partial', captureSelection }, }; ensureWritten(await writeContextSnapshot(workspaceRoot, snapshot)); @@ -321,7 +329,7 @@ const captureTestSnapshot = async ( ? {} : { execution: executionFacet as unknown as JsonValue }), }, - source: { inputs, inputCompleteness: 'partial' }, + source: { inputs, inputCompleteness: 'partial', captureSelection }, }; ensureWritten(await writeContextSnapshot(workspaceRoot, snapshot)); diff --git a/tests/diff.test.ts b/tests/diff.test.ts index e4c8f8e..684390d 100644 --- a/tests/diff.test.ts +++ b/tests/diff.test.ts @@ -15,6 +15,7 @@ import { type ContextProducer, type ContextRunManifest, type ContextSnapshot, + type JsonValue, type LintFacet, type StoredContextSnapshot, type TestFacet, @@ -56,12 +57,14 @@ const storedSnapshot = ({ 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 = { @@ -92,6 +95,7 @@ const storedSnapshot = ({ : facet?.producer === 'rstest' ? { test: facet } : {}, + ...(captureSelection === undefined ? {} : { source: { captureSelection } }), } as ContextSnapshot; return { run, context, snapshot }; }; @@ -150,6 +154,26 @@ test('identifies the missing side when a requested snapshot does not exist', asy } }); +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 = [ @@ -471,6 +495,41 @@ test('diffs project-qualified test results and reports execution changes', () => }); }); +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', diff --git a/tests/lint.test.ts b/tests/lint.test.ts index 4207ba6..77af432 100644 --- a/tests/lint.test.ts +++ b/tests/lint.test.ts @@ -186,6 +186,7 @@ test('captures a deterministic file snapshot with complete inputs and fail statu }, }); expect(stored?.snapshot.source).toMatchObject({ + captureSelection: { mode: 'files', patterns: ['.'] }, inputCompleteness: 'complete', inputs: [{ path: 'a.ts' }, { path: 'b.ts' }], }); @@ -250,6 +251,7 @@ test('captures text without persisting the input and exposes only stored fix out 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); diff --git a/tests/records.test.ts b/tests/records.test.ts index 043da68..48aa934 100644 --- a/tests/records.test.ts +++ b/tests/records.test.ts @@ -397,6 +397,12 @@ test('rejects malformed known facets and unpaired source input metadata', () => source: { inputCompleteness: 'complete' }, }), ).toBeUndefined(); + expect( + validateSnapshot({ + ...snapshot, + source: { captureSelection: { patterns: [Symbol('not-json')] } }, + }), + ).toBeUndefined(); expect( validateSnapshot({ ...snapshot, diff --git a/tests/testRun.test.ts b/tests/testRun.test.ts index fff9ed0..b5369d9 100644 --- a/tests/testRun.test.ts +++ b/tests/testRun.test.ts @@ -137,6 +137,10 @@ test('captures one passing run with partial source freshness', async () => { status: 'pass', completeness: { test: 'complete' }, source: { + captureSelection: { + files: ['src/math.test.ts'], + testNamePattern: 'adds', + }, inputCompleteness: 'partial', inputs: [ { @@ -520,6 +524,7 @@ test('persists partial execution evidence when a covered source path is unreadab expect(stored?.snapshot.completeness).toEqual({ test: 'complete', execution: 'partial' }); expect(stored?.snapshot.source).toEqual({ + captureSelection: {}, inputCompleteness: 'partial', inputs: [ { From 5f550a4e34d1ea17d16b92a135dd118169b9dcb9 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 13 Aug 2026 06:57:00 +0000 Subject: [PATCH 09/46] fix(context): qualify lint snapshot freshness --- src/lint.ts | 21 ++++++++++++++++++++- tests/lint.test.ts | 20 ++++++++++++++------ 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/src/lint.ts b/src/lint.ts index 3cde79f..090f72f 100644 --- a/src/lint.ts +++ b/src/lint.ts @@ -203,6 +203,20 @@ const lintCaptureSelection = ( 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.', { @@ -336,7 +350,12 @@ const captureLintSnapshot = async ( path: filePath, digest: fileDigest, })), - inputCompleteness: 'complete', + inputCompleteness: lintInputCompleteness( + workspaceRoot, + target.packageRoot, + request.patterns ?? ['.'], + files, + ), captureSelection, }; const status: ContextRunStatus = totals.errors > 0 ? 'fail' : 'pass'; diff --git a/tests/lint.test.ts b/tests/lint.test.ts index 77af432..75a626f 100644 --- a/tests/lint.test.ts +++ b/tests/lint.test.ts @@ -112,7 +112,7 @@ await listDiagnostics(${JSON.stringify(workspaceRoot)}).catch(() => undefined);` }); }); -test('captures a deterministic file snapshot with complete inputs and fail status', async () => { +test('captures an open-ended file snapshot with partial inputs and fail status', async () => { await withTempWorkspace(async (workspaceRoot) => { const aPath = path.join(workspaceRoot, 'a.ts'); const bPath = path.join(workspaceRoot, 'b.ts'); @@ -176,7 +176,7 @@ test('captures a deterministic file snapshot with complete inputs and fail statu expect(mocks.closeCalls).toBe(1); expect(result).toMatchObject({ status: 'fail', - freshness: { state: 'fresh', changedPaths: [] }, + freshness: { state: 'partial', changedPaths: [] }, summary: { files: 2, errors: 1, @@ -187,7 +187,7 @@ test('captures a deterministic file snapshot with complete inputs and fail statu }); expect(stored?.snapshot.source).toMatchObject({ captureSelection: { mode: 'files', patterns: ['.'] }, - inputCompleteness: 'complete', + inputCompleteness: 'partial', inputs: [{ path: 'a.ts' }, { path: 'b.ts' }], }); expect(stored?.snapshot.facets.lint).toMatchObject({ @@ -312,7 +312,7 @@ test('paginates and filters diagnostics from one frozen snapshot deterministical }); expect(first).toMatchObject({ snapshotId: capture.snapshotId, - freshness: { state: 'fresh', changedPaths: [] }, + freshness: { state: 'partial', changedPaths: [] }, total: 2, items: [{ path: 'src/a.ts', ruleId: 'a', severity: 'error', message: 'error' }], }); @@ -487,7 +487,11 @@ test('rejects a malformed diagnostics cursor', async () => { messages: [], }, ]; - const capture = await captureLintSnapshot(workspaceRoot, { mode: 'files' }, createRslint); + const capture = await captureLintSnapshot( + workspaceRoot, + { mode: 'files', patterns: ['a.ts'] }, + createRslint, + ); await expect( listDiagnostics(workspaceRoot, { snapshotId: capture.snapshotId, cursor: '?' }), @@ -509,7 +513,11 @@ test('returns snapshot provenance for an empty diagnostics page', async () => { messages: [], }, ]; - const capture = await captureLintSnapshot(workspaceRoot, { mode: 'files' }, createRslint); + const capture = await captureLintSnapshot( + workspaceRoot, + { mode: 'files', patterns: ['a.ts'] }, + createRslint, + ); const stored = await readContextSnapshotById(workspaceRoot, capture.snapshotId); await expect( From a05db1988bebd669b154d994eaf5a62600b4e32b Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 14 Aug 2026 00:00:03 +0000 Subject: [PATCH 10/46] fix(context): normalize Rsdoctor module relations --- src/rsdoctorGraph.ts | 31 +++++++++++++++++++------- tests/rsdoctorGraph.test.ts | 44 +++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 8 deletions(-) diff --git a/src/rsdoctorGraph.ts b/src/rsdoctorGraph.ts index 84a1dbc..a6372f5 100644 --- a/src/rsdoctorGraph.ts +++ b/src/rsdoctorGraph.ts @@ -150,20 +150,35 @@ const normalizeRsdoctorModuleGraph = (artifact: RsdoctorArtifact): ObservedModul const edgeKeys = new Set(); const edges: ObservedModuleGraph['edges'] = []; - for (const row of Array.isArray(moduleGraph.dependencies) ? moduleGraph.dependencies : []) { - if (!isObject(row)) continue; - const usesDependencyShape = Object.hasOwn(row, 'dependency'); - const from = getId(usesDependencyShape ? row.module : row.issuer); - const to = getId(usesDependencyShape ? row.dependency : row.module); - if (from === undefined || to === undefined) continue; + 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'); - continue; + return; } const key = `${from}\u0000${to}`; - if (edgeKeys.has(key)) continue; + if (edgeKeys.has(key)) return; edgeKeys.add(key); edges.push({ from, to }); + }; + for (const row of Array.isArray(moduleGraph.dependencies) ? moduleGraph.dependencies : []) { + if (!isObject(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 (!isObject(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 { diff --git a/tests/rsdoctorGraph.test.ts b/tests/rsdoctorGraph.test.ts index d6584f3..04d6a2c 100644 --- a/tests/rsdoctorGraph.test.ts +++ b/tests/rsdoctorGraph.test.ts @@ -194,6 +194,50 @@ test('inherits chunk membership from a concatenated module container', async () } }); +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 { From 8b3be602b6d66d662b77bd555486cef4fcb4573c Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 14 Aug 2026 00:00:22 +0000 Subject: [PATCH 11/46] feat(context): capture related Rstest evidence --- package.json | 5 ++ src/codeEvidence.ts | 37 +++++++++ src/index.ts | 3 + src/mcp.ts | 15 +++- src/model.ts | 7 ++ src/records.ts | 8 ++ src/testRun.ts | 160 +++++++++++++++++++++++++++---------- tests/codeEvidence.test.ts | 74 +++++++++++++++++ tests/records.test.ts | 10 +++ tests/testRun.test.ts | 125 ++++++++++++++++++++++++++++- 10 files changed, 397 insertions(+), 47 deletions(-) diff --git a/package.json b/package.json index efec989..e1d5c14 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,11 @@ "peerDependencies": { "@rstest/coverage-istanbul": "0.11.6" }, + "peerDependenciesMeta": { + "@rstest/coverage-istanbul": { + "optional": true + } + }, "engines": { "node": ">=22.12.0" }, diff --git a/src/codeEvidence.ts b/src/codeEvidence.ts index cb215c8..9746b5f 100644 --- a/src/codeEvidence.ts +++ b/src/codeEvidence.ts @@ -59,6 +59,12 @@ type TestOutcomeEvidence = { 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; @@ -70,6 +76,7 @@ type CodeEvidenceResult = { path: string; line?: number; executionCoverage: ExecutionCoverageEvidence; + testRelation: TestRelationEvidence; testOutcome: TestOutcomeEvidence; diagnostics: CodeDiagnosticEvidence; module?: DeadCodeExplanation; @@ -241,6 +248,33 @@ const testOutcome = ( return { state: 'not-run', matchingFiles: files.length, matchingTests: tests.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) || @@ -365,6 +399,7 @@ const readCodeEvidence = async ( }; const bounds = [ 'aggregate-execution-no-test-attribution', + 'test-relation-static-build-graph', 'test-outcome-exact-path-only', 'diagnostics-exact-path-only', ...(module !== undefined && module.provenance.artifactBinding !== 'exact' @@ -375,6 +410,7 @@ const readCodeEvidence = async ( 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 }), @@ -391,4 +427,5 @@ export type { ExecutionCoverageEvidence, SnapshotEvidence, TestOutcomeEvidence, + TestRelationEvidence, }; diff --git a/src/index.ts b/src/index.ts index 42da441..93b3ffa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,6 +27,7 @@ export { type TestErrorRecord, type TestFacet, type TestFileRecord, + type TestRelationRecord, } from './model.ts'; export { readContextSnapshotById, @@ -71,6 +72,8 @@ export { export { captureTestSnapshot, listTestResults, + type RelatedTestRequest, + type ResolveRelatedTests, type TestCaptureDependencies, type TestCaptureResult, type TestResultPage, diff --git a/src/mcp.ts b/src/mcp.ts index e7aa481..10d67f0 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -221,6 +221,14 @@ const lintSnapshotInput = z 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(), @@ -344,7 +352,7 @@ const formatCodeEvidence = (result: CodeEvidenceResult): string => { result.module === undefined ? 'not-requested' : `${result.module.classification}(binding=${result.module.provenance.artifactBinding})`; - return `Code evidence for ${result.path}: coverage=${result.executionCoverage.state}, test=${result.testOutcome.state}, diagnostics=${diagnostics}, module=${module}. See structuredContent for complete data.`; + 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 => @@ -607,7 +615,7 @@ const createContextMcpServer = ( { title: 'Inspect code evidence', description: - 'Join exact-path aggregate execution, test outcome, diagnostics, and optional explicit artifact module evidence without collapsing their bounds.', + '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, }, @@ -747,7 +755,8 @@ const createContextMcpServer = ( 'test_snapshot', { title: 'Capture test snapshot', - description: 'Run one explicit one-shot Rstest capture and store its immutable results.', + description: + 'Run one explicit one-shot Rstest capture, optionally selected from related source files, and store its immutable results.', inputSchema: testSnapshotInput, annotations: { readOnlyHint: false, diff --git a/src/model.ts b/src/model.ts index 520d096..ef771b7 100644 --- a/src/model.ts +++ b/src/model.ts @@ -127,8 +127,14 @@ type TestFileRecord = { tests: TestCaseRecord[]; }; +type TestRelationRecord = { + sources: string[]; + testFiles: string[]; +}; + type TestFacet = { producer: 'rstest'; + relation?: TestRelationRecord; files: TestFileRecord[]; stats: { tests: { @@ -317,4 +323,5 @@ export type { TestExecutionStatement, TestFacet, TestFileRecord, + TestRelationRecord, }; diff --git a/src/records.ts b/src/records.ts index 5dd985f..3f08b62 100644 --- a/src/records.ts +++ b/src/records.ts @@ -159,10 +159,18 @@ const isTestFile = (value: unknown): boolean => 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) || diff --git a/src/testRun.ts b/src/testRun.ts index 58bf96c..686b733 100644 --- a/src/testRun.ts +++ b/src/testRun.ts @@ -36,6 +36,7 @@ import { type TestSnapshotRequest = { files?: string[]; + related?: string[]; testNamePattern?: string; packageRoot?: string; configPath?: string; @@ -74,6 +75,14 @@ type TestCaptureResult = { type RunRstest = (options?: RunRstestOptions) => Promise; +type RelatedTestRequest = { + packageRoot: string; + configPath?: string; + sources: string[]; +}; + +type ResolveRelatedTests = (request: RelatedTestRequest) => Promise; + type TestCaptureDependencies = { runRstest?: RunRstest; createRunId?: () => string; @@ -81,6 +90,7 @@ type TestCaptureDependencies = { now?: () => Date; wrapperConfigPath?: string; withConfigTarget?: ConfigTargetRunner; + resolveRelatedTests?: ResolveRelatedTests; }; const toWorkspacePath = (workspaceRoot: string, filePath: string): string => @@ -139,8 +149,13 @@ const normalizeTestFile = ( .sort(compareTestCases), }); -const normalizeTestFacet = (workspaceRoot: string, result: TestRunResult): TestFacet => ({ +const normalizeTestFacet = ( + workspaceRoot: string, + result: TestRunResult, + relation?: TestFacet['relation'], +): TestFacet => ({ producer: 'rstest', + ...(relation === undefined ? {} : { relation }), files: result.files .map((fileResult) => normalizeTestFile(workspaceRoot, fileResult)) .sort( @@ -159,6 +174,9 @@ 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 }), }); @@ -173,12 +191,38 @@ const ensureWritten = (result: Awaited>) const loadRunRstest = async (): Promise => (await import('@rstest/core/api')).runRstest; +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 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); const target = await resolveExplicitCaptureTarget(workspaceRoot, request); const wrapperConfigPath = dependencies.wrapperConfigPath ?? @@ -198,46 +242,74 @@ const captureTestSnapshot = async ( now, }); ensureWritten(await writeContextRunManifest(workspaceRoot, run)); - let result: TestRunResult; + let result: TestRunResult | undefined; + let relation: TestFacet['relation']; try { - const runRstest = dependencies.runRstest ?? (await loadRunRstest()); - const withConfigTarget = - dependencies.withConfigTarget ?? - (async (_configRoot, _configPath, action) => { - if (dependencies.runRstest === undefined) { - throw new Error('Rstack test capture requires a config adapter.'); - } - return action(); - }); - result = await withConfigTarget(target.packageRoot, target.configPath, () => - runRstest({ - cwd: target.packageRoot, - config: wrapperConfigPath, - ...(request.execution === undefined - ? {} - : { - inlineConfig: { - coverage: { - enabled: true, - provider: 'istanbul' as const, - reporters: [], - reportOnFailure: true, - ...(request.execution.include === undefined - ? {} - : { include: request.execution.include }), - ...(request.execution.exclude === undefined - ? {} - : { exclude: request.execution.exclude }), - allowExternal: request.execution.allowExternal ?? false, + let selectedFiles = request.files; + if (request.related !== undefined) { + if (dependencies.resolveRelatedTests === undefined) { + throw new Error('Rstack test capture requires a related-test resolver.'); + } + const sourceFiles = [ + ...new Set(request.related.map((source) => path.resolve(target.packageRoot, source))), + ]; + const testFiles = [ + ...new Set( + await dependencies.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) => { + if (dependencies.runRstest === undefined) { + throw new Error('Rstack test capture requires a config adapter.'); + } + return action(); + }); + result = await withConfigTarget(target.packageRoot, target.configPath, () => + runRstest({ + cwd: target.packageRoot, + config: wrapperConfigPath, + ...(request.execution === undefined + ? {} + : { + inlineConfig: { + coverage: { + enabled: true, + provider: 'istanbul' as const, + reporters: [], + reportOnFailure: true, + ...(request.execution.include === undefined + ? {} + : { include: request.execution.include }), + ...(request.execution.exclude === undefined + ? {} + : { exclude: request.execution.exclude }), + allowExternal: request.execution.allowExternal ?? false, + }, }, - }, - }), - ...(request.files === undefined ? {} : { files: request.files }), - ...(request.testNamePattern === undefined - ? {} - : { testNamePattern: request.testNamePattern }), - }), - ); + }), + ...(selectedFiles === undefined ? {} : { files: selectedFiles }), + ...(request.testNamePattern === undefined + ? {} + : { testNamePattern: request.testNamePattern }), + }), + ); + } } catch (error) { const capturedError: TestErrorRecord = error instanceof Error @@ -283,7 +355,7 @@ const captureTestSnapshot = async ( ensureWritten(await writeContextSnapshot(workspaceRoot, snapshot)); throw error; } - const facet = normalizeTestFacet(workspaceRoot, result); + const facet = normalizeTestFacet(workspaceRoot, result, relation); const executionFacet = request.execution === undefined ? undefined @@ -294,7 +366,11 @@ const captureTestSnapshot = async ( result.coverage, ); const testInputs = await recordContextInputFiles(workspaceRoot, [ - ...new Set(facet.files.map((file) => file.path)), + ...new Set([ + ...facet.files.map((file) => file.path), + ...(facet.relation?.sources ?? []), + ...(facet.relation?.testFiles ?? []), + ]), ]); const executionInputs = executionFacet?.files.flatMap((file) => @@ -407,6 +483,8 @@ export { captureTestSnapshot, listTestResults }; export type { TestCaptureDependencies, TestCaptureResult, + RelatedTestRequest, + ResolveRelatedTests, TestResultPage, TestResultsQuery, TestSnapshotRequest, diff --git a/tests/codeEvidence.test.ts b/tests/codeEvidence.test.ts index a7e42e3..f5828d0 100644 --- a/tests/codeEvidence.test.ts +++ b/tests/codeEvidence.test.ts @@ -279,6 +279,79 @@ test('joins newest exact-path execution, test outcome, and diagnostics without i }); }); +test('reports captured related-test evidence independently from test execution', async () => { + await withTempWorkspace(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: 'unknown', reason: 'no-exact-test-record' }, + }); + 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 missing, stale, incomplete, and non-overlapping execution evidence inconclusive', async () => { await withTempWorkspace(async (workspaceRoot) => { const sourcePath = path.join(workspaceRoot, 'packages', 'one', 'src', 'value.ts'); @@ -325,6 +398,7 @@ test('keeps missing, stale, incomplete, and non-overlapping execution evidence i 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( diff --git a/tests/records.test.ts b/tests/records.test.ts index 48aa934..6e98302 100644 --- a/tests/records.test.ts +++ b/tests/records.test.ts @@ -124,6 +124,10 @@ test('validates lint and test facets with captured source inputs', () => { } as const; const testFacet = { producer: 'rstest', + relation: { + sources: ['src/index.ts'], + testFiles: ['src/index.test.ts'], + }, files: [ { project: 'unit', @@ -365,6 +369,12 @@ test('rejects malformed known facets and unpaired source input metadata', () => 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, diff --git a/tests/testRun.test.ts b/tests/testRun.test.ts index b5369d9..1609e82 100644 --- a/tests/testRun.test.ts +++ b/tests/testRun.test.ts @@ -52,7 +52,7 @@ const createDependencies = ( now: () => new Date('2026-08-12T08:00:00.000Z'), }); -test('publishes the Istanbul provider as an exact required peer', async () => { +test('publishes the opt-in Istanbul provider as an exact optional peer', async () => { const packageJson = JSON.parse( await readFile(new URL('../package.json', import.meta.url), 'utf8'), ) as { @@ -65,7 +65,9 @@ test('publishes the Istanbul provider as an exact required peer', async () => { expect(packageJson.dependencies?.['@rstest/coverage-istanbul']).toBeUndefined(); expect(packageJson.devDependencies?.['@rstest/coverage-istanbul']).toBe('catalog:'); expect(packageJson.peerDependencies?.['@rstest/coverage-istanbul']).toBe('0.11.6'); - expect(packageJson.peerDependenciesMeta?.['@rstest/coverage-istanbul']).toBeUndefined(); + expect(packageJson.peerDependenciesMeta?.['@rstest/coverage-istanbul']).toEqual({ + optional: true, + }); }); test('captures one passing run with partial source freshness', async () => { @@ -196,6 +198,123 @@ test('captures one passing run with partial source freshness', async () => { }); }); +test('resolves related source files before running and records the static test relation', async () => { + await withTempWorkspace(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('rejects files and related source selection together', async () => { + await withTempWorkspace(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(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(async (workspaceRoot) => { const testPath = path.join(workspaceRoot, 'src', 'math.test.ts'); @@ -1060,7 +1179,7 @@ type AssertNever = T; type UnsupportedRequestFields = AssertNever< Extract< keyof TestSnapshotRequest, - 'apply' | 'changed' | 'related' | 'reporter' | 'shard' | 'update' | 'watch' + 'apply' | 'changed' | 'reporter' | 'shard' | 'update' | 'watch' > >; From 518e89c6d8b7f8ec12720b3ba1839b4ffd495891 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 14 Aug 2026 02:09:26 +0000 Subject: [PATCH 12/46] chore: prepare context extraction --- README.md | 2 -- 1 file changed, 2 deletions(-) delete mode 100644 README.md diff --git a/README.md b/README.md deleted file mode 100644 index 2efcde3..0000000 --- a/README.md +++ /dev/null @@ -1,2 +0,0 @@ -# context -Rstack project context, evidence storage, and MCP runtime. From e12e17259470b23f3aca53172d51e22930827eeb Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 14 Aug 2026 02:15:01 +0000 Subject: [PATCH 13/46] feat: establish standalone context repository --- .github/workflows/ci.yml | 41 + .github/workflows/pkg-pr-new.yml | 33 + .gitignore | 5 + .prettierignore | 4 + .prettierrc.json | 4 + AGENTS.md | 35 + README.md | 14 + docs/repository-extraction.md | 213 ++++ docs/rfc.md | 694 +++++++++++ package.json | 19 +- pnpm-lock.yaml | 1899 ++++++++++++++++++++++++++++++ pnpm-workspace.yaml | 27 + rslint.config.ts | 29 + tests/mcp.test.ts | 70 ++ 14 files changed, 3081 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/pkg-pr-new.yml create mode 100644 .gitignore create mode 100644 .prettierignore create mode 100644 .prettierrc.json create mode 100644 AGENTS.md create mode 100644 docs/repository-extraction.md create mode 100644 docs/rfc.md create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 rslint.config.ts create mode 100644 tests/mcp.test.ts 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..c90f7fe --- /dev/null +++ b/.github/workflows/pkg-pr-new.yml @@ -0,0 +1,33 @@ +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 + run: pnpm exec pkg-pr-new publish --pnpm --previewVersion --no-template '.' 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/README.md b/README.md index 1770001..77815ec 100644 --- a/README.md +++ b/README.md @@ -13,3 +13,17 @@ 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. diff --git a/docs/repository-extraction.md b/docs/repository-extraction.md new file mode 100644 index 0000000..d716826 --- /dev/null +++ b/docs/repository-extraction.md @@ -0,0 +1,213 @@ +# 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 leaves execution evidence unavailable rather than zero; +- 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..ead439b --- /dev/null +++ b/docs/rfc.md @@ -0,0 +1,694 @@ +# 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. + +```mermaid +flowchart LR + Artifact["Explicit Rsdoctor artifact"] --> Normalize["Normalize module graph"] + Context["Selected application or library context"] --> 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. + +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 test 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. Test outcomes do not imply a source-to-test relation, and module reachability, +shipment, public contract, and optimizer retention remain separate from runtime evidence. +No exact test 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 only aggregate execution, exact-path outcomes and +diagnostics, and an optional explicit artifact module. It does not claim per-test attribution or a +source-to-test relationship. + +## 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/package.json b/package.json index e1d5c14..a3b0d81 100644 --- a/package.json +++ b/package.json @@ -4,12 +4,11 @@ "description": "Rstack project context, evidence storage, and MCP runtime.", "homepage": "https://rstack.rs", "bugs": { - "url": "https://github.com/rstackjs/rstack-cli/issues" + "url": "https://github.com/rstackjs/context/issues" }, "repository": { "type": "git", - "url": "git+https://github.com/rstackjs/rstack-cli.git", - "directory": "packages/context" + "url": "git+https://github.com/rstackjs/context.git" }, "license": "MIT", "type": "module", @@ -30,8 +29,13 @@ ], "scripts": { "build": "rslib", + "check": "rslint --type-check . && prettier --check .", "dev": "rslib --watch", - "test": "rstest" + "format": "prettier --write .", + "lint": "rslint --type-check .", + "test": "rstest run", + "test:coverage": "rstest run --coverage", + "test:watch": "rstest" }, "dependencies": { "@modelcontextprotocol/sdk": "catalog:", @@ -45,7 +49,8 @@ "@rslib/core": "catalog:", "@rstest/coverage-istanbul": "catalog:", "@types/node": "catalog:", - "fast-json-stable-stringify": "catalog:", + "pkg-pr-new": "catalog:", + "prettier": "catalog:", "typescript": "catalog:" }, "peerDependencies": { @@ -57,8 +62,10 @@ } }, "engines": { - "node": ">=22.12.0" + "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..da3ec0e --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1899 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: false + dedupePeers: true + excludeLinksFromLockfile: false + +catalogs: + default: + '@modelcontextprotocol/sdk': + specifier: 1.29.0 + version: 1.29.0 + '@rsbuild/core': + specifier: ~2.1.11 + version: 2.1.11 + '@rsdoctor/agent-cli': + specifier: 0.1.1 + version: 0.1.1 + '@rslib/core': + specifier: ~1.0.0-beta.3 + version: 1.0.0-beta.3 + '@rslint/core': + specifier: ~0.8.0 + version: 0.8.0 + '@rstest/core': + specifier: ~0.11.6 + version: 0.11.6 + '@rstest/coverage-istanbul': + specifier: 0.11.6 + version: 0.11.6 + '@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 + zod: + specifier: 4.4.3 + version: 4.4.3 + +importers: + + .: + dependencies: + '@modelcontextprotocol/sdk': + specifier: 'catalog:' + version: 1.29.0(supports-color@7.2.0)(zod@4.4.3) + '@rsbuild/core': + specifier: 'catalog:' + version: 2.1.11 + '@rsdoctor/agent-cli': + specifier: 'catalog:' + version: 0.1.1 + '@rslint/core': + specifier: 'catalog:' + version: 0.8.0 + '@rstest/core': + specifier: 'catalog:' + version: 0.11.6 + zod: + specifier: 'catalog:' + version: 4.4.3 + devDependencies: + '@rslib/core': + specifier: 'catalog:' + version: 1.0.0-beta.3(typescript@7.0.2) + '@rstest/coverage-istanbul': + specifier: 'catalog:' + version: 0.11.6(@rstest/core@0.11.6)(supports-color@7.2.0) + '@types/node': + specifier: 'catalog:' + version: 24.13.3 + pkg-pr-new: + specifier: 'catalog:' + version: 0.0.87 + prettier: + specifier: 'catalog:' + version: 3.9.6 + typescript: + specifier: 'catalog:' + 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@0.1.1': + resolution: {integrity: sha512-5bLoaIvvrlVcDRKGQFKXf5OiQkpcoHivVxqy+93ujxcATc6quWiJ3il9rLLzEV0mP0cF3IEsFpituVzHtBy6MA==} + 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-arm64@2.1.9': + resolution: {integrity: sha512-sQQgKx+1ckW5GI6w/ozJkoaQM0Skbwj7hFiv+Y2d7+iAB7OVz83LWFrodRl1QGCg1QNuaEmlVo9AUapWUSr/8g==} + cpu: [arm64] + os: [darwin] + + '@rspack/binding-darwin-x64@2.1.10': + resolution: {integrity: sha512-my/0h2LwxCRT6cg3oDDC2e0ZOxQLVajAdIcv0fqnQk5JRNvVuL89PuTutitnSqie1A0/JSL8OQz5XHwmoS3kow==} + cpu: [x64] + os: [darwin] + + '@rspack/binding-darwin-x64@2.1.9': + resolution: {integrity: sha512-kuWzn8JFKJUxSFwX/+rzvZUm4fRrSbXCTCAlzb0iHvP6vftjCFHGpNJfWjD7yX9O7C/dtoGiNT1O1veJytVsWA==} + 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-gnu@2.1.9': + resolution: {integrity: sha512-rj1TjWGuG9Zc+fmwfvOpRO9npuHMKE4xXSB/BZqZNcH5Uey4NcAo1/GF8zHRoalQrwf0roP9WsFX1tk85rzP/Q==} + 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-arm64-musl@2.1.9': + resolution: {integrity: sha512-Z2+sS2z9Imt3og0e3Kq4hEumiqTajQBXkl9cqSYj1lwOgmViLAvMX4BlCL1cinIEstixFKQ+xsta9SI6ivCKtg==} + 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-ppc64-gnu@2.1.9': + resolution: {integrity: sha512-xK90IHipRDgxvDF9n90HRNklci0G2Amk0ZMsM3t3YX+/8jIJNcuEPk6hJ1hlY6KZu7U9enqGbNQ7Fe0StBeLVA==} + 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-gnu@2.1.9': + resolution: {integrity: sha512-aaOwU3voq20Vwmfu1V6UPz8eQJBitBUNbLc08dxHGsmQM+ap6dd4j5xn/i5Y6AfLro2Q3GJvpCayc+dVIOR32g==} + 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-riscv64-musl@2.1.9': + resolution: {integrity: sha512-tF7XnEtpTYyVS8ef96IKUjFhd9lFa9Swv212fcyWxRhxRn4PEYVWpSh/D3NDVX+i69Z7xFDDEoyMUdYBkkVTlw==} + 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-s390x-gnu@2.1.9': + resolution: {integrity: sha512-rGmeME3Pd8k/55txP+19ceZJxhVN2mtC8TLzB1ycTrhy8U+ZnN7J8rZSl89LYrZGYewd1UmOcY0QKKy05FS3FA==} + 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-gnu@2.1.9': + resolution: {integrity: sha512-YimUCS//wl+AZjXvgVig7sJtPiGs+WNMH4g49F1msB5T40rw0aOopp9O3MdC+LFfRm6vpIJwOHd6alo/UUs7nA==} + 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-linux-x64-musl@2.1.9': + resolution: {integrity: sha512-tYJRdoB3IWVVcnGPZqCnRjC5MJle7xHucKkIuKtGbSMS6hzg6B1oKZav1BBw72gxnrW4n2Bwt82TBKQKBUSjmA==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rspack/binding-wasm32-wasi@2.1.10': + resolution: {integrity: sha512-KY5YbWbuvYcoaLXnV+vzZOvGRCeb6jt4EpVpKdph1h1IJjwX/ju15EQ+GOe3iecZEdf0OttQcNVcwBkLkFT9ag==} + cpu: [wasm32] + + '@rspack/binding-wasm32-wasi@2.1.9': + resolution: {integrity: sha512-TF6oZRU23x6vHzGuvRFszvEnmC5Yn8PHbKmeZWsUHj4Mtv4tDdDGVdlxj6Kq3pySS6sRknv8gzpRMhXqHD3I5g==} + 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-arm64-msvc@2.1.9': + resolution: {integrity: sha512-tLt4gbvUelmtJGI3PHtQHZuGpaO2z/ym6+OXAjc6NR2jWbzljardSjONiXVWIi1zmzODt4dD/fL3Ncb+RU/jHQ==} + 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-ia32-msvc@2.1.9': + resolution: {integrity: sha512-VznxSrGPb4mvxkTHTdGolEpBOuDW7RICD9Rp266MhYLQG4c0uNcX7+vWF4HbFvB0kN+Gdkj7vz+5shWdrfK72Q==} + cpu: [ia32] + os: [win32] + + '@rspack/binding-win32-x64-msvc@2.1.10': + resolution: {integrity: sha512-pgp23pLrzfhGnKycxzr7ifP17lAbWZEfnx1bX8gXtYrnpJ66DRNyTKSzxB6sa/HBWjS1L8PX5TjMZ44WfPydqQ==} + cpu: [x64] + os: [win32] + + '@rspack/binding-win32-x64-msvc@2.1.9': + resolution: {integrity: sha512-P1dGC6t3PJinqN6tBZ+jyou4LvwpD2ygeovKgg5tuYWKFMUwh1v60yX3afOUwaJMEGbHUye7xuYRd84NCMSNOQ==} + cpu: [x64] + os: [win32] + + '@rspack/binding@2.1.10': + resolution: {integrity: sha512-vnu/UP5HnrND15lO9+VeG6eUrbTyycHNQNQ3XEiRiFojuoiGZkIZC3Hbzr8qQH44C6vScPODEPvvIVvLcO2LpQ==} + + '@rspack/binding@2.1.9': + resolution: {integrity: sha512-0ZZj+RE62/jFRwX5czqjjGiMGgzSK0hm7/66PtCKjyZjb2tNAg3WGyWv+ref7684ZAjtbHHpZ+EOGswQYBjklA==} + + '@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 + + '@rspack/core@2.1.9': + resolution: {integrity: sha512-kXd5aYrkO+91fYolNYAjUhW/1F9kGmw7PtneNRY6z3KK6ttJgQWMVNypiCkhK3TOcyWPGH42qd0bzHZlH72JaA==} + 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.9(@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@0.1.1': {} + + '@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-arm64@2.1.9': + optional: true + + '@rspack/binding-darwin-x64@2.1.10': + optional: true + + '@rspack/binding-darwin-x64@2.1.9': + optional: true + + '@rspack/binding-linux-arm64-gnu@2.1.10': + optional: true + + '@rspack/binding-linux-arm64-gnu@2.1.9': + optional: true + + '@rspack/binding-linux-arm64-musl@2.1.10': + optional: true + + '@rspack/binding-linux-arm64-musl@2.1.9': + optional: true + + '@rspack/binding-linux-ppc64-gnu@2.1.10': + optional: true + + '@rspack/binding-linux-ppc64-gnu@2.1.9': + optional: true + + '@rspack/binding-linux-riscv64-gnu@2.1.10': + optional: true + + '@rspack/binding-linux-riscv64-gnu@2.1.9': + optional: true + + '@rspack/binding-linux-riscv64-musl@2.1.10': + optional: true + + '@rspack/binding-linux-riscv64-musl@2.1.9': + optional: true + + '@rspack/binding-linux-s390x-gnu@2.1.10': + optional: true + + '@rspack/binding-linux-s390x-gnu@2.1.9': + optional: true + + '@rspack/binding-linux-x64-gnu@2.1.10': + optional: true + + '@rspack/binding-linux-x64-gnu@2.1.9': + optional: true + + '@rspack/binding-linux-x64-musl@2.1.10': + optional: true + + '@rspack/binding-linux-x64-musl@2.1.9': + 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-wasm32-wasi@2.1.9': + 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-arm64-msvc@2.1.9': + optional: true + + '@rspack/binding-win32-ia32-msvc@2.1.10': + optional: true + + '@rspack/binding-win32-ia32-msvc@2.1.9': + optional: true + + '@rspack/binding-win32-x64-msvc@2.1.10': + optional: true + + '@rspack/binding-win32-x64-msvc@2.1.9': + 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/binding@2.1.9': + optionalDependencies: + '@rspack/binding-darwin-arm64': 2.1.9 + '@rspack/binding-darwin-x64': 2.1.9 + '@rspack/binding-linux-arm64-gnu': 2.1.9 + '@rspack/binding-linux-arm64-musl': 2.1.9 + '@rspack/binding-linux-ppc64-gnu': 2.1.9 + '@rspack/binding-linux-riscv64-gnu': 2.1.9 + '@rspack/binding-linux-riscv64-musl': 2.1.9 + '@rspack/binding-linux-s390x-gnu': 2.1.9 + '@rspack/binding-linux-x64-gnu': 2.1.9 + '@rspack/binding-linux-x64-musl': 2.1.9 + '@rspack/binding-wasm32-wasi': 2.1.9 + '@rspack/binding-win32-arm64-msvc': 2.1.9 + '@rspack/binding-win32-ia32-msvc': 2.1.9 + '@rspack/binding-win32-x64-msvc': 2.1.9 + + '@rspack/core@2.1.10(@swc/helpers@0.5.23)': + dependencies: + '@rspack/binding': 2.1.10 + optionalDependencies: + '@swc/helpers': 0.5.23 + + '@rspack/core@2.1.9(@swc/helpers@0.5.23)': + dependencies: + '@rspack/binding': 2.1.9 + optionalDependencies: + '@swc/helpers': 0.5.23 + + '@rstest/core@0.11.6': + dependencies: + '@rsbuild/core': 2.1.11 + '@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..c2d562f --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,27 @@ +catalogMode: prefer + +catalog: + '@modelcontextprotocol/sdk': '1.29.0' + '@rsbuild/core': '~2.1.11' + '@rsdoctor/agent-cli': '0.1.1' + '@rslib/core': '~1.0.0-beta.3' + '@rslint/core': '~0.8.0' + '@rstest/core': '~0.11.6' + '@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' + zod: '4.4.3' + +autoInstallPeers: false +cleanupUnusedCatalogs: true +dedupePeers: true +engineStrict: true + +minimumReleaseAge: 1440 +minimumReleaseAgeExclude: + - '@rsbuild/*' + - '@rslib/*' + - '@rspack/*' + - 'rsbuild-plugin-*' 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/tests/mcp.test.ts b/tests/mcp.test.ts new file mode 100644 index 0000000..bc11672 --- /dev/null +++ b/tests/mcp.test.ts @@ -0,0 +1,70 @@ +import { mkdtemp, rm } 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 } from '../src/mcp.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) => Promise): Promise => { + const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), 'rstack-context-mcp-')); + const server = createContextMcpServer(workspaceRoot, { 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); + } 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); + }); +}); + +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: [], + }); + }); +}); From 3571e69a7b8e0899059e2e11b7523331ad252e2f Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 14 Aug 2026 02:19:09 +0000 Subject: [PATCH 14/46] build: support source dependency installs --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index a3b0d81..0ad5336 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "dev": "rslib --watch", "format": "prettier --write .", "lint": "rslint --type-check .", + "prepare": "pnpm build", "test": "rstest run", "test:coverage": "rstest run --coverage", "test:watch": "rstest" From 993e4f2e15976cad09c7a3596681d169ab6edcea Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 14 Aug 2026 02:21:15 +0000 Subject: [PATCH 15/46] fix: make source installs portable --- package.json | 24 ++--- pnpm-lock.yaml | 245 +++----------------------------------------- pnpm-workspace.yaml | 17 --- 3 files changed, 26 insertions(+), 260 deletions(-) diff --git a/package.json b/package.json index 0ad5336..b3e95f7 100644 --- a/package.json +++ b/package.json @@ -39,20 +39,20 @@ "test:watch": "rstest" }, "dependencies": { - "@modelcontextprotocol/sdk": "catalog:", - "@rsbuild/core": "catalog:", - "@rsdoctor/agent-cli": "catalog:", - "@rslint/core": "catalog:", - "@rstest/core": "catalog:", - "zod": "catalog:" + "@modelcontextprotocol/sdk": "1.29.0", + "@rsbuild/core": "~2.1.11", + "@rsdoctor/agent-cli": "0.1.1", + "@rslint/core": "~0.8.0", + "@rstest/core": "~0.11.6", + "zod": "4.4.3" }, "devDependencies": { - "@rslib/core": "catalog:", - "@rstest/coverage-istanbul": "catalog:", - "@types/node": "catalog:", - "pkg-pr-new": "catalog:", - "prettier": "catalog:", - "typescript": "catalog:" + "@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": { "@rstest/coverage-istanbul": "0.11.6" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da3ec0e..e592b24 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,85 +5,46 @@ settings: dedupePeers: true excludeLinksFromLockfile: false -catalogs: - default: - '@modelcontextprotocol/sdk': - specifier: 1.29.0 - version: 1.29.0 - '@rsbuild/core': - specifier: ~2.1.11 - version: 2.1.11 - '@rsdoctor/agent-cli': - specifier: 0.1.1 - version: 0.1.1 - '@rslib/core': - specifier: ~1.0.0-beta.3 - version: 1.0.0-beta.3 - '@rslint/core': - specifier: ~0.8.0 - version: 0.8.0 - '@rstest/core': - specifier: ~0.11.6 - version: 0.11.6 - '@rstest/coverage-istanbul': - specifier: 0.11.6 - version: 0.11.6 - '@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 - zod: - specifier: 4.4.3 - version: 4.4.3 - importers: .: dependencies: '@modelcontextprotocol/sdk': - specifier: 'catalog:' + specifier: 1.29.0 version: 1.29.0(supports-color@7.2.0)(zod@4.4.3) '@rsbuild/core': - specifier: 'catalog:' - version: 2.1.11 + specifier: ~2.1.11 + version: 2.1.12 '@rsdoctor/agent-cli': - specifier: 'catalog:' + specifier: 0.1.1 version: 0.1.1 '@rslint/core': - specifier: 'catalog:' + specifier: ~0.8.0 version: 0.8.0 '@rstest/core': - specifier: 'catalog:' + specifier: ~0.11.6 version: 0.11.6 zod: - specifier: 'catalog:' + specifier: 4.4.3 version: 4.4.3 devDependencies: '@rslib/core': - specifier: 'catalog:' + specifier: ~1.0.0-beta.3 version: 1.0.0-beta.3(typescript@7.0.2) '@rstest/coverage-istanbul': - specifier: 'catalog:' + specifier: 0.11.6 version: 0.11.6(@rstest/core@0.11.6)(supports-color@7.2.0) '@types/node': - specifier: 'catalog:' + specifier: ^24.13.3 version: 24.13.3 pkg-pr-new: - specifier: 'catalog:' + specifier: 0.0.87 version: 0.0.87 prettier: - specifier: 'catalog:' + specifier: 3.9.6 version: 3.9.6 typescript: - specifier: 'catalog:' + specifier: ^7.0.2 version: 7.0.2 packages: @@ -191,16 +152,6 @@ packages: '@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} @@ -286,161 +237,81 @@ packages: cpu: [arm64] os: [darwin] - '@rspack/binding-darwin-arm64@2.1.9': - resolution: {integrity: sha512-sQQgKx+1ckW5GI6w/ozJkoaQM0Skbwj7hFiv+Y2d7+iAB7OVz83LWFrodRl1QGCg1QNuaEmlVo9AUapWUSr/8g==} - cpu: [arm64] - os: [darwin] - '@rspack/binding-darwin-x64@2.1.10': resolution: {integrity: sha512-my/0h2LwxCRT6cg3oDDC2e0ZOxQLVajAdIcv0fqnQk5JRNvVuL89PuTutitnSqie1A0/JSL8OQz5XHwmoS3kow==} cpu: [x64] os: [darwin] - '@rspack/binding-darwin-x64@2.1.9': - resolution: {integrity: sha512-kuWzn8JFKJUxSFwX/+rzvZUm4fRrSbXCTCAlzb0iHvP6vftjCFHGpNJfWjD7yX9O7C/dtoGiNT1O1veJytVsWA==} - 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-gnu@2.1.9': - resolution: {integrity: sha512-rj1TjWGuG9Zc+fmwfvOpRO9npuHMKE4xXSB/BZqZNcH5Uey4NcAo1/GF8zHRoalQrwf0roP9WsFX1tk85rzP/Q==} - 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-arm64-musl@2.1.9': - resolution: {integrity: sha512-Z2+sS2z9Imt3og0e3Kq4hEumiqTajQBXkl9cqSYj1lwOgmViLAvMX4BlCL1cinIEstixFKQ+xsta9SI6ivCKtg==} - 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-ppc64-gnu@2.1.9': - resolution: {integrity: sha512-xK90IHipRDgxvDF9n90HRNklci0G2Amk0ZMsM3t3YX+/8jIJNcuEPk6hJ1hlY6KZu7U9enqGbNQ7Fe0StBeLVA==} - 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-gnu@2.1.9': - resolution: {integrity: sha512-aaOwU3voq20Vwmfu1V6UPz8eQJBitBUNbLc08dxHGsmQM+ap6dd4j5xn/i5Y6AfLro2Q3GJvpCayc+dVIOR32g==} - 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-riscv64-musl@2.1.9': - resolution: {integrity: sha512-tF7XnEtpTYyVS8ef96IKUjFhd9lFa9Swv212fcyWxRhxRn4PEYVWpSh/D3NDVX+i69Z7xFDDEoyMUdYBkkVTlw==} - 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-s390x-gnu@2.1.9': - resolution: {integrity: sha512-rGmeME3Pd8k/55txP+19ceZJxhVN2mtC8TLzB1ycTrhy8U+ZnN7J8rZSl89LYrZGYewd1UmOcY0QKKy05FS3FA==} - 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-gnu@2.1.9': - resolution: {integrity: sha512-YimUCS//wl+AZjXvgVig7sJtPiGs+WNMH4g49F1msB5T40rw0aOopp9O3MdC+LFfRm6vpIJwOHd6alo/UUs7nA==} - 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-linux-x64-musl@2.1.9': - resolution: {integrity: sha512-tYJRdoB3IWVVcnGPZqCnRjC5MJle7xHucKkIuKtGbSMS6hzg6B1oKZav1BBw72gxnrW4n2Bwt82TBKQKBUSjmA==} - cpu: [x64] - os: [linux] - libc: [musl] - '@rspack/binding-wasm32-wasi@2.1.10': resolution: {integrity: sha512-KY5YbWbuvYcoaLXnV+vzZOvGRCeb6jt4EpVpKdph1h1IJjwX/ju15EQ+GOe3iecZEdf0OttQcNVcwBkLkFT9ag==} cpu: [wasm32] - '@rspack/binding-wasm32-wasi@2.1.9': - resolution: {integrity: sha512-TF6oZRU23x6vHzGuvRFszvEnmC5Yn8PHbKmeZWsUHj4Mtv4tDdDGVdlxj6Kq3pySS6sRknv8gzpRMhXqHD3I5g==} - 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-arm64-msvc@2.1.9': - resolution: {integrity: sha512-tLt4gbvUelmtJGI3PHtQHZuGpaO2z/ym6+OXAjc6NR2jWbzljardSjONiXVWIi1zmzODt4dD/fL3Ncb+RU/jHQ==} - 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-ia32-msvc@2.1.9': - resolution: {integrity: sha512-VznxSrGPb4mvxkTHTdGolEpBOuDW7RICD9Rp266MhYLQG4c0uNcX7+vWF4HbFvB0kN+Gdkj7vz+5shWdrfK72Q==} - cpu: [ia32] - os: [win32] - '@rspack/binding-win32-x64-msvc@2.1.10': resolution: {integrity: sha512-pgp23pLrzfhGnKycxzr7ifP17lAbWZEfnx1bX8gXtYrnpJ66DRNyTKSzxB6sa/HBWjS1L8PX5TjMZ44WfPydqQ==} cpu: [x64] os: [win32] - '@rspack/binding-win32-x64-msvc@2.1.9': - resolution: {integrity: sha512-P1dGC6t3PJinqN6tBZ+jyou4LvwpD2ygeovKgg5tuYWKFMUwh1v60yX3afOUwaJMEGbHUye7xuYRd84NCMSNOQ==} - cpu: [x64] - os: [win32] - '@rspack/binding@2.1.10': resolution: {integrity: sha512-vnu/UP5HnrND15lO9+VeG6eUrbTyycHNQNQ3XEiRiFojuoiGZkIZC3Hbzr8qQH44C6vScPODEPvvIVvLcO2LpQ==} - '@rspack/binding@2.1.9': - resolution: {integrity: sha512-0ZZj+RE62/jFRwX5czqjjGiMGgzSK0hm7/66PtCKjyZjb2tNAg3WGyWv+ref7684ZAjtbHHpZ+EOGswQYBjklA==} - '@rspack/core@2.1.10': resolution: {integrity: sha512-YSS2/Xxz8uiG/KXDkqOoA3dTetNo/vysk7bAexQOrU8iuq7JuzDTTAwLKvWZnwmvME8M8m5wcM4YvfIwYmidHA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -453,18 +324,6 @@ packages: '@swc/helpers': optional: true - '@rspack/core@2.1.9': - resolution: {integrity: sha512-kXd5aYrkO+91fYolNYAjUhW/1F9kGmw7PtneNRY6z3KK6ttJgQWMVNypiCkhK3TOcyWPGH42qd0bzHZlH72JaA==} - 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} @@ -1146,13 +1005,6 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true - '@rsbuild/core@2.1.11': - dependencies: - '@rspack/core': 2.1.9(@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) @@ -1212,63 +1064,33 @@ snapshots: '@rspack/binding-darwin-arm64@2.1.10': optional: true - '@rspack/binding-darwin-arm64@2.1.9': - optional: true - '@rspack/binding-darwin-x64@2.1.10': optional: true - '@rspack/binding-darwin-x64@2.1.9': - optional: true - '@rspack/binding-linux-arm64-gnu@2.1.10': optional: true - '@rspack/binding-linux-arm64-gnu@2.1.9': - optional: true - '@rspack/binding-linux-arm64-musl@2.1.10': optional: true - '@rspack/binding-linux-arm64-musl@2.1.9': - optional: true - '@rspack/binding-linux-ppc64-gnu@2.1.10': optional: true - '@rspack/binding-linux-ppc64-gnu@2.1.9': - optional: true - '@rspack/binding-linux-riscv64-gnu@2.1.10': optional: true - '@rspack/binding-linux-riscv64-gnu@2.1.9': - optional: true - '@rspack/binding-linux-riscv64-musl@2.1.10': optional: true - '@rspack/binding-linux-riscv64-musl@2.1.9': - optional: true - '@rspack/binding-linux-s390x-gnu@2.1.10': optional: true - '@rspack/binding-linux-s390x-gnu@2.1.9': - optional: true - '@rspack/binding-linux-x64-gnu@2.1.10': optional: true - '@rspack/binding-linux-x64-gnu@2.1.9': - optional: true - '@rspack/binding-linux-x64-musl@2.1.10': optional: true - '@rspack/binding-linux-x64-musl@2.1.9': - optional: true - '@rspack/binding-wasm32-wasi@2.1.10': dependencies: '@emnapi/core': 1.11.3 @@ -1276,31 +1098,15 @@ snapshots: '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) optional: true - '@rspack/binding-wasm32-wasi@2.1.9': - 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-arm64-msvc@2.1.9': - optional: true - '@rspack/binding-win32-ia32-msvc@2.1.10': optional: true - '@rspack/binding-win32-ia32-msvc@2.1.9': - optional: true - '@rspack/binding-win32-x64-msvc@2.1.10': optional: true - '@rspack/binding-win32-x64-msvc@2.1.9': - optional: true - '@rspack/binding@2.1.10': optionalDependencies: '@rspack/binding-darwin-arm64': 2.1.10 @@ -1318,38 +1124,15 @@ snapshots: '@rspack/binding-win32-ia32-msvc': 2.1.10 '@rspack/binding-win32-x64-msvc': 2.1.10 - '@rspack/binding@2.1.9': - optionalDependencies: - '@rspack/binding-darwin-arm64': 2.1.9 - '@rspack/binding-darwin-x64': 2.1.9 - '@rspack/binding-linux-arm64-gnu': 2.1.9 - '@rspack/binding-linux-arm64-musl': 2.1.9 - '@rspack/binding-linux-ppc64-gnu': 2.1.9 - '@rspack/binding-linux-riscv64-gnu': 2.1.9 - '@rspack/binding-linux-riscv64-musl': 2.1.9 - '@rspack/binding-linux-s390x-gnu': 2.1.9 - '@rspack/binding-linux-x64-gnu': 2.1.9 - '@rspack/binding-linux-x64-musl': 2.1.9 - '@rspack/binding-wasm32-wasi': 2.1.9 - '@rspack/binding-win32-arm64-msvc': 2.1.9 - '@rspack/binding-win32-ia32-msvc': 2.1.9 - '@rspack/binding-win32-x64-msvc': 2.1.9 - '@rspack/core@2.1.10(@swc/helpers@0.5.23)': dependencies: '@rspack/binding': 2.1.10 optionalDependencies: '@swc/helpers': 0.5.23 - '@rspack/core@2.1.9(@swc/helpers@0.5.23)': - dependencies: - '@rspack/binding': 2.1.9 - optionalDependencies: - '@swc/helpers': 0.5.23 - '@rstest/core@0.11.6': dependencies: - '@rsbuild/core': 2.1.11 + '@rsbuild/core': 2.1.12 '@types/chai': 5.2.3 transitivePeerDependencies: - '@module-federation/runtime-tools' diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c2d562f..f9f1d38 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,21 +1,4 @@ -catalogMode: prefer - -catalog: - '@modelcontextprotocol/sdk': '1.29.0' - '@rsbuild/core': '~2.1.11' - '@rsdoctor/agent-cli': '0.1.1' - '@rslib/core': '~1.0.0-beta.3' - '@rslint/core': '~0.8.0' - '@rstest/core': '~0.11.6' - '@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' - zod: '4.4.3' - autoInstallPeers: false -cleanupUnusedCatalogs: true dedupePeers: true engineStrict: true From 7f58090bc8436276c2f4c8927b5e504d4a60e768 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 14 Aug 2026 02:21:43 +0000 Subject: [PATCH 16/46] test: assert portable coverage dependency --- tests/testRun.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/testRun.test.ts b/tests/testRun.test.ts index 1609e82..06b0cf9 100644 --- a/tests/testRun.test.ts +++ b/tests/testRun.test.ts @@ -63,7 +63,7 @@ test('publishes the opt-in Istanbul provider as an exact optional peer', async ( }; expect(packageJson.dependencies?.['@rstest/coverage-istanbul']).toBeUndefined(); - expect(packageJson.devDependencies?.['@rstest/coverage-istanbul']).toBe('catalog:'); + expect(packageJson.devDependencies?.['@rstest/coverage-istanbul']).toBe('0.11.6'); expect(packageJson.peerDependencies?.['@rstest/coverage-istanbul']).toBe('0.11.6'); expect(packageJson.peerDependenciesMeta?.['@rstest/coverage-istanbul']).toEqual({ optional: true, From 6c992fac8c8e2b22a1a66f8f0d9a954437649bf7 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 14 Aug 2026 02:23:56 +0000 Subject: [PATCH 17/46] build: pin integrated Rstack tool versions --- package.json | 12 ++++++------ pnpm-lock.yaml | 31 ++++++++++++++++++++++++------- 2 files changed, 30 insertions(+), 13 deletions(-) diff --git a/package.json b/package.json index b3e95f7..fa9c518 100644 --- a/package.json +++ b/package.json @@ -40,19 +40,19 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "1.29.0", - "@rsbuild/core": "~2.1.11", + "@rsbuild/core": "2.1.11", "@rsdoctor/agent-cli": "0.1.1", - "@rslint/core": "~0.8.0", - "@rstest/core": "~0.11.6", + "@rslint/core": "0.8.0", + "@rstest/core": "0.11.6", "zod": "4.4.3" }, "devDependencies": { - "@rslib/core": "~1.0.0-beta.3", + "@rslib/core": "1.0.0-beta.3", "@rstest/coverage-istanbul": "0.11.6", - "@types/node": "^24.13.3", + "@types/node": "24.13.3", "pkg-pr-new": "0.0.87", "prettier": "3.9.6", - "typescript": "^7.0.2" + "typescript": "7.0.2" }, "peerDependencies": { "@rstest/coverage-istanbul": "0.11.6" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e592b24..0869fcf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,29 +13,29 @@ importers: specifier: 1.29.0 version: 1.29.0(supports-color@7.2.0)(zod@4.4.3) '@rsbuild/core': - specifier: ~2.1.11 - version: 2.1.12 + specifier: 2.1.11 + version: 2.1.11 '@rsdoctor/agent-cli': specifier: 0.1.1 version: 0.1.1 '@rslint/core': - specifier: ~0.8.0 + specifier: 0.8.0 version: 0.8.0 '@rstest/core': - specifier: ~0.11.6 + specifier: 0.11.6 version: 0.11.6 zod: specifier: 4.4.3 version: 4.4.3 devDependencies: '@rslib/core': - specifier: ~1.0.0-beta.3 + 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 + specifier: 24.13.3 version: 24.13.3 pkg-pr-new: specifier: 0.0.87 @@ -44,7 +44,7 @@ importers: specifier: 3.9.6 version: 3.9.6 typescript: - specifier: ^7.0.2 + specifier: 7.0.2 version: 7.0.2 packages: @@ -152,6 +152,16 @@ packages: '@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} @@ -1005,6 +1015,13 @@ snapshots: '@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) From f2408b0497a0ee56ce0a9c72fa2603beb8375b42 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 14 Aug 2026 03:49:43 +0000 Subject: [PATCH 18/46] fix: harden context dogfood workflows --- .github/workflows/pkg-pr-new.yml | 19 +++++- src/mcp.ts | 79 ++++++++++++++++++++-- src/queries.ts | 12 ++-- src/testRun.ts | 14 ++++ tests/mcp.test.ts | 112 ++++++++++++++++++++++++++++++- tests/queries.test.ts | 35 +++++++--- tests/testRun.test.ts | 16 +++++ 7 files changed, 263 insertions(+), 24 deletions(-) diff --git a/.github/workflows/pkg-pr-new.yml b/.github/workflows/pkg-pr-new.yml index c90f7fe..d34f654 100644 --- a/.github/workflows/pkg-pr-new.yml +++ b/.github/workflows/pkg-pr-new.yml @@ -30,4 +30,21 @@ jobs: run: pnpm build - name: Publish package preview - run: pnpm exec pkg-pr-new publish --pnpm --previewVersion --no-template '.' + 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/src/mcp.ts b/src/mcp.ts index 10d67f0..333c993 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -11,7 +11,7 @@ import { readProductRoots, traceModuleImpact, } from './queries.ts'; -import { validateLintFacet } from './records.ts'; +import { validateLintFacet, validateTestFacet } from './records.ts'; import { analyzeRsdoctorArtifact, listRsdoctorToolNames } from './rsdoctor.ts'; import { resolveRsdoctorReport } from './report.ts'; import { assessSnapshotFreshness } from './source.ts'; @@ -21,8 +21,76 @@ import { captureTestSnapshot, listTestResults, type TestSnapshotRequest } from ' declare const RSTACK_CONTEXT_VERSION: string; +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const summarizeBuildFacet = (value: unknown) => { + if ( + !isRecord(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; + } + + return { + command: value.command, + ...(typeof value.mode === 'string' ? { mode: value.mode } : {}), + environment: value.environment, + durationMs: value.durationMs, + ...(typeof value.hash === 'string' ? { hash: value.hash } : {}), + hasErrors: value.hasErrors, + hasWarnings: value.hasWarnings, + assets: value.assets.length, + chunks: value.chunks.length, + }; +}; + const renderProjectStatus = (status: Awaited>): string => - `Rstack project status: ${status.contexts.length} current context${status.contexts.length === 1 ? '' : 's'} (${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 details.`; + `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 summarizeProjectStatus = (status: Awaited>) => ({ + schemaVersion: status.schemaVersion, + workspaceId: status.workspaceId, + contexts: status.contexts.map( + ({ runId, producer, context, state, latestSnapshot, freshness }) => { + const build = summarizeBuildFacet(latestSnapshot?.facets.build); + const lint = validateLintFacet(latestSnapshot?.facets.lint); + const test = validateTestFacet(latestSnapshot?.facets.test); + return { + runId, + producer, + context, + state, + ...(latestSnapshot === undefined + ? {} + : { + latestSnapshot: { + snapshotId: latestSnapshot.snapshotId, + observedAt: latestSnapshot.observedAt, + status: latestSnapshot.status, + completeness: latestSnapshot.completeness, + facets: Object.keys(latestSnapshot.facets).sort(), + summary: { + ...(build === undefined ? {} : { build }), + ...(lint === undefined ? {} : { lint: lint.totals }), + ...(test === undefined + ? {} + : { test: { stats: test.stats, durationMs: test.durationMs } }), + }, + }, + freshness, + }), + }; + }, + ), + issues: status.issues, +}); const contextIdInput = z.string().min(1).describe('Context ID returned by project_status.'); const rsdoctorDataFileInput = z @@ -254,9 +322,6 @@ const toMcpError = (error: unknown) => ({ isError: true, }); -const isRecord = (value: unknown): value is Record => - typeof value === 'object' && value !== null && !Array.isArray(value); - const formatStructuredResult = (result: unknown): string => { if (!isRecord(result)) return 'Rstack result is available in structuredContent.'; @@ -495,7 +560,7 @@ const createContextMcpServer = ( const status = await readProjectStatus(workspaceRoot); return { content: [{ type: 'text', text: renderProjectStatus(status) }], - structuredContent: status, + structuredContent: summarizeProjectStatus(status), }; }, ); @@ -756,7 +821,7 @@ const createContextMcpServer = ( { title: 'Capture test snapshot', description: - 'Run one explicit one-shot Rstest capture, optionally selected from related source files, and store its immutable results.', + '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, diff --git a/src/queries.ts b/src/queries.ts index 6ddafe8..5f0f0d3 100644 --- a/src/queries.ts +++ b/src/queries.ts @@ -106,13 +106,13 @@ const bindArtifactToSnapshot = ( let identity: RsdoctorArtifactCompilationIdentity; let artifactEnvironment: string | undefined; if (metadata.build.compilers !== undefined) { - const matchingCompilers = metadata.build.compilers.filter( - (compiler) => - compiler.environment === snapshotEnvironment || compiler.name === snapshotEnvironment, + const primaryCompilers = metadata.build.compilers.filter( + (compiler) => compiler.name === metadata.build.compiler.name, ); - if (matchingCompilers.length !== 1) return 'mismatch'; - identity = matchingCompilers[0]!; - artifactEnvironment = identity.environment ?? matchingCompilers[0].name; + if (primaryCompilers.length !== 1) return 'mismatch'; + const primaryCompiler = primaryCompilers[0]!; + identity = primaryCompiler; + artifactEnvironment = primaryCompiler.environment ?? primaryCompiler.name; } else { identity = metadata.build; artifactEnvironment = diff --git a/src/testRun.ts b/src/testRun.ts index 686b733..4cf6772 100644 --- a/src/testRun.ts +++ b/src/testRun.ts @@ -91,6 +91,10 @@ type TestCaptureDependencies = { wrapperConfigPath?: string; withConfigTarget?: ConfigTargetRunner; resolveRelatedTests?: ResolveRelatedTests; + isTestConfigured?: (target: { + packageRoot: string; + configPath?: string; + }) => boolean | Promise; }; const toWorkspacePath = (workspaceRoot: string, filePath: string): string => @@ -224,6 +228,16 @@ const captureTestSnapshot = async ( validateExecutionRequest(request.execution); validateRelatedSelection(request); 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}".`); + } const wrapperConfigPath = dependencies.wrapperConfigPath ?? resolveInternalConfigPath(import.meta.dirname, 'rstestConfig.js'); diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index bc11672..e2b8ec5 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -6,6 +6,7 @@ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; import { expect, test } from '@rstest/core'; import pkgJson from '../package.json' with { type: 'json' }; import { createContextMcpServer } from '../src/mcp.ts'; +import { writeContextRunManifest, writeContextSnapshot } from '../src/store.ts'; const toolNames = [ 'project_status', @@ -25,7 +26,9 @@ const toolNames = [ 'report_link', ] as const; -const withClient = async (callback: (client: Client) => Promise): Promise => { +const withClient = async ( + callback: (client: Client, workspaceRoot: string) => Promise, +): Promise => { const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), 'rstack-context-mcp-')); const server = createContextMcpServer(workspaceRoot, { serverVersion: pkgJson.version }); const client = new Client({ name: 'context-test-client', version: '1.0.0' }); @@ -34,7 +37,7 @@ const withClient = async (callback: (client: Client) => Promise): Promise< try { await server.connect(serverTransport); await client.connect(clientTransport); - await callback(client); + await callback(client, workspaceRoot); } finally { await client.close(); await server.close(); @@ -68,3 +71,108 @@ test('reports an empty checkout without requiring optional producers', async () }); }); }); + +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 }], + chunks: [{ files: ['assets/app.js'], initial: true }], + truncated: { assets: 0, 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', + durationMs: 500, + hash: 'build-hash', + hasErrors: false, + hasWarnings: true, + assets: 1, + chunks: 1, + }, + }, + }, + freshness: { state: 'unknown', changedPaths: [] }, + }, + ], + issues: [], + }); + expect(JSON.stringify(result.structuredContent)).not.toContain('assets/app.js'); + }); +}); diff --git a/tests/queries.test.ts b/tests/queries.test.ts index 78f9638..357742d 100644 --- a/tests/queries.test.ts +++ b/tests/queries.test.ts @@ -356,24 +356,36 @@ test('does not use a module graph that v1 metadata marks as omitted', async () = }); }); -test('selects the matching multi-compiler child by environment name', async () => { +test('does not bind a primary compiler graph to a different multi-compiler child', async () => { await withFixtureWorkspace('application', async (workspaceRoot) => { - const context = { + const webContext = { 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', { + 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: 'multi-compiler', type: 'rspack' }, + compiler: { name: 'server', type: 'rspack' }, compilers: [ { name: 'server', @@ -385,13 +397,20 @@ test('selects the matching multi-compiler child by environment name', async () = ], }); - const candidates = await findUnusedCandidates(workspaceRoot, { - contextId: context.contextId, + 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(candidates.provenance.artifactBinding).toBe('exact'); - expect(candidates.total).toBe(1); + 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); }); }); diff --git a/tests/testRun.test.ts b/tests/testRun.test.ts index 06b0cf9..5228317 100644 --- a/tests/testRun.test.ts +++ b/tests/testRun.test.ts @@ -70,6 +70,22 @@ test('publishes the opt-in Istanbul provider as an exact optional peer', async ( }); }); +test('does not run Rstest when the host reports that tests are not configured', async () => { + await withTempWorkspace(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 ".".', + ); + expect(calls).toEqual([]); + await expect(readProjectStatus(workspaceRoot)).resolves.toMatchObject({ contexts: [] }); + }); +}); + test('captures one passing run with partial source freshness', async () => { await withTempWorkspace(async (workspaceRoot) => { const testPath = path.join(workspaceRoot, 'src', 'math.test.ts'); From cf93f7b75d80092cea4a25534463bab7223577a3 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 14 Aug 2026 04:29:23 +0000 Subject: [PATCH 19/46] test: dogfood Rsdoctor agent canary --- package.json | 2 +- pnpm-lock.yaml | 12 +++++++----- tests/rsdoctor.test.ts | 12 +++++++++++- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index fa9c518..69e228a 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "dependencies": { "@modelcontextprotocol/sdk": "1.29.0", "@rsbuild/core": "2.1.11", - "@rsdoctor/agent-cli": "0.1.1", + "@rsdoctor/agent-cli": "https://pkg.pr.new/@rsdoctor/agent-cli@1903", "@rslint/core": "0.8.0", "@rstest/core": "0.11.6", "zod": "4.4.3" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0869fcf..5ecbc3b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,8 +16,8 @@ importers: specifier: 2.1.11 version: 2.1.11 '@rsdoctor/agent-cli': - specifier: 0.1.1 - version: 0.1.1 + specifier: https://pkg.pr.new/@rsdoctor/agent-cli@1903 + version: https://pkg.pr.new/@rsdoctor/agent-cli@1903 '@rslint/core': specifier: 0.8.0 version: 0.8.0 @@ -172,8 +172,10 @@ packages: core-js: optional: true - '@rsdoctor/agent-cli@0.1.1': - resolution: {integrity: sha512-5bLoaIvvrlVcDRKGQFKXf5OiQkpcoHivVxqy+93ujxcATc6quWiJ3il9rLLzEV0mP0cF3IEsFpituVzHtBy6MA==} + '@rsdoctor/agent-cli@https://pkg.pr.new/@rsdoctor/agent-cli@1903': + resolution: {integrity: sha512-k23rz020WxM9KK5Z84SoCoxxz7qMmSMlF8G2wkPasEdiJr6sce93wec/7XEQm+FjgNKrYJBTpY+onkGCMRVI2g==, tarball: https://pkg.pr.new/@rsdoctor/agent-cli@1903} + version: 0.0.0-preview-ba5f0a8 + engines: {node: '>=22.18.0'} hasBin: true '@rslib/core@1.0.0-beta.3': @@ -1029,7 +1031,7 @@ snapshots: transitivePeerDependencies: - '@module-federation/runtime-tools' - '@rsdoctor/agent-cli@0.1.1': {} + '@rsdoctor/agent-cli@https://pkg.pr.new/@rsdoctor/agent-cli@1903': {} '@rslib/core@1.0.0-beta.3(typescript@7.0.2)': dependencies: diff --git a/tests/rsdoctor.test.ts b/tests/rsdoctor.test.ts index cdc88ad..b837939 100644 --- a/tests/rsdoctor.test.ts +++ b/tests/rsdoctor.test.ts @@ -292,7 +292,17 @@ test('distinguishes collected empty data from an omitted artifact section', asyn toolName: 'build_summary', }); - expect(collected.result).toEqual(omitted.result); + expect(collected.result).toMatchObject({ ok: true }); + 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' }, From 7b24854260e33342f940b8e2a5f5c1ddb212bead Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 14 Aug 2026 08:07:43 +0000 Subject: [PATCH 20/46] feat: expose focused context entry points --- package.json | 22 +++++++++++++++- pnpm-lock.yaml | 3 +++ pnpm-workspace.yaml | 3 +++ rslib.config.ts | 5 ++++ src/rsbuild.ts | 5 ++++ src/rslib.ts | 5 ++++ src/rslint.ts | 13 ++++++++++ src/rstest.ts | 22 ++++++++++++++++ tests/packageExports.test.ts | 50 ++++++++++++++++++++++++++++++++++++ 9 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 src/rsbuild.ts create mode 100644 src/rslib.ts create mode 100644 src/rslint.ts create mode 100644 src/rstest.ts create mode 100644 tests/packageExports.test.ts diff --git a/package.json b/package.json index 69e228a..e4f8131 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,26 @@ "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" + }, + "./rstest": { + "types": "./dist/rstest.d.ts", + "default": "./dist/rstest.js" + }, "./package.json": "./package.json" }, "types": "./dist/index.d.ts", @@ -41,7 +61,7 @@ "dependencies": { "@modelcontextprotocol/sdk": "1.29.0", "@rsbuild/core": "2.1.11", - "@rsdoctor/agent-cli": "https://pkg.pr.new/@rsdoctor/agent-cli@1903", + "@rsdoctor/agent-cli": "0.1.1", "@rslint/core": "0.8.0", "@rstest/core": "0.11.6", "zod": "4.4.3" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5ecbc3b..5ab6ca2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,6 +5,9 @@ settings: dedupePeers: true excludeLinksFromLockfile: false +overrides: + '@rsdoctor/agent-cli': https://pkg.pr.new/@rsdoctor/agent-cli@1903 + importers: .: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f9f1d38..e1bba37 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -8,3 +8,6 @@ minimumReleaseAgeExclude: - '@rslib/*' - '@rspack/*' - 'rsbuild-plugin-*' + +overrides: + '@rsdoctor/agent-cli': 'https://pkg.pr.new/@rsdoctor/agent-cli@1903' diff --git a/rslib.config.ts b/rslib.config.ts index 409d776..309f38e 100644 --- a/rslib.config.ts +++ b/rslib.config.ts @@ -8,6 +8,11 @@ export default defineConfig({ 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', + rstest: './src/rstest.ts', }, define: { RSTACK_CONTEXT_VERSION: JSON.stringify(pkgJson.version), 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/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/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/tests/packageExports.test.ts b/tests/packageExports.test.ts new file mode 100644 index 0000000..33dd909 --- /dev/null +++ b/tests/packageExports.test.ts @@ -0,0 +1,50 @@ +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', + './rstest', + ]); + expect(packageJson.dependencies?.['@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']; + 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); +}); From 4dab4abeaae24c05cf52cf273d6e5339d252db95 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 14 Aug 2026 08:11:14 +0000 Subject: [PATCH 21/46] feat: add structural Rstack context plugin --- package.json | 4 ++ rslib.config.ts | 1 + src/index.ts | 8 ++++ src/rstack.ts | 93 ++++++++++++++++++++++++++++++++++++ tests/packageExports.test.ts | 3 +- tests/rstack.test.ts | 92 +++++++++++++++++++++++++++++++++++ 6 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 src/rstack.ts create mode 100644 tests/rstack.test.ts diff --git a/package.json b/package.json index e4f8131..d279173 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,10 @@ "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" diff --git a/rslib.config.ts b/rslib.config.ts index 309f38e..9c5f09d 100644 --- a/rslib.config.ts +++ b/rslib.config.ts @@ -12,6 +12,7 @@ export default defineConfig({ rsdoctor: './src/rsdoctor.ts', rslib: './src/rslib.ts', rslint: './src/rslint.ts', + rstack: './src/rstack.ts', rstest: './src/rstest.ts', }, define: { diff --git a/src/index.ts b/src/index.ts index 93b3ffa..aa6475e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -90,3 +90,11 @@ export { 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/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/tests/packageExports.test.ts b/tests/packageExports.test.ts index 33dd909..01ce263 100644 --- a/tests/packageExports.test.ts +++ b/tests/packageExports.test.ts @@ -26,6 +26,7 @@ test('publishes focused Context entry points without a Rstack dependency', async './rsdoctor', './rslib', './rslint', + './rstack', './rstest', ]); expect(packageJson.dependencies?.['@rsdoctor/agent-cli']).toBe('0.1.1'); @@ -39,7 +40,7 @@ test('publishes focused Context entry points without a Rstack dependency', async }); test('loads each focused Context entry point independently', () => { - const entryPoints = ['rsbuild', 'rslib', 'rstest', 'rslint', 'rsdoctor', 'mcp']; + 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, diff --git a/tests/rstack.test.ts b/tests/rstack.test.ts new file mode 100644 index 0000000..c6cf091 --- /dev/null +++ b/tests/rstack.test.ts @@ -0,0 +1,92 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import type { ConfigParams, RsbuildConfig } from '@rsbuild/core'; +import { expect, test } from '@rstest/core'; +import { createRstackContextPlugin } from '../src/rstack.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 => { + const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), 'rstack-context-plugin-')); + try { + await writeFile( + path.join(workspaceRoot, 'package.json'), + JSON.stringify({ name: 'context-fixture', private: true }), + ); + await callback(workspaceRoot); + } finally { + await rm(workspaceRoot, { force: true, recursive: true }); + } +}; + +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']); + }); +}); From 88c35a49834f25cbfd1bb2da77c61a951902af26 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 14 Aug 2026 09:13:57 +0000 Subject: [PATCH 22/46] fix: improve degraded evidence capture --- docs/repository-extraction.md | 3 +- docs/rfc.md | 3 +- src/mcp.ts | 6 ++- src/queries.ts | 12 +++++- src/rsdoctor.ts | 2 + src/testRun.ts | 73 ++++++++++++++++++++++------------- tests/mcp.test.ts | 2 +- tests/queries.test.ts | 4 +- tests/rsdoctor.test.ts | 1 + tests/testRun.test.ts | 67 +++++++++++++++++++++++++++++++- 10 files changed, 138 insertions(+), 35 deletions(-) diff --git a/docs/repository-extraction.md b/docs/repository-extraction.md index d716826..524dd9d 100644 --- a/docs/repository-extraction.md +++ b/docs/repository-extraction.md @@ -175,7 +175,8 @@ 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 leaves execution evidence unavailable rather than zero; +- 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. diff --git a/docs/rfc.md b/docs/rfc.md index ead439b..14b3189 100644 --- a/docs/rfc.md +++ b/docs/rfc.md @@ -320,7 +320,8 @@ name pattern. The resulting snapshot records normalized test files, cases, error 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. +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 branch does not attach to an existing watch process, control watch cycles, or keep a resident Rstest session. diff --git a/src/mcp.ts b/src/mcp.ts index 333c993..c5ffd0e 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -42,7 +42,7 @@ const summarizeBuildFacet = (value: unknown) => { command: value.command, ...(typeof value.mode === 'string' ? { mode: value.mode } : {}), environment: value.environment, - durationMs: value.durationMs, + environmentCompileDurationMs: value.durationMs, ...(typeof value.hash === 'string' ? { hash: value.hash } : {}), hasErrors: value.hasErrors, hasWarnings: value.hasWarnings, @@ -369,6 +369,10 @@ const formatStructuredResult = (result: unknown): string => { } } + if (Array.isArray(result.unhandledErrors) && isRecord(result.unhandledErrors[0])) { + addDetail('firstError', result.unhandledErrors[0].message); + } + return details.length === 0 ? 'Rstack result is available in structuredContent.' : `Rstack result: ${details.join(', ')}. See structuredContent for complete data.`; diff --git a/src/queries.ts b/src/queries.ts index 5f0f0d3..2b8da81 100644 --- a/src/queries.ts +++ b/src/queries.ts @@ -360,6 +360,14 @@ const isCandidate = ( !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; @@ -370,14 +378,14 @@ const resolveModule = (graph: ObservedModuleGraph, selector: string): ObservedMo normalizeSelector(module.name) === normalized, ); if (exact.length === 1) return exact[0]; - if (exact.length > 1) throw new Error(`Ambiguous module selector: ${selector}`); + if (exact.length > 1) return ambiguous(exact); const suffix = graph.modules.filter(({ path: modulePath }) => { const normalizedPath = normalizeSelector(modulePath); return normalizedPath === normalized || normalizedPath.endsWith(`/${normalized}`); }); if (suffix.length === 1) return suffix[0]; - if (suffix.length > 1) throw new Error(`Ambiguous module selector: ${selector}`); + if (suffix.length > 1) return ambiguous(suffix); throw new Error(`Unknown module selector: ${selector}`); }; diff --git a/src/rsdoctor.ts b/src/rsdoctor.ts index 7c4a900..2ec1d45 100644 --- a/src/rsdoctor.ts +++ b/src/rsdoctor.ts @@ -66,6 +66,7 @@ type RsdoctorAnalysisResult = { dataFile: string; result: JsonValue; sectionEvidence: RsdoctorSectionEvidence[]; + artifactMetadata?: RsdoctorArtifactMetadata; }; type RsdoctorArtifactCompilationIdentity = { @@ -430,6 +431,7 @@ const analyzeRsdoctorArtifact = async ( const metadata = artifact.metadata; return { dataFile: request.dataFile, + ...(metadata === undefined ? {} : { artifactMetadata: metadata }), result: result as JsonValue, sectionEvidence: metadata === undefined diff --git a/src/testRun.ts b/src/testRun.ts index 4cf6772..67e8802 100644 --- a/src/testRun.ts +++ b/src/testRun.ts @@ -1,4 +1,5 @@ import { randomUUID } from 'node:crypto'; +import { createRequire } from 'node:module'; import path from 'node:path'; import type { RunRstestOptions, TestRunResult } from '@rstest/core/api'; import { @@ -71,6 +72,7 @@ type TestCaptureResult = { status: ContextRunStatus; freshness: ContextFreshness; summary: Record; + unhandledErrors?: TestErrorRecord[]; }; type RunRstest = (options?: RunRstestOptions) => Promise; @@ -95,6 +97,7 @@ type TestCaptureDependencies = { packageRoot: string; configPath?: string; }) => boolean | Promise; + hasCoverageProvider?: (packageRoot: string) => boolean | Promise; }; const toWorkspacePath = (workspaceRoot: string, filePath: string): string => @@ -195,6 +198,15 @@ const ensureWritten = (result: Awaited>) const loadRunRstest = async (): Promise => (await import('@rstest/core/api')).runRstest; +const hasCoverageProvider = (packageRoot: string): boolean => { + try { + createRequire(path.join(packageRoot, 'package.json')).resolve('@rstest/coverage-istanbul'); + 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.'); @@ -236,11 +248,33 @@ const captureTestSnapshot = async ( })) ) { const packageRoot = toWorkspacePath(workspaceRoot, target.packageRoot) || '.'; - throw new Error(`Rstest is not configured for package root "${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, @@ -298,25 +332,7 @@ const captureTestSnapshot = async ( runRstest({ cwd: target.packageRoot, config: wrapperConfigPath, - ...(request.execution === undefined - ? {} - : { - inlineConfig: { - coverage: { - enabled: true, - provider: 'istanbul' as const, - reporters: [], - reportOnFailure: true, - ...(request.execution.include === undefined - ? {} - : { include: request.execution.include }), - ...(request.execution.exclude === undefined - ? {} - : { exclude: request.execution.exclude }), - allowExternal: request.execution.allowExternal ?? false, - }, - }, - }), + ...executionOptions, ...(selectedFiles === undefined ? {} : { files: selectedFiles }), ...(request.testNamePattern === undefined ? {} @@ -371,14 +387,16 @@ const captureTestSnapshot = async ( } const facet = normalizeTestFacet(workspaceRoot, result, relation); const executionFacet = - request.execution === undefined + execution === undefined ? undefined - : await normalizeExecutionFacet( - workspaceRoot, - target.packageRoot, - request.execution, - result.coverage, - ); + : !captureExecution + ? unavailableExecutionFacet(execution) + : await normalizeExecutionFacet( + workspaceRoot, + target.packageRoot, + execution, + result.coverage, + ); const testInputs = await recordContextInputFiles(workspaceRoot, [ ...new Set([ ...facet.files.map((file) => file.path), @@ -436,6 +454,7 @@ const captureTestSnapshot = async ( failedTests: facet.stats.tests.failed, unhandledErrors: facet.unhandledErrors.length, }, + ...(facet.unhandledErrors.length === 0 ? {} : { unhandledErrors: facet.unhandledErrors }), }; }; diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index e2b8ec5..8e1c384 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -159,7 +159,7 @@ test('keeps project status compact while preserving build selection evidence', a command: 'build', mode: 'production', environment: 'web', - durationMs: 500, + environmentCompileDurationMs: 500, hash: 'build-hash', hasErrors: false, hasWarnings: true, diff --git a/tests/queries.test.ts b/tests/queries.test.ts index 357742d..3def368 100644 --- a/tests/queries.test.ts +++ b/tests/queries.test.ts @@ -771,6 +771,8 @@ test('rejects unknown contexts, ambiguous selectors, and invalid query bounds', dataFile: 'rsdoctor-data.json', module: 'shared', }), - ).rejects.toThrow('Ambiguous module selector: shared'); + ).rejects.toThrow( + 'Ambiguous module selector: shared. Matches: 2 (src/a/shared.ts), 3 (src/b/shared.ts).', + ); }); }); diff --git a/tests/rsdoctor.test.ts b/tests/rsdoctor.test.ts index b837939..fe9e2d2 100644 --- a/tests/rsdoctor.test.ts +++ b/tests/rsdoctor.test.ts @@ -293,6 +293,7 @@ test('distinguishes collected empty data from an omitted artifact section', asyn }); expect(collected.result).toMatchObject({ ok: true }); + expect(collected.artifactMetadata).toEqual(createArtifactMetadata()); expect(omitted.result).toEqual({ ok: false, error: { diff --git a/tests/testRun.test.ts b/tests/testRun.test.ts index 5228317..ce5e46b 100644 --- a/tests/testRun.test.ts +++ b/tests/testRun.test.ts @@ -43,6 +43,7 @@ const createDependencies = ( calls: unknown[], suffix: string, ): TestCaptureDependencies => ({ + hasCoverageProvider: () => true, runRstest: (options) => { calls.push(options); return Promise.resolve(result); @@ -79,7 +80,7 @@ test('does not run Rstest when the host reports that tests are not configured', } satisfies TestCaptureDependencies; await expect(captureTestSnapshot(workspaceRoot, {}, dependencies)).rejects.toThrow( - 'Rstest is not configured for package root ".".', + '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: [] }); @@ -617,6 +618,63 @@ test('records requested execution as unavailable when Rstest returns no coverage }); }); +test('runs tests without coverage when the optional Istanbul provider is unavailable', async () => { + await withTempWorkspace(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 } }); + 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(async (workspaceRoot) => { const testPath = path.join(workspaceRoot, 'src', 'present.test.ts'); @@ -1010,6 +1068,13 @@ test('records unhandled Rstest errors as an error snapshot', async () => { status: 'error', freshness: { state: 'partial', changedPaths: [] }, summary: { unhandledErrors: 1 }, + unhandledErrors: [ + { + name: 'ConfigError', + message: 'configuration failed', + stack: 'config stack', + }, + ], }); expect( (await readContextSnapshotById(workspaceRoot, capture.snapshotId))?.snapshot.facets, From 2f5062e709f39e638347d6fd3c8cdcdc77da0bcf Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 14 Aug 2026 09:24:56 +0000 Subject: [PATCH 23/46] fix: surface Rstest capture errors --- src/mcp.ts | 10 ++++++++-- src/testRun.ts | 21 +++++++++++++++++++++ tests/testRun.test.ts | 24 ++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 2 deletions(-) diff --git a/src/mcp.ts b/src/mcp.ts index c5ffd0e..0890828 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -369,8 +369,14 @@ const formatStructuredResult = (result: unknown): string => { } } - if (Array.isArray(result.unhandledErrors) && isRecord(result.unhandledErrors[0])) { - addDetail('firstError', result.unhandledErrors[0].message); + const firstError = + Array.isArray(result.errors) && isRecord(result.errors[0]) + ? result.errors[0] + : Array.isArray(result.unhandledErrors) && isRecord(result.unhandledErrors[0]) + ? result.unhandledErrors[0] + : undefined; + if (firstError !== undefined) { + addDetail('firstError', firstError.message); } return details.length === 0 diff --git a/src/testRun.ts b/src/testRun.ts index 67e8802..e18deab 100644 --- a/src/testRun.ts +++ b/src/testRun.ts @@ -72,9 +72,16 @@ type TestCaptureResult = { status: ContextRunStatus; freshness: ContextFreshness; summary: Record; + errors?: TestCaptureError[]; unhandledErrors?: TestErrorRecord[]; }; +type TestCaptureError = TestErrorRecord & { + scope: 'file' | 'run'; + project?: string; + path?: string; +}; + type RunRstest = (options?: RunRstestOptions) => Promise; type RelatedTestRequest = { @@ -441,6 +448,17 @@ const captureTestSnapshot = async ( }; ensureWritten(await writeContextSnapshot(workspaceRoot, snapshot)); + 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, @@ -452,8 +470,10 @@ const captureTestSnapshot = async ( failedFiles: facet.stats.files.failed, tests: facet.stats.tests.total, failedTests: facet.stats.tests.failed, + errors: errors.length, unhandledErrors: facet.unhandledErrors.length, }, + ...(errors.length === 0 ? {} : { errors }), ...(facet.unhandledErrors.length === 0 ? {} : { unhandledErrors: facet.unhandledErrors }), }; }; @@ -515,6 +535,7 @@ const listTestResults = async ( export { captureTestSnapshot, listTestResults }; export type { TestCaptureDependencies, + TestCaptureError, TestCaptureResult, RelatedTestRequest, ResolveRelatedTests, diff --git a/tests/testRun.test.ts b/tests/testRun.test.ts index ce5e46b..3bc0048 100644 --- a/tests/testRun.test.ts +++ b/tests/testRun.test.ts @@ -137,6 +137,7 @@ test('captures one passing run with partial source freshness', async () => { failedFiles: 0, tests: 1, failedTests: 0, + errors: 0, unhandledErrors: 0, }, }); @@ -998,6 +999,21 @@ test('captures file-level failures without inventing test cases', async () => { 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({ @@ -1068,6 +1084,14 @@ test('records unhandled Rstest errors as an error snapshot', async () => { status: 'error', freshness: { state: 'partial', changedPaths: [] }, summary: { unhandledErrors: 1 }, + errors: [ + { + scope: 'run', + name: 'ConfigError', + message: 'configuration failed', + stack: 'config stack', + }, + ], unhandledErrors: [ { name: 'ConfigError', From 8dbb4263aed894d1938f2bfe3056e30b5997d8e6 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 14 Aug 2026 09:35:15 +0000 Subject: [PATCH 24/46] docs: clarify code evidence artifact inputs --- src/mcp.ts | 12 ++++++++++-- tests/mcp.test.ts | 9 +++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/mcp.ts b/src/mcp.ts index 0890828..f6f6bad 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -173,8 +173,16 @@ 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.optional(), - dataFile: rsdoctorDataFileInput.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.', diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index 8e1c384..8c6baf2 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -55,6 +55,15 @@ test('publishes the complete standalone MCP catalog', async () => { }); 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', + ); }); }); From a384ebc4ade35a1d017851e87aac66914fbca79b Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 14 Aug 2026 10:18:11 +0000 Subject: [PATCH 25/46] feat: explain related test outcomes --- docs/rfc.md | 20 +++++++---- src/codeEvidence.ts | 70 ++++++++++++++++++++++++++++++-------- src/mcp.ts | 6 ++++ src/testRun.ts | 15 ++++++++ tests/codeEvidence.test.ts | 9 +++-- tests/mcp.test.ts | 57 +++++++++++++++++++++++++++++-- tests/testRun.test.ts | 10 +++++- 7 files changed, 160 insertions(+), 27 deletions(-) diff --git a/docs/rfc.md b/docs/rfc.md index 14b3189..365f5b0 100644 --- a/docs/rfc.md +++ b/docs/rfc.md @@ -322,6 +322,8 @@ aggregate statement, function, and branch-arm locations with exact source digest 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. @@ -343,7 +345,7 @@ flowchart LR Lint["Selected Rslint snapshot"] --> Join Artifact["Optional exact-bound
Rsdoctor artifact"] --> Join Join --> Coverage["Aggregate execution
observed / not-observed / unknown"] - Join --> Outcome["Exact-path test outcome"] + Join --> Outcome["Exact-path or isolated
related-selection outcome"] Join --> Diagnostics["Exact-path diagnostics"] Join --> Module["Independent module state axes"] ``` @@ -351,9 +353,12 @@ flowchart LR 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. Test outcomes do not imply a source-to-test relation, and module reachability, -shipment, public contract, and optimizer retention remain separate from runtime evidence. -No exact test record is unknown rather than not-run; not-run requires matching skipped or todo +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 @@ -619,9 +624,10 @@ The lean delivery sequence is: 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 only aggregate execution, exact-path outcomes and -diagnostics, and an optional explicit artifact module. It does not claim per-test attribution or a -source-to-test relationship. +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 diff --git a/src/codeEvidence.ts b/src/codeEvidence.ts index 9746b5f..c2d0f30 100644 --- a/src/codeEvidence.ts +++ b/src/codeEvidence.ts @@ -54,7 +54,8 @@ type ExecutionCoverageEvidence = { type TestOutcomeEvidence = { state: 'failed' | 'passed' | 'not-run' | 'unknown'; - reason?: 'no-exact-test-record'; + basis?: 'exact-path' | 'related-selection'; + reason?: 'no-exact-test-record' | 'related-tests-not-reported'; matchingFiles: number; matchingTests: number; }; @@ -225,27 +226,66 @@ const testOutcome = ( 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) { - return { - state: 'unknown', - reason: 'no-exact-test-record', - matchingFiles: 0, - matchingTests: 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 ( - files.some((file) => file.status === 'fail') || - tests.some((test) => test.status === 'fail') + facet.unhandledErrors.length > 0 || + matchingFiles.some((file) => file.status === 'fail' || (file.errors?.length ?? 0) > 0) || + matchingTests.some((test) => test.status === 'fail') ) { - return { state: 'failed', matchingFiles: files.length, matchingTests: tests.length }; + return { + state: 'failed', + basis, + matchingFiles: matchingFiles.length, + matchingTests: matchingTests.length, + }; } if ( - files.some((file) => file.status === 'pass') || - tests.some((test) => test.status === 'pass') + matchingFiles.some((file) => file.status === 'pass') || + matchingTests.some((test) => test.status === 'pass') ) { - return { state: 'passed', matchingFiles: files.length, matchingTests: tests.length }; + return { + state: 'passed', + basis, + matchingFiles: matchingFiles.length, + matchingTests: matchingTests.length, + }; } - return { state: 'not-run', matchingFiles: files.length, matchingTests: tests.length }; + return { + state: 'not-run', + basis, + matchingFiles: matchingFiles.length, + matchingTests: matchingTests.length, + }; }; const testRelation = ( @@ -400,7 +440,7 @@ const readCodeEvidence = async ( const bounds = [ 'aggregate-execution-no-test-attribution', 'test-relation-static-build-graph', - 'test-outcome-exact-path-only', + 'test-outcome-exact-path-or-isolated-related-selection', 'diagnostics-exact-path-only', ...(module !== undefined && module.provenance.artifactBinding !== 'exact' ? ['artifact-binding-not-exact'] diff --git a/src/mcp.ts b/src/mcp.ts index f6f6bad..6e64e7d 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -377,6 +377,12 @@ const formatStructuredResult = (result: unknown): string => { } } + if (isRecord(result.execution)) { + addDetail('executionProvider', result.execution.provider); + addDetail('executionAvailability', result.execution.availability); + addDetail('executionCompleteness', result.execution.completeness); + } + const firstError = Array.isArray(result.errors) && isRecord(result.errors[0]) ? result.errors[0] diff --git a/src/testRun.ts b/src/testRun.ts index e18deab..66ec479 100644 --- a/src/testRun.ts +++ b/src/testRun.ts @@ -10,6 +10,7 @@ import { type JsonValue, type TestCaseRecord, type TestErrorRecord, + type TestExecutionFacet, type TestFacet, type TestFileRecord, } from './model.ts'; @@ -72,6 +73,11 @@ type TestCaptureResult = { status: ContextRunStatus; freshness: ContextFreshness; summary: Record; + execution?: { + provider: TestExecutionFacet['provider']; + availability: TestExecutionFacet['availability']; + completeness: TestExecutionFacet['universe']['completeness']; + }; errors?: TestCaptureError[]; unhandledErrors?: TestErrorRecord[]; }; @@ -473,6 +479,15 @@ const captureTestSnapshot = async ( 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 }), }; diff --git a/tests/codeEvidence.test.ts b/tests/codeEvidence.test.ts index f5828d0..6328880 100644 --- a/tests/codeEvidence.test.ts +++ b/tests/codeEvidence.test.ts @@ -262,7 +262,7 @@ test('joins newest exact-path execution, test outcome, and diagnostics without i lint: { snapshotId: 'snap_lint', completeness: { lint: 'complete' } }, }, }); - expect(result.bounds).toContain('test-outcome-exact-path-only'); + expect(result.bounds).toContain('test-outcome-exact-path-or-isolated-related-selection'); expect(result.bounds).toContain('aggregate-execution-no-test-attribution'); await expect( @@ -322,7 +322,12 @@ test('reports captured related-test evidence independently from test execution', }), ).resolves.toMatchObject({ testRelation: { state: 'related', testFiles: ['tests/value.test.ts'] }, - testOutcome: { state: 'unknown', reason: 'no-exact-test-record' }, + testOutcome: { + state: 'passed', + basis: 'related-selection', + matchingFiles: 1, + matchingTests: 1, + }, }); await expect( readCodeEvidence(workspaceRoot, { diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index 8c6baf2..40125ea 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -5,7 +5,7 @@ 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 } from '../src/mcp.ts'; +import { createContextMcpServer, type ContextMcpDependencies } from '../src/mcp.ts'; import { writeContextRunManifest, writeContextSnapshot } from '../src/store.ts'; const toolNames = [ @@ -28,9 +28,13 @@ const toolNames = [ 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, { serverVersion: pkgJson.version }); + 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(); @@ -81,6 +85,55 @@ test('reports an empty checkout without requiring optional producers', async () }); }); +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('keeps project status compact while preserving build selection evidence', async () => { await withClient(async (client, workspaceRoot) => { const runId = 'run_build'; diff --git a/tests/testRun.test.ts b/tests/testRun.test.ts index 3bc0048..90a5417 100644 --- a/tests/testRun.test.ts +++ b/tests/testRun.test.ts @@ -657,7 +657,15 @@ test('runs tests without coverage when the optional Istanbul provider is unavail files: ['src/math.test.ts'], }, ]); - expect(capture).toMatchObject({ status: 'pass', summary: { files: 1, tests: 1 } }); + 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: { From 2f306596af1aa05a8ce56b67b0fc677446fd3057 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 14 Aug 2026 23:19:11 +0000 Subject: [PATCH 26/46] refactor: simplify context internals --- src/analysisModel.ts | 6 +- src/build.ts | 21 +++---- src/codeEvidence.ts | 14 ++--- src/diff.ts | 32 ++-------- src/execution.ts | 33 ++++------- src/guards.ts | 31 ++++++++++ src/lint.ts | 73 +++++++++++------------ src/mcp.ts | 105 ++++++++++----------------------- src/order.ts | 7 +++ src/pagination.ts | 17 ++++++ src/paths.ts | 9 +++ src/products.ts | 42 ++++++------- src/queries.ts | 60 +++++-------------- src/reachability.ts | 6 +- src/records.ts | 30 ++++------ src/rsdoctor.ts | 63 ++++++++++---------- src/rsdoctorGraph.ts | 36 +++++------ src/source.ts | 27 +++------ src/status.ts | 8 +-- src/store.ts | 10 ++-- src/testRun.ts | 16 +---- tests/build.test.ts | 32 ++++------ tests/codeEvidence.test.ts | 31 ++++------ tests/helpers.ts | 18 ++++++ tests/lint.test.ts | 37 ++++-------- tests/rawRspackContext.test.ts | 91 ++++++++++++++++++++++++++++ tests/report.test.ts | 28 +++------ tests/rsdoctor.test.ts | 36 ++++------- tests/rstack.test.ts | 14 ++--- tests/source.test.ts | 22 ++----- tests/status.test.ts | 20 ++----- tests/store.test.ts | 36 ++++------- tests/testRun.test.ts | 50 ++++++---------- 33 files changed, 480 insertions(+), 581 deletions(-) create mode 100644 src/guards.ts create mode 100644 src/order.ts create mode 100644 src/pagination.ts create mode 100644 src/paths.ts create mode 100644 tests/helpers.ts create mode 100644 tests/rawRspackContext.test.ts diff --git a/src/analysisModel.ts b/src/analysisModel.ts index 1579ac4..25c1a30 100644 --- a/src/analysisModel.ts +++ b/src/analysisModel.ts @@ -1,3 +1,5 @@ +import type { ContextCompleteness, ContextRunStatus } from './model.ts'; + type ModuleRef = { id: string; path: string; @@ -65,8 +67,8 @@ type AnalysisProvenance = { runId: string; snapshotId: string; observedAt: string; - status: 'queued' | 'running' | 'pass' | 'fail' | 'cancelled' | 'error'; - buildCompleteness?: 'complete' | 'partial' | 'disabled' | 'unsupported'; + status: ContextRunStatus; + buildCompleteness?: ContextCompleteness; }; }; diff --git a/src/build.ts b/src/build.ts index 9fd1445..c8ba663 100644 --- a/src/build.ts +++ b/src/build.ts @@ -1,4 +1,4 @@ -import { createHash, randomUUID } from 'node:crypto'; +import { randomUUID } from 'node:crypto'; import path from 'node:path'; import type { ConfigParams, @@ -8,6 +8,7 @@ import type { RsbuildConfig, RsbuildPlugin, } from '@rsbuild/core'; +import { sha256Hex } from './guards.ts'; import { contextStoreSchemaVersion, type BuildMetadataFacet, @@ -17,6 +18,7 @@ import { type ContextSnapshot, type ContextStoreWriteResult, } from './model.ts'; +import { toWorkspacePath } from './paths.ts'; import { writeContextRunManifest, writeContextSnapshot } from './store.ts'; import type { ResolvedContextWorkspace } from './workspace.ts'; @@ -33,20 +35,17 @@ type BuildContextPluginOptions = { now?: () => Date; }; -const normalizeWorkspacePath = (workspaceRoot: string, value: string): string => - path.relative(workspaceRoot, value).split(path.sep).join('/') || '.'; - const getTarget = (environment: EnvironmentContext): string => environment.config.output.target; const getDistPath = (workspaceRoot: string, environment: EnvironmentContext): string => - normalizeWorkspacePath(workspaceRoot, environment.distPath); + 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) - ? normalizeWorkspacePath(workspaceRoot, value) + ? toWorkspacePath(workspaceRoot, value) || '.' : value.split(path.sep).join('/').replaceAll('\\', '/'), ); @@ -185,14 +184,12 @@ const createContextDescriptor = ( options: BuildContextPluginOptions, environment: EnvironmentContext, ): ContextDescriptor => { - const packageRoot = normalizeWorkspacePath( - options.workspace.workspaceRoot, - options.workspace.packageRoot, - ); + const packageRoot = + toWorkspacePath(options.workspace.workspaceRoot, options.workspace.packageRoot) || '.'; const configPath = options.configPath === undefined ? undefined - : normalizeWorkspacePath(options.workspace.workspaceRoot, options.configPath); + : toWorkspacePath(options.workspace.workspaceRoot, options.configPath) || '.'; const mode = getMode(options.params); const target = getTarget(environment); const distPath = getDistPath(options.workspace.workspaceRoot, environment); @@ -208,7 +205,7 @@ const createContextDescriptor = ( distPath, options.variant ?? '', ].join('\u0000'); - const contextId = `ctx_${createHash('sha256').update(identity).digest('hex').slice(0, 24)}`; + const contextId = `ctx_${sha256Hex(identity).slice(0, 24)}`; return { contextId, diff --git a/src/codeEvidence.ts b/src/codeEvidence.ts index c2d0f30..315b54a 100644 --- a/src/codeEvidence.ts +++ b/src/codeEvidence.ts @@ -1,7 +1,7 @@ -import { createHash } from 'node:crypto'; 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, @@ -11,6 +11,7 @@ import type { TestExecutionLocation, TestFacet, } from './model.ts'; +import { normalizeModuleSelector, toWorkspacePath } from './paths.ts'; import { explainDeadCodeCandidate, readProductRoots } from './queries.ts'; import { assessSnapshotFreshness } from './source.ts'; import { readContextSnapshotById, readContextSnapshots } from './store.ts'; @@ -90,11 +91,8 @@ const normalizeSourcePath = (workspaceRoot: string, value: string): string => { if (portable.length === 0 || path.posix.isAbsolute(portable)) { throw new Error('path must be a non-empty checkout-relative source path.'); } - const normalized = path.posix.normalize(portable).replace(/^\.\//u, ''); - const relative = path - .relative(workspaceRoot, path.resolve(workspaceRoot, normalized)) - .split(path.sep) - .join('/'); + 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.'); } @@ -156,9 +154,7 @@ const readCurrentDigest = async ( sourcePath: string, ): Promise => { try { - return createHash('sha256') - .update(await readFile(path.resolve(workspaceRoot, sourcePath))) - .digest('hex'); + return sha256Hex(await readFile(path.resolve(workspaceRoot, sourcePath))); } catch { return undefined; } diff --git a/src/diff.ts b/src/diff.ts index bef1bf1..a92ee28 100644 --- a/src/diff.ts +++ b/src/diff.ts @@ -1,4 +1,5 @@ import { isDeepStrictEqual } from 'node:util'; +import { lintFacetDiagnostics, type LintFacetDiagnostic } from './lint.ts'; import { type ContextFreshness, type LintFacet, @@ -22,17 +23,7 @@ type SnapshotDiffRequest = { type SnapshotDiffIncompatibilityReason = 'schema-version' | 'producer' | 'context' | 'facet' | 'selection'; -type SnapshotDiagnostic = { - path: string; - ruleId: string | null; - severity: 'error' | 'warning'; - message: string; - line: number; - column: number; - endLine?: number; - endColumn?: number; - fixable: boolean; -}; +type SnapshotDiagnostic = LintFacetDiagnostic; type SnapshotTestFileError = { kind: 'file-error'; @@ -84,21 +75,6 @@ const testIdentity = ( : JSON.stringify([result.kind, result.error.name]) : JSON.stringify([result.project, result.path, result.parentNames ?? [], result.name]); -const lintDiagnostics = (facet: LintFacet): SnapshotDiagnostic[] => - 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 testResults = ( facet: TestFacet, ): Array => [ @@ -196,8 +172,8 @@ const diffStoredContextSnapshots = ( const items = kind === 'diagnostics' ? diffItems( - lintDiagnostics(leftFacet as LintFacet), - lintDiagnostics(rightFacet as LintFacet), + lintFacetDiagnostics(leftFacet as LintFacet), + lintFacetDiagnostics(rightFacet as LintFacet), diagnosticIdentity, ) : diffItems( diff --git a/src/execution.ts b/src/execution.ts index 2f0b1cf..1e016a3 100644 --- a/src/execution.ts +++ b/src/execution.ts @@ -1,4 +1,3 @@ -import { createHash } from 'node:crypto'; import { readFile } from 'node:fs/promises'; import path from 'node:path'; import type { TestRunResult } from '@rstest/core/api'; @@ -11,6 +10,15 @@ import type { TestExecutionRequestedSelection, TestExecutionStatement, } from './model.ts'; +import { + isIdentifier, + isNonNegativeInteger, + isPositiveInteger, + isRecordObject, + sha256Hex, + sha256Pattern, +} from './guards.ts'; +import { toWorkspacePath } from './paths.ts'; type TestExecutionRequest = { include?: string[]; @@ -40,28 +48,11 @@ type ExecutionFileCandidate = { entries: ExecutionEntry[]; }; -const sha256Pattern = /^[0-9a-f]{64}$/u; - -const isRecordObject = (value: unknown): value is Record => - typeof value === 'object' && value !== null && !Array.isArray(value); - -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 hasOnlyKeys = (value: Record, keys: readonly string[]): boolean => { const allowed = new Set(keys); return Object.keys(value).every((key) => allowed.has(key)); }; -const toWorkspacePath = (workspaceRoot: string, filePath: string): string => - path.relative(workspaceRoot, path.resolve(workspaceRoot, filePath)).split(path.sep).join('/'); - const normalizeLocation = (value: unknown): TestExecutionLocation | undefined => { if (!isRecordObject(value) || !isRecordObject(value.start) || !isRecordObject(value.end)) { return undefined; @@ -221,7 +212,7 @@ const requestedExecutionSelection = ( const withExecutionDigest = (facet: Omit): TestExecutionFacet => ({ ...facet, - digest: createHash('sha256').update(JSON.stringify(facet)).digest('hex'), + digest: sha256Hex(JSON.stringify(facet)), }); const unavailableExecutionFacet = (request: TestExecutionRequest): TestExecutionFacet => @@ -285,9 +276,7 @@ const normalizeExecutionFacet = async ( branches: [], }; try { - file.digest = createHash('sha256') - .update(await readFile(candidate.absolutePath)) - .digest('hex'); + file.digest = sha256Hex(await readFile(candidate.absolutePath)); } catch { readable = false; } 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/lint.ts b/src/lint.ts index 090f72f..7fc4811 100644 --- a/src/lint.ts +++ b/src/lint.ts @@ -1,7 +1,7 @@ -import { createHash } from 'node:crypto'; 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, @@ -14,6 +14,9 @@ import { 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, @@ -78,6 +81,18 @@ type DiagnosticsQuery = { 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; @@ -123,18 +138,6 @@ type LintFixPreviewResult = const defaultLimit = 50; const maximumLimit = 200; -const digest = (content: string | Buffer): string => - createHash('sha256').update(content).digest('hex'); - -const toWorkspacePath = (workspaceRoot: string, filePath: string): string => - path - .relative(path.resolve(workspaceRoot), path.resolve(workspaceRoot, filePath)) - .split(path.sep) - .join('/'); - -const compareStrings = (left: string, right: string): number => - left === right ? 0 : left < right ? -1 : 1; - const normalizeMessage = (message: LintMessage): LintMessageRecord => ({ ruleId: message.ruleId, severity: message.severity, @@ -162,7 +165,7 @@ const normalizeResult = async ( ): Promise => { const filePath = toWorkspacePath(workspaceRoot, result.filePath); const source = textCode ?? (await readFile(result.filePath, 'utf8')); - const fileDigest = digest(source); + const fileDigest = sha256Hex(source); const fixedOutput = includeFixPreview && result.output !== undefined && result.output !== source ? result.output @@ -284,7 +287,7 @@ const captureLintSnapshot = async ( ? toWorkspacePath(workspaceRoot, path.resolve(target.packageRoot, request.filePath)) || context.packageRoot : context.packageRoot, - digest: request.mode === 'text' ? digest(request.code) : digest(''), + digest: request.mode === 'text' ? sha256Hex(request.code) : sha256Hex(''), errorCount: 1, warningCount: 0, fixableErrorCount: 0, @@ -344,7 +347,7 @@ const captureLintSnapshot = async ( }; const source: ContextSnapshot['source'] = request.mode === 'text' - ? { virtualInputDigest: digest(request.code), captureSelection } + ? { virtualInputDigest: sha256Hex(request.code), captureSelection } : { inputs: files.map(({ path: filePath, digest: fileDigest }) => ({ path: filePath, @@ -394,10 +397,9 @@ const asTestFacet = (stored: StoredContextSnapshot): TestFacet | undefined => ? (stored.snapshot.facets.test as TestFacet | undefined) : undefined; -const lintDiagnostics = (facet: LintFacet): DiagnosticRecord[] => +const lintFacetDiagnostics = (facet: LintFacet): LintFacetDiagnostic[] => facet.files.flatMap((file) => file.messages.map((message) => ({ - producer: 'rslint' as const, path: file.path, ruleId: message.ruleId, severity: message.severity === 2 ? ('error' as const) : ('warning' as const), @@ -410,6 +412,12 @@ const lintDiagnostics = (facet: LintFacet): DiagnosticRecord[] => })), ); +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 }, @@ -460,20 +468,6 @@ const compareDiagnostics = (left: DiagnosticRecord, right: DiagnosticRecord): nu compareStrings(left.name ?? '', right.name ?? '') || compareStrings(left.message, right.message); -const decodeCursor = (cursor: string | undefined): number => { - if (cursor === undefined) return 0; - const decoded = Buffer.from(cursor, 'base64url').toString('utf8'); - const offset = Number(decoded); - if ( - !/^(?:0|[1-9]\d*)$/u.test(decoded) || - Buffer.from(decoded).toString('base64url') !== cursor || - !Number.isSafeInteger(offset) - ) { - throw new Error('Invalid diagnostics cursor.'); - } - return offset; -}; - const getLimit = (limit: number | undefined): number => { const resolved = limit ?? defaultLimit; if (!Number.isSafeInteger(resolved) || resolved < 1 || resolved > maximumLimit) { @@ -517,7 +511,7 @@ const listDiagnostics = async ( .filter((item) => query.severity === undefined || item.severity === query.severity) .filter((item) => query.ruleId === undefined || item.ruleId === query.ruleId) .sort(compareDiagnostics); - const offset = decodeCursor(query.cursor); + const offset = decodeCursor(query.cursor, 'Invalid diagnostics cursor.'); const limit = getLimit(query.limit); const page = items.slice(offset, offset + limit); const nextOffset = offset + page.length; @@ -531,9 +525,7 @@ const listDiagnostics = async ( freshness: await assessSnapshotFreshness(workspaceRoot, stored.snapshot), total: items.length, items: page, - ...(nextOffset < items.length - ? { nextCursor: Buffer.from(String(nextOffset)).toString('base64url') } - : {}), + ...(nextOffset < items.length ? { nextCursor: encodeCursor(nextOffset) } : {}), }; }; @@ -573,13 +565,20 @@ const getLintFixPreview = async ( }; }; -export { captureLintSnapshot, diagnosticsFromStoredSnapshot, getLintFixPreview, listDiagnostics }; +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 index 6e64e7d..24a3c30 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -4,7 +4,9 @@ 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 } from './lint.ts'; +import { decodeCursor, encodeCursor } from './pagination.ts'; import { explainDeadCodeCandidate, findUnusedCandidates, @@ -21,12 +23,15 @@ import { captureTestSnapshot, listTestResults, type TestSnapshotRequest } from ' declare const RSTACK_CONTEXT_VERSION: string; -const isRecord = (value: unknown): value is Record => - typeof value === 'object' && value !== null && !Array.isArray(value); +const readOnlyAnnotations = { + readOnlyHint: true, + destructiveHint: false, + openWorldHint: false, +} as const; const summarizeBuildFacet = (value: unknown) => { if ( - !isRecord(value) || + !isRecordObject(value) || typeof value.command !== 'string' || typeof value.environment !== 'string' || typeof value.durationMs !== 'number' || @@ -331,7 +336,7 @@ const toMcpError = (error: unknown) => ({ }); const formatStructuredResult = (result: unknown): string => { - if (!isRecord(result)) return 'Rstack result is available in structuredContent.'; + if (!isRecordObject(result)) return 'Rstack result is available in structuredContent.'; const details: string[] = []; const addDetail = (key: string, value: unknown): void => { @@ -351,17 +356,17 @@ const formatStructuredResult = (result: unknown): string => { } addDetail('totalVisited', result.totalVisited); - if (isRecord(result.ownership)) { + if (isRecordObject(result.ownership)) { addDetail('project', result.ownership.project); addDetail('dependency', result.ownership.dependency); } - if (isRecord(result.graph)) { + if (isRecordObject(result.graph)) { addDetail('moduleCount', result.graph.moduleCount); addDetail('edgeCount', result.graph.edgeCount); } - if (isRecord(result.summary)) { + if (isRecordObject(result.summary)) { for (const key of [ 'files', 'tests', @@ -377,16 +382,16 @@ const formatStructuredResult = (result: unknown): string => { } } - if (isRecord(result.execution)) { + 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) && isRecord(result.errors[0]) + Array.isArray(result.errors) && isRecordObject(result.errors[0]) ? result.errors[0] - : Array.isArray(result.unhandledErrors) && isRecord(result.unhandledErrors[0]) + : Array.isArray(result.unhandledErrors) && isRecordObject(result.unhandledErrors[0]) ? result.unhandledErrors[0] : undefined; if (firstError !== undefined) { @@ -407,9 +412,7 @@ const toProductRootsMcpResult = ( result: Awaited>, rootLimit: number, ) => { - const rootCounts = Object.fromEntries( - result.product.roots.map(({ kind }) => kind).map((kind) => [kind, 0]), - ) as Record; + 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 = { @@ -447,20 +450,20 @@ const formatCodeEvidence = (result: CodeEvidenceResult): string => { const isLiteralEmpty = (value: unknown): boolean => value === '' || (Array.isArray(value) && value.length === 0) || - (isRecord(value) && Object.keys(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 isRecord(value) && Object.values(value).every(isRecursivelyZeroShaped); + return isRecordObject(value) && Object.values(value).every(isRecursivelyZeroShaped); }; const findUnavailableSections = (value: unknown): string[] => { - if (!isRecord(value) || !Array.isArray(value.sectionEvidence)) return []; + if (!isRecordObject(value) || !Array.isArray(value.sectionEvidence)) return []; return value.sectionEvidence .flatMap((section) => { if ( - !isRecord(section) || + !isRecordObject(section) || typeof section.section !== 'string' || (section.status !== 'omitted' && section.status !== 'unavailable') ) { @@ -496,27 +499,12 @@ type ContextMcpDependencies = { serverVersion?: string; }; -const readOnlyAnnotations = { - readOnlyHint: true, - destructiveHint: false, - openWorldHint: false, -} as const; - -const decodeSnapshotCursor = (cursor: string | undefined): number => { - if (cursor === undefined) return 0; - const value = Buffer.from(cursor, 'base64url').toString('utf8'); - if (!/^(?:0|[1-9]\d*)$/u.test(value) || Buffer.from(value).toString('base64url') !== cursor) { - throw new Error('Invalid snapshot cursor.'); - } - return Number(value); -}; - const listSnapshots = async (workspaceRoot: string, input: z.infer) => { const snapshots = await readContextSnapshots(workspaceRoot, { producer: input.producer, contextId: input.contextId, }); - const offset = decodeSnapshotCursor(input.cursor); + const offset = decodeCursor(input.cursor, 'Invalid snapshot cursor.'); const selected = snapshots.slice(offset, offset + input.limit); const items = await Promise.all( selected.map(async ({ run, context, snapshot }) => { @@ -547,9 +535,7 @@ const listSnapshots = async (workspaceRoot: string, input: z.infer { const status = await readProjectStatus(workspaceRoot); @@ -595,11 +577,7 @@ const createContextMcpServer = ( title: 'Resolve product roots', description: 'Return selected roots for one explicit Rsdoctor module graph.', inputSchema: productRootsInput, - annotations: { - readOnlyHint: true, - destructiveHint: false, - openWorldHint: false, - }, + annotations: readOnlyAnnotations, }, async ({ contextId, dataFile, rootLimit }) => { try { @@ -621,11 +599,7 @@ const createContextMcpServer = ( description: 'Return unreachable module candidates from one explicit Rsdoctor artifact graph.', inputSchema: unusedCandidatesInput, - annotations: { - readOnlyHint: true, - destructiveHint: false, - openWorldHint: false, - }, + annotations: readOnlyAnnotations, }, async ({ contextId, dataFile, limit, cursor }) => { try { @@ -649,11 +623,7 @@ const createContextMcpServer = ( description: 'Explain why one module is reachable, conservatively preserved, or an artifact-scoped candidate.', inputSchema: deadCodeExplainInput, - annotations: { - readOnlyHint: true, - destructiveHint: false, - openWorldHint: false, - }, + annotations: readOnlyAnnotations, }, async ({ contextId, dataFile, module, maxDepth }) => { try { @@ -677,11 +647,7 @@ const createContextMcpServer = ( description: 'Trace bounded module dependencies or dependents within one explicit artifact graph.', inputSchema: moduleImpactInput, - annotations: { - readOnlyHint: true, - destructiveHint: false, - openWorldHint: false, - }, + annotations: readOnlyAnnotations, }, async ({ contextId, dataFile, module, direction, maxDepth }) => { try { @@ -872,11 +838,7 @@ const createContextMcpServer = ( title: 'Analyze Rsdoctor artifact', description: 'Analyze an explicit Rsdoctor artifact with a catalog tool.', inputSchema: rsdoctorAnalyzeInput, - annotations: { - readOnlyHint: true, - destructiveHint: false, - openWorldHint: false, - }, + annotations: readOnlyAnnotations, }, async ({ dataFile, input, toolName }) => { try { @@ -889,10 +851,7 @@ const createContextMcpServer = ( }, ); const analysisData = - typeof analysis.result === 'object' && - analysis.result !== null && - !Array.isArray(analysis.result) && - 'data' in analysis.result + isRecordObject(analysis.result) && 'data' in analysis.result ? analysis.result.data : analysis.result; return { @@ -917,11 +876,7 @@ const createContextMcpServer = ( description: 'Return a link to an explicit checkout-local Rsdoctor report artifact when present.', inputSchema: reportLinkInput, - annotations: { - readOnlyHint: true, - destructiveHint: false, - openWorldHint: false, - }, + annotations: readOnlyAnnotations, }, async ({ dataFile }) => { try { 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..bbaa040 --- /dev/null +++ b/src/pagination.ts @@ -0,0 +1,17 @@ +const encodeCursor = (offset: number): string => Buffer.from(String(offset)).toString('base64url'); + +const decodeCursor = (cursor: string | undefined, errorMessage: string): number => { + if (cursor === undefined) return 0; + const value = Buffer.from(cursor, 'base64url').toString('utf8'); + const offset = Number(value); + if ( + !/^(?:0|[1-9]\d*)$/u.test(value) || + Buffer.from(value).toString('base64url') !== cursor || + !Number.isSafeInteger(offset) + ) { + throw new Error(errorMessage); + } + return offset; +}; + +export { decodeCursor, encodeCursor }; diff --git a/src/paths.ts b/src/paths.ts new file mode 100644 index 0000000..aaa49c0 --- /dev/null +++ b/src/paths.ts @@ -0,0 +1,9 @@ +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, ''); + +export { normalizeModuleSelector, toWorkspacePath }; diff --git a/src/products.ts b/src/products.ts index 4961683..8c97ee2 100644 --- a/src/products.ts +++ b/src/products.ts @@ -3,25 +3,23 @@ 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'; - -const isObject = (value: unknown): value is Record => - typeof value === 'object' && value !== null && !Array.isArray(value); - -const compareStrings = (left: string, right: string): number => - left === right ? 0 : left < right ? -1 : 1; +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 (isObject(value)) { + } else if (isRecordObject(value)) { for (const entry of Object.values(value)) collectStringLeaves(entry, targets); } }; @@ -38,7 +36,7 @@ const readContractTargets = async ( } catch { return undefined; } - if (!isObject(manifest)) return undefined; + if (!isRecordObject(manifest)) return undefined; const pairs: Array<{ field: ContractField; target: string }> = []; for (const field of ['exports', 'bin'] as const) { @@ -57,27 +55,24 @@ const readContractTargets = async ( ); }; -const normalizeTarget = (value: string): string => - path.posix.normalize(value.replaceAll('\\', '/')).replace(/^\.\//u, ''); - const matchesTarget = (module: ObservedModule, target: string, packageRoot: string): boolean => { - const modulePath = normalizeTarget(module.path); - const normalizedTarget = normalizeTarget(target); + const modulePath = normalizeModuleSelector(module.path); + const normalizedTarget = normalizeModuleSelector(target); if (modulePath.split('/').includes('node_modules')) return false; - const normalizedPackageRoot = normalizeTarget(packageRoot); + const normalizedPackageRoot = normalizeModuleSelector(packageRoot); const scopedTarget = normalizedPackageRoot === '.' ? normalizedTarget - : normalizeTarget(`${normalizedPackageRoot}/${normalizedTarget}`); + : normalizeModuleSelector(`${normalizedPackageRoot}/${normalizedTarget}`); return modulePath === scopedTarget || modulePath.endsWith(`/${scopedTarget}`); }; -const toRootModule = ({ +const toModuleRef = ({ isEntry: _, optimizerBound: __, optimizerReasons: ___, ...module -}: ObservedModule) => module; +}: ObservedModule): ModuleRef => module; const addRoot = (roots: ProductRoot[], root: ProductRoot): void => { if (roots.some(({ kind, module }) => kind === root.kind && module.id === root.module.id)) { @@ -101,7 +96,7 @@ const resolveProductRoots = async ( for (const module of entries) { addRoot(roots, { kind: 'production-entry', - module: toRootModule(module), + module: toModuleRef(module), label: `entry: ${module.name}`, }); } @@ -113,6 +108,7 @@ const resolveProductRoots = async ( 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, @@ -127,10 +123,10 @@ const resolveProductRoots = async ( } if (target.field === 'types') continue; for (const moduleId of target.matchedModuleIds) { - const module = graph.modules.find(({ id }) => id === moduleId)!; + const module = modulesById.get(moduleId)!; addRoot(roots, { kind: 'published-contract', - module: toRootModule(module), + module: toModuleRef(module), label: `package.json ${target.field}: ${target.target}`, }); } @@ -143,7 +139,7 @@ const resolveProductRoots = async ( if (module.optimizerBound === 'side-effect') { addRoot(roots, { kind: 'side-effect', - module: toRootModule(module), + module: toModuleRef(module), label: `side-effect bailout: ${module.name}`, }); } @@ -152,7 +148,7 @@ const resolveProductRoots = async ( if (module.optimizerBound !== undefined && module.optimizerBound !== 'side-effect') { addRoot(roots, { kind: 'conservative-runtime', - module: toRootModule(module), + module: toModuleRef(module), label: `${module.optimizerBound} bailout: ${module.name}`, }); } @@ -171,4 +167,4 @@ const resolveProductRoots = async ( }; }; -export { resolveProductRoots }; +export { resolveProductRoots, toModuleRef }; diff --git a/src/queries.ts b/src/queries.ts index 2b8da81..04a2f1e 100644 --- a/src/queries.ts +++ b/src/queries.ts @@ -1,4 +1,3 @@ -import path from 'node:path'; import type { AnalysisProvenance, DeadCodeExplanation, @@ -14,8 +13,12 @@ import type { ProductRootsResult, UnusedCandidatesResult, } from './analysisModel.ts'; +import { getNonEmptyString, isRecordObject } from './guards.ts'; import type { ContextDescriptor, ContextSnapshot } from './model.ts'; -import { resolveProductRoots } from './products.ts'; +import { compareStrings } from './order.ts'; +import { decodeCursor, encodeCursor } from './pagination.ts'; +import { normalizeModuleSelector } from './paths.ts'; +import { resolveProductRoots, toModuleRef } from './products.ts'; import { traceModuleGraph, type TraversalResult } from './reachability.ts'; import { readRsdoctorArtifact, @@ -52,9 +55,6 @@ type RootTraversals = { const candidateTraversalOptions = { maxDepth: 32, maxVisited: 20_000 } as const; const explanationVisitLimit = 5_000; -const compareStrings = (left: string, right: string): number => - left === right ? 0 : left < right ? -1 : 1; - const isDependencyModulePath = (modulePath: string): boolean => { const normalized = modulePath.split('\\').join('/'); const segments = normalized.split('/'); @@ -66,12 +66,6 @@ const isDependencyModulePath = (modulePath: string): boolean => { ); }; -const isObject = (value: unknown): value is Record => - typeof value === 'object' && value !== null && !Array.isArray(value); - -const getString = (value: unknown): string | undefined => - typeof value === 'string' && value.length > 0 ? value : undefined; - const getTargets = (value: unknown): string[] => (Array.isArray(value) ? value : [value]) .filter((entry): entry is string => typeof entry === 'string' && entry.length > 0) @@ -92,10 +86,10 @@ const bindArtifactToSnapshot = ( ): AnalysisProvenance['artifactBinding'] => { if (snapshot === undefined || metadata === undefined) return 'explicit-unverified'; const build = snapshot.facets.build; - if (!isObject(build)) return 'explicit-unverified'; + if (!isRecordObject(build)) return 'explicit-unverified'; - const snapshotHash = getString(build.hash); - const snapshotEnvironment = getString(build.environment) ?? context.environment; + const snapshotHash = getNonEmptyString(build.hash); + const snapshotEnvironment = getNonEmptyString(build.environment) ?? context.environment; if (snapshotHash === undefined || snapshotEnvironment === undefined) { return 'explicit-unverified'; } @@ -142,21 +136,11 @@ const bindArtifactToSnapshot = ( return artifactEnvironment === snapshotEnvironment ? 'exact' : 'mismatch'; }; -const toModuleRef = ({ - isEntry: _, - optimizerBound: __, - optimizerReasons: ___, - ...module -}: ObservedModule): ModuleRef => module; - const toSubject = (module: ObservedModule): ModuleCandidate['subject'] => ({ kind: 'module', ...toModuleRef(module), }); -const normalizeSelector = (value: string): string => - path.posix.normalize(value.replaceAll('\\', '/')).replace(/^\.\//u, ''); - const validateLimit = (limit: number | undefined): number => { const resolved = limit ?? 50; if (!Number.isInteger(resolved) || resolved < 1 || resolved > 100) { @@ -165,20 +149,6 @@ const validateLimit = (limit: number | undefined): number => { return resolved; }; -const decodeUnusedCandidatesCursor = (cursor: string | undefined): number => { - if (cursor === undefined) return 0; - const value = Buffer.from(cursor, 'base64url').toString('utf8'); - const offset = Number(value); - if ( - !/^(?:0|[1-9]\d*)$/u.test(value) || - Buffer.from(value).toString('base64url') !== cursor || - !Number.isSafeInteger(offset) - ) { - throw new Error('Invalid unused candidates cursor.'); - } - return offset; -}; - const validateMaxDepth = (maxDepth: number | undefined, maximum = 16, fallback = 8): number => { const resolved = maxDepth ?? fallback; if (!Number.isInteger(resolved) || resolved < 1 || resolved > maximum) { @@ -371,17 +341,17 @@ const resolveModule = (graph: ObservedModuleGraph, selector: string): ObservedMo const byId = graph.modules.find(({ id }) => id === selector); if (byId !== undefined) return byId; - const normalized = normalizeSelector(selector); + const normalized = normalizeModuleSelector(selector); const exact = graph.modules.filter( (module) => - normalizeSelector(module.path) === normalized || - normalizeSelector(module.name) === normalized, + 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 = normalizeSelector(modulePath); + const normalizedPath = normalizeModuleSelector(modulePath); return normalizedPath === normalized || normalizedPath.endsWith(`/${normalized}`); }); if (suffix.length === 1) return suffix[0]; @@ -437,7 +407,7 @@ const findUnusedCandidates = async ( query: UnusedCandidatesQuery, ): Promise => { const limit = validateLimit(query.limit); - const offset = decodeUnusedCandidatesCursor(query.cursor); + const offset = decodeCursor(query.cursor, 'Invalid unused candidates cursor.'); const { provenance, graph, product } = await loadAnalysis(workspaceRoot, query); if (!hasAuthoritativeGraph(graph)) { return { @@ -501,9 +471,7 @@ const findUnusedCandidates = async ( traversals.conservative.truncated, resultTruncated: nextOffset < candidates.length, candidates: returnedCandidates, - ...(nextOffset < candidates.length - ? { nextCursor: Buffer.from(String(nextOffset)).toString('base64url') } - : {}), + ...(nextOffset < candidates.length ? { nextCursor: encodeCursor(nextOffset) } : {}), bounds, }; }; diff --git a/src/reachability.ts b/src/reachability.ts index 8e9bab0..7e31327 100644 --- a/src/reachability.ts +++ b/src/reachability.ts @@ -1,4 +1,5 @@ import type { ObservedModule, ObservedModuleGraph } from './analysisModel.ts'; +import { compareStrings } from './order.ts'; type TraversalOptions = { maxDepth: number; @@ -12,9 +13,6 @@ type TraversalResult = { truncated: boolean; }; -const compareStrings = (left: string, right: string): number => - left === right ? 0 : left < right ? -1 : 1; - const compareModules = (left: ObservedModule, right: ObservedModule): number => compareStrings(left.path, right.path) || compareStrings(left.name, right.name) || @@ -79,5 +77,5 @@ const traceModuleGraph = ( return { visited, predecessor, depth, truncated }; }; -export { traceModuleGraph }; +export { compareModules, traceModuleGraph }; export type { TraversalOptions, TraversalResult }; diff --git a/src/records.ts b/src/records.ts index 3f08b62..8136f6a 100644 --- a/src/records.ts +++ b/src/records.ts @@ -10,6 +10,14 @@ import { 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', @@ -34,13 +42,6 @@ const completenessValues = new Set([ 'unsupported', ]); const testStatuses = new Set(['skip', 'pass', 'fail', 'todo']); -const sha256Pattern = /^[0-9a-f]{64}$/u; - -const isRecordObject = (value: unknown): value is Record => - typeof value === 'object' && value !== null && !Array.isArray(value); - -const isIdentifier = (value: unknown): value is string => - typeof value === 'string' && value.length > 0; const isRecordPath = (value: unknown): value is string => typeof value === 'string' && value.length > 0; @@ -48,12 +49,6 @@ const isRecordPath = (value: unknown): value is string => const isNonNegativeNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value) && value >= 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 isOptionalString = (value: unknown): boolean => value === undefined || typeof value === 'string'; @@ -323,9 +318,6 @@ const parseContextSnapshotGenerationFileName = ( return { sequence, snapshotId }; }; -const compareDescending = (left: string, right: string): number => - left === right ? 0 : left > right ? -1 : 1; - const compareContextSnapshotGenerationFileNames = (left: string, right: string): number => { const leftGeneration = parseContextSnapshotGenerationFileName(left); const rightGeneration = parseContextSnapshotGenerationFileName(right); @@ -336,14 +328,14 @@ const compareContextSnapshotGenerationFileNames = (left: string, right: string): if (rightGeneration !== undefined) { return 1; } - return compareDescending(left, right); + return compareStringsDescending(left, right); } if (leftGeneration.sequence !== rightGeneration.sequence) { return leftGeneration.sequence > rightGeneration.sequence ? -1 : 1; } return ( - compareDescending(leftGeneration.snapshotId, rightGeneration.snapshotId) || - compareDescending(left, right) + compareStringsDescending(leftGeneration.snapshotId, rightGeneration.snapshotId) || + compareStringsDescending(left, right) ); }; diff --git a/src/rsdoctor.ts b/src/rsdoctor.ts index 2ec1d45..2867c97 100644 --- a/src/rsdoctor.ts +++ b/src/rsdoctor.ts @@ -1,5 +1,6 @@ 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 = [ @@ -109,9 +110,6 @@ type RsdoctorAdapter = { >; }; -const isObject = (value: unknown): value is Record => - typeof value === 'object' && value !== null && !Array.isArray(value); - const isSupportedToolName = (name: string): name is RsdoctorToolName => supportedToolNames.some((supportedName) => supportedName === name); @@ -177,7 +175,7 @@ const matchesSchemaType = (value: unknown, type: unknown): boolean => { case 'number': return typeof value === 'number' && Number.isFinite(value); case 'object': - return isObject(value); + return isRecordObject(value); case 'string': return typeof value === 'string'; default: @@ -186,7 +184,7 @@ const matchesSchemaType = (value: unknown, type: unknown): boolean => { }; const matchesJsonSchema = (value: unknown, schema: unknown): boolean => { - if (!isObject(schema) || !matchesSchemaType(value, schema.type)) { + if (!isRecordObject(schema) || !matchesSchemaType(value, schema.type)) { return false; } @@ -199,15 +197,15 @@ const matchesJsonSchema = (value: unknown, schema: unknown): boolean => { } } - if (Array.isArray(value) && isObject(schema.items)) { + if (Array.isArray(value) && isRecordObject(schema.items)) { return value.every((entry) => matchesJsonSchema(entry, schema.items)); } - if (!isObject(value)) { + if (!isRecordObject(value)) { return true; } - const properties = isObject(schema.properties) ? schema.properties : {}; + const properties = isRecordObject(schema.properties) ? schema.properties : {}; if ( Array.isArray(schema.required) && schema.required.some((key) => typeof key === 'string' && !(key in value)) @@ -223,7 +221,7 @@ const matchesJsonSchema = (value: unknown, schema: unknown): boolean => { } } else if (schema.additionalProperties === false) { return false; - } else if (isObject(schema.additionalProperties)) { + } else if (isRecordObject(schema.additionalProperties)) { if (!matchesJsonSchema(entry, schema.additionalProperties)) { return false; } @@ -235,24 +233,21 @@ const matchesJsonSchema = (value: unknown, schema: unknown): boolean => { const getInput = (input: unknown, tool: RsdoctorToolDescriptor): Record => { const resolvedInput = input === undefined ? {} : input; - if (!isObject(resolvedInput) || !matchesJsonSchema(resolvedInput, tool.inputSchema)) { + if (!isRecordObject(resolvedInput) || !matchesJsonSchema(resolvedInput, tool.inputSchema)) { throw new Error('Rsdoctor tool input does not match its schema.'); } return resolvedInput; }; -const getOptionalString = (value: unknown): string | undefined => - typeof value === 'string' && value.length > 0 ? value : undefined; - const getCompilationIdentity = ( value: Record, ): RsdoctorArtifactCompilationIdentity | undefined => { - const compilationHash = getOptionalString(value.compilationHash); - const environment = getOptionalString(value.environment); - const target = getOptionalString(value.target); + 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 => getOptionalString(entry) !== undefined) + ? 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; @@ -266,20 +261,24 @@ const getCompilationIdentity = ( }; const getArtifactMetadata = (value: unknown): RsdoctorArtifactMetadata | undefined => { - if (!isObject(value) || value.schemaVersion !== 1) return undefined; - if (!isObject(value.producer) || !isObject(value.output) || !isObject(value.build)) { + if (!isRecordObject(value) || value.schemaVersion !== 1) return undefined; + if ( + !isRecordObject(value.producer) || + !isRecordObject(value.output) || + !isRecordObject(value.build) + ) { return undefined; } - if (!isObject(value.build.compiler) || !isObject(value.sections)) return undefined; + if (!isRecordObject(value.build.compiler) || !isRecordObject(value.sections)) return undefined; - const producerName = getOptionalString(value.producer.name); - const producerVersion = getOptionalString(value.producer.version); + const producerName = getNonEmptyString(value.producer.name); + const producerVersion = getNonEmptyString(value.producer.version); const mode = value.output.mode; - const id = getOptionalString(value.build.id); - const root = getOptionalString(value.build.root); - const compilerName = getOptionalString(value.build.compiler.name); - const compilerType = getOptionalString(value.build.compiler.type); - const compilerVersion = getOptionalString(value.build.compiler.version); + 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' || @@ -301,7 +300,7 @@ const getArtifactMetadata = (value: unknown): RsdoctorArtifactMetadata | undefin const sections: RsdoctorArtifactMetadata['sections'] = {}; for (const [name, state] of Object.entries(value.sections)) { - if (!isObject(state)) return undefined; + if (!isRecordObject(state)) return undefined; if (state.status === 'collected') { sections[name] = { status: 'collected' }; } else if (state.status === 'omitted' && isArtifactOmissionReason(state.reason)) { @@ -317,8 +316,8 @@ const getArtifactMetadata = (value: unknown): RsdoctorArtifactMetadata | undefin if (!Array.isArray(value.build.compilers)) return undefined; compilers = []; for (const entry of value.build.compilers) { - if (!isObject(entry)) return undefined; - const name = getOptionalString(entry.name); + if (!isRecordObject(entry)) return undefined; + const name = getNonEmptyString(entry.name); const compilerIdentity = getCompilationIdentity(entry); if ( name === undefined || @@ -378,7 +377,7 @@ const readRsdoctorArtifact = async ( throw new Error('Rsdoctor data file must contain valid JSON.'); } - if (!isObject(parsed) || !isObject(parsed.data)) { + if (!isRecordObject(parsed) || !isRecordObject(parsed.data)) { throw new Error('Rsdoctor data file must contain an object data field.'); } @@ -405,7 +404,7 @@ const analyzeRsdoctorArtifact = async ( workspaceRoot: string, request: RsdoctorAnalysisRequest, ): Promise => { - if (!isObject(request) || typeof request.toolName !== 'string' || !request.toolName) { + if (!isRecordObject(request) || typeof request.toolName !== 'string' || !request.toolName) { throw new Error('Rsdoctor tool name is invalid.'); } if (!isSupportedToolName(request.toolName)) { diff --git a/src/rsdoctorGraph.ts b/src/rsdoctorGraph.ts index a6372f5..d6a411c 100644 --- a/src/rsdoctorGraph.ts +++ b/src/rsdoctorGraph.ts @@ -1,16 +1,10 @@ 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 isObject = (value: unknown): value is Record => - typeof value === 'object' && value !== null && !Array.isArray(value); - -const compareStrings = (left: string, right: string): number => - left === right ? 0 : left < right ? -1 : 1; - -const getString = (value: unknown): string | undefined => - typeof value === 'string' && value.length > 0 ? value : undefined; - const getId = (value: unknown): string | undefined => typeof value === 'string' && value.length > 0 ? value @@ -28,7 +22,7 @@ const stringifyBailoutReason = (value: unknown): string => { if (Array.isArray(value)) { return value.map(stringifyBailoutReason).join(' '); } - if (!isObject(value)) { + if (!isRecordObject(value)) { return ''; } @@ -70,13 +64,16 @@ const getOptimizerBound = (value: unknown): OptimizerBound | undefined => { }; const normalizeModule = (value: unknown): ObservedModule | undefined => { - if (!isObject(value)) return undefined; + if (!isRecordObject(value)) return undefined; const id = getId(value.id); if (id === undefined) return undefined; const rawPath = - getString(value.path) ?? getString(value.webpackId) ?? getString(value.name) ?? ''; - const rawName = getString(value.webpackId) ?? getString(value.name) ?? 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)), @@ -96,11 +93,6 @@ const normalizeModule = (value: unknown): ObservedModule | undefined => { }; }; -const compareModules = (left: ObservedModule, right: ObservedModule): number => - compareStrings(left.path, right.path) || - compareStrings(left.name, right.name) || - compareStrings(left.id, right.id); - const normalizeRsdoctorModuleGraph = (artifact: RsdoctorArtifact): ObservedModuleGraph => { if (artifact.metadata?.sections.moduleGraph?.status === 'omitted') { return { @@ -112,7 +104,7 @@ const normalizeRsdoctorModuleGraph = (artifact: RsdoctorArtifact): ObservedModul } const moduleGraph = artifact.data.moduleGraph; - if (!isObject(moduleGraph)) { + if (!isRecordObject(moduleGraph)) { return { modules: [], edges: [], @@ -134,7 +126,7 @@ const normalizeRsdoctorModuleGraph = (artifact: RsdoctorArtifact): ObservedModul } for (const row of Array.isArray(moduleGraph.modules) ? moduleGraph.modules : []) { - if (!isObject(row) || !Array.isArray(row.modules)) continue; + 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)) { @@ -162,7 +154,7 @@ const normalizeRsdoctorModuleGraph = (artifact: RsdoctorArtifact): ObservedModul edges.push({ from, to }); }; for (const row of Array.isArray(moduleGraph.dependencies) ? moduleGraph.dependencies : []) { - if (!isObject(row)) continue; + if (!isRecordObject(row)) continue; const usesDependencyShape = Object.hasOwn(row, 'dependency'); addEdge( getId(usesDependencyShape ? row.module : row.issuer), @@ -171,7 +163,7 @@ const normalizeRsdoctorModuleGraph = (artifact: RsdoctorArtifact): ObservedModul } for (const row of Array.isArray(moduleGraph.modules) ? moduleGraph.modules : []) { - if (!isObject(row)) continue; + if (!isRecordObject(row)) continue; const moduleId = getId(row.id); for (const importerId of Array.isArray(row.imported) ? row.imported.map(getId) : []) { addEdge(importerId, moduleId); diff --git a/src/source.ts b/src/source.ts index a25b79a..3afa669 100644 --- a/src/source.ts +++ b/src/source.ts @@ -1,7 +1,9 @@ -import { createHash, randomUUID } from 'node:crypto'; +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 { toWorkspacePath } from './paths.ts'; import { contextStoreSchemaVersion, type ContextDescriptor, @@ -51,17 +53,6 @@ const rstackConfigFileNames = [ 'rstack.config.mjs', ] as const; -const toWorkspacePath = (workspaceRoot: string, filePath: string): string => { - const relativePath = path - .relative(path.resolve(workspaceRoot), path.resolve(workspaceRoot, filePath)) - .split(path.sep) - .join('/'); - return relativePath || '.'; -}; - -const digest = (content: string | Buffer): string => - createHash('sha256').update(content).digest('hex'); - const resolveInternalConfigPath = (moduleDirectory: string, fileName: string): string => { const siblingPath = path.join(moduleDirectory, fileName); return existsSync(siblingPath) ? siblingPath : path.join(moduleDirectory, '..', fileName); @@ -116,15 +107,15 @@ const resolveExplicitCaptureTarget = async ( }; const createExplicitContextDescriptor = (options: ExplicitContextOptions): ContextDescriptor => { - const packageRoot = toWorkspacePath(options.workspaceRoot, options.packageRoot); + const packageRoot = toWorkspacePath(options.workspaceRoot, options.packageRoot) || '.'; const configPath = options.configPath === undefined ? undefined - : toWorkspacePath(options.workspaceRoot, options.configPath); + : toWorkspacePath(options.workspaceRoot, options.configPath) || '.'; const identity = [options.producer, packageRoot, configPath ?? ''].join('\u0000'); return { - contextId: `ctx_${digest(identity).slice(0, 24)}`, + contextId: `ctx_${sha256Hex(identity).slice(0, 24)}`, packageRoot, product: 'development', ...(options.packageName === undefined ? {} : { packageName: options.packageName }), @@ -148,10 +139,10 @@ const recordContextInputFiles = async ( ): Promise => Promise.all( filePaths.map(async (filePath) => { - const relativePath = toWorkspacePath(workspaceRoot, filePath); + const relativePath = toWorkspacePath(workspaceRoot, filePath) || '.'; return { path: relativePath, - digest: digest(await readFile(path.resolve(workspaceRoot, relativePath))), + digest: sha256Hex(await readFile(path.resolve(workspaceRoot, relativePath))), }; }), ).then((inputs) => inputs.sort((left, right) => left.path.localeCompare(right.path))); @@ -169,7 +160,7 @@ const assessSnapshotFreshness = async ( await Promise.all( source.inputs.map(async (input) => { try { - const currentDigest = digest(await readFile(path.resolve(workspaceRoot, input.path))); + const currentDigest = sha256Hex(await readFile(path.resolve(workspaceRoot, input.path))); return currentDigest === input.digest ? undefined : input.path; } catch { return input.path; diff --git a/src/status.ts b/src/status.ts index 765a37c..918dca3 100644 --- a/src/status.ts +++ b/src/status.ts @@ -1,12 +1,10 @@ -import { createHash } from 'node:crypto'; import { realpath } from 'node:fs/promises'; +import { sha256Hex } from './guards.ts'; import { type ProjectContextStatus, type ProjectStatus } from './model.ts'; +import { compareStrings } from './order.ts'; import { assessSnapshotFreshness } from './source.ts'; import { readContextWorkspaceStatus } from './store.ts'; -const compareStrings = (left: string, right: string): number => - left === right ? 0 : left < right ? -1 : 1; - const compareProjectContexts = ( left: ProjectContextStatus & { startedAt: string }, right: ProjectContextStatus & { startedAt: string }, @@ -29,7 +27,7 @@ const compareProjectContexts = ( const readProjectStatus = async (workspaceRoot: string): Promise => { const workspace = await readContextWorkspaceStatus(workspaceRoot); const workspacePath = await realpath(workspaceRoot); - const workspaceId = `ws_${createHash('sha256').update(workspacePath).digest('hex').slice(0, 24)}`; + const workspaceId = `ws_${sha256Hex(workspacePath).slice(0, 24)}`; const currentByContextId = new Map< string, (typeof workspace.runs)[number]['contexts'][number] & { diff --git a/src/store.ts b/src/store.ts index 5cdfe13..cea1d23 100644 --- a/src/store.ts +++ b/src/store.ts @@ -13,6 +13,7 @@ import { type ContextWorkspaceStatus, type StoredContextSnapshot, } from './model.ts'; +import { compareStringsDescending } from './order.ts'; import { compareContextSnapshotGenerationFileNames, getContextSnapshotGenerationFileName, @@ -243,12 +244,9 @@ const compareStoredSnapshots = ( left: StoredContextSnapshot, right: StoredContextSnapshot, ): number => - compareDescending(left.snapshot.observedAt, right.snapshot.observedAt) || - compareDescending(left.run.startedAt, right.run.startedAt) || - compareDescending(left.snapshot.snapshotId, right.snapshot.snapshotId); - -const compareDescending = (left: string, right: string): number => - left === right ? 0 : left > right ? -1 : 1; + 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, diff --git a/src/testRun.ts b/src/testRun.ts index 66ec479..a008c3c 100644 --- a/src/testRun.ts +++ b/src/testRun.ts @@ -20,6 +20,8 @@ import { validateExecutionRequest, type TestExecutionRequest, } from './execution.ts'; +import { decodeCursor, encodeCursor } from './pagination.ts'; +import { toWorkspacePath } from './paths.ts'; import { assessSnapshotFreshness, createExplicitContextDescriptor, @@ -113,9 +115,6 @@ type TestCaptureDependencies = { hasCoverageProvider?: (packageRoot: string) => boolean | Promise; }; -const toWorkspacePath = (workspaceRoot: string, filePath: string): string => - path.relative(workspaceRoot, path.resolve(workspaceRoot, filePath)).split(path.sep).join('/'); - const optionalString = ( key: K, value: TestErrorRecord[K] | undefined, @@ -493,15 +492,6 @@ const captureTestSnapshot = async ( }; }; -const decodeCursor = (cursor: string | undefined): number => { - if (cursor === undefined) return 0; - const value = Buffer.from(cursor, 'base64url').toString('utf8'); - if (!/^(?:0|[1-9]\d*)$/u.test(value)) throw new Error('Invalid test result cursor.'); - return Number(value); -}; - -const encodeCursor = (offset: number): string => Buffer.from(String(offset)).toString('base64url'); - const listTestResults = async ( workspaceRoot: string, query: TestResultsQuery, @@ -527,7 +517,7 @@ const listTestResults = async ( (query.status === undefined || item.status === query.status), ) .sort(compareTestCases); - const offset = decodeCursor(query.cursor); + const offset = decodeCursor(query.cursor, 'Invalid test result cursor.'); 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.'); diff --git a/tests/build.test.ts b/tests/build.test.ts index 6da0905..c80918f 100644 --- a/tests/build.test.ts +++ b/tests/build.test.ts @@ -1,6 +1,5 @@ /* rslint-disable @typescript-eslint/no-unsafe-assignment -- Rstest asymmetric matchers are intentionally untyped. */ -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; -import os from 'node:os'; +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'; @@ -12,6 +11,7 @@ import { type BuildMetadataFacet, type ResolvedContextWorkspace, } from '../src/index.ts'; +import { withTempWorkspace } from './helpers.ts'; type BeforeHook = (context: { environments: Record }) => Promise | void; @@ -38,18 +38,6 @@ type ObserverHarness = { warnings: string[]; }; -const withTempWorkspace = async ( - callback: (workspaceRoot: string) => Promise, -): Promise => { - const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), 'rstack-build-context-')); - - try { - await callback(workspaceRoot); - } finally { - await rm(workspaceRoot, { force: true, recursive: true }); - } -}; - const getObserverHarness = ( plugin: ReturnType, ): ObserverHarness => { @@ -196,7 +184,7 @@ test('appends one observer without mutating user config or nested Rslib entries' }); test('publishes an aggregate manifest once and advances sequences per environment', async () => { - await withTempWorkspace(async (workspaceRoot) => { + await withTempWorkspace('rstack-build-context-', async (workspaceRoot) => { const plugin = createBuildContextPlugin({ producer: 'rslib', product: 'library', @@ -317,7 +305,7 @@ test('publishes an aggregate manifest once and advances sequences per environmen }); test('bounds metadata rows and distinguishes disabled deep capture from partial builds', async () => { - await withTempWorkspace(async (workspaceRoot) => { + await withTempWorkspace('rstack-build-context-', async (workspaceRoot) => { const plugin = createBuildContextPlugin({ producer: 'rsbuild', product: 'application', @@ -389,7 +377,7 @@ test('bounds metadata rows and distinguishes disabled deep capture from partial }); test('retains bounded valid metadata rows and counts only dropped valid rows', async () => { - await withTempWorkspace(async (workspaceRoot) => { + await withTempWorkspace('rstack-build-context-', async (workspaceRoot) => { const plugin = createBuildContextPlugin({ producer: 'rsbuild', product: 'application', @@ -463,7 +451,7 @@ test('retains bounded valid metadata rows and counts only dropped valid rows', a }); test('keeps capture failures out of build hooks and warns once per observer', async () => { - await withTempWorkspace(async (workspaceRoot) => { + await withTempWorkspace('rstack-build-context-', async (workspaceRoot) => { const plugin = createBuildContextPlugin({ producer: 'rsbuild', product: 'application', @@ -509,7 +497,7 @@ test('keeps capture failures out of build hooks and warns once per observer', as }); test('serializes Stats before awaiting manifest publication', async () => { - await withTempWorkspace(async (workspaceRoot) => { + await withTempWorkspace('rstack-build-context-', async (workspaceRoot) => { const plugin = createBuildContextPlugin({ producer: 'rsbuild', product: 'application', @@ -542,7 +530,7 @@ test('serializes Stats before awaiting manifest publication', async () => { }); test('normalizes metadata paths', async () => { - await withTempWorkspace(async (workspaceRoot) => { + await withTempWorkspace('rstack-build-context-', async (workspaceRoot) => { const plugin = createBuildContextPlugin({ producer: 'rsbuild', product: 'application', @@ -593,7 +581,7 @@ test('normalizes metadata paths', async () => { }); test('derives stable IDs from normalized identity inputs and separates every identity field', async () => { - await withTempWorkspace(async (workspaceRoot) => { + await withTempWorkspace('rstack-build-context-', async (workspaceRoot) => { const workspace = { workspaceRoot, packageRoot: path.join(workspaceRoot, 'packages', 'app'), @@ -663,7 +651,7 @@ test('derives stable IDs from normalized identity inputs and separates every ide }); test('records explicit build variants, normalized output paths, and partial config inputs', async () => { - await withTempWorkspace(async (workspaceRoot) => { + 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 }); diff --git a/tests/codeEvidence.test.ts b/tests/codeEvidence.test.ts index 6328880..e2db323 100644 --- a/tests/codeEvidence.test.ts +++ b/tests/codeEvidence.test.ts @@ -1,6 +1,5 @@ import { createHash } from 'node:crypto'; -import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; -import os from 'node:os'; +import { cp, mkdir, readFile, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { expect, test } from '@rstest/core'; import { @@ -17,6 +16,7 @@ import { } 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, @@ -25,17 +25,6 @@ const fixtureRoot = path.resolve( const digest = (value: string): string => createHash('sha256').update(value).digest('hex'); -const withTempWorkspace = async ( - callback: (workspaceRoot: string) => Promise, -): Promise => { - const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), 'rstack-code-evidence-')); - try { - await callback(workspaceRoot); - } finally { - await rm(workspaceRoot, { recursive: true, force: true }); - } -}; - const writeSnapshot = async ( workspaceRoot: string, options: { @@ -203,7 +192,7 @@ const lintFacet = (filePath: string): LintFacet => ({ }); test('joins newest exact-path execution, test outcome, and diagnostics without inferring related tests', async () => { - await withTempWorkspace(async (workspaceRoot) => { + 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 }); @@ -280,7 +269,7 @@ test('joins newest exact-path execution, test outcome, and diagnostics without i }); test('reports captured related-test evidence independently from test execution', async () => { - await withTempWorkspace(async (workspaceRoot) => { + await withTempWorkspace('rstack-code-evidence-', async (workspaceRoot) => { const facet = testFacet('tests/value.test.ts', 'pass', 'unused'); facet.relation = { sources: ['src/value.ts'], @@ -358,7 +347,7 @@ test('reports captured related-test evidence independently from test execution', }); test('keeps missing, stale, incomplete, and non-overlapping execution evidence inconclusive', async () => { - await withTempWorkspace(async (workspaceRoot) => { + 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'); @@ -416,7 +405,7 @@ test('keeps missing, stale, incomplete, and non-overlapping execution evidence i }); test('distinguishes absent exact test records from matching skipped or todo records', async () => { - await withTempWorkspace(async (workspaceRoot) => { + await withTempWorkspace('rstack-code-evidence-', async (workspaceRoot) => { const facet = testFacet('src/other.test.ts', 'pass', 'unused'); facet.files.push({ project: 'default', @@ -454,7 +443,7 @@ test('distinguishes absent exact test records from matching skipped or todo reco }); test('bounds exact-path diagnostics to two hundred records', async () => { - await withTempWorkspace(async (workspaceRoot) => { + 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', @@ -480,7 +469,7 @@ test('bounds exact-path diagnostics to two hundred records', async () => { }); test('adds an independent module axis only for an explicit artifact and exposes binding mismatch', async () => { - await withTempWorkspace(async (workspaceRoot) => { + 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 }); @@ -587,7 +576,7 @@ test('adds an independent module axis only for an explicit artifact and exposes }); test('uses the full workspace path before a package-relative artifact fallback', async () => { - await withTempWorkspace(async (workspaceRoot) => { + 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 { @@ -623,7 +612,7 @@ test('uses the full workspace path before a package-relative artifact fallback', }); test('preserves execution and diagnostics when the full artifact module path is ambiguous', async () => { - await withTempWorkspace(async (workspaceRoot) => { + 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'); 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 index 75a626f..72e275a 100644 --- a/tests/lint.test.ts +++ b/tests/lint.test.ts @@ -1,7 +1,6 @@ /* rslint-disable @typescript-eslint/no-unsafe-assignment -- Rstest asymmetric matchers are intentionally untyped. */ import { spawnSync } from 'node:child_process'; -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; -import os from 'node:os'; +import { mkdir, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; import type { LintResult, RslintOptions } from '@rslint/core'; @@ -15,6 +14,7 @@ import { writeContextRunManifest, writeContextSnapshot, } from '../src/store.ts'; +import { withTempWorkspace } from './helpers.ts'; const mocks = { closeCalls: 0, @@ -58,19 +58,8 @@ beforeEach(() => { mocks.lintError = undefined; }); -const withTempWorkspace = async ( - callback: (workspaceRoot: string) => Promise, -): Promise => { - const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), 'rstack-context-lint-')); - try { - await callback(workspaceRoot); - } finally { - await rm(workspaceRoot, { force: true, recursive: true }); - } -}; - test('loading lint queries does not load the Rslint runtime', async () => { - await withTempWorkspace(async (workspaceRoot) => { + await withTempWorkspace('rstack-context-lint-', async (workspaceRoot) => { const markerFile = path.join(workspaceRoot, 'rslint-loaded'); const hookFile = path.join(workspaceRoot, 'import-hook.mjs'); await writeFile( @@ -113,7 +102,7 @@ await listDiagnostics(${JSON.stringify(workspaceRoot)}).catch(() => undefined);` }); test('captures an open-ended file snapshot with partial inputs and fail status', async () => { - await withTempWorkspace(async (workspaceRoot) => { + 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'); @@ -212,7 +201,7 @@ test('captures an open-ended file snapshot with partial inputs and fail status', }); test('captures text without persisting the input and exposes only stored fix output', async () => { - await withTempWorkspace(async (workspaceRoot) => { + await withTempWorkspace('rstack-context-lint-', async (workspaceRoot) => { const code = 'let value = 1;\n'; mocks.results = [ { @@ -268,7 +257,7 @@ test('captures text without persisting the input and exposes only stored fix out }); test('paginates and filters diagnostics from one frozen snapshot deterministically', async () => { - await withTempWorkspace(async (workspaceRoot) => { + 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 }); @@ -334,7 +323,7 @@ test('paginates and filters diagnostics from one frozen snapshot deterministical }); test('reports only terminal test failures as current diagnostics', async () => { - await withTempWorkspace(async (workspaceRoot) => { + await withTempWorkspace('rstack-context-lint-', async (workspaceRoot) => { const context = { contextId: 'ctx_test', packageRoot: '.', @@ -430,7 +419,7 @@ test('reports only terminal test failures as current diagnostics', async () => { }); test('selects the newest diagnostic snapshot rather than an unrelated newer build', async () => { - await withTempWorkspace(async (workspaceRoot) => { + await withTempWorkspace('rstack-context-lint-', async (workspaceRoot) => { const filePath = path.join(workspaceRoot, 'a.ts'); await writeFile(filePath, 'a'); mocks.results = [ @@ -474,7 +463,7 @@ test('selects the newest diagnostic snapshot rather than an unrelated newer buil }); test('rejects a malformed diagnostics cursor', async () => { - await withTempWorkspace(async (workspaceRoot) => { + await withTempWorkspace('rstack-context-lint-', async (workspaceRoot) => { const filePath = path.join(workspaceRoot, 'a.ts'); await writeFile(filePath, 'a'); mocks.results = [ @@ -500,7 +489,7 @@ test('rejects a malformed diagnostics cursor', async () => { }); test('returns snapshot provenance for an empty diagnostics page', async () => { - await withTempWorkspace(async (workspaceRoot) => { + await withTempWorkspace('rstack-context-lint-', async (workspaceRoot) => { const filePath = path.join(workspaceRoot, 'a.ts'); await writeFile(filePath, 'a'); mocks.results = [ @@ -536,7 +525,7 @@ test('returns snapshot provenance for an empty diagnostics page', async () => { }); test('reports preview availability without rerunning or applying Rslint', async () => { - await withTempWorkspace(async (workspaceRoot) => { + await withTempWorkspace('rstack-context-lint-', async (workspaceRoot) => { const filePath = path.join(workspaceRoot, 'a.ts'); await writeFile(filePath, 'const a = 1;\n'); mocks.results = [ @@ -586,7 +575,7 @@ test('reports preview availability without rerunning or applying Rslint', async }); test('persists a partial diagnostic snapshot and closes the engine when linting throws', async () => { - await withTempWorkspace(async (workspaceRoot) => { + await withTempWorkspace('rstack-context-lint-', async (workspaceRoot) => { const lintError = new Error('lint failed'); mocks.lintError = lintError; @@ -638,7 +627,7 @@ test('persists a partial diagnostic snapshot and closes the engine when linting }); test('persists a partial diagnostic snapshot when creating the lint engine throws', async () => { - await withTempWorkspace(async (workspaceRoot) => { + await withTempWorkspace('rstack-context-lint-', async (workspaceRoot) => { const factoryError = new Error('Rslint configuration failed'); await expect( diff --git a/tests/rawRspackContext.test.ts b/tests/rawRspackContext.test.ts new file mode 100644 index 0000000..7f4db72 --- /dev/null +++ b/tests/rawRspackContext.test.ts @@ -0,0 +1,91 @@ +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).toMatchObject({ + product: 'unknown', + roots: expect.arrayContaining([ + expect.objectContaining({ kind: 'production-entry', module: { id: '1' } }), + ]), + bounds: expect.arrayContaining(['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/report.test.ts b/tests/report.test.ts index a89ea03..9c4f39f 100644 --- a/tests/report.test.ts +++ b/tests/report.test.ts @@ -1,24 +1,12 @@ -import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; -import os from 'node:os'; +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 withTempWorkspace = async ( - callback: (workspaceRoot: string) => Promise, -): Promise => { - const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), 'rstack-rsdoctor-report-')); - - try { - await callback(workspaceRoot); - } finally { - await rm(workspaceRoot, { force: true, recursive: true }); - } -}; - const writeWorkspaceFile = async ( workspaceRoot: string, relativeFile: string, @@ -35,7 +23,7 @@ const writeDataFile = async (workspaceRoot: string): Promise => { }; test('resolves the conventional sibling HTML report before other report candidates', async () => { - await withTempWorkspace(async (workspaceRoot) => { + await withTempWorkspace('rstack-rsdoctor-report-', async (workspaceRoot) => { await writeDataFile(workspaceRoot); const reportPath = await writeWorkspaceFile( workspaceRoot, @@ -55,7 +43,7 @@ test('resolves the conventional sibling HTML report before other report candidat }); test('returns a typed missing outcome when a report file does not exist', async () => { - await withTempWorkspace(async (workspaceRoot) => { + await withTempWorkspace('rstack-rsdoctor-report-', async (workspaceRoot) => { await expect( resolveReportFile(workspaceRoot, 'artifacts/report-rsdoctor.html'), ).resolves.toEqual({ kind: 'missing' }); @@ -63,7 +51,7 @@ test('returns a typed missing outcome when a report file does not exist', async }); test('resolves one custom sibling HTML report when the conventional report is absent', async () => { - await withTempWorkspace(async (workspaceRoot) => { + await withTempWorkspace('rstack-rsdoctor-report-', async (workspaceRoot) => { await writeDataFile(workspaceRoot); const reportPath = await writeWorkspaceFile( workspaceRoot, @@ -83,7 +71,7 @@ test('resolves one custom sibling HTML report when the conventional report is ab }); test('resolves the normal workspace .rsdoctor manifest when no sibling HTML report exists', async () => { - await withTempWorkspace(async (workspaceRoot) => { + await withTempWorkspace('rstack-rsdoctor-report-', async (workspaceRoot) => { await writeDataFile(workspaceRoot); const manifestPath = await writeWorkspaceFile( workspaceRoot, @@ -103,7 +91,7 @@ test('resolves the normal workspace .rsdoctor manifest when no sibling HTML repo }); test('returns a no-report response for ambiguous sibling HTML reports', async () => { - await withTempWorkspace(async (workspaceRoot) => { + await withTempWorkspace('rstack-rsdoctor-report-', async (workspaceRoot) => { await writeDataFile(workspaceRoot); await writeWorkspaceFile(workspaceRoot, 'artifacts/first.html', ''); await writeWorkspaceFile(workspaceRoot, 'artifacts/second.html', ''); @@ -120,7 +108,7 @@ test('returns a no-report response for ambiguous sibling HTML reports', async () }); test('returns a portable next action for a standalone workspace without a GUI report', async () => { - await withTempWorkspace(async (workspaceRoot) => { + await withTempWorkspace('rstack-rsdoctor-report-', async (workspaceRoot) => { await writeDataFile(workspaceRoot); const result = await resolveRsdoctorReport(workspaceRoot, validDataFile); diff --git a/tests/rsdoctor.test.ts b/tests/rsdoctor.test.ts index fe9e2d2..bde51ea 100644 --- a/tests/rsdoctor.test.ts +++ b/tests/rsdoctor.test.ts @@ -1,10 +1,10 @@ import { spawnSync } from 'node:child_process'; -import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; -import os from 'node:os'; +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'; @@ -39,18 +39,6 @@ const createArtifactMetadata = ( ), }); -const withTempWorkspace = async ( - callback: (workspaceRoot: string) => Promise, -): Promise => { - const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), 'rstack-rsdoctor-')); - - try { - await callback(workspaceRoot); - } finally { - await rm(workspaceRoot, { force: true, recursive: true }); - } -}; - const writeArtifact = async ( workspaceRoot: string, dataFile: string, @@ -80,7 +68,7 @@ test('lists the complete pinned Rsdoctor tool names', () => { }); test('listing the pinned Rsdoctor tools does not load the adapter package', async () => { - await withTempWorkspace(async (workspaceRoot) => { + await withTempWorkspace('rstack-rsdoctor-', async (workspaceRoot) => { const markerFile = path.join(workspaceRoot, 'rsdoctor-loaded'); const hookFile = path.join(workspaceRoot, 'import-hook.mjs'); await writeFile( @@ -123,7 +111,7 @@ listRsdoctorToolNames();`, }); test('rejects a missing Rsdoctor artifact', async () => { - await withTempWorkspace(async (workspaceRoot) => { + await withTempWorkspace('rstack-rsdoctor-', async (workspaceRoot) => { const artifactPath = path.resolve(workspaceRoot, validDataFile); await expect( analyzeRsdoctorArtifact(workspaceRoot, { @@ -137,7 +125,7 @@ test('rejects a missing Rsdoctor artifact', async () => { }); test('rejects malformed Rsdoctor artifact JSON', async () => { - await withTempWorkspace(async (workspaceRoot) => { + await withTempWorkspace('rstack-rsdoctor-', async (workspaceRoot) => { await writeArtifact(workspaceRoot, validDataFile, '{not-json'); await expect( @@ -150,7 +138,7 @@ test('rejects malformed Rsdoctor artifact JSON', async () => { }); test('rejects a Rsdoctor artifact missing its data envelope', async () => { - await withTempWorkspace(async (workspaceRoot) => { + await withTempWorkspace('rstack-rsdoctor-', async (workspaceRoot) => { await writeArtifact(workspaceRoot, validDataFile, '{}'); await expect( @@ -163,7 +151,7 @@ test('rejects a Rsdoctor artifact missing its data envelope', async () => { }); test('rejects a Rsdoctor artifact whose data envelope is not an object', async () => { - await withTempWorkspace(async (workspaceRoot) => { + await withTempWorkspace('rstack-rsdoctor-', async (workspaceRoot) => { await writeArtifact(workspaceRoot, validDataFile, '{"data":[]}'); await expect( @@ -176,7 +164,7 @@ test('rejects a Rsdoctor artifact whose data envelope is not an object', async ( }); test('rejects an unknown Rsdoctor tool name', async () => { - await withTempWorkspace(async (workspaceRoot) => { + await withTempWorkspace('rstack-rsdoctor-', async (workspaceRoot) => { await writeArtifact(workspaceRoot, validDataFile, '{"data":{}}'); await expect( @@ -189,7 +177,7 @@ test('rejects an unknown Rsdoctor tool name', async () => { }); test('rejects input that does not match the selected tool schema', async () => { - await withTempWorkspace(async (workspaceRoot) => { + await withTempWorkspace('rstack-rsdoctor-', async (workspaceRoot) => { await writeArtifact(workspaceRoot, validDataFile, '{"data":{}}'); await expect( @@ -203,7 +191,7 @@ test('rejects input that does not match the selected tool schema', async () => { }); test('runs a real Rsdoctor catalog tool against a valid artifact fixture', async () => { - await withTempWorkspace(async (workspaceRoot) => { + await withTempWorkspace('rstack-rsdoctor-', async (workspaceRoot) => { await writeArtifact( workspaceRoot, validDataFile, @@ -242,7 +230,7 @@ test('maps every Rsdoctor catalog tool to its required artifact sections', async ['tree_shaking_summary', ['errors']], ] as const; - await withTempWorkspace(async (workspaceRoot) => { + await withTempWorkspace('rstack-rsdoctor-', async (workspaceRoot) => { for (const [toolName, sections] of cases) { const dataFile = `artifacts/${toolName}.json`; await writeArtifact( @@ -264,7 +252,7 @@ test('maps every Rsdoctor catalog tool to its required artifact sections', async }); test('distinguishes collected empty data from an omitted artifact section', async () => { - await withTempWorkspace(async (workspaceRoot) => { + await withTempWorkspace('rstack-rsdoctor-', async (workspaceRoot) => { const collectedDataFile = 'artifacts/collected.json'; const omittedDataFile = 'artifacts/omitted.json'; await writeArtifact( diff --git a/tests/rstack.test.ts b/tests/rstack.test.ts index c6cf091..ae550fd 100644 --- a/tests/rstack.test.ts +++ b/tests/rstack.test.ts @@ -1,9 +1,9 @@ -import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; -import os from 'node:os'; +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 = ( @@ -13,18 +13,14 @@ type Modifier = ( const withTempWorkspace = async ( callback: (workspaceRoot: string) => Promise, -): Promise => { - const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), 'rstack-context-plugin-')); - try { +): Promise => + withSharedTempWorkspace('rstack-context-plugin-', async (workspaceRoot) => { await writeFile( path.join(workspaceRoot, 'package.json'), JSON.stringify({ name: 'context-fixture', private: true }), ); await callback(workspaceRoot); - } finally { - await rm(workspaceRoot, { force: true, recursive: true }); - } -}; + }); const setupPlugin = ( plugin: ReturnType, diff --git a/tests/source.test.ts b/tests/source.test.ts index 1a1739e..55daaea 100644 --- a/tests/source.test.ts +++ b/tests/source.test.ts @@ -1,6 +1,5 @@ /* rslint-disable @typescript-eslint/no-unsafe-assignment -- Rstest asymmetric matchers are intentionally untyped. */ -import { mkdir, mkdtemp, rm, unlink, writeFile } from 'node:fs/promises'; -import os from 'node:os'; +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'; @@ -11,18 +10,7 @@ import { createExplicitRun, recordContextInputFiles, } from '../src/source.ts'; - -const withTempWorkspace = async ( - callback: (workspaceRoot: string) => Promise, -): Promise => { - const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), 'rstack-context-source-')); - - try { - await callback(workspaceRoot); - } finally { - await rm(workspaceRoot, { force: true, recursive: true }); - } -}; +import { withTempWorkspace } from './helpers.ts'; const createSnapshot = (source?: ContextSnapshot['source']): ContextSnapshot => ({ schemaVersion: contextStoreSchemaVersion, @@ -38,7 +26,7 @@ const createSnapshot = (source?: ContextSnapshot['source']): ContextSnapshot => }); test('records sorted SHA-256 inputs and reports complete inputs as fresh', async () => { - await withTempWorkspace(async (workspaceRoot) => { + 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'); @@ -68,7 +56,7 @@ test('records sorted SHA-256 inputs and reports complete inputs as fresh', async }); test('reports changed and missing inputs as stale in lexical order', async () => { - await withTempWorkspace(async (workspaceRoot) => { + 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'); @@ -90,7 +78,7 @@ test('reports changed and missing inputs as stale in lexical order', async () => }); test('preserves partial and unknown freshness semantics', async () => { - await withTempWorkspace(async (workspaceRoot) => { + await withTempWorkspace('rstack-context-source-', async (workspaceRoot) => { await writeFile(path.join(workspaceRoot, 'test.ts'), 'test'); const inputs = await recordContextInputFiles(workspaceRoot, ['test.ts']); diff --git a/tests/status.test.ts b/tests/status.test.ts index 423e715..da7b475 100644 --- a/tests/status.test.ts +++ b/tests/status.test.ts @@ -1,6 +1,5 @@ /* rslint-disable @typescript-eslint/no-unsafe-assignment -- Rstest asymmetric matchers are intentionally untyped. */ -import { mkdir, mkdtemp, rm } from 'node:fs/promises'; -import os from 'node:os'; +import { mkdir } from 'node:fs/promises'; import path from 'node:path'; import { expect, test } from '@rstest/core'; import { @@ -12,18 +11,7 @@ import { type ContextRunManifest, type ContextSnapshot, } from '../src/index.ts'; - -const withTempWorkspace = async ( - callback: (workspaceRoot: string) => Promise, -): Promise => { - const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), 'rstack-context-status-')); - - try { - await callback(workspaceRoot); - } finally { - await rm(workspaceRoot, { force: true, recursive: true }); - } -}; +import { withTempWorkspace } from './helpers.ts'; const createRun = ( runId: string, @@ -40,7 +28,7 @@ const createRun = ( }); test('returns a stable anonymous status for an empty standalone store', async () => { - await withTempWorkspace(async (workspaceRoot) => { + await withTempWorkspace('rstack-context-status-', async (workspaceRoot) => { const status = await readProjectStatus(workspaceRoot); expect(status).toEqual({ @@ -54,7 +42,7 @@ test('returns a stable anonymous status for an empty standalone store', async () }); test('projects only the latest run for each context in deterministic order', async () => { - await withTempWorkspace(async (workspaceRoot) => { + await withTempWorkspace('rstack-context-status-', async (workspaceRoot) => { await mkdir(path.join(workspaceRoot, 'packages', 'app'), { recursive: true, }); diff --git a/tests/store.test.ts b/tests/store.test.ts index 01ecf67..d55e8b7 100644 --- a/tests/store.test.ts +++ b/tests/store.test.ts @@ -1,5 +1,4 @@ -import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; -import os from 'node:os'; +import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { expect, test } from '@rstest/core'; import { @@ -11,6 +10,7 @@ import { type ContextSnapshot, } from '../src/index.ts'; import { readContextSnapshotById, readContextSnapshots } from '../src/store.ts'; +import { withTempWorkspace } from './helpers.ts'; const context = { contextId: 'ctx_library_esm', @@ -50,20 +50,8 @@ const secondSnapshot: ContextSnapshot = { facets: { summary: { errors: 0, warnings: 1 } }, }; -const withTempWorkspace = async ( - callback: (workspaceRoot: string) => Promise, -): Promise => { - const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), 'rstack-context-store-')); - - try { - await callback(workspaceRoot); - } finally { - await rm(workspaceRoot, { force: true, recursive: true }); - } -}; - test('publishes immutable run snapshots and reads the latest context state', async () => { - await withTempWorkspace(async (workspaceRoot) => { + await withTempWorkspace('rstack-context-store-', async (workspaceRoot) => { expect(await writeContextRunManifest(workspaceRoot, run)).toMatchObject({ written: true, }); @@ -94,7 +82,7 @@ test('publishes immutable run snapshots and reads the latest context state', asy }); test('does not replace an immutable snapshot record', async () => { - await withTempWorkspace(async (workspaceRoot) => { + await withTempWorkspace('rstack-context-store-', async (workspaceRoot) => { await writeContextRunManifest(workspaceRoot, run); expect(await writeContextSnapshot(workspaceRoot, firstSnapshot)).toMatchObject({ written: true, @@ -114,7 +102,7 @@ test('does not replace an immutable snapshot record', async () => { }); test('lists every immutable snapshot newest-first and filters or finds exact records', async () => { - await withTempWorkspace(async (workspaceRoot) => { + await withTempWorkspace('rstack-context-store-', async (workspaceRoot) => { const newerRun = { ...run, runId: 'run_library_lint', @@ -177,7 +165,7 @@ test('lists every immutable snapshot newest-first and filters or finds exact rec }); test('reports malformed completed records and ignores temporary files', async () => { - await withTempWorkspace(async (workspaceRoot) => { + await withTempWorkspace('rstack-context-store-', async (workspaceRoot) => { await writeContextRunManifest(workspaceRoot, run); await writeContextSnapshot(workspaceRoot, firstSnapshot); const generationRoot = path.join( @@ -214,7 +202,7 @@ test('reports malformed completed records and ignores temporary files', async () }); test('uses the same manifest validation when writing and reading', async () => { - await withTempWorkspace(async (workspaceRoot) => { + await withTempWorkspace('rstack-context-store-', async (workspaceRoot) => { const invalidRun = { ...run, contexts: [context, { ...context, packageRoot: 'packages/other' }], @@ -238,7 +226,7 @@ test('uses the same manifest validation when writing and reading', async () => { }); test('uses the same snapshot validation when writing and reading', async () => { - await withTempWorkspace(async (workspaceRoot) => { + await withTempWorkspace('rstack-context-store-', async (workspaceRoot) => { await writeContextRunManifest(workspaceRoot, run); const invalidSnapshot = { ...firstSnapshot, status: 'unknown' }; expect( @@ -278,7 +266,7 @@ test('uses the same snapshot validation when writing and reading', async () => { }); test('rejects snapshot records stored under a non-canonical generation name', async () => { - await withTempWorkspace(async (workspaceRoot) => { + await withTempWorkspace('rstack-context-store-', async (workspaceRoot) => { await writeContextRunManifest(workspaceRoot, run); const generationRoot = path.join( workspaceRoot, @@ -314,7 +302,7 @@ test('rejects snapshot records stored under a non-canonical generation name', as }); test('stops reading generations after the newest valid snapshot', async () => { - await withTempWorkspace(async (workspaceRoot) => { + await withTempWorkspace('rstack-context-store-', async (workspaceRoot) => { await writeContextRunManifest(workspaceRoot, run); const latestSnapshot = { ...secondSnapshot, @@ -366,7 +354,7 @@ test('stops reading generations after the newest valid snapshot', async () => { }); test('orders generation sequences numerically across the ten-digit boundary', async () => { - await withTempWorkspace(async (workspaceRoot) => { + await withTempWorkspace('rstack-context-store-', async (workspaceRoot) => { await writeContextRunManifest(workspaceRoot, run); const olderSnapshot = { ...secondSnapshot, @@ -387,7 +375,7 @@ test('orders generation sequences numerically across the ten-digit boundary', as }); test('breaks equal generation sequence ties by raw snapshot ID descending', async () => { - await withTempWorkspace(async (workspaceRoot) => { + await withTempWorkspace('rstack-context-store-', async (workspaceRoot) => { await writeContextRunManifest(workspaceRoot, run); const lowerSnapshotId = { ...secondSnapshot, diff --git a/tests/testRun.test.ts b/tests/testRun.test.ts index 90a5417..307a70f 100644 --- a/tests/testRun.test.ts +++ b/tests/testRun.test.ts @@ -1,6 +1,5 @@ /* rslint-disable @typescript-eslint/no-unsafe-assignment -- Rstest asymmetric matchers are intentionally untyped. */ -import { mkdir, mkdtemp, readFile, rm, unlink, writeFile } from 'node:fs/promises'; -import os from 'node:os'; +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'; @@ -13,18 +12,7 @@ import { type TestCaptureDependencies, type TestSnapshotRequest, } from '../src/testRun.ts'; - -const withTempWorkspace = async ( - callback: (workspaceRoot: string) => Promise, -): Promise => { - const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), 'rstack-context-test-run-')); - - try { - await callback(workspaceRoot); - } finally { - await rm(workspaceRoot, { force: true, recursive: true }); - } -}; +import { withTempWorkspace } from './helpers.ts'; const createResult = (overrides: Partial = {}): TestRunResult => ({ ok: true, @@ -72,7 +60,7 @@ test('publishes the opt-in Istanbul provider as an exact optional peer', async ( }); test('does not run Rstest when the host reports that tests are not configured', async () => { - await withTempWorkspace(async (workspaceRoot) => { + await withTempWorkspace('rstack-context-test-run-', async (workspaceRoot) => { const calls: unknown[] = []; const dependencies = { ...createDependencies(createResult(), calls, 'not_configured'), @@ -88,7 +76,7 @@ test('does not run Rstest when the host reports that tests are not configured', }); test('captures one passing run with partial source freshness', async () => { - await withTempWorkspace(async (workspaceRoot) => { + 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'); @@ -217,7 +205,7 @@ test('captures one passing run with partial source freshness', async () => { }); test('resolves related source files before running and records the static test relation', async () => { - await withTempWorkspace(async (workspaceRoot) => { + 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 }); @@ -293,7 +281,7 @@ test('resolves related source files before running and records the static test r }); test('rejects files and related source selection together', async () => { - await withTempWorkspace(async (workspaceRoot) => { + await withTempWorkspace('rstack-context-test-run-', async (workspaceRoot) => { const calls: unknown[] = []; const dependencies = createDependencies(createResult(), calls, 'invalid-related'); await expect( @@ -308,7 +296,7 @@ test('rejects files and related source selection together', async () => { }); test('records an empty related selection without falling back to the full test suite', async () => { - await withTempWorkspace(async (workspaceRoot) => { + 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'); @@ -334,7 +322,7 @@ test('records an empty related selection without falling back to the full test s }); test('captures requested aggregate Istanbul execution evidence', async () => { - await withTempWorkspace(async (workspaceRoot) => { + 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'); @@ -562,7 +550,7 @@ test('captures requested aggregate Istanbul execution evidence', async () => { }); test('records requested execution as unavailable when Rstest returns no coverage map', async () => { - await withTempWorkspace(async (workspaceRoot) => { + await withTempWorkspace('rstack-context-test-run-', async (workspaceRoot) => { const calls: unknown[] = []; const capture = await captureTestSnapshot( workspaceRoot, @@ -620,7 +608,7 @@ test('records requested execution as unavailable when Rstest returns no coverage }); test('runs tests without coverage when the optional Istanbul provider is unavailable', async () => { - await withTempWorkspace(async (workspaceRoot) => { + 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'); @@ -685,7 +673,7 @@ test('runs tests without coverage when the optional Istanbul provider is unavail }); test('persists partial execution evidence when a covered source path is unreadable', async () => { - await withTempWorkspace(async (workspaceRoot) => { + 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 }); @@ -752,7 +740,7 @@ test('persists partial execution evidence when a covered source path is unreadab }); test('bounds requested execution selectors before invoking Rstest', async () => { - await withTempWorkspace(async (workspaceRoot) => { + await withTempWorkspace('rstack-context-test-run-', async (workspaceRoot) => { let called = false; const dependencies: TestCaptureDependencies = { runRstest: () => { @@ -777,7 +765,7 @@ test('bounds requested execution selectors before invoking Rstest', async () => }); test('rejects malformed execution selectors before invoking Rstest', async () => { - await withTempWorkspace(async (workspaceRoot) => { + await withTempWorkspace('rstack-context-test-run-', async (workspaceRoot) => { let called = false; const dependencies: TestCaptureDependencies = { runRstest: () => { @@ -812,7 +800,7 @@ test('rejects malformed execution selectors before invoking Rstest', async () => }); test('truncates aggregate execution files deterministically', async () => { - await withTempWorkspace(async (workspaceRoot) => { + await withTempWorkspace('rstack-context-test-run-', async (workspaceRoot) => { const coverage: Record = {}; const sourceDirectory = path.join(workspaceRoot, 'src'); await mkdir(sourceDirectory, { recursive: true }); @@ -887,7 +875,7 @@ test('truncates aggregate execution files deterministically', async () => { }, 15_000); test('normalizes failures, retries, skipped and todo cases', async () => { - await withTempWorkspace(async (workspaceRoot) => { + 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'); @@ -972,7 +960,7 @@ test('normalizes failures, retries, skipped and todo cases', async () => { }); test('captures file-level failures without inventing test cases', async () => { - await withTempWorkspace(async (workspaceRoot) => { + 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'); @@ -1069,7 +1057,7 @@ test('captures file-level failures without inventing test cases', async () => { }); test('records unhandled Rstest errors as an error snapshot', async () => { - await withTempWorkspace(async (workspaceRoot) => { + await withTempWorkspace('rstack-context-test-run-', async (workspaceRoot) => { const calls: unknown[] = []; const result = createResult({ ok: false, @@ -1125,7 +1113,7 @@ test('records unhandled Rstest errors as an error snapshot', async () => { }); test('persists a thrown Rstest configuration error as a completed diagnostic snapshot', async () => { - await withTempWorkspace(async (workspaceRoot) => { + await withTempWorkspace('rstack-context-test-run-', async (workspaceRoot) => { const configError = new Error('configuration failed'); configError.name = 'ConfigError'; const dependencies: TestCaptureDependencies = { @@ -1183,7 +1171,7 @@ test('persists a thrown Rstest configuration error as a completed diagnostic sna }); test('pages project-qualified results in deterministic identity order', async () => { - await withTempWorkspace(async (workspaceRoot) => { + 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'); From 0b89c75ba9b55d73a6c325ccdeb4670df9b2655d Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 14 Aug 2026 23:50:53 +0000 Subject: [PATCH 27/46] feat: improve context evidence fidelity --- README.md | 5 + docs/rfc.md | 6 +- docs/upstream-requests.md | 270 ++++++++++++++++++++++++++++++++ package.json | 6 +- pnpm-lock.yaml | 6 +- src/analysisModel.ts | 2 +- src/artifactProducts.ts | 26 +++ src/build.ts | 7 +- src/codeEvidence.ts | 38 ++--- src/execution.ts | 19 ++- src/lint.ts | 60 ++++--- src/mcp.ts | 33 +++- src/model.ts | 4 + src/paths.ts | 21 ++- src/queries.ts | 59 ++++--- src/records.ts | 57 ++++--- src/rsdoctor.ts | 5 +- src/source.ts | 71 +++++++-- src/status.ts | 60 +++++-- src/testRun.ts | 118 +++++++++++--- tests/codeEvidence.test.ts | 86 ++++++++++ tests/lint.test.ts | 187 ++++++++++++++++++++-- tests/mcp.test.ts | 193 ++++++++++++++++++++++- tests/rawRspackContext.test.ts | 13 +- tests/records.test.ts | 78 +++++++++ tests/rsdoctor.test.ts | 23 +++ tests/source.test.ts | 68 ++++++++ tests/status.test.ts | 76 ++++++++- tests/testRun.test.ts | 278 ++++++++++++++++++++++++++++++++- 29 files changed, 1683 insertions(+), 192 deletions(-) create mode 100644 docs/upstream-requests.md create mode 100644 src/artifactProducts.ts diff --git a/README.md b/README.md index 77815ec..acb1d2d 100644 --- a/README.md +++ b/README.md @@ -27,3 +27,8 @@ 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/rfc.md b/docs/rfc.md index 365f5b0..e9e4c1d 100644 --- a/docs/rfc.md +++ b/docs/rfc.md @@ -287,11 +287,15 @@ edges, entry flags, chunk membership, optimizer bounds, and parse issues. Root s 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 application or library context"] --> Roots["Resolve product roots"] + Context["Selected context (product may be unknown)"] --> Roots["Resolve product roots"] Manifest["Library package.json"] --> Roots Normalize --> Roots Roots --> Traverse["Bounded graph traversal"] diff --git a/docs/upstream-requests.md b/docs/upstream-requests.md new file mode 100644 index 0000000..7422515 --- /dev/null +++ b/docs/upstream-requests.md @@ -0,0 +1,270 @@ +# 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 `"@rsdoctor/agent-cli": "0.1.1"` (`package.json:67`), +but `pnpm-workspace.yaml` overrides that resolution to a preview build: + +```yaml +overrides: + '@rsdoctor/agent-cli': 'https://pkg.pr.new/@rsdoctor/agent-cli@1903' +``` + +The two builds 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:** cut a release of `@rsdoctor/agent-cli` that includes the +`RSDOCTOR_SECTION_UNAVAILABLE` semantics from the pkg.pr.new preview (PR/build +`1903`), and once it ships, drop the `pnpm-workspace.yaml` override and bump +`package.json`'s `@rsdoctor/agent-cli` dependency to that release. + +## 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/package.json b/package.json index d279173..fca7a50 100644 --- a/package.json +++ b/package.json @@ -64,13 +64,13 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "1.29.0", - "@rsbuild/core": "2.1.11", "@rsdoctor/agent-cli": "0.1.1", "@rslint/core": "0.8.0", "@rstest/core": "0.11.6", "zod": "4.4.3" }, "devDependencies": { + "@rsbuild/core": "2.1.11", "@rslib/core": "1.0.0-beta.3", "@rstest/coverage-istanbul": "0.11.6", "@types/node": "24.13.3", @@ -79,10 +79,10 @@ "typescript": "7.0.2" }, "peerDependencies": { - "@rstest/coverage-istanbul": "0.11.6" + "@rsbuild/core": "^2.0.0" }, "peerDependenciesMeta": { - "@rstest/coverage-istanbul": { + "@rsbuild/core": { "optional": true } }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5ab6ca2..95dc2b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,9 +15,6 @@ importers: '@modelcontextprotocol/sdk': specifier: 1.29.0 version: 1.29.0(supports-color@7.2.0)(zod@4.4.3) - '@rsbuild/core': - specifier: 2.1.11 - version: 2.1.11 '@rsdoctor/agent-cli': specifier: https://pkg.pr.new/@rsdoctor/agent-cli@1903 version: https://pkg.pr.new/@rsdoctor/agent-cli@1903 @@ -31,6 +28,9 @@ importers: 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) diff --git a/src/analysisModel.ts b/src/analysisModel.ts index 25c1a30..88e26a1 100644 --- a/src/analysisModel.ts +++ b/src/analysisModel.ts @@ -53,7 +53,7 @@ type ContractTarget = { type ProductRootSet = { contextId: string; packageRoot: string; - product: 'application' | 'library'; + product: 'application' | 'library' | 'unknown'; roots: ProductRoot[]; contractTargets: ContractTarget[]; bounds: string[]; 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 index c8ba663..d827c48 100644 --- a/src/build.ts +++ b/src/build.ts @@ -5,6 +5,7 @@ import type { EnvironmentContext, OnAfterEnvironmentCompileFn, OnBeforeBuildFn, + Rspack, RsbuildConfig, RsbuildPlugin, } from '@rsbuild/core'; @@ -71,11 +72,7 @@ const buildMetadataFacet = ({ chunks: true, errors: false, warnings: false, - }) as { - assets?: Array<{ name?: unknown; size?: unknown }>; - chunks?: Array<{ files?: unknown; id?: unknown; initial?: unknown }>; - hash?: unknown; - }; + }) as Pick; const assets: BuildMetadataFacet['assets'] = []; let droppedAssets = 0; for (const asset of json.assets ?? []) { diff --git a/src/codeEvidence.ts b/src/codeEvidence.ts index 315b54a..e4d51e7 100644 --- a/src/codeEvidence.ts +++ b/src/codeEvidence.ts @@ -12,7 +12,7 @@ import type { TestFacet, } from './model.ts'; import { normalizeModuleSelector, toWorkspacePath } from './paths.ts'; -import { explainDeadCodeCandidate, readProductRoots } from './queries.ts'; +import { explainAnalysisModule, loadAnalysis } from './queries.ts'; import { assessSnapshotFreshness } from './source.ts'; import { readContextSnapshotById, readContextSnapshots } from './store.ts'; @@ -254,7 +254,9 @@ const testOutcome = ( } } if ( - facet.unhandledErrors.length > 0 || + // 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') ) { @@ -322,23 +324,20 @@ const moduleEvidence = async ( 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 explainDeadCodeCandidate(workspaceRoot, { - contextId: query.contextId, - dataFile: query.dataFile, - module: query.module, - maxDepth: query.maxDepth, - }); + return explainAnalysisModule(analysis, { module: query.module, maxDepth: query.maxDepth }); } - const roots = await readProductRoots(workspaceRoot, query); + const { product } = analysis; const packageRelativePath = - roots.product.packageRoot === '.' + product.packageRoot === '.' ? query.path - : query.path.startsWith(`${roots.product.packageRoot}/`) - ? query.path.slice(roots.product.packageRoot.length + 1) + : query.path.startsWith(`${product.packageRoot}/`) + ? query.path.slice(product.packageRoot.length + 1) : query.path; const insufficientEvidence = (): DeadCodeExplanation => ({ - provenance: roots.provenance, + provenance: analysis.provenance, classification: 'insufficient-evidence', state: { productionReachability: 'unknown', @@ -349,15 +348,10 @@ const moduleEvidence = async ( paths: [], evidence: ['No unique artifact module matched the exact source path.'], analysisTruncated: false, - bounds: [...roots.product.bounds, 'source-path-module-match-unavailable'], + bounds: [...product.bounds, 'source-path-module-match-unavailable'], }); try { - return await explainDeadCodeCandidate(workspaceRoot, { - contextId: query.contextId, - dataFile: query.dataFile, - module: query.path, - maxDepth: query.maxDepth, - }); + return explainAnalysisModule(analysis, { module: query.path, maxDepth: query.maxDepth }); } catch (error) { if (error instanceof Error && /^Ambiguous module selector:/u.test(error.message)) { return insufficientEvidence(); @@ -365,9 +359,7 @@ const moduleEvidence = async ( if (!(error instanceof Error) || !/^Unknown module selector:/u.test(error.message)) throw error; if (packageRelativePath !== query.path) { try { - return await explainDeadCodeCandidate(workspaceRoot, { - contextId: query.contextId, - dataFile: query.dataFile, + return explainAnalysisModule(analysis, { module: packageRelativePath, maxDepth: query.maxDepth, }); diff --git a/src/execution.ts b/src/execution.ts index 1e016a3..8408677 100644 --- a/src/execution.ts +++ b/src/execution.ts @@ -1,6 +1,7 @@ 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, @@ -71,10 +72,20 @@ const normalizeLocation = (value: unknown): TestExecutionLocation | undefined => }; }; -const sortedRecordEntries = (value: Record): Array<[string, unknown]> => +/** + * 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: unknown): Record | undefined => { +const unwrapCoverageFile = ( + value: CoverageMapFileEntry | undefined, +): Record | undefined => { if (!isRecordObject(value)) return undefined; return isRecordObject(value.data) ? value.data : value; }; @@ -83,7 +94,7 @@ const normalizeExecutionFile = ( workspaceRoot: string, packageRoot: string, mapPath: string, - rawValue: unknown, + rawValue: CoverageMapFileEntry | undefined, ): ExecutionFileCandidate => { const data = unwrapCoverageFile(rawValue); const sourcePath = @@ -243,7 +254,7 @@ const normalizeExecutionFacet = async ( ): Promise => { if (!isRecordObject(coverage)) return unavailableExecutionFacet(request); - const candidates = sortedRecordEntries(coverage) + const candidates = sortedRecordEntries(coverage) .map(([mapPath, value]) => normalizeExecutionFile(workspaceRoot, packageRoot, mapPath, value)) .sort( (left, right) => diff --git a/src/lint.ts b/src/lint.ts index 7fc4811..e185423 100644 --- a/src/lint.ts +++ b/src/lint.ts @@ -56,6 +56,7 @@ type LintCaptureResult = { status: ContextRunStatus; freshness: ContextFreshness; summary: LintFacet['totals']; + unreadableInputs?: string[]; }; type RslintEngine = { @@ -157,14 +158,23 @@ const compareMessages = (left: LintMessageRecord, right: LintMessageRecord): num 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 => { +): Promise => { const filePath = toWorkspacePath(workspaceRoot, result.filePath); - const source = textCode ?? (await readFile(result.filePath, 'utf8')); + 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 @@ -233,6 +243,9 @@ const captureLintSnapshot = async ( 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 = @@ -262,13 +275,7 @@ const captureLintSnapshot = async ( let results: LintResult[]; try { const withConfigTarget = - adapter?.withConfigTarget ?? - (async (_configRoot, _configPath, action) => { - if (createRslint === undefined) { - throw new Error('Rstack lint capture requires a config adapter.'); - } - return action(); - }); + 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 { @@ -325,18 +332,24 @@ const captureLintSnapshot = async ( throw error; } - const files = ( - await Promise.all( - results.map((result) => - normalizeResult( - workspaceRoot, - result, - includeFixPreview, - request.mode === 'text' ? request.code : undefined, - ), + 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((left, right) => compareStrings(left.path, right.path)); + .sort(compareStrings); const totals = totalsFor(files); const facet: LintFacet = { producer: 'rslint', @@ -370,7 +383,10 @@ const captureLintSnapshot = async ( sequence: 0, observedAt: new Date().toISOString(), status, - completeness: { lint: 'complete' }, + completeness: { + lint: unreadableInputs.length === 0 ? 'complete' : 'partial', + ...(unreadableInputs.length === 0 ? {} : { source: 'partial' as const }), + }, facets: { lint: facet }, source, }; @@ -384,6 +400,7 @@ const captureLintSnapshot = async ( status, freshness: await assessSnapshotFreshness(workspaceRoot, snapshot), summary: totals, + ...(unreadableInputs.length === 0 ? {} : { unreadableInputs }), }; }; @@ -535,7 +552,8 @@ const getLintFixPreview = async ( filePath: string, ): Promise => { const stored = await readContextSnapshotById(workspaceRoot, snapshotId); - const facet = stored === undefined ? undefined : asLintFacet(stored); + 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.'); diff --git a/src/mcp.ts b/src/mcp.ts index 24a3c30..6673adb 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -5,7 +5,13 @@ 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 } from './lint.ts'; +import { + captureLintSnapshot, + getLintFixPreview, + listDiagnostics, + type LintCaptureAdapter, + type RslintFactory, +} from './lint.ts'; import { decodeCursor, encodeCursor } from './pagination.ts'; import { explainDeadCodeCandidate, @@ -19,7 +25,12 @@ import { resolveRsdoctorReport } from './report.ts'; import { assessSnapshotFreshness } from './source.ts'; import { readProjectStatus } from './status.ts'; import { readContextSnapshots } from './store.ts'; -import { captureTestSnapshot, listTestResults, type TestSnapshotRequest } from './testRun.ts'; +import { + captureTestSnapshot, + listTestResults, + type TestCaptureDependencies, + type TestSnapshotRequest, +} from './testRun.ts'; declare const RSTACK_CONTEXT_VERSION: string; @@ -115,11 +126,15 @@ const paginationCursorInput = z const packageRootInput = z .string() .min(1) - .describe('Checkout-relative package directory; defaults to the checkout root.'); + .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; defaults to ordinary discovery in packageRoot.'); + .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.'); @@ -398,6 +413,10 @@ const formatStructuredResult = (result: unknown): string => { 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.`; @@ -496,6 +515,9 @@ type ContextMcpDependencies = { analyzeRsdoctorArtifact?: typeof analyzeRsdoctorArtifact; captureLintSnapshot?: typeof captureLintSnapshot; captureTestSnapshot?: typeof captureTestSnapshot; + createRslint?: RslintFactory; + lintCaptureAdapter?: LintCaptureAdapter; + testCaptureDependencies?: TestCaptureDependencies; serverVersion?: string; }; @@ -798,6 +820,8 @@ const createContextMcpServer = ( const result = await (dependencies.captureLintSnapshot ?? captureLintSnapshot)( workspaceRoot, request, + dependencies.createRslint, + dependencies.lintCaptureAdapter, ); return toStructuredMcpResult(result); } catch (error) { @@ -824,6 +848,7 @@ const createContextMcpServer = ( const result = await (dependencies.captureTestSnapshot ?? captureTestSnapshot)( workspaceRoot, input as TestSnapshotRequest, + dependencies.testCaptureDependencies, ); return toStructuredMcpResult(result); } catch (error) { diff --git a/src/model.ts b/src/model.ts index ef771b7..42a4f2b 100644 --- a/src/model.ts +++ b/src/model.ts @@ -104,6 +104,8 @@ type TestErrorRecord = { actual?: string; expected?: string; retryCount?: number; + /** Recursive cause chain reported by Rstest, truncated at a fixed depth on capture. */ + cause?: TestErrorRecord; }; type TestCaseRecord = { @@ -116,6 +118,8 @@ type TestCaseRecord = { errors?: TestErrorRecord[]; retryErrors?: TestErrorRecord[]; retryCount?: number; + /** User-defined task metadata reported by Rstest; stored only when JSON-safe. */ + meta?: Record; }; type TestFileRecord = { diff --git a/src/paths.ts b/src/paths.ts index aaa49c0..4edb5ee 100644 --- a/src/paths.ts +++ b/src/paths.ts @@ -6,4 +6,23 @@ const toWorkspacePath = (workspaceRoot: string, filePath: string): string => const normalizeModuleSelector = (value: string): string => path.posix.normalize(value.replaceAll('\\', '/')).replace(/^\.\//u, ''); -export { normalizeModuleSelector, toWorkspacePath }; +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/queries.ts b/src/queries.ts index 04a2f1e..f2637d1 100644 --- a/src/queries.ts +++ b/src/queries.ts @@ -13,12 +13,13 @@ import type { 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 { resolveProductRoots, toModuleRef } from './products.ts'; +import { toModuleRef } from './products.ts'; import { traceModuleGraph, type TraversalResult } from './reachability.ts'; import { readRsdoctorArtifact, @@ -227,7 +228,7 @@ const loadAnalysis = async ( issues: graph.issues, } : graph; - const product = await resolveProductRoots(workspaceRoot, context, graphForProducts); + const product = await resolveArtifactProductRoots(workspaceRoot, context, graphForProducts); return { provenance: { ...provenance, artifactBinding }, graph, product }; }; @@ -386,21 +387,25 @@ const shortestRootPath = ( 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 => { - const { provenance, graph, product } = await loadAnalysis(workspaceRoot, query); - return { - provenance, - graph: { - moduleCount: graph.modules.length, - edgeCount: graph.edges.length, - issues: graph.issues, - }, - product, - }; -}; +): Promise => + productRootsFromAnalysis(await loadAnalysis(workspaceRoot, query)); const findUnusedCandidates = async ( workspaceRoot: string, @@ -476,16 +481,15 @@ const findUnusedCandidates = async ( }; }; -const explainDeadCodeCandidate = async ( - workspaceRoot: string, - query: ExplanationQuery, -): Promise => { +const explainAnalysisModule = ( + { provenance, graph, product }: LoadedAnalysis, + query: Pick, +): DeadCodeExplanation => { const maxDepth = validateMaxDepth( query.maxDepth, candidateTraversalOptions.maxDepth, candidateTraversalOptions.maxDepth, ); - const { provenance, graph, product } = await loadAnalysis(workspaceRoot, query); if (!hasAuthoritativeGraph(graph)) { return { provenance, @@ -574,6 +578,12 @@ const explainDeadCodeCandidate = async ( }; }; +const explainDeadCodeCandidate = async ( + workspaceRoot: string, + query: ExplanationQuery, +): Promise => + explainAnalysisModule(await loadAnalysis(workspaceRoot, query), query); + const traceModuleImpact = async ( workspaceRoot: string, query: ImpactQuery, @@ -619,5 +629,12 @@ const traceModuleImpact = async ( }; }; -export { explainDeadCodeCandidate, findUnusedCandidates, readProductRoots, traceModuleImpact }; -export type { ArtifactQuery, ExplanationQuery, ImpactQuery, UnusedCandidatesQuery }; +export { + explainAnalysisModule, + explainDeadCodeCandidate, + findUnusedCandidates, + loadAnalysis, + readProductRoots, + traceModuleImpact, +}; +export type { ArtifactQuery, ExplanationQuery, ImpactQuery, LoadedAnalysis, UnusedCandidatesQuery }; diff --git a/src/records.ts b/src/records.ts index 8136f6a..d29e12d 100644 --- a/src/records.ts +++ b/src/records.ts @@ -117,7 +117,24 @@ const validateLintFacet = (value: unknown): LintFacet | undefined => { return value as LintFacet; }; -const isTestError = (value: unknown): boolean => +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' && @@ -125,7 +142,15 @@ const isTestError = (value: unknown): boolean => isOptionalString(value.diff) && isOptionalString(value.actual) && isOptionalString(value.expected) && - (value.retryCount === undefined || isNonNegativeInteger(value.retryCount)); + (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) && @@ -137,11 +162,10 @@ const isTestCase = (value: unknown): boolean => value.parentNames.every((entry) => typeof entry === 'string'))) && testStatuses.has(value.status as string) && (value.durationMs === undefined || isNonNegativeNumber(value.durationMs)) && - (value.errors === undefined || - (Array.isArray(value.errors) && value.errors.every(isTestError))) && - (value.retryErrors === undefined || - (Array.isArray(value.retryErrors) && value.retryErrors.every(isTestError))) && - (value.retryCount === undefined || isNonNegativeInteger(value.retryCount)); + (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) && @@ -149,8 +173,7 @@ const isTestFile = (value: unknown): boolean => isRecordPath(value.path) && testStatuses.has(value.status as string) && (value.durationMs === undefined || isNonNegativeNumber(value.durationMs)) && - (value.errors === undefined || - (Array.isArray(value.errors) && value.errors.every(isTestError))) && + (value.errors === undefined || areTestErrors(value.errors)) && Array.isArray(value.tests) && value.tests.every(isTestCase); @@ -179,27 +202,13 @@ const validateTestFacet = (value: unknown): TestFacet | undefined => { !isNonNegativeInteger(value.stats.files.total) || !isNonNegativeInteger(value.stats.files.failed) || !isNonNegativeNumber(value.durationMs) || - !Array.isArray(value.unhandledErrors) || - !value.unhandledErrors.every(isTestError) + !areTestErrors(value.unhandledErrors) ) { return undefined; } return value as TestFacet; }; -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); -}; - const isSnapshotSource = (value: unknown): boolean => { if (!isRecordObject(value)) return false; const hasInputs = value.inputs !== undefined; diff --git a/src/rsdoctor.ts b/src/rsdoctor.ts index 2867c97..e5d9b5b 100644 --- a/src/rsdoctor.ts +++ b/src/rsdoctor.ts @@ -423,8 +423,9 @@ const analyzeRsdoctorArtifact = async ( input, toolName: request.toolName, }); - } catch { - throw new Error('Rsdoctor analysis failed.'); + } catch (error) { + const cause = error instanceof Error ? ` Cause: ${error.message}` : ''; + throw new Error(`Rsdoctor analysis failed.${cause}`, { cause: error }); } const metadata = artifact.metadata; diff --git a/src/source.ts b/src/source.ts index 3afa669..49b7de0 100644 --- a/src/source.ts +++ b/src/source.ts @@ -3,7 +3,7 @@ import { existsSync } from 'node:fs'; import { access, readFile } from 'node:fs/promises'; import path from 'node:path'; import { sha256Hex } from './guards.ts'; -import { toWorkspacePath } from './paths.ts'; +import { resolveContainedPath, toWorkspacePath } from './paths.ts'; import { contextStoreSchemaVersion, type ContextDescriptor, @@ -40,6 +40,13 @@ type ExplicitCaptureTarget = { configPath?: string; }; +type ContextInputEntry = { input: ContextInputFile } | { unreadablePath: string }; + +type ContextInputRecording = { + inputs: ContextInputFile[]; + unreadablePaths: string[]; +}; + type ConfigTargetRunner = ( configRoot: string, configPath: string | undefined, @@ -55,7 +62,12 @@ const rstackConfigFileNames = [ const resolveInternalConfigPath = (moduleDirectory: string, fileName: string): string => { const siblingPath = path.join(moduleDirectory, fileName); - return existsSync(siblingPath) ? 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 => { @@ -92,11 +104,15 @@ const resolveExplicitCaptureTarget = async ( workspaceRoot: string, request: ExplicitCaptureTargetRequest, ): Promise => { - const packageRoot = path.resolve(workspaceRoot, request.packageRoot ?? '.'); + const packageRoot = resolveContainedPath( + workspaceRoot, + 'packageRoot', + request.packageRoot ?? '.', + ); const configPath = request.configPath === undefined ? await findPackageConfig(packageRoot) - : path.resolve(workspaceRoot, request.configPath); + : resolveContainedPath(workspaceRoot, 'configPath', request.configPath); const packageName = await readPackageName(packageRoot); return { @@ -133,19 +149,40 @@ const createExplicitRun = (options: ExplicitRunOptions): ContextRunManifest => ( contexts: [options.context], }); -const recordContextInputFiles = async ( +const collectContextInputFiles = async ( workspaceRoot: string, filePaths: string[], -): Promise => - Promise.all( - filePaths.map(async (filePath) => { +): Promise => { + const entries = await Promise.all( + filePaths.map(async (filePath): Promise => { const relativePath = toWorkspacePath(workspaceRoot, filePath) || '.'; - return { - path: relativePath, - digest: sha256Hex(await readFile(path.resolve(workspaceRoot, relativePath))), - }; + try { + return { + input: { + path: relativePath, + digest: sha256Hex(await readFile(path.resolve(workspaceRoot, relativePath))), + }, + }; + } catch { + return { unreadablePath: relativePath }; + } }), - ).then((inputs) => inputs.sort((left, right) => left.path.localeCompare(right.path))); + ); + + 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, @@ -180,10 +217,16 @@ const assessSnapshotFreshness = async ( export { assessSnapshotFreshness, + collectContextInputFiles, createExplicitContextDescriptor, createExplicitRun, recordContextInputFiles, resolveExplicitCaptureTarget, resolveInternalConfigPath, }; -export type { ConfigTargetRunner, ExplicitContextOptions, ExplicitRunOptions }; +export type { + ConfigTargetRunner, + ContextInputRecording, + ExplicitContextOptions, + ExplicitRunOptions, +}; diff --git a/src/status.ts b/src/status.ts index 918dca3..47049b2 100644 --- a/src/status.ts +++ b/src/status.ts @@ -1,6 +1,11 @@ import { realpath } from 'node:fs/promises'; import { sha256Hex } from './guards.ts'; -import { type ProjectContextStatus, type ProjectStatus } from './model.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'; @@ -24,6 +29,25 @@ const compareProjectContexts = ( 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 readProjectStatus = async (workspaceRoot: string): Promise => { const workspace = await readContextWorkspaceStatus(workspaceRoot); const workspacePath = await realpath(workspaceRoot); @@ -32,38 +56,40 @@ const readProjectStatus = async (workspaceRoot: string): Promise string, (typeof workspace.runs)[number]['contexts'][number] & { run: (typeof workspace.runs)[number]['run']; + observation?: SnapshotObservation; } >(); for (const { run, contexts } of workspace.runs) { for (const contextStatus of contexts) { const current = currentByContextId.get(contextStatus.context.contextId); - if ( - current === undefined || - compareStrings(current.run.startedAt, run.startedAt) < 0 || - (current.run.startedAt === run.startedAt && - compareStrings(current.run.runId, run.runId) < 0) - ) { - currentByContextId.set(contextStatus.context.contextId, { - ...contextStatus, - run, - }); - } + const observation = + contextStatus.latestSnapshot === undefined + ? current?.observation + : newerObservation(current?.observation, { run, snapshot: contextStatus.latestSnapshot }); + 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 }), + }); } } const contexts = ( await Promise.all( - [...currentByContextId.values()].map(async ({ run, context, latestSnapshot }) => ({ + [...currentByContextId.values()].map(async ({ run, context, observation }) => ({ runId: run.runId, producer: run.producer, context, - state: latestSnapshot === undefined ? ('pending' as const) : ('ready' as const), - ...(latestSnapshot === undefined + state: observation === undefined ? ('pending' as const) : ('ready' as const), + ...(observation === undefined ? {} : { - latestSnapshot, - freshness: await assessSnapshotFreshness(workspaceRoot, latestSnapshot), + latestSnapshot: observation.snapshot, + freshness: await assessSnapshotFreshness(workspaceRoot, observation.snapshot), }), startedAt: run.startedAt, })), diff --git a/src/testRun.ts b/src/testRun.ts index a008c3c..3fd5071 100644 --- a/src/testRun.ts +++ b/src/testRun.ts @@ -1,5 +1,4 @@ import { randomUUID } from 'node:crypto'; -import { createRequire } from 'node:module'; import path from 'node:path'; import type { RunRstestOptions, TestRunResult } from '@rstest/core/api'; import { @@ -20,13 +19,14 @@ import { 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, - recordContextInputFiles, resolveExplicitCaptureTarget, resolveInternalConfigPath, type ConfigTargetRunner, @@ -82,6 +82,7 @@ type TestCaptureResult = { }; errors?: TestCaptureError[]; unhandledErrors?: TestErrorRecord[]; + unreadableInputs?: string[]; }; type TestCaptureError = TestErrorRecord & { @@ -121,7 +122,14 @@ const optionalString = ( ): Pick | Record => value === undefined ? {} : ({ [key]: value } as Pick); -const normalizeError = (error: TestRunResult['unhandledErrors'][number]): TestErrorRecord => ({ +// 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), @@ -129,8 +137,48 @@ const normalizeError = (error: TestRunResult['unhandledErrors'][number]): TestEr ...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) || @@ -147,11 +195,10 @@ const normalizeTestCase = ( ...(result.parentNames === undefined ? {} : { parentNames: [...result.parentNames] }), status: result.status, ...(result.duration === undefined ? {} : { durationMs: result.duration }), - ...(result.errors === undefined ? {} : { errors: result.errors.map(normalizeError) }), - ...(result.retryErrors === undefined - ? {} - : { retryErrors: result.retryErrors.map(normalizeError) }), + ...(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 = ( @@ -162,7 +209,7 @@ const normalizeTestFile = ( path: toWorkspacePath(workspaceRoot, result.testPath), status: result.status, ...(result.duration === undefined ? {} : { durationMs: result.duration }), - ...(result.errors === undefined ? {} : { errors: result.errors.map(normalizeError) }), + ...(result.errors === undefined ? {} : { errors: normalizeErrors(result.errors) }), tests: result.results .map((testResult) => normalizeTestCase(workspaceRoot, testResult)) .sort(compareTestCases), @@ -186,7 +233,7 @@ const normalizeTestFacet = ( files: { ...result.stats.files }, }, durationMs: result.duration.total, - unhandledErrors: result.unhandledErrors.map(normalizeError), + unhandledErrors: normalizeErrors(result.unhandledErrors), }); const testCaptureSelection = (request: TestSnapshotRequest): JsonValue => ({ @@ -210,9 +257,18 @@ const ensureWritten = (result: Awaited>) const loadRunRstest = async (): Promise => (await import('@rstest/core/api')).runRstest; -const hasCoverageProvider = (packageRoot: string): boolean => { +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 { - createRequire(path.join(packageRoot, 'package.json')).resolve('@rstest/coverage-istanbul'); + const loadCoverageProvider = await loadCoverageProviderLoader(); + await loadCoverageProvider({ provider: 'istanbul' }, packageRoot); return true; } catch { return false; @@ -233,6 +289,18 @@ const validateRelatedSelection = (request: TestSnapshotRequest): void => { } }; +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: [], @@ -251,6 +319,7 @@ const captureTestSnapshot = async ( ): Promise => { validateExecutionRequest(request.execution); validateRelatedSelection(request); + validateTestCaptureWiring(request, dependencies); const target = await resolveExplicitCaptureTarget(workspaceRoot, request); if ( dependencies.isTestConfigured !== undefined && @@ -306,16 +375,14 @@ const captureTestSnapshot = async ( let relation: TestFacet['relation']; try { let selectedFiles = request.files; - if (request.related !== undefined) { - if (dependencies.resolveRelatedTests === undefined) { - throw new Error('Rstack test capture requires a related-test resolver.'); - } + 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 dependencies.resolveRelatedTests({ + await resolveRelatedTests({ packageRoot: target.packageRoot, configPath: target.configPath, sources: sourceFiles, @@ -333,13 +400,7 @@ const captureTestSnapshot = async ( if (result === undefined) { const runRstest = dependencies.runRstest ?? (await loadRunRstest()); const withConfigTarget = - dependencies.withConfigTarget ?? - (async (_configRoot, _configPath, action) => { - if (dependencies.runRstest === undefined) { - throw new Error('Rstack test capture requires a config adapter.'); - } - return action(); - }); + dependencies.withConfigTarget ?? (async (_configRoot, _configPath, action) => action()); result = await withConfigTarget(target.packageRoot, target.configPath, () => runRstest({ cwd: target.packageRoot, @@ -409,7 +470,7 @@ const captureTestSnapshot = async ( execution, result.coverage, ); - const testInputs = await recordContextInputFiles(workspaceRoot, [ + const recording = await collectContextInputFiles(workspaceRoot, [ ...new Set([ ...facet.files.map((file) => file.path), ...(facet.relation?.sources ?? []), @@ -421,8 +482,13 @@ const captureTestSnapshot = async ( file.digest === undefined ? [] : [{ path: file.path, digest: file.digest }], ) ?? []; const inputs = [ - ...new Map([...testInputs, ...executionInputs].map((input) => [input.path, input])).values(), + ...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 snapshot: ContextSnapshot = { schemaVersion: contextStoreSchemaVersion, snapshotId: dependencies.createSnapshotId?.() ?? `snap_${Date.now()}_${randomUUID()}`, @@ -442,6 +508,7 @@ const captureTestSnapshot = async ( ? 'complete' : 'partial', }), + ...(unreadableInputs.length === 0 ? {} : { source: 'partial' as const }), }, facets: { test: facet as unknown as JsonValue, @@ -489,6 +556,7 @@ const captureTestSnapshot = async ( }), ...(errors.length === 0 ? {} : { errors }), ...(facet.unhandledErrors.length === 0 ? {} : { unhandledErrors: facet.unhandledErrors }), + ...(unreadableInputs.length === 0 ? {} : { unreadableInputs }), }; }; diff --git a/tests/codeEvidence.test.ts b/tests/codeEvidence.test.ts index e2db323..6eca50c 100644 --- a/tests/codeEvidence.test.ts +++ b/tests/codeEvidence.test.ts @@ -442,6 +442,40 @@ test('distinguishes absent exact test records from matching skipped or todo reco }); }); +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'); @@ -575,6 +609,58 @@ test('adds an independent module axis only for an explicit artifact and exposes }); }); +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 }); diff --git a/tests/lint.test.ts b/tests/lint.test.ts index 72e275a..0e6a327 100644 --- a/tests/lint.test.ts +++ b/tests/lint.test.ts @@ -5,7 +5,12 @@ 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 } from '../src/lint.ts'; +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 { @@ -49,7 +54,18 @@ const createRslint = (options: RslintOptions) => { }; }; +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; @@ -150,7 +166,12 @@ test('captures an open-ended file snapshot with partial inputs and fail status', }, ]; - const result = await captureLintSnapshot(workspaceRoot, { mode: 'files' }, createRslint); + const result = await captureLintSnapshot( + workspaceRoot, + { mode: 'files' }, + createRslint, + adapter, + ); const stored = await readContextSnapshotById(workspaceRoot, result.snapshotId); const status = await readProjectStatus(workspaceRoot); @@ -232,6 +253,7 @@ test('captures text without persisting the input and exposes only stored fix out includeFixPreview: true, }, createRslint, + adapter, ); const stored = await readContextSnapshotById(workspaceRoot, result.snapshotId); @@ -291,6 +313,7 @@ test('paginates and filters diagnostics from one frozen snapshot deterministical patterns: ['src/**/*.ts'], }, createRslint, + adapter, ); const first = await listDiagnostics(workspaceRoot, { @@ -432,7 +455,12 @@ test('selects the newest diagnostic snapshot rather than an unrelated newer buil messages: [{ ruleId: 'a', severity: 2, message: 'error', line: 1, column: 1 }], }, ]; - const lintCapture = await captureLintSnapshot(workspaceRoot, { mode: 'files' }, createRslint); + const lintCapture = await captureLintSnapshot( + workspaceRoot, + { mode: 'files' }, + createRslint, + adapter, + ); const context = { contextId: 'ctx_build', packageRoot: '.', product: 'application' } as const; const run = { schemaVersion: contextStoreSchemaVersion, @@ -480,6 +508,7 @@ test('rejects a malformed diagnostics cursor', async () => { workspaceRoot, { mode: 'files', patterns: ['a.ts'] }, createRslint, + adapter, ); await expect( @@ -506,6 +535,7 @@ test('returns snapshot provenance for an empty diagnostics page', async () => { workspaceRoot, { mode: 'files', patterns: ['a.ts'] }, createRslint, + adapter, ); const stored = await readContextSnapshotById(workspaceRoot, capture.snapshotId); @@ -546,6 +576,7 @@ test('reports preview availability without rerunning or applying Rslint', async mode: 'files', }, createRslint, + adapter, ); await expect(getLintFixPreview(workspaceRoot, notCaptured.snapshotId, 'a.ts')).resolves.toEqual( { @@ -563,6 +594,7 @@ test('reports preview availability without rerunning or applying Rslint', async includeFixPreview: true, }, createRslint, + adapter, ); await expect(getLintFixPreview(workspaceRoot, noChange.snapshotId, 'a.ts')).resolves.toEqual({ available: false, @@ -574,14 +606,146 @@ test('reports preview availability without rerunning or applying Rslint', async }); }); +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('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)).rejects.toBe( - lintError, - ); + await expect( + captureLintSnapshot(workspaceRoot, { mode: 'files' }, createRslint, adapter), + ).rejects.toBe(lintError); expect(mocks.options).toHaveLength(1); expect(mocks.closeCalls).toBe(1); @@ -631,9 +795,14 @@ test('persists a partial diagnostic snapshot when creating the lint engine throw const factoryError = new Error('Rslint configuration failed'); await expect( - captureLintSnapshot(workspaceRoot, { mode: 'files' }, () => { - throw factoryError; - }), + captureLintSnapshot( + workspaceRoot, + { mode: 'files' }, + () => { + throw factoryError; + }, + adapter, + ), ).rejects.toBe(factoryError); const [stored] = await readContextSnapshots(workspaceRoot, { diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index 40125ea..5191beb 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm } from 'node:fs/promises'; +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'; @@ -134,6 +134,197 @@ test('surfaces requested execution availability in test capture text', async () ); }); +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 degraded = await client.callTool({ + name: 'test_snapshot', + arguments: { files: ['tests/gone.test.ts'] }, + }); + + expect(degraded.isError).not.toBe(true); + expect(degraded.content).toEqual([ + { type: 'text', text: expect.stringContaining('unreadableInputs=1') }, + ]); + expect(degraded.structuredContent).toMatchObject({ + snapshotId: 'snap_mcp_2', + status: 'pass', + unreadableInputs: ['tests/gone.test.ts'], + }); + await expect( + client.callTool({ name: 'snapshot_list', arguments: {} }), + ).resolves.toMatchObject({ + structuredContent: { + items: expect.arrayContaining([ + expect.objectContaining({ + snapshotId: 'snap_mcp_2', + completeness: { test: 'complete', 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'; diff --git a/tests/rawRspackContext.test.ts b/tests/rawRspackContext.test.ts index 7f4db72..aea1028 100644 --- a/tests/rawRspackContext.test.ts +++ b/tests/rawRspackContext.test.ts @@ -66,13 +66,12 @@ test('uses raw artifact entry roots when only an Rstest context is available', a contextId: context.contextId, dataFile: 'rsdoctor-data.json', }); - expect(roots.product).toMatchObject({ - product: 'unknown', - roots: expect.arrayContaining([ - expect.objectContaining({ kind: 'production-entry', module: { id: '1' } }), - ]), - bounds: expect.arrayContaining(['product-context-unavailable']), - }); + 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', diff --git a/tests/records.test.ts b/tests/records.test.ts index 6e98302..2ff8270 100644 --- a/tests/records.test.ts +++ b/tests/records.test.ts @@ -458,6 +458,84 @@ test('rejects malformed snapshots', () => { } }); +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( diff --git a/tests/rsdoctor.test.ts b/tests/rsdoctor.test.ts index bde51ea..c3a2825 100644 --- a/tests/rsdoctor.test.ts +++ b/tests/rsdoctor.test.ts @@ -216,6 +216,29 @@ test('runs a real Rsdoctor catalog tool against a valid artifact fixture', async }); }); +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']], diff --git a/tests/source.test.ts b/tests/source.test.ts index 55daaea..5299db4 100644 --- a/tests/source.test.ts +++ b/tests/source.test.ts @@ -6,9 +6,12 @@ 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'; @@ -55,6 +58,71 @@ test('records sorted SHA-256 inputs and reports complete inputs as fresh', async }); }); +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')); diff --git a/tests/status.test.ts b/tests/status.test.ts index da7b475..357fb19 100644 --- a/tests/status.test.ts +++ b/tests/status.test.ts @@ -41,6 +41,78 @@ test('returns a stable anonymous status for an empty standalone store', async () }); }); +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('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'), { @@ -125,7 +197,9 @@ test('projects only the latest run for each context in deterministic order', asy runId: secondAppRun.runId, producer: secondAppRun.producer, context: appContext, - state: 'pending', + state: 'ready', + latestSnapshot: appSnapshot, + freshness: { state: 'unknown', changedPaths: [] }, }, { runId: secondLibraryRun.runId, diff --git a/tests/testRun.test.ts b/tests/testRun.test.ts index 307a70f..2a4373b 100644 --- a/tests/testRun.test.ts +++ b/tests/testRun.test.ts @@ -5,6 +5,7 @@ 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, @@ -14,6 +15,8 @@ import { } 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: [], @@ -32,6 +35,7 @@ const createDependencies = ( suffix: string, ): TestCaptureDependencies => ({ hasCoverageProvider: () => true, + wrapperConfigPath, runRstest: (options) => { calls.push(options); return Promise.resolve(result); @@ -41,7 +45,7 @@ const createDependencies = ( now: () => new Date('2026-08-12T08:00:00.000Z'), }); -test('publishes the opt-in Istanbul provider as an exact optional peer', async () => { +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 { @@ -51,12 +55,18 @@ test('publishes the opt-in Istanbul provider as an exact optional peer', async ( 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']).toBe('0.11.6'); - expect(packageJson.peerDependenciesMeta?.['@rstest/coverage-istanbul']).toEqual({ - optional: true, - }); + 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 () => { @@ -280,6 +290,102 @@ test('resolves related source files before running and records the static test r }); }); +test('persists a degraded 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 }, + }, + }); + + const capture = await captureTestSnapshot( + workspaceRoot, + { related: ['src/missing.ts'] }, + { + ...createDependencies(result, calls, 'unreadable'), + resolveRelatedTests: () => Promise.resolve([testPath]), + }, + ); + + expect(capture.unreadableInputs).toEqual(['src/missing.ts']); + const stored = await readContextSnapshotById(workspaceRoot, capture.snapshotId); + expect(stored?.snapshot.completeness).toEqual({ test: 'complete', source: 'partial' }); + expect(stored?.snapshot.source).toEqual({ + captureSelection: { related: ['src/missing.ts'] }, + inputCompleteness: 'partial', + inputs: [ + { + path: 'tests/config.test.ts', + digest: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08', + }, + ], + }); + expect(stored?.snapshot.facets.test).toMatchObject({ + relation: { sources: ['src/missing.ts'], testFiles: ['tests/config.test.ts'] }, + unhandledErrors: [], + }); + }); +}); + +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[] = []; @@ -1117,6 +1223,7 @@ test('persists a thrown Rstest configuration error as a completed diagnostic sna 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' }], @@ -1276,6 +1383,167 @@ test('pages project-qualified results in deterministic identity order', async () }); }); +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< From e921c340f2e27bc0eeea3056ace9731ae873e33f Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 14 Aug 2026 23:42:44 +0000 Subject: [PATCH 28/46] fix: collapse nested entry roots --- src/products.ts | 14 +++++++++++++- tests/products.test.ts | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/products.ts b/src/products.ts index 8c97ee2..5a060e4 100644 --- a/src/products.ts +++ b/src/products.ts @@ -92,7 +92,19 @@ const resolveProductRoots = async ( const roots: ProductRoot[] = []; const bounds: string[] = []; - const entries = graph.modules.filter(({ isEntry }) => isEntry); + 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', diff --git a/tests/products.test.ts b/tests/products.test.ts index ad77b68..82f3c0d 100644 --- a/tests/products.test.ts +++ b/tests/products.test.ts @@ -39,6 +39,41 @@ test('derives application entry and conservative optimizer roots', async () => { ]); }); +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'); From c01fd3b7e60db5f62a798f019aaa6c8af4315bc9 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 15 Aug 2026 00:00:30 +0000 Subject: [PATCH 29/46] build: pin the Rsdoctor agent CLI canary as the dependency The pkg.pr.new override never propagated to consumers, so downstream installs resolved the published 0.1.1 whose omitted-section semantics this package cannot ship against. Declaring the commit-pinned canary of web-infra-dev/rsdoctor#1903 directly makes every install resolve the build the tests certify. Swap for a release version once the PR ships. Co-Authored-By: Claude Fable 5 --- docs/upstream-requests.md | 25 +++++++++++++++---------- package.json | 2 +- pnpm-lock.yaml | 13 +++++-------- pnpm-workspace.yaml | 3 --- tests/packageExports.test.ts | 7 ++++++- 5 files changed, 27 insertions(+), 23 deletions(-) diff --git a/docs/upstream-requests.md b/docs/upstream-requests.md index 7422515..e50654c 100644 --- a/docs/upstream-requests.md +++ b/docs/upstream-requests.md @@ -119,15 +119,19 @@ 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 `"@rsdoctor/agent-cli": "0.1.1"` (`package.json:67`), -but `pnpm-workspace.yaml` overrides that resolution to a preview build: +`package.json` now declares the commit-pinned pkg.pr.new canary directly as +the dependency: -```yaml -overrides: - '@rsdoctor/agent-cli': 'https://pkg.pr.new/@rsdoctor/agent-cli@1903' +```json +"@rsdoctor/agent-cli": "https://pkg.pr.new/@rsdoctor/agent-cli@ba5f0a83" ``` -The two builds disagree on how an _omitted_ artifact section (one whose +so installs and CI resolve exactly the build the tests certify, and the +resolution propagates to downstream consumers (a `pnpm-workspace.yaml` +override would not). The canary is the head of open PR +web-infra-dev/rsdoctor#1903 (`codex/rsdoctor-rstack-artifact-contract`, +mergeable, 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 @@ -138,10 +142,11 @@ data from an omitted artifact section", asserting the `RSDOCTOR_SECTION_UNAVAILA 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:** cut a release of `@rsdoctor/agent-cli` that includes the -`RSDOCTOR_SECTION_UNAVAILABLE` semantics from the pkg.pr.new preview (PR/build -`1903`), and once it ships, drop the `pnpm-workspace.yaml` override and bump -`package.json`'s `@rsdoctor/agent-cli` dependency to that release. +**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, swap `package.json`'s canary URL for that release +version. Until then the commit-pinned canary above is the supported +resolution; the canary URL depends on pkg.pr.new artifact retention. ## Cross-repo follow-ups (rstack-cli coordination) diff --git a/package.json b/package.json index fca7a50..ba36ec7 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "1.29.0", - "@rsdoctor/agent-cli": "0.1.1", + "@rsdoctor/agent-cli": "https://pkg.pr.new/@rsdoctor/agent-cli@ba5f0a83", "@rslint/core": "0.8.0", "@rstest/core": "0.11.6", "zod": "4.4.3" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 95dc2b9..ce1bb03 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,9 +5,6 @@ settings: dedupePeers: true excludeLinksFromLockfile: false -overrides: - '@rsdoctor/agent-cli': https://pkg.pr.new/@rsdoctor/agent-cli@1903 - importers: .: @@ -16,8 +13,8 @@ importers: 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@1903 - version: https://pkg.pr.new/@rsdoctor/agent-cli@1903 + specifier: https://pkg.pr.new/@rsdoctor/agent-cli@ba5f0a83 + version: https://pkg.pr.new/@rsdoctor/agent-cli@ba5f0a83 '@rslint/core': specifier: 0.8.0 version: 0.8.0 @@ -175,8 +172,8 @@ packages: core-js: optional: true - '@rsdoctor/agent-cli@https://pkg.pr.new/@rsdoctor/agent-cli@1903': - resolution: {integrity: sha512-k23rz020WxM9KK5Z84SoCoxxz7qMmSMlF8G2wkPasEdiJr6sce93wec/7XEQm+FjgNKrYJBTpY+onkGCMRVI2g==, tarball: https://pkg.pr.new/@rsdoctor/agent-cli@1903} + '@rsdoctor/agent-cli@https://pkg.pr.new/@rsdoctor/agent-cli@ba5f0a83': + resolution: {integrity: sha512-k23rz020WxM9KK5Z84SoCoxxz7qMmSMlF8G2wkPasEdiJr6sce93wec/7XEQm+FjgNKrYJBTpY+onkGCMRVI2g==, tarball: https://pkg.pr.new/@rsdoctor/agent-cli@ba5f0a83} version: 0.0.0-preview-ba5f0a8 engines: {node: '>=22.18.0'} hasBin: true @@ -1034,7 +1031,7 @@ snapshots: transitivePeerDependencies: - '@module-federation/runtime-tools' - '@rsdoctor/agent-cli@https://pkg.pr.new/@rsdoctor/agent-cli@1903': {} + '@rsdoctor/agent-cli@https://pkg.pr.new/@rsdoctor/agent-cli@ba5f0a83': {} '@rslib/core@1.0.0-beta.3(typescript@7.0.2)': dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e1bba37..f9f1d38 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -8,6 +8,3 @@ minimumReleaseAgeExclude: - '@rslib/*' - '@rspack/*' - 'rsbuild-plugin-*' - -overrides: - '@rsdoctor/agent-cli': 'https://pkg.pr.new/@rsdoctor/agent-cli@1903' diff --git a/tests/packageExports.test.ts b/tests/packageExports.test.ts index 01ce263..547a87a 100644 --- a/tests/packageExports.test.ts +++ b/tests/packageExports.test.ts @@ -29,7 +29,12 @@ test('publishes focused Context entry points without a Rstack dependency', async './rstack', './rstest', ]); - expect(packageJson.dependencies?.['@rsdoctor/agent-cli']).toBe('0.1.1'); + // Commit-pinned canary of web-infra-dev/rsdoctor#1903: the dependency itself carries the + // resolution so downstream installs get the build these tests certify (overrides do not + // propagate). Swap for a release version once the PR merges and ships. + expect(packageJson.dependencies?.['@rsdoctor/agent-cli']).toBe( + 'https://pkg.pr.new/@rsdoctor/agent-cli@ba5f0a83', + ); for (const section of [ packageJson.dependencies, packageJson.devDependencies, From 50bce81ad04d274343daea2a89b98a4c9b8726b2 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 15 Aug 2026 00:07:50 +0000 Subject: [PATCH 30/46] build: keep the Rsdoctor canary override root-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert the URL dependency from c01fd3b: published previews must carry ordinary semver dependencies because blockExoticSubdeps consumers reject URL-resolved subdependencies (rstack-cli context-plugin-boundary design, "Preview dependency policy" — adopting it broke rstack-cli installs with ERR_PNPM_EXOTIC_SUBDEP). Local validation still resolves the pinned canary of web-infra-dev/rsdoctor#1903, now commit-addressed instead of floating on the PR ref; consumers on 0.1.1 fall back to the existing zero-shape degraded reporting for omitted sections. Co-Authored-By: Claude Fable 5 --- docs/upstream-requests.md | 31 ++++++++++++++++++------------- package.json | 2 +- pnpm-lock.yaml | 3 +++ pnpm-workspace.yaml | 7 +++++++ tests/packageExports.test.ts | 11 +++++------ 5 files changed, 34 insertions(+), 20 deletions(-) diff --git a/docs/upstream-requests.md b/docs/upstream-requests.md index e50654c..2ae7219 100644 --- a/docs/upstream-requests.md +++ b/docs/upstream-requests.md @@ -119,19 +119,22 @@ 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` now declares the commit-pinned pkg.pr.new canary directly as -the dependency: +`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`: -```json -"@rsdoctor/agent-cli": "https://pkg.pr.new/@rsdoctor/agent-cli@ba5f0a83" +```yaml +overrides: + '@rsdoctor/agent-cli': 'https://pkg.pr.new/@rsdoctor/agent-cli@ba5f0a83' ``` -so installs and CI resolve exactly the build the tests certify, and the -resolution propagates to downstream consumers (a `pnpm-workspace.yaml` -override would not). The canary is the head of open PR -web-infra-dev/rsdoctor#1903 (`codex/rsdoctor-rstack-artifact-contract`, -mergeable, checks green). The published `0.1.1` release and that canary -disagree on how an _omitted_ artifact section (one whose +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#1903 +(`codex/rsdoctor-rstack-artifact-contract`, mergeable, 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 @@ -144,9 +147,11 @@ 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, swap `package.json`'s canary URL for that release -version. Until then the commit-pinned canary above is the supported -resolution; the canary URL depends on pkg.pr.new artifact retention. +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) diff --git a/package.json b/package.json index ba36ec7..fca7a50 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "1.29.0", - "@rsdoctor/agent-cli": "https://pkg.pr.new/@rsdoctor/agent-cli@ba5f0a83", + "@rsdoctor/agent-cli": "0.1.1", "@rslint/core": "0.8.0", "@rstest/core": "0.11.6", "zod": "4.4.3" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce1bb03..16b08dc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,6 +5,9 @@ settings: dedupePeers: true excludeLinksFromLockfile: false +overrides: + '@rsdoctor/agent-cli': https://pkg.pr.new/@rsdoctor/agent-cli@ba5f0a83 + importers: .: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f9f1d38..38c7141 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -8,3 +8,10 @@ minimumReleaseAgeExclude: - '@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 head of web-infra-dev/rsdoctor#1903. +overrides: + '@rsdoctor/agent-cli': 'https://pkg.pr.new/@rsdoctor/agent-cli@ba5f0a83' diff --git a/tests/packageExports.test.ts b/tests/packageExports.test.ts index 547a87a..949c472 100644 --- a/tests/packageExports.test.ts +++ b/tests/packageExports.test.ts @@ -29,12 +29,11 @@ test('publishes focused Context entry points without a Rstack dependency', async './rstack', './rstest', ]); - // Commit-pinned canary of web-infra-dev/rsdoctor#1903: the dependency itself carries the - // resolution so downstream installs get the build these tests certify (overrides do not - // propagate). Swap for a release version once the PR merges and ships. - expect(packageJson.dependencies?.['@rsdoctor/agent-cli']).toBe( - 'https://pkg.pr.new/@rsdoctor/agent-cli@ba5f0a83', - ); + // The packed manifest must carry an ordinary semver dependency: blockExoticSubdeps + // consumers reject URL-resolved subdependencies. The commit-pinned canary of + // web-infra-dev/rsdoctor#1903 rides a root-only pnpm override for this repo's own + // validation instead (see pnpm-workspace.yaml). Bump once the PR merges and ships. + expect(packageJson.dependencies?.['@rsdoctor/agent-cli']).toBe('0.1.1'); for (const section of [ packageJson.dependencies, packageJson.devDependencies, From 291a442873918b4397865ad28bb27c125214a179 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 15 Aug 2026 00:17:46 +0000 Subject: [PATCH 31/46] build: make Rsdoctor runtime host-provided --- package.json | 6 +++++- tests/packageExports.test.ts | 11 ++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index fca7a50..ae22535 100644 --- a/package.json +++ b/package.json @@ -64,13 +64,13 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "1.29.0", - "@rsdoctor/agent-cli": "0.1.1", "@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", @@ -79,9 +79,13 @@ "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 } diff --git a/tests/packageExports.test.ts b/tests/packageExports.test.ts index 949c472..9be872c 100644 --- a/tests/packageExports.test.ts +++ b/tests/packageExports.test.ts @@ -29,11 +29,12 @@ test('publishes focused Context entry points without a Rstack dependency', async './rstack', './rstest', ]); - // The packed manifest must carry an ordinary semver dependency: blockExoticSubdeps - // consumers reject URL-resolved subdependencies. The commit-pinned canary of - // web-infra-dev/rsdoctor#1903 rides a root-only pnpm override for this repo's own - // validation instead (see pnpm-workspace.yaml). Bump once the PR merges and ships. - expect(packageJson.dependencies?.['@rsdoctor/agent-cli']).toBe('0.1.1'); + // 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, From 2e7c5066c737bb590a788514fc0320be93b24625 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 15 Aug 2026 00:51:04 +0000 Subject: [PATCH 32/46] fix: ignore test duration jitter in snapshot diffs --- src/codeEvidence.ts | 17 ++++-- src/diff.ts | 21 +++++--- src/lint.ts | 5 ++ src/mcp.ts | 9 +++- src/pagination.ts | 51 ++++++++++++++---- src/testRun.ts | 12 ++++- tests/codeEvidence.test.ts | 44 ++++++++++++++++ tests/diff.test.ts | 105 +++++++++++++++++++++++++++++++++++++ tests/lint.test.ts | 35 +++++++++++++ tests/mcp.test.ts | 60 +++++++++++++++++++++ tests/testRun.test.ts | 11 +++- 11 files changed, 345 insertions(+), 25 deletions(-) diff --git a/src/codeEvidence.ts b/src/codeEvidence.ts index e4d51e7..138d55b 100644 --- a/src/codeEvidence.ts +++ b/src/codeEvidence.ts @@ -108,6 +108,13 @@ const packageContainsPath = (packageRoot: string, sourcePath: string): boolean = ); }; +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', @@ -124,12 +131,16 @@ const selectSnapshot = async ( `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( - ({ context, snapshot }) => - packageContainsPath(context.packageRoot, sourcePath) && - snapshot.facets[producer === 'rstest' ? 'test' : 'lint'] !== undefined, + (stored) => + packageContainsPath(stored.context.packageRoot, sourcePath) && + stored.snapshot.facets[producer === 'rstest' ? 'test' : 'lint'] !== undefined && + (producer !== 'rslint' || lintSnapshotCapturedPath(stored, sourcePath)), ); }; diff --git a/src/diff.ts b/src/diff.ts index a92ee28..2773b81 100644 --- a/src/diff.ts +++ b/src/diff.ts @@ -40,6 +40,8 @@ type SnapshotTestUnhandledError = { type SnapshotDiffItem = SnapshotDiagnostic | SnapshotTestFileError | SnapshotTestUnhandledError | TestCaseRecord; +type SnapshotTestResult = SnapshotTestFileError | SnapshotTestUnhandledError | TestCaseRecord; + type SnapshotDiffResult = | { compatible: false; @@ -66,18 +68,14 @@ const diagnosticIdentity = (diagnostic: SnapshotDiagnostic): string => diagnostic.column, ]); -const testIdentity = ( - result: SnapshotTestFileError | SnapshotTestUnhandledError | TestCaseRecord, -): string => +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, -): Array => [ +const testResults = (facet: TestFacet): SnapshotTestResult[] => [ ...facet.files.flatMap((file) => [ ...(file.errors ?? []).map((error) => ({ kind: 'file-error' as const, @@ -90,10 +88,18 @@ const testResults = ( ...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(); @@ -113,7 +119,7 @@ const diffItems = ( for (const itemIdentity of identities) { const after = [...(rightByIdentity.get(itemIdentity) ?? [])]; const before = (leftByIdentity.get(itemIdentity) ?? []).filter((item) => { - const exactIndex = after.findIndex((candidate) => isDeepStrictEqual(item, candidate)); + const exactIndex = after.findIndex((candidate) => equal(item, candidate)); if (exactIndex === -1) return true; after.splice(exactIndex, 1); return false; @@ -180,6 +186,7 @@ const diffStoredContextSnapshots = ( testResults(leftFacet as TestFacet), testResults(rightFacet as TestFacet), testIdentity, + testResultsEqual, ); return { diff --git a/src/lint.ts b/src/lint.ts index e185423..9d10ebf 100644 --- a/src/lint.ts +++ b/src/lint.ts @@ -286,6 +286,11 @@ const captureLintSnapshot = async ( 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 = { diff --git a/src/mcp.ts b/src/mcp.ts index 6673adb..3925047 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -526,7 +526,12 @@ const listSnapshots = async (workspaceRoot: string, input: z.infer { @@ -557,7 +562,7 @@ const listSnapshots = async (workspaceRoot: string, input: z.infer Buffer.from(String(offset)).toString('base64url'); +type CursorScope = Readonly>; -const decodeCursor = (cursor: string | undefined, errorMessage: string): number => { +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'); - const offset = Number(value); - if ( - !/^(?:0|[1-9]\d*)$/u.test(value) || - Buffer.from(value).toString('base64url') !== cursor || - !Number.isSafeInteger(offset) - ) { - throw new Error(errorMessage); + 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. + } + } } - return offset; + throw new Error(errorMessage); }; export { decodeCursor, encodeCursor }; diff --git a/src/testRun.ts b/src/testRun.ts index 3fd5071..622f911 100644 --- a/src/testRun.ts +++ b/src/testRun.ts @@ -585,7 +585,15 @@ const listTestResults = async ( (query.status === undefined || item.status === query.status), ) .sort(compareTestCases); - const offset = decodeCursor(query.cursor, 'Invalid test result cursor.'); + 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.'); @@ -601,7 +609,7 @@ const listTestResults = async ( freshness: await assessSnapshotFreshness(workspaceRoot, stored.snapshot), total: items.length, items: pageItems, - ...(nextOffset < items.length ? { nextCursor: encodeCursor(nextOffset) } : {}), + ...(nextOffset < items.length ? { nextCursor: encodeCursor(nextOffset, cursorScope) } : {}), }; }; diff --git a/tests/codeEvidence.test.ts b/tests/codeEvidence.test.ts index 6eca50c..497318b 100644 --- a/tests/codeEvidence.test.ts +++ b/tests/codeEvidence.test.ts @@ -34,6 +34,7 @@ const writeSnapshot = async ( context?: ContextDescriptor; facets: ContextSnapshot['facets']; completeness: ContextSnapshot['completeness']; + source?: ContextSnapshot['source']; }, ): Promise => { const context = @@ -69,6 +70,7 @@ const writeSnapshot = async ( status: 'pass', completeness: options.completeness, facets: options.facets, + ...(options.source === undefined ? {} : { source: options.source }), }), ).toMatchObject({ written: true }); }; @@ -502,6 +504,48 @@ test('bounds exact-path diagnostics to two hundred records', async () => { }); }); +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 }); diff --git a/tests/diff.test.ts b/tests/diff.test.ts index 684390d..e258fd4 100644 --- a/tests/diff.test.ts +++ b/tests/diff.test.ts @@ -382,6 +382,111 @@ test('preserves duplicate lint diagnostics when only one occurrence is removed', }); }); +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 = [ diff --git a/tests/lint.test.ts b/tests/lint.test.ts index 0e6a327..18e7329 100644 --- a/tests/lint.test.ts +++ b/tests/lint.test.ts @@ -700,6 +700,41 @@ test('records unreadable lint inputs as degraded completeness instead of throwin }); }); +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( diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index 5191beb..d545148 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -85,6 +85,66 @@ test('reports an empty checkout without requiring optional producers', async () }); }); +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) => { diff --git a/tests/testRun.test.ts b/tests/testRun.test.ts index 2a4373b..58af0e5 100644 --- a/tests/testRun.test.ts +++ b/tests/testRun.test.ts @@ -1347,8 +1347,17 @@ test('pages project-qualified results in deterministic identity order', async () status: 'pass', }, ], - nextCursor: 'Mg', + 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, From dfec1ffaac6eb72de4a9ba7d33dc016cba8980cc Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 15 Aug 2026 00:57:30 +0000 Subject: [PATCH 33/46] fix: bind diagnostics cursors to query filters --- src/lint.ts | 13 +++++++++++-- tests/lint.test.ts | 9 +++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/lint.ts b/src/lint.ts index 9d10ebf..3a451d9 100644 --- a/src/lint.ts +++ b/src/lint.ts @@ -533,7 +533,16 @@ const listDiagnostics = async ( .filter((item) => query.severity === undefined || item.severity === query.severity) .filter((item) => query.ruleId === undefined || item.ruleId === query.ruleId) .sort(compareDiagnostics); - const offset = decodeCursor(query.cursor, 'Invalid diagnostics cursor.'); + 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; @@ -547,7 +556,7 @@ const listDiagnostics = async ( freshness: await assessSnapshotFreshness(workspaceRoot, stored.snapshot), total: items.length, items: page, - ...(nextOffset < items.length ? { nextCursor: encodeCursor(nextOffset) } : {}), + ...(nextOffset < items.length ? { nextCursor: encodeCursor(nextOffset, cursorScope) } : {}), }; }; diff --git a/tests/lint.test.ts b/tests/lint.test.ts index 18e7329..b54eade 100644 --- a/tests/lint.test.ts +++ b/tests/lint.test.ts @@ -329,6 +329,15 @@ test('paginates and filters diagnostics from one frozen snapshot deterministical 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, From 2dba313dda3a02a9cfef4865a5da34839854bc83 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 15 Aug 2026 00:58:42 +0000 Subject: [PATCH 34/46] fix: bind lint evidence to captured paths --- src/codeEvidence.ts | 5 +---- tests/codeEvidence.test.ts | 4 +++- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/codeEvidence.ts b/src/codeEvidence.ts index 138d55b..0caa2df 100644 --- a/src/codeEvidence.ts +++ b/src/codeEvidence.ts @@ -108,10 +108,7 @@ const packageContainsPath = (packageRoot: string, sourcePath: string): boolean = ); }; -const lintSnapshotCapturedPath = ( - stored: StoredContextSnapshot, - sourcePath: string, -): boolean => +const lintSnapshotCapturedPath = (stored: StoredContextSnapshot, sourcePath: string): boolean => stored.snapshot.source?.inputs?.some((input) => input.path === sourcePath) === true || diagnosticsFromStoredSnapshot(stored).some((diagnostic) => diagnostic.path === sourcePath); diff --git a/tests/codeEvidence.test.ts b/tests/codeEvidence.test.ts index 497318b..5114e65 100644 --- a/tests/codeEvidence.test.ts +++ b/tests/codeEvidence.test.ts @@ -533,7 +533,9 @@ test('selects lint provenance only from snapshots that captured the exact source diagnostics: { total: 0, items: [] }, provenance: { lint: { snapshotId: 'snap_relevant_lint' } }, }); - await expect(readCodeEvidence(workspaceRoot, { path: 'src/missing.ts' })).resolves.toMatchObject({ + await expect( + readCodeEvidence(workspaceRoot, { path: 'src/missing.ts' }), + ).resolves.toMatchObject({ diagnostics: { total: 0, items: [] }, provenance: {}, }); From eba8a34014d3084652aef93879e602d90cbcb851 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 15 Aug 2026 01:02:05 +0000 Subject: [PATCH 35/46] fix: reject unrelated report HTML --- src/mcp.ts | 2 +- src/report.ts | 13 +++++++++--- tests/report.test.ts | 50 ++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 57 insertions(+), 8 deletions(-) diff --git a/src/mcp.ts b/src/mcp.ts index 3925047..b61e6e2 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -904,7 +904,7 @@ const createContextMcpServer = ( { title: 'Link Rsdoctor report', description: - 'Return a link to an explicit checkout-local Rsdoctor report artifact when present.', + 'Return a link to a conventionally named checkout-local Rsdoctor report artifact when present.', inputSchema: reportLinkInput, annotations: readOnlyAnnotations, }, diff --git a/src/report.ts b/src/report.ts index 4e442dc..c864243 100644 --- a/src/report.ts +++ b/src/report.ts @@ -70,10 +70,15 @@ const resolveReportFile = async ( }; }; -const getSiblingHtmlReports = async (directory: string): Promise => { +const getSiblingRsdoctorHtmlReports = async (directory: string): Promise => { try { return (await readdir(directory, { withFileTypes: true })) - .filter((entry) => (entry.isFile() || entry.isSymbolicLink()) && entry.name.endsWith('.html')) + .filter( + (entry) => + (entry.isFile() || entry.isSymbolicLink()) && + entry.name.endsWith('.html') && + /(?:^|[-_.])rsdoctor(?:[-_.]|$)/i.test(entry.name), + ) .map((entry) => entry.name) .sort(); } catch { @@ -112,7 +117,9 @@ const resolveRsdoctorReport = async ( }; } - const htmlReports = await getSiblingHtmlReports(path.resolve(workspaceRoot, dataDirectory)); + 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) { diff --git a/tests/report.test.ts b/tests/report.test.ts index 9c4f39f..d1395a7 100644 --- a/tests/report.test.ts +++ b/tests/report.test.ts @@ -55,7 +55,7 @@ test('resolves one custom sibling HTML report when the conventional report is ab await writeDataFile(workspaceRoot); const reportPath = await writeWorkspaceFile( workspaceRoot, - 'artifacts/custom-report.html', + 'artifacts/custom-rsdoctor-report.html', '', ); @@ -63,13 +63,55 @@ test('resolves one custom sibling HTML report when the conventional report is ab dataFile: validDataFile, report: { kind: 'html', - path: 'artifacts/custom-report.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); @@ -93,8 +135,8 @@ test('resolves the normal workspace .rsdoctor manifest when no sibling HTML repo 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.html', ''); - await writeWorkspaceFile(workspaceRoot, 'artifacts/second.html', ''); + await writeWorkspaceFile(workspaceRoot, 'artifacts/first-rsdoctor.html', ''); + await writeWorkspaceFile(workspaceRoot, 'artifacts/second-rsdoctor.html', ''); await expect(resolveRsdoctorReport(workspaceRoot, validDataFile)).resolves.toEqual({ dataFile: validDataFile, From ff4dbfe963d08fd89f9077a71ce679fa46a10210 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 15 Aug 2026 01:03:02 +0000 Subject: [PATCH 36/46] fix: expose truncated build counts --- src/mcp.ts | 20 ++++++++++++++++++++ tests/mcp.test.ts | 14 +++++++++++--- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/mcp.ts b/src/mcp.ts index b61e6e2..135dd58 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -54,6 +54,17 @@ const summarizeBuildFacet = (value: unknown) => { 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 } : {}), @@ -64,6 +75,15 @@ const summarizeBuildFacet = (value: unknown) => { 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, + }), }; }; diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index d545148..70d7555 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -430,9 +430,15 @@ test('keeps project status compact while preserving build selection evidence', a hash: 'build-hash', hasErrors: false, hasWarnings: true, - assets: [{ name: 'assets/app.js', size: 1234 }], + 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: 0, chunks: 0 }, + truncated: { assets: 247, chunks: 0 }, }, }, }), @@ -476,8 +482,10 @@ test('keeps project status compact while preserving build selection evidence', a hash: 'build-hash', hasErrors: false, hasWarnings: true, - assets: 1, + assets: 100, chunks: 1, + total: { assets: 347, chunks: 1 }, + truncated: { assets: 247, chunks: 0 }, }, }, }, From c4b9bffb537c5bd4fde7fa64723b228548a1deeb Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 15 Aug 2026 01:06:43 +0000 Subject: [PATCH 37/46] fix: reject unreadable test snapshot inputs --- src/codeEvidence.ts | 5 ++ src/model.ts | 1 + src/records.ts | 4 ++ src/testRun.ts | 40 ++++++++++-- tests/codeEvidence.test.ts | 75 +++++++++++++++++++++- tests/mcp.test.ts | 21 ++++--- tests/records.test.ts | 8 +++ tests/testRun.test.ts | 123 +++++++++++++++++++++++++++++++++---- 8 files changed, 248 insertions(+), 29 deletions(-) diff --git a/src/codeEvidence.ts b/src/codeEvidence.ts index 0caa2df..c0fa365 100644 --- a/src/codeEvidence.ts +++ b/src/codeEvidence.ts @@ -35,6 +35,7 @@ type SnapshotEvidence = { completeness: ContextSnapshot['completeness']; freshness: ContextFreshness; packageRoot: string; + unreadableInputs?: string[]; }; type ExecutionCoverageEvidence = { @@ -136,6 +137,7 @@ const selectSnapshot = async ( 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)), ); @@ -152,6 +154,9 @@ const snapshotEvidence = async ( 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 => diff --git a/src/model.ts b/src/model.ts index 42a4f2b..12b6466 100644 --- a/src/model.ts +++ b/src/model.ts @@ -19,6 +19,7 @@ type ContextSnapshotSource = { dirtyDigest?: string; inputs?: ContextInputFile[]; inputCompleteness?: ContextInputCompleteness; + unreadableInputs?: string[]; virtualInputDigest?: string; captureSelection?: JsonValue; }; diff --git a/src/records.ts b/src/records.ts index d29e12d..99f5a98 100644 --- a/src/records.ts +++ b/src/records.ts @@ -227,6 +227,10 @@ const isSnapshotSource = (value: unknown): boolean => { 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))) && diff --git a/src/testRun.ts b/src/testRun.ts index 622f911..8222605 100644 --- a/src/testRun.ts +++ b/src/testRun.ts @@ -489,6 +489,29 @@ const captureTestSnapshot = async ( 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()}`, @@ -496,9 +519,9 @@ const captureTestSnapshot = async ( contextId: context.contextId, sequence: 0, observedAt: now().toISOString(), - status: getRunStatus(result), + status: inputError === undefined ? getRunStatus(result) : 'error', completeness: { - test: 'complete', + test: inputError === undefined ? 'complete' : 'partial', ...(executionFacet === undefined ? {} : { @@ -511,15 +534,21 @@ const captureTestSnapshot = async ( ...(unreadableInputs.length === 0 ? {} : { source: 'partial' as const }), }, facets: { - test: facet as unknown as JsonValue, + test: persistedFacet as unknown as JsonValue, ...(executionFacet === undefined ? {} : { execution: executionFacet as unknown as JsonValue }), }, - source: { inputs, inputCompleteness: 'partial', captureSelection }, + 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) => ({ @@ -567,7 +596,8 @@ const listTestResults = async ( const stored = query.snapshotId === undefined ? (await readContextSnapshots(workspaceRoot, { producer: 'rstest' })).find( - ({ snapshot }) => snapshot.facets.test !== undefined, + ({ snapshot }) => + snapshot.completeness.test === 'complete' && snapshot.facets.test !== undefined, ) : await readContextSnapshotById(workspaceRoot, query.snapshotId); if (stored === undefined || stored.run.producer !== 'rstest') { diff --git a/tests/codeEvidence.test.ts b/tests/codeEvidence.test.ts index 5114e65..dab040f 100644 --- a/tests/codeEvidence.test.ts +++ b/tests/codeEvidence.test.ts @@ -35,6 +35,7 @@ const writeSnapshot = async ( facets: ContextSnapshot['facets']; completeness: ContextSnapshot['completeness']; source?: ContextSnapshot['source']; + status?: ContextSnapshot['status']; }, ): Promise => { const context = @@ -67,7 +68,7 @@ const writeSnapshot = async ( contextId: context.contextId, sequence: 0, observedAt: options.observedAt, - status: 'pass', + status: options.status ?? 'pass', completeness: options.completeness, facets: options.facets, ...(options.source === undefined ? {} : { source: options.source }), @@ -348,6 +349,78 @@ test('reports captured related-test evidence independently from test execution', }); }); +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'); diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index 70d7555..be3b38d 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -222,20 +222,20 @@ test('captures a real test snapshot through injected capture dependencies', asyn }, }); - const degraded = await client.callTool({ + const inputError = await client.callTool({ name: 'test_snapshot', arguments: { files: ['tests/gone.test.ts'] }, }); - expect(degraded.isError).not.toBe(true); - expect(degraded.content).toEqual([ - { type: 'text', text: expect.stringContaining('unreadableInputs=1') }, + expect(inputError.isError).toBe(true); + expect(inputError.content).toEqual([ + { + type: 'text', + text: expect.stringContaining( + 'Could not read Rstest snapshot inputs: tests/gone.test.ts.', + ), + }, ]); - expect(degraded.structuredContent).toMatchObject({ - snapshotId: 'snap_mcp_2', - status: 'pass', - unreadableInputs: ['tests/gone.test.ts'], - }); await expect( client.callTool({ name: 'snapshot_list', arguments: {} }), ).resolves.toMatchObject({ @@ -243,7 +243,8 @@ test('captures a real test snapshot through injected capture dependencies', asyn items: expect.arrayContaining([ expect.objectContaining({ snapshotId: 'snap_mcp_2', - completeness: { test: 'complete', source: 'partial' }, + status: 'error', + completeness: { test: 'partial', source: 'partial' }, }), ]), }, diff --git a/tests/records.test.ts b/tests/records.test.ts index 2ff8270..0e840d3 100644 --- a/tests/records.test.ts +++ b/tests/records.test.ts @@ -451,6 +451,14 @@ test('rejects malformed snapshots', () => { { ...snapshot, completeness: [] }, { ...snapshot, completeness: { build: 'unknown' } }, { ...snapshot, facets: [] }, + { + ...snapshot, + source: { + inputs: [], + inputCompleteness: 'partial', + unreadableInputs: [42], + }, + }, ]; for (const value of invalidSnapshots) { diff --git a/tests/testRun.test.ts b/tests/testRun.test.ts index 58af0e5..54951ca 100644 --- a/tests/testRun.test.ts +++ b/tests/testRun.test.ts @@ -290,7 +290,7 @@ test('resolves related source files before running and records the static test r }); }); -test('persists a degraded snapshot when a related source file cannot be read', async () => { +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 }); @@ -312,18 +312,26 @@ test('persists a degraded snapshot when a related source file cannot be read', a }, }); - const capture = await captureTestSnapshot( - workspaceRoot, - { related: ['src/missing.ts'] }, - { - ...createDependencies(result, calls, 'unreadable'), - resolveRelatedTests: () => Promise.resolve([testPath]), - }, - ); + 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.', + }); - expect(capture.unreadableInputs).toEqual(['src/missing.ts']); - const stored = await readContextSnapshotById(workspaceRoot, capture.snapshotId); - expect(stored?.snapshot.completeness).toEqual({ test: 'complete', source: 'partial' }); + 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', @@ -333,10 +341,99 @@ test('persists a degraded snapshot when a related source file cannot be read', a digest: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08', }, ], + unreadableInputs: ['src/missing.ts'], }); expect(stored?.snapshot.facets.test).toMatchObject({ relation: { sources: ['src/missing.ts'], testFiles: ['tests/config.test.ts'] }, - unhandledErrors: [], + 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' }], }); }); }); From d48e1512daa7c204e1c1b52b2a17b684ba1a7f6a Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 15 Aug 2026 01:19:00 +0000 Subject: [PATCH 38/46] fix: preserve complete project status evidence --- src/mcp.ts | 42 ++++++++++++--------- src/model.ts | 1 + src/status.ts | 52 ++++++++++++++++++-------- tests/mcp.test.ts | 87 ++++++++++++++++++++++++++++++++++++++++++++ tests/status.test.ts | 64 ++++++++++++++++++++++++++++++++ 5 files changed, 213 insertions(+), 33 deletions(-) diff --git a/src/mcp.ts b/src/mcp.ts index 135dd58..01de3e0 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -20,6 +20,7 @@ import { 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'; @@ -90,14 +91,29 @@ const summarizeBuildFacet = (value: unknown) => { 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, freshness }) => { - const build = summarizeBuildFacet(latestSnapshot?.facets.build); - const lint = validateLintFacet(latestSnapshot?.facets.lint); - const test = validateTestFacet(latestSnapshot?.facets.test); + ({ runId, producer, context, state, latestSnapshot, latestAttempt, freshness }) => { return { runId, producer, @@ -106,22 +122,12 @@ const summarizeProjectStatus = (status: Awaited + 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); @@ -57,16 +60,25 @@ const readProjectStatus = async (workspaceRoot: string): Promise (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 observation = + const candidate = contextStatus.latestSnapshot === undefined + ? undefined + : { run, snapshot: contextStatus.latestSnapshot }; + const observation = + candidate === undefined || !isCompleteSuccessfulObservation(candidate) ? current?.observation - : newerObservation(current?.observation, { run, snapshot: contextStatus.latestSnapshot }); + : 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 } @@ -74,25 +86,35 @@ const readProjectStatus = async (workspaceRoot: string): Promise currentByContextId.set(contextStatus.context.contextId, { ...newest, ...(observation === undefined ? {} : { observation }), + ...(latestAttempt === undefined ? {} : { latestAttempt }), }); } } const contexts = ( await Promise.all( - [...currentByContextId.values()].map(async ({ run, context, observation }) => ({ - runId: run.runId, - producer: run.producer, - context, - state: observation === undefined ? ('pending' as const) : ('ready' as const), - ...(observation === undefined - ? {} - : { - latestSnapshot: observation.snapshot, - freshness: await assessSnapshotFreshness(workspaceRoot, observation.snapshot), - }), - startedAt: run.startedAt, - })), + [...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) diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index be3b38d..e32ab7a 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -498,3 +498,90 @@ test('keeps project status compact while preserving build selection evidence', a 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/status.test.ts b/tests/status.test.ts index 357fb19..7031958 100644 --- a/tests/status.test.ts +++ b/tests/status.test.ts @@ -113,6 +113,70 @@ test('keeps the newest completed snapshot when a later run recorded none', async }); }); +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'), { From 711b987b3c1d7f289bd5b88e097909ebbca32dba Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 15 Aug 2026 02:33:07 +0000 Subject: [PATCH 39/46] build: validate stacked Rsdoctor preview --- docs/upstream-requests.md | 7 ++++--- pnpm-lock.yaml | 14 +++++++------- pnpm-workspace.yaml | 5 +++-- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/docs/upstream-requests.md b/docs/upstream-requests.md index 2ae7219..9a630a0 100644 --- a/docs/upstream-requests.md +++ b/docs/upstream-requests.md @@ -125,15 +125,16 @@ root-only override in `pnpm-workspace.yaml`: ```yaml overrides: - '@rsdoctor/agent-cli': 'https://pkg.pr.new/@rsdoctor/agent-cli@ba5f0a83' + '@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#1903 -(`codex/rsdoctor-rstack-artifact-contract`, mergeable, checks green). The +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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 16b08dc..4114386 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,7 +6,7 @@ settings: excludeLinksFromLockfile: false overrides: - '@rsdoctor/agent-cli': https://pkg.pr.new/@rsdoctor/agent-cli@ba5f0a83 + '@rsdoctor/agent-cli': https://pkg.pr.new/@rsdoctor/agent-cli@8926633c importers: @@ -16,8 +16,8 @@ importers: 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@ba5f0a83 - version: https://pkg.pr.new/@rsdoctor/agent-cli@ba5f0a83 + 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 @@ -175,9 +175,9 @@ packages: core-js: optional: true - '@rsdoctor/agent-cli@https://pkg.pr.new/@rsdoctor/agent-cli@ba5f0a83': - resolution: {integrity: sha512-k23rz020WxM9KK5Z84SoCoxxz7qMmSMlF8G2wkPasEdiJr6sce93wec/7XEQm+FjgNKrYJBTpY+onkGCMRVI2g==, tarball: https://pkg.pr.new/@rsdoctor/agent-cli@ba5f0a83} - version: 0.0.0-preview-ba5f0a8 + '@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 @@ -1034,7 +1034,7 @@ snapshots: transitivePeerDependencies: - '@module-federation/runtime-tools' - '@rsdoctor/agent-cli@https://pkg.pr.new/@rsdoctor/agent-cli@ba5f0a83': {} + '@rsdoctor/agent-cli@https://pkg.pr.new/@rsdoctor/agent-cli@8926633c': {} '@rslib/core@1.0.0-beta.3(typescript@7.0.2)': dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 38c7141..5f7d1fb 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -12,6 +12,7 @@ minimumReleaseAgeExclude: # 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 head of web-infra-dev/rsdoctor#1903. +# 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@ba5f0a83' + '@rsdoctor/agent-cli': 'https://pkg.pr.new/@rsdoctor/agent-cli@8926633c' From b52259e2ea1a9bc43b898a458720c64a4c04af9c Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 17 Aug 2026 22:56:05 +0000 Subject: [PATCH 40/46] fix: keep optimizer bounds out of product roots --- docs/rfc.md | 30 ++++++++++++++------------ src/analysisModel.ts | 10 ++------- src/mcp.ts | 2 +- src/products.ts | 19 ---------------- src/queries.ts | 49 ++++++------------------------------------ tests/products.test.ts | 5 +---- tests/queries.test.ts | 47 +++++++++++++++------------------------- 7 files changed, 43 insertions(+), 119 deletions(-) diff --git a/docs/rfc.md b/docs/rfc.md index e9e4c1d..bcc1d58 100644 --- a/docs/rfc.md +++ b/docs/rfc.md @@ -281,14 +281,16 @@ For reachability, the adapter normalizes the artifact's module graph into stable 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. +- mapped `package.json` contract targets for library contexts. + +Optimizer bounds remain independent module evidence. A side-effect or CommonJS bailout can explain +why code was retained after it was reached, but it does not make that module a product entry. This +distinction prevents ordinary optimizer notes from turning every retained dependency into a root. 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 +Rsdoctor artifact can still supply observed entry 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. @@ -427,7 +429,7 @@ The implemented server exposes these 15 tools: | `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. | +| `dead_code_explain` | Query | Explain one module's reachability, optimizer 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. | @@ -447,17 +449,17 @@ which Rsdoctor artifact the user intended. ### Module claim vocabulary -The reachability tools use four classifications: +The reachability tools use three 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. | +| Classification | Meaning | +| ------------------------------ | ------------------------------------------------------------------- | +| `reachable` | A production or contract 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. +decision. Its independent optimizer-retention state and reasons remain visible for verification. +Export-level and local-symbol conclusions remain outside this branch. ## Agent plugin distribution @@ -549,7 +551,7 @@ Implemented downstream: Implemented downstream: - normalized Rsdoctor module graphs; -- application entries, library contract targets, and conservative roots; +- application entries and library contract targets; - 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`. diff --git a/src/analysisModel.ts b/src/analysisModel.ts index 88e26a1..b9ebe84 100644 --- a/src/analysisModel.ts +++ b/src/analysisModel.ts @@ -33,8 +33,7 @@ type ObservedModuleGraph = { >; }; -type ProductRootKind = - 'production-entry' | 'published-contract' | 'side-effect' | 'conservative-runtime'; +type ProductRootKind = 'production-entry' | 'published-contract'; type ProductRoot = { kind: ProductRootKind; @@ -103,7 +102,6 @@ type UnusedCandidatesResult = { roots: { production: number; contract: number; - conservative: number; }; total: number; returned: number; @@ -125,11 +123,7 @@ type ModulePath = { type DeadCodeExplanation = { provenance: AnalysisProvenance; subject?: ModuleRef & { kind: 'module' }; - classification: - | 'reachable' - | 'unreachable-module-candidate' - | 'preserved-by-conservative-root' - | 'insufficient-evidence'; + classification: 'reachable' | 'unreachable-module-candidate' | 'insufficient-evidence'; state: ModuleState; paths: ModulePath[]; evidence: string[]; diff --git a/src/mcp.ts b/src/mcp.ts index 01de3e0..e4c80fc 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -674,7 +674,7 @@ const createContextMcpServer = ( { title: 'Explain module reachability', description: - 'Explain why one module is reachable, conservatively preserved, or an artifact-scoped candidate.', + 'Explain why one module is reachable or an artifact-scoped candidate, including independent optimizer-retention evidence.', inputSchema: deadCodeExplainInput, annotations: readOnlyAnnotations, }, diff --git a/src/products.ts b/src/products.ts index 5a060e4..c777890 100644 --- a/src/products.ts +++ b/src/products.ts @@ -147,25 +147,6 @@ const resolveProductRoots = async ( 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); diff --git a/src/queries.ts b/src/queries.ts index f2637d1..acf8822 100644 --- a/src/queries.ts +++ b/src/queries.ts @@ -50,7 +50,6 @@ type LoadedAnalysis = { type RootTraversals = { production: TraversalResult; contract: TraversalResult; - conservative: TraversalResult; }; const candidateTraversalOptions = { maxDepth: 32, maxVisited: 20_000 } as const; @@ -242,16 +241,9 @@ const unavailableGraphEvidence = (graph: ObservedModuleGraph): string => ? '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[] => +const rootsOfKind = (product: ProductRootSet, family: 'production' | 'contract'): ProductRoot[] => product.roots.filter(({ kind }) => - family === 'production' - ? kind === 'production-entry' - : family === 'contract' - ? kind === 'published-contract' - : kind === 'side-effect' || kind === 'conservative-runtime', + family === 'production' ? kind === 'production-entry' : kind === 'published-contract', ); const traceRootFamilies = ( @@ -272,19 +264,12 @@ const traceRootFamilies = ( '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; }; @@ -325,10 +310,8 @@ const isCandidate = ( !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); + !traversals.contract.predecessor.has(moduleId); const resolveModule = (graph: ObservedModuleGraph, selector: string): ObservedModule => { const ambiguous = (matches: ObservedModule[]): never => { @@ -417,7 +400,7 @@ const findUnusedCandidates = async ( if (!hasAuthoritativeGraph(graph)) { return { provenance, - roots: { production: 0, contract: 0, conservative: 0 }, + roots: { production: 0, contract: 0 }, total: 0, returned: 0, ownership: { project: 0, dependency: 0 }, @@ -462,7 +445,6 @@ const findUnusedCandidates = async ( roots: { production: rootsOfKind(product, 'production').length, contract: rootsOfKind(product, 'contract').length, - conservative: rootsOfKind(product, 'conservative').length, }, total: candidates.length, returned: returnedCandidates.length, @@ -470,10 +452,7 @@ const findUnusedCandidates = async ( project: candidates.length - dependencyCandidates, dependency: dependencyCandidates, }, - analysisTruncated: - traversals.production.truncated || - traversals.contract.truncated || - traversals.conservative.truncated, + analysisTruncated: traversals.production.truncated || traversals.contract.truncated, resultTruncated: nextOffset < candidates.length, candidates: returnedCandidates, ...(nextOffset < candidates.length ? { nextCursor: encodeCursor(nextOffset) } : {}), @@ -526,13 +505,6 @@ const explainAnalysisModule = ( 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[]; @@ -540,12 +512,6 @@ const explainAnalysisModule = ( 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 = []; @@ -570,10 +536,7 @@ const explainAnalysisModule = ( state: moduleState(module, product, traversals), paths, evidence, - analysisTruncated: - traversals.production.truncated || - traversals.contract.truncated || - traversals.conservative.truncated, + analysisTruncated: traversals.production.truncated || traversals.contract.truncated, bounds, }; }; diff --git a/tests/products.test.ts b/tests/products.test.ts index 82f3c0d..91a80c9 100644 --- a/tests/products.test.ts +++ b/tests/products.test.ts @@ -12,7 +12,7 @@ const context = (contextId: string, product: 'application' | 'library'): Context product, }); -test('derives application entry and conservative optimizer roots', async () => { +test('derives application roots only from observed entries', async () => { const workspaceRoot = path.join(fixtureRoot, 'application'); const graph = await readRsdoctorModuleGraph(workspaceRoot, 'rsdoctor-data.json'); @@ -28,9 +28,6 @@ test('derives application entry and conservative optimizer roots', async () => { 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', diff --git a/tests/queries.test.ts b/tests/queries.test.ts index 3def368..ef3f815 100644 --- a/tests/queries.test.ts +++ b/tests/queries.test.ts @@ -154,9 +154,6 @@ test('accepts a legacy artifact with explicit-unverified build provenance', asyn }); expect(result.product.roots.map(({ kind, module }) => [kind, module.id])).toEqual([ ['production-entry', '1'], - ['side-effect', '4'], - ['conservative-runtime', '8'], - ['conservative-runtime', '5'], ]); }); }); @@ -194,7 +191,7 @@ test('binds v1 artifact metadata to the selected build snapshot on an exact iden }); expect(roots.provenance.artifactBinding).toBe('exact'); - expect(candidates.total).toBe(1); + expect(candidates.total).toBe(4); }); }); @@ -410,7 +407,7 @@ test('does not bind a primary compiler graph to a different multi-compiler child expect(webCandidates.total).toBe(0); expect(webCandidates.bounds).toContain('artifact-build-mismatch'); expect(serverCandidates.provenance.artifactBinding).toBe('exact'); - expect(serverCandidates.total).toBe(1); + expect(serverCandidates.total).toBe(4); }); }); @@ -431,32 +428,22 @@ test('returns only artifact-scoped unreachable module candidates', async () => { expect(result.roots).toEqual({ production: 1, contract: 0, - conservative: 3, }); - expect(result.total).toBe(1); - expect(result.returned).toBe(1); + expect(result.total).toBe(4); + expect(result.returned).toBe(4); 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'], - }, + expect( + result.candidates.map(({ subject, state }) => ({ + id: subject.id, + shipped: state.shipped, + optimizerRetention: state.optimizerRetention, + })), + ).toEqual([ + { id: '8', shipped: 'unknown', optimizerRetention: 'bailout' }, + { id: '5', shipped: 'unknown', optimizerRetention: 'bailout' }, + { id: '3', shipped: 'unknown', optimizerRetention: 'unknown' }, + { id: '4', shipped: 'yes', optimizerRetention: 'side-effect' }, ]); }); }); @@ -566,7 +553,7 @@ test('keeps rootless modules unknown instead of deriving unreachable candidates' }); }); -test('explains reachable, candidate, and conservatively preserved modules', async () => { +test('explains reachable and unreachable modules without treating optimizer bounds as roots', async () => { await withFixtureWorkspace('application', async (workspaceRoot) => { const context = { contextId: 'ctx_app', @@ -609,7 +596,7 @@ test('explains reachable, candidate, and conservatively preserved modules', asyn ]); expect(candidate.classification).toBe('unreachable-module-candidate'); expect(candidate.state.productionReachability).toBe('unreachable'); - expect(preserved.classification).toBe('preserved-by-conservative-root'); + expect(preserved.classification).toBe('unreachable-module-candidate'); expect(preserved.state.optimizerRetention).toBe('side-effect'); expect(preserved.evidence).toContain('Rsdoctor optimizer: Top-level side effects'); }); From 619cc51cff7c22c4edfd52581a6791e35c55d21d Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 23 Sep 2026 17:03:40 -0700 Subject: [PATCH 41/46] refactor: externalize Context from Rstack CLI --- AGENTS.md | 6 +- README.md | 82 +++++++-- docs/repository-extraction.md | 253 +++++++--------------------- docs/rfc.md | 99 +++++------ docs/upstream-requests.md | 295 ++++----------------------------- package.json | 16 +- pnpm-lock.yaml | 236 ++++++++++++++++++++++---- pnpm-workspace.yaml | 10 +- rslib.config.ts | 2 +- src/build.ts | 148 ++++++++++++----- src/index.ts | 17 +- src/lint.ts | 35 +--- src/mcp.ts | 72 +++++++- src/rsdoctor.ts | 59 +++++-- src/rslint.ts | 2 - src/rstack.ts | 93 ----------- src/rstest.ts | 5 +- src/sharedConfig.ts | 77 +++++++++ src/source.ts | 29 +--- src/testRun.ts | 302 ++++++++++++++++++++++------------ tests/build.test.ts | 73 +++++++- tests/lint.test.ts | 132 ++++++++------- tests/mcp.test.ts | 254 +++++++++++++++++++++++----- tests/packageExports.test.ts | 13 +- tests/rstack.test.ts | 88 ---------- tests/sharedConfig.test.ts | 81 +++++++++ tests/source.test.ts | 10 -- tests/testRun.test.ts | 292 +++++++++++++++++++------------- 28 files changed, 1573 insertions(+), 1208 deletions(-) delete mode 100644 src/rstack.ts create mode 100644 src/sharedConfig.ts delete mode 100644 tests/rstack.test.ts create mode 100644 tests/sharedConfig.test.ts diff --git a/AGENTS.md b/AGENTS.md index a91bc9a..7d4ba2f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,9 @@ 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`. +- Do not add a Context-specific CLI command, plugin SPI, or generated lint/test wrapper configs. +- Integrate passive builds only through the public Rstack shared-config API from RFC #504. +- Run MCP directly through the programmatic `@rstackjs/context/mcp` stdio entry point. - 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 @@ -32,4 +34,4 @@ pnpm test:coverage - 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. +- Use pkg.pr.new previews for cross-repository shared-config and Agent Skills validation. diff --git a/README.md b/README.md index acb1d2d..57fd7e4 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,74 @@ -# Rstack context +# 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. +`@rstackjs/context` records and queries checkout-local build, lint, test, coverage, and Rsdoctor +evidence while keeping freshness, completeness, and provenance explicit. The package is independent +of Rstack CLI. Its MCP server runs directly, while passive build capture integrates through ordinary +Rstack shared configuration. -Most users install and invoke it through the `rstack` package: +## Add build context -```bash -rs mcp +> [!IMPORTANT] +> This integration requires the public `define.extends()` API proposed in +> [Rstack RFC #504](https://github.com/rstackjs/rstack-cli/discussions/504). The lower-level +> implementation is still landing in Rstack, so keep this setup gated until a released Rstack +> version exports that API. + +After that API is released, install `@rstackjs/context` and add its shared configuration to +`rstack.config.ts`: + +```ts +import { createRstackContextConfig } from '@rstackjs/context/shared-config'; +import { define } from 'rstack'; + +define.extends([ + createRstackContextConfig({ + products: 'application', + }), +]); + +define.app({ + // Project-specific settings have the normal highest precedence. +}); ``` -The `rstack/context` export re-exports this package for programmatic consumers. Codex and Claude -Code workflow guidance is distributed separately by +Use `products: 'library'` for a library-only project, or +`products: ['application', 'library']` when the same Rstack configuration intentionally defines +both. Product selection is explicit so adding Context does not change Rstack's automatic Rstest +App-versus-Lib inheritance. + +The factory enables metadata capture by default. Set `capture: 'off'`, or set +`RSTACK_CONTEXT=0`, to disable it. `RSTACK_CONTEXT=1` enables metadata capture for an invocation. + +## Run the MCP server + +The package exports a programmatic stdio entry point rather than adding a custom CLI command. + +For an MCP host configuration: + +```json +{ + "mcpServers": { + "rstack-context": { + "command": "node", + "args": [ + "--input-type=module", + "--eval", + "import('@rstackjs/context/mcp').then(({ runContextMcpServer }) => runContextMcpServer())" + ] + } + } +} +``` + +The server reads completed build observations from `.rstack/cache/context-v1`. Explicit +`lint_snapshot` and `test_snapshot` requests use the selected package's native +`rslint.config.*` and `rstest.config.*` files. `related_tests` lists the Rstest files related to +one or more source files without executing test cases or writing a snapshot. Both Rstest tools +reject a package that has no native `rstest.config.*` instead of falling back to default workspace +discovery. None of these tools requires a custom command, a Rstack CLI plugin SPI, or generated +wrapper configs. + +Codex and Claude Code workflow guidance is distributed separately by [`rstackjs/agent-skills`](https://github.com/rstackjs/agent-skills). ## Development @@ -26,9 +83,8 @@ 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. +[context engine RFC](./docs/rfc.md) for the evidence model. 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. +`@rstackjs/context` itself. Install `@rstest/coverage-istanbul` or another supported Rstest +coverage provider in the checkout being analyzed. diff --git a/docs/repository-extraction.md b/docs/repository-extraction.md index 524dd9d..2e945e4 100644 --- a/docs/repository-extraction.md +++ b/docs/repository-extraction.md @@ -1,214 +1,87 @@ -# Rstack Context repository extraction design +# Rstack Context ownership ## Status -Approved on 2026-08-14. +Revised on 2026-09-23 after +[Rstack RFC #504](https://github.com/rstackjs/rstack-cli/discussions/504) replaced the proposed +Context-specific CLI plugin system. -## Summary +## Decision -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`. +`rstackjs/context` owns the complete Context runtime: -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. +- the evidence model and checkout-local store; +- Rsbuild and Rslib observers; +- native Rslint and Rstest capture; +- related-test discovery through Rstest's public reusable instance API; +- Rsdoctor artifact analysis; +- queries and diffs; and +- the programmatic stdio MCP server. -## 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. +`rstackjs/agent-skills` owns installation manifests and agent workflows. Rstack CLI owns only its +general shared-configuration feature. It does not host Context, add `rs mcp`, generate lint/test +wrapper configs, or provide a Context plugin SPI. ## 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 + Host["Codex or Claude"] -->|"stdio"| MCP["@rstackjs/context/mcp"] + Shared["Rstack define.extends()"] --> Observer["Context build observer"] + Observer --> Store[("checkout-local evidence store")] + MCP --> Store + MCP -->|"explicit capture"| Lint["native Rslint config"] + MCP -->|"explicit preflight/capture"| Test["native Rstest config"] + MCP -->|"explicit file"| Doctor["Rsdoctor artifact"] ``` -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 stdio process is started by importing `runContextMcpServer()`. It is not a package binary or a +custom Rstack command. -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. +## Shared-config gate -## Verification +Passive build integration uses `@rstackjs/context/shared-config`, but it remains release-gated until +Rstack publishes the complete RFC #504 contract: -The new repository must pass: +- public `RstackConfig`; +- public `define.extends()`; +- shared-layer loading; +- App and Lib native merges; and +- merged Test configuration before automatic App-versus-Lib inheritance. -- 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. +The package can still ship and run its MCP, lint, test, Rsdoctor, store, and query features without +that Rstack release. Documentation must not present passive build setup as currently available until +the gate is satisfied. + +## Native test boundary -Rstack CLI must pass: +Context uses `@rstest/core`'s public `loadConfig()` and reusable `createRstest()` API. It trusts +Rstest's native config discovery order and extensions. If discovery returns no config file, +`related_tests` and `test_snapshot` fail before constructing Rstest or writing a run manifest. -- 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`. +`related_tests` calls `listTests({ filesOnly: true, filters: sources, related: true })`. +`test_snapshot` runs the original source filters with `related: true`; it does not substitute the +preflight's test paths into execution. -Agent Skills must pass: +## Compatibility -- 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. +- Preserve the `@rstackjs/context` package name. +- Preserve `.rstack/cache/context-v1` reads and evidence semantics. +- Keep producer failures independent. +- Keep freshness, completeness, provenance, and evidence axes separate. +- Avoid runtime or peer dependencies on `rstack`. +- Do not preserve the abandoned CLI host APIs as compatibility shims. -## Rollback +## Verification -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. +The repository must pass: + +- frozen install; +- Rslint type-aware checks and formatting; +- Rslib build and declaration generation; +- the full Rstest suite; +- real stdio MCP initialization; +- native Rstest related preflight and execution; +- missing-config rejection before `createRstest`; +- packed-package import from a clean consumer; and +- pkg.pr.new preview publication. diff --git a/docs/rfc.md b/docs/rfc.md index bcc1d58..0dfe473 100644 --- a/docs/rfc.md +++ b/docs/rfc.md @@ -4,15 +4,15 @@ | ------- | --------------------------------------------------------------------------------- | | 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 | +| Target | Rspack 2, Rsbuild 2, Rslib 1, Rstest 0.12, Rslint 0.8, 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. +immutable records under the workspace cache. A single programmatic stdio process launched from +`@rstackjs/context/mcp` 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: @@ -21,8 +21,8 @@ This is deliberately a lean, file-based design: - 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 +- the repository-based `rstack` plugin in `rstackjs/agent-skills` launches the Context package + directly 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 @@ -92,7 +92,7 @@ flowchart LR Store[(".rstack/cache/context-v1
immutable run records")] Artifact["Explicit Rsdoctor artifact"] Report["Existing Rsdoctor report"] - MCP["rs mcp
stdio query server"] + MCP["@rstackjs/context/mcp
stdio query server"] Plugin["rstackjs/agent-skills
one rstack plugin + six context skills"] @@ -132,17 +132,16 @@ publish their results after the one-shot command completes. | 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. +`@rstackjs/context` owns the store, producer adapters, normalized evidence, queries, build observer, +and MCP implementation. Rstack integration is ordinary shared configuration through RFC #504. +Explicit lint and test captures use native producer configuration and do not depend on Rstack CLI. ### 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. +The MCP process does not use its launch directory as a package or build identifier. The stdio entry +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 native plugin context while retaining their package root. ```mermaid flowchart TB @@ -159,7 +158,7 @@ flowchart TB App -->|"run + packageRoot + contexts"| Store end - Agent["Agent session at repository root"] -->|stdio| MCP["one rs mcp server"] + Agent["Agent session at repository root"] -->|stdio| MCP["one Context MCP server"] MCP -->|"all completed runs"| Store ``` @@ -212,7 +211,7 @@ their original project-selected location. sequenceDiagram participant P as Package process participant S as Workspace store - participant M as rs mcp + participant M as Context MCP participant A as Agent P->>S: publish run.json @@ -321,15 +320,22 @@ When `includeFixPreview` is true, the snapshot may contain whole-file fixed outp ### 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. +`related_tests` and `test_snapshot` require a native Rstest config for the selected package. Context +uses Rstest's public `loadConfig()` result as the source of truth for discovery, including supported +extensions and precedence. If no config file is found, both tools fail before constructing an Rstest +instance; `test_snapshot` also writes no run manifest. + +`related_tests` lists the Rstest files statically related to one or more source files without +executing test cases or writing a snapshot. `test_snapshot` runs Rstest once through its reusable +programmatic API. The request can limit files, source files through `related`, and a test name +pattern. Related execution passes the original source filters back to Rstest with `related: true`; +the preflight's test paths are evidence, not replacement execution filters. + +The resulting snapshot records normalized test files, cases, errors, totals, and 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. 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 branch does not attach to an existing watch process, control watch cycles, or keep a resident Rstest session. @@ -403,18 +409,15 @@ flowchart LR ### 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 official `rstack` plugin in `rstackjs/agent-skills` registers one local stdio server for Codex +and Claude Code. Its Node launcher resolves `@rstackjs/context/mcp` from the checkout and invokes +`runContextMcpServer()` directly. There is no Context binary, custom Rstack command, or PATH +fallback. -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 plugin runtime launches in the agent session current working directory and binds one checkout +root for the process lifetime. Package identity comes from producer records rather than from the +MCP launch directory. 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, @@ -422,7 +425,7 @@ subscriptions, or a separate Rsdoctor MCP server. ### Tool catalog -The implemented server exposes these 15 tools: +The implemented server exposes these 16 tools: | Tool | Kind | Purpose | | ------------------- | ------------------ | --------------------------------------------------------------------- | @@ -438,6 +441,7 @@ The implemented server exposes these 15 tools: | `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. | +| `related_tests` | Discovery | List native Rstest files related to selected sources without a run. | | `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. | @@ -542,7 +546,7 @@ Implemented downstream: - 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; +- the root-resolving programmatic stdio server; - explicit Rsdoctor Agent CLI analysis; and - optional links to existing Rsdoctor reports. @@ -565,12 +569,13 @@ Implemented downstream: - one-shot Rslint snapshots with normalized diagnostics; - one-shot Rstest snapshots with normalized file and case results; +- native related-test discovery and related one-shot execution; - 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. +Passive lint/test attachment, watch control, resident workers, type-check/timing capture, and +per-test coverage attribution remain deferred. ### Phase 4: review workflows @@ -624,8 +629,8 @@ 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. +2. Use Rstest's public related-file API as a distinct static relation. This branch implements this + step through `related_tests` and related `test_snapshot` captures. 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. @@ -642,7 +647,7 @@ 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; +- 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; @@ -667,11 +672,11 @@ The downstream implementation includes focused coverage for: - 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 +- the 16-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`. +The repository verification matrix is `pnpm check`, `pnpm build`, `pnpm test`, packed-package +imports, and the real stdio MCP test. ## Acceptance criteria diff --git a/docs/upstream-requests.md b/docs/upstream-requests.md index 9a630a0..fce6027 100644 --- a/docs/upstream-requests.md +++ b/docs/upstream-requests.md @@ -1,281 +1,48 @@ -# Upstream and cross-repo follow-ups +# Upstream and cross-repository 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. +This file lists only gaps that remain after Context moved out of Rstack CLI. -## Upstream API requests +## Rstack shared configuration -### 1. rstest (`web-infra-dev/rstest`): expose related-test selection publicly +[RFC #504](https://github.com/rstackjs/rstack-cli/discussions/504) is the only required Rstack +integration. Internal layer primitives and shared-config normalization have landed, while public +`define.extends()`, shared-layer loading, and the remaining merge paths are still incomplete. -`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. +Context's `@rstackjs/context/shared-config` export must remain draft-gated until a released Rstack +package provides the complete public contract and an App/Lib integration fixture passes. -Verified against the installed `@rstest/core@0.11.6`: +## Rsbuild stats -- `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. +`src/build.ts` serializes the `Stats` object synchronously in +`onAfterEnvironmentCompile`, then stores bounded assets, chunks, and the compilation hash. Rsbuild +has richer internal helpers for multi-compiler stats, but they are not public. -**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`. +**Request:** export a supported thin stats-normalization helper from `@rsbuild/core`. -**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. +**Payoff:** Context could inherit Rsbuild's multi-compiler child merging instead of keeping a +conservative direct `toJson()` projection. -### 2. rsbuild (`web-infra-dev/rsbuild`): export the internal thin stats contract +## Rsdoctor Agent CLI -`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. +Context still validates tool input against each catalog entry's JSON schema before calling the +in-process executor. -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: +**Request:** validate catalog tool input inside `createInProcessRsdoctorCliToolExecutor()`, or expose +a supported validation helper. -```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; -``` +**Payoff:** delete Context's small JSON-schema matcher and keep catalog validation in one package. -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. +## Rstest configuration provenance -**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. +Rstest 0.12.1 resolved the former related-test API gap with public `createRstest()`, +`listTests({ related: true })`, and `run({ related: true })`. No CLI subprocess or injected relation +resolver is required in production. -**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. +The remaining opportunity is richer provenance. `loadConfig()` returns the config file and +dependencies, but Context does not yet include all imported config files in test snapshot freshness. -### 3. rsdoctor (`web-infra-dev/rsdoctor`) `agent-cli` +**Request:** keep the current `LoadedRstestConfig` metadata stable while the reusable instance API is +experimental. -**(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. +**Payoff:** Context can record the complete native config dependency set without depending on +private Rstest internals. diff --git a/package.json b/package.json index ae22535..d023d1b 100644 --- a/package.json +++ b/package.json @@ -37,9 +37,9 @@ "types": "./dist/rslint.d.ts", "default": "./dist/rslint.js" }, - "./rstack": { - "types": "./dist/rstack.d.ts", - "default": "./dist/rstack.js" + "./shared-config": { + "types": "./dist/sharedConfig.d.ts", + "default": "./dist/sharedConfig.js" }, "./rstest": { "types": "./dist/rstest.d.ts", @@ -65,22 +65,22 @@ "dependencies": { "@modelcontextprotocol/sdk": "1.29.0", "@rslint/core": "0.8.0", - "@rstest/core": "0.11.6", + "@rstest/core": "0.12.1", "zod": "4.4.3" }, "devDependencies": { "@rsbuild/core": "2.1.11", - "@rsdoctor/agent-cli": "0.1.1", + "@rsdoctor/agent-cli": "0.1.2", "@rslib/core": "1.0.0-beta.3", - "@rstest/coverage-istanbul": "0.11.6", + "@rstest/coverage-istanbul": "0.12.1", "@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" + "@rsbuild/core": "^2.0.0", + "@rsdoctor/agent-cli": ">=0.1.2" }, "peerDependenciesMeta": { "@rsdoctor/agent-cli": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4114386..bc96688 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,9 +5,6 @@ settings: dedupePeers: true excludeLinksFromLockfile: false -overrides: - '@rsdoctor/agent-cli': https://pkg.pr.new/@rsdoctor/agent-cli@8926633c - importers: .: @@ -15,15 +12,12 @@ importers: '@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 + specifier: 0.12.1 + version: 0.12.1 zod: specifier: 4.4.3 version: 4.4.3 @@ -31,12 +25,15 @@ importers: '@rsbuild/core': specifier: 2.1.11 version: 2.1.11 + '@rsdoctor/agent-cli': + specifier: 0.1.2 + version: 0.1.2 '@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) + specifier: 0.12.1 + version: 0.12.1(@rstest/core@0.12.1)(supports-color@7.2.0) '@types/node': specifier: 24.13.3 version: 24.13.3 @@ -175,10 +172,19 @@ packages: 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'} + '@rsbuild/core@2.2.9': + resolution: {integrity: sha512-ZeOYQYIBOtYkfvmbNUiKEYBK1aMGE03mhuHKVcHiWk+Y63FuUTnuQtlr1RyLErh0E7F/KyDTIaraDVH3g3GNkA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + core-js: '>= 3.0.0' + peerDependenciesMeta: + core-js: + optional: true + + '@rsdoctor/agent-cli@0.1.2': + resolution: {integrity: sha512-xjFWWtQodShNTdLCo13LoeYtnJ5nhTQ9mb7fkggrNGuY7HkAy4xFsHKqq3lHydTMvpX9+nR6g2uer+S105K0fw==} + engines: {node: ^20.19.0 || >=22.12.0} hasBin: true '@rslib/core@1.0.0-beta.3': @@ -252,81 +258,161 @@ packages: cpu: [arm64] os: [darwin] + '@rspack/binding-darwin-arm64@2.2.7': + resolution: {integrity: sha512-m0ijteZwZfUZeFcbyxwTe6N+I7aceV5/iW39b7fIIoMN/mSv9zL0ieeRzwuFe7mTYGwCuwNieDZHXfMFdp2Q2w==} + cpu: [arm64] + os: [darwin] + '@rspack/binding-darwin-x64@2.1.10': resolution: {integrity: sha512-my/0h2LwxCRT6cg3oDDC2e0ZOxQLVajAdIcv0fqnQk5JRNvVuL89PuTutitnSqie1A0/JSL8OQz5XHwmoS3kow==} cpu: [x64] os: [darwin] + '@rspack/binding-darwin-x64@2.2.7': + resolution: {integrity: sha512-f27/OubIeCe5Hzze1bm+k/CQ2XX9hk3E/FuK4lym1bjqZ9spWSuolC3sK/yU/Pc3kwMPuf53byUm3FM+QC/blw==} + 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-gnu@2.2.7': + resolution: {integrity: sha512-uGEX6kY/Pi3wPZgaEk6vTevZOHjKw8qmd5pqWVwRmpLhwvPB7LGqVqx5BMnNYBjsndVUWJQQRlGL8DoVG5IXXQ==} + 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-arm64-musl@2.2.7': + resolution: {integrity: sha512-qBvztXsOgdi69zi8BhIUz+yc/alqR/wQVmcdCh+1SbhcsNpMsFUndxvVoR1tOboBsEo//gxOG4GxH8zlemhbUA==} + 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-ppc64-gnu@2.2.7': + resolution: {integrity: sha512-t+/tOPoMu3ZZF8I1Wvqd4Jrib1teOOgb0S4jGb4nSVryuDIWgjny0FLGv5xc8RC7e4esVF93D4wF62Kt9Pxfvw==} + 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-gnu@2.2.7': + resolution: {integrity: sha512-ZkN6K4PRTPqfYI7gPYv1PqgUXKsZDeeFPFoQ4LLiFxhIGczdJY5XAPPoHN+N7d91hAGWi6u+UHYrUp1HvudBOw==} + 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-riscv64-musl@2.2.7': + resolution: {integrity: sha512-ywcmeNAC59HtVJ4a5jy5cEUA4JNa19QlVtTW09Pzh0+luNEasWaDfOaXJpYJTGWcycVwzMm3A9aNN3MqvO87EQ==} + 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-s390x-gnu@2.2.7': + resolution: {integrity: sha512-U/np+nOj9mka5CdM9FKNkrAShw0UoaarBLzOxbBHu4sqMt3iXQCi91fblLdhUDoWuIQrnV/WrqygFeIP2qAHIw==} + 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-gnu@2.2.7': + resolution: {integrity: sha512-PkryZX3GZeBQfzvKEhwGGd9m43PwqcsqWd14kmsVADeBCqHLxJahMGiGmajd28vSa42xbw7HTb8yY4jXzv5QtA==} + 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-linux-x64-musl@2.2.7': + resolution: {integrity: sha512-atJn62IgXnc/RhWiFgEiNx7IHAJbHunttzjMpWJLjWiFuJ2c+tLQrTBIiqO3VAIXh+haN1jZLHXuMVj5/J+edA==} + cpu: [x64] + os: [linux] + libc: [musl] + '@rspack/binding-wasm32-wasi@2.1.10': resolution: {integrity: sha512-KY5YbWbuvYcoaLXnV+vzZOvGRCeb6jt4EpVpKdph1h1IJjwX/ju15EQ+GOe3iecZEdf0OttQcNVcwBkLkFT9ag==} cpu: [wasm32] + '@rspack/binding-wasm32-wasi@2.2.7': + resolution: {integrity: sha512-SRDYrqWnD8Y+VNj8+11KggFaVMFSbyu+9WC2f9sygzjFu78GmF0kLKaHNQMaJv6MUMSBk/OhtEbUB4hmH7czVQ==} + 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-arm64-msvc@2.2.7': + resolution: {integrity: sha512-etHFjzSP/jnnrVYuim9C/nhSlN0/tQowVz0CSJOUHtDH7ZZiUsvWtkH9Zx3tDaZOB7sAw9cbL36mbmdkb4U6cw==} + 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-ia32-msvc@2.2.7': + resolution: {integrity: sha512-26HiMUQk8QKVw477gW9HorIQ3r0le3AL4OFigJr1/HAumi5qyOPazck+wWtUDh1R9bSf8etZjkuseBGtjN5+KQ==} + cpu: [ia32] + os: [win32] + '@rspack/binding-win32-x64-msvc@2.1.10': resolution: {integrity: sha512-pgp23pLrzfhGnKycxzr7ifP17lAbWZEfnx1bX8gXtYrnpJ66DRNyTKSzxB6sa/HBWjS1L8PX5TjMZ44WfPydqQ==} cpu: [x64] os: [win32] + '@rspack/binding-win32-x64-msvc@2.2.7': + resolution: {integrity: sha512-ut8tVSXP8DWQE/JUwQ1oqwuAaAgySgYF66HpWmxfR9J4nLzGmhd7CnnaCTT3vUyQQNmLKFida/AbiqJ/gWmPrw==} + cpu: [x64] + os: [win32] + '@rspack/binding@2.1.10': resolution: {integrity: sha512-vnu/UP5HnrND15lO9+VeG6eUrbTyycHNQNQ3XEiRiFojuoiGZkIZC3Hbzr8qQH44C6vScPODEPvvIVvLcO2LpQ==} + '@rspack/binding@2.2.7': + resolution: {integrity: sha512-opY7Sw6iwtBa23CeNj18GOqKBlaJUZXp52kNIGyNq+7jpCQKrJYeX5wiK7uBn8/ZpHQfhwNL2xxu5yzG4JFH1g==} + '@rspack/core@2.1.10': resolution: {integrity: sha512-YSS2/Xxz8uiG/KXDkqOoA3dTetNo/vysk7bAexQOrU8iuq7JuzDTTAwLKvWZnwmvME8M8m5wcM4YvfIwYmidHA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -339,8 +425,20 @@ packages: '@swc/helpers': optional: true - '@rstest/core@0.11.6': - resolution: {integrity: sha512-P3wgYGDF3JmhapwN3p4DnbNV9N6+E+dlbp0coT1lAuXN5Po6bHeP/rduGLsTUIX85qWxmJD7ekF7nlOKm9RoOA==} + '@rspack/core@2.2.7': + resolution: {integrity: sha512-kWKd4lYp13GPAQy2eQITLTvPbl+ST7ffG/MCGHoX138o5eTetMQMe/oc7UAZAfjervAM6UOzEhiiCirPTpRBUQ==} + 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.12.1': + resolution: {integrity: sha512-irrbH5VPqiHqIVM2Ta7Ib9sPyQ5Hsvxt0sSqqwRp/3j2by0sSu9ZeIMYzMEZj1puPwx2a8l3aFWW+Bk2ycKlEA==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -352,10 +450,10 @@ packages: jsdom: optional: true - '@rstest/coverage-istanbul@0.11.6': - resolution: {integrity: sha512-FvJf9EKLp7jxidaxHSY6HiPlDtQLHAATtG3HdxE3Je3aazp10Je4bjh9vmRDqOquaRIhMZgpLanENS6vA8P+HQ==} + '@rstest/coverage-istanbul@0.12.1': + resolution: {integrity: sha512-j34qn/v8Z/a8RJn0ocbzwv1eLch4/T6VAhZR6fTDk5kzaK0is4kymIBEqnN9qK0ozoNWehhQdveVFTn2Jxc7fw==} peerDependencies: - '@rstest/core': ^0.11.0 + '@rstest/core': ^0.12.0 '@swc/helpers@0.5.23': resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} @@ -522,6 +620,9 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} + cjs-module-lexer@2.2.1: + resolution: {integrity: sha512-Ca8swihM+/4yKecYHY52kgJd300hi2lADU/a1RxNTRe+RJ9jvqQlESpbz9DnG9mowez8qwXHB8qYdIUw9e+F5Q==} + content-disposition@1.1.0: resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} engines: {node: '>=18'} @@ -875,8 +976,8 @@ packages: 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==} + swc-plugin-coverage-instrument@0.0.33: + resolution: {integrity: sha512-zM17DgG3jURPNOrEHye0MtrkrsZ9BtCMf3W5osQTHAtlNolfDUtZl7740wdx7XhUSL322qdph//DgHzZe62wHg==} toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} @@ -1034,7 +1135,14 @@ snapshots: transitivePeerDependencies: - '@module-federation/runtime-tools' - '@rsdoctor/agent-cli@https://pkg.pr.new/@rsdoctor/agent-cli@8926633c': {} + '@rsbuild/core@2.2.9': + dependencies: + '@rspack/core': 2.2.7(@swc/helpers@0.5.23) + '@swc/helpers': 0.5.23 + transitivePeerDependencies: + - '@module-federation/runtime-tools' + + '@rsdoctor/agent-cli@0.1.2': {} '@rslib/core@1.0.0-beta.3(typescript@7.0.2)': dependencies: @@ -1086,33 +1194,63 @@ snapshots: '@rspack/binding-darwin-arm64@2.1.10': optional: true + '@rspack/binding-darwin-arm64@2.2.7': + optional: true + '@rspack/binding-darwin-x64@2.1.10': optional: true + '@rspack/binding-darwin-x64@2.2.7': + optional: true + '@rspack/binding-linux-arm64-gnu@2.1.10': optional: true + '@rspack/binding-linux-arm64-gnu@2.2.7': + optional: true + '@rspack/binding-linux-arm64-musl@2.1.10': optional: true + '@rspack/binding-linux-arm64-musl@2.2.7': + optional: true + '@rspack/binding-linux-ppc64-gnu@2.1.10': optional: true + '@rspack/binding-linux-ppc64-gnu@2.2.7': + optional: true + '@rspack/binding-linux-riscv64-gnu@2.1.10': optional: true + '@rspack/binding-linux-riscv64-gnu@2.2.7': + optional: true + '@rspack/binding-linux-riscv64-musl@2.1.10': optional: true + '@rspack/binding-linux-riscv64-musl@2.2.7': + optional: true + '@rspack/binding-linux-s390x-gnu@2.1.10': optional: true + '@rspack/binding-linux-s390x-gnu@2.2.7': + optional: true + '@rspack/binding-linux-x64-gnu@2.1.10': optional: true + '@rspack/binding-linux-x64-gnu@2.2.7': + optional: true + '@rspack/binding-linux-x64-musl@2.1.10': optional: true + '@rspack/binding-linux-x64-musl@2.2.7': + optional: true + '@rspack/binding-wasm32-wasi@2.1.10': dependencies: '@emnapi/core': 1.11.3 @@ -1120,15 +1258,31 @@ snapshots: '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) optional: true + '@rspack/binding-wasm32-wasi@2.2.7': + 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-arm64-msvc@2.2.7': + optional: true + '@rspack/binding-win32-ia32-msvc@2.1.10': optional: true + '@rspack/binding-win32-ia32-msvc@2.2.7': + optional: true + '@rspack/binding-win32-x64-msvc@2.1.10': optional: true + '@rspack/binding-win32-x64-msvc@2.2.7': + optional: true + '@rspack/binding@2.1.10': optionalDependencies: '@rspack/binding-darwin-arm64': 2.1.10 @@ -1146,28 +1300,52 @@ snapshots: '@rspack/binding-win32-ia32-msvc': 2.1.10 '@rspack/binding-win32-x64-msvc': 2.1.10 + '@rspack/binding@2.2.7': + optionalDependencies: + '@rspack/binding-darwin-arm64': 2.2.7 + '@rspack/binding-darwin-x64': 2.2.7 + '@rspack/binding-linux-arm64-gnu': 2.2.7 + '@rspack/binding-linux-arm64-musl': 2.2.7 + '@rspack/binding-linux-ppc64-gnu': 2.2.7 + '@rspack/binding-linux-riscv64-gnu': 2.2.7 + '@rspack/binding-linux-riscv64-musl': 2.2.7 + '@rspack/binding-linux-s390x-gnu': 2.2.7 + '@rspack/binding-linux-x64-gnu': 2.2.7 + '@rspack/binding-linux-x64-musl': 2.2.7 + '@rspack/binding-wasm32-wasi': 2.2.7 + '@rspack/binding-win32-arm64-msvc': 2.2.7 + '@rspack/binding-win32-ia32-msvc': 2.2.7 + '@rspack/binding-win32-x64-msvc': 2.2.7 + '@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': + '@rspack/core@2.2.7(@swc/helpers@0.5.23)': dependencies: - '@rsbuild/core': 2.1.12 + '@rspack/binding': 2.2.7 + optionalDependencies: + '@swc/helpers': 0.5.23 + + '@rstest/core@0.12.1': + dependencies: + '@rsbuild/core': 2.2.9 '@types/chai': 5.2.3 + cjs-module-lexer: 2.2.1 transitivePeerDependencies: - '@module-federation/runtime-tools' - core-js - '@rstest/coverage-istanbul@0.11.6(@rstest/core@0.11.6)(supports-color@7.2.0)': + '@rstest/coverage-istanbul@0.12.1(@rstest/core@0.12.1)(supports-color@7.2.0)': dependencies: - '@rstest/core': 0.11.6 + '@rstest/core': 0.12.1 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 + swc-plugin-coverage-instrument: 0.0.33 transitivePeerDependencies: - supports-color @@ -1295,6 +1473,8 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 + cjs-module-lexer@2.2.1: {} + content-disposition@1.1.0: {} content-type@1.0.5: {} @@ -1650,7 +1830,7 @@ snapshots: dependencies: has-flag: 4.0.0 - swc-plugin-coverage-instrument@0.0.32: {} + swc-plugin-coverage-instrument@0.0.33: {} toidentifier@1.0.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5f7d1fb..b25dbe1 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,14 +5,8 @@ engineStrict: true minimumReleaseAge: 1440 minimumReleaseAgeExclude: - '@rsbuild/*' + - '@rsdoctor/*' - '@rslib/*' - '@rspack/*' + - '@rstest/*' - '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 index 9c5f09d..cb0b8a4 100644 --- a/rslib.config.ts +++ b/rslib.config.ts @@ -12,7 +12,7 @@ export default defineConfig({ rsdoctor: './src/rsdoctor.ts', rslib: './src/rslib.ts', rslint: './src/rslint.ts', - rstack: './src/rstack.ts', + sharedConfig: './src/sharedConfig.ts', rstest: './src/rstest.ts', }, define: { diff --git a/src/build.ts b/src/build.ts index d827c48..6457f26 100644 --- a/src/build.ts +++ b/src/build.ts @@ -7,6 +7,7 @@ import type { OnBeforeBuildFn, Rspack, RsbuildConfig, + RsbuildContext, RsbuildPlugin, } from '@rsbuild/core'; import { sha256Hex } from './guards.ts'; @@ -20,22 +21,70 @@ import { type ContextStoreWriteResult, } from './model.ts'; import { toWorkspacePath } from './paths.ts'; +import { recordContextInputFiles } from './source.ts'; import { writeContextRunManifest, writeContextSnapshot } from './store.ts'; +import { resolveContextWorkspace } from './workspace.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; + workspace?: ResolvedContextWorkspace; + configPath?: string; inputs?: ContextInputFile[]; createRunId?: () => string; now?: () => Date; }; +type ResolvedBuildContextPluginOptions = BuildContextPluginOptions & { + workspace: ResolvedContextWorkspace; + configPath?: string; + inputs?: ContextInputFile[]; +}; + +type BuildContextProvenance = Pick< + RsbuildContext, + 'configFile' | 'configFileDependencies' | 'rootPath' +>; + +const resolveBuildContextPluginOptions = async ( + options: BuildContextPluginOptions, + provenance: BuildContextProvenance, +): Promise => { + if (options.workspace !== undefined) { + return { + ...options, + workspace: options.workspace, + }; + } + + const configPath = provenance.configFile; + const workspace = await resolveContextWorkspace(configPath ?? provenance.rootPath); + const configInputs = + configPath === undefined ? [] : [configPath, ...provenance.configFileDependencies]; + const inputs = + configInputs.length === 0 + ? undefined + : await recordContextInputFiles(workspace.workspaceRoot, [...new Set(configInputs)]); + + return { + ...options, + workspace, + ...(configPath === undefined ? {} : { configPath }), + ...(inputs === undefined ? {} : { inputs }), + }; +}; + +const createBuildContextPluginOptionsResolver = (options: BuildContextPluginOptions) => { + let resolvedOptionsPromise: Promise | undefined; + + return (provenance: BuildContextProvenance) => + (resolvedOptionsPromise ??= resolveBuildContextPluginOptions(options, provenance)); +}; + const getTarget = (environment: EnvironmentContext): string => environment.config.output.target; const getDistPath = (workspaceRoot: string, environment: EnvironmentContext): string => @@ -50,29 +99,43 @@ const normalizeMetadataPath = (workspaceRoot: string, value: string): string => : value.split(path.sep).join('/').replaceAll('\\', '/'), ); +type CapturedBuildStats = { + json: Pick; + hasErrors: boolean; + hasWarnings: boolean; +}; + +const captureBuildStats = ( + stats: NonNullable[0]['stats']>, +): CapturedBuildStats => ({ + json: stats.toJson({ + all: false, + hash: true, + assets: true, + chunks: true, + errors: false, + warnings: false, + }) as Pick, + hasErrors: stats.hasErrors(), + hasWarnings: stats.hasWarnings(), +}); + const buildMetadataFacet = ({ options, environment, isFirstCompile, isWatch, - stats, + capturedStats, time, }: { - options: BuildContextPluginOptions; + options: ResolvedBuildContextPluginOptions; environment: EnvironmentContext; isFirstCompile: boolean; isWatch: boolean; - stats: NonNullable[0]['stats']>; + capturedStats: CapturedBuildStats; time: number; }): BuildMetadataFacet => { - const json = stats.toJson({ - all: false, - hash: true, - assets: true, - chunks: true, - errors: false, - warnings: false, - }) as Pick; + const { json } = capturedStats; const assets: BuildMetadataFacet['assets'] = []; let droppedAssets = 0; for (const asset of json.assets ?? []) { @@ -125,8 +188,8 @@ const buildMetadataFacet = ({ isFirstCompile, durationMs: time, ...(typeof json.hash === 'string' ? { hash: json.hash } : {}), - hasErrors: stats.hasErrors(), - hasWarnings: stats.hasWarnings(), + hasErrors: capturedStats.hasErrors, + hasWarnings: capturedStats.hasWarnings, assets, chunks, truncated: { @@ -141,14 +204,15 @@ const captureSnapshot = ({ environment, isFirstCompile, isWatch, - stats, + capturedStats, time, -}: Parameters[0] & { - options: BuildContextPluginOptions; +}: Omit[0], 'stats'> & { + options: ResolvedBuildContextPluginOptions; + capturedStats?: CapturedBuildStats; }): Pick => { const deep = options.capture === 'deep' ? 'unsupported' : 'disabled'; - if (stats === undefined) { + if (capturedStats === undefined) { return { completeness: { build: 'partial', deep }, facets: {}, @@ -161,7 +225,7 @@ const captureSnapshot = ({ environment, isFirstCompile, isWatch, - stats, + capturedStats, time, }); return { @@ -178,7 +242,7 @@ const ensureContextWrite = (result: ContextStoreWriteResult): void => { }; const createContextDescriptor = ( - options: BuildContextPluginOptions, + options: ResolvedBuildContextPluginOptions, environment: EnvironmentContext, ): ContextDescriptor => { const packageRoot = @@ -229,6 +293,7 @@ const appendBuildContextPlugin = { + const resolveOptions = createBuildContextPluginOptionsResolver(options); let runPromise: Promise | undefined; let run: ContextRunManifest | undefined; let descriptorsByEnvironment: ReadonlyMap | undefined; @@ -261,15 +326,16 @@ const createBuildContextPlugin = (options: BuildContextPluginOptions): RsbuildPl return; } - const now = options.now ?? (() => new Date()); + const resolvedOptions = await resolveOptions(api.context); + const now = resolvedOptions.now ?? (() => new Date()); const contexts = Object.values(environments) - .map((environment) => createContextDescriptor(options, environment)) + .map((environment) => createContextDescriptor(resolvedOptions, 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, + runId: resolvedOptions.createRunId?.() ?? `run_${Date.now()}_${randomUUID()}`, + producer: resolvedOptions.producer, + command: resolvedOptions.params.command, startedAt: now().toISOString(), contexts, }; @@ -278,7 +344,7 @@ const createBuildContextPlugin = (options: BuildContextPluginOptions): RsbuildPl descriptorsByEnvironment = new Map( contexts.map((context) => [context.environment!, context]), ); - runPromise = writeContextRunManifest(options.workspace.workspaceRoot, nextRun).then( + runPromise = writeContextRunManifest(resolvedOptions.workspace.workspaceRoot, nextRun).then( ensureContextWrite, ); await runPromise; @@ -291,14 +357,7 @@ const createBuildContextPlugin = (options: BuildContextPluginOptions): RsbuildPl stats, time, }) => { - const capture = captureSnapshot({ - options, - environment, - isFirstCompile, - isWatch, - stats, - time, - }); + const capturedStats = stats === undefined ? undefined : captureBuildStats(stats); const environmentName = environment.name; const currentRun = run; const currentRunPromise = runPromise; @@ -314,13 +373,22 @@ const createBuildContextPlugin = (options: BuildContextPluginOptions): RsbuildPl } await currentRunPromise; + const resolvedOptions = await resolveOptions(api.context); + const capture = captureSnapshot({ + options: resolvedOptions, + environment, + isFirstCompile, + isWatch, + capturedStats, + time, + }); 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 now = resolvedOptions.now ?? (() => new Date()); const snapshot: ContextSnapshot = { schemaVersion: contextStoreSchemaVersion, snapshotId: `snap_${currentRun.runId}_${descriptor.contextId}_${sequence}`, @@ -329,17 +397,19 @@ const createBuildContextPlugin = (options: BuildContextPluginOptions): RsbuildPl sequence, observedAt: now().toISOString(), ...capture, - ...(options.inputs === undefined + ...(resolvedOptions.inputs === undefined ? {} : { source: { - inputs: options.inputs, + inputs: resolvedOptions.inputs, inputCompleteness: 'partial', }, }), }; - ensureContextWrite(await writeContextSnapshot(options.workspace.workspaceRoot, snapshot)); + ensureContextWrite( + await writeContextSnapshot(resolvedOptions.workspace.workspaceRoot, snapshot), + ); sequencesByContext.set(descriptor.contextId, sequence); })(); }; diff --git a/src/index.ts b/src/index.ts index aa6475e..6de22a5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -64,17 +64,14 @@ export { type DiagnosticRecord, type DiagnosticsQuery, type LintCaptureResult, - type LintCaptureAdapter, type LintFixPreviewResult, type LintSnapshotRequest, - type RslintFactory, } from './lint.ts'; export { captureTestSnapshot, + listRelatedTests, listTestResults, - type RelatedTestRequest, - type ResolveRelatedTests, - type TestCaptureDependencies, + type RelatedTestSelection, type TestCaptureResult, type TestResultPage, type TestResultsQuery, @@ -89,12 +86,4 @@ export { 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'; +export { createContextMcpServer, runContextMcpServer, type ContextMcpDependencies } from './mcp.ts'; diff --git a/src/lint.ts b/src/lint.ts index 3a451d9..36c4053 100644 --- a/src/lint.ts +++ b/src/lint.ts @@ -22,8 +22,6 @@ import { createExplicitContextDescriptor, createExplicitRun, resolveExplicitCaptureTarget, - resolveInternalConfigPath, - type ConfigTargetRunner, } from './source.ts'; import { readContextSnapshotById, @@ -67,11 +65,6 @@ type RslintEngine = { type RslintFactory = (options: RslintOptions) => RslintEngine; -type LintCaptureAdapter = { - wrapperConfigPath: string; - withConfigTarget: ConfigTargetRunner; -}; - type DiagnosticsQuery = { snapshotId?: string; producer?: 'rslint' | 'rstest'; @@ -241,15 +234,9 @@ 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, @@ -263,7 +250,7 @@ const captureLintSnapshot = async ( }); const options = { cwd: target.packageRoot, - overrideConfigFile: wrapperConfigPath, + ...(target.configPath === undefined ? {} : { overrideConfigFile: target.configPath }), fix: includeFixPreview, } satisfies RslintOptions; const runWrite = await writeContextRunManifest(workspaceRoot, run); @@ -274,21 +261,18 @@ const captureLintSnapshot = async ( } 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' + const engine = createRslint?.(options) ?? new (await import('@rslint/core')).Rslint(options); + try { + results = + request.mode === 'files' ? await engine.lintFiles(request.patterns ?? ['.']) : await engine.lintText(request.code, { filePath: request.filePath }); - } finally { - await engine.close(); - } - }); + } 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.', + 'Rslint reported no files. Verify that the native Rslint configuration and requested patterns select lint inputs.', ); } } catch (error) { @@ -609,7 +593,6 @@ export type { DiagnosticRecord, DiagnosticsQuery, LintCaptureResult, - LintCaptureAdapter, LintFacetDiagnostic, LintFixPreviewResult, LintSnapshotRequest, diff --git a/src/mcp.ts b/src/mcp.ts index e4c80fc..d167764 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -1,5 +1,7 @@ // cspell:ignore modelcontextprotocol +import { Writable } from 'node:stream'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import type { ContentBlock } from '@modelcontextprotocol/sdk/types.js'; import { z } from 'zod'; import { readCodeEvidence, type CodeEvidenceResult } from './codeEvidence.ts'; @@ -9,7 +11,6 @@ import { captureLintSnapshot, getLintFixPreview, listDiagnostics, - type LintCaptureAdapter, type RslintFactory, } from './lint.ts'; import { decodeCursor, encodeCursor } from './pagination.ts'; @@ -28,6 +29,7 @@ import { readProjectStatus } from './status.ts'; import { readContextSnapshots } from './store.ts'; import { captureTestSnapshot, + listRelatedTests, listTestResults, type TestCaptureDependencies, type TestSnapshotRequest, @@ -366,6 +368,18 @@ const testSnapshotInput = z }) .strict(); +const relatedTestsInput = z + .object({ + related: z + .array(z.string().min(1)) + .min(1) + .max(200) + .describe('Source paths resolved from packageRoot.'), + packageRoot: packageRootInput.optional(), + configPath: configPathInput.optional(), + }) + .strict(); + const toMcpError = (error: unknown) => ({ content: [ { @@ -542,7 +556,6 @@ type ContextMcpDependencies = { captureLintSnapshot?: typeof captureLintSnapshot; captureTestSnapshot?: typeof captureTestSnapshot; createRslint?: RslintFactory; - lintCaptureAdapter?: LintCaptureAdapter; testCaptureDependencies?: TestCaptureDependencies; serverVersion?: string; }; @@ -852,7 +865,33 @@ const createContextMcpServer = ( workspaceRoot, request, dependencies.createRslint, - dependencies.lintCaptureAdapter, + ); + return toStructuredMcpResult(result); + } catch (error) { + return toMcpError(error); + } + }, + ); + + server.registerTool( + 'related_tests', + { + title: 'List related tests', + description: + 'Resolve statically related Rstest files through the selected package native configuration without executing test cases or writing a Context snapshot.', + inputSchema: relatedTestsInput, + annotations: { + readOnlyHint: false, + destructiveHint: false, + openWorldHint: true, + }, + }, + async (input) => { + try { + const result = await listRelatedTests( + workspaceRoot, + input, + dependencies.testCaptureDependencies?.createRstest, ); return toStructuredMcpResult(result); } catch (error) { @@ -963,5 +1002,30 @@ const createContextMcpServer = ( return server; }; -export { createContextMcpServer }; +const reserveStandardOutputForProtocol = (): Writable => { + const standardOutput = process.stdout; + const writeProtocol = standardOutput.write.bind(standardOutput); + + Object.defineProperty(standardOutput, 'write', { + configurable: true, + value: process.stderr.write.bind(process.stderr), + writable: true, + }); + + return new Writable({ + decodeStrings: false, + write(chunk, encoding, callback) { + if (writeProtocol(String(chunk), encoding)) callback(); + else standardOutput.once('drain', callback); + }, + }); +}; + +const runContextMcpServer = async (workspaceRoot: string = process.cwd()): Promise => { + process.execArgv = []; + const server = createContextMcpServer(workspaceRoot); + await server.connect(new StdioServerTransport(process.stdin, reserveStandardOutputForProtocol())); +}; + +export { createContextMcpServer, runContextMcpServer }; export type { ContextMcpDependencies }; diff --git a/src/rsdoctor.ts b/src/rsdoctor.ts index e5d9b5b..37611eb 100644 --- a/src/rsdoctor.ts +++ b/src/rsdoctor.ts @@ -400,6 +400,47 @@ const resolveRsdoctorDataFile = async ( return toRelativeWorkspaceFile(workspaceRoot, artifact.path); }; +const omittedSectionResult = ( + toolName: RsdoctorToolName, + metadata: RsdoctorArtifactMetadata | undefined, +): JsonValue | undefined => { + if (metadata === undefined) return undefined; + const unavailableSection = requiredSectionsByTool[toolName].find( + (section) => metadata.sections[section].status === 'omitted', + ); + if (unavailableSection === undefined) return undefined; + const section = metadata.sections[unavailableSection]; + if (section.status !== 'omitted') return undefined; + return { + ok: false, + error: { + code: 'RSDOCTOR_SECTION_UNAVAILABLE', + message: `Rsdoctor artifact section "${unavailableSection}" is unavailable (${section.reason}).`, + reason: section.reason, + section: unavailableSection, + status: 'omitted', + }, + }; +}; + +const executeRsdoctorTool = async ( + executor: RsdoctorAdapter['executor'], + artifact: RsdoctorArtifact, + toolName: RsdoctorToolName, + input: Record, +): Promise => { + try { + return (await executor.execute({ + dataFile: artifact.path, + input, + toolName, + })) as JsonValue; + } catch (error) { + const cause = error instanceof Error ? ` Cause: ${error.message}` : ''; + throw new Error(`Rsdoctor analysis failed.${cause}`, { cause: error }); + } +}; + const analyzeRsdoctorArtifact = async ( workspaceRoot: string, request: RsdoctorAnalysisRequest, @@ -415,24 +456,14 @@ const analyzeRsdoctorArtifact = async ( 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; + const result = + omittedSectionResult(request.toolName, metadata) ?? + (await executeRsdoctorTool(executor, artifact, request.toolName, input)); return { dataFile: request.dataFile, ...(metadata === undefined ? {} : { artifactMetadata: metadata }), - result: result as JsonValue, + result, sectionEvidence: metadata === undefined ? [] diff --git a/src/rslint.ts b/src/rslint.ts index 784b003..722352b 100644 --- a/src/rslint.ts +++ b/src/rslint.ts @@ -5,9 +5,7 @@ export { 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 deleted file mode 100644 index 050d642..0000000 --- a/src/rstack.ts +++ /dev/null @@ -1,93 +0,0 @@ -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 index 935b66b..d360b70 100644 --- a/src/rstest.ts +++ b/src/rstest.ts @@ -11,10 +11,9 @@ export type { } from './model.ts'; export { captureTestSnapshot, + listRelatedTests, listTestResults, - type RelatedTestRequest, - type ResolveRelatedTests, - type TestCaptureDependencies, + type RelatedTestSelection, type TestCaptureResult, type TestResultPage, type TestResultsQuery, diff --git a/src/sharedConfig.ts b/src/sharedConfig.ts new file mode 100644 index 0000000..6456722 --- /dev/null +++ b/src/sharedConfig.ts @@ -0,0 +1,77 @@ +import type { ConfigParams, RsbuildConfig } from '@rsbuild/core'; +import { createBuildContextPlugin } from './build.ts'; +import { resolveContextCapture, type ContextConfig } from './config.ts'; + +type ContextProduct = 'application' | 'library'; + +type RstackContextConfigOptions = ContextConfig & { + products: ContextProduct | readonly ContextProduct[]; +}; + +type ContextRstackConfig = { + app?: (params: ConfigParams) => Promise<{ + plugins: NonNullable; + }>; + lib?: (params: ConfigParams) => Promise<{ + plugins: NonNullable; + }>; +}; + +type ContextConfigFactoryOptions = { + capture: 'metadata' | 'deep'; + variant?: string; +}; + +const createConfigFactory = ( + producer: 'rsbuild' | 'rslib', + product: ContextProduct, + options: ContextConfigFactoryOptions, +): NonNullable => { + return async (params) => ({ + plugins: [ + createBuildContextPlugin({ + producer, + product, + capture: options.capture, + params, + variant: options.variant, + }), + ], + }); +}; + +const createRstackContextConfig = (options: RstackContextConfigOptions): ContextRstackConfig => { + const capture = resolveContextCapture({ + enabled: options.enabled ?? true, + capture: options.capture, + variant: options.variant, + }); + if (capture === 'off') return {}; + + const products = new Set( + typeof options.products === 'string' ? [options.products] : options.products, + ); + if ( + products.size === 0 || + [...products].some((product) => product !== 'application' && product !== 'library') + ) { + throw new Error('Rstack Context products must contain "application", "library", or both.'); + } + + const factoryOptions: ContextConfigFactoryOptions = { + capture, + variant: options.variant, + }; + + return { + ...(products.has('application') + ? { app: createConfigFactory('rsbuild', 'application', factoryOptions) } + : {}), + ...(products.has('library') + ? { lib: createConfigFactory('rslib', 'library', factoryOptions) } + : {}), + }; +}; + +export { createRstackContextConfig }; +export type { ContextProduct, ContextRstackConfig, RstackContextConfigOptions }; diff --git a/src/source.ts b/src/source.ts index 49b7de0..967470f 100644 --- a/src/source.ts +++ b/src/source.ts @@ -1,5 +1,4 @@ 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'; @@ -53,23 +52,6 @@ type ConfigTargetRunner = ( 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( @@ -87,8 +69,11 @@ const readPackageName = async (packageRoot: string): Promise } }; -const findPackageConfig = async (packageRoot: string): Promise => { - for (const fileName of rstackConfigFileNames) { +const findPackageConfig = async ( + packageRoot: string, + configFileNames: readonly string[], +): Promise => { + for (const fileName of configFileNames) { const configPath = path.join(packageRoot, fileName); try { await access(configPath); @@ -103,6 +88,7 @@ const findPackageConfig = async (packageRoot: string): Promise => { const packageRoot = resolveContainedPath( workspaceRoot, @@ -111,7 +97,7 @@ const resolveExplicitCaptureTarget = async ( ); const configPath = request.configPath === undefined - ? await findPackageConfig(packageRoot) + ? await findPackageConfig(packageRoot, configFileNames) : resolveContainedPath(workspaceRoot, 'configPath', request.configPath); const packageName = await readPackageName(packageRoot); @@ -222,7 +208,6 @@ export { createExplicitRun, recordContextInputFiles, resolveExplicitCaptureTarget, - resolveInternalConfigPath, }; export type { ConfigTargetRunner, diff --git a/src/testRun.ts b/src/testRun.ts index 8222605..03c6598 100644 --- a/src/testRun.ts +++ b/src/testRun.ts @@ -1,6 +1,14 @@ import { randomUUID } from 'node:crypto'; import path from 'node:path'; -import type { RunRstestOptions, TestRunResult } from '@rstest/core/api'; +import { + createRstest, + type CreateRstestOptions, + type ListedTest, + type RstestInstance, + type RunOptions, + type TestRunResult as NativeTestRunResult, +} from '@rstest/core/api'; +import type { loadConfig as loadRstestConfig } from '@rstest/core'; import { contextStoreSchemaVersion, type ContextFreshness, @@ -28,8 +36,6 @@ import { createExplicitContextDescriptor, createExplicitRun, resolveExplicitCaptureTarget, - resolveInternalConfigPath, - type ConfigTargetRunner, } from './source.ts'; import { readContextSnapshotById, @@ -85,34 +91,57 @@ type TestCaptureResult = { unreadableInputs?: string[]; }; +type RelatedTestSelection = { + producer: 'rstest'; + packageRoot: string; + sources: string[]; + testFiles: string[]; + total: number; +}; + type TestCaptureError = TestErrorRecord & { scope: 'file' | 'run'; project?: string; path?: string; }; -type RunRstest = (options?: RunRstestOptions) => Promise; +type CapturedTestError = NativeTestRunResult['unhandledErrors'][number] & { + cause?: CapturedTestError; +}; -type RelatedTestRequest = { - packageRoot: string; - configPath?: string; - sources: string[]; +type CapturedTestCase = Omit< + NativeTestRunResult['testResults'][number], + 'errors' | 'retryErrors' | 'testId' +> & { + testId?: string; + errors?: CapturedTestError[]; + retryErrors?: CapturedTestError[]; +}; + +type CapturedTestFile = CapturedTestCase & { + results: CapturedTestCase[]; }; -type ResolveRelatedTests = (request: RelatedTestRequest) => Promise; +type CapturedTestRunResult = { + ok: boolean; + files: CapturedTestFile[]; + stats: NativeTestRunResult['summary']; + unhandledErrors: CapturedTestError[]; + duration: { total: number }; + snapshot?: NativeTestRunResult['snapshotSummary']; + coverage?: NativeTestRunResult['coverage']; +}; + +type RstestRuntime = Pick; +type CreateRstestRuntime = (options?: CreateRstestOptions) => Promise; +type LoadRstestConfig = typeof loadRstestConfig; type TestCaptureDependencies = { - runRstest?: RunRstest; + createRstest?: CreateRstestRuntime; + loadConfig?: LoadRstestConfig; 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; }; @@ -126,11 +155,8 @@ const optionalString = ( // 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, +const normalizeError = (error: CapturedTestError, depth = 0): TestErrorRecord => ({ + name: error.name ?? 'Error', message: error.message, ...optionalString('stack', error.stack), ...optionalString('diff', error.diff), @@ -142,12 +168,9 @@ const normalizeError = ( : { cause: normalizeError(error.cause, depth + 1) }), }); -const normalizeErrors = ( - errors: ReadonlyArray, -): TestErrorRecord[] => errors.map((error) => normalizeError(error)); +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 => { @@ -187,7 +210,7 @@ const compareTestCases = (left: TestCaseRecord, right: TestCaseRecord): number = const normalizeTestCase = ( workspaceRoot: string, - result: TestRunResult['files'][number]['results'][number], + result: CapturedTestRunResult['files'][number]['results'][number], ): TestCaseRecord => ({ project: result.project, path: toWorkspacePath(workspaceRoot, result.testPath), @@ -203,7 +226,7 @@ const normalizeTestCase = ( const normalizeTestFile = ( workspaceRoot: string, - result: TestRunResult['files'][number], + result: CapturedTestRunResult['files'][number], ): TestFileRecord => ({ project: result.project, path: toWorkspacePath(workspaceRoot, result.testPath), @@ -217,7 +240,7 @@ const normalizeTestFile = ( const normalizeTestFacet = ( workspaceRoot: string, - result: TestRunResult, + result: CapturedTestRunResult, relation?: TestFacet['relation'], ): TestFacet => ({ producer: 'rstest', @@ -246,7 +269,7 @@ const testCaptureSelection = (request: TestSnapshotRequest): JsonValue => ({ ...(request.testNamePattern === undefined ? {} : { testNamePattern: request.testNamePattern }), }); -const getRunStatus = (result: TestRunResult): ContextRunStatus => { +const getRunStatus = (result: CapturedTestRunResult): ContextRunStatus => { if (result.unhandledErrors.length > 0) return 'error'; return result.ok ? 'pass' : 'fail'; }; @@ -255,16 +278,102 @@ const ensureWritten = (result: Awaited>) if (!result.written) throw result.error; }; -const loadRunRstest = async (): Promise => (await import('@rstest/core/api')).runRstest; +const normalizeNativeTestRun = (result: NativeTestRunResult): CapturedTestRunResult => ({ + ok: result.status === 'pass', + files: result.results, + stats: result.summary, + unhandledErrors: result.unhandledErrors, + duration: { total: result.duration.totalTime }, + snapshot: result.snapshotSummary, + ...(result.coverage === undefined ? {} : { coverage: result.coverage }), +}); + +const createRstestNotConfiguredError = (workspaceRoot: string, packageRoot: string): Error => { + const relativePackageRoot = toWorkspacePath(workspaceRoot, packageRoot) || '.'; + return new Error( + `Rstest is not configured for package root "${relativePackageRoot}". Add a native rstest.config.* file supported by Rstest, or pass configPath.`, + ); +}; + +const loadNativeRstestConfig = async ( + workspaceRoot: string, + packageRoot: string, + configPath: string | undefined, + loadConfig?: LoadRstestConfig, +): Promise>> => { + const loader = + loadConfig ?? + (await (async () => { + const moduleName = '@rstest/core'; + return (await import(moduleName)).loadConfig; + })()); + const config = await loader({ + cwd: packageRoot, + ...(configPath === undefined ? {} : { path: configPath }), + }); + if (config.filePath === null) { + throw createRstestNotConfiguredError(workspaceRoot, packageRoot); + } + return config; +}; + +const createNativeRstest = async ( + workspaceRoot: string, + packageRoot: string, + configPath: string | undefined, + factory: CreateRstestRuntime = createRstest, + loadConfig?: LoadRstestConfig, +): Promise<{ configPath: string; runtime: RstestRuntime }> => { + const config = await loadNativeRstestConfig(workspaceRoot, packageRoot, configPath, loadConfig); + return { + configPath: config.filePath!, + runtime: await factory({ cwd: packageRoot, config }), + }; +}; + +const relatedTestFiles = (tests: ListedTest[]): string[] => [ + ...new Set(tests.map(({ testPath }) => testPath)), +]; + +const listRelatedTests = async ( + workspaceRoot: string, + request: Pick & { + related: string[]; + }, + factory?: CreateRstestRuntime, +): Promise => { + validateRelatedSelection(request); + const target = await resolveExplicitCaptureTarget(workspaceRoot, request); + const { runtime } = await createNativeRstest( + workspaceRoot, + target.packageRoot, + target.configPath, + factory, + ); + const sources = [ + ...new Set(request.related.map((source) => path.resolve(target.packageRoot, source))), + ]; + const testFiles = relatedTestFiles( + await runtime.listTests({ + filesOnly: true, + filters: sources, + related: true, + }), + ); + const packageRoot = toWorkspacePath(workspaceRoot, target.packageRoot) || '.'; + return { + producer: 'rstest', + packageRoot, + sources: sources.map((source) => toWorkspacePath(workspaceRoot, source)).sort(), + testFiles: testFiles.map((file) => toWorkspacePath(workspaceRoot, file)).sort(), + total: testFiles.length, + }; +}; 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(); @@ -289,19 +398,7 @@ const validateRelatedSelection = (request: TestSnapshotRequest): void => { } }; -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 => ({ +const emptyTestRunResult = (): CapturedTestRunResult => ({ ok: true, files: [], stats: { @@ -319,47 +416,23 @@ const captureTestSnapshot = async ( ): 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 { configPath, runtime } = await createNativeRstest( + workspaceRoot, + target.packageRoot, + target.configPath, + dependencies.createRstest, + dependencies.loadConfig, + ); 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, + configPath, }); const captureSelection = testCaptureSelection(request); const now = dependencies.now ?? (() => new Date()); @@ -371,24 +444,21 @@ const captureTestSnapshot = async ( now, }); ensureWritten(await writeContextRunManifest(workspaceRoot, run)); - let result: TestRunResult | undefined; + let result: CapturedTestRunResult | undefined; let relation: TestFacet['relation']; try { let selectedFiles = request.files; - if (request.related !== undefined && dependencies.resolveRelatedTests !== undefined) { - const resolveRelatedTests = dependencies.resolveRelatedTests; + if (request.related !== undefined) { 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, - }), - ), - ]; + const testFiles = relatedTestFiles( + await runtime.listTests({ + filesOnly: true, + filters: sourceFiles, + related: true, + }), + ); relation = { sources: sourceFiles.map((source) => toWorkspacePath(workspaceRoot, source)).sort(), testFiles: testFiles.map((file) => toWorkspacePath(workspaceRoot, file)).sort(), @@ -398,20 +468,33 @@ const captureTestSnapshot = async ( } 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 + const runOptions: RunOptions = { + ...(request.related === undefined + ? selectedFiles === undefined ? {} - : { testNamePattern: request.testNamePattern }), - }), - ); + : { filters: selectedFiles } + : { + filters: request.related.map((source) => path.resolve(target.packageRoot, source)), + related: true, + }), + ...(request.testNamePattern === undefined + ? {} + : { testNamePattern: request.testNamePattern }), + ...(execution === undefined || !captureExecution + ? {} + : { + coverage: { + enabled: true, + provider: 'istanbul', + reporters: [], + reportOnFailure: true, + ...(execution.include === undefined ? {} : { include: execution.include }), + ...(execution.exclude === undefined ? {} : { exclude: execution.exclude }), + allowExternal: execution.allowExternal ?? false, + }, + }), + }; + result = normalizeNativeTestRun(await runtime.run(runOptions)); } } catch (error) { const capturedError: TestErrorRecord = @@ -643,13 +726,14 @@ const listTestResults = async ( }; }; -export { captureTestSnapshot, listTestResults }; +export { captureTestSnapshot, listRelatedTests, listTestResults }; export type { + CapturedTestRunResult, + CreateRstestRuntime, + RelatedTestSelection, TestCaptureDependencies, TestCaptureError, TestCaptureResult, - RelatedTestRequest, - ResolveRelatedTests, TestResultPage, TestResultsQuery, TestSnapshotRequest, diff --git a/tests/build.test.ts b/tests/build.test.ts index c80918f..f3f8dd2 100644 --- a/tests/build.test.ts +++ b/tests/build.test.ts @@ -1,5 +1,5 @@ /* rslint-disable @typescript-eslint/no-unsafe-assignment -- Rstest asymmetric matchers are intentionally untyped. */ -import { mkdir, writeFile } from 'node:fs/promises'; +import { mkdir, realpath, writeFile } from 'node:fs/promises'; import path from 'node:path'; import type { ConfigParams, RsbuildConfig } from '@rsbuild/core'; import { expect, test } from '@rstest/core'; @@ -40,11 +40,17 @@ type ObserverHarness = { const getObserverHarness = ( plugin: ReturnType, + context?: { + configFile?: string; + configFileDependencies: readonly string[]; + rootPath: string; + }, ): ObserverHarness => { const hooks: ObserverHooks = {}; const warnings: string[] = []; void plugin.setup?.({ + ...(context === undefined ? {} : { context }), logger: { warn: (message: string) => { warnings.push(message); @@ -703,3 +709,68 @@ test('records explicit build variants, normalized output paths, and partial conf }); }); }); + +test('resolves workspace and config provenance from the native build context', async () => { + await withTempWorkspace('rstack-build-context-', async (workspaceRoot) => { + const canonicalWorkspaceRoot = await realpath(workspaceRoot); + const packageRoot = path.join(canonicalWorkspaceRoot, 'packages', 'app'); + const configPath = path.join(packageRoot, 'rstack.config.ts'); + const dependencyPath = path.join(packageRoot, 'config', 'shared.ts'); + await mkdir(path.dirname(dependencyPath), { recursive: true }); + await writeFile( + path.join(canonicalWorkspaceRoot, 'pnpm-workspace.yaml'), + 'packages:\n - packages/*\n', + ); + await writeFile(path.join(packageRoot, 'package.json'), JSON.stringify({ name: '@repo/app' })); + await writeFile(configPath, 'export {}\n'); + await writeFile(dependencyPath, 'export const shared = true;\n'); + + const plugin = createBuildContextPlugin({ + producer: 'rsbuild', + product: 'application', + capture: 'metadata', + params: { command: 'build', env: 'production' }, + createRunId: () => 'run_native_provenance', + now: () => new Date('2026-08-12T09:00:00.000Z'), + }); + const { hooks } = getObserverHarness(plugin, { + rootPath: packageRoot, + configFile: configPath, + configFileDependencies: [dependencyPath, configPath, dependencyPath], + }); + const environment = { + ...createEnvironment('web', 'web'), + distPath: path.join(packageRoot, 'dist'), + }; + + 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({ + packageRoot: 'packages/app', + packageName: '@repo/app', + configPath: 'packages/app/rstack.config.ts', + distPath: 'packages/app/dist', + }); + expect(status.runs[0].contexts[0].latestSnapshot!.source).toMatchObject({ + inputCompleteness: 'partial', + inputs: [ + { + path: 'packages/app/config/shared.ts', + digest: expect.stringMatching(/^[0-9a-f]{64}$/u), + }, + { + path: 'packages/app/rstack.config.ts', + digest: expect.stringMatching(/^[0-9a-f]{64}$/u), + }, + ], + }); + }); +}); diff --git a/tests/lint.test.ts b/tests/lint.test.ts index b54eade..f97c869 100644 --- a/tests/lint.test.ts +++ b/tests/lint.test.ts @@ -5,12 +5,7 @@ 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 { captureLintSnapshot, getLintFixPreview, listDiagnostics } from '../src/lint.ts'; import { contextStoreSchemaVersion, type ContextRunManifest } from '../src/model.ts'; import { readProjectStatus } from '../src/status.ts'; import { @@ -54,18 +49,7 @@ const createRslint = (options: RslintOptions) => { }; }; -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; @@ -166,12 +150,7 @@ test('captures an open-ended file snapshot with partial inputs and fail status', }, ]; - const result = await captureLintSnapshot( - workspaceRoot, - { mode: 'files' }, - createRslint, - adapter, - ); + const result = await captureLintSnapshot(workspaceRoot, { mode: 'files' }, createRslint); const stored = await readContextSnapshotById(workspaceRoot, result.snapshotId); const status = await readProjectStatus(workspaceRoot); @@ -179,7 +158,6 @@ test('captures an open-ended file snapshot with partial inputs and fail status', { cwd: workspaceRoot, fix: false, - overrideConfigFile: expect.stringMatching(/[\\/]rslintConfig\.js$/u), }, ]); expect(mocks.lintFilesCalls).toEqual([['.']]); @@ -253,7 +231,6 @@ test('captures text without persisting the input and exposes only stored fix out includeFixPreview: true, }, createRslint, - adapter, ); const stored = await readContextSnapshotById(workspaceRoot, result.snapshotId); @@ -313,7 +290,6 @@ test('paginates and filters diagnostics from one frozen snapshot deterministical patterns: ['src/**/*.ts'], }, createRslint, - adapter, ); const first = await listDiagnostics(workspaceRoot, { @@ -464,12 +440,7 @@ test('selects the newest diagnostic snapshot rather than an unrelated newer buil messages: [{ ruleId: 'a', severity: 2, message: 'error', line: 1, column: 1 }], }, ]; - const lintCapture = await captureLintSnapshot( - workspaceRoot, - { mode: 'files' }, - createRslint, - adapter, - ); + const lintCapture = await captureLintSnapshot(workspaceRoot, { mode: 'files' }, createRslint); const context = { contextId: 'ctx_build', packageRoot: '.', product: 'application' } as const; const run = { schemaVersion: contextStoreSchemaVersion, @@ -517,7 +488,6 @@ test('rejects a malformed diagnostics cursor', async () => { workspaceRoot, { mode: 'files', patterns: ['a.ts'] }, createRslint, - adapter, ); await expect( @@ -544,7 +514,6 @@ test('returns snapshot provenance for an empty diagnostics page', async () => { workspaceRoot, { mode: 'files', patterns: ['a.ts'] }, createRslint, - adapter, ); const stored = await readContextSnapshotById(workspaceRoot, capture.snapshotId); @@ -585,7 +554,6 @@ test('reports preview availability without rerunning or applying Rslint', async mode: 'files', }, createRslint, - adapter, ); await expect(getLintFixPreview(workspaceRoot, notCaptured.snapshotId, 'a.ts')).resolves.toEqual( { @@ -603,7 +571,6 @@ test('reports preview availability without rerunning or applying Rslint', async includeFixPreview: true, }, createRslint, - adapter, ); await expect(getLintFixPreview(workspaceRoot, noChange.snapshotId, 'a.ts')).resolves.toEqual({ available: false, @@ -629,7 +596,7 @@ test('separates an unknown snapshot id from a snapshot without a lint facet', as messages: [], }, ]; - await captureLintSnapshot(workspaceRoot, { mode: 'files' }, createRslint, adapter); + await captureLintSnapshot(workspaceRoot, { mode: 'files' }, createRslint); await expect(getLintFixPreview(workspaceRoot, 'snap_missing', 'a.ts')).rejects.toThrow( 'Unknown snapshot: snap_missing', @@ -691,7 +658,6 @@ test('records unreadable lint inputs as degraded completeness instead of throwin workspaceRoot, { mode: 'files', patterns: ['a.ts', 'gone.ts'] }, createRslint, - adapter, ); expect(capture.unreadableInputs).toEqual(['gone.ts']); @@ -714,7 +680,7 @@ test('records zero reported lint files as an actionable partial error instead of await writeFile(path.join(workspaceRoot, 'a.ts'), 'const a = 1;\n'); await expect( - captureLintSnapshot(workspaceRoot, { mode: 'files' }, createRslint, adapter), + captureLintSnapshot(workspaceRoot, { mode: 'files' }, createRslint), ).rejects.toThrow('Rslint reported no files'); const [stored] = await readContextSnapshots(workspaceRoot, { producer: 'rslint' }); @@ -731,7 +697,7 @@ test('records zero reported lint files as an actionable partial error instead of messages: [ { severity: 2, - message: expect.stringContaining('define.lint'), + message: expect.stringContaining('native Rslint configuration'), }, ], }, @@ -744,26 +710,78 @@ test('records zero reported lint files as an actionable partial error instead of }); }); -test('reports a missing lint config adapter before writing any run manifest', async () => { +test('runs standalone with native Rslint config discovery', async () => { await withTempWorkspace('rstack-context-lint-', async (workspaceRoot) => { - await expect(captureLintSnapshot(workspaceRoot, { mode: 'files' })).rejects.toThrow( - 'Rstack lint capture requires a config adapter.', + const filePath = path.join(workspaceRoot, 'index.ts'); + await writeFile(filePath, 'debugger;\n'); + await writeFile( + path.join(workspaceRoot, 'rslint.config.mjs'), + "export default [{ files: ['**/*.ts'], rules: { 'no-debugger': 'error' } }];\n", ); - await expect(readProjectStatus(workspaceRoot)).resolves.toMatchObject({ contexts: [] }); - await expect(readContextSnapshots(workspaceRoot, { producer: 'rslint' })).resolves.toEqual([]); + const result = await captureLintSnapshot(workspaceRoot, { + mode: 'files', + patterns: ['index.ts'], + }); + + expect(result).toMatchObject({ + status: 'fail', + summary: { files: 1, errors: 1 }, + }); + const stored = await readContextSnapshotById(workspaceRoot, result.snapshotId); + expect(stored?.snapshot.facets.lint).toMatchObject({ + files: [ + { + path: 'index.ts', + messages: [{ ruleId: 'no-debugger', severity: 2 }], + }, + ], + }); }); }); -test('rejects lint capture targets that escape the checkout', async () => { +test('passes an explicit native Rslint config path to the injected factory', async () => { await withTempWorkspace('rstack-context-lint-', async (workspaceRoot) => { + const filePath = path.join(workspaceRoot, 'index.ts'); + const configPath = path.join(workspaceRoot, 'config', 'custom-rslint.mjs'); + await mkdir(path.dirname(configPath), { recursive: true }); + await writeFile(filePath, 'export {};\n'); + await writeFile(configPath, 'export default [];\n'); + mocks.results = [ + { + filePath, + errorCount: 0, + warningCount: 0, + fixableErrorCount: 0, + fixableWarningCount: 0, + messages: [], + }, + ]; + await expect( captureLintSnapshot( workspaceRoot, - { mode: 'files', packageRoot: '../../..' }, + { + mode: 'files', + configPath: 'config/custom-rslint.mjs', + }, createRslint, - adapter, ), + ).resolves.toMatchObject({ status: 'pass' }); + expect(mocks.options).toEqual([ + { + cwd: workspaceRoot, + fix: false, + overrideConfigFile: configPath, + }, + ]); + }); +}); + +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), ).rejects.toThrow( 'packageRoot must be a non-empty checkout-relative path that stays inside the checkout.', ); @@ -772,7 +790,6 @@ test('rejects lint capture targets that escape the checkout', async () => { 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.', @@ -787,9 +804,9 @@ test('persists a partial diagnostic snapshot and closes the engine when linting const lintError = new Error('lint failed'); mocks.lintError = lintError; - await expect( - captureLintSnapshot(workspaceRoot, { mode: 'files' }, createRslint, adapter), - ).rejects.toBe(lintError); + await expect(captureLintSnapshot(workspaceRoot, { mode: 'files' }, createRslint)).rejects.toBe( + lintError, + ); expect(mocks.options).toHaveLength(1); expect(mocks.closeCalls).toBe(1); @@ -839,14 +856,9 @@ test('persists a partial diagnostic snapshot when creating the lint engine throw const factoryError = new Error('Rslint configuration failed'); await expect( - captureLintSnapshot( - workspaceRoot, - { mode: 'files' }, - () => { - throw factoryError; - }, - adapter, - ), + captureLintSnapshot(workspaceRoot, { mode: 'files' }, () => { + throw factoryError; + }), ).rejects.toBe(factoryError); const [stored] = await readContextSnapshots(workspaceRoot, { diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index e32ab7a..9715b7c 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -2,8 +2,10 @@ 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 { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; import { expect, test } from '@rstest/core'; +import type { TestRunResult } from '@rstest/core/api'; import pkgJson from '../package.json' with { type: 'json' }; import { createContextMcpServer, type ContextMcpDependencies } from '../src/mcp.ts'; import { writeContextRunManifest, writeContextSnapshot } from '../src/store.ts'; @@ -21,6 +23,7 @@ const toolNames = [ 'snapshot_diff', 'lint_fix_preview', 'lint_snapshot', + 'related_tests', 'test_snapshot', 'rsdoctor_analyze', 'report_link', @@ -49,6 +52,59 @@ const withClient = async ( } }; +const createNativeTestResult = (testPath: string): TestRunResult => ({ + status: 'pass', + results: [ + { + testId: `file:${testPath}`, + project: 'default', + testPath, + name: path.basename(testPath), + status: 'pass', + results: [ + { + testId: 'case:adds', + project: 'default', + testPath, + name: 'adds', + status: 'pass', + }, + ], + }, + ], + testResults: [ + { + testId: 'case:adds', + project: 'default', + testPath, + name: 'adds', + status: 'pass', + }, + ], + summary: { + tests: { total: 1, passed: 1, failed: 0, skipped: 0, todo: 0 }, + files: { total: 1, failed: 0 }, + }, + duration: { totalTime: 1, buildTime: 0, testTime: 1 }, + snapshotSummary: { + added: 0, + failure: false, + filesAdded: 0, + filesRemoved: 0, + filesRemovedList: [], + filesUnmatched: 0, + filesUpdated: 0, + matched: 0, + total: 0, + unchecked: 0, + uncheckedKeysByFile: [], + unmatched: 0, + updated: 0, + didUpdate: false, + }, + unhandledErrors: [], +}); + test('publishes the complete standalone MCP catalog', async () => { await withClient(async (client) => { const { tools } = await client.listTools(); @@ -71,6 +127,149 @@ test('publishes the complete standalone MCP catalog', async () => { }); }); +test('serves the Context catalog through the programmatic stdio entry point', async () => { + const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), 'rstack-context-cli-')); + const client = new Client({ name: 'context-cli-test-client', version: '1.0.0' }); + const mcpEntry = new URL('../dist/mcp.js', import.meta.url).href; + await writeFile( + path.join(workspaceRoot, 'rstest.config.js'), + `export default { globals: true, include: ['**/*.test.js'] };\n`, + ); + await writeFile(path.join(workspaceRoot, 'math.js'), 'export const add = (a, b) => a + b;\n'); + await writeFile( + path.join(workspaceRoot, 'math.test.js'), + `import { add } from './math.js';\ntest('adds', () => expect(add(1, 2)).toBe(3));\n`, + ); + const transport = new StdioClientTransport({ + command: process.execPath, + args: [ + '--input-type=module', + '--eval', + `import(${JSON.stringify(mcpEntry)}).then(({ runContextMcpServer }) => runContextMcpServer())`, + ], + cwd: workspaceRoot, + stderr: 'pipe', + }); + + try { + await client.connect(transport); + const { tools } = await client.listTools(); + expect(tools.map(({ name }) => name)).toEqual(toolNames); + await expect( + client.callTool({ + name: 'related_tests', + arguments: { related: ['math.js'] }, + }), + ).resolves.toMatchObject({ + structuredContent: { + sources: ['math.js'], + testFiles: ['math.test.js'], + total: 1, + }, + }); + await expect( + client.callTool({ + name: 'test_snapshot', + arguments: { related: ['math.js'] }, + }), + ).resolves.toMatchObject({ + structuredContent: { + status: 'pass', + summary: { files: 1, tests: 1 }, + }, + }); + } finally { + await client.close(); + await rm(workspaceRoot, { force: true, recursive: true }); + } +}); + +test('lists native related tests without executing cases or writing a snapshot', async () => { + const calls: unknown[] = []; + await withClient( + async (client, workspaceRoot) => { + await writeFile(path.join(workspaceRoot, 'source.ts'), 'export const value = 1;\n'); + await writeFile(path.join(workspaceRoot, 'rstest.config.js'), 'export default {};\n'); + + const result = await client.callTool({ + name: 'related_tests', + arguments: { related: ['source.ts'] }, + }); + + expect(result.isError, JSON.stringify(result)).not.toBe(true); + expect(result.structuredContent).toEqual({ + producer: 'rstest', + packageRoot: '.', + sources: ['source.ts'], + testFiles: ['source.test.ts'], + total: 1, + }); + expect(calls).toEqual([ + { + filesOnly: true, + filters: [path.join(workspaceRoot, 'source.ts')], + related: true, + }, + ]); + await expect( + client.callTool({ name: 'snapshot_list', arguments: { producer: 'rstest' } }), + ).resolves.toMatchObject({ structuredContent: { items: [] } }); + }, + { + testCaptureDependencies: { + createRstest: (instanceOptions) => + Promise.resolve({ + listTests: (listOptions) => { + calls.push(listOptions); + return Promise.resolve([ + { + project: 'default', + testPath: path.join(instanceOptions?.cwd ?? '.', 'source.test.ts'), + type: 'file', + }, + ]); + }, + run: () => { + throw new Error('must not run'); + }, + }), + }, + }, + ); +}); + +test('rejects native test tools when the selected package has no Rstest config', async () => { + let createCalls = 0; + await withClient( + async (client) => { + const expected = + 'Rstest is not configured for package root ".". Add a native rstest.config.* file supported by Rstest, or pass configPath.'; + + for (const request of [ + { name: 'related_tests', arguments: { related: ['src/index.ts'] } }, + { name: 'test_snapshot', arguments: {} }, + ]) { + const result = await client.callTool(request); + expect(result.isError).toBe(true); + expect(result.content).toEqual([{ type: 'text', text: expected }]); + } + + expect(createCalls).toBe(0); + await expect( + client.callTool({ name: 'project_status', arguments: {} }), + ).resolves.toMatchObject({ structuredContent: { contexts: [] } }); + }, + { + testCaptureDependencies: { + createRstest: async () => { + createCalls += 1; + throw new Error('must not create Rstest'); + }, + }, + }, + ); +}); + test('reports an empty checkout without requiring optional producers', async () => { await withClient(async (client) => { const result = await client.callTool({ name: 'project_status', arguments: {} }); @@ -252,36 +451,27 @@ test('captures a real test snapshot through injected capture dependencies', asyn }, { testCaptureDependencies: { - wrapperConfigPath: path.join(path.sep, 'wrapper', 'rstestConfig.js'), + loadConfig: ({ cwd = process.cwd(), path: configPath } = {}) => + Promise.resolve({ + content: {}, + filePath: configPath ?? path.join(cwd, 'rstest.config.ts'), + dependencies: [], + }), 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 }, - }); - }, + createRstest: ({ cwd = process.cwd() } = {}) => + Promise.resolve({ + listTests: () => Promise.resolve([]), + run: (options) => + Promise.resolve( + createNativeTestResult( + path.join(cwd, options?.filters?.[0] ?? 'tests/math.test.ts'), + ), + ), + }), }, }, ); @@ -311,10 +501,6 @@ test('captures a real lint snapshot through an injected capture adapter', async }); }, { - lintCaptureAdapter: { - wrapperConfigPath: path.join(path.sep, 'wrapper', 'rslintConfig.js'), - withConfigTarget: (_configRoot, _configPath, action) => action(), - }, createRslint: (options) => ({ lintFiles: () => Promise.resolve([ @@ -369,16 +555,6 @@ test('rejects capture targets that escape the checkout', async () => { ).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'); }, diff --git a/tests/packageExports.test.ts b/tests/packageExports.test.ts index 9be872c..3899865 100644 --- a/tests/packageExports.test.ts +++ b/tests/packageExports.test.ts @@ -26,15 +26,14 @@ test('publishes focused Context entry points without a Rstack dependency', async './rsdoctor', './rslib', './rslint', - './rstack', './rstest', + './shared-config', ]); - // 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. + // Consumers own this optional runtime rather than receiving it as a + // URL-resolved transitive dependency. 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'); + expect(packageJson.devDependencies?.['@rsdoctor/agent-cli']).toBe('0.1.2'); + expect(packageJson.peerDependencies?.['@rsdoctor/agent-cli']).toBe('>=0.1.2'); for (const section of [ packageJson.dependencies, packageJson.devDependencies, @@ -45,7 +44,7 @@ test('publishes focused Context entry points without a Rstack dependency', async }); test('loads each focused Context entry point independently', () => { - const entryPoints = ['rsbuild', 'rslib', 'rstest', 'rslint', 'rsdoctor', 'mcp', 'rstack']; + const entryPoints = ['rsbuild', 'rslib', 'rstest', 'rslint', 'rsdoctor', 'mcp', 'shared-config']; 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, diff --git a/tests/rstack.test.ts b/tests/rstack.test.ts deleted file mode 100644 index ae550fd..0000000 --- a/tests/rstack.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -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/sharedConfig.test.ts b/tests/sharedConfig.test.ts new file mode 100644 index 0000000..c497cad --- /dev/null +++ b/tests/sharedConfig.test.ts @@ -0,0 +1,81 @@ +import { writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import type { ConfigParams } from '@rsbuild/core'; +import { expect, test } from '@rstest/core'; +import { createRstackContextConfig } from '../src/sharedConfig.ts'; +import { withTempWorkspace as withSharedTempWorkspace } from './helpers.ts'; + +const withTempWorkspace = async ( + callback: (workspaceRoot: string) => Promise, +): Promise => + withSharedTempWorkspace('rstack-context-config-', async (workspaceRoot) => { + await writeFile( + path.join(workspaceRoot, 'package.json'), + JSON.stringify({ name: 'context-fixture', private: true }), + ); + await callback(workspaceRoot); + }); + +test('returns no shared config layers when Context capture is off', () => { + expect( + createRstackContextConfig({ + capture: 'off', + products: ['application', 'library'], + }), + ).toEqual({}); +}); + +test('requires the consumer to select at least one product', () => { + expect(() => + createRstackContextConfig({ + products: [], + }), + ).toThrow('Rstack Context products must contain'); +}); + +test('rejects unsupported products at the JavaScript boundary', () => { + expect(() => + createRstackContextConfig({ + products: ['application', 'website'] as never, + }), + ).toThrow('Rstack Context products must contain'); +}); + +test('defines only the selected product to preserve Rstest inheritance', () => { + expect( + Object.keys( + createRstackContextConfig({ + products: 'application', + }), + ), + ).toEqual(['app']); + expect( + Object.keys( + createRstackContextConfig({ + products: 'library', + }), + ), + ).toEqual(['lib']); +}); + +test('creates independent application and library shared config layers', async () => { + await withTempWorkspace(async () => { + const sharedConfig = createRstackContextConfig({ + products: ['application', 'library'], + variant: 'fixture', + }); + const params = { + command: 'build', + env: 'production', + envMode: 'production', + } as ConfigParams; + + const application = await sharedConfig.app!(params); + const library = await sharedConfig.lib!(params); + + expect(application.plugins).toEqual([ + expect.objectContaining({ name: 'rstack:context-build' }), + ]); + expect(library.plugins).toEqual([expect.objectContaining({ name: 'rstack:context-build' })]); + }); +}); diff --git a/tests/source.test.ts b/tests/source.test.ts index 5299db4..1835fcf 100644 --- a/tests/source.test.ts +++ b/tests/source.test.ts @@ -11,7 +11,6 @@ import { createExplicitRun, recordContextInputFiles, resolveExplicitCaptureTarget, - resolveInternalConfigPath, } from '../src/source.ts'; import { withTempWorkspace } from './helpers.ts'; @@ -114,15 +113,6 @@ test('rejects capture targets that escape the checkout', async () => { }); }); -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')); diff --git a/tests/testRun.test.ts b/tests/testRun.test.ts index 54951ca..44777cb 100644 --- a/tests/testRun.test.ts +++ b/tests/testRun.test.ts @@ -1,23 +1,23 @@ /* 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 type { ListedTest, ListOptions, RunOptions, TestRunResult } from '@rstest/core/api'; 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, + listRelatedTests, listTestResults, + type CapturedTestRunResult, 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 => ({ +const createResult = (overrides: Partial = {}): CapturedTestRunResult => ({ ok: true, files: [], stats: { @@ -29,17 +29,56 @@ const createResult = (overrides: Partial = {}): TestRunResult => ...overrides, }); +const toNativeResult = (result: CapturedTestRunResult): TestRunResult => ({ + status: result.unhandledErrors.length > 0 ? 'error' : result.ok ? 'pass' : 'fail', + results: result.files as TestRunResult['results'], + testResults: result.files.flatMap((file) => file.results) as TestRunResult['testResults'], + summary: result.stats, + duration: { totalTime: result.duration.total, buildTime: 0, testTime: result.duration.total }, + snapshotSummary: + result.snapshot ?? + ({ + added: 0, + failure: false, + filesAdded: 0, + filesRemoved: 0, + filesRemovedList: [], + filesUnmatched: 0, + filesUpdated: 0, + matched: 0, + total: 0, + unchecked: 0, + uncheckedKeysByFile: [], + unmatched: 0, + updated: 0, + didUpdate: false, + } satisfies TestRunResult['snapshotSummary']), + unhandledErrors: result.unhandledErrors, + ...(result.coverage === undefined ? {} : { coverage: result.coverage }), +}); + const createDependencies = ( - result: TestRunResult, + result: CapturedTestRunResult, calls: unknown[], suffix: string, + listTests: (options?: ListOptions & RunOptions) => Promise = () => + Promise.resolve([]), ): TestCaptureDependencies => ({ hasCoverageProvider: () => true, - wrapperConfigPath, - runRstest: (options) => { - calls.push(options); - return Promise.resolve(result); - }, + loadConfig: ({ cwd = process.cwd(), path: configPath } = {}) => + Promise.resolve({ + content: {}, + filePath: configPath ?? path.join(cwd, 'rstest.config.ts'), + dependencies: [], + }), + createRstest: () => + Promise.resolve({ + listTests, + run: (options) => { + calls.push(options); + return Promise.resolve(toNativeResult(result)); + }, + }), createRunId: () => `run_${suffix}`, createSnapshotId: () => `snap_${suffix}`, now: () => new Date('2026-08-12T08:00:00.000Z'), @@ -58,7 +97,7 @@ test('keeps the coverage provider out of the published dependency surface', asyn // 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.devDependencies?.['@rstest/coverage-istanbul']).toBe('0.12.1'); expect(packageJson.peerDependencies?.['@rstest/coverage-istanbul']).toBeUndefined(); expect(packageJson.peerDependenciesMeta?.['@rstest/coverage-istanbul']).toBeUndefined(); @@ -69,22 +108,64 @@ test('keeps the coverage provider out of the published dependency surface', asyn expect(packageJson.peerDependenciesMeta?.['@rsbuild/core']).toEqual({ optional: true }); }); -test('does not run Rstest when the host reports that tests are not configured', async () => { +test('rejects native test capture when no Rstest config exists', 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; + let createCalls = 0; + const createRstest = () => { + createCalls += 1; + throw new Error('must not create Rstest'); + }; + const message = + 'Rstest is not configured for package root ".". Add a native rstest.config.* file supported by Rstest, or pass configPath.'; - 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(captureTestSnapshot(workspaceRoot, {}, { createRstest })).rejects.toThrow(message); + await expect( + listRelatedTests(workspaceRoot, { related: ['src/index.ts'] }, createRstest), + ).rejects.toThrow(message); + + expect(createCalls).toBe(0); await expect(readProjectStatus(workspaceRoot)).resolves.toMatchObject({ contexts: [] }); }); }); +test('uses Rstest native config discovery for CommonJS config files', async () => { + await withTempWorkspace('rstack-context-test-run-', async (workspaceRoot) => { + const configPath = path.join(workspaceRoot, 'rstest.config.cjs'); + const sourcePath = path.join(workspaceRoot, 'src', 'index.ts'); + const testPath = path.join(workspaceRoot, 'src', 'index.test.ts'); + await mkdir(path.dirname(sourcePath), { recursive: true }); + await writeFile(configPath, 'module.exports = {};\n'); + await writeFile(sourcePath, 'export const value = 1;\n'); + + const createCalls: unknown[] = []; + const result = await listRelatedTests( + workspaceRoot, + { related: ['src/index.ts'] }, + (options) => { + createCalls.push(options); + return Promise.resolve({ + listTests: () => Promise.resolve([{ project: 'default', testPath, type: 'file' }]), + run: () => { + throw new Error('must not run'); + }, + }); + }, + ); + + expect(createCalls).toEqual([ + expect.objectContaining({ + cwd: workspaceRoot, + config: expect.objectContaining({ filePath: configPath }), + }), + ]); + expect(result).toMatchObject({ + sources: ['src/index.ts'], + testFiles: ['src/index.test.ts'], + total: 1, + }); + }); +}); + 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'); @@ -141,9 +222,7 @@ test('captures one passing run with partial source freshness', async () => { }); expect(calls).toEqual([ { - cwd: workspaceRoot, - config: expect.stringMatching(/rstestConfig\.js$/u), - files: ['src/math.test.ts'], + filters: ['src/math.test.ts'], testNamePattern: 'adds', }, ]); @@ -197,6 +276,7 @@ test('captures one passing run with partial source freshness', async () => { contextId: expect.stringMatching(/^ctx_[0-9a-f]{24}$/u), packageRoot: '.', product: 'development', + configPath: 'rstest.config.ts', environment: 'test', }); @@ -223,7 +303,7 @@ test('resolves related source files before running and records the static test r await writeFile(sourcePath, 'export const value = 1;\n'); await writeFile(testPath, 'test'); const calls: unknown[] = []; - const relatedCalls: unknown[] = []; + const listCalls: unknown[] = []; const result = createResult({ files: [ { @@ -243,27 +323,23 @@ test('resolves related source files before running and records the static test r 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]); - }, - }, + createDependencies(result, calls, 'related', (options) => { + listCalls.push(options); + return Promise.resolve([{ project: 'default', testPath, type: 'file' }]); + }), ); - expect(relatedCalls).toEqual([ + expect(listCalls).toEqual([ { - packageRoot: path.join(workspaceRoot, 'packages/app'), - configPath: undefined, - sources: [sourcePath], + filesOnly: true, + filters: [sourcePath], + related: true, }, ]); expect(calls).toEqual([ { - cwd: path.join(workspaceRoot, 'packages/app'), - config: expect.stringMatching(/rstestConfig\.js$/u), - files: ['tests/config.test.ts'], + filters: [sourcePath], + related: true, }, ]); const stored = await readContextSnapshotById(workspaceRoot, capture.snapshotId); @@ -316,10 +392,9 @@ test('persists an actionable error snapshot when a related source file cannot be captureTestSnapshot( workspaceRoot, { related: ['src/missing.ts'] }, - { - ...createDependencies(result, calls, 'unreadable'), - resolveRelatedTests: () => Promise.resolve([testPath]), - }, + createDependencies(result, calls, 'unreadable', () => + Promise.resolve([{ project: 'default', testPath, type: 'file' }]), + ), ), ).rejects.toMatchObject({ name: 'TestInputError', @@ -401,25 +476,21 @@ test('keeps the latest complete test results when a newer snapshot has unreadabl files: { total: 1, failed: 0 }, }, }); - const resolveRelatedTests = () => Promise.resolve([testPath]); - const complete = await captureTestSnapshot( workspaceRoot, { related: ['src/config.ts'] }, - { - ...createDependencies(result, [], 'complete'), - resolveRelatedTests, - }, + createDependencies(result, [], 'complete', () => + Promise.resolve([{ project: 'default', testPath, type: 'file' }]), + ), ); await unlink(sourcePath); await expect( captureTestSnapshot( workspaceRoot, { related: ['src/config.ts'] }, - { - ...createDependencies(result, [], 'incomplete'), - resolveRelatedTests, - }, + createDependencies(result, [], 'incomplete', () => + Promise.resolve([{ project: 'default', testPath, type: 'file' }]), + ), ), ).rejects.toMatchObject({ name: 'TestInputError' }); @@ -438,28 +509,21 @@ test('keeps the latest complete test results when a newer snapshot has unreadabl }); }); -test('reports a missing config adapter before writing any run manifest', async () => { +test('runs through one native Rstest instance', async () => { await withTempWorkspace('rstack-context-test-run-', async (workspaceRoot) => { - await expect(captureTestSnapshot(workspaceRoot, {}, { wrapperConfigPath })).rejects.toThrow( - 'Rstack test capture requires a config adapter.', + const calls: unknown[] = []; + const result = await captureTestSnapshot( + workspaceRoot, + {}, + createDependencies(createResult(), calls, 'standalone'), ); - 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.', - ); + expect(result.status).toBe('pass'); + expect(calls).toEqual([{}]); + + await expect(readProjectStatus(workspaceRoot)).resolves.toMatchObject({ + contexts: [{ producer: 'rstest' }], + }); }); }); @@ -508,10 +572,7 @@ test('records an empty related selection without falling back to the full test s const capture = await captureTestSnapshot( workspaceRoot, { related: ['src/unused.ts'] }, - { - ...createDependencies(createResult(), calls, 'unrelated'), - resolveRelatedTests: () => Promise.resolve([]), - }, + createDependencies(createResult(), calls, 'unrelated'), ); expect(calls).toEqual([]); @@ -587,7 +648,7 @@ test('captures requested aggregate Istanbul execution evidence', async () => { b: {}, }, }, - } as unknown as NonNullable; + } as unknown as NonNullable; const result = createResult({ coverage, files: [ @@ -615,18 +676,14 @@ test('captures requested aggregate Istanbul execution evidence', async () => { 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, - }, + coverage: { + enabled: true, + provider: 'istanbul', + reporters: [], + reportOnFailure: true, + include: ['src/**/*.ts'], + exclude: ['src/**/*.test.ts'], + allowExternal: true, }, }, ]); @@ -763,16 +820,12 @@ test('records requested execution as unavailable when Rstest returns no coverage expect(calls).toEqual([ { - cwd: workspaceRoot, - config: expect.stringMatching(/rstestConfig\.js$/u), - inlineConfig: { - coverage: { - enabled: true, - provider: 'istanbul', - reporters: [], - reportOnFailure: true, - allowExternal: false, - }, + coverage: { + enabled: true, + provider: 'istanbul', + reporters: [], + reportOnFailure: true, + allowExternal: false, }, }, ]); @@ -843,9 +896,7 @@ test('runs tests without coverage when the optional Istanbul provider is unavail expect(calls).toEqual([ { - cwd: workspaceRoot, - config: expect.stringMatching(/rstestConfig\.js$/u), - files: ['src/math.test.ts'], + filters: ['src/math.test.ts'], }, ]); expect(capture).toMatchObject({ @@ -893,7 +944,7 @@ test('persists partial execution evidence when a covered source path is unreadab f: {}, b: {}, }, - } as unknown as NonNullable; + } as unknown as NonNullable; const result = createResult({ files: [ { @@ -946,9 +997,9 @@ test('bounds requested execution selectors before invoking Rstest', async () => await withTempWorkspace('rstack-context-test-run-', async (workspaceRoot) => { let called = false; const dependencies: TestCaptureDependencies = { - runRstest: () => { + createRstest: () => { called = true; - return Promise.resolve(createResult()); + throw new Error('must not create Rstest'); }, }; @@ -971,9 +1022,9 @@ test('rejects malformed execution selectors before invoking Rstest', async () => await withTempWorkspace('rstack-context-test-run-', async (workspaceRoot) => { let called = false; const dependencies: TestCaptureDependencies = { - runRstest: () => { + createRstest: () => { called = true; - return Promise.resolve(createResult()); + throw new Error('must not create Rstest'); }, }; @@ -1028,7 +1079,7 @@ test('truncates aggregate execution files deterministically', async () => { { execution: {} }, createDependencies( createResult({ - coverage: coverage as NonNullable, + coverage: coverage as NonNullable, }), calls, 'execution-truncated', @@ -1320,13 +1371,22 @@ test('persists a thrown Rstest configuration error as a completed diagnostic sna 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; - }, + loadConfig: ({ cwd = process.cwd(), path: configPath } = {}) => + Promise.resolve({ + content: {}, + filePath: configPath ?? path.join(cwd, 'rstest.config.ts'), + dependencies: [], + }), + createRstest: () => + Promise.resolve({ + listTests: () => Promise.resolve([]), + run: 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'), @@ -1489,7 +1549,7 @@ test('pages project-qualified results in deterministic identity order', async () }); }); -const nestedCause = (depth: number): TestRunResult['unhandledErrors'][number] => ({ +const nestedCause = (depth: number): CapturedTestRunResult['unhandledErrors'][number] => ({ name: `Cause${depth}`, message: `cause ${depth}`, ...(depth === 0 ? {} : { cause: nestedCause(depth - 1) }), From 17dd89edba23e5e331ef84210aa97e6babea4dea Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 8 Oct 2026 15:53:08 -0700 Subject: [PATCH 42/46] fix: bind context evidence to captured inputs --- docs/rfc.md | 13 ++++ src/codeEvidence.ts | 5 +- src/execution.ts | 26 +++++-- src/lint.ts | 56 +++++++++----- src/testRun.ts | 26 +++++++ tests/lint.test.ts | 52 ++++++++++++- tests/testRun.test.ts | 166 ++++++++++++++++++++++++++++++++++++++++-- 7 files changed, 311 insertions(+), 33 deletions(-) diff --git a/docs/rfc.md b/docs/rfc.md index 0dfe473..8cd833c 100644 --- a/docs/rfc.md +++ b/docs/rfc.md @@ -383,6 +383,19 @@ Lint and test snapshots record input digests. Query results assess those inputs workspace and report `fresh`, `stale`, `partial`, or `unknown` with changed paths where available. Build, lint, and test freshness remain independent. +Execution digests come from source bytes observed during Rstest compilation. Files without a +captured source version (including files reported only by coverage discovery or externalized +modules) retain their coverage counts but cannot establish current-source execution evidence. +Conflicting source versions within one capture also leave the digest unavailable. +The source observer supports the root config and inline projects. When `projects` includes file +paths or globs, Rstest loads those configs internally; aggregate coverage stays unbound rather +than using digests from only some projects. + +Lint captures hash an explicit config, or config candidates along the selected file and package +ancestor directories, before running Rslint. Lint freshness remains `partial` while recorded inputs +match: Rslint does not expose a complete dependency set for imported configs, nested discovery, or +type-aware rules. A change to a recorded source or config reports `stale`. + `snapshot_diff` compares only compatible immutable snapshots: - the schema version must match; diff --git a/src/codeEvidence.ts b/src/codeEvidence.ts index c0fa365..82ab348 100644 --- a/src/codeEvidence.ts +++ b/src/codeEvidence.ts @@ -6,6 +6,7 @@ import { diagnosticsFromStoredSnapshot, type DiagnosticRecord } from './lint.ts' import type { ContextFreshness, ContextSnapshot, + LintFacet, StoredContextSnapshot, TestExecutionFacet, TestExecutionLocation, @@ -110,7 +111,9 @@ const packageContainsPath = (packageRoot: string, sourcePath: string): boolean = }; const lintSnapshotCapturedPath = (stored: StoredContextSnapshot, sourcePath: string): boolean => - stored.snapshot.source?.inputs?.some((input) => input.path === sourcePath) === true || + (stored.snapshot.facets.lint as LintFacet | undefined)?.files.some( + (file) => file.path === sourcePath, + ) === true || diagnosticsFromStoredSnapshot(stored).some((diagnostic) => diagnostic.path === sourcePath); const selectSnapshot = async ( diff --git a/src/execution.ts b/src/execution.ts index 8408677..ec8b34d 100644 --- a/src/execution.ts +++ b/src/execution.ts @@ -1,5 +1,5 @@ -import { readFile } from 'node:fs/promises'; import path from 'node:path'; +import type { RsbuildPlugin } from '@rsbuild/core'; import type { TestRunResult } from '@rstest/core/api'; import type { CoverageMapData } from '@rstest/core/internal/browser'; import type { @@ -27,6 +27,20 @@ type TestExecutionRequest = { allowExternal?: boolean; }; +const createExecutionSourcePlugin = (digests: Map): RsbuildPlugin => ({ + name: 'rstack-context:execution-sources', + setup(api) { + api.transform({ order: 'pre', raw: true }, ({ code, resourcePath }) => { + const digest = sha256Hex(code); + digests.set( + resourcePath, + digests.has(resourcePath) && digests.get(resourcePath) !== digest ? undefined : digest, + ); + return code; + }); + }, +}); + const executionBounds = { attribution: 'aggregate-run-only', testAttribution: false, @@ -251,6 +265,7 @@ const normalizeExecutionFacet = async ( packageRoot: string, request: TestExecutionRequest, coverage: TestRunResult['coverage'], + sourceDigests: ReadonlyMap, ): Promise => { if (!isRecordObject(coverage)) return unavailableExecutionFacet(request); @@ -286,11 +301,9 @@ const normalizeExecutionFacet = async ( functions: [], branches: [], }; - try { - file.digest = sha256Hex(await readFile(candidate.absolutePath)); - } catch { - readable = false; - } + const digest = sourceDigests.get(candidate.absolutePath); + if (digest === undefined) readable = false; + else file.digest = digest; let fileLocations = 0; for (const entry of candidate.entries) { if ( @@ -541,6 +554,7 @@ const validateExecutionFacet = (value: unknown): TestExecutionFacet | undefined }; export { + createExecutionSourcePlugin, normalizeExecutionFacet, unavailableExecutionFacet, validateExecutionFacet, diff --git a/src/lint.ts b/src/lint.ts index 36c4053..733747e 100644 --- a/src/lint.ts +++ b/src/lint.ts @@ -21,6 +21,7 @@ import { assessSnapshotFreshness, createExplicitContextDescriptor, createExplicitRun, + recordContextInputFiles, resolveExplicitCaptureTarget, } from './source.ts'; import { @@ -209,18 +210,34 @@ const lintCaptureSelection = ( filePath: toWorkspacePath(workspaceRoot, path.resolve(packageRoot, request.filePath)), }; -const lintInputCompleteness = ( +const recordLintConfigInputs = ( workspaceRoot: string, packageRoot: string, - patterns: string[], - files: LintFileRecord[], -): 'complete' | 'partial' => { - const selectedPaths = new Set( - patterns.map((pattern) => toWorkspacePath(workspaceRoot, path.resolve(packageRoot, pattern))), + request: LintSnapshotRequest, +) => { + if (request.configPath !== undefined) { + return recordContextInputFiles(workspaceRoot, [request.configPath]); + } + const directories = new Set(); + const targets = request.mode === 'text' ? [request.filePath] : (request.patterns ?? []); + for (let directory of [ + packageRoot, + ...targets.map((target) => path.resolve(packageRoot, target)), + ]) { + while (directory === workspaceRoot || directory.startsWith(`${workspaceRoot}${path.sep}`)) { + directories.add(directory); + if (directory === workspaceRoot) break; + directory = path.dirname(directory); + } + } + return recordContextInputFiles( + workspaceRoot, + [...directories].flatMap((directory) => + ['js', 'mjs', 'cjs', 'ts', 'mts', 'cts'].map((extension) => + path.join(directory, `rslint.config.${extension}`), + ), + ), ); - return selectedPaths.size === files.length && files.every((file) => selectedPaths.has(file.path)) - ? 'complete' - : 'partial'; }; const ensureWritten = (result: Awaited>): void => { @@ -237,6 +254,7 @@ const captureLintSnapshot = async ( ): Promise => { const includeFixPreview = request.includeFixPreview ?? false; const target = await resolveExplicitCaptureTarget(workspaceRoot, request); + const configInputs = await recordLintConfigInputs(workspaceRoot, target.packageRoot, request); const context = createExplicitContextDescriptor({ producer: 'rslint', workspaceRoot, @@ -351,16 +369,16 @@ const captureLintSnapshot = async ( 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, - ), + inputs: [ + ...new Map( + [ + ...files.map(({ path: filePath, digest }) => ({ path: filePath, digest })), + ...configInputs, + ].map((input) => [input.path, input]), + ).values(), + ].sort((left, right) => compareStrings(left.path, right.path)), + // Rslint does not expose imported configs, nested discovery, or type dependencies. + inputCompleteness: 'partial', captureSelection, }; const status: ContextRunStatus = totals.errors > 0 ? 'fail' : 'pass'; diff --git a/src/testRun.ts b/src/testRun.ts index 03c6598..d478a8e 100644 --- a/src/testRun.ts +++ b/src/testRun.ts @@ -22,6 +22,7 @@ import { type TestFileRecord, } from './model.ts'; import { + createExecutionSourcePlugin, normalizeExecutionFacet, unavailableExecutionFacet, validateExecutionRequest, @@ -323,8 +324,30 @@ const createNativeRstest = async ( configPath: string | undefined, factory: CreateRstestRuntime = createRstest, loadConfig?: LoadRstestConfig, + sourceDigests?: Map, ): Promise<{ configPath: string; runtime: RstestRuntime }> => { const config = await loadNativeRstestConfig(workspaceRoot, packageRoot, configPath, loadConfig); + // File/glob projects are loaded internally by Rstest and cannot receive this observer. + // Do not bind aggregate coverage using only a subset of the participating projects. + if ( + sourceDigests !== undefined && + !config.content.projects?.some((project) => typeof project === 'string') + ) { + const plugin = createExecutionSourcePlugin(sourceDigests); + config.content = { + ...config.content, + plugins: [...(config.content.plugins ?? []), plugin], + ...(config.content.projects === undefined + ? {} + : { + projects: config.content.projects.map((project) => + typeof project === 'string' + ? project + : { ...project, plugins: [...(project.plugins ?? []), plugin] }, + ), + }), + }; + } return { configPath: config.filePath!, runtime: await factory({ cwd: packageRoot, config }), @@ -417,12 +440,14 @@ const captureTestSnapshot = async ( validateExecutionRequest(request.execution); validateRelatedSelection(request); const target = await resolveExplicitCaptureTarget(workspaceRoot, request); + const sourceDigests = new Map(); const { configPath, runtime } = await createNativeRstest( workspaceRoot, target.packageRoot, target.configPath, dependencies.createRstest, dependencies.loadConfig, + request.execution === undefined ? undefined : sourceDigests, ); const execution = request.execution; const captureExecution = @@ -552,6 +577,7 @@ const captureTestSnapshot = async ( target.packageRoot, execution, result.coverage, + sourceDigests, ); const recording = await collectContextInputFiles(workspaceRoot, [ ...new Set([ diff --git a/tests/lint.test.ts b/tests/lint.test.ts index f97c869..059b054 100644 --- a/tests/lint.test.ts +++ b/tests/lint.test.ts @@ -5,6 +5,7 @@ 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 { readCodeEvidence } from '../src/codeEvidence.ts'; import { captureLintSnapshot, getLintFixPreview, listDiagnostics } from '../src/lint.ts'; import { contextStoreSchemaVersion, type ContextRunManifest } from '../src/model.ts'; import { readProjectStatus } from '../src/status.ts'; @@ -525,7 +526,7 @@ test('returns snapshot provenance for an empty diagnostics page', async () => { contextId: capture.contextId, observedAt: stored?.snapshot.observedAt, completeness: { lint: 'complete' }, - freshness: { state: 'fresh', changedPaths: [] }, + freshness: { state: 'partial', changedPaths: [] }, total: 0, items: [], }); @@ -877,3 +878,52 @@ test('persists a partial diagnostic snapshot when creating the lint engine throw expect(mocks.closeCalls).toBe(0); }); }); + +test.each(['explicit', 'discovered', 'ancestor', 'nested'] as const)( + 'invalidates lint evidence when its %s config changes', + async (selection) => { + await withTempWorkspace('rstack-context-lint-config-', async (workspaceRoot) => { + const packageRoot = selection === 'ancestor' ? 'packages/app' : '.'; + const sourcePath = selection === 'nested' ? 'src/index.ts' : 'index.ts'; + const configPath = + selection === 'explicit' + ? 'custom.mjs' + : selection === 'nested' + ? 'src/rslint.config.mjs' + : 'rslint.config.mjs'; + await mkdir(path.dirname(path.join(workspaceRoot, packageRoot, sourcePath)), { + recursive: true, + }); + await writeFile(path.join(workspaceRoot, packageRoot, sourcePath), 'debugger;\n'); + await writeFile( + path.join(workspaceRoot, configPath), + "export default [{ files: ['**/*.ts'], rules: { 'no-debugger': 'off' } }];\n", + ); + const captured = await captureLintSnapshot(workspaceRoot, { + mode: 'files', + packageRoot, + patterns: [sourcePath], + ...(selection === 'explicit' ? { configPath } : {}), + }); + expect(captured.status).toBe('pass'); + expect(captured.freshness.state).toBe('partial'); + if (selection === 'explicit' || selection === 'discovered') { + await expect( + readCodeEvidence(workspaceRoot, { + path: configPath, + lintSnapshotId: captured.snapshotId, + }), + ).rejects.toThrow('Selected Rslint snapshot did not capture the source path.'); + } + await writeFile( + path.join(workspaceRoot, configPath), + "export default [{ files: ['**/*.ts'], rules: { 'no-debugger': 'error' } }];\n", + ); + await expect( + listDiagnostics(workspaceRoot, { snapshotId: captured.snapshotId }), + ).resolves.toMatchObject({ + freshness: { state: 'stale', changedPaths: [configPath] }, + }); + }); + }, +); diff --git a/tests/testRun.test.ts b/tests/testRun.test.ts index 44777cb..4ae2b12 100644 --- a/tests/testRun.test.ts +++ b/tests/testRun.test.ts @@ -1,9 +1,19 @@ /* rslint-disable @typescript-eslint/no-unsafe-assignment -- Rstest asymmetric matchers are intentionally untyped. */ -import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises'; +import { mkdir, readFile, realpath, symlink, unlink, writeFile } from 'node:fs/promises'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { + RsbuildPlugin, + RsbuildPluginAPI, + TransformContext, + TransformHandler, +} from '@rsbuild/core'; import { expect, test } from '@rstest/core'; import type { ListedTest, ListOptions, RunOptions, TestRunResult } from '@rstest/core/api'; import { listDiagnostics } from '../src/lint.ts'; +import { readCodeEvidence } from '../src/codeEvidence.ts'; +import { sha256Hex } from '../src/guards.ts'; +import { createExecutionSourcePlugin, normalizeExecutionFacet } from '../src/execution.ts'; import { readProjectStatus } from '../src/status.ts'; import { validateTestFacet } from '../src/records.ts'; import { readContextSnapshotById } from '../src/store.ts'; @@ -71,14 +81,30 @@ const createDependencies = ( filePath: configPath ?? path.join(cwd, 'rstest.config.ts'), dependencies: [], }), - createRstest: () => - Promise.resolve({ + createRstest: async (options) => { + let transform: TransformHandler | undefined; + const config = options?.config; + if (config !== undefined && 'content' in config) { + const plugin = config.content.plugins?.at(-1) as RsbuildPlugin | undefined; + await plugin?.setup({ + transform: (_descriptor, handler) => { + transform = handler as TransformHandler; + }, + } as RsbuildPluginAPI); + } + return { listTests, - run: (options) => { + run: async (options) => { + for (const resourcePath of Object.keys(result.coverage ?? {})) { + const code = await readFile(resourcePath).catch(() => undefined); + if (code !== undefined) + await transform?.({ code, resourcePath } as TransformContext); + } calls.push(options); - return Promise.resolve(toNativeResult(result)); + return toNativeResult(result); }, - }), + }; + }, createRunId: () => `run_${suffix}`, createSnapshotId: () => `snap_${suffix}`, now: () => new Date('2026-08-12T08:00:00.000Z'), @@ -1722,3 +1748,131 @@ test('keeps unsupported execution controls out of snapshot requests', () => { const unsupportedRequestField = undefined as UnsupportedRequestFields; expect(unsupportedRequestField).toBeUndefined(); }); + +test.each(['single', 'inline', 'file', 'mixed'] as const)( + 'binds execution to compiled sources when a test edits its input (projects: %s)', + async (projects) => { + await withTempWorkspace('rstack-context-execution-', async (temporaryRoot) => { + const workspaceRoot = await realpath(temporaryRoot); + await symlink( + fileURLToPath(new URL('../node_modules', import.meta.url)), + path.join(workspaceRoot, 'node_modules'), + ); + await writeFile(path.join(workspaceRoot, 'package.json'), '{"type":"module"}'); + await writeFile( + path.join(workspaceRoot, 'rstest.config.mjs'), + projects === 'single' + ? 'export default { include: ["src/*.test.ts"] };' + : projects === 'inline' + ? 'export default { projects: [{ name: "app", include: ["src/*.test.ts"] }] };' + : projects === 'file' + ? 'export default { projects: ["./project.config.mjs"] };' + : 'export default { projects: ["./project.config.mjs", {name: "inline", include: ["src/*.test.ts"]}] };', + ); + await writeFile( + path.join(workspaceRoot, 'project.config.mjs'), + 'export default { name: "external", include: ["src/*.test.ts"] };', + ); + await mkdir(path.join(workspaceRoot, 'src')); + const sourcePath = path.join(workspaceRoot, 'src/math.ts'); + const originalSource = 'export function math() { return 1; }\n'; + await writeFile(sourcePath, originalSource); + const testPath = path.join(workspaceRoot, 'src/math.test.ts'); + const testSource = `import { test, expect } from '@rstest/core'; +import { writeFileSync } from 'node:fs'; +import { math } from './math'; +test('math', () => { expect(math()).toBe(1); EDIT_SOURCE });`; + await writeFile(testPath, testSource.replace('EDIT_SOURCE', '')); + const request = { related: ['src/math.ts'], execution: { include: ['src/math.ts'] } }; + const initial = await captureTestSnapshot(workspaceRoot, request); + expect(initial.status).toBe('pass'); + const unbound = projects === 'file' || projects === 'mixed'; + await expect( + readCodeEvidence(workspaceRoot, { + path: 'src/math.ts', + testSnapshotId: initial.snapshotId, + }), + ).resolves.toMatchObject({ + executionCoverage: unbound + ? { state: 'unknown', reason: 'digest-unavailable' } + : { state: 'observed', fileDigest: sha256Hex(originalSource) }, + }); + + await writeFile( + testPath, + testSource.replace( + 'EDIT_SOURCE', + `writeFileSync(${JSON.stringify(sourcePath)}, 'export function math() { return 2; }\\n');`, + ), + ); + const edited = await captureTestSnapshot(workspaceRoot, request); + expect(edited.status).toBe('pass'); + expect(edited.freshness).toEqual( + unbound + ? { state: 'partial', changedPaths: [] } + : { state: 'stale', changedPaths: ['src/math.ts'] }, + ); + await expect( + readCodeEvidence(workspaceRoot, { path: 'src/math.ts', testSnapshotId: edited.snapshotId }), + ).resolves.toMatchObject({ + executionCoverage: { + state: 'unknown', + reason: unbound ? 'digest-unavailable' : 'digest-mismatch', + fileDigest: unbound ? undefined : sha256Hex(originalSource), + }, + }); + }); + }, + 15_000, +); + +test('leaves coverage unbound when compiled source bytes are absent or conflict', async () => { + await withTempWorkspace('rstack-context-execution-', async (workspaceRoot) => { + const sourcePath = path.join(workspaceRoot, 'source.ts'); + await writeFile(sourcePath, 'export const value = 2;'); + const coverage = { + [sourcePath]: { + path: sourcePath, + statementMap: { '0': { start: { line: 1, column: 0 }, end: { line: 1, column: 23 } } }, + fnMap: {}, + branchMap: {}, + s: { '0': 1 }, + f: {}, + b: {}, + }, + } as NonNullable; + const digests = new Map(); + let transform: TransformHandler | undefined; + await createExecutionSourcePlugin(digests).setup({ + transform: (_descriptor, handler) => { + transform = handler as TransformHandler; + }, + } as RsbuildPluginAPI); + const absent = await normalizeExecutionFacet( + workspaceRoot, + workspaceRoot, + {}, + coverage, + digests, + ); + expect(absent.files[0]?.digest).toBeUndefined(); + expect(absent.universe.completeness).toBe('partial'); + + for (const value of [1, 2, 2]) { + await transform!({ + resourcePath: sourcePath, + code: Buffer.from(`export const value = ${value};`), + } as TransformContext); + } + const conflicting = await normalizeExecutionFacet( + workspaceRoot, + workspaceRoot, + {}, + coverage, + digests, + ); + expect(conflicting.files[0]?.digest).toBeUndefined(); + expect(conflicting.universe.completeness).toBe('partial'); + expect(conflicting.files[0]?.statements[0]?.hits).toBe(1); + }); +}); From 37f01bc553613c41bee0bba71b3e6f771c05ba21 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 8 Oct 2026 16:42:37 -0700 Subject: [PATCH 43/46] fix: retain execution source digests across cached builds --- docs/rfc.md | 4 +- src/execution.ts | 21 ++++++--- tests/testRun.test.ts | 99 +++++++++++++++++++++++++++++++++---------- 3 files changed, 95 insertions(+), 29 deletions(-) diff --git a/docs/rfc.md b/docs/rfc.md index 8cd833c..2ebcfaf 100644 --- a/docs/rfc.md +++ b/docs/rfc.md @@ -383,7 +383,9 @@ Lint and test snapshots record input digests. Query results assess those inputs workspace and report `fresh`, `stale`, `partial`, or `unknown` with changed paths where available. Build, lint, and test freshness remain independent. -Execution digests come from source bytes observed during Rstest compilation. Files without a +Execution digests come from source bytes observed during Rstest compilation. The observer stores +each digest as a loader asset so Rspack restores it alongside cached modules; compilation consumes +and removes these metadata assets before output. Files without a captured source version (including files reported only by coverage discovery or externalized modules) retain their coverage counts but cannot establish current-source execution evidence. Conflicting source versions within one capture also leave the digest unavailable. diff --git a/src/execution.ts b/src/execution.ts index ec8b34d..dd4444b 100644 --- a/src/execution.ts +++ b/src/execution.ts @@ -30,14 +30,25 @@ type TestExecutionRequest = { const createExecutionSourcePlugin = (digests: Map): RsbuildPlugin => ({ name: 'rstack-context:execution-sources', setup(api) { - api.transform({ order: 'pre', raw: true }, ({ code, resourcePath }) => { + const assetPrefix = '__rstack_context_execution_sources__/'; + api.transform({ order: 'pre', raw: true }, ({ code, resourcePath, emitFile }) => { const digest = sha256Hex(code); - digests.set( - resourcePath, - digests.has(resourcePath) && digests.get(resourcePath) !== digest ? undefined : digest, - ); + // Loader assets survive module cache restoration, when this callback does not run. + emitFile(`${assetPrefix}${sha256Hex(resourcePath)}/${digest}`, resourcePath); return code; }); + api.processAssets({ stage: 'additional' }, ({ assets, compilation }) => { + for (const [name, source] of Object.entries(assets)) { + if (!name.startsWith(assetPrefix)) continue; + const resourcePath = source.source().toString(); + const digest = name.slice(name.lastIndexOf('/') + 1); + digests.set( + resourcePath, + digests.has(resourcePath) && digests.get(resourcePath) !== digest ? undefined : digest, + ); + compilation.deleteAsset(name); + } + }); }, }); diff --git a/tests/testRun.test.ts b/tests/testRun.test.ts index 4ae2b12..f4a3517 100644 --- a/tests/testRun.test.ts +++ b/tests/testRun.test.ts @@ -3,6 +3,7 @@ import { mkdir, readFile, realpath, symlink, unlink, writeFile } from 'node:fs/p import path from 'node:path'; import { fileURLToPath } from 'node:url'; import type { + ProcessAssetsHandler, RsbuildPlugin, RsbuildPluginAPI, TransformContext, @@ -67,6 +68,37 @@ const toNativeResult = (result: CapturedTestRunResult): TestRunResult => ({ ...(result.coverage === undefined ? {} : { coverage: result.coverage }), }); +const createSourceObserver = async (plugin: RsbuildPlugin) => { + let transform: TransformHandler | undefined; + let processAssets: ProcessAssetsHandler | undefined; + const assets: Parameters[0]['assets'] = {}; + await plugin.setup({ + transform: (_descriptor, handler) => { + transform = handler as TransformHandler; + }, + processAssets: (_descriptor, handler) => { + processAssets = handler; + }, + } as RsbuildPluginAPI); + return async (resourcePath: string, code: Buffer) => { + await transform!({ + resourcePath, + code, + emitFile: (name, content) => { + assets[name] = { source: () => content } as (typeof assets)[string]; + }, + } as TransformContext); + await processAssets!({ + assets, + compilation: { + deleteAsset: (name: string) => { + delete assets[name]; + }, + }, + } as Parameters[0]); + }; +}; + const createDependencies = ( result: CapturedTestRunResult, calls: unknown[], @@ -82,23 +114,18 @@ const createDependencies = ( dependencies: [], }), createRstest: async (options) => { - let transform: TransformHandler | undefined; + let observeSource: Awaited> | undefined; const config = options?.config; if (config !== undefined && 'content' in config) { const plugin = config.content.plugins?.at(-1) as RsbuildPlugin | undefined; - await plugin?.setup({ - transform: (_descriptor, handler) => { - transform = handler as TransformHandler; - }, - } as RsbuildPluginAPI); + if (plugin !== undefined) observeSource = await createSourceObserver(plugin); } return { listTests, run: async (options) => { for (const resourcePath of Object.keys(result.coverage ?? {})) { const code = await readFile(resourcePath).catch(() => undefined); - if (code !== undefined) - await transform?.({ code, resourcePath } as TransformContext); + if (code !== undefined) await observeSource?.(resourcePath, code); } calls.push(options); return toNativeResult(result); @@ -1749,7 +1776,7 @@ test('keeps unsupported execution controls out of snapshot requests', () => { expect(unsupportedRequestField).toBeUndefined(); }); -test.each(['single', 'inline', 'file', 'mixed'] as const)( +test.each(['single', 'cached', 'inline', 'file', 'glob', 'mixed'] as const)( 'binds execution to compiled sources when a test edits its input (projects: %s)', async (projects) => { await withTempWorkspace('rstack-context-execution-', async (temporaryRoot) => { @@ -1761,13 +1788,15 @@ test.each(['single', 'inline', 'file', 'mixed'] as const)( await writeFile(path.join(workspaceRoot, 'package.json'), '{"type":"module"}'); await writeFile( path.join(workspaceRoot, 'rstest.config.mjs'), - projects === 'single' - ? 'export default { include: ["src/*.test.ts"] };' + projects === 'single' || projects === 'cached' + ? `export default { include: ["src/*.test.ts"], performance: {buildCache: ${projects === 'cached'}} };` : projects === 'inline' ? 'export default { projects: [{ name: "app", include: ["src/*.test.ts"] }] };' : projects === 'file' ? 'export default { projects: ["./project.config.mjs"] };' - : 'export default { projects: ["./project.config.mjs", {name: "inline", include: ["src/*.test.ts"]}] };', + : projects === 'glob' + ? 'export default { projects: ["./project.*.mjs"] };' + : 'export default { projects: ["./project.config.mjs", {name: "inline", include: ["src/*.test.ts"]}] };', ); await writeFile( path.join(workspaceRoot, 'project.config.mjs'), @@ -1786,7 +1815,7 @@ test('math', () => { expect(math()).toBe(1); EDIT_SOURCE });`; const request = { related: ['src/math.ts'], execution: { include: ['src/math.ts'] } }; const initial = await captureTestSnapshot(workspaceRoot, request); expect(initial.status).toBe('pass'); - const unbound = projects === 'file' || projects === 'mixed'; + const unbound = projects === 'file' || projects === 'glob' || projects === 'mixed'; await expect( readCodeEvidence(workspaceRoot, { path: 'src/math.ts', @@ -1798,6 +1827,19 @@ test('math', () => { expect(math()).toBe(1); EDIT_SOURCE });`; : { state: 'observed', fileDigest: sha256Hex(originalSource) }, }); + if (projects === 'cached') { + const restored = await captureTestSnapshot(workspaceRoot, request); + expect(restored.status).toBe('pass'); + await expect( + readCodeEvidence(workspaceRoot, { + path: 'src/math.ts', + testSnapshotId: restored.snapshotId, + }), + ).resolves.toMatchObject({ + executionCoverage: { state: 'observed', fileDigest: sha256Hex(originalSource) }, + }); + } + await writeFile( testPath, testSource.replace( @@ -1821,6 +1863,25 @@ test('math', () => { expect(math()).toBe(1); EDIT_SOURCE });`; fileDigest: unbound ? undefined : sha256Hex(originalSource), }, }); + if (projects === 'cached') { + await writeFile( + testPath, + testSource.replace('EDIT_SOURCE', '').replace('toBe(1)', 'toBe(2)'), + ); + const rebuilt = await captureTestSnapshot(workspaceRoot, request); + expect(rebuilt.status).toBe('pass'); + await expect( + readCodeEvidence(workspaceRoot, { + path: 'src/math.ts', + testSnapshotId: rebuilt.snapshotId, + }), + ).resolves.toMatchObject({ + executionCoverage: { + state: 'observed', + fileDigest: sha256Hex('export function math() { return 2; }\n'), + }, + }); + } }); }, 15_000, @@ -1842,12 +1903,7 @@ test('leaves coverage unbound when compiled source bytes are absent or conflict' }, } as NonNullable; const digests = new Map(); - let transform: TransformHandler | undefined; - await createExecutionSourcePlugin(digests).setup({ - transform: (_descriptor, handler) => { - transform = handler as TransformHandler; - }, - } as RsbuildPluginAPI); + const observeSource = await createSourceObserver(createExecutionSourcePlugin(digests)); const absent = await normalizeExecutionFacet( workspaceRoot, workspaceRoot, @@ -1859,10 +1915,7 @@ test('leaves coverage unbound when compiled source bytes are absent or conflict' expect(absent.universe.completeness).toBe('partial'); for (const value of [1, 2, 2]) { - await transform!({ - resourcePath: sourcePath, - code: Buffer.from(`export const value = ${value};`), - } as TransformContext); + await observeSource(sourcePath, Buffer.from(`export const value = ${value};`)); } const conflicting = await normalizeExecutionFacet( workspaceRoot, From e4129b6ae7274cb14967dbf95faccaffb461f007 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 8 Oct 2026 17:06:18 -0700 Subject: [PATCH 44/46] refactor: simplify execution capture and clarify docs --- README.md | 4 ++-- docs/rfc.md | 44 ++++++++++++++++++++++--------------------- src/execution.ts | 4 ++-- src/testRun.ts | 18 +++++++----------- tests/testRun.test.ts | 29 ++++++++++++---------------- 5 files changed, 46 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 57fd7e4..34ea70f 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ The factory enables metadata capture by default. Set `capture: 'off'`, or set ## Run the MCP server -The package exports a programmatic stdio entry point rather than adding a custom CLI command. +The package exports a programmatic stdio entry point. For an MCP host configuration: @@ -82,7 +82,7 @@ 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 +Build-context observers use Rsbuild's public APIs. See the [context engine RFC](./docs/rfc.md) for the evidence model. Execution coverage evidence is optional and resolved against the package under test, not against diff --git a/docs/rfc.md b/docs/rfc.md index 2ebcfaf..22cc75b 100644 --- a/docs/rfc.md +++ b/docs/rfc.md @@ -47,12 +47,12 @@ guess which package, build, or source revision an observation describes. That be 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. +The context engine stores observations from these processes in a shared format. Agent hosts query +them through one MCP server. ## Goals -- Provide one checkout-local MCP surface for Rstack evidence. +- Provide one MCP server per checkout 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. @@ -383,20 +383,22 @@ Lint and test snapshots record input digests. Query results assess those inputs workspace and report `fresh`, `stale`, `partial`, or `unknown` with changed paths where available. Build, lint, and test freshness remain independent. -Execution digests come from source bytes observed during Rstest compilation. The observer stores -each digest as a loader asset so Rspack restores it alongside cached modules; compilation consumes -and removes these metadata assets before output. Files without a -captured source version (including files reported only by coverage discovery or externalized -modules) retain their coverage counts but cannot establish current-source execution evidence. -Conflicting source versions within one capture also leave the digest unavailable. -The source observer supports the root config and inline projects. When `projects` includes file -paths or globs, Rstest loads those configs internally; aggregate coverage stays unbound rather -than using digests from only some projects. - -Lint captures hash an explicit config, or config candidates along the selected file and package -ancestor directories, before running Rslint. Lint freshness remains `partial` while recorded inputs -match: Rslint does not expose a complete dependency set for imported configs, nested discovery, or -type-aware rules. A change to a recorded source or config reports `stale`. +The source observer hashes the bytes Rstest compiles. It stores each digest as a loader asset so +Rspack can restore it with cached modules. The observer reads and removes these assets before +output. + +Files without a captured digest retain coverage counts but cannot prove execution of the current +source. This includes externalized modules and files found only through coverage discovery. +Conflicting source versions in one capture also leave the digest unavailable. + +The observer supports the root config and inline projects. Rstest loads file and glob project +configs internally. If any project uses those configs, Context cannot bind the combined coverage +to source digests. + +Lint captures hash the explicit config before running Rslint. Without an explicit config, Context +hashes config candidates in the selected file and package directories and their ancestors within +the workspace. Matching inputs yield `partial` freshness because Rslint does not report all +imported configs, nested configs, or type dependencies. Changes to recorded inputs yield `stale`. `snapshot_diff` compares only compatible immutable snapshots: @@ -637,10 +639,10 @@ 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. +exact identity and freshness joins, and the MCP tools and skills that explain the combined +evidence. It should not duplicate those compiler graphs or test instrumentation. -The lean delivery sequence is: +The delivery sequence is: 1. Aggregate existing Istanbul evidence into immutable snapshots and expose it through bounded exact-path composition. This branch implements this step. @@ -667,7 +669,7 @@ The following are potential later work, not part of the implemented contract: - 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 +- a coordinator daemon, remote transport, or custom user interface; and - repository-wide claims that combine several artifact graphs automatically. These additions should continue using the same workspace, context, run, snapshot, and provenance diff --git a/src/execution.ts b/src/execution.ts index dd4444b..15890ab 100644 --- a/src/execution.ts +++ b/src/execution.ts @@ -271,13 +271,13 @@ const unavailableExecutionFacet = (request: TestExecutionRequest): TestExecution files: [], }); -const normalizeExecutionFacet = async ( +const normalizeExecutionFacet = ( workspaceRoot: string, packageRoot: string, request: TestExecutionRequest, coverage: TestRunResult['coverage'], sourceDigests: ReadonlyMap, -): Promise => { +): TestExecutionFacet => { if (!isRecordObject(coverage)) return unavailableExecutionFacet(request); const candidates = sortedRecordEntries(coverage) diff --git a/src/testRun.ts b/src/testRun.ts index d478a8e..cb1fb68 100644 --- a/src/testRun.ts +++ b/src/testRun.ts @@ -327,25 +327,21 @@ const createNativeRstest = async ( sourceDigests?: Map, ): Promise<{ configPath: string; runtime: RstestRuntime }> => { const config = await loadNativeRstestConfig(workspaceRoot, packageRoot, configPath, loadConfig); + const { projects } = config.content; // File/glob projects are loaded internally by Rstest and cannot receive this observer. // Do not bind aggregate coverage using only a subset of the participating projects. if ( sourceDigests !== undefined && - !config.content.projects?.some((project) => typeof project === 'string') + (projects === undefined || projects.every((project) => typeof project !== 'string')) ) { const plugin = createExecutionSourcePlugin(sourceDigests); config.content = { ...config.content, plugins: [...(config.content.plugins ?? []), plugin], - ...(config.content.projects === undefined - ? {} - : { - projects: config.content.projects.map((project) => - typeof project === 'string' - ? project - : { ...project, plugins: [...(project.plugins ?? []), plugin] }, - ), - }), + projects: projects?.map((project) => ({ + ...project, + plugins: [...(project.plugins ?? []), plugin], + })), }; } return { @@ -572,7 +568,7 @@ const captureTestSnapshot = async ( ? undefined : !captureExecution ? unavailableExecutionFacet(execution) - : await normalizeExecutionFacet( + : normalizeExecutionFacet( workspaceRoot, target.packageRoot, execution, diff --git a/tests/testRun.test.ts b/tests/testRun.test.ts index f4a3517..86a52a7 100644 --- a/tests/testRun.test.ts +++ b/tests/testRun.test.ts @@ -1786,17 +1786,18 @@ test.each(['single', 'cached', 'inline', 'file', 'glob', 'mixed'] as const)( path.join(workspaceRoot, 'node_modules'), ); await writeFile(path.join(workspaceRoot, 'package.json'), '{"type":"module"}'); + const inlineProject = { name: 'app', include: ['src/*.test.ts'] }; + const config = { + single: { include: ['src/*.test.ts'] }, + cached: { include: ['src/*.test.ts'], performance: { buildCache: true } }, + inline: { projects: [inlineProject] }, + file: { projects: ['./project.config.mjs'] }, + glob: { projects: ['./project.*.mjs'] }, + mixed: { projects: ['./project.config.mjs', inlineProject] }, + }[projects]; await writeFile( path.join(workspaceRoot, 'rstest.config.mjs'), - projects === 'single' || projects === 'cached' - ? `export default { include: ["src/*.test.ts"], performance: {buildCache: ${projects === 'cached'}} };` - : projects === 'inline' - ? 'export default { projects: [{ name: "app", include: ["src/*.test.ts"] }] };' - : projects === 'file' - ? 'export default { projects: ["./project.config.mjs"] };' - : projects === 'glob' - ? 'export default { projects: ["./project.*.mjs"] };' - : 'export default { projects: ["./project.config.mjs", {name: "inline", include: ["src/*.test.ts"]}] };', + `export default ${JSON.stringify(config)};`, ); await writeFile( path.join(workspaceRoot, 'project.config.mjs'), @@ -1904,20 +1905,14 @@ test('leaves coverage unbound when compiled source bytes are absent or conflict' } as NonNullable; const digests = new Map(); const observeSource = await createSourceObserver(createExecutionSourcePlugin(digests)); - const absent = await normalizeExecutionFacet( - workspaceRoot, - workspaceRoot, - {}, - coverage, - digests, - ); + const absent = normalizeExecutionFacet(workspaceRoot, workspaceRoot, {}, coverage, digests); expect(absent.files[0]?.digest).toBeUndefined(); expect(absent.universe.completeness).toBe('partial'); for (const value of [1, 2, 2]) { await observeSource(sourcePath, Buffer.from(`export const value = ${value};`)); } - const conflicting = await normalizeExecutionFacet( + const conflicting = normalizeExecutionFacet( workspaceRoot, workspaceRoot, {}, From 445bd13a49a715221f23aab3b24bfc8d8af1a33d Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 20 Oct 2026 14:37:15 -0700 Subject: [PATCH 45/46] feat: validate Context against released Rstack shared configs --- README.md | 30 +- docs/repository-extraction.md | 30 +- docs/rfc.md | 4 +- docs/upstream-requests.md | 10 +- package.json | 5 +- pnpm-lock.yaml | 833 ++++++++++++++++--------- src/sharedConfig.ts | 3 +- tests/packageExports.test.ts | 13 +- tests/sharedConfig.integration.test.ts | 122 ++++ 9 files changed, 719 insertions(+), 331 deletions(-) create mode 100644 tests/sharedConfig.integration.test.ts diff --git a/README.md b/README.md index 34ea70f..49a68a5 100644 --- a/README.md +++ b/README.md @@ -7,14 +7,14 @@ Rstack shared configuration. ## Add build context -> [!IMPORTANT] -> This integration requires the public `define.extends()` API proposed in -> [Rstack RFC #504](https://github.com/rstackjs/rstack-cli/discussions/504). The lower-level -> implementation is still landing in Rstack, so keep this setup gated until a released Rstack -> version exports that API. +Use Rstack 0.8.4 or later for the tested shared-config integration. Install Context alongside +Rstack in the consuming project: -After that API is released, install `@rstackjs/context` and add its shared configuration to -`rstack.config.ts`: +```bash +pnpm add -D @rstackjs/context rstack@^0.8.4 +``` + +Add its shared configuration to `rstack.config.ts`: ```ts import { createRstackContextConfig } from '@rstackjs/context/shared-config'; @@ -31,11 +31,23 @@ define.app({ }); ``` -Use `products: 'library'` for a library-only project, or -`products: ['application', 'library']` when the same Rstack configuration intentionally defines +For a library, select the library product and configure Rslib: + +```ts +import { createRstackContextConfig } from '@rstackjs/context/shared-config'; +import { define } from 'rstack'; + +define.extends([createRstackContextConfig({ products: 'library' })]); +define.lib({ lib: [{ format: 'esm' }, { format: 'cjs' }] }); +``` + +Use `products: ['application', 'library']` when the same Rstack configuration intentionally defines both. Product selection is explicit so adding Context does not change Rstack's automatic Rstest App-versus-Lib inheritance. +Run `rs build` for applications or `rs lib build` for libraries. Each completed build writes +metadata to `.rstack/cache/context-v1` in the workspace root. + The factory enables metadata capture by default. Set `capture: 'off'`, or set `RSTACK_CONTEXT=0`, to disable it. `RSTACK_CONTEXT=1` enables metadata capture for an invocation. diff --git a/docs/repository-extraction.md b/docs/repository-extraction.md index 2e945e4..deacd64 100644 --- a/docs/repository-extraction.md +++ b/docs/repository-extraction.md @@ -2,9 +2,9 @@ ## Status -Revised on 2026-09-23 after -[Rstack RFC #504](https://github.com/rstackjs/rstack-cli/discussions/504) replaced the proposed -Context-specific CLI plugin system. +Updated on 2026-10-20. Rstack shipped the shared-configuration API from +[RFC #504](https://github.com/rstackjs/rstack-cli/discussions/504) in 0.8.2. Context validates its +integration against Rstack 0.8.4 and stable Rslib 1.0.3. ## Decision @@ -38,20 +38,20 @@ flowchart LR The stdio process is started by importing `runContextMcpServer()`. It is not a package binary or a custom Rstack command. -## Shared-config gate +## Shared-config integration -Passive build integration uses `@rstackjs/context/shared-config`, but it remains release-gated until -Rstack publishes the complete RFC #504 contract: +`@rstackjs/context/shared-config` returns ordinary shared configurations for `define.extends()`. +The factory checks its return value against Rstack's public `RstackConfig` type at build time. +Its published declarations keep the narrower Context type, so standalone consumers do not need +Rstack installed. -- public `RstackConfig`; -- public `define.extends()`; -- shared-layer loading; -- App and Lib native merges; and -- merged Test configuration before automatic App-versus-Lib inheritance. +Rstack loads shared layers, merges App and Lib settings, and resolves test inheritance. Context +adds the build observer to the selected products. It does not implement a second config loader +or merge policy. Native CLI tests cover both products, nested layers, local overrides, disabled +capture, config provenance, and automatic test inheritance. -The package can still ship and run its MCP, lint, test, Rsdoctor, store, and query features without -that Rstack release. Documentation must not present passive build setup as currently available until -the gate is satisfied. +Rstack is a development dependency for these checks. MCP, native lint and test capture, Rsdoctor, +and store queries still run independently of the CLI. ## Native test boundary @@ -79,7 +79,7 @@ The repository must pass: - frozen install; - Rslint type-aware checks and formatting; - Rslib build and declaration generation; -- the full Rstest suite; +- the full Rstest suite, including native `rs build`, `rs lib build`, and `rs test run` fixtures; - real stdio MCP initialization; - native Rstest related preflight and execution; - missing-config rejection before `createRstest`; diff --git a/docs/rfc.md b/docs/rfc.md index 22cc75b..c4a8d47 100644 --- a/docs/rfc.md +++ b/docs/rfc.md @@ -133,7 +133,9 @@ publish their results after the one-shot command completes. | Plugin skills | Select the relevant tools and present their evidence boundaries to the model. | `@rstackjs/context` owns the store, producer adapters, normalized evidence, queries, build observer, -and MCP implementation. Rstack integration is ordinary shared configuration through RFC #504. +and MCP implementation. Rstack integration uses the released `define.extends()` API from RFC #504. +Rstack owns layer loading, tool-specific merges, and test inheritance. Context adds build observers +through the selected App and Lib configs. Native CLI fixtures validate this with Rstack 0.8.4. Explicit lint and test captures use native producer configuration and do not depend on Rstack CLI. ### Monorepo process model diff --git a/docs/upstream-requests.md b/docs/upstream-requests.md index fce6027..3fde7e3 100644 --- a/docs/upstream-requests.md +++ b/docs/upstream-requests.md @@ -4,12 +4,12 @@ This file lists only gaps that remain after Context moved out of Rstack CLI. ## Rstack shared configuration -[RFC #504](https://github.com/rstackjs/rstack-cli/discussions/504) is the only required Rstack -integration. Internal layer primitives and shared-config normalization have landed, while public -`define.extends()`, shared-layer loading, and the remaining merge paths are still incomplete. +[RFC #504](https://github.com/rstackjs/rstack-cli/discussions/504) shipped in +[Rstack 0.8.2](https://github.com/rstackjs/rstack-cli/releases/tag/v0.8.2). The public +`define.extends()` API, `RstackConfig` type, layer loading, and tool merges are available. -Context's `@rstackjs/context/shared-config` export must remain draft-gated until a released Rstack -package provides the complete public contract and an App/Lib integration fixture passes. +Context checks its shared-config factory against the public type and tests App and Lib builds +with Rstack 0.8.4. This integration no longer has an upstream API blocker. ## Rsbuild stats diff --git a/package.json b/package.json index d023d1b..1f9a642 100644 --- a/package.json +++ b/package.json @@ -69,13 +69,14 @@ "zod": "4.4.3" }, "devDependencies": { - "@rsbuild/core": "2.1.11", + "@rsbuild/core": "2.2.14", "@rsdoctor/agent-cli": "0.1.2", - "@rslib/core": "1.0.0-beta.3", + "@rslib/core": "1.0.3", "@rstest/coverage-istanbul": "0.12.1", "@types/node": "24.13.3", "pkg-pr-new": "0.0.87", "prettier": "3.9.6", + "rstack": "0.8.4", "typescript": "7.0.2" }, "peerDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bc96688..ec76ccd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,14 +23,14 @@ importers: version: 4.4.3 devDependencies: '@rsbuild/core': - specifier: 2.1.11 - version: 2.1.11 + specifier: 2.2.14 + version: 2.2.14 '@rsdoctor/agent-cli': specifier: 0.1.2 version: 0.1.2 '@rslib/core': - specifier: 1.0.0-beta.3 - version: 1.0.0-beta.3(typescript@7.0.2) + specifier: 1.0.3 + version: 1.0.3(typescript@7.0.2) '@rstest/coverage-istanbul': specifier: 0.12.1 version: 0.12.1(@rstest/core@0.12.1)(supports-color@7.2.0) @@ -43,72 +43,75 @@ importers: prettier: specifier: 3.9.6 version: 3.9.6 + rstack: + specifier: 0.8.4 + version: 0.8.4(typescript@7.0.2) 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==} + '@ast-grep/napi-darwin-arm64@0.45.3': + resolution: {integrity: sha512-rMZJ6frSpR/xSbeV4hiA9WR5hHqk3rVYX8lOqEoUCaUnjEuFXvHdGN4WawJ8pVDq/IqM1UnTtsDSYgUdVmO5DA==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@ast-grep/napi-darwin-x64@0.37.0': - resolution: {integrity: sha512-zvcvdgekd4ySV3zUbUp8HF5nk5zqwiMXTuVzTUdl/w08O7JjM6XPOIVT+d2o/MqwM9rsXdzdergY5oY2RdhSPA==} + '@ast-grep/napi-darwin-x64@0.45.3': + resolution: {integrity: sha512-tIclZ5pM+YSgk8NNTBoTkaclbS39cU5lbVfSrFWX4LUnxXfUPG+F+ppxyFjKwJTBELsGHEO4LdX3/Lc+FDwPNA==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@ast-grep/napi-linux-arm64-gnu@0.37.0': - resolution: {integrity: sha512-L7Sj0lXy8X+BqSMgr1LB8cCoWk0rericdeu+dC8/c8zpsav5Oo2IQKY1PmiZ7H8IHoFBbURLf8iklY9wsD+cyA==} + '@ast-grep/napi-linux-arm64-gnu@0.45.3': + resolution: {integrity: sha512-Bm8F7/oRHQkDXbjH+gBi9oB5huJ0q12r/AIOMIypf5PWlAVvh206Tsw468phw4Oh6kd4Ad1Z+gflNW1MutgK2g==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@ast-grep/napi-linux-arm64-musl@0.37.0': - resolution: {integrity: sha512-LF9sAvYy6es/OdyJDO3RwkX3I82Vkfsng1sqUBcoWC1jVb1wX5YVzHtpQox9JrEhGl+bNp7FYxB4Qba9OdA5GA==} + '@ast-grep/napi-linux-arm64-musl@0.45.3': + resolution: {integrity: sha512-yr5xcRerV41GeRxmZ8SKbfGeyZRhQePoNNCgTAb4OrWMelWwbMI80aY7/IlF2bo1sN40BSW4hZdGIO/akTd1RQ==} 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==} + '@ast-grep/napi-linux-x64-gnu@0.45.3': + resolution: {integrity: sha512-lluRK3hxfHMQdFuVID6seAKVx4FH0kEwF6rCh5/I40Sf3EtNR2UCBXAqd6RBA6hqlSckktvBxCfw+h4aescDmg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@ast-grep/napi-linux-x64-musl@0.37.0': - resolution: {integrity: sha512-/BcCH33S9E3ovOAEoxYngUNXgb+JLg991sdyiNP2bSoYd30a9RHrG7CYwW6fMgua3ijQ474eV6cq9yZO1bCpXg==} + '@ast-grep/napi-linux-x64-musl@0.45.3': + resolution: {integrity: sha512-09wJQKq86LqILqQtw0+X09SzhcPwUnMVFKyia7S3XLbLqSeQpAMF55Y+zll53JRQ1dCzFEZJyvTT/7j9vwfE3w==} 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==} + '@ast-grep/napi-win32-arm64-msvc@0.45.3': + resolution: {integrity: sha512-/HiVgw2cdtzXLDLoG4eBBcauekNXewfagy7qiVbjkVu/KcCH8dJ9lpj8DzeWwuBNtZxuD8jpiwP0M1pz3Bc3uw==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@ast-grep/napi-win32-ia32-msvc@0.37.0': - resolution: {integrity: sha512-uNmVka8fJCdYsyOlF9aZqQMLTatEYBynjChVTzUfFMDfmZ0bihs/YTqJVbkSm8TZM7CUX82apvn50z/dX5iWRA==} + '@ast-grep/napi-win32-ia32-msvc@0.45.3': + resolution: {integrity: sha512-/gwJrJQUw65uNtAFcG2rtSTr117Gqqy3bfjaljCPKsa65dTeRcMMnjKLlZ6V9/g8z+4Xf78kdhp5ECbir4yfSw==} engines: {node: '>= 10'} cpu: [ia32] os: [win32] - '@ast-grep/napi-win32-x64-msvc@0.37.0': - resolution: {integrity: sha512-vCiFOT3hSCQuHHfZ933GAwnPzmL0G04JxQEsBRfqONywyT8bSdDc/ECpAfr3S9VcS4JZ9/F6tkePKW/Om2Dq2g==} + '@ast-grep/napi-win32-x64-msvc@0.45.3': + resolution: {integrity: sha512-CEZAMAsaDoQoVE3MT2c/ry3/ydfyaqlTjxv8DK5PG355SrdAYPgevGC2Me6tz4dhphJin89Y20VsnrS5UrYV5Q==} engines: {node: '>= 10'} cpu: [x64] os: [win32] - '@ast-grep/napi@0.37.0': - resolution: {integrity: sha512-Hb4o6h1Pf6yRUAX07DR4JVY7dmQw+RVQMW5/m55GoiAT/VRoKCWBtIUPPOnqDVhbx1Cjfil9b6EDrgJsUAujEQ==} + '@ast-grep/napi@0.45.3': + resolution: {integrity: sha512-bjJsUt0g3UCwbn9bjv7MnoR15rszSm79yPCXn4DWWK0NuYUUqBU/srtNMR4WnpfR7TnfBRdYFoqSNx0pGCNWJg==} engines: {node: '>= 10'} '@emnapi/core@1.11.3': @@ -152,28 +155,8 @@ packages: '@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 - - '@rsbuild/core@2.2.9': - resolution: {integrity: sha512-ZeOYQYIBOtYkfvmbNUiKEYBK1aMGE03mhuHKVcHiWk+Y63FuUTnuQtlr1RyLErh0E7F/KyDTIaraDVH3g3GNkA==} + '@rsbuild/core@2.2.14': + resolution: {integrity: sha512-HNzuviBQuaPn24ltmlW0BwwB9YclY9sFz847Qyh8CeTtLLkfznmkSMeXeUHSTcZC22gveRKkut7iLjI1HPzCjw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -187,8 +170,8 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - '@rslib/core@1.0.0-beta.3': - resolution: {integrity: sha512-OtfmaBoGlHo1KYvQ3+B0ZLy3zUsAdoRZfWieaxoJMJ9uKAws2//afFgvUqeSDkkSJDlp8TDdgt4hib+QaFOaGQ==} + '@rslib/core@1.0.3': + resolution: {integrity: sha512-a5IZiyf9sNeeE9jcTsTmz16vxcThpbOSJg0BZXzJAxrUFOZX5xG25PJua9hr0LDWGryBX2UQTruVsLQ8BCpqng==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -209,236 +192,296 @@ packages: jiti: optional: true + '@rslint/core@0.9.5': + resolution: {integrity: sha512-bqeT2VHn0zlvRUecq7naWBqDCdr2eHphijSAAOVHAdKoNJx5KHpL+/wchJUnaPop5KtsPTi5crNJQJ4GWyoleg==} + 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-arm64@0.9.5': + resolution: {integrity: sha512-O+GcAJ/4eH1a+uykSsg8YlOUNKRLekO0mOplYdqYdO1PLWMpIZOyRP9elpkBh8kVvKz+6T8Edk7/tey6xe1Q4g==} + cpu: [arm64] + os: [darwin] + '@rslint/native-darwin-x64@0.8.0': resolution: {integrity: sha512-F5pabdH7dluxoj7PGuQjPYuoNU+yxnctcJzqrb/CZ122FLP3uJPrbiLufh+ZF9e5ui700rNyb7macJqnHlBV2w==} cpu: [x64] os: [darwin] + '@rslint/native-darwin-x64@0.9.5': + resolution: {integrity: sha512-coi3Tc3oVyE3jQHeuTcM9tmJfpeaqvAHBAsSdc/EdfS0DtqJPuXgZ2RGHIO6Nnk2Y1iXQTVaeClMBJ7msTGhXg==} + 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-gnu@0.9.5': + resolution: {integrity: sha512-+Atb7o2DrMWCHdvTBe22YoVNwbgbZDqI1va17IUv71YKkraA8smoCsTLBKT2AyuOFrhMEHIibg5wj3nQqcuVLQ==} + 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-arm64-musl@0.9.5': + resolution: {integrity: sha512-S05xGzZcHBDFucN/sM9yuAk707Ngrz+bR7aRk8EHhk4gss5CvMjKTNqp+ZTBcduPtmKHtwwoeSFtOlTAmZq30Q==} + 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-gnu@0.9.5': + resolution: {integrity: sha512-5f/KsD7TbWdg/V9Csij60GGlIhOM6q2v6c3Js1TUYfsDItP7z894okFHH03FjCJchI9ghB4dl7JicxHAgcBvEg==} + 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-linux-x64-musl@0.9.5': + resolution: {integrity: sha512-opGVrJmAvDTBVQ7r74c9T6XyNXJsNnA5jlgsijzgz7jZi/yTudbspOFMVr8j1qO0hItgDZb2h5oow3FkqPCylA==} + 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-arm64-msvc@0.9.5': + resolution: {integrity: sha512-2KUzAKS7oy5wEaam0MHAUUGwPQnDXvp8AY/XfwdIYbQP69hWxCBYcUabnCxqb+1YGUIVmmpy/CwP1u9Wknigww==} + 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] + '@rslint/native-win32-x64-msvc@0.9.5': + resolution: {integrity: sha512-KKzNb2IIa/rv0H8YCrfrd3RSRIxBxm25Io4QAW4ZDOTyJurruOvJBzajNvck7bARXz9t0Si451IlKYnj5SRS/g==} + cpu: [x64] + os: [win32] - '@rspack/binding-darwin-arm64@2.2.7': - resolution: {integrity: sha512-m0ijteZwZfUZeFcbyxwTe6N+I7aceV5/iW39b7fIIoMN/mSv9zL0ieeRzwuFe7mTYGwCuwNieDZHXfMFdp2Q2w==} + '@rspack/binding-darwin-arm64@2.2.9': + resolution: {integrity: sha512-aIHkk/wngpwgWkwVQFz2AIkmG9WpWtWlkqy4BlYhs5a7l9z0BrB0kXnUb6VSbqZ7Tc3cRepKw7ipkGjKa8KKXw==} cpu: [arm64] os: [darwin] - '@rspack/binding-darwin-x64@2.1.10': - resolution: {integrity: sha512-my/0h2LwxCRT6cg3oDDC2e0ZOxQLVajAdIcv0fqnQk5JRNvVuL89PuTutitnSqie1A0/JSL8OQz5XHwmoS3kow==} + '@rspack/binding-darwin-x64@2.2.9': + resolution: {integrity: sha512-QtUS7lyOE5oxdMm7RH/Fw176jqwdHiTW2qEsmbdCYzPw3t7QkmI5ohoyiuxOp3czJfaayXP6bVyvm2FqqnWPAw==} cpu: [x64] os: [darwin] - '@rspack/binding-darwin-x64@2.2.7': - resolution: {integrity: sha512-f27/OubIeCe5Hzze1bm+k/CQ2XX9hk3E/FuK4lym1bjqZ9spWSuolC3sK/yU/Pc3kwMPuf53byUm3FM+QC/blw==} - 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-gnu@2.2.7': - resolution: {integrity: sha512-uGEX6kY/Pi3wPZgaEk6vTevZOHjKw8qmd5pqWVwRmpLhwvPB7LGqVqx5BMnNYBjsndVUWJQQRlGL8DoVG5IXXQ==} + '@rspack/binding-linux-arm64-gnu@2.2.9': + resolution: {integrity: sha512-GMKxtBJAMoqBPqXZVi0QanDkneWwgiKRpTgHW/pbKPY/2XMfZJ98thm4YlATO18XZJ2KET+GfsNfaf1o5Y+aaA==} 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-arm64-musl@2.2.7': - resolution: {integrity: sha512-qBvztXsOgdi69zi8BhIUz+yc/alqR/wQVmcdCh+1SbhcsNpMsFUndxvVoR1tOboBsEo//gxOG4GxH8zlemhbUA==} + '@rspack/binding-linux-arm64-musl@2.2.9': + resolution: {integrity: sha512-aW3fcM64R5mY2+ym6Q8okAo/pcVIjAliqxFOc9lf9hCyS7hHaabWo30smeMX3neEjsqw+iyNbzWMuH0G5AugTw==} cpu: [arm64] os: [linux] libc: [musl] - '@rspack/binding-linux-ppc64-gnu@2.1.10': - resolution: {integrity: sha512-U7HlNzHcDtZ+LYOtOJmtx67kHEybZzUUAaP7aEXjGYO5WTCgh/176sW2UYP0rmZLrgUNFUuzn+B98RLaClNaVg==} + '@rspack/binding-linux-ppc64-gnu@2.2.9': + resolution: {integrity: sha512-dVEiyZdANq+g/qugI64vKnxO+PCVicidu/GQ6sO0QEWGVzdSUbxHztHviSnMwdvRs6OLrN7Lwhaw536hJ/cU8w==} cpu: [ppc64] os: [linux] libc: [glibc] - '@rspack/binding-linux-ppc64-gnu@2.2.7': - resolution: {integrity: sha512-t+/tOPoMu3ZZF8I1Wvqd4Jrib1teOOgb0S4jGb4nSVryuDIWgjny0FLGv5xc8RC7e4esVF93D4wF62Kt9Pxfvw==} - cpu: [ppc64] + '@rspack/binding-linux-riscv64-gnu@2.2.9': + resolution: {integrity: sha512-uBHWmfMm502145le0EUOsBoV6bJ9XA5s3NQLL4smSMoSEuMbNrRhqDJ2+B1u8aXVfjfhxCPyNlMDmgbsdVaO0Q==} + cpu: [riscv64] os: [linux] libc: [glibc] - '@rspack/binding-linux-riscv64-gnu@2.1.10': - resolution: {integrity: sha512-GMGTJpy9/ecE+5F5IfxZH4bXv0Wx/b2TiehTlCbTksbL+pKpLHYy0rwGdjWDKbmBkhxMMqPiC7PDnn9LbdnnLA==} + '@rspack/binding-linux-riscv64-musl@2.2.9': + resolution: {integrity: sha512-MR7e6PP7KcG3jHYWznk17npBBvjUXQg7qQbS+yrAu0R3p/27wLp+aP5m/zk4ev1Iq93ccIBIAGVRGA3v1fSKew==} cpu: [riscv64] os: [linux] + libc: [musl] + + '@rspack/binding-linux-s390x-gnu@2.2.9': + resolution: {integrity: sha512-Tc9Nuv7XvkrAG79Ps/6F7vT4bxZqiaiQ3Rd3BidKUjVYiwTPJogk1W3gPXp7Lw+AMoN/dY63W22bMJ7Oh0FeZw==} + cpu: [s390x] + os: [linux] libc: [glibc] - '@rspack/binding-linux-riscv64-gnu@2.2.7': - resolution: {integrity: sha512-ZkN6K4PRTPqfYI7gPYv1PqgUXKsZDeeFPFoQ4LLiFxhIGczdJY5XAPPoHN+N7d91hAGWi6u+UHYrUp1HvudBOw==} - cpu: [riscv64] + '@rspack/binding-linux-x64-gnu@2.2.9': + resolution: {integrity: sha512-xQYhQwC8q3mSCzHV+yi76Gt2Lt9BhnmeJnQgXGjuhyvjZWBB9jFNp9Uj6umun7tlMliMRhoqTesqvK4Vo/kkcQ==} + cpu: [x64] os: [linux] libc: [glibc] - '@rspack/binding-linux-riscv64-musl@2.1.10': - resolution: {integrity: sha512-rkurnAWc04vIbzG1QCrPBWSJadZvaOt1mazFH3EdiJO8VUiu0I1T9zdiwuDOPrd50lOKIZlcTXbd5aaAkWEnvQ==} - cpu: [riscv64] + '@rspack/binding-linux-x64-musl@2.2.9': + resolution: {integrity: sha512-FfEYdFhOd1nZL+U/up8nlbcRTTyWX4ohXUQcApQMYKIZoR1EJ0g/oBhS915+TSray0DchA+NT6yVL1sqWJOFaQ==} + cpu: [x64] os: [linux] libc: [musl] - '@rspack/binding-linux-riscv64-musl@2.2.7': - resolution: {integrity: sha512-ywcmeNAC59HtVJ4a5jy5cEUA4JNa19QlVtTW09Pzh0+luNEasWaDfOaXJpYJTGWcycVwzMm3A9aNN3MqvO87EQ==} - cpu: [riscv64] + '@rspack/binding-wasm32-wasi@2.2.9': + resolution: {integrity: sha512-+29JEBiUi2TMDOGYmT3Tm3JGk3f19+CbjxNEt1bVreR8axuZZj+wOSwB/sx85lKQkL5fuNPh0BhOZ7cdkDxQpA==} + cpu: [wasm32] + + '@rspack/binding-win32-arm64-msvc@2.2.9': + resolution: {integrity: sha512-x/BDAwQCkR+ANWAAWV+15PFWuae2z9DzL+QcVeb+5taOri+tHa/iQdG8e3GJ60d0amsR+u9BJ2CBtJLK1fQkxw==} + cpu: [arm64] + os: [win32] + + '@rspack/binding-win32-ia32-msvc@2.2.9': + resolution: {integrity: sha512-i622t0Mom3STiDi8VHLaX62LKwhbT2kg37fKjzv3wQT+9bJvQ/+St6QQzFZg9C7fV53LXqAw8jFi5yIPIjg0Qw==} + cpu: [ia32] + os: [win32] + + '@rspack/binding-win32-x64-msvc@2.2.9': + resolution: {integrity: sha512-K98FPCAv0VpDPAz5ZqZ0XI1CmdJu9Kw1FmUDl4rN/zQIt9iqYn+Hgxq6Nk6Zx7g938jiG7ABLwITXSIKGbs6aw==} + cpu: [x64] + os: [win32] + + '@rspack/binding@2.2.9': + resolution: {integrity: sha512-Y8wDJoRo8n0KXDGq6J3KGVekkUQcOcUHSppMHoJv8JLbNBEsyaaKiDx4ljNTGEDtTON0CRbMEWbSiLRe+bJxOQ==} + + '@rspack/core@2.2.9': + resolution: {integrity: sha512-Tp6/ZdM5UrGB3BeLfvh69l1iX03PZ46IgXjKqubVEuJVAhnOjpshooH/YAxcxR0AymcBLKA997Z4v+LV2aXzCw==} + 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 + + '@rstackjs/cli-darwin-arm64@0.8.4': + resolution: {integrity: sha512-ccRtjHXRPa1h4CpEegsg88ajUP3F3yUpyK+TVRwxbjlVJSt/wPZk1198euCRWkfOEdEv5Q9IVdpxb5RmCUZHAQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rstackjs/cli-darwin-x64@0.8.4': + resolution: {integrity: sha512-UNYaw6eaek7jf4+7lweliFElQ2QDI3q/eYq5zeUZKSzL1Dk8mOI6WuIMbmG4bQJeh2fdho5ABAyxlcS3UprlMw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rstackjs/cli-linux-arm64-gnu@0.8.4': + resolution: {integrity: sha512-JsKBQx41Vkvb4VuJlZ3SB/x8ifrkPYLNLGBLABK9xy+JIXiJtL6XEQs5coKUvm+uELj8EnHkmR4gXdDTsF5jBw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rstackjs/cli-linux-arm64-musl@0.8.4': + resolution: {integrity: sha512-WiN3ISusxn623D7runNEftyYPVH5gI/1UHCA/8VsEp/fptY++fCXFjZDaj1KgFWYtV4MHYcKhsx3cZJn3eHsNw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] os: [linux] libc: [musl] - '@rspack/binding-linux-s390x-gnu@2.1.10': - resolution: {integrity: sha512-X+DyxkriZEAF/wihI7ERDv+CAS0mbMv36aEuQ+vXzTlvS6cSmpou/r29AHbvIF3NlG1UeAbDVlOs9QrMBZjpUQ==} - cpu: [s390x] + '@rstackjs/cli-linux-ppc64-gnu@0.8.4': + resolution: {integrity: sha512-rkGXQLbMDNHfX66yVFfK+KAtFVX4KyP2g+7+ALSRupFaKAJBB7mMfIvya9mmEns/6vKgHoJgXC3hCalByhYAJw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] os: [linux] libc: [glibc] - '@rspack/binding-linux-s390x-gnu@2.2.7': - resolution: {integrity: sha512-U/np+nOj9mka5CdM9FKNkrAShw0UoaarBLzOxbBHu4sqMt3iXQCi91fblLdhUDoWuIQrnV/WrqygFeIP2qAHIw==} - cpu: [s390x] + '@rstackjs/cli-linux-riscv64-gnu@0.8.4': + resolution: {integrity: sha512-skPAB8iy5Fww5mYeSOHkwxi9t96qCO86CM0RUxM19/ynw9/jCqlRIB5blDUHo5C1nkg5qzS1MRpF9a+ef0MBrA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] os: [linux] libc: [glibc] - '@rspack/binding-linux-x64-gnu@2.1.10': - resolution: {integrity: sha512-Fat09V6jUuyo9qG7Wyj9cQ31VDfLmokXyBtGqKxY5OvSWHereB7QUub5btbPXHwbp6Iq4aAQyUbbLTzvR1YaBw==} - cpu: [x64] + '@rstackjs/cli-linux-riscv64-musl@0.8.4': + resolution: {integrity: sha512-eZe5TGbJpQBTt/2wnmNNVizVzlJPIpqFfgmpBEzYhEsbTp52+bC9K5+1nlurJ+I2aMXtsXrPJF7qwB1d+j0DQg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] os: [linux] - libc: [glibc] + libc: [musl] - '@rspack/binding-linux-x64-gnu@2.2.7': - resolution: {integrity: sha512-PkryZX3GZeBQfzvKEhwGGd9m43PwqcsqWd14kmsVADeBCqHLxJahMGiGmajd28vSa42xbw7HTb8yY4jXzv5QtA==} - cpu: [x64] + '@rstackjs/cli-linux-s390x-gnu@0.8.4': + resolution: {integrity: sha512-a8gje5rHiBCJNcCuKrVh9yA5GM//eXQplDvzFhyYzUvvmHK9Fse6XpZO1/ub14grWua5k0qKcq63CafdX2wyxw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] os: [linux] libc: [glibc] - '@rspack/binding-linux-x64-musl@2.1.10': - resolution: {integrity: sha512-lhHOnIJ4ClpIlA1f1L8aoxEZivYLjnjq5A6jKKz7BKsm+cHK8kqqEm6lO5KqA5xQT0Lonq1o28bmKHEj6JHInw==} + '@rstackjs/cli-linux-x64-gnu@0.8.4': + resolution: {integrity: sha512-pCWue/NTJP8rKclQg+wKp7hmEjGmhaByz6UYOKIetM2TgYlV1IZYC29rK1m8xs6MRT47z15Mqw0oKx7HOrA8tA==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] + libc: [glibc] - '@rspack/binding-linux-x64-musl@2.2.7': - resolution: {integrity: sha512-atJn62IgXnc/RhWiFgEiNx7IHAJbHunttzjMpWJLjWiFuJ2c+tLQrTBIiqO3VAIXh+haN1jZLHXuMVj5/J+edA==} + '@rstackjs/cli-linux-x64-musl@0.8.4': + resolution: {integrity: sha512-vaOK/zdXtb5ibbYb6w7g2lrunlgGnvARlyf2qLGcLMGOkol4cSiANhuMg3cLQynQKCn0cjkJ8sE2SGkNPRSQmw==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@rspack/binding-wasm32-wasi@2.1.10': - resolution: {integrity: sha512-KY5YbWbuvYcoaLXnV+vzZOvGRCeb6jt4EpVpKdph1h1IJjwX/ju15EQ+GOe3iecZEdf0OttQcNVcwBkLkFT9ag==} - cpu: [wasm32] - - '@rspack/binding-wasm32-wasi@2.2.7': - resolution: {integrity: sha512-SRDYrqWnD8Y+VNj8+11KggFaVMFSbyu+9WC2f9sygzjFu78GmF0kLKaHNQMaJv6MUMSBk/OhtEbUB4hmH7czVQ==} - 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-arm64-msvc@2.2.7': - resolution: {integrity: sha512-etHFjzSP/jnnrVYuim9C/nhSlN0/tQowVz0CSJOUHtDH7ZZiUsvWtkH9Zx3tDaZOB7sAw9cbL36mbmdkb4U6cw==} + '@rstackjs/cli-win32-arm64-msvc@0.8.4': + resolution: {integrity: sha512-UADzq2Qn1d8lxsR9ACKAwFFlcEgsBKHXuE8r6TWHlRlglMYIr5oKhuVrG5eEIQCXWixcqdSaxZd57wWA0S8FpQ==} + engines: {node: ^20.19.0 || >=22.12.0} 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-ia32-msvc@2.2.7': - resolution: {integrity: sha512-26HiMUQk8QKVw477gW9HorIQ3r0le3AL4OFigJr1/HAumi5qyOPazck+wWtUDh1R9bSf8etZjkuseBGtjN5+KQ==} + '@rstackjs/cli-win32-ia32-msvc@0.8.4': + resolution: {integrity: sha512-3vfBm7aoCMFuTmMS32EtSFQpcPgeReG5B2qcDtPxFXhCTpqFzPR81R27OF9NQuVUJxMKtQsCJPEfAcdxJnxQ+w==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@rspack/binding-win32-x64-msvc@2.1.10': - resolution: {integrity: sha512-pgp23pLrzfhGnKycxzr7ifP17lAbWZEfnx1bX8gXtYrnpJ66DRNyTKSzxB6sa/HBWjS1L8PX5TjMZ44WfPydqQ==} - cpu: [x64] - os: [win32] - - '@rspack/binding-win32-x64-msvc@2.2.7': - resolution: {integrity: sha512-ut8tVSXP8DWQE/JUwQ1oqwuAaAgySgYF66HpWmxfR9J4nLzGmhd7CnnaCTT3vUyQQNmLKFida/AbiqJ/gWmPrw==} + '@rstackjs/cli-win32-x64-msvc@0.8.4': + resolution: {integrity: sha512-loiQHQzulGzzE2Rj/JiNl0CczN9fhOtCoyqFWw3OieCdNF+mGjrd0hI0pRyHWgLomAk+iisGJBS6WyXgp4f1fA==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@rspack/binding@2.1.10': - resolution: {integrity: sha512-vnu/UP5HnrND15lO9+VeG6eUrbTyycHNQNQ3XEiRiFojuoiGZkIZC3Hbzr8qQH44C6vScPODEPvvIVvLcO2LpQ==} - - '@rspack/binding@2.2.7': - resolution: {integrity: sha512-opY7Sw6iwtBa23CeNj18GOqKBlaJUZXp52kNIGyNq+7jpCQKrJYeX5wiK7uBn8/ZpHQfhwNL2xxu5yzG4JFH1g==} - - '@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 - - '@rspack/core@2.2.7': - resolution: {integrity: sha512-kWKd4lYp13GPAQy2eQITLTvPbl+ST7ffG/MCGHoX138o5eTetMQMe/oc7UAZAfjervAM6UOzEhiiCirPTpRBUQ==} + '@rstest/core@0.12.1': + resolution: {integrity: sha512-irrbH5VPqiHqIVM2Ta7Ib9sPyQ5Hsvxt0sSqqwRp/3j2by0sSu9ZeIMYzMEZj1puPwx2a8l3aFWW+Bk2ycKlEA==} engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true peerDependencies: - '@module-federation/runtime-tools': ^0.24.1 || ^2.0.0 - '@swc/helpers': ^0.5.23 + happy-dom: ^20.8.3 + jsdom: '>=15.0.0' peerDependenciesMeta: - '@module-federation/runtime-tools': + happy-dom: optional: true - '@swc/helpers': + jsdom: optional: true - '@rstest/core@0.12.1': - resolution: {integrity: sha512-irrbH5VPqiHqIVM2Ta7Ib9sPyQ5Hsvxt0sSqqwRp/3j2by0sSu9ZeIMYzMEZj1puPwx2a8l3aFWW+Bk2ycKlEA==} + '@rstest/core@0.12.4': + resolution: {integrity: sha512-NL+lxZ6GiJkL2BYgUR8WU2awQr71GiIzG0rDNKljvhEhmIX61YUoG6qjvLlEFJsvmB/AZmKIYwP/PyFTHZZy1A==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -455,6 +498,90 @@ packages: peerDependencies: '@rstest/core': ^0.12.0 + '@swc-next/decoder@0.2.4': + resolution: {integrity: sha512-jueQ/rCGC+VsJ37KVbZy48/DETXla1b5+ycXXXipwaqMt2cjmN5qcheLh146gGrFEv31d6dTXTW/SmKzpHj+fg==} + + '@swc-next/parser-binding-darwin-arm64@0.2.4': + resolution: {integrity: sha512-MV0H2E3vWRiJkbZrn7c+QwwOamYefmET9vxLuvWOtTCx1GhC7tMi0sLtaZT4D/BRic+7InuaFj63M8c4q7QEJg==} + cpu: [arm64] + os: [darwin] + + '@swc-next/parser-binding-darwin-x64@0.2.4': + resolution: {integrity: sha512-aI+Py4zkeL3avtx+9cs6zzjIYwN15jU5PGKYqiuwZrMAgZ+Lpjsr/0e9gof3L9RG71/OMnFxNgVoZq0XflxFLg==} + cpu: [x64] + os: [darwin] + + '@swc-next/parser-binding-linux-arm64-gnu@0.2.4': + resolution: {integrity: sha512-VdrcT+a80SmL1zPC46EwmIlRspvCX8UDZM1lAtWZFxZ8YU+chusD5pk2GEmy9NUtSeWy1yidZrs2s8siX08F1A==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@swc-next/parser-binding-linux-arm64-musl@0.2.4': + resolution: {integrity: sha512-pZhkP32X1KuU4ej/cA43DgnFH5slNdYgMiOxhErDkCkEXeknb5tTQKbJ9kYwkVWYanJ6c1IUPWiqVng7hn9JGA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@swc-next/parser-binding-linux-ppc64-gnu@0.2.4': + resolution: {integrity: sha512-opxP4I22zAiS3pq+U50h2S42kE6IQXdZUjl/sUtuKnmnw6iGcX94/XcoVrMx1r9rEi+Bkb/Ugg+RTWHHKoy0WA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@swc-next/parser-binding-linux-riscv64-gnu@0.2.4': + resolution: {integrity: sha512-Lt2rHiCV7ybaekjQ7aS1dZf7BAHxA7IyphXG1ag9+QB3sA6AIv2TfbbutRxGL1s/s5Pn8y3a7k2Hz0kXqmE80w==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@swc-next/parser-binding-linux-riscv64-musl@0.2.4': + resolution: {integrity: sha512-12Xz09yyHKV8udkzLlWpG8ms7cv7NR/WK23H60+pWoDUyWJStJZFzC+XpGuMd5AVnBlLyRE9eOsuCzi2tAiyKQ==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@swc-next/parser-binding-linux-s390x-gnu@0.2.4': + resolution: {integrity: sha512-9L5oxUNeZTeB+wmMf9SuVa9lYSb621SAUAGOb3y/wSX8tKZvoW8ey2ywauCt6faiTxe7kLB5e0Fd4XIvV9tLlQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@swc-next/parser-binding-linux-x64-gnu@0.2.4': + resolution: {integrity: sha512-DUzEptm1OgyKUj59KSG1lQTBiXE0cCofPUfomFXgwgFsPVlWCA+ZSi0khqlSU1ogj1TbOyuQSuLJD1AH7e+YYg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@swc-next/parser-binding-linux-x64-musl@0.2.4': + resolution: {integrity: sha512-v8eFhWcJ/D2TAkH+/QjWWVIiyTtUMkfm+jROX1CdcyJWtweTMG400ItiqXJg+XsR2Bu/az7djIdzVACt9k1org==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@swc-next/parser-binding-win32-arm64-msvc@0.2.4': + resolution: {integrity: sha512-vvfUs1z0v5uPr1HxzHGuJ9FI1YegR18g26r0AR6mW2XxPv05rYzyyV+DuUyqTrcLQqHf6qNkIwVdLJaMHLOtDw==} + cpu: [arm64] + os: [win32] + + '@swc-next/parser-binding-win32-ia32-msvc@0.2.4': + resolution: {integrity: sha512-9fiuLK1bHgpQ0zlzU+V9WSKUHmBDHxi0UDtWYACRfUFDniwq8tnjL0/NNKvRuRaj5OaOXf7Pdj+65hAiIp+7UA==} + cpu: [ia32] + os: [win32] + + '@swc-next/parser-binding-win32-x64-msvc@0.2.4': + resolution: {integrity: sha512-L/JAOTervJADBWmU9mWsLBwRtuPZd+xlHPdBWefGK2zqe0jSeEzfrinNt1mtFx7CHaVOJD69KjM20Ush4rDVMA==} + cpu: [x64] + os: [win32] + + '@swc-next/parser@0.2.4': + resolution: {integrity: sha512-nb79vL2u4WnyrweXInk6AQaVfRqhHQSaP2E1gMtktTg+cDJQqFWuxVSkNl6ScZrjkZ6qfzOfQipV96TA9bZY9A==} + peerDependencies: + '@swc-next/parser-binding-wasm': 0.2.4 + peerDependenciesMeta: + '@swc-next/parser-binding-wasm': + optional: true + '@swc/helpers@0.5.23': resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} @@ -623,6 +750,9 @@ packages: cjs-module-lexer@2.2.1: resolution: {integrity: sha512-Ca8swihM+/4yKecYHY52kgJd300hi2lADU/a1RxNTRe+RJ9jvqQlESpbz9DnG9mowez8qwXHB8qYdIUw9e+F5Q==} + cjs-module-lexer@2.3.0: + resolution: {integrity: sha512-lmNyBzi6iYiqyrExTctfmjd8tInx+dHu9PBomTfH5RXS53c27JMFJgzNv5TyAnm3PgGf+MLy0+Ws41/HlVxdKQ==} + content-disposition@1.1.0: resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} engines: {node: '>=18'} @@ -883,6 +1013,11 @@ packages: resolution: {integrity: sha512-nm+30Py1csXWfyMH1ueQyTR11IZGHS5oW8Qok/MxMwjPx9g1jX3wMRrJf8TgEvNo0a0M4i14T0zQsEPWdZfAhg==} hasBin: true + prettier@3.9.10: + resolution: {integrity: sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==} + engines: {node: '>=14'} + hasBin: true + prettier@3.9.6: resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} engines: {node: '>=14'} @@ -912,8 +1047,8 @@ packages: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} - rsbuild-plugin-dts@1.0.0-beta.3: - resolution: {integrity: sha512-Q8x/yyOsy8sNR8sHn0xuZsul7ErT5kXkTmDwCIxQ8esiMCW7QKjfyXDa4kvTxWn7oSQ5NP/O6qZM2vEA07thKw==} + rsbuild-plugin-dts@1.0.3: + resolution: {integrity: sha512-wVxMYvWmVPam6SeZsz8OixHm+4JPfLJAAMTD0Q2te2SxksvjuHC3wuvU12teSmloQ3nFX1YC6guNzuYqDYw+vQ==} engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: '@microsoft/api-extractor': ^7 @@ -925,6 +1060,16 @@ packages: typescript: optional: true + rstack@0.8.4: + resolution: {integrity: sha512-BMAh+fXeNsXxPoPcZ3J551A+E0fAMFvMvY6ra6ykKk008V60gpTdSo8GDjJjGckzerrrM5PeXLuGWF/1KoMFMQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@rspress/core': ^2.0.17 + peerDependenciesMeta: + '@rspress/core': + optional: true + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -979,6 +1124,10 @@ packages: swc-plugin-coverage-instrument@0.0.33: resolution: {integrity: sha512-zM17DgG3jURPNOrEHye0MtrkrsZ9BtCMf3W5osQTHAtlNolfDUtZl7740wdx7XhUSL322qdph//DgHzZe62wHg==} + tinypool@2.2.0: + resolution: {integrity: sha512-jBrmx4lYmaC9k/mgPbylxs7kBUxHtD8256up+HjLaDFfXScKJQyil+SWXSvhAtT7XHo+yTVlpB81PGmHP8oLSQ==} + engines: {node: ^20.0.0 || >=22.0.0} + toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} @@ -1024,44 +1173,44 @@ packages: snapshots: - '@ast-grep/napi-darwin-arm64@0.37.0': + '@ast-grep/napi-darwin-arm64@0.45.3': optional: true - '@ast-grep/napi-darwin-x64@0.37.0': + '@ast-grep/napi-darwin-x64@0.45.3': optional: true - '@ast-grep/napi-linux-arm64-gnu@0.37.0': + '@ast-grep/napi-linux-arm64-gnu@0.45.3': optional: true - '@ast-grep/napi-linux-arm64-musl@0.37.0': + '@ast-grep/napi-linux-arm64-musl@0.45.3': optional: true - '@ast-grep/napi-linux-x64-gnu@0.37.0': + '@ast-grep/napi-linux-x64-gnu@0.45.3': optional: true - '@ast-grep/napi-linux-x64-musl@0.37.0': + '@ast-grep/napi-linux-x64-musl@0.45.3': optional: true - '@ast-grep/napi-win32-arm64-msvc@0.37.0': + '@ast-grep/napi-win32-arm64-msvc@0.45.3': optional: true - '@ast-grep/napi-win32-ia32-msvc@0.37.0': + '@ast-grep/napi-win32-ia32-msvc@0.45.3': optional: true - '@ast-grep/napi-win32-x64-msvc@0.37.0': + '@ast-grep/napi-win32-x64-msvc@0.45.3': optional: true - '@ast-grep/napi@0.37.0': + '@ast-grep/napi@0.45.3': 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 + '@ast-grep/napi-darwin-arm64': 0.45.3 + '@ast-grep/napi-darwin-x64': 0.45.3 + '@ast-grep/napi-linux-arm64-gnu': 0.45.3 + '@ast-grep/napi-linux-arm64-musl': 0.45.3 + '@ast-grep/napi-linux-x64-gnu': 0.45.3 + '@ast-grep/napi-linux-x64-musl': 0.45.3 + '@ast-grep/napi-win32-arm64-msvc': 0.45.3 + '@ast-grep/napi-win32-ia32-msvc': 0.45.3 + '@ast-grep/napi-win32-x64-msvc': 0.45.3 '@emnapi/core@1.11.3': dependencies: @@ -1121,33 +1270,19 @@ snapshots: '@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' - - '@rsbuild/core@2.2.9': + '@rsbuild/core@2.2.14': dependencies: - '@rspack/core': 2.2.7(@swc/helpers@0.5.23) + '@rspack/core': 2.2.9(@swc/helpers@0.5.23) '@swc/helpers': 0.5.23 transitivePeerDependencies: - '@module-federation/runtime-tools' '@rsdoctor/agent-cli@0.1.2': {} - '@rslib/core@1.0.0-beta.3(typescript@7.0.2)': + '@rslib/core@1.0.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) + '@rsbuild/core': 2.2.14 + rsbuild-plugin-dts: 1.0.3(@rsbuild/core@2.2.14)(typescript@7.0.2) optionalDependencies: typescript: 7.0.2 transitivePeerDependencies: @@ -1167,177 +1302,193 @@ snapshots: '@rslint/native-win32-arm64-msvc': 0.8.0 '@rslint/native-win32-x64-msvc': 0.8.0 + '@rslint/core@0.9.5': + dependencies: + picomatch: 4.0.5 + optionalDependencies: + '@rslint/native-darwin-arm64': 0.9.5 + '@rslint/native-darwin-x64': 0.9.5 + '@rslint/native-linux-arm64-gnu': 0.9.5 + '@rslint/native-linux-arm64-musl': 0.9.5 + '@rslint/native-linux-x64-gnu': 0.9.5 + '@rslint/native-linux-x64-musl': 0.9.5 + '@rslint/native-win32-arm64-msvc': 0.9.5 + '@rslint/native-win32-x64-msvc': 0.9.5 + '@rslint/native-darwin-arm64@0.8.0': optional: true + '@rslint/native-darwin-arm64@0.9.5': + optional: true + '@rslint/native-darwin-x64@0.8.0': optional: true + '@rslint/native-darwin-x64@0.9.5': + optional: true + '@rslint/native-linux-arm64-gnu@0.8.0': optional: true + '@rslint/native-linux-arm64-gnu@0.9.5': + optional: true + '@rslint/native-linux-arm64-musl@0.8.0': optional: true + '@rslint/native-linux-arm64-musl@0.9.5': + optional: true + '@rslint/native-linux-x64-gnu@0.8.0': optional: true + '@rslint/native-linux-x64-gnu@0.9.5': + optional: true + '@rslint/native-linux-x64-musl@0.8.0': optional: true - '@rslint/native-win32-arm64-msvc@0.8.0': + '@rslint/native-linux-x64-musl@0.9.5': optional: true - '@rslint/native-win32-x64-msvc@0.8.0': + '@rslint/native-win32-arm64-msvc@0.8.0': optional: true - '@rspack/binding-darwin-arm64@2.1.10': + '@rslint/native-win32-arm64-msvc@0.9.5': optional: true - '@rspack/binding-darwin-arm64@2.2.7': + '@rslint/native-win32-x64-msvc@0.8.0': optional: true - '@rspack/binding-darwin-x64@2.1.10': + '@rslint/native-win32-x64-msvc@0.9.5': optional: true - '@rspack/binding-darwin-x64@2.2.7': + '@rspack/binding-darwin-arm64@2.2.9': optional: true - '@rspack/binding-linux-arm64-gnu@2.1.10': + '@rspack/binding-darwin-x64@2.2.9': optional: true - '@rspack/binding-linux-arm64-gnu@2.2.7': + '@rspack/binding-linux-arm64-gnu@2.2.9': optional: true - '@rspack/binding-linux-arm64-musl@2.1.10': + '@rspack/binding-linux-arm64-musl@2.2.9': optional: true - '@rspack/binding-linux-arm64-musl@2.2.7': + '@rspack/binding-linux-ppc64-gnu@2.2.9': optional: true - '@rspack/binding-linux-ppc64-gnu@2.1.10': + '@rspack/binding-linux-riscv64-gnu@2.2.9': optional: true - '@rspack/binding-linux-ppc64-gnu@2.2.7': + '@rspack/binding-linux-riscv64-musl@2.2.9': optional: true - '@rspack/binding-linux-riscv64-gnu@2.1.10': + '@rspack/binding-linux-s390x-gnu@2.2.9': optional: true - '@rspack/binding-linux-riscv64-gnu@2.2.7': + '@rspack/binding-linux-x64-gnu@2.2.9': optional: true - '@rspack/binding-linux-riscv64-musl@2.1.10': + '@rspack/binding-linux-x64-musl@2.2.9': optional: true - '@rspack/binding-linux-riscv64-musl@2.2.7': + '@rspack/binding-wasm32-wasi@2.2.9': + 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-linux-s390x-gnu@2.1.10': + '@rspack/binding-win32-arm64-msvc@2.2.9': optional: true - '@rspack/binding-linux-s390x-gnu@2.2.7': + '@rspack/binding-win32-ia32-msvc@2.2.9': optional: true - '@rspack/binding-linux-x64-gnu@2.1.10': + '@rspack/binding-win32-x64-msvc@2.2.9': optional: true - '@rspack/binding-linux-x64-gnu@2.2.7': + '@rspack/binding@2.2.9': + optionalDependencies: + '@rspack/binding-darwin-arm64': 2.2.9 + '@rspack/binding-darwin-x64': 2.2.9 + '@rspack/binding-linux-arm64-gnu': 2.2.9 + '@rspack/binding-linux-arm64-musl': 2.2.9 + '@rspack/binding-linux-ppc64-gnu': 2.2.9 + '@rspack/binding-linux-riscv64-gnu': 2.2.9 + '@rspack/binding-linux-riscv64-musl': 2.2.9 + '@rspack/binding-linux-s390x-gnu': 2.2.9 + '@rspack/binding-linux-x64-gnu': 2.2.9 + '@rspack/binding-linux-x64-musl': 2.2.9 + '@rspack/binding-wasm32-wasi': 2.2.9 + '@rspack/binding-win32-arm64-msvc': 2.2.9 + '@rspack/binding-win32-ia32-msvc': 2.2.9 + '@rspack/binding-win32-x64-msvc': 2.2.9 + + '@rspack/core@2.2.9(@swc/helpers@0.5.23)': + dependencies: + '@rspack/binding': 2.2.9 + optionalDependencies: + '@swc/helpers': 0.5.23 + + '@rstackjs/cli-darwin-arm64@0.8.4': optional: true - '@rspack/binding-linux-x64-musl@2.1.10': + '@rstackjs/cli-darwin-x64@0.8.4': optional: true - '@rspack/binding-linux-x64-musl@2.2.7': + '@rstackjs/cli-linux-arm64-gnu@0.8.4': 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) + '@rstackjs/cli-linux-arm64-musl@0.8.4': optional: true - '@rspack/binding-wasm32-wasi@2.2.7': - 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) + '@rstackjs/cli-linux-ppc64-gnu@0.8.4': optional: true - '@rspack/binding-win32-arm64-msvc@2.1.10': + '@rstackjs/cli-linux-riscv64-gnu@0.8.4': optional: true - '@rspack/binding-win32-arm64-msvc@2.2.7': + '@rstackjs/cli-linux-riscv64-musl@0.8.4': optional: true - '@rspack/binding-win32-ia32-msvc@2.1.10': + '@rstackjs/cli-linux-s390x-gnu@0.8.4': optional: true - '@rspack/binding-win32-ia32-msvc@2.2.7': + '@rstackjs/cli-linux-x64-gnu@0.8.4': optional: true - '@rspack/binding-win32-x64-msvc@2.1.10': + '@rstackjs/cli-linux-x64-musl@0.8.4': optional: true - '@rspack/binding-win32-x64-msvc@2.2.7': + '@rstackjs/cli-win32-arm64-msvc@0.8.4': 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/binding@2.2.7': - optionalDependencies: - '@rspack/binding-darwin-arm64': 2.2.7 - '@rspack/binding-darwin-x64': 2.2.7 - '@rspack/binding-linux-arm64-gnu': 2.2.7 - '@rspack/binding-linux-arm64-musl': 2.2.7 - '@rspack/binding-linux-ppc64-gnu': 2.2.7 - '@rspack/binding-linux-riscv64-gnu': 2.2.7 - '@rspack/binding-linux-riscv64-musl': 2.2.7 - '@rspack/binding-linux-s390x-gnu': 2.2.7 - '@rspack/binding-linux-x64-gnu': 2.2.7 - '@rspack/binding-linux-x64-musl': 2.2.7 - '@rspack/binding-wasm32-wasi': 2.2.7 - '@rspack/binding-win32-arm64-msvc': 2.2.7 - '@rspack/binding-win32-ia32-msvc': 2.2.7 - '@rspack/binding-win32-x64-msvc': 2.2.7 - - '@rspack/core@2.1.10(@swc/helpers@0.5.23)': - dependencies: - '@rspack/binding': 2.1.10 - optionalDependencies: - '@swc/helpers': 0.5.23 + '@rstackjs/cli-win32-ia32-msvc@0.8.4': + optional: true - '@rspack/core@2.2.7(@swc/helpers@0.5.23)': - dependencies: - '@rspack/binding': 2.2.7 - optionalDependencies: - '@swc/helpers': 0.5.23 + '@rstackjs/cli-win32-x64-msvc@0.8.4': + optional: true '@rstest/core@0.12.1': dependencies: - '@rsbuild/core': 2.2.9 + '@rsbuild/core': 2.2.14 '@types/chai': 5.2.3 cjs-module-lexer: 2.2.1 transitivePeerDependencies: - '@module-federation/runtime-tools' - core-js + '@rstest/core@0.12.4': + dependencies: + '@rsbuild/core': 2.2.14 + '@types/chai': 5.2.3 + cjs-module-lexer: 2.3.0 + transitivePeerDependencies: + - '@module-federation/runtime-tools' + - core-js + '@rstest/coverage-istanbul@0.12.1(@rstest/core@0.12.1)(supports-color@7.2.0)': dependencies: '@rstest/core': 0.12.1 @@ -1349,6 +1500,65 @@ snapshots: transitivePeerDependencies: - supports-color + '@swc-next/decoder@0.2.4': {} + + '@swc-next/parser-binding-darwin-arm64@0.2.4': + optional: true + + '@swc-next/parser-binding-darwin-x64@0.2.4': + optional: true + + '@swc-next/parser-binding-linux-arm64-gnu@0.2.4': + optional: true + + '@swc-next/parser-binding-linux-arm64-musl@0.2.4': + optional: true + + '@swc-next/parser-binding-linux-ppc64-gnu@0.2.4': + optional: true + + '@swc-next/parser-binding-linux-riscv64-gnu@0.2.4': + optional: true + + '@swc-next/parser-binding-linux-riscv64-musl@0.2.4': + optional: true + + '@swc-next/parser-binding-linux-s390x-gnu@0.2.4': + optional: true + + '@swc-next/parser-binding-linux-x64-gnu@0.2.4': + optional: true + + '@swc-next/parser-binding-linux-x64-musl@0.2.4': + optional: true + + '@swc-next/parser-binding-win32-arm64-msvc@0.2.4': + optional: true + + '@swc-next/parser-binding-win32-ia32-msvc@0.2.4': + optional: true + + '@swc-next/parser-binding-win32-x64-msvc@0.2.4': + optional: true + + '@swc-next/parser@0.2.4': + dependencies: + '@swc-next/decoder': 0.2.4 + optionalDependencies: + '@swc-next/parser-binding-darwin-arm64': 0.2.4 + '@swc-next/parser-binding-darwin-x64': 0.2.4 + '@swc-next/parser-binding-linux-arm64-gnu': 0.2.4 + '@swc-next/parser-binding-linux-arm64-musl': 0.2.4 + '@swc-next/parser-binding-linux-ppc64-gnu': 0.2.4 + '@swc-next/parser-binding-linux-riscv64-gnu': 0.2.4 + '@swc-next/parser-binding-linux-riscv64-musl': 0.2.4 + '@swc-next/parser-binding-linux-s390x-gnu': 0.2.4 + '@swc-next/parser-binding-linux-x64-gnu': 0.2.4 + '@swc-next/parser-binding-linux-x64-musl': 0.2.4 + '@swc-next/parser-binding-win32-arm64-msvc': 0.2.4 + '@swc-next/parser-binding-win32-ia32-msvc': 0.2.4 + '@swc-next/parser-binding-win32-x64-msvc': 0.2.4 + '@swc/helpers@0.5.23': dependencies: tslib: 2.8.1 @@ -1475,6 +1685,8 @@ snapshots: cjs-module-lexer@2.2.1: {} + cjs-module-lexer@2.3.0: {} + content-disposition@1.1.0: {} content-type@1.0.5: {} @@ -1719,6 +1931,8 @@ snapshots: pkg-pr-new@0.0.87: {} + prettier@3.9.10: {} + prettier@3.9.6: {} proxy-addr@2.0.7: @@ -1752,13 +1966,46 @@ snapshots: transitivePeerDependencies: - supports-color - rsbuild-plugin-dts@1.0.0-beta.3(@rsbuild/core@2.1.12)(typescript@7.0.2): + rsbuild-plugin-dts@1.0.3(@rsbuild/core@2.2.14)(typescript@7.0.2): dependencies: - '@ast-grep/napi': 0.37.0 - '@rsbuild/core': 2.1.12 + '@ast-grep/napi': 0.45.3 + '@rsbuild/core': 2.2.14 optionalDependencies: typescript: 7.0.2 + rstack@0.8.4(typescript@7.0.2): + dependencies: + '@rsbuild/core': 2.2.14 + '@rslib/core': 1.0.3(typescript@7.0.2) + '@rslint/core': 0.9.5 + '@rstest/core': 0.12.4 + '@swc-next/parser': 0.2.4 + prettier: 3.9.10 + tinypool: 2.2.0 + optionalDependencies: + '@rstackjs/cli-darwin-arm64': 0.8.4 + '@rstackjs/cli-darwin-x64': 0.8.4 + '@rstackjs/cli-linux-arm64-gnu': 0.8.4 + '@rstackjs/cli-linux-arm64-musl': 0.8.4 + '@rstackjs/cli-linux-ppc64-gnu': 0.8.4 + '@rstackjs/cli-linux-riscv64-gnu': 0.8.4 + '@rstackjs/cli-linux-riscv64-musl': 0.8.4 + '@rstackjs/cli-linux-s390x-gnu': 0.8.4 + '@rstackjs/cli-linux-x64-gnu': 0.8.4 + '@rstackjs/cli-linux-x64-musl': 0.8.4 + '@rstackjs/cli-win32-arm64-msvc': 0.8.4 + '@rstackjs/cli-win32-ia32-msvc': 0.8.4 + '@rstackjs/cli-win32-x64-msvc': 0.8.4 + transitivePeerDependencies: + - '@microsoft/api-extractor' + - '@module-federation/runtime-tools' + - '@swc-next/parser-binding-wasm' + - core-js + - happy-dom + - jiti + - jsdom + - typescript + safer-buffer@2.1.2: {} semver@7.8.5: {} @@ -1832,6 +2079,8 @@ snapshots: swc-plugin-coverage-instrument@0.0.33: {} + tinypool@2.2.0: {} + toidentifier@1.0.1: {} tslib@2.8.1: {} diff --git a/src/sharedConfig.ts b/src/sharedConfig.ts index 6456722..33464b5 100644 --- a/src/sharedConfig.ts +++ b/src/sharedConfig.ts @@ -1,4 +1,5 @@ import type { ConfigParams, RsbuildConfig } from '@rsbuild/core'; +import type { RstackConfig } from 'rstack'; import { createBuildContextPlugin } from './build.ts'; import { resolveContextCapture, type ContextConfig } from './config.ts'; @@ -70,7 +71,7 @@ const createRstackContextConfig = (options: RstackContextConfigOptions): Context ...(products.has('library') ? { lib: createConfigFactory('rslib', 'library', factoryOptions) } : {}), - }; + } satisfies RstackConfig; }; export { createRstackContextConfig }; diff --git a/tests/packageExports.test.ts b/tests/packageExports.test.ts index 3899865..228b5bc 100644 --- a/tests/packageExports.test.ts +++ b/tests/packageExports.test.ts @@ -15,7 +15,7 @@ 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 () => { +test('publishes focused Context entry points without a Rstack runtime or peer dependency', async () => { const packageJson = await readPackageJson(); expect(Object.keys(packageJson.exports ?? {}).sort()).toEqual([ @@ -34,13 +34,14 @@ test('publishes focused Context entry points without a Rstack dependency', async expect(packageJson.dependencies?.['@rsdoctor/agent-cli']).toBeUndefined(); expect(packageJson.devDependencies?.['@rsdoctor/agent-cli']).toBe('0.1.2'); expect(packageJson.peerDependencies?.['@rsdoctor/agent-cli']).toBe('>=0.1.2'); - for (const section of [ - packageJson.dependencies, - packageJson.devDependencies, - packageJson.peerDependencies, - ]) { + for (const section of [packageJson.dependencies, packageJson.peerDependencies]) { expect(section?.rstack).toBeUndefined(); } + const sharedConfigTypes = await readFile( + path.join(repositoryRoot, 'dist/sharedConfig.d.ts'), + 'utf8', + ); + expect(sharedConfigTypes).not.toMatch(/from ['"]rstack(?:\/[^'"]*)?['"]/); }); test('loads each focused Context entry point independently', () => { diff --git a/tests/sharedConfig.integration.test.ts b/tests/sharedConfig.integration.test.ts new file mode 100644 index 0000000..d221fdc --- /dev/null +++ b/tests/sharedConfig.integration.test.ts @@ -0,0 +1,122 @@ +/* rslint-disable @typescript-eslint/no-unsafe-assignment -- Rstest asymmetric matchers are intentionally untyped. */ +import { execFile } from 'node:child_process'; +import { mkdir, readFile, realpath, symlink, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; +import { expect, test } from '@rstest/core'; +import { readContextWorkspaceStatus } from '../src/store.ts'; +import type { BuildMetadataFacet } from '../src/model.ts'; +import { withTempWorkspace } from './helpers.ts'; + +const execFileAsync = promisify(execFile); +const repositoryRoot = fileURLToPath(new URL('../', import.meta.url)); +const rstackRoot = path.dirname(createRequire(import.meta.url).resolve('rstack/package.json')); + +test.each(['application', 'library'] as const)( + 'captures a native Rstack %s build through define.extends', + async (product) => { + await withTempWorkspace('context-rstack-', async (temporaryRoot) => { + const workspaceRoot = await realpath(temporaryRoot); + const packageRoot = path.join(workspaceRoot, 'packages', product); + const tool = product === 'application' ? 'app' : 'lib'; + await mkdir(path.join(workspaceRoot, 'node_modules', '@rstackjs'), { recursive: true }); + await symlink( + repositoryRoot, + path.join(workspaceRoot, 'node_modules', '@rstackjs', 'context'), + ); + await symlink(rstackRoot, path.join(workspaceRoot, 'node_modules', 'rstack')); + await writeFile(path.join(workspaceRoot, 'package.json'), '{"private":true}'); + await writeFile(path.join(workspaceRoot, 'pnpm-workspace.yaml'), 'packages: ["packages/*"]'); + await mkdir(path.join(packageRoot, 'src'), { recursive: true }); + await writeFile( + path.join(packageRoot, 'package.json'), + JSON.stringify({ name: `fixture-${product}`, type: 'module' }), + ); + await writeFile( + path.join(packageRoot, 'src/index.ts'), + 'export const value = CONTEXT_VALUE; console.log(value);', + ); + await writeFile( + path.join(packageRoot, 'src/index.test.ts'), + `import { test, expect } from 'rstack/test'; +import { value } from './index'; +test('inherits the selected build config', () => expect(value).toBe('local'));`, + ); + await writeFile( + path.join(packageRoot, 'rstack.config.ts'), + `import { define } from 'rstack'; +import { createRstackContextConfig } from '@rstackjs/context/shared-config'; + +define.${tool}({ + ${tool === 'lib' ? "lib: [{id: 'esm', format: 'esm'}, {id: 'cjs', format: 'cjs'}], dts: false," : ''} + source: { entry: {index: './src/index.ts'}, define: {CONTEXT_VALUE: '"local"'} }, + output: {distPath: {root: 'out'}, minify: false}, +}); +define.extends([{extends: [createRstackContextConfig({products: '${product}', variant: 'integration'})], + ${tool}: {source: {define: {CONTEXT_VALUE: '"shared"'}}}, + test: {include: ['src/*.test.ts'], testEnvironment: 'node'}, +}]);`, + ); + const command = tool === 'app' ? ['build'] : ['lib', 'build']; + const options = { + cwd: packageRoot, + timeout: 30_000, + env: { ...process.env, RSTACK_CONTEXT: '0' }, + }; + await execFileAsync( + process.execPath, + [path.join(rstackRoot, 'bin/rs.js'), ...command], + options, + ); + expect((await readContextWorkspaceStatus(workspaceRoot)).runs).toHaveLength(0); + options.env.RSTACK_CONTEXT = '1'; + const build = await execFileAsync( + process.execPath, + [path.join(rstackRoot, 'bin/rs.js'), ...command], + options, + ); + expect(build.stderr).not.toContain('Failed to capture'); + const status = await readContextWorkspaceStatus(workspaceRoot); + expect(status.runs).toHaveLength(1); + const { run, contexts } = status.runs[0]; + expect(run.producer).toBe(tool === 'app' ? 'rsbuild' : 'rslib'); + expect(contexts).toHaveLength(tool === 'app' ? 1 : 2); + for (const { context, latestSnapshot } of contexts) { + expect(context).toMatchObject({ + product, + packageRoot: `packages/${product}`, + packageName: `fixture-${product}`, + configPath: `packages/${product}/rstack.config.ts`, + variant: 'integration', + }); + expect(latestSnapshot).toMatchObject({ + status: 'pass', + completeness: { build: 'complete' }, + }); + } + for (const { context, latestSnapshot } of contexts) { + const build = latestSnapshot!.facets.build as unknown as BuildMetadataFacet; + const scripts = build.assets.filter((asset) => /\.(?:js|mjs|cjs)$/.test(asset.name)); + expect(scripts.length).toBeGreaterThan(0); + for (const script of scripts) { + expect( + await readFile(path.join(workspaceRoot, context.distPath!, script.name), 'utf8'), + ).toContain('local'); + } + expect(latestSnapshot!.source?.inputs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: `packages/${product}/rstack.config.ts` }), + ]), + ); + } + await execFileAsync( + process.execPath, + [path.join(rstackRoot, 'bin/rs.js'), 'test', 'run'], + options, + ); + }); + }, + 60_000, +); From 9bcfacaf4ebbf56ae378acc6987c0bd3d6fec8a8 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 22 Oct 2026 14:55:53 -0700 Subject: [PATCH 46/46] chore: update dependencies and package manager --- docs/repository-extraction.md | 4 +- docs/rfc.md | 2 +- docs/upstream-requests.md | 2 +- package.json | 22 +- pnpm-lock.yaml | 1037 ++++++++++++++++++++------------- tests/testRun.test.ts | 4 +- 6 files changed, 650 insertions(+), 421 deletions(-) diff --git a/docs/repository-extraction.md b/docs/repository-extraction.md index deacd64..c9c49ee 100644 --- a/docs/repository-extraction.md +++ b/docs/repository-extraction.md @@ -2,9 +2,9 @@ ## Status -Updated on 2026-10-20. Rstack shipped the shared-configuration API from +Updated on 2026-10-22. Rstack shipped the shared-configuration API from [RFC #504](https://github.com/rstackjs/rstack-cli/discussions/504) in 0.8.2. Context validates its -integration against Rstack 0.8.4 and stable Rslib 1.0.3. +integration against Rstack 0.8.5 and stable Rslib 1.0.4. ## Decision diff --git a/docs/rfc.md b/docs/rfc.md index c4a8d47..53d58d3 100644 --- a/docs/rfc.md +++ b/docs/rfc.md @@ -135,7 +135,7 @@ publish their results after the one-shot command completes. `@rstackjs/context` owns the store, producer adapters, normalized evidence, queries, build observer, and MCP implementation. Rstack integration uses the released `define.extends()` API from RFC #504. Rstack owns layer loading, tool-specific merges, and test inheritance. Context adds build observers -through the selected App and Lib configs. Native CLI fixtures validate this with Rstack 0.8.4. +through the selected App and Lib configs. Native CLI fixtures validate this with Rstack 0.8.5. Explicit lint and test captures use native producer configuration and do not depend on Rstack CLI. ### Monorepo process model diff --git a/docs/upstream-requests.md b/docs/upstream-requests.md index 3fde7e3..54415f5 100644 --- a/docs/upstream-requests.md +++ b/docs/upstream-requests.md @@ -9,7 +9,7 @@ This file lists only gaps that remain after Context moved out of Rstack CLI. `define.extends()` API, `RstackConfig` type, layer loading, and tool merges are available. Context checks its shared-config factory against the public type and tests App and Lib builds -with Rstack 0.8.4. This integration no longer has an upstream API blocker. +with Rstack 0.8.5. This integration no longer has an upstream API blocker. ## Rsbuild stats diff --git a/package.json b/package.json index 1f9a642..31ed2ed 100644 --- a/package.json +++ b/package.json @@ -63,20 +63,20 @@ "test:watch": "rstest" }, "dependencies": { - "@modelcontextprotocol/sdk": "1.29.0", - "@rslint/core": "0.8.0", - "@rstest/core": "0.12.1", - "zod": "4.4.3" + "@modelcontextprotocol/sdk": "1.32.1", + "@rslint/core": "0.9.5", + "@rstest/core": "0.12.4", + "zod": "4.6.7" }, "devDependencies": { "@rsbuild/core": "2.2.14", "@rsdoctor/agent-cli": "0.1.2", - "@rslib/core": "1.0.3", - "@rstest/coverage-istanbul": "0.12.1", - "@types/node": "24.13.3", - "pkg-pr-new": "0.0.87", - "prettier": "3.9.6", - "rstack": "0.8.4", + "@rslib/core": "1.0.4", + "@rstest/coverage-istanbul": "0.12.4", + "@types/node": "26.6.5", + "pkg-pr-new": "0.0.91", + "prettier": "3.9.10", + "rstack": "0.8.5", "typescript": "7.0.2" }, "peerDependencies": { @@ -95,7 +95,7 @@ "node": ">=22.12.0", "pnpm": ">=11.0.0" }, - "packageManager": "pnpm@11.21.0", + "packageManager": "pnpm@12.15.0", "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ec76ccd..2459c51 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,3 +1,161 @@ +--- +lockfileVersion: '9.0' + +importers: + + .: + configDependencies: {} + packageManagerDependencies: + pnpm: + specifier: 12.15.0 + version: 12.15.0 + +packages: + + '@pnpm/exe.android-arm64@12.15.0': + resolution: {integrity: sha512-eVCqmscY70LGvgRzu3mi8IBWBFGix+a6mNZoWmXxESsBdeiKEF9xQSuaeel10yn+zI7TXy2jlI63vFLzETk+aQ==} + cpu: [arm64] + os: [android] + + '@pnpm/exe.android-x64@12.15.0': + resolution: {integrity: sha512-Gx/WbkFcdUFUg8g8AShezcS9EiQxfYvdAm2hfKLO8sfJmJvKOCt5fzlgHWDzmGhoclibgW+tUGZ/KF4F4wOTCA==} + cpu: [x64] + os: [android] + + '@pnpm/exe.darwin-arm64@12.15.0': + resolution: {integrity: sha512-N0ZykiDWCvqSBJvt71j7cSoBR993FqvYZFtmpCtqvW3pguiI+NAgraLPA5OTwu5mV+/d8hYpVcPktJA3xFDLtA==} + cpu: [arm64] + os: [darwin] + + '@pnpm/exe.darwin-x64@12.15.0': + resolution: {integrity: sha512-7Ntr2HlBUxzwWOg7NwDxCbRW/3vXuqKO+hcXm+w3SBi9rrHY53li1wLqWVT7Gjxd+lVEHM1QJZo6WJEGFU8C6A==} + cpu: [x64] + os: [darwin] + + '@pnpm/exe.freebsd-x64@12.15.0': + resolution: {integrity: sha512-btOiyL7fL7W4uQGH0aqFai2TNC3IoKcZ7gNHBVbEFZt2dWrnrD0SufL3iGs3fLlmb4OtuU35uf4Zpr9V9Jh9mA==} + cpu: [x64] + os: [freebsd] + + '@pnpm/exe.linux-arm64-musl@12.15.0': + resolution: {integrity: sha512-pNC38k+O+90FOjbPNSnw0E7apq1PKTmz3fFe1amn+lys/U5kL++uzir8bRiho8ONcyA3d9EU7wdqcY0DKkj+JA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-arm64@12.15.0': + resolution: {integrity: sha512-qML/8aCXslh8td/w4U+HJNqUcZ2+m6EfTbWScIzZRmyPsRFN+xB4LP1U/hymxc1iSJiKtG56HJx2fvUErWFIGw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-ppc64@12.15.0': + resolution: {integrity: sha512-tjRDUUl7RCJ6T/6eoNPxOqkwU4Mzi5RtTXmSAxujyz37OTwwsMU7Ps087Zk4gHcUvRSeyF40ZFsew3vtE+nmUA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-riscv64@12.15.0': + resolution: {integrity: sha512-bF1uBWEfbwaeryz6lIiaJ8AWD082otZfvzGX5l+6LZRpRwUEpJHUsJrORaYP5/cNMv5ZR2p6TvvcJcQzwtDzmw==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-s390x@12.15.0': + resolution: {integrity: sha512-zKMhIzsexYMToeLmqV5jPsvV/f9vwsJb2kUH2NFYzalbxImeBrlA4kL7GwF8gD4pagV6jYYcJb8RjbZIdjHptg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-x64-musl@12.15.0': + resolution: {integrity: sha512-A9J0Jg0Rdx+qQU4qXtwyST5tto9IOu1zi4fDk35nbc6DWahggbcKtFvZPKFwL2qgUXGXOX//hXv6z/sp6emO8A==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-x64@12.15.0': + resolution: {integrity: sha512-yc+QlZR+9+nr29J4fRzhMoapgxTBDndq/+peyoq1XUufnQFnfbXUMFudDIKzHi545linGqmOmXfn0NoufNgcOg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.win32-arm64@12.15.0': + resolution: {integrity: sha512-fPXZxuY1VclkOga9bc53qNCXcegfeoBR8+Ue+AospDwKzuGGSHll0oDxkLfkmpjyRqpe7bH5DyQPYp/xSMaIVA==} + cpu: [arm64] + os: [win32] + + '@pnpm/exe.win32-x64@12.15.0': + resolution: {integrity: sha512-nrd+Tp1We1DGj4DGG5Bea9lZNh9y1PjlLBdEwdInS4D0h0UIDtM7JaGGGDJhNzIwcHASyihWI7HLV+dtG+fhYg==} + cpu: [x64] + os: [win32] + + pnpm@12.15.0: + resolution: {integrity: sha512-s5UdbW/0qfE9x89WzSjSuOs328ycaOUuHhGRhz6p+CoBcoerULqtxczElPA2xnwOR+sTYIwvo29HcRth6hKOYQ==} + engines: {node: '>=18'} + hasBin: true + +snapshots: + + '@pnpm/exe.android-arm64@12.15.0': + optional: true + + '@pnpm/exe.android-x64@12.15.0': + optional: true + + '@pnpm/exe.darwin-arm64@12.15.0': + optional: true + + '@pnpm/exe.darwin-x64@12.15.0': + optional: true + + '@pnpm/exe.freebsd-x64@12.15.0': + optional: true + + '@pnpm/exe.linux-arm64-musl@12.15.0': + optional: true + + '@pnpm/exe.linux-arm64@12.15.0': + optional: true + + '@pnpm/exe.linux-ppc64@12.15.0': + optional: true + + '@pnpm/exe.linux-riscv64@12.15.0': + optional: true + + '@pnpm/exe.linux-s390x@12.15.0': + optional: true + + '@pnpm/exe.linux-x64-musl@12.15.0': + optional: true + + '@pnpm/exe.linux-x64@12.15.0': + optional: true + + '@pnpm/exe.win32-arm64@12.15.0': + optional: true + + '@pnpm/exe.win32-x64@12.15.0': + optional: true + + pnpm@12.15.0: + optionalDependencies: + '@pnpm/exe.android-arm64': 12.15.0 + '@pnpm/exe.android-x64': 12.15.0 + '@pnpm/exe.darwin-arm64': 12.15.0 + '@pnpm/exe.darwin-x64': 12.15.0 + '@pnpm/exe.freebsd-x64': 12.15.0 + '@pnpm/exe.linux-arm64': 12.15.0 + '@pnpm/exe.linux-arm64-musl': 12.15.0 + '@pnpm/exe.linux-ppc64': 12.15.0 + '@pnpm/exe.linux-riscv64': 12.15.0 + '@pnpm/exe.linux-s390x': 12.15.0 + '@pnpm/exe.linux-x64': 12.15.0 + '@pnpm/exe.linux-x64-musl': 12.15.0 + '@pnpm/exe.win32-arm64': 12.15.0 + '@pnpm/exe.win32-x64': 12.15.0 + +--- lockfileVersion: '9.0' settings: @@ -10,17 +168,17 @@ importers: .: dependencies: '@modelcontextprotocol/sdk': - specifier: 1.29.0 - version: 1.29.0(supports-color@7.2.0)(zod@4.4.3) + specifier: 1.32.1 + version: 1.32.1(supports-color@7.2.0)(zod@4.6.7) '@rslint/core': - specifier: 0.8.0 - version: 0.8.0 + specifier: 0.9.5 + version: 0.9.5 '@rstest/core': - specifier: 0.12.1 - version: 0.12.1 + specifier: 0.12.4 + version: 0.12.4 zod: - specifier: 4.4.3 - version: 4.4.3 + specifier: 4.6.7 + version: 4.6.7 devDependencies: '@rsbuild/core': specifier: 2.2.14 @@ -29,89 +187,89 @@ importers: specifier: 0.1.2 version: 0.1.2 '@rslib/core': - specifier: 1.0.3 - version: 1.0.3(typescript@7.0.2) + specifier: 1.0.4 + version: 1.0.4(typescript@7.0.2) '@rstest/coverage-istanbul': - specifier: 0.12.1 - version: 0.12.1(@rstest/core@0.12.1)(supports-color@7.2.0) + specifier: 0.12.4 + version: 0.12.4(@rstest/core@0.12.4)(supports-color@7.2.0) '@types/node': - specifier: 24.13.3 - version: 24.13.3 + specifier: 26.6.5 + version: 26.6.5 pkg-pr-new: - specifier: 0.0.87 - version: 0.0.87 + specifier: 0.0.91 + version: 0.0.91 prettier: - specifier: 3.9.6 - version: 3.9.6 + specifier: 3.9.10 + version: 3.9.10 rstack: - specifier: 0.8.4 - version: 0.8.4(typescript@7.0.2) + specifier: 0.8.5 + version: 0.8.5(typescript@7.0.2) typescript: specifier: 7.0.2 version: 7.0.2 packages: - '@ast-grep/napi-darwin-arm64@0.45.3': - resolution: {integrity: sha512-rMZJ6frSpR/xSbeV4hiA9WR5hHqk3rVYX8lOqEoUCaUnjEuFXvHdGN4WawJ8pVDq/IqM1UnTtsDSYgUdVmO5DA==} + '@ast-grep/napi-darwin-arm64@0.51.0': + resolution: {integrity: sha512-jKzwYtHqVAY3F6Ch9sE4zRc2iDP74U2bJB2AucRDpQe3HrHUz4rCCfXS4PZpq9FL+kI6YS+qglZG3LRgnv1JsA==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@ast-grep/napi-darwin-x64@0.45.3': - resolution: {integrity: sha512-tIclZ5pM+YSgk8NNTBoTkaclbS39cU5lbVfSrFWX4LUnxXfUPG+F+ppxyFjKwJTBELsGHEO4LdX3/Lc+FDwPNA==} + '@ast-grep/napi-darwin-x64@0.51.0': + resolution: {integrity: sha512-P0Vrid8pJrLuhJ23TskxsqzZYdATOY49JfC6u4295CTz2O9hPH+YkURIPYpV424AItsQ6I4LfyJieXaK+noNcQ==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@ast-grep/napi-linux-arm64-gnu@0.45.3': - resolution: {integrity: sha512-Bm8F7/oRHQkDXbjH+gBi9oB5huJ0q12r/AIOMIypf5PWlAVvh206Tsw468phw4Oh6kd4Ad1Z+gflNW1MutgK2g==} + '@ast-grep/napi-linux-arm64-gnu@0.51.0': + resolution: {integrity: sha512-0TEQf4dydUVL2ieG8aVyxSF+JdoPJDqyHTHfXIOVpYRmuwFS5MC0Lt1kjh5mO3uFzxF1dqobqigeKB7UhklfRQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@ast-grep/napi-linux-arm64-musl@0.45.3': - resolution: {integrity: sha512-yr5xcRerV41GeRxmZ8SKbfGeyZRhQePoNNCgTAb4OrWMelWwbMI80aY7/IlF2bo1sN40BSW4hZdGIO/akTd1RQ==} + '@ast-grep/napi-linux-arm64-musl@0.51.0': + resolution: {integrity: sha512-2rqfTEwNjaYsi2Db76BIfmRTF28P2fLul05G8EvX1p7YHL9y2pn+on8Ex/SuPnS2whpNn399h8GQ7hupPNugwQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@ast-grep/napi-linux-x64-gnu@0.45.3': - resolution: {integrity: sha512-lluRK3hxfHMQdFuVID6seAKVx4FH0kEwF6rCh5/I40Sf3EtNR2UCBXAqd6RBA6hqlSckktvBxCfw+h4aescDmg==} + '@ast-grep/napi-linux-x64-gnu@0.51.0': + resolution: {integrity: sha512-V2kuGNPF6rA5OGK6eMxEdUtzDy4lMJyPeCh0LnO3nL7gscRBaytwUBhx851mutOUqQkGbgZwAdDXv77VaeWKDg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@ast-grep/napi-linux-x64-musl@0.45.3': - resolution: {integrity: sha512-09wJQKq86LqILqQtw0+X09SzhcPwUnMVFKyia7S3XLbLqSeQpAMF55Y+zll53JRQ1dCzFEZJyvTT/7j9vwfE3w==} + '@ast-grep/napi-linux-x64-musl@0.51.0': + resolution: {integrity: sha512-yOYdRAP6i8tEuDx8RgayAirn3fhNIA/aA3mQ4OibPIR9q4uzl0uJeGH6xjdcFhXCbCgIuTuPQKxIIO5HnPazIQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@ast-grep/napi-win32-arm64-msvc@0.45.3': - resolution: {integrity: sha512-/HiVgw2cdtzXLDLoG4eBBcauekNXewfagy7qiVbjkVu/KcCH8dJ9lpj8DzeWwuBNtZxuD8jpiwP0M1pz3Bc3uw==} + '@ast-grep/napi-win32-arm64-msvc@0.51.0': + resolution: {integrity: sha512-/pwe01sSnKdVMyybKiJ8tvOaaM3dUVXj9JiYHEAs14xTAS0RWKMOAoqpKxB97Yg4ukssQiemUiViBby88TGy6A==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@ast-grep/napi-win32-ia32-msvc@0.45.3': - resolution: {integrity: sha512-/gwJrJQUw65uNtAFcG2rtSTr117Gqqy3bfjaljCPKsa65dTeRcMMnjKLlZ6V9/g8z+4Xf78kdhp5ECbir4yfSw==} + '@ast-grep/napi-win32-ia32-msvc@0.51.0': + resolution: {integrity: sha512-RF49CWQslYIx1DGq090a3gqBQEYxLBmg8CsVRgnEyF0KDlcGinCDeRg/EqxVyDQ+e9VT3ycKpUjby+V7aMujXg==} engines: {node: '>= 10'} cpu: [ia32] os: [win32] - '@ast-grep/napi-win32-x64-msvc@0.45.3': - resolution: {integrity: sha512-CEZAMAsaDoQoVE3MT2c/ry3/ydfyaqlTjxv8DK5PG355SrdAYPgevGC2Me6tz4dhphJin89Y20VsnrS5UrYV5Q==} + '@ast-grep/napi-win32-x64-msvc@0.51.0': + resolution: {integrity: sha512-OlGEtNSx1q2NdRXOSHBEGtuyZXS6Ix3bE2HdGepuYnNZJoafvX/WljkO+kzNimJGGSXTs6/t+o7XeGsB8vL/Bw==} engines: {node: '>= 10'} cpu: [x64] os: [win32] - '@ast-grep/napi@0.45.3': - resolution: {integrity: sha512-bjJsUt0g3UCwbn9bjv7MnoR15rszSm79yPCXn4DWWK0NuYUUqBU/srtNMR4WnpfR7TnfBRdYFoqSNx0pGCNWJg==} + '@ast-grep/napi@0.51.0': + resolution: {integrity: sha512-thHIZoygVkwW/6yBFBgqPtmKpAnEfwsJyIFliKo6hxkXlYLLfvwReDH2le/5zk358ir/97IJdTB7xv309LYbSQ==} engines: {node: '>= 10'} '@emnapi/core@1.11.3': @@ -123,24 +281,24 @@ packages: '@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'} + '@hono/node-server@2.1.5': + resolution: {integrity: sha512-MgP/I06GdvsilDspMk/qmRzpk8LOUStP6jFAbSRi40W09DhoyUn+Mj7eyKICPqeYKybOjVFLVIAQzvu1bfIa7Q==} + engines: {node: '>=20'} peerDependencies: - hono: ^4 + hono: ^4 || ^5.0.0-0 '@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/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@modelcontextprotocol/sdk@1.29.0': - resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} + '@modelcontextprotocol/sdk@1.32.1': + resolution: {integrity: sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw==} engines: {node: '>=18'} peerDependencies: '@cfworker/json-schema': ^4.1.1 @@ -165,33 +323,34 @@ packages: core-js: optional: true + '@rsbuild/core@2.3.0-beta.0': + resolution: {integrity: sha512-g1N0YmOc/ZVMYJAq/AnNaz0arrFYYS3cPmOAG+RDvOKlxXpWZ/HAd3OjgsZQfmiv4FuN5V12FxflWweeh4c9Hg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + core-js: '>= 3.0.0' + peerDependenciesMeta: + core-js: + optional: true + '@rsdoctor/agent-cli@0.1.2': resolution: {integrity: sha512-xjFWWtQodShNTdLCo13LoeYtnJ5nhTQ9mb7fkggrNGuY7HkAy4xFsHKqq3lHydTMvpX9+nR6g2uer+S105K0fw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - '@rslib/core@1.0.3': - resolution: {integrity: sha512-a5IZiyf9sNeeE9jcTsTmz16vxcThpbOSJg0BZXzJAxrUFOZX5xG25PJua9hr0LDWGryBX2UQTruVsLQ8BCpqng==} + '@rslib/core@1.0.4': + resolution: {integrity: sha512-rZN0RMouy5ftByUQpIoQLkVzBgatkTRYxt282ZKBQ2yn+KPTW2fnUWsCJJt12VhopldIZ6P+A/tWajYymRZ40Q==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: '@microsoft/api-extractor': ^7 - typescript: ^5 || ^6 || ^7 + typescript: ^5 || ^6 || ^7 || ^7.1.0-0 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/core@0.9.5': resolution: {integrity: sha512-bqeT2VHn0zlvRUecq7naWBqDCdr2eHphijSAAOVHAdKoNJx5KHpL+/wchJUnaPop5KtsPTi5crNJQJ4GWyoleg==} hasBin: true @@ -201,89 +360,45 @@ packages: 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-arm64@0.9.5': resolution: {integrity: sha512-O+GcAJ/4eH1a+uykSsg8YlOUNKRLekO0mOplYdqYdO1PLWMpIZOyRP9elpkBh8kVvKz+6T8Edk7/tey6xe1Q4g==} cpu: [arm64] os: [darwin] - '@rslint/native-darwin-x64@0.8.0': - resolution: {integrity: sha512-F5pabdH7dluxoj7PGuQjPYuoNU+yxnctcJzqrb/CZ122FLP3uJPrbiLufh+ZF9e5ui700rNyb7macJqnHlBV2w==} - cpu: [x64] - os: [darwin] - '@rslint/native-darwin-x64@0.9.5': resolution: {integrity: sha512-coi3Tc3oVyE3jQHeuTcM9tmJfpeaqvAHBAsSdc/EdfS0DtqJPuXgZ2RGHIO6Nnk2Y1iXQTVaeClMBJ7msTGhXg==} 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-gnu@0.9.5': resolution: {integrity: sha512-+Atb7o2DrMWCHdvTBe22YoVNwbgbZDqI1va17IUv71YKkraA8smoCsTLBKT2AyuOFrhMEHIibg5wj3nQqcuVLQ==} 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-arm64-musl@0.9.5': resolution: {integrity: sha512-S05xGzZcHBDFucN/sM9yuAk707Ngrz+bR7aRk8EHhk4gss5CvMjKTNqp+ZTBcduPtmKHtwwoeSFtOlTAmZq30Q==} 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-gnu@0.9.5': resolution: {integrity: sha512-5f/KsD7TbWdg/V9Csij60GGlIhOM6q2v6c3Js1TUYfsDItP7z894okFHH03FjCJchI9ghB4dl7JicxHAgcBvEg==} 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-linux-x64-musl@0.9.5': resolution: {integrity: sha512-opGVrJmAvDTBVQ7r74c9T6XyNXJsNnA5jlgsijzgz7jZi/yTudbspOFMVr8j1qO0hItgDZb2h5oow3FkqPCylA==} 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-arm64-msvc@0.9.5': resolution: {integrity: sha512-2KUzAKS7oy5wEaam0MHAUUGwPQnDXvp8AY/XfwdIYbQP69hWxCBYcUabnCxqb+1YGUIVmmpy/CwP1u9Wknigww==} cpu: [arm64] os: [win32] - '@rslint/native-win32-x64-msvc@0.8.0': - resolution: {integrity: sha512-bun7uURKl6NdChwmw/i2mk3Yj9klZQyh1uRbIQG0RDEJ9oTIbU5M3c7K5sd/Rukat0TVCGgbBAzR+YB0DAXPZQ==} - cpu: [x64] - os: [win32] - '@rslint/native-win32-x64-msvc@0.9.5': resolution: {integrity: sha512-KKzNb2IIa/rv0H8YCrfrd3RSRIxBxm25Io4QAW4ZDOTyJurruOvJBzajNvck7bARXz9t0Si451IlKYnj5SRS/g==} cpu: [x64] @@ -294,81 +409,161 @@ packages: cpu: [arm64] os: [darwin] + '@rspack/binding-darwin-arm64@2.3.0-beta.1': + resolution: {integrity: sha512-MY51chNCxpS4pAOF5jXkKMLsHpJGYlsdX6An3OoMlihQnRRK9jaPjATJRAtowyYMlz4+nyBMJLTH9aLSkxB+hw==} + cpu: [arm64] + os: [darwin] + '@rspack/binding-darwin-x64@2.2.9': resolution: {integrity: sha512-QtUS7lyOE5oxdMm7RH/Fw176jqwdHiTW2qEsmbdCYzPw3t7QkmI5ohoyiuxOp3czJfaayXP6bVyvm2FqqnWPAw==} cpu: [x64] os: [darwin] + '@rspack/binding-darwin-x64@2.3.0-beta.1': + resolution: {integrity: sha512-WWt9bKn+Gda2XA8GRIWjKhhAkMtgcFaVUxE1NB9n/VxWP8x7XSLkpjGViNm91xXptf+b00FbpVkqQGm2JbzuAA==} + cpu: [x64] + os: [darwin] + '@rspack/binding-linux-arm64-gnu@2.2.9': resolution: {integrity: sha512-GMKxtBJAMoqBPqXZVi0QanDkneWwgiKRpTgHW/pbKPY/2XMfZJ98thm4YlATO18XZJ2KET+GfsNfaf1o5Y+aaA==} cpu: [arm64] os: [linux] libc: [glibc] + '@rspack/binding-linux-arm64-gnu@2.3.0-beta.1': + resolution: {integrity: sha512-e2kmMHGK3y8e3VATacqtsE2DuCAeYVhXpLNTxkvS5iW4hZM6r+rFn/i+mvtPJIlOCz97pe/nOzqegkYITTd8Cg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@rspack/binding-linux-arm64-musl@2.2.9': resolution: {integrity: sha512-aW3fcM64R5mY2+ym6Q8okAo/pcVIjAliqxFOc9lf9hCyS7hHaabWo30smeMX3neEjsqw+iyNbzWMuH0G5AugTw==} cpu: [arm64] os: [linux] libc: [musl] + '@rspack/binding-linux-arm64-musl@2.3.0-beta.1': + resolution: {integrity: sha512-3bHDU2e+DzcR1JceeyaIe+DsMfbdNDrKt08YJ5SF1+OI5wv1I7FNo6/iD+vcmpMVKs31gh/6a+Ox+bXAGxw9Pg==} + cpu: [arm64] + os: [linux] + libc: [musl] + '@rspack/binding-linux-ppc64-gnu@2.2.9': resolution: {integrity: sha512-dVEiyZdANq+g/qugI64vKnxO+PCVicidu/GQ6sO0QEWGVzdSUbxHztHviSnMwdvRs6OLrN7Lwhaw536hJ/cU8w==} cpu: [ppc64] os: [linux] libc: [glibc] + '@rspack/binding-linux-ppc64-gnu@2.3.0-beta.1': + resolution: {integrity: sha512-22wx/9v2o4cgD0bdYiMIrocHoMKfZP1Jlv2bQpvLVyyDgbmAlzV+ttXpG6ZudmOIGyNOPbZ//Igf1qC8c2I4vA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@rspack/binding-linux-riscv64-gnu@2.2.9': resolution: {integrity: sha512-uBHWmfMm502145le0EUOsBoV6bJ9XA5s3NQLL4smSMoSEuMbNrRhqDJ2+B1u8aXVfjfhxCPyNlMDmgbsdVaO0Q==} cpu: [riscv64] os: [linux] libc: [glibc] + '@rspack/binding-linux-riscv64-gnu@2.3.0-beta.1': + resolution: {integrity: sha512-7j31Jk83zBlyLpczEExIebN6f7ud6hNgC460Ztj60UBVCGdzuUlAYmJBIyOCy9r0FsSBomxOHZ598OeXl3QgUw==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + '@rspack/binding-linux-riscv64-musl@2.2.9': resolution: {integrity: sha512-MR7e6PP7KcG3jHYWznk17npBBvjUXQg7qQbS+yrAu0R3p/27wLp+aP5m/zk4ev1Iq93ccIBIAGVRGA3v1fSKew==} cpu: [riscv64] os: [linux] libc: [musl] + '@rspack/binding-linux-riscv64-musl@2.3.0-beta.1': + resolution: {integrity: sha512-HFRztoFBXFziHaO5R2smTcauDFaIbj6Mg2R/2wdM5CYwr7nA76o+EOaKyb/XcGCiBsKPcMT2xK5pEE+qezpUvA==} + cpu: [riscv64] + os: [linux] + libc: [musl] + '@rspack/binding-linux-s390x-gnu@2.2.9': resolution: {integrity: sha512-Tc9Nuv7XvkrAG79Ps/6F7vT4bxZqiaiQ3Rd3BidKUjVYiwTPJogk1W3gPXp7Lw+AMoN/dY63W22bMJ7Oh0FeZw==} cpu: [s390x] os: [linux] libc: [glibc] + '@rspack/binding-linux-s390x-gnu@2.3.0-beta.1': + resolution: {integrity: sha512-8ra54iSlw93eT5Oy7jv4oojK7JF1BUM7ofbJQxwUQKMwLqq9NQiOoHRiGLoHzWOEXb3Qw7udlvBF2AdgXpejmQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@rspack/binding-linux-x64-gnu@2.2.9': resolution: {integrity: sha512-xQYhQwC8q3mSCzHV+yi76Gt2Lt9BhnmeJnQgXGjuhyvjZWBB9jFNp9Uj6umun7tlMliMRhoqTesqvK4Vo/kkcQ==} cpu: [x64] os: [linux] libc: [glibc] + '@rspack/binding-linux-x64-gnu@2.3.0-beta.1': + resolution: {integrity: sha512-ZMS6g+gS9LK3xKP0oujIYurfbR38hG6oksiAygZX1DBUlGrenIImJLaq7DxUvSlWnw8mcOoBHqdTTHP2wAtEmA==} + cpu: [x64] + os: [linux] + libc: [glibc] + '@rspack/binding-linux-x64-musl@2.2.9': resolution: {integrity: sha512-FfEYdFhOd1nZL+U/up8nlbcRTTyWX4ohXUQcApQMYKIZoR1EJ0g/oBhS915+TSray0DchA+NT6yVL1sqWJOFaQ==} cpu: [x64] os: [linux] libc: [musl] + '@rspack/binding-linux-x64-musl@2.3.0-beta.1': + resolution: {integrity: sha512-tSfUD8yRL1ZH0RlUYFLfQuk0a4yxlyYNMzLG0DrcySoPFZ2re0l4T6Ut1CgL8tD1Lup7225U1dzt6B/MWf8SIA==} + cpu: [x64] + os: [linux] + libc: [musl] + '@rspack/binding-wasm32-wasi@2.2.9': resolution: {integrity: sha512-+29JEBiUi2TMDOGYmT3Tm3JGk3f19+CbjxNEt1bVreR8axuZZj+wOSwB/sx85lKQkL5fuNPh0BhOZ7cdkDxQpA==} cpu: [wasm32] + '@rspack/binding-wasm32-wasi@2.3.0-beta.1': + resolution: {integrity: sha512-Hn/qzKyrkBA/ppuJYwpFpWCwf4P2ce9CuCT8CW2CkqsCpH/p7xCvM8m58YspPPZDh3TPK4F/tQgg2l4D3vkeCw==} + cpu: [wasm32] + '@rspack/binding-win32-arm64-msvc@2.2.9': resolution: {integrity: sha512-x/BDAwQCkR+ANWAAWV+15PFWuae2z9DzL+QcVeb+5taOri+tHa/iQdG8e3GJ60d0amsR+u9BJ2CBtJLK1fQkxw==} cpu: [arm64] os: [win32] + '@rspack/binding-win32-arm64-msvc@2.3.0-beta.1': + resolution: {integrity: sha512-voecMdHGIePUkhSaBAu8EJEJTbtSfz5u+3bUq5PlXk/kxHCIME3zVJpPfMxZr/Xf3YJWEGQQVKU1rXk0i2x3FQ==} + cpu: [arm64] + os: [win32] + '@rspack/binding-win32-ia32-msvc@2.2.9': resolution: {integrity: sha512-i622t0Mom3STiDi8VHLaX62LKwhbT2kg37fKjzv3wQT+9bJvQ/+St6QQzFZg9C7fV53LXqAw8jFi5yIPIjg0Qw==} cpu: [ia32] os: [win32] + '@rspack/binding-win32-ia32-msvc@2.3.0-beta.1': + resolution: {integrity: sha512-vKKx7KPxlSN/KTIJYWm/UZe29sMMrphaCDBMHnugkrb4OATroc5riBBa09YH4XVXCYynxSg776ZW8IFM6yYMwA==} + cpu: [ia32] + os: [win32] + '@rspack/binding-win32-x64-msvc@2.2.9': resolution: {integrity: sha512-K98FPCAv0VpDPAz5ZqZ0XI1CmdJu9Kw1FmUDl4rN/zQIt9iqYn+Hgxq6Nk6Zx7g938jiG7ABLwITXSIKGbs6aw==} cpu: [x64] os: [win32] + '@rspack/binding-win32-x64-msvc@2.3.0-beta.1': + resolution: {integrity: sha512-to8QGzbmiL8Mvlbg0CbuTbse0LIVozRH37F3WoV1Tk9RGw4VtzRnkMRMYhmMEgf65rON9kcKQ3a5efmNYUYeaA==} + cpu: [x64] + os: [win32] + '@rspack/binding@2.2.9': resolution: {integrity: sha512-Y8wDJoRo8n0KXDGq6J3KGVekkUQcOcUHSppMHoJv8JLbNBEsyaaKiDx4ljNTGEDtTON0CRbMEWbSiLRe+bJxOQ==} + '@rspack/binding@2.3.0-beta.1': + resolution: {integrity: sha512-D/9lWn0gYkub0j5YiTnD/VOofEIv+AukPCkNnOrPQlF/OW/KzOCLy1F98eLwfCxEO8grVMfMJa7O6xSPM5Mjng==} + '@rspack/core@2.2.9': resolution: {integrity: sha512-Tp6/ZdM5UrGB3BeLfvh69l1iX03PZ46IgXjKqubVEuJVAhnOjpshooH/YAxcxR0AymcBLKA997Z4v+LV2aXzCw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -381,105 +576,104 @@ packages: '@swc/helpers': optional: true - '@rstackjs/cli-darwin-arm64@0.8.4': - resolution: {integrity: sha512-ccRtjHXRPa1h4CpEegsg88ajUP3F3yUpyK+TVRwxbjlVJSt/wPZk1198euCRWkfOEdEv5Q9IVdpxb5RmCUZHAQ==} + '@rspack/core@2.3.0-beta.1': + resolution: {integrity: sha512-zqQLyj5POKs9VMI4hIkVzixy7ikFfUOqZMTgaC0PNx4pPBrBJvmRV/Y2IQlRGDVhkzN2RezSUWjcFju7pVxeqg==} + 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 + + '@rstackjs/cli-darwin-arm64@0.8.5': + resolution: {integrity: sha512-JC/E/ZqK9dj3zPIL1lXgfcv6iX8qTfT3VVfHcs3VKqi5qspdb2FWAFQekwYTT0s9lh98xyrMv0LkYlozwUnM3Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rstackjs/cli-darwin-x64@0.8.4': - resolution: {integrity: sha512-UNYaw6eaek7jf4+7lweliFElQ2QDI3q/eYq5zeUZKSzL1Dk8mOI6WuIMbmG4bQJeh2fdho5ABAyxlcS3UprlMw==} + '@rstackjs/cli-darwin-x64@0.8.5': + resolution: {integrity: sha512-ziBwOkEHDbu6hLZj5FIZz1N7N9Rm2rHdfzpHUpfRZq+HpWZZQiGrSn1Qo29YODFXEOhA+dmBjkTp+2tp+E6L7w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rstackjs/cli-linux-arm64-gnu@0.8.4': - resolution: {integrity: sha512-JsKBQx41Vkvb4VuJlZ3SB/x8ifrkPYLNLGBLABK9xy+JIXiJtL6XEQs5coKUvm+uELj8EnHkmR4gXdDTsF5jBw==} + '@rstackjs/cli-linux-arm64-gnu@0.8.5': + resolution: {integrity: sha512-0xg9FVQis46RX5GwfpWIdlL8UPC+L0N5BN8LR8OR5kAmqslG51UVKH2PRbyCrtbz0/69yP9QJ44ZILJnJe8MrA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@rstackjs/cli-linux-arm64-musl@0.8.4': - resolution: {integrity: sha512-WiN3ISusxn623D7runNEftyYPVH5gI/1UHCA/8VsEp/fptY++fCXFjZDaj1KgFWYtV4MHYcKhsx3cZJn3eHsNw==} + '@rstackjs/cli-linux-arm64-musl@0.8.5': + resolution: {integrity: sha512-cyebAv2DR9oMofphf+p01btETdFiexMeXDwIYORzZjWRv47fZLhWTkKTAXfNDIW2PlKBMtsvjrPGbIVwj7xXgA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@rstackjs/cli-linux-ppc64-gnu@0.8.4': - resolution: {integrity: sha512-rkGXQLbMDNHfX66yVFfK+KAtFVX4KyP2g+7+ALSRupFaKAJBB7mMfIvya9mmEns/6vKgHoJgXC3hCalByhYAJw==} + '@rstackjs/cli-linux-ppc64-gnu@0.8.5': + resolution: {integrity: sha512-aO8c8qNsqF+Ae6URY6b1Dg3FEo5P/0/U99gnTPL6Dkyac84sRWDq6pwoTGXgKz/T1UZeTFEaX2mS8iv+/xlZmQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@rstackjs/cli-linux-riscv64-gnu@0.8.4': - resolution: {integrity: sha512-skPAB8iy5Fww5mYeSOHkwxi9t96qCO86CM0RUxM19/ynw9/jCqlRIB5blDUHo5C1nkg5qzS1MRpF9a+ef0MBrA==} + '@rstackjs/cli-linux-riscv64-gnu@0.8.5': + resolution: {integrity: sha512-vorhKQ9YR03zGL9U2xDYbfxKxBia+YQWrHJoUUOA2zHFCqk/AIyB93Ukpz2BsYUfP8/8/5EmGX+wDmjiWxSdeg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@rstackjs/cli-linux-riscv64-musl@0.8.4': - resolution: {integrity: sha512-eZe5TGbJpQBTt/2wnmNNVizVzlJPIpqFfgmpBEzYhEsbTp52+bC9K5+1nlurJ+I2aMXtsXrPJF7qwB1d+j0DQg==} + '@rstackjs/cli-linux-riscv64-musl@0.8.5': + resolution: {integrity: sha512-DiAZzqg7ZHm4yVyB2KukcHHxSpa+GRAMAhINcJ3sa8CvOp4Ii0zEHBJssILvIyxOvXML/vCD5MUdzfb+bOjyZg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@rstackjs/cli-linux-s390x-gnu@0.8.4': - resolution: {integrity: sha512-a8gje5rHiBCJNcCuKrVh9yA5GM//eXQplDvzFhyYzUvvmHK9Fse6XpZO1/ub14grWua5k0qKcq63CafdX2wyxw==} + '@rstackjs/cli-linux-s390x-gnu@0.8.5': + resolution: {integrity: sha512-g0/DXUHgRTG9VnaOh8rCLDXIzujEuTRGiPc2/8s6glH/GcKPJpwaLygxmQURgSzsSKcT+FPkcc5RjW/f8W/zzg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@rstackjs/cli-linux-x64-gnu@0.8.4': - resolution: {integrity: sha512-pCWue/NTJP8rKclQg+wKp7hmEjGmhaByz6UYOKIetM2TgYlV1IZYC29rK1m8xs6MRT47z15Mqw0oKx7HOrA8tA==} + '@rstackjs/cli-linux-x64-gnu@0.8.5': + resolution: {integrity: sha512-pQWFOYsMAjw1lIuMFVWIxApv0+ZpNLXSC/2TdX/Dg7g0k3IXFwuHUhGyfF+isELJIxWrFKsUx/pvWhLuU8MR9Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@rstackjs/cli-linux-x64-musl@0.8.4': - resolution: {integrity: sha512-vaOK/zdXtb5ibbYb6w7g2lrunlgGnvARlyf2qLGcLMGOkol4cSiANhuMg3cLQynQKCn0cjkJ8sE2SGkNPRSQmw==} + '@rstackjs/cli-linux-x64-musl@0.8.5': + resolution: {integrity: sha512-0NYg4zVA5q6mHMoLU41cqmLUE5nQxQfNHeUUUHJ6kpkdaBxNKpPavN2SacM2QwP1nsVVRz3mSNH8tLneXfO4Kw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@rstackjs/cli-win32-arm64-msvc@0.8.4': - resolution: {integrity: sha512-UADzq2Qn1d8lxsR9ACKAwFFlcEgsBKHXuE8r6TWHlRlglMYIr5oKhuVrG5eEIQCXWixcqdSaxZd57wWA0S8FpQ==} + '@rstackjs/cli-win32-arm64-msvc@0.8.5': + resolution: {integrity: sha512-P5PXumyTI8iElekLo/rPW0dTHZ38vfkPgs1H3mHp1+kl7Ar2cg522QQKz/ImuSKfCWNaVkxmNTjwhinGQQcNcA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rstackjs/cli-win32-ia32-msvc@0.8.4': - resolution: {integrity: sha512-3vfBm7aoCMFuTmMS32EtSFQpcPgeReG5B2qcDtPxFXhCTpqFzPR81R27OF9NQuVUJxMKtQsCJPEfAcdxJnxQ+w==} + '@rstackjs/cli-win32-ia32-msvc@0.8.5': + resolution: {integrity: sha512-eCHB5rnqSnbQ/sgVDtTvxY8gALcOqZMYyLp6+/cQLRASmnIA83I8hMoGmDO+bWR7kf+EUvoq2i2837MwwwbOig==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@rstackjs/cli-win32-x64-msvc@0.8.4': - resolution: {integrity: sha512-loiQHQzulGzzE2Rj/JiNl0CczN9fhOtCoyqFWw3OieCdNF+mGjrd0hI0pRyHWgLomAk+iisGJBS6WyXgp4f1fA==} + '@rstackjs/cli-win32-x64-msvc@0.8.5': + resolution: {integrity: sha512-yom5lagmQaeIRFcDvUtN7qwhvHTRLREL12CZf0tg/0+8FYT8WEJynpdMyXti3Z0+LOwMAPCEBFoe7b0aPS1WQg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@rstest/core@0.12.1': - resolution: {integrity: sha512-irrbH5VPqiHqIVM2Ta7Ib9sPyQ5Hsvxt0sSqqwRp/3j2by0sSu9ZeIMYzMEZj1puPwx2a8l3aFWW+Bk2ycKlEA==} - 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/core@0.12.4': resolution: {integrity: sha512-NL+lxZ6GiJkL2BYgUR8WU2awQr71GiIzG0rDNKljvhEhmIX61YUoG6qjvLlEFJsvmB/AZmKIYwP/PyFTHZZy1A==} engines: {node: ^20.19.0 || >=22.12.0} @@ -493,100 +687,103 @@ packages: jsdom: optional: true - '@rstest/coverage-istanbul@0.12.1': - resolution: {integrity: sha512-j34qn/v8Z/a8RJn0ocbzwv1eLch4/T6VAhZR6fTDk5kzaK0is4kymIBEqnN9qK0ozoNWehhQdveVFTn2Jxc7fw==} + '@rstest/coverage-istanbul@0.12.4': + resolution: {integrity: sha512-8HeZ6AwJCRsWXYutHljEAKjcYkXKKKLkiZMCM8luu+a80/puIoxsTBdAvJXKbW6Pxgt3a0zB564uAYJJzvn4iA==} peerDependencies: '@rstest/core': ^0.12.0 - '@swc-next/decoder@0.2.4': - resolution: {integrity: sha512-jueQ/rCGC+VsJ37KVbZy48/DETXla1b5+ycXXXipwaqMt2cjmN5qcheLh146gGrFEv31d6dTXTW/SmKzpHj+fg==} + '@swc-next/decoder@0.3.0': + resolution: {integrity: sha512-/vOZdBoTARfUf8yALKGnFBpWSkRK+3KuFGFaEnWLLWogbjfh3EEJ8dmPHJS9GXfnxolKnqaFHkEOQ33X4C9E1w==} - '@swc-next/parser-binding-darwin-arm64@0.2.4': - resolution: {integrity: sha512-MV0H2E3vWRiJkbZrn7c+QwwOamYefmET9vxLuvWOtTCx1GhC7tMi0sLtaZT4D/BRic+7InuaFj63M8c4q7QEJg==} + '@swc-next/parser-binding-darwin-arm64@0.3.0': + resolution: {integrity: sha512-A397Xdo3vLlScQqr/B1LCTITzQTe/nqad6NcTkt0jrD+Ses/3VKoQERpRbgcC5HPvKMMLli1Nyvd3mg50Y60BA==} cpu: [arm64] os: [darwin] - '@swc-next/parser-binding-darwin-x64@0.2.4': - resolution: {integrity: sha512-aI+Py4zkeL3avtx+9cs6zzjIYwN15jU5PGKYqiuwZrMAgZ+Lpjsr/0e9gof3L9RG71/OMnFxNgVoZq0XflxFLg==} + '@swc-next/parser-binding-darwin-x64@0.3.0': + resolution: {integrity: sha512-AcyVlPkNUXaj1j29pc6rvcWMvtUwqRBaXrJ/ZHEgleyy+8bqDW46JUnYU13q6D5v5tXV/khJn+eqb1w0YdxlKg==} cpu: [x64] os: [darwin] - '@swc-next/parser-binding-linux-arm64-gnu@0.2.4': - resolution: {integrity: sha512-VdrcT+a80SmL1zPC46EwmIlRspvCX8UDZM1lAtWZFxZ8YU+chusD5pk2GEmy9NUtSeWy1yidZrs2s8siX08F1A==} + '@swc-next/parser-binding-linux-arm64-gnu@0.3.0': + resolution: {integrity: sha512-S+jBZJCVQDNk5dYGGp5oTwXYal32eyCa+vc+No5GyCXjJC+3Vi1SGZEC5O8DFsDgLDr7cxZ5sp+M7KbAA/VHYQ==} cpu: [arm64] os: [linux] libc: [glibc] - '@swc-next/parser-binding-linux-arm64-musl@0.2.4': - resolution: {integrity: sha512-pZhkP32X1KuU4ej/cA43DgnFH5slNdYgMiOxhErDkCkEXeknb5tTQKbJ9kYwkVWYanJ6c1IUPWiqVng7hn9JGA==} + '@swc-next/parser-binding-linux-arm64-musl@0.3.0': + resolution: {integrity: sha512-DraHfo66/BHZNycTwzeD34gEQlcqWwAMv9lzyaJk+SZL2ck7mUDB+JnQ9lHKy4PXuycYI7JMK8sLqsBuyXwTjQ==} cpu: [arm64] os: [linux] libc: [musl] - '@swc-next/parser-binding-linux-ppc64-gnu@0.2.4': - resolution: {integrity: sha512-opxP4I22zAiS3pq+U50h2S42kE6IQXdZUjl/sUtuKnmnw6iGcX94/XcoVrMx1r9rEi+Bkb/Ugg+RTWHHKoy0WA==} + '@swc-next/parser-binding-linux-ppc64-gnu@0.3.0': + resolution: {integrity: sha512-P+jpP2rYAe68ETK8GieWpRDEvwBvbwUmFugtDevVlXHlrYYRZ8tkCczQ08C8mzhyxZ31zNiMopY1X5AY/laiFQ==} cpu: [ppc64] os: [linux] libc: [glibc] - '@swc-next/parser-binding-linux-riscv64-gnu@0.2.4': - resolution: {integrity: sha512-Lt2rHiCV7ybaekjQ7aS1dZf7BAHxA7IyphXG1ag9+QB3sA6AIv2TfbbutRxGL1s/s5Pn8y3a7k2Hz0kXqmE80w==} + '@swc-next/parser-binding-linux-riscv64-gnu@0.3.0': + resolution: {integrity: sha512-R+OmeFXcJ+RO3t1ef2yVcax4tDAl9Mo676iuT0ANpWu0IWax34EW4c9DCs/AO5mGdnCMJWdzxhyL8tapy8aBmg==} cpu: [riscv64] os: [linux] libc: [glibc] - '@swc-next/parser-binding-linux-riscv64-musl@0.2.4': - resolution: {integrity: sha512-12Xz09yyHKV8udkzLlWpG8ms7cv7NR/WK23H60+pWoDUyWJStJZFzC+XpGuMd5AVnBlLyRE9eOsuCzi2tAiyKQ==} + '@swc-next/parser-binding-linux-riscv64-musl@0.3.0': + resolution: {integrity: sha512-8tv2eU1djxLzIrjckIElLimn5O7tUpxHto9Of+Dr1Lvg6ON3qzcrLuAv6eOaqOkSPF1nvX4k5L/TcGNaHRKj9A==} cpu: [riscv64] os: [linux] libc: [musl] - '@swc-next/parser-binding-linux-s390x-gnu@0.2.4': - resolution: {integrity: sha512-9L5oxUNeZTeB+wmMf9SuVa9lYSb621SAUAGOb3y/wSX8tKZvoW8ey2ywauCt6faiTxe7kLB5e0Fd4XIvV9tLlQ==} + '@swc-next/parser-binding-linux-s390x-gnu@0.3.0': + resolution: {integrity: sha512-Pg5OuDna5/uyW2jQ/a26coMNT8ELDUdWYXgOCmk10Lg5PMww79CgrUyysdtz9qcCjTZoQD0Q/d6UXSHtZfC2nA==} cpu: [s390x] os: [linux] libc: [glibc] - '@swc-next/parser-binding-linux-x64-gnu@0.2.4': - resolution: {integrity: sha512-DUzEptm1OgyKUj59KSG1lQTBiXE0cCofPUfomFXgwgFsPVlWCA+ZSi0khqlSU1ogj1TbOyuQSuLJD1AH7e+YYg==} + '@swc-next/parser-binding-linux-x64-gnu@0.3.0': + resolution: {integrity: sha512-EFe+LC/esvJ3qxNBiJN26wQETBSOy1zCQuaxl4ef6aeK15Uqg+u7mtpzUf27jvC8BsWWRo2ABmwh9C+9FaHJAA==} cpu: [x64] os: [linux] libc: [glibc] - '@swc-next/parser-binding-linux-x64-musl@0.2.4': - resolution: {integrity: sha512-v8eFhWcJ/D2TAkH+/QjWWVIiyTtUMkfm+jROX1CdcyJWtweTMG400ItiqXJg+XsR2Bu/az7djIdzVACt9k1org==} + '@swc-next/parser-binding-linux-x64-musl@0.3.0': + resolution: {integrity: sha512-sGncpKBiTEmtZF0ZMpUaVWZW2Zebc1uEBL/qcDPc7MZlrI/ahjwq10WTShE1lu4VDAHmvA1H0lOY4ayJZCdnGQ==} cpu: [x64] os: [linux] libc: [musl] - '@swc-next/parser-binding-win32-arm64-msvc@0.2.4': - resolution: {integrity: sha512-vvfUs1z0v5uPr1HxzHGuJ9FI1YegR18g26r0AR6mW2XxPv05rYzyyV+DuUyqTrcLQqHf6qNkIwVdLJaMHLOtDw==} + '@swc-next/parser-binding-win32-arm64-msvc@0.3.0': + resolution: {integrity: sha512-MFp5gJvHcApJd33V7e2qdcvgATbAZIrUi8obD8MRl/aGNLBkJfmpZHIFLiDTdzAfXmBpFSYNOn2vemwxyTKNig==} cpu: [arm64] os: [win32] - '@swc-next/parser-binding-win32-ia32-msvc@0.2.4': - resolution: {integrity: sha512-9fiuLK1bHgpQ0zlzU+V9WSKUHmBDHxi0UDtWYACRfUFDniwq8tnjL0/NNKvRuRaj5OaOXf7Pdj+65hAiIp+7UA==} + '@swc-next/parser-binding-win32-ia32-msvc@0.3.0': + resolution: {integrity: sha512-7VrmSFiEBBtYTtZ2clVprJ/jR0tbWSmHUsE1AzYwBoHpB+AizcmMwolt15yvKIpmO5VnApfk7dPQ+9ON6ApcQQ==} cpu: [ia32] os: [win32] - '@swc-next/parser-binding-win32-x64-msvc@0.2.4': - resolution: {integrity: sha512-L/JAOTervJADBWmU9mWsLBwRtuPZd+xlHPdBWefGK2zqe0jSeEzfrinNt1mtFx7CHaVOJD69KjM20Ush4rDVMA==} + '@swc-next/parser-binding-win32-x64-msvc@0.3.0': + resolution: {integrity: sha512-3qTFeftUtok2JeOgfpD78d8VYC+RKBRKulfxsTOZCWkpgQ5GKxUg/oS8RBUVo9sYTlQBVU4X7fzkZ+VLYThhmw==} cpu: [x64] os: [win32] - '@swc-next/parser@0.2.4': - resolution: {integrity: sha512-nb79vL2u4WnyrweXInk6AQaVfRqhHQSaP2E1gMtktTg+cDJQqFWuxVSkNl6ScZrjkZ6qfzOfQipV96TA9bZY9A==} + '@swc-next/parser@0.3.0': + resolution: {integrity: sha512-fBaw4lniDiCcQaSuomn4N+L5Gj3wBLniGFf02YqP+gxeT+nyptKF05L3E/FqhQMl0zIJO6zTBXHo3S2Dbm0+Fw==} peerDependencies: - '@swc-next/parser-binding-wasm': 0.2.4 + '@swc-next/parser-binding-wasm': 0.3.0 peerDependenciesMeta: '@swc-next/parser-binding-wasm': optional: true + '@swc-next/types@0.3.0': + resolution: {integrity: sha512-mOnFOQMeu3kew/Xk/ShWKFsHNlJSxdeJfy07xFvCkFmqvumM+Sl2AIIkUDEegFTa3zrffiFwzo9JHNwM/JEQAA==} + '@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==} + '@tybys/wasm-util@0.10.4': + resolution: {integrity: sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==} '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -594,8 +791,8 @@ packages: '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} - '@types/node@24.13.3': - resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + '@types/node@26.6.5': + resolution: {integrity: sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==} '@typescript/typescript-aix-ppc64@7.0.2': resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} @@ -723,6 +920,11 @@ packages: ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} @@ -747,22 +949,19 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} - cjs-module-lexer@2.2.1: - resolution: {integrity: sha512-Ca8swihM+/4yKecYHY52kgJd300hi2lADU/a1RxNTRe+RJ9jvqQlESpbz9DnG9mowez8qwXHB8qYdIUw9e+F5Q==} - cjs-module-lexer@2.3.0: resolution: {integrity: sha512-lmNyBzi6iYiqyrExTctfmjd8tInx+dHu9PBomTfH5RXS53c27JMFJgzNv5TyAnm3PgGf+MLy0+Ws41/HlVxdKQ==} - content-disposition@1.1.0: - resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + content-disposition@2.0.1: + resolution: {integrity: sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==} 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==} + content-type@2.1.0: + resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} engines: {node: '>=18'} cookie-signature@1.2.2: @@ -832,21 +1031,21 @@ packages: resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} engines: {node: '>=18.0.0'} - express-rate-limit@8.6.2: - resolution: {integrity: sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==} + express-rate-limit@8.7.1: + resolution: {integrity: sha512-2FXbqkygil3mZiXzEtM0IjE/UBmBNot0J1GEVrJT4JbrvwOSoEtqtO+wHbiMQELWe1eNvHoYRg2dkjOid+6JkA==} engines: {node: '>= 16'} peerDependencies: express: '>= 4.11' - express@5.2.1: - resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + express@5.3.0: + resolution: {integrity: sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==} 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==} + fast-uri@3.1.9: + resolution: {integrity: sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==} finalhandler@2.1.1: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} @@ -887,8 +1086,8 @@ packages: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} - hono@4.13.1: - resolution: {integrity: sha512-kdJoFVv2xmayw6cY09H7AbMJMt8Jn5jdlEdXsP7AGBdF2DIptVlKlOLKXP41yPip4/a3yQPv9gVcJYI8YY04dw==} + hono@4.13.13: + resolution: {integrity: sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==} engines: {node: '>=16.9.0'} html-escaper@2.0.2: @@ -905,8 +1104,8 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - ip-address@10.5.0: - resolution: {integrity: sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==} + ip-address@10.7.4: + resolution: {integrity: sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==} engines: {node: '>= 12'} ipaddr.js@1.9.1: @@ -935,8 +1134,8 @@ packages: resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} engines: {node: '>=8'} - jose@6.2.8: - resolution: {integrity: sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==} + jose@6.2.13: + resolution: {integrity: sha512-7TIg10ZE1x82YediN+lcrPNvGMKge9yFHQD+Qho/b8y2adfugU3AsvvHfxV0fzyFllT6l9xCjf4RWY9g/Z9PJQ==} json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} @@ -971,9 +1170,9 @@ packages: 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'} + negotiator@1.1.0: + resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==} + engines: {node: '>=18'} object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} @@ -1001,16 +1200,16 @@ packages: path-to-regexp@8.4.2: resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} - picomatch@4.0.5: - resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} 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==} + pkg-pr-new@0.0.91: + resolution: {integrity: sha512-FQPmjspKRKYV4MlHQnun9snJqlYm6Yhmo12x1O3pgE2Akim/y/BZjMOppK//BFdh222WOj6WGqlmeZfrtUtfgQ==} hasBin: true prettier@3.9.10: @@ -1018,17 +1217,12 @@ packages: engines: {node: '>=14'} 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==} + proxy-addr@2.0.8: + resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==} engines: {node: '>= 0.10'} - qs@6.15.3: - resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + qs@6.16.1: + resolution: {integrity: sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==} engines: {node: '>=0.6'} range-parser@1.3.0: @@ -1047,25 +1241,25 @@ packages: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} - rsbuild-plugin-dts@1.0.3: - resolution: {integrity: sha512-wVxMYvWmVPam6SeZsz8OixHm+4JPfLJAAMTD0Q2te2SxksvjuHC3wuvU12teSmloQ3nFX1YC6guNzuYqDYw+vQ==} + rsbuild-plugin-dts@1.0.4: + resolution: {integrity: sha512-M6JZmuhfbkFDeb5gYNh5WCS74Ufv5YPupvk9wnP1loOAxnyhzlvpFZ7jj2EIv8EwF+FMojmmOVsODT/Qg3tzdA==} engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: '@microsoft/api-extractor': ^7 '@rsbuild/core': ^2.0.0 - typescript: ^5 || ^6 || ^7 + typescript: ^5 || ^6 || ^7 || ^7.1.0-0 peerDependenciesMeta: '@microsoft/api-extractor': optional: true typescript: optional: true - rstack@0.8.4: - resolution: {integrity: sha512-BMAh+fXeNsXxPoPcZ3J551A+E0fAMFvMvY6ra6ykKk008V60gpTdSo8GDjJjGckzerrrM5PeXLuGWF/1KoMFMQ==} + rstack@0.8.5: + resolution: {integrity: sha512-9qZwwyvOW1nXsENDH3blplBNZCgy8MLSCosn7Ff26Ju+905yDVWDoZIUzO9P78N5a7ACd15T2iUBXPsry8Gahw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: - '@rspress/core': ^2.0.17 + '@rspress/core': ^2.0.17 || ^2.1.0-beta.1 peerDependenciesMeta: '@rspress/core': optional: true @@ -1144,8 +1338,8 @@ packages: engines: {node: '>=16.20.0'} hasBin: true - undici-types@7.18.2: - resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + undici-types@8.9.0: + resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} @@ -1168,49 +1362,49 @@ packages: peerDependencies: zod: ^3.25.28 || ^4 - zod@4.4.3: - resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zod@4.6.7: + resolution: {integrity: sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==} snapshots: - '@ast-grep/napi-darwin-arm64@0.45.3': + '@ast-grep/napi-darwin-arm64@0.51.0': optional: true - '@ast-grep/napi-darwin-x64@0.45.3': + '@ast-grep/napi-darwin-x64@0.51.0': optional: true - '@ast-grep/napi-linux-arm64-gnu@0.45.3': + '@ast-grep/napi-linux-arm64-gnu@0.51.0': optional: true - '@ast-grep/napi-linux-arm64-musl@0.45.3': + '@ast-grep/napi-linux-arm64-musl@0.51.0': optional: true - '@ast-grep/napi-linux-x64-gnu@0.45.3': + '@ast-grep/napi-linux-x64-gnu@0.51.0': optional: true - '@ast-grep/napi-linux-x64-musl@0.45.3': + '@ast-grep/napi-linux-x64-musl@0.51.0': optional: true - '@ast-grep/napi-win32-arm64-msvc@0.45.3': + '@ast-grep/napi-win32-arm64-msvc@0.51.0': optional: true - '@ast-grep/napi-win32-ia32-msvc@0.45.3': + '@ast-grep/napi-win32-ia32-msvc@0.51.0': optional: true - '@ast-grep/napi-win32-x64-msvc@0.45.3': + '@ast-grep/napi-win32-x64-msvc@0.51.0': optional: true - '@ast-grep/napi@0.45.3': + '@ast-grep/napi@0.51.0': optionalDependencies: - '@ast-grep/napi-darwin-arm64': 0.45.3 - '@ast-grep/napi-darwin-x64': 0.45.3 - '@ast-grep/napi-linux-arm64-gnu': 0.45.3 - '@ast-grep/napi-linux-arm64-musl': 0.45.3 - '@ast-grep/napi-linux-x64-gnu': 0.45.3 - '@ast-grep/napi-linux-x64-musl': 0.45.3 - '@ast-grep/napi-win32-arm64-msvc': 0.45.3 - '@ast-grep/napi-win32-ia32-msvc': 0.45.3 - '@ast-grep/napi-win32-x64-msvc': 0.45.3 + '@ast-grep/napi-darwin-arm64': 0.51.0 + '@ast-grep/napi-darwin-x64': 0.51.0 + '@ast-grep/napi-linux-arm64-gnu': 0.51.0 + '@ast-grep/napi-linux-arm64-musl': 0.51.0 + '@ast-grep/napi-linux-x64-gnu': 0.51.0 + '@ast-grep/napi-linux-x64-musl': 0.51.0 + '@ast-grep/napi-win32-arm64-msvc': 0.51.0 + '@ast-grep/napi-win32-ia32-msvc': 0.51.0 + '@ast-grep/napi-win32-x64-msvc': 0.51.0 '@emnapi/core@1.11.3': dependencies: @@ -1228,22 +1422,22 @@ snapshots: tslib: 2.8.1 optional: true - '@hono/node-server@1.19.17(hono@4.13.1)': + '@hono/node-server@2.1.5(hono@4.13.13)': dependencies: - hono: 4.13.1 + hono: 4.13.13 '@jridgewell/resolve-uri@3.1.2': {} - '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/sourcemap-codec@1.6.0': {} '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 - '@modelcontextprotocol/sdk@1.29.0(supports-color@7.2.0)(zod@4.4.3)': + '@modelcontextprotocol/sdk@1.32.1(supports-color@7.2.0)(zod@4.6.7)': dependencies: - '@hono/node-server': 1.19.17(hono@4.13.1) + '@hono/node-server': 2.1.5(hono@4.13.13) ajv: 8.20.0 ajv-formats: 3.0.1 content-type: 1.0.5 @@ -1251,15 +1445,15 @@ snapshots: 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 + express: 5.3.0(supports-color@7.2.0) + express-rate-limit: 8.7.1(express@5.3.0)(supports-color@7.2.0) + hono: 4.13.13 + jose: 6.2.13 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) + zod: 4.6.7 + zod-to-json-schema: 3.25.2(zod@4.6.7) transitivePeerDependencies: - supports-color @@ -1267,7 +1461,7 @@ snapshots: dependencies: '@emnapi/core': 1.11.3 '@emnapi/runtime': 1.11.3 - '@tybys/wasm-util': 0.10.3 + '@tybys/wasm-util': 0.10.4 optional: true '@rsbuild/core@2.2.14': @@ -1277,34 +1471,28 @@ snapshots: transitivePeerDependencies: - '@module-federation/runtime-tools' + '@rsbuild/core@2.3.0-beta.0': + dependencies: + '@rspack/core': 2.3.0-beta.1(@swc/helpers@0.5.23) + '@swc/helpers': 0.5.23 + transitivePeerDependencies: + - '@module-federation/runtime-tools' + '@rsdoctor/agent-cli@0.1.2': {} - '@rslib/core@1.0.3(typescript@7.0.2)': + '@rslib/core@1.0.4(typescript@7.0.2)': dependencies: - '@rsbuild/core': 2.2.14 - rsbuild-plugin-dts: 1.0.3(@rsbuild/core@2.2.14)(typescript@7.0.2) + '@rsbuild/core': 2.3.0-beta.0 + rsbuild-plugin-dts: 1.0.4(@rsbuild/core@2.3.0-beta.0)(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/core@0.9.5': dependencies: - picomatch: 4.0.5 + picomatch: 4.0.7 optionalDependencies: '@rslint/native-darwin-arm64': 0.9.5 '@rslint/native-darwin-x64': 0.9.5 @@ -1315,84 +1503,90 @@ snapshots: '@rslint/native-win32-arm64-msvc': 0.9.5 '@rslint/native-win32-x64-msvc': 0.9.5 - '@rslint/native-darwin-arm64@0.8.0': - optional: true - '@rslint/native-darwin-arm64@0.9.5': optional: true - '@rslint/native-darwin-x64@0.8.0': - optional: true - '@rslint/native-darwin-x64@0.9.5': optional: true - '@rslint/native-linux-arm64-gnu@0.8.0': - optional: true - '@rslint/native-linux-arm64-gnu@0.9.5': optional: true - '@rslint/native-linux-arm64-musl@0.8.0': - optional: true - '@rslint/native-linux-arm64-musl@0.9.5': optional: true - '@rslint/native-linux-x64-gnu@0.8.0': - optional: true - '@rslint/native-linux-x64-gnu@0.9.5': optional: true - '@rslint/native-linux-x64-musl@0.8.0': - optional: true - '@rslint/native-linux-x64-musl@0.9.5': optional: true - '@rslint/native-win32-arm64-msvc@0.8.0': - optional: true - '@rslint/native-win32-arm64-msvc@0.9.5': optional: true - '@rslint/native-win32-x64-msvc@0.8.0': - optional: true - '@rslint/native-win32-x64-msvc@0.9.5': optional: true '@rspack/binding-darwin-arm64@2.2.9': optional: true + '@rspack/binding-darwin-arm64@2.3.0-beta.1': + optional: true + '@rspack/binding-darwin-x64@2.2.9': optional: true + '@rspack/binding-darwin-x64@2.3.0-beta.1': + optional: true + '@rspack/binding-linux-arm64-gnu@2.2.9': optional: true + '@rspack/binding-linux-arm64-gnu@2.3.0-beta.1': + optional: true + '@rspack/binding-linux-arm64-musl@2.2.9': optional: true + '@rspack/binding-linux-arm64-musl@2.3.0-beta.1': + optional: true + '@rspack/binding-linux-ppc64-gnu@2.2.9': optional: true + '@rspack/binding-linux-ppc64-gnu@2.3.0-beta.1': + optional: true + '@rspack/binding-linux-riscv64-gnu@2.2.9': optional: true + '@rspack/binding-linux-riscv64-gnu@2.3.0-beta.1': + optional: true + '@rspack/binding-linux-riscv64-musl@2.2.9': optional: true + '@rspack/binding-linux-riscv64-musl@2.3.0-beta.1': + optional: true + '@rspack/binding-linux-s390x-gnu@2.2.9': optional: true + '@rspack/binding-linux-s390x-gnu@2.3.0-beta.1': + optional: true + '@rspack/binding-linux-x64-gnu@2.2.9': optional: true + '@rspack/binding-linux-x64-gnu@2.3.0-beta.1': + optional: true + '@rspack/binding-linux-x64-musl@2.2.9': optional: true + '@rspack/binding-linux-x64-musl@2.3.0-beta.1': + optional: true + '@rspack/binding-wasm32-wasi@2.2.9': dependencies: '@emnapi/core': 1.11.3 @@ -1400,15 +1594,31 @@ snapshots: '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) optional: true + '@rspack/binding-wasm32-wasi@2.3.0-beta.1': + 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.2.9': optional: true + '@rspack/binding-win32-arm64-msvc@2.3.0-beta.1': + optional: true + '@rspack/binding-win32-ia32-msvc@2.2.9': optional: true + '@rspack/binding-win32-ia32-msvc@2.3.0-beta.1': + optional: true + '@rspack/binding-win32-x64-msvc@2.2.9': optional: true + '@rspack/binding-win32-x64-msvc@2.3.0-beta.1': + optional: true + '@rspack/binding@2.2.9': optionalDependencies: '@rspack/binding-darwin-arm64': 2.2.9 @@ -1426,59 +1636,73 @@ snapshots: '@rspack/binding-win32-ia32-msvc': 2.2.9 '@rspack/binding-win32-x64-msvc': 2.2.9 + '@rspack/binding@2.3.0-beta.1': + optionalDependencies: + '@rspack/binding-darwin-arm64': 2.3.0-beta.1 + '@rspack/binding-darwin-x64': 2.3.0-beta.1 + '@rspack/binding-linux-arm64-gnu': 2.3.0-beta.1 + '@rspack/binding-linux-arm64-musl': 2.3.0-beta.1 + '@rspack/binding-linux-ppc64-gnu': 2.3.0-beta.1 + '@rspack/binding-linux-riscv64-gnu': 2.3.0-beta.1 + '@rspack/binding-linux-riscv64-musl': 2.3.0-beta.1 + '@rspack/binding-linux-s390x-gnu': 2.3.0-beta.1 + '@rspack/binding-linux-x64-gnu': 2.3.0-beta.1 + '@rspack/binding-linux-x64-musl': 2.3.0-beta.1 + '@rspack/binding-wasm32-wasi': 2.3.0-beta.1 + '@rspack/binding-win32-arm64-msvc': 2.3.0-beta.1 + '@rspack/binding-win32-ia32-msvc': 2.3.0-beta.1 + '@rspack/binding-win32-x64-msvc': 2.3.0-beta.1 + '@rspack/core@2.2.9(@swc/helpers@0.5.23)': dependencies: '@rspack/binding': 2.2.9 optionalDependencies: '@swc/helpers': 0.5.23 - '@rstackjs/cli-darwin-arm64@0.8.4': - optional: true + '@rspack/core@2.3.0-beta.1(@swc/helpers@0.5.23)': + dependencies: + '@rspack/binding': 2.3.0-beta.1 + optionalDependencies: + '@swc/helpers': 0.5.23 - '@rstackjs/cli-darwin-x64@0.8.4': + '@rstackjs/cli-darwin-arm64@0.8.5': optional: true - '@rstackjs/cli-linux-arm64-gnu@0.8.4': + '@rstackjs/cli-darwin-x64@0.8.5': optional: true - '@rstackjs/cli-linux-arm64-musl@0.8.4': + '@rstackjs/cli-linux-arm64-gnu@0.8.5': optional: true - '@rstackjs/cli-linux-ppc64-gnu@0.8.4': + '@rstackjs/cli-linux-arm64-musl@0.8.5': optional: true - '@rstackjs/cli-linux-riscv64-gnu@0.8.4': + '@rstackjs/cli-linux-ppc64-gnu@0.8.5': optional: true - '@rstackjs/cli-linux-riscv64-musl@0.8.4': + '@rstackjs/cli-linux-riscv64-gnu@0.8.5': optional: true - '@rstackjs/cli-linux-s390x-gnu@0.8.4': + '@rstackjs/cli-linux-riscv64-musl@0.8.5': optional: true - '@rstackjs/cli-linux-x64-gnu@0.8.4': + '@rstackjs/cli-linux-s390x-gnu@0.8.5': optional: true - '@rstackjs/cli-linux-x64-musl@0.8.4': + '@rstackjs/cli-linux-x64-gnu@0.8.5': optional: true - '@rstackjs/cli-win32-arm64-msvc@0.8.4': + '@rstackjs/cli-linux-x64-musl@0.8.5': optional: true - '@rstackjs/cli-win32-ia32-msvc@0.8.4': + '@rstackjs/cli-win32-arm64-msvc@0.8.5': optional: true - '@rstackjs/cli-win32-x64-msvc@0.8.4': + '@rstackjs/cli-win32-ia32-msvc@0.8.5': optional: true - '@rstest/core@0.12.1': - dependencies: - '@rsbuild/core': 2.2.14 - '@types/chai': 5.2.3 - cjs-module-lexer: 2.2.1 - transitivePeerDependencies: - - '@module-federation/runtime-tools' - - core-js + '@rstackjs/cli-win32-x64-msvc@0.8.5': + optional: true '@rstest/core@0.12.4': dependencies: @@ -1489,9 +1713,9 @@ snapshots: - '@module-federation/runtime-tools' - core-js - '@rstest/coverage-istanbul@0.12.1(@rstest/core@0.12.1)(supports-color@7.2.0)': + '@rstest/coverage-istanbul@0.12.4(@rstest/core@0.12.4)(supports-color@7.2.0)': dependencies: - '@rstest/core': 0.12.1 + '@rstest/core': 0.12.4 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 istanbul-lib-source-maps: 5.0.6(supports-color@7.2.0) @@ -1500,70 +1724,75 @@ snapshots: transitivePeerDependencies: - supports-color - '@swc-next/decoder@0.2.4': {} + '@swc-next/decoder@0.3.0': + dependencies: + '@swc-next/types': 0.3.0 - '@swc-next/parser-binding-darwin-arm64@0.2.4': + '@swc-next/parser-binding-darwin-arm64@0.3.0': optional: true - '@swc-next/parser-binding-darwin-x64@0.2.4': + '@swc-next/parser-binding-darwin-x64@0.3.0': optional: true - '@swc-next/parser-binding-linux-arm64-gnu@0.2.4': + '@swc-next/parser-binding-linux-arm64-gnu@0.3.0': optional: true - '@swc-next/parser-binding-linux-arm64-musl@0.2.4': + '@swc-next/parser-binding-linux-arm64-musl@0.3.0': optional: true - '@swc-next/parser-binding-linux-ppc64-gnu@0.2.4': + '@swc-next/parser-binding-linux-ppc64-gnu@0.3.0': optional: true - '@swc-next/parser-binding-linux-riscv64-gnu@0.2.4': + '@swc-next/parser-binding-linux-riscv64-gnu@0.3.0': optional: true - '@swc-next/parser-binding-linux-riscv64-musl@0.2.4': + '@swc-next/parser-binding-linux-riscv64-musl@0.3.0': optional: true - '@swc-next/parser-binding-linux-s390x-gnu@0.2.4': + '@swc-next/parser-binding-linux-s390x-gnu@0.3.0': optional: true - '@swc-next/parser-binding-linux-x64-gnu@0.2.4': + '@swc-next/parser-binding-linux-x64-gnu@0.3.0': optional: true - '@swc-next/parser-binding-linux-x64-musl@0.2.4': + '@swc-next/parser-binding-linux-x64-musl@0.3.0': optional: true - '@swc-next/parser-binding-win32-arm64-msvc@0.2.4': + '@swc-next/parser-binding-win32-arm64-msvc@0.3.0': optional: true - '@swc-next/parser-binding-win32-ia32-msvc@0.2.4': + '@swc-next/parser-binding-win32-ia32-msvc@0.3.0': optional: true - '@swc-next/parser-binding-win32-x64-msvc@0.2.4': + '@swc-next/parser-binding-win32-x64-msvc@0.3.0': optional: true - '@swc-next/parser@0.2.4': + '@swc-next/parser@0.3.0': dependencies: - '@swc-next/decoder': 0.2.4 + '@swc-next/decoder': 0.3.0 + '@swc-next/types': 0.3.0 optionalDependencies: - '@swc-next/parser-binding-darwin-arm64': 0.2.4 - '@swc-next/parser-binding-darwin-x64': 0.2.4 - '@swc-next/parser-binding-linux-arm64-gnu': 0.2.4 - '@swc-next/parser-binding-linux-arm64-musl': 0.2.4 - '@swc-next/parser-binding-linux-ppc64-gnu': 0.2.4 - '@swc-next/parser-binding-linux-riscv64-gnu': 0.2.4 - '@swc-next/parser-binding-linux-riscv64-musl': 0.2.4 - '@swc-next/parser-binding-linux-s390x-gnu': 0.2.4 - '@swc-next/parser-binding-linux-x64-gnu': 0.2.4 - '@swc-next/parser-binding-linux-x64-musl': 0.2.4 - '@swc-next/parser-binding-win32-arm64-msvc': 0.2.4 - '@swc-next/parser-binding-win32-ia32-msvc': 0.2.4 - '@swc-next/parser-binding-win32-x64-msvc': 0.2.4 + '@swc-next/parser-binding-darwin-arm64': 0.3.0 + '@swc-next/parser-binding-darwin-x64': 0.3.0 + '@swc-next/parser-binding-linux-arm64-gnu': 0.3.0 + '@swc-next/parser-binding-linux-arm64-musl': 0.3.0 + '@swc-next/parser-binding-linux-ppc64-gnu': 0.3.0 + '@swc-next/parser-binding-linux-riscv64-gnu': 0.3.0 + '@swc-next/parser-binding-linux-riscv64-musl': 0.3.0 + '@swc-next/parser-binding-linux-s390x-gnu': 0.3.0 + '@swc-next/parser-binding-linux-x64-gnu': 0.3.0 + '@swc-next/parser-binding-linux-x64-musl': 0.3.0 + '@swc-next/parser-binding-win32-arm64-msvc': 0.3.0 + '@swc-next/parser-binding-win32-ia32-msvc': 0.3.0 + '@swc-next/parser-binding-win32-x64-msvc': 0.3.0 + + '@swc-next/types@0.3.0': {} '@swc/helpers@0.5.23': dependencies: tslib: 2.8.1 - '@tybys/wasm-util@0.10.3': + '@tybys/wasm-util@0.10.4': dependencies: tslib: 2.8.1 optional: true @@ -1575,9 +1804,9 @@ snapshots: '@types/deep-eql@4.0.2': {} - '@types/node@24.13.3': + '@types/node@26.6.5': dependencies: - undici-types: 7.18.2 + undici-types: 8.9.0 '@typescript/typescript-aix-ppc64@7.0.2': optional: true @@ -1642,7 +1871,7 @@ snapshots: accepts@2.0.0: dependencies: mime-types: 3.0.2 - negotiator: 1.0.0 + negotiator: 1.1.0 ajv-formats@3.0.1: dependencies: @@ -1651,7 +1880,7 @@ snapshots: ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.5 + fast-uri: 3.1.9 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -1660,12 +1889,12 @@ snapshots: body-parser@2.3.0(supports-color@7.2.0): dependencies: bytes: 3.1.2 - content-type: 2.0.0 + content-type: 2.1.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 + qs: 6.16.1 raw-body: 3.0.2 type-is: 2.1.0 transitivePeerDependencies: @@ -1683,15 +1912,13 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 - cjs-module-lexer@2.2.1: {} - cjs-module-lexer@2.3.0: {} - content-disposition@1.1.0: {} + content-disposition@2.0.1: {} content-type@1.0.5: {} - content-type@2.0.0: {} + content-type@2.1.0: {} cookie-signature@1.2.2: {} @@ -1744,20 +1971,20 @@ snapshots: dependencies: eventsource-parser: 3.1.1 - express-rate-limit@8.6.2(express@5.2.1)(supports-color@7.2.0): + express-rate-limit@8.7.1(express@5.3.0)(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 + express: 5.3.0(supports-color@7.2.0) + ip-address: 10.7.4 transitivePeerDependencies: - supports-color - express@5.2.1(supports-color@7.2.0): + express@5.3.0(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 + content-disposition: 2.0.1 + content-type: 2.1.0 cookie: 0.7.2 cookie-signature: 1.2.2 debug: 4.4.3(supports-color@7.2.0) @@ -1773,8 +2000,8 @@ snapshots: on-finished: 2.4.1 once: 1.4.0 parseurl: 1.3.3 - proxy-addr: 2.0.7 - qs: 6.15.3 + proxy-addr: 2.0.8 + qs: 6.16.1 range-parser: 1.3.0 router: 2.2.0(supports-color@7.2.0) send: 1.2.1(supports-color@7.2.0) @@ -1787,7 +2014,7 @@ snapshots: fast-deep-equal@3.1.3: {} - fast-uri@3.1.5: {} + fast-uri@3.1.9: {} finalhandler@2.1.1(supports-color@7.2.0): dependencies: @@ -1834,7 +2061,7 @@ snapshots: dependencies: function-bind: 1.1.2 - hono@4.13.1: {} + hono@4.13.13: {} html-escaper@2.0.2: {} @@ -1852,7 +2079,7 @@ snapshots: inherits@2.0.4: {} - ip-address@10.5.0: {} + ip-address@10.7.4: {} ipaddr.js@1.9.1: {} @@ -1881,7 +2108,7 @@ snapshots: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 - jose@6.2.8: {} + jose@6.2.13: {} json-schema-traverse@1.0.0: {} @@ -1905,7 +2132,9 @@ snapshots: ms@2.1.3: {} - negotiator@1.0.0: {} + negotiator@1.1.0: + dependencies: + content-type: 2.1.0 object-assign@4.1.1: {} @@ -1925,22 +2154,20 @@ snapshots: path-to-regexp@8.4.2: {} - picomatch@4.0.5: {} + picomatch@4.0.7: {} pkce-challenge@5.0.1: {} - pkg-pr-new@0.0.87: {} + pkg-pr-new@0.0.91: {} prettier@3.9.10: {} - prettier@3.9.6: {} - - proxy-addr@2.0.7: + proxy-addr@2.0.8: dependencies: forwarded: 0.2.0 ipaddr.js: 1.9.1 - qs@6.15.3: + qs@6.16.1: dependencies: es-define-property: 1.0.1 side-channel: 1.1.1 @@ -1966,36 +2193,36 @@ snapshots: transitivePeerDependencies: - supports-color - rsbuild-plugin-dts@1.0.3(@rsbuild/core@2.2.14)(typescript@7.0.2): + rsbuild-plugin-dts@1.0.4(@rsbuild/core@2.3.0-beta.0)(typescript@7.0.2): dependencies: - '@ast-grep/napi': 0.45.3 - '@rsbuild/core': 2.2.14 + '@ast-grep/napi': 0.51.0 + '@rsbuild/core': 2.3.0-beta.0 optionalDependencies: typescript: 7.0.2 - rstack@0.8.4(typescript@7.0.2): + rstack@0.8.5(typescript@7.0.2): dependencies: '@rsbuild/core': 2.2.14 - '@rslib/core': 1.0.3(typescript@7.0.2) + '@rslib/core': 1.0.4(typescript@7.0.2) '@rslint/core': 0.9.5 '@rstest/core': 0.12.4 - '@swc-next/parser': 0.2.4 + '@swc-next/parser': 0.3.0 prettier: 3.9.10 tinypool: 2.2.0 optionalDependencies: - '@rstackjs/cli-darwin-arm64': 0.8.4 - '@rstackjs/cli-darwin-x64': 0.8.4 - '@rstackjs/cli-linux-arm64-gnu': 0.8.4 - '@rstackjs/cli-linux-arm64-musl': 0.8.4 - '@rstackjs/cli-linux-ppc64-gnu': 0.8.4 - '@rstackjs/cli-linux-riscv64-gnu': 0.8.4 - '@rstackjs/cli-linux-riscv64-musl': 0.8.4 - '@rstackjs/cli-linux-s390x-gnu': 0.8.4 - '@rstackjs/cli-linux-x64-gnu': 0.8.4 - '@rstackjs/cli-linux-x64-musl': 0.8.4 - '@rstackjs/cli-win32-arm64-msvc': 0.8.4 - '@rstackjs/cli-win32-ia32-msvc': 0.8.4 - '@rstackjs/cli-win32-x64-msvc': 0.8.4 + '@rstackjs/cli-darwin-arm64': 0.8.5 + '@rstackjs/cli-darwin-x64': 0.8.5 + '@rstackjs/cli-linux-arm64-gnu': 0.8.5 + '@rstackjs/cli-linux-arm64-musl': 0.8.5 + '@rstackjs/cli-linux-ppc64-gnu': 0.8.5 + '@rstackjs/cli-linux-riscv64-gnu': 0.8.5 + '@rstackjs/cli-linux-riscv64-musl': 0.8.5 + '@rstackjs/cli-linux-s390x-gnu': 0.8.5 + '@rstackjs/cli-linux-x64-gnu': 0.8.5 + '@rstackjs/cli-linux-x64-musl': 0.8.5 + '@rstackjs/cli-win32-arm64-msvc': 0.8.5 + '@rstackjs/cli-win32-ia32-msvc': 0.8.5 + '@rstackjs/cli-win32-x64-msvc': 0.8.5 transitivePeerDependencies: - '@microsoft/api-extractor' - '@module-federation/runtime-tools' @@ -2087,7 +2314,7 @@ snapshots: type-is@2.1.0: dependencies: - content-type: 2.0.0 + content-type: 2.1.0 media-typer: 1.1.1 mime-types: 3.0.2 @@ -2114,7 +2341,7 @@ snapshots: '@typescript/typescript-win32-arm64': 7.0.2 '@typescript/typescript-win32-x64': 7.0.2 - undici-types@7.18.2: {} + undici-types@8.9.0: {} unpipe@1.0.0: {} @@ -2126,8 +2353,8 @@ snapshots: wrappy@1.0.2: {} - zod-to-json-schema@3.25.2(zod@4.4.3): + zod-to-json-schema@3.25.2(zod@4.6.7): dependencies: - zod: 4.4.3 + zod: 4.6.7 - zod@4.4.3: {} + zod@4.6.7: {} diff --git a/tests/testRun.test.ts b/tests/testRun.test.ts index 86a52a7..7048706 100644 --- a/tests/testRun.test.ts +++ b/tests/testRun.test.ts @@ -150,7 +150,9 @@ test('keeps the coverage provider out of the published dependency surface', asyn // 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.12.1'); + expect(packageJson.devDependencies?.['@rstest/coverage-istanbul']).toBe( + packageJson.dependencies?.['@rstest/core'], + ); expect(packageJson.peerDependencies?.['@rstest/coverage-istanbul']).toBeUndefined(); expect(packageJson.peerDependenciesMeta?.['@rstest/coverage-istanbul']).toBeUndefined();