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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,25 @@ All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Changed

- Preserve request causes, cancellation/timeout classifications, and
aggregated concurrency errors instead of silently discarding them.
- Report unreadable files and unexpected checker failures while allowing the
rest of the scan to continue.
- Improve CLI and GitHub Action validation and error messages, including
report-write failures and auxiliary publication warnings.

### Added

- JSON, Markdown, and console reporting for scan-integrity errors, including
file paths and messages.
- For programmatic API consumers, `ScanReport.fileErrors` and
`ScanSummary.filesFailed` now expose files that could not be scanned.
- Error codes are included in per-link JSON results when available.

## [1.0.0] - 2026-07-22

### Added
Expand Down
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,10 @@ npx urldn-link-check docs/ --verbose
| `--verbose` | Log every URL as it is checked | `false` |
| `--output <file>` | Write the JSON report to this file | — |

Unreadable or otherwise failed files are reported in the scan instead of
aborting it, so links from other files can still be checked. A scan with file
errors is considered incomplete and exits with code `2`.

## GitHub Action usage

```yaml
Expand Down Expand Up @@ -167,6 +171,9 @@ See [`examples/workflows`](./examples/workflows) for a more complete setup
| `total-links` | Total number of unique links checked |
| `report-path` | Path to the written JSON report, if `output` was set |

The action also reports unreadable or failed files in its report and fails the
step when the scan is incomplete.

When run on a pull request, the action posts a single, self-updating comment
with the full Markdown report (subsequent runs edit the same comment instead
of piling up new ones), and it always writes a step summary via
Expand All @@ -189,6 +196,14 @@ the `Config` object in the [programmatic API](#programmatic-api)):
| Fail on HTTP | `--fail-on-http` | `fail-on-http` | `failOnHttp` |
| Output file | `--output` | `output` | `output` |

### CLI exit codes

- **`0`** — the scan completed cleanly and no enabled link policy failed.
- **`1`** — the scan completed, but a configured link policy failed (for
example, broken links, redirects, or insecure HTTP links).
- **`2`** — a configuration or scan-integrity error occurred, including
unreadable or otherwise failed files.

## What gets checked

For every unique URL found across your Markdown/MDX files:
Expand Down Expand Up @@ -219,7 +234,9 @@ blocks and inline code spans are intentionally skipped.
redirects, `✖` broken, plus a summary line.
- **JSON** (`--json` / `output` input) — a stable, scriptable shape with a
`summary` object and a `links` array, each entry including every file,
line, and column where that URL was referenced.
line, and column where that URL was referenced. The report includes
`fileErrors` for files that could not be read, `summary.filesFailed`, and
`links[].errorCode` when the checker provides an error code.
- **Markdown** (`--markdown`) — the same content used for GitHub Step
Summaries and PR comments, safe to pipe into any other Markdown-consuming
tool.
Expand Down
7 changes: 6 additions & 1 deletion src/action-entry.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import { runAction } from './github.js';
import * as core from '@actions/core';
import { formatError } from './errors.js';

void runAction();
void runAction().catch((error: unknown) => {
core.setFailed(formatError(error));
core.debug(formatError(error, true));
});
43 changes: 35 additions & 8 deletions src/checker.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import axios, { type AxiosError } from 'axios';
import axios from 'axios';
import { formatError, getErrorCode } from './errors.js';
import { isInsecureHttp, isValidUrl } from './utils.js';

export type LinkStatus = 'ok' | 'redirect' | 'broken' | 'timeout' | 'malformed' | 'insecure-http';
Expand All @@ -16,6 +17,7 @@ export interface CheckResult {
redirectChain: string[];
finalUrl?: string;
error?: string;
errorCode?: string;
isHttp: boolean;
durationMs: number;
}
Expand Down Expand Up @@ -85,16 +87,23 @@ export async function checkUrl(url: string, options: CheckOptions): Promise<Chec
};
} catch (error) {
const durationMs = Date.now() - started;
const axiosError = error as AxiosError;

if (axiosError.code === 'ECONNABORTED' || axiosError.message?.includes('timeout')) {
const axiosError = axios.isAxiosError(error) ? error : undefined;
const errorCode = axiosError?.code ?? getErrorCode(error);

if (
errorCode === 'ECONNABORTED' ||
errorCode === 'ETIMEDOUT' ||
errorCode === 'ERR_CANCELED' ||
(error instanceof Error && error.message.includes('timeout'))
) {
return {
url,
status: 'timeout',
redirectChain: [],
isHttp,
durationMs,
error: `Request timed out after ${options.timeout}ms`,
errorCode,
};
}

Expand All @@ -104,7 +113,8 @@ export async function checkUrl(url: string, options: CheckOptions): Promise<Chec
redirectChain: [],
isHttp,
durationMs,
error: axiosError.message ?? 'Unknown network error',
error: formatError(error),
errorCode,
};
}
}
Expand Down Expand Up @@ -138,11 +148,22 @@ export async function performRequest(url: string, timeout: number): Promise<RawR
let response;
try {
response = await client.head(currentUrl);
// Some servers (incorrectly) reject HEAD; retry with GET in that case.
if (response.status === 405 || response.status === 501) {
} catch (headError) {
if (isTimeoutOrCancellation(headError)) throw headError;
try {
response = await client.get(currentUrl);
} catch (getError) {
const wrapped = new Error(`GET request failed after HEAD request failed: ${formatError(getError)}`, {
cause: headError,
});
const code = getErrorCode(getError);
if (code) Object.assign(wrapped, { code });
throw wrapped;
}
} catch {
}

// Some servers (incorrectly) reject HEAD; retry with GET in that case.
if (response.status === 405 || response.status === 501) {
response = await client.get(currentUrl);
}

Expand All @@ -158,3 +179,9 @@ export async function performRequest(url: string, timeout: number): Promise<RawR

throw new Error(`Too many redirects (> ${MAX_REDIRECTS})`);
}

function isTimeoutOrCancellation(error: unknown): boolean {
const code = getErrorCode(error);
if (code === 'ECONNABORTED' || code === 'ETIMEDOUT' || code === 'ERR_CANCELED') return true;
return error instanceof Error && /timeout|abort|cancel/i.test(error.message);
}
58 changes: 38 additions & 20 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { resolveConfig } from './config.js';
import { runScan } from './scanner.js';
import { printConsoleReport, printVerboseLine } from './formatter.js';
import { toJsonReport, toMarkdownReport, writeJsonReport } from './report.js';
import { formatError } from './errors.js';
import { ZodError } from 'zod';
import packageJson from '../package.json' with { type: 'json' };

const program = new Command();
Expand Down Expand Up @@ -49,25 +51,26 @@ interface CliOptions {
async function main(path: string, rawOptions: Record<string, unknown>): Promise<void> {
const options = rawOptions as unknown as CliOptions;

const config = resolveConfig({
path,
json: options.json,
markdown: options.markdown,
failOnBroken: options.failOnBroken,
failOnRedirect: options.failOnRedirect,
failOnHttp: options.failOnHttp,
maxUrlLength: Number(options.maxUrlLength),
ignore: options.ignore ?? [],
timeout: Number(options.timeout),
concurrency: Number(options.concurrency),
verbose: options.verbose,
output: options.output,
});

const useSpinner = !config.json && !config.markdown && !config.verbose && process.stdout.isTTY;
const spinner = useSpinner ? ora('Scanning for links...').start() : undefined;
let spinner: ReturnType<typeof ora> | undefined;

try {
const config = resolveConfig({
path,
json: options.json,
markdown: options.markdown,
failOnBroken: options.failOnBroken,
failOnRedirect: options.failOnRedirect,
failOnHttp: options.failOnHttp,
maxUrlLength: parseNumericOption('--max-url-length', options.maxUrlLength),
ignore: options.ignore ?? [],
timeout: parseNumericOption('--timeout', options.timeout),
concurrency: parseNumericOption('--concurrency', options.concurrency),
verbose: options.verbose,
output: options.output,
});

const useSpinner = !config.json && !config.markdown && !config.verbose && process.stdout.isTTY;
spinner = useSpinner ? ora('Scanning for links...').start() : undefined;
const report = await runScan(config, {
onFileFound: (files) => {
if (spinner) spinner.text = `Found ${files.length} file(s). Checking links...`;
Expand Down Expand Up @@ -99,15 +102,30 @@ async function main(path: string, rawOptions: Record<string, unknown>): Promise<
(config.failOnRedirect && report.summary.redirects > 0) ||
(config.failOnHttp && report.summary.insecureHttp > 0);

process.exitCode = shouldFail ? 1 : 0;
process.exitCode = report.fileErrors.length > 0 ? 2 : shouldFail ? 1 : 0;
} catch (error) {
spinner?.fail('Scan failed');
console.error(chalk.red(error instanceof Error ? error.message : String(error)));
if (error instanceof ZodError) {
console.error(chalk.red('Invalid configuration:'));
for (const issue of error.issues) {
console.error(chalk.red(`- ${issue.path.join('.') || 'options'}: ${issue.message}`));
}
} else {
console.error(chalk.red(formatError(error, options.verbose)));
}
process.exitCode = 2;
}
}

function parseNumericOption(flag: string, value: string): number {
const parsed = Number(value);
if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed <= 0) {
throw new Error(`Invalid value for ${flag}: "${value}" (expected a positive integer).`);
}
return parsed;
}

program.parseAsync(process.argv).catch((error: unknown) => {
console.error(chalk.red(error instanceof Error ? error.message : String(error)));
console.error(chalk.red(formatError(error)));
process.exitCode = 2;
});
57 changes: 57 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/** Returns a useful message for an arbitrary thrown value. */
export function getErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
return String(error);
}

/** Returns the first error code found on an error in the cause chain. */
export function getErrorCode(error: unknown): string | undefined {
let current: unknown = error;
const seen = new Set<unknown>();

while (current !== undefined && current !== null && !seen.has(current)) {
seen.add(current);
if (typeof current === 'object' && 'code' in current) {
const code = (current as { code?: unknown }).code;
if (typeof code === 'string') return code;
}
current =
typeof current === 'object' && current !== null && 'cause' in current
? (current as { cause?: unknown }).cause
: undefined;
}

return undefined;
}

/**
* Formats an error message and its cause chain. Stacks are included only
* when requested, so callers can use the same formatter for user output and
* debug logs.
*/
export function formatError(error: unknown, includeStack = false): string {
const messages: string[] = [];
const stacks: string[] = [];
const seen = new Set<unknown>();

const visit = (current: unknown): void => {
if (current === undefined || current === null || seen.has(current)) return;
seen.add(current);
messages.push(getErrorMessage(current));
if (includeStack && current instanceof Error && current.stack) stacks.push(current.stack);

if (current instanceof AggregateError) {
for (const nested of current.errors) visit(nested);
}

const cause =
typeof current === 'object' && 'cause' in current ? (current as { cause?: unknown }).cause : undefined;
visit(cause);
};

visit(error);

const message = messages.join(' (caused by: ');
const chain = messages.length > 1 ? `${message}${')'.repeat(messages.length - 1)}` : message;
return includeStack && stacks.length > 0 ? `${chain}\n${stacks.join('\n')}` : chain;
}
7 changes: 7 additions & 0 deletions src/formatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,5 +102,12 @@ export function printConsoleReport(report: ScanReport): void {
`${chalk.red(`${summary.malformed} malformed`)} ${chalk.yellow(`${summary.insecureHttp} http`)} ` +
`${chalk.cyan(`${summary.tooLong} long`)}`,
);
if (report.fileErrors.length > 0) {
console.log(chalk.red(`\n✖ File errors (${report.fileErrors.length})`));
for (const fileError of report.fileErrors) {
console.log(` ${chalk.red('✖')} ${fileError.file}`);
console.log(chalk.dim(` ${fileError.message}`));
}
}
console.log('');
}
Loading
Loading