@wix/pathgrade 1.0.39 → 1.0.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +6 -6
  2. package/dist/adapters/jest/results.d.ts +1 -0
  3. package/dist/adapters/jest/results.js +36 -14
  4. package/dist/adapters/jest/runner-adapter.d.ts +1 -0
  5. package/dist/adapters/jest/runner-adapter.js +5 -1
  6. package/dist/adapters/node-test/runner-adapter.js +6 -5
  7. package/dist/affected/meta.d.ts +4 -1
  8. package/dist/affected/meta.js +9 -5
  9. package/dist/affected/select.js +1 -1
  10. package/dist/commands/report.js +1 -2
  11. package/dist/reporting/comparison-contract.d.ts +3 -2
  12. package/dist/reporting/comparison-contract.js +66 -33
  13. package/dist/reporting/core.js +18 -1
  14. package/dist/reporting/reliability-contract.d.ts +1 -0
  15. package/dist/reporting/report-parser.js +68 -3
  16. package/dist/reporting/source-metadata.d.ts +3 -0
  17. package/dist/reporting/source-metadata.js +67 -2
  18. package/dist/reporting/types.d.ts +3 -0
  19. package/dist/runners/model-builders.d.ts +1 -0
  20. package/dist/runners/model-builders.js +36 -12
  21. package/dist/runners/model.d.ts +2 -0
  22. package/dist/runners/report-projection.js +83 -0
  23. package/dist/runners/vitest-adapter.js +6 -2
  24. package/dist/sdk/evaluate.js +1 -1
  25. package/dist/sdk/index.d.ts +1 -1
  26. package/dist/sdk/index.js +1 -1
  27. package/dist/sdk/mcp-evidence.d.ts +4 -0
  28. package/dist/sdk/mcp-evidence.js +39 -14
  29. package/dist/sdk/run-scorer.js +10 -0
  30. package/dist/sdk/scorers.d.ts +5 -0
  31. package/dist/sdk/scorers.js +4 -0
  32. package/dist/sdk/tool-event-log.js +31 -1
  33. package/dist/sdk/tool-event-secrets.js +1 -0
  34. package/dist/sdk/types.d.ts +2 -1
  35. package/dist/tool-events.d.ts +2 -0
  36. package/dist/types.d.ts +5 -2
  37. package/package.json +2 -2
@@ -1,7 +1,18 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { readFileSync } from 'node:fs';
3
+ import { readdirSync } from 'node:fs';
3
4
  import * as path from 'node:path';
5
+ import picomatch from 'picomatch';
6
+ import { canonicalizeJson } from '../core/canonical-json.js';
7
+ import { parsePathgradeMeta } from '../affected/meta.js';
4
8
  export function resolveSourceMetadata(filePath, cwd) {
9
+ return resolveSourceMetadataWithFiles(filePath, cwd, () => listRepositoryFiles(cwd));
10
+ }
11
+ export function createSourceMetadataResolver(cwd) {
12
+ let repositoryFiles;
13
+ return filePath => resolveSourceMetadataWithFiles(filePath, cwd, () => repositoryFiles ??= listRepositoryFiles(cwd));
14
+ }
15
+ function resolveSourceMetadataWithFiles(filePath, cwd, repositoryFiles) {
5
16
  if (!filePath)
6
17
  return {};
7
18
  const absolute = path.resolve(cwd, filePath);
@@ -11,10 +22,64 @@ export function resolveSourceMetadata(filePath, cwd) {
11
22
  try {
12
23
  return {
13
24
  sourceFile: relative,
14
- sourceRevision: `sha256:${createHash('sha256').update(readFileSync(absolute)).digest('hex')}`,
25
+ sourceRevision: digest(readFileSync(absolute)),
26
+ comparisonInputs: resolveComparisonInputs(absolute, cwd, repositoryFiles),
27
+ };
28
+ }
29
+ catch {
30
+ return { sourceFile: relative, comparisonInputs: { state: 'invalid', reason: 'unreadable-input' } };
31
+ }
32
+ }
33
+ function resolveComparisonInputs(evalFile, cwd, repositoryFiles) {
34
+ let declarations;
35
+ try {
36
+ declarations = parsePathgradeMeta(evalFile)?.comparisonInputs;
37
+ }
38
+ catch (error) {
39
+ const message = error instanceof Error ? error.message : '';
40
+ return {
41
+ state: 'invalid',
42
+ reason: message.includes('escapes the repo root') ? 'outside-repository' : 'malformed-declaration',
43
+ };
44
+ }
45
+ if (declarations === undefined)
46
+ return { state: 'missing' };
47
+ const normalized = declarations.map(value => value.replaceAll('\\', '/').normalize('NFC'));
48
+ const matched = [...new Set(normalized.flatMap(declaration => {
49
+ const matches = picomatch(declaration, { dot: true });
50
+ return repositoryFiles().filter(file => matches(file));
51
+ }))].toSorted();
52
+ if (normalized.length > 0 && matched.length === 0)
53
+ return { state: 'invalid', reason: 'no-matches' };
54
+ try {
55
+ const files = matched.map(file => ({ path: file, revision: digest(readFileSync(path.join(cwd, file))) }));
56
+ return {
57
+ state: 'resolved',
58
+ declarations: normalized,
59
+ files,
60
+ revision: digest(canonicalizeJson({ declarations: normalized, files })),
15
61
  };
16
62
  }
17
63
  catch {
18
- return { sourceFile: relative };
64
+ return { state: 'invalid', reason: 'unreadable-input' };
19
65
  }
20
66
  }
67
+ function listRepositoryFiles(cwd) {
68
+ const files = [];
69
+ const walk = (directory, relative) => {
70
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
71
+ const childRelative = relative ? `${relative}/${entry.name}` : entry.name;
72
+ if (!relative && (entry.name === '.git' || entry.name === '.pathgrade'))
73
+ continue;
74
+ if (entry.isDirectory())
75
+ walk(path.join(directory, entry.name), childRelative);
76
+ else if (entry.isFile())
77
+ files.push(childRelative);
78
+ }
79
+ };
80
+ walk(path.resolve(cwd), '');
81
+ return files.toSorted();
82
+ }
83
+ function digest(value) {
84
+ return `sha256:${createHash('sha256').update(value).digest('hex')}`;
85
+ }
@@ -2,6 +2,7 @@ import type { DiagnosticsReport } from '../sdk/diagnostics.js';
2
2
  import type { AgentExecutionMetadata, EvaluationResultKind } from '../sdk/types.js';
