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.
Files changed (222) hide show
  1. package/dist/commander.d.ts.map +1 -1
  2. package/dist/commander.js +2 -0
  3. package/dist/commands/agents.d.ts +0 -9
  4. package/dist/commands/agents.d.ts.map +1 -1
  5. package/dist/commands/agents.js +70 -95
  6. package/dist/commands/browse-session.d.ts +21 -0
  7. package/dist/commands/browse-session.d.ts.map +1 -0
  8. package/dist/commands/browse-session.js +157 -0
  9. package/dist/commands/browse.d.ts.map +1 -1
  10. package/dist/commands/browse.js +205 -37
  11. package/dist/commands/checkpoint.js +1 -1
  12. package/dist/commands/deinit.d.ts.map +1 -1
  13. package/dist/commands/deinit.js +4 -0
  14. package/dist/commands/docs.d.ts.map +1 -1
  15. package/dist/commands/docs.js +40 -0
  16. package/dist/commands/doctor.d.ts.map +1 -1
  17. package/dist/commands/doctor.js +100 -17
  18. package/dist/commands/init.d.ts +1 -2
  19. package/dist/commands/init.d.ts.map +1 -1
  20. package/dist/commands/init.js +16 -5
  21. package/dist/commands/tunnel.d.ts.map +1 -1
  22. package/dist/commands/tunnel.js +2 -3
  23. package/dist/core/agents/canonical-emit.d.ts +1 -2
  24. package/dist/core/agents/canonical-emit.d.ts.map +1 -1
  25. package/dist/core/agents/canonical-emit.js +1 -2
  26. package/dist/core/agents/cli.js +123 -88
  27. package/dist/core/agents/coord-client.d.ts +11 -4
  28. package/dist/core/agents/coord-client.d.ts.map +1 -1
  29. package/dist/core/agents/coord-client.js +27 -9
  30. package/dist/core/agents/finalization.d.ts +68 -0
  31. package/dist/core/agents/finalization.d.ts.map +1 -0
  32. package/dist/core/agents/finalization.js +443 -0
  33. package/dist/core/agents/git-hook.d.ts +51 -0
  34. package/dist/core/agents/git-hook.d.ts.map +1 -0
  35. package/dist/core/agents/git-hook.js +118 -0
  36. package/dist/core/agents/render/prompt-context.d.ts +6 -5
  37. package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
  38. package/dist/core/agents/render/prompt-context.js +26 -13
  39. package/dist/core/agents/render/session-context.d.ts.map +1 -1
  40. package/dist/core/agents/render/session-context.js +15 -2
  41. package/dist/core/agents/rules/claim-conflict.js +3 -3
  42. package/dist/core/agents/rules/commit-conflict.d.ts +15 -6
  43. package/dist/core/agents/rules/commit-conflict.d.ts.map +1 -1
  44. package/dist/core/agents/rules/commit-conflict.js +21 -5
  45. package/dist/core/agents/rules/stop-hook.d.ts +3 -0
  46. package/dist/core/agents/rules/stop-hook.d.ts.map +1 -1
  47. package/dist/core/agents/rules/stop-hook.js +59 -23
  48. package/dist/core/agents/session-events.d.ts +8 -16
  49. package/dist/core/agents/session-events.d.ts.map +1 -1
  50. package/dist/core/agents/session-events.js +12 -26
  51. package/dist/core/agents/state/heartbeat-projector.d.ts +3 -0
  52. package/dist/core/agents/state/heartbeat-projector.d.ts.map +1 -1
  53. package/dist/core/agents/state/heartbeat-projector.js +44 -1
  54. package/dist/core/agents/state/heartbeat-writer.d.ts +32 -5
  55. package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
  56. package/dist/core/agents/state/heartbeat-writer.js +54 -17
  57. package/dist/core/agents/state/names.d.ts +22 -1
  58. package/dist/core/agents/state/names.d.ts.map +1 -1
  59. package/dist/core/agents/state/names.js +40 -2
  60. package/dist/core/config.d.ts +30 -2
  61. package/dist/core/config.d.ts.map +1 -1
  62. package/dist/core/config.js +74 -11
  63. package/dist/core/governor/planning.d.ts.map +1 -1
  64. package/dist/core/governor/planning.js +12 -13
  65. package/dist/core/hooks/adapter/detect.d.ts +2 -4
  66. package/dist/core/hooks/adapter/detect.d.ts.map +1 -1
  67. package/dist/core/hooks/adapter/detect.js +4 -14
  68. package/dist/core/hooks/adapter/output.d.ts +1 -1
  69. package/dist/core/hooks/adapter/output.d.ts.map +1 -1
  70. package/dist/core/hooks/adapter/output.js +5 -4
  71. package/dist/core/hooks/adapter/parse.d.ts +1 -1
  72. package/dist/core/hooks/adapter/parse.d.ts.map +1 -1
  73. package/dist/core/hooks/adapter/parse.js +22 -5
  74. package/dist/core/hooks/adapter/wiring.d.ts +23 -0
  75. package/dist/core/hooks/adapter/wiring.d.ts.map +1 -1
  76. package/dist/core/hooks/adapter/wiring.js +32 -0
  77. package/dist/core/hooks/cli.d.ts +1 -2
  78. package/dist/core/hooks/cli.d.ts.map +1 -1
  79. package/dist/core/hooks/cli.js +169 -27
  80. package/dist/core/hooks/codex-wsl-bridge.d.ts +46 -0
  81. package/dist/core/hooks/codex-wsl-bridge.d.ts.map +1 -0
  82. package/dist/core/hooks/codex-wsl-bridge.js +137 -0
  83. package/dist/core/hooks/effects/index.d.ts +1 -1
  84. package/dist/core/hooks/effects/index.js +3 -3
  85. package/dist/core/hooks/events/schema.d.ts +27 -0
  86. package/dist/core/hooks/events/schema.d.ts.map +1 -1
  87. package/dist/core/hooks/resolve/owner.d.ts +7 -1
  88. package/dist/core/hooks/resolve/owner.d.ts.map +1 -1
  89. package/dist/core/hooks/resolve/owner.js +15 -0
  90. package/dist/core/hooks/resolve/transcript.d.ts +44 -0
  91. package/dist/core/hooks/resolve/transcript.d.ts.map +1 -1
  92. package/dist/core/hooks/resolve/transcript.js +176 -1
  93. package/dist/core/hooks/session-name-presence.d.ts +30 -0
  94. package/dist/core/hooks/session-name-presence.d.ts.map +1 -0
  95. package/dist/core/hooks/session-name-presence.js +43 -0
  96. package/dist/core/hooks/unsafe-cross-shell.d.ts +13 -0
  97. package/dist/core/hooks/unsafe-cross-shell.d.ts.map +1 -0
  98. package/dist/core/hooks/unsafe-cross-shell.js +151 -0
  99. package/dist/core/work/state.d.ts +4 -5
  100. package/dist/core/work/state.d.ts.map +1 -1
  101. package/dist/core/work/state.js +5 -8
  102. package/dist/core/workflow/index.d.ts +1 -1
  103. package/dist/core/workflow/index.d.ts.map +1 -1
  104. package/dist/core/workflow/proof.d.ts +2 -2
  105. package/dist/core/workflow/proof.d.ts.map +1 -1
  106. package/dist/core/workflow/proof.js +2 -2
  107. package/dist/core/workflow/run-state.d.ts +2 -2
  108. package/dist/core/workflow/run-state.d.ts.map +1 -1
  109. package/dist/core/workflow/run-state.js +2 -2
  110. package/dist/core/workflow/types.d.ts +3 -3
  111. package/dist/core/workflow/types.d.ts.map +1 -1
  112. package/dist/core/workflow/workspaces/execution.d.ts +2 -2
  113. package/dist/core/workflow/workspaces/execution.d.ts.map +1 -1
  114. package/dist/core/workflow/workspaces/execution.js +5 -0
  115. package/dist/core/workflow/workspaces/index.d.ts +1 -1
  116. package/dist/core/workflow/workspaces/index.d.ts.map +1 -1
  117. package/dist/core/workflow/workspaces/local-git.d.ts.map +1 -1
  118. package/dist/core/workflow/workspaces/local-git.js +1 -2
  119. package/dist/core/workflow/workspaces/paths.d.ts +2 -2
  120. package/dist/core/workflow/workspaces/types.d.ts +0 -6
  121. package/dist/core/workflow/workspaces/types.d.ts.map +1 -1
  122. package/dist/core/workflow/workspaces/validate.d.ts +0 -2
  123. package/dist/core/workflow/workspaces/validate.d.ts.map +1 -1
  124. package/dist/core/workflow/workspaces/validate.js +0 -2
  125. package/dist/lib/browser/client.d.ts +118 -1
  126. package/dist/lib/browser/client.d.ts.map +1 -1
  127. package/dist/lib/browser/client.js +435 -6
  128. package/dist/lib/browser/geometry.d.ts.map +1 -1
  129. package/dist/lib/browser/geometry.js +180 -37
  130. package/dist/lib/browser/index.d.ts +5 -1
  131. package/dist/lib/browser/index.d.ts.map +1 -1
  132. package/dist/lib/browser/index.js +5 -1
  133. package/dist/lib/browser/netscape-cookies.d.ts +6 -0
  134. package/dist/lib/browser/netscape-cookies.d.ts.map +1 -0
  135. package/dist/lib/browser/netscape-cookies.js +31 -0
  136. package/dist/lib/browser/proxy.d.ts +24 -0
  137. package/dist/lib/browser/proxy.d.ts.map +1 -0
  138. package/dist/lib/browser/proxy.js +84 -0
  139. package/dist/lib/browser/runts.d.ts +6 -0
  140. package/dist/lib/browser/runts.d.ts.map +1 -1
  141. package/dist/lib/browser/runts.js +21 -4
  142. package/dist/lib/browser/session-control.d.ts +112 -0
  143. package/dist/lib/browser/session-control.d.ts.map +1 -0
  144. package/dist/lib/browser/session-control.js +670 -0
  145. package/dist/lib/docs-links.d.ts +108 -0
  146. package/dist/lib/docs-links.d.ts.map +1 -0
  147. package/dist/lib/docs-links.js +555 -0
  148. package/dist/lib/exec.d.ts +1 -1
  149. package/dist/lib/exec.js +1 -1
  150. package/dist/lib/identities/assume.d.ts +4 -2
  151. package/dist/lib/identities/assume.d.ts.map +1 -1
  152. package/dist/lib/identities/assume.js +15 -2
  153. package/dist/lib/instructions/git-hooks.d.ts +75 -0
  154. package/dist/lib/instructions/git-hooks.d.ts.map +1 -0
  155. package/dist/lib/instructions/git-hooks.js +238 -0
  156. package/dist/lib/instructions/splice.d.ts +10 -4
  157. package/dist/lib/instructions/splice.d.ts.map +1 -1
  158. package/dist/lib/instructions/splice.js +19 -11
  159. package/package.json +1 -1
  160. package/schemas/config.schema.json +32 -0
  161. package/src/commander.ts +2 -0
  162. package/src/commands/agents.ts +96 -115
  163. package/src/commands/browse-session.ts +245 -0
  164. package/src/commands/browse.ts +287 -44
  165. package/src/commands/checkpoint.ts +1 -1
  166. package/src/commands/deinit.ts +7 -1
  167. package/src/commands/docs.ts +56 -0
  168. package/src/commands/doctor.ts +100 -18
  169. package/src/commands/init.ts +17 -9
  170. package/src/commands/tunnel.ts +2 -5
  171. package/src/core/agents/canonical-emit.ts +1 -2
  172. package/src/core/agents/cli.ts +137 -88
  173. package/src/core/agents/coord-client.ts +38 -9
  174. package/src/core/agents/finalization.ts +595 -0
  175. package/src/core/agents/git-hook.ts +126 -0
  176. package/src/core/agents/render/prompt-context.ts +28 -13
  177. package/src/core/agents/render/session-context.ts +15 -2
  178. package/src/core/agents/rules/claim-conflict.ts +3 -3
  179. package/src/core/agents/rules/commit-conflict.ts +35 -8
  180. package/src/core/agents/rules/stop-hook.ts +71 -23
  181. package/src/core/agents/session-events.ts +14 -41
  182. package/src/core/agents/state/heartbeat-projector.ts +45 -1
  183. package/src/core/agents/state/heartbeat-writer.ts +78 -16
  184. package/src/core/agents/state/names.ts +54 -2
  185. package/src/core/config.ts +92 -12
  186. package/src/core/governor/planning.ts +17 -12
  187. package/src/core/hooks/adapter/detect.ts +4 -13
  188. package/src/core/hooks/adapter/output.ts +9 -4
  189. package/src/core/hooks/adapter/parse.ts +24 -5
  190. package/src/core/hooks/adapter/wiring.ts +43 -0
  191. package/src/core/hooks/cli.ts +201 -23
  192. package/src/core/hooks/codex-wsl-bridge.ts +188 -0
  193. package/src/core/hooks/effects/index.ts +5 -5
  194. package/src/core/hooks/events/schema.ts +27 -0
  195. package/src/core/hooks/resolve/owner.ts +22 -1
  196. package/src/core/hooks/resolve/transcript.ts +164 -1
  197. package/src/core/hooks/session-name-presence.ts +52 -0
  198. package/src/core/hooks/unsafe-cross-shell.ts +160 -0
  199. package/src/core/work/state.ts +8 -12
  200. package/src/core/workflow/engine.ts +2 -2
  201. package/src/core/workflow/index.ts +0 -1
  202. package/src/core/workflow/proof.ts +4 -4
  203. package/src/core/workflow/run-state.ts +5 -5
  204. package/src/core/workflow/types.ts +3 -3
  205. package/src/core/workflow/workspaces/execution.ts +11 -4
  206. package/src/core/workflow/workspaces/index.ts +0 -3
  207. package/src/core/workflow/workspaces/local-git.ts +1 -2
  208. package/src/core/workflow/workspaces/paths.ts +2 -2
  209. package/src/core/workflow/workspaces/types.ts +0 -9
  210. package/src/core/workflow/workspaces/validate.ts +1 -5
  211. package/src/lib/browser/client.ts +528 -6
  212. package/src/lib/browser/geometry.ts +197 -37
  213. package/src/lib/browser/index.ts +39 -0
  214. package/src/lib/browser/netscape-cookies.ts +39 -0
  215. package/src/lib/browser/proxy.ts +105 -0
  216. package/src/lib/browser/runts.ts +27 -3
  217. package/src/lib/browser/session-control.ts +892 -0
  218. package/src/lib/docs-links.ts +674 -0
  219. package/src/lib/exec.ts +1 -1
  220. package/src/lib/identities/assume.ts +22 -1
  221. package/src/lib/instructions/git-hooks.ts +259 -0
  222. 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 `[![alt](img.png)](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 (back-compat with every existing caller). Pass
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