@vibe-agent-toolkit/resources 0.1.38 → 0.1.39-rc.10

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 (75) hide show
  1. package/README.md +33 -24
  2. package/dist/frontmatter-link-validator.d.ts +9 -9
  3. package/dist/frontmatter-link-validator.d.ts.map +1 -1
  4. package/dist/frontmatter-link-validator.js +28 -27
  5. package/dist/frontmatter-link-validator.js.map +1 -1
  6. package/dist/frontmatter-validator.d.ts +3 -2
  7. package/dist/frontmatter-validator.d.ts.map +1 -1
  8. package/dist/frontmatter-validator.js +8 -14
  9. package/dist/frontmatter-validator.js.map +1 -1
  10. package/dist/html-link-parser.d.ts +47 -0
  11. package/dist/html-link-parser.d.ts.map +1 -0
  12. package/dist/html-link-parser.js +111 -0
  13. package/dist/html-link-parser.js.map +1 -0
  14. package/dist/html-transform.d.ts +45 -0
  15. package/dist/html-transform.d.ts.map +1 -0
  16. package/dist/html-transform.js +116 -0
  17. package/dist/html-transform.js.map +1 -0
  18. package/dist/index.d.ts +5 -2
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +4 -2
  21. package/dist/index.js.map +1 -1
  22. package/dist/link-parser.d.ts +26 -2
  23. package/dist/link-parser.d.ts.map +1 -1
  24. package/dist/link-parser.js +40 -6
  25. package/dist/link-parser.js.map +1 -1
  26. package/dist/link-validator.d.ts +53 -6
  27. package/dist/link-validator.d.ts.map +1 -1
  28. package/dist/link-validator.js +100 -131
  29. package/dist/link-validator.js.map +1 -1
  30. package/dist/multi-schema-validator.d.ts.map +1 -1
  31. package/dist/multi-schema-validator.js +6 -8
  32. package/dist/multi-schema-validator.js.map +1 -1
  33. package/dist/resource-registry.d.ts +56 -13
  34. package/dist/resource-registry.d.ts.map +1 -1
  35. package/dist/resource-registry.js +165 -66
  36. package/dist/resource-registry.js.map +1 -1
  37. package/dist/schemas/link-auth.d.ts +382 -0
  38. package/dist/schemas/link-auth.d.ts.map +1 -0
  39. package/dist/schemas/link-auth.js +124 -0
  40. package/dist/schemas/link-auth.js.map +1 -0
  41. package/dist/schemas/project-config.d.ts +3277 -171
  42. package/dist/schemas/project-config.d.ts.map +1 -1
  43. package/dist/schemas/project-config.js +68 -0
  44. package/dist/schemas/project-config.js.map +1 -1
  45. package/dist/schemas/resource-metadata.d.ts +46 -10
  46. package/dist/schemas/resource-metadata.d.ts.map +1 -1
  47. package/dist/schemas/resource-metadata.js +14 -1
  48. package/dist/schemas/resource-metadata.js.map +1 -1
  49. package/dist/schemas/validation-result.d.ts +36 -57
  50. package/dist/schemas/validation-result.d.ts.map +1 -1
  51. package/dist/schemas/validation-result.js +5 -27
  52. package/dist/schemas/validation-result.js.map +1 -1
  53. package/dist/types/resources.d.ts +1 -1
  54. package/dist/types/resources.d.ts.map +1 -1
  55. package/dist/types/resources.js.map +1 -1
  56. package/dist/utils.d.ts +10 -0
  57. package/dist/utils.d.ts.map +1 -1
  58. package/dist/utils.js +14 -0
  59. package/dist/utils.js.map +1 -1
  60. package/package.json +4 -3
  61. package/src/frontmatter-link-validator.ts +28 -30
  62. package/src/frontmatter-validator.ts +19 -16
  63. package/src/html-link-parser.ts +140 -0
  64. package/src/html-transform.ts +157 -0
  65. package/src/index.ts +10 -1
  66. package/src/link-parser.ts +60 -11
  67. package/src/link-validator.ts +175 -145
  68. package/src/multi-schema-validator.ts +10 -8
  69. package/src/resource-registry.ts +205 -72
  70. package/src/schemas/link-auth.ts +146 -0
  71. package/src/schemas/project-config.ts +76 -0
  72. package/src/schemas/resource-metadata.ts +17 -1
  73. package/src/schemas/validation-result.ts +5 -29
  74. package/src/types/resources.ts +2 -1
  75. package/src/utils.ts +15 -0
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Markdown link parser and analyzer.
2
+ * Markdown link parser and shared resource-parsing types.
3
3
  *
4
4
  * Parses markdown files to extract:
5
5
  * - Links (regular, reference-style, autolinks)
@@ -7,6 +7,10 @@
7
7
  * - File size and token estimates
8
8
  *
9
9
  * Uses unified/remark for robust markdown parsing with GFM support.
10
+ *
11
+ * Also defines the format-neutral `ParseResult` contract shared with the HTML
12
+ * parser (`html-link-parser.ts`). The `HtmlParseError` shape is Zod-sourced
13
+ * from `schemas/resource-metadata.ts` (single source of truth).
10
14
  */
11
15
 
12
16
  import { readFile, stat } from 'node:fs/promises';
@@ -20,10 +24,11 @@ import { unified } from 'unified';
20
24
  import { visit } from 'unist-util-visit';
21
25
  import * as yaml from 'yaml';
22
26
 
27
+ import type { HtmlParseError } from './schemas/resource-metadata.js';
23
28
  import type { HeadingNode, LinkType, ResourceLink } from './types.js';
24
29
 
25
30
  /**
26
- * Result of parsing a markdown file.
31
+ * Result of parsing a resource file (markdown or HTML).
27
32
  */
28
33
  export interface ParseResult {
29
34
  links: ResourceLink[];
@@ -33,6 +38,10 @@ export interface ParseResult {
33
38
  content: string;
34
39
  sizeBytes: number;
35
40
  estimatedTokenCount: number;
41
+ /** Fragment targets (HTML `id`/`name` attributes). Markdown leaves this undefined. */
42
+ anchors?: string[];
43
+ /** HTML well-formedness diagnostics. Markdown leaves this undefined. */
44
+ parseErrors?: HtmlParseError[];
36
45
  }
37
46
 
38
47
  /**
@@ -185,6 +194,11 @@ function extractLinkText(node: Link | LinkReference): string {
185
194
  * classifyLink('#heading') // 'anchor'
186
195
  * classifyLink('./file.md') // 'local_file'
187
196
  * classifyLink('./file.md#anchor') // 'local_file'
197
+ * classifyLink('docs/') // 'local_directory'
198
+ * classifyLink('./docs/') // 'local_directory'
199
+ * classifyLink('../docs/') // 'local_directory'
200
+ * classifyLink('/docs/') // 'local_directory'
201
+ * classifyLink('https://x.com/docs/') // 'external' (not a local ref)
188
202
  * ```
189
203
  */
190
204
  export function classifyLink(href: string): LinkType {
@@ -202,6 +216,12 @@ export function classifyLink(href: string): LinkType {
202
216
  if (href.includes(':')) {
203
217
  return 'unknown';
204
218
  }
219
+ // Local directory: path component (before any # or ?) ends in '/'.
220
+ // Must come after all protocol guards so external URLs are never reclassified.
221
+ const pathPart = href.split(/[#?]/u)[0] ?? href;
222
+ if (pathPart.endsWith('/')) {
223
+ return 'local_directory';
224
+ }
205
225
  // Links with anchors are still local file links
206
226
  if (href.includes('#')) {
207
227
  return 'local_file';
@@ -242,6 +262,19 @@ export function classifyLink(href: string): LinkType {
242
262
  return 'unknown';
243
263
  }
244
264
 
265
+ /**
266
+ * Returns true for link types that represent local filesystem targets — both
267
+ * regular files and directories. Other packages (e.g. agent-skills walker)
268
+ * import this predicate as the single source of truth for "should we treat
269
+ * this link like a file link during validation/traversal?"
270
+ *
271
+ * @param type - The classified link type
272
+ * @returns `true` for `'local_file'` and `'local_directory'`
273
+ */
274
+ export function isLocalFileLink(type: LinkType): boolean {
275
+ return type === 'local_file' || type === 'local_directory';
276
+ }
277
+
245
278
  /**
246
279
  * Extract headings from the markdown AST and build a nested tree structure.
247
280
  *
@@ -310,29 +343,45 @@ function extractHeadingText(node: Heading): string {
310
343
  return extractTextFromChildren(node.children);
311
344
  }
312
345
 
346
+ /**
347
+ * Inline node shape for heading text extraction. Leaf inline nodes (`text`,
348
+ * `inlineCode`) carry their content in `value`; container inline nodes
349
+ * (`strong`, `emphasis`, `link`, `delete`) carry it in `children`.
350
+ */
351
+ interface InlineNode {
352
+ type: string;
353
+ value?: unknown;
354
+ children?: InlineNode[];
355
+ }
356
+
313
357
  /**
314
358
  * Extract text content from inline children nodes.
315
359
  *
316
- * Handles text nodes, inline code, emphasis, and other inline elements.
360
+ * Handles leaf nodes (`text`, `inlineCode`) and recurses into container inline
361
+ * elements (`strong`, `emphasis`, `link`, `delete`) so that styled headings —
362
+ * e.g. `### **CRITICAL: ...**` — produce the same text (and therefore the same
363
+ * GitHub slug) as their plain-text equivalents. Without recursion, bold/italic
364
+ * headings yielded empty text and bogus slugs, causing false LINK_BROKEN_ANCHOR
365
+ * errors for links targeting them.
317
366
  *
318
367
  * @param children - Array of child nodes or undefined
319
368
  * @returns Concatenated text content
320
369
  */
321
- function extractTextFromChildren(
322
- children: Array<{ type: string; value?: unknown }> | undefined
323
- ): string {
370
+ function extractTextFromChildren(children: InlineNode[] | undefined): string {
324
371
  if (!children || children.length === 0) {
325
372
  return '';
326
373
  }
327
374
 
328
375
  return children
329
376
  .map((child) => {
330
- if (child.type === 'text') {
331
- return child.value as string;
377
+ // Leaf inline nodes (text, inlineCode) hold their content in `value`.
378
+ if (typeof child.value === 'string') {
379
+ return child.value;
332
380
  }
333
- // Handle other inline elements (code, emphasis, etc.)
334
- if ('value' in child) {
335
- return String(child.value);
381
+ // Container inline nodes (strong, emphasis, link, delete) hold text in
382
+ // their children recurse to gather it.
383
+ if (child.children) {
384
+ return extractTextFromChildren(child.children);
336
385
  }
337
386
  return '';
338
387
  })
@@ -18,15 +18,36 @@
18
18
  import fs from 'node:fs/promises';
19
19
  import path from 'node:path';
20
20
 
21
+ import { createRegistryIssue, type ValidationIssue } from '@vibe-agent-toolkit/agent-schema';
21
22
  import {
22
23
  isGitIgnored,
23
24
  type GitTracker,
24
25
  verifyCaseSensitiveFilename,
25
26
  } from '@vibe-agent-toolkit/utils';
26
27
 
27
- import type { ValidationIssue } from './schemas/validation-result.js';
28
- import type { HeadingNode, ResourceLink } from './types.js';
29
- import { isWithinProject, resolveLocalHref } from './utils.js';
28
+ import type { ResourceLink } from './types.js';
29
+ import { isWithinProject, issueLocation, resolveLocalHref } from './utils.js';
30
+
31
+ type LinkIssueExtras = Partial<Pick<ValidationIssue, 'location' | 'line' | 'link' | 'suggestion'>>;
32
+
33
+ /**
34
+ * Build the common `createRegistryIssue` extras for a link issue: relative
35
+ * location, the problematic href, the line (only when defined — required for
36
+ * exactOptionalPropertyTypes), and an optional suggestion.
37
+ */
38
+ function linkExtras(
39
+ link: ResourceLink,
40
+ sourceFilePath: string,
41
+ projectRoot: string | undefined,
42
+ suggestion?: string,
43
+ ): LinkIssueExtras {
44
+ return {
45
+ location: issueLocation(sourceFilePath, projectRoot),
46
+ link: link.href,
47
+ ...(link.line !== undefined && { line: link.line }),
48
+ ...(suggestion !== undefined && { suggestion }),
49
+ };
50
+ }
30
51
 
31
52
  /**
32
53
  * Options for link validation.
@@ -45,7 +66,7 @@ export interface ValidateLinkOptions {
45
66
  *
46
67
  * @param link - The link to validate
47
68
  * @param sourceFilePath - Absolute path to the file containing the link
48
- * @param headingsByFile - Map of file paths to their heading trees
69
+ * @param fragmentsByFile - Fragment index: file path set of valid fragments (markdown slugs + HTML id/name)
49
70
  * @param options - Validation options (projectRoot, skipGitIgnoreCheck)
50
71
  * @returns ValidationIssue if link is broken, null if valid
51
72
  *
@@ -63,15 +84,16 @@ export interface ValidateLinkOptions {
63
84
  export async function validateLink(
64
85
  link: ResourceLink,
65
86
  sourceFilePath: string,
66
- headingsByFile: Map<string, HeadingNode[]>,
87
+ fragmentsByFile: FragmentIndex,
67
88
  options?: ValidateLinkOptions
68
89
  ): Promise<ValidationIssue | null> {
69
90
  switch (link.type) {
70
91
  case 'local_file':
71
- return await validateLocalFileLink(link, sourceFilePath, headingsByFile, options);
92
+ case 'local_directory':
93
+ return await validateLocalFileLink(link, sourceFilePath, fragmentsByFile, options);
72
94
 
73
95
  case 'anchor':
74
- return await validateAnchorLink(link, sourceFilePath, headingsByFile);
96
+ return await validateAnchorLink(link, sourceFilePath, fragmentsByFile, options?.projectRoot);
75
97
 
76
98
  case 'external':
77
99
  // External URLs are not validated - don't report them
@@ -82,13 +104,11 @@ export async function validateLink(
82
104
  return null;
83
105
 
84
106
  case 'unknown':
85
- return {
86
- resourcePath: sourceFilePath,
87
- line: link.line,
88
- type: 'unknown_link',
89
- link: link.href,
90
- message: 'Unknown link type',
91
- };
107
+ return createRegistryIssue(
108
+ 'LINK_UNKNOWN',
109
+ 'Unknown link type',
110
+ linkExtras(link, sourceFilePath, options?.projectRoot),
111
+ );
92
112
 
93
113
  default: {
94
114
  // TypeScript exhaustiveness check
@@ -107,31 +127,29 @@ export function resolutionFailureIssue(
107
127
  resolved: ReturnType<typeof resolveLocalHref>,
108
128
  link: ResourceLink,
109
129
  sourceFilePath: string,
130
+ projectRoot?: string,
110
131
  ): ValidationIssue | null {
111
132
  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; ` +
133
+ return createRegistryIssue(
134
+ 'LINK_BROKEN_FILE',
135
+ `Absolute-path link "${link.href}" requires a configured projectRoot; ` +
119
136
  `none was provided. Configure vibe-agent-toolkit.config.yaml or run ` +
120
137
  `from within a git repository.`,
121
- suggestion:
138
+ linkExtras(
139
+ link,
140
+ sourceFilePath,
141
+ projectRoot,
122
142
  'Rewrite as a source-relative link, or run from a directory with a config or git ancestor.',
123
- };
143
+ ),
144
+ );
124
145
  }
125
146
 
126
147
  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
- };
148
+ return createRegistryIssue(
149
+ 'LINK_BROKEN_FILE',
150
+ `Absolute-path link "${link.href}" escapes the project root via path traversal.`,
151
+ linkExtras(link, sourceFilePath, projectRoot, ''),
152
+ );
135
153
  }
136
154
 
137
155
  return null;
@@ -145,29 +163,29 @@ export function fileExistenceIssue(
145
163
  fileResult: { exists: boolean; resolvedPath: string; actualName?: string },
146
164
  link: ResourceLink,
147
165
  sourceFilePath: string,
166
+ projectRoot?: string,
148
167
  ): ValidationIssue | null {
149
168
  if (fileResult.exists) return null;
150
169
 
151
170
  if (fileResult.actualName) {
152
171
  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
- };
172
+ return createRegistryIssue(
173
+ 'LINK_BROKEN_FILE',
174
+ `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.`,
175
+ linkExtras(
176
+ link,
177
+ sourceFilePath,
178
+ projectRoot,
179
+ `Use "${fileResult.actualName}" instead of "${expectedName}"`,
180
+ ),
181
+ );
161
182
  }
162
183
 
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
- };
184
+ return createRegistryIssue(
185
+ 'LINK_BROKEN_FILE',
186
+ `File not found: ${fileResult.resolvedPath}`,
187
+ linkExtras(link, sourceFilePath, projectRoot, ''),
188
+ );
171
189
  }
172
190
 
173
191
  /**
@@ -202,14 +220,11 @@ export function gitIgnoreSafetyIssue(
202
220
 
203
221
  if (sourceIsIgnored || !targetIsIgnored) return null;
204
222
 
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
- };
223
+ return createRegistryIssue(
224
+ 'LINK_TO_GITIGNORED',
225
+ `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.`,
226
+ linkExtras(link, sourceFilePath, options.projectRoot, ''),
227
+ );
213
228
  }
214
229
 
215
230
  /**
@@ -218,78 +233,73 @@ export function gitIgnoreSafetyIssue(
218
233
  async function validateLocalFileLink(
219
234
  link: ResourceLink,
220
235
  sourceFilePath: string,
221
- headingsByFile: Map<string, HeadingNode[]>,
236
+ fragmentsByFile: FragmentIndex,
222
237
  options?: ValidateLinkOptions
223
238
  ): Promise<ValidationIssue | null> {
224
239
  const resolved = resolveLocalHref(link.href, sourceFilePath, options?.projectRoot);
225
240
 
226
241
  if (resolved.kind !== 'resolved') {
227
242
  // anchor_only → null no-op; absolute_no_root / absolute_escapes_root → broken_file.
228
- return resolutionFailureIssue(resolved, link, sourceFilePath);
243
+ return resolutionFailureIssue(resolved, link, sourceFilePath, options?.projectRoot);
229
244
  }
230
245
 
231
246
  const fileResult = await validateResolvedFile(resolved.resolvedPath);
232
- const notFound = fileExistenceIssue(fileResult, link, sourceFilePath);
247
+ const notFound = fileExistenceIssue(fileResult, link, sourceFilePath, options?.projectRoot);
233
248
  if (notFound) return notFound;
234
249
 
235
- if (fileResult.isDirectory) {
236
- return {
237
- resourcePath: sourceFilePath,
238
- line: link.line,
239
- type: 'broken_file',
240
- link: link.href,
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.',
244
- };
245
- }
246
-
247
250
  const gitIgnoreIssue = gitIgnoreSafetyIssue(link, sourceFilePath, fileResult.resolvedPath, options);
248
251
  if (gitIgnoreIssue) return gitIgnoreIssue;
249
252
 
250
253
  if (resolved.anchor) {
251
- const anchorValid = await validateAnchor(resolved.anchor, fileResult.resolvedPath, headingsByFile);
252
- if (!anchorValid) {
253
- return {
254
- resourcePath: sourceFilePath,
255
- line: link.line,
256
- type: 'broken_anchor',
257
- link: link.href,
258
- message: `Anchor not found: #${resolved.anchor} in ${fileResult.resolvedPath}`,
259
- suggestion: '',
260
- };
254
+ const check = checkAnchor(resolved.anchor, fileResult.resolvedPath, fragmentsByFile);
255
+ if (check === 'broken') {
256
+ return createRegistryIssue(
257
+ 'LINK_BROKEN_ANCHOR',
258
+ `Anchor not found: #${resolved.anchor} in ${fileResult.resolvedPath}`,
259
+ linkExtras(link, sourceFilePath, options?.projectRoot, ''),
260
+ );
261
261
  }
262
262
  }
263
263
 
264
264
  return null;
265
265
  }
266
266
 
267
+ /**
268
+ * Exhaustiveness guard for the {@link AnchorCheck} union: a compile error at the
269
+ * call site means a new variant was added without being handled here.
270
+ */
271
+ function assertNever(value: never): never {
272
+ throw new Error(`Unhandled AnchorCheck variant: ${String(value)}`);
273
+ }
274
+
267
275
  /**
268
276
  * Validate an anchor link (within current file).
269
277
  */
270
278
  async function validateAnchorLink(
271
279
  link: ResourceLink,
272
280
  sourceFilePath: string,
273
- headingsByFile: Map<string, HeadingNode[]>
281
+ fragmentsByFile: FragmentIndex,
282
+ projectRoot?: string,
274
283
  ): Promise<ValidationIssue | null> {
275
284
  // Extract anchor (strip leading #)
276
285
  const anchor = link.href.startsWith('#') ? link.href.slice(1) : link.href;
277
286
 
278
287
  // Validate anchor exists in current file
279
- const isValid = await validateAnchor(anchor, sourceFilePath, headingsByFile);
280
-
281
- if (!isValid) {
282
- return {
283
- resourcePath: sourceFilePath,
284
- line: link.line,
285
- type: 'broken_anchor',
286
- link: link.href,
287
- message: `Anchor not found: ${link.href}`,
288
- suggestion: '',
289
- };
290
- }
288
+ const check = checkAnchor(anchor, sourceFilePath, fragmentsByFile);
291
289
 
292
- return null;
290
+ switch (check) {
291
+ case 'skip':
292
+ case 'valid':
293
+ return null;
294
+ case 'broken':
295
+ return createRegistryIssue(
296
+ 'LINK_BROKEN_ANCHOR',
297
+ `Anchor not found: ${link.href}`,
298
+ linkExtras(link, sourceFilePath, projectRoot, ''),
299
+ );
300
+ default:
301
+ return assertNever(check);
302
+ }
293
303
  }
294
304
 
295
305
 
@@ -328,65 +338,85 @@ async function validateResolvedFile(
328
338
  return result;
329
339
  }
330
340
 
341
+ /** Result of checking an anchor against the fragment index. */
342
+ export type AnchorCheck = 'skip' | 'valid' | 'broken';
343
+
331
344
  /**
332
- * Validate that an anchor (heading slug) exists in a file.
333
- *
334
- * @param anchor - The heading slug to find (without leading #)
335
- * @param targetFilePath - Absolute path to the file containing the heading
336
- * @param headingsByFile - Map of file paths to their heading trees
337
- * @returns True if anchor exists, false otherwise
338
- *
339
- * @example
340
- * ```typescript
341
- * const valid = await validateAnchor('my-heading', '/project/docs/guide.md', headingsMap);
342
- * ```
345
+ * A file's fragment targets plus how to match against them. HTML `id`/`name`
346
+ * anchors are matched case-sensitively; markdown heading slugs are case-folded.
347
+ * The policy lives on the entry rather than being re-derived from the file
348
+ * extension at match time, so a new resource format only has to set this flag.
343
349
  */
344
- async function validateAnchor(
345
- anchor: string,
346
- targetFilePath: string,
347
- headingsByFile: Map<string, HeadingNode[]>
348
- ): Promise<boolean> {
349
- // Get headings for target file
350
- const headings = headingsByFile.get(targetFilePath);
351
- if (!headings) {
352
- return false;
353
- }
350
+ export interface FragmentIndexEntry {
351
+ /** `true` for case-sensitive matching (HTML ids), `false` for case-folded (markdown slugs). */
352
+ caseSensitive: boolean;
353
+ fragments: Set<string>;
354
+ }
354
355
 
355
- // Search for matching slug (case-insensitive)
356
- return findHeadingBySlug(headings, anchor);
356
+ /** File path → its fragment targets and matching policy. */
357
+ export type FragmentIndex = Map<string, FragmentIndexEntry>;
358
+
359
+ /**
360
+ * Whether a path is an HTML resource (`.html`/`.htm`). The single place the
361
+ * extension drives format-specific behavior: case-sensitive ids (via
362
+ * {@link fragmentIndexEntry}) and the HTML top-fragment navigation rule.
363
+ */
364
+ export function isHtmlPath(filePath: string): boolean {
365
+ return /\.html?$/i.test(filePath);
366
+ }
367
+
368
+ /** Build one index entry, choosing the case-folding policy from the file type. */
369
+ export function fragmentIndexEntry(filePath: string, fragments: Set<string> = new Set()): FragmentIndexEntry {
370
+ return { caseSensitive: isHtmlPath(filePath), fragments };
357
371
  }
358
372
 
359
373
  /**
360
- * Recursively search heading tree for a matching slug.
361
- *
362
- * Performs case-insensitive comparison of slugs.
374
+ * Build a {@link FragmentIndex} from `[path, fragments]` pairs, deriving each
375
+ * entry's matching policy from its path. Single construction path shared by the
376
+ * registry and tests.
377
+ */
378
+ export function fragmentIndex(entries: Iterable<readonly [string, Set<string>]> = []): FragmentIndex {
379
+ const map: FragmentIndex = new Map();
380
+ for (const [filePath, fragments] of entries) {
381
+ map.set(filePath, fragmentIndexEntry(filePath, fragments));
382
+ }
383
+ return map;
384
+ }
385
+
386
+ /**
387
+ * Check whether a fragment exists in the target file's anchor set.
363
388
  *
364
- * @param headings - Array of heading nodes to search
365
- * @param targetSlug - The slug to find
366
- * @returns True if slug found, false otherwise
389
+ * - `'skip'` — target file is not indexed; we cannot prove the anchor is
390
+ * broken, so callers must not emit an issue.
391
+ * - Matching follows the entry's `caseSensitive` policy (HTML ids exact,
392
+ * markdown slugs case-folded). The index carries the policy, so this never
393
+ * re-derives the folding rule from the file extension.
394
+ * - For HTML targets the empty fragment (`#`) and `top` (ASCII
395
+ * case-insensitive) are always valid: per the HTML fragment-navigation
396
+ * algorithm both scroll to the top of the document regardless of whether a
397
+ * matching element exists, so `href="#"` / `href="#top"` are not broken.
367
398
  *
368
- * @example
369
- * ```typescript
370
- * const found = findHeadingBySlug(headings, 'my-heading');
371
- * ```
399
+ * @param anchor - Fragment without the leading `#`.
400
+ * @param targetFilePath - Absolute path of the file the fragment lives in.
401
+ * @param fragmentsByFile - Fragment index carrying each file's matching policy.
372
402
  */
373
- function findHeadingBySlug(
374
- headings: HeadingNode[],
375
- targetSlug: string
376
- ): boolean {
377
- const normalizedTarget = targetSlug.toLowerCase();
378
-
379
- for (const heading of headings) {
380
- // Check current heading
381
- if (heading.slug.toLowerCase() === normalizedTarget) {
382
- return true;
383
- }
384
-
385
- // Recursively check children
386
- if (heading.children && findHeadingBySlug(heading.children, targetSlug)) {
387
- return true;
388
- }
403
+ export function checkAnchor(
404
+ anchor: string,
405
+ targetFilePath: string,
406
+ fragmentsByFile: FragmentIndex,
407
+ ): AnchorCheck {
408
+ const entry = fragmentsByFile.get(targetFilePath);
409
+ if (!entry) {
410
+ return 'skip';
389
411
  }
390
-
391
- return false;
412
+ // HTML spec: the empty fragment and "top" (case-insensitive) always navigate
413
+ // to the top of the document — valid even with no matching id. This is an
414
+ // HTML-format navigation rule, distinct from the case-folding policy above.
415
+ if (isHtmlPath(targetFilePath) && (anchor === '' || anchor.toLowerCase() === 'top')) {
416
+ return 'valid';
417
+ }
418
+ const found = entry.caseSensitive
419
+ ? entry.fragments.has(anchor)
420
+ : entry.fragments.has(anchor.toLowerCase());
421
+ return found ? 'valid' : 'broken';
392
422
  }
@@ -12,12 +12,14 @@
12
12
  import { promises as fs } from 'node:fs';
13
13
  import path from 'node:path';
14
14
 
15
+ import { createRegistryIssue } from '@vibe-agent-toolkit/agent-schema';
15
16
  import { safePath } from '@vibe-agent-toolkit/utils';
16
17
 
17
18
  import { validateFrontmatter } from './frontmatter-validator.js';
18
19
  import type { ValidationMode } from './schemas/project-config.js';
19
20
  import type { ValidationIssue } from './schemas/validation-result.js';
20
21
  import type { SchemaReference } from './types/resources.js';
22
+ import { issueLocation } from './utils.js';
21
23
 
22
24
  /**
23
25
  * Load a JSON Schema from a file path
@@ -67,7 +69,7 @@ export async function validateFrontmatterMultiSchema(
67
69
  const schema = await loadSchema(schemaRef.schema, projectRoot);
68
70
 
69
71
  // Validate frontmatter
70
- const issues = validateFrontmatter(frontmatter, schema, resourcePath, mode);
72
+ const issues = validateFrontmatter(frontmatter, schema, resourcePath, mode, undefined, projectRoot);
71
73
 
72
74
  // Update schema reference with results
73
75
  const result: SchemaReference = {
@@ -89,13 +91,13 @@ export async function validateFrontmatterMultiSchema(
89
91
  ...schemaRef,
90
92
  applied: true,
91
93
  valid: false,
92
- errors: [{
93
- resourcePath,
94
- line: 1,
95
- type: 'frontmatter_schema_error',
96
- link: '',
97
- message: `Failed to load or validate schema ${schemaRef.schema}: ${message}`,
98
- }],
94
+ errors: [
95
+ createRegistryIssue(
96
+ 'FRONTMATTER_SCHEMA_ERROR',
97
+ `Failed to load or validate schema ${schemaRef.schema}: ${message}`,
98
+ { location: issueLocation(resourcePath, projectRoot), line: 1 },
99
+ ),
100
+ ],
99
101
  });
100
102
  }
101
103
  }