3
3
  import type { AttemptOutcome, RunStatus } from '../runners/model.js';
4
4
  import type { PathgradeReport, PathgradeSelectionReport, TrialResult } from '../types.js';
5
+ import type { ComparisonInputsState, TaskInventory } from './reliability-contract.js';
5
6
  export type ReportCaseState = 'passed' | 'failed' | 'skipped' | 'pending';
6
7
  export interface ReportRunInput {
7
8
  runStatus?: RunStatus;
@@ -9,12 +10,14 @@ export interface ReportRunInput {
9
10
  selection?: PathgradeSelectionReport;
10
11
  attemptsRequested?: number;
11
12
  attemptsCompleted?: number;
13
+ taskInventory?: TaskInventory;
12
14
  groups: ReportGroupInput[];
13
15
  }
14
16
  export interface ReportGroupInput {
15
17
  groupName: string;
16
18
  sourceFile?: string;
17
19
  sourceRevision?: string;
20
+ comparisonInputs?: ComparisonInputsState;
18
21
  cases: ReportCaseInput[];
19
22
  }
20
23
  export interface ReportCaseInput {
@@ -3,4 +3,5 @@ import type { AdapterRunHandle } from './adapter.js';
3
3
  import type { NormalizedRunSnapshot } from './model.js';
4
4
  export declare function buildNormalizedRunSnapshotFromReportGroups(run: AdapterRunHandle, groups: ReportGroupInput[], options?: {
5
5
  cwd?: string;
6
+ discoveredFiles?: readonly string[];
6
7
  }): NormalizedRunSnapshot;
@@ -1,6 +1,7 @@
1
- import { resolveSourceMetadata } from '../reporting/source-metadata.js';
1
+ import { createSourceMetadataResolver } from '../reporting/source-metadata.js';
2
2
  export function buildNormalizedRunSnapshotFromReportGroups(run, groups, options = {}) {
3
3
  const cwd = options.cwd ?? process.cwd();
4
+ const resolveSourceMetadata = createSourceMetadataResolver(cwd);
4
5
  return {
5
6
  version: 1,
6
7
  completeness: 'final',
@@ -14,18 +15,41 @@ export function buildNormalizedRunSnapshotFromReportGroups(run, groups, options
14
15
  message: diagnostic.message,
15
16
  })) } : {}),
16
17
  },
