@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.
- 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 -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-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 -11
- 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.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.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 -51
- 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 -11
- package/src/link-validator.ts +153 -88
- package/src/rewriter-helpers.ts +166 -0
- package/src/types/resource-parser.ts +2 -3
- package/src/types.ts +0 -1
- package/src/utils.ts +57 -14
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FrontmatterEditor — round-trip-safe primitive for editing YAML frontmatter
|
|
3
|
+
* in markdown files.
|
|
4
|
+
*
|
|
5
|
+
* Public surface: openFrontmatter(markdown) → FrontmatterEditor.
|
|
6
|
+
*
|
|
7
|
+
* Round-trip identity contract: openFrontmatter(x).toString() === x for any
|
|
8
|
+
* well-formed input, byte-for-byte. Mutations preserve comments, blank lines,
|
|
9
|
+
* key ordering, quoting style, and detected EOL.
|
|
10
|
+
*
|
|
11
|
+
* See docs/superpowers/specs/2026-05-17-frontmatter-editor-and-yaml-consolidation-design.md
|
|
12
|
+
* §5 for the full contract.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { Document, parseDocument } from 'yaml';
|
|
16
|
+
|
|
17
|
+
export class FrontmatterParseError extends Error {
|
|
18
|
+
public override readonly cause: unknown;
|
|
19
|
+
|
|
20
|
+
constructor(message: string, cause: unknown) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.name = 'FrontmatterParseError';
|
|
23
|
+
this.cause = cause;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Path to a value in the parsed frontmatter document. */
|
|
28
|
+
export type FrontmatterPath = string | readonly (string | number)[];
|
|
29
|
+
|
|
30
|
+
/** Scalar value type accepted by mutation methods. */
|
|
31
|
+
export type FrontmatterScalar = string | number | boolean | null;
|
|
32
|
+
|
|
33
|
+
export interface FrontmatterEditor {
|
|
34
|
+
body: string;
|
|
35
|
+
get(path: FrontmatterPath): unknown;
|
|
36
|
+
set(path: FrontmatterPath, value: FrontmatterScalar): void;
|
|
37
|
+
setArrayItem(path: FrontmatterPath, index: number, value: FrontmatterScalar): void;
|
|
38
|
+
appendArrayItem(path: FrontmatterPath, value: FrontmatterScalar): void;
|
|
39
|
+
delete(path: FrontmatterPath): void;
|
|
40
|
+
toString(): string;
|
|
41
|
+
/**
|
|
42
|
+
* Returns true if any mutating method has been called or `body` was
|
|
43
|
+
* reassigned to a different string. Use to gate writeFileSync and avoid
|
|
44
|
+
* the no-op-rewrite churn described in §"What's preserved, what isn't"
|
|
45
|
+
* of the markdown-rewriting skill.
|
|
46
|
+
*
|
|
47
|
+
* **Caveat:** any call to `set` / `setArrayItem` / `appendArrayItem` /
|
|
48
|
+
* `delete` flips the flag, even if the underlying value didn't change
|
|
49
|
+
* (e.g. `set('foo', sameValue)`). For strict byte-level dirty detection
|
|
50
|
+
* compare `editor.toString() !== originalText` instead. `body =` is the
|
|
51
|
+
* one exception — it only flips dirty on actual string change.
|
|
52
|
+
*/
|
|
53
|
+
isDirty(): boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface FrontmatterSplit {
|
|
57
|
+
hasFrontmatter: boolean;
|
|
58
|
+
frontmatterText: string;
|
|
59
|
+
body: string;
|
|
60
|
+
eol: '\n' | '\r\n';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const OPENING_FENCE = /^---\r?\n/;
|
|
64
|
+
// Closing fence: either immediately after opening (empty frontmatter), or
|
|
65
|
+
// preceded by a newline. Trailing variants accept newline or EOF.
|
|
66
|
+
const EMPTY_CLOSING_FENCE = /^---(?:\r?\n|$)/;
|
|
67
|
+
const CLOSING_FENCE = /(?:\r?\n---\r?\n|\r?\n---$)/;
|
|
68
|
+
|
|
69
|
+
function detectEol(input: string): '\n' | '\r\n' {
|
|
70
|
+
const firstBreak = input.indexOf('\n');
|
|
71
|
+
if (firstBreak === -1) return '\n';
|
|
72
|
+
return firstBreak > 0 && input.charAt(firstBreak - 1) === '\r' ? '\r\n' : '\n';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function splitFrontmatter(input: string): FrontmatterSplit {
|
|
76
|
+
const eol = detectEol(input);
|
|
77
|
+
const openingMatch = OPENING_FENCE.exec(input);
|
|
78
|
+
if (!openingMatch) {
|
|
79
|
+
return { hasFrontmatter: false, frontmatterText: '', body: input, eol };
|
|
80
|
+
}
|
|
81
|
+
const afterOpening = input.slice(openingMatch[0].length);
|
|
82
|
+
// Handle empty frontmatter (closing fence immediately follows opening fence)
|
|
83
|
+
const emptyMatch = EMPTY_CLOSING_FENCE.exec(afterOpening);
|
|
84
|
+
if (emptyMatch) {
|
|
85
|
+
const bodyStart = emptyMatch[0].length;
|
|
86
|
+
return {
|
|
87
|
+
hasFrontmatter: true,
|
|
88
|
+
frontmatterText: '',
|
|
89
|
+
body: afterOpening.slice(bodyStart),
|
|
90
|
+
eol,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
const closingMatch = CLOSING_FENCE.exec(afterOpening);
|
|
94
|
+
if (!closingMatch) {
|
|
95
|
+
return { hasFrontmatter: false, frontmatterText: '', body: input, eol };
|
|
96
|
+
}
|
|
97
|
+
const frontmatterText = afterOpening.slice(0, closingMatch.index);
|
|
98
|
+
const bodyStart = closingMatch.index + closingMatch[0].length;
|
|
99
|
+
const body = afterOpening.slice(bodyStart);
|
|
100
|
+
return { hasFrontmatter: true, frontmatterText, body, eol };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
class FrontmatterEditorImpl implements FrontmatterEditor {
|
|
104
|
+
private readonly doc: Document.Parsed | Document;
|
|
105
|
+
private readonly hasFrontmatter: boolean;
|
|
106
|
+
private readonly eol: '\n' | '\r\n';
|
|
107
|
+
private _body: string;
|
|
108
|
+
private _dirty = false;
|
|
109
|
+
|
|
110
|
+
get body(): string {
|
|
111
|
+
return this._body;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
set body(value: string) {
|
|
115
|
+
if (value !== this._body) {
|
|
116
|
+
this._body = value;
|
|
117
|
+
this._dirty = true;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
isDirty(): boolean {
|
|
122
|
+
return this._dirty;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
constructor(input: string) {
|
|
126
|
+
const split = splitFrontmatter(input);
|
|
127
|
+
this.hasFrontmatter = split.hasFrontmatter;
|
|
128
|
+
this.eol = split.eol;
|
|
129
|
+
this._body = split.body;
|
|
130
|
+
if (!split.hasFrontmatter) {
|
|
131
|
+
this.doc = new Document({});
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
this.doc = parseDocument(split.frontmatterText, { prettyErrors: true });
|
|
136
|
+
if (this.doc.errors.length > 0) {
|
|
137
|
+
throw new FrontmatterParseError(
|
|
138
|
+
`Invalid YAML frontmatter: ${this.doc.errors[0]?.message ?? 'unknown error'}`,
|
|
139
|
+
this.doc.errors[0],
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
} catch (error) {
|
|
143
|
+
if (error instanceof FrontmatterParseError) throw error;
|
|
144
|
+
throw new FrontmatterParseError(
|
|
145
|
+
`Failed to parse frontmatter: ${error instanceof Error ? error.message : String(error)}`,
|
|
146
|
+
error,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private toPath(path: FrontmatterPath): readonly (string | number)[] {
|
|
152
|
+
if (Array.isArray(path)) return path;
|
|
153
|
+
if (typeof path === 'string') return [path];
|
|
154
|
+
return path as readonly (string | number)[];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
get(path: FrontmatterPath): unknown {
|
|
158
|
+
const segments = this.toPath(path);
|
|
159
|
+
if (segments.length === 0) return this.doc.toJS();
|
|
160
|
+
return this.doc.getIn(segments as Iterable<unknown>, false);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
set(path: FrontmatterPath, value: FrontmatterScalar): void {
|
|
164
|
+
const segments = this.toPath(path);
|
|
165
|
+
this.doc.setIn(segments as Iterable<unknown>, value);
|
|
166
|
+
this._dirty = true;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
setArrayItem(path: FrontmatterPath, index: number, value: FrontmatterScalar): void {
|
|
170
|
+
const segments = [...this.toPath(path), index];
|
|
171
|
+
this.doc.setIn(segments as Iterable<unknown>, value);
|
|
172
|
+
this._dirty = true;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
appendArrayItem(path: FrontmatterPath, value: FrontmatterScalar): void {
|
|
176
|
+
const segments = this.toPath(path);
|
|
177
|
+
this.doc.addIn(segments as Iterable<unknown>, value);
|
|
178
|
+
this._dirty = true;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
delete(path: FrontmatterPath): void {
|
|
182
|
+
const segments = this.toPath(path);
|
|
183
|
+
this.doc.deleteIn(segments as Iterable<unknown>);
|
|
184
|
+
this._dirty = true;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
toString(): string {
|
|
188
|
+
// No frontmatter originally, and nothing was added → return body unchanged.
|
|
189
|
+
if (!this.hasFrontmatter && this.isDocEffectivelyEmpty()) {
|
|
190
|
+
return this.body;
|
|
191
|
+
}
|
|
192
|
+
// Empty frontmatter (e.g. `---\n---\n`) where the doc remained empty —
|
|
193
|
+
// preserve the empty fence block without injecting `null` or `{}` between.
|
|
194
|
+
if (this.hasFrontmatter && this.isDocEffectivelyEmpty()) {
|
|
195
|
+
return `---${this.eol}---${this.eol}${this.body}`;
|
|
196
|
+
}
|
|
197
|
+
const fmText = this.doc.toString();
|
|
198
|
+
const normalized = this.eol === '\r\n' ? fmText.replaceAll('\n', '\r\n') : fmText;
|
|
199
|
+
return `---${this.eol}${normalized}---${this.eol}${this.body}`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private isDocEffectivelyEmpty(): boolean {
|
|
203
|
+
const contents = this.doc.contents;
|
|
204
|
+
if (contents === null) return true;
|
|
205
|
+
// yaml.YAMLMap and YAMLSeq expose `items`; an empty map/seq counts as empty.
|
|
206
|
+
const maybeItems = (contents as { items?: unknown[] }).items;
|
|
207
|
+
if (Array.isArray(maybeItems) && maybeItems.length === 0) return true;
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function openFrontmatter(markdown: string): FrontmatterEditor {
|
|
213
|
+
return new FrontmatterEditorImpl(markdown);
|
|
214
|
+
}
|
|
@@ -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,16 +431,7 @@ function extractFrontmatter(tree: Root): {
|
|
|
431
431
|
}
|
|
432
432
|
|
|
433
433
|
try {
|
|
434
|
-
|
|
435
|
-
// strings (`2026-04-15` stays `"2026-04-15"`) instead of promoting
|
|
436
|
-
// them to JS Date objects (js-yaml's default DEFAULT_SCHEMA still
|
|
437
|
-
// applies the YAML 1.1 timestamp tag). Date promotion broke schema
|
|
438
|
-
// validation for any frontmatter field typed `string` in the schema:
|
|
439
|
-
// the validator saw an instanceof Date and rejected it. Adopters'
|
|
440
|
-
// ADR/PRD frontmatter conventionally uses unquoted ISO dates per
|
|
441
|
-
// YAML 1.2; quoting all of them just to placate the parser is
|
|
442
|
-
// unreasonable.
|
|
443
|
-
const parsed = yaml.load(node.value, { schema: yaml.CORE_SCHEMA });
|
|
434
|
+
const parsed = yaml.parse(node.value);
|
|
444
435
|
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
|
445
436
|
frontmatterData = parsed as Record<string, unknown>;
|
|
446
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) {
|