Skip to content

Commit b9e9601

Browse files
authored
feat(test): add "test lint" offline plan/steps validator (#176)
* feat(test): add "test lint" offline plan/steps validator * fix(lint): report physical JSONL line numbers (blank lines no longer shift them)
1 parent 4b72461 commit b9e9601

3 files changed

Lines changed: 255 additions & 0 deletions

File tree

src/commands/test.test.ts

Lines changed: 81 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,
@@ -126,6 +127,7 @@ describe('createTestCommand — surface', () => {
126127
'failure',
127128
'flaky',
128129
'get',
130+
'lint',
129131
'list',
130132
'plan',
131133
'rerun',
@@ -2961,6 +2963,85 @@ describe('runDiff', () => {
29612963
});
29622964
});
29632965

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

src/commands/test.ts

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3862,6 +3862,144 @@ function renderRunDiffText(diff: CliRunDiff): string {
38623862
return lines.join('\n');
38633863
}
38643864

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

7810+
test
7811+
.command('lint')
7812+
.description(
7813+
'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.',
7814+
)
7815+
.option('--plan-from <file>', 'single plan JSON file')
7816+
.option(
7817+
'--plan-from-dir <dir>',
7818+
'directory of *.json plan files (each checked, all errors reported)',
7819+
)
7820+
.option('--plans <file>', 'JSONL file with one plan spec per line (each line checked)')
7821+
.option('--steps <file>', 'plan-steps JSON file (the shape `test plan put` ingests)')
7822+
.addHelpText('after', GLOBAL_OPTS_HINT)
7823+
.action(
7824+
async (
7825+
cmdOpts: { planFrom?: string; planFromDir?: string; plans?: string; steps?: string },
7826+
command: Command,
7827+
) => {
7828+
await runLint(
7829+
{
7830+
...resolveCommonOptions(command),
7831+
planFrom: cmdOpts.planFrom,
7832+
planFromDir: cmdOpts.planFromDir,
7833+
plans: cmdOpts.plans,
7834+
steps: cmdOpts.steps,
7835+
},
7836+
deps,
7837+
);
7838+
},
7839+
);
7840+
76727841
test
76737842
.command('result <test-id>')
76747843
.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)