Skip to content

Commit 9ef7c36

Browse files
committed
feat(test): add "test lint" offline plan/steps validator
1 parent 2ddb03d commit 9ef7c36

3 files changed

Lines changed: 248 additions & 0 deletions

File tree

src/commands/test.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
runFailureGet,
3232
runFailureSummary,
3333
runGet,
34+
runLint,
3435
runList,
3536
runPlanPut,
3637
runResult,
@@ -125,6 +126,7 @@ describe('createTestCommand — surface', () => {
125126
'diff',
126127
'failure',
127128
'get',
129+
'lint',
128130
'list',
129131
'plan',
130132
'rerun',
@@ -2960,6 +2962,81 @@ describe('runDiff', () => {
29602962
});
29612963
});
29622964

2965+
describe('runLint', () => {
2966+
const VALID_PLAN = JSON.stringify({
2967+
projectId: 'project_alice',
2968+
type: 'frontend',
2969+
name: 'Checkout works',
2970+
planSteps: [
2971+
{ type: 'action', description: 'Open the cart' },
2972+
{ type: 'assertion', description: 'Assert the total is visible' },
2973+
],
2974+
});
2975+
const INVALID_PLAN = JSON.stringify({
2976+
projectId: 'project_alice',
2977+
type: 'frontend',
2978+
name: 'Broken',
2979+
planSteps: [{ type: 'hover', description: 'Bad step type' }],
2980+
});
2981+
2982+
it('a directory with valid and invalid plans reports EVERY problem and exits 5', async () => {
2983+
const dir = mkdtempSync(join(tmpdir(), 'cli-lint-'));
2984+
writeFileSync(join(dir, 'a-valid.json'), VALID_PLAN, 'utf8');
2985+
writeFileSync(join(dir, 'b-invalid.json'), INVALID_PLAN, 'utf8');
2986+
writeFileSync(join(dir, 'c-notjson.json'), '{oops', 'utf8');
2987+
const out: string[] = [];
2988+
const rejection = await runLint(
2989+
{ profile: 'default', output: 'json', debug: false, planFromDir: dir },
2990+
{ stdout: line => out.push(line) },
2991+
).catch((error: unknown) => error);
2992+
expect(rejection).toMatchObject({ exitCode: 5 });
2993+
const report = JSON.parse(out.join('')) as {
2994+
checked: number;
2995+
valid: number;
2996+
issues: Array<{ file: string; field: string }>;
2997+
};
2998+
// All three files were checked (no first-error-fatal bailout).
2999+
expect(report.checked).toBe(3);
3000+
expect(report.valid).toBe(1);
3001+
expect(report.issues.map(issue => issue.file).sort()).toEqual([
3002+
'b-invalid.json',
3003+
'c-notjson.json',
3004+
]);
3005+
});
3006+
3007+
it('an all-valid directory resolves with exit 0 and no network/credentials needed', async () => {
3008+
const dir = mkdtempSync(join(tmpdir(), 'cli-lint-ok-'));
3009+
writeFileSync(join(dir, 'a.json'), VALID_PLAN, 'utf8');
3010+
const report = await runLint(
3011+
{ profile: 'default', output: 'json', debug: false, planFromDir: dir },
3012+
{ stdout: () => undefined },
3013+
);
3014+
expect(report).toMatchObject({ checked: 1, valid: 1, issues: [] });
3015+
});
3016+
3017+
it('a JSONL file reports each bad line with its line number', async () => {
3018+
const dir = mkdtempSync(join(tmpdir(), 'cli-lint-jsonl-'));
3019+
const file = join(dir, 'plans.jsonl');
3020+
writeFileSync(file, `${VALID_PLAN}\nnot json at all\n${INVALID_PLAN}\n`, 'utf8');
3021+
const out: string[] = [];
3022+
const rejection = await runLint(
3023+
{ profile: 'default', output: 'json', debug: false, plans: file },
3024+
{ stdout: line => out.push(line) },
3025+
).catch((error: unknown) => error);
3026+
expect(rejection).toMatchObject({ exitCode: 5 });
3027+
const report = JSON.parse(out.join('')) as { checked: number; issues: Array<{ file: string }> };
3028+
expect(report.checked).toBe(3);
3029+
expect(report.issues.some(issue => issue.file.endsWith(':2'))).toBe(true);
3030+
expect(report.issues.some(issue => issue.file.endsWith(':3'))).toBe(true);
3031+
});
3032+
3033+
it('requires exactly one input source', async () => {
3034+
await expect(
3035+
runLint({ profile: 'default', output: 'json', debug: false }, { stdout: () => undefined }),
3036+
).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 });
3037+
});
3038+
});
3039+
29633040
describe('runResult', () => {
29643041
it('JSON mode prints the §6.5 LatestResult shape verbatim', async () => {
29653042
const { credentialsPath } = makeCreds();

src/commands/test.ts

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3854,6 +3854,141 @@ function renderRunDiffText(diff: CliRunDiff): string {
38543854
return lines.join('\n');
38553855
}
38563856

3857+
export interface LintOptions extends CommonOptions {
3858+
planFrom?: string;
3859+
planFromDir?: string;
3860+
plans?: string;
3861+
steps?: string;
3862+
}
3863+
3864+
export interface CliLintIssue {
3865+
file: string;
3866+
field: string;
3867+
reason: string;
3868+
}
3869+
3870+
export interface CliLintReport {
3871+
checked: number;
3872+
valid: number;
3873+
issues: CliLintIssue[];
3874+
}
3875+
3876+
/**
3877+
* `test lint` (issue #98): validate plan/steps files fully OFFLINE with the
3878+
* SAME validators the create paths run, but collecting EVERY problem instead
3879+
* of dying on the first one, and without any network write. The create-batch
3880+
* reader is first-error-fatal and only reachable through a command that POSTs,
3881+
* so authoring a 12-plan directory meant one error per paid round-trip. Zero
3882+
* network, zero credentials: exit 0 when everything is valid, 5 otherwise, so
3883+
* it drops into a pre-commit hook or CI step before `create-batch`.
3884+
*/
3885+
export async function runLint(opts: LintOptions, deps: TestDeps = {}): Promise<CliLintReport> {
3886+
const out = makeOutput(opts.output, deps);
3887+
const sources = [opts.planFrom, opts.planFromDir, opts.plans, opts.steps].filter(
3888+
source => source !== undefined,
3889+
);
3890+
if (sources.length !== 1) {
3891+
throw localValidationError(
3892+
'plan-from',
3893+
'exactly one of --plan-from, --plan-from-dir, --plans, or --steps is required',
3894+
);
3895+
}
3896+
3897+
const issues: CliLintIssue[] = [];
3898+
let checked = 0;
3899+
// Run one existing validator, converting its typed throw into a report row
3900+
// (same envelopes, so `details.field` pointers like planSteps[2].type
3901+
// survive verbatim).
3902+
const collect = (file: string, validate: () => void): void => {
3903+
checked += 1;
3904+
try {
3905+
validate();
3906+
} catch (err) {
3907+
if (err instanceof ApiError) {
3908+
issues.push({
3909+
file,
3910+
field: String(err.getDetail('field') ?? '(file)'),
3911+
reason: String(err.getDetail('reason') ?? err.nextAction ?? err.message),
3912+
});
3913+
} else {
3914+
issues.push({
3915+
file,
3916+
field: '(file)',
3917+
reason: err instanceof Error ? err.message : String(err),
3918+
});
3919+
}
3920+
}
3921+
};
3922+
3923+
if (opts.planFrom !== undefined) {
3924+
const planFrom = opts.planFrom;
3925+
collect(planFrom, () => void readPlanFromGuarded(planFrom));
3926+
} else if (opts.steps !== undefined) {
3927+
const steps = opts.steps;
3928+
collect(steps, () => void readPlanStepsFileGuarded(steps));
3929+
} else if (opts.planFromDir !== undefined) {
3930+
const dir = resolveAbsolute(opts.planFromDir);
3931+
let entries: string[];
3932+
try {
3933+
entries = readdirSync(dir)
3934+
.filter(name => name.endsWith('.json'))
3935+
.sort();
3936+
} catch {
3937+
throw localValidationError('plan-from-dir', `cannot read directory: ${dir}`);
3938+
}
3939+
if (entries.length === 0) {
3940+
throw localValidationError('plan-from-dir', 'contains no *.json plan files');
3941+
}
3942+
for (const entry of entries) {
3943+
collect(entry, () => void readPlanFromGuarded(join(dir, entry)));
3944+
}
3945+
} else if (opts.plans !== undefined) {
3946+
// JSONL: validate PER LINE so every bad line reports (the create path's
3947+
// reader stays throw-on-first; this is the collecting counterpart).
3948+
const absolute = resolveAbsolute(opts.plans);
3949+
let content: string;
3950+
try {
3951+
content = readFileSync(absolute, 'utf8');
3952+
} catch {
3953+
throw localValidationError('plans', `cannot read file: ${absolute}`);
3954+
}
3955+
const lines = content
3956+
.split('\n')
3957+
.map(line => line.trim())
3958+
.filter(line => line.length > 0);
3959+
if (lines.length === 0) throw localValidationError('plans', 'contains no plan lines');
3960+
lines.forEach((line, index) => {
3961+
collect(`${opts.plans}:${index + 1}`, () => {
3962+
let parsed: unknown;
3963+
try {
3964+
parsed = JSON.parse(line);
3965+
} catch {
3966+
throw localValidationError(
3967+
'plans',
3968+
`line ${index + 1} is not valid JSON`,
3969+
undefined,
3970+
'field',
3971+
);
3972+
}
3973+
assertPlanShape(parsed, { specIndex: index });
3974+
});
3975+
});
3976+
}
3977+
3978+
const filesWithIssues = new Set(issues.map(issue => issue.file)).size;
3979+
const report: CliLintReport = { checked, valid: checked - filesWithIssues, issues };
3980+
out.print(report, () =>
3981+
[
3982+
...issues.map(issue => `${issue.file}: ${issue.field}: ${issue.reason}`),
3983+
`${report.valid}/${report.checked} valid, ${issues.length} problem(s)`,
3984+
].join('\n'),
3985+
);
3986+
if (issues.length > 0) {
3987+
throw new CLIError(`lint: ${issues.length} problem(s) across ${report.checked} file(s)`, 5);
3988+
}
3989+
return report;
3990+
}
3991+
38573992
export async function runSteps(
38583993
opts: StepsOptions,
38593994
deps: TestDeps = {},
@@ -7661,6 +7796,37 @@ export function createTestCommand(deps: TestDeps = {}): Command {
76617796
await runDiff({ ...resolveCommonOptions(command), runA, runB }, deps);
76627797
});
76637798

7799+
test
7800+
.command('lint')
7801+
.description(
7802+
'Validate plan/steps files offline with the same validators `create` runs, collecting EVERY problem. No network, no credentials. Exit 0 when all valid, 5 otherwise.',
7803+
)
7804+
.option('--plan-from <file>', 'single plan JSON file')
7805+
.option(
7806+
'--plan-from-dir <dir>',
7807+
'directory of *.json plan files (each checked, all errors reported)',
7808+
)
7809+
.option('--plans <file>', 'JSONL file with one plan spec per line (each line checked)')
7810+
.option('--steps <file>', 'plan-steps JSON file (the shape `test plan put` ingests)')
7811+
.addHelpText('after', GLOBAL_OPTS_HINT)
7812+
.action(
7813+
async (
7814+
cmdOpts: { planFrom?: string; planFromDir?: string; plans?: string; steps?: string },
7815+
command: Command,
7816+
) => {
7817+
await runLint(
7818+
{
7819+
...resolveCommonOptions(command),
7820+
planFrom: cmdOpts.planFrom,
7821+
planFromDir: cmdOpts.planFromDir,
7822+
plans: cmdOpts.plans,
7823+
steps: cmdOpts.steps,
7824+
},
7825+
deps,
7826+
);
7827+
},
7828+
);
7829+
76647830
test
76657831
.command('result <test-id>')
76667832
.description(

test/__snapshots__/help.snapshot.test.ts.snap

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,11 @@ Commands:
208208
failedStepIndex, per-step status flips,
209209
codeVersion drift. Exit 0 when verdicts
210210
match, 1 when they differ.
211+
lint [options] Validate plan/steps files offline with
212+
the same validators \`create\` runs,
213+
collecting EVERY problem. No network,
214+
no credentials. Exit 0 when all valid,
215+
5 otherwise.
211216
result [options] <test-id> Get the latest result for a test (default) or list prior runs (--history).
212217
213218
--output json shape differs by mode:

0 commit comments

Comments
 (0)