harnery 0.31.5 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commander.d.ts.map +1 -1
- package/dist/commander.js +2 -0
- package/dist/commands/agents.d.ts +0 -9
- package/dist/commands/agents.d.ts.map +1 -1
- package/dist/commands/agents.js +70 -95
- package/dist/commands/browse-session.d.ts +21 -0
- package/dist/commands/browse-session.d.ts.map +1 -0
- package/dist/commands/browse-session.js +157 -0
- package/dist/commands/browse.d.ts.map +1 -1
- package/dist/commands/browse.js +205 -37
- package/dist/commands/checkpoint.js +1 -1
- package/dist/commands/deinit.d.ts.map +1 -1
- package/dist/commands/deinit.js +4 -0
- package/dist/commands/docs.d.ts.map +1 -1
- package/dist/commands/docs.js +40 -0
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +100 -17
- package/dist/commands/init.d.ts +1 -2
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +16 -5
- package/dist/commands/tunnel.d.ts.map +1 -1
- package/dist/commands/tunnel.js +2 -3
- package/dist/core/agents/canonical-emit.d.ts +1 -2
- package/dist/core/agents/canonical-emit.d.ts.map +1 -1
- package/dist/core/agents/canonical-emit.js +1 -2
- package/dist/core/agents/cli.js +123 -88
- package/dist/core/agents/coord-client.d.ts +11 -4
- package/dist/core/agents/coord-client.d.ts.map +1 -1
- package/dist/core/agents/coord-client.js +27 -9
- package/dist/core/agents/finalization.d.ts +68 -0
- package/dist/core/agents/finalization.d.ts.map +1 -0
- package/dist/core/agents/finalization.js +443 -0
- package/dist/core/agents/git-hook.d.ts +51 -0
- package/dist/core/agents/git-hook.d.ts.map +1 -0
- package/dist/core/agents/git-hook.js +118 -0
- package/dist/core/agents/render/prompt-context.d.ts +6 -5
- package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
- package/dist/core/agents/render/prompt-context.js +26 -13
- package/dist/core/agents/render/session-context.d.ts.map +1 -1
- package/dist/core/agents/render/session-context.js +15 -2
- package/dist/core/agents/rules/claim-conflict.js +3 -3
- package/dist/core/agents/rules/commit-conflict.d.ts +15 -6
- package/dist/core/agents/rules/commit-conflict.d.ts.map +1 -1
- package/dist/core/agents/rules/commit-conflict.js +21 -5
- package/dist/core/agents/rules/stop-hook.d.ts +3 -0
- package/dist/core/agents/rules/stop-hook.d.ts.map +1 -1
- package/dist/core/agents/rules/stop-hook.js +59 -23
- package/dist/core/agents/session-events.d.ts +8 -16
- package/dist/core/agents/session-events.d.ts.map +1 -1
- package/dist/core/agents/session-events.js +12 -26
- package/dist/core/agents/state/heartbeat-projector.d.ts +3 -0
- package/dist/core/agents/state/heartbeat-projector.d.ts.map +1 -1
- package/dist/core/agents/state/heartbeat-projector.js +44 -1
- package/dist/core/agents/state/heartbeat-writer.d.ts +32 -5
- package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
- package/dist/core/agents/state/heartbeat-writer.js +54 -17
- package/dist/core/agents/state/names.d.ts +22 -1
- package/dist/core/agents/state/names.d.ts.map +1 -1
- package/dist/core/agents/state/names.js +40 -2
- package/dist/core/config.d.ts +30 -2
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +74 -11
- package/dist/core/governor/planning.d.ts.map +1 -1
- package/dist/core/governor/planning.js +12 -13
- package/dist/core/hooks/adapter/detect.d.ts +2 -4
- package/dist/core/hooks/adapter/detect.d.ts.map +1 -1
- package/dist/core/hooks/adapter/detect.js +4 -14
- package/dist/core/hooks/adapter/output.d.ts +1 -1
- package/dist/core/hooks/adapter/output.d.ts.map +1 -1
- package/dist/core/hooks/adapter/output.js +5 -4
- package/dist/core/hooks/adapter/parse.d.ts +1 -1
- package/dist/core/hooks/adapter/parse.d.ts.map +1 -1
- package/dist/core/hooks/adapter/parse.js +22 -5
- package/dist/core/hooks/adapter/wiring.d.ts +23 -0
- package/dist/core/hooks/adapter/wiring.d.ts.map +1 -1
- package/dist/core/hooks/adapter/wiring.js +32 -0
- package/dist/core/hooks/cli.d.ts +1 -2
- package/dist/core/hooks/cli.d.ts.map +1 -1
- package/dist/core/hooks/cli.js +169 -27
- package/dist/core/hooks/codex-wsl-bridge.d.ts +46 -0
- package/dist/core/hooks/codex-wsl-bridge.d.ts.map +1 -0
- package/dist/core/hooks/codex-wsl-bridge.js +137 -0
- package/dist/core/hooks/effects/index.d.ts +1 -1
- package/dist/core/hooks/effects/index.js +3 -3
- package/dist/core/hooks/events/schema.d.ts +27 -0
- package/dist/core/hooks/events/schema.d.ts.map +1 -1
- package/dist/core/hooks/resolve/owner.d.ts +7 -1
- package/dist/core/hooks/resolve/owner.d.ts.map +1 -1
- package/dist/core/hooks/resolve/owner.js +15 -0
- package/dist/core/hooks/resolve/transcript.d.ts +44 -0
- package/dist/core/hooks/resolve/transcript.d.ts.map +1 -1
- package/dist/core/hooks/resolve/transcript.js +176 -1
- package/dist/core/hooks/session-name-presence.d.ts +30 -0
- package/dist/core/hooks/session-name-presence.d.ts.map +1 -0
- package/dist/core/hooks/session-name-presence.js +43 -0
- package/dist/core/hooks/unsafe-cross-shell.d.ts +13 -0
- package/dist/core/hooks/unsafe-cross-shell.d.ts.map +1 -0
- package/dist/core/hooks/unsafe-cross-shell.js +151 -0
- package/dist/core/work/state.d.ts +4 -5
- package/dist/core/work/state.d.ts.map +1 -1
- package/dist/core/work/state.js +5 -8
- package/dist/core/workflow/index.d.ts +1 -1
- package/dist/core/workflow/index.d.ts.map +1 -1
- package/dist/core/workflow/proof.d.ts +2 -2
- package/dist/core/workflow/proof.d.ts.map +1 -1
- package/dist/core/workflow/proof.js +2 -2
- package/dist/core/workflow/run-state.d.ts +2 -2
- package/dist/core/workflow/run-state.d.ts.map +1 -1
- package/dist/core/workflow/run-state.js +2 -2
- package/dist/core/workflow/types.d.ts +3 -3
- package/dist/core/workflow/types.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/execution.d.ts +2 -2
- package/dist/core/workflow/workspaces/execution.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/execution.js +5 -0
- package/dist/core/workflow/workspaces/index.d.ts +1 -1
- package/dist/core/workflow/workspaces/index.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/local-git.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/local-git.js +1 -2
- package/dist/core/workflow/workspaces/paths.d.ts +2 -2
- package/dist/core/workflow/workspaces/types.d.ts +0 -6
- package/dist/core/workflow/workspaces/types.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/validate.d.ts +0 -2
- package/dist/core/workflow/workspaces/validate.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/validate.js +0 -2
- package/dist/lib/browser/client.d.ts +118 -1
- package/dist/lib/browser/client.d.ts.map +1 -1
- package/dist/lib/browser/client.js +435 -6
- package/dist/lib/browser/geometry.d.ts.map +1 -1
- package/dist/lib/browser/geometry.js +180 -37
- package/dist/lib/browser/index.d.ts +5 -1
- package/dist/lib/browser/index.d.ts.map +1 -1
- package/dist/lib/browser/index.js +5 -1
- package/dist/lib/browser/netscape-cookies.d.ts +6 -0
- package/dist/lib/browser/netscape-cookies.d.ts.map +1 -0
- package/dist/lib/browser/netscape-cookies.js +31 -0
- package/dist/lib/browser/proxy.d.ts +24 -0
- package/dist/lib/browser/proxy.d.ts.map +1 -0
- package/dist/lib/browser/proxy.js +84 -0
- package/dist/lib/browser/runts.d.ts +6 -0
- package/dist/lib/browser/runts.d.ts.map +1 -1
- package/dist/lib/browser/runts.js +21 -4
- package/dist/lib/browser/session-control.d.ts +112 -0
- package/dist/lib/browser/session-control.d.ts.map +1 -0
- package/dist/lib/browser/session-control.js +670 -0
- package/dist/lib/docs-links.d.ts +108 -0
- package/dist/lib/docs-links.d.ts.map +1 -0
- package/dist/lib/docs-links.js +555 -0
- package/dist/lib/exec.d.ts +1 -1
- package/dist/lib/exec.js +1 -1
- package/dist/lib/identities/assume.d.ts +4 -2
- package/dist/lib/identities/assume.d.ts.map +1 -1
- package/dist/lib/identities/assume.js +15 -2
- package/dist/lib/instructions/git-hooks.d.ts +75 -0
- package/dist/lib/instructions/git-hooks.d.ts.map +1 -0
- package/dist/lib/instructions/git-hooks.js +238 -0
- package/dist/lib/instructions/splice.d.ts +10 -4
- package/dist/lib/instructions/splice.d.ts.map +1 -1
- package/dist/lib/instructions/splice.js +19 -11
- package/package.json +1 -1
- package/schemas/config.schema.json +32 -0
- package/src/commander.ts +2 -0
- package/src/commands/agents.ts +96 -115
- package/src/commands/browse-session.ts +245 -0
- package/src/commands/browse.ts +287 -44
- package/src/commands/checkpoint.ts +1 -1
- package/src/commands/deinit.ts +7 -1
- package/src/commands/docs.ts +56 -0
- package/src/commands/doctor.ts +100 -18
- package/src/commands/init.ts +17 -9
- package/src/commands/tunnel.ts +2 -5
- package/src/core/agents/canonical-emit.ts +1 -2
- package/src/core/agents/cli.ts +137 -88
- package/src/core/agents/coord-client.ts +38 -9
- package/src/core/agents/finalization.ts +595 -0
- package/src/core/agents/git-hook.ts +126 -0
- package/src/core/agents/render/prompt-context.ts +28 -13
- package/src/core/agents/render/session-context.ts +15 -2
- package/src/core/agents/rules/claim-conflict.ts +3 -3
- package/src/core/agents/rules/commit-conflict.ts +35 -8
- package/src/core/agents/rules/stop-hook.ts +71 -23
- package/src/core/agents/session-events.ts +14 -41
- package/src/core/agents/state/heartbeat-projector.ts +45 -1
- package/src/core/agents/state/heartbeat-writer.ts +78 -16
- package/src/core/agents/state/names.ts +54 -2
- package/src/core/config.ts +92 -12
- package/src/core/governor/planning.ts +17 -12
- package/src/core/hooks/adapter/detect.ts +4 -13
- package/src/core/hooks/adapter/output.ts +9 -4
- package/src/core/hooks/adapter/parse.ts +24 -5
- package/src/core/hooks/adapter/wiring.ts +43 -0
- package/src/core/hooks/cli.ts +201 -23
- package/src/core/hooks/codex-wsl-bridge.ts +188 -0
- package/src/core/hooks/effects/index.ts +5 -5
- package/src/core/hooks/events/schema.ts +27 -0
- package/src/core/hooks/resolve/owner.ts +22 -1
- package/src/core/hooks/resolve/transcript.ts +164 -1
- package/src/core/hooks/session-name-presence.ts +52 -0
- package/src/core/hooks/unsafe-cross-shell.ts +160 -0
- package/src/core/work/state.ts +8 -12
- package/src/core/workflow/engine.ts +2 -2
- package/src/core/workflow/index.ts +0 -1
- package/src/core/workflow/proof.ts +4 -4
- package/src/core/workflow/run-state.ts +5 -5
- package/src/core/workflow/types.ts +3 -3
- package/src/core/workflow/workspaces/execution.ts +11 -4
- package/src/core/workflow/workspaces/index.ts +0 -3
- package/src/core/workflow/workspaces/local-git.ts +1 -2
- package/src/core/workflow/workspaces/paths.ts +2 -2
- package/src/core/workflow/workspaces/types.ts +0 -9
- package/src/core/workflow/workspaces/validate.ts +1 -5
- package/src/lib/browser/client.ts +528 -6
- package/src/lib/browser/geometry.ts +197 -37
- package/src/lib/browser/index.ts +39 -0
- package/src/lib/browser/netscape-cookies.ts +39 -0
- package/src/lib/browser/proxy.ts +105 -0
- package/src/lib/browser/runts.ts +27 -3
- package/src/lib/browser/session-control.ts +892 -0
- package/src/lib/docs-links.ts +674 -0
- package/src/lib/exec.ts +1 -1
- package/src/lib/identities/assume.ts +22 -1
- package/src/lib/instructions/git-hooks.ts +259 -0
- package/src/lib/instructions/splice.ts +41 -11
|
@@ -0,0 +1,674 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { type DocKind, readDocStatusFromText } from "./docs-frontmatter.ts";
|
|
4
|
+
import { sh } from "./exec.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Internal Markdown link checker.
|
|
8
|
+
*
|
|
9
|
+
* Answers one question per link: does this relative target, and the heading
|
|
10
|
+
* fragment it points at, actually exist right now? File-existence checks alone
|
|
11
|
+
* miss fragments, which silently land a reader at the top of the page, and they
|
|
12
|
+
* miss case-only mismatches, which work on macOS and break on Linux.
|
|
13
|
+
*
|
|
14
|
+
* The design constraint is noise. A checker that reports hundreds of items
|
|
15
|
+
* nobody intends to fix gets ignored, so several categories are excluded by
|
|
16
|
+
* construction rather than left for a human to filter:
|
|
17
|
+
*
|
|
18
|
+
* - External links, mail/tel schemes, protocol-relative URLs, and bare anchors
|
|
19
|
+
* into non-Markdown targets are not resolved at all.
|
|
20
|
+
* - Fenced code blocks and inline code spans are stripped before parsing, so a
|
|
21
|
+
* documented example link is never mistaken for a real one.
|
|
22
|
+
* - Targets carrying template syntax (`{{x}}`, `${x}`, `<placeholder>`) are
|
|
23
|
+
* skipped as unresolvable by design.
|
|
24
|
+
* - Root-absolute targets (`/foo`) are counted but not resolved: in practice
|
|
25
|
+
* they are site routes far more often than repo paths.
|
|
26
|
+
* - Findings in immutable-history documents are downgraded to warnings, because
|
|
27
|
+
* an audit or changelog that names a path as it existed then is correct.
|
|
28
|
+
*
|
|
29
|
+
* The remaining escape hatch is explicit: `<!-- links-allow: reason -->` on the
|
|
30
|
+
* link's line, or `<!-- links-allow-file: reason -->` anywhere in the file.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
// Module-level context, initialized by initDocsContext() before any other
|
|
34
|
+
// function here runs. Mirrors the docs-lint.ts convention.
|
|
35
|
+
let REPO_ROOT = "";
|
|
36
|
+
let SUBMODULES: readonly string[] = [];
|
|
37
|
+
let EXTRA_EXCLUDED_PREFIXES: readonly string[] = [];
|
|
38
|
+
|
|
39
|
+
export function initDocsContext(opts: {
|
|
40
|
+
repoRoot: string;
|
|
41
|
+
submodules: readonly string[];
|
|
42
|
+
extraExcludedPrefixes?: readonly string[];
|
|
43
|
+
}): void {
|
|
44
|
+
REPO_ROOT = opts.repoRoot;
|
|
45
|
+
SUBMODULES = opts.submodules;
|
|
46
|
+
EXTRA_EXCLUDED_PREFIXES = opts.extraExcludedPrefixes ?? [];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type LinkSeverity = "error" | "warning";
|
|
50
|
+
|
|
51
|
+
export type LinkRule = "missing-target" | "missing-fragment" | "case-mismatch" | "escapes-repo";
|
|
52
|
+
|
|
53
|
+
export interface LinkFinding {
|
|
54
|
+
severity: LinkSeverity;
|
|
55
|
+
repo: string;
|
|
56
|
+
/** Source file, relative to its repo root. */
|
|
57
|
+
path: string;
|
|
58
|
+
line: number;
|
|
59
|
+
rule: LinkRule;
|
|
60
|
+
/** The raw link target as written, fragment included. */
|
|
61
|
+
target: string;
|
|
62
|
+
message: string;
|
|
63
|
+
/** Populated for case-mismatch: the path that does exist on disk. */
|
|
64
|
+
suggestion?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface LinkOpts {
|
|
68
|
+
/** Limit to one submodule, or "." for the parent repo. */
|
|
69
|
+
repo?: string;
|
|
70
|
+
/** Skip heading-fragment validation; check target existence only. */
|
|
71
|
+
noFragments?: boolean;
|
|
72
|
+
/** Report findings in immutable-history docs at error severity too. */
|
|
73
|
+
strict?: boolean;
|
|
74
|
+
/** Also flag links that resolve outside their own repo root. */
|
|
75
|
+
checkEscapes?: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface LinkReport {
|
|
79
|
+
repo: string | null;
|
|
80
|
+
files_scanned: number;
|
|
81
|
+
links_checked: number;
|
|
82
|
+
links_skipped: number;
|
|
83
|
+
error_count: number;
|
|
84
|
+
warning_count: number;
|
|
85
|
+
findings: LinkFinding[];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Framework/generated dirs excluded at any depth. Mirrors docs-lint.ts. */
|
|
89
|
+
const EXCLUDED_PREFIXES = [".agents/", ".claude/", ".harnery/", ".codex/", ".cursor/"];
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Path segments whose documents record a past state. A link there naming a file
|
|
93
|
+
* that has since moved is accurate history, not a defect, so findings are
|
|
94
|
+
* downgraded to warnings unless --strict.
|
|
95
|
+
*/
|
|
96
|
+
const HISTORY_SEGMENTS = ["archive/", "audits/", "changelogs/", "handoffs/", "decisions/"];
|
|
97
|
+
|
|
98
|
+
/** Schemes and forms that are never resolved against the filesystem. */
|
|
99
|
+
const EXTERNAL_SCHEME = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;
|
|
100
|
+
|
|
101
|
+
/** Template/placeholder syntax that makes a target unresolvable by design. */
|
|
102
|
+
const PLACEHOLDER = /[{}<>$*]|\.\.\.|%s|%d/;
|
|
103
|
+
|
|
104
|
+
/** Fragments like #L42 or #L10-L20 are line refs into source, not headings. */
|
|
105
|
+
const LINE_REF_FRAGMENT = /^L\d+(?:[-,]L?\d+)?$/;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* A fragment beginning with a slash is a single-page-app hash route
|
|
109
|
+
* (`#/marketing/contact`), not a heading anchor. These show up in Markdown
|
|
110
|
+
* captured from a rendered site and can never resolve to a heading.
|
|
111
|
+
*/
|
|
112
|
+
const HASH_ROUTE_FRAGMENT = /^\//;
|
|
113
|
+
|
|
114
|
+
/** Fragments that are structurally incapable of naming a heading. */
|
|
115
|
+
function isNonHeadingFragment(fragment: string): boolean {
|
|
116
|
+
return LINE_REF_FRAGMENT.test(fragment) || HASH_ROUTE_FRAGMENT.test(fragment);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const ALLOW_LINE = /<!--\s*links-allow\s*:/;
|
|
120
|
+
const ALLOW_FILE = /<!--\s*links-allow-file\s*:/;
|
|
121
|
+
|
|
122
|
+
function submodulePath(name: string): string {
|
|
123
|
+
return resolve(REPO_ROOT, name);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function isSubmoduleInitialized(name: string): boolean {
|
|
127
|
+
return existsSync(resolve(REPO_ROOT, name, ".git"));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function getTargetRepos(opts: LinkOpts): { name: string; path: string }[] {
|
|
131
|
+
const all: { name: string; path: string }[] = [{ name: "(root)", path: REPO_ROOT }];
|
|
132
|
+
for (const name of SUBMODULES) {
|
|
133
|
+
if (!isSubmoduleInitialized(name)) continue;
|
|
134
|
+
all.push({ name, path: submodulePath(name) });
|
|
135
|
+
}
|
|
136
|
+
if (opts.repo) {
|
|
137
|
+
const filter = opts.repo === "." ? "(root)" : opts.repo;
|
|
138
|
+
return all.filter((r) => r.name === filter);
|
|
139
|
+
}
|
|
140
|
+
return all;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function isExcluded(rel: string): boolean {
|
|
144
|
+
const all = [...EXCLUDED_PREFIXES, ...EXTRA_EXCLUDED_PREFIXES];
|
|
145
|
+
return all.some((p) => rel.startsWith(p) || rel.includes(`/${p}`));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function findMarkdownFiles(root: string): Promise<string[]> {
|
|
149
|
+
const result = await sh('git ls-files --cached "**/*.md" "*.md"', { cwd: root });
|
|
150
|
+
if (result.exitCode !== 0 || !result.stdout) return [];
|
|
151
|
+
return result.stdout
|
|
152
|
+
.split("\n")
|
|
153
|
+
.filter((f) => f.endsWith(".md"))
|
|
154
|
+
.filter((f) => !isExcluded(f));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function isHistoryDoc(rel: string): boolean {
|
|
158
|
+
return HISTORY_SEGMENTS.some((seg) => rel.startsWith(seg) || rel.includes(`/${seg}`));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Lifecycle docs in a terminal state are records too. An issue with
|
|
163
|
+
* `status: resolved` describes paths as they existed when the incident was
|
|
164
|
+
* worked; a link there naming a since-moved file is accurate history, exactly
|
|
165
|
+
* like an archived plan. Open lifecycle docs stay at error severity: their
|
|
166
|
+
* guidance is live and a broken link in one misdirects real work.
|
|
167
|
+
*/
|
|
168
|
+
const TERMINAL_STATUSES = new Set(["resolved", "wontfix", "shipped", "abandoned"]);
|
|
169
|
+
|
|
170
|
+
function isSettledLifecycleDoc(rel: string, content: string): boolean {
|
|
171
|
+
let kind: DocKind | undefined;
|
|
172
|
+
if (rel.includes("issues/")) kind = "issue";
|
|
173
|
+
else if (rel.includes("plans/")) kind = "plan";
|
|
174
|
+
else if (rel.includes("handoffs/")) kind = "handoff";
|
|
175
|
+
if (!kind) return false;
|
|
176
|
+
const status = readDocStatusFromText(content, kind);
|
|
177
|
+
return status !== null && TERMINAL_STATUSES.has(status);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// --- Markdown parsing ---------------------------------------------------
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Blank out fenced code blocks, preserving line count so reported line numbers
|
|
184
|
+
* stay accurate. Replacing rather than deleting keeps the line index trivially
|
|
185
|
+
* correct.
|
|
186
|
+
*/
|
|
187
|
+
export function maskFences(content: string): string {
|
|
188
|
+
const lines = content.split("\n");
|
|
189
|
+
let fence: string | null = null;
|
|
190
|
+
const out: string[] = [];
|
|
191
|
+
|
|
192
|
+
for (const line of lines) {
|
|
193
|
+
const fenceMatch = line.match(/^\s*(`{3,}|~{3,})/);
|
|
194
|
+
if (fence) {
|
|
195
|
+
// Inside a fence: blank everything, and close on a matching marker.
|
|
196
|
+
if (
|
|
197
|
+
fenceMatch &&
|
|
198
|
+
fenceMatch[1]!.startsWith(fence[0]!) &&
|
|
199
|
+
fenceMatch[1]!.length >= fence.length
|
|
200
|
+
) {
|
|
201
|
+
fence = null;
|
|
202
|
+
}
|
|
203
|
+
out.push("");
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (fenceMatch) {
|
|
207
|
+
fence = fenceMatch[1]!;
|
|
208
|
+
out.push("");
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
out.push(line);
|
|
212
|
+
}
|
|
213
|
+
return out.join("\n");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Blank fenced blocks and inline code spans, preserving line count and column
|
|
218
|
+
* positions. This is the input for link extraction only.
|
|
219
|
+
*
|
|
220
|
+
* Anchor collection deliberately uses {@link maskFences} instead: a heading is
|
|
221
|
+
* very often entirely inline code (`### \`some command\``), and masking the span
|
|
222
|
+
* would erase the heading text and with it the anchor the document really has.
|
|
223
|
+
*/
|
|
224
|
+
export function maskCode(content: string): string {
|
|
225
|
+
return (
|
|
226
|
+
maskFences(content)
|
|
227
|
+
.split("\n")
|
|
228
|
+
// Longest-run-first so ``a `b` c`` is handled as one span.
|
|
229
|
+
.map((line) => line.replace(/(`+)(?:(?!\1).)*\1/g, (m) => " ".repeat(m.length)))
|
|
230
|
+
.join("\n")
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export interface ExtractedLink {
|
|
235
|
+
target: string;
|
|
236
|
+
line: number;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Read one inline destination starting just after the `(` of a `](`. Returns
|
|
241
|
+
* the destination and the index of the closing paren, or null if the
|
|
242
|
+
* destination is malformed or runs off the end of the line.
|
|
243
|
+
*
|
|
244
|
+
* Parens are balanced rather than stopped at the first `)`, so a target like
|
|
245
|
+
* `foo_(bar).md` survives; an optional "title" or 'title' is discarded.
|
|
246
|
+
*/
|
|
247
|
+
function readDestination(
|
|
248
|
+
line: string,
|
|
249
|
+
start: number,
|
|
250
|
+
): { dest: string; end: number; bracketed: boolean } | null {
|
|
251
|
+
let i = start;
|
|
252
|
+
while (i < line.length && /\s/.test(line[i]!)) i++;
|
|
253
|
+
|
|
254
|
+
let dest = "";
|
|
255
|
+
let bracketed = false;
|
|
256
|
+
if (line[i] === "<") {
|
|
257
|
+
const close = line.indexOf(">", i + 1);
|
|
258
|
+
if (close === -1) return null;
|
|
259
|
+
dest = line.slice(i + 1, close);
|
|
260
|
+
bracketed = true;
|
|
261
|
+
i = close + 1;
|
|
262
|
+
} else {
|
|
263
|
+
let depth = 0;
|
|
264
|
+
while (i < line.length) {
|
|
265
|
+
const ch = line[i]!;
|
|
266
|
+
if (ch === "\\" && i + 1 < line.length) {
|
|
267
|
+
dest += line[i + 1];
|
|
268
|
+
i += 2;
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
if (/\s/.test(ch)) break;
|
|
272
|
+
if (ch === "(") depth++;
|
|
273
|
+
if (ch === ")") {
|
|
274
|
+
if (depth === 0) break;
|
|
275
|
+
depth--;
|
|
276
|
+
}
|
|
277
|
+
dest += ch;
|
|
278
|
+
i++;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Skip an optional title, then require the closing paren.
|
|
283
|
+
while (i < line.length && /\s/.test(line[i]!)) i++;
|
|
284
|
+
const q = line[i];
|
|
285
|
+
if (q === '"' || q === "'") {
|
|
286
|
+
const close = line.indexOf(q, i + 1);
|
|
287
|
+
if (close === -1) return null;
|
|
288
|
+
i = close + 1;
|
|
289
|
+
while (i < line.length && /\s/.test(line[i]!)) i++;
|
|
290
|
+
}
|
|
291
|
+
if (line[i] !== ")") return null;
|
|
292
|
+
return { dest, end: i, bracketed };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Angle brackets serve two unrelated purposes in a destination: escaping a
|
|
297
|
+
* filename that contains spaces, and standing in for a value the reader
|
|
298
|
+
* supplies (`[docs](<your-repo>)`). Unwrapping erases the difference, so a
|
|
299
|
+
* bracketed destination only counts as a path when it is shaped like one.
|
|
300
|
+
*/
|
|
301
|
+
function looksLikePath(dest: string): boolean {
|
|
302
|
+
return dest.includes("/") || /\.[a-z0-9]+$/i.test(dest);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Pull every link target out of masked Markdown: inline links, images, and
|
|
307
|
+
* reference definitions.
|
|
308
|
+
*
|
|
309
|
+
* The scan keys off each `](` rather than trying to match the link text, which
|
|
310
|
+
* is what makes nested constructs work: in `[](page.md)` both
|
|
311
|
+
* destinations are found, where a text-matching regex sees only one. Angle-
|
|
312
|
+
* bracket-wrapped destinations are unwrapped here so a legitimately spaced
|
|
313
|
+
* filename is not later mistaken for placeholder syntax.
|
|
314
|
+
*/
|
|
315
|
+
export function extractLinks(masked: string): ExtractedLink[] {
|
|
316
|
+
const links: ExtractedLink[] = [];
|
|
317
|
+
// [id]: dest "title" — but never [^id]:, which is a footnote definition
|
|
318
|
+
// whose body is prose, not a destination; parsing it would turn the first
|
|
319
|
+
// word of every footnote into a phantom link target.
|
|
320
|
+
const refDef = /^\s{0,3}\[[^\]^][^\]]*\]:\s*(\S+)/;
|
|
321
|
+
|
|
322
|
+
masked.split("\n").forEach((line, i) => {
|
|
323
|
+
const lineNo = i + 1;
|
|
324
|
+
|
|
325
|
+
for (let at = line.indexOf("]("); at !== -1; at = line.indexOf("](", at + 1)) {
|
|
326
|
+
const parsed = readDestination(line, at + 2);
|
|
327
|
+
if (!parsed) continue;
|
|
328
|
+
if (parsed.bracketed && !looksLikePath(parsed.dest)) continue;
|
|
329
|
+
if (parsed.dest) links.push({ target: parsed.dest, line: lineNo });
|
|
330
|
+
// Continue from just before the closing paren; overlapping starts are
|
|
331
|
+
// fine because indexOf resumes at at+1 regardless.
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const ref = line.match(refDef);
|
|
335
|
+
if (ref?.[1]) {
|
|
336
|
+
let dest = ref[1];
|
|
337
|
+
if (dest.startsWith("<") && dest.endsWith(">")) {
|
|
338
|
+
dest = dest.slice(1, -1);
|
|
339
|
+
if (!looksLikePath(dest)) return;
|
|
340
|
+
}
|
|
341
|
+
links.push({ target: dest, line: lineNo });
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
return links;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* GitHub's heading-anchor slug: lowercase, drop punctuation other than hyphen
|
|
350
|
+
* and underscore, then map each remaining space to one hyphen. Duplicate slugs
|
|
351
|
+
* in one document get -1, -2, ... suffixes in document order.
|
|
352
|
+
*
|
|
353
|
+
* Runs of whitespace are deliberately NOT collapsed. Dropping a punctuation
|
|
354
|
+
* mark leaves the spaces that surrounded it, so "Intent — capture" becomes
|
|
355
|
+
* "intent--capture" with a double hyphen, and that is the anchor GitHub renders
|
|
356
|
+
* and the one real links in the wild are written against.
|
|
357
|
+
*/
|
|
358
|
+
export function slugify(heading: string): string {
|
|
359
|
+
return heading
|
|
360
|
+
.trim()
|
|
361
|
+
.toLowerCase()
|
|
362
|
+
.replace(/[^\p{L}\p{N}\s_-]/gu, "")
|
|
363
|
+
.replace(/\s/g, "-");
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Every fragment a reader can legitimately target in one document: Markdown
|
|
368
|
+
* heading slugs (ATX and Setext), explicit `{#custom-id}` suffixes, and HTML
|
|
369
|
+
* `id=` / `name=` attributes, which docs use for stable anchors that survive a
|
|
370
|
+
* heading rename.
|
|
371
|
+
*/
|
|
372
|
+
export function collectAnchors(content: string): Set<string> {
|
|
373
|
+
const anchors = new Set<string>();
|
|
374
|
+
const seen = new Map<string, number>();
|
|
375
|
+
const masked = maskFences(content);
|
|
376
|
+
const lines = masked.split("\n");
|
|
377
|
+
|
|
378
|
+
const addHeading = (raw: string): void => {
|
|
379
|
+
let text = raw.trim();
|
|
380
|
+
const custom = text.match(/\{#([^}]+)\}\s*$/);
|
|
381
|
+
if (custom?.[1]) {
|
|
382
|
+
anchors.add(custom[1].toLowerCase());
|
|
383
|
+
text = text.slice(0, custom.index).trim();
|
|
384
|
+
}
|
|
385
|
+
// Strip inline markup so "**Bold** `code`" slugs like GitHub's does.
|
|
386
|
+
// Underscores are special: GitHub keeps literal ones (`not_in_channel`
|
|
387
|
+
// anchors as not_in_channel), and GFM emphasis never binds intra-word,
|
|
388
|
+
// so only word-boundary underscores are markup. `_` counts as a word
|
|
389
|
+
// character in JS regex, so \b sits exactly at the space-to-underscore
|
|
390
|
+
// seam these delimiters occupy.
|
|
391
|
+
text = text
|
|
392
|
+
// Backslash escapes render as the bare character, so `snake\_case`
|
|
393
|
+
// must anchor exactly like `snake_case`. Unescape before the emphasis
|
|
394
|
+
// strip or the escaped underscore gains a phantom word boundary.
|
|
395
|
+
.replace(/\\([\\`*_{}[\]()#+.!~-])/g, "$1")
|
|
396
|
+
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
|
|
397
|
+
.replace(/[*~`]/g, "")
|
|
398
|
+
.replace(/\b_+|_+\b/g, "")
|
|
399
|
+
.replace(/<[^>]+>/g, "");
|
|
400
|
+
const base = slugify(text);
|
|
401
|
+
if (!base) return;
|
|
402
|
+
const n = seen.get(base) ?? 0;
|
|
403
|
+
seen.set(base, n + 1);
|
|
404
|
+
anchors.add(n === 0 ? base : `${base}-${n}`);
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
lines.forEach((line, i) => {
|
|
408
|
+
const atx = line.match(/^\s{0,3}#{1,6}\s+(.*?)\s*#*\s*$/);
|
|
409
|
+
if (atx?.[1] !== undefined) {
|
|
410
|
+
addHeading(atx[1]);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
// Setext: an underline of = or - directly under non-blank text.
|
|
414
|
+
if (/^\s{0,3}(=+|-{2,})\s*$/.test(line) && i > 0 && lines[i - 1]!.trim()) {
|
|
415
|
+
addHeading(lines[i - 1]!);
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
// HTML anchors are read from the unmasked source: they often sit inside
|
|
420
|
+
// raw HTML blocks that the fence mask leaves alone anyway, and an id inside
|
|
421
|
+
// a code sample is harmless to accept.
|
|
422
|
+
for (const m of content.matchAll(/<[a-z][^>]*?\s(?:id|name)\s*=\s*["']([^"']+)["']/gi)) {
|
|
423
|
+
if (m[1]) anchors.add(m[1].toLowerCase());
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return anchors;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// --- Resolution ---------------------------------------------------------
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Case-insensitive sibling lookup. Only called once a target has already been
|
|
433
|
+
* proven missing, so the directory read stays off the hot path.
|
|
434
|
+
*/
|
|
435
|
+
function findCaseVariant(absPath: string): string | null {
|
|
436
|
+
const dir = dirname(absPath);
|
|
437
|
+
const base = absPath.slice(dir.length + 1);
|
|
438
|
+
let entries: string[];
|
|
439
|
+
try {
|
|
440
|
+
entries = readdirSync(dir);
|
|
441
|
+
} catch {
|
|
442
|
+
return null;
|
|
443
|
+
}
|
|
444
|
+
const hit = entries.find((e) => e.toLowerCase() === base.toLowerCase());
|
|
445
|
+
return hit && hit !== base ? join(dir, hit) : null;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** Does the target path exist, allowing the extension-less form of a doc? */
|
|
449
|
+
function resolveTarget(absPath: string): { found: string | null; isDir: boolean } {
|
|
450
|
+
// `path/file.ts:42` (and `:42-51`) is a common editor-facing line-reference
|
|
451
|
+
// convention; the file is what must exist.
|
|
452
|
+
const lineRef = absPath.match(/^(.*):\d+(?:-\d+)?$/);
|
|
453
|
+
if (lineRef?.[1] && !existsSync(absPath)) absPath = lineRef[1];
|
|
454
|
+
if (existsSync(absPath)) {
|
|
455
|
+
let isDir = false;
|
|
456
|
+
try {
|
|
457
|
+
isDir = statSync(absPath).isDirectory();
|
|
458
|
+
} catch {
|
|
459
|
+
/* race or permission: treat as file */
|
|
460
|
+
}
|
|
461
|
+
return { found: absPath, isDir };
|
|
462
|
+
}
|
|
463
|
+
// GitHub resolves an extension-less link to a sibling .md when one exists.
|
|
464
|
+
if (!/\.[a-z0-9]+$/i.test(absPath) && existsSync(`${absPath}.md`)) {
|
|
465
|
+
return { found: `${absPath}.md`, isDir: false };
|
|
466
|
+
}
|
|
467
|
+
return { found: null, isDir: false };
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
interface CheckFileOpts {
|
|
471
|
+
repoName: string;
|
|
472
|
+
repoPath: string;
|
|
473
|
+
/** Source file path relative to repoPath. */
|
|
474
|
+
rel: string;
|
|
475
|
+
content: string;
|
|
476
|
+
noFragments: boolean;
|
|
477
|
+
strict: boolean;
|
|
478
|
+
checkEscapes: boolean;
|
|
479
|
+
/** Cache of absolute file path -> anchor set, shared across the run. */
|
|
480
|
+
anchorCache: Map<string, Set<string>>;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
interface FileResult {
|
|
484
|
+
findings: LinkFinding[];
|
|
485
|
+
checked: number;
|
|
486
|
+
skipped: number;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
export function checkFile(opts: CheckFileOpts): FileResult {
|
|
490
|
+
const { repoName, repoPath, rel, content, anchorCache } = opts;
|
|
491
|
+
const findings: LinkFinding[] = [];
|
|
492
|
+
let checked = 0;
|
|
493
|
+
let skipped = 0;
|
|
494
|
+
|
|
495
|
+
if (ALLOW_FILE.test(content)) return { findings, checked, skipped };
|
|
496
|
+
|
|
497
|
+
const sourceLines = content.split("\n");
|
|
498
|
+
const sourceAbs = join(repoPath, rel);
|
|
499
|
+
const sourceDir = dirname(sourceAbs);
|
|
500
|
+
const history = isHistoryDoc(rel) || isSettledLifecycleDoc(rel, content);
|
|
501
|
+
const sev: LinkSeverity = history && !opts.strict ? "warning" : "error";
|
|
502
|
+
|
|
503
|
+
const add = (
|
|
504
|
+
line: number,
|
|
505
|
+
rule: LinkRule,
|
|
506
|
+
target: string,
|
|
507
|
+
message: string,
|
|
508
|
+
suggestion?: string,
|
|
509
|
+
): void => {
|
|
510
|
+
findings.push({
|
|
511
|
+
severity: sev,
|
|
512
|
+
repo: repoName,
|
|
513
|
+
path: rel,
|
|
514
|
+
line,
|
|
515
|
+
rule,
|
|
516
|
+
target,
|
|
517
|
+
message,
|
|
518
|
+
suggestion,
|
|
519
|
+
});
|
|
520
|
+
};
|
|
521
|
+
|
|
522
|
+
for (const { target, line } of extractLinks(maskCode(content))) {
|
|
523
|
+
if (ALLOW_LINE.test(sourceLines[line - 1] ?? "")) {
|
|
524
|
+
skipped++;
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
// `path/file.ts:42` looks like a scheme to the URL test (`file.ts:`),
|
|
528
|
+
// but a dot or slash before the colon plus an all-digit tail marks the
|
|
529
|
+
// editor-facing line-reference convention instead; `tel:911` has neither.
|
|
530
|
+
const isLineRefPath = /^[^:]*[/.][^:]*:\d+(?:-\d+)?$/.test(target);
|
|
531
|
+
if ((EXTERNAL_SCHEME.test(target) && !isLineRefPath) || PLACEHOLDER.test(target)) {
|
|
532
|
+
skipped++;
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const hashAt = target.indexOf("#");
|
|
537
|
+
const rawPath = hashAt === -1 ? target : target.slice(0, hashAt);
|
|
538
|
+
const fragment = hashAt === -1 ? "" : target.slice(hashAt + 1);
|
|
539
|
+
|
|
540
|
+
// Root-absolute targets are site routes far more often than repo paths.
|
|
541
|
+
if (rawPath.startsWith("/")) {
|
|
542
|
+
skipped++;
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
let decoded: string;
|
|
547
|
+
try {
|
|
548
|
+
decoded = decodeURIComponent(rawPath);
|
|
549
|
+
} catch {
|
|
550
|
+
decoded = rawPath;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Same-document fragment.
|
|
554
|
+
if (!decoded) {
|
|
555
|
+
if (!fragment || opts.noFragments) {
|
|
556
|
+
skipped++;
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
559
|
+
checked++;
|
|
560
|
+
if (isNonHeadingFragment(fragment)) continue;
|
|
561
|
+
const anchors = anchorCache.get(sourceAbs) ?? collectAnchors(content);
|
|
562
|
+
anchorCache.set(sourceAbs, anchors);
|
|
563
|
+
if (!anchors.has(decodeFragment(fragment))) {
|
|
564
|
+
add(line, "missing-fragment", target, `no heading or anchor "#${fragment}" in this file`);
|
|
565
|
+
}
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
checked++;
|
|
570
|
+
const absTarget = resolve(sourceDir, decoded);
|
|
571
|
+
const { found, isDir } = resolveTarget(absTarget);
|
|
572
|
+
|
|
573
|
+
if (!found) {
|
|
574
|
+
const variant = findCaseVariant(absTarget);
|
|
575
|
+
if (variant) {
|
|
576
|
+
add(
|
|
577
|
+
line,
|
|
578
|
+
"case-mismatch",
|
|
579
|
+
target,
|
|
580
|
+
"target differs only by case; this resolves on macOS and fails on Linux",
|
|
581
|
+
relative(repoPath, variant),
|
|
582
|
+
);
|
|
583
|
+
} else {
|
|
584
|
+
add(line, "missing-target", target, "target does not exist");
|
|
585
|
+
}
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
if (opts.checkEscapes && isOutside(repoPath, found)) {
|
|
590
|
+
add(
|
|
591
|
+
line,
|
|
592
|
+
"escapes-repo",
|
|
593
|
+
target,
|
|
594
|
+
"target resolves outside this repo; the link breaks for a standalone clone",
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
if (opts.noFragments || !fragment || isDir || !found.endsWith(".md")) continue;
|
|
599
|
+
if (isNonHeadingFragment(fragment)) continue;
|
|
600
|
+
|
|
601
|
+
let anchors = anchorCache.get(found);
|
|
602
|
+
if (!anchors) {
|
|
603
|
+
try {
|
|
604
|
+
anchors = collectAnchors(readFileSync(found, "utf8"));
|
|
605
|
+
} catch {
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
anchorCache.set(found, anchors);
|
|
609
|
+
}
|
|
610
|
+
if (!anchors.has(decodeFragment(fragment))) {
|
|
611
|
+
add(line, "missing-fragment", target, `no heading or anchor "#${fragment}" in ${decoded}`);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
return { findings, checked, skipped };
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function decodeFragment(fragment: string): string {
|
|
619
|
+
try {
|
|
620
|
+
return decodeURIComponent(fragment).toLowerCase();
|
|
621
|
+
} catch {
|
|
622
|
+
return fragment.toLowerCase();
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function isOutside(root: string, target: string): boolean {
|
|
627
|
+
const rel = relative(root, target);
|
|
628
|
+
return rel.startsWith(`..${sep}`) || rel === ".." || isAbsolute(rel);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// --- Runner -------------------------------------------------------------
|
|
632
|
+
|
|
633
|
+
export async function runLinks(opts: LinkOpts): Promise<LinkReport> {
|
|
634
|
+
const findings: LinkFinding[] = [];
|
|
635
|
+
const anchorCache = new Map<string, Set<string>>();
|
|
636
|
+
let filesScanned = 0;
|
|
637
|
+
let linksChecked = 0;
|
|
638
|
+
let linksSkipped = 0;
|
|
639
|
+
|
|
640
|
+
for (const { name, path } of getTargetRepos(opts)) {
|
|
641
|
+
for (const rel of await findMarkdownFiles(path)) {
|
|
642
|
+
let content: string;
|
|
643
|
+
try {
|
|
644
|
+
content = readFileSync(join(path, rel), "utf8");
|
|
645
|
+
} catch {
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
648
|
+
filesScanned++;
|
|
649
|
+
const result = checkFile({
|
|
650
|
+
repoName: name,
|
|
651
|
+
repoPath: path,
|
|
652
|
+
rel,
|
|
653
|
+
content,
|
|
654
|
+
noFragments: !!opts.noFragments,
|
|
655
|
+
strict: !!opts.strict,
|
|
656
|
+
checkEscapes: !!opts.checkEscapes,
|
|
657
|
+
anchorCache,
|
|
658
|
+
});
|
|
659
|
+
findings.push(...result.findings);
|
|
660
|
+
linksChecked += result.checked;
|
|
661
|
+
linksSkipped += result.skipped;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
return {
|
|
666
|
+
repo: opts.repo ?? null,
|
|
667
|
+
files_scanned: filesScanned,
|
|
668
|
+
links_checked: linksChecked,
|
|
669
|
+
links_skipped: linksSkipped,
|
|
670
|
+
error_count: findings.filter((f) => f.severity === "error").length,
|
|
671
|
+
warning_count: findings.filter((f) => f.severity === "warning").length,
|
|
672
|
+
findings,
|
|
673
|
+
};
|
|
674
|
+
}
|
package/src/lib/exec.ts
CHANGED
|
@@ -36,7 +36,7 @@ export interface ExecOpts {
|
|
|
36
36
|
/**
|
|
37
37
|
* Run a shell command and return stdout/stderr/exitCode.
|
|
38
38
|
*
|
|
39
|
-
* `trim` defaults to true
|
|
39
|
+
* `trim` defaults to true. Pass
|
|
40
40
|
* `trim: false` when the consumer depends on leading whitespace. `git status
|
|
41
41
|
* --porcelain` is the canonical case: the first line of ` M PATH` output gets
|
|
42
42
|
* its leading space stripped by .trim(), shifting the X/Y status columns and
|