@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.
Files changed (87) hide show
  1. package/README.md +35 -26
  2. package/dist/ajv-factory.d.ts +33 -0
  3. package/dist/ajv-factory.d.ts.map +1 -0
  4. package/dist/ajv-factory.js +51 -0
  5. package/dist/ajv-factory.js.map +1 -0
  6. package/dist/config-parser.d.ts +0 -18
  7. package/dist/config-parser.d.ts.map +1 -1
  8. package/dist/config-parser.js +5 -46
  9. package/dist/config-parser.js.map +1 -1
  10. package/dist/frontmatter-editor.d.ts +45 -0
  11. package/dist/frontmatter-editor.d.ts.map +1 -0
  12. package/dist/frontmatter-editor.js +161 -0
  13. package/dist/frontmatter-editor.js.map +1 -0
  14. package/dist/frontmatter-link-validator.d.ts +5 -5
  15. package/dist/frontmatter-link-validator.d.ts.map +1 -1
  16. package/dist/frontmatter-link-validator.js +25 -24
  17. package/dist/frontmatter-link-validator.js.map +1 -1
  18. package/dist/frontmatter-validator.d.ts +3 -2
  19. package/dist/frontmatter-validator.d.ts.map +1 -1
  20. package/dist/frontmatter-validator.js +19 -20
  21. package/dist/frontmatter-validator.js.map +1 -1
  22. package/dist/index.d.ts +5 -2
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +9 -1
  25. package/dist/index.js.map +1 -1
  26. package/dist/json-pointer-path.d.ts +13 -0
  27. package/dist/json-pointer-path.d.ts.map +1 -0
  28. package/dist/json-pointer-path.js +30 -0
  29. package/dist/json-pointer-path.js.map +1 -0
  30. package/dist/link-parser.js +14 -16
  31. package/dist/link-parser.js.map +1 -1
  32. package/dist/link-validator.d.ts +23 -1
  33. package/dist/link-validator.d.ts.map +1 -1
  34. package/dist/link-validator.js +107 -104
  35. package/dist/link-validator.js.map +1 -1
  36. package/dist/multi-schema-validator.d.ts.map +1 -1
  37. package/dist/multi-schema-validator.js +6 -8
  38. package/dist/multi-schema-validator.js.map +1 -1
  39. package/dist/resource-registry.d.ts +10 -2
  40. package/dist/resource-registry.d.ts.map +1 -1
  41. package/dist/resource-registry.js +25 -32
  42. package/dist/resource-registry.js.map +1 -1
  43. package/dist/rewriter-helpers.d.ts +49 -0
  44. package/dist/rewriter-helpers.d.ts.map +1 -0
  45. package/dist/rewriter-helpers.js +142 -0
  46. package/dist/rewriter-helpers.js.map +1 -0
  47. package/dist/schemas/project-config.d.ts +219 -171
  48. package/dist/schemas/project-config.d.ts.map +1 -1
  49. package/dist/schemas/project-config.js +2 -0
  50. package/dist/schemas/project-config.js.map +1 -1
  51. package/dist/schemas/validation-result.d.ts +36 -57
  52. package/dist/schemas/validation-result.d.ts.map +1 -1
  53. package/dist/schemas/validation-result.js +5 -27
  54. package/dist/schemas/validation-result.js.map +1 -1
  55. package/dist/types/resource-parser.d.ts.map +1 -1
  56. package/dist/types/resource-parser.js +2 -3
  57. package/dist/types/resource-parser.js.map +1 -1
  58. package/dist/types/resources.d.ts +1 -1
  59. package/dist/types/resources.d.ts.map +1 -1
  60. package/dist/types/resources.js.map +1 -1
  61. package/dist/types.d.ts +1 -1
  62. package/dist/types.d.ts.map +1 -1
  63. package/dist/types.js +1 -1
  64. package/dist/types.js.map +1 -1
  65. package/dist/utils.d.ts +50 -11
  66. package/dist/utils.d.ts.map +1 -1
  67. package/dist/utils.js +53 -13
  68. package/dist/utils.js.map +1 -1
  69. package/package.json +5 -5
  70. package/src/ajv-factory.ts +56 -0
  71. package/src/config-parser.ts +5 -51
  72. package/src/frontmatter-editor.ts +214 -0
  73. package/src/frontmatter-link-validator.ts +23 -25
  74. package/src/frontmatter-validator.ts +29 -22
  75. package/src/index.ts +21 -2
  76. package/src/json-pointer-path.ts +29 -0
  77. package/src/link-parser.ts +27 -20
  78. package/src/link-validator.ts +194 -119
  79. package/src/multi-schema-validator.ts +10 -8
  80. package/src/resource-registry.ts +48 -33
  81. package/src/rewriter-helpers.ts +166 -0
  82. package/src/schemas/project-config.ts +2 -0
  83. package/src/schemas/validation-result.ts +5 -29
  84. package/src/types/resource-parser.ts +2 -3
  85. package/src/types/resources.ts +2 -1
  86. package/src/types.ts +0 -1
  87. 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
