Skip to content
Merged
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
90 changes: 69 additions & 21 deletions src/cli/commands/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { formatText } from '../formatters/text.js';
import { formatJson } from '../formatters/json.js';
import { formatScorecard } from '../formatters/scorecard.js';
import type { SamplingStrategy } from '../../types.js';
import { findConfig } from '../../helpers/config.js';

// Ensure all checks are registered
import '../../checks/index.js';
Expand All @@ -13,28 +14,48 @@ const FORMAT_OPTIONS = ['text', 'json', 'scorecard'] as const;

export function registerCheckCommand(program: Command): void {
program
.command('check <url>')
.command('check [url]')
.description('Run agent-friendly documentation checks against a URL')
.option('--config <path>', 'Path to config file (default: auto-discover agent-docs.config.yml)')
.option('-f, --format <format>', 'Output format: text, json, or scorecard', 'text')
.option('-c, --checks <checks>', 'Comma-separated list of check IDs to run')
.option('--max-concurrency <n>', 'Maximum concurrent requests', '3')
.option('--request-delay <ms>', 'Delay between requests in ms', '200')
.option('--max-links <n>', 'Maximum links to test', '50')
.option(
'--sampling <strategy>',
'URL sampling strategy: random, deterministic, or none',
'random',
)
.option('--pass-threshold <n>', 'Pass threshold in characters', '50000')
.option('--fail-threshold <n>', 'Fail threshold in characters', '100000')
.option('--max-concurrency <n>', 'Maximum concurrent requests')
.option('--request-delay <ms>', 'Delay between requests in ms')
.option('--max-links <n>', 'Maximum links to test')
.option('--sampling <strategy>', 'URL sampling strategy: random, deterministic, or none')
.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('--fixes', 'Show fix suggestions for warn/fail checks')
.option('--score', 'Include scoring data in JSON output')
.action(async (rawUrl: string, opts: Record<string, string>) => {
const url = normalizeUrl(rawUrl);
const checkIds = opts.checks ? opts.checks.split(',').map((s) => s.trim()) : undefined;
const format = opts.format as string;
.action(async (rawUrl: string | undefined, opts: Record<string, unknown>) => {
// Load config: explicit path or auto-discover
let config;
try {
config = await findConfig(opts.config as string | undefined);
} catch (err) {
process.stderr.write(`Error: ${(err as Error).message}\n`);
process.exitCode = 1;
return;
}

// Resolve URL: CLI arg > config url > error
const resolvedUrl = rawUrl ?? config?.url;
if (!resolvedUrl) {
process.stderr.write(
'Error: No URL provided. Pass a URL as an argument or set "url" in agent-docs.config.yml\n',
);
process.exitCode = 1;
return;
}
const url = normalizeUrl(resolvedUrl);

// Resolve options: CLI flags > config > hardcoded defaults
const checkIds = opts.checks
? (opts.checks as string).split(',').map((s) => s.trim())
: config?.checks;

const format = opts.format as string;
if (!FORMAT_OPTIONS.includes(format as (typeof FORMAT_OPTIONS)[number])) {
process.stderr.write(
`Error: Invalid format "${format}". Must be one of: ${FORMAT_OPTIONS.join(', ')}\n`,
Expand All @@ -43,7 +64,9 @@ export function registerCheckCommand(program: Command): void {
return;
}

const sampling = opts.sampling as SamplingStrategy;
const samplingRaw =
(opts.sampling as string | undefined) ?? config?.options?.samplingStrategy ?? 'random';
const sampling = samplingRaw as SamplingStrategy;
if (!SAMPLING_STRATEGIES.includes(sampling)) {
process.stderr.write(
`Error: Invalid sampling strategy "${sampling}". Must be one of: ${SAMPLING_STRATEGIES.join(', ')}\n`,
Expand All @@ -52,6 +75,31 @@ export function registerCheckCommand(program: Command): void {
return;
}

const maxConcurrency = parseInt(
String((opts.maxConcurrency as string | undefined) ?? config?.options?.maxConcurrency ?? 3),
10,
);
const requestDelay = parseInt(
String((opts.requestDelay as string | undefined) ?? config?.options?.requestDelay ?? 200),
10,
);
const maxLinksToTest = parseInt(
String((opts.maxLinks as string | undefined) ?? config?.options?.maxLinksToTest ?? 50),
10,
);
const passThreshold = parseInt(
String(
(opts.passThreshold as string | undefined) ?? config?.options?.thresholds?.pass ?? 50000,
),
10,
);
const failThreshold = parseInt(
String(
(opts.failThreshold as string | undefined) ?? config?.options?.thresholds?.fail ?? 100000,
),
10,
);

if (format !== 'json') {
const parsed = new URL(url);
const target =
Expand All @@ -63,13 +111,13 @@ export function registerCheckCommand(program: Command): void {

const report = await runChecks(url, {
checkIds,
maxConcurrency: parseInt(opts.maxConcurrency, 10),
requestDelay: parseInt(opts.requestDelay, 10),
maxLinksToTest: parseInt(opts.maxLinks, 10),
maxConcurrency,
requestDelay,
maxLinksToTest,
samplingStrategy: sampling,
thresholds: {
pass: parseInt(opts.passThreshold, 10),
fail: parseInt(opts.failThreshold, 10),
pass: passThreshold,
fail: failThreshold,
},
});

Expand Down
197 changes: 197 additions & 0 deletions src/cli/formatters/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,203 @@ const DETAIL_FORMATTERS: Record<string, DetailFormatter> = {
return formatDetailLine('fail', b.url, info);
});
},

'rendering-strategy': (details) => {
const pages = details.pageResults as
| Array<{
url: string;
status: string;
analysis?: { spaMarker?: string | null; visibleTextLength?: number };
error?: string;
}>
| undefined;
if (!pages) return [];
return pages
.filter((p) => p.status !== 'pass')
.map((p) => {
if (p.error) return formatDetailLine('fail', p.url, p.error);
const marker = p.analysis?.spaMarker;
const textLen = p.analysis?.visibleTextLength ?? 0;
const info = marker
? `SPA shell (${marker}, ${textLen} chars visible)`
: `sparse content (${textLen} chars visible)`;
return formatDetailLine(p.status, p.url, info);
});
},

'redirect-behavior': (details) => {
const pages = details.pageResults as
| Array<{ url: string; classification: string; redirectTarget?: string; error?: string }>
| undefined;
if (!pages) return [];
return pages
.filter(
(p) =>
p.classification === 'cross-host' ||
p.classification === 'js-redirect' ||
p.classification === 'fetch-error',
)
.map((p) => {
if (p.error) return formatDetailLine('fail', p.url, p.error);
const target = p.redirectTarget ? ` → ${p.redirectTarget}` : '';
return formatDetailLine(
p.classification === 'cross-host' ? 'warn' : 'fail',
p.url,
`${p.classification}${target}`,
);
});
},

'auth-gate-detection': (details) => {
const pages = details.pageResults as
| Array<{
url: string;
classification: string;
status?: number | null;
hint?: string;
ssoDomain?: string;
error?: string;
}>
| undefined;
if (!pages) return [];
return pages
.filter((p) => p.classification !== 'accessible')
.map((p) => {
if (p.error) return formatDetailLine('fail', p.url, p.error);
let info = p.classification;
if (p.ssoDomain) info += ` (${p.ssoDomain})`;
else if (p.hint) info += ` (${p.hint})`;
else if (p.status) info += ` (HTTP ${p.status})`;
return formatDetailLine('fail', p.url, info);
});
},

'llms-txt-directive': (details) => {
const pages = details.pageResults as
| Array<{ url: string; found: boolean; positionPercent?: number; error?: string }>
| undefined;
if (!pages) return [];
return pages
.filter((p) => !p.found || (p.positionPercent != null && p.positionPercent > 10))
.map((p) => {
if (p.error) return formatDetailLine('fail', p.url, p.error);
if (!p.found) return formatDetailLine('fail', p.url, 'no directive found');
return formatDetailLine('warn', p.url, `directive at ${p.positionPercent}% of page`);
});
},

'tabbed-content-serialization': (details) => {
const pages = details.tabbedPages as
| Array<{
url: string;
status: string;
tabGroups?: unknown[];
totalTabbedChars?: number;
error?: string;
}>
| undefined;
if (!pages) return [];
return pages
.filter((p) => p.status !== 'pass')
.map((p) => {
if (p.error) return formatDetailLine('fail', p.url, p.error);
const groups = p.tabGroups?.length ?? 0;
const size = formatSize(p.totalTabbedChars ?? 0);
return formatDetailLine(p.status, p.url, `${groups} tab groups, ${size} serialized`);
});
},

'markdown-content-parity': (details) => {
const pages = details.pageResults as
| Array<{
url: string;
status: string;
missingPercent?: number;
sampleDiffs?: string[];
error?: string;
}>
| undefined;
if (!pages) return [];
return pages
.filter((p) => p.status !== 'pass')
.map((p) => {
if (p.error) return formatDetailLine('fail', p.url, p.error);
const pct =
p.missingPercent != null ? `${Math.round(p.missingPercent)}% missing` : 'content differs';
return formatDetailLine(p.status, p.url, pct);
});
},

'http-status-codes': (details) => {
const pages = details.pageResults as
| Array<{
url: string;
testUrl?: string;
classification: string;
status?: number | null;
bodyHint?: string;
error?: string;
}>
| undefined;
if (!pages) return [];
return pages
.filter((p) => p.classification !== 'correct-error')
.map((p) => {
if (p.error) return formatDetailLine('fail', p.testUrl ?? p.url, p.error);
const info = p.bodyHint
? `HTTP ${p.status} (${p.bodyHint})`
: `HTTP ${p.status} instead of 404`;
return formatDetailLine('fail', p.testUrl ?? p.url, info);
});
},

'cache-header-hygiene': (details) => {
const endpoints = details.endpointResults as
| Array<{
url: string;
status: string;
effectiveMaxAge?: number | null;
noStore?: boolean;
error?: string;
}>
| undefined;
if (!endpoints) return [];
return endpoints
.filter((e) => e.status !== 'pass')
.map((e) => {
if (e.error) return formatDetailLine('fail', e.url, e.error);
if (e.noStore) return formatDetailLine(e.status, e.url, 'no-store');
if (e.effectiveMaxAge == null) return formatDetailLine(e.status, e.url, 'no cache headers');
const age = e.effectiveMaxAge;
const human =
age >= 86400
? `${Math.round(age / 86400)}d`
: age >= 3600
? `${Math.round(age / 3600)}h`
: `${age}s`;
return formatDetailLine(e.status, e.url, `max-age ${human}`);
});
},

'section-header-quality': (details) => {
const analyses = details.analyses as
| Array<{
url: string;
framework?: string;
genericHeaders?: number;
totalHeaders?: number;
hasGenericMajority?: boolean;
}>
| undefined;
if (!analyses) return [];
return analyses
.filter((a) => a.hasGenericMajority)
.map((a) => {
const ratio = `${a.genericHeaders}/${a.totalHeaders} generic`;
const fw = a.framework ? ` (${a.framework})` : '';
return formatDetailLine('warn', a.url, `${ratio}${fw}`);
});
},
};

function formatDetailLine(status: string, url: string, metric: string): string {
Expand Down
37 changes: 37 additions & 0 deletions src/helpers/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,40 @@ export async function loadConfig(dir?: string): Promise<AgentDocsConfig> {
`No agent-docs config file found. Create ${CONFIG_FILENAMES[0]} with at least a "url" field.`,
);
}

/**
* CLI-oriented config loader:
* - If explicitPath is given, reads that file directly and throws if not found.
* - Otherwise, auto-discovers by walking up from startDir (default: cwd).
* - Returns null if no config file is found (instead of throwing).
* - Does not require the "url" field — the CLI can supply it via argument.
*/
export async function findConfig(
explicitPath?: string,
startDir?: string,
): Promise<AgentDocsConfig | null> {
if (explicitPath) {
const filepath = resolve(process.cwd(), explicitPath);
const content = await readFile(filepath, 'utf-8');
return parseYaml(content) as AgentDocsConfig;
}

let searchDir = resolve(startDir ?? process.cwd());
while (true) {
for (const filename of CONFIG_FILENAMES) {
const filepath = resolve(searchDir, filename);
try {
const content = await readFile(filepath, 'utf-8');
return parseYaml(content) as AgentDocsConfig;
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') continue;
throw err;
}
}
const parent = dirname(searchDir);
if (parent === searchDir) break;
searchDir = parent;
}

return null;
}
2 changes: 1 addition & 1 deletion src/helpers/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export { loadConfig } from './config.js';
export { loadConfig, findConfig } from './config.js';
export { describeAgentDocs, describeAgentDocsPerCheck } from './vitest-runner.js';
export { looksLikeMarkdown, looksLikeHtml } from './detect-markdown.js';
export {
Expand Down
Loading