@vibe-agent-toolkit/resources 0.1.2 → 0.1.4

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 (92) hide show
  1. package/README.md +255 -17
  2. package/dist/collection-matcher.d.ts +63 -0
  3. package/dist/collection-matcher.d.ts.map +1 -0
  4. package/dist/collection-matcher.js +127 -0
  5. package/dist/collection-matcher.js.map +1 -0
  6. package/dist/config-parser.d.ts +63 -0
  7. package/dist/config-parser.d.ts.map +1 -0
  8. package/dist/config-parser.js +113 -0
  9. package/dist/config-parser.js.map +1 -0
  10. package/dist/frontmatter-validator.d.ts +50 -0
  11. package/dist/frontmatter-validator.d.ts.map +1 -0
  12. package/dist/frontmatter-validator.js +238 -0
  13. package/dist/frontmatter-validator.js.map +1 -0
  14. package/dist/index.d.ts +4 -3
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +3 -1
  17. package/dist/index.js.map +1 -1
  18. package/dist/link-parser.d.ts +2 -0
  19. package/dist/link-parser.d.ts.map +1 -1
  20. package/dist/link-parser.js +41 -0
  21. package/dist/link-parser.js.map +1 -1
  22. package/dist/link-validator.d.ts +25 -3
  23. package/dist/link-validator.d.ts.map +1 -1
  24. package/dist/link-validator.js +52 -40
  25. package/dist/link-validator.js.map +1 -1
  26. package/dist/multi-schema-validator.d.ts +42 -0
  27. package/dist/multi-schema-validator.d.ts.map +1 -0
  28. package/dist/multi-schema-validator.js +107 -0
  29. package/dist/multi-schema-validator.js.map +1 -0
  30. package/dist/pattern-expander.d.ts +63 -0
  31. package/dist/pattern-expander.d.ts.map +1 -0
  32. package/dist/pattern-expander.js +93 -0
  33. package/dist/pattern-expander.js.map +1 -0
  34. package/dist/resource-registry.d.ts +104 -8
  35. package/dist/resource-registry.d.ts.map +1 -1
  36. package/dist/resource-registry.js +230 -30
  37. package/dist/resource-registry.js.map +1 -1
  38. package/dist/schema-assignment.d.ts +49 -0
  39. package/dist/schema-assignment.d.ts.map +1 -0
  40. package/dist/schema-assignment.js +95 -0
  41. package/dist/schema-assignment.js.map +1 -0
  42. package/dist/schemas/project-config.d.ts +254 -0
  43. package/dist/schemas/project-config.d.ts.map +1 -0
  44. package/dist/schemas/project-config.js +57 -0
  45. package/dist/schemas/project-config.js.map +1 -0
  46. package/dist/schemas/resource-metadata.d.ts +10 -1
  47. package/dist/schemas/resource-metadata.d.ts.map +1 -1
  48. package/dist/schemas/resource-metadata.js +7 -1
  49. package/dist/schemas/resource-metadata.js.map +1 -1
  50. package/dist/schemas/validation-result.d.ts +9 -24
  51. package/dist/schemas/validation-result.d.ts.map +1 -1
  52. package/dist/schemas/validation-result.js +11 -18
  53. package/dist/schemas/validation-result.js.map +1 -1
  54. package/dist/types/resource-parser.d.ts +53 -0
  55. package/dist/types/resource-parser.d.ts.map +1 -0
  56. package/dist/types/resource-parser.js +233 -0
  57. package/dist/types/resource-parser.js.map +1 -0
  58. package/dist/types/resource-path-utils.d.ts +43 -0
  59. package/dist/types/resource-path-utils.d.ts.map +1 -0
  60. package/dist/types/resource-path-utils.js +89 -0
  61. package/dist/types/resource-path-utils.js.map +1 -0
  62. package/dist/types/resources.d.ts +140 -0
  63. package/dist/types/resources.d.ts.map +1 -0
  64. package/dist/types/resources.js +58 -0
  65. package/dist/types/resources.js.map +1 -0
  66. package/dist/types.d.ts +14 -1
  67. package/dist/types.d.ts.map +1 -1
  68. package/dist/types.js +17 -0
  69. package/dist/types.js.map +1 -1
  70. package/dist/utils.d.ts +18 -0
  71. package/dist/utils.d.ts.map +1 -1
  72. package/dist/utils.js +39 -0
  73. package/dist/utils.js.map +1 -1
  74. package/package.json +5 -2
  75. package/src/collection-matcher.ts +148 -0
  76. package/src/config-parser.ts +125 -0
  77. package/src/frontmatter-validator.ts +279 -0
  78. package/src/index.ts +10 -2
  79. package/src/link-parser.ts +50 -0
  80. package/src/link-validator.ts +70 -43
  81. package/src/multi-schema-validator.ts +128 -0
  82. package/src/pattern-expander.ts +100 -0
  83. package/src/resource-registry.ts +347 -34
  84. package/src/schema-assignment.ts +119 -0
  85. package/src/schemas/project-config.ts +71 -0
  86. package/src/schemas/resource-metadata.ts +7 -1
  87. package/src/schemas/validation-result.ts +11 -21
  88. package/src/types/resource-parser.ts +302 -0
  89. package/src/types/resource-path-utils.ts +102 -0
  90. package/src/types/resources.ts +211 -0
  91. package/src/types.ts +89 -1
  92. package/src/utils.ts +43 -0
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Frontmatter validation using JSON Schema.
3
+ *
4
+ * IMPORTANT: This module uses AJV specifically for validating arbitrary
5
+ * user-provided JSON Schemas against frontmatter data. For all TypeScript
6
+ * validation and internal schemas, use Zod instead.
7
+ *
8
+ * Why AJV here?
9
+ * - Users provide standard JSON Schema files for frontmatter validation
10
+ * - AJV is the industry standard JSON Schema validator
11
+ * - Zod is for TypeScript type safety + runtime validation
12
+ *
13
+ * This is the ONLY place in the codebase that should use AJV.
14
+ */
15
+
16
+ import { Ajv } from 'ajv';
17
+
18
+ import type { ValidationMode } from './schemas/project-config.js';
19
+ import type { ValidationIssue } from './schemas/validation-result.js';
20
+
21
+ /**
22
+ * Validate frontmatter against a JSON Schema.
23
+ *
24
+ * Behavior:
25
+ * - Missing frontmatter: Error only if schema has required fields
26
+ * - Extra fields: Allowed by default (unless schema sets additionalProperties: false)
27
+ * - Type mismatches: Always reported as errors
28
+ * - Permissive mode: Ignores additionalProperties: false (allows schema layering)
29
+ *
30
+ * @param frontmatter - Parsed frontmatter object (or undefined if no frontmatter)
31
+ * @param schema - JSON Schema object
32
+ * @param resourcePath - File path for error reporting
33
+ * @param mode - Validation mode: 'strict' (default) or 'permissive'
34
+ * @param schemaPath - Path to schema file (for error context)
35
+ * @returns Array of validation issues (empty if valid)
36
+ *
37
+ * @example
38
+ * ```typescript
39
+ * const schema = {
40
+ * type: 'object',
41
+ * required: ['title'],
42
+ * properties: { title: { type: 'string' } }
43
+ * };
44
+ * const issues = validateFrontmatter(
45
+ * frontmatter,
46
+ * schema,
47
+ * '/docs/guide.md',
48
+ * 'strict',
49
+ * '/schema.json'
50
+ * );
51
+ * ```
52
+ */
53
+ export function validateFrontmatter(
54
+ frontmatter: Record<string, unknown> | undefined,
55
+ schema: object,
56
+ resourcePath: string,
57
+ mode: ValidationMode = 'strict',
58
+ schemaPath?: string
59
+ ): ValidationIssue[] {
60
+ const issues: ValidationIssue[] = [];
61
+
62
+ // In permissive mode, clone schema and set additionalProperties: true
63
+ let effectiveSchema = schema;
64
+ if (mode === 'permissive') {
65
+ effectiveSchema = makeSchemaPermissive(schema);
66
+ }
67
+
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
73
+ });
74
+
75
+ const validate = ajv.compile(effectiveSchema);
76
+
77
+ // Case 1: No frontmatter present
78
+ if (!frontmatter) {
79
+ // Check if schema requires any fields
80
+ const schemaRequires = (schema as { required?: string[] }).required;
81
+ if (schemaRequires && schemaRequires.length > 0) {
82
+ // Build context message with schema path and validation mode
83
+ const schemaContext = schemaPath ? ` (schema: ${schemaPath}, mode: ${mode})` : '';
84
+ const requiredFields = schemaRequires.join(', ');
85
+
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
+ });
93
+ }
94
+ return issues;
95
+ }
96
+
97
+ // Case 2: Frontmatter present, validate against schema
98
+ const valid = validate(frontmatter);
99
+
100
+ if (valid || !validate.errors) {
101
+ return issues;
102
+ }
103
+
104
+ // Format validation errors with helpful messages
105
+ for (const error of validate.errors) {
106
+ 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
+ });
114
+ }
115
+
116
+ return issues;
117
+ }
118
+
119
+ /**
120
+ * Format AJV validation error into helpful message
121
+ *
122
+ * @param error - AJV error object
123
+ * @param frontmatter - Frontmatter data
124
+ * @param mode - Validation mode (strict/permissive)
125
+ * @param schemaPath - Path to schema file (for error context)
126
+ * @returns Formatted error message
127
+ */
128
+ function formatValidationError(
129
+ error: { instancePath: string; keyword: string; message?: string; params?: Record<string, unknown> },
130
+ frontmatter: Record<string, unknown>,
131
+ mode: ValidationMode,
132
+ schemaPath?: string
133
+ ): string {
134
+ const field = error.instancePath.replace(/^\//, '') || 'root';
135
+ const fieldName = field === 'root' ? '(root)' : field;
136
+
137
+ // Get the actual invalid value
138
+ const actualValue = field === 'root' ? frontmatter : getNestedValue(frontmatter, field);
139
+ const actualValueStr = actualValue === undefined ? 'undefined' : JSON.stringify(actualValue);
140
+
141
+ let message = `Frontmatter validation failed for '${fieldName}' (got: ${actualValueStr})`;
142
+
143
+ // Add context based on error type
144
+ if (error.keyword === 'enum' && error.params?.['allowedValues']) {
145
+ const allowed = (error.params['allowedValues'] as unknown[])
146
+ .map((v: unknown) => JSON.stringify(v))
147
+ .join(', ');
148
+ message += `. Expected one of: ${allowed}`;
149
+ } else if (error.keyword === 'pattern' && error.params?.['pattern']) {
150
+ // Convert to string directly in template to avoid SonarQube warning
151
+ message += `. Must match pattern: ${JSON.stringify(error.params['pattern'])}`;
152
+ } else if (error.keyword === 'type' && error.params?.['type']) {
153
+ // Convert to string directly in template to avoid SonarQube warning
154
+ message += `. Expected type: ${JSON.stringify(error.params['type'])}`;
155
+ } else if (error.keyword === 'required' && error.params?.['missingProperty']) {
156
+ // Convert to string directly in template to avoid SonarQube warning
157
+ message += `. Missing required property: ${JSON.stringify(error.params['missingProperty'])}`;
158
+ } else if (error.message) {
159
+ message += `. ${error.message}`;
160
+ }
161
+
162
+ // Add schema context to help users understand the requirement
163
+ const schemaContext = schemaPath ? ` (schema: ${schemaPath}, mode: ${mode})` : '';
164
+ message += schemaContext;
165
+
166
+ return message;
167
+ }
168
+
169
+ /**
170
+ * Get nested value from object using dot-separated path
171
+ *
172
+ * @param obj - Object to get value from
173
+ * @param path - Dot-separated path (e.g., "user.name")
174
+ * @returns Value at path or undefined
175
+ */
176
+ function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
177
+ // eslint-disable-next-line local/no-hardcoded-path-split -- JSON Schema instancePath uses forward slashes (not file paths)
178
+ const parts = path.split('/').filter(Boolean);
179
+ let current: unknown = obj;
180
+
181
+ for (const part of parts) {
182
+ if (typeof current !== 'object' || current === null) {
183
+ return undefined;
184
+ }
185
+ current = (current as Record<string, unknown>)[part];
186
+ }
187
+
188
+ return current;
189
+ }
190
+
191
+ /**
192
+ * Clone schema and recursively set additionalProperties: true
193
+ *
194
+ * Used in permissive mode to allow extra fields for schema layering.
195
+ * Handles nested objects and properties recursively.
196
+ *
197
+ * @param schema - Original JSON Schema
198
+ * @returns Cloned schema with additionalProperties: true
199
+ */
200
+ function makeSchemaPermissive(schema: object): object {
201
+ // Deep clone to avoid mutating original
202
+ const cloned = structuredClone(schema) as Record<string, unknown>;
203
+
204
+ // Recursively process schema to set additionalProperties: true
205
+ processSchemaRecursively(cloned);
206
+
207
+ return cloned;
208
+ }
209
+
210
+ /**
211
+ * Recursively process schema object to set additionalProperties: true
212
+ *
213
+ * @param obj - Schema object or nested schema fragment
214
+ */
215
+ function processSchemaRecursively(obj: Record<string, unknown>): void {
216
+ // eslint-disable-next-line sonarjs/different-types-comparison
217
+ if (typeof obj !== 'object' || obj === null) {
218
+ return;
219
+ }
220
+
221
+ // Set additionalProperties: true if this is an object schema
222
+ const typeValue = obj['type'];
223
+ const isObjectType = typeValue === 'object';
224
+ const hasProperties = 'properties' in obj;
225
+
226
+ if (isObjectType || hasProperties) {
227
+ obj['additionalProperties'] = true;
228
+ }
229
+
230
+ // Recurse into properties
231
+ processSchemaProperties(obj);
232
+
233
+ // Recurse into nested schemas (allOf, anyOf, oneOf, items)
234
+ processNestedSchemas(obj);
235
+ }
236
+
237
+ /**
238
+ * Process properties field of a schema
239
+ *
240
+ * @param obj - Schema object
241
+ */
242
+ function processSchemaProperties(obj: Record<string, unknown>): void {
243
+ if (obj['properties'] === undefined || typeof obj['properties'] !== 'object') {
244
+ return;
245
+ }
246
+
247
+ const properties = obj['properties'] as Record<string, unknown>;
248
+ for (const value of Object.values(properties)) {
249
+ if (typeof value === 'object' && value !== null) {
250
+ processSchemaRecursively(value as Record<string, unknown>);
251
+ }
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Process nested schema keywords (allOf, anyOf, oneOf, items)
257
+ *
258
+ * @param obj - Schema object
259
+ */
260
+ function processNestedSchemas(obj: Record<string, unknown>): void {
261
+ const nestedKeys = ['allOf', 'anyOf', 'oneOf', 'items'];
262
+
263
+ for (const key of nestedKeys) {
264
+ const value = obj[key];
265
+ if (value === undefined) {
266
+ continue;
267
+ }
268
+
269
+ if (Array.isArray(value)) {
270
+ for (const item of value) {
271
+ if (typeof item === 'object' && item !== null) {
272
+ processSchemaRecursively(item as Record<string, unknown>);
273
+ }
274
+ }
275
+ } else if (typeof value === 'object' && value !== null) {
276
+ processSchemaRecursively(value as Record<string, unknown>);
277
+ }
278
+ }
279
+ }
package/src/index.ts CHANGED
@@ -32,6 +32,8 @@ export {
32
32
  type CrawlOptions,
33
33
  type ResourceRegistryOptions,
34
34
  type RegistryStats,
35
+ type CollectionStats,
36
+ type CollectionStat,
35
37
  } from './resource-registry.js';
36
38
 
37
39
  // Export ResourceQuery for lazy evaluation and filtering
@@ -49,9 +51,13 @@ export type {
49
51
  HeadingNode,
50
52
  ResourceLink,
51
53
  ResourceMetadata,
52
- ValidationSeverity,
53
54
  ValidationIssue,
54
55
  ValidationResult,
56
+ ProjectConfig,
57
+ ResourcesConfig,
58
+ CollectionConfig,
59
+ CollectionValidation,
60
+ ValidationMode,
55
61
  } from './types.js';
56
62
 
57
63
  // Export schemas for external use (e.g., JSON Schema generation, runtime validation)
@@ -63,7 +69,6 @@ export {
63
69
  } from './schemas/resource-metadata.js';
64
70
 
65
71
  export {
66
- ValidationSeveritySchema,
67
72
  ValidationIssueSchema,
68
73
  ValidationResultSchema,
69
74
  } from './schemas/validation-result.js';
@@ -71,5 +76,8 @@ export {
71
76
  // Export parser interface for advanced use cases
72
77
  export { parseMarkdown, type ParseResult } from './link-parser.js';
73
78
 
79
+ // Export frontmatter validation
80
+ export { validateFrontmatter } from './frontmatter-validator.js';
81
+
74
82
  // Note: link-parser and link-validator internals are NOT exported
75
83
  // They are implementation details. Users should use ResourceRegistry API.
@@ -11,6 +11,7 @@
11
11
 
12
12
  import { readFile, stat } from 'node:fs/promises';
13
13
 
14
+ import * as yaml from 'js-yaml';
14
15
  import type { Heading, Link, LinkReference, Root } from 'mdast';
15
16
  import remarkFrontmatter from 'remark-frontmatter';
16
17
  import remarkGfm from 'remark-gfm';
@@ -26,6 +27,8 @@ import type { HeadingNode, LinkType, ResourceLink } from './types.js';
26
27
  export interface ParseResult {
27
28
  links: ResourceLink[];
28
29
  headings: HeadingNode[];
30
+ frontmatter?: Record<string, unknown>;
31
+ frontmatterError?: string;
29
32
  content: string;
30
33
  sizeBytes: number;
31
34
  estimatedTokenCount: number;
@@ -71,9 +74,16 @@ export async function parseMarkdown(filePath: string): Promise<ParseResult> {
71
74
  // Extract headings with tree structure
72
75
  const headings = extractHeadings(tree);
73
76
 
77
+ // Extract frontmatter
78
+ const { frontmatter, error: frontmatterError } = extractFrontmatter(tree);
79
+
80
+ // With exactOptionalPropertyTypes: true, we must conditionally include the property
81
+ // rather than assigning undefined to it
74
82
  return {
75
83
  links,
76
84
  headings,
85
+ ...(frontmatter !== undefined && { frontmatter }),
86
+ ...(frontmatterError !== undefined && { frontmatterError }),
77
87
  content,
78
88
  sizeBytes,
79
89
  estimatedTokenCount,
@@ -369,3 +379,43 @@ function cleanupEmptyChildren(headings: HeadingNode[]): void {
369
379
  }
370
380
  }
371
381
  }
382
+
383
+ /**
384
+ * Extract and parse frontmatter from the markdown AST.
385
+ *
386
+ * Uses remark-frontmatter which creates 'yaml' nodes for frontmatter blocks.
387
+ * Parses YAML content and returns as plain object.
388
+ *
389
+ * @param tree - Markdown AST from unified/remark
390
+ * @returns Object with parsed frontmatter and any error message
391
+ */
392
+ function extractFrontmatter(tree: Root): {
393
+ frontmatter?: Record<string, unknown>;
394
+ error?: string;
395
+ } {
396
+ let frontmatterData: Record<string, unknown> | undefined;
397
+ let errorMessage: string | undefined;
398
+
399
+ visit(tree, 'yaml', (node: { value: string }) => {
400
+ if (node.value.trim() === '') {
401
+ // Empty frontmatter block
402
+ return;
403
+ }
404
+
405
+ try {
406
+ const parsed = yaml.load(node.value);
407
+ if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
408
+ frontmatterData = parsed as Record<string, unknown>;
409
+ }
410
+ } catch (error) {
411
+ // Capture YAML parsing error for validation reporting
412
+ errorMessage = error instanceof Error ? error.message : String(error);
413
+ }
414
+ });
415
+
416
+ // With exactOptionalPropertyTypes: true, we must conditionally include properties
417
+ return {
418
+ ...(frontmatterData !== undefined && { frontmatter: frontmatterData }),
419
+ ...(errorMessage !== undefined && { error: errorMessage }),
420
+ };
421
+ }
@@ -2,21 +2,39 @@
2
2
  * Link validation for markdown resources.
3
3
  *
4
4
  * Validates different types of links:
5
- * - local_file: Checks if file exists, validates anchors if present
5
+ * - local_file: Checks if file exists, validates anchors if present, checks git-ignore safety
6
6
  * - anchor: Validates heading exists in current or target file
7
7
  * - external: Returns info (not validated)
8
8
  * - email: Returns null (valid by default)
9
9
  * - unknown: Returns warning
10
+ *
11
+ * Git-ignore safety (Phase 3):
12
+ * - Non-ignored files cannot link to ignored files (error: link_to_gitignored)
13
+ * - Ignored files CAN link to ignored files (no error)
14
+ * - Ignored files CAN link to non-ignored files (no error)
15
+ * - External resources (outside project) skip git-ignore checks
10
16
  */
11
17
 
12
18
  import fs from 'node:fs/promises';
13
19
  import path from 'node:path';
14
20
 
15
- import { isGitignored } from '@vibe-agent-toolkit/utils';
21
+ import { isGitIgnored, type GitTracker } from '@vibe-agent-toolkit/utils';
16
22
 
17
23
  import type { ValidationIssue } from './schemas/validation-result.js';
18
24
  import type { HeadingNode, ResourceLink } from './types.js';
19
- import { splitHrefAnchor } from './utils.js';
25
+ import { isWithinProject, splitHrefAnchor } from './utils.js';
26
+
27
+ /**
28
+ * Options for link validation.
29
+ */
30
+ export interface ValidateLinkOptions {
31
+ /** Project root directory (for git-ignore checking) */
32
+ projectRoot?: string;
33
+ /** Skip git-ignore checks (optimization when checkGitIgnored is false) */
34
+ skipGitIgnoreCheck?: boolean;
35
+ /** Git tracker for efficient git-ignore checking (optional, improves performance) */
36
+ gitTracker?: GitTracker;
37
+ }
20
38
 
21
39
  /**
22
40
  * Validate a single link in a markdown resource.
@@ -24,11 +42,15 @@ import { splitHrefAnchor } from './utils.js';
24
42
  * @param link - The link to validate
25
43
  * @param sourceFilePath - Absolute path to the file containing the link
26
44
  * @param headingsByFile - Map of file paths to their heading trees
45
+ * @param options - Validation options (projectRoot, skipGitIgnoreCheck)
27
46
  * @returns ValidationIssue if link is broken, null if valid
28
47
  *
29
48
  * @example
30
49
  * ```typescript
31
- * const issue = await validateLink(link, '/project/docs/guide.md', headingsMap);
50
+ * const issue = await validateLink(link, '/project/docs/guide.md', headingsMap, {
51
+ * projectRoot: '/project',
52
+ * skipGitIgnoreCheck: false
53
+ * });
32
54
  * if (issue) {
33
55
  * console.log(`${issue.severity}: ${issue.message}`);
34
56
  * }
@@ -37,25 +59,19 @@ import { splitHrefAnchor } from './utils.js';
37
59
  export async function validateLink(
38
60
  link: ResourceLink,
39
61
  sourceFilePath: string,
40
- headingsByFile: Map<string, HeadingNode[]>
62
+ headingsByFile: Map<string, HeadingNode[]>,
63
+ options?: ValidateLinkOptions
41
64
  ): Promise<ValidationIssue | null> {
42
65
  switch (link.type) {
43
66
  case 'local_file':
44
- return await validateLocalFileLink(link, sourceFilePath, headingsByFile);
67
+ return await validateLocalFileLink(link, sourceFilePath, headingsByFile, options);
45
68
 
46
69
  case 'anchor':
47
70
  return await validateAnchorLink(link, sourceFilePath, headingsByFile);
48
71
 
49
72
  case 'external':
50
- // External URLs are not validated - return info
51
- return {
52
- severity: 'info',
53
- resourcePath: sourceFilePath,
54
- line: link.line,
55
- type: 'external_url',
56
- link: link.href,
57
- message: 'External URL not validated',
58
- };
73
+ // External URLs are not validated - don't report them
74
+ return null;
59
75
 
60
76
  case 'email':
61
77
  // Email links are valid by default
@@ -63,7 +79,6 @@ export async function validateLink(
63
79
 
64
80
  case 'unknown':
65
81
  return {
66
- severity: 'warning',
67
82
  resourcePath: sourceFilePath,
68
83
  line: link.line,
69
84
  type: 'unknown_link',
@@ -85,7 +100,8 @@ export async function validateLink(
85
100
  async function validateLocalFileLink(
86
101
  link: ResourceLink,
87
102
  sourceFilePath: string,
88
- headingsByFile: Map<string, HeadingNode[]>
103
+ headingsByFile: Map<string, HeadingNode[]>,
104
+ options?: ValidateLinkOptions
89
105
  ): Promise<ValidationIssue | null> {
90
106
  // Extract file path and anchor from href
91
107
  const [filePath, anchor] = splitHrefAnchor(link.href);
@@ -95,28 +111,44 @@ async function validateLocalFileLink(
95
111
 
96
112
  if (!fileResult.exists) {
97
113
  return {
98
- severity: 'error',
99
114
  resourcePath: sourceFilePath,
100
115
  line: link.line,
101
116
  type: 'broken_file',
102
117
  link: link.href,
103
118
  message: `File not found: ${fileResult.resolvedPath}`,
104
- suggestion: 'Check that the file path is correct and the file exists',
119
+ suggestion: '',
105
120
  };
106
121
  }
107
122
 
108
- // Check if the file is gitignored
109
- if (fileResult.isGitignored) {
110
- return {
111
- severity: 'error',
112
- resourcePath: sourceFilePath,
113
- line: link.line,
114
- type: 'broken_file',
115
- link: link.href,
116
- message: `File is gitignored: ${fileResult.resolvedPath}`,
117
- suggestion:
118
- 'Gitignored files are local-only and will not exist in the repository. Remove this link or unignore the target file.',
119
- };
123
+ // Check git-ignore safety (Phase 3)
124
+ // Only check if:
125
+ // 1. skipGitIgnoreCheck is NOT true
126
+ // 2. projectRoot is provided
127
+ // 3. target is within project (skip for external resources)
128
+ if (
129
+ options?.skipGitIgnoreCheck !== true &&
130
+ options?.projectRoot !== undefined &&
131
+ isWithinProject(fileResult.resolvedPath, options.projectRoot)
132
+ ) {
133
+ // Use GitTracker if available (cached), otherwise fall back to isGitIgnored
134
+ const sourceIsIgnored = options.gitTracker
135
+ ? options.gitTracker.isIgnored(sourceFilePath)
136
+ : isGitIgnored(sourceFilePath, options.projectRoot);
137
+ const targetIsIgnored = options.gitTracker
138
+ ? options.gitTracker.isIgnored(fileResult.resolvedPath)
139
+ : isGitIgnored(fileResult.resolvedPath, options.projectRoot);
140
+
141
+ // Error ONLY if: source is NOT ignored AND target IS ignored
142
+ if (!sourceIsIgnored && targetIsIgnored) {
143
+ return {
144
+ resourcePath: sourceFilePath,
145
+ line: link.line,
146
+ type: 'link_to_gitignored',
147
+ link: link.href,
148
+ 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.`,
149
+ suggestion: '',
150
+ };
151
+ }
120
152
  }
121
153
 
122
154
  // If there's an anchor, validate it too
@@ -129,13 +161,12 @@ async function validateLocalFileLink(
129
161
 
130
162
  if (!anchorValid) {
131
163
  return {
132
- severity: 'error',
133
164
  resourcePath: sourceFilePath,
134
165
  line: link.line,
135
166
  type: 'broken_anchor',
136
167
  link: link.href,
137
168
  message: `Anchor not found: #${anchor} in ${fileResult.resolvedPath}`,
138
- suggestion: 'Check that the heading exists in the target file',
169
+ suggestion: '',
139
170
  };
140
171
  }
141
172
  }
@@ -159,13 +190,12 @@ async function validateAnchorLink(
159
190
 
160
191
  if (!isValid) {
161
192
  return {
162
- severity: 'error',
163
193
  resourcePath: sourceFilePath,
164
194
  line: link.line,
165
195
  type: 'broken_anchor',
166
196
  link: link.href,
167
197
  message: `Anchor not found: ${link.href}`,
168
- suggestion: 'Check that the heading exists in this file',
198
+ suggestion: '',
169
199
  };
170
200
  }
171
201
 
@@ -174,16 +204,16 @@ async function validateAnchorLink(
174
204
 
175
205
 
176
206
  /**
177
- * Validate that a local file exists and is not gitignored.
207
+ * Validate that a local file exists.
178
208
  *
179
209
  * @param href - The href to the file (relative or absolute)
180
210
  * @param sourceFilePath - Absolute path to the source file
181
- * @returns Object with exists flag, resolved absolute path, and gitignored flag
211
+ * @returns Object with exists flag and resolved absolute path
182
212
  *
183
213
  * @example
184
214
  * ```typescript
185
215
  * const result = await validateLocalFile('./docs/guide.md', '/project/README.md');
186
- * if (result.exists && !result.isGitignored) {
216
+ * if (result.exists) {
187
217
  * console.log('File exists at:', result.resolvedPath);
188
218
  * }
189
219
  * ```
@@ -191,7 +221,7 @@ async function validateAnchorLink(
191
221
  async function validateLocalFile(
192
222
  href: string,
193
223
  sourceFilePath: string
194
- ): Promise<{ exists: boolean; resolvedPath: string; isGitignored: boolean }> {
224
+ ): Promise<{ exists: boolean; resolvedPath: string }> {
195
225
  // Resolve the path relative to the source file's directory
196
226
  const sourceDir = path.dirname(sourceFilePath);
197
227
  const resolvedPath = path.resolve(sourceDir, href);
@@ -205,10 +235,7 @@ async function validateLocalFile(
205
235
  exists = false;
206
236
  }
207
237
 
208
- // Check if file is gitignored (only if it exists)
209
- const gitignored = exists && isGitignored(resolvedPath);
210
-
211
- return { exists, resolvedPath, isGitignored: gitignored };
238
+ return { exists, resolvedPath };
212
239
  }
213
240
 
214
241
  /**