@vibe-agent-toolkit/resources 0.1.37 → 0.1.38

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 (58) hide show
  1. package/README.md +2 -2
  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-validator.d.ts.map +1 -1
  15. package/dist/frontmatter-validator.js +11 -6
  16. package/dist/frontmatter-validator.js.map +1 -1
  17. package/dist/index.d.ts +5 -2
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +9 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/json-pointer-path.d.ts +13 -0
  22. package/dist/json-pointer-path.d.ts.map +1 -0
  23. package/dist/json-pointer-path.js +30 -0
  24. package/dist/json-pointer-path.js.map +1 -0
  25. package/dist/link-parser.js +2 -11
  26. package/dist/link-parser.js.map +1 -1
  27. package/dist/link-validator.d.ts +22 -0
  28. package/dist/link-validator.d.ts.map +1 -1
  29. package/dist/link-validator.js +126 -75
  30. package/dist/link-validator.js.map +1 -1
  31. package/dist/rewriter-helpers.d.ts +49 -0
  32. package/dist/rewriter-helpers.d.ts.map +1 -0
  33. package/dist/rewriter-helpers.js +142 -0
  34. package/dist/rewriter-helpers.js.map +1 -0
  35. package/dist/types/resource-parser.d.ts.map +1 -1
  36. package/dist/types/resource-parser.js +2 -3
  37. package/dist/types/resource-parser.js.map +1 -1
  38. package/dist/types.d.ts +1 -1
  39. package/dist/types.d.ts.map +1 -1
  40. package/dist/types.js +1 -1
  41. package/dist/types.js.map +1 -1
  42. package/dist/utils.d.ts +40 -11
  43. package/dist/utils.d.ts.map +1 -1
  44. package/dist/utils.js +39 -13
  45. package/dist/utils.js.map +1 -1
  46. package/package.json +5 -5
  47. package/src/ajv-factory.ts +56 -0
  48. package/src/config-parser.ts +5 -51
  49. package/src/frontmatter-editor.ts +214 -0
  50. package/src/frontmatter-validator.ts +11 -7
  51. package/src/index.ts +21 -2
  52. package/src/json-pointer-path.ts +29 -0
  53. package/src/link-parser.ts +2 -11
  54. package/src/link-validator.ts +153 -88
  55. package/src/rewriter-helpers.ts +166 -0
  56. package/src/types/resource-parser.ts +2 -3
  57. package/src/types.ts +0 -1
  58. package/src/utils.ts +57 -14