- resourcePath,
94
- line: 1,
95
- type: 'frontmatter_schema_error',
96
- link: '',
97
- message: `Failed to load or validate schema ${schemaRef.schema}: ${message}`,
98
- }],
94
+ errors: [
95
+ createRegistryIssue(
96
+ 'FRONTMATTER_SCHEMA_ERROR',
97
+ `Failed to load or validate schema ${schemaRef.schema}: ${message}`,
98
+ { location: issueLocation(resourcePath, projectRoot), line: 1 },
99
+ ),
100
+ ],
99
101
  });
100
102
  }
101
103
  }
@@ -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 { ValidationIssue, ValidationResult } from './schemas/validation-result.js';
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
- resourcePath: resource.filePath,
427
- line: 1,
428
- type: 'frontmatter_invalid_yaml',
429
- link: '',
430
- message: `Invalid YAML syntax in frontmatter: ${resource.frontmatterError}`,
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
- resourcePath: resource.filePath,
629
- line: 1,
630
- type: 'frontmatter_schema_error',
631
- link: '',
632
- message: `Failed to load or parse frontmatter schema '${validation.frontmatterSchema}': ${errorMessage}`,
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
- // Count issues (all are errors now)
710
- const errorCount = issues.length;
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 issueType = this.determineExternalUrlIssueType(result.statusCode, result.error);
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
- resourcePath: location.resourcePath,
839
- line: location.line,
840
- type: issueType,
841
- link: result.url,
842
- message: `External URL failed: ${errorMessage}`,
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 type based on validation error.
866
+ * Determine the registry issue code based on the external-URL validation error.
852
867
  * @private
853
868
  */
854
- private determineExternalUrlIssueType(statusCode: number, error?: string): string {
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 'external_url_timeout';
873
+ return 'EXTERNAL_URL_TIMEOUT';
859
874
  }
860
- return 'external_url_error';
875
+ return 'EXTERNAL_URL_ERROR';
861
876
  }
862
- return 'external_url_dead';
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('All validation issues found'),
42
- errorCount: z.number().int().nonnegative().describe('Number of issues found'),
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 'js-yaml';
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
- // CORE_SCHEMA: YAML 1.2 spec — see link-parser.ts for rationale.
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);
@@ -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
@@ -89,7 +89,6 @@ export type {
89
89
 
90
90
  // Config parsing
91
91
  export {
92
- findConfigFile,
93
92
  parseConfigFile,
94
93
  loadConfig,
95
94
  } from './config-parser.js';
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
- * Resolve a markdown link href to an absolute filesystem path.
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 relative to the source file's directory
82
- *
83
- * Returns `null` for anchor-only links (e.g., `#heading`).
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
- * @returns Resolved path info, or null for anchor-only links
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
- * // null
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
- ): { resolvedPath: string; anchor: string | undefined } | null {
138
+ projectRoot?: string,
139
+ ): ResolveLocalHrefResult {
102
140
  const [fileHref, anchor] = splitHrefAnchor(href);
103
141
  if (fileHref === '') {
104
- return null;
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
- // Resolve symlinks to get real paths
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
- const resolvedProjectRoot = safePath.resolve(projectRoot);
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;