@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.
@@ -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 ![alt](url)
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[]>;