@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
|
@@ -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
|
}
|
package/src/resource-registry.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import type fs from 'node:fs/promises';
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
|
|
14
|
+
import { createRegistryIssue, type IssueCode, runValidationFramework, type ValidationConfig, type ValidationIssue } from '@vibe-agent-toolkit/agent-schema';
|
|
14
15
|
import { crawlDirectory, type CrawlOptions as UtilsCrawlOptions, type GitTracker, normalizedTmpdir, resolveAssetReference, safePath, toForwardSlash } from '@vibe-agent-toolkit/utils';
|
|
15
16
|
|
|
16
17
|
import { calculateChecksum } from './checksum.js';
|
|
@@ -27,8 +28,8 @@ import type { ResourceCollectionInterface } from './resource-collection-interfac
|
|
|
27
28
|
import type { SHA256 } from './schemas/checksum.js';
|
|
28
29
|
import type { ProjectConfig } from './schemas/project-config.js';
|
|
29
30
|
import type { HeadingNode, ResourceMetadata } from './schemas/resource-metadata.js';
|
|
30
|
-
import type {
|
|
31
|
-
import { matchesGlobPattern, splitHrefAnchor } from './utils.js';
|
|
31
|
+
import type { ValidationResult } from './schemas/validation-result.js';
|
|
32
|
+
import { issueLocation, matchesGlobPattern, splitHrefAnchor } from './utils.js';
|
|
32
33
|
|
|
33
34
|
/**
|
|
34
35
|
* Options for crawling directories to add resources.
|
|
@@ -72,6 +73,13 @@ export interface ValidateOptions {
|
|
|
72
73
|
checkExternalUrls?: boolean;
|
|
73
74
|
/** Disable cache for external URL checks (default: false) */
|
|
74
75
|
noCache?: boolean;
|
|
76
|
+
/**
|
|
77
|
+
* Validation framework config (severity overrides + per-code allow entries).
|
|
78
|
+
* Applied INSIDE validate() via runValidationFramework — the library, not the
|
|
79
|
+
* CLI, resolves severity and drops ignored issues. Defaults to `{}` (no
|
|
80
|
+
* overrides: every issue keeps its registry default severity).
|
|
81
|
+
*/
|
|
82
|
+
validationConfig?: ValidationConfig;
|
|
75
83
|
}
|
|
76
84
|
|
|
77
85
|
/**
|
|
@@ -422,13 +430,13 @@ export class ResourceRegistry implements ResourceCollectionInterface {
|
|
|
422
430
|
const issues: ValidationIssue[] = [];
|
|
423
431
|
for (const resource of this.resourcesByPath.values()) {
|
|
424
432
|
if (resource.frontmatterError) {
|
|
425
|
-
issues.push(
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
433
|
+
issues.push(
|
|
434
|
+
createRegistryIssue(
|
|
435
|
+
'FRONTMATTER_INVALID_YAML',
|
|
436
|
+
`Invalid YAML syntax in frontmatter: ${resource.frontmatterError}`,
|
|
437
|
+
{ location: issueLocation(resource.filePath, this.baseDir), line: 1 },
|
|
438
|
+
),
|
|
439
|
+
);
|
|
432
440
|
}
|
|
433
441
|
}
|
|
434
442
|
return issues;
|
|
@@ -479,7 +487,9 @@ export class ResourceRegistry implements ResourceCollectionInterface {
|
|
|
479
487
|
resource.frontmatter,
|
|
480
488
|
schema,
|
|
481
489
|
resource.filePath,
|
|
482
|
-
mode
|
|
490
|
+
mode,
|
|
491
|
+
undefined,
|
|
492
|
+
this.baseDir,
|
|
483
493
|
);
|
|
484
494
|
issues.push(...frontmatterIssues);
|
|
485
495
|
}
|
|
@@ -593,6 +603,7 @@ export class ResourceRegistry implements ResourceCollectionInterface {
|
|
|
593
603
|
resource.filePath,
|
|
594
604
|
mode,
|
|
595
605
|
schemaPath,
|
|
606
|
+
this.baseDir,
|
|
596
607
|
);
|
|
597
608
|
|
|
598
609
|
// New: walk URI-family frontmatter values. Default-on; explicit `false` disables.
|
|
@@ -624,13 +635,13 @@ export class ResourceRegistry implements ResourceCollectionInterface {
|
|
|
624
635
|
} catch (error) {
|
|
625
636
|
// Handle missing or invalid schema files gracefully
|
|
626
637
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
627
|
-
return [
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
638
|
+
return [
|
|
639
|
+
createRegistryIssue(
|
|
640
|
+
'FRONTMATTER_SCHEMA_ERROR',
|
|
641
|
+
`Failed to load or parse frontmatter schema '${validation.frontmatterSchema}': ${errorMessage}`,
|
|
642
|
+
{ location: issueLocation(resource.filePath, this.baseDir), line: 1 },
|
|
643
|
+
),
|
|
644
|
+
];
|
|
634
645
|
}
|
|
635
646
|
}
|
|
636
647
|
|
|
@@ -706,8 +717,11 @@ export class ResourceRegistry implements ResourceCollectionInterface {
|
|
|
706
717
|
issues.push(...externalUrlIssues);
|
|
707
718
|
}
|
|
708
719
|
|
|
709
|
-
//
|
|
710
|
-
|
|
720
|
+
// Resolve severity + apply allow-filter INSIDE the library (not the CLI).
|
|
721
|
+
// `emitted` = post-allow-filter, severity-resolved, with `ignore`d dropped.
|
|
722
|
+
const framework = runValidationFramework(issues, options?.validationConfig ?? {});
|
|
723
|
+
const emitted = framework.emitted;
|
|
724
|
+
const errorCount = emitted.length;
|
|
711
725
|
|
|
712
726
|
// Count links by type
|
|
713
727
|
const linksByType: Record<string, number> = {};
|
|
@@ -726,9 +740,10 @@ export class ResourceRegistry implements ResourceCollectionInterface {
|
|
|
726
740
|
0
|
|
727
741
|
),
|
|
728
742
|
linksByType,
|
|
729
|
-
issues,
|
|
743
|
+
issues: emitted,
|
|
730
744
|
errorCount,
|
|
731
745
|
passed: errorCount === 0,
|
|
746
|
+
hasErrors: framework.hasErrors,
|
|
732
747
|
durationMs,
|
|
733
748
|
timestamp: new Date(),
|
|
734
749
|
};
|
|
@@ -830,17 +845,17 @@ export class ResourceRegistry implements ResourceCollectionInterface {
|
|
|
830
845
|
continue;
|
|
831
846
|
}
|
|
832
847
|
|
|
833
|
-
const
|
|
848
|
+
const issueCode = this.determineExternalUrlIssueCode(result.statusCode, result.error);
|
|
834
849
|
const errorMessage = result.error ?? `HTTP ${result.statusCode}`;
|
|
835
850
|
|
|
836
851
|
for (const location of locations) {
|
|
837
|
-
issues.push(
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
852
|
+
issues.push(
|
|
853
|
+
createRegistryIssue(issueCode, `External URL failed: ${errorMessage}`, {
|
|
854
|
+
location: issueLocation(location.resourcePath, this.baseDir),
|
|
855
|
+
link: result.url,
|
|
856
|
+
...(location.line !== undefined && { line: location.line }),
|
|
857
|
+
}),
|
|
858
|
+
);
|
|
844
859
|
}
|
|
845
860
|
}
|
|
846
861
|
|
|
@@ -848,18 +863,18 @@ export class ResourceRegistry implements ResourceCollectionInterface {
|
|
|
848
863
|
}
|
|
849
864
|
|
|
850
865
|
/**
|
|
851
|
-
* Determine issue
|
|
866
|
+
* Determine the registry issue code based on the external-URL validation error.
|
|
852
867
|
* @private
|
|
853
868
|
*/
|
|
854
|
-
private
|
|
869
|
+
private determineExternalUrlIssueCode(statusCode: number, error?: string): IssueCode {
|
|
855
870
|
if (statusCode === 0) {
|
|
856
871
|
const errorLower = error?.toString().toLowerCase();
|
|
857
872
|
if (errorLower?.includes('timeout')) {
|
|
858
|
-
return '
|
|
873
|
+
return 'EXTERNAL_URL_TIMEOUT';
|
|
859
874
|
}
|
|
860
|
-
return '
|
|
875
|
+
return 'EXTERNAL_URL_ERROR';
|
|
861
876
|
}
|
|
862
|
-
return '
|
|
877
|
+
return 'EXTERNAL_URL_DEAD';
|
|
863
878
|
}
|
|
864
879
|
|
|
865
880
|
/**
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public rewriter helpers built on FrontmatterEditor and shared callback
|
|
3
|
+
* shape `(href: string) => string`.
|
|
4
|
+
*
|
|
5
|
+
* Three layers:
|
|
6
|
+
* 1. rewriteFrontmatterUriReferencesFromSchema — schema-driven; used by
|
|
7
|
+
* VAT packager and by schema-aware adopter scripts.
|
|
8
|
+
* 2. rewriteFrontmatterFieldsAtPaths — path-driven; used by ad-hoc
|
|
9
|
+
* adopter scripts (no JSON Schema available; field paths known by
|
|
10
|
+
* convention).
|
|
11
|
+
* 3. rewriteBodyLinks — standalone body-side counterpart. Parallel to
|
|
12
|
+
* `transformContent` but with a callback model instead of templates.
|
|
13
|
+
*
|
|
14
|
+
* See docs/superpowers/specs/2026-05-17-frontmatter-editor-and-yaml-consolidation-design.md
|
|
15
|
+
* §6 for the design contract.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { isNode } from 'yaml';
|
|
19
|
+
|
|
20
|
+
import type { FrontmatterEditor } from './frontmatter-editor.js';
|
|
21
|
+
import { jsonPointerToPath } from './json-pointer-path.js';
|
|
22
|
+
import { walkFrontmatterUriReferences } from './schema-uri-walker.js';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Normalize a value returned by `FrontmatterEditor.get(path)` into plain JS.
|
|
26
|
+
*
|
|
27
|
+
* The underlying yaml library's `getIn(path, false)` unwraps scalar leaves to
|
|
28
|
+
* JS primitives but leaves YAMLMap / YAMLSeq collections as Node instances.
|
|
29
|
+
* The rewriter helpers reason in terms of JS values (arrays, strings), so we
|
|
30
|
+
* unwrap collection Nodes here once at the boundary.
|
|
31
|
+
*/
|
|
32
|
+
function toPlainJs(value: unknown): unknown {
|
|
33
|
+
if (isNode(value)) return value.toJSON();
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Type for the rewrite policy callback — same shape for body and frontmatter. */
|
|
38
|
+
export type RewriteHref = (href: string) => string;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Apply `rewriteHref` to every frontmatter scalar whose schema position has
|
|
42
|
+
* a URI-family `format`. Preserves comments via FrontmatterEditor.
|
|
43
|
+
*/
|
|
44
|
+
export function rewriteFrontmatterUriReferencesFromSchema(
|
|
45
|
+
editor: FrontmatterEditor,
|
|
46
|
+
schema: object,
|
|
47
|
+
rewriteHref: RewriteHref,
|
|
48
|
+
): void {
|
|
49
|
+
const frontmatter = editor.get([]);
|
|
50
|
+
if (frontmatter === undefined || frontmatter === null) return;
|
|
51
|
+
if (typeof frontmatter !== 'object') return;
|
|
52
|
+
|
|
53
|
+
const captures = walkFrontmatterUriReferences(frontmatter, schema);
|
|
54
|
+
for (const capture of captures) {
|
|
55
|
+
const path = jsonPointerToPath(capture.pointer);
|
|
56
|
+
if (path.length === 0) continue;
|
|
57
|
+
const newValue = rewriteHref(capture.value);
|
|
58
|
+
if (newValue === capture.value) continue;
|
|
59
|
+
const lastSegment = path.at(-1);
|
|
60
|
+
if (typeof lastSegment === 'number') {
|
|
61
|
+
const parentPath = path.slice(0, -1);
|
|
62
|
+
editor.setArrayItem(parentPath, lastSegment, newValue);
|
|
63
|
+
} else {
|
|
64
|
+
editor.set(path, newValue);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface ParsedPath {
|
|
70
|
+
segments: readonly (string | number)[];
|
|
71
|
+
isArray: boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function parsePathPattern(pattern: string): ParsedPath {
|
|
75
|
+
const isArray = pattern.endsWith('[]');
|
|
76
|
+
const cleaned = isArray ? pattern.slice(0, -2) : pattern;
|
|
77
|
+
const segments = cleaned.split('.');
|
|
78
|
+
return { segments, isArray };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function rewriteArrayPath(
|
|
82
|
+
editor: FrontmatterEditor,
|
|
83
|
+
segments: readonly (string | number)[],
|
|
84
|
+
value: unknown,
|
|
85
|
+
rewriteHref: RewriteHref,
|
|
86
|
+
): void {
|
|
87
|
+
if (!Array.isArray(value)) return;
|
|
88
|
+
for (const [i, item] of value.entries()) {
|
|
89
|
+
if (typeof item !== 'string') continue;
|
|
90
|
+
const newValue = rewriteHref(item);
|
|
91
|
+
if (newValue !== item) editor.setArrayItem(segments, i, newValue);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function rewriteScalarPath(
|
|
96
|
+
editor: FrontmatterEditor,
|
|
97
|
+
segments: readonly (string | number)[],
|
|
98
|
+
value: unknown,
|
|
99
|
+
rewriteHref: RewriteHref,
|
|
100
|
+
): void {
|
|
101
|
+
if (typeof value !== 'string') return;
|
|
102
|
+
const newValue = rewriteHref(value);
|
|
103
|
+
if (newValue !== value) editor.set(segments, newValue);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Apply `rewriteHref` to specific frontmatter fields named by explicit
|
|
108
|
+
* dotted paths. Used by adopter scripts that know which fields hold
|
|
109
|
+
* references without a JSON Schema.
|
|
110
|
+
*
|
|
111
|
+
* Path syntax:
|
|
112
|
+
* - 'name' — top-level scalar
|
|
113
|
+
* - 'name[]' — array of strings (rewrite each string item)
|
|
114
|
+
* - 'meta.parent' — dotted nested scalar
|
|
115
|
+
* - 'meta.refs[]' — dotted nested array of strings
|
|
116
|
+
*
|
|
117
|
+
* Missing paths are silent no-ops. Non-string values are skipped.
|
|
118
|
+
*/
|
|
119
|
+
export function rewriteFrontmatterFieldsAtPaths(
|
|
120
|
+
editor: FrontmatterEditor,
|
|
121
|
+
paths: readonly string[],
|
|
122
|
+
rewriteHref: RewriteHref,
|
|
123
|
+
): void {
|
|
124
|
+
for (const pattern of paths) {
|
|
125
|
+
const { segments, isArray } = parsePathPattern(pattern);
|
|
126
|
+
const value = toPlainJs(editor.get(segments));
|
|
127
|
+
if (value === undefined || value === null) continue;
|
|
128
|
+
if (isArray) {
|
|
129
|
+
rewriteArrayPath(editor, segments, value, rewriteHref);
|
|
130
|
+
} else {
|
|
131
|
+
rewriteScalarPath(editor, segments, value, rewriteHref);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Inline + reference-style link patterns. These match `transformContent`'s
|
|
138
|
+
* regex contract — see content-transform.ts for the rationale on negated
|
|
139
|
+
* character classes.
|
|
140
|
+
*/
|
|
141
|
+
// eslint-disable-next-line sonarjs/slow-regex -- negated character classes [^\]] and [^)] are non-backtracking
|
|
142
|
+
const INLINE_LINK_REGEX = /\[([^\]]*)\]\(([^)]*)\)/g;
|
|
143
|
+
// eslint-disable-next-line sonarjs/slow-regex -- Controlled markdown reference link definitions on line boundaries
|
|
144
|
+
const DEFINITION_LINK_REGEX = /^\[([^\]]*?)\]:\s*(.+)$/gm;
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Apply `rewriteHref` to every inline-link and reference-definition href in
|
|
148
|
+
* a markdown body. Parallel to `transformContent` but with a callback model
|
|
149
|
+
* — appropriate for adopter scripts and for any caller that wants a
|
|
150
|
+
* per-href rewrite without rule/template ceremony.
|
|
151
|
+
*
|
|
152
|
+
* `rewriteHref` receives the raw href (including any fragment) and returns
|
|
153
|
+
* the new href. Returning the same string is a no-op for that link.
|
|
154
|
+
*/
|
|
155
|
+
export function rewriteBodyLinks(body: string, rewriteHref: RewriteHref): string {
|
|
156
|
+
let out = body.replaceAll(INLINE_LINK_REGEX, (full, text: string, href: string) => {
|
|
157
|
+
const next = rewriteHref(href);
|
|
158
|
+
return next === href ? full : `[${text}](${next})`;
|
|
159
|
+
});
|
|
160
|
+
out = out.replaceAll(DEFINITION_LINK_REGEX, (full, ref: string, href: string) => {
|
|
161
|
+
const trimmed = href.trim();
|
|
162
|
+
const next = rewriteHref(trimmed);
|
|
163
|
+
return next === trimmed ? full : `[${ref}]: ${next}`;
|
|
164
|
+
});
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
@@ -96,6 +96,8 @@ export const ResourcesConfigSchema = z.object({
|
|
|
96
96
|
.describe('Global default exclude patterns (not used by collections in Phase 2)'),
|
|
97
97
|
collections: z.record(z.string(), CollectionConfigSchema).optional()
|
|
98
98
|
.describe('Named collections of resources'),
|
|
99
|
+
validation: ValidationConfigSchema.optional()
|
|
100
|
+
.describe('Validation framework config: severity overrides and per-code allow entries (applied inside ResourceRegistry.validate)'),
|
|
99
101
|
}).describe('Resources section of project configuration');
|
|
100
102
|
|
|
101
103
|
export type ResourcesConfig = z.infer<typeof ResourcesConfigSchema>;
|
|
@@ -1,32 +1,7 @@
|
|
|
1
|
+
import { ValidationIssueSchema } from '@vibe-agent-toolkit/agent-schema';
|
|
1
2
|
import { z } from 'zod';
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
* A single validation issue found during link validation.
|
|
5
|
-
*
|
|
6
|
-
* Issue types:
|
|
7
|
-
* - broken_file: Local file link points to non-existent file
|
|
8
|
-
* - broken_anchor: Anchor link points to non-existent heading
|
|
9
|
-
* - frontmatter_missing: Schema requires frontmatter, file has none
|
|
10
|
-
* - frontmatter_invalid_yaml: YAML syntax error in frontmatter
|
|
11
|
-
* - frontmatter_schema_error: Frontmatter fails JSON Schema validation
|
|
12
|
-
* - external_url_dead: External URL returned error status (4xx, 5xx)
|
|
13
|
-
* - external_url_timeout: External URL request timed out
|
|
14
|
-
* - external_url_error: External URL validation failed (DNS, network, etc.)
|
|
15
|
-
* - unknown_link: Unknown link type
|
|
16
|
-
*
|
|
17
|
-
* Includes details about what went wrong, where it occurred, and optionally
|
|
18
|
-
* how to fix it.
|
|
19
|
-
*/
|
|
20
|
-
export const ValidationIssueSchema = z.object({
|
|
21
|
-
resourcePath: z.string().describe('Absolute path to the resource containing the issue'),
|
|
22
|
-
line: z.number().int().positive().optional().describe('Line number where the issue occurs'),
|
|
23
|
-
type: z.string().describe('Issue type identifier (e.g., "broken_file", "broken_anchor", "frontmatter_schema_error", "unknown_link")'),
|
|
24
|
-
link: z.string().describe('The problematic link'),
|
|
25
|
-
message: z.string().describe('Human-readable description of the issue'),
|
|
26
|
-
suggestion: z.string().optional().describe('Optional suggestion for fixing the issue'),
|
|
27
|
-
}).describe('A single validation issue found during link validation');
|
|
28
|
-
|
|
29
|
-
export type ValidationIssue = z.infer<typeof ValidationIssueSchema>;
|
|
4
|
+
export { type ValidationIssue, ValidationIssueSchema } from '@vibe-agent-toolkit/agent-schema';
|
|
30
5
|
|
|
31
6
|
/**
|
|
32
7
|
* Complete results from validating a collection of resources.
|
|
@@ -38,9 +13,10 @@ export const ValidationResultSchema = z.object({
|
|
|
38
13
|
totalResources: z.number().int().nonnegative().describe('Total number of resources validated'),
|
|
39
14
|
totalLinks: z.number().int().nonnegative().describe('Total number of links found across all resources'),
|
|
40
15
|
linksByType: z.record(z.string(), z.number().int().nonnegative()).describe('Count of links by type (e.g., {"local_file": 10, "external": 5})'),
|
|
41
|
-
issues: z.array(ValidationIssueSchema).describe('
|
|
42
|
-
errorCount: z.number().int().nonnegative().describe('Number of issues
|
|
16
|
+
issues: z.array(ValidationIssueSchema).describe('Emitted validation issues (allow-filtered + severity-resolved; ignored issues dropped)'),
|
|
17
|
+
errorCount: z.number().int().nonnegative().describe('Number of emitted issues'),
|
|
43
18
|
passed: z.boolean().describe('True if validation succeeded (errorCount === 0)'),
|
|
19
|
+
hasErrors: z.boolean().describe('True when any emitted issue has resolved severity "error"'),
|
|
44
20
|
durationMs: z.number().nonnegative().describe('Validation duration in milliseconds'),
|
|
45
21
|
timestamp: z.date().describe('When validation was performed'),
|
|
46
22
|
}).describe('Complete results from validating a collection of resources');
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { promises as fs } from 'node:fs';
|
|
9
9
|
|
|
10
|
-
import yaml from '
|
|
10
|
+
import * as yaml from 'yaml';
|
|
11
11
|
|
|
12
12
|
import { calculateChecksum } from '../checksum.js';
|
|
13
13
|
import { parseMarkdown } from '../link-parser.js';
|
|
@@ -213,8 +213,7 @@ export async function parseYamlResource(
|
|
|
213
213
|
// Read and parse YAML
|
|
214
214
|
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
215
215
|
const content = await fs.readFile(absolutePath, 'utf-8');
|
|
216
|
-
|
|
217
|
-
const data = yaml.load(content, { schema: yaml.CORE_SCHEMA });
|
|
216
|
+
const data = yaml.parse(content);
|
|
218
217
|
|
|
219
218
|
// Calculate checksum
|
|
220
219
|
const checksum = await calculateChecksum(absolutePath);
|
package/src/types/resources.ts
CHANGED
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
* with optional absolutePath for runtime operations.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import type { ValidationIssue } from '@vibe-agent-toolkit/agent-schema';
|
|
10
|
+
|
|
9
11
|
import type { SHA256 } from '../schemas/checksum.js';
|
|
10
|
-
import type { ValidationIssue } from '../schemas/validation-result.js';
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Resource type discriminator for type-safe handling
|
package/src/types.ts
CHANGED
package/src/utils.ts
CHANGED
|
@@ -9,6 +9,21 @@ import path from 'node:path';
|
|
|
9
9
|
import { toForwardSlash, safePath } from '@vibe-agent-toolkit/utils';
|
|
10
10
|
import picomatch from 'picomatch';
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Compute a `ValidationIssue.location`: the (absolute) source file path made
|
|
14
|
+
* relative to the project root. When no project root is known, fall back to the
|
|
15
|
+
* source path forward-slashed so the location is still useful and stable.
|
|
16
|
+
*
|
|
17
|
+
* @param sourceFilePath - Absolute path to the file the issue was found in.
|
|
18
|
+
* @param projectRoot - Project root, or undefined when none could be determined.
|
|
19
|
+
* @returns Forward-slashed relative location (or the forward-slashed absolute path).
|
|
20
|
+
*/
|
|
21
|
+
export function issueLocation(sourceFilePath: string, projectRoot: string | undefined): string {
|
|
22
|
+
return projectRoot === undefined
|
|
23
|
+
? toForwardSlash(sourceFilePath)
|
|
24
|
+
: safePath.relative(projectRoot, sourceFilePath);
|
|
25
|
+
}
|
|
26
|
+
|
|
12
27
|
/**
|
|
13
28
|
* Check if a file path matches a glob pattern.
|
|
14
29
|
*
|
|
@@ -73,35 +88,58 @@ export function splitHrefAnchor(href: string): [string, string | undefined] {
|
|
|
73
88
|
}
|
|
74
89
|
|
|
75
90
|
/**
|
|
76
|
-
*
|
|
91
|
+
* Discriminated union returned by {@link resolveLocalHref}.
|
|
92
|
+
*
|
|
93
|
+
* - `anchor_only` — the href was `#fragment` only (no file component).
|
|
94
|
+
* - `resolved` — the href resolved to an absolute filesystem path.
|
|
95
|
+
* - `absolute_no_root` — the href is an RFC 3986 §4.2 absolute-path
|
|
96
|
+
* reference (starts with `/`) but no `projectRoot` was supplied.
|
|
97
|
+
* - `absolute_escapes_root` — the absolute-path reference resolved to a
|
|
98
|
+
* location outside `projectRoot` (e.g., via `..` traversal or a symlink
|
|
99
|
+
* pointing outside the project).
|
|
100
|
+
*/
|
|
101
|
+
export type ResolveLocalHrefResult =
|
|
102
|
+
| { kind: 'anchor_only' }
|
|
103
|
+
| { kind: 'resolved'; resolvedPath: string; anchor: string | undefined }
|
|
104
|
+
| { kind: 'absolute_no_root'; href: string; anchor: string | undefined }
|
|
105
|
+
| { kind: 'absolute_escapes_root'; href: string; anchor: string | undefined };
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Resolve a markdown link href to a filesystem path or a typed failure.
|
|
77
109
|
*
|
|
78
110
|
* Performs the standard href → path conversion used by both audit and validate:
|
|
79
111
|
* 1. Strips anchor fragment (`#section`)
|
|
80
112
|
* 2. Decodes URL-encoded characters (`%20` → space, `%26` → `&`)
|
|
81
|
-
* 3. Resolves the path
|
|
82
|
-
*
|
|
83
|
-
*
|
|
113
|
+
* 3. Resolves the path:
|
|
114
|
+
* - Leading `/` (RFC 3986 §4.2 absolute-path reference) → resolve against
|
|
115
|
+
* `projectRoot`. Requires a `projectRoot`; must not escape it.
|
|
116
|
+
* - Otherwise → resolve relative to the source file's directory.
|
|
84
117
|
*
|
|
85
118
|
* @param href - Raw href from a markdown link
|
|
86
119
|
* @param sourceFilePath - Absolute path of the file containing the link
|
|
87
|
-
* @
|
|
120
|
+
* @param projectRoot - Optional project root for absolute-path references.
|
|
121
|
+
* @returns A {@link ResolveLocalHrefResult} discriminating success vs failure modes.
|
|
88
122
|
*
|
|
89
123
|
* @example
|
|
90
124
|
* ```typescript
|
|
91
125
|
* resolveLocalHref('My%20Folder/doc.md#intro', '/project/README.md')
|
|
92
|
-
* // { resolvedPath: '/project/My Folder/doc.md', anchor: 'intro' }
|
|
126
|
+
* // { kind: 'resolved', resolvedPath: '/project/My Folder/doc.md', anchor: 'intro' }
|
|
93
127
|
*
|
|
94
128
|
* resolveLocalHref('#heading', '/project/README.md')
|
|
95
|
-
* //
|
|
129
|
+
* // { kind: 'anchor_only' }
|
|
130
|
+
*
|
|
131
|
+
* resolveLocalHref('/docs/foo.md', '/project/docs/sub/page.md', '/project')
|
|
132
|
+
* // { kind: 'resolved', resolvedPath: '/project/docs/foo.md', anchor: undefined }
|
|
96
133
|
* ```
|
|
97
134
|
*/
|
|
98
135
|
export function resolveLocalHref(
|
|
99
136
|
href: string,
|
|
100
137
|
sourceFilePath: string,
|
|
101
|
-
|
|
138
|
+
projectRoot?: string,
|
|
139
|
+
): ResolveLocalHrefResult {
|
|
102
140
|
const [fileHref, anchor] = splitHrefAnchor(href);
|
|
103
141
|
if (fileHref === '') {
|
|
104
|
-
return
|
|
142
|
+
return { kind: 'anchor_only' };
|
|
105
143
|
}
|
|
106
144
|
|
|
107
145
|
let decodedHref: string;
|
|
@@ -111,10 +149,22 @@ export function resolveLocalHref(
|
|
|
111
149
|
decodedHref = fileHref;
|
|
112
150
|
}
|
|
113
151
|
|
|
152
|
+
// RFC 3986 §4.2 absolute-path reference — resolve against projectRoot.
|
|
153
|
+
if (decodedHref.startsWith('/')) {
|
|
154
|
+
if (!projectRoot) {
|
|
155
|
+
return { kind: 'absolute_no_root', href: fileHref, anchor };
|
|
156
|
+
}
|
|
157
|
+
const candidate = safePath.resolve(projectRoot, decodedHref.slice(1));
|
|
158
|
+
if (!isWithinProject(candidate, projectRoot)) {
|
|
159
|
+
return { kind: 'absolute_escapes_root', href: fileHref, anchor };
|
|
160
|
+
}
|
|
161
|
+
return { kind: 'resolved', resolvedPath: candidate, anchor };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Relative reference — resolve against the source file's directory.
|
|
114
165
|
const sourceDir = path.dirname(sourceFilePath);
|
|
115
166
|
const resolvedPath = safePath.resolve(sourceDir, decodedHref);
|
|
116
|
-
|
|
117
|
-
return { resolvedPath, anchor };
|
|
167
|
+
return { kind: 'resolved', resolvedPath, anchor };
|
|
118
168
|
}
|
|
119
169
|
|
|
120
170
|
/**
|
|
@@ -135,7 +185,9 @@ export function resolveLocalHref(
|
|
|
135
185
|
* ```
|
|
136
186
|
*/
|
|
137
187
|
export function isWithinProject(filePath: string, projectRoot: string): boolean {
|
|
138
|
-
//
|
|
188
|
+
// Canonicalize both sides symmetrically. Asymmetric handling (realpath one
|
|
189
|
+
// side, resolve the other) false-flags legitimate matches when projectRoot
|
|
190
|
+
// traverses a symlink — e.g. macOS /tmp → /private/tmp, bind mounts.
|
|
139
191
|
let resolvedFilePath: string;
|
|
140
192
|
try {
|
|
141
193
|
// eslint-disable-next-line security/detect-non-literal-fs-filename -- filePath is validated path parameter
|
|
@@ -145,7 +197,13 @@ export function isWithinProject(filePath: string, projectRoot: string): boolean
|
|
|
145
197
|
resolvedFilePath = safePath.resolve(filePath);
|
|
146
198
|
}
|
|
147
199
|
|
|
148
|
-
|
|
200
|
+
let resolvedProjectRoot: string;
|
|
201
|
+
try {
|
|
202
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename -- projectRoot is validated path parameter
|
|
203
|
+
resolvedProjectRoot = fs.realpathSync(projectRoot);
|
|
204
|
+
} catch {
|
|
205
|
+
resolvedProjectRoot = safePath.resolve(projectRoot);
|
|
206
|
+
}
|
|
149
207
|
|
|
150
208
|
// Normalize to forward slashes for cross-platform comparison
|
|
151
209
|
const normalizedFile = toForwardSlash(resolvedFilePath);
|
|
@@ -199,7 +257,7 @@ export function formatJsonPointerAsDotted(pointer: string): string {
|
|
|
199
257
|
return out;
|
|
200
258
|
}
|
|
201
259
|
|
|
202
|
-
function isCanonicalArrayIndex(s: string): boolean {
|
|
260
|
+
export function isCanonicalArrayIndex(s: string): boolean {
|
|
203
261
|
// Canonical integer per RFC 6901 §4 + JSON canonical form: no leading zeros
|
|
204
262
|
// except for "0" itself.
|
|
205
263
|
if (s === '') return false;
|