@vibe-agent-toolkit/resources 0.1.35 → 0.1.36

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.
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Frontmatter URI-reference link validation.
3
+ *
4
+ * For every value in `frontmatter` sitting at a JSON Schema position with a
5
+ * URI-family `format`, classify the value and run local-file references
6
+ * through the existing `validateLink` engine. Returns issues with
7
+ * frontmatter-specific type codes plus a list of external URLs the registry
8
+ * can fold into its existing external URL collection.
9
+ *
10
+ * Type mapping:
11
+ * broken_file -> frontmatter_link_broken
12
+ * broken_anchor -> frontmatter_anchor_missing
13
+ * link_to_gitignored -> frontmatter_link_to_gitignored
14
+ * unknown_link -> frontmatter_unknown_link
15
+ *
16
+ * Skipped (no issue, no external):
17
+ * email (mailto:)
18
+ * anchor-only (validated as anchor in current file via validateLink)
19
+ */
20
+
21
+ import { classifyLink } from './link-parser.js';
22
+ import { validateLink, type ValidateLinkOptions } from './link-validator.js';
23
+ import { walkFrontmatterUriReferences } from './schema-uri-walker.js';
24
+ import type { HeadingNode, ResourceLink, ValidationIssue } from './types.js';
25
+
26
+ /** A frontmatter-sourced external URL captured for downstream health checking. */
27
+ export interface FrontmatterExternalUrl {
28
+ url: string;
29
+ sourcePath: string;
30
+ dottedPath: string;
31
+ }
32
+
33
+ export interface FrontmatterLinkValidationResult {
34
+ issues: ValidationIssue[];
35
+ externalUrls: FrontmatterExternalUrl[];
36
+ }
37
+
38
+ /**
39
+ * Validate every URI-family frontmatter value against the file system.
40
+ *
41
+ * @param frontmatter - Parsed frontmatter (or undefined)
42
+ * @param schema - JSON Schema for the collection
43
+ * @param sourceFilePath - Absolute path to the source file
44
+ * @param headingsByFile - Heading trees (for anchor validation)
45
+ * @param options - Same shape as validateLink (projectRoot, gitTracker, ...)
46
+ */
47
+ export async function validateFrontmatterLinks(
48
+ frontmatter: Record<string, unknown> | undefined,
49
+ schema: object,
50
+ sourceFilePath: string,
51
+ headingsByFile: Map<string, HeadingNode[]>,
52
+ options?: ValidateLinkOptions,
53
+ ): Promise<FrontmatterLinkValidationResult> {
54
+ if (!frontmatter) return { issues: [], externalUrls: [] };
55
+
56
+ const captures = walkFrontmatterUriReferences(frontmatter, schema);
57
+ if (captures.length === 0) return { issues: [], externalUrls: [] };
58
+
59
+ const issues: ValidationIssue[] = [];
60
+ const externalUrls: FrontmatterExternalUrl[] = [];
61
+
62
+ for (const capture of captures) {
63
+ const linkType = classifyLink(capture.value);
64
+
65
+ if (linkType === 'external') {
66
+ externalUrls.push({
67
+ url: capture.value,
68
+ sourcePath: sourceFilePath,
69
+ dottedPath: capture.dottedPath,
70
+ });
71
+ continue;
72
+ }
73
+ if (linkType === 'email') continue;
74
+
75
+ const syntheticLink: ResourceLink = {
76
+ text: capture.dottedPath,
77
+ href: capture.value,
78
+ type: linkType,
79
+ line: 1, // Frontmatter per-field line numbers are post-v1.
80
+ };
81
+
82
+ const issue = await validateLink(syntheticLink, sourceFilePath, headingsByFile, options);
83
+ if (!issue) continue;
84
+
85
+ issues.push(rewriteIssue(issue, capture.dottedPath));
86
+ }
87
+
88
+ return { issues, externalUrls };
89
+ }
90
+
91
+ function rewriteIssue(issue: ValidationIssue, dottedPath: string): ValidationIssue {
92
+ return {
93
+ ...issue,
94
+ type: mapType(issue.type),
95
+ message: `field \`${dottedPath}\`: ${issue.message}`,
96
+ };
97
+ }
98
+
99
+ function mapType(originalType: string): string {
100
+ switch (originalType) {
101
+ case 'broken_file':
102
+ return 'frontmatter_link_broken';
103
+ case 'broken_anchor':
104
+ return 'frontmatter_anchor_missing';
105
+ case 'link_to_gitignored':
106
+ return 'frontmatter_link_to_gitignored';
107
+ case 'unknown_link':
108
+ return 'frontmatter_unknown_link';
109
+ default:
110
+ return originalType;
111
+ }
112
+ }
@@ -169,7 +169,11 @@ function extractLinkText(node: Link | LinkReference): string {
169
169
  }
