Skip to content

Commit 59daddc

Browse files
feat(context): capture related Rstest evidence
1 parent 2f61aa7 commit 59daddc

17 files changed

Lines changed: 571 additions & 60 deletions

File tree

packages/context/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,11 @@
5151
"peerDependencies": {
5252
"@rstest/coverage-istanbul": "0.11.6"
5353
},
54+
"peerDependenciesMeta": {
55+
"@rstest/coverage-istanbul": {
56+
"optional": true
57+
}
58+
},
5459
"engines": {
5560
"node": ">=22.12.0"
5661
},

packages/context/src/codeEvidence.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,12 @@ type TestOutcomeEvidence = {
5959
matchingTests: number;
6060
};
6161

62+
type TestRelationEvidence = {
63+
state: 'related' | 'unrelated' | 'unknown' | 'unavailable';
64+
reason?: 'no-test-snapshot' | 'not-captured' | 'source-not-selected' | 'selection-not-isolated';
65+
testFiles: string[];
66+
};
67+
6268
type CodeDiagnosticEvidence = {
6369
total: number;
6470
returned: number;
@@ -70,6 +76,7 @@ type CodeEvidenceResult = {
7076
path: string;
7177
line?: number;
7278
executionCoverage: ExecutionCoverageEvidence;
79+
testRelation: TestRelationEvidence;
7380
testOutcome: TestOutcomeEvidence;
7481
diagnostics: CodeDiagnosticEvidence;
7582
module?: DeadCodeExplanation;
@@ -241,6 +248,33 @@ const testOutcome = (
241248
return { state: 'not-run', matchingFiles: files.length, matchingTests: tests.length };
242249
};
243250

251+
const testRelation = (
252+
sourcePath: string,
253+
stored: StoredContextSnapshot | undefined,
254+
): TestRelationEvidence => {
255+
if (stored === undefined) {
256+
return { state: 'unavailable', reason: 'no-test-snapshot', testFiles: [] };
257+
}
258+
const facet = stored.snapshot.facets.test as unknown as TestFacet | undefined;
259+
if (facet?.relation === undefined) {
260+
return { state: 'unavailable', reason: 'not-captured', testFiles: [] };
261+
}
262+
if (!facet.relation.sources.includes(sourcePath)) {
263+
return { state: 'unknown', reason: 'source-not-selected', testFiles: [] };
264+
}
265+
if (facet.relation.sources.length !== 1) {
266+
return {
267+
state: 'unknown',
268+
reason: 'selection-not-isolated',
269+
testFiles: [...facet.relation.testFiles],
270+
};
271+
}
272+
return {
273+
state: facet.relation.testFiles.length > 0 ? 'related' : 'unrelated',
274+
testFiles: [...facet.relation.testFiles],
275+
};
276+
};
277+
244278
const compareDiagnostics = (left: DiagnosticRecord, right: DiagnosticRecord): number =>
245279
left.producer.localeCompare(right.producer) ||
246280
(left.line ?? 0) - (right.line ?? 0) ||
@@ -365,6 +399,7 @@ const readCodeEvidence = async (
365399
};
366400
const bounds = [
367401
'aggregate-execution-no-test-attribution',
402+
'test-relation-static-build-graph',
368403
'test-outcome-exact-path-only',
369404
'diagnostics-exact-path-only',
370405
...(module !== undefined && module.provenance.artifactBinding !== 'exact'
@@ -375,6 +410,7 @@ const readCodeEvidence = async (
375410
path: sourcePath,
376411
...(query.line === undefined ? {} : { line: query.line }),
377412
executionCoverage: await executionCoverage(workspaceRoot, sourcePath, query.line, testSnapshot),
413+
testRelation: testRelation(sourcePath, testSnapshot),
378414
testOutcome: testOutcome(sourcePath, testSnapshot),
379415
diagnostics,
380416
...(module === undefined ? {} : { module }),
@@ -391,4 +427,5 @@ export type {
391427
ExecutionCoverageEvidence,
392428
SnapshotEvidence,
393429
TestOutcomeEvidence,
430+
TestRelationEvidence,
394431
};

packages/context/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export {
2727
type TestErrorRecord,
2828
type TestFacet,
2929
type TestFileRecord,
30+
type TestRelationRecord,
3031
} from './model.ts';
3132
export {
3233
readContextSnapshotById,
@@ -71,6 +72,8 @@ export {
7172
export {
7273
captureTestSnapshot,
7374
listTestResults,
75+
type RelatedTestRequest,
76+
type ResolveRelatedTests,
7477
type TestCaptureDependencies,
7578
type TestCaptureResult,
7679
type TestResultPage,

packages/context/src/mcp.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,14 @@ const lintSnapshotInput = z
221221
const testSnapshotInput = z
222222
.object({
223223
files: z.array(z.string().min(1)).optional(),
224+
related: z
225+
.array(z.string().min(1))
226+
.min(1)
227+
.max(200)
228+
.describe(
229+
'Source paths resolved from packageRoot; Rstest selects and runs only statically related test files.',
230+
)
231+
.optional(),
224232
testNamePattern: z.string().min(1).optional(),
225233
packageRoot: packageRootInput.optional(),
226234
configPath: configPathInput.optional(),
@@ -344,7 +352,7 @@ const formatCodeEvidence = (result: CodeEvidenceResult): string => {
344352
result.module === undefined
345353
? 'not-requested'
346354
: `${result.module.classification}(binding=${result.module.provenance.artifactBinding})`;
347-
return `Code evidence for ${result.path}: coverage=${result.executionCoverage.state}, test=${result.testOutcome.state}, diagnostics=${diagnostics}, module=${module}. See structuredContent for complete data.`;
355+
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.`;
348356
};
349357

350358
const isLiteralEmpty = (value: unknown): boolean =>
@@ -607,7 +615,7 @@ const createContextMcpServer = (
607615
{
608616
title: 'Inspect code evidence',
609617
description:
610-
'Join exact-path aggregate execution, test outcome, diagnostics, and optional explicit artifact module evidence without collapsing their bounds.',
618+
'Join static related-test selection, exact-path test outcome, aggregate execution, diagnostics, and optional explicit artifact module evidence without collapsing their bounds.',
611619
inputSchema: codeEvidenceInput,
612620
annotations: readOnlyAnnotations,
613621
},
@@ -747,7 +755,8 @@ const createContextMcpServer = (
747755
'test_snapshot',
748756
{
749757
title: 'Capture test snapshot',
750-
description: 'Run one explicit one-shot Rstest capture and store its immutable results.',
758+
description:
759+
'Run one explicit one-shot Rstest capture, optionally selected from related source files, and store its immutable results.',
751760
inputSchema: testSnapshotInput,
752761
annotations: {
753762
readOnlyHint: false,

packages/context/src/model.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,8 +127,14 @@ type TestFileRecord = {
127127
tests: TestCaseRecord[];
128128
};
129129

130+
type TestRelationRecord = {
131+
sources: string[];
132+
testFiles: string[];
133+
};
134+
130135
type TestFacet = {
131136
producer: 'rstest';
137+
relation?: TestRelationRecord;
132138
files: TestFileRecord[];
133139
stats: {
134140
tests: {
@@ -317,4 +323,5 @@ export type {
317323
TestExecutionStatement,
318324
TestFacet,
319325
TestFileRecord,
326+
TestRelationRecord,
320327
};

packages/context/src/records.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,10 +159,18 @@ const isTestFile = (value: unknown): boolean =>
159159
Array.isArray(value.tests) &&
160160
value.tests.every(isTestCase);
161161

162+
const isTestRelation = (value: unknown): boolean =>
163+
isRecordObject(value) &&
164+
Array.isArray(value.sources) &&
165+
value.sources.every(isRecordPath) &&
166+
Array.isArray(value.testFiles) &&
167+
value.testFiles.every(isRecordPath);
168+
162169
const validateTestFacet = (value: unknown): TestFacet | undefined => {
163170
if (
164171
!isRecordObject(value) ||
165172
value.producer !== 'rstest' ||
173+
(value.relation !== undefined && !isTestRelation(value.relation)) ||
166174
!Array.isArray(value.files) ||
167175
!value.files.every(isTestFile) ||
168176
!isRecordObject(value.stats) ||

0 commit comments

Comments
 (0)