17
- units: groups.map((group, index) => {
18
- const source = group.sourceFile && group.sourceRevision
19
- ? { sourceFile: group.sourceFile, sourceRevision: group.sourceRevision }
20
- : resolveSourceMetadata(group.sourceFile ?? group.cases.map(testCase => testCase.filePath ?? testCase.sourceRef).find(Boolean), cwd);
21
- return {
22
- id: unitId(group.groupName, index),
18
+ units: [
19
+ ...groups.map((group, index) => {
20
+ const source = group.sourceFile && group.sourceRevision
21
+ ? {
22
+ sourceFile: group.sourceFile,
23
+ sourceRevision: group.sourceRevision,
24
+ ...(group.comparisonInputs ? { comparisonInputs: group.comparisonInputs } : {}),
25
+ }
26
+ : resolveSourceMetadata(group.sourceFile ?? group.cases.map(testCase => testCase.filePath ?? testCase.sourceRef).find(Boolean));
27
+ return {
28
+ id: unitId(group.groupName, index),
29
+ runId: `${run.adapterName}:run`,
30
+ displayName: group.groupName,
31
+ collection: run.status === 'completed' || run.status === 'failed'
32
+ ? { state: 'complete' }
33
+ : { state: 'incomplete', reason: 'run-incomplete' },
34
+ ...source,
35
+ groupingHints: [{ kind: 'suite', key: group.groupName, label: group.groupName, order: index }],
36
+ };
37
+ }),
38
+ ...(options.discoveredFiles ?? [])
39
+ .filter(file => !groups.some(group => group.sourceFile === file
40
+ || group.cases.some(testCase => testCase.filePath === file || testCase.sourceRef === file)))
41
+ .map((file, index) => ({
42
+ id: unitId(file, groups.length + index),
23
43
  runId: `${run.adapterName}:run`,
24
- displayName: group.groupName,
25
- ...source,
26
- groupingHints: [{ kind: 'suite', key: group.groupName, label: group.groupName, order: index }],
27
- };
28
- }),
44
+ displayName: file,
45
+ collection: {
46
+ state: 'incomplete',
47
+ reason: 'adapter-cannot-prove-completeness',
48
+ },
49
+ ...resolveSourceMetadata(file),
50
+ groupingHints: [{ kind: 'source', key: file, label: file, order: groups.length + index }],
51
+ })),
52
+ ],
29
53
  cases: groups.flatMap((group, groupIndex) => group.cases.map((testCase, caseIndex) => {
30
54
  const caseId = testCase.caseId ?? `${unitId(group.groupName, groupIndex)}:case-${caseIndex + 1}`;
31
55
  const attemptId = `${caseId}:attempt-1`;
@@ -1,6 +1,7 @@
1
1
  import type { DiagnosticsReport } from '../sdk/diagnostics.js';
2
2
  import type { TrialResult } from '../types.js';
3
3
  import type { CollectionIncompleteReason } from '../reporting/reliability-contract.js';
4
+ import type { ComparisonInputsState } from '../reporting/reliability-contract.js';
4
5
  export interface NormalizedRunSnapshot {
5
6
  version: 1;
6
7
  completeness: SnapshotCompleteness;
@@ -29,6 +30,7 @@ export interface EvalUnitRecord {
29
30
  collection?: EvalUnitCollection;
30
31
  sourceFile?: string;
31
32
  sourceRevision?: string;
33
+ comparisonInputs?: ComparisonInputsState;
32
34
  diagnostics?: Diagnostic[];
33
35
  nativeReferences?: NativeReference[];
34
36
  groupingHints?: GroupingHint[];
@@ -1,3 +1,4 @@
1
+ import { TASK_INVENTORY_VERSION } from '../reporting/reliability-contract.js';
1
2
  import { validateNormalizedRunSnapshot } from './model-validation.js';
2
3
  export function projectNormalizedRunSnapshotToReportInput(snapshot, options = {}) {
3
4
  const validation = validateNormalizedRunSnapshot(snapshot, { completeness: 'final' });
@@ -20,6 +21,7 @@ export function projectNormalizedRunSnapshotToReportInput(snapshot, options = {}
20
21
  groupName: key,
21
22
  ...(unit?.sourceFile ? { sourceFile: unit.sourceFile } : {}),
22
23
  ...(unit?.sourceRevision ? { sourceRevision: unit.sourceRevision } : {}),
24
+ ...(unit?.comparisonInputs ? { comparisonInputs: unit.comparisonInputs } : {}),
23
25
  cases: [],
24
26
  };
25
27
  group.cases.push({
@@ -44,9 +46,90 @@ export function projectNormalizedRunSnapshotToReportInput(snapshot, options = {}
44
46
  runStatus: snapshot.model.run.status,
45
47
  attemptsRequested: snapshot.model.run.attemptsRequested ?? 1,
46
48
  attemptsCompleted: snapshot.model.run.attemptsCompleted ?? 1,
49
+ taskInventory: buildTaskInventory(snapshot, [...groups.values()]),
47
50
  groups: [...groups.values()],
48
51
  };
49
52
  }
53
+ function buildTaskInventory(snapshot, groups) {
54
+ const unitsBySource = new Map(snapshot.model.units
55
+ .filter(unit => unit.sourceFile)
56
+ .map(unit => [unit.sourceFile, unit]));
57
+ const files = new Map();
58
+ for (const unit of snapshot.model.units) {
59
+ if (!unit.sourceFile)
60
+ continue;
61
+ const collection = unit.collection ?? {
62
+ state: 'incomplete',
63
+ reason: 'adapter-cannot-prove-completeness',
64
+ };
65
+ const existing = files.get(unit.sourceFile);
66
+ if (!existing) {
67
+ files.set(unit.sourceFile, {
68
+ tasks: [],
69
+ completeness: collection.state,
70
+ ...(collection.state === 'incomplete' ? { reason: collection.reason } : {}),
71
+ });
72
+ }
73
+ else if (collection.state === 'incomplete') {
74
+ existing.completeness = 'incomplete';
75
+ existing.reason = collection.reason;
76
+ }
77
+ }
78
+ for (const group of groups) {
79
+ if (!group.sourceFile)
80
+ continue;
81
+ const unit = unitsBySource.get(group.sourceFile);
82
+ const collection = unit?.collection ?? {
83
+ state: 'incomplete',
84
+ reason: 'adapter-cannot-prove-completeness',
85
+ };
86
+ const file = files.get(group.sourceFile) ?? {
87
+ tasks: [],
88
+ completeness: collection.state,
89
+ ...(collection.state === 'incomplete' ? { reason: collection.reason } : {}),
90
+ };
91
+ file.tasks.push(taskInventoryEntry(group));
92
+ if (collection.state === 'incomplete') {
93
+ file.completeness = 'incomplete';
94
+ file.reason = collection.reason;
95
+ }
96
+ files.set(group.sourceFile, file);
97
+ }
98
+ return {
99
+ version: TASK_INVENTORY_VERSION,
100
+ files: [...files.entries()].toSorted(([left], [right]) => left.localeCompare(right)).map(([eval_file, file]) => ({
101
+ eval_file,
102
+ tasks: file.tasks.toSorted((left, right) => left.task_key.localeCompare(right.task_key)),
103
+ ...(file.completeness === 'complete'
104
+ ? { completeness: 'complete' }
105
+ : { completeness: 'incomplete', reason: file.reason ?? 'adapter-cannot-prove-completeness' }),
106
+ })),
107
+ };
108
+ }
109
+ function taskInventoryEntry(group) {
110
+ const states = group.cases.map(testCase => testCase.state);
111
+ if (states.every(state => state === 'skipped')) {
112
+ return { task_key: group.groupName, state: 'not-scored', reason: 'skipped' };
113
+ }
114
+ if (states.every(state => state === 'pending')) {
115
+ return { task_key: group.groupName, state: 'not-scored', reason: 'pending' };
116
+ }
117
+ if (group.cases.some(testCase => testCase.attempts?.some(attempt => attempt.outcome.kind === 'timed-out'))) {
118
+ return { task_key: group.groupName, state: 'not-scored', reason: 'timed-out' };
119
+ }
120
+ if (group.cases.some(isPromotableCase))
121
+ return { task_key: group.groupName, state: 'scored' };
122
+ return { task_key: group.groupName, state: 'not-scored', reason: 'failed-before-evaluation' };
123
+ }
124
+ function isPromotableCase(testCase) {
125
+ if (testCase.state === 'skipped' || testCase.state === 'pending')
126
+ return false;
127
+ const evaluations = testCase.attempts?.flatMap(attempt => attempt.evaluations ?? []) ?? testCase.evaluations;
128
+ if (evaluations === undefined)
129
+ return true;
130
+ return evaluations.some(evaluation => evaluation.score !== undefined
131
+ && evaluation.resultKind !== 'synthetic_no_evaluation');
132
+ }
50
133
  function totalDurationMs(runCase) {
51
134
  return runCase.attempts.reduce((sum, attempt) => sum + (attempt.durationMs ?? 0), 0);
52
135
  }
@@ -31,11 +31,15 @@ export function createVitestAdapter(options = {}) {
31
31
  native: {
32
32
  testModules: options.testModules ?? [],
33
33
  cwd: discoveryCwd,
34
+ discoveredFiles: input.discovered.units.flatMap(unit => unit.sourceRef ? [unit.sourceRef] : []),
34
35
  },
35
36
  };
36
37
  },
37
38
  async collectNormalizedRunSnapshot(run) {
38
- return buildNormalizedRunSnapshotFromReportGroups(run, collectVitestReportGroups(readVitestRunNative(run).testModules), { cwd: readVitestRunNative(run).cwd });
39
+ return buildNormalizedRunSnapshotFromReportGroups(run, collectVitestReportGroups(readVitestRunNative(run).testModules), {
40
+ cwd: readVitestRunNative(run).cwd,
41
+ discoveredFiles: readVitestRunNative(run).discoveredFiles,
42
+ });
39
43
  },
40
44
  };
41
45
  }
@@ -59,7 +63,7 @@ export function collectVitestReportGroups(testModules) {
59
63
  function readVitestRunNative(run) {
60
64
  if (isVitestRunNative(run.native))
61
65
  return run.native;
62
- return { testModules: [], cwd: process.cwd() };
66
+ return { testModules: [], cwd: process.cwd(), discoveredFiles: [] };
63
67
  }
64
68
  function isVitestRunNative(native) {
65
69
  return typeof native === 'object'
@@ -270,7 +270,7 @@ function createLiveDeterministicContext(ctx) {
270
270
  tool: event.arguments?.tool,
271
271
  status: event.arguments?.status,
272
272
  ...structuredClone(originalInput),
273
- }, ...(mcp ? { mcp } : {}) })
273
+ }, redactedArgumentPaths: undefined, ...(mcp ? { mcp } : {}) })
274
274
  : event;
275
275
  }),