@@ -0,0 +1,214 @@
1
+ /**
2
+ * FrontmatterEditor — round-trip-safe primitive for editing YAML frontmatter
3
+ * in markdown files.
4
+ *
5
+ * Public surface: openFrontmatter(markdown) → FrontmatterEditor.
6
+ *
7
+ * Round-trip identity contract: openFrontmatter(x).toString() === x for any
8
+ * well-formed input, byte-for-byte. Mutations preserve comments, blank lines,
9
+ * key ordering, quoting style, and detected EOL.
10
+ *
11
+ * See docs/superpowers/specs/2026-05-17-frontmatter-editor-and-yaml-consolidation-design.md
12
+ * §5 for the full contract.
13
+ */
14
+
15
+ import { Document, parseDocument } from 'yaml';
16
+
17
+ export class FrontmatterParseError extends Error {
18
+ public override readonly cause: unknown;
19
+
20
+ constructor(message: string, cause: unknown) {
21
+ super(message);
22
+ this.name = 'FrontmatterParseError';
23
+ this.cause = cause;
24
+ }
25
+ }
26
+
27
+ /** Path to a value in the parsed frontmatter document. */
28
+ export type FrontmatterPath = string | readonly (string | number)[];
29
+
30
+ /** Scalar value type accepted by mutation methods. */
31
+ export type FrontmatterScalar = string | number | boolean | null;
32
+
33
+ export interface FrontmatterEditor {
34
+ body: string;
35
+ get(path: FrontmatterPath): unknown;
36
+ set(path: FrontmatterPath, value: FrontmatterScalar): void;
37
+ setArrayItem(path: FrontmatterPath, index: number, value: FrontmatterScalar): void;
38
+ appendArrayItem(path: FrontmatterPath, value: FrontmatterScalar): void;
39
+ delete(path: FrontmatterPath): void;
40
+ toString(): string;
41
+ /**
42
+ * Returns true if any mutating method has been called or `body` was
43
+ * reassigned to a different string. Use to gate writeFileSync and avoid
44
+ * the no-op-rewrite churn described in §"What's preserved, what isn't"
45
+ * of the markdown-rewriting skill.
46
+ *
47
+ * **Caveat:** any call to `set` / `setArrayItem` / `appendArrayItem` /
48
+ * `delete` flips the flag, even if the underlying value didn't change
49
+ * (e.g. `set('foo', sameValue)`). For strict byte-level dirty detection
50
+ * compare `editor.toString() !== originalText` instead. `body =` is the
51
+ * one exception — it only flips dirty on actual string change.
52
+ */
53
+ isDirty(): boolean;
54
+ }
55
+
56
+ interface FrontmatterSplit {
57
+ hasFrontmatter: boolean;
58
+ frontmatterText: string;
59
+ body: string;
60
+ eol: '\n' | '\r\n';
61
+ }
62
+
63
+ const OPENING_FENCE = /^---\r?\n/;
64
+ // Closing fence: either immediately after opening (empty frontmatter), or
65
+ // preceded by a newline. Trailing variants accept newline or EOF.
66
+ const EMPTY_CLOSING_FENCE = /^---(?:\r?\n|$)/;
67
+ const CLOSING_FENCE = /(?:\r?\n---\r?\n|\r?\n---$)/;
68
+
69
+ function detectEol(input: string): '\n' | '\r\n' {
70
+ const firstBreak = input.indexOf('\n');
71
+ if (firstBreak === -1) return '\n';
72
+ return firstBreak > 0 && input.charAt(firstBreak - 1) === '\r' ? '\r\n' : '\n';
73
+ }
74
+
75
+ function splitFrontmatter(input: string): FrontmatterSplit {
76
+ const eol = detectEol(input);
77
+ const openingMatch = OPENING_FENCE.exec(input);
78
+ if (!openingMatch) {
79
+ return { hasFrontmatter: false, frontmatterText: '', body: input, eol };
80
+ }
81
+ const afterOpening = input.slice(openingMatch[0].length);
82
+ // Handle empty frontmatter (closing fence immediately follows opening fence)
83
+ const emptyMatch = EMPTY_CLOSING_FENCE.exec(afterOpening);
84
+ if (emptyMatch) {
85
+ const bodyStart = emptyMatch[0].length;
86
+ return {
87
+ hasFrontmatter: true,
88
+ frontmatterText: '',
89
+ body: afterOpening.slice(bodyStart),
90
+ eol,
91
+ };
92
+ }
93
+ const closingMatch = CLOSING_FENCE.exec(afterOpening);
94
+ if (!closingMatch) {
95
+ return { hasFrontmatter: false, frontmatterText: '', body: input, eol };
96
+ }
97
+ const frontmatterText = afterOpening.slice(0, closingMatch.index);
98
+ const bodyStart = closingMatch.index + closingMatch[0].length;
99
+ const body = afterOpening.slice(bodyStart);
100
+ return { hasFrontmatter: true, frontmatterText, body, eol };
101
+ }
102
+
103
+ class FrontmatterEditorImpl implements FrontmatterEditor {
104
+ private readonly doc: Document.Parsed | Document;
105
+ private readonly hasFrontmatter: boolean;
106
+ private readonly eol: '\n' | '\r\n';
107
+ private _body: string;
108
+ private _dirty = false;
109
+
110
+ get body(): string {
111
+ return this._body;
112
+ }
113
+
114
+ set body(value: string) {
115
+ if (value !== this._body) {
116
+ this._body = value;
117
+ this._dirty = true;
118
+ }
119
+ }
120
+
121
+ isDirty(): boolean {
122
+ return this._dirty;
123
+ }
124
+
125
+ constructor(input: string) {
126
+ const split = splitFrontmatter(input);
127
+ this.hasFrontmatter = split.hasFrontmatter;
128
+ this.eol = split.eol;
129
+ this._body = split.body;
130
+ if (!split.hasFrontmatter) {
131
+ this.doc = new Document({});
132
+ return;
133
+ }
134
+ try {
135
+ this.doc = parseDocument(split.frontmatterText, { prettyErrors: true });
136
+ if (this.doc.errors.length > 0) {
137
+ throw new FrontmatterParseError(
138
+ `Invalid YAML frontmatter: ${this.doc.errors[0]?.message ?? 'unknown error'}`,
139
+ this.doc.errors[0],
140
+ );
141
+ }
142
+ } catch (error) {
143
+ if (error instanceof FrontmatterParseError) throw error;
144
+ throw new FrontmatterParseError(
145
+ `Failed to parse frontmatter: ${error instanceof Error ? error.message : String(error)}`,
146
+ error,
147
+ );
148
+ }
149
+ }
150
+
151
+ private toPath(path: FrontmatterPath): readonly (string | number)[] {
152
+ if (Array.isArray(path)) return path;
153
+ if (typeof path === 'string') return [path];
154
+ return path as readonly (string | number)[];
155
+ }
156
+
157
+ get(path: FrontmatterPath): unknown {
158
+ const segments = this.toPath(path);
159
+ if (segments.length === 0) return this.doc.toJS();
160
+ return this.doc.getIn(segments as Iterable<unknown>, false);
161
+ }
162
+
163
+ set(path: FrontmatterPath, value: FrontmatterScalar): void {
164
+ const segments = this.toPath(path);
165
+ this.doc.setIn(segments as Iterable<unknown>, value);
166
+ this._dirty = true;
167
+ }
168
+
169
+ setArrayItem(path: FrontmatterPath, index: number, value: FrontmatterScalar): void {
170
+ const segments = [...this.toPath(path), index];
171
+ this.doc.setIn(segments as Iterable<unknown>, value);
172
+ this._dirty = true;
173
+ }
174
+
175
+ appendArrayItem(path: FrontmatterPath, value: FrontmatterScalar): void {
176
+ const segments = this.toPath(path);
177
+ this.doc.addIn(segments as Iterable<unknown>, value);
178
+ this._dirty = true;
179
+ }
180
+
181
+ delete(path: FrontmatterPath): void {
182
+ const segments = this.toPath(path);
183
+ this.doc.deleteIn(segments as Iterable<unknown>);
184
+ this._dirty = true;
185
+ }
186
+
187
+ toString(): string {
188
+ // No frontmatter originally, and nothing was added → return body unchanged.
189
+ if (!this.hasFrontmatter && this.isDocEffectivelyEmpty()) {
190
+ return this.body;
191
+ }
192
+ // Empty frontmatter (e.g. `---\n---\n`) where the doc remained empty —
193
+ // preserve the empty fence block without injecting `null` or `{}` between.
194
+ if (this.hasFrontmatter && this.isDocEffectivelyEmpty()) {
195
+ return `---${this.eol}---${this.eol}${this.body}`;
196
+ }
197
+ const fmText = this.doc.toString();
198
+ const normalized = this.eol === '\r\n' ? fmText.replaceAll('\n', '\r\n') : fmText;
199
+ return `---${this.eol}${normalized}---${this.eol}${this.body}`;
200
+ }
201
+
202
+ private isDocEffectivelyEmpty(): boolean {
203
+ const contents = this.doc.contents;
204
+ if (contents === null) return true;
205
+ // yaml.YAMLMap and YAMLSeq expose `items`; an empty map/seq counts as empty.
206
+ const maybeItems = (contents as { items?: unknown[] }).items;
207
+ if (Array.isArray(maybeItems) && maybeItems.length === 0) return true;
208
+ return false;
209
+ }
210
+ }
211
+
212
+ export function openFrontmatter(markdown: string): FrontmatterEditor {
213
+ return new FrontmatterEditorImpl(markdown);
214
+ }
@@ -13,8 +13,7 @@
13
13
  * This is the ONLY place in the codebase that should use AJV.
