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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ Need to check links into **private repositories** in the same org? See [Cross-re
| `files` | no | `**/*.md,**/*.mdx` | Comma-separated glob patterns for files to scan. |
| `check-external` | no | `true` | Whether to check external HTTP/HTTPS links. |
| `check-same-org` | no | `true` | Whether to verify same-org GitHub links via the API. |
| `check-relative` | no | `true` | Whether to check relative file links (`../x.md`, `./x.md`, `folder/x.md`). Root-relative (`/x.md`) links are always checked. |
| `relative-suggestion-depth` | no | `0` | Skip root-relative conversion suggestions for relative links within this many directory levels (`0` = only same-folder; `1` also exempts `../x.md` and `folder/x.md`). Max `5`; disable `check-relative` for deeper. |
| `ignore-patterns` | no | _(empty)_ | Comma-separated regex patterns. Matching URLs are skipped. |
| `timeout` | no | `10000` | Timeout in milliseconds for each external link request. |
| `concurrency` | no | `5` | Number of links checked in parallel. |
Expand Down
14 changes: 14 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,20 @@ inputs:
description: Whether to check same-org GitHub links via API
required: false
default: 'true'
check-relative:
description: >
Whether to check relative file links (../x.md, ./x.md, folder/x.md).
Root-relative links (/x.md) are always checked.
required: false
default: 'true'
relative-suggestion-depth:
description: >
Relative links that traverse this many directory levels or fewer are not
given a root-relative conversion suggestion (0 = only same-folder links
are exempt; 1 also exempts ../x.md and folder/x.md). Maximum 5; to skip
relative-link checks entirely, set check-relative to false instead.
required: false
default: '0'
ignore-patterns:
description: Comma-separated list of URL patterns to ignore (regex)
required: false
Expand Down
2 changes: 1 addition & 1 deletion dist/index.js

Large diffs are not rendered by default.

38 changes: 38 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,44 @@ On pull requests, only changed files are scanned. Broken links in files that are

The following directories are always excluded from scanning: `node_modules/`, `.git/`, `dist/`, `lib/`.

## Relative link checking

HyperHawk checks relative file links (`../guide.md`, `./guide.md`, `folder/guide.md`) by resolving them against the file they appear in. Two inputs control this behaviour.

### Disabling relative links entirely

Set `check-relative: false` to skip relative links completely. They are neither validated for broken targets nor offered root-relative conversion suggestions. Root-relative links (`/docs/guide.md`) and anchors (`#section`) are still checked.

```yaml
- uses: dvdstelt/hyperhawk@v1
with:
token: ${{ secrets.GITHUB_TOKEN }}
check-relative: false
```

### Tuning conversion-suggestion depth

Working relative links get a suggestion to convert them to root-relative paths (see [How it works](/docs/how-it-works.md#root-relative-path-suggestions)). Links that stay close to the current file often "belong together" and moving them in tandem is unlikely to break the link, so a suggestion is just noise.

`relative-suggestion-depth` sets how many directory levels a link may traverse before a suggestion is offered. A link's depth counts each `..` (up) or named directory (down) segment, ignoring the filename:

| Link | Depth |
|------|-------|
| `readme.md`, `./readme.md` | 0 (same folder) |
| `../readme.md`, `folder/readme.md` | 1 |
| `../../readme.md`, `../folder/readme.md` | 2 |

Links at or below the configured depth are left as-is; deeper links still get a suggestion. The default `0` exempts only same-folder links (the original behaviour). For example, `relative-suggestion-depth: 1` also leaves `../readme.md` and `folder/readme.md` alone:

```yaml
- uses: dvdstelt/hyperhawk@v1
with:
token: ${{ secrets.GITHUB_TOKEN }}
relative-suggestion-depth: 1
```

This only suppresses the conversion suggestion; genuinely broken relative links are still reported regardless of depth. The maximum is `5`. If you want to suppress suggestions for links deeper than that, disable relative checking with `check-relative: false` instead.

## Skip code blocks

By default, links inside fenced code blocks are checked like any other link. If your code blocks contain example URLs or configuration snippets that should not be validated, enable `skip-code-blocks`:
Expand Down
4 changes: 4 additions & 0 deletions docs/how-it-works.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ Each unique URL is checked only once per run, regardless of how many files refer

Links that are not verifiable (such as `mailto:` links and URLs with invalid hostnames) are silently skipped.

Internal links split into two kinds: **relative** (`../x.md`, `./x.md`, `folder/x.md`) and **root-relative** (`/x.md`). External and same-org checks can be turned off with `check-external` and `check-same-org`; relative checks can be turned off with `check-relative` (root-relative links are always checked). See [Relative link checking](/docs/configuration.md#relative-link-checking).

### URL parsing

HyperHawk supports one level of balanced parentheses inside markdown link URLs, so links like `[topic](https://en.wikipedia.org/wiki/Topic_(DJ))` are extracted correctly.
Expand Down Expand Up @@ -64,6 +66,8 @@ When a broken internal link can be located elsewhere in the repo, HyperHawk sugg

Working links that use relative paths (`../../docs/guide.md`) get a suggestion to convert them to root-relative paths (`/docs/guide.md`). Root-relative links never break when the file containing them is moved. Same-folder links (e.g. `readme.md` or `./readme.md`) are left as-is since they are simple and unlikely to break.

How close counts as "leave it alone" is configurable via [`relative-suggestion-depth`](/docs/configuration.md#tuning-conversion-suggestion-depth): links that traverse no more than the configured number of directory levels are exempt from this suggestion. Relative-link checking can also be turned off entirely with `check-relative: false`. Neither setting affects broken-link detection: a relative link to a missing file is always reported.

### Self-repo URL suggestions

Full GitHub URLs that point back to the current repository (e.g. `https://github.com/owner/repo/blob/main/README.md`) get a suggestion to rewrite them as local paths. This avoids unnecessary network requests and keeps links working across forks. When the target file has been moved, the same fuzzy-matching logic is used to suggest the corrected local path.
Expand Down
39 changes: 36 additions & 3 deletions src/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,13 @@ async function checkInternal(link: LinkInfo, config: Config): Promise<CheckResul
const decodedPath = decodeURIComponent(urlWithoutAnchor);

const isRootRelative = decodedPath.startsWith('/');

// Relative-link checking can be disabled entirely. Root-relative links
// (and anchors, handled above) are still verified.
if (!isRootRelative && !config.checkRelative) {
return { link, ok: true };
}

const sourceDir = path.dirname(path.join(config.repoRoot, link.filePath));

const resolvedPath = isRootRelative
Expand All @@ -241,9 +248,12 @@ async function checkInternal(link: LinkInfo, config: Config): Promise<CheckResul
// However, skip the suggestion for same-folder links (e.g. "readme.md" or "./readme.md")
// because they are simple and unlikely to break.
if (!isRootRelative && !isIssueTemplate(link.filePath)) {
const normalized = urlWithoutAnchor.replace(/^\.\//, '');
const isSameFolder = !normalized.includes('/') && !normalized.includes('\\');
if (!isSameFolder) {
// Suggest a root-relative conversion only for links that traverse more
// directory levels than the configured depth. Closer links (at or below
// the threshold, e.g. same-folder by default) are left as-is because
// they are simple and unlikely to break.
const depth = relativeLinkDepth(urlWithoutAnchor);
if (depth > config.relativeSuggestionDepth) {
const anchor = hashIdx >= 0 ? url.slice(hashIdx) : '';
const rootRelUrl = toRootRelative(resolvedPath, config.repoRoot) + anchor;
const suggestion = link.lineContent.trimEnd().replace(url, rootRelUrl);
Expand Down Expand Up @@ -336,6 +346,29 @@ function isIssueTemplate(filePath: string): boolean {
return filePath.startsWith('.github/ISSUE_TEMPLATE/');
}

/**
* Count how many directory levels a relative link traverses, ignoring the
* filename itself. Each '..' (up) or named directory segment (down) counts
* as one level; '.' and empty segments are ignored.
*
* readme.md -> 0 (same folder)
* ./readme.md -> 0
* ../readme.md -> 1
* sub/readme.md -> 1
* ../../readme.md -> 2
* ../sub/readme.md -> 2
*/
function relativeLinkDepth(relativePath: string): number {
const segments = relativePath.replace(/\\/g, '/').split('/');
segments.pop(); // drop the filename
let depth = 0;
for (const segment of segments) {
if (segment === '' || segment === '.') continue;
depth++;
}
return depth;
}

/**
* Suggest rewriting a full GitHub URL that points to the current repo
* as a local path. Uses the same convention as checkInternal: same-folder
Expand Down
30 changes: 30 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ async function run(): Promise<void> {
const filesInput = core.getInput('files') || '**/*.md,**/*.mdx';
const checkExternal = core.getInput('check-external') !== 'false';
const checkSameOrg = core.getInput('check-same-org') !== 'false';
const checkRelative = core.getInput('check-relative') !== 'false';
const relativeSuggestionDepth = parseRelativeSuggestionDepth(core.getInput('relative-suggestion-depth'));
const ignorePatternsInput = core.getInput('ignore-patterns');
const timeout = parseInt(core.getInput('timeout') || '10000', 10);
const concurrency = parseInt(core.getInput('concurrency') || '5', 10);
Expand Down Expand Up @@ -101,6 +103,8 @@ async function run(): Promise<void> {
strict,
checkExternal,
checkSameOrg,
checkRelative,
relativeSuggestionDepth,
ignorePatterns,
timeout,
filePatterns,
Expand Down Expand Up @@ -180,6 +184,32 @@ async function run(): Promise<void> {
}
}

// Relative links deeper than this should be disabled outright via
// check-relative rather than exempted from suggestions one level at a time.
const MAX_RELATIVE_SUGGESTION_DEPTH = 5;

/**
* Parse and validate the relative-suggestion-depth input. Falls back to 0
* (only same-folder links exempt) for empty/invalid values, and clamps to
* MAX_RELATIVE_SUGGESTION_DEPTH with a warning when set too high.
*/
function parseRelativeSuggestionDepth(raw: string): number {
if (!raw) return 0;
const value = parseInt(raw, 10);
if (Number.isNaN(value) || value < 0) {
core.warning(`Invalid relative-suggestion-depth "${raw}"; falling back to 0.`);
return 0;
}
if (value > MAX_RELATIVE_SUGGESTION_DEPTH) {
core.warning(
`relative-suggestion-depth ${value} exceeds the maximum of ${MAX_RELATIVE_SUGGESTION_DEPTH}; ` +
`clamping to ${MAX_RELATIVE_SUGGESTION_DEPTH}. To skip relative-link checks entirely, set check-relative: false.`
);
return MAX_RELATIVE_SUGGESTION_DEPTH;
}
return value;
}

async function globFiles(patterns: string[], repoRoot: string): Promise<string[]> {
const excludePatterns = [
'!**/node_modules/**',
Expand Down
2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export interface Config {
strict: boolean;
checkExternal: boolean;
checkSameOrg: boolean;
checkRelative: boolean;
relativeSuggestionDepth: number;
ignorePatterns: RegExp[];
timeout: number;
filePatterns: string[];
Expand Down
34 changes: 34 additions & 0 deletions tests/expected-output.txt
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,37 @@ extract:71 | https://github.com/dvdstelt
.github/ISSUE_TEMPLATE/bug_report.md:13 | https://github.com/dvdstelt/hyperhawk/blob/main/README.md | suggestion
.github/ISSUE_TEMPLATE/bug_report.md:15 | ../docs/configuration.md | broken -> /docs/configuration.md
codeblock:tests/test-document.md:82 | ../READM.md
reloff:11 | ../release.ps | ok
reloff:13 | ../tsconfig.jso | ok
reloff:15 | ../action.ym | ok
reloff:19 | ../READM.md#troubleshooting | ok
reloff:23 | ../docs/setup-guide.md | ok
reloff:25 | ../../faq/common-issues.md | ok
reloff:27 | ../CONTRIBUTING.md | ok
reloff:31 | ../LICENS.md | ok
reloff:31 | ../READM.md | ok
reloff:35 | ./configuration.md | ok
reloff:37 | ./placeholder.md | ok
reloff:41 | ../AGENTS.md | ok
reloff:43 | ../action.yml | ok
reloff:47 | ./file%20with%20spaces.md | ok
reloff:7 | ../READM.md | ok
reloff:82 | ../READM.md | ok
reloff:9 | ../LICENS.md | ok
reldepth1:11 | ../release.ps | broken
reldepth1:13 | ../tsconfig.jso | broken
reldepth1:15 | ../action.ym | broken
reldepth1:19 | ../READM.md#troubleshooting | broken
reldepth1:23 | ../docs/setup-guide.md | broken
reldepth1:25 | ../../faq/common-issues.md | broken
reldepth1:27 | ../CONTRIBUTING.md | broken
reldepth1:31 | ../LICENS.md | broken
reldepth1:31 | ../READM.md | broken
reldepth1:35 | ./configuration.md | broken
reldepth1:37 | ./placeholder.md | ok
reldepth1:41 | ../AGENTS.md | ok
reldepth1:43 | ../action.yml | ok
reldepth1:47 | ./file%20with%20spaces.md | ok
reldepth1:7 | ../READM.md | broken
reldepth1:82 | ../READM.md | broken
reldepth1:9 | ../LICENS.md | broken
52 changes: 52 additions & 0 deletions tests/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,8 @@ async function runIssueTemplateTest(repoRoot: string, testFile: string): Promise
strict: false,
checkExternal: false,
checkSameOrg: true,
checkRelative: true,
relativeSuggestionDepth: 0,
ignorePatterns: [],
timeout: 5000,
filePatterns: ['.github/ISSUE_TEMPLATE/bug_report.md'],
Expand Down Expand Up @@ -194,6 +196,8 @@ async function runCodeBlockTest(repoRoot: string, testFile: string): Promise<voi
strict: false,
checkExternal: false,
checkSameOrg: false,
checkRelative: true,
relativeSuggestionDepth: 0,
ignorePatterns: [],
timeout: 5000,
filePatterns: ['tests/test-document.md'],
Expand All @@ -216,6 +220,51 @@ async function runCodeBlockTest(repoRoot: string, testFile: string): Promise<voi
process.stdout.write(lines.join('\n') + '\n');
}

/**
* Verify the relative-link controls:
* - check-relative: false skips relative links entirely (all reported ok)
* - relative-suggestion-depth raises the threshold below which valid
* relative links are not given a root-relative conversion suggestion
* (depth 1 exempts one-level links like ../AGENTS.md and ../action.yml)
*/
async function runRelativeControlsTest(repoRoot: string, testFile: string): Promise<void> {
const base: Config = {
token: '',
repoRoot,
owner: 'dvdstelt',
repo: 'hyperhawk',
strict: false,
checkExternal: false,
checkSameOrg: false,
checkRelative: true,
relativeSuggestionDepth: 0,
ignorePatterns: [],
timeout: 5000,
filePatterns: ['tests/test-document.md'],
concurrency: 1,
skipCodeBlocks: false,
reportOnlyChanged: false,
};

const internalLinks = extractLinks(testFile, base).filter(l => l.type === 'internal');

const summarize = async (prefix: string, config: Config): Promise<void> => {
const results = await checkLinks(internalLinks, config, null as any);
const lines = results
.map(r => {
const status = r.ok ? (r.suggestionOnly ? 'suggestion' : 'ok') : 'broken';
return `${prefix}:${r.link.line} | ${r.link.url} | ${status}`;
})
.sort();
process.stdout.write(lines.join('\n') + '\n');
};

// Relative checking disabled: every relative link is skipped (ok).
await summarize('reloff', { ...base, checkRelative: false });
// Depth 1: same-folder and one-level links are exempt from suggestions.
await summarize('reldepth1', { ...base, relativeSuggestionDepth: 1 });
}

async function main(): Promise<void> {
const repoRoot = path.resolve(__dirname, '..');
const testFile = path.join(repoRoot, 'tests', 'test-document.md');
Expand All @@ -227,6 +276,7 @@ async function main(): Promise<void> {
await runTests(repoRoot, testFile, baseUrl);
await runIssueTemplateTest(repoRoot, issueTemplateFile);
await runCodeBlockTest(repoRoot, testFile);
await runRelativeControlsTest(repoRoot, testFile);
} finally {
await closeServer();
}
Expand Down Expand Up @@ -261,6 +311,8 @@ async function runTests(repoRoot: string, testFile: string, baseUrl: string): Pr
strict: false,
checkExternal: true,
checkSameOrg: true,
checkRelative: true,
relativeSuggestionDepth: 0,
ignorePatterns: [],
timeout: 5000,
filePatterns: ['tests/test-document.md'],
Expand Down
Loading