@specglass/core 0.0.4 → 0.0.6
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/dist/content/mdx-loader.js +5 -2
- package/dist/index.d.ts +5 -1
- package/dist/index.js +6 -1
- package/dist/integration.js +4 -0
- package/dist/navigation/builder.d.ts +13 -0
- package/dist/navigation/builder.js +29 -8
- package/dist/openapi/code-generator.js +5 -3
- package/dist/openapi/example-generator.js +8 -1
- package/dist/openapi/types.d.ts +4 -0
- package/dist/openapi/utils.d.ts +13 -1
- package/dist/openapi/utils.js +18 -0
- package/dist/validation/frontmatter-validator.d.ts +54 -0
- package/dist/validation/frontmatter-validator.js +197 -0
- package/dist/validation/index.d.ts +17 -0
- package/dist/validation/index.js +17 -0
- package/dist/validation/link-validator.d.ts +84 -0
- package/dist/validation/link-validator.js +490 -0
- package/dist/validation/spec-drift-validator.d.ts +88 -0
- package/dist/validation/spec-drift-validator.js +276 -0
- package/dist/validation/types.d.ts +45 -0
- package/dist/validation/types.js +15 -0
- package/package.json +2 -1
- package/src/pages/api-reference/[...slug].astro +27 -16
- package/src/pages/index.astro +27 -0
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Link validator — checks internal and external links across MDX/MD content.
|
|
3
|
+
*
|
|
4
|
+
* Internal broken links are reported as errors.
|
|
5
|
+
* External unreachable links are reported as warnings (prevents flaky CI).
|
|
6
|
+
*
|
|
7
|
+
* Composable: `(context: ValidatorContext) => Promise<ValidationResult[]>`
|
|
8
|
+
*/
|
|
9
|
+
import { readFile, readdir, stat, access } from "node:fs/promises";
|
|
10
|
+
import { join, dirname, resolve, basename, relative } from "node:path";
|
|
11
|
+
// --- Error codes ---
|
|
12
|
+
export const LINK_INTERNAL_BROKEN = "LINK_INTERNAL_BROKEN";
|
|
13
|
+
export const LINK_INTERNAL_ANCHOR_BROKEN = "LINK_INTERNAL_ANCHOR_BROKEN";
|
|
14
|
+
export const LINK_EXTERNAL_UNREACHABLE = "LINK_EXTERNAL_UNREACHABLE";
|
|
15
|
+
/**
|
|
16
|
+
* Extract all links from MDX/MD content string.
|
|
17
|
+
*
|
|
18
|
+
* Finds both markdown links `[text](url)` and HTML `<a href="url">` links.
|
|
19
|
+
* Returns line numbers for each extracted link.
|
|
20
|
+
*/
|
|
21
|
+
export function extractLinks(content) {
|
|
22
|
+
const links = [];
|
|
23
|
+
const lines = content.split("\n");
|
|
24
|
+
// Markdown links: [text](url) — but NOT image definitions 
|
|
25
|
+
// Also captures nested content like [text `code`](url)
|
|
26
|
+
const markdownLinkRegex = /(?<!!)\[(?:[^\]]*)\]\(([^)]+)\)/g;
|
|
27
|
+
// HTML links: <a href="url"> or <a href='url'>
|
|
28
|
+
const htmlLinkRegex = /<a\s[^>]*href=["']([^"']+)["'][^>]*>/gi;
|
|
29
|
+
for (let i = 0; i < lines.length; i++) {
|
|
30
|
+
const lineNum = i + 1;
|
|
31
|
+
const line = lines[i];
|
|
32
|
+
let match;
|
|
33
|
+
// Reset lastIndex for each line
|
|
34
|
+
markdownLinkRegex.lastIndex = 0;
|
|
35
|
+
while ((match = markdownLinkRegex.exec(line)) !== null) {
|
|
36
|
+
const target = match[1].trim();
|
|
37
|
+
if (target && !target.startsWith("mailto:") && !target.startsWith("tel:")) {
|
|
38
|
+
links.push({
|
|
39
|
+
target,
|
|
40
|
+
line: lineNum,
|
|
41
|
+
isExternal: isExternalLink(target),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
htmlLinkRegex.lastIndex = 0;
|
|
46
|
+
while ((match = htmlLinkRegex.exec(line)) !== null) {
|
|
47
|
+
const target = match[1].trim();
|
|
48
|
+
if (target && !target.startsWith("mailto:") && !target.startsWith("tel:")) {
|
|
49
|
+
links.push({
|
|
50
|
+
target,
|
|
51
|
+
line: lineNum,
|
|
52
|
+
isExternal: isExternalLink(target),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// Filter out links inside fenced code blocks
|
|
58
|
+
return filterCodeBlockLinks(content, links);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Filter out links that appear inside fenced code blocks (``` ... ```).
|
|
62
|
+
*/
|
|
63
|
+
function filterCodeBlockLinks(content, links) {
|
|
64
|
+
const lines = content.split("\n");
|
|
65
|
+
const inCodeBlock = new Set();
|
|
66
|
+
let insideCode = false;
|
|
67
|
+
for (let i = 0; i < lines.length; i++) {
|
|
68
|
+
const trimmed = lines[i].trimStart();
|
|
69
|
+
if (trimmed.startsWith("```")) {
|
|
70
|
+
insideCode = !insideCode;
|
|
71
|
+
inCodeBlock.add(i + 1);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (insideCode) {
|
|
75
|
+
inCodeBlock.add(i + 1);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return links.filter((link) => !inCodeBlock.has(link.line));
|
|
79
|
+
}
|
|
80
|
+
/** Check if a URL is external (http:// or https://). */
|
|
81
|
+
export function isExternalLink(target) {
|
|
82
|
+
return target.startsWith("http://") || target.startsWith("https://");
|
|
83
|
+
}
|
|
84
|
+
// --- Internal link resolution ---
|
|
85
|
+
/**
|
|
86
|
+
* Resolve and validate all internal links in a single file.
|
|
87
|
+
*/
|
|
88
|
+
export async function resolveInternalLinks(links, sourceFilePath, contentDir, knownPages) {
|
|
89
|
+
const results = [];
|
|
90
|
+
const internalLinks = links.filter((l) => !l.isExternal);
|
|
91
|
+
for (const link of internalLinks) {
|
|
92
|
+
const { target, line } = link;
|
|
93
|
+
// Anchor-only links (#heading-id) — check heading in same file
|
|
94
|
+
if (target.startsWith("#")) {
|
|
95
|
+
const anchorResult = await checkAnchorInFile(target.slice(1), sourceFilePath, line);
|
|
96
|
+
if (anchorResult) {
|
|
97
|
+
results.push(anchorResult);
|
|
98
|
+
}
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
// Split path and anchor (only at first #)
|
|
102
|
+
const hashIndex = target.indexOf("#");
|
|
103
|
+
const linkPath = hashIndex >= 0 ? target.slice(0, hashIndex) : target;
|
|
104
|
+
const anchor = hashIndex >= 0 ? target.slice(hashIndex + 1) : undefined;
|
|
105
|
+
// Resolve the page path
|
|
106
|
+
const resolved = resolvePagePath(linkPath, sourceFilePath, contentDir);
|
|
107
|
+
// Check if the resolved file exists
|
|
108
|
+
const fileExists = await checkFileExists(resolved, contentDir, knownPages);
|
|
109
|
+
if (!fileExists.exists) {
|
|
110
|
+
const suggestion = findSuggestion(linkPath, knownPages);
|
|
111
|
+
results.push({
|
|
112
|
+
code: LINK_INTERNAL_BROKEN,
|
|
113
|
+
message: `Broken internal link: "${target}"`,
|
|
114
|
+
filePath: sourceFilePath,
|
|
115
|
+
line,
|
|
116
|
+
severity: "error",
|
|
117
|
+
hint: suggestion
|
|
118
|
+
? `Did you mean "${suggestion}"?`
|
|
119
|
+
: `Link target "${linkPath}" does not match any known page. Available pages: ${knownPages.slice(0, 5).join(", ")}${knownPages.length > 5 ? "..." : ""}`,
|
|
120
|
+
});
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
// If there's an anchor, check it in the target file
|
|
124
|
+
if (anchor) {
|
|
125
|
+
const targetFilePath = fileExists.resolvedPath;
|
|
126
|
+
const anchorResult = await checkAnchorInFile(anchor, targetFilePath, line, sourceFilePath);
|
|
127
|
+
if (anchorResult) {
|
|
128
|
+
results.push(anchorResult);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return results;
|
|
133
|
+
}
|
|
134
|
+
/** Resolve a link path to an absolute file path. */
|
|
135
|
+
export function resolvePagePath(linkPath, sourceFilePath, contentDir) {
|
|
136
|
+
// Strip leading slash for absolute links
|
|
137
|
+
if (linkPath.startsWith("/")) {
|
|
138
|
+
return join(contentDir, linkPath.slice(1));
|
|
139
|
+
}
|
|
140
|
+
// Relative links resolve against source file's directory
|
|
141
|
+
return resolve(dirname(sourceFilePath), linkPath);
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Check if a resolved path corresponds to an existing content file.
|
|
145
|
+
* Handles extension normalization (.mdx/.md), index files, and trailing slashes.
|
|
146
|
+
*/
|
|
147
|
+
export async function checkFileExists(resolvedPath, contentDir, _knownPages) {
|
|
148
|
+
// Normalize: strip trailing slash
|
|
149
|
+
const normalized = resolvedPath.replace(/\/$/, "");
|
|
150
|
+
// Try exact path first
|
|
151
|
+
const candidates = [
|
|
152
|
+
normalized,
|
|
153
|
+
`${normalized}.mdx`,
|
|
154
|
+
`${normalized}.md`,
|
|
155
|
+
join(normalized, "index.mdx"),
|
|
156
|
+
join(normalized, "index.md"),
|
|
157
|
+
];
|
|
158
|
+
// If path already has extension, it's already covered by the candidates list above
|
|
159
|
+
for (const candidate of candidates) {
|
|
160
|
+
try {
|
|
161
|
+
await access(candidate);
|
|
162
|
+
return { exists: true, resolvedPath: candidate };
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
// noop
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// Also check against knownPages slugs
|
|
169
|
+
const relPath = relative(contentDir, normalized);
|
|
170
|
+
const normalizedSlug = relPath
|
|
171
|
+
.replace(/\\/g, "/")
|
|
172
|
+
.replace(/\.(mdx|md)$/, "")
|
|
173
|
+
.replace(/\/index$/, "");
|
|
174
|
+
for (const page of _knownPages) {
|
|
175
|
+
const normalizedPage = page.replace(/\.(mdx|md)$/, "").replace(/\/index$/, "");
|
|
176
|
+
if (normalizedPage === normalizedSlug) {
|
|
177
|
+
// Find the actual file
|
|
178
|
+
for (const fileExt of [".mdx", ".md"]) {
|
|
179
|
+
const filePath = join(contentDir, `${page}${page.endsWith(fileExt) ? "" : fileExt}`);
|
|
180
|
+
try {
|
|
181
|
+
await access(filePath);
|
|
182
|
+
return { exists: true, resolvedPath: filePath };
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
// noop
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
// Matched slug but can't find file — still consider it existing
|
|
189
|
+
return { exists: true, resolvedPath: join(contentDir, page) };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return { exists: false };
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Check if a heading anchor exists in a markdown file.
|
|
196
|
+
* Extracts headings and converts them to slug IDs.
|
|
197
|
+
*/
|
|
198
|
+
export async function checkAnchorInFile(anchor, filePath, sourceLine, sourceFilePath) {
|
|
199
|
+
let content;
|
|
200
|
+
try {
|
|
201
|
+
content = await readFile(filePath, "utf-8");
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
// File doesn't exist — handled elsewhere
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
const headingIds = extractHeadingIds(content);
|
|
208
|
+
if (!headingIds.includes(anchor)) {
|
|
209
|
+
return {
|
|
210
|
+
code: LINK_INTERNAL_ANCHOR_BROKEN,
|
|
211
|
+
message: `Broken anchor link: "#${anchor}" not found in ${basename(filePath)}`,
|
|
212
|
+
filePath: sourceFilePath || filePath,
|
|
213
|
+
line: sourceLine,
|
|
214
|
+
severity: "error",
|
|
215
|
+
hint: headingIds.length > 0
|
|
216
|
+
? `Available anchors: ${headingIds.slice(0, 5).join(", ")}${headingIds.length > 5 ? "..." : ""}`
|
|
217
|
+
: `No headings found in ${basename(filePath)}`,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Extract heading IDs from markdown content.
|
|
224
|
+
* Converts headings to GitHub-style slug IDs.
|
|
225
|
+
* Filters out headings that appear inside fenced code blocks.
|
|
226
|
+
*/
|
|
227
|
+
export function extractHeadingIds(content) {
|
|
228
|
+
const lines = content.split("\n");
|
|
229
|
+
const headingRegex = /^#{1,6}\s+(.+)$/;
|
|
230
|
+
const ids = [];
|
|
231
|
+
let insideCode = false;
|
|
232
|
+
for (const line of lines) {
|
|
233
|
+
const trimmed = line.trimStart();
|
|
234
|
+
if (trimmed.startsWith("```")) {
|
|
235
|
+
insideCode = !insideCode;
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (insideCode)
|
|
239
|
+
continue;
|
|
240
|
+
const match = headingRegex.exec(line);
|
|
241
|
+
if (match) {
|
|
242
|
+
ids.push(headingToSlug(match[1].trim()));
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return ids;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Convert a heading text to a GitHub-style anchor slug.
|
|
249
|
+
* e.g., "Getting Started" → "getting-started"
|
|
250
|
+
*/
|
|
251
|
+
export function headingToSlug(text) {
|
|
252
|
+
return text
|
|
253
|
+
.toLowerCase()
|
|
254
|
+
.replace(/[^\w\s-]/g, "") // Remove non-word chars except spaces and hyphens
|
|
255
|
+
.replace(/\s+/g, "-") // Replace spaces with hyphens
|
|
256
|
+
.replace(/-+/g, "-") // Collapse multiple hyphens
|
|
257
|
+
.replace(/^-|-$/g, ""); // Trim leading/trailing hyphens
|
|
258
|
+
}
|
|
259
|
+
// --- External link checking ---
|
|
260
|
+
/** URLs to skip during external checking (dev/test/example URLs). */
|
|
261
|
+
const SKIP_HOSTS = new Set([
|
|
262
|
+
"localhost",
|
|
263
|
+
"127.0.0.1",
|
|
264
|
+
"0.0.0.0",
|
|
265
|
+
"example.com",
|
|
266
|
+
"example.org",
|
|
267
|
+
"example.net",
|
|
268
|
+
]);
|
|
269
|
+
/** Maximum concurrent external link checks. */
|
|
270
|
+
const MAX_CONCURRENT = 5;
|
|
271
|
+
/** Timeout per external link check (ms). */
|
|
272
|
+
const EXTERNAL_TIMEOUT_MS = 10_000;
|
|
273
|
+
/**
|
|
274
|
+
* Check external links for reachability via HTTP HEAD requests.
|
|
275
|
+
* Failures are reported as warnings, not errors.
|
|
276
|
+
*/
|
|
277
|
+
export async function checkExternalLinks(links, sourceFilePath) {
|
|
278
|
+
const externalLinks = links.filter((l) => l.isExternal);
|
|
279
|
+
if (externalLinks.length === 0)
|
|
280
|
+
return [];
|
|
281
|
+
// Deduplicate by target URL (keep first occurrence for line number)
|
|
282
|
+
const uniqueLinks = new Map();
|
|
283
|
+
for (const link of externalLinks) {
|
|
284
|
+
if (!uniqueLinks.has(link.target)) {
|
|
285
|
+
uniqueLinks.set(link.target, link);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
const results = [];
|
|
289
|
+
const entries = [...uniqueLinks.values()];
|
|
290
|
+
// Process in batches with bounded concurrency
|
|
291
|
+
for (let i = 0; i < entries.length; i += MAX_CONCURRENT) {
|
|
292
|
+
const batch = entries.slice(i, i + MAX_CONCURRENT);
|
|
293
|
+
const batchResults = await Promise.allSettled(batch.map((link) => checkSingleExternalLink(link, sourceFilePath)));
|
|
294
|
+
for (const result of batchResults) {
|
|
295
|
+
if (result.status === "fulfilled" && result.value) {
|
|
296
|
+
results.push(result.value);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return results;
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Check a single external link via HTTP HEAD request.
|
|
304
|
+
* Returns a ValidationResult for unreachable links, null for valid ones.
|
|
305
|
+
*/
|
|
306
|
+
export async function checkSingleExternalLink(link, sourceFilePath) {
|
|
307
|
+
// Skip dev/test URLs
|
|
308
|
+
try {
|
|
309
|
+
const url = new URL(link.target);
|
|
310
|
+
if (SKIP_HOSTS.has(url.hostname)) {
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
return {
|
|
316
|
+
code: LINK_EXTERNAL_UNREACHABLE,
|
|
317
|
+
message: `Invalid URL: "${link.target}"`,
|
|
318
|
+
filePath: sourceFilePath,
|
|
319
|
+
line: link.line,
|
|
320
|
+
severity: "warning",
|
|
321
|
+
hint: "URL could not be parsed",
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
try {
|
|
325
|
+
const controller = new AbortController();
|
|
326
|
+
const timeoutId = setTimeout(() => controller.abort(), EXTERNAL_TIMEOUT_MS);
|
|
327
|
+
try {
|
|
328
|
+
const response = await fetch(link.target, {
|
|
329
|
+
method: "HEAD",
|
|
330
|
+
signal: controller.signal,
|
|
331
|
+
redirect: "follow",
|
|
332
|
+
});
|
|
333
|
+
clearTimeout(timeoutId);
|
|
334
|
+
if (response.ok) {
|
|
335
|
+
return null; // Link is valid
|
|
336
|
+
}
|
|
337
|
+
// Some servers reject HEAD — retry with GET
|
|
338
|
+
if (response.status === 405) {
|
|
339
|
+
const getResponse = await fetch(link.target, {
|
|
340
|
+
method: "GET",
|
|
341
|
+
signal: AbortSignal.timeout(EXTERNAL_TIMEOUT_MS),
|
|
342
|
+
redirect: "follow",
|
|
343
|
+
});
|
|
344
|
+
if (getResponse.ok) {
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return {
|
|
349
|
+
code: LINK_EXTERNAL_UNREACHABLE,
|
|
350
|
+
message: `External link unreachable: "${link.target}" (HTTP ${response.status})`,
|
|
351
|
+
filePath: sourceFilePath,
|
|
352
|
+
line: link.line,
|
|
353
|
+
severity: "warning",
|
|
354
|
+
hint: `Server returned HTTP ${response.status}. This may be temporary.`,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
catch (err) {
|
|
358
|
+
clearTimeout(timeoutId);
|
|
359
|
+
throw err;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
catch (err) {
|
|
363
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
364
|
+
return {
|
|
365
|
+
code: LINK_EXTERNAL_UNREACHABLE,
|
|
366
|
+
message: `External link unreachable: "${link.target}"`,
|
|
367
|
+
filePath: sourceFilePath,
|
|
368
|
+
line: link.line,
|
|
369
|
+
severity: "warning",
|
|
370
|
+
hint: `Request failed: ${errorMessage}. This may be a network issue.`,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
// --- Suggestion engine ---
|
|
375
|
+
/**
|
|
376
|
+
* Find a suggested page for a broken internal link.
|
|
377
|
+
* Simple string distance comparison against known pages.
|
|
378
|
+
*/
|
|
379
|
+
export function findSuggestion(brokenPath, knownPages) {
|
|
380
|
+
if (knownPages.length === 0)
|
|
381
|
+
return null;
|
|
382
|
+
// Normalize the broken path
|
|
383
|
+
const normalized = brokenPath
|
|
384
|
+
.replace(/^\//, "")
|
|
385
|
+
.replace(/\.(mdx|md)$/, "")
|
|
386
|
+
.replace(/\/index$/, "")
|
|
387
|
+
.replace(/\/$/, "");
|
|
388
|
+
// Find the closest match
|
|
389
|
+
let bestMatch = null;
|
|
390
|
+
let bestScore = Infinity;
|
|
391
|
+
for (const page of knownPages) {
|
|
392
|
+
const normalizedPage = page
|
|
393
|
+
.replace(/\.(mdx|md)$/, "")
|
|
394
|
+
.replace(/\/index$/, "");
|
|
395
|
+
const distance = levenshtein(normalized, normalizedPage);
|
|
396
|
+
// Only suggest if reasonably close (within 3 edits)
|
|
397
|
+
if (distance < bestScore && distance <= 3) {
|
|
398
|
+
bestScore = distance;
|
|
399
|
+
bestMatch = `/${normalizedPage}`;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return bestMatch;
|
|
403
|
+
}
|
|
404
|
+
/** Simple Levenshtein distance implementation. */
|
|
405
|
+
export function levenshtein(a, b) {
|
|
406
|
+
const matrix = [];
|
|
407
|
+
for (let i = 0; i <= a.length; i++) {
|
|
408
|
+
matrix[i] = [i];
|
|
409
|
+
}
|
|
410
|
+
for (let j = 0; j <= b.length; j++) {
|
|
411
|
+
matrix[0][j] = j;
|
|
412
|
+
}
|
|
413
|
+
for (let i = 1; i <= a.length; i++) {
|
|
414
|
+
for (let j = 1; j <= b.length; j++) {
|
|
415
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
416
|
+
matrix[i][j] = Math.min(matrix[i - 1][j] + 1, // deletion
|
|
417
|
+
matrix[i][j - 1] + 1, // insertion
|
|
418
|
+
matrix[i - 1][j - 1] + cost);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return matrix[a.length][b.length];
|
|
422
|
+
}
|
|
423
|
+
// --- Content discovery ---
|
|
424
|
+
/**
|
|
425
|
+
* Recursively discover all .md/.mdx files in a directory.
|
|
426
|
+
*/
|
|
427
|
+
export async function discoverContentFiles(dir) {
|
|
428
|
+
const files = [];
|
|
429
|
+
let entries;
|
|
430
|
+
try {
|
|
431
|
+
entries = await readdir(dir);
|
|
432
|
+
}
|
|
433
|
+
catch {
|
|
434
|
+
return files;
|
|
435
|
+
}
|
|
436
|
+
for (const entry of entries) {
|
|
437
|
+
if (entry.startsWith("_") || entry.startsWith("."))
|
|
438
|
+
continue;
|
|
439
|
+
const fullPath = join(dir, entry);
|
|
440
|
+
const info = await stat(fullPath);
|
|
441
|
+
if (info.isDirectory()) {
|
|
442
|
+
const subFiles = await discoverContentFiles(fullPath);
|
|
443
|
+
files.push(...subFiles);
|
|
444
|
+
}
|
|
445
|
+
else if (entry.endsWith(".mdx") || entry.endsWith(".md")) {
|
|
446
|
+
files.push(fullPath);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return files;
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Build the list of known page slugs from the content directory.
|
|
453
|
+
*/
|
|
454
|
+
export function buildKnownPages(contentFiles, contentDir) {
|
|
455
|
+
return contentFiles.map((f) => {
|
|
456
|
+
const rel = relative(contentDir, f).replace(/\\/g, "/");
|
|
457
|
+
return rel.replace(/\.(mdx|md)$/, "").replace(/\/index$/, "");
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
// --- Main validator entry point ---
|
|
461
|
+
/**
|
|
462
|
+
* Link validator — the composable validator function for link checking.
|
|
463
|
+
*
|
|
464
|
+
* @param context - Validator context with contentDir and options
|
|
465
|
+
* @returns Array of validation results (errors for internal, warnings for external)
|
|
466
|
+
*/
|
|
467
|
+
export async function validateLinks(context) {
|
|
468
|
+
const { contentDir, skipExternal } = context;
|
|
469
|
+
// Discover all content files
|
|
470
|
+
const contentFiles = await discoverContentFiles(contentDir);
|
|
471
|
+
const knownPages = context.knownPages.length > 0
|
|
472
|
+
? context.knownPages
|
|
473
|
+
: buildKnownPages(contentFiles, contentDir);
|
|
474
|
+
const allResults = [];
|
|
475
|
+
for (const filePath of contentFiles) {
|
|
476
|
+
const content = await readFile(filePath, "utf-8");
|
|
477
|
+
const links = extractLinks(content);
|
|
478
|
+
if (links.length === 0)
|
|
479
|
+
continue;
|
|
480
|
+
// Internal link validation
|
|
481
|
+
const internalResults = await resolveInternalLinks(links, filePath, contentDir, knownPages);
|
|
482
|
+
allResults.push(...internalResults);
|
|
483
|
+
// External link validation (unless skipped)
|
|
484
|
+
if (!skipExternal) {
|
|
485
|
+
const externalResults = await checkExternalLinks(links, filePath);
|
|
486
|
+
allResults.push(...externalResults);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
return allResults;
|
|
490
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAPI spec drift detection validator.
|
|
3
|
+
*
|
|
4
|
+
* Compares the current OpenAPI specification against a previously saved
|
|
5
|
+
* baseline snapshot to detect drift: added endpoints, removed endpoints,
|
|
6
|
+
* changed parameters, and modified response schemas.
|
|
7
|
+
*
|
|
8
|
+
* Follows the composable validator pattern established by `validateLinks`
|
|
9
|
+
* (Story 6.1) and `validateFrontmatter` (Story 6.2).
|
|
10
|
+
*
|
|
11
|
+
* @module
|
|
12
|
+
*/
|
|
13
|
+
import type { ParsedOpenApiSpec, ApiEndpoint } from "../openapi/types.js";
|
|
14
|
+
import type { ValidationResult, ValidatorContext } from "./types.js";
|
|
15
|
+
export declare const SPEC_DRIFT_ENDPOINT_ADDED = "SPEC_DRIFT_ENDPOINT_ADDED";
|
|
16
|
+
export declare const SPEC_DRIFT_ENDPOINT_REMOVED = "SPEC_DRIFT_ENDPOINT_REMOVED";
|
|
17
|
+
export declare const SPEC_DRIFT_PARAMETER_CHANGED = "SPEC_DRIFT_PARAMETER_CHANGED";
|
|
18
|
+
export declare const SPEC_DRIFT_RESPONSE_CHANGED = "SPEC_DRIFT_RESPONSE_CHANGED";
|
|
19
|
+
export declare const SPEC_DRIFT_REQUEST_BODY_CHANGED = "SPEC_DRIFT_REQUEST_BODY_CHANGED";
|
|
20
|
+
export declare const SPEC_DRIFT_UNREACHABLE = "SPEC_DRIFT_UNREACHABLE";
|
|
21
|
+
export declare const SPEC_DRIFT_PARSE_ERROR = "SPEC_DRIFT_PARSE_ERROR";
|
|
22
|
+
/** Fingerprint for a single endpoint, enabling efficient diffing. */
|
|
23
|
+
export interface EndpointFingerprint {
|
|
24
|
+
operationId?: string;
|
|
25
|
+
/** Sorted array of "name:in:type" strings for parameters. */
|
|
26
|
+
parameters: string[];
|
|
27
|
+
/** Sorted content types accepted in request body. */
|
|
28
|
+
requestBodyContentTypes: string[];
|
|
29
|
+
/** Sorted response status codes. */
|
|
30
|
+
responseStatusCodes: string[];
|
|
31
|
+
deprecated: boolean;
|
|
32
|
+
}
|
|
33
|
+
/** A serializable snapshot of an OpenAPI spec for baseline comparison. */
|
|
34
|
+
export interface SpecSnapshot {
|
|
35
|
+
specPath: string;
|
|
36
|
+
version: string;
|
|
37
|
+
generatedAt: string;
|
|
38
|
+
endpoints: Record<string, EndpointFingerprint>;
|
|
39
|
+
}
|
|
40
|
+
/** A single drift change detected between baseline and current spec. */
|
|
41
|
+
export interface DriftChange {
|
|
42
|
+
type: "added" | "removed" | "parameter-changed" | "response-changed" | "request-body-changed";
|
|
43
|
+
endpointKey: string;
|
|
44
|
+
details: string;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Build an endpoint fingerprint key: "METHOD /path".
|
|
48
|
+
*/
|
|
49
|
+
export declare function endpointKey(endpoint: ApiEndpoint): string;
|
|
50
|
+
/**
|
|
51
|
+
* Create a fingerprint for a single endpoint.
|
|
52
|
+
*/
|
|
53
|
+
export declare function fingerprintEndpoint(endpoint: ApiEndpoint): EndpointFingerprint;
|
|
54
|
+
/**
|
|
55
|
+
* Create a baseline snapshot from a parsed OpenAPI specification.
|
|
56
|
+
*/
|
|
57
|
+
export declare function createSnapshot(spec: ParsedOpenApiSpec, specPath: string): SpecSnapshot;
|
|
58
|
+
/**
|
|
59
|
+
* Resolve the baseline file path for a given spec path.
|
|
60
|
+
* Baseline is stored at `<projectRoot>/.specglass/openapi-baseline-<specName>.json`.
|
|
61
|
+
* We use the spec filename (without extension) as the disambiguator.
|
|
62
|
+
*/
|
|
63
|
+
export declare function resolveBaselinePath(projectRoot: string, specPath: string): string;
|
|
64
|
+
/**
|
|
65
|
+
* Load a previously saved baseline snapshot from disk.
|
|
66
|
+
* Returns null if no baseline exists (first run).
|
|
67
|
+
*/
|
|
68
|
+
export declare function loadBaseline(baselinePath: string): Promise<SpecSnapshot | null>;
|
|
69
|
+
/**
|
|
70
|
+
* Save a baseline snapshot to disk.
|
|
71
|
+
* Creates parent directories if they don't exist.
|
|
72
|
+
*/
|
|
73
|
+
export declare function saveBaseline(snapshot: SpecSnapshot, baselinePath: string): Promise<void>;
|
|
74
|
+
/**
|
|
75
|
+
* Compare two snapshots and detect all drift changes.
|
|
76
|
+
*/
|
|
77
|
+
export declare function detectDrift(baseline: SpecSnapshot, current: SpecSnapshot): DriftChange[];
|
|
78
|
+
/**
|
|
79
|
+
* Map drift changes to ValidationResult objects.
|
|
80
|
+
*/
|
|
81
|
+
export declare function driftToValidationResults(changes: DriftChange[], specPath: string): ValidationResult[];
|
|
82
|
+
/**
|
|
83
|
+
* Spec drift validator — composable validator for OpenAPI spec drift detection.
|
|
84
|
+
*
|
|
85
|
+
* @param context - Validator context with `specPaths` and `projectRoot`
|
|
86
|
+
* @returns Array of validation results for detected drift
|
|
87
|
+
*/
|
|
88
|
+
export declare function validateSpecDrift(context: ValidatorContext): Promise<ValidationResult[]>;
|