276
276
  };
@@ -4,7 +4,7 @@ export { AgentCrashError } from './agent-crash.js';
4
4
  export { check, score, judge, toolUsage } from './scorers.js';
5
5
  export { compileScenario, compileScenarioText } from './scenario-machine-v2.js';
6
6
  export { findScenarioCaseMatches, getFinalScenarioState, getScenarioStateTimeline, ScenarioEvidenceError, wasScenarioCaseMatched, } from './scenario-evidence.js';
7
- export { getMcpToolCall, isMcpToolCall, findMcpToolCalls, getMcpStartupStatus, isMcpStartupStatus, getMcpApproval, isMcpApproval, findMcpApprovals, getMcpInvocation, wasMcpToolInvoked, } from './mcp-evidence.js';
7
+ export { getMcpToolCall, isMcpToolCall, findMcpToolCalls, McpEvidenceUnavailableError, getMcpStartupStatus, isMcpStartupStatus, getMcpApproval, isMcpApproval, findMcpApprovals, getMcpInvocation, wasMcpToolInvoked, } from './mcp-evidence.js';
8
8
  export type { McpMockApprovalRule, McpMockJsonValue } from './mcp-mock-approvals.js';
9
9
  export type { CompileScenarioResult, EvidenceEnvelope, JsonObject, JsonPrimitive, JsonValue, MatchExpr, MatchPredicate, McpContent, ScenarioArtifact, ScenarioCase, ScenarioDiagnostic, ScenarioEvidence, ScenarioMachineV2, ScenarioRejection, ScenarioSuccess, ScenarioToolError, } from './scenario-machine-v2.js';
