skrypt-ai 0.8.0 → 0.8.1
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/auth/index.js +6 -0
- package/dist/binding/binder.d.ts +5 -0
- package/dist/binding/binder.js +63 -0
- package/dist/binding/detector.d.ts +5 -0
- package/dist/binding/detector.js +51 -0
- package/dist/binding/doc-parser.d.ts +9 -0
- package/dist/binding/doc-parser.js +138 -0
- package/dist/binding/extractor.d.ts +14 -0
- package/dist/binding/extractor.js +39 -0
- package/dist/binding/index.d.ts +5 -0
- package/dist/binding/index.js +5 -0
- package/dist/binding/types.d.ts +74 -0
- package/dist/binding/types.js +1 -0
- package/dist/claims/extractor.d.ts +13 -0
- package/dist/claims/extractor.js +138 -0
- package/dist/claims/index.d.ts +4 -0
- package/dist/claims/index.js +4 -0
- package/dist/claims/reporter.d.ts +9 -0
- package/dist/claims/reporter.js +65 -0
- package/dist/claims/store.d.ts +13 -0
- package/dist/claims/store.js +51 -0
- package/dist/claims/types.d.ts +34 -0
- package/dist/claims/types.js +1 -0
- package/dist/cli.js +516 -56
- package/dist/commands/bind.d.ts +2 -0
- package/dist/commands/bind.js +139 -0
- package/dist/commands/claims.d.ts +2 -0
- package/dist/commands/claims.js +84 -0
- package/dist/commands/coverage.d.ts +2 -0
- package/dist/commands/coverage.js +61 -0
- package/dist/commands/generate/index.js +5 -0
- package/dist/commands/generate/scan.js +33 -14
- package/dist/commands/generate/write.d.ts +7 -0
- package/dist/commands/generate/write.js +65 -1
- package/dist/commands/import.js +12 -3
- package/dist/commands/init.js +68 -5
- package/dist/commands/monitor.d.ts +15 -0
- package/dist/commands/monitor.js +2 -2
- package/dist/commands/mutate.d.ts +2 -0
- package/dist/commands/mutate.js +177 -0
- package/dist/config/types.js +2 -2
- package/dist/coverage/calculator.d.ts +7 -0
- package/dist/coverage/calculator.js +86 -0
- package/dist/coverage/index.d.ts +3 -0
- package/dist/coverage/index.js +3 -0
- package/dist/coverage/reporter.d.ts +9 -0
- package/dist/coverage/reporter.js +65 -0
- package/dist/coverage/types.d.ts +36 -0
- package/dist/coverage/types.js +1 -0
- package/dist/generator/generator.d.ts +3 -1
- package/dist/generator/generator.js +137 -23
- package/dist/generator/mdx-serializer.js +3 -2
- package/dist/generator/organizer.d.ts +5 -1
- package/dist/generator/organizer.js +29 -14
- package/dist/generator/types.d.ts +6 -0
- package/dist/generator/writer.js +7 -2
- package/dist/github/org-discovery.js +5 -0
- package/dist/importers/mintlify.js +4 -3
- package/dist/llm/anthropic-client.js +1 -0
- package/dist/llm/index.d.ts +15 -0
- package/dist/llm/index.js +148 -29
- package/dist/llm/openai-client.js +2 -0
- package/dist/mutation/index.d.ts +4 -0
- package/dist/mutation/index.js +4 -0
- package/dist/mutation/mutator.d.ts +5 -0
- package/dist/mutation/mutator.js +101 -0
- package/dist/mutation/reporter.d.ts +14 -0
- package/dist/mutation/reporter.js +66 -0
- package/dist/mutation/runner.d.ts +9 -0
- package/dist/mutation/runner.js +70 -0
- package/dist/mutation/types.d.ts +51 -0
- package/dist/mutation/types.js +1 -0
- package/dist/qa/checks.d.ts +1 -0
- package/dist/qa/checks.js +47 -0
- package/dist/qa/index.js +2 -1
- package/dist/scanner/index.js +78 -11
- package/dist/scanner/typescript.js +42 -31
- package/dist/sentry.d.ts +3 -0
- package/dist/sentry.js +28 -0
- package/dist/template/docs.json +6 -3
- package/dist/template/next.config.mjs +15 -1
- package/dist/template/package.json +4 -3
- package/dist/template/public/docs-schema.json +257 -0
- package/dist/template/sentry.client.config.ts +12 -0
- package/dist/template/sentry.edge.config.ts +7 -0
- package/dist/template/sentry.server.config.ts +7 -0
- package/dist/template/src/app/docs/[...slug]/page.tsx +11 -5
- package/dist/template/src/app/docs/layout.tsx +2 -4
- package/dist/template/src/app/global-error.tsx +60 -0
- package/dist/template/src/app/layout.tsx +7 -16
- package/dist/template/src/app/page.tsx +2 -5
- package/dist/template/src/components/ai-chat-impl.tsx +1 -1
- package/dist/template/src/components/docs-layout.tsx +1 -15
- package/dist/template/src/components/footer.tsx +95 -19
- package/dist/template/src/components/header.tsx +1 -1
- package/dist/template/src/components/search-dialog.tsx +5 -0
- package/dist/template/src/instrumentation.ts +11 -0
- package/dist/template/src/lib/docs-config.ts +235 -0
- package/dist/template/src/lib/fonts.ts +3 -3
- package/dist/testing/runner.js +8 -1
- package/package.json +2 -1
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
|
|
3
|
+
import { resolve, join, extname } from 'path';
|
|
4
|
+
import { requirePro } from '../auth/index.js';
|
|
5
|
+
import { generateMutants } from '../mutation/mutator.js';
|
|
6
|
+
import { runMutant } from '../mutation/runner.js';
|
|
7
|
+
import { printMutationReport, formatMutationJson } from '../mutation/reporter.js';
|
|
8
|
+
export const mutateCommand = new Command('mutate')
|
|
9
|
+
.description('Run mutation testing on documented code examples')
|
|
10
|
+
.argument('[source]', 'Source code directory', '.')
|
|
11
|
+
.option('--files <paths...>', 'Target specific source files')
|
|
12
|
+
.option('--max-mutants <n>', 'Maximum mutants to generate', '100')
|
|
13
|
+
.option('--min-score <percent>', 'Fail if mutation score is below threshold')
|
|
14
|
+
.option('--timeout <ms>', 'Timeout per example in milliseconds', '10000')
|
|
15
|
+
.option('--docs <dir>', 'Documentation directory', './docs')
|
|
16
|
+
.option('--json', 'Output as JSON')
|
|
17
|
+
.action(async (source, options) => {
|
|
18
|
+
try {
|
|
19
|
+
if (!await requirePro('mutate')) {
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
const sourcePath = resolve(source);
|
|
23
|
+
const docsPath = resolve(options.docs || './docs');
|
|
24
|
+
const maxMutants = parseInt(options.maxMutants || '100', 10);
|
|
25
|
+
const timeout = parseInt(options.timeout || '10000', 10);
|
|
26
|
+
if (!existsSync(sourcePath)) {
|
|
27
|
+
console.error(`Error: Source directory not found: ${sourcePath}`);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
console.log('skrypt mutate');
|
|
31
|
+
console.log(` source: ${sourcePath}`);
|
|
32
|
+
console.log(` docs: ${docsPath}`);
|
|
33
|
+
console.log(` max: ${maxMutants} mutants`);
|
|
34
|
+
console.log('');
|
|
35
|
+
// Step 1: Find source files to mutate
|
|
36
|
+
const sourceFiles = options.files
|
|
37
|
+
? options.files.map(f => resolve(f))
|
|
38
|
+
: findSourceFiles(sourcePath);
|
|
39
|
+
console.log(`Found ${sourceFiles.length} source files`);
|
|
40
|
+
// Step 2: Extract doc examples
|
|
41
|
+
console.log('Extracting doc examples...');
|
|
42
|
+
const docExamples = extractDocExamples(docsPath);
|
|
43
|
+
console.log(` Found ${docExamples.length} code examples in docs`);
|
|
44
|
+
if (docExamples.length === 0) {
|
|
45
|
+
console.log('\nNo code examples found in documentation. Nothing to test.');
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
// Step 3: Generate mutants
|
|
49
|
+
console.log('\nGenerating mutants...');
|
|
50
|
+
let allMutants = sourceFiles.flatMap(f => generateMutants(f, maxMutants));
|
|
51
|
+
allMutants = allMutants.slice(0, maxMutants);
|
|
52
|
+
console.log(` Generated ${allMutants.length} mutants`);
|
|
53
|
+
if (allMutants.length === 0) {
|
|
54
|
+
console.log('\nNo mutants generated. Source files may not contain mutable patterns.');
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
// Step 4: Run mutations
|
|
58
|
+
console.log('\nRunning mutation tests...');
|
|
59
|
+
const results = [];
|
|
60
|
+
for (let i = 0; i < allMutants.length; i++) {
|
|
61
|
+
const mutant = allMutants[i];
|
|
62
|
+
process.stdout.write(`\r [${i + 1}/${allMutants.length}] Testing mutant ${mutant.id}...`);
|
|
63
|
+
const result = runMutant(mutant, docExamples, timeout);
|
|
64
|
+
results.push(result);
|
|
65
|
+
}
|
|
66
|
+
console.log('');
|
|
67
|
+
// Step 5: Build report
|
|
68
|
+
const killed = results.filter(r => r.status === 'killed').length;
|
|
69
|
+
const survived = results.filter(r => r.status === 'survived').length;
|
|
70
|
+
const errors = results.filter(r => r.status === 'error').length;
|
|
71
|
+
const timeouts = results.filter(r => r.status === 'timeout').length;
|
|
72
|
+
const total = killed + survived;
|
|
73
|
+
// Per-file breakdown
|
|
74
|
+
const byFile = new Map();
|
|
75
|
+
for (const r of results) {
|
|
76
|
+
const key = r.mutant.filePath;
|
|
77
|
+
if (!byFile.has(key))
|
|
78
|
+
byFile.set(key, []);
|
|
79
|
+
byFile.get(key).push(r);
|
|
80
|
+
}
|
|
81
|
+
const report = {
|
|
82
|
+
totalMutants: results.length,
|
|
83
|
+
killed,
|
|
84
|
+
survived,
|
|
85
|
+
errors,
|
|
86
|
+
timeouts,
|
|
87
|
+
mutationScore: total > 0 ? (killed / total) * 100 : 100,
|
|
88
|
+
files: [...byFile.entries()].map(([filePath, fileResults]) => {
|
|
89
|
+
const fKilled = fileResults.filter(r => r.status === 'killed').length;
|
|
90
|
+
const fSurvived = fileResults.filter(r => r.status === 'survived').length;
|
|
91
|
+
const fTotal = fKilled + fSurvived;
|
|
92
|
+
return {
|
|
93
|
+
filePath,
|
|
94
|
+
mutants: fileResults.length,
|
|
95
|
+
killed: fKilled,
|
|
96
|
+
survived: fSurvived,
|
|
97
|
+
score: fTotal > 0 ? (fKilled / fTotal) * 100 : 100,
|
|
98
|
+
};
|
|
99
|
+
}),
|
|
100
|
+
survivors: results.filter(r => r.status === 'survived'),
|
|
101
|
+
};
|
|
102
|
+
// Step 6: Output
|
|
103
|
+
if (options.json) {
|
|
104
|
+
console.log(formatMutationJson(report));
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
printMutationReport(report);
|
|
108
|
+
}
|
|
109
|
+
// Step 7: Threshold check
|
|
110
|
+
if (options.minScore) {
|
|
111
|
+
const threshold = parseInt(options.minScore, 10);
|
|
112
|
+
if (!isNaN(threshold) && report.mutationScore < threshold) {
|
|
113
|
+
console.error(`\nMutation score ${report.mutationScore.toFixed(1)}% is below threshold ${threshold}%`);
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
console.error(`Error: ${err instanceof Error ? err.message : err}`);
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
function findSourceFiles(dir) {
|
|
124
|
+
const codeExts = new Set(['.ts', '.tsx', '.js', '.jsx', '.py', '.go', '.rs']);
|
|
125
|
+
const files = [];
|
|
126
|
+
function walk(d) {
|
|
127
|
+
const entries = readdirSync(d);
|
|
128
|
+
for (const entry of entries) {
|
|
129
|
+
const fullPath = join(d, entry);
|
|
130
|
+
const s = statSync(fullPath);
|
|
131
|
+
if (s.isDirectory() && !entry.startsWith('.') && entry !== 'node_modules' && entry !== 'dist') {
|
|
132
|
+
walk(fullPath);
|
|
133
|
+
}
|
|
134
|
+
else if (s.isFile() && codeExts.has(extname(entry))) {
|
|
135
|
+
files.push(fullPath);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
walk(dir);
|
|
140
|
+
return files;
|
|
141
|
+
}
|
|
142
|
+
function extractDocExamples(docsDir) {
|
|
143
|
+
const examples = [];
|
|
144
|
+
if (!existsSync(docsDir))
|
|
145
|
+
return examples;
|
|
146
|
+
function walk(d) {
|
|
147
|
+
const entries = readdirSync(d);
|
|
148
|
+
for (const entry of entries) {
|
|
149
|
+
const fullPath = join(d, entry);
|
|
150
|
+
const s = statSync(fullPath);
|
|
151
|
+
if (s.isDirectory() && !entry.startsWith('.')) {
|
|
152
|
+
walk(fullPath);
|
|
153
|
+
}
|
|
154
|
+
else if (s.isFile() && (extname(entry) === '.md' || extname(entry) === '.mdx')) {
|
|
155
|
+
const content = readFileSync(fullPath, 'utf-8');
|
|
156
|
+
const blocks = content.matchAll(/```(\w+)\n([\s\S]*?)```/g);
|
|
157
|
+
for (const block of blocks) {
|
|
158
|
+
const lang = block[1].toLowerCase();
|
|
159
|
+
if (['typescript', 'javascript', 'python', 'ts', 'js', 'py'].includes(lang)) {
|
|
160
|
+
examples.push({ code: block[2], language: normalizeLang(lang), filePath: fullPath });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
walk(docsDir);
|
|
167
|
+
return examples;
|
|
168
|
+
}
|
|
169
|
+
function normalizeLang(lang) {
|
|
170
|
+
if (lang === 'ts' || lang === 'typescript')
|
|
171
|
+
return 'typescript';
|
|
172
|
+
if (lang === 'js' || lang === 'javascript')
|
|
173
|
+
return 'javascript';
|
|
174
|
+
if (lang === 'py' || lang === 'python')
|
|
175
|
+
return 'python';
|
|
176
|
+
return lang;
|
|
177
|
+
}
|
package/dist/config/types.js
CHANGED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { APIElement } from '../scanner/types.js';
|
|
2
|
+
import { DocumentedElement } from '../audit/types.js';
|
|
3
|
+
import { CoverageReport } from './types.js';
|
|
4
|
+
/**
|
|
5
|
+
* Calculate documentation coverage by cross-referencing code symbols with docs
|
|
6
|
+
*/
|
|
7
|
+
export declare function calculateCoverage(codeElements: APIElement[], docElements: DocumentedElement[], includeInternal?: boolean): CoverageReport;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Calculate documentation coverage by cross-referencing code symbols with docs
|
|
3
|
+
*/
|
|
4
|
+
export function calculateCoverage(codeElements, docElements, includeInternal = false) {
|
|
5
|
+
// Filter to exported/public unless includeInternal
|
|
6
|
+
const symbols = includeInternal
|
|
7
|
+
? codeElements
|
|
8
|
+
: codeElements.filter(el => el.isExported || el.isPublic);
|
|
9
|
+
// Build a set of documented names (normalized, lowercased for matching).
|
|
10
|
+
// Note: matching is by name only because DocumentedElement tracks the doc file,
|
|
11
|
+
// not the source file. If two source files export the same name (e.g. `parse`),
|
|
12
|
+
// documenting either one counts both as covered. This is intentional: docs
|
|
13
|
+
// reference symbols by name, not by file path.
|
|
14
|
+
const documentedNames = new Set();
|
|
15
|
+
const exampleNames = new Set();
|
|
16
|
+
for (const doc of docElements) {
|
|
17
|
+
// Use just name for matching (docs don't track filePath)
|
|
18
|
+
documentedNames.add(doc.name.toLowerCase());
|
|
19
|
+
if (doc.hasExample) {
|
|
20
|
+
exampleNames.add(doc.name.toLowerCase());
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
// Group symbols by file
|
|
24
|
+
const byFile = new Map();
|
|
25
|
+
for (const el of symbols) {
|
|
26
|
+
if (!byFile.has(el.filePath))
|
|
27
|
+
byFile.set(el.filePath, []);
|
|
28
|
+
byFile.get(el.filePath).push(el);
|
|
29
|
+
}
|
|
30
|
+
// Calculate per-file coverage
|
|
31
|
+
const files = [];
|
|
32
|
+
const undocumented = [];
|
|
33
|
+
for (const [filePath, fileElements] of byFile) {
|
|
34
|
+
let documented = 0;
|
|
35
|
+
let withExamples = 0;
|
|
36
|
+
const undocNames = [];
|
|
37
|
+
for (const el of fileElements) {
|
|
38
|
+
const normalizedName = el.name.toLowerCase();
|
|
39
|
+
if (documentedNames.has(normalizedName)) {
|
|
40
|
+
documented++;
|
|
41
|
+
if (exampleNames.has(normalizedName)) {
|
|
42
|
+
withExamples++;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
undocNames.push(el.name);
|
|
47
|
+
undocumented.push({
|
|
48
|
+
name: el.name,
|
|
49
|
+
kind: el.kind,
|
|
50
|
+
filePath: el.filePath,
|
|
51
|
+
isExported: el.isExported ?? false,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
files.push({
|
|
56
|
+
filePath,
|
|
57
|
+
totalSymbols: fileElements.length,
|
|
58
|
+
documentedSymbols: documented,
|
|
59
|
+
exampleSymbols: withExamples,
|
|
60
|
+
coveragePercent: fileElements.length > 0
|
|
61
|
+
? Math.round((documented / fileElements.length) * 100)
|
|
62
|
+
: 100,
|
|
63
|
+
undocumentedNames: undocNames,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
// Sort files by coverage (lowest first)
|
|
67
|
+
files.sort((a, b) => a.coveragePercent - b.coveragePercent);
|
|
68
|
+
// Sort undocumented: exported first, then by name
|
|
69
|
+
undocumented.sort((a, b) => {
|
|
70
|
+
if (a.isExported !== b.isExported)
|
|
71
|
+
return a.isExported ? -1 : 1;
|
|
72
|
+
return a.name.localeCompare(b.name);
|
|
73
|
+
});
|
|
74
|
+
const totalSymbols = symbols.length;
|
|
75
|
+
const documentedSymbols = symbols.filter(el => documentedNames.has(el.name.toLowerCase())).length;
|
|
76
|
+
const exampleSymbols = symbols.filter(el => exampleNames.has(el.name.toLowerCase())).length;
|
|
77
|
+
return {
|
|
78
|
+
totalSymbols,
|
|
79
|
+
documentedSymbols,
|
|
80
|
+
exampleSymbols,
|
|
81
|
+
coveragePercent: totalSymbols > 0 ? Math.round((documentedSymbols / totalSymbols) * 100) : 100,
|
|
82
|
+
examplePercent: totalSymbols > 0 ? Math.round((exampleSymbols / totalSymbols) * 100) : 100,
|
|
83
|
+
files,
|
|
84
|
+
undocumented,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { CoverageReport } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Print coverage report to terminal
|
|
4
|
+
*/
|
|
5
|
+
export declare function printCoverageReport(report: CoverageReport): void;
|
|
6
|
+
/**
|
|
7
|
+
* Format report as JSON
|
|
8
|
+
*/
|
|
9
|
+
export declare function formatCoverageJson(report: CoverageReport): string;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
const GREEN = '\x1b[32m';
|
|
2
|
+
const YELLOW = '\x1b[33m';
|
|
3
|
+
const RED = '\x1b[31m';
|
|
4
|
+
const DIM = '\x1b[2m';
|
|
5
|
+
const BOLD = '\x1b[1m';
|
|
6
|
+
const RESET = '\x1b[0m';
|
|
7
|
+
/**
|
|
8
|
+
* Format a coverage bar: [████████░░] 80%
|
|
9
|
+
*/
|
|
10
|
+
function coverageBar(percent, width = 30) {
|
|
11
|
+
const filled = Math.round((percent / 100) * width);
|
|
12
|
+
const empty = width - filled;
|
|
13
|
+
const color = percent >= 80 ? GREEN : percent >= 50 ? YELLOW : RED;
|
|
14
|
+
return `${color}[${'█'.repeat(filled)}${'░'.repeat(empty)}]${RESET} ${percent}%`;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Print coverage report to terminal
|
|
18
|
+
*/
|
|
19
|
+
export function printCoverageReport(report) {
|
|
20
|
+
console.log(`\n${BOLD}Documentation Coverage Report${RESET}\n`);
|
|
21
|
+
// Overall bar
|
|
22
|
+
console.log(` Coverage: ${coverageBar(report.coveragePercent)}`);
|
|
23
|
+
console.log(` Examples: ${coverageBar(report.examplePercent)}`);
|
|
24
|
+
console.log(` Symbols: ${report.documentedSymbols}/${report.totalSymbols} documented, ${report.exampleSymbols} with examples`);
|
|
25
|
+
console.log('');
|
|
26
|
+
// Per-file table
|
|
27
|
+
if (report.files.length > 0) {
|
|
28
|
+
console.log(`${BOLD} Per-file Breakdown${RESET}`);
|
|
29
|
+
console.log(` ${'File'.padEnd(45)} ${'Symbols'.padStart(8)} ${'Docs'.padStart(6)} ${'Ex'.padStart(4)} ${'Coverage'.padStart(10)}`);
|
|
30
|
+
console.log(` ${'─'.repeat(45)} ${'─'.repeat(8)} ${'─'.repeat(6)} ${'─'.repeat(4)} ${'─'.repeat(10)}`);
|
|
31
|
+
for (const file of report.files) {
|
|
32
|
+
const shortPath = shortenPath(file.filePath, 44);
|
|
33
|
+
const color = file.coveragePercent >= 80 ? GREEN : file.coveragePercent >= 50 ? YELLOW : RED;
|
|
34
|
+
console.log(` ${shortPath.padEnd(45)} ${String(file.totalSymbols).padStart(8)} ${String(file.documentedSymbols).padStart(6)} ${String(file.exampleSymbols).padStart(4)} ${color}${String(file.coveragePercent + '%').padStart(10)}${RESET}`);
|
|
35
|
+
}
|
|
36
|
+
console.log('');
|
|
37
|
+
}
|
|
38
|
+
// Undocumented symbols (top 20)
|
|
39
|
+
if (report.undocumented.length > 0) {
|
|
40
|
+
const shown = report.undocumented.slice(0, 20);
|
|
41
|
+
console.log(`${BOLD} Undocumented Symbols${RESET} (${report.undocumented.length} total)`);
|
|
42
|
+
for (const sym of shown) {
|
|
43
|
+
const exported = sym.isExported ? `${RED}exported${RESET}` : `${DIM}internal${RESET}`;
|
|
44
|
+
console.log(` ${sym.kind.padEnd(10)} ${sym.name.padEnd(35)} ${exported}`);
|
|
45
|
+
}
|
|
46
|
+
if (report.undocumented.length > 20) {
|
|
47
|
+
console.log(` ${DIM}... and ${report.undocumented.length - 20} more${RESET}`);
|
|
48
|
+
}
|
|
49
|
+
console.log('');
|
|
50
|
+
}
|
|
51
|
+
// Summary line
|
|
52
|
+
const color = report.coveragePercent >= 80 ? GREEN : report.coveragePercent >= 50 ? YELLOW : RED;
|
|
53
|
+
console.log(`${color}${BOLD} Coverage: ${report.coveragePercent}% (${report.documentedSymbols}/${report.totalSymbols}) | Examples: ${report.examplePercent}% (${report.exampleSymbols}/${report.totalSymbols})${RESET}\n`);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Format report as JSON
|
|
57
|
+
*/
|
|
58
|
+
export function formatCoverageJson(report) {
|
|
59
|
+
return JSON.stringify(report, null, 2);
|
|
60
|
+
}
|
|
61
|
+
function shortenPath(filePath, maxLen) {
|
|
62
|
+
if (filePath.length <= maxLen)
|
|
63
|
+
return filePath;
|
|
64
|
+
return '...' + filePath.slice(filePath.length - maxLen + 3);
|
|
65
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coverage report for a single file
|
|
3
|
+
*/
|
|
4
|
+
export interface FileCoverage {
|
|
5
|
+
filePath: string;
|
|
6
|
+
totalSymbols: number;
|
|
7
|
+
documentedSymbols: number;
|
|
8
|
+
exampleSymbols: number;
|
|
9
|
+
coveragePercent: number;
|
|
10
|
+
undocumentedNames: string[];
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Overall coverage report
|
|
14
|
+
*/
|
|
15
|
+
export interface CoverageReport {
|
|
16
|
+
totalSymbols: number;
|
|
17
|
+
documentedSymbols: number;
|
|
18
|
+
exampleSymbols: number;
|
|
19
|
+
coveragePercent: number;
|
|
20
|
+
examplePercent: number;
|
|
21
|
+
files: FileCoverage[];
|
|
22
|
+
undocumented: Array<{
|
|
23
|
+
name: string;
|
|
24
|
+
kind: string;
|
|
25
|
+
filePath: string;
|
|
26
|
+
isExported: boolean;
|
|
27
|
+
}>;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Options for coverage calculation
|
|
31
|
+
*/
|
|
32
|
+
export interface CoverageOptions {
|
|
33
|
+
includeInternal?: boolean;
|
|
34
|
+
minCoverage?: number;
|
|
35
|
+
json?: boolean;
|
|
36
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -2,7 +2,9 @@ import { APIElement } from '../scanner/types.js';
|
|
|
2
2
|
import { LLMClient } from '../llm/index.js';
|
|
3
3
|
import { GeneratedDoc, GenerationOptions, GenerationProgress } from './types.js';
|
|
4
4
|
/**
|
|
5
|
-
* Generate documentation for an API element (no testing)
|
|
5
|
+
* Generate documentation for an API element (no testing).
|
|
6
|
+
* Supports hash-based caching: if the element's inputs haven't changed,
|
|
7
|
+
* the cached result is returned without calling the LLM.
|
|
6
8
|
*/
|
|
7
9
|
export declare function generateForElement(element: APIElement, client: LLMClient, options: GenerationOptions, onProgress?: (progress: GenerationProgress) => void): Promise<GeneratedDoc>;
|
|
8
10
|
/**
|
|
@@ -1,5 +1,10 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
1
4
|
import { generateDocumentation } from '../llm/index.js';
|
|
2
5
|
import { matchImportsToContext } from '../context-hub/index.js';
|
|
6
|
+
/** Directory name for generation cache (project-local) */
|
|
7
|
+
const CACHE_DIR = '.skrypt-cache';
|
|
3
8
|
/**
|
|
4
9
|
* Build enhanced context for LLM from API element
|
|
5
10
|
*/
|
|
@@ -23,6 +28,50 @@ function buildElementContext(element, externalContext, projectContext) {
|
|
|
23
28
|
}
|
|
24
29
|
return ctx;
|
|
25
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* Compute a deterministic hash for an element's generation inputs.
|
|
33
|
+
* Covers: element name, source context, and the system prompt template (via multiLanguage flag).
|
|
34
|
+
*/
|
|
35
|
+
function computeCacheKey(element, multiLanguage) {
|
|
36
|
+
const h = createHash('sha256');
|
|
37
|
+
h.update(element.name);
|
|
38
|
+
h.update(element.signature || '');
|
|
39
|
+
h.update(element.sourceContext || '');
|
|
40
|
+
h.update(element.docstring || '');
|
|
41
|
+
h.update(element.filePath || '');
|
|
42
|
+
h.update(multiLanguage ? 'multi' : 'single');
|
|
43
|
+
return h.digest('hex');
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Attempt to read a cached generation result.
|
|
47
|
+
* Returns undefined on miss.
|
|
48
|
+
*/
|
|
49
|
+
function readCache(cacheDir, key) {
|
|
50
|
+
const filePath = join(cacheDir, `${key}.json`);
|
|
51
|
+
try {
|
|
52
|
+
if (!existsSync(filePath))
|
|
53
|
+
return undefined;
|
|
54
|
+
const raw = readFileSync(filePath, 'utf-8');
|
|
55
|
+
return JSON.parse(raw);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Write a generation result to the cache directory (created lazily).
|
|
63
|
+
*/
|
|
64
|
+
function writeCache(cacheDir, key, doc) {
|
|
65
|
+
try {
|
|
66
|
+
if (!existsSync(cacheDir)) {
|
|
67
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
68
|
+
}
|
|
69
|
+
writeFileSync(join(cacheDir, `${key}.json`), JSON.stringify(doc), 'utf-8');
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// Cache write failure is non-fatal
|
|
73
|
+
}
|
|
74
|
+
}
|
|
26
75
|
/**
|
|
27
76
|
* Detect code language from file path
|
|
28
77
|
*/
|
|
@@ -52,7 +101,9 @@ function detectLanguageFromFile(filePath) {
|
|
|
52
101
|
return 'typescript';
|
|
53
102
|
}
|
|
54
103
|
/**
|
|
55
|
-
* Generate documentation for an API element (no testing)
|
|
104
|
+
* Generate documentation for an API element (no testing).
|
|
105
|
+
* Supports hash-based caching: if the element's inputs haven't changed,
|
|
106
|
+
* the cached result is returned without calling the LLM.
|
|
56
107
|
*/
|
|
57
108
|
export async function generateForElement(element, client, options, onProgress) {
|
|
58
109
|
const report = (status) => {
|
|
@@ -65,13 +116,24 @@ export async function generateForElement(element, client, options, onProgress) {
|
|
|
65
116
|
};
|
|
66
117
|
const codeLanguage = detectLanguageFromFile(element.filePath);
|
|
67
118
|
const useMultiLang = options.multiLanguage ?? false;
|
|
119
|
+
// --- Cache check ---
|
|
120
|
+
const useCache = !options.noCache;
|
|
121
|
+
const cacheDir = join(options.cacheDir || process.cwd(), CACHE_DIR);
|
|
122
|
+
if (useCache) {
|
|
123
|
+
const cacheKey = computeCacheKey(element, useMultiLang);
|
|
124
|
+
const cached = readCache(cacheDir, cacheKey);
|
|
125
|
+
if (cached) {
|
|
126
|
+
report('done');
|
|
127
|
+
return cached;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
68
130
|
report('generating');
|
|
69
131
|
try {
|
|
70
132
|
const elementContext = buildElementContext(element, options.externalContext, options.projectContext);
|
|
71
133
|
const errorContext = options.previousErrors?.get(element.name);
|
|
72
134
|
const result = await generateDocumentation(client, elementContext, { multiLanguage: useMultiLang, verify: options.verify, previousError: errorContext });
|
|
73
135
|
report('done');
|
|
74
|
-
|
|
136
|
+
const doc = {
|
|
75
137
|
element,
|
|
76
138
|
markdown: result.markdown,
|
|
77
139
|
codeExample: result.codeExample,
|
|
@@ -80,6 +142,12 @@ export async function generateForElement(element, client, options, onProgress) {
|
|
|
80
142
|
pythonExample: result.pythonExample,
|
|
81
143
|
usage: result.usage
|
|
82
144
|
};
|
|
145
|
+
// --- Cache write ---
|
|
146
|
+
if (useCache) {
|
|
147
|
+
const cacheKey = computeCacheKey(element, useMultiLang);
|
|
148
|
+
writeCache(cacheDir, cacheKey, doc);
|
|
149
|
+
}
|
|
150
|
+
return doc;
|
|
83
151
|
}
|
|
84
152
|
catch (err) {
|
|
85
153
|
report('failed');
|
|
@@ -92,35 +160,81 @@ export async function generateForElement(element, client, options, onProgress) {
|
|
|
92
160
|
};
|
|
93
161
|
}
|
|
94
162
|
}
|
|
163
|
+
/**
|
|
164
|
+
* Simple concurrency limiter (pLimit-style). Limits the number of
|
|
165
|
+
* concurrently executing async functions without adding a dependency.
|
|
166
|
+
*/
|
|
167
|
+
function createConcurrencyLimiter(concurrency) {
|
|
168
|
+
let active = 0;
|
|
169
|
+
const queue = [];
|
|
170
|
+
function next() {
|
|
171
|
+
if (queue.length > 0 && active < concurrency) {
|
|
172
|
+
active++;
|
|
173
|
+
const resolve = queue.shift();
|
|
174
|
+
resolve();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return async function limit(fn) {
|
|
178
|
+
// Wait for a slot to open
|
|
179
|
+
if (active >= concurrency) {
|
|
180
|
+
await new Promise((resolve) => {
|
|
181
|
+
queue.push(resolve);
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
active++;
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
return await fn();
|
|
189
|
+
}
|
|
190
|
+
finally {
|
|
191
|
+
active--;
|
|
192
|
+
next();
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
}
|
|
95
196
|
/**
|
|
96
197
|
* Generate documentation for multiple elements
|
|
97
198
|
*/
|
|
98
199
|
export async function generateForElements(elements, client, options) {
|
|
99
|
-
const results = [];
|
|
100
|
-
let totalTokens = 0;
|
|
101
200
|
const MAX_TOKENS = options.maxTokens ?? 1_000_000; // default 1M tokens (~$10-20)
|
|
102
|
-
|
|
103
|
-
|
|
201
|
+
const concurrency = options.concurrency ?? 5;
|
|
202
|
+
const limit = createConcurrencyLimiter(concurrency);
|
|
203
|
+
// Shared mutable state — safe because JS is single-threaded;
|
|
204
|
+
// mutations happen synchronously between await points.
|
|
205
|
+
let totalTokens = 0;
|
|
206
|
+
let budgetExceeded = false;
|
|
207
|
+
// Pre-allocate results array to preserve original ordering
|
|
208
|
+
const results = new Array(elements.length);
|
|
209
|
+
const promises = elements.map((element, i) => {
|
|
104
210
|
if (!element)
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
211
|
+
return Promise.resolve();
|
|
212
|
+
return limit(async () => {
|
|
213
|
+
// Check budget before starting — another concurrent task may have exceeded it
|
|
214
|
+
if (budgetExceeded)
|
|
215
|
+
return;
|
|
216
|
+
const doc = await generateForElement(element, client, options, (progress) => {
|
|
217
|
+
options.onProgress?.({
|
|
218
|
+
...progress,
|
|
219
|
+
current: i + 1,
|
|
220
|
+
total: elements.length
|
|
221
|
+
});
|
|
111
222
|
});
|
|
223
|
+
// Store result at original index to preserve ordering
|
|
224
|
+
results[i] = doc;
|
|
225
|
+
if (doc.usage) {
|
|
226
|
+
totalTokens += (doc.usage.inputTokens ?? 0) + (doc.usage.outputTokens ?? 0);
|
|
227
|
+
}
|
|
228
|
+
if (totalTokens > MAX_TOKENS) {
|
|
229
|
+
budgetExceeded = true;
|
|
230
|
+
console.warn(`\nToken budget exceeded (${totalTokens.toLocaleString()} tokens used). Stopping generation.`);
|
|
231
|
+
console.warn('Use --max-tokens to increase the limit.');
|
|
232
|
+
}
|
|
112
233
|
});
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
if (totalTokens > MAX_TOKENS) {
|
|
118
|
-
console.warn(`\nToken budget exceeded (${totalTokens.toLocaleString()} tokens used). Stopping generation.`);
|
|
119
|
-
console.warn('Use --max-tokens to increase the limit.');
|
|
120
|
-
break;
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
return results;
|
|
234
|
+
});
|
|
235
|
+
await Promise.all(promises);
|
|
236
|
+
// Filter out undefined slots (skipped elements or budget-exceeded gaps)
|
|
237
|
+
return results.filter((r) => r !== undefined);
|
|
124
238
|
}
|
|
125
239
|
/**
|
|
126
240
|
* Format generated docs as markdown file content
|
|
@@ -130,6 +130,7 @@ export function serializeMdxToMarkdown(mdx) {
|
|
|
130
130
|
}
|
|
131
131
|
function formatBlockquote(label, content) {
|
|
132
132
|
const lines = content.split('\n');
|
|
133
|
-
const
|
|
134
|
-
|
|
133
|
+
const first = `> **${label}:** ${lines[0]}`;
|
|
134
|
+
const rest = lines.slice(1).map(line => `> ${line}`);
|
|
135
|
+
return [first, ...rest].join('\n') + '\n';
|
|
135
136
|
}
|
|
@@ -8,7 +8,11 @@ export declare const DEFAULT_TOPIC_CONFIG: TopicConfig;
|
|
|
8
8
|
*/
|
|
9
9
|
export declare function organizeByTopic(docs: GeneratedDoc[], config?: TopicConfig): Topic[];
|
|
10
10
|
/**
|
|
11
|
-
* Detect cross-references between elements
|
|
11
|
+
* Detect cross-references between elements.
|
|
12
|
+
*
|
|
13
|
+
* Instead of checking every element name against every doc's source text (O(n^2) with
|
|
14
|
+
* string.includes), we build a single regex alternation of all element names and scan
|
|
15
|
+
* each doc's text once. This reduces the inner loop to a single regex pass per doc.
|
|
12
16
|
*/
|
|
13
17
|
export declare function detectCrossReferences(docs: GeneratedDoc[]): CrossReference[];
|
|
14
18
|
/**
|