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.
Files changed (101) hide show
  1. package/dist/auth/index.js +6 -0
  2. package/dist/binding/binder.d.ts +5 -0
  3. package/dist/binding/binder.js +63 -0
  4. package/dist/binding/detector.d.ts +5 -0
  5. package/dist/binding/detector.js +51 -0
  6. package/dist/binding/doc-parser.d.ts +9 -0
  7. package/dist/binding/doc-parser.js +138 -0
  8. package/dist/binding/extractor.d.ts +14 -0
  9. package/dist/binding/extractor.js +39 -0
  10. package/dist/binding/index.d.ts +5 -0
  11. package/dist/binding/index.js +5 -0
  12. package/dist/binding/types.d.ts +74 -0
  13. package/dist/binding/types.js +1 -0
  14. package/dist/claims/extractor.d.ts +13 -0
  15. package/dist/claims/extractor.js +138 -0
  16. package/dist/claims/index.d.ts +4 -0
  17. package/dist/claims/index.js +4 -0
  18. package/dist/claims/reporter.d.ts +9 -0
  19. package/dist/claims/reporter.js +65 -0
  20. package/dist/claims/store.d.ts +13 -0
  21. package/dist/claims/store.js +51 -0
  22. package/dist/claims/types.d.ts +34 -0
  23. package/dist/claims/types.js +1 -0
  24. package/dist/cli.js +516 -56
  25. package/dist/commands/bind.d.ts +2 -0
  26. package/dist/commands/bind.js +139 -0
  27. package/dist/commands/claims.d.ts +2 -0
  28. package/dist/commands/claims.js +84 -0
  29. package/dist/commands/coverage.d.ts +2 -0
  30. package/dist/commands/coverage.js +61 -0
  31. package/dist/commands/generate/index.js +5 -0
  32. package/dist/commands/generate/scan.js +33 -14
  33. package/dist/commands/generate/write.d.ts +7 -0
  34. package/dist/commands/generate/write.js +65 -1
  35. package/dist/commands/import.js +12 -3
  36. package/dist/commands/init.js +68 -5
  37. package/dist/commands/monitor.d.ts +15 -0
  38. package/dist/commands/monitor.js +2 -2
  39. package/dist/commands/mutate.d.ts +2 -0
  40. package/dist/commands/mutate.js +177 -0
  41. package/dist/config/types.js +2 -2
  42. package/dist/coverage/calculator.d.ts +7 -0
  43. package/dist/coverage/calculator.js +86 -0
  44. package/dist/coverage/index.d.ts +3 -0
  45. package/dist/coverage/index.js +3 -0
  46. package/dist/coverage/reporter.d.ts +9 -0
  47. package/dist/coverage/reporter.js +65 -0
  48. package/dist/coverage/types.d.ts +36 -0
  49. package/dist/coverage/types.js +1 -0
  50. package/dist/generator/generator.d.ts +3 -1
  51. package/dist/generator/generator.js +137 -23
  52. package/dist/generator/mdx-serializer.js +3 -2
  53. package/dist/generator/organizer.d.ts +5 -1
  54. package/dist/generator/organizer.js +29 -14
  55. package/dist/generator/types.d.ts +6 -0
  56. package/dist/generator/writer.js +7 -2
  57. package/dist/github/org-discovery.js +5 -0
  58. package/dist/importers/mintlify.js +4 -3
  59. package/dist/llm/anthropic-client.js +1 -0
  60. package/dist/llm/index.d.ts +15 -0
  61. package/dist/llm/index.js +148 -29
  62. package/dist/llm/openai-client.js +2 -0
  63. package/dist/mutation/index.d.ts +4 -0
  64. package/dist/mutation/index.js +4 -0
  65. package/dist/mutation/mutator.d.ts +5 -0
  66. package/dist/mutation/mutator.js +101 -0
  67. package/dist/mutation/reporter.d.ts +14 -0
  68. package/dist/mutation/reporter.js +66 -0
  69. package/dist/mutation/runner.d.ts +9 -0
  70. package/dist/mutation/runner.js +70 -0
  71. package/dist/mutation/types.d.ts +51 -0
  72. package/dist/mutation/types.js +1 -0
  73. package/dist/qa/checks.d.ts +1 -0
  74. package/dist/qa/checks.js +47 -0
  75. package/dist/qa/index.js +2 -1
  76. package/dist/scanner/index.js +78 -11
  77. package/dist/scanner/typescript.js +42 -31
  78. package/dist/sentry.d.ts +3 -0
  79. package/dist/sentry.js +28 -0
  80. package/dist/template/docs.json +6 -3
  81. package/dist/template/next.config.mjs +15 -1
  82. package/dist/template/package.json +4 -3
  83. package/dist/template/public/docs-schema.json +257 -0
  84. package/dist/template/sentry.client.config.ts +12 -0
  85. package/dist/template/sentry.edge.config.ts +7 -0
  86. package/dist/template/sentry.server.config.ts +7 -0
  87. package/dist/template/src/app/docs/[...slug]/page.tsx +11 -5
  88. package/dist/template/src/app/docs/layout.tsx +2 -4
  89. package/dist/template/src/app/global-error.tsx +60 -0
  90. package/dist/template/src/app/layout.tsx +7 -16
  91. package/dist/template/src/app/page.tsx +2 -5
  92. package/dist/template/src/components/ai-chat-impl.tsx +1 -1
  93. package/dist/template/src/components/docs-layout.tsx +1 -15
  94. package/dist/template/src/components/footer.tsx +95 -19
  95. package/dist/template/src/components/header.tsx +1 -1
  96. package/dist/template/src/components/search-dialog.tsx +5 -0
  97. package/dist/template/src/instrumentation.ts +11 -0
  98. package/dist/template/src/lib/docs-config.ts +235 -0
  99. package/dist/template/src/lib/fonts.ts +3 -3
  100. package/dist/testing/runner.js +8 -1
  101. 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
+ }
@@ -28,7 +28,7 @@ export const DEFAULT_CONFIG = {
28
28
  format: 'markdown'
29
29
  },
30
30
  llm: {
31
- provider: 'deepseek',
32
- model: 'deepseek-v3'
31
+ provider: 'anthropic',
32
+ model: 'claude-sonnet-4-6'
33
33
  }
34
34
  };
@@ -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,3 @@
1
+ export * from './types.js';
2
+ export * from './calculator.js';
3
+ export * from './reporter.js';
@@ -0,0 +1,3 @@
1
+ export * from './types.js';
2
+ export * from './calculator.js';
3
+ export * from './reporter.js';
@@ -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
- return {
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
- for (let i = 0; i < elements.length; i++) {
103
- const element = elements[i];
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
- continue;
106
- const doc = await generateForElement(element, client, options, (progress) => {
107
- options.onProgress?.({
108
- ...progress,
109
- current: i + 1,
110
- total: elements.length
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
- results.push(doc);
114
- if (doc.usage) {
115
- totalTokens += (doc.usage.inputTokens ?? 0) + (doc.usage.outputTokens ?? 0);
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 quoted = lines.map(line => `> ${line}`).join('\n');
134
- return `> **${label}:** ${quoted.replace(/^> /, '')}\n`;
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
  /**