@chappibunny/repolens 0.4.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.
@@ -0,0 +1,266 @@
1
+ /**
2
+ * RepoLens Configuration Schema Validator
3
+ *
4
+ * Schema Version: 1
5
+ *
6
+ * This validator ensures .repolens.yml files conform to the expected structure.
7
+ * Breaking changes to this schema will require a major version bump.
8
+ */
9
+
10
+ const CURRENT_SCHEMA_VERSION = 1;
11
+ const SUPPORTED_PUBLISHERS = ["notion", "markdown"];
12
+ const SUPPORTED_PAGE_KEYS = [
13
+ "system_overview",
14
+ "module_catalog",
15
+ "api_surface",
16
+ "arch_diff",
17
+ "route_map",
18
+ "system_map",
19
+ // New AI-enhanced document types
20
+ "executive_summary",
21
+ "business_domains",
22
+ "architecture_overview",
23
+ "data_flows",
24
+ "change_impact",
25
+ "developer_onboarding"
26
+ ];
27
+
28
+ class ValidationError extends Error {
29
+ constructor(message, path) {
30
+ super(message);
31
+ this.name = "ValidationError";
32
+ this.path = path;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Validate the complete config object
38
+ */
39
+ export function validateConfig(config) {
40
+ const errors = [];
41
+
42
+ // Check schema version
43
+ if (config.configVersion !== undefined) {
44
+ if (typeof config.configVersion !== "number") {
45
+ errors.push("configVersion must be a number");
46
+ } else if (config.configVersion > CURRENT_SCHEMA_VERSION) {
47
+ errors.push(
48
+ `Config schema version ${config.configVersion} is not supported. ` +
49
+ `This version of RepoLens supports schema version ${CURRENT_SCHEMA_VERSION}. ` +
50
+ `Please upgrade RepoLens or downgrade your config.`
51
+ );
52
+ }
53
+ }
54
+
55
+ // Validate project section
56
+ if (!config.project) {
57
+ errors.push("Missing required section: project");
58
+ } else {
59
+ if (!config.project.name || typeof config.project.name !== "string") {
60
+ errors.push("project.name is required and must be a string");
61
+ }
62
+ if (config.project.docs_title_prefix && typeof config.project.docs_title_prefix !== "string") {
63
+ errors.push("project.docs_title_prefix must be a string");
64
+ }
65
+ }
66
+
67
+ // Validate publishers
68
+ if (!config.publishers) {
69
+ errors.push("Missing required section: publishers");
70
+ } else if (!Array.isArray(config.publishers)) {
71
+ errors.push("publishers must be an array");
72
+ } else if (config.publishers.length === 0) {
73
+ errors.push("publishers array cannot be empty");
74
+ } else {
75
+ config.publishers.forEach((pub, idx) => {
76
+ if (!SUPPORTED_PUBLISHERS.includes(pub)) {
77
+ errors.push(
78
+ `publishers[${idx}]: "${pub}" is not a valid publisher. ` +
79
+ `Supported: ${SUPPORTED_PUBLISHERS.join(", ")}`
80
+ );
81
+ }
82
+ });
83
+ }
84
+
85
+ // Validate scan section
86
+ if (!config.scan) {
87
+ errors.push("Missing required section: scan");
88
+ } else {
89
+ if (!config.scan.include || !Array.isArray(config.scan.include)) {
90
+ errors.push("scan.include is required and must be an array");
91
+ } else if (config.scan.include.length === 0) {
92
+ errors.push("scan.include cannot be empty");
93
+ }
94
+
95
+ if (!config.scan.ignore || !Array.isArray(config.scan.ignore)) {
96
+ errors.push("scan.ignore is required and must be an array");
97
+ }
98
+ }
99
+
100
+ // Validate module_roots (optional but must be array if present)
101
+ if (config.module_roots !== undefined) {
102
+ if (!Array.isArray(config.module_roots)) {
103
+ errors.push("module_roots must be an array");
104
+ }
105
+ }
106
+
107
+ // Validate outputs section
108
+ if (!config.outputs) {
109
+ errors.push("Missing required section: outputs");
110
+ } else {
111
+ if (!config.outputs.pages || !Array.isArray(config.outputs.pages)) {
112
+ errors.push("outputs.pages is required and must be an array");
113
+ } else if (config.outputs.pages.length === 0) {
114
+ errors.push("outputs.pages cannot be empty");
115
+ } else {
116
+ config.outputs.pages.forEach((page, idx) => {
117
+ if (!page.key || typeof page.key !== "string") {
118
+ errors.push(`outputs.pages[${idx}]: missing required field "key"`);
119
+ } else if (!SUPPORTED_PAGE_KEYS.includes(page.key)) {
120
+ errors.push(
121
+ `outputs.pages[${idx}]: "${page.key}" is not a valid page key. ` +
122
+ `Supported: ${SUPPORTED_PAGE_KEYS.join(", ")}`
123
+ );
124
+ }
125
+
126
+ if (!page.title || typeof page.title !== "string") {
127
+ errors.push(`outputs.pages[${idx}]: missing required field "title"`);
128
+ }
129
+
130
+ if (page.description && typeof page.description !== "string") {
131
+ errors.push(`outputs.pages[${idx}]: description must be a string`);
132
+ }
133
+ });
134
+ }
135
+ }
136
+
137
+ // Validate notion configuration (optional)
138
+ if (config.notion !== undefined) {
139
+ if (typeof config.notion !== "object" || Array.isArray(config.notion)) {
140
+ errors.push("notion must be an object");
141
+ } else {
142
+ // Validate branches filter
143
+ if (config.notion.branches !== undefined) {
144
+ if (!Array.isArray(config.notion.branches)) {
145
+ errors.push("notion.branches must be an array");
146
+ } else {
147
+ config.notion.branches.forEach((branch, idx) => {
148
+ if (typeof branch !== "string") {
149
+ errors.push(`notion.branches[${idx}] must be a string`);
150
+ }
151
+ });
152
+ }
153
+ }
154
+
155
+ // Validate includeBranchInTitle
156
+ if (config.notion.includeBranchInTitle !== undefined) {
157
+ if (typeof config.notion.includeBranchInTitle !== "boolean") {
158
+ errors.push("notion.includeBranchInTitle must be a boolean");
159
+ }
160
+ }
161
+ }
162
+ }
163
+
164
+ // Validate feature flags (optional)
165
+ if (config.features !== undefined) {
166
+ if (typeof config.features !== "object" || Array.isArray(config.features)) {
167
+ errors.push("features must be an object");
168
+ } else {
169
+ Object.entries(config.features).forEach(([key, value]) => {
170
+ if (typeof value !== "boolean") {
171
+ errors.push(`features.${key} must be a boolean`);
172
+ }
173
+ });
174
+ }
175
+ }
176
+
177
+ // Validate AI configuration (optional)
178
+ if (config.ai !== undefined) {
179
+ if (typeof config.ai !== "object" || Array.isArray(config.ai)) {
180
+ errors.push("ai must be an object");
181
+ } else {
182
+ if (config.ai.enabled !== undefined && typeof config.ai.enabled !== "boolean") {
183
+ errors.push("ai.enabled must be a boolean");
184
+ }
185
+ if (config.ai.mode !== undefined && !["hybrid", "full", "off"].includes(config.ai.mode)) {
186
+ errors.push("ai.mode must be one of: hybrid, full, off");
187
+ }
188
+ if (config.ai.temperature !== undefined && typeof config.ai.temperature !== "number") {
189
+ errors.push("ai.temperature must be a number");
190
+ }
191
+ if (config.ai.max_tokens !== undefined && typeof config.ai.max_tokens !== "number") {
192
+ errors.push("ai.max_tokens must be a number");
193
+ }
194
+ }
195
+ }
196
+
197
+ // Validate documentation configuration (optional)
198
+ if (config.documentation !== undefined) {
199
+ if (typeof config.documentation !== "object" || Array.isArray(config.documentation)) {
200
+ errors.push("documentation must be an object");
201
+ } else {
202
+ if (config.documentation.output_dir && typeof config.documentation.output_dir !== "string") {
203
+ errors.push("documentation.output_dir must be a string");
204
+ }
205
+ if (config.documentation.include_artifacts !== undefined && typeof config.documentation.include_artifacts !== "boolean") {
206
+ errors.push("documentation.include_artifacts must be a boolean");
207
+ }
208
+ if (config.documentation.sections !== undefined) {
209
+ if (!Array.isArray(config.documentation.sections)) {
210
+ errors.push("documentation.sections must be an array");
211
+ }
212
+ }
213
+ }
214
+ }
215
+
216
+ // Validate domains configuration (optional)
217
+ if (config.domains !== undefined) {
218
+ if (typeof config.domains !== "object" || Array.isArray(config.domains)) {
219
+ errors.push("domains must be an object");
220
+ } else {
221
+ Object.entries(config.domains).forEach(([domainKey, domain]) => {
222
+ if (typeof domain !== "object") {
223
+ errors.push(`domains.${domainKey} must be an object`);
224
+ } else {
225
+ if (!domain.match || !Array.isArray(domain.match)) {
226
+ errors.push(`domains.${domainKey}.match is required and must be an array`);
227
+ }
228
+ if (domain.description && typeof domain.description !== "string") {
229
+ errors.push(`domains.${domainKey}.description must be a string`);
230
+ }
231
+ }
232
+ });
233
+ }
234
+ }
235
+
236
+ if (errors.length > 0) {
237
+ const errorMessage = [
238
+ "Invalid .repolens.yml configuration:",
239
+ "",
240
+ ...errors.map(e => ` • ${e}`),
241
+ "",
242
+ "See https://github.com/CHAPIBUNNY/repolens#configuration for documentation."
243
+ ].join("\n");
244
+
245
+ throw new ValidationError(errorMessage);
246
+ }
247
+
248
+ return true;
249
+ }
250
+
251
+ /**
252
+ * Get the schema version this validator supports
253
+ */
254
+ export function getSchemaVersion() {
255
+ return CURRENT_SCHEMA_VERSION;
256
+ }
257
+
258
+ /**
259
+ * Check if a feature is enabled (with default fallback)
260
+ */
261
+ export function isFeatureEnabled(config, featureName, defaultValue = true) {
262
+ if (!config.features) return defaultValue;
263
+ return config.features[featureName] !== undefined
264
+ ? config.features[featureName]
265
+ : defaultValue;
266
+ }
@@ -0,0 +1,18 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import yaml from "js-yaml";
4
+ import { validateConfig } from "./config-schema.js";
5
+
6
+ export async function loadConfig(configPath) {
7
+ const absoluteConfigPath = path.resolve(configPath);
8
+ const raw = await fs.readFile(absoluteConfigPath, "utf8");
9
+ const cfg = yaml.load(raw);
10
+
11
+ // Validate config against schema
12
+ validateConfig(cfg);
13
+
14
+ cfg.__configPath = absoluteConfigPath;
15
+ cfg.__repoRoot = path.dirname(absoluteConfigPath);
16
+
17
+ return cfg;
18
+ }
@@ -0,0 +1,45 @@
1
+ import { execSync } from "node:child_process";
2
+ import { warn } from "../utils/logger.js";
3
+
4
+ export function getGitDiff(baseRef = "origin/main") {
5
+ let output = "";
6
+
7
+ try {
8
+ output = execSync(`git diff --name-status ${baseRef}`, {
9
+ encoding: "utf8"
10
+ });
11
+ } catch (error) {
12
+ warn("git diff failed, returning empty diff.");
13
+ return {
14
+ added: [],
15
+ removed: [],
16
+ modified: []
17
+ };
18
+ }
19
+
20
+ const lines = output.split("\n").filter(Boolean);
21
+
22
+ const added = [];
23
+ const removed = [];
24
+ const modified = [];
25
+
26
+ for (const line of lines) {
27
+ const [status, file] = line.split("\t");
28
+
29
+ if (!file) continue;
30
+
31
+ if (status === "A") {
32
+ added.push(file);
33
+ } else if (status === "D") {
34
+ removed.push(file);
35
+ } else {
36
+ modified.push(file);
37
+ }
38
+ }
39
+
40
+ return {
41
+ added,
42
+ removed,
43
+ modified
44
+ };
45
+ }
@@ -0,0 +1,312 @@
1
+ import fg from "fast-glob";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { info, warn } from "../utils/logger.js";
5
+
6
+ const norm = (p) => p.replace(/\\/g, "/");
7
+
8
+ // Performance guardrails
9
+ const MAX_FILES_WARNING = 10000;
10
+ const MAX_FILES_LIMIT = 50000;
11
+
12
+ function isNextRoute(file) {
13
+ const f = norm(file);
14
+ return (
15
+ f.includes("/pages/api/") ||
16
+ (f.includes("/app/") && (f.endsWith("/route.ts") || f.endsWith("/route.js")))
17
+ );
18
+ }
19
+
20
+ function isNextPage(file) {
21
+ const f = norm(file);
22
+
23
+ if (f.includes("/app/")) {
24
+ return (
25
+ f.endsWith("/page.tsx") ||
26
+ f.endsWith("/page.jsx") ||
27
+ f.endsWith("/page.ts") ||
28
+ f.endsWith("/page.js")
29
+ );
30
+ }
31
+
32
+ if (f.includes("/pages/") && !f.includes("/pages/api/")) {
33
+ return (
34
+ f.endsWith(".tsx") ||
35
+ f.endsWith(".jsx") ||
36
+ f.endsWith(".ts") ||
37
+ f.endsWith(".js")
38
+ );
39
+ }
40
+
41
+ return false;
42
+ }
43
+
44
+ async function readFileSafe(file) {
45
+ try {
46
+ return await fs.readFile(file, "utf8");
47
+ } catch {
48
+ return "";
49
+ }
50
+ }
51
+
52
+ function moduleKeyForFile(file, moduleRoots) {
53
+ const normalized = norm(file);
54
+
55
+ const sortedRoots = [...moduleRoots].sort((a, b) => b.length - a.length);
56
+
57
+ for (const root of sortedRoots) {
58
+ if (normalized === root) return root;
59
+ if (normalized.startsWith(`${root}/`)) {
60
+ const remainder = normalized.slice(root.length + 1);
61
+ const nextSegment = remainder.split("/")[0];
62
+ return nextSegment ? `${root}/${nextSegment}` : root;
63
+ }
64
+ }
65
+
66
+ const parts = normalized.split("/");
67
+ return parts.length > 1 ? parts[0] : "root";
68
+ }
69
+
70
+ function routePathFromFile(file) {
71
+ const f = norm(file);
72
+
73
+ if (f.includes("/app/")) {
74
+ const appIndex = f.indexOf("/app/");
75
+ const relative = f.slice(appIndex + 5);
76
+
77
+ const cleaned = relative
78
+ .replace(/\/page\.(ts|tsx|js|jsx)$/, "")
79
+ .replace(/\/route\.(ts|tsx|js|jsx)$/, "")
80
+ .replace(/\[(.*?)\]/g, ":$1")
81
+ .replace(/\/$/, "");
82
+
83
+ return cleaned ? `/${cleaned}` : "/";
84
+ }
85
+
86
+ if (f.includes("/pages/api/")) {
87
+ const apiIndex = f.indexOf("/pages/api/");
88
+ const relative = f.slice(apiIndex + 11);
89
+
90
+ return "/api/" + relative.replace(/\.(ts|tsx|js|jsx)$/, "");
91
+ }
92
+
93
+ if (f.includes("/pages/")) {
94
+ const pagesIndex = f.indexOf("/pages/");
95
+ const relative = f.slice(pagesIndex + 7);
96
+
97
+ const cleaned = relative
98
+ .replace(/\.(ts|tsx|js|jsx)$/, "")
99
+ .replace(/\/index$/, "")
100
+ .replace(/\[(.*?)\]/g, ":$1")
101
+ .replace(/\/$/, "");
102
+
103
+ return cleaned ? `/${cleaned}` : "/";
104
+ }
105
+
106
+ return file;
107
+ }
108
+
109
+ async function extractRepoMetadata(repoRoot) {
110
+ const metadata = {
111
+ hasPackageJson: false,
112
+ frameworks: [],
113
+ languages: new Set(),
114
+ buildTools: [],
115
+ testFrameworks: []
116
+ };
117
+
118
+ // Try to read package.json
119
+ try {
120
+ const pkgPath = path.join(repoRoot, "package.json");
121
+ const pkgContent = await fs.readFile(pkgPath, "utf8");
122
+ const pkg = JSON.parse(pkgContent);
123
+ metadata.hasPackageJson = true;
124
+
125
+ const allDeps = {
126
+ ...pkg.dependencies,
127
+ ...pkg.devDependencies,
128
+ ...pkg.optionalDependencies
129
+ };
130
+
131
+ // Detect frameworks
132
+ if (allDeps["next"]) metadata.frameworks.push("Next.js");
133
+ if (allDeps["react"]) metadata.frameworks.push("React");
134
+ if (allDeps["vue"]) metadata.frameworks.push("Vue");
135
+ if (allDeps["angular"] || allDeps["@angular/core"]) metadata.frameworks.push("Angular");
136
+ if (allDeps["express"]) metadata.frameworks.push("Express");
137
+ if (allDeps["fastify"]) metadata.frameworks.push("Fastify");
138
+ if (allDeps["nestjs"] || allDeps["@nestjs/core"]) metadata.frameworks.push("NestJS");
139
+ if (allDeps["svelte"]) metadata.frameworks.push("Svelte");
140
+ if (allDeps["solid-js"]) metadata.frameworks.push("Solid");
141
+
142
+ // Detect test frameworks
143
+ if (allDeps["vitest"]) metadata.testFrameworks.push("Vitest");
144
+ if (allDeps["jest"]) metadata.testFrameworks.push("Jest");
145
+ if (allDeps["mocha"]) metadata.testFrameworks.push("Mocha");
146
+ if (allDeps["playwright"]) metadata.testFrameworks.push("Playwright");
147
+ if (allDeps["cypress"]) metadata.testFrameworks.push("Cypress");
148
+
149
+ // Detect build tools
150
+ if (allDeps["vite"]) metadata.buildTools.push("Vite");
151
+ if (allDeps["webpack"]) metadata.buildTools.push("Webpack");
152
+ if (allDeps["rollup"]) metadata.buildTools.push("Rollup");
153
+ if (allDeps["esbuild"]) metadata.buildTools.push("esbuild");
154
+ if (allDeps["turbo"]) metadata.buildTools.push("Turborepo");
155
+
156
+ // Detect TypeScript
157
+ if (allDeps["typescript"]) metadata.languages.add("TypeScript");
158
+ } catch {
159
+ // No package.json or invalid JSON
160
+ }
161
+
162
+ return metadata;
163
+ }
164
+
165
+ export async function scanRepo(cfg) {
166
+ const repoRoot = cfg.__repoRoot;
167
+
168
+ const files = await fg(cfg.scan.include, {
169
+ cwd: repoRoot,
170
+ ignore: cfg.scan.ignore,
171
+ onlyFiles: true
172
+ });
173
+
174
+ // Performance guardrails
175
+ if (files.length > MAX_FILES_LIMIT) {
176
+ throw new Error(
177
+ `Repository too large: ${files.length} files matched scan patterns. ` +
178
+ `Maximum supported: ${MAX_FILES_LIMIT}. Consider refining scan.include patterns.`
179
+ );
180
+ }
181
+
182
+ if (files.length > MAX_FILES_WARNING) {
183
+ warn(`Large repository detected: ${files.length} files. Scan may take longer than usual.`);
184
+ }
185
+
186
+ const moduleCounts = new Map();
187
+
188
+ for (const file of files) {
189
+ const key = moduleKeyForFile(file, cfg.module_roots || []);
190
+ moduleCounts.set(key, (moduleCounts.get(key) || 0) + 1);
191
+ }
192
+
193
+ const modules = [...moduleCounts.entries()]
194
+ .map(([key, fileCount]) => ({ key, fileCount }))
195
+ .sort((a, b) => b.fileCount - a.fileCount);
196
+
197
+ const apiFiles = files.filter(isNextRoute);
198
+ const api = [];
199
+
200
+ for (const file of apiFiles) {
201
+ const absoluteFile = path.join(repoRoot, file);
202
+ const content = await readFileSafe(absoluteFile);
203
+ const methods = [];
204
+
205
+ if (content.includes("export async function GET")) methods.push("GET");
206
+ if (content.includes("export async function POST")) methods.push("POST");
207
+ if (content.includes("export async function PUT")) methods.push("PUT");
208
+ if (content.includes("export async function PATCH")) methods.push("PATCH");
209
+ if (content.includes("export async function DELETE")) methods.push("DELETE");
210
+
211
+ api.push({
212
+ file,
213
+ path: routePathFromFile(file),
214
+ methods: methods.length ? methods : ["UNKNOWN"]
215
+ });
216
+ }
217
+
218
+ const pageFiles = files.filter(isNextPage);
219
+ const pages = pageFiles.map((file) => ({
220
+ file,
221
+ path: routePathFromFile(file)
222
+ }));
223
+
224
+ // Extract repository metadata
225
+ const metadata = await extractRepoMetadata(repoRoot);
226
+
227
+ // Detect external API integrations
228
+ const externalApis = await detectExternalApis(files, repoRoot);
229
+
230
+ return {
231
+ filesCount: files.length,
232
+ modules,
233
+ api,
234
+ pages,
235
+ metadata,
236
+ externalApis
237
+ };
238
+ }
239
+
240
+ async function detectExternalApis(files, repoRoot) {
241
+ const integrations = [];
242
+ const detectedServices = new Set();
243
+
244
+ // Common API patterns to detect
245
+ const apiPatterns = [
246
+ {
247
+ name: "OpenAI API",
248
+ patterns: [
249
+ /api\.openai\.com/,
250
+ /chat\/completions/,
251
+ /OPENAI.*API_KEY/,
252
+ /REPOLENS_AI_API_KEY/
253
+ ],
254
+ category: "AI/ML"
255
+ },
256
+ {
257
+ name: "Notion API",
258
+ patterns: [
259
+ /api\.notion\.com/,
260
+ /NOTION_TOKEN/,
261
+ /notion.*pages/
262
+ ],
263
+ category: "Publishing"
264
+ },
265
+ {
266
+ name: "npm Registry",
267
+ patterns: [
268
+ /registry\.npmjs\.org/,
269
+ /npm.*latest/
270
+ ],
271
+ category: "Package Management"
272
+ },
273
+ {
274
+ name: "GitHub API",
275
+ patterns: [
276
+ /api\.github\.com/,
277
+ /GITHUB_TOKEN/
278
+ ],
279
+ category: "Version Control"
280
+ }
281
+ ];
282
+
283
+ // Only scan JavaScript/TypeScript files for performance
284
+ const jsFiles = files.filter(f =>
285
+ f.endsWith('.js') || f.endsWith('.ts') ||
286
+ f.endsWith('.jsx') || f.endsWith('.tsx')
287
+ );
288
+
289
+ for (const file of jsFiles) {
290
+ const absoluteFile = path.join(repoRoot, file);
291
+ const content = await readFileSafe(absoluteFile);
292
+
293
+ if (!content) continue;
294
+
295
+ for (const { name, patterns, category } of apiPatterns) {
296
+ if (detectedServices.has(name)) continue;
297
+
298
+ const matched = patterns.some(pattern => pattern.test(content));
299
+
300
+ if (matched) {
301
+ integrations.push({
302
+ name,
303
+ category,
304
+ detectedIn: file
305
+ });
306
+ detectedServices.add(name);
307
+ }
308
+ }
309
+ }
310
+
311
+ return integrations;
312
+ }