10
10
  export type { ExpectedScenarioCaseMatch, ScenarioCaseMatchEvidence, ScenarioStateTransitionEvidence, } from './scenario-evidence.js';
package/dist/sdk/index.js CHANGED
@@ -5,7 +5,7 @@ export { AgentCrashError } from './agent-crash.js';
5
5
  export { check, score, judge, toolUsage } from './scorers.js';
6
6
  export { compileScenario, compileScenarioText } from './scenario-machine-v2.js';
7
7
  export { findScenarioCaseMatches, getFinalScenarioState, getScenarioStateTimeline, ScenarioEvidenceError, wasScenarioCaseMatched, } from './scenario-evidence.js';
8
- export { getMcpToolCall, isMcpToolCall, findMcpToolCalls, getMcpStartupStatus, isMcpStartupStatus, getMcpApproval, isMcpApproval, findMcpApprovals, getMcpInvocation, wasMcpToolInvoked, } from './mcp-evidence.js';
8
+ export { getMcpToolCall, isMcpToolCall, findMcpToolCalls, McpEvidenceUnavailableError, getMcpStartupStatus, isMcpStartupStatus, getMcpApproval, isMcpApproval, findMcpApprovals, getMcpInvocation, wasMcpToolInvoked, } from './mcp-evidence.js';
9
9
  export { decideMcpToolCall, redactMcpSecrets, } from './mcp-safety.js';
10
10
  export { evaluate, evaluateFlow, EvalScorerError } from './evaluate.js';
11
11
  export { RUN_SNAPSHOT_VERSION, buildRunSnapshot, loadRunSnapshot, SnapshotParseError, SnapshotVersionError, WorkspaceMissingError, } from './snapshots.js';
@@ -1,4 +1,7 @@
1
1
  import type { ToolEvent } from '../tool-events.js';
