Skip to content

Commit c22137e

Browse files
authored
feat: add eval:inventory CLI command and reporting logic (#28009)
1 parent 6613e12 commit c22137e

5 files changed

Lines changed: 412 additions & 4 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"schema:settings": "tsx ./scripts/generate-settings-schema.ts",
3333
"docs:settings": "tsx ./scripts/generate-settings-doc.ts",
3434
"docs:keybindings": "tsx ./scripts/generate-keybindings-doc.ts",
35+
"eval:inventory": "tsx ./scripts/eval-inventory-cli.ts",
3536
"build": "node scripts/build.js",
3637
"build-and-start": "npm run build && npm run start --",
3738
"build:vscode": "node scripts/build_vscode_companion.js",

scripts/eval-inventory-cli.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
#!/usr/bin/env tsx
2+
3+
/**
4+
* @license
5+
* Copyright 2026 Google LLC
6+
* SPDX-License-Identifier: Apache-2.0
7+
*/
8+
9+
/**
10+
* @fileoverview CLI entry point for the eval inventory command.
11+
*
12+
* Scans all eval source files, runs the static analyzer on each,
13+
* and prints a human-readable inventory report grouped by policy,
14+
* file, and suite.
15+
*
16+
* Usage:
17+
* npm run eval:inventory
18+
* npm run eval:inventory -- --root /path/to/repo
19+
*/
20+
21+
import {
22+
collectInventory,
23+
formatInventoryReport,
24+
} from './utils/eval-inventory.js';
25+
26+
async function main() {
27+
const rootFlagIndex = process.argv.indexOf('--root');
28+
const repoRoot =
29+
rootFlagIndex !== -1 && process.argv[rootFlagIndex + 1]
30+
? process.argv[rootFlagIndex + 1]
31+
: process.cwd();
32+
33+
const result = await collectInventory(repoRoot);
34+
35+
if (result.totalFiles === 0) {
36+
console.error('No eval files found under evals/.');
37+
process.exit(1);
38+
}
39+
40+
console.log(formatInventoryReport(result));
41+
}
42+
43+
main().catch((error) => {
44+
console.error('Fatal error:', error);
45+
process.exit(1);
46+
});
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import path from 'node:path';
8+
import { describe, expect, it } from 'vitest';
9+
import {
10+
collectInventory,
11+
formatInventoryReport,
12+
type InventoryResult,
13+
} from '../utils/eval-inventory.js';
14+
import type { EvalCaseRecord } from '../utils/eval-analysis.js';
15+
16+
function makeCaseRecord(
17+
overrides: Partial<EvalCaseRecord> = {},
18+
): EvalCaseRecord {
19+
return {
20+
filePath: '/repo/evals/test.eval.ts',
21+
relativePath: 'evals/test.eval.ts',
22+
helperName: 'evalTest',
23+
baseHelperName: 'evalTest',
24+
policy: 'USUALLY_PASSES',
25+
name: 'test case',
26+
hasFiles: false,
27+
hasPrompt: true,
28+
location: { line: 1, column: 1 },
29+
...overrides,
30+
};
31+
}
32+
33+
describe('eval-inventory', () => {
34+
describe('collectInventory', () => {
35+
it('discovers eval files from the real evals directory', async () => {
36+
const repoRoot = path.resolve(import.meta.dirname, '../../');
37+
const result = await collectInventory(repoRoot);
38+
39+
expect(result.totalFiles).toBeGreaterThanOrEqual(36);
40+
expect(result.totalCases).toBeGreaterThanOrEqual(90);
41+
expect(result.files.length).toBe(result.totalFiles);
42+
expect(result.cases.length).toBe(result.totalCases);
43+
44+
for (const evalCase of result.cases) {
45+
expect(evalCase.name).toBeTruthy();
46+
expect(evalCase.relativePath).toBeTruthy();
47+
expect(evalCase.relativePath).toMatch(/^evals\//);
48+
}
49+
});
50+
51+
it('returns zero counts for a directory with no eval files', async () => {
52+
const result = await collectInventory(import.meta.dirname);
53+
54+
expect(result.totalFiles).toBe(0);
55+
expect(result.totalCases).toBe(0);
56+
expect(result.files).toEqual([]);
57+
expect(result.cases).toEqual([]);
58+
});
59+
});
60+
61+
describe('formatInventoryReport', () => {
62+
it('includes summary line with correct counts', () => {
63+
const result: InventoryResult = {
64+
totalFiles: 2,
65+
totalCases: 3,
66+
files: [],
67+
cases: [
68+
makeCaseRecord({ policy: 'ALWAYS_PASSES', name: 'case-1' }),
69+
makeCaseRecord({ policy: 'USUALLY_PASSES', name: 'case-2' }),
70+
makeCaseRecord({ policy: 'USUALLY_PASSES', name: 'case-3' }),
71+
],
72+
diagnostics: [],
73+
};
74+
75+
const report = formatInventoryReport(result);
76+
77+
expect(report).toContain('2 files · 3 cases · 0 diagnostics');
78+
});
79+
80+
it('groups cases by policy', () => {
81+
const result: InventoryResult = {
82+
totalFiles: 1,
83+
totalCases: 2,
84+
files: [],
85+
cases: [
86+
makeCaseRecord({
87+
policy: 'ALWAYS_PASSES',
88+
name: 'stable test',
89+
}),
90+
makeCaseRecord({
91+
policy: 'USUALLY_PASSES',
92+
name: 'flaky test',
93+
}),
94+
],
95+
diagnostics: [],
96+
};
97+
98+
const report = formatInventoryReport(result);
99+
100+
expect(report).toContain('By Policy');
101+
expect(report).toContain('ALWAYS_PASSES (1 cases)');
102+
expect(report).toContain('USUALLY_PASSES (1 cases)');
103+
expect(report).toContain('• stable test');
104+
expect(report).toContain('• flaky test');
105+
});
106+
107+
it('groups cases by suite name', () => {
108+
const result: InventoryResult = {
109+
totalFiles: 1,
110+
totalCases: 2,
111+
files: [],
112+
cases: [
113+
makeCaseRecord({ suiteName: 'default', name: 'suite-test' }),
114+
makeCaseRecord({ name: 'no-suite-test' }),
115+
],
116+
diagnostics: [],
117+
};
118+
119+
const report = formatInventoryReport(result);
120+
121+
expect(report).toContain('By Suite');
122+
expect(report).toContain('default (1 cases)');
123+
expect(report).toContain('(no suite) (1 cases)');
124+
});
125+
126+
it('shows diagnostics section when diagnostics exist', () => {
127+
const result: InventoryResult = {
128+
totalFiles: 1,
129+
totalCases: 0,
130+
files: [],
131+
cases: [],
132+
diagnostics: [
133+
{
134+
severity: 'warning',
135+
message: 'Could not resolve policy',
136+
filePath: '/repo/evals/bad.eval.ts',
137+
location: { line: 5, column: 3 },
138+
},
139+
],
140+
};
141+
142+
const report = formatInventoryReport(result);
143+
144+
expect(report).toContain('Diagnostics');
145+
expect(report).toContain('1 diagnostics');
146+
expect(report).toContain(
147+
'⚠ /repo/evals/bad.eval.ts:5:3 — Could not resolve policy',
148+
);
149+
});
150+
151+
it('omits diagnostics section when there are none', () => {
152+
const result: InventoryResult = {
153+
totalFiles: 1,
154+
totalCases: 1,
155+
files: [],
156+
cases: [makeCaseRecord()],
157+
diagnostics: [],
158+
};
159+
160+
const report = formatInventoryReport(result);
161+
162+
expect(report).not.toContain('Diagnostics');
163+
expect(report).not.toContain('⚠');
164+
});
165+
166+
it('includes helper name in case listing', () => {
167+
const result: InventoryResult = {
168+
totalFiles: 1,
169+
totalCases: 1,
170+
files: [],
171+
cases: [
172+
makeCaseRecord({
173+
helperName: 'customHelper',
174+
name: 'custom test',
175+
}),
176+
],
177+
diagnostics: [],
178+
};
179+
180+
const report = formatInventoryReport(result);
181+
182+
expect(report).toContain('• custom test [customHelper]');
183+
});
184+
});
185+
});

scripts/tests/test-setup.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@
66

77
import { vi } from 'vitest';
88

9-
vi.mock('fs', () => ({
10-
...vi.importActual('fs'),
11-
appendFileSync: vi.fn(),
12-
}));
9+
vi.mock('fs', async () => {
10+
const actual = await vi.importActual<typeof import('fs')>('fs');
11+
return {
12+
...actual,
13+
appendFileSync: vi.fn(),
14+
};
15+
});

0 commit comments

Comments
 (0)