@vibe-agent-toolkit/resources 0.1.37 → 0.1.38

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 (58) hide show
  1. package/README.md +2 -2
  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-validator.d.ts.map +1 -1
  15. package/dist/frontmatter-validator.js +11 -6
  16. package/dist/frontmatter-validator.js.map +1 -1
  17. package/dist/index.d.ts +5 -2
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +9 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/json-pointer-path.d.ts +13 -0
  22. package/dist/json-pointer-path.d.ts.map +1 -0
  23. package/dist/json-pointer-path.js +30 -0
  24. package/dist/json-pointer-path.js.map +1 -0
  25. package/dist/link-parser.js +2 -11
  26. package/dist/link-parser.js.map +1 -1
  27. package/dist/link-validator.d.ts +22 -0
  28. package/dist/link-validator.d.ts.map +1 -1
  29. package/dist/link-validator.js +126 -75
  30. package/dist/link-validator.js.map +1 -1
  31. package/dist/rewriter-helpers.d.ts +49 -0
  32. package/dist/rewriter-helpers.d.ts.map +1 -0
  33. package/dist/rewriter-helpers.js +142 -0
  34. package/dist/rewriter-helpers.js.map +1 -0
  35. package/dist/types/resource-parser.d.ts.map +1 -1
  36. package/dist/types/resource-parser.js +2 -3
  37. package/dist/types/resource-parser.js.map +1 -1
  38. package/dist/types.d.ts +1 -1
  39. package/dist/types.d.ts.map +1 -1
  40. package/dist/types.js +1 -1
  41. package/dist/types.js.map +1 -1
  42. package/dist/utils.d.ts +40 -11
  43. package/dist/utils.d.ts.map +1 -1
  44. package/dist/utils.js +39 -13
  45. package/dist/utils.js.map +1 -1
  46. package/package.json +5 -5
  47. package/src/ajv-factory.ts +56 -0
  48. package/src/config-parser.ts +5 -51
  49. package/src/frontmatter-editor.ts +214 -0
  50. package/src/frontmatter-validator.ts +11 -7
  51. package/src/index.ts +21 -2
  52. package/src/json-pointer-path.ts +29 -0
  53. package/src/link-parser.ts +2 -11
  54. package/src/link-validator.ts +153 -88
  55. package/src/rewriter-helpers.ts +166 -0
  56. package/src/types/resource-parser.ts +2 -3
  57. package/src/types.ts +0 -1
  58. package/src/utils.ts +57 -14
@@ -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
+ }
@@ -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);
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
@@ -73,35 +73,58 @@ export function splitHrefAnchor(href: string): [string, string | undefined] {
73
73
  }
74
74
 
75
75
  /**
76
- * Resolve a markdown link href to an absolute filesystem path.
76
+ * Discriminated union returned by {@link resolveLocalHref}.
77
+ *
78
+ * - `anchor_only` — the href was `#fragment` only (no file component).
79
+ * - `resolved` — the href resolved to an absolute filesystem path.
80
+ * - `absolute_no_root` — the href is an RFC 3986 §4.2 absolute-path
81
+ * reference (starts with `/`) but no `projectRoot` was supplied.
82
+ * - `absolute_escapes_root` — the absolute-path reference resolved to a
83
+ * location outside `projectRoot` (e.g., via `..` traversal or a symlink
84
+ * pointing outside the project).
85
+ */
86
+ export type ResolveLocalHrefResult =
87
+ | { kind: 'anchor_only' }
88
+ | { kind: 'resolved'; resolvedPath: string; anchor: string | undefined }
89
+ | { kind: 'absolute_no_root'; href: string; anchor: string | undefined }
90
+ | { kind: 'absolute_escapes_root'; href: string; anchor: string | undefined };
91
+
92
+ /**
93
+ * Resolve a markdown link href to a filesystem path or a typed failure.
77
94
  *
78
95
  * Performs the standard href → path conversion used by both audit and validate:
79
96
  * 1. Strips anchor fragment (`#section`)
80
97
  * 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`).
98
+ * 3. Resolves the path:
99
+ * - Leading `/` (RFC 3986 §4.2 absolute-path reference) → resolve against
100
+ * `projectRoot`. Requires a `projectRoot`; must not escape it.
101
+ * - Otherwise → resolve relative to the source file's directory.
84
102
  *
85
103
  * @param href - Raw href from a markdown link
86
104
  * @param sourceFilePath - Absolute path of the file containing the link
87
- * @returns Resolved path info, or null for anchor-only links
105
+ * @param projectRoot - Optional project root for absolute-path references.
106
+ * @returns A {@link ResolveLocalHrefResult} discriminating success vs failure modes.
88
107
  *
89
108
  * @example
90
109
  * ```typescript
91
110
  * resolveLocalHref('My%20Folder/doc.md#intro', '/project/README.md')
92
- * // { resolvedPath: '/project/My Folder/doc.md', anchor: 'intro' }
111
+ * // { kind: 'resolved', resolvedPath: '/project/My Folder/doc.md', anchor: 'intro' }
93
112
  *
94
113
  * resolveLocalHref('#heading', '/project/README.md')
95
- * // null
114
+ * // { kind: 'anchor_only' }
115
+ *
116
+ * resolveLocalHref('/docs/foo.md', '/project/docs/sub/page.md', '/project')
117
+ * // { kind: 'resolved', resolvedPath: '/project/docs/foo.md', anchor: undefined }
96
118
  * ```
97
119
  */