170
170
 
171
171
  /**
172
- * Classify a link based on its href.
172
+ * Classify a link based on its href shape.
173
+ *
174
+ * Public so frontmatter-link validation can reuse identical URI classification
175
+ * logic (markdown links and frontmatter URI-reference values share one
176
+ * classifier).
173
177
  *
174
178
  * @param href - The href attribute from the link
175
179
  * @returns Classified link type
@@ -183,7 +187,7 @@ function extractLinkText(node: Link | LinkReference): string {
183
187
  * classifyLink('./file.md#anchor') // 'local_file'
184
188
  * ```
185
189
  */
186
- function classifyLink(href: string): LinkType {
190
+ export function classifyLink(href: string): LinkType {
187
191
  if (href.startsWith('http://') || href.startsWith('https://')) {
188
192
  return 'external';
189
193
  }
@@ -11,14 +11,18 @@
11
11
  import type fs from 'node:fs/promises';
12
12
  import path from 'node:path';
13
13
 
14
- import { crawlDirectory, type CrawlOptions as UtilsCrawlOptions, type GitTracker, normalizedTmpdir, toForwardSlash, safePath } from '@vibe-agent-toolkit/utils';
14
+ import { crawlDirectory, type CrawlOptions as UtilsCrawlOptions, type GitTracker, normalizedTmpdir, resolveAssetReference, safePath, toForwardSlash } from '@vibe-agent-toolkit/utils';
15
15
 
16
16
  import { calculateChecksum } from './checksum.js';
17
17
  import { getCollectionsForFile } from './collection-matcher.js';
18
18
  import { ExternalLinkValidator } from './external-link-validator.js';
19
+ import {
20
+ validateFrontmatterLinks,
21
+ type FrontmatterExternalUrl,
22
+ } from './frontmatter-link-validator.js';
19
23
  import { validateFrontmatter } from './frontmatter-validator.js';
20
24
  import { parseMarkdown } from './link-parser.js';
21
- import { validateLink } from './link-validator.js';
25
+ import { validateLink, type ValidateLinkOptions } from './link-validator.js';
22
26
  import type { ResourceCollectionInterface } from './resource-collection-interface.js';
23
27
  import type { SHA256 } from './schemas/checksum.js';
24
28
  import type { ProjectConfig } from './schemas/project-config.js';
@@ -150,6 +154,13 @@ export class ResourceRegistry implements ResourceCollectionInterface {
150
154
  private readonly resourcesByName: Map<string, ResourceMetadata[]> = new Map();
151
155
  private readonly resourcesByChecksum: Map<SHA256, ResourceMetadata[]> = new Map();
152
156
 
157
+ /**
158
+ * Frontmatter-sourced external URLs keyed by resource absolute path.
159
+ * Populated during collection-schema validation; consumed by
160
+ * collectExternalUrls so the URLs feed into the existing health-check pass.
161
+ */
162
+ private readonly frontmatterExternalUrlsByResource: Map<string, FrontmatterExternalUrl[]> = new Map();
163
+
153
164
  constructor(options?: ResourceRegistryOptions) {
154
165
  if (options?.baseDir !== undefined) {
155
166
  this.baseDir = options.baseDir;
@@ -479,7 +490,10 @@ export class ResourceRegistry implements ResourceCollectionInterface {
479
490
  * Validate frontmatter against per-collection schemas.
480
491
  * @private
481
492
  */
482
- private async validateCollectionFrontmatter(): Promise<ValidationIssue[]> {
493
+ private async validateCollectionFrontmatter(
494
+ headingsByFile: Map<string, HeadingNode[]>,
495
+ skipGitIgnoreCheck: boolean,
496
+ ): Promise<ValidationIssue[]> {
483
497
  const issues: ValidationIssue[] = [];
484
498
 
485
499
  // Skip if no config
@@ -498,7 +512,9 @@ export class ResourceRegistry implements ResourceCollectionInterface {
498
512
  // Validate against each collection's schema
499
513
  const collectionIssues = await this.validateResourceCollectionSchemas(
500
514
  resource,
501
- fsPromises
515
+ fsPromises,
516
+ headingsByFile,
517
+ skipGitIgnoreCheck,
502
518
  );
503
519
  issues.push(...collectionIssues);
504
520
  }
@@ -512,7 +528,9 @@ export class ResourceRegistry implements ResourceCollectionInterface {
512
528
  */
513
529
  private async validateResourceCollectionSchemas(
514
530
  resource: ResourceMetadata,
515
- fsModule: typeof fs
531
+ fsModule: typeof fs,
532
+ headingsByFile: Map<string, HeadingNode[]>,
533
+ skipGitIgnoreCheck: boolean,
516
534
  ): Promise<ValidationIssue[]> {
517
535
  const issues: ValidationIssue[] = [];
518
536
 
@@ -531,7 +549,9 @@ export class ResourceRegistry implements ResourceCollectionInterface {
531
549
  const collectionIssues = await this.validateAgainstCollectionSchema(
532
550
  resource,
533
551
  collection.validation,
534
- fsModule
552
+ fsModule,
553
+ headingsByFile,
554
+ skipGitIgnoreCheck,
535
555
  );
536
556
  issues.push(...collectionIssues);
537
557
  }
@@ -546,15 +566,17 @@ export class ResourceRegistry implements ResourceCollectionInterface {
546
566
  private async validateAgainstCollectionSchema(
547
567
  resource: ResourceMetadata,
548
568
  validation: NonNullable<NonNullable<ProjectConfig['resources']>['collections']>[string]['validation'],
549
- fsModule: typeof fs
569
+ fsModule: typeof fs,
570
+ headingsByFile: Map<string, HeadingNode[]>,
571
+ skipGitIgnoreCheck: boolean,
550
572
  ): Promise<ValidationIssue[]> {
551
573
  if (!validation?.frontmatterSchema) {
552
574
  return [];
553
575
  }
554
576
 
555
- const schemaPath = safePath.resolve(
577
+ const schemaPath = resolveAssetReference(
578
+ validation.frontmatterSchema,
556
579
  this.baseDir ?? process.cwd(),
557
- validation.frontmatterSchema
558
580
  );
559
581
 
560
582
  try {
@@ -564,14 +586,41 @@ export class ResourceRegistry implements ResourceCollectionInterface {
564
586
  // Determine validation mode (default to permissive)
565
587
  const mode = validation.mode ?? 'permissive';
566
588
 
567
- // Validate frontmatter
568
- return validateFrontmatter(
589
+ // Validate frontmatter against JSON Schema
590
+ const issues = validateFrontmatter(
569
591
  resource.frontmatter,
570
592
  schema,
571
593
  resource.filePath,
572
594
  mode,
573
- schemaPath
595
+ schemaPath,
574
596
  );
597
+
598
+ // New: walk URI-family frontmatter values. Default-on; explicit `false` disables.
599
+ if (validation.checkFrontmatterLinks !== false && resource.frontmatter) {
600
+ const linkOptions: ValidateLinkOptions = this.baseDir === undefined
601
+ ? { skipGitIgnoreCheck }
602
+ : {
603
+ projectRoot: this.baseDir,
604
+ skipGitIgnoreCheck,
605
+ ...(this.gitTracker !== undefined && { gitTracker: this.gitTracker }),
606
+ };
607
+
608
+ const { issues: linkIssues, externalUrls } = await validateFrontmatterLinks(
609
+ resource.frontmatter,
610
+ schema,
611
+ resource.filePath,
612
+ headingsByFile,
613
+ linkOptions,
614
+ );
615
+ issues.push(...linkIssues);
616
+
617
+ if (externalUrls.length > 0) {
618
+ const prior = this.frontmatterExternalUrlsByResource.get(resource.filePath) ?? [];
619
+ this.frontmatterExternalUrlsByResource.set(resource.filePath, [...prior, ...externalUrls]);
620
+ }
621
+ }
622
+
623
+ return issues;
575
624
  } catch (error) {
576
625
  // Handle missing or invalid schema files gracefully
577
626
  const errorMessage = error instanceof Error ? error.message : String(error);
@@ -622,6 +671,9 @@ export class ResourceRegistry implements ResourceCollectionInterface {
622
671
  // Build headings map for validation
623
672
  const headingsByFile = this.buildHeadingsByFileMap();
624
673
 
674
+ // Reset frontmatter external URL state for this validation run
675
+ this.frontmatterExternalUrlsByResource.clear();
676
+
625
677
  // Collect all validation issues
626
678
  const issues: ValidationIssue[] = [];
627
679
 
@@ -636,7 +688,10 @@ export class ResourceRegistry implements ResourceCollectionInterface {
636
688
  issues.push(...linkIssues);
637
689
 
638
690
  // Per-collection frontmatter validation
639
- const collectionFrontmatterIssues = await this.validateCollectionFrontmatter();
691
+ const collectionFrontmatterIssues = await this.validateCollectionFrontmatter(
692
+ headingsByFile,
693
+ options?.skipGitIgnoreCheck ?? false,
694
+ );
640
695
  issues.push(...collectionFrontmatterIssues);
641
696
 
642
697
  // Global frontmatter validation (if schema provided)
@@ -743,6 +798,15 @@ export class ResourceRegistry implements ResourceCollectionInterface {
743
798
  }
744
799
  }
745
800
 
801
+ // Merge frontmatter-sourced external URLs from collection validation
802
+ for (const [resourcePath, urls] of this.frontmatterExternalUrlsByResource) {
803
+ for (const fmUrl of urls) {
804
+ const locations = urlsToValidate.get(fmUrl.url) ?? [];
805
+ locations.push({ resourcePath });
806
+ urlsToValidate.set(fmUrl.url, locations);
807
+ }
808
+ }
809
+
746
810
  return urlsToValidate;
747
811
  }
748
812
 
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Pure schema/data traversal that captures every string value sitting at a
3
+ * JSON Schema position whose `format` is in the URI family.
4
+ *
5
+ * Handles:
6
+ * - `properties` (recursion)
7
+ * - `items` (single-schema and tuple)
8
+ * - `oneOf` / `anyOf` / `allOf` (every branch walked) AND sibling keywords
9
+ * in the same node (JSON Schema AND semantics)
10
+ * - `$ref` (resolved against schema root via JSON Pointer; cycle-protected)
11
+ * - `definitions` and `$defs` as ref targets
12
+ *
13
+ * Does NOT handle (intentional, see spec §"Non-Goals"):
14
+ * - `if`/`then`/`else`, `dependentSchemas`
15
+ * - `patternProperties`, schema-form `additionalProperties`
16
+ * - `prefixItems` (JSON Schema 2020-12)
17
+ *
18
+ * Captures are deduplicated by `(pointer, value)` before return so that
19
+ * multiple matching composite branches don't produce duplicate issues.
20
+ *
21
+ * No I/O. No side effects.
22
+ */
23
+
24
+ import { decodeJsonPointerSegment, encodeJsonPointerSegment, formatJsonPointerAsDotted } from './utils.js';
25
+
26
+ const URI_FAMILY_FORMATS = new Set<UriFamilyFormat>([
27
+ 'uri-reference',
28
+ 'uri',
29
+ 'iri-reference',
30
+ 'iri',
31
+ ]);
32
+
33
+ export type UriFamilyFormat = 'uri' | 'uri-reference' | 'iri' | 'iri-reference';
34
+
35
+ export interface FrontmatterUriCapture {
36
+ /** Raw string value from frontmatter */
37
+ value: string;
38
+ /** RFC 6901 JSON Pointer to the value within the frontmatter document */
39
+ pointer: string;
40
+ /** Developer-friendly dotted form (e.g., adr-citations[0].adr) */
41
+ dottedPath: string;
42
+ /** The URI-family format keyword present on the schema node */
43
+ format: UriFamilyFormat;
44
+ }
45
+
46
+ interface SchemaNode {
47
+ type?: string | string[];
48
+ format?: string;
49
+ properties?: Record<string, SchemaNode>;
50
+ items?: SchemaNode | SchemaNode[];
51
+ oneOf?: SchemaNode[];
52
+ anyOf?: SchemaNode[];
53
+ allOf?: SchemaNode[];
54
+ $ref?: string;
55
+ // $defs / definitions / etc. are arbitrary root-level keys reached via $ref.
56
+ [key: string]: unknown;
57
+ }
58
+
59
+ /**
60
+ * Walk a frontmatter document against a JSON Schema and return every value
61
+ * whose schema position has a URI-family `format` keyword.
62
+ */
63
+ export function walkFrontmatterUriReferences(
64
+ data: unknown,
65
+ schema: object,
66
+ ): FrontmatterUriCapture[] {
67
+ if (data === undefined || data === null) return [];
68
+ const captures: FrontmatterUriCapture[] = [];
69
+ walk(data, schema as SchemaNode, schema as SchemaNode, [], new Set<string>(), captures);
70
+ return dedupe(captures);
71
+ }
72
+
73
+ function walkComposites(
74
+ data: unknown,
75
+ node: SchemaNode,
76
+ root: SchemaNode,
77
+ pointerSegments: string[],
78
+ visitedRefs: Set<string>,
79
+ captures: FrontmatterUriCapture[],
80
+ ): void {
81
+ for (const branchList of [node.oneOf, node.anyOf, node.allOf]) {
82
+ if (Array.isArray(branchList)) {
83
+ for (const branch of branchList) {
84
+ walk(data, branch, root, pointerSegments, visitedRefs, captures);
85
+ }
86
+ }
87
+ }
88
+ }
89
+
90
+ function walkProperties(
91
+ data: unknown,
92
+ node: SchemaNode,
93
+ root: SchemaNode,
94
+ pointerSegments: string[],
95
+ visitedRefs: Set<string>,
96
+ captures: FrontmatterUriCapture[],
97
+ ): void {
98
+ if (!node.properties || data === null || typeof data !== 'object' || Array.isArray(data)) return;
99
+ const dataObj = data as Record<string, unknown>;
100
+ for (const [key, propSchema] of Object.entries(node.properties)) {
101
+ if (key in dataObj) {
102
+ walk(
103
+ dataObj[key],
104
+ propSchema,
105
+ root,
106
+ [...pointerSegments, encodeJsonPointerSegment(key)],
107
+ visitedRefs,
108
+ captures,
109
+ );
110
+ }
111
+ }
112
+ }
113
+
114
+ function walkItems(
115
+ data: unknown,
116
+ node: SchemaNode,
117
+ root: SchemaNode,
118
+ pointerSegments: string[],
119
+ visitedRefs: Set<string>,
120
+ captures: FrontmatterUriCapture[],
121
+ ): void {
122
+ if (!node.items || !Array.isArray(data)) return;
123
+ if (Array.isArray(node.items)) {
124
+ const tupleSchemas = node.items;
125
+ for (const [i, itemValue] of data.entries()) {
126
+ if (i >= tupleSchemas.length) break;
127
+ walk(itemValue, tupleSchemas[i] as SchemaNode, root, [...pointerSegments, String(i)], visitedRefs, captures);
128
+ }
129
+ } else {
130
+ for (const [i, itemValue] of data.entries()) {
131
+ walk(itemValue, node.items, root, [...pointerSegments, String(i)], visitedRefs, captures);
132
+ }
133
+ }
134
+ }
135
+
136
+ function walk(
137
+ data: unknown,
138
+ node: SchemaNode,
139
+ root: SchemaNode,
140
+ pointerSegments: string[],
141
+ visitedRefs: Set<string>,
142
+ captures: FrontmatterUriCapture[],
143
+ ): void {
144
+ if (!node || typeof node !== 'object') return;
145
+
146
+ // Resolve $ref against schema root. Cycle protection: skip if already on the
147
+ // recursion stack. Pop after recursion.
148
+ if (typeof node.$ref === 'string') {
149
+ if (visitedRefs.has(node.$ref)) return;
150
+ const resolved = resolveRef(node.$ref, root);
151
+ if (!resolved) return;
152
+ visitedRefs.add(node.$ref);
153
+ walk(data, resolved, root, pointerSegments, visitedRefs, captures);
154
+ visitedRefs.delete(node.$ref);
155
+ return;
156
+ }
157
+
158
+ // Composite schemas: walk every branch. CRITICAL: do NOT short-circuit;
159
+ // sibling `properties`/`items` are AND-combined with the composite.
160
+ walkComposites(data, node, root, pointerSegments, visitedRefs, captures);
161
+
162
+ // URI-family format leaf
163
+ if (
164
+ typeof node.format === 'string' &&
165
+ URI_FAMILY_FORMATS.has(node.format as UriFamilyFormat) &&
166
+ typeof data === 'string'
167
+ ) {
168
+ const pointer = pointerSegments.length === 0 ? '' : '/' + pointerSegments.join('/');
169
+ captures.push({
170
+ value: data,
171
+ pointer,
172
+ dottedPath: formatJsonPointerAsDotted(pointer),
173
+ format: node.format as UriFamilyFormat,
174
+ });
175
+ // Fall through — schemas with both `format` and sibling object/array
176
+ // structure are unusual but possible; let recursion proceed.
177
+ }
178
+
179
+ // Object recursion
180
+ walkProperties(data, node, root, pointerSegments, visitedRefs, captures);
181
+
182
+ // Array recursion
183
+ walkItems(data, node, root, pointerSegments, visitedRefs, captures);
184
+
185
+ // Intentionally NOT handled: if/then/else, dependentSchemas, patternProperties,
186
+ // schema-form additionalProperties, prefixItems (2020-12). See spec §"Non-Goals".
187
+ }
188
+
189
+ /**
190
+ * Resolve a local $ref (e.g., "#/$defs/Foo") against the schema root using a
191
+ * generic JSON Pointer walk. Returns null for unresolvable refs or non-local
192
+ * refs (no cross-file support in v1).
193
+ */
194
+ function resolveRef(ref: string, root: SchemaNode): SchemaNode | null {
195
+ if (!ref.startsWith('#/')) return null;
196
+ // eslint-disable-next-line local/no-hardcoded-path-split -- RFC 6901 JSON Pointer segment splitting, not a file path
197
+ const segments = ref.slice(2).split('/').map(decodeJsonPointerSegment);
198
+ let cursor: unknown = root;
199
+ for (const seg of segments) {
200
+ if (cursor === null || typeof cursor !== 'object') return null;
201
+ cursor = (cursor as Record<string, unknown>)[seg];
202
+ if (cursor === undefined) return null;
203
+ }
204
+ return (cursor as SchemaNode) ?? null;
205
+ }
206
+
207
+ /**
208
+ * Remove duplicate captures by (pointer, value). Multiple matching branches
209
+ * of `oneOf`/`anyOf`/`allOf` can produce duplicates; users should see one
210
+ * issue per field, not one per branch.
211
+ */
212
+ function dedupe(captures: FrontmatterUriCapture[]): FrontmatterUriCapture[] {
213
+ const seen = new Set<string>();
214
+ const out: FrontmatterUriCapture[] = [];
215
+ for (const c of captures) {
216
+ const key = c.pointer + ' ' + c.value;
217
+ if (seen.has(key)) continue;
218
+ seen.add(key);
219
+ out.push(c);
220
+ }
221
+ return out;
222
+ }
@@ -61,6 +61,8 @@ export const CollectionValidationSchema = z.object({
61
61
  .describe('Whether to validate external URL links (default: false)'),
62
62
  checkGitIgnored: z.boolean().optional()
63
63
  .describe('Whether to check if non-ignored files link to git-ignored files (default: true)'),
64
+ checkFrontmatterLinks: z.boolean().optional()
65
+ .describe('Whether to validate frontmatter values at JSON Schema positions with a URI-family format (default: true). Set to false to disable for this collection.'),
64
66
  externalUrls: ExternalUrlValidationSchema.optional()
65
67
  .describe('External URL validation configuration'),
66
68
  }).describe('Validation configuration for a collection');
package/src/types.ts CHANGED
@@ -110,6 +110,7 @@ export {
110
110
  // Link validation
111
111
  export type { ValidateLinkOptions } from './link-validator.js';
112
112
  export { validateLink } from './link-validator.js';
113
+ export { classifyLink } from './link-parser.js';
113
114
 
114
115
  // Schema assignment
115
116
  export {
@@ -124,3 +125,10 @@ export {
124
125
  hasSchemaErrors,
125
126
  validateFrontmatterMultiSchema,
126
127
  } from './multi-schema-validator.js';
128
+
129
+ // Frontmatter link validation
130
+ export type {
131
+ FrontmatterExternalUrl,
132
+ FrontmatterLinkValidationResult,
133
+ } from './frontmatter-link-validator.js';
134
+ export { validateFrontmatterLinks } from './frontmatter-link-validator.js';
package/src/utils.ts CHANGED
@@ -156,3 +156,57 @@ export function isWithinProject(filePath: string, projectRoot: string): boolean
156
156
  // /project-other starting with /project
157
157
  return normalizedFile.startsWith(normalizedRoot + '/') || normalizedFile === normalizedRoot;
158
158
  }
159
+
160
+ /**
161
+ * Escape a property name as a JSON Pointer segment per RFC 6901:
162
+ * `~` -> `~0`, `/` -> `~1`. Order matters (escape `~` first).
163
+ */
164
+ export function encodeJsonPointerSegment(name: string): string {
165
+ return name.replaceAll('~', '~0').replaceAll('/', '~1');
166
+ }
167
+
168
+ /**
169
+ * Reverse RFC 6901 escapes: `~1` -> `/`, `~0` -> `~`. Order matters
170
+ * (unescape `~1` first).
171
+ */
172
+ export function decodeJsonPointerSegment(segment: string): string {
173
+ return segment.replaceAll('~1', '/').replaceAll('~0', '~');
174
+ }
175
+
176
+ /**
177
+ * Format a JSON Pointer (RFC 6901) as developer-friendly dotted notation.
178
+ *
179
+ * Numeric segments become bracketed array indices (`0` → `[0]`); non-numeric
180
+ * segments are dot-joined. Reverses RFC 6901 escapes inside segments.
181
+ *
182
+ * @example
183
+ * formatJsonPointerAsDotted('/adr-citations/0/adr') // 'adr-citations[0].adr'
184
+ * formatJsonPointerAsDotted('') // ''
185
+ */
186
+ export function formatJsonPointerAsDotted(pointer: string): string {
187
+ if (pointer === '') return '';
188
+ // eslint-disable-next-line local/no-hardcoded-path-split -- JSON Pointer RFC 6901 delimiter, not a file path
189
+ const segments = pointer.slice(1).split('/').map(decodeJsonPointerSegment);
190
+
191
+ let out = '';
192
+ for (const seg of segments) {
193
+ if (isCanonicalArrayIndex(seg)) {
194
+ out += `[${seg}]`;
195
+ } else {
196
+ out += out === '' ? seg : `.${seg}`;
197
+ }
198
+ }
199
+ return out;
200
+ }
201
+
202
+ function isCanonicalArrayIndex(s: string): boolean {
203
+ // Canonical integer per RFC 6901 §4 + JSON canonical form: no leading zeros
204
+ // except for "0" itself.
205
+ if (s === '') return false;
206
+ if (s === '0') return true;
207
+ if (s.startsWith('0')) return false;
208
+ for (const ch of s) {
209
+ if (ch < '0' || ch > '9') return false;
210
+ }
211
+ return true;
212
+ }