@chappibunny/repolens 1.9.4 → 1.9.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,31 @@
2
2
 
3
3
  All notable changes to RepoLens will be documented in this file.
4
4
 
5
+ ## 1.9.6
6
+
7
+ ### 📹 Documentation
8
+
9
+ - **Updated demo video**: README now embeds new Loom demo video
10
+
11
+ ## 1.9.5
12
+
13
+ ### ✨ UX Improvements
14
+
15
+ - **AI Provider Presets**: New `REPOLENS_AI_PRESET` environment variable for one-line AI setup. Supports `github`, `openai`, `anthropic`, and `google` presets that automatically configure provider, base URL, and model.
16
+
17
+ - **Progress Indicator**: Document generation now shows step-by-step progress: `[1/15] Generating executive_summary...`, `[2/15] Generating business_domains...`, etc.
18
+
19
+ - **Config Typo Detection**: `repolens doctor` now detects typos in config keys and suggests corrections using Levenshtein distance. For example: `Unknown config key "projet" — did you mean "project"?`
20
+
21
+ - **Cache Age Display**: Publishing shows when the cache was last updated: `Cache: 5/15 documents unchanged (last run: 2h ago)`.
22
+
23
+ - **Verbose Export**: The `--verbose` flag and `isVerbose` constant are now exported from logger for use by plugins and custom scripts.
24
+
25
+ ### 🧪 Tests
26
+
27
+ - Updated doc-cache tests to verify new `{ cache, age }` return format
28
+ - **380 tests passing** across 22 test files
29
+
5
30
  ## 1.9.3
6
31
 
7
32
  ### ✨ Premium Deterministic Documentation
package/README.md CHANGED
@@ -17,7 +17,7 @@
17
17
 
18
18
  RepoLens scans your repository, generates living architecture documentation, and publishes it to Notion, Confluence, GitHub Wiki, or Markdown — automatically on every push. Engineers get technical docs. Stakeholders get readable system overviews. Nobody writes a word.
19
19
 
20
- > Stable as of v1.0 — [API guarantees](docs/STABILITY.md) · [Security hardened](SECURITY.md) · v1.9.4
20
+ > Stable as of v1.0 — [API guarantees](docs/STABILITY.md) · [Security hardened](SECURITY.md) · v1.9.6
21
21
 
22
22
  ---
23
23
 
@@ -25,9 +25,9 @@ RepoLens scans your repository, generates living architecture documentation, and
25
25
 
26
26
  > **Try it now** — no installation required. Run `npx @chappibunny/repolens demo` on any repo for an instant local preview.
27
27
 
