@vibe-agent-toolkit/resources 0.1.36 → 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.
- package/README.md +2 -2
- 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 -45
- 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-validator.d.ts.map +1 -1
- package/dist/frontmatter-validator.js +11 -6
- 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 +2 -2
- package/dist/link-parser.js.map +1 -1
- package/dist/link-validator.d.ts +22 -0
- package/dist/link-validator.d.ts.map +1 -1
- package/dist/link-validator.js +126 -75
- package/dist/link-validator.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/types/resource-parser.js +2 -2
- package/dist/types/resource-parser.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 +40 -11
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +39 -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 -50
- package/src/frontmatter-editor.ts +214 -0
- package/src/frontmatter-validator.ts +11 -7
- package/src/index.ts +21 -2
- package/src/json-pointer-path.ts +29 -0
- package/src/link-parser.ts +2 -2
- package/src/link-validator.ts +153 -88
- package/src/rewriter-helpers.ts +166 -0
- package/src/types/resource-parser.ts +2 -2
- package/src/types.ts +0 -1
- package/src/utils.ts +57 -14
|
@@ -13,8 +13,7 @@
|
|
|
13
13
|
* This is the ONLY place in the codebase that should use AJV.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import {
|
|
17
|
-
|
|
16
|
+
import { createAjvWithUriFormats } from './ajv-factory.js';
|
|
18
17
|
import type { ValidationMode } from './schemas/project-config.js';
|
|
19
18
|
import type { ValidationIssue } from './schemas/validation-result.js';
|
|
20
19
|
|
|
@@ -65,11 +64,16 @@ export function validateFrontmatter(
|
|
|
65
64
|
effectiveSchema = makeSchemaPermissive(schema);
|
|
66
65
|
}
|
|
67
66
|
|
|
68
|
-
//
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
67
|
+
// Use the shared Ajv factory so the internal validator and any adopter
|
|
68
|
+
// consuming `createAjvWithUriFormats` see identical format behavior.
|
|
69
|
+
// Permissive options match how VAT validates user-supplied schemas:
|
|
70
|
+
// - strict: false so non-strict schemas compile (older JSON Schema drafts).
|
|
71
|
+
// - allErrors: true so we report all issues, not just the first.
|
|
72
|
+
// - allowUnionTypes: true for draft-2019-09+ union type support.
|
|
73
|
+
const ajv = createAjvWithUriFormats({
|
|
74
|
+
strict: false,
|
|
75
|
+
allErrors: true,
|
|
76
|
+
allowUnionTypes: true,
|
|
73
77
|
});
|
|
74
78
|
|
|
75
79
|
const validate = ajv.compile(effectiveSchema);
|
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
|
|
|
@@ -431,7 +431,7 @@ function extractFrontmatter(tree: Root): {
|
|
|
431
431
|
}
|
|
432
432
|
|
|
433
433
|
try {
|
|
434
|
-
const parsed = yaml.
|
|
434
|
+
const parsed = yaml.parse(node.value);
|
|
435
435
|
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
|
436
436
|
frontmatterData = parsed as Record<string, unknown>;
|
|
437
437
|
}
|
package/src/link-validator.ts
CHANGED
|
@@ -15,17 +15,18 @@
|
|
|
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
|
|
|
20
21
|
import {
|
|
21
22
|
isGitIgnored,
|
|
22
23
|
type GitTracker,
|
|
23
|
-
verifyCaseSensitiveFilename,
|
|
24
|
+
verifyCaseSensitiveFilename,
|
|
24
25
|
} from '@vibe-agent-toolkit/utils';
|
|
25
26
|
|
|
26
27
|
import type { ValidationIssue } from './schemas/validation-result.js';
|
|
27
28
|
import type { HeadingNode, ResourceLink } from './types.js';
|
|
28
|
-
import { isWithinProject, resolveLocalHref
|
|
29
|
+
import { isWithinProject, resolveLocalHref } from './utils.js';
|
|
29
30
|
|
|
30
31
|
/**
|
|
31
32
|
* Options for link validation.
|
|
@@ -97,6 +98,120 @@ export async function validateLink(
|
|
|
97
98
|
}
|
|
98
99
|
}
|
|
99
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Convert a resolution failure kind to a broken_file ValidationIssue. Returns
|
|
103
|
+
* null for `resolved` (caller continues) and `anchor_only` (defensive no-op —
|
|
104
|
+
* the parser classifies anchor-only hrefs as 'anchor', not 'local_file').
|
|
105
|
+
*/
|
|
106
|
+
export function resolutionFailureIssue(
|
|
107
|
+
resolved: ReturnType<typeof resolveLocalHref>,
|
|
108
|
+
link: ResourceLink,
|
|
109
|
+
sourceFilePath: string,
|
|
110
|
+
): ValidationIssue | null {
|
|
111
|
+
if (resolved.kind === 'absolute_no_root') {
|
|
112
|
+
return {
|
|
113
|
+
resourcePath: sourceFilePath,
|
|
114
|
+
line: link.line,
|
|
115
|
+
type: 'broken_file',
|
|
116
|
+
link: link.href,
|
|
117
|
+
message:
|
|
118
|
+
`Absolute-path link "${link.href}" requires a configured projectRoot; ` +
|
|
119
|
+
`none was provided. Configure vibe-agent-toolkit.config.yaml or run ` +
|
|
120
|
+
`from within a git repository.`,
|
|
121
|
+
suggestion:
|
|
122
|
+
'Rewrite as a source-relative link, or run from a directory with a config or git ancestor.',
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (resolved.kind === 'absolute_escapes_root') {
|
|
127
|
+
return {
|
|
128
|
+
resourcePath: sourceFilePath,
|
|
129
|
+
line: link.line,
|
|
130
|
+
type: 'broken_file',
|
|
131
|
+
link: link.href,
|
|
132
|
+
message: `Absolute-path link "${link.href}" escapes the project root via path traversal.`,
|
|
133
|
+
suggestion: '',
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Convert a non-existent file result into a broken_file ValidationIssue.
|
|
142
|
+
* Returns null when the file exists.
|
|
143
|
+
*/
|
|
144
|
+
export function fileExistenceIssue(
|
|
145
|
+
fileResult: { exists: boolean; resolvedPath: string; actualName?: string },
|
|
146
|
+
link: ResourceLink,
|
|
147
|
+
sourceFilePath: string,
|
|
148
|
+
): ValidationIssue | null {
|
|
149
|
+
if (fileResult.exists) return null;
|
|
150
|
+
|
|
151
|
+
if (fileResult.actualName) {
|
|
152
|
+
const expectedName = path.basename(fileResult.resolvedPath);
|
|
153
|
+
return {
|
|
154
|
+
resourcePath: sourceFilePath,
|
|
155
|
+
line: link.line,
|
|
156
|
+
type: 'broken_file',
|
|
157
|
+
link: link.href,
|
|
158
|
+
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.`,
|
|
159
|
+
suggestion: `Use "${fileResult.actualName}" instead of "${expectedName}"`,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
resourcePath: sourceFilePath,
|
|
165
|
+
line: link.line,
|
|
166
|
+
type: 'broken_file',
|
|
167
|
+
link: link.href,
|
|
168
|
+
message: `File not found: ${fileResult.resolvedPath}`,
|
|
169
|
+
suggestion: '',
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Check git-ignore safety: a non-ignored source file must not link to a
|
|
175
|
+
* gitignored target. Returns a ValidationIssue when this rule is violated,
|
|
176
|
+
* null otherwise (including when checks are disabled or out of scope).
|
|
177
|
+
*/
|
|
178
|
+
export function gitIgnoreSafetyIssue(
|
|
179
|
+
link: ResourceLink,
|
|
180
|
+
sourceFilePath: string,
|
|
181
|
+
resolvedTarget: string,
|
|
182
|
+
options: ValidateLinkOptions | undefined,
|
|
183
|
+
): ValidationIssue | null {
|
|
184
|
+
if (
|
|
185
|
+
options?.skipGitIgnoreCheck === true ||
|
|
186
|
+
options?.projectRoot === undefined ||
|
|
187
|
+
!isWithinProject(resolvedTarget, options.projectRoot)
|
|
188
|
+
) {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Prefer the O(1) active-set lookup on the shared GitTracker (no spawn).
|
|
193
|
+
// isIgnoredByActiveSet falls back internally to isIgnored for paths outside
|
|
194
|
+
// the project root, so this is safe for the rare out-of-project case.
|
|
195
|
+
// When no tracker is threaded in, fall back to isGitIgnored (one-off spawn).
|
|
196
|
+
const sourceIsIgnored = options.gitTracker
|
|
197
|
+
? options.gitTracker.isIgnoredByActiveSet(sourceFilePath)
|
|
198
|
+
: isGitIgnored(sourceFilePath, options.projectRoot);
|
|
199
|
+
const targetIsIgnored = options.gitTracker
|
|
200
|
+
? options.gitTracker.isIgnoredByActiveSet(resolvedTarget)
|
|
201
|
+
: isGitIgnored(resolvedTarget, options.projectRoot);
|
|
202
|
+
|
|
203
|
+
if (sourceIsIgnored || !targetIsIgnored) return null;
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
resourcePath: sourceFilePath,
|
|
207
|
+
line: link.line,
|
|
208
|
+
type: 'link_to_gitignored',
|
|
209
|
+
link: link.href,
|
|
210
|
+
message: `Non-ignored file links to gitignored file: ${resolvedTarget}. Gitignored files are local-only and will not exist in the repository. Remove this link or unignore the target file.`,
|
|
211
|
+
suggestion: '',
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
100
215
|
/**
|
|
101
216
|
* Validate a local file link (with optional anchor).
|
|
102
217
|
*/
|
|
@@ -106,85 +221,41 @@ async function validateLocalFileLink(
|
|
|
106
221
|
headingsByFile: Map<string, HeadingNode[]>,
|
|
107
222
|
options?: ValidateLinkOptions
|
|
108
223
|
): Promise<ValidationIssue | null> {
|
|
109
|
-
|
|
110
|
-
const [filePath, anchor] = splitHrefAnchor(link.href);
|
|
224
|
+
const resolved = resolveLocalHref(link.href, sourceFilePath, options?.projectRoot);
|
|
111
225
|
|
|
112
|
-
|
|
113
|
-
|
|
226
|
+
if (resolved.kind !== 'resolved') {
|
|
227
|
+
// anchor_only → null no-op; absolute_no_root / absolute_escapes_root → broken_file.
|
|
228
|
+
return resolutionFailureIssue(resolved, link, sourceFilePath);
|
|
229
|
+
}
|
|
114
230
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
}
|
|
231
|
+
const fileResult = await validateResolvedFile(resolved.resolvedPath);
|
|
232
|
+
const notFound = fileExistenceIssue(fileResult, link, sourceFilePath);
|
|
233
|
+
if (notFound) return notFound;
|
|
128
234
|
|
|
235
|
+
if (fileResult.isDirectory) {
|
|
129
236
|
return {
|
|
130
237
|
resourcePath: sourceFilePath,
|
|
131
238
|
line: link.line,
|
|
132
239
|
type: 'broken_file',
|
|
133
240
|
link: link.href,
|
|
134
|
-
message: `
|
|
135
|
-
suggestion:
|
|
241
|
+
message: `Link target is a directory: ${fileResult.resolvedPath}`,
|
|
242
|
+
suggestion:
|
|
243
|
+
'Link to a file inside the directory (e.g., README.md or index.md), or fix the link to point at the intended file.',
|
|
136
244
|
};
|
|
137
245
|
}
|
|
138
246
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
// 1. skipGitIgnoreCheck is NOT true
|
|
142
|
-
// 2. projectRoot is provided
|
|
143
|
-
// 3. target is within project (skip for external resources)
|
|
144
|
-
if (
|
|
145
|
-
options?.skipGitIgnoreCheck !== true &&
|
|
146
|
-
options?.projectRoot !== undefined &&
|
|
147
|
-
isWithinProject(fileResult.resolvedPath, options.projectRoot)
|
|
148
|
-
) {
|
|
149
|
-
// Prefer the O(1) active-set lookup on the shared GitTracker (no spawn).
|
|
150
|
-
// isIgnoredByActiveSet falls back internally to isIgnored for paths outside
|
|
151
|
-
// the project root, so this is safe for the rare out-of-project case.
|
|
152
|
-
// When no tracker is threaded in, fall back to isGitIgnored (one-off spawn).
|
|
153
|
-
const sourceIsIgnored = options.gitTracker
|
|
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
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// If there's an anchor, validate it too
|
|
174
|
-
if (anchor) {
|
|
175
|
-
const anchorValid = await validateAnchor(
|
|
176
|
-
anchor,
|
|
177
|
-
fileResult.resolvedPath,
|
|
178
|
-
headingsByFile
|
|
179
|
-
);
|
|
247
|
+
const gitIgnoreIssue = gitIgnoreSafetyIssue(link, sourceFilePath, fileResult.resolvedPath, options);
|
|
248
|
+
if (gitIgnoreIssue) return gitIgnoreIssue;
|
|
180
249
|
|
|
250
|
+
if (resolved.anchor) {
|
|
251
|
+
const anchorValid = await validateAnchor(resolved.anchor, fileResult.resolvedPath, headingsByFile);
|
|
181
252
|
if (!anchorValid) {
|
|
182
253
|
return {
|
|
183
254
|
resourcePath: sourceFilePath,
|
|
184
255
|
line: link.line,
|
|
185
256
|
type: 'broken_anchor',
|
|
186
257
|
link: link.href,
|
|
187
|
-
message: `Anchor not found: #${anchor} in ${fileResult.resolvedPath}`,
|
|
258
|
+
message: `Anchor not found: #${resolved.anchor} in ${fileResult.resolvedPath}`,
|
|
188
259
|
suggestion: '',
|
|
189
260
|
};
|
|
190
261
|
}
|
|
@@ -223,37 +294,31 @@ async function validateAnchorLink(
|
|
|
223
294
|
|
|
224
295
|
|
|
225
296
|
/**
|
|
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
|
|
297
|
+
* Verify that the resolved filesystem path exists with the correct case.
|
|
231
298
|
*
|
|
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
|
-
* ```
|
|
299
|
+
* @param resolvedPath - Absolute filesystem path produced by {@link resolveLocalHref}.
|
|
300
|
+
* @returns Object with exists flag, the path, and optional case-mismatch info.
|
|
241
301
|
*/
|
|
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
|
|
302
|
+
async function validateResolvedFile(
|
|
303
|
+
resolvedPath: string,
|
|
304
|
+
): Promise<{ exists: boolean; resolvedPath: string; actualName?: string; isDirectory: boolean }> {
|
|
251
305
|
const verification = await verifyCaseSensitiveFilename(resolvedPath);
|
|
252
306
|
|
|
253
|
-
|
|
254
|
-
|
|
307
|
+
let isDirectory = false;
|
|
308
|
+
if (verification.exists) {
|
|
309
|
+
try {
|
|
310
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename -- resolvedPath validated by verifyCaseSensitiveFilename
|
|
311
|
+
const stats = await fs.stat(resolvedPath);
|
|
312
|
+
isDirectory = stats.isDirectory();
|
|
313
|
+
} catch {
|
|
314
|
+
// Stat failed after verifyCaseSensitiveFilename said exists — treat as file.
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const result: { exists: boolean; resolvedPath: string; actualName?: string; isDirectory: boolean } = {
|
|
255
319
|
exists: verification.exists,
|
|
256
320
|
resolvedPath,
|
|
321
|
+
isDirectory,
|
|
257
322
|
};
|
|
258
323
|
|
|
259
324
|
if (verification.actualName) {
|
|
@@ -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 '
|
|
10
|
+
import * as yaml from 'yaml';
|
|
11
11
|
|
|
12
12
|
import { calculateChecksum } from '../checksum.js';
|
|
13
13
|
import { parseMarkdown } from '../link-parser.js';
|
|
@@ -213,7 +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
|
-
const data = yaml.
|
|
216
|
+
const data = yaml.parse(content);
|
|
217
217
|
|
|
218
218
|
// Calculate checksum
|
|
219
219
|
const checksum = await calculateChecksum(absolutePath);
|