14
14
  */
15
15
 
16
- import { Ajv } from 'ajv';
17
-
16
+ import { createAjvWithUriFormats } from './ajv-factory.js';
18
17
  import type { ValidationMode } from './schemas/project-config.js';
19
18
  import type { ValidationIssue } from './schemas/validation-result.js';
20
19
 
@@ -65,11 +64,16 @@ export function validateFrontmatter(
65
64
  effectiveSchema = makeSchemaPermissive(schema);
66
65
  }
67
66
 
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
67
+ // Use the shared Ajv factory so the internal validator and any adopter
68
+ // consuming `createAjvWithUriFormats` see identical format behavior.
69
+ // Permissive options match how VAT validates user-supplied schemas:
70
+ // - strict: false so non-strict schemas compile (older JSON Schema drafts).
71
+ // - allErrors: true so we report all issues, not just the first.
72
+ // - allowUnionTypes: true for draft-2019-09+ union type support.
73
+ const ajv = createAjvWithUriFormats({
74
+ strict: false,
75
+ allErrors: true,
76
+ allowUnionTypes: true,
73
77
  });
74
78
 
75
79
  const validate = ajv.compile(effectiveSchema);
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
 
@@ -431,16 +431,7 @@ function extractFrontmatter(tree: Root): {
431
431
  }