2
+ export declare class McpEvidenceUnavailableError extends TypeError {
3
+ readonly name = "McpEvidenceUnavailableError";
4
+ }
2
5
  export type McpInvocationResult = {
3
6
  serverName: string;
4
7
  toolName: string;
@@ -46,6 +49,7 @@ export interface ExpectedMcpToolCall {
46
49
  status?: string;
47
50
  invocation?: McpInvocationResult['invocation'];
48
51
  outcome?: McpInvocationResult['outcome'];
52
+ /** Throws when persisted redaction makes the requested argument match unknowable. */
49
53
  argumentsContaining?: Record<string, unknown>;
50
54
  }
51
55
  export interface McpStartupStatusEvidence {
@@ -1,13 +1,21 @@
1
+ const REDACTED_ARGUMENTS_ERROR = "argumentsContaining cannot be evaluated against redacted MCP arguments; use the scorer's ctx.toolEvents with deterministicToolEvidence: 'live'";
2
+ export class McpEvidenceUnavailableError extends TypeError {
3
+ name = 'McpEvidenceUnavailableError';
4
+ }
1
5
  export function getMcpToolCall(event) {
2
6
  if (event.action !== 'mcp_tool_call')
3
7
  return undefined;
4
8
  const args = event.arguments ?? {};
9
+ const separator = event.providerToolName.indexOf('.');
5
10
  const serverName = event.mcp?.serverName
6
- ?? (typeof args.server === 'string' ? args.server : undefined);
11
+ ?? (typeof args.server === 'string' ? args.server : undefined)
12
+ ?? (separator > 0 ? event.providerToolName.slice(0, separator) : undefined);
7
13
  const toolName = event.mcp?.toolName
8
- ?? (typeof args.tool === 'string' ? args.tool : undefined);
14
+ ?? (typeof args.tool === 'string' ? args.tool : undefined)
15
+ ?? (separator > 0 && separator < event.providerToolName.length - 1
16
+ ? event.providerToolName.slice(separator + 1) : undefined);
9
17
  const argumentStatus = typeof args.status === 'string' ? args.status : undefined;
10
- const status = mcpClassificationStatus(event.mcp, argumentStatus) ?? argumentStatus;
18
+ const status = mcpClassificationStatus(event.mcp, argumentStatus) ?? argumentStatus ?? event.status;
11
19
  if (!serverName || !toolName || !status)
12
20
  return undefined;
13
21
  return {
@@ -151,29 +159,46 @@ function matchesMcpToolCall(call, expected) {
151
159
  if (expected.outcome !== undefined && invocation.outcome !== expected.outcome)
152
160
  return false;
153
161
  }
154
- if (expected.argumentsContaining && !containsArguments(call.arguments, expected.argumentsContaining)) {
162
+ if (expected.argumentsContaining && !containsArguments(call.arguments, expected.argumentsContaining, call.event.redactedArgumentPaths)) {
155
163
  return false;
156
164
  }
157
165
  return true;
158
166
  }
159
- function containsArguments(actual, expected) {
160
- return Object.entries(expected).every(([key, expectedValue]) => valuesEqual(actual[key], expectedValue));
167
+ function containsArguments(actual, expected, redactedPaths = []) {
168
+ const matches = Object.entries(expected).every(([key, expectedValue]) => valuesEqual(actual[key], expectedValue, `/${escapeJsonPointer(key)}`, redactedPaths));
169
+ return matches;
161
170
  }
162
- function valuesEqual(actual, expected) {
163
- if (Object.is(actual, expected))
171
+ function valuesEqual(actual, expected, path, redactedPaths) {
172
+ if (Object.is(actual, expected)) {
173
+ assertMcpArgumentAvailable(path, redactedPaths);
164
174
  return true;
175
+ }
165
176
  if (Array.isArray(actual) || Array.isArray(expected)) {
166
- if (!Array.isArray(actual) || !Array.isArray(expected))
167
- return false;
168
- if (actual.length !== expected.length)
169
- return false;
170
- return actual.every((value, index) => valuesEqual(value, expected[index]));
177
+ if (Array.isArray(actual) && Array.isArray(expected) && actual.length === expected.length) {
178
+ return actual.every((value, index) => valuesEqual(value, expected[index], `${path}/${index}`, redactedPaths));
179
+ }
180
+ return rejectRedactedMismatch(path, redactedPaths);
171
181
  }
172
182
  if (isRecord(actual) && isRecord(expected)) {
173
- return Object.entries(expected).every(([key, value]) => valuesEqual(actual[key], value));
183
+ return Object.entries(expected).every(([key, value]) => valuesEqual(actual[key], value, `${path}/${escapeJsonPointer(key)}`, redactedPaths));
174
184
  }
185
+ return rejectRedactedMismatch(path, redactedPaths);
186
+ }
187
+ function rejectRedactedMismatch(path, redactedPaths) {
188
+ assertMcpArgumentAvailable(path, redactedPaths);
175
189
  return false;
176
190
  }
191
+ function assertMcpArgumentAvailable(path, redactedPaths) {
192
+ if (redactedPaths.some((redactedPath) => redactedPath === ''
193
+ || redactedPath === path
194
+ || redactedPath.startsWith(`${path}/`)
195
+ || path.startsWith(`${redactedPath}/`))) {
196
+ throw new McpEvidenceUnavailableError(REDACTED_ARGUMENTS_ERROR);
197
+ }
198
+ }
199
+ function escapeJsonPointer(value) {
200
+ return value.replaceAll('~', '~0').replaceAll('/', '~1');
201
+ }
177
202
  function isRecord(value) {
178
203
  return !!value && typeof value === 'object' && !Array.isArray(value);
179
204
  }
@@ -1,6 +1,7 @@
1
1
  import { runJudgePipeline } from './judge-pipeline.js';
2
2
  import { runJudgeSession } from './judge-tool-session.js';
3
3
  import { clamp, makeErroredResult, matchesExpectation } from './scorer-utils.js';
4
+ import { McpEvidenceUnavailableError } from './mcp-evidence.js';
4
5
  export async function runScorer(scorer, ctx, opts = {}) {
5
6
  switch (scorer.type) {
6
7
  case 'check':
@@ -56,6 +57,9 @@ async function runCheckScorer(scorer, ctx) {
56
57
  };
57
58
  }
58
59
  catch (error) {
60
+ if (error instanceof McpEvidenceUnavailableError) {
61
+ return makeUnavailableEvidenceResult('check', scorer.name, scorer.weight, error);
62
+ }
59
63
  return makeErroredResult('check', scorer.name, scorer.weight, error);
60
64
  }
61
65
  }
@@ -81,9 +85,15 @@ async function runScoreScorer(scorer, ctx) {
81
85
  };
82
86
  }
83
87
  catch (error) {
88
+ if (error instanceof McpEvidenceUnavailableError) {
89
+ return makeUnavailableEvidenceResult('score', scorer.name, scorer.weight, error);
90
+ }
84
91
  return makeErroredResult('score', scorer.name, scorer.weight, error);
85
92
  }
86
93
  }
94
+ function makeUnavailableEvidenceResult(type, name, weight, error) {
95
+ return { name, type, score: 0, weight, details: error.message, status: 'ok' };
96
+ }
87
97
  async function runToolUsageScorer(scorer, ctx) {
88
98
  try {
89
99
  const toolEvents = ctx.toolEvents;
@@ -1,15 +1,18 @@
1
1
  import type { CheckScorer, CodeJudgeToolName, ScorerContext, JudgeScorer, ScoreScorer, ScoreResult, ToolExpectation, ToolUsageScorer } from './types.js';
2
+ import type { JsonValue } from '../internal/direct-mcp-v2/types.js';
2
3
  /**
3
4
  * Create a check scorer — a boolean gate that passes (1.0) or fails (0.0).
4
5
  */
5
6
  export declare function check(name: string, fn: (ctx: ScorerContext) => boolean | Promise<boolean>, opts?: {
6
7
  weight?: number;
8
+ revision?: JsonValue;
7
9
  }): CheckScorer;
8
10
  /**
9
11
  * Create a score scorer — returns a number (0-1) or { score, details } for partial credit.
10
12
  */
11
13
  export declare function score(name: string, fn: (ctx: ScorerContext) => number | ScoreResult | Promise<number | ScoreResult>, opts?: {
12
14
  weight?: number;
15
+ revision?: JsonValue;
13
16
  }): ScoreScorer;
14
17
  /**
15
18
  * Create a judge scorer — uses an LLM to evaluate against a rubric.
@@ -55,10 +58,12 @@ export declare function judge(name: string, opts: {
55
58
  maxRounds?: number;
56
59
  /** Anthropic prompt caching. Default: true when tools is set, unchanged otherwise. */
57
60
  cacheControl?: boolean;
61
+ revision?: JsonValue;
58
62
  }): JudgeScorer;
59
63
  /**
60
64
  * Create a tool usage scorer — matches tool events against expectations.
61
65
  */
62
66
  export declare function toolUsage(name: string, expectations: ToolExpectation[], opts?: {
63
67
  weight?: number;
68
+ revision?: JsonValue;
64
69
  }): ToolUsageScorer;
@@ -7,6 +7,7 @@ export function check(name, fn, opts) {
7
7
  name,
8
8
  weight: opts?.weight ?? 1,
9
9
  fn,
10
+ ...(opts?.revision === undefined ? {} : { revision: opts.revision }),
10
11
  };
11
12
  }
12
13
  /**
@@ -18,6 +19,7 @@ export function score(name, fn, opts) {
18
19
  name,
19
20
  weight: opts?.weight ?? 1,
20
21
  fn,
22
+ ...(opts?.revision === undefined ? {} : { revision: opts.revision }),
21
23
  };
22
24
  }
23
25
  /**
@@ -65,6 +67,7 @@ export function judge(name, opts) {
65
67
  tools,
66
68
  maxRounds: opts.maxRounds,
67
69
  cacheControl,
70
+ ...(opts.revision === undefined ? {} : { revision: opts.revision }),
68
71
  };
69
72
  }
70
73
  /**
@@ -76,5 +79,6 @@ export function toolUsage(name, expectations, opts) {
76
79
  name,
77
80
  weight: opts?.weight ?? 1,
78
81
  expectations,
82
+ ...(opts?.revision === undefined ? {} : { revision: opts.revision }),
79
83
  };
80
84
  }
@@ -1,5 +1,8 @@
1
+ import { isDeepStrictEqual } from 'node:util';
1
2
  import { collectStructuredSensitiveValues, isSensitiveValueScanLimitError, sanitizePersistenceValue, } from '../tool-event-results.js';
2
3
  import { attachToolEventSensitiveValues, cloneToolEventWithRuntimeMetadata, getToolEventSensitiveValues, redactToolEventPayload, } from './tool-event-secrets.js';
4
+ import { getOriginalMcpInput } from './mcp-event-input.js';
5
+ const MCP_METADATA_ARGUMENTS = new Set(['server', 'tool', 'status']);
3
6
  export function buildToolEventLogEntry(toolEvent, fallbackTimestamp) {
4
7
  let event = toolEvent;
5
8
  let discoveredValues;
@@ -16,10 +19,37 @@ export function buildToolEventLogEntry(toolEvent, fallbackTimestamp) {
16
19
  ...getToolEventSensitiveValues(toolEvent),
17
20
  ...discoveredValues,
18
21
  ])];
19
- const persistedEvent = attachToolEventSensitiveValues(cloneToolEventWithRuntimeMetadata(event, sanitizePersistenceValue(event, sensitiveValues)), sensitiveValues);
22
+ const sanitizedEvent = sanitizePersistenceValue(event, sensitiveValues);
23
+ const originalInput = getOriginalMcpInput(event);
24
+ const originalArguments = originalInput ? {
25
+ ...event.arguments,
26
+ ...Object.fromEntries(Object.entries(originalInput).filter(([key]) => !MCP_METADATA_ARGUMENTS.has(key))),
27
+ } : event.arguments;
28
+ const redactedArgumentPaths = [...new Set([
29
+ ...(event.redactedArgumentPaths ?? []),
30
+ ...collectChangedPaths(originalArguments, sanitizedEvent.arguments),
31
+ ])];
32
+ const persistedEvent = attachToolEventSensitiveValues(cloneToolEventWithRuntimeMetadata(event, {
33
+ ...sanitizedEvent,
34
+ ...(redactedArgumentPaths.length > 0 ? { redactedArgumentPaths } : {}),
35
+ }), sensitiveValues);
20
36
  return {
21
37
  type: 'tool_event',
22
38
  timestamp: persistedEvent.startedAt ?? fallbackTimestamp,
23
39
  tool_event: persistedEvent,
24
40
  };
25
41
  }
42
+ function collectChangedPaths(original, sanitized, path = '') {
43
+ if (isDeepStrictEqual(original, sanitized))
44
+ return [];
45
+ if (Array.isArray(original) && Array.isArray(sanitized) && original.length === sanitized.length) {
46
+ return original.flatMap((value, index) => collectChangedPaths(value, sanitized[index], `${path}/${index}`));
47
+ }
48
+ if (isRecord(original) && isRecord(sanitized)) {
49
+ return Object.keys(original).flatMap((key) => collectChangedPaths(original[key], sanitized[key], `${path}/${key.replaceAll('~', '~0').replaceAll('/', '~1')}`));
50
+ }
51
+ return [path];
52
+ }
53
+ function isRecord(value) {
54
+ return !!value && typeof value === 'object' && !Array.isArray(value);
55
+ }
@@ -27,6 +27,7 @@ export function redactToolEventPayload(event) {
27
27
  return {
28
28
  ...event,
29
29
  arguments: { redacted: true },
30
+ redactedArgumentPaths: [''],
30
31
  summary: `${event.action} via ${event.providerToolName}`,
31
32
  rawSnippet: '<redacted>',
32
33
  };
@@ -348,6 +348,7 @@ export interface ScorerContext {
348
348
  workspace: string;
349
349
  log: LogEntry[];
350
350
  transcript: string;
351
+ /** Use this scorer-context field for live MCP arguments; `log` always remains sanitized. */
351
352
  toolEvents: import('../tool-events.js').ToolEvent[];
352
353
  runCommand: (cmd: string) => Promise<CommandResult>;
353
354
  artifacts: SessionArtifacts;
@@ -360,7 +361,7 @@ export interface EvaluateOptions {
360
361
  failFast?: boolean;
361
362
  llm?: LLMPort;
362
363
  onScorerError?: 'skip' | 'zero' | 'fail';
363
- /** Evidence visible to deterministic scorers. Live mode is unavailable for snapshot replay. */
364
+ /** Restores exact MCP arguments only on deterministic scorers' `ctx.toolEvents`; `agent.log` remains sanitized. Live mode is unavailable for snapshot replay. */
364
365
  deterministicToolEvidence?: 'persisted' | 'live';
365
366
  /** Stable identity for this evaluation definition across separate runs. */
366
367
  evaluationDefinitionKey?: string;
@@ -20,6 +20,8 @@ export interface ToolEvent {
20
20
  toolUseId?: string;
21
21
  turnNumber?: number;
22
22
  arguments?: Record<string, unknown>;
23
+ /** Producer-controlled JSON Pointer paths identifying persisted arguments that were sanitized. */
24
+ redactedArgumentPaths?: string[];
23
25
  /** Lifecycle state observed by PathGrade. Absent on legacy provider events. */
24
26
  status?: 'completed' | 'error' | 'incomplete';
25
27
  /** Receipt/enforcement-backed classification for canonical MCP calls. */