@vibe-agent-toolkit/resources 0.1.37 → 0.1.39-rc.1

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 (87) hide show
  1. package/README.md +35 -26
  2. package/dist/ajv-factory.d.ts +33 -0
  3. package/dist/ajv-factory.d.ts.map +1 -0
  4. package/dist/ajv-factory.js +51 -0
  5. package/dist/ajv-factory.js.map +1 -0
  6. package/dist/config-parser.d.ts +0 -18
  7. package/dist/config-parser.d.ts.map +1 -1
  8. package/dist/config-parser.js +5 -46
  9. package/dist/config-parser.js.map +1 -1
  10. package/dist/frontmatter-editor.d.ts +45 -0
  11. package/dist/frontmatter-editor.d.ts.map +1 -0
  12. package/dist/frontmatter-editor.js +161 -0
  13. package/dist/frontmatter-editor.js.map +1 -0
  14. package/dist/frontmatter-link-validator.d.ts +5 -5
  15. package/dist/frontmatter-link-validator.d.ts.map +1 -1
  16. package/dist/frontmatter-link-validator.js +25 -24
  17. package/dist/frontmatter-link-validator.js.map +1 -1
  18. package/dist/frontmatter-validator.d.ts +3 -2
  19. package/dist/frontmatter-validator.d.ts.map +1 -1
  20. package/dist/frontmatter-validator.js +19 -20
  21. package/dist/frontmatter-validator.js.map +1 -1
  22. package/dist/index.d.ts +5 -2
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +9 -1
  25. package/dist/index.js.map +1 -1
  26. package/dist/json-pointer-path.d.ts +13 -0
  27. package/dist/json-pointer-path.d.ts.map +1 -0
  28. package/dist/json-pointer-path.js +30 -0
  29. package/dist/json-pointer-path.js.map +1 -0
  30. package/dist/link-parser.js +14 -16
  31. package/dist/link-parser.js.map +1 -1
  32. package/dist/link-validator.d.ts +23 -1
  33. package/dist/link-validator.d.ts.map +1 -1
  34. package/dist/link-validator.js +107 -104
  35. package/dist/link-validator.js.map +1 -1
  36. package/dist/multi-schema-validator.d.ts.map +1 -1
  37. package/dist/multi-schema-validator.js +6 -8
  38. package/dist/multi-schema-validator.js.map +1 -1
  39. package/dist/resource-registry.d.ts +10 -2
  40. package/dist/resource-registry.d.ts.map +1 -1
  41. package/dist/resource-registry.js +25 -32
  42. package/dist/resource-registry.js.map +1 -1
  43. package/dist/rewriter-helpers.d.ts +49 -0
  44. package/dist/rewriter-helpers.d.ts.map +1 -0
  45. package/dist/rewriter-helpers.js +142 -0
  46. package/dist/rewriter-helpers.js.map +1 -0
  47. package/dist/schemas/project-config.d.ts +219 -171
  48. package/dist/schemas/project-config.d.ts.map +1 -1
  49. package/dist/schemas/project-config.js +2 -0
  50. package/dist/schemas/project-config.js.map +1 -1
  51. package/dist/schemas/validation-result.d.ts +36 -57
  52. package/dist/schemas/validation-result.d.ts.map +1 -1
  53. package/dist/schemas/validation-result.js +5 -27
  54. package/dist/schemas/validation-result.js.map +1 -1
  55. package/dist/types/resource-parser.d.ts.map +1 -1
  56. package/dist/types/resource-parser.js +2 -3
  57. package/dist/types/resource-parser.js.map +1 -1
  58. package/dist/types/resources.d.ts +1 -1
  59. package/dist/types/resources.d.ts.map +1 -1
  60. package/dist/types/resources.js.map +1 -1
  61. package/dist/types.d.ts +1 -1
  62. package/dist/types.d.ts.map +1 -1
  63. package/dist/types.js +1 -1
  64. package/dist/types.js.map +1 -1
  65. package/dist/utils.d.ts +50 -11
  66. package/dist/utils.d.ts.map +1 -1
  67. package/dist/utils.js +53 -13
  68. package/dist/utils.js.map +1 -1
  69. package/package.json +5 -5
  70. package/src/ajv-factory.ts +56 -0
  71. package/src/config-parser.ts +5 -51
  72. package/src/frontmatter-editor.ts +214 -0
  73. package/src/frontmatter-link-validator.ts +23 -25
  74. package/src/frontmatter-validator.ts +29 -22
  75. package/src/index.ts +21 -2
  76. package/src/json-pointer-path.ts +29 -0
  77. package/src/link-parser.ts +27 -20
  78. package/src/link-validator.ts +194 -119
  79. package/src/multi-schema-validator.ts +10 -8
  80. package/src/resource-registry.ts +48 -33
  81. package/src/rewriter-helpers.ts +166 -0
  82. package/src/schemas/project-config.ts +2 -0
  83. package/src/schemas/validation-result.ts +5 -29
  84. package/src/types/resource-parser.ts +2 -3
  85. package/src/types/resources.ts +2 -1
  86. package/src/types.ts +0 -1
  87. package/src/utils.ts +72 -14
