@wix/pathgrade 1.0.39 → 1.0.40

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.
package/README.md CHANGED
@@ -178,7 +178,7 @@ Use `check()` for binary requirements, `score()` for partial credit, `judge()` f
178
178
  check('tests-pass', async ({ runCommand }) => {
179
179
  const { exitCode } = await runCommand('npm test');
180
180
  return exitCode === 0;
181
- });
181
+ }, { revision: { contract: 1 } });
182
182
  ```
183
183
 
184
184
  ### `score()` - Partial credit
@@ -187,7 +187,7 @@ check('tests-pass', async ({ runCommand }) => {
187
187
  score('coverage', async ({ runCommand }) => {
188
188
  const { stdout } = await runCommand('npx coverage-summary');
189
189
  return parseFloat(stdout) / 100;
190
- });
190
+ }, { revision: { contract: 1 } });
191
191
  ```
192
192
 
193
193
  ### `judge()` - Rubric evaluation
@@ -199,6 +199,7 @@ Was the fix minimal and correct? (0-0.5)`,
199
199
  });
200
200
  ```
201
201
 
202
+ Function-bearing `check()` and `score()` scorers require explicit canonical-JSON `revision` data for authoritative baseline comparisons. A `judge()` with a function-valued `input` requires the same declaration. Imported scorer helpers belong in `comparisonInputs`; closed-over values belong in `revision`.
202
203
  Judge scorers also support:
203
204
 
204
205
  - `retry` for transient judge failures
@@ -440,9 +441,8 @@ Useful details:
440
441
  - `pathgrade preview browser` starts a local viewer on `http://localhost:3847`.
441
442
  - `pathgrade report` posts or updates a PR comment in GitHub Actions; locally it prints the markdown report and then the numeric pass rate. Provider orchestrators can add a `--details-url`, suppress stale updates with `--expected-head-sha`, and opt into surfaced API failures with `--strict`.
442
443
  - `pathgrade validate --affected` is a strict mode for CI: every discovered eval must either live under a `SKILL.md` anchor or export valid `__pathgradeMeta`.
443
-
444
+ Declare methodology files separately from affected-selection dependencies with `__pathgradeMeta.comparisonInputs`, for example `comparisonInputs: ['evals/fixtures/**', 'evals/scorers/**']`. An empty array explicitly declares a self-contained eval. Pathgrade hashes the raw eval source plus each declared glob, matched repository-relative path, and raw file bytes; `.git` internals and `.pathgrade` outputs are excluded. Missing, invalid, unmatched, escaping, or unreadable inputs suppress numeric deltas. `deps`, `extraDeps`, inferred skill roots, and global triggers affect selection only and never definition identity.
444
445
  Run `pathgrade --help` for the full help text.
445
-
446
446
  ## Configuration
447
447
 
