@vibe-agent-toolkit/resources 0.1.37 → 0.1.39-rc.1
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 +35 -26
- package/dist/ajv-factory.d.ts +33 -0
- package/dist/ajv-factory.d.ts.map +1 -0
- package/dist/ajv-factory.js +51 -0
- package/dist/ajv-factory.js.map +1 -0
- package/dist/config-parser.d.ts +0 -18
- package/dist/config-parser.d.ts.map +1 -1
- package/dist/config-parser.js +5 -46
- package/dist/config-parser.js.map +1 -1
- package/dist/frontmatter-editor.d.ts +45 -0
- package/dist/frontmatter-editor.d.ts.map +1 -0
- package/dist/frontmatter-editor.js +161 -0
- package/dist/frontmatter-editor.js.map +1 -0
- package/dist/frontmatter-link-validator.d.ts +5 -5
- package/dist/frontmatter-link-validator.d.ts.map +1 -1
- package/dist/frontmatter-link-validator.js +25 -24
- 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 +19 -20
- package/dist/frontmatter-validator.js.map +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -1
- package/dist/index.js.map +1 -1
- package/dist/json-pointer-path.d.ts +13 -0
- package/dist/json-pointer-path.d.ts.map +1 -0
- package/dist/json-pointer-path.js +30 -0
- package/dist/json-pointer-path.js.map +1 -0
- package/dist/link-parser.js +14 -16
- package/dist/link-parser.js.map +1 -1
- package/dist/link-validator.d.ts +23 -1
- package/dist/link-validator.d.ts.map +1 -1
- package/dist/link-validator.js +107 -104
- 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 +10 -2
- package/dist/resource-registry.d.ts.map +1 -1
- package/dist/resource-registry.js +25 -32
- package/dist/resource-registry.js.map +1 -1
- package/dist/rewriter-helpers.d.ts +49 -0
- package/dist/rewriter-helpers.d.ts.map +1 -0
- package/dist/rewriter-helpers.js +142 -0
- package/dist/rewriter-helpers.js.map +1 -0
- package/dist/schemas/project-config.d.ts +219 -171
- package/dist/schemas/project-config.d.ts.map +1 -1
- package/dist/schemas/project-config.js +2 -0
- package/dist/schemas/project-config.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/resource-parser.d.ts.map +1 -1
- package/dist/types/resource-parser.js +2 -3
- package/dist/types/resource-parser.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/types.d.ts +1 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +1 -1
- package/dist/types.js.map +1 -1
- package/dist/utils.d.ts +50 -11
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +53 -13
- package/dist/utils.js.map +1 -1
- package/package.json +5 -5
- package/src/ajv-factory.ts +56 -0
- package/src/config-parser.ts +5 -51
- package/src/frontmatter-editor.ts +214 -0
- package/src/frontmatter-link-validator.ts +23 -25
- package/src/frontmatter-validator.ts +29 -22
- package/src/index.ts +21 -2
- package/src/json-pointer-path.ts +29 -0
- package/src/link-parser.ts +27 -20
- package/src/link-validator.ts +194 -119
- package/src/multi-schema-validator.ts +10 -8
- package/src/resource-registry.ts +48 -33
- package/src/rewriter-helpers.ts +166 -0
- package/src/schemas/project-config.ts +2 -0
- package/src/schemas/validation-result.ts +5 -29
- package/src/types/resource-parser.ts +2 -3
- package/src/types/resources.ts +2 -1
- package/src/types.ts +0 -1
- package/src/utils.ts +72 -14
|
@@ -13,10 +13,11 @@
|
|
|
13
13
|
* This is the ONLY place in the codebase that should use AJV.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import {
|
|
16
|
+
import { createRegistryIssue, type ValidationIssue } from '@vibe-agent-toolkit/agent-schema';
|
|
17
17
|
|
|
18
|
+
import { createAjvWithUriFormats } from './ajv-factory.js';
|
|
18
19
|
import type { ValidationMode } from './schemas/project-config.js';
|
|
19
|
-
import
|
|
20
|
+
import { issueLocation } from './utils.js';
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* Validate frontmatter against a JSON Schema.
|
|
@@ -32,6 +33,7 @@ import type { ValidationIssue } from './schemas/validation-result.js';
|
|
|
32
33
|
* @param resourcePath - File path for error reporting
|
|
33
34
|
* @param mode - Validation mode: 'strict' (default) or 'permissive'
|
|
34
35
|
* @param schemaPath - Path to schema file (for error context)
|
|
36
|
+
* @param projectRoot - Project root for computing relative issue locations
|
|
35
37
|
* @returns Array of validation issues (empty if valid)
|
|
36
38
|
*
|
|
37
39
|
* @example
|
|
@@ -55,7 +57,8 @@ export function validateFrontmatter(
|
|
|
55
57
|
schema: object,
|
|
56
58
|
resourcePath: string,
|
|
57
59
|
mode: ValidationMode = 'strict',
|
|
58
|
-
schemaPath?: string
|
|
60
|
+
schemaPath?: string,
|
|
61
|
+
projectRoot?: string,
|
|
59
62
|
): ValidationIssue[] {
|
|
60
63
|
const issues: ValidationIssue[] = [];
|
|
61
64
|
|
|
@@ -65,11 +68,16 @@ export function validateFrontmatter(
|
|
|
65
68
|
effectiveSchema = makeSchemaPermissive(schema);
|
|
66
69
|
}
|
|
67
70
|
|
|
68
|
-
//
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
// Use the shared Ajv factory so the internal validator and any adopter
|
|
72
|
+
// consuming `createAjvWithUriFormats` see identical format behavior.
|
|
73
|
+
// Permissive options match how VAT validates user-supplied schemas:
|
|
74
|
+
// - strict: false so non-strict schemas compile (older JSON Schema drafts).
|
|
75
|
+
// - allErrors: true so we report all issues, not just the first.
|
|
76
|
+
// - allowUnionTypes: true for draft-2019-09+ union type support.
|
|
77
|
+
const ajv = createAjvWithUriFormats({
|
|
78
|
+
strict: false,
|
|
79
|
+
allErrors: true,
|
|
80
|
+
allowUnionTypes: true,
|
|
73
81
|
});
|
|
74
82
|
|
|
75
83
|
const validate = ajv.compile(effectiveSchema);
|
|
@@ -83,13 +91,13 @@ export function validateFrontmatter(
|
|
|
83
91
|
const schemaContext = schemaPath ? ` (schema: ${schemaPath}, mode: ${mode})` : '';
|
|
84
92
|
const requiredFields = schemaRequires.join(', ');
|
|
85
93
|
|
|
86
|
-
issues.push(
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
94
|
+
issues.push(
|
|
95
|
+
createRegistryIssue(
|
|
96
|
+
'FRONTMATTER_MISSING',
|
|
97
|
+
`No frontmatter found in file. Schema requires: ${requiredFields}${schemaContext}`,
|
|
98
|
+
{ location: issueLocation(resourcePath, projectRoot), line: 1 },
|
|
99
|
+
),
|
|
100
|
+
);
|
|
93
101
|
}
|
|
94
102
|
return issues;
|
|
95
103
|
}
|
|
@@ -104,13 +112,12 @@ export function validateFrontmatter(
|
|
|
104
112
|
// Format validation errors with helpful messages
|
|
105
113
|
for (const error of validate.errors) {
|
|
106
114
|
const message = formatValidationError(error, frontmatter, mode, schemaPath);
|
|
107
|
-
issues.push(
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
});
|
|
115
|
+
issues.push(
|
|
116
|
+
createRegistryIssue('FRONTMATTER_SCHEMA_ERROR', message, {
|
|
117
|
+
location: issueLocation(resourcePath, projectRoot),
|
|
118
|
+
line: 1,
|
|
119
|
+
}),
|
|
120
|
+
);
|
|
114
121
|
}
|
|
115
122
|
|
|
116
123
|
return issues;
|
package/src/index.ts
CHANGED
|
@@ -88,6 +88,11 @@ export { parseMarkdown, type ParseResult } from './link-parser.js';
|
|
|
88
88
|
// Export frontmatter validation
|
|
89
89
|
export { validateFrontmatter } from './frontmatter-validator.js';
|
|
90
90
|
|
|
91
|
+
// Public Ajv factory for adopters consuming VAT-generated schemas. Registers
|
|
92
|
+
// URI-family formats (uri, uri-reference, iri, iri-reference) so schemas
|
|
93
|
+
// compile cleanly under Ajv strict mode without throwing on "unknown format".
|
|
94
|
+
export { createAjvWithUriFormats } from './ajv-factory.js';
|
|
95
|
+
|
|
91
96
|
// Export content transform engine for link rewriting
|
|
92
97
|
export {
|
|
93
98
|
transformContent,
|
|
@@ -101,11 +106,25 @@ export {
|
|
|
101
106
|
// They are implementation details. Users should use ResourceRegistry API.
|
|
102
107
|
|
|
103
108
|
// Export href resolution utility (shared by audit and validate code paths)
|
|
104
|
-
export { resolveLocalHref } from './utils.js';
|
|
109
|
+
export { resolveLocalHref, type ResolveLocalHrefResult } from './utils.js';
|
|
110
|
+
|
|
111
|
+
// Export frontmatter editor primitive (comment-preserving round-trip)
|
|
112
|
+
export {
|
|
113
|
+
openFrontmatter,
|
|
114
|
+
FrontmatterParseError,
|
|
115
|
+
type FrontmatterEditor,
|
|
116
|
+
} from './frontmatter-editor.js';
|
|
117
|
+
|
|
118
|
+
// Export rewriter helpers (built on FrontmatterEditor + shared callback shape)
|
|
119
|
+
export {
|
|
120
|
+
rewriteFrontmatterUriReferencesFromSchema,
|
|
121
|
+
rewriteFrontmatterFieldsAtPaths,
|
|
122
|
+
rewriteBodyLinks,
|
|
123
|
+
type RewriteHref,
|
|
124
|
+
} from './rewriter-helpers.js';
|
|
105
125
|
|
|
106
126
|
// Export project config parsing
|
|
107
127
|
export {
|
|
108
|
-
findConfigFile,
|
|
109
128
|
parseConfigFile,
|
|
110
129
|
loadConfig,
|
|
111
130
|
} from './config-parser.js';
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert an RFC 6901 JSON Pointer string into a path of (string | number)
|
|
3
|
+
* segments suitable for use with the FrontmatterEditor mutation API.
|
|
4
|
+
*
|
|
5
|
+
* Canonical array indices (RFC 6901 §4: no leading zeros except for "0")
|
|
6
|
+
* are converted to numbers; all other segments are decoded strings.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* jsonPointerToPath('/adrs-cited/0') // ['adrs-cited', 0]
|
|
10
|
+
* jsonPointerToPath('') // []
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { decodeJsonPointerSegment, isCanonicalArrayIndex } from './utils.js';
|
|
14
|
+
|
|
15
|
+
export function jsonPointerToPath(pointer: string): (string | number)[] {
|
|
16
|
+
if (pointer === '') return [];
|
|
17
|
+
// eslint-disable-next-line local/no-hardcoded-path-split -- RFC 6901 JSON Pointer delimiter, not a file path
|
|
18
|
+
const raw = pointer.slice(1).split('/');
|
|
19
|
+
const result: (string | number)[] = [];
|
|
20
|
+
for (const seg of raw) {
|
|
21
|
+
const decoded = decodeJsonPointerSegment(seg);
|
|
22
|
+
if (isCanonicalArrayIndex(decoded)) {
|
|
23
|
+
result.push(Number(decoded));
|
|
24
|
+
} else {
|
|
25
|
+
result.push(decoded);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return result;
|
|
29
|
+
}
|
package/src/link-parser.ts
CHANGED
|
@@ -12,13 +12,13 @@
|
|
|
12
12
|
import { readFile, stat } from 'node:fs/promises';
|
|
13
13
|
|
|
14
14
|
import GithubSlugger from 'github-slugger';
|
|
15
|
-
import * as yaml from 'js-yaml';
|
|
16
15
|
import type { Definition, Heading, Link, LinkReference, Root } from 'mdast';
|
|
17
16
|
import remarkFrontmatter from 'remark-frontmatter';
|
|
18
17
|
import remarkGfm from 'remark-gfm';
|
|
19
18
|
import remarkParse from 'remark-parse';
|
|
20
19
|
import { unified } from 'unified';
|
|
21
20
|
import { visit } from 'unist-util-visit';
|
|
21
|
+
import * as yaml from 'yaml';
|
|
22
22
|
|
|
23
23
|
import type { HeadingNode, LinkType, ResourceLink } from './types.js';
|
|
24
24
|
|
|
@@ -310,29 +310,45 @@ function extractHeadingText(node: Heading): string {
|
|
|
310
310
|
return extractTextFromChildren(node.children);
|
|
311
311
|
}
|
|
312
312
|
|
|
313
|
+
/**
|
|
314
|
+
* Inline node shape for heading text extraction. Leaf inline nodes (`text`,
|
|
315
|
+
* `inlineCode`) carry their content in `value`; container inline nodes
|
|
316
|
+
* (`strong`, `emphasis`, `link`, `delete`) carry it in `children`.
|
|
317
|
+
*/
|
|
318
|
+
interface InlineNode {
|
|
319
|
+
type: string;
|
|
320
|
+
value?: unknown;
|
|
321
|
+
children?: InlineNode[];
|
|
322
|
+
}
|
|
323
|
+
|
|
313
324
|
/**
|
|
314
325
|
* Extract text content from inline children nodes.
|
|
315
326
|
*
|
|
316
|
-
* Handles
|
|
327
|
+
* Handles leaf nodes (`text`, `inlineCode`) and recurses into container inline
|
|
328
|
+
* elements (`strong`, `emphasis`, `link`, `delete`) so that styled headings —
|
|
329
|
+
* e.g. `### **CRITICAL: ...**` — produce the same text (and therefore the same
|
|
330
|
+
* GitHub slug) as their plain-text equivalents. Without recursion, bold/italic
|
|
331
|
+
* headings yielded empty text and bogus slugs, causing false LINK_BROKEN_ANCHOR
|
|
332
|
+
* errors for links targeting them.
|
|
317
333
|
*
|
|
318
334
|
* @param children - Array of child nodes or undefined
|
|
319
335
|
* @returns Concatenated text content
|
|
320
336
|
*/
|
|
321
|
-
function extractTextFromChildren(
|
|
322
|
-
children: Array<{ type: string; value?: unknown }> | undefined
|
|
323
|
-
): string {
|
|
337
|
+
function extractTextFromChildren(children: InlineNode[] | undefined): string {
|
|
324
338
|
if (!children || children.length === 0) {
|
|
325
339
|
return '';
|
|
326
340
|
}
|
|
327
341
|
|
|
328
342
|
return children
|
|
329
343
|
.map((child) => {
|
|
330
|
-
|
|
331
|
-
|
|
344
|
+
// Leaf inline nodes (text, inlineCode) hold their content in `value`.
|
|
345
|
+
if (typeof child.value === 'string') {
|
|
346
|
+
return child.value;
|
|
332
347
|
}
|
|
333
|
-
//
|
|
334
|
-
|
|
335
|
-
|
|
348
|
+
// Container inline nodes (strong, emphasis, link, delete) hold text in
|
|
349
|
+
// their children — recurse to gather it.
|
|
350
|
+
if (child.children) {
|
|
351
|
+
return extractTextFromChildren(child.children);
|
|
336
352
|
}
|
|
337
353
|
return '';
|
|
338
354
|
})
|
|
@@ -431,16 +447,7 @@ function extractFrontmatter(tree: Root): {
|
|
|
431
447
|
}
|
|
432
448
|
|
|
433
449
|
try {
|
|
434
|
-
|
|
435
|
-
// strings (`2026-04-15` stays `"2026-04-15"`) instead of promoting
|
|
436
|
-
// them to JS Date objects (js-yaml's default DEFAULT_SCHEMA still
|
|
437
|
-
// applies the YAML 1.1 timestamp tag). Date promotion broke schema
|
|
438
|
-
// validation for any frontmatter field typed `string` in the schema:
|
|
439
|
-
// the validator saw an instanceof Date and rejected it. Adopters'
|
|
440
|
-
// ADR/PRD frontmatter conventionally uses unquoted ISO dates per
|
|
441
|
-
// YAML 1.2; quoting all of them just to placate the parser is
|
|
442
|
-
// unreasonable.
|
|
443
|
-
const parsed = yaml.load(node.value, { schema: yaml.CORE_SCHEMA });
|
|
450
|
+
const parsed = yaml.parse(node.value);
|
|
444
451
|
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
|
445
452
|
frontmatterData = parsed as Record<string, unknown>;
|
|
446
453
|
}
|
package/src/link-validator.ts
CHANGED
|
@@ -15,17 +15,39 @@
|
|
|
15
15
|
* - External resources (outside project) skip git-ignore checks
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
+
import fs from 'node:fs/promises';
|
|
18
19
|
import path from 'node:path';
|
|
19
20
|
|
|
21
|
+
import { createRegistryIssue, type ValidationIssue } from '@vibe-agent-toolkit/agent-schema';
|
|
20
22
|
import {
|
|
21
23
|
isGitIgnored,
|
|
22
24
|
type GitTracker,
|
|
23
|
-
verifyCaseSensitiveFilename,
|
|
25
|
+
verifyCaseSensitiveFilename,
|
|
24
26
|
} from '@vibe-agent-toolkit/utils';
|
|
25
27
|
|
|
26
|
-
import type { ValidationIssue } from './schemas/validation-result.js';
|
|
27
28
|
import type { HeadingNode, ResourceLink } from './types.js';
|
|
28
|
-
import { isWithinProject,
|
|
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
|
+
}
|
|
29
51
|
|
|
30
52
|
/**
|
|
31
53
|
* Options for link validation.
|
|
@@ -70,7 +92,7 @@ export async function validateLink(
|
|
|
70
92
|
return await validateLocalFileLink(link, sourceFilePath, headingsByFile, options);
|
|
71
93
|
|
|
72
94
|
case 'anchor':
|
|
73
|
-
return await validateAnchorLink(link, sourceFilePath, headingsByFile);
|
|
95
|
+
return await validateAnchorLink(link, sourceFilePath, headingsByFile, options?.projectRoot);
|
|
74
96
|
|
|
75
97
|
case 'external':
|
|
76
98
|
// External URLs are not validated - don't report them
|
|
@@ -81,13 +103,11 @@ export async function validateLink(
|
|
|
81
103
|
return null;
|
|
82
104
|
|
|
83
105
|
case 'unknown':
|
|
84
|
-
return
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
message: 'Unknown link type',
|
|
90
|
-
};
|
|
106
|
+
return createRegistryIssue(
|
|
107
|
+
'LINK_UNKNOWN',
|
|
108
|
+
'Unknown link type',
|
|
109
|
+
linkExtras(link, sourceFilePath, options?.projectRoot),
|
|
110
|
+
);
|
|
91
111
|
|
|
92
112
|
default: {
|
|
93
113
|
// TypeScript exhaustiveness check
|
|
@@ -97,6 +117,115 @@ export async function validateLink(
|
|
|
97
117
|
}
|
|
98
118
|
}
|
|
99
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Convert a resolution failure kind to a broken_file ValidationIssue. Returns
|
|
122
|
+
* null for `resolved` (caller continues) and `anchor_only` (defensive no-op —
|
|
123
|
+
* the parser classifies anchor-only hrefs as 'anchor', not 'local_file').
|
|
124
|
+
*/
|
|
125
|
+
export function resolutionFailureIssue(
|
|
126
|
+
resolved: ReturnType<typeof resolveLocalHref>,
|
|
127
|
+
link: ResourceLink,
|
|
128
|
+
sourceFilePath: string,
|
|
129
|
+
projectRoot?: string,
|
|
130
|
+
): ValidationIssue | null {
|
|
131
|
+
if (resolved.kind === 'absolute_no_root') {
|
|
132
|
+
return createRegistryIssue(
|
|
133
|
+
'LINK_BROKEN_FILE',
|
|
134
|
+
`Absolute-path link "${link.href}" requires a configured projectRoot; ` +
|
|
135
|
+
`none was provided. Configure vibe-agent-toolkit.config.yaml or run ` +
|
|
136
|
+
`from within a git repository.`,
|
|
137
|
+
linkExtras(
|
|
138
|
+
link,
|
|
139
|
+
sourceFilePath,
|
|
140
|
+
projectRoot,
|
|
141
|
+
'Rewrite as a source-relative link, or run from a directory with a config or git ancestor.',
|
|
142
|
+
),
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (resolved.kind === 'absolute_escapes_root') {
|
|
147
|
+
return createRegistryIssue(
|
|
148
|
+
'LINK_BROKEN_FILE',
|
|
149
|
+
`Absolute-path link "${link.href}" escapes the project root via path traversal.`,
|
|
150
|
+
linkExtras(link, sourceFilePath, projectRoot, ''),
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Convert a non-existent file result into a broken_file ValidationIssue.
|
|
159
|
+
* Returns null when the file exists.
|
|
160
|
+
*/
|
|
161
|
+
export function fileExistenceIssue(
|
|
162
|
+
fileResult: { exists: boolean; resolvedPath: string; actualName?: string },
|
|
163
|
+
link: ResourceLink,
|
|
164
|
+
sourceFilePath: string,
|
|
165
|
+
projectRoot?: string,
|
|
166
|
+
): ValidationIssue | null {
|
|
167
|
+
if (fileResult.exists) return null;
|
|
168
|
+
|
|
169
|
+
if (fileResult.actualName) {
|
|
170
|
+
const expectedName = path.basename(fileResult.resolvedPath);
|
|
171
|
+
return createRegistryIssue(
|
|
172
|
+
'LINK_BROKEN_FILE',
|
|
173
|
+
`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.`,
|
|
174
|
+
linkExtras(
|
|
175
|
+
link,
|
|
176
|
+
sourceFilePath,
|
|
177
|
+
projectRoot,
|
|
178
|
+
`Use "${fileResult.actualName}" instead of "${expectedName}"`,
|
|
179
|
+
),
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return createRegistryIssue(
|
|
184
|
+
'LINK_BROKEN_FILE',
|
|
185
|
+
`File not found: ${fileResult.resolvedPath}`,
|
|
186
|
+
linkExtras(link, sourceFilePath, projectRoot, ''),
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Check git-ignore safety: a non-ignored source file must not link to a
|
|
192
|
+
* gitignored target. Returns a ValidationIssue when this rule is violated,
|
|
193
|
+
* null otherwise (including when checks are disabled or out of scope).
|
|
194
|
+
*/
|
|
195
|
+
export function gitIgnoreSafetyIssue(
|
|
196
|
+
link: ResourceLink,
|
|
197
|
+
sourceFilePath: string,
|
|
198
|
+
resolvedTarget: string,
|
|
199
|
+
options: ValidateLinkOptions | undefined,
|
|
200
|
+
): ValidationIssue | null {
|
|
201
|
+
if (
|
|
202
|
+
options?.skipGitIgnoreCheck === true ||
|
|
203
|
+
options?.projectRoot === undefined ||
|
|
204
|
+
!isWithinProject(resolvedTarget, options.projectRoot)
|
|
205
|
+
) {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Prefer the O(1) active-set lookup on the shared GitTracker (no spawn).
|
|
210
|
+
// isIgnoredByActiveSet falls back internally to isIgnored for paths outside
|
|
211
|
+
// the project root, so this is safe for the rare out-of-project case.
|
|
212
|
+
// When no tracker is threaded in, fall back to isGitIgnored (one-off spawn).
|
|
213
|
+
const sourceIsIgnored = options.gitTracker
|
|
214
|
+
? options.gitTracker.isIgnoredByActiveSet(sourceFilePath)
|
|
215
|
+
: isGitIgnored(sourceFilePath, options.projectRoot);
|
|
216
|
+
const targetIsIgnored = options.gitTracker
|
|
217
|
+
? options.gitTracker.isIgnoredByActiveSet(resolvedTarget)
|
|
218
|
+
: isGitIgnored(resolvedTarget, options.projectRoot);
|
|
219
|
+
|
|
220
|
+
if (sourceIsIgnored || !targetIsIgnored) return null;
|
|
221
|
+
|
|
222
|
+
return createRegistryIssue(
|
|
223
|
+
'LINK_TO_GITIGNORED',
|
|
224
|
+
`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.`,
|
|
225
|
+
linkExtras(link, sourceFilePath, options.projectRoot, ''),
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
100
229
|
/**
|
|
101
230
|
* Validate a local file link (with optional anchor).
|
|
102
231
|
*/
|
|
@@ -106,87 +235,41 @@ async function validateLocalFileLink(
|
|
|
106
235
|
headingsByFile: Map<string, HeadingNode[]>,
|
|
107
236
|
options?: ValidateLinkOptions
|
|
108
237
|
): Promise<ValidationIssue | null> {
|
|
109
|
-
|
|
110
|
-
const [filePath, anchor] = splitHrefAnchor(link.href);
|
|
111
|
-
|
|
112
|
-
// Validate the file exists
|
|
113
|
-
const fileResult = await validateLocalFile(filePath, sourceFilePath);
|
|
114
|
-
|
|
115
|
-
if (!fileResult.exists) {
|
|
116
|
-
// Check if it's a case mismatch
|
|
117
|
-
if (fileResult.actualName) {
|
|
118
|
-
const expectedName = path.basename(fileResult.resolvedPath);
|
|
119
|
-
return {
|
|
120
|
-
resourcePath: sourceFilePath,
|
|
121
|
-
line: link.line,
|
|
122
|
-
type: 'broken_file',
|
|
123
|
-
link: link.href,
|
|
124
|
-
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.`,
|
|
125
|
-
suggestion: `Use "${fileResult.actualName}" instead of "${expectedName}"`,
|
|
126
|
-
};
|
|
127
|
-
}
|
|
238
|
+
const resolved = resolveLocalHref(link.href, sourceFilePath, options?.projectRoot);
|
|
128
239
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
type: 'broken_file',
|
|
133
|
-
link: link.href,
|
|
134
|
-
message: `File not found: ${fileResult.resolvedPath}`,
|
|
135
|
-
suggestion: '',
|
|
136
|
-
};
|
|
240
|
+
if (resolved.kind !== 'resolved') {
|
|
241
|
+
// anchor_only → null no-op; absolute_no_root / absolute_escapes_root → broken_file.
|
|
242
|
+
return resolutionFailureIssue(resolved, link, sourceFilePath, options?.projectRoot);
|
|
137
243
|
}
|
|
138
244
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
? options.gitTracker.isIgnoredByActiveSet(sourceFilePath)
|
|
155
|
-
: isGitIgnored(sourceFilePath, options.projectRoot);
|
|
156
|
-
const targetIsIgnored = options.gitTracker
|
|
157
|
-
? options.gitTracker.isIgnoredByActiveSet(fileResult.resolvedPath)
|
|
158
|
-
: isGitIgnored(fileResult.resolvedPath, options.projectRoot);
|
|
159
|
-
|
|
160
|
-
// Error ONLY if: source is NOT ignored AND target IS ignored
|
|
161
|
-
if (!sourceIsIgnored && targetIsIgnored) {
|
|
162
|
-
return {
|
|
163
|
-
resourcePath: sourceFilePath,
|
|
164
|
-
line: link.line,
|
|
165
|
-
type: 'link_to_gitignored',
|
|
166
|
-
link: link.href,
|
|
167
|
-
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.`,
|
|
168
|
-
suggestion: '',
|
|
169
|
-
};
|
|
170
|
-
}
|
|
245
|
+
const fileResult = await validateResolvedFile(resolved.resolvedPath);
|
|
246
|
+
const notFound = fileExistenceIssue(fileResult, link, sourceFilePath, options?.projectRoot);
|
|
247
|
+
if (notFound) return notFound;
|
|
248
|
+
|
|
249
|
+
if (fileResult.isDirectory) {
|
|
250
|
+
return createRegistryIssue(
|
|
251
|
+
'LINK_BROKEN_FILE',
|
|
252
|
+
`Link target is a directory: ${fileResult.resolvedPath}`,
|
|
253
|
+
linkExtras(
|
|
254
|
+
link,
|
|
255
|
+
sourceFilePath,
|
|
256
|
+
options?.projectRoot,
|
|
257
|
+
'Link to a file inside the directory (e.g., README.md or index.md), or fix the link to point at the intended file.',
|
|
258
|
+
),
|
|
259
|
+
);
|
|
171
260
|
}
|
|
172
261
|
|
|
173
|
-
|
|
174
|
-
if (
|
|
175
|
-
const anchorValid = await validateAnchor(
|
|
176
|
-
anchor,
|
|
177
|
-
fileResult.resolvedPath,
|
|
178
|
-
headingsByFile
|
|
179
|
-
);
|
|
262
|
+
const gitIgnoreIssue = gitIgnoreSafetyIssue(link, sourceFilePath, fileResult.resolvedPath, options);
|
|
263
|
+
if (gitIgnoreIssue) return gitIgnoreIssue;
|
|
180
264
|
|
|
265
|
+
if (resolved.anchor) {
|
|
266
|
+
const anchorValid = await validateAnchor(resolved.anchor, fileResult.resolvedPath, headingsByFile);
|
|
181
267
|
if (!anchorValid) {
|
|
182
|
-
return
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
message: `Anchor not found: #${anchor} in ${fileResult.resolvedPath}`,
|
|
188
|
-
suggestion: '',
|
|
189
|
-
};
|
|
268
|
+
return createRegistryIssue(
|
|
269
|
+
'LINK_BROKEN_ANCHOR',
|
|
270
|
+
`Anchor not found: #${resolved.anchor} in ${fileResult.resolvedPath}`,
|
|
271
|
+
linkExtras(link, sourceFilePath, options?.projectRoot, ''),
|
|
272
|
+
);
|
|
190
273
|
}
|
|
191
274
|
}
|
|
192
275
|
|
|
@@ -199,7 +282,8 @@ async function validateLocalFileLink(
|
|
|
199
282
|
async function validateAnchorLink(
|
|
200
283
|
link: ResourceLink,
|
|
201
284
|
sourceFilePath: string,
|
|
202
|
-
headingsByFile: Map<string, HeadingNode[]
|
|
285
|
+
headingsByFile: Map<string, HeadingNode[]>,
|
|
286
|
+
projectRoot?: string,
|
|
203
287
|
): Promise<ValidationIssue | null> {
|
|
204
288
|
// Extract anchor (strip leading #)
|
|
205
289
|
const anchor = link.href.startsWith('#') ? link.href.slice(1) : link.href;
|
|
@@ -208,14 +292,11 @@ async function validateAnchorLink(
|
|
|
208
292
|
const isValid = await validateAnchor(anchor, sourceFilePath, headingsByFile);
|
|
209
293
|
|
|
210
294
|
if (!isValid) {
|
|
211
|
-
return
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
message: `Anchor not found: ${link.href}`,
|
|
217
|
-
suggestion: '',
|
|
218
|
-
};
|
|
295
|
+
return createRegistryIssue(
|
|
296
|
+
'LINK_BROKEN_ANCHOR',
|
|
297
|
+
`Anchor not found: ${link.href}`,
|
|
298
|
+
linkExtras(link, sourceFilePath, projectRoot, ''),
|
|
299
|
+
);
|
|
219
300
|
}
|
|
220
301
|
|
|
221
302
|
return null;
|
|
@@ -223,37 +304,31 @@ async function validateAnchorLink(
|
|
|
223
304
|
|
|
224
305
|
|
|
225
306
|
/**
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
* @param href - The href to the file (relative or absolute)
|
|
229
|
-
* @param sourceFilePath - Absolute path to the source file
|
|
230
|
-
* @returns Object with exists flag, resolved absolute path, and optional case mismatch info
|
|
307
|
+
* Verify that the resolved filesystem path exists with the correct case.
|
|
231
308
|
*
|
|
232
|
-
* @
|
|
233
|
-
*
|
|
234
|
-
* const result = await validateLocalFile('./docs/guide.md', '/project/README.md');
|
|
235
|
-
* if (result.exists) {
|
|
236
|
-
* console.log('File exists at:', result.resolvedPath);
|
|
237
|
-
* } else if (result.actualName) {
|
|
238
|
-
* console.log('Case mismatch:', result.actualName);
|
|
239
|
-
* }
|
|
240
|
-
* ```
|
|
309
|
+
* @param resolvedPath - Absolute filesystem path produced by {@link resolveLocalHref}.
|
|
310
|
+
* @returns Object with exists flag, the path, and optional case-mismatch info.
|
|
241
311
|
*/
|
|
242
|
-
async function
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
): Promise<{ exists: boolean; resolvedPath: string; actualName?: string }> {
|
|
246
|
-
// Resolve href to filesystem path (decode percent-encoding, resolve relative to source)
|
|
247
|
-
const resolved = resolveLocalHref(href, sourceFilePath);
|
|
248
|
-
const resolvedPath = resolved?.resolvedPath ?? safePath.resolve(path.dirname(sourceFilePath), href);
|
|
249
|
-
|
|
250
|
-
// Check if file exists with correct case
|
|
312
|
+
async function validateResolvedFile(
|
|
313
|
+
resolvedPath: string,
|
|
314
|
+
): Promise<{ exists: boolean; resolvedPath: string; actualName?: string; isDirectory: boolean }> {
|
|
251
315
|
const verification = await verifyCaseSensitiveFilename(resolvedPath);
|
|
252
316
|
|
|
253
|
-
|
|
254
|
-
|
|
317
|
+
let isDirectory = false;
|
|
318
|
+
if (verification.exists) {
|
|
319
|
+
try {
|
|
320
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename -- resolvedPath validated by verifyCaseSensitiveFilename
|
|
321
|
+
const stats = await fs.stat(resolvedPath);
|
|
322
|
+
isDirectory = stats.isDirectory();
|
|
323
|
+
} catch {
|
|
324
|
+
// Stat failed after verifyCaseSensitiveFilename said exists — treat as file.
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const result: { exists: boolean; resolvedPath: string; actualName?: string; isDirectory: boolean } = {
|
|
255
329
|
exists: verification.exists,
|
|
256
330
|
resolvedPath,
|
|
331
|
+
isDirectory,
|
|
257
332
|
};
|
|
258
333
|
|
|
259
334
|
if (verification.actualName) {
|