@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.
- package/README.md +255 -17
- package/dist/collection-matcher.d.ts +63 -0
- package/dist/collection-matcher.d.ts.map +1 -0
- package/dist/collection-matcher.js +127 -0
- package/dist/collection-matcher.js.map +1 -0
- package/dist/config-parser.d.ts +63 -0
- package/dist/config-parser.d.ts.map +1 -0
- package/dist/config-parser.js +113 -0
- package/dist/config-parser.js.map +1 -0
- package/dist/frontmatter-validator.d.ts +50 -0
- package/dist/frontmatter-validator.d.ts.map +1 -0
- package/dist/frontmatter-validator.js +238 -0
- package/dist/frontmatter-validator.js.map +1 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/link-parser.d.ts +2 -0
- package/dist/link-parser.d.ts.map +1 -1
- package/dist/link-parser.js +41 -0
- package/dist/link-parser.js.map +1 -1
- package/dist/link-validator.d.ts +25 -3
- package/dist/link-validator.d.ts.map +1 -1
- package/dist/link-validator.js +52 -40
- package/dist/link-validator.js.map +1 -1
- package/dist/multi-schema-validator.d.ts +42 -0
- package/dist/multi-schema-validator.d.ts.map +1 -0
- package/dist/multi-schema-validator.js +107 -0
- package/dist/multi-schema-validator.js.map +1 -0
- package/dist/pattern-expander.d.ts +63 -0
- package/dist/pattern-expander.d.ts.map +1 -0
- package/dist/pattern-expander.js +93 -0
- package/dist/pattern-expander.js.map +1 -0
- package/dist/resource-registry.d.ts +104 -8
- package/dist/resource-registry.d.ts.map +1 -1
- package/dist/resource-registry.js +230 -30
- package/dist/resource-registry.js.map +1 -1
- package/dist/schema-assignment.d.ts +49 -0
- package/dist/schema-assignment.d.ts.map +1 -0
- package/dist/schema-assignment.js +95 -0
- package/dist/schema-assignment.js.map +1 -0
- package/dist/schemas/project-config.d.ts +254 -0
- package/dist/schemas/project-config.d.ts.map +1 -0
- package/dist/schemas/project-config.js +57 -0
- package/dist/schemas/project-config.js.map +1 -0
- package/dist/schemas/resource-metadata.d.ts +10 -1
- package/dist/schemas/resource-metadata.d.ts.map +1 -1
- package/dist/schemas/resource-metadata.js +7 -1
- package/dist/schemas/resource-metadata.js.map +1 -1
- package/dist/schemas/validation-result.d.ts +9 -24
- package/dist/schemas/validation-result.d.ts.map +1 -1
- package/dist/schemas/validation-result.js +11 -18
- package/dist/schemas/validation-result.js.map +1 -1
- package/dist/types/resource-parser.d.ts +53 -0
- package/dist/types/resource-parser.d.ts.map +1 -0
- package/dist/types/resource-parser.js +233 -0
- package/dist/types/resource-parser.js.map +1 -0
- package/dist/types/resource-path-utils.d.ts +43 -0
- package/dist/types/resource-path-utils.d.ts.map +1 -0
- package/dist/types/resource-path-utils.js +89 -0
- package/dist/types/resource-path-utils.js.map +1 -0
- package/dist/types/resources.d.ts +140 -0
- package/dist/types/resources.d.ts.map +1 -0
- package/dist/types/resources.js +58 -0
- package/dist/types/resources.js.map +1 -0
- package/dist/types.d.ts +14 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +17 -0
- package/dist/types.js.map +1 -1
- package/dist/utils.d.ts +18 -0
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +39 -0
- package/dist/utils.js.map +1 -1
- package/package.json +5 -2
- package/src/collection-matcher.ts +148 -0
- package/src/config-parser.ts +125 -0
- package/src/frontmatter-validator.ts +279 -0
- package/src/index.ts +10 -2
- package/src/link-parser.ts +50 -0
- package/src/link-validator.ts +70 -43
- package/src/multi-schema-validator.ts +128 -0
- package/src/pattern-expander.ts +100 -0
- package/src/resource-registry.ts +347 -34
- package/src/schema-assignment.ts +119 -0
- package/src/schemas/project-config.ts +71 -0
- package/src/schemas/resource-metadata.ts +7 -1
- package/src/schemas/validation-result.ts +11 -21
- package/src/types/resource-parser.ts +302 -0
- package/src/types/resource-path-utils.ts +102 -0
- package/src/types/resources.ts +211 -0
- package/src/types.ts +89 -1
- 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.
|
package/src/link-parser.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/link-validator.ts
CHANGED
|
@@ -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 {
|
|
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 -
|
|
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: '
|
|
119
|
+
suggestion: '',
|
|
105
120
|
};
|
|
106
121
|
}
|
|
107
122
|
|
|
108
|
-
// Check
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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: '
|
|
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: '
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
209
|
-
const gitignored = exists && isGitignored(resolvedPath);
|
|
210
|
-
|
|
211
|
-
return { exists, resolvedPath, isGitignored: gitignored };
|
|
238
|
+
return { exists, resolvedPath };
|
|
212
239
|
}
|
|
213
240
|
|
|
214
241
|
/**
|