Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ The URL can be a site root or a specific page. When pointing to a site root, `af
| `-v, --verbose` | | Show per-page details for checks with issues |
| `--fixes` | | Show per-check fix suggestions for warn/fail checks (only needed for `text` format; the other two formats include fix suggestions automatically) |
| `--score` | | Include scoring data in JSON output (only usable with `json` output format) |
| `-q, --quiet` | | Suppress progress output on stderr |

While a run is in progress, each check writes a numbered progress line to stderr as it starts and completes, e.g. `[7/24] llms-txt-directive-html... done (47 tested, 3 fetch errors, 12s)`. Progress goes to stderr for every format, so piped `json` or `scorecard` output on stdout stays parseable. Use `--quiet` to suppress it (e.g. in CI logs).

**Which format to use:**

Expand Down
13 changes: 12 additions & 1 deletion src/cli/commands/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import { normalizeCanonical, normalizeUrl, runChecks } from '../../runner.js';
import { formatText } from '../formatters/text.js';
import { formatJson } from '../formatters/json.js';
import { formatScorecard } from '../formatters/scorecard.js';
import { formatProgressEvent } from '../formatters/progress.js';
import type {
CheckProgressEvent,
PageConfigEntry,
RunnerOptions,
SamplingStrategy,
Expand Down Expand Up @@ -45,6 +47,7 @@ export function registerCheckCommand(program: Command): void {
.option('--pass-threshold <n>', 'Pass threshold in characters')
.option('--fail-threshold <n>', 'Fail threshold in characters')
.option('-v, --verbose', 'Show per-page details for checks with issues')
.option('-q, --quiet', 'Suppress progress output on stderr')
.option('--fixes', 'Show fix suggestions for warn/fail checks')
.option('--score', 'Include scoring data in JSON output')
.option(
Expand Down Expand Up @@ -187,7 +190,9 @@ export function registerCheckCommand(program: Command): void {
10,
);

if (format !== 'json') {
const quiet = !!opts.quiet;

if (format !== 'json' && !quiet) {
const parsed = new URL(url);
const target =
parsed.pathname && parsed.pathname !== '/'
Expand Down Expand Up @@ -314,6 +319,12 @@ export function registerCheckCommand(program: Command): void {
...(parityPassThreshold != null && { parityPassThreshold }),
...(parityWarnThreshold != null && { parityWarnThreshold }),
...(parityExclusions && { parityExclusions }),
// Progress goes to stderr so it never contaminates parseable stdout
// formats (json, piped scorecard output).
...(!quiet && {
onProgress: (event: CheckProgressEvent) =>
process.stderr.write(formatProgressEvent(event)),
}),
};

const validation = validateRunnerOptions(runnerOptions);
Expand Down
36 changes: 36 additions & 0 deletions src/cli/formatters/progress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { CheckProgressEvent } from '../../types.js';

function formatDuration(ms: number): string {
if (ms < 1000) return `${Math.round(ms)}ms`;
const seconds = Math.round(ms / 1000);
if (seconds < 60) return `${seconds}s`;
return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
}

/**
* Render a runner progress event as a stderr line fragment. A check's 'start'
* event opens the line (no newline) so a stalled check leaves its name visible;
* the 'complete' event finishes it.
*/
export function formatProgressEvent(event: CheckProgressEvent): string {
if (event.phase === 'start') {
return `[${event.index}/${event.total}] ${event.checkId}... `;
}

if (event.result.status === 'skip') {
return 'skipped\n';
}

const details = event.result.details ?? {};
const parts: string[] = [];
const tested = details.testedPages ?? details.tested;
if (typeof tested === 'number') {
parts.push(`${tested} tested`);
}
const fetchErrors = details.fetchErrors;
if (typeof fetchErrors === 'number' && fetchErrors > 0) {
parts.push(`${fetchErrors} fetch error${fetchErrors === 1 ? '' : 's'}`);
}
parts.push(formatDuration(event.durationMs));
return `done (${parts.join(', ')})\n`;
}
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ export type {
CheckOptions,
CheckFunction,
CheckDefinition,
CheckProgressEvent,
CheckProgressStartEvent,
CheckProgressCompleteEvent,
RunnerOptions,
ReportResult,
AgentDocsConfig,
Expand Down
96 changes: 55 additions & 41 deletions src/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,63 +136,77 @@ export async function runChecks(
}

const results: CheckResult[] = [];

for (const check of allChecks) {
// Filter by requested check IDs if provided
if (checkIds && checkIds.length > 0 && !checkIds.includes(check.id)) {
continue;
}
const onProgress = options?.onProgress;

// Filter by requested check IDs if provided
const selectedChecks =
checkIds && checkIds.length > 0
? allChecks.filter((check) => checkIds.includes(check.id))
: allChecks;

let index = 0;
for (const check of selectedChecks) {
index++;
onProgress?.({ phase: 'start', checkId: check.id, index, total: selectedChecks.length });
const startedAt = Date.now();

let result: CheckResult;
// Explicitly excluded skips are intentionally not stored in previousResults so
// dependent checks see "dependency never ran" and can run in standalone mode —
// matching the behaviour of checks filtered out by checkIds.
let storeInPreviousResults = true;

// Emit a skip result for explicitly excluded checks without running them.
// Intentionally not stored in previousResults so dependent checks see
// "dependency never ran" and can run in standalone mode — matching the
// behaviour of checks filtered out by checkIds.
if (skipCheckIds.includes(check.id)) {
results.push({
result = {
id: check.id,
category: check.category,
status: 'skip',
message: 'Check skipped (excluded via --skip-checks)',
});
continue;
}

// Check dependencies — only skip if at least one dependency actually ran and none passed.
// If no dependencies ran at all (e.g. filtered out via --checks), let the check handle
// standalone mode itself.
if (check.dependsOn.length > 0) {
const normalized = normalizeDeps(check.dependsOn);
const anyDepRan = normalized.some((orGroup) =>
};
storeInPreviousResults = false;
} else if (
// Check dependencies — only skip if at least one dependency actually ran and none
// passed. If no dependencies ran at all (e.g. filtered out via --checks), let the
// check handle standalone mode itself.
check.dependsOn.length > 0 &&
normalizeDeps(check.dependsOn).some((orGroup) =>
orGroup.some((id) => ctx.previousResults.has(id)),
);
if (anyDepRan && !checkDependenciesMet(check.dependsOn, ctx.previousResults)) {
const result: CheckResult = {
) &&
!checkDependenciesMet(check.dependsOn, ctx.previousResults)
) {
result = {
id: check.id,
category: check.category,
status: 'skip',
message: 'Skipped: dependency check did not pass',
dependsOn: normalizeDeps(check.dependsOn).flat(),
};
} else {
try {
result = await check.run(ctx);
} catch (err) {
result = {
id: check.id,
category: check.category,
status: 'skip',
message: 'Skipped: dependency check did not pass',
dependsOn: normalized.flat(),
status: 'error',
message: `Check error: ${err instanceof Error ? err.message : String(err)}`,
};
results.push(result);
ctx.previousResults.set(check.id, result);
continue;
}
}

try {
const result = await check.run(ctx);
results.push(result);
ctx.previousResults.set(check.id, result);
} catch (err) {
const result: CheckResult = {
id: check.id,
category: check.category,
status: 'error',
message: `Check error: ${err instanceof Error ? err.message : String(err)}`,
};
results.push(result);
results.push(result);
if (storeInPreviousResults) {
ctx.previousResults.set(check.id, result);
}
onProgress?.({
phase: 'complete',
checkId: check.id,
index,
total: selectedChecks.length,
result,
durationMs: Date.now() - startedAt,
});
}

const summary = {
Expand Down
25 changes: 25 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,13 +173,38 @@ export interface DiscoveredFile {
crossHostRedirect?: boolean;
}

export interface CheckProgressStartEvent {
phase: 'start';
checkId: string;
/** 1-based position among the checks selected for this run. */
index: number;
/** Number of checks selected for this run. */
total: number;
}

export interface CheckProgressCompleteEvent {
phase: 'complete';
checkId: string;
/** 1-based position among the checks selected for this run. */
index: number;
/** Number of checks selected for this run. */
total: number;
result: CheckResult;
/** Wall-clock time the check took, in ms (0 for skipped checks). */
durationMs: number;
}

export type CheckProgressEvent = CheckProgressStartEvent | CheckProgressCompleteEvent;

export interface RunnerOptions extends CheckOptions {
/** Only run checks matching these IDs. If empty, run all. */
checkIds?: string[];
/** Skip checks matching these IDs, emitting a 'skip' result without running them. */
skipCheckIds?: string[];
/** Curated page list from config or --urls. Used when samplingStrategy is 'curated'. */
curatedPages?: PageConfigEntry[];
/** Called as each selected check starts and completes. The CLI uses this for stderr progress. */
onProgress?: (event: CheckProgressEvent) => void;
}

export interface ReportResult {
Expand Down
109 changes: 109 additions & 0 deletions test/unit/cli/check-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1089,4 +1089,113 @@ describe('check command config integration', () => {

writeSpy.mockRestore();
});

it('writes per-check progress lines to stderr', async () => {
server.use(
http.get('http://cmd-progress.local/llms.txt', () => HttpResponse.text(VALID_LLMS_TXT)),
http.get(
'http://cmd-progress.local/docs/llms.txt',
() => new HttpResponse(null, { status: 404 }),
),
);

const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);

const { run } = await import('../../../src/cli/index.js');
await run([
'node',
'afdocs',
'check',
'http://cmd-progress.local',
'--checks',
'llms-txt-exists',
'--request-delay',
'0',
]);
await new Promise((r) => setTimeout(r, 100));

const stderr = stderrSpy.mock.calls.map((c) => c[0]).join('');
expect(stderr).toContain('[1/1] llms-txt-exists... ');
expect(stderr).toContain('done (');

stdoutSpy.mockRestore();
stderrSpy.mockRestore();
});

it('--quiet suppresses the banner and progress lines', async () => {
server.use(
http.get('http://cmd-quiet.local/llms.txt', () => HttpResponse.text(VALID_LLMS_TXT)),
http.get(
'http://cmd-quiet.local/docs/llms.txt',
() => new HttpResponse(null, { status: 404 }),
),
);

const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);

const { run } = await import('../../../src/cli/index.js');
await run([
'node',
'afdocs',
'check',
'http://cmd-quiet.local',
'--checks',
'llms-txt-exists',
'--request-delay',
'0',
'--quiet',
]);
await new Promise((r) => setTimeout(r, 100));

const stderr = stderrSpy.mock.calls.map((c) => c[0]).join('');
expect(stderr).not.toContain('Running checks on');
expect(stderr).not.toContain('[1/1]');

// The report itself still goes to stdout
const stdout = stdoutSpy.mock.calls.map((c) => c[0]).join('');
expect(stdout).toContain('llms-txt-exists');

stdoutSpy.mockRestore();
stderrSpy.mockRestore();
});

it('keeps stdout parseable in json format while progress goes to stderr', async () => {
server.use(
http.get('http://cmd-json-progress.local/llms.txt', () => HttpResponse.text(VALID_LLMS_TXT)),
http.get(
'http://cmd-json-progress.local/docs/llms.txt',
() => new HttpResponse(null, { status: 404 }),
),
);

const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);

const { run } = await import('../../../src/cli/index.js');
await run([
'node',
'afdocs',
'check',
'http://cmd-json-progress.local',
'--checks',
'llms-txt-exists',
'--format',
'json',
'--request-delay',
'0',
]);
await new Promise((r) => setTimeout(r, 100));

const stdout = stdoutSpy.mock.calls.map((c) => c[0]).join('');
const parsed = JSON.parse(stdout.trim());
expect(parsed.results[0].id).toBe('llms-txt-exists');

const stderr = stderrSpy.mock.calls.map((c) => c[0]).join('');
expect(stderr).toContain('[1/1] llms-txt-exists... ');

stdoutSpy.mockRestore();
stderrSpy.mockRestore();
});
});
Loading