448
448
  ```typescript
@@ -461,7 +461,7 @@ export default {
461
461
 
462
462
  Pathgrade reads `pathgrade.config.*` for CLI and affected-selection behavior. `runner.adapter` and `--adapter=<name|path>` select the runner; `--adapter` wins over config. `attempts` defaults to `1` and must be a positive integer. Built-in adapters `vitest`, `jest`, and `node-test` support repeated attempts; third-party invocation adapters must advertise `supportsRepeatedAttempts: true` and produce the normalized child snapshot contract.
463
463
 
464
- New reports use schema version 2. They preserve every attempt with `case_id`, `attempt_id`, and `attempt_index`; include the display-safe `runner_outcome` that determined binary success; publish `mean_reward` for partial scores; and publish finite-sample pass@k only for complete binary attempts. `runner_status` and `threshold_status` expose the two gates independently, and canonical `status` passes only when runner assertions pass and any configured threshold passes. Runner assertions, runner diagnostics, and native runner references remain in the normalized run model and are not copied into public report artifacts; evaluation diagnostics retain their existing report behavior. A partial reward or incomplete attempt makes pass@k explicitly unavailable. Version-1 and older version-2 reports remain readable with missing gate fields derived during loading, but Pathgrade no longer computes pass@k or pass^k by pooling heterogeneous cases.
464
+ New reports use schema version 3. They add a versioned task inventory with every selected eval file, collection completeness, stable task keys, and bounded non-scoring reasons. They also preserve every attempt with `case_id`, `attempt_id`, and `attempt_index`; include the display-safe `runner_outcome`; publish `mean_reward`; and publish finite-sample pass@k only for complete binary attempts. `runner_status` and `threshold_status` expose the two gates independently, and canonical `status` passes only when runner assertions pass and any configured threshold passes. Runner assertions, runner diagnostics, native runner references, and full flow traces remain transient or in trace artifacts rather than being copied into consolidated public reports. A partial reward or incomplete attempt makes pass@k explicitly unavailable. Report versions 1 and 2 remain readable, but they cannot be promoted as authoritative task-accounted baselines and must be rerun.
465
465
 
466
466
  Third-party runner adapters are supported through `@wix/pathgrade/adapter-kit`. Adapter names resolve as follows:
467
467
 
@@ -23,6 +23,7 @@ export declare function normalizeJestRunResults(input: {
23
23
  run: AdapterRunHandle;
24
24
  results: JestAggregatedResult;
25
25
  metadataByCaseId?: Map<string, PathgradeTestMeta[]>;
26
+ discoveredFiles?: readonly string[];
26
27
  cwd?: string;
27
28
  }): NormalizedRunSnapshot;
28
29
  export declare function jestCaseId(input: {
@@ -1,20 +1,39 @@
1
- import { resolveSourceMetadata } from '../../reporting/source-metadata.js';
1
+ import { createSourceMetadataResolver } from '../../reporting/source-metadata.js';
2
2
  export function normalizeJestRunResults(input) {
3
3
  const runId = `${input.run.adapterName}:run`;
4
+ const cwd = input.cwd ?? process.cwd();
5
+ const resolveSourceMetadata = createSourceMetadataResolver(cwd);
4
6
  const files = input.results.testResults ?? [];
5
- const units = files.map((fileResult, index) => ({
6
- id: unitId(index),
7
- runId,
8
- displayName: fileResult.testFilePath,
9
- ...resolveSourceMetadata(fileResult.testFilePath, input.cwd ?? process.cwd()),
10
- groupingHints: [{
11
- kind: 'source',
12
- key: fileResult.testFilePath,
13
- label: fileResult.testFilePath,
14
- order: index,
15
- }],
16
- nativeReferences: [{ kind: 'jest-file', id: fileResult.testFilePath }],
17
- }));
7
+ const collection = input.run.status === 'completed'
8
+ ? { state: 'complete' }
9
+ : { state: 'incomplete', reason: 'run-incomplete' };
10
+ const units = [
11
+ ...files.map((fileResult, index) => ({
12
+ id: unitId(index),
13
+ runId,
14
+ displayName: fileResult.testFilePath,
15
+ collection,
16
+ ...resolveSourceMetadata(fileResult.testFilePath),
17
+ groupingHints: [{
18
+ kind: 'source',
19
+ key: fileResult.testFilePath,
20
+ label: fileResult.testFilePath,
21
+ order: index,
22
+ }],
23
+ nativeReferences: [{ kind: 'jest-file', id: fileResult.testFilePath }],
24
+ })),
25
+ ...(input.discoveredFiles ?? [])
26
+ .filter(file => !files.some(result => normalizePath(result.testFilePath, resolveSourceMetadata) === file))
27
+ .map((file, index) => ({
28
+ id: unitId(files.length + index),
29
+ runId,
30
+ displayName: file,
31
+ collection,
32
+ ...resolveSourceMetadata(file),
33
+ groupingHints: [{ kind: 'source', key: file, label: file, order: files.length + index }],
34
+ nativeReferences: [{ kind: 'jest-file', id: file }],
35
+ })),
36
+ ];
18
37
  const occurrenceCounts = new Map();
19
38
  return {
20
39
  version: 1,
@@ -45,6 +64,9 @@ export function normalizeJestRunResults(input) {
45
64
  },
46
65
  };
47
66
  }
67
+ function normalizePath(file, resolveSourceMetadata) {
68
+ return resolveSourceMetadata(file).sourceFile ?? file.replaceAll('\\', '/');
69
+ }
48
70
  function assertionsForFile(fileResult) {
49
71
  return fileResult.assertionResults ?? fileResult.testResults ?? [];
50
72
  }
@@ -3,6 +3,7 @@ import { type PathgradeTestMeta, RunnerAdapter } from '@wix/pathgrade/adapter-ki
3
3
  interface JestRunNative {
4
4
  results?: JestAggregatedResult;
5
5
  metadataByCaseId?: Map<string, PathgradeTestMeta[]>;
6
+ discoveredFiles?: string[];
6
7
  }
7
8
  export declare function createJestAdapter(options?: JestRunNative): RunnerAdapter;
8
9
  export declare function createPathgradeAdapter(): RunnerAdapter;
@@ -27,7 +27,10 @@ export function createJestAdapter(options = {}) {
27
27
  adapterName: this.name,
28
28
  status: input.signal?.aborted ? 'cancelled' : 'completed',
29
29
  exitCode: input.signal?.aborted ? 1 : 0,
30
- native: options,
30
+ native: {
31
+ ...options,
32
+ discoveredFiles: input.discovered.units.flatMap(unit => unit.sourceRef ? [unit.sourceRef] : []),
33
+ },
31
34
  };
32
35
  },
33
36
  async collectNormalizedRunSnapshot(run) {
@@ -36,6 +39,7 @@ export function createJestAdapter(options = {}) {
36
39
  run,
37
40
  results: native.results ?? { testResults: [] },
38
41
  metadataByCaseId: native.metadataByCaseId,
42
+ discoveredFiles: native.discoveredFiles,
39
43
  cwd: discoveryCwd,
40
44
  });
41
45
  },
@@ -37,7 +37,7 @@ export function createNodeTestAdapter() {
37
37
  adapterName: this.name,
38
38
  status: 'completed',
39
39
  exitCode: 0,
40
- native: { resultsPath, resultsDir, cwd },
40
+ native: { resultsPath, resultsDir, cwd, discoveredFiles: files },
41
41
  };
42
42
  }
43
43
  const exitCode = await spawnNodeTest({
@@ -54,7 +54,7 @@ export function createNodeTestAdapter() {
54
54
  adapterName: this.name,
55
55
  status: input.signal?.aborted ? 'cancelled' : (exitCode === 0 ? 'completed' : 'failed'),
56
56
  exitCode,
57
- native: { resultsPath, resultsDir, cwd },
57
+ native: { resultsPath, resultsDir, cwd, discoveredFiles: files },
58
58
  };
59
59
  },
60
60
  async collectNormalizedRunSnapshot(run) {
@@ -71,7 +71,7 @@ export function createNodeTestAdapter() {
71
71
  return buildNormalizedRunSnapshotFromReportGroups(run, Array.from(groupMap.entries()).map(([groupName, groupedCases]) => ({
72
72
  groupName,
73
73
  cases: groupedCases,
74
- })), { cwd: native.cwd });
74
+ })), { cwd: native.cwd, discoveredFiles: native.discoveredFiles });
75
75
  }
76
76
  finally {
77
77
  if (native.resultsDir)
@@ -104,10 +104,11 @@ function readNodeTestRunNative(run) {
104
104
  && run.native !== null
105
105
  && typeof run.native.resultsPath === 'string'
106
106
  && typeof run.native.resultsDir === 'string'
107
- && typeof run.native.cwd === 'string') {
107
+ && typeof run.native.cwd === 'string'
108
+ && Array.isArray(run.native.discoveredFiles)) {
108
109
  return run.native;
109
110
  }
110
- return { resultsPath: '', resultsDir: '', cwd: process.cwd() };
111
+ return { resultsPath: '', resultsDir: '', cwd: process.cwd(), discoveredFiles: [] };
111
112
  }
112
113
  async function readCases(resultsPath) {
113
114
  if (!resultsPath || !(await fs.pathExists(resultsPath)))
@@ -17,10 +17,13 @@
17
17
  export interface ParsedMeta {
18
18
  deps?: string[];
19
19
  extraDeps?: string[];
20
+ comparisonInputs?: string[];
20
21
  alwaysRun?: boolean;
21
22
  }
22
23
  /**
23
24
  * Parse `__pathgradeMeta` from an eval file's AST. Returns `null` if the
24
25
  * export is not present.
25
26
  */
26
- export declare function parsePathgradeMeta(evalFile: string): ParsedMeta | null;
27
+ export declare function parsePathgradeMeta(evalFile: string, options?: {
28
+ validateComparisonInputs?: boolean;
29
+ }): ParsedMeta | null;
@@ -21,14 +21,14 @@ import picomatch from 'picomatch';
21
21
  * Parse `__pathgradeMeta` from an eval file's AST. Returns `null` if the
22
22
  * export is not present.
23
23
  */
24
- export function parsePathgradeMeta(evalFile) {
24
+ export function parsePathgradeMeta(evalFile, options = {}) {
25
25
  const source = fs.readFileSync(evalFile, 'utf-8');
26
26
  const sourceFile = ts.createSourceFile(evalFile, source, ts.ScriptTarget.Latest,
27
27
  /* setParentNodes */ true, ts.ScriptKind.TS);
28
28
  for (const stmt of sourceFile.statements) {
29
29
  const initializer = findMetaInitializer(stmt);
30
30
  if (initializer) {
31
- return extractMeta(initializer, evalFile);
31
+ return extractMeta(initializer, evalFile, options);
32
32
  }
33
33
  }
34
34
  return null;
@@ -50,7 +50,7 @@ function findMetaInitializer(stmt) {
50
50
  }
51
51
  return null;
52
52
  }
53
- function extractMeta(expr, evalFile) {
53
+ function extractMeta(expr, evalFile, options) {
54
54
  // Optionally unwrap a type assertion: `{...} as PathgradeMeta` / `<PathgradeMeta>{...}`.
55
55
  let node = expr;
56
56
  while (ts.isAsExpression(node) || ts.isTypeAssertionExpression(node) || ts.isSatisfiesExpression(node)) {
@@ -64,7 +64,9 @@ function extractMeta(expr, evalFile) {
64
64
  if (!ts.isPropertyAssignment(prop))
65
65
  continue;
66
66
  const name = propertyKeyName(prop.name);
67
- if (name === 'deps' || name === 'extraDeps') {
67
+ if (name === 'deps' || name === 'extraDeps' || name === 'comparisonInputs') {
68
+ if (name === 'comparisonInputs' && options.validateComparisonInputs === false)
69
+ continue;
68
70
  const globs = extractStringArray(prop.initializer, evalFile, name);
69
71
  for (const g of globs)
70
72
  validateGlob(g, evalFile, name);
@@ -89,7 +91,9 @@ function validateGlob(glob, evalFile, field) {
89
91
  if (glob.length === 0) {
90
92
  throw new Error(`${evalFile}: __pathgradeMeta.${field} contains an empty glob.`);
91
93
  }
92
- if (glob.startsWith('../') || glob.includes('/../') || glob === '..') {
94
+ const normalized = glob.replaceAll('\\', '/');
95
+ if (normalized.startsWith('/') || /^[A-Za-z]:\//.test(normalized)
96
+ || normalized.startsWith('../') || normalized.includes('/../') || normalized === '..') {
93
97
  throw new Error(`${evalFile}: __pathgradeMeta.${field} entry "${glob}" escapes the repo root — ` +
94
98
  `globs must be repo-root-relative.`);
95
99
  }
@@ -44,7 +44,7 @@ export function selectAffected(input) {
44
44
  for (const evalFile of evalFiles) {
45
45
  const absEval = path.resolve(repoRoot, evalFile);
46
46
  const skillRoot = findSkillRoot(absEval, repoRoot);
47
- const meta = parsePathgradeMeta(absEval);
47
+ const meta = parsePathgradeMeta(absEval, { validateComparisonInputs: false });
48
48
  // Precedence #2: alwaysRun wins over dep matching.
49
49
  if (meta?.alwaysRun === true) {
50
50
  selected.push({ file: evalFile, reason: 'always-run' });
@@ -44,9 +44,8 @@ async function loadReport(resolvedPath) {
44
44
  if (!(await fs.pathExists(resolvedPath))) {
45
45
  throw new Error(`results file not found at ${resolvedPath}`);
46
46
  }
47
- const raw = await fs.readJSON(resolvedPath);
48
47
  try {
49
- return parsePathgradeReport(raw);
48
+ return parsePathgradeReport(await fs.readJSON(resolvedPath));
50
49
  }
51
50
  catch {
52
51
  throw new Error(`results file at ${resolvedPath} is not a valid pathgrade report`);
@@ -1,10 +1,11 @@
1
- import type { ComparisonContract, EvalReport } from '../types.js';
1
+ import type { EvalReport } from '../types.js';
2
2
  import type { Scorer } from '../sdk/types.js';
3
3
  import type { ReportGroupInput } from './types.js';
4
+ import type { ComparisonContractV2 } from './reliability-contract.js';
4
5
  export declare function buildComparisonContract(input: {
5
6
  group: ReportGroupInput;
6
7
  report: EvalReport;
7
8
  attemptsRequested: number;
8
9
  attemptsCompleted: number;
9
- }): ComparisonContract;
10
+ }): ComparisonContractV2;
10
11
  export declare function createScorerRevision(scorers: readonly Scorer[]): string | undefined;
@@ -1,7 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { canonicalizeJson } from '../core/canonical-json.js';
3
3
  export function buildComparisonContract(input) {
4
- const unavailable = new Set();
5
4
  const evaluations = input.group.cases.flatMap(testCase => {
6
5
  const caseIdentity = testCase.repeatKey ?? testCase.caseId ?? testCase.name;
7
6
  return (testCase.attempts?.flatMap(attempt => attempt.evaluations ?? [])
@@ -15,16 +14,16 @@ export function buildComparisonContract(input) {
15
14
  runtime: runtimeIdentity(evaluation),
16
15
  }));
17
16
  });
18
- const definitionRevision = input.group.sourceRevision
17
+ const comparisonInputs = input.group.comparisonInputs ?? { state: 'missing' };
18
+ const definitionRevision = input.group.sourceRevision && comparisonInputs.state === 'resolved'
19
19
  ? revision('definition', {
20
20
  source: input.group.sourceRevision,
21
+ comparison_inputs: comparisonInputs.revision,
21
22
  evaluations: uniqueSorted(evaluations.map(evaluation => ({
22
23
  case: evaluation.case, definition: evaluation.definition,
23
24
  }))),
24
25
  })
25
26
  : undefined;
26
- if (!definitionRevision)
27
- unavailable.add('definition_metadata_missing');
28
27
  const scorerRevision = evaluations.length > 0 && evaluations.every(evaluation => evaluation.scorer)
29
28
  ? revision('scorers', uniqueSorted(evaluations.map(evaluation => ({
30
29
  case: evaluation.case,
@@ -32,20 +31,10 @@ export function buildComparisonContract(input) {
32
31
  scorer: evaluation.scorer,
33
32
  }))))
34
33
  : undefined;
35
- if (!scorerRevision)
36
- unavailable.add('scorer_metadata_missing');
37
34
  const runtimeComplete = evaluations.length > 0
38
- && evaluations.every(evaluation => Object.values(evaluation.runtime)
39
- .every(value => value !== undefined));
40
- const runtimeRevision = runtimeComplete
41
- ? revision('runtime', uniqueSorted(evaluations.map(evaluation => ({
42
- case: evaluation.case,
43
- definition: evaluation.definition,
44
- runtime: evaluation.runtime,
45
- }))))
46
- : undefined;
47
- if (!runtimeRevision)
48
- unavailable.add('runtime_metadata_missing');
35
+ && evaluations.every(evaluation => runtimeIdentityComplete(evaluation.runtime));
36
+ const runtimeComponents = runtimeComplete ? runtimeComponentRevisions(evaluations) : undefined;
37
+ const runtimeRevision = runtimeComponents ? revision('runtime', runtimeComponents) : undefined;
49
38
  const reportableCases = input.group.cases.filter(testCase => testCase.reportable !== false
50
39
  && testCase.state !== 'skipped' && testCase.state !== 'pending');
51
40
  const samplingComplete = input.attemptsCompleted === input.attemptsRequested
@@ -59,16 +48,41 @@ export function buildComparisonContract(input) {
59
48
  cases: reportableCases.map(testCase => testCase.repeatKey ?? testCase.caseId ?? testCase.name).toSorted(),
60
49
  })
61
50
  : undefined;
62
- if (!samplingRevision)
63
- unavailable.add('sampling_incomplete');
64
51
  return {
65
- version: 1,
66
- ...(definitionRevision ? { definition_revision: definitionRevision } : {}),
67
- ...(scorerRevision ? { scorer_revision: scorerRevision } : {}),
68
- ...(runtimeRevision ? { runtime_revision: runtimeRevision } : {}),
69
- ...(samplingRevision ? { sampling_revision: samplingRevision } : {}),
70
- report_schema_revision: 'pathgrade-results-v2',
71
- ...(unavailable.size > 0 ? { unavailable_reasons: [...unavailable].sort() } : {}),
52
+ version: 2,
53
+ ...(definitionRevision
54
+ ? { comparison_inputs: comparisonInputs, definition_revision: definitionRevision }
55
+ : { comparison_inputs: comparisonInputs }),
56
+ ...(scorerRevision
57
+ ? { scorer_revision_state: { state: 'complete' }, scorer_revision: scorerRevision }
58
+ : { scorer_revision_state: { state: 'incomplete', reason: 'explicit-revision-required' } }),
59
+ ...(runtimeRevision && runtimeComponents
60
+ ? {
61
+ runtime_revision_state: { state: 'complete' },
62
+ runtime_revision: runtimeRevision,
63
+ runtime_components: runtimeComponents,
64
+ }
65
+ : { runtime_revision_state: { state: 'incomplete', reason: 'metadata-missing' } }),
66
+ ...(samplingRevision
67
+ ? { sampling_revision_state: { state: 'complete' }, sampling_revision: samplingRevision }
68
+ : { sampling_revision_state: { state: 'incomplete', reason: 'sampling-incomplete' } }),
69
+ report_schema_revision: 'pathgrade-results-v3',
70
+ };
71
+ }
72
+ function runtimeComponentRevisions(evaluations) {
73
+ const component = (name) => revision(`runtime-${name}`, uniqueSorted(evaluations.map(evaluation => ({
74
+ case: evaluation.case,
75
+ definition: evaluation.definition,
76
+ value: evaluation.runtime[name] ?? null,
77
+ }))));
78
+ return {
79
+ agent_name: component('agent_name'),
80
+ model: component('model'),
81
+ transport: component('transport'),
82
+ interaction_mode: component('interaction_mode'),
83
+ ...(evaluations.some(evaluation => evaluation.runtime.flow)
84
+ ? { flow: component('flow') }
85
+ : {}),
72
86
  };
73
87
  }
74
88
  function hasTerminalEvaluation(evaluations) {
@@ -85,16 +99,20 @@ export function createScorerRevision(scorers) {
85
99
  }
86
100
  function runtimeIdentity(evaluation) {
87
101
  const agent = evaluation.agent ?? evaluation.trial?.agent;
88
- if (agent) {
102
+ if (agent)
89
103
  return {
90
- name: agent.name,
104
+ agent_name: agent.name,
91
105
  model: agent.resolvedModel,
92
106
  transport: agent.transport,
93
- interaction: agent.interactionMode,
107
+ interaction_mode: agent.interactionMode,
108
+ flow: undefined,
94
109
  };
95
- }
96
110
  const flow = evaluation.trial?.flow_trace;
97
111
  return {
112
+ agent_name: undefined,
113
+ model: undefined,
114
+ transport: undefined,
115
+ interaction_mode: undefined,
98
116
  flow: flow?.completeness.runtimeIdentity === 'complete'
99
117
  && flow.participants.every(participant => participant.runtime) ? {
100
118
  ...(flow.protocol ? { protocol: flow.protocol } : {}),
@@ -105,10 +123,22 @@ function runtimeIdentity(evaluation) {
105
123
  } : undefined,
106
124
  };
107
125
  }
126
+ function runtimeIdentityComplete(identity) {
127
+ return identity.flow !== undefined || [
128
+ identity.agent_name, identity.model, identity.transport, identity.interaction_mode,
129
+ ].every(value => value !== undefined);
130
+ }
108
131
  function scorerDeclaration(scorer) {
109
- const common = { type: scorer.type, name: scorer.name.normalize('NFC'), weight: scorer.weight };
132
+ const common = {
133
+ type: scorer.type,
134
+ name: scorer.name.normalize('NFC'),
135
+ weight: scorer.weight,
136
+ ...(scorer.revision === undefined ? {} : { revision: scorer.revision }),
137
+ };
110
138
  if (scorer.type === 'check' || scorer.type === 'score') {
111
- return { ...common, function: scorer.fn.toString() };
139
+ if (scorer.revision === undefined)
140
+ throw new Error('explicit scorer revision required');
141
+ return common;
112
142
  }
113
143
  if (scorer.type === 'tool_usage') {
114
144
  return {
@@ -117,13 +147,16 @@ function scorerDeclaration(scorer) {
117
147
  .toSorted((left, right) => canonicalizeJson(left).localeCompare(canonicalizeJson(right))),
118
148
  };
119
149
  }
150
+ if (typeof scorer.input === 'function' && scorer.revision === undefined) {
151
+ throw new Error('explicit scorer revision required');
152
+ }
120
153
  return {
121
154
  ...common,
122
155
  rubric: scorer.rubric,
123
156
  model: scorer.model ?? null,
124
157
  retry: scorer.retry ?? null,
125
158
  includeToolEvents: scorer.includeToolEvents ?? null,
126
- input: typeof scorer.input === 'function' ? scorer.input.toString() : scorer.input ?? null,
159
+ input: typeof scorer.input === 'function' ? null : scorer.input ?? null,
127
160
  tools: scorer.tools?.toSorted() ?? null,
128
161
  maxRounds: scorer.maxRounds ?? null,
129
162
  cacheControl: scorer.cacheControl ?? null,
@@ -1,6 +1,7 @@
1
1
  import { buildDiagnosticsReport } from '../sdk/diagnostics.js';
2
2
  import { extractSkillsFromLog } from '../tool-events.js';
3
3
  import { buildComparisonContract } from './comparison-contract.js';
4
+ import { TASK_INVENTORY_VERSION } from './reliability-contract.js';
4
5
  export function buildPathgradeReport(input) {
5
6
  const warnings = [];
6
7
  const consolidatedGroups = [];
@@ -60,7 +61,7 @@ export function buildPathgradeReport(input) {
60
61
  : overallMeanReward >= input.threshold ? 'pass' : 'fail';
61
62
  return {
62
63
  report: {
63
- version: 2,
64
+ version: 3,
64
65
  timestamp: new Date().toISOString(),
65
66
  ...(input.threshold != null ? { threshold: input.threshold } : {}),
66
67
  attempts_requested: attemptsRequested,
@@ -71,6 +72,15 @@ export function buildPathgradeReport(input) {
71
72
  threshold_status: thresholdStatus,
72
73
  status: runnerStatus === 'fail' || thresholdStatus === 'fail' ? 'fail' : 'pass',
73
74
  groups: consolidatedGroups,
75
+ task_inventory: input.taskInventory ?? {
76
+ version: TASK_INVENTORY_VERSION,
77
+ files: input.groups.flatMap(group => group.sourceFile ? [{
78
+ eval_file: group.sourceFile,
79
+ completeness: 'incomplete',
80
+ reason: 'adapter-cannot-prove-completeness',
81
+ tasks: [taskInventoryEntry(group)],
82
+ }] : []),
83
+ },
74
84
  ...(input.selection ? { selection: input.selection } : {}),
75
85
  },
76
86
  traces,
@@ -78,6 +88,13 @@ export function buildPathgradeReport(input) {
78
88
  warnings,
79
89
  };
80
90
  }
91
+ function taskInventoryEntry(group) {
92
+ const scored = group.cases.some(testCase => testCase.state !== 'skipped' && testCase.state !== 'pending');
93
+ if (scored)
94
+ return { task_key: group.groupName, state: 'scored' };
95
+ const reason = group.cases.every(testCase => testCase.state === 'skipped') ? 'skipped' : 'pending';
96
+ return { task_key: group.groupName, state: 'not-scored', reason };
97
+ }
81
98
  function reportableBuiltCase(testCase) {
82
99
  if (testCase.reportable !== undefined)
83
100
  return testCase.reportable;
@@ -53,6 +53,7 @@ export interface RuntimeComponentRevisions {
53
53
  model: string;
54
54
  transport: string;
55
55
  interaction_mode: string;
56
+ flow?: string;
56
57
  }
57
58
  type DefinitionRevisionContract = {
58
59
  comparison_inputs: Extract<ComparisonInputsState, {
@@ -1,9 +1,9 @@
1
1
  export function parsePathgradeReport(value) {
2
2
  if (!isRecord(value)
3
- || (value.version !== 1 && value.version !== 2)
3
+ || (value.version !== 1 && value.version !== 2 && value.version !== 3)
4
4
  || typeof value.timestamp !== 'string'
5
5
  || typeof value.overall_pass_rate !== 'number'
6
- || (value.version === 2 && (typeof value.overall_mean_reward !== 'number' || !validAttemptCounts(value)))
6
+ || (value.version !== 1 && (typeof value.overall_mean_reward !== 'number' || !validAttemptCounts(value)))
7
7
  || !optionalNumber(value.threshold)
8
8
  || (value.status !== 'pass' && value.status !== 'fail')
9
9
  || !optionalGateStatus(value.runner_status)
@@ -11,6 +11,7 @@ export function parsePathgradeReport(value) {
11
11
  || (value.run_kind !== undefined && value.run_kind !== 'evaluation' && value.run_kind !== 'no-affected')
12
12
  || !Array.isArray(value.groups)
13
13
  || !value.groups.every(group => isReportGroup(group, value.version))
14
+ || (value.version === 3 && !isTaskInventory(value.task_inventory))
14
15
  || !isSelection(value.selection)) {
15
16
  throw new Error('PathGrade results.json is missing or has an unsupported schema');
16
17
  }
@@ -50,7 +51,9 @@ function isReportGroup(value, version) {
50
51
  && value.skills_used.every(item => typeof item === 'string')
51
52
  && Array.isArray(value.trials)
52
53
  && value.trials.every(isTrial)
53
- && (value.comparison_contract === undefined || isComparisonContract(value.comparison_contract));
54
+ && (version === 3
55
+ ? isComparisonContractV2(value.comparison_contract)
56
+ : value.comparison_contract === undefined || isComparisonContract(value.comparison_contract));
54
57
  }
55
58
  function deriveRunnerStatus(value) {
56
59
  if (value.runner_status === 'fail')
@@ -137,6 +140,9 @@ function isSelection(value) {
137
140
  && item.reason === 'no-matching-deps'));
138
141
  }
139
142
  function isComparisonContract(value) {
143
+ return isComparisonContractV1(value) || isComparisonContractV2(value);
144
+ }
145
+ function isComparisonContractV1(value) {
140
146
  return isRecord(value)
141
147
  && value.version === 1
142
148
  && optionalString(value.definition_revision)
@@ -150,6 +156,65 @@ function isComparisonContract(value) {
150
156
  || reason === 'runtime_metadata_missing'
151
157
  || reason === 'sampling_incomplete')));
152
158
  }
159
+ function isComparisonContractV2(value) {
160
+ if (!isRecord(value) || value.version !== 2 || value.report_schema_revision !== 'pathgrade-results-v3')
161
+ return false;
162
+ const comparisonInputs = value.comparison_inputs;
163
+ const definitionComplete = isRecord(comparisonInputs) && comparisonInputs.state === 'resolved';
164
+ const scorerComplete = isState(value.scorer_revision_state, 'complete');
165
+ const runtimeComplete = isState(value.runtime_revision_state, 'complete');
166
+ const samplingComplete = isState(value.sampling_revision_state, 'complete');
167
+ return isComparisonInputs(comparisonInputs)
168
+ && (definitionComplete ? typeof value.definition_revision === 'string' : value.definition_revision === undefined)
169
+ && isRevisionState(value.scorer_revision_state, ['explicit-revision-required', 'metadata-missing'])
170
+ && (scorerComplete ? typeof value.scorer_revision === 'string' : value.scorer_revision === undefined)
171
+ && isRevisionState(value.runtime_revision_state, ['metadata-missing'])
172
+ && (runtimeComplete
173
+ ? typeof value.runtime_revision === 'string' && isRuntimeComponents(value.runtime_components)
174
+ : value.runtime_revision === undefined && value.runtime_components === undefined)
175
+ && isRevisionState(value.sampling_revision_state, ['sampling-incomplete'])
176
+ && (samplingComplete ? typeof value.sampling_revision === 'string' : value.sampling_revision === undefined);
177
+ }
178
+ function isComparisonInputs(value) {
179
+ if (!isRecord(value))
180
+ return false;
181
+ if (value.state === 'missing')
182
+ return true;
183
+ if (value.state === 'invalid') {
184
+ return value.reason === 'malformed-declaration' || value.reason === 'no-matches'
185
+ || value.reason === 'outside-repository' || value.reason === 'unreadable-input';
186
+ }
187
+ return value.state === 'resolved'
188
+ && Array.isArray(value.declarations) && value.declarations.every(item => typeof item === 'string')
189
+ && Array.isArray(value.files) && value.files.every(file => isRecord(file)
190
+ && typeof file.path === 'string' && typeof file.revision === 'string')
191
+ && typeof value.revision === 'string';
192
+ }
193
+ function isRevisionState(value, reasons) {
194
+ return isState(value, 'complete') || (isRecord(value) && value.state === 'incomplete'
195
+ && typeof value.reason === 'string' && reasons.includes(value.reason));
196
+ }
197
+ function isState(value, state) {
198
+ return isRecord(value) && value.state === state;
199
+ }
200
+ function isRuntimeComponents(value) {
201
+ return isRecord(value) && ['agent_name', 'model', 'transport', 'interaction_mode']
202
+ .every(key => typeof value[key] === 'string')
203
+ && optionalString(value.flow);
204
+ }
205
+ function isTaskInventory(value) {
206
+ return isRecord(value) && value.version === 1 && Array.isArray(value.files)
207
+ && value.files.every(file => isRecord(file)
208
+ && typeof file.eval_file === 'string'
209
+ && (file.completeness === 'complete' || (file.completeness === 'incomplete'
210
+ && (file.reason === 'adapter-cannot-prove-completeness'
211
+ || file.reason === 'collection-failed' || file.reason === 'run-incomplete')))
212
+ && Array.isArray(file.tasks)
213
+ && file.tasks.every(task => isRecord(task) && typeof task.task_key === 'string'
214
+ && (task.state === 'scored' || (task.state === 'not-scored'
215
+ && (task.reason === 'skipped' || task.reason === 'pending'
216
+ || task.reason === 'timed-out' || task.reason === 'failed-before-evaluation')))));
217
+ }
153
218
  function optionalString(value) {
154
219
  return value === undefined || typeof value === 'string';
155
220
  }
@@ -1,5 +1,8 @@
1
+ import type { ComparisonInputsState } from './reliability-contract.js';
1
2
  export interface SourceMetadata {
2
3
  sourceFile?: string;
3
4
  sourceRevision?: string;
5
+ comparisonInputs?: ComparisonInputsState;
4
6
  }
5
7
  export declare function resolveSourceMetadata(filePath: string | undefined, cwd: string): SourceMetadata;
8
+ export declare function createSourceMetadataResolver(cwd: string): (filePath: string | undefined) => SourceMetadata;
@@ -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'
@@ -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
  }
package/dist/types.d.ts CHANGED
@@ -207,7 +207,7 @@ export interface EvalReport {
207
207
  skills_used: string[];
208
208
  }
209
209
  export type ComparisonUnavailableReason = 'definition_metadata_missing' | 'scorer_metadata_missing' | 'runtime_metadata_missing' | 'sampling_incomplete';
210
- export interface ComparisonContract {
210
+ export interface ComparisonContractV1 {
211
211
  version: 1;
212
212
  definition_revision?: string;
213
213
  scorer_revision?: string;
@@ -216,6 +216,7 @@ export interface ComparisonContract {
216
216
  report_schema_revision: 'pathgrade-results-v2';
217
217
  unavailable_reasons?: ComparisonUnavailableReason[];
218
218
  }
219
+ export type ComparisonContract = ComparisonContractV1 | import('./reporting/reliability-contract.js').ComparisonContractV2;
219
220
  /**
220
221
  * TrialResult with large trace fields stripped. These fields
221
222
  * live only in the per-group trace files; the consolidated results.json keeps
@@ -260,7 +261,7 @@ export interface PathgradeSelectionReport {
260
261
  * external consumers can gate on schema revisions.
261
262
  */
262
263
  export interface PathgradeReport {
263
- version: 1 | 2;
264
+ version: 1 | 2 | 3;
264
265
  timestamp: string;
265
266
  /** `ci.threshold` from Pathgrade config or legacy plugin config, if configured. */
266
267
  threshold?: number;
@@ -279,6 +280,8 @@ export interface PathgradeReport {
279
280
  /** Agent Evals compatibility manifest when changed selection finds no runnable evals. */
280
281
  run_kind?: 'evaluation' | 'no-affected';
281
282
  groups: PathgradeGroupReport[];
283
+ /** Current selected-task accounting, required for schema-v3 reports. */
284
+ task_inventory?: import('./reporting/reliability-contract.js').TaskInventory;
282
285
  /**
283
286
  * Present when `pathgrade run --changed` produced the run. Absent on
284
287
  * plain `pathgrade run`. Backward compatible — older consumers ignore
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wix/pathgrade",
3
- "version": "1.0.39",
3
+ "version": "1.0.40",
4
4
  "packageManager": "yarn@4.12.0",
5
5
  "description": "Evaluate whether AI agents discover and use your skills correctly",
6
6
  "exports": {
@@ -142,5 +142,5 @@
142
142
  "typescript": "^5.9.3",
143
143
  "zod": "4.3.6"
144
144
  },
145
- "falconPackageHash": "df08d3b446a37c3224730fca89a408f6238eef420358705733692eee"
145
+ "falconPackageHash": "4c4d43a7c8d439f02756404accbc72b44db02c52db4d0c2b2d18452f"
146
146
  }