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