@@ -13,10 +13,11 @@
13
13
  * This is the ONLY place in the codebase that should use AJV.
14
14
  */
15
15
 
16
- import { Ajv } from 'ajv';
16
+ import { createRegistryIssue, type ValidationIssue } from '@vibe-agent-toolkit/agent-schema';
17
17
 
18
+ import { createAjvWithUriFormats } from './ajv-factory.js';
18
19
  import type { ValidationMode } from './schemas/project-config.js';
19
- import type { ValidationIssue } from './schemas/validation-result.js';
20
+ import { issueLocation } from './utils.js';
20
21
 
21
22
  /**
22
23
  * Validate frontmatter against a JSON Schema.
@@ -32,6 +33,7 @@ import type { ValidationIssue } from './schemas/validation-result.js';
32
33
  * @param resourcePath - File path for error reporting
33
34
  * @param mode - Validation mode: 'strict' (default) or 'permissive'
34
35
  * @param schemaPath - Path to schema file (for error context)
36
+ * @param projectRoot - Project root for computing relative issue locations
35
37
  * @returns Array of validation issues (empty if valid)
36
38
  *
37
39
  * @example
@@ -55,7 +57,8 @@ export function validateFrontmatter(
55
57
  schema: object,
56
58
  resourcePath: string,
57
59
  mode: ValidationMode = 'strict',
58
- schemaPath?: string
60
+ schemaPath?: string,
61
+ projectRoot?: string,
59
62
  ): ValidationIssue[] {
60
63
  const issues: ValidationIssue[] = [];
61
64
 
@@ -65,11 +68,16 @@ export function validateFrontmatter(
65
68
  effectiveSchema = makeSchemaPermissive(schema);
66
69
  }
67
70
 
68
- // Configure AJV with permissive settings
69
- const ajv = new Ajv({
70
- strict: false, // Allow non-strict schemas
71
- allErrors: true, // Report all errors, not just first
72
- allowUnionTypes: true, // Support JSON Schema draft features
71
+ // Use the shared Ajv factory so the internal validator and any adopter
72
+ // consuming `createAjvWithUriFormats` see identical format behavior.
73
+ // Permissive options match how VAT validates user-supplied schemas:
74
+ // - strict: false so non-strict schemas compile (older JSON Schema drafts).
75
+ // - allErrors: true so we report all issues, not just the first.
76
+ // - allowUnionTypes: true for draft-2019-09+ union type support.
77
+ const ajv = createAjvWithUriFormats({
78
+ strict: false,
79
+ allErrors: true,
80
+ allowUnionTypes: true,
73
81
  });
74
82
 
75
83
  const validate = ajv.compile(effectiveSchema);
@@ -83,13 +91,13 @@ export function validateFrontmatter(
83
91
  const schemaContext = schemaPath ? ` (schema: ${schemaPath}, mode: ${mode})` : '';
84
92
  const requiredFields = schemaRequires.join(', ');
85
93
 
86
- issues.push({
87
- resourcePath,
88
- line: 1,
89
- type: 'frontmatter_missing',
90
- link: '',
91
- message: `No frontmatter found in file. Schema requires: ${requiredFields}${schemaContext}`,
92
- });
94
+ issues.push(
95
+ createRegistryIssue(
96
+ 'FRONTMATTER_MISSING',
97
+ `No frontmatter found in file. Schema requires: ${requiredFields}${schemaContext}`,
98
+ { location: issueLocation(resourcePath, projectRoot), line: 1 },
99
+ ),
100
+ );
93
101
  }
94
102
  return issues;
95
103
  }
@@ -104,13 +112,12 @@ export function validateFrontmatter(
104
112
  // Format validation errors with helpful messages
105
113
  for (const error of validate.errors) {
106
114
  const message = formatValidationError(error, frontmatter, mode, schemaPath);
107
- issues.push({
108
- resourcePath,
109
- line: 1,
110
- type: 'frontmatter_schema_error',
111
- link: '',
112
- message,
113
- });
115
+ issues.push(
116
+ createRegistryIssue('FRONTMATTER_SCHEMA_ERROR', message, {
117
+ location: issueLocation(resourcePath, projectRoot),
118
+ line: 1,
119
+ }),
120
+ );
114
121
  }
115
122
 
116
123
  return issues;
package/src/index.ts CHANGED
@@ -88,6 +88,11 @@ export { parseMarkdown, type ParseResult } from './link-parser.js';
88
88
  // Export frontmatter validation
89
89
  export { validateFrontmatter } from './frontmatter-validator.js';
90
90
 
91
+ // Public Ajv factory for adopters consuming VAT-generated schemas. Registers
92
+ // URI-family formats (uri, uri-reference, iri, iri-reference) so schemas
93
+ // compile cleanly under Ajv strict mode without throwing on "unknown format".
94
+ export { createAjvWithUriFormats } from './ajv-factory.js';
95
+
91
96
  // Export content transform engine for link rewriting
92
97
  export {
93
98
  transformContent,
@@ -101,11 +106,25 @@ export {
101
106
  // They are implementation details. Users should use ResourceRegistry API.
102
107
 
103
108
  // Export href resolution utility (shared by audit and validate code paths)
104
- export { resolveLocalHref } from './utils.js';
109
+ export { resolveLocalHref, type ResolveLocalHrefResult } from './utils.js';
110
+
111
+ // Export frontmatter editor primitive (comment-preserving round-trip)
112
+ export {
113
+ openFrontmatter,
114
+ FrontmatterParseError,
115
+ type FrontmatterEditor,
116
+ } from './frontmatter-editor.js';
117
+
118
+ // Export rewriter helpers (built on FrontmatterEditor + shared callback shape)
119
+ export {
120
+ rewriteFrontmatterUriReferencesFromSchema,
121
+ rewriteFrontmatterFieldsAtPaths,
122
+ rewriteBodyLinks,
123
+ type RewriteHref,
124
+ } from './rewriter-helpers.js';
105
125
 
106
126
  // Export project config parsing
107
127
  export {
108
- findConfigFile,
109
128
  parseConfigFile,
110
129
  loadConfig,
111
130
  } from './config-parser.js';
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Convert an RFC 6901 JSON Pointer string into a path of (string | number)
3
+ * segments suitable for use with the FrontmatterEditor mutation API.
4
+ *
5
+ * Canonical array indices (RFC 6901 §4: no leading zeros except for "0")
6
+ * are converted to numbers; all other segments are decoded strings.
7
+ *
8
+ * @example
9
+ * jsonPointerToPath('/adrs-cited/0') // ['adrs-cited', 0]
10
+ * jsonPointerToPath('') // []
11
+ */
12
+
13
+ import { decodeJsonPointerSegment, isCanonicalArrayIndex } from './utils.js';
14
+
15
+ export function jsonPointerToPath(pointer: string): (string | number)[] {
16
+ if (pointer === '') return [];
17
+ // eslint-disable-next-line local/no-hardcoded-path-split -- RFC 6901 JSON Pointer delimiter, not a file path
18
+ const raw = pointer.slice(1).split('/');
19
+ const result: (string | number)[] = [];
20
+ for (const seg of raw) {
21
+ const decoded = decodeJsonPointerSegment(seg);
22
+ if (isCanonicalArrayIndex(decoded)) {
23
+ result.push(Number(decoded));
24
+ } else {
25
+ result.push(decoded);
26
+ }
27
+ }
28
+ return result;
29
+ }
@@ -12,13 +12,13 @@
12
12
  import { readFile, stat } from 'node:fs/promises';