432
432
 
433
433
  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 });
434
+ const parsed = yaml.parse(node.value);
444
435
  if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
445
436
  frontmatterData = parsed as Record<string, unknown>;
446
437
  }
@@ -15,17 +15,18 @@
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
 
20
21
  import {
21
22
  isGitIgnored,
22
23
  type GitTracker,
23
- verifyCaseSensitiveFilename, safePath,
24
+ verifyCaseSensitiveFilename,
24
25
  } from '@vibe-agent-toolkit/utils';
25
26
 
26
27
  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, resolveLocalHref } from './utils.js';
29
30
 
30
31
  /**
31
32
  * Options for link validation.
@@ -97,6 +98,120 @@ export async function validateLink(
97
98
  }
98
99
  }
99
100
 
101
+ /**
102
+ * Convert a resolution failure kind to a broken_file ValidationIssue. Returns
103
+ * null for `resolved` (caller continues) and `anchor_only` (defensive no-op —
104
+ * the parser classifies anchor-only hrefs as 'anchor', not 'local_file').
105
+ */
106
+ export function resolutionFailureIssue(
107
+ resolved: ReturnType<typeof resolveLocalHref>,
108
+ link: ResourceLink,
109
+ sourceFilePath: string,
110
+ ): ValidationIssue | null {
111
+ if (resolved.kind === 'absolute_no_root') {
112
+ return {
113
+ resourcePath: sourceFilePath,
114
+ line: link.line,
115
+ type: 'broken_file',
116
+ link: link.href,
117
+ message:
118
+ `Absolute-path link "${link.href}" requires a configured projectRoot; ` +
119
+ `none was provided. Configure vibe-agent-toolkit.config.yaml or run ` +
120
+ `from within a git repository.`,
121
+ suggestion:
122
+ 'Rewrite as a source-relative link, or run from a directory with a config or git ancestor.',
123
+ };
124
+ }
125
+
126
+ if (resolved.kind === 'absolute_escapes_root') {
127
+ return {
128
+ resourcePath: sourceFilePath,
129
+ line: link.line,
130
+ type: 'broken_file',
131
+ link: link.href,
132
+ message: `Absolute-path link "${link.href}" escapes the project root via path traversal.`,
133
+ suggestion: '',
134
+ };
135
+ }
136
+
137
+ return null;
138
+ }
139
+
140
+ /**
141
+ * Convert a non-existent file result into a broken_file ValidationIssue.
142
+ * Returns null when the file exists.
143
+ */
144
+ export function fileExistenceIssue(
145
+ fileResult: { exists: boolean; resolvedPath: string; actualName?: string },
146
+ link: ResourceLink,
147
+ sourceFilePath: string,
148
+ ): ValidationIssue | null {
149
+ if (fileResult.exists) return null;
150
+
151
+ if (fileResult.actualName) {
152
+ const expectedName = path.basename(fileResult.resolvedPath);
153
+ return {
154
+ resourcePath: sourceFilePath,
155
+ line: link.line,
156
+ type: 'broken_file',
157
+ link: link.href,
158
+ 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.`,
159
+ suggestion: `Use "${fileResult.actualName}" instead of "${expectedName}"`,
160
+ };
161
+ }
162
+
163
+ return {
164
+ resourcePath: sourceFilePath,
165
+ line: link.line,
166
+ type: 'broken_file',
167
+ link: link.href,
168
+ message: `File not found: ${fileResult.resolvedPath}`,
169
+ suggestion: '',
170
+ };
171
+ }
172
+
173
+ /**
174
+ * Check git-ignore safety: a non-ignored source file must not link to a
175
+ * gitignored target. Returns a ValidationIssue when this rule is violated,
176
+ * null otherwise (including when checks are disabled or out of scope).
177
+ */
178
+ export function gitIgnoreSafetyIssue(
179
+ link: ResourceLink,
180
+ sourceFilePath: string,
181
+ resolvedTarget: string,
182
+ options: ValidateLinkOptions | undefined,
183
+ ): ValidationIssue | null {
184
+ if (
185
+ options?.skipGitIgnoreCheck === true ||
186
+ options?.projectRoot === undefined ||
187
+ !isWithinProject(resolvedTarget, options.projectRoot)
188
+ ) {
189
+ return null;
190
+ }
191
+
192
+ // Prefer the O(1) active-set lookup on the shared GitTracker (no spawn).
193
+ // isIgnoredByActiveSet falls back internally to isIgnored for paths outside
194
+ // the project root, so this is safe for the rare out-of-project case.
195
+ // When no tracker is threaded in, fall back to isGitIgnored (one-off spawn).
196
+ const sourceIsIgnored = options.gitTracker
197
+ ? options.gitTracker.isIgnoredByActiveSet(sourceFilePath)
198
+ : isGitIgnored(sourceFilePath, options.projectRoot);
199
+ const targetIsIgnored = options.gitTracker
200
+ ? options.gitTracker.isIgnoredByActiveSet(resolvedTarget)
201
+ : isGitIgnored(resolvedTarget, options.projectRoot);
202
+
203
+ if (sourceIsIgnored || !targetIsIgnored) return null;
204
+
205
+ return {
206
+ resourcePath: sourceFilePath,
207
+ line: link.line,
208
+ type: 'link_to_gitignored',
209
+ link: link.href,
210
+ message: `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.`,
211
+ suggestion: '',
212
+ };
213
+ }
214
+
100
215
  /**
101
216
  * Validate a local file link (with optional anchor).
102
217
  */
@@ -106,85 +221,41 @@ async function validateLocalFileLink(
106
221
  headingsByFile: Map<string, HeadingNode[]>,
107
222
  options?: ValidateLinkOptions
108
223
  ): Promise<ValidationIssue | null> {
109
- // Extract file path and anchor from href
110
- const [filePath, anchor] = splitHrefAnchor(link.href);
224
+ const resolved = resolveLocalHref(link.href, sourceFilePath, options?.projectRoot);
111
225
 
112
- // Validate the file exists
113
- const fileResult = await validateLocalFile(filePath, sourceFilePath);
226
+ if (resolved.kind !== 'resolved') {
227
+ // anchor_only null no-op; absolute_no_root / absolute_escapes_root → broken_file.
228
+ return resolutionFailureIssue(resolved, link, sourceFilePath);
229
+ }
114
230
 
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
- }
231
+ const fileResult = await validateResolvedFile(resolved.resolvedPath);
232
+ const notFound = fileExistenceIssue(fileResult, link, sourceFilePath);
233
+ if (notFound) return notFound;
128
234
 
235
+ if (fileResult.isDirectory) {
129
236
  return {
130
237
  resourcePath: sourceFilePath,
131
238
  line: link.line,
132
239
  type: 'broken_file',
133
240
  link: link.href,
134
- message: `File not found: ${fileResult.resolvedPath}`,
135
- suggestion: '',
241
+ message: `Link target is a directory: ${fileResult.resolvedPath}`,
242
+ suggestion:
243
+ 'Link to a file inside the directory (e.g., README.md or index.md), or fix the link to point at the intended file.',
136
244
  };
137
245
  }
138
246
 
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
- }
171
- }
172
-
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
- );
247
+ const gitIgnoreIssue = gitIgnoreSafetyIssue(link, sourceFilePath, fileResult.resolvedPath, options);
248
+ if (gitIgnoreIssue) return gitIgnoreIssue;
180
249
 
250
+ if (resolved.anchor) {
251
+ const anchorValid = await validateAnchor(resolved.anchor, fileResult.resolvedPath, headingsByFile);
181
252
  if (!anchorValid) {
182
253
  return {
183
254
  resourcePath: sourceFilePath,
184
255
  line: link.line,
185
256
  type: 'broken_anchor',
186
257
  link: link.href,
187
- message: `Anchor not found: #${anchor} in ${fileResult.resolvedPath}`,
258
+ message: `Anchor not found: #${resolved.anchor} in ${fileResult.resolvedPath}`,
188
259
  suggestion: '',
189
260
  };
190
261
  }
@@ -223,37 +294,31 @@ async function validateAnchorLink(
223
294
 
224
295
 
225
296
  /**
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
297
+ * Verify that the resolved filesystem path exists with the correct case.
231
298
  *
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
- * ```
299
+ * @param resolvedPath - Absolute filesystem path produced by {@link resolveLocalHref}.
300
+ * @returns Object with exists flag, the path, and optional case-mismatch info.
241
301
  */
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
302
+ async function validateResolvedFile(
303
+ resolvedPath: string,
304
+ ): Promise<{ exists: boolean; resolvedPath: string; actualName?: string; isDirectory: boolean }> {
251
305
  const verification = await verifyCaseSensitiveFilename(resolvedPath);
252
306
 
253
- // Build result with optional actualName (only include if present)
254
- const result: { exists: boolean; resolvedPath: string; actualName?: string } = {
307
+ let isDirectory = false;
308
+ if (verification.exists) {
309
+ try {
310
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- resolvedPath validated by verifyCaseSensitiveFilename
311
+ const stats = await fs.stat(resolvedPath);
312
+ isDirectory = stats.isDirectory();
313
+ } catch {
314
+ // Stat failed after verifyCaseSensitiveFilename said exists — treat as file.
315
+ }
316
+ }
317
+
318
+ const result: { exists: boolean; resolvedPath: string; actualName?: string; isDirectory: boolean } = {
255
319
  exists: verification.exists,
256
320
  resolvedPath,
321
+ isDirectory,
257
322
  };
258
323
 
259
324
  if (verification.actualName) {