98
120
  export function resolveLocalHref(
99
121
  href: string,
100
122
  sourceFilePath: string,
101
- ): { resolvedPath: string; anchor: string | undefined } | null {
123
+ projectRoot?: string,
124
+ ): ResolveLocalHrefResult {
102
125
  const [fileHref, anchor] = splitHrefAnchor(href);
103
126
  if (fileHref === '') {
104
- return null;
127
+ return { kind: 'anchor_only' };
105
128
  }
106
129
 
107
130
  let decodedHref: string;
@@ -111,10 +134,22 @@ export function resolveLocalHref(
111
134
  decodedHref = fileHref;
112
135
  }
113
136
 
137
+ // RFC 3986 §4.2 absolute-path reference — resolve against projectRoot.
138
+ if (decodedHref.startsWith('/')) {
139
+ if (!projectRoot) {
140
+ return { kind: 'absolute_no_root', href: fileHref, anchor };
141
+ }
142
+ const candidate = safePath.resolve(projectRoot, decodedHref.slice(1));
143
+ if (!isWithinProject(candidate, projectRoot)) {
144
+ return { kind: 'absolute_escapes_root', href: fileHref, anchor };
145
+ }
146
+ return { kind: 'resolved', resolvedPath: candidate, anchor };
147
+ }
148
+
149
+ // Relative reference — resolve against the source file's directory.
114
150
  const sourceDir = path.dirname(sourceFilePath);
115
151
  const resolvedPath = safePath.resolve(sourceDir, decodedHref);
116
-
117
- return { resolvedPath, anchor };
152
+ return { kind: 'resolved', resolvedPath, anchor };
118
153
  }
119
154
 
120
155
  /**
@@ -135,7 +170,9 @@ export function resolveLocalHref(
135
170
  * ```
136
171
  */
137
172
  export function isWithinProject(filePath: string, projectRoot: string): boolean {
138
- // Resolve symlinks to get real paths
173
+ // Canonicalize both sides symmetrically. Asymmetric handling (realpath one
174
+ // side, resolve the other) false-flags legitimate matches when projectRoot
175
+ // traverses a symlink — e.g. macOS /tmp → /private/tmp, bind mounts.
139
176
  let resolvedFilePath: string;
140
177
  try {
141
178
  // eslint-disable-next-line security/detect-non-literal-fs-filename -- filePath is validated path parameter
@@ -145,7 +182,13 @@ export function isWithinProject(filePath: string, projectRoot: string): boolean
145
182
  resolvedFilePath = safePath.resolve(filePath);
146
183
  }
147
184
 
148
- const resolvedProjectRoot = safePath.resolve(projectRoot);
185
+ let resolvedProjectRoot: string;
186
+ try {
187
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- projectRoot is validated path parameter
188
+ resolvedProjectRoot = fs.realpathSync(projectRoot);
189
+ } catch {
190
+ resolvedProjectRoot = safePath.resolve(projectRoot);
191
+ }
149
192
 
150
193
  // Normalize to forward slashes for cross-platform comparison
151
194
  const normalizedFile = toForwardSlash(resolvedFilePath);
@@ -199,7 +242,7 @@ export function formatJsonPointerAsDotted(pointer: string): string {
199
242
  return out;
200
243
  }
201
244
 
202
- function isCanonicalArrayIndex(s: string): boolean {
245
+ export function isCanonicalArrayIndex(s: string): boolean {
203
246
  // Canonical integer per RFC 6901 §4 + JSON canonical form: no leading zeros
204
247
  // except for "0" itself.
205
248
  if (s === '') return false;