create-universal-ai-context 2.0.0 → 2.1.2
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 +55 -23
- package/bin/create-ai-context.js +159 -1
- package/lib/adapters/claude.js +180 -29
- package/lib/doc-discovery.js +741 -0
- package/lib/drift-checker.js +920 -0
- package/lib/index.js +89 -7
- package/lib/installer.js +1 -0
- package/lib/placeholder.js +11 -1
- package/lib/prompts.js +55 -1
- package/lib/smart-merge.js +540 -0
- package/lib/spinner.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,920 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI Context Engineering - Drift Checker Module
|
|
3
|
+
*
|
|
4
|
+
* Validates documentation references against the actual codebase.
|
|
5
|
+
* Detects stale file paths, outdated line numbers, missing functions, etc.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const { glob } = require('glob');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Drift severity levels
|
|
14
|
+
*/
|
|
15
|
+
const DRIFT_LEVEL = {
|
|
16
|
+
NONE: 'none',
|
|
17
|
+
LOW: 'low', // Minor issues, easily fixable
|
|
18
|
+
MEDIUM: 'medium', // Requires review
|
|
19
|
+
HIGH: 'high', // Significant issues
|
|
20
|
+
CRITICAL: 'critical' // Major issues, documentation may be unusable
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Health status thresholds
|
|
25
|
+
*/
|
|
26
|
+
const HEALTH_THRESHOLDS = {
|
|
27
|
+
HEALTHY: 90, // 90-100%
|
|
28
|
+
NEEDS_UPDATE: 70, // 70-89%
|
|
29
|
+
STALE: 50, // 50-69%
|
|
30
|
+
CRITICAL: 0 // < 50%
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Patterns to extract references from markdown content
|
|
35
|
+
*/
|
|
36
|
+
const REFERENCE_PATTERNS = {
|
|
37
|
+
// File paths in backticks: `src/auth.py`
|
|
38
|
+
backtickPath: /`([a-zA-Z0-9_\-./\\]+\.[a-zA-Z0-9]+)`/g,
|
|
39
|
+
|
|
40
|
+
// Line references: file.js:123 or file.py:45-67
|
|
41
|
+
lineReference: /([a-zA-Z0-9_\-./\\]+\.[a-zA-Z0-9]+):(\d+)(?:-(\d+))?/g,
|
|
42
|
+
|
|
43
|
+
// Anchor references: file.py::function_name() or file.js::ClassName
|
|
44
|
+
anchorReference: /([a-zA-Z0-9_\-./\\]+\.[a-zA-Z0-9]+)::(\w+)(?:\(\))?/g,
|
|
45
|
+
|
|
46
|
+
// Directory references: src/components/
|
|
47
|
+
directoryReference: /`?([a-zA-Z0-9_\-./\\]+\/)`?/g,
|
|
48
|
+
|
|
49
|
+
// Markdown links: [text](./path/to/file.md)
|
|
50
|
+
markdownLink: /\[([^\]]+)\]\(([^)]+)\)/g,
|
|
51
|
+
|
|
52
|
+
// Tech stack mentions
|
|
53
|
+
techStackMention: /(?:built\s+with|using|technologies?|stack)[:\s]+([^\n]+)/gi
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Common source file extensions (for filtering path matches)
|
|
58
|
+
*/
|
|
59
|
+
const SOURCE_EXTENSIONS = new Set([
|
|
60
|
+
'js', 'ts', 'jsx', 'tsx', 'mjs', 'cjs',
|
|
61
|
+
'py', 'pyw', 'pyi',
|
|
62
|
+
'go', 'rs', 'rb', 'java', 'kt', 'scala',
|
|
63
|
+
'c', 'cpp', 'cc', 'h', 'hpp',
|
|
64
|
+
'cs', 'fs', 'vb',
|
|
65
|
+
'php', 'swift', 'dart',
|
|
66
|
+
'md', 'json', 'yaml', 'yml', 'toml',
|
|
67
|
+
'sh', 'bash', 'zsh', 'ps1',
|
|
68
|
+
'sql', 'graphql'
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Extract all reference types from markdown content
|
|
73
|
+
* @param {string} content - Markdown content
|
|
74
|
+
* @returns {object} Categorized references
|
|
75
|
+
*/
|
|
76
|
+
function extractAllReferences(content) {
|
|
77
|
+
const references = {
|
|
78
|
+
filePaths: [],
|
|
79
|
+
lineReferences: [],
|
|
80
|
+
anchorReferences: [],
|
|
81
|
+
directoryReferences: [],
|
|
82
|
+
markdownLinks: [],
|
|
83
|
+
techStackClaims: []
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// Reset regex lastIndex
|
|
87
|
+
const resetRegex = (regex) => { regex.lastIndex = 0; return regex; };
|
|
88
|
+
|
|
89
|
+
// Extract file paths from backticks
|
|
90
|
+
let match;
|
|
91
|
+
const backtickRegex = resetRegex(REFERENCE_PATTERNS.backtickPath);
|
|
92
|
+
while ((match = backtickRegex.exec(content)) !== null) {
|
|
93
|
+
const filePath = match[1].replace(/\\/g, '/');
|
|
94
|
+
const ext = path.extname(filePath).slice(1).toLowerCase();
|
|
95
|
+
if (SOURCE_EXTENSIONS.has(ext) || ext === '') {
|
|
96
|
+
references.filePaths.push({
|
|
97
|
+
type: 'file_path',
|
|
98
|
+
file: filePath,
|
|
99
|
+
original: match[0],
|
|
100
|
+
position: match.index
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Extract line references
|
|
106
|
+
const lineRegex = resetRegex(REFERENCE_PATTERNS.lineReference);
|
|
107
|
+
while ((match = lineRegex.exec(content)) !== null) {
|
|
108
|
+
references.lineReferences.push({
|
|
109
|
+
type: 'line_reference',
|
|
110
|
+
file: match[1].replace(/\\/g, '/'),
|
|
111
|
+
line: parseInt(match[2], 10),
|
|
112
|
+
endLine: match[3] ? parseInt(match[3], 10) : null,
|
|
113
|
+
original: match[0],
|
|
114
|
+
position: match.index
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Extract anchor references
|
|
119
|
+
const anchorRegex = resetRegex(REFERENCE_PATTERNS.anchorReference);
|
|
120
|
+
while ((match = anchorRegex.exec(content)) !== null) {
|
|
121
|
+
references.anchorReferences.push({
|
|
122
|
+
type: 'anchor_reference',
|
|
123
|
+
file: match[1].replace(/\\/g, '/'),
|
|
124
|
+
anchor: match[2],
|
|
125
|
+
original: match[0],
|
|
126
|
+
position: match.index
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Extract directory references
|
|
131
|
+
const dirRegex = resetRegex(REFERENCE_PATTERNS.directoryReference);
|
|
132
|
+
while ((match = dirRegex.exec(content)) !== null) {
|
|
133
|
+
const dir = match[1].replace(/\\/g, '/');
|
|
134
|
+
// Filter out URLs and common false positives
|
|
135
|
+
if (!dir.includes('://') && !dir.startsWith('http') && dir.length > 2) {
|
|
136
|
+
references.directoryReferences.push({
|
|
137
|
+
type: 'directory_reference',
|
|
138
|
+
directory: dir,
|
|
139
|
+
original: match[0],
|
|
140
|
+
position: match.index
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Extract markdown links to local files
|
|
146
|
+
const linkRegex = resetRegex(REFERENCE_PATTERNS.markdownLink);
|
|
147
|
+
while ((match = linkRegex.exec(content)) !== null) {
|
|
148
|
+
const href = match[2];
|
|
149
|
+
// Filter out external URLs, anchors-only, and mailto
|
|
150
|
+
if (!href.startsWith('http') &&
|
|
151
|
+
!href.startsWith('#') &&
|
|
152
|
+
!href.startsWith('mailto:') &&
|
|
153
|
+
!href.startsWith('tel:')) {
|
|
154
|
+
references.markdownLinks.push({
|
|
155
|
+
type: 'markdown_link',
|
|
156
|
+
text: match[1],
|
|
157
|
+
href: href.replace(/\\/g, '/'),
|
|
158
|
+
original: match[0],
|
|
159
|
+
position: match.index
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Extract tech stack claims
|
|
165
|
+
const techRegex = resetRegex(REFERENCE_PATTERNS.techStackMention);
|
|
166
|
+
while ((match = techRegex.exec(content)) !== null) {
|
|
167
|
+
const technologies = match[1]
|
|
168
|
+
.split(/[,;]+/)
|
|
169
|
+
.map(t => t.trim())
|
|
170
|
+
.filter(t => t.length > 0 && t.length < 50);
|
|
171
|
+
if (technologies.length > 0) {
|
|
172
|
+
references.techStackClaims.push({
|
|
173
|
+
type: 'tech_stack_claim',
|
|
174
|
+
technologies,
|
|
175
|
+
original: match[0],
|
|
176
|
+
position: match.index
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Deduplicate references
|
|
182
|
+
references.filePaths = dedupeByFile(references.filePaths);
|
|
183
|
+
references.lineReferences = dedupeByOriginal(references.lineReferences);
|
|
184
|
+
references.anchorReferences = dedupeByOriginal(references.anchorReferences);
|
|
185
|
+
references.directoryReferences = dedupeByOriginal(references.directoryReferences);
|
|
186
|
+
references.markdownLinks = dedupeByOriginal(references.markdownLinks);
|
|
187
|
+
|
|
188
|
+
return references;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Deduplicate by file path
|
|
193
|
+
*/
|
|
194
|
+
function dedupeByFile(refs) {
|
|
195
|
+
const seen = new Set();
|
|
196
|
+
return refs.filter(r => {
|
|
197
|
+
if (seen.has(r.file)) return false;
|
|
198
|
+
seen.add(r.file);
|
|
199
|
+
return true;
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Deduplicate by original text
|
|
205
|
+
*/
|
|
206
|
+
function dedupeByOriginal(refs) {
|
|
207
|
+
const seen = new Set();
|
|
208
|
+
return refs.filter(r => {
|
|
209
|
+
if (seen.has(r.original)) return false;
|
|
210
|
+
seen.add(r.original);
|
|
211
|
+
return true;
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Validate a file path reference
|
|
217
|
+
* @param {object} ref - Reference object
|
|
218
|
+
* @param {string} projectRoot - Project root directory
|
|
219
|
+
* @returns {object} Validation result
|
|
220
|
+
*/
|
|
221
|
+
function validateFilePath(ref, projectRoot) {
|
|
222
|
+
const fullPath = path.join(projectRoot, ref.file);
|
|
223
|
+
|
|
224
|
+
if (fs.existsSync(fullPath)) {
|
|
225
|
+
return { valid: true };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Try to find similar file
|
|
229
|
+
const suggestion = findSimilarFile(ref.file, projectRoot);
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
valid: false,
|
|
233
|
+
level: DRIFT_LEVEL.CRITICAL,
|
|
234
|
+
issue: 'File not found',
|
|
235
|
+
suggestion: suggestion ? `Did you mean: ${suggestion}` : null
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Validate a line reference
|
|
241
|
+
* @param {object} ref - Reference object
|
|
242
|
+
* @param {string} projectRoot - Project root directory
|
|
243
|
+
* @returns {object} Validation result
|
|
244
|
+
*/
|
|
245
|
+
function validateLineReference(ref, projectRoot) {
|
|
246
|
+
const fullPath = path.join(projectRoot, ref.file);
|
|
247
|
+
|
|
248
|
+
if (!fs.existsSync(fullPath)) {
|
|
249
|
+
return {
|
|
250
|
+
valid: false,
|
|
251
|
+
level: DRIFT_LEVEL.CRITICAL,
|
|
252
|
+
issue: 'Referenced file not found'
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
258
|
+
const lineCount = content.split('\n').length;
|
|
259
|
+
|
|
260
|
+
if (ref.line > lineCount) {
|
|
261
|
+
return {
|
|
262
|
+
valid: false,
|
|
263
|
+
level: DRIFT_LEVEL.HIGH,
|
|
264
|
+
issue: `Line ${ref.line} exceeds file length (${lineCount} lines)`,
|
|
265
|
+
suggestion: `File now has ${lineCount} lines`,
|
|
266
|
+
actualLineCount: lineCount
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (ref.endLine && ref.endLine > lineCount) {
|
|
271
|
+
return {
|
|
272
|
+
valid: false,
|
|
273
|
+
level: DRIFT_LEVEL.HIGH,
|
|
274
|
+
issue: `End line ${ref.endLine} exceeds file length (${lineCount} lines)`,
|
|
275
|
+
actualLineCount: lineCount
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return { valid: true, lineCount };
|
|
280
|
+
} catch (error) {
|
|
281
|
+
return {
|
|
282
|
+
valid: false,
|
|
283
|
+
level: DRIFT_LEVEL.HIGH,
|
|
284
|
+
issue: `Cannot read file: ${error.message}`
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Validate an anchor reference (function/class)
|
|
291
|
+
* @param {object} ref - Reference object
|
|
292
|
+
* @param {string} projectRoot - Project root directory
|
|
293
|
+
* @returns {object} Validation result
|
|
294
|
+
*/
|
|
295
|
+
function validateAnchorReference(ref, projectRoot) {
|
|
296
|
+
const fullPath = path.join(projectRoot, ref.file);
|
|
297
|
+
|
|
298
|
+
if (!fs.existsSync(fullPath)) {
|
|
299
|
+
return {
|
|
300
|
+
valid: false,
|
|
301
|
+
level: DRIFT_LEVEL.CRITICAL,
|
|
302
|
+
issue: 'Referenced file not found'
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
try {
|
|
307
|
+
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
308
|
+
const ext = path.extname(ref.file).slice(1).toLowerCase();
|
|
309
|
+
|
|
310
|
+
// Get language-specific patterns
|
|
311
|
+
const patterns = getSymbolPatterns(ext);
|
|
312
|
+
const symbols = extractSymbols(content, patterns);
|
|
313
|
+
|
|
314
|
+
// Check if anchor exists
|
|
315
|
+
const foundSymbol = symbols.find(s =>
|
|
316
|
+
s.name === ref.anchor ||
|
|
317
|
+
s.name === ref.anchor.replace(/\(\)$/, '')
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
if (foundSymbol) {
|
|
321
|
+
return {
|
|
322
|
+
valid: true,
|
|
323
|
+
currentLine: foundSymbol.line,
|
|
324
|
+
symbolType: foundSymbol.type
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Suggest similar symbols
|
|
329
|
+
const similarSymbols = symbols
|
|
330
|
+
.filter(s => s.name.toLowerCase().includes(ref.anchor.toLowerCase().substring(0, 3)))
|
|
331
|
+
.slice(0, 5)
|
|
332
|
+
.map(s => s.name);
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
valid: false,
|
|
336
|
+
level: DRIFT_LEVEL.HIGH,
|
|
337
|
+
issue: `Symbol '${ref.anchor}' not found in ${ref.file}`,
|
|
338
|
+
suggestion: similarSymbols.length > 0
|
|
339
|
+
? `Available symbols: ${similarSymbols.join(', ')}`
|
|
340
|
+
: null,
|
|
341
|
+
availableSymbols: symbols.slice(0, 10).map(s => s.name)
|
|
342
|
+
};
|
|
343
|
+
} catch (error) {
|
|
344
|
+
return {
|
|
345
|
+
valid: false,
|
|
346
|
+
level: DRIFT_LEVEL.HIGH,
|
|
347
|
+
issue: `Cannot analyze file: ${error.message}`
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Validate a directory reference
|
|
354
|
+
* @param {object} ref - Reference object
|
|
355
|
+
* @param {string} projectRoot - Project root directory
|
|
356
|
+
* @returns {object} Validation result
|
|
357
|
+
*/
|
|
358
|
+
function validateDirectory(ref, projectRoot) {
|
|
359
|
+
const fullPath = path.join(projectRoot, ref.directory);
|
|
360
|
+
|
|
361
|
+
if (fs.existsSync(fullPath)) {
|
|
362
|
+
const stat = fs.statSync(fullPath);
|
|
363
|
+
if (stat.isDirectory()) {
|
|
364
|
+
return { valid: true };
|
|
365
|
+
}
|
|
366
|
+
return {
|
|
367
|
+
valid: false,
|
|
368
|
+
level: DRIFT_LEVEL.MEDIUM,
|
|
369
|
+
issue: 'Path exists but is not a directory'
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Try to find similar directory
|
|
374
|
+
const suggestion = findSimilarDirectory(ref.directory, projectRoot);
|
|
375
|
+
|
|
376
|
+
return {
|
|
377
|
+
valid: false,
|
|
378
|
+
level: DRIFT_LEVEL.MEDIUM,
|
|
379
|
+
issue: 'Directory not found',
|
|
380
|
+
suggestion: suggestion ? `Did you mean: ${suggestion}` : null
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Validate a markdown link
|
|
386
|
+
* @param {object} ref - Reference object
|
|
387
|
+
* @param {string} docDir - Directory containing the document
|
|
388
|
+
* @param {string} projectRoot - Project root directory
|
|
389
|
+
* @returns {object} Validation result
|
|
390
|
+
*/
|
|
391
|
+
function validateMarkdownLink(ref, docDir, projectRoot) {
|
|
392
|
+
// Handle relative paths
|
|
393
|
+
let targetPath;
|
|
394
|
+
if (ref.href.startsWith('./') || ref.href.startsWith('../')) {
|
|
395
|
+
targetPath = path.resolve(docDir, ref.href);
|
|
396
|
+
} else if (ref.href.startsWith('/')) {
|
|
397
|
+
targetPath = path.join(projectRoot, ref.href);
|
|
398
|
+
} else {
|
|
399
|
+
targetPath = path.resolve(docDir, ref.href);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Remove anchor from path
|
|
403
|
+
const pathWithoutAnchor = targetPath.split('#')[0];
|
|
404
|
+
|
|
405
|
+
if (fs.existsSync(pathWithoutAnchor)) {
|
|
406
|
+
return { valid: true };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
return {
|
|
410
|
+
valid: false,
|
|
411
|
+
level: DRIFT_LEVEL.MEDIUM,
|
|
412
|
+
issue: `Link target not found: ${ref.href}`
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Check drift for a single documentation file
|
|
418
|
+
* @param {string} docPath - Path to markdown file
|
|
419
|
+
* @param {string} projectRoot - Project root directory
|
|
420
|
+
* @returns {object} Drift report
|
|
421
|
+
*/
|
|
422
|
+
function checkDocumentDrift(docPath, projectRoot = process.cwd()) {
|
|
423
|
+
const fullPath = path.isAbsolute(docPath) ? docPath : path.join(projectRoot, docPath);
|
|
424
|
+
const relativePath = path.isAbsolute(docPath) ? path.relative(projectRoot, docPath) : docPath;
|
|
425
|
+
|
|
426
|
+
if (!fs.existsSync(fullPath)) {
|
|
427
|
+
return {
|
|
428
|
+
document: relativePath,
|
|
429
|
+
status: 'error',
|
|
430
|
+
error: 'Document file not found',
|
|
431
|
+
healthScore: 0
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
try {
|
|
436
|
+
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
437
|
+
const docDir = path.dirname(fullPath);
|
|
438
|
+
const references = extractAllReferences(content);
|
|
439
|
+
const issues = [];
|
|
440
|
+
const validRefs = [];
|
|
441
|
+
|
|
442
|
+
// Validate file paths
|
|
443
|
+
for (const ref of references.filePaths) {
|
|
444
|
+
const result = validateFilePath(ref, projectRoot);
|
|
445
|
+
if (result.valid) {
|
|
446
|
+
validRefs.push({ ...ref, ...result });
|
|
447
|
+
} else {
|
|
448
|
+
issues.push({ ...ref, ...result });
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Validate line references
|
|
453
|
+
for (const ref of references.lineReferences) {
|
|
454
|
+
const result = validateLineReference(ref, projectRoot);
|
|
455
|
+
if (result.valid) {
|
|
456
|
+
validRefs.push({ ...ref, ...result });
|
|
457
|
+
} else {
|
|
458
|
+
issues.push({ ...ref, ...result });
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Validate anchor references
|
|
463
|
+
for (const ref of references.anchorReferences) {
|
|
464
|
+
const result = validateAnchorReference(ref, projectRoot);
|
|
465
|
+
if (result.valid) {
|
|
466
|
+
validRefs.push({ ...ref, ...result });
|
|
467
|
+
} else {
|
|
468
|
+
issues.push({ ...ref, ...result });
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Validate directory references
|
|
473
|
+
for (const ref of references.directoryReferences) {
|
|
474
|
+
const result = validateDirectory(ref, projectRoot);
|
|
475
|
+
if (result.valid) {
|
|
476
|
+
validRefs.push({ ...ref, ...result });
|
|
477
|
+
} else {
|
|
478
|
+
issues.push({ ...ref, ...result });
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Validate markdown links
|
|
483
|
+
for (const ref of references.markdownLinks) {
|
|
484
|
+
const result = validateMarkdownLink(ref, docDir, projectRoot);
|
|
485
|
+
if (result.valid) {
|
|
486
|
+
validRefs.push({ ...ref, ...result });
|
|
487
|
+
} else {
|
|
488
|
+
issues.push({ ...ref, ...result });
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Calculate health score
|
|
493
|
+
const totalRefs = validRefs.length + issues.length;
|
|
494
|
+
const healthScore = totalRefs > 0
|
|
495
|
+
? Math.round((validRefs.length / totalRefs) * 100)
|
|
496
|
+
: 100;
|
|
497
|
+
|
|
498
|
+
// Determine status
|
|
499
|
+
let status;
|
|
500
|
+
if (healthScore >= HEALTH_THRESHOLDS.HEALTHY) {
|
|
501
|
+
status = 'healthy';
|
|
502
|
+
} else if (healthScore >= HEALTH_THRESHOLDS.NEEDS_UPDATE) {
|
|
503
|
+
status = 'needs_update';
|
|
504
|
+
} else if (healthScore >= HEALTH_THRESHOLDS.STALE) {
|
|
505
|
+
status = 'stale';
|
|
506
|
+
} else {
|
|
507
|
+
status = 'critical';
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Determine overall drift level
|
|
511
|
+
const level = calculateDriftLevel(issues);
|
|
512
|
+
|
|
513
|
+
return {
|
|
514
|
+
document: relativePath,
|
|
515
|
+
status,
|
|
516
|
+
level,
|
|
517
|
+
healthScore,
|
|
518
|
+
summary: {
|
|
519
|
+
total: totalRefs,
|
|
520
|
+
valid: validRefs.length,
|
|
521
|
+
issues: issues.length
|
|
522
|
+
},
|
|
523
|
+
references: {
|
|
524
|
+
valid: validRefs,
|
|
525
|
+
invalid: issues
|
|
526
|
+
},
|
|
527
|
+
byType: {
|
|
528
|
+
filePaths: references.filePaths.length,
|
|
529
|
+
lineReferences: references.lineReferences.length,
|
|
530
|
+
anchorReferences: references.anchorReferences.length,
|
|
531
|
+
directories: references.directoryReferences.length,
|
|
532
|
+
markdownLinks: references.markdownLinks.length,
|
|
533
|
+
techStackClaims: references.techStackClaims.length
|
|
534
|
+
},
|
|
535
|
+
checkedAt: new Date().toISOString()
|
|
536
|
+
};
|
|
537
|
+
} catch (error) {
|
|
538
|
+
return {
|
|
539
|
+
document: relativePath,
|
|
540
|
+
status: 'error',
|
|
541
|
+
error: `Failed to check document: ${error.message}`,
|
|
542
|
+
healthScore: 0
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Generate comprehensive drift report for multiple documents
|
|
549
|
+
* @param {string[]} docPaths - Array of document paths to analyze
|
|
550
|
+
* @param {string} projectRoot - Project root directory
|
|
551
|
+
* @returns {object} Complete drift report
|
|
552
|
+
*/
|
|
553
|
+
function generateDriftReport(docPaths, projectRoot = process.cwd()) {
|
|
554
|
+
const report = {
|
|
555
|
+
version: '2.0.0',
|
|
556
|
+
generatedAt: new Date().toISOString(),
|
|
557
|
+
projectRoot,
|
|
558
|
+
summary: {
|
|
559
|
+
totalDocuments: 0,
|
|
560
|
+
healthyDocuments: 0,
|
|
561
|
+
documentsWithIssues: 0,
|
|
562
|
+
overallHealthScore: 0,
|
|
563
|
+
totalReferences: 0,
|
|
564
|
+
validReferences: 0,
|
|
565
|
+
invalidReferences: 0
|
|
566
|
+
},
|
|
567
|
+
healthBreakdown: {
|
|
568
|
+
filePaths: { valid: 0, invalid: 0, score: 100 },
|
|
569
|
+
lineReferences: { valid: 0, invalid: 0, score: 100 },
|
|
570
|
+
anchorReferences: { valid: 0, invalid: 0, score: 100 },
|
|
571
|
+
directories: { valid: 0, invalid: 0, score: 100 },
|
|
572
|
+
markdownLinks: { valid: 0, invalid: 0, score: 100 }
|
|
573
|
+
},
|
|
574
|
+
documents: [],
|
|
575
|
+
suggestedFixes: []
|
|
576
|
+
};
|
|
577
|
+
|
|
578
|
+
for (const docPath of docPaths) {
|
|
579
|
+
const result = checkDocumentDrift(docPath, projectRoot);
|
|
580
|
+
report.documents.push(result);
|
|
581
|
+
|
|
582
|
+
if (result.status === 'error') continue;
|
|
583
|
+
|
|
584
|
+
report.summary.totalDocuments++;
|
|
585
|
+
report.summary.totalReferences += result.summary.total;
|
|
586
|
+
report.summary.validReferences += result.summary.valid;
|
|
587
|
+
report.summary.invalidReferences += result.summary.issues;
|
|
588
|
+
|
|
589
|
+
if (result.status === 'healthy') {
|
|
590
|
+
report.summary.healthyDocuments++;
|
|
591
|
+
} else {
|
|
592
|
+
report.summary.documentsWithIssues++;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// Aggregate type stats
|
|
596
|
+
for (const ref of result.references.valid) {
|
|
597
|
+
const typeKey = getTypeKey(ref.type);
|
|
598
|
+
if (typeKey && report.healthBreakdown[typeKey]) {
|
|
599
|
+
report.healthBreakdown[typeKey].valid++;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
for (const ref of result.references.invalid) {
|
|
603
|
+
const typeKey = getTypeKey(ref.type);
|
|
604
|
+
if (typeKey && report.healthBreakdown[typeKey]) {
|
|
605
|
+
report.healthBreakdown[typeKey].invalid++;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Generate suggested fixes
|
|
609
|
+
if (ref.suggestion) {
|
|
610
|
+
report.suggestedFixes.push({
|
|
611
|
+
document: result.document,
|
|
612
|
+
original: ref.original,
|
|
613
|
+
issue: ref.issue,
|
|
614
|
+
suggestion: ref.suggestion,
|
|
615
|
+
level: ref.level
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// Calculate overall health score
|
|
622
|
+
report.summary.overallHealthScore = report.summary.totalReferences > 0
|
|
623
|
+
? Math.round((report.summary.validReferences / report.summary.totalReferences) * 100)
|
|
624
|
+
: 100;
|
|
625
|
+
|
|
626
|
+
// Calculate per-type scores
|
|
627
|
+
for (const [type, stats] of Object.entries(report.healthBreakdown)) {
|
|
628
|
+
const total = stats.valid + stats.invalid;
|
|
629
|
+
stats.score = total > 0 ? Math.round((stats.valid / total) * 100) : 100;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
return report;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Find documentation files in project
|
|
637
|
+
* @param {string} projectRoot - Project root
|
|
638
|
+
* @returns {Promise<string[]>} Array of doc paths
|
|
639
|
+
*/
|
|
640
|
+
async function findDocumentationFiles(projectRoot) {
|
|
641
|
+
const patterns = [
|
|
642
|
+
'CLAUDE.md',
|
|
643
|
+
'AI_CONTEXT.md',
|
|
644
|
+
'README.md',
|
|
645
|
+
'.github/copilot-instructions.md',
|
|
646
|
+
'.clinerules',
|
|
647
|
+
'docs/**/*.md',
|
|
648
|
+
'.claude/**/*.md',
|
|
649
|
+
'.ai-context/**/*.md'
|
|
650
|
+
];
|
|
651
|
+
|
|
652
|
+
const files = [];
|
|
653
|
+
for (const pattern of patterns) {
|
|
654
|
+
try {
|
|
655
|
+
const matches = await glob(pattern, {
|
|
656
|
+
cwd: projectRoot,
|
|
657
|
+
ignore: ['node_modules/**', '**/node_modules/**'],
|
|
658
|
+
nodir: true
|
|
659
|
+
});
|
|
660
|
+
files.push(...matches);
|
|
661
|
+
} catch {
|
|
662
|
+
// Ignore glob errors
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
return [...new Set(files)];
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Calculate drift level from issues
|
|
671
|
+
*/
|
|
672
|
+
function calculateDriftLevel(issues) {
|
|
673
|
+
if (issues.length === 0) return DRIFT_LEVEL.NONE;
|
|
674
|
+
|
|
675
|
+
const hasCritical = issues.some(i => i.level === DRIFT_LEVEL.CRITICAL);
|
|
676
|
+
const hasHigh = issues.some(i => i.level === DRIFT_LEVEL.HIGH);
|
|
677
|
+
const hasMedium = issues.some(i => i.level === DRIFT_LEVEL.MEDIUM);
|
|
678
|
+
|
|
679
|
+
if (hasCritical) return DRIFT_LEVEL.CRITICAL;
|
|
680
|
+
if (hasHigh) return DRIFT_LEVEL.HIGH;
|
|
681
|
+
if (hasMedium) return DRIFT_LEVEL.MEDIUM;
|
|
682
|
+
return DRIFT_LEVEL.LOW;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Get type key for health breakdown
|
|
687
|
+
*/
|
|
688
|
+
function getTypeKey(type) {
|
|
689
|
+
const mapping = {
|
|
690
|
+
'file_path': 'filePaths',
|
|
691
|
+
'line_reference': 'lineReferences',
|
|
692
|
+
'anchor_reference': 'anchorReferences',
|
|
693
|
+
'directory_reference': 'directories',
|
|
694
|
+
'markdown_link': 'markdownLinks'
|
|
695
|
+
};
|
|
696
|
+
return mapping[type];
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Get symbol extraction patterns for a language
|
|
701
|
+
*/
|
|
702
|
+
function getSymbolPatterns(ext) {
|
|
703
|
+
const patterns = {
|
|
704
|
+
// Python
|
|
705
|
+
py: [
|
|
706
|
+
/^(?:async\s+)?def\s+(\w+)\s*\(/gm,
|
|
707
|
+
/^class\s+(\w+)/gm
|
|
708
|
+
],
|
|
709
|
+
pyw: [
|
|
710
|
+
/^(?:async\s+)?def\s+(\w+)\s*\(/gm,
|
|
711
|
+
/^class\s+(\w+)/gm
|
|
712
|
+
],
|
|
713
|
+
// JavaScript/TypeScript
|
|
714
|
+
js: [
|
|
715
|
+
/^(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\(/gm,
|
|
716
|
+
/^(?:export\s+)?class\s+(\w+)/gm,
|
|
717
|
+
/^(?:export\s+)?const\s+(\w+)\s*=\s*(?:async\s+)?\(/gm,
|
|
718
|
+
/^(?:export\s+)?const\s+(\w+)\s*=\s*(?:async\s+)?\([^)]*\)\s*=>/gm
|
|
719
|
+
],
|
|
720
|
+
ts: [
|
|
721
|
+
/^(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*[<(]/gm,
|
|
722
|
+
/^(?:export\s+)?class\s+(\w+)/gm,
|
|
723
|
+
/^(?:export\s+)?const\s+(\w+)\s*=\s*(?:async\s+)?\(/gm,
|
|
724
|
+
/^(?:export\s+)?interface\s+(\w+)/gm,
|
|
725
|
+
/^(?:export\s+)?type\s+(\w+)/gm
|
|
726
|
+
],
|
|
727
|
+
// Go
|
|
728
|
+
go: [
|
|
729
|
+
/^func\s+(?:\([^)]+\)\s+)?(\w+)\s*\(/gm,
|
|
730
|
+
/^type\s+(\w+)\s+struct/gm,
|
|
731
|
+
/^type\s+(\w+)\s+interface/gm
|
|
732
|
+
],
|
|
733
|
+
// Rust
|
|
734
|
+
rs: [
|
|
735
|
+
/^(?:pub\s+)?(?:async\s+)?fn\s+(\w+)/gm,
|
|
736
|
+
/^(?:pub\s+)?struct\s+(\w+)/gm,
|
|
737
|
+
/^(?:pub\s+)?enum\s+(\w+)/gm,
|
|
738
|
+
/^(?:pub\s+)?trait\s+(\w+)/gm,
|
|
739
|
+
/^impl(?:<[^>]+>)?\s+(\w+)/gm
|
|
740
|
+
],
|
|
741
|
+
// Ruby
|
|
742
|
+
rb: [
|
|
743
|
+
/^(?:\s*)def\s+(\w+)/gm,
|
|
744
|
+
/^(?:\s*)class\s+(\w+)/gm,
|
|
745
|
+
/^(?:\s*)module\s+(\w+)/gm
|
|
746
|
+
]
|
|
747
|
+
};
|
|
748
|
+
|
|
749
|
+
// Handle jsx, tsx, mjs, cjs as their base type
|
|
750
|
+
const normalized = ext.replace(/[jt]sx$/, ext[0] + 's').replace(/[cm]js$/, 'js');
|
|
751
|
+
return patterns[normalized] || patterns.js; // Default to JS patterns
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* Extract symbols from content using patterns
|
|
756
|
+
*/
|
|
757
|
+
function extractSymbols(content, patterns) {
|
|
758
|
+
const symbols = [];
|
|
759
|
+
const lines = content.split('\n');
|
|
760
|
+
|
|
761
|
+
for (const pattern of patterns) {
|
|
762
|
+
pattern.lastIndex = 0;
|
|
763
|
+
let match;
|
|
764
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
765
|
+
const name = match[1];
|
|
766
|
+
// Calculate line number
|
|
767
|
+
const beforeMatch = content.substring(0, match.index);
|
|
768
|
+
const line = beforeMatch.split('\n').length;
|
|
769
|
+
|
|
770
|
+
symbols.push({
|
|
771
|
+
name,
|
|
772
|
+
line,
|
|
773
|
+
type: pattern.source.includes('class') ? 'class' :
|
|
774
|
+
pattern.source.includes('struct') ? 'struct' :
|
|
775
|
+
pattern.source.includes('interface') ? 'interface' :
|
|
776
|
+
pattern.source.includes('trait') ? 'trait' :
|
|
777
|
+
pattern.source.includes('type') ? 'type' :
|
|
778
|
+
pattern.source.includes('module') ? 'module' : 'function'
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
return symbols;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* Find similar file in project
|
|
788
|
+
*/
|
|
789
|
+
function findSimilarFile(targetFile, projectRoot) {
|
|
790
|
+
const basename = path.basename(targetFile);
|
|
791
|
+
const dirname = path.dirname(targetFile);
|
|
792
|
+
|
|
793
|
+
// Try common variations
|
|
794
|
+
const variations = [
|
|
795
|
+
targetFile,
|
|
796
|
+
path.join(dirname, basename.toLowerCase()),
|
|
797
|
+
path.join('src', targetFile),
|
|
798
|
+
path.join('lib', targetFile),
|
|
799
|
+
basename
|
|
800
|
+
];
|
|
801
|
+
|
|
802
|
+
for (const variation of variations) {
|
|
803
|
+
const fullPath = path.join(projectRoot, variation);
|
|
804
|
+
if (fs.existsSync(fullPath)) {
|
|
805
|
+
return variation;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
return null;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* Find similar directory in project
|
|
814
|
+
*/
|
|
815
|
+
function findSimilarDirectory(targetDir, projectRoot) {
|
|
816
|
+
const basename = path.basename(targetDir.replace(/\/$/, ''));
|
|
817
|
+
|
|
818
|
+
// Try common variations
|
|
819
|
+
const variations = [
|
|
820
|
+
targetDir,
|
|
821
|
+
path.join('src', basename),
|
|
822
|
+
path.join('lib', basename),
|
|
823
|
+
basename
|
|
824
|
+
];
|
|
825
|
+
|
|
826
|
+
for (const variation of variations) {
|
|
827
|
+
const fullPath = path.join(projectRoot, variation);
|
|
828
|
+
if (fs.existsSync(fullPath) && fs.statSync(fullPath).isDirectory()) {
|
|
829
|
+
return variation + '/';
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
return null;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* Format drift report for console output
|
|
838
|
+
*/
|
|
839
|
+
function formatDriftReportConsole(report) {
|
|
840
|
+
const lines = [];
|
|
841
|
+
|
|
842
|
+
lines.push('');
|
|
843
|
+
lines.push('Documentation Drift Report');
|
|
844
|
+
lines.push('=' .repeat(50));
|
|
845
|
+
lines.push('');
|
|
846
|
+
|
|
847
|
+
// Overall summary
|
|
848
|
+
const healthEmoji = report.summary.overallHealthScore >= 90 ? '\u2713' :
|
|
849
|
+
report.summary.overallHealthScore >= 70 ? '\u26A0' : '\u2717';
|
|
850
|
+
lines.push(`Overall Health: ${report.summary.overallHealthScore}% ${healthEmoji}`);
|
|
851
|
+
lines.push('');
|
|
852
|
+
lines.push(`Documents: ${report.summary.totalDocuments} (${report.summary.healthyDocuments} healthy)`);
|
|
853
|
+
lines.push(`References: ${report.summary.validReferences}/${report.summary.totalReferences} valid`);
|
|
854
|
+
lines.push('');
|
|
855
|
+
|
|
856
|
+
// Per-document results
|
|
857
|
+
for (const doc of report.documents) {
|
|
858
|
+
if (doc.status === 'error') {
|
|
859
|
+
lines.push(`${doc.document} - ERROR: ${doc.error}`);
|
|
860
|
+
continue;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
const emoji = doc.status === 'healthy' ? '\u2713' :
|
|
864
|
+
doc.status === 'needs_update' ? '\u26A0' : '\u2717';
|
|
865
|
+
lines.push(`${doc.document} - ${doc.healthScore}% ${emoji} ${doc.status}`);
|
|
866
|
+
|
|
867
|
+
if (doc.references.invalid.length > 0) {
|
|
868
|
+
for (const issue of doc.references.invalid.slice(0, 5)) {
|
|
869
|
+
lines.push(` \u2717 ${issue.original} - ${issue.issue}`);
|
|
870
|
+
if (issue.suggestion) {
|
|
871
|
+
lines.push(` ${issue.suggestion}`);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
if (doc.references.invalid.length > 5) {
|
|
875
|
+
lines.push(` ... and ${doc.references.invalid.length - 5} more issues`);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
lines.push('');
|
|
881
|
+
|
|
882
|
+
// Suggested fixes
|
|
883
|
+
if (report.suggestedFixes.length > 0) {
|
|
884
|
+
lines.push('Suggested Fixes:');
|
|
885
|
+
for (const fix of report.suggestedFixes.slice(0, 10)) {
|
|
886
|
+
lines.push(` ${fix.document}: ${fix.original}`);
|
|
887
|
+
lines.push(` -> ${fix.suggestion}`);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
return lines.join('\n');
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
module.exports = {
|
|
895
|
+
// Core functions
|
|
896
|
+
extractAllReferences,
|
|
897
|
+
checkDocumentDrift,
|
|
898
|
+
generateDriftReport,
|
|
899
|
+
findDocumentationFiles,
|
|
900
|
+
formatDriftReportConsole,
|
|
901
|
+
|
|
902
|
+
// Validation functions
|
|
903
|
+
validateFilePath,
|
|
904
|
+
validateLineReference,
|
|
905
|
+
validateAnchorReference,
|
|
906
|
+
validateDirectory,
|
|
907
|
+
validateMarkdownLink,
|
|
908
|
+
|
|
909
|
+
// Utilities
|
|
910
|
+
extractSymbols,
|
|
911
|
+
getSymbolPatterns,
|
|
912
|
+
findSimilarFile,
|
|
913
|
+
findSimilarDirectory,
|
|
914
|
+
calculateDriftLevel,
|
|
915
|
+
|
|
916
|
+
// Constants
|
|
917
|
+
DRIFT_LEVEL,
|
|
918
|
+
HEALTH_THRESHOLDS,
|
|
919
|
+
REFERENCE_PATTERNS
|
|
920
|
+
};
|