13
13
 
14
14
  import GithubSlugger from 'github-slugger';
15
- import * as yaml from 'js-yaml';
16
15
  import type { Definition, Heading, Link, LinkReference, Root } from 'mdast';
17
16
  import remarkFrontmatter from 'remark-frontmatter';
18
17
  import remarkGfm from 'remark-gfm';
19
18
  import remarkParse from 'remark-parse';
20
19
  import { unified } from 'unified';
21
20
  import { visit } from 'unist-util-visit';
21
+ import * as yaml from 'yaml';
22
22
 
23
23
  import type { HeadingNode, LinkType, ResourceLink } from './types.js';
24
24
 
@@ -310,29 +310,45 @@ function extractHeadingText(node: Heading): string {
310
310
  return extractTextFromChildren(node.children);
311
311
  }
312
312
 
313
+ /**
314
+ * Inline node shape for heading text extraction. Leaf inline nodes (`text`,
315
+ * `inlineCode`) carry their content in `value`; container inline nodes
316
+ * (`strong`, `emphasis`, `link`, `delete`) carry it in `children`.
317
+ */
318
+ interface InlineNode {
319
+ type: string;
320
+ value?: unknown;
321
+ children?: InlineNode[];
322
+ }
323
+
313
324
  /**
314
325
  * Extract text content from inline children nodes.
315
326
  *
316
- * Handles text nodes, inline code, emphasis, and other inline elements.
327
+ * Handles leaf nodes (`text`, `inlineCode`) and recurses into container inline
328
+ * elements (`strong`, `emphasis`, `link`, `delete`) so that styled headings —
329
+ * e.g. `### **CRITICAL: ...**` — produce the same text (and therefore the same
330
+ * GitHub slug) as their plain-text equivalents. Without recursion, bold/italic
331
+ * headings yielded empty text and bogus slugs, causing false LINK_BROKEN_ANCHOR
332
+ * errors for links targeting them.
317
333
  *
318
334
  * @param children - Array of child nodes or undefined
319
335
  * @returns Concatenated text content
320
336
  */
321
- function extractTextFromChildren(
322
- children: Array<{ type: string; value?: unknown }> | undefined
323
- ): string {
337
+ function extractTextFromChildren(children: InlineNode[] | undefined): string {
324
338
  if (!children || children.length === 0) {
325
339
  return '';
326
340
  }
327
341
 
328
342
  return children
329
343
  .map((child) => {
330
- if (child.type === 'text') {
331
- return child.value as string;
344
+ // Leaf inline nodes (text, inlineCode) hold their content in `value`.
345
+ if (typeof child.value === 'string') {
346
+ return child.value;
332
347
  }
333
- // Handle other inline elements (code, emphasis, etc.)
334
- if ('value' in child) {
335
- return String(child.value);
348
+ // Container inline nodes (strong, emphasis, link, delete) hold text in
349
+ // their children recurse to gather it.
350
+ if (child.children) {
351
+ return extractTextFromChildren(child.children);
336
352
  }
337
353
  return '';
338
354
  })
@@ -431,16 +447,7 @@ function extractFrontmatter(tree: Root): {
431
447
  }
432
448
 
433
449
  try {
434
- // CORE_SCHEMA is the YAML 1.2 spec — keeps unquoted ISO dates as
435
- // strings (`2026-04-15` stays `"2026-04-15"`) instead of promoting
436
- // them to JS Date objects (js-yaml's default DEFAULT_SCHEMA still
437
- // applies the YAML 1.1 timestamp tag). Date promotion broke schema
438
- // validation for any frontmatter field typed `string` in the schema:
439
- // the validator saw an instanceof Date and rejected it. Adopters'
440
- // ADR/PRD frontmatter conventionally uses unquoted ISO dates per
441
- // YAML 1.2; quoting all of them just to placate the parser is
442
- // unreasonable.
443
- const parsed = yaml.load(node.value, { schema: yaml.CORE_SCHEMA });
450
+ const parsed = yaml.parse(node.value);
444
451
  if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
445
452
  frontmatterData = parsed as Record<string, unknown>;
446
453
  }
@@ -15,17 +15,39 @@
15
15
  * - External resources (outside project) skip git-ignore checks
16
16
  */
17
17
 
18
+ import fs from 'node:fs/promises';
18
19
  import path from 'node:path';
19
20
 
21
+ import { createRegistryIssue, type ValidationIssue } from '@vibe-agent-toolkit/agent-schema';
20
22
  import {
21
23
  isGitIgnored,
22
24
  type GitTracker,
23
- verifyCaseSensitiveFilename, safePath,
25
+ verifyCaseSensitiveFilename,
24
26
  } from '@vibe-agent-toolkit/utils';
25
27
 
26
- import type { ValidationIssue } from './schemas/validation-result.js';
27
28
  import type { HeadingNode, ResourceLink } from './types.js';
28
- import { isWithinProject, resolveLocalHref, splitHrefAnchor } from './utils.js';
29
+ import { isWithinProject, issueLocation, resolveLocalHref } from './utils.js';
30
+
31
+ type LinkIssueExtras = Partial<Pick<ValidationIssue, 'location' | 'line' | 'link' | 'suggestion'>>;
32
+
33
+ /**
34
+ * Build the common `createRegistryIssue` extras for a link issue: relative
35
+ * location, the problematic href, the line (only when defined — required for
36
+ * exactOptionalPropertyTypes), and an optional suggestion.
37
+ */
38
+ function linkExtras(
39
+ link: ResourceLink,
40
+ sourceFilePath: string,
41
+ projectRoot: string | undefined,
42
+ suggestion?: string,
43
+ ): LinkIssueExtras {
44
+ return {
45
+ location: issueLocation(sourceFilePath, projectRoot),
46
+ link: link.href,
47
+ ...(link.line !== undefined && { line: link.line }),
48
+ ...(suggestion !== undefined && { suggestion }),
49
+ };
50
+ }
29
51
 
30
52
  /**
31
53
  * Options for link validation.
@@ -70,7 +92,7 @@ export async function validateLink(
70
92
  return await validateLocalFileLink(link, sourceFilePath, headingsByFile, options);
71
93
 
72
94
  case 'anchor':
73
- return await validateAnchorLink(link, sourceFilePath, headingsByFile);
95
+ return await validateAnchorLink(link, sourceFilePath, headingsByFile, options?.projectRoot);
74
96
 
75
97
  case 'external':
76
98
  // External URLs are not validated - don't report them
@@ -81,13 +103,11 @@ export async function validateLink(
81
103
  return null;
82
104
 
83
105
  case 'unknown':
84
- return {
85
- resourcePath: sourceFilePath,
86
- line: link.line,
87
- type: 'unknown_link',
88
- link: link.href,
89
- message: 'Unknown link type',
90
- };
106
+ return createRegistryIssue(
107
+ 'LINK_UNKNOWN',
108
+ 'Unknown link type',
109
+ linkExtras(link, sourceFilePath, options?.projectRoot),
110
+ );
91
111
 
92
112
  default: {
93
113
  // TypeScript exhaustiveness check
@@ -97,6 +117,115 @@ export async function validateLink(
97
117
  }
98
118
  }
99
119
 
120
+ /**
121
+ * Convert a resolution failure kind to a broken_file ValidationIssue. Returns
122
+ * null for `resolved` (caller continues) and `anchor_only` (defensive no-op —
123
+ * the parser classifies anchor-only hrefs as 'anchor', not 'local_file').
124
+ */
125
+ export function resolutionFailureIssue(
126
+ resolved: ReturnType<typeof resolveLocalHref>,
127
+ link: ResourceLink,
128
+ sourceFilePath: string,
129
+ projectRoot?: string,
130
+ ): ValidationIssue | null {
131
+ if (resolved.kind === 'absolute_no_root') {
132
+ return createRegistryIssue(
133
+ 'LINK_BROKEN_FILE',
134
+ `Absolute-path link "${link.href}" requires a configured projectRoot; ` +
135
+ `none was provided. Configure vibe-agent-toolkit.config.yaml or run ` +
136
+ `from within a git repository.`,
137
+ linkExtras(
138
+ link,
139
+ sourceFilePath,
140
+ projectRoot,
141
+ 'Rewrite as a source-relative link, or run from a directory with a config or git ancestor.',
142
+ ),
143
+ );
144
+ }
145
+
146
+ if (resolved.kind === 'absolute_escapes_root') {
147
+ return createRegistryIssue(
148
+ 'LINK_BROKEN_FILE',
149
+ `Absolute-path link "${link.href}" escapes the project root via path traversal.`,
150
+ linkExtras(link, sourceFilePath, projectRoot, ''),
151
+ );
152
+ }
153
+
154
+ return null;
155
+ }
156
+
157
+ /**
158
+ * Convert a non-existent file result into a broken_file ValidationIssue.
159
+ * Returns null when the file exists.
160
+ */
161
+ export function fileExistenceIssue(
162
+ fileResult: { exists: boolean; resolvedPath: string; actualName?: string },
163
+ link: ResourceLink,
164
+ sourceFilePath: string,
165
+ projectRoot?: string,
166
+ ): ValidationIssue | null {
167
+ if (fileResult.exists) return null;
168
+
169
+ if (fileResult.actualName) {
170
+ const expectedName = path.basename(fileResult.resolvedPath);
171
+ return createRegistryIssue(
172
+ 'LINK_BROKEN_FILE',
173
+ `File found but case mismatch: expected "${expectedName}" but found "${fileResult.actualName}". This will fail on case-sensitive filesystems (Linux). Update the link to match the actual filename.`,
174
+ linkExtras(
175
+ link,
176
+ sourceFilePath,
177
+ projectRoot,
178
+ `Use "${fileResult.actualName}" instead of "${expectedName}"`,
179
+ ),
180
+ );
181
+ }
182
+
183
+ return createRegistryIssue(
184
+ 'LINK_BROKEN_FILE',
185
+ `File not found: ${fileResult.resolvedPath}`,
186
+ linkExtras(link, sourceFilePath, projectRoot, ''),
187
+ );
188
+ }
189
+
190
+ /**
191
+ * Check git-ignore safety: a non-ignored source file must not link to a
192
+ * gitignored target. Returns a ValidationIssue when this rule is violated,
193
+ * null otherwise (including when checks are disabled or out of scope).
194
+ */
195
+ export function gitIgnoreSafetyIssue(
196
+ link: ResourceLink,
197
+ sourceFilePath: string,
198
+ resolvedTarget: string,
199
+ options: ValidateLinkOptions | undefined,
200
+ ): ValidationIssue | null {
201
+ if (
202
+ options?.skipGitIgnoreCheck === true ||
203
+ options?.projectRoot === undefined ||
204
+ !isWithinProject(resolvedTarget, options.projectRoot)
205
+ ) {
206
+ return null;
207
+ }
208
+
209
+ // Prefer the O(1) active-set lookup on the shared GitTracker (no spawn).
210
+ // isIgnoredByActiveSet falls back internally to isIgnored for paths outside
211
+ // the project root, so this is safe for the rare out-of-project case.
212
+ // When no tracker is threaded in, fall back to isGitIgnored (one-off spawn).
213
+ const sourceIsIgnored = options.gitTracker
214
+ ? options.gitTracker.isIgnoredByActiveSet(sourceFilePath)
215
+ : isGitIgnored(sourceFilePath, options.projectRoot);
216
+ const targetIsIgnored = options.gitTracker
217
+ ? options.gitTracker.isIgnoredByActiveSet(resolvedTarget)
218
+ : isGitIgnored(resolvedTarget, options.projectRoot);
219
+
220
+ if (sourceIsIgnored || !targetIsIgnored) return null;
221
+
222
+ return createRegistryIssue(
223
+ 'LINK_TO_GITIGNORED',
224
+ `Non-ignored file links to gitignored file: ${resolvedTarget}. Gitignored files are local-only and will not exist in the repository. Remove this link or unignore the target file.`,
225
+ linkExtras(link, sourceFilePath, options.projectRoot, ''),
226
+ );
227
+ }
228
+
100
229
  /**
101
230
  * Validate a local file link (with optional anchor).
102
231
  */
@@ -106,87 +235,41 @@ async function validateLocalFileLink(
106
235
  headingsByFile: Map<string, HeadingNode[]>,
107
236
  options?: ValidateLinkOptions
108
237
  ): Promise<ValidationIssue | null> {
109
- // Extract file path and anchor from href
110
- const [filePath, anchor] = splitHrefAnchor(link.href);
111
-
112
- // Validate the file exists
113
- const fileResult = await validateLocalFile(filePath, sourceFilePath);
114
-
115
- if (!fileResult.exists) {
116
- // Check if it's a case mismatch
117
- if (fileResult.actualName) {
118
- const expectedName = path.basename(fileResult.resolvedPath);
119
- return {
120
- resourcePath: sourceFilePath,
121
- line: link.line,
122
- type: 'broken_file',
123
- link: link.href,
124
- message: `File found but case mismatch: expected "${expectedName}" but found "${fileResult.actualName}". This will fail on case-sensitive filesystems (Linux). Update the link to match the actual filename.`,
125
- suggestion: `Use "${fileResult.actualName}" instead of "${expectedName}"`,
126
- };
127
- }
238
+ const resolved = resolveLocalHref(link.href, sourceFilePath, options?.projectRoot);
128
239
 
129
- return {
130
- resourcePath: sourceFilePath,
131
- line: link.line,
132
- type: 'broken_file',
133
- link: link.href,
134
- message: `File not found: ${fileResult.resolvedPath}`,
135
- suggestion: '',
136
- };
240
+ if (resolved.kind !== 'resolved') {
241
+ // anchor_only → null no-op; absolute_no_root / absolute_escapes_root → broken_file.
242
+ return resolutionFailureIssue(resolved, link, sourceFilePath, options?.projectRoot);
137
243
  }
138
244
 
139
- // Check git-ignore safety (Phase 3)
140
- // Only check if:
141
- // 1. skipGitIgnoreCheck is NOT true
142
- // 2. projectRoot is provided
143
- // 3. target is within project (skip for external resources)
144
- if (
145
- options?.skipGitIgnoreCheck !== true &&
146
- options?.projectRoot !== undefined &&
147
- isWithinProject(fileResult.resolvedPath, options.projectRoot)
148
- ) {
149
- // Prefer the O(1) active-set lookup on the shared GitTracker (no spawn).
150
- // isIgnoredByActiveSet falls back internally to isIgnored for paths outside
151
- // the project root, so this is safe for the rare out-of-project case.
152
- // When no tracker is threaded in, fall back to isGitIgnored (one-off spawn).
153
- const sourceIsIgnored = options.gitTracker
154
- ? options.gitTracker.isIgnoredByActiveSet(sourceFilePath)
155
- : isGitIgnored(sourceFilePath, options.projectRoot);
156
- const targetIsIgnored = options.gitTracker
157
- ? options.gitTracker.isIgnoredByActiveSet(fileResult.resolvedPath)
158
- : isGitIgnored(fileResult.resolvedPath, options.projectRoot);
159
-
160
- // Error ONLY if: source is NOT ignored AND target IS ignored
161
- if (!sourceIsIgnored && targetIsIgnored) {
162
- return {
163
- resourcePath: sourceFilePath,
164
- line: link.line,
165
- type: 'link_to_gitignored',
166
- link: link.href,
167
- message: `Non-ignored file links to gitignored file: ${fileResult.resolvedPath}. Gitignored files are local-only and will not exist in the repository. Remove this link or unignore the target file.`,
168
- suggestion: '',
169
- };
170
- }
245
+ const fileResult = await validateResolvedFile(resolved.resolvedPath);
246
+ const notFound = fileExistenceIssue(fileResult, link, sourceFilePath, options?.projectRoot);
247
+ if (notFound) return notFound;
248
+
249
+ if (fileResult.isDirectory) {
250
+ return createRegistryIssue(
251
+ 'LINK_BROKEN_FILE',
252
+ `Link target is a directory: ${fileResult.resolvedPath}`,
253
+ linkExtras(
254
+ link,
255
+ sourceFilePath,
256
+ options?.projectRoot,
257
+ 'Link to a file inside the directory (e.g., README.md or index.md), or fix the link to point at the intended file.',
258
+ ),
259
+ );
171
260
  }
172
261
 
173
- // If there's an anchor, validate it too
174
- if (anchor) {
175
- const anchorValid = await validateAnchor(
176
- anchor,
177
- fileResult.resolvedPath,
178
- headingsByFile
179
- );
262
+ const gitIgnoreIssue = gitIgnoreSafetyIssue(link, sourceFilePath, fileResult.resolvedPath, options);
263
+ if (gitIgnoreIssue) return gitIgnoreIssue;
180
264
 
265
+ if (resolved.anchor) {
266
+ const anchorValid = await validateAnchor(resolved.anchor, fileResult.resolvedPath, headingsByFile);
181
267
  if (!anchorValid) {
182
- return {
183
- resourcePath: sourceFilePath,
184
- line: link.line,
185
- type: 'broken_anchor',
186
- link: link.href,
187
- message: `Anchor not found: #${anchor} in ${fileResult.resolvedPath}`,
188
- suggestion: '',
189
- };
268
+ return createRegistryIssue(
269
+ 'LINK_BROKEN_ANCHOR',
270
+ `Anchor not found: #${resolved.anchor} in ${fileResult.resolvedPath}`,
271
+ linkExtras(link, sourceFilePath, options?.projectRoot, ''),
272
+ );
190
273
  }
191
274
  }
192
275
 
@@ -199,7 +282,8 @@ async function validateLocalFileLink(
199
282
  async function validateAnchorLink(
200
283
  link: ResourceLink,
201
284
  sourceFilePath: string,
202
- headingsByFile: Map<string, HeadingNode[]>
285
+ headingsByFile: Map<string, HeadingNode[]>,
286
+ projectRoot?: string,
203
287
  ): Promise<ValidationIssue | null> {
204
288
  // Extract anchor (strip leading #)
205
289
  const anchor = link.href.startsWith('#') ? link.href.slice(1) : link.href;
@@ -208,14 +292,11 @@ async function validateAnchorLink(
208
292
  const isValid = await validateAnchor(anchor, sourceFilePath, headingsByFile);
209
293
 
210
294
  if (!isValid) {
211
- return {
212
- resourcePath: sourceFilePath,
213
- line: link.line,
214
- type: 'broken_anchor',
215
- link: link.href,
216
- message: `Anchor not found: ${link.href}`,
217
- suggestion: '',
218
- };
295
+ return createRegistryIssue(
296
+ 'LINK_BROKEN_ANCHOR',
297
+ `Anchor not found: ${link.href}`,
298
+ linkExtras(link, sourceFilePath, projectRoot, ''),
299
+ );
219
300
  }
220
301
 
221
302
  return null;
@@ -223,37 +304,31 @@ async function validateAnchorLink(
223
304
 
224
305
 
225
306
  /**
226
- * Validate that a local file exists with the correct case.
227
- *
228
- * @param href - The href to the file (relative or absolute)
229
- * @param sourceFilePath - Absolute path to the source file
230
- * @returns Object with exists flag, resolved absolute path, and optional case mismatch info
307
+ * Verify that the resolved filesystem path exists with the correct case.
231
308
  *
232
- * @example
233
- * ```typescript
234
- * const result = await validateLocalFile('./docs/guide.md', '/project/README.md');
235
- * if (result.exists) {
236
- * console.log('File exists at:', result.resolvedPath);
237
- * } else if (result.actualName) {
238
- * console.log('Case mismatch:', result.actualName);
239
- * }
240
- * ```
309
+ * @param resolvedPath - Absolute filesystem path produced by {@link resolveLocalHref}.
310
+ * @returns Object with exists flag, the path, and optional case-mismatch info.
241
311
  */
242
- async function validateLocalFile(
243
- href: string,
244
- sourceFilePath: string
245
- ): Promise<{ exists: boolean; resolvedPath: string; actualName?: string }> {
246
- // Resolve href to filesystem path (decode percent-encoding, resolve relative to source)
247
- const resolved = resolveLocalHref(href, sourceFilePath);
248
- const resolvedPath = resolved?.resolvedPath ?? safePath.resolve(path.dirname(sourceFilePath), href);
249
-
250
- // Check if file exists with correct case
312
+ async function validateResolvedFile(
313
+ resolvedPath: string,
314
+ ): Promise<{ exists: boolean; resolvedPath: string; actualName?: string; isDirectory: boolean }> {
251
315
  const verification = await verifyCaseSensitiveFilename(resolvedPath);
252
316
 
253
- // Build result with optional actualName (only include if present)
254
- const result: { exists: boolean; resolvedPath: string; actualName?: string } = {
317
+ let isDirectory = false;
318
+ if (verification.exists) {
319
+ try {
320
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- resolvedPath validated by verifyCaseSensitiveFilename
321
+ const stats = await fs.stat(resolvedPath);
322
+ isDirectory = stats.isDirectory();
323
+ } catch {
324
+ // Stat failed after verifyCaseSensitiveFilename said exists — treat as file.
325
+ }
326
+ }
327
+
328
+ const result: { exists: boolean; resolvedPath: string; actualName?: string; isDirectory: boolean } = {
255
329
  exists: verification.exists,
256
330
  resolvedPath,
331
+ isDirectory,
257
332
  };
258
333
 
259
334
  if (verification.actualName) {