@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.
- package/README.md +33 -24
- package/dist/frontmatter-link-validator.d.ts +9 -9
- package/dist/frontmatter-link-validator.d.ts.map +1 -1
- package/dist/frontmatter-link-validator.js +28 -27
- package/dist/frontmatter-link-validator.js.map +1 -1
- package/dist/frontmatter-validator.d.ts +3 -2
- package/dist/frontmatter-validator.d.ts.map +1 -1
- package/dist/frontmatter-validator.js +8 -14
- package/dist/frontmatter-validator.js.map +1 -1
- package/dist/html-link-parser.d.ts +47 -0
- package/dist/html-link-parser.d.ts.map +1 -0
- package/dist/html-link-parser.js +111 -0
- package/dist/html-link-parser.js.map +1 -0
- package/dist/html-transform.d.ts +45 -0
- package/dist/html-transform.d.ts.map +1 -0
- package/dist/html-transform.js +116 -0
- package/dist/html-transform.js.map +1 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/link-parser.d.ts +26 -2
- package/dist/link-parser.d.ts.map +1 -1
- package/dist/link-parser.js +40 -6
- package/dist/link-parser.js.map +1 -1
- package/dist/link-validator.d.ts +53 -6
- package/dist/link-validator.d.ts.map +1 -1
- package/dist/link-validator.js +100 -131
- package/dist/link-validator.js.map +1 -1
- package/dist/multi-schema-validator.d.ts.map +1 -1
- package/dist/multi-schema-validator.js +6 -8
- package/dist/multi-schema-validator.js.map +1 -1
- package/dist/resource-registry.d.ts +56 -13
- package/dist/resource-registry.d.ts.map +1 -1
- package/dist/resource-registry.js +165 -66
- package/dist/resource-registry.js.map +1 -1
- package/dist/schemas/link-auth.d.ts +382 -0
- package/dist/schemas/link-auth.d.ts.map +1 -0
- package/dist/schemas/link-auth.js +124 -0
- package/dist/schemas/link-auth.js.map +1 -0
- package/dist/schemas/project-config.d.ts +3277 -171
- package/dist/schemas/project-config.d.ts.map +1 -1
- package/dist/schemas/project-config.js +68 -0
- package/dist/schemas/project-config.js.map +1 -1
- package/dist/schemas/resource-metadata.d.ts +46 -10
- package/dist/schemas/resource-metadata.d.ts.map +1 -1
- package/dist/schemas/resource-metadata.js +14 -1
- package/dist/schemas/resource-metadata.js.map +1 -1
- package/dist/schemas/validation-result.d.ts +36 -57
- package/dist/schemas/validation-result.d.ts.map +1 -1
- package/dist/schemas/validation-result.js +5 -27
- package/dist/schemas/validation-result.js.map +1 -1
- package/dist/types/resources.d.ts +1 -1
- package/dist/types/resources.d.ts.map +1 -1
- package/dist/types/resources.js.map +1 -1
- package/dist/utils.d.ts +10 -0
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +14 -0
- package/dist/utils.js.map +1 -1
- package/package.json +4 -3
- package/src/frontmatter-link-validator.ts +28 -30
- package/src/frontmatter-validator.ts +19 -16
- package/src/html-link-parser.ts +140 -0
- package/src/html-transform.ts +157 -0
- package/src/index.ts +10 -1
- package/src/link-parser.ts +60 -11
- package/src/link-validator.ts +175 -145
- package/src/multi-schema-validator.ts +10 -8
- package/src/resource-registry.ts +205 -72
- package/src/schemas/link-auth.ts +146 -0
- package/src/schemas/project-config.ts +76 -0
- package/src/schemas/resource-metadata.ts +17 -1
- package/src/schemas/validation-result.ts +5 -29
- package/src/types/resources.ts +2 -1
- package/src/utils.ts +15 -0
package/src/link-parser.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Markdown link parser and
|
|
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
|
|
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
|
|
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
|
-
|
|
331
|
-
|
|
377
|
+
// Leaf inline nodes (text, inlineCode) hold their content in `value`.
|
|
378
|
+
if (typeof child.value === 'string') {
|
|
379
|
+
return child.value;
|
|
332
380
|
}
|
|
333
|
-
//
|
|
334
|
-
|
|
335
|
-
|
|
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
|
})
|
package/src/link-validator.ts
CHANGED
|
@@ -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 {
|
|
28
|
-
import
|
|
29
|
-
|
|
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
|
|
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
|
-
|
|
87
|
+
fragmentsByFile: FragmentIndex,
|
|
67
88
|
options?: ValidateLinkOptions
|
|
68
89
|
): Promise<ValidationIssue | null> {
|
|
69
90
|
switch (link.type) {
|
|
70
91
|
case 'local_file':
|
|
71
|
-
|
|
92
|
+
case 'local_directory':
|
|
93
|
+
return await validateLocalFileLink(link, sourceFilePath, fragmentsByFile, options);
|
|
72
94
|
|
|
73
95
|
case 'anchor':
|
|
74
|
-
return await validateAnchorLink(link, sourceFilePath,
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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
|
|
252
|
-
if (
|
|
253
|
-
return
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
333
|
-
*
|
|
334
|
-
*
|
|
335
|
-
*
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
356
|
-
|
|
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
|
-
*
|
|
361
|
-
*
|
|
362
|
-
*
|
|
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
|
-
*
|
|
365
|
-
*
|
|
366
|
-
*
|
|
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
|
-
* @
|
|
369
|
-
*
|
|
370
|
-
*
|
|
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
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
}
|