28
- [![RepoLens Demo](https://img.youtube.com/vi/Lpyg0dGsiws/maxresdefault.jpg)](https://youtu.be/Lpyg0dGsiws)
28
+ <div style="position: relative; padding-bottom: 40.955631399317404%; height: 0;"><iframe src="https://www.loom.com/embed/8e077624e69f41319fd93acbbe03871e" frameborder="0" webkitallowfullscreen mozallowfullscreen allowfullscreen style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></iframe></div>
29
29
 
30
- ▶️ *Click to watch on YouTube*
30
+ ▶️ *Click to watch demo*
31
31
 
32
32
  <details>
33
33
  <summary>🔍 <strong>Supported Languages</strong> (16 auto-detected)</summary>
@@ -36,6 +36,7 @@
36
36
  | Variable | Required | Description |
37
37
  |----------|----------|-------------|
38
38
  | `REPOLENS_AI_ENABLED` | No | Enable AI-powered sections (`true`/`false`) |
39
+ | `REPOLENS_AI_PRESET` | No | Quick setup preset: `github`, `openai`, `anthropic`, `google` (overrides provider/baseUrl/model) |
39
40
  | `REPOLENS_AI_PROVIDER` | No | Provider: `openai_compatible` (default), `anthropic`, `google`, `github` |
40
41
  | `REPOLENS_AI_API_KEY` | No | API key for AI provider (not needed for `github` provider) |
41
42
  | `REPOLENS_AI_BASE_URL` | No | API base URL (auto-set per provider; override for custom endpoints) |
@@ -43,7 +44,28 @@
43
44
  | `REPOLENS_AI_TEMPERATURE` | No | Generation temperature (omitted by default for GPT-5 compatibility) |
44
45
  | `REPOLENS_AI_MAX_TOKENS` | No | Max completion tokens per request (default: `2000`) |
45
46
 
46
- > **GitHub Models (free):** Set `REPOLENS_AI_PROVIDER=github` in GitHub Actions — uses `GITHUB_TOKEN` automatically, no separate API key required. See [AI.md](AI.md) for details.
47
+ ### AI Presets (Quick Setup)
48
+
49
+ Instead of configuring provider, base URL, and model separately, use a preset:
50
+
51
+ ```bash
52
+ # GitHub Models (free in GitHub Actions)
53
+ REPOLENS_AI_PRESET=github
54
+
55
+ # OpenAI
56
+ REPOLENS_AI_PRESET=openai
57
+ REPOLENS_AI_API_KEY=sk-...
58
+
59
+ # Anthropic
60
+ REPOLENS_AI_PRESET=anthropic
61
+ REPOLENS_AI_API_KEY=sk-ant-...
62
+
63
+ # Google Gemini
64
+ REPOLENS_AI_PRESET=google
65
+ REPOLENS_AI_API_KEY=...
66
+ ```
67
+
68
+ > **GitHub Models (free):** Set `REPOLENS_AI_PRESET=github` in GitHub Actions — uses `GITHUB_TOKEN` automatically, no separate API key required. See [AI.md](AI.md) for details.
47
69
 
48
70
  ## Telemetry
49
71
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chappibunny/repolens",
3
- "version": "1.9.4",
3
+ "version": "1.9.6",
4
4
  "description": "AI-assisted documentation intelligence system for technical and non-technical audiences",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -6,6 +6,33 @@ import { executeAIRequest } from "../utils/rate-limit.js";
6
6
  const DEFAULT_TIMEOUT_MS = 60000;
7
7
  const DEFAULT_MAX_TOKENS = 2500;
8
8
 
9
+ /**
10
+ * AI Provider Presets - one env var to configure common providers.
11
+ * REPOLENS_AI_PRESET takes precedence over individual settings.
12
+ */
13
+ const AI_PRESETS = {
14
+ github: {
15
+ provider: "github",
16
+ baseUrl: "https://models.inference.ai.azure.com",
17
+ model: "gpt-4o-mini",
18
+ },
19
+ openai: {
20
+ provider: "openai_compatible",
21
+ baseUrl: "https://api.openai.com/v1",
22
+ model: "gpt-4o-mini",
23
+ },
24
+ anthropic: {
25
+ provider: "anthropic",
26
+ baseUrl: "https://api.anthropic.com",
27
+ model: "claude-sonnet-4-20250514",
28
+ },
29
+ google: {
30
+ provider: "google",
31
+ baseUrl: "https://generativelanguage.googleapis.com/v1beta",
32
+ model: "gemini-2.0-flash",
33
+ },
34
+ };
35
+
9
36
  export async function generateText({ system, user, temperature, maxTokens, config, jsonMode, jsonSchema }) {
10
37
  // Check if AI is enabled (env var takes precedence, then config)
11
38
  const aiConfig = config?.ai || {};
@@ -19,13 +46,21 @@ export async function generateText({ system, user, temperature, maxTokens, confi
19
46
  };
20
47
  }
21
48
 
22
- // Get provider configuration (env vars take precedence, then config, then defaults)
23
- const provider = process.env.REPOLENS_AI_PROVIDER || aiConfig.provider || "openai_compatible";
24
- const baseUrl = process.env.REPOLENS_AI_BASE_URL || aiConfig.base_url;
49
+ // Check for preset (takes precedence over individual settings)
50
+ const preset = process.env.REPOLENS_AI_PRESET?.toLowerCase();
51
+ const presetConfig = preset ? AI_PRESETS[preset] : null;
52
+
53
+ if (preset && !presetConfig) {
54
+ warn(`Unknown AI preset "${preset}". Valid presets: ${Object.keys(AI_PRESETS).join(", ")}`);
55
+ }
56
+
57
+ // Get provider configuration (preset > env vars > config > defaults)
58
+ const provider = presetConfig?.provider || process.env.REPOLENS_AI_PROVIDER || aiConfig.provider || "openai_compatible";
59
+ const baseUrl = presetConfig?.baseUrl || process.env.REPOLENS_AI_BASE_URL || aiConfig.base_url;
25
60
  // For "github" provider, fall back to GITHUB_TOKEN when no explicit AI key is set
26
61
  const apiKey = process.env.REPOLENS_AI_API_KEY
27
62
  || (provider === "github" ? process.env.GITHUB_TOKEN : undefined);
28
- const model = process.env.REPOLENS_AI_MODEL || aiConfig.model || getDefaultModel(provider);
63
+ const model = presetConfig?.model || process.env.REPOLENS_AI_MODEL || aiConfig.model || getDefaultModel(provider);
29
64
  const timeoutMs = parseInt(process.env.REPOLENS_AI_TIMEOUT_MS || aiConfig.timeout_ms || DEFAULT_TIMEOUT_MS);
30
65
 
31
66
  // Use config values as fallback for maxTokens; temperature only when explicitly set
@@ -367,3 +367,89 @@ export function isFeatureEnabled(config, featureName, defaultValue = true) {
367
367
  ? config.features[featureName]
368
368
  : defaultValue;
369
369
  }
370
+
371
+ // Valid top-level config keys
372
+ const VALID_CONFIG_KEYS = [
373
+ "configVersion",
374
+ "project",
375
+ "publishers",
376
+ "scan",
377
+ "module_roots",
378
+ "outputs",
379
+ "notion",
380
+ "confluence",
381
+ "github_wiki",
382
+ "discord",
383
+ "features",
384
+ "ai",
385
+ "documentation",
386
+ "domains",
387
+ "plugins",
388
+ ];
389
+
390
+ /**
391
+ * Calculate Levenshtein distance between two strings.
392
+ */
393
+ function levenshteinDistance(a, b) {
394
+ const matrix = Array.from({ length: a.length + 1 }, (_, i) =>
395
+ Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0))
396
+ );
397
+
398
+ for (let i = 1; i <= a.length; i++) {
399
+ for (let j = 1; j <= b.length; j++) {
400
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
401
+ matrix[i][j] = Math.min(
402
+ matrix[i - 1][j] + 1, // deletion
403
+ matrix[i][j - 1] + 1, // insertion
404
+ matrix[i - 1][j - 1] + cost // substitution
405
+ );
406
+ }
407
+ }
408
+
409
+ return matrix[a.length][b.length];
410
+ }
411
+
412
+ /**
413
+ * Find closest matching valid key for a typo.
414
+ * @param {string} typo - The potentially misspelled key
415
+ * @param {string[]} validKeys - Array of valid keys
416
+ * @param {number} maxDistance - Maximum Levenshtein distance to consider (default: 3)
417
+ * @returns {string|null} - Suggestion or null if no close match
418
+ */
419
+ function findClosestKey(typo, validKeys, maxDistance = 3) {
420
+ let closest = null;
421
+ let minDist = Infinity;
422
+
423
+ for (const key of validKeys) {
424
+ const dist = levenshteinDistance(typo.toLowerCase(), key.toLowerCase());
425
+ if (dist < minDist && dist <= maxDistance) {
426
+ minDist = dist;
427
+ closest = key;
428
+ }
429
+ }
430
+
431
+ return closest;
432
+ }
433
+
434
+ /**
435
+ * Check config for typos in top-level keys.
436
+ * @param {object} config - The parsed config object
437
+ * @returns {Array<{key: string, suggestion: string}>} - List of detected typos with suggestions
438
+ */
439
+ export function detectConfigTypos(config) {
440
+ const typos = [];
441
+
442
+ for (const key of Object.keys(config)) {
443
+ if (!VALID_CONFIG_KEYS.includes(key)) {
444
+ const suggestion = findClosestKey(key, VALID_CONFIG_KEYS);
445
+ if (suggestion) {
446
+ typos.push({ key, suggestion });
447
+ }
448
+ }
449
+ }
450
+
451
+ return typos;
452
+ }
453
+
454
+ // Export constants for use in doctor
455
+ export { SUPPORTED_PUBLISHERS, SUPPORTED_PAGE_KEYS, VALID_CONFIG_KEYS };
@@ -99,7 +99,12 @@ export async function generateDocumentSet(scanResult, config, diffData = null, p
99
99
  }
100
100
 
101
101
  // Generate each document
102
+ const totalDocs = activeDocuments.length;
103
+ let docIndex = 0;
104
+
102
105
  for (const docPlan of activeDocuments) {
106
+ docIndex++;
107
+ info(`[${docIndex}/${totalDocs}] Generating ${docPlan.key}...`);
103
108
  let content = null;
104
109
 
105
110
  try {
package/src/doctor.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import yaml from "js-yaml";
3
4
  import { loadConfig } from "./core/config.js";
5
+ import { detectConfigTypos } from "./core/config-schema.js";
4
6
  import { info } from "./utils/logger.js";
5
7
  import { forceCheckForUpdates } from "./utils/update-check.js";
6
8
 
@@ -112,13 +114,34 @@ export async function runDoctor(targetDir = process.cwd()) {
112
114
  let cfg = null;
113
115
 
114
116
  if (await fileExists(repolensConfigPath)) {
117
+ // First parse YAML to check for typos (before full validation)
118
+ let rawConfig = null;
115
119
  try {
116
- cfg = await loadConfig(repolensConfigPath);
117
- ok("RepoLens config parsed successfully");
118
- } catch (error) {
119
- fail(`RepoLens config is invalid: ${error.message}`);
120
+ const rawYaml = await fs.readFile(repolensConfigPath, "utf8");
121
+ rawConfig = yaml.load(rawYaml);
122
+
123
+ // Check for config key typos before validation
124
+ if (rawConfig && typeof rawConfig === "object") {
125
+ const typos = detectConfigTypos(rawConfig);
126
+ for (const { key, suggestion } of typos) {
127
+ warn(`Unknown config key "${key}" — did you mean "${suggestion}"?`);
128
+ }
129
+ }
130
+ } catch (yamlError) {
131
+ fail(`RepoLens config has invalid YAML syntax: ${yamlError.message}`);
120
132
  hasFailures = true;
121
133
  }
134
+
135
+ // Now run full validation
136
+ if (rawConfig) {
137
+ try {
138
+ cfg = await loadConfig(repolensConfigPath);
139
+ ok("RepoLens config parsed successfully");
140
+ } catch (error) {
141
+ fail(`RepoLens config is invalid: ${error.message}`);
142
+ hasFailures = true;
143
+ }
144
+ }
122
145
  }
123
146
 
124
147
  if (cfg) {
@@ -28,9 +28,9 @@ export async function publishDocs(cfg, renderedPages, scanResult, pluginManager
28
28
 
29
29
  // --- Hash-based caching: skip unchanged documents ---
30
30
  const cacheDir = path.join(process.cwd(), cfg.documentation?.output_dir || ".repolens");
31
- const previousCache = await loadDocCache(cacheDir);
31
+ const { cache: previousCache, age: cacheAge } = await loadDocCache(cacheDir);
32
32
  const { changedPages, unchangedKeys, newCache } = filterChangedDocs(renderedPages, previousCache);
33
- logCacheStats(Object.keys(changedPages).length, unchangedKeys.length);
33
+ logCacheStats(Object.keys(changedPages).length, unchangedKeys.length, cacheAge);
34
34
 
35
35
  // Use changedPages for API publishers (Notion / Confluence / Wiki), full set for Markdown
36
36
  const pagesForAPIs = Object.keys(changedPages).length > 0 ? changedPages : renderedPages;
@@ -6,7 +6,7 @@
6
6
  import fs from "node:fs/promises";
7
7
  import path from "node:path";
8
8
  import { createHash } from "node:crypto";
9
- import { info } from "./logger.js";
9
+ import { info, verbose } from "./logger.js";
10
10
 
11
11
  const CACHE_FILENAME = "doc-hashes.json";
12
12
 
@@ -19,14 +19,19 @@ function hashContent(content) {
19
19
 
20
20
  /**
21
21
  * Load the previous cache from disk.
22
- * @returns {Record<string, string>} Map of docKey → contentHash
22
+ * @returns {{ cache: Record<string, string>, age: number|null }} Map of docKey → contentHash and cache age in ms
23
23
  */
24
24
  export async function loadDocCache(cacheDir) {
25
+ const cachePath = path.join(cacheDir, CACHE_FILENAME);
25
26
  try {
26
- const raw = await fs.readFile(path.join(cacheDir, CACHE_FILENAME), "utf8");
27
- return JSON.parse(raw);
27
+ const [raw, stat] = await Promise.all([
28
+ fs.readFile(cachePath, "utf8"),
29
+ fs.stat(cachePath),
30
+ ]);
31
+ const age = Date.now() - stat.mtimeMs;
32
+ return { cache: JSON.parse(raw), age };
28
33
  } catch {
29
- return {};
34
+ return { cache: {}, age: null };
30
35
  }
31
36
  }
32
37
 
@@ -65,14 +70,25 @@ export function filterChangedDocs(renderedPages, previousCache) {
65
70
  return { changedPages, unchangedKeys, newCache };
66
71
  }
67
72
 
73
+ /**
74
+ * Format duration in human-readable form.
75
+ */
76
+ function formatAge(ms) {
77
+ if (ms < 60000) return `${Math.round(ms / 1000)}s ago`;
78
+ if (ms < 3600000) return `${Math.round(ms / 60000)}m ago`;
79
+ if (ms < 86400000) return `${Math.round(ms / 3600000)}h ago`;
80
+ return `${Math.round(ms / 86400000)}d ago`;
81
+ }
82
+
68
83
  /**
69
84
  * Log cache statistics.
70
85
  */
71
- export function logCacheStats(changedCount, unchangedCount) {
86
+ export function logCacheStats(changedCount, unchangedCount, cacheAge = null) {
72
87
  const total = changedCount + unchangedCount;
88
+ const ageStr = cacheAge ? ` (last run: ${formatAge(cacheAge)})` : "";
73
89
  if (unchangedCount > 0) {
74
- info(`Cache: ${unchangedCount}/${total} documents unchanged, skipping. ${changedCount} to publish.`);
90
+ info(`Cache: ${unchangedCount}/${total} documents unchanged, skipping. ${changedCount} to publish.${ageStr}`);
75
91
  } else {
76
- info(`Cache: All ${total} documents changed or new.`);
92
+ info(`Cache: All ${total} documents changed or new.${ageStr}`);
77
93
  }
78
94
  }
@@ -1,6 +1,6 @@
1
1
  import { sanitizeSecrets } from "./secrets.js";
2
2
 
3
- const isVerbose = process.argv.includes("--verbose");
3
+ export const isVerbose = process.argv.includes("--verbose");
4
4
  const isTest = process.env.NODE_ENV === "test";
5
5
 
6
6
  // Terminal color support detection
@@ -71,6 +71,16 @@ export function log(...args) {
71
71
  }
72
72
  }
73
73
 
74
+ /**
75
+ * Verbose-only logging (alias for log, for semantic clarity).
76
+ * Only outputs when --verbose flag is present.
77
+ */
78
+ export function verbose(...args) {
79
+ if (!isTest && isVerbose) {
80
+ console.log("[RepoLens]", ...sanitizeArgs(args));
81
+ }
82
+ }
83
+
74
84
  export function info(...args) {
75
85
  if (!isTest) {
76
86
  console.log("[RepoLens]", ...sanitizeArgs(args));