dev-loops 0.1.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 (156) hide show
  1. package/.pi/dev-loop/defaults.yaml +477 -0
  2. package/AGENTS.md +25 -0
  3. package/CHANGELOG.md +18 -0
  4. package/LICENSE +21 -0
  5. package/README.md +178 -0
  6. package/agents/dev-loop.agent.md +82 -0
  7. package/agents/developer.agent.md +37 -0
  8. package/agents/docs.agent.md +33 -0
  9. package/agents/fixer.agent.md +53 -0
  10. package/agents/quality.agent.md +28 -0
  11. package/agents/refiner.agent.md +87 -0
  12. package/agents/review.agent.md +64 -0
  13. package/cli/index.mjs +424 -0
  14. package/extension/README.md +233 -0
  15. package/extension/checks.ts +94 -0
  16. package/extension/index.ts +131 -0
  17. package/extension/post-merge-update.ts +512 -0
  18. package/extension/presentation.ts +107 -0
  19. package/lib/dev-loops-core.mjs +284 -0
  20. package/package.json +103 -0
  21. package/scripts/README.md +1007 -0
  22. package/scripts/_cli-primitives.mjs +10 -0
  23. package/scripts/_core-helpers.mjs +30 -0
  24. package/scripts/docs/validate-links.mjs +567 -0
  25. package/scripts/docs/validate-no-duplicate-rules.mjs +250 -0
  26. package/scripts/github/_review-thread-mutations.mjs +214 -0
  27. package/scripts/github/capture-review-threads.mjs +180 -0
  28. package/scripts/github/create-draft-pr.mjs +108 -0
  29. package/scripts/github/detect-checkpoint-evidence.mjs +393 -0
  30. package/scripts/github/detect-linked-issue-pr.mjs +331 -0
  31. package/scripts/github/manage-sub-issues.mjs +394 -0
  32. package/scripts/github/probe-copilot-review.mjs +323 -0
  33. package/scripts/github/ready-for-review.mjs +93 -0
  34. package/scripts/github/reconcile-draft-gate.mjs +328 -0
  35. package/scripts/github/reply-resolve-review-thread.mjs +42 -0
  36. package/scripts/github/reply-resolve-review-threads.mjs +329 -0
  37. package/scripts/github/request-copilot-review.mjs +551 -0
  38. package/scripts/github/resolve-tracker-local-spec.mjs +205 -0
  39. package/scripts/github/stage-reviewer-draft.mjs +191 -0
  40. package/scripts/github/upsert-checkpoint-verdict.mjs +694 -0
  41. package/scripts/github/verify-fresh-review-context.mjs +125 -0
  42. package/scripts/github/write-gate-findings-log.mjs +212 -0
  43. package/scripts/loop/_checkpoint-io.mjs +55 -0
  44. package/scripts/loop/_checkpoint-paths.mjs +28 -0
  45. package/scripts/loop/_handoff-contract.mjs +230 -0
  46. package/scripts/loop/_inspect-run-viewer-adapter.mjs +345 -0
  47. package/scripts/loop/_loop-evidence.mjs +32 -0
  48. package/scripts/loop/_pr-runner-coordination.mjs +611 -0
  49. package/scripts/loop/_stale-runner-detection.mjs +145 -0
  50. package/scripts/loop/_steering-state-file.mjs +134 -0
  51. package/scripts/loop/build-handoff-envelope.mjs +181 -0
  52. package/scripts/loop/checkpoint-contract.mjs +49 -0
  53. package/scripts/loop/conductor-monitor.mjs +1850 -0
  54. package/scripts/loop/conductor.mjs +214 -0
  55. package/scripts/loop/copilot-pr-handoff.mjs +493 -0
  56. package/scripts/loop/debt-remediate.mjs +304 -0
  57. package/scripts/loop/detect-change-scope.mjs +102 -0
  58. package/scripts/loop/detect-copilot-loop-state.mjs +454 -0
  59. package/scripts/loop/detect-copilot-session-activity.mjs +186 -0
  60. package/scripts/loop/detect-initial-copilot-pr-state.mjs +318 -0
  61. package/scripts/loop/detect-internal-only-pr.mjs +270 -0
  62. package/scripts/loop/detect-issue-refinement-artifact.mjs +163 -0
  63. package/scripts/loop/detect-pr-gate-coordination-state.mjs +509 -0
  64. package/scripts/loop/detect-reviewer-loop-state.mjs +231 -0
  65. package/scripts/loop/detect-stale-runner.mjs +250 -0
  66. package/scripts/loop/detect-tracker-first-loop-state.mjs +76 -0
  67. package/scripts/loop/detect-tracker-pr-state.mjs +102 -0
  68. package/scripts/loop/info.mjs +267 -0
  69. package/scripts/loop/inspect-run-viewer/cli.mjs +117 -0
  70. package/scripts/loop/inspect-run-viewer/constants.mjs +80 -0
  71. package/scripts/loop/inspect-run-viewer/graph.mjs +757 -0
  72. package/scripts/loop/inspect-run-viewer/handoff-envelope-renderer.mjs +398 -0
  73. package/scripts/loop/inspect-run-viewer/inbox.mjs +308 -0
  74. package/scripts/loop/inspect-run-viewer/managed-instance.mjs +750 -0
  75. package/scripts/loop/inspect-run-viewer/rendering.mjs +411 -0
  76. package/scripts/loop/inspect-run-viewer/server.mjs +638 -0
  77. package/scripts/loop/inspect-run-viewer/shared.mjs +103 -0
  78. package/scripts/loop/inspect-run-viewer/status.mjs +715 -0
  79. package/scripts/loop/inspect-run-viewer-ci-changes.mjs +77 -0
  80. package/scripts/loop/inspect-run-viewer.mjs +82 -0
  81. package/scripts/loop/inspect-run.mjs +382 -0
  82. package/scripts/loop/outer-loop.mjs +419 -0
  83. package/scripts/loop/pr-runner-coordination.mjs +143 -0
  84. package/scripts/loop/pre-commit-branch-guard.mjs +68 -0
  85. package/scripts/loop/pre-flight-gate.mjs +236 -0
  86. package/scripts/loop/pre-pr-ready-gate.mjs +183 -0
  87. package/scripts/loop/pre-push-main-guard.mjs +103 -0
  88. package/scripts/loop/pre-write-remote-freshness-guard.mjs +32 -0
  89. package/scripts/loop/print-gates.mjs +42 -0
  90. package/scripts/loop/resolve-dev-loop-startup.mjs +533 -0
  91. package/scripts/loop/run-conductor-cycle.mjs +322 -0
  92. package/scripts/loop/run-queue.mjs +124 -0
  93. package/scripts/loop/run-refinement-audit.mjs +513 -0
  94. package/scripts/loop/run-watch-cycle.mjs +358 -0
  95. package/scripts/loop/steer-loop.mjs +841 -0
  96. package/scripts/loop/ui-designer-review-contract.mjs +76 -0
  97. package/scripts/loop/watch-initial-copilot-pr.mjs +253 -0
  98. package/scripts/projects/add-queue-item.mjs +528 -0
  99. package/scripts/projects/ensure-queue-board.mjs +837 -0
  100. package/scripts/projects/list-queue-items.mjs +489 -0
  101. package/scripts/projects/move-queue-item.mjs +549 -0
  102. package/scripts/projects/reorder-queue-item.mjs +518 -0
  103. package/scripts/refine/_refine-helpers.mjs +258 -0
  104. package/scripts/refine/prose-linkage-detector.mjs +92 -0
  105. package/scripts/refine/refinement-completeness-checker.mjs +88 -0
  106. package/scripts/refine/scope-boundary-cross-checker.mjs +163 -0
  107. package/scripts/refine/tree-integrity-validator.mjs +211 -0
  108. package/scripts/refine/verify.mjs +178 -0
  109. package/scripts/repo-wiki-local.mjs +156 -0
  110. package/scripts/repo-wiki.mjs +119 -0
  111. package/skills/copilot-pr-followup/SKILL.md +380 -0
  112. package/skills/dev-loop/SKILL.md +141 -0
  113. package/skills/dev-loop/scripts/dev-mode-context.mjs +152 -0
  114. package/skills/dev-loop/scripts/dev-mode-context.test.mjs +80 -0
  115. package/skills/dev-loop/scripts/init-phase.mjs +71 -0
  116. package/skills/dev-loop/scripts/log-bash-exit-1.mjs +25 -0
  117. package/skills/dev-loop/scripts/phase-files.mjs +29 -0
  118. package/skills/dev-loop/scripts/post-gate-verdict-fallback.mjs +480 -0
  119. package/skills/dev-loop/scripts/post-gate-verdict-fallback.test.mjs +732 -0
  120. package/skills/dev-loop/scripts/render-template.mjs +82 -0
  121. package/skills/dev-loop/scripts/render-template.test.mjs +63 -0
  122. package/skills/dev-loop/templates/bootstrap-agents.md +26 -0
  123. package/skills/dev-loop/templates/bootstrap-implementation-state.md +31 -0
  124. package/skills/dev-loop/templates/bootstrap-implementation-workflow.md +17 -0
  125. package/skills/dev-loop/templates/dev-mode-retrospective.md +15 -0
  126. package/skills/dev-loop/templates/dev-mode-review.md +17 -0
  127. package/skills/dev-loop/templates/dev-mode-skill-changes.md +11 -0
  128. package/skills/dev-loop/templates/merged-phase-plan.md +19 -0
  129. package/skills/dev-loop/templates/phase-doc.md +27 -0
  130. package/skills/dev-loop/templates/phase-summary.md +13 -0
  131. package/skills/dev-loop/templates/phase-variant.md +15 -0
  132. package/skills/dev-loop/templates/retrospective.md +11 -0
  133. package/skills/dev-loop/templates/review.md +32 -0
  134. package/skills/dev-loop/templates/ui-vision-review.md +55 -0
  135. package/skills/docs/acceptance-criteria-verification.md +21 -0
  136. package/skills/docs/anti-patterns.md +21 -0
  137. package/skills/docs/artifact-authority-contract.md +119 -0
  138. package/skills/docs/confirmation-rules.md +28 -0
  139. package/skills/docs/copilot-ci-status-contract.md +52 -0
  140. package/skills/docs/copilot-loop-operations.md +233 -0
  141. package/skills/docs/debt-remediation-contract.md +107 -0
  142. package/skills/docs/entrypoint-strategies.md +115 -0
  143. package/skills/docs/epic-tree-refinement-procedure.md +234 -0
  144. package/skills/docs/issue-intake-procedure.md +235 -0
  145. package/skills/docs/main-agent-contract.md +72 -0
  146. package/skills/docs/merge-preconditions.md +29 -0
  147. package/skills/docs/pr-lifecycle-contract.md +209 -0
  148. package/skills/docs/public-dev-loop-contract.md +497 -0
  149. package/skills/docs/retrospective-checkpoint-contract.md +159 -0
  150. package/skills/docs/stop-conditions.md +29 -0
  151. package/skills/docs/structural-quality.md +42 -0
  152. package/skills/docs/tracker-first-loop-state.md +281 -0
  153. package/skills/docs/validation-policy.md +27 -0
  154. package/skills/docs/workflow-handoff-contract.md +135 -0
  155. package/skills/final-approval/SKILL.md +19 -0
  156. package/skills/local-implementation/SKILL.md +640 -0
@@ -0,0 +1,10 @@
1
+ // Re-export from shared library (Phase 2, issue #548)
2
+ export {
3
+ requireOptionValue,
4
+ parsePositiveInteger,
5
+ parseNonNegativeInteger,
6
+ parsePrNumber,
7
+ parseIssueNumber,
8
+ runChild,
9
+ runCommand,
10
+ } from "@dev-loops/core/cli/primitives";
@@ -0,0 +1,30 @@
1
+ // Re-exports from shared library (Phase 2, issue #548)
2
+
3
+ export {
4
+ formatCliError,
5
+ parseJsonText,
6
+ classifyReviewThreadsSignal,
7
+ parseReviewThreads,
8
+ readInput,
9
+ } from "@dev-loops/core/github/review-threads";
10
+
11
+ export {
12
+ buildPhasePaths,
13
+ readJsonIfExists,
14
+ } from "@dev-loops/core/loop/phase-files";
15
+
16
+ export {
17
+ extractReviewCommitSha,
18
+ isCopilotLogin,
19
+ normalizeTimestamp,
20
+ parseGateReviewCommentBody,
21
+ parseGateReviewCommentMarkerBody,
22
+ summarizeCopilotReviews,
23
+ summarizeGateReviewCommentMarkers,
24
+ summarizeGateReviewComments,
25
+ } from "@dev-loops/core/github/copilot-helpers";
26
+
27
+ export {
28
+ buildParseError,
29
+ isDirectCliRun,
30
+ } from "@dev-loops/core/cli/helpers";
@@ -0,0 +1,567 @@
1
+ #!/usr/bin/env node
2
+ import { lstat, readdir, readFile, stat } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ import { isDirectCliRun } from "../_core-helpers.mjs";
7
+ import { requireOptionValue } from "../_cli-primitives.mjs";
8
+
9
+ const DEFAULT_SCAN_PATHS = Object.freeze([
10
+ "README.md",
11
+ "PLAN.md",
12
+ "AGENTS.md",
13
+ "scripts/README.md",
14
+ "extension/README.md",
15
+ "docs",
16
+ "skills",
17
+ "agents",
18
+ ]);
19
+
20
+ const DEFAULT_SOURCE_EXCLUDES = Object.freeze([
21
+ "docs/archive",
22
+ ]);
23
+
24
+ const DEFAULT_CANDIDATE_EXCLUDED_DIRS = new Set([
25
+ ".git",
26
+ "node_modules",
27
+ "tmp",
28
+ "coverage",
29
+ "dist",
30
+ "worktrees",
31
+ "playwright-report",
32
+ "test-results",
33
+ ]);
34
+
35
+ const DEFAULT_IGNORE_FILE = ".linkcheckignore";
36
+ const LINK_PATTERN = /(?<!!)\[[^\]]*\]\(([^)\n]+)\)/g;
37
+
38
+ const USAGE = `Usage: validate-links.mjs [--root <path>]
39
+
40
+ Validate repo-owned markdown relative links.
41
+
42
+ Options:
43
+ --root <path> Override the repo root to scan (defaults to this repository)
44
+ --help, -h Show this help text`.trim();
45
+
46
+ function resolveDefaultRepoRoot() {
47
+ return fileURLToPath(new URL("../../", import.meta.url));
48
+ }
49
+
50
+ function parseError(message) {
51
+ return Object.assign(new Error(message), { usage: USAGE });
52
+ }
53
+
54
+ export function parseValidateLinksCliArgs(argv) {
55
+ const args = [...argv];
56
+ const options = {
57
+ help: false,
58
+ repoRoot: resolveDefaultRepoRoot(),
59
+ };
60
+
61
+ while (args.length > 0) {
62
+ const token = args.shift();
63
+
64
+ if (token === "--help" || token === "-h") {
65
+ options.help = true;
66
+ return options;
67
+ }
68
+
69
+ if (token === "--root") {
70
+ options.repoRoot = path.resolve(requireOptionValue(args, "--root"));
71
+ continue;
72
+ }
73
+
74
+ throw parseError(`Unknown argument: ${token}`);
75
+ }
76
+
77
+ return options;
78
+ }
79
+
80
+ function toPosixPath(value) {
81
+ return value.split(path.sep).join("/");
82
+ }
83
+
84
+ function normalizeRepoRelative(relativePath) {
85
+ const normalized = path.normalize(relativePath);
86
+ return toPosixPath(normalized).replace(/^\.\//, "");
87
+ }
88
+
89
+ function shouldSkipSource(repoRelativePath) {
90
+ return DEFAULT_SOURCE_EXCLUDES.some((prefix) => {
91
+ return repoRelativePath === prefix || repoRelativePath.startsWith(`${prefix}/`);
92
+ });
93
+ }
94
+
95
+ async function readPathKind(targetPath) {
96
+ try {
97
+ const entry = await stat(targetPath);
98
+ if (entry.isDirectory()) {
99
+ return "directory";
100
+ }
101
+ if (entry.isFile()) {
102
+ return "file";
103
+ }
104
+ return null;
105
+ } catch {
106
+ return null;
107
+ }
108
+ }
109
+
110
+ async function pathExists(targetPath) {
111
+ return (await readPathKind(targetPath)) !== null;
112
+ }
113
+
114
+ function normalizeLinkTarget(rawTarget) {
115
+ let normalized = rawTarget.trim();
116
+
117
+ if (normalized.startsWith("<") && normalized.endsWith(">")) {
118
+ normalized = normalized.slice(1, -1).trim();
119
+ }
120
+
121
+ const titleMatch = normalized.match(/^(\S+)\s+(?:"[^"]*"|'[^']*'|\([^)]*\))$/);
122
+ if (titleMatch) {
123
+ normalized = titleMatch[1];
124
+ }
125
+
126
+ return normalized;
127
+ }
128
+
129
+ function stripFragment(rawTarget) {
130
+ const hashIndex = rawTarget.indexOf("#");
131
+ return hashIndex === -1 ? rawTarget : rawTarget.slice(0, hashIndex);
132
+ }
133
+
134
+ function shouldIgnoreRawTarget(rawTarget) {
135
+ if (rawTarget.length === 0) {
136
+ return true;
137
+ }
138
+
139
+ if (rawTarget.startsWith("#") || rawTarget.startsWith("/") || rawTarget.startsWith("//")) {
140
+ return true;
141
+ }
142
+
143
+ if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(rawTarget)) {
144
+ return true;
145
+ }
146
+
147
+ return false;
148
+ }
149
+
150
+ export function extractRelativeMarkdownLinks(content) {
151
+ const links = [];
152
+ const lines = content.split(/\r?\n/);
153
+ let activeFence = null;
154
+
155
+ for (const [index, line] of lines.entries()) {
156
+ const trimmed = line.trimStart();
157
+ const fenceMatch = trimmed.match(/^(`{3,}|~{3,})/);
158
+
159
+ if (fenceMatch) {
160
+ const fenceToken = fenceMatch[1];
161
+ const fenceInfo = { marker: fenceToken[0], length: fenceToken.length };
162
+
163
+ if (!activeFence) {
164
+ activeFence = fenceInfo;
165
+ continue;
166
+ }
167
+
168
+ if (activeFence.marker === fenceInfo.marker && fenceInfo.length >= activeFence.length) {
169
+ activeFence = null;
170
+ }
171
+ continue;
172
+ }
173
+
174
+ if (activeFence) {
175
+ continue;
176
+ }
177
+
178
+ for (const match of line.matchAll(LINK_PATTERN)) {
179
+ const rawTarget = normalizeLinkTarget(match[1] ?? "");
180
+ if (shouldIgnoreRawTarget(rawTarget)) {
181
+ continue;
182
+ }
183
+
184
+ links.push({
185
+ line: index + 1,
186
+ rawTarget,
187
+ });
188
+ }
189
+ }
190
+
191
+ return links;
192
+ }
193
+
194
+ async function collectMarkdownFiles(repoRoot) {
195
+ const collected = new Set();
196
+
197
+ async function walkDirectory(absoluteDir, repoRelativeDir) {
198
+ const entries = await readdir(absoluteDir, { withFileTypes: true });
199
+
200
+ for (const entry of entries) {
201
+ const absoluteEntryPath = path.join(absoluteDir, entry.name);
202
+ const repoRelativePath = normalizeRepoRelative(path.join(repoRelativeDir, entry.name));
203
+
204
+ if (shouldSkipSource(repoRelativePath)) {
205
+ continue;
206
+ }
207
+
208
+ if (entry.isDirectory()) {
209
+ await walkDirectory(absoluteEntryPath, repoRelativePath);
210
+ continue;
211
+ }
212
+
213
+ if (entry.isFile()) {
214
+ if (repoRelativePath.endsWith(".md")) {
215
+ collected.add(repoRelativePath);
216
+ }
217
+ continue;
218
+ }
219
+
220
+ if (!entry.isSymbolicLink()) {
221
+ continue;
222
+ }
223
+
224
+ const kind = await readPathKind(absoluteEntryPath);
225
+ if (kind === "file" && repoRelativePath.endsWith(".md")) {
226
+ collected.add(repoRelativePath);
227
+ }
228
+ }
229
+ }
230
+
231
+ for (const scanPath of DEFAULT_SCAN_PATHS) {
232
+ const absolutePath = path.join(repoRoot, scanPath);
233
+
234
+ let stats;
235
+ try {
236
+ stats = await lstat(absolutePath);
237
+ } catch {
238
+ continue;
239
+ }
240
+
241
+ const repoRelativePath = normalizeRepoRelative(scanPath);
242
+ if (shouldSkipSource(repoRelativePath)) {
243
+ continue;
244
+ }
245
+
246
+ if (stats.isDirectory()) {
247
+ await walkDirectory(absolutePath, repoRelativePath);
248
+ continue;
249
+ }
250
+
251
+ if (stats.isFile()) {
252
+ if (repoRelativePath.endsWith(".md")) {
253
+ collected.add(repoRelativePath);
254
+ }
255
+ continue;
256
+ }
257
+
258
+ if (!stats.isSymbolicLink()) {
259
+ continue;
260
+ }
261
+
262
+ const kind = await readPathKind(absolutePath);
263
+ if (kind === "file" && repoRelativePath.endsWith(".md")) {
264
+ collected.add(repoRelativePath);
265
+ }
266
+ }
267
+
268
+ return [...collected].sort();
269
+ }
270
+
271
+ async function loadIgnoreList(repoRoot, ignoreFileName = DEFAULT_IGNORE_FILE) {
272
+ const ignorePath = path.join(repoRoot, ignoreFileName);
273
+
274
+ try {
275
+ const content = await readFile(ignorePath, "utf8");
276
+ const ignored = new Set();
277
+
278
+ for (const line of content.split(/\r?\n/)) {
279
+ const withoutComment = line.split("#", 1)[0].trim();
280
+ if (withoutComment.length === 0) {
281
+ continue;
282
+ }
283
+ ignored.add(normalizeRepoRelative(withoutComment));
284
+ }
285
+
286
+ return ignored;
287
+ } catch (error) {
288
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
289
+ return new Set();
290
+ }
291
+ throw error;
292
+ }
293
+ }
294
+
295
+ async function buildCandidateIndex(repoRoot) {
296
+ const candidates = [];
297
+
298
+ async function walkDirectory(absoluteDir, repoRelativeDir = "") {
299
+ const entries = await readdir(absoluteDir, { withFileTypes: true });
300
+
301
+ for (const entry of entries) {
302
+ if (entry.isDirectory() && DEFAULT_CANDIDATE_EXCLUDED_DIRS.has(entry.name)) {
303
+ continue;
304
+ }
305
+
306
+ const absoluteEntryPath = path.join(absoluteDir, entry.name);
307
+ const repoRelativePath = normalizeRepoRelative(path.join(repoRelativeDir, entry.name));
308
+
309
+ if (shouldSkipSource(repoRelativePath)) {
310
+ continue;
311
+ }
312
+
313
+ if (entry.isDirectory()) {
314
+ candidates.push({
315
+ repoRelativePath,
316
+ absolutePath: absoluteEntryPath,
317
+ parentDir: normalizeRepoRelative(path.dirname(repoRelativePath)),
318
+ baseName: path.basename(repoRelativePath),
319
+ baseNameLower: path.basename(repoRelativePath).toLowerCase(),
320
+ });
321
+ await walkDirectory(absoluteEntryPath, repoRelativePath);
322
+ continue;
323
+ }
324
+
325
+ if (entry.isFile()) {
326
+ candidates.push({
327
+ repoRelativePath,
328
+ absolutePath: absoluteEntryPath,
329
+ parentDir: normalizeRepoRelative(path.dirname(repoRelativePath)),
330
+ baseName: path.basename(repoRelativePath),
331
+ baseNameLower: path.basename(repoRelativePath).toLowerCase(),
332
+ });
333
+ continue;
334
+ }
335
+
336
+ if (!entry.isSymbolicLink()) {
337
+ continue;
338
+ }
339
+
340
+ const kind = await readPathKind(absoluteEntryPath);
341
+ if (kind === null) {
342
+ continue;
343
+ }
344
+
345
+ candidates.push({
346
+ repoRelativePath,
347
+ absolutePath: absoluteEntryPath,
348
+ parentDir: normalizeRepoRelative(path.dirname(repoRelativePath)),
349
+ baseName: path.basename(repoRelativePath),
350
+ baseNameLower: path.basename(repoRelativePath).toLowerCase(),
351
+ });
352
+ }
353
+ }
354
+
355
+ await walkDirectory(repoRoot);
356
+ return candidates;
357
+ }
358
+
359
+ function isInsideRepoRoot(repoRoot, candidatePath) {
360
+ const relative = path.relative(repoRoot, candidatePath);
361
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
362
+ }
363
+
364
+ function levenshteinDistance(left, right) {
365
+ if (left === right) {
366
+ return 0;
367
+ }
368
+
369
+ if (left.length === 0) {
370
+ return right.length;
371
+ }
372
+
373
+ if (right.length === 0) {
374
+ return left.length;
375
+ }
376
+
377
+ const previous = Array.from({ length: right.length + 1 }, (_, index) => index);
378
+ const current = new Array(right.length + 1).fill(0);
379
+
380
+ for (let row = 1; row <= left.length; row += 1) {
381
+ current[0] = row;
382
+ for (let column = 1; column <= right.length; column += 1) {
383
+ const cost = left[row - 1] === right[column - 1] ? 0 : 1;
384
+ current[column] = Math.min(
385
+ current[column - 1] + 1,
386
+ previous[column] + 1,
387
+ previous[column - 1] + cost,
388
+ );
389
+ }
390
+ previous.splice(0, previous.length, ...current);
391
+ }
392
+
393
+ return previous[right.length];
394
+ }
395
+
396
+ function toSuggestedRelativePath(sourceAbsolutePath, targetAbsolutePath) {
397
+ return toPosixPath(path.relative(path.dirname(sourceAbsolutePath), targetAbsolutePath));
398
+ }
399
+
400
+ function suggestCorrection({ sourceAbsolutePath, attemptedName, attemptedParentPath, repoRoot, candidateIndex }) {
401
+ if (attemptedName.length === 0) {
402
+ return null;
403
+ }
404
+
405
+ const exactBaseNameMatches = candidateIndex.filter((candidate) => candidate.baseNameLower === attemptedName.toLowerCase());
406
+ if (exactBaseNameMatches.length === 1) {
407
+ return toSuggestedRelativePath(sourceAbsolutePath, exactBaseNameMatches[0].absolutePath);
408
+ }
409
+
410
+ if (!isInsideRepoRoot(repoRoot, attemptedParentPath)) {
411
+ return null;
412
+ }
413
+
414
+ const attemptedParentRelative = normalizeRepoRelative(path.relative(repoRoot, attemptedParentPath));
415
+ const nearbyCandidates = candidateIndex
416
+ .filter((candidate) => candidate.parentDir === attemptedParentRelative)
417
+ .map((candidate) => ({
418
+ candidate,
419
+ distance: levenshteinDistance(attemptedName.toLowerCase(), candidate.baseNameLower),
420
+ }))
421
+ .filter(({ distance }) => distance <= 2)
422
+ .sort((left, right) => left.distance - right.distance || left.candidate.repoRelativePath.localeCompare(right.candidate.repoRelativePath));
423
+
424
+ if (nearbyCandidates.length === 0) {
425
+ return null;
426
+ }
427
+
428
+ if (nearbyCandidates.length > 1 && nearbyCandidates[0].distance === nearbyCandidates[1].distance) {
429
+ return null;
430
+ }
431
+
432
+ return toSuggestedRelativePath(sourceAbsolutePath, nearbyCandidates[0].candidate.absolutePath);
433
+ }
434
+
435
+ export async function validateMarkdownLinks({ repoRoot = resolveDefaultRepoRoot() } = {}) {
436
+ const absoluteRepoRoot = path.resolve(repoRoot);
437
+ const scannedFiles = await collectMarkdownFiles(absoluteRepoRoot);
438
+ const ignoredResolvedPaths = await loadIgnoreList(absoluteRepoRoot);
439
+ let candidateIndex = null;
440
+ let candidateIndexUnavailable = false;
441
+ const brokenLinks = [];
442
+ let checkedLinkCount = 0;
443
+
444
+ async function getCandidateIndex() {
445
+ if (candidateIndexUnavailable) {
446
+ return [];
447
+ }
448
+
449
+ if (candidateIndex === null) {
450
+ try {
451
+ candidateIndex = await buildCandidateIndex(absoluteRepoRoot);
452
+ } catch {
453
+ candidateIndexUnavailable = true;
454
+ return [];
455
+ }
456
+ }
457
+
458
+ return candidateIndex;
459
+ }
460
+
461
+ for (const sourcePath of scannedFiles) {
462
+ const sourceAbsolutePath = path.join(absoluteRepoRoot, sourcePath);
463
+ const content = await readFile(sourceAbsolutePath, "utf8");
464
+ const extractedLinks = extractRelativeMarkdownLinks(content);
465
+
466
+ for (const extractedLink of extractedLinks) {
467
+ const strippedTarget = stripFragment(extractedLink.rawTarget);
468
+ if (strippedTarget.length === 0) {
469
+ continue;
470
+ }
471
+
472
+ checkedLinkCount += 1;
473
+ const resolvedAbsolutePath = path.resolve(path.dirname(sourceAbsolutePath), strippedTarget);
474
+ const resolvedPath = normalizeRepoRelative(path.relative(absoluteRepoRoot, resolvedAbsolutePath));
475
+ const resolvedInsideRepoRoot = isInsideRepoRoot(absoluteRepoRoot, resolvedAbsolutePath);
476
+
477
+ if (resolvedInsideRepoRoot && ignoredResolvedPaths.has(resolvedPath)) {
478
+ continue;
479
+ }
480
+
481
+ if (resolvedInsideRepoRoot && await pathExists(resolvedAbsolutePath)) {
482
+ continue;
483
+ }
484
+
485
+ brokenLinks.push({
486
+ sourcePath,
487
+ line: extractedLink.line,
488
+ rawTarget: extractedLink.rawTarget,
489
+ resolvedPath,
490
+ suggestion: resolvedInsideRepoRoot
491
+ ? suggestCorrection({
492
+ sourceAbsolutePath,
493
+ attemptedName: path.basename(strippedTarget),
494
+ attemptedParentPath: path.dirname(resolvedAbsolutePath),
495
+ repoRoot: absoluteRepoRoot,
496
+ candidateIndex: await getCandidateIndex(),
497
+ })
498
+ : null,
499
+ });
500
+ }
501
+ }
502
+
503
+ brokenLinks.sort((left, right) => {
504
+ return left.sourcePath.localeCompare(right.sourcePath)
505
+ || left.line - right.line
506
+ || left.rawTarget.localeCompare(right.rawTarget);
507
+ });
508
+
509
+ return {
510
+ ok: brokenLinks.length === 0,
511
+ repoRoot: absoluteRepoRoot,
512
+ scannedFiles,
513
+ checkedLinkCount,
514
+ ignoredResolvedPaths: [...ignoredResolvedPaths].sort(),
515
+ brokenLinks,
516
+ };
517
+ }
518
+
519
+ export function formatBrokenLinkReport(brokenLinks) {
520
+ const lines = ["Broken markdown links found:"];
521
+
522
+ for (const brokenLink of brokenLinks) {
523
+ lines.push(`- ${brokenLink.sourcePath}:${brokenLink.line} -> ${brokenLink.rawTarget}`);
524
+ lines.push(` resolved: ${brokenLink.resolvedPath}`);
525
+ if (brokenLink.suggestion) {
526
+ lines.push(` suggestion: ${brokenLink.suggestion}`);
527
+ }
528
+ }
529
+
530
+ return lines.join("\n");
531
+ }
532
+
533
+ async function main() {
534
+ try {
535
+ const options = parseValidateLinksCliArgs(process.argv.slice(2));
536
+
537
+ if (options.help) {
538
+ process.stdout.write(`${USAGE}\n`);
539
+ process.exitCode = 0;
540
+ return;
541
+ }
542
+
543
+ const result = await validateMarkdownLinks({ repoRoot: options.repoRoot });
544
+ if (result.ok) {
545
+ process.stdout.write(`Markdown links OK (${result.scannedFiles.length} files, ${result.checkedLinkCount} links checked).\n`);
546
+ process.exitCode = 0;
547
+ return;
548
+ }
549
+
550
+ process.stderr.write(`${formatBrokenLinkReport(result.brokenLinks)}\n`);
551
+ process.exitCode = 1;
552
+ } catch (error) {
553
+ if (error instanceof Error && "usage" in error && typeof error.usage === "string") {
554
+ process.stderr.write(`${error.message}\n\n${error.usage}\n`);
555
+ process.exitCode = 2;
556
+ return;
557
+ }
558
+
559
+ const message = error instanceof Error ? error.message : String(error);
560
+ process.stderr.write(`Markdown link validation failed: ${message}\n`);
561
+ process.exitCode = 2;
562
+ }
563
+ }
564
+
565
+ if (isDirectCliRun(import.meta.url)) {
566
+ await main();
567
+ }