@chappibunny/repolens 1.4.0 โ 1.5.0
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 +15 -0
- package/LICENSE +21 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/ai/generate-sections.js +77 -97
- package/src/ai/prompts.js +122 -0
- package/src/ai/provider.js +213 -7
- package/src/analyzers/codeowners.js +146 -0
- package/src/analyzers/context-builder.js +11 -1
- package/src/analyzers/monorepo-detector.js +155 -0
- package/src/core/scan.js +5 -0
- package/src/docs/generate-doc-set.js +13 -3
- package/src/publishers/index.js +16 -3
- package/src/renderers/render.js +40 -5
- package/src/utils/doc-cache.js +78 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to RepoLens will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## 1.5.0
|
|
6
|
+
|
|
7
|
+
### ๐ New Features (Tier 3 โ Differentiation)
|
|
8
|
+
|
|
9
|
+
- **Document caching**: Hash-based caching skips redundant API calls for unchanged documents. Notion, Confluence, and GitHub Wiki publishers now receive only changed pages; Markdown always gets the full set. Cache persists in `.repolens/doc-hashes.json`.
|
|
10
|
+
- **Structured AI output**: AI sections now request JSON-mode responses with schema validation. If JSON parsing or schema validation fails, a single re-prompt is attempted before falling back to plain-text AI, then deterministic generation. All 6 AI document types have JSON schemas and Markdown renderers.
|
|
11
|
+
- **Multi-provider AI**: Added native adapters for Anthropic (Messages API) and Google Gemini alongside existing OpenAI-compatible support. Set `REPOLENS_AI_PROVIDER` to `anthropic`, `google`, or `openai_compatible` (default). Azure OpenAI uses the OpenAI-compatible adapter.
|
|
12
|
+
- **Monorepo awareness**: Automatic detection of npm/yarn workspaces, pnpm workspaces, and Lerna configurations. Scan results include workspace metadata. System Overview renderer shows package inventory table. AI context includes monorepo structure.
|
|
13
|
+
- **CODEOWNERS integration**: Parses `CODEOWNERS` / `.github/CODEOWNERS` / `docs/CODEOWNERS` files. Maps file ownership to modules via last-match-wins pattern matching. Module Catalog now displays an "Owners" column when CODEOWNERS is present. Ownership data is included in artifacts.
|
|
14
|
+
|
|
15
|
+
### ๐ Test Coverage
|
|
16
|
+
|
|
17
|
+
- **219 tests** passing across **17 test files** (up from 188/16).
|
|
18
|
+
- New `tests/tier3.test.js` with 31 tests covering caching, monorepo detection, CODEOWNERS parsing, multi-provider AI config, and structured output rendering.
|
|
19
|
+
|
|
5
20
|
## 1.4.0
|
|
6
21
|
|
|
7
22
|
### ๐ Bug Fixes (Tier 1 โ Production)
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Charl Van Zyl
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
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](STABILITY.md) ยท [Security hardened](SECURITY.md) ยท v1.
|
|
20
|
+
> Stable as of v1.0 โ [API guarantees](STABILITY.md) ยท [Security hardened](SECURITY.md) ยท v1.5.0
|
|
21
21
|
|
|
22
22
|
---
|
|
23
23
|
|
package/package.json
CHANGED
|
@@ -11,129 +11,109 @@ import {
|
|
|
11
11
|
createDeveloperOnboardingPrompt,
|
|
12
12
|
createModuleSummaryPrompt,
|
|
13
13
|
createRouteSummaryPrompt,
|
|
14
|
-
createAPIDocumentationPrompt
|
|
14
|
+
createAPIDocumentationPrompt,
|
|
15
|
+
AI_SCHEMAS,
|
|
16
|
+
renderStructuredToMarkdown,
|
|
15
17
|
} from "./prompts.js";
|
|
16
18
|
import { info, warn } from "../utils/logger.js";
|
|
17
19
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
/**
|
|
21
|
+
* Try structured JSON mode first, fall back to plain-text AI, then deterministic.
|
|
22
|
+
*/
|
|
23
|
+
async function generateWithStructuredFallback(key, promptText, maxTokens, fallbackFn) {
|
|
24
|
+
if (!isAIEnabled()) return fallbackFn();
|
|
25
|
+
|
|
26
|
+
const schema = AI_SCHEMAS[key];
|
|
27
|
+
|
|
28
|
+
// Try structured JSON mode
|
|
29
|
+
if (schema) {
|
|
30
|
+
info(`Generating ${key} with structured AI...`);
|
|
31
|
+
const jsonPrompt = promptText + `\n\nRespond ONLY with a JSON object matching this schema: ${JSON.stringify({ required: schema.required })}. No markdown, no explanation โ just the JSON object.`;
|
|
32
|
+
|
|
33
|
+
const result = await generateText({
|
|
34
|
+
system: SYSTEM_PROMPT,
|
|
35
|
+
user: jsonPrompt,
|
|
36
|
+
maxTokens,
|
|
37
|
+
jsonMode: true,
|
|
38
|
+
jsonSchema: schema,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
if (result.success && result.parsed) {
|
|
42
|
+
const md = renderStructuredToMarkdown(key, result.parsed);
|
|
43
|
+
if (md) return md;
|
|
44
|
+
}
|
|
45
|
+
// If structured mode failed, fall through to plain-text
|
|
46
|
+
warn(`Structured AI failed for ${key}, trying plain-text mode...`);
|
|
21
47
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
48
|
+
|
|
49
|
+
// Plain-text AI fallback
|
|
50
|
+
info(`Generating ${key} with AI...`);
|
|
25
51
|
const result = await generateText({
|
|
26
52
|
system: SYSTEM_PROMPT,
|
|
27
|
-
user:
|
|
28
|
-
maxTokens
|
|
53
|
+
user: promptText,
|
|
54
|
+
maxTokens,
|
|
29
55
|
});
|
|
30
|
-
|
|
56
|
+
|
|
31
57
|
if (!result.success) {
|
|
32
58
|
warn("AI generation failed, using fallback");
|
|
33
|
-
return
|
|
59
|
+
return fallbackFn();
|
|
34
60
|
}
|
|
35
|
-
|
|
61
|
+
|
|
36
62
|
return result.text;
|
|
37
63
|
}
|
|
38
64
|
|
|
65
|
+
export async function generateExecutiveSummary(context) {
|
|
66
|
+
return generateWithStructuredFallback(
|
|
67
|
+
"executive_summary",
|
|
68
|
+
createExecutiveSummaryPrompt(context),
|
|
69
|
+
1500,
|
|
70
|
+
() => getFallbackExecutiveSummary(context),
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
39
74
|
export async function generateSystemOverview(context) {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const result = await generateText({
|
|
47
|
-
system: SYSTEM_PROMPT,
|
|
48
|
-
user: createSystemOverviewPrompt(context),
|
|
49
|
-
maxTokens: 1200
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
if (!result.success) {
|
|
53
|
-
return getFallbackSystemOverview(context);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
return result.text;
|
|
75
|
+
return generateWithStructuredFallback(
|
|
76
|
+
"system_overview",
|
|
77
|
+
createSystemOverviewPrompt(context),
|
|
78
|
+
1200,
|
|
79
|
+
() => getFallbackSystemOverview(context),
|
|
80
|
+
);
|
|
57
81
|
}
|
|
58
82
|
|
|
59
83
|
export async function generateBusinessDomains(context) {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const result = await generateText({
|
|
67
|
-
system: SYSTEM_PROMPT,
|
|
68
|
-
user: createBusinessDomainsPrompt(context),
|
|
69
|
-
maxTokens: 2000
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
if (!result.success) {
|
|
73
|
-
return getFallbackBusinessDomains(context);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
return result.text;
|
|
84
|
+
return generateWithStructuredFallback(
|
|
85
|
+
"business_domains",
|
|
86
|
+
createBusinessDomainsPrompt(context),
|
|
87
|
+
2000,
|
|
88
|
+
() => getFallbackBusinessDomains(context),
|
|
89
|
+
);
|
|
77
90
|
}
|
|
78
91
|
|
|
79
92
|
export async function generateArchitectureOverview(context) {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const result = await generateText({
|
|
87
|
-
system: SYSTEM_PROMPT,
|
|
88
|
-
user: createArchitectureOverviewPrompt(context),
|
|
89
|
-
maxTokens: 1800
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
if (!result.success) {
|
|
93
|
-
return getFallbackArchitectureOverview(context);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
return result.text;
|
|
93
|
+
return generateWithStructuredFallback(
|
|
94
|
+
"architecture_overview",
|
|
95
|
+
createArchitectureOverviewPrompt(context),
|
|
96
|
+
1800,
|
|
97
|
+
() => getFallbackArchitectureOverview(context),
|
|
98
|
+
);
|
|
97
99
|
}
|
|
98
100
|
|
|
99
101
|
export async function generateDataFlows(flows, context) {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const result = await generateText({
|
|
107
|
-
system: SYSTEM_PROMPT,
|
|
108
|
-
user: createDataFlowsPrompt(flows, context),
|
|
109
|
-
maxTokens: 1800
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
if (!result.success) {
|
|
113
|
-
return getFallbackDataFlows(flows);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
return result.text;
|
|
102
|
+
return generateWithStructuredFallback(
|
|
103
|
+
"data_flows",
|
|
104
|
+
createDataFlowsPrompt(flows, context),
|
|
105
|
+
1800,
|
|
106
|
+
() => getFallbackDataFlows(flows),
|
|
107
|
+
);
|
|
117
108
|
}
|
|
118
109
|
|
|
119
110
|
export async function generateDeveloperOnboarding(context) {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
const result = await generateText({
|
|
127
|
-
system: SYSTEM_PROMPT,
|
|
128
|
-
user: createDeveloperOnboardingPrompt(context),
|
|
129
|
-
maxTokens: 2200
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
if (!result.success) {
|
|
133
|
-
return getFallbackDeveloperOnboarding(context);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
return result.text;
|
|
111
|
+
return generateWithStructuredFallback(
|
|
112
|
+
"developer_onboarding",
|
|
113
|
+
createDeveloperOnboardingPrompt(context),
|
|
114
|
+
2200,
|
|
115
|
+
() => getFallbackDeveloperOnboarding(context),
|
|
116
|
+
);
|
|
137
117
|
}
|
|
138
118
|
|
|
139
119
|
// Fallback generators (deterministic, no AI)
|
package/src/ai/prompts.js
CHANGED
|
@@ -353,3 +353,125 @@ Dependencies:
|
|
|
353
353
|
Risks:
|
|
354
354
|
[if applicable]`;
|
|
355
355
|
}
|
|
356
|
+
|
|
357
|
+
// --- JSON schemas for structured AI output ---
|
|
358
|
+
|
|
359
|
+
export const AI_SCHEMAS = {
|
|
360
|
+
executive_summary: {
|
|
361
|
+
required: ["whatItDoes", "whoItServes", "coreCapabilities", "mainAreas", "risks"],
|
|
362
|
+
description: "Executive summary for mixed audience",
|
|
363
|
+
},
|
|
364
|
+
system_overview: {
|
|
365
|
+
required: ["snapshot", "layers", "domains", "patterns", "observations"],
|
|
366
|
+
description: "High-level system overview",
|
|
367
|
+
},
|
|
368
|
+
business_domains: {
|
|
369
|
+
required: ["domains"],
|
|
370
|
+
description: "Business domain breakdown",
|
|
371
|
+
},
|
|
372
|
+
architecture_overview: {
|
|
373
|
+
required: ["style", "layers", "strengths", "weaknesses"],
|
|
374
|
+
description: "Architecture overview for engineers",
|
|
375
|
+
},
|
|
376
|
+
data_flows: {
|
|
377
|
+
required: ["flows"],
|
|
378
|
+
description: "Data flow documentation",
|
|
379
|
+
},
|
|
380
|
+
developer_onboarding: {
|
|
381
|
+
required: ["startHere", "mainFolders", "coreFlows", "complexityHotspots"],
|
|
382
|
+
description: "Developer onboarding guide",
|
|
383
|
+
},
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Render a structured JSON response into Markdown for the given document type.
|
|
388
|
+
*/
|
|
389
|
+
export function renderStructuredToMarkdown(key, parsed) {
|
|
390
|
+
switch (key) {
|
|
391
|
+
case "executive_summary":
|
|
392
|
+
return renderExecutiveSummaryJSON(parsed);
|
|
393
|
+
case "system_overview":
|
|
394
|
+
return renderSystemOverviewJSON(parsed);
|
|
395
|
+
case "business_domains":
|
|
396
|
+
return renderBusinessDomainsJSON(parsed);
|
|
397
|
+
case "architecture_overview":
|
|
398
|
+
return renderArchitectureOverviewJSON(parsed);
|
|
399
|
+
case "data_flows":
|
|
400
|
+
return renderDataFlowsJSON(parsed);
|
|
401
|
+
case "developer_onboarding":
|
|
402
|
+
return renderDeveloperOnboardingJSON(parsed);
|
|
403
|
+
default:
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function renderExecutiveSummaryJSON(d) {
|
|
409
|
+
let md = `# Executive Summary\n\n`;
|
|
410
|
+
md += `## What This System Does\n\n${d.whatItDoes}\n\n`;
|
|
411
|
+
md += `## Who It Serves\n\n${d.whoItServes}\n\n`;
|
|
412
|
+
md += `## Core Capabilities\n\n`;
|
|
413
|
+
if (Array.isArray(d.coreCapabilities)) {
|
|
414
|
+
md += d.coreCapabilities.map(c => `- ${c}`).join("\n") + "\n\n";
|
|
415
|
+
} else {
|
|
416
|
+
md += `${d.coreCapabilities}\n\n`;
|
|
417
|
+
}
|
|
418
|
+
md += `## Main System Areas\n\n${Array.isArray(d.mainAreas) ? d.mainAreas.map(a => `- **${a.name || a}**${a.description ? `: ${a.description}` : ""}`).join("\n") : d.mainAreas}\n\n`;
|
|
419
|
+
if (d.dependencies) md += `## Key Dependencies\n\n${Array.isArray(d.dependencies) ? d.dependencies.map(dep => `- ${dep}`).join("\n") : d.dependencies}\n\n`;
|
|
420
|
+
md += `## Operational and Architectural Risks\n\n${Array.isArray(d.risks) ? d.risks.map(r => `- ${r}`).join("\n") : d.risks}\n\n`;
|
|
421
|
+
if (d.focusAreas) md += `## Recommended Focus Areas\n\n${Array.isArray(d.focusAreas) ? d.focusAreas.map(f => `- ${f}`).join("\n") : d.focusAreas}\n`;
|
|
422
|
+
return md;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function renderSystemOverviewJSON(d) {
|
|
426
|
+
let md = `# System Overview\n\n`;
|
|
427
|
+
md += `## Repository Snapshot\n\n${d.snapshot}\n\n`;
|
|
428
|
+
md += `## Main Architectural Layers\n\n${Array.isArray(d.layers) ? d.layers.map(l => `- **${l.name || l}**${l.description ? `: ${l.description}` : ""}`).join("\n") : d.layers}\n\n`;
|
|
429
|
+
md += `## Dominant Domains\n\n${Array.isArray(d.domains) ? d.domains.map(dom => `- ${dom}`).join("\n") : d.domains}\n\n`;
|
|
430
|
+
md += `## Main Technology Patterns\n\n${Array.isArray(d.patterns) ? d.patterns.map(p => `- ${p}`).join("\n") : d.patterns}\n\n`;
|
|
431
|
+
md += `## Key Observations\n\n${Array.isArray(d.observations) ? d.observations.map(o => `- ${o}`).join("\n") : d.observations}\n`;
|
|
432
|
+
return md;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function renderBusinessDomainsJSON(d) {
|
|
436
|
+
let md = `# Business Domains\n\n`;
|
|
437
|
+
if (!Array.isArray(d.domains)) return md + d.domains;
|
|
438
|
+
for (const dom of d.domains) {
|
|
439
|
+
md += `## ${dom.name}\n\n${dom.description || ""}\n\n`;
|
|
440
|
+
if (dom.modules) md += `**Key modules:** ${Array.isArray(dom.modules) ? dom.modules.join(", ") : dom.modules}\n\n`;
|
|
441
|
+
if (dom.userFunctionality) md += `**User-visible functionality:** ${dom.userFunctionality}\n\n`;
|
|
442
|
+
if (dom.dependencies) md += `**Dependencies:** ${Array.isArray(dom.dependencies) ? dom.dependencies.join(", ") : dom.dependencies}\n\n`;
|
|
443
|
+
}
|
|
444
|
+
return md;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function renderArchitectureOverviewJSON(d) {
|
|
448
|
+
let md = `# Architecture Overview\n\n`;
|
|
449
|
+
md += `## Architecture Style\n\n${d.style}\n\n`;
|
|
450
|
+
md += `## Layers\n\n${Array.isArray(d.layers) ? d.layers.map(l => `### ${l.name || l}\n\n${l.description || ""}`).join("\n\n") : d.layers}\n\n`;
|
|
451
|
+
md += `## Architectural Strengths\n\n${Array.isArray(d.strengths) ? d.strengths.map(s => `- ${s}`).join("\n") : d.strengths}\n\n`;
|
|
452
|
+
md += `## Architectural Weaknesses\n\n${Array.isArray(d.weaknesses) ? d.weaknesses.map(w => `- ${w}`).join("\n") : d.weaknesses}\n`;
|
|
453
|
+
return md;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function renderDataFlowsJSON(d) {
|
|
457
|
+
let md = `# Data Flows\n\n`;
|
|
458
|
+
if (!Array.isArray(d.flows)) return md + d.flows;
|
|
459
|
+
for (const flow of d.flows) {
|
|
460
|
+
md += `## ${flow.name}\n\n${flow.description || ""}\n\n`;
|
|
461
|
+
if (flow.steps) md += `**Steps:**\n${Array.isArray(flow.steps) ? flow.steps.map((s, i) => `${i + 1}. ${s}`).join("\n") : flow.steps}\n\n`;
|
|
462
|
+
if (flow.modules) md += `**Involved modules:** ${Array.isArray(flow.modules) ? flow.modules.join(", ") : flow.modules}\n\n`;
|
|
463
|
+
if (flow.criticalDependencies) md += `**Critical dependencies:** ${flow.criticalDependencies}\n\n`;
|
|
464
|
+
}
|
|
465
|
+
return md;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function renderDeveloperOnboardingJSON(d) {
|
|
469
|
+
let md = `# Developer Onboarding\n\n`;
|
|
470
|
+
md += `## Start Here\n\n${d.startHere}\n\n`;
|
|
471
|
+
md += `## Main Folders\n\n${Array.isArray(d.mainFolders) ? d.mainFolders.map(f => `- **${f.name || f}**${f.description ? `: ${f.description}` : ""}`).join("\n") : d.mainFolders}\n\n`;
|
|
472
|
+
md += `## Core Product Flows\n\n${Array.isArray(d.coreFlows) ? d.coreFlows.map(f => `- ${f}`).join("\n") : d.coreFlows}\n\n`;
|
|
473
|
+
if (d.importantRoutes) md += `## Important Routes\n\n${Array.isArray(d.importantRoutes) ? d.importantRoutes.map(r => `- ${r}`).join("\n") : d.importantRoutes}\n\n`;
|
|
474
|
+
if (d.sharedLibraries) md += `## Important Shared Libraries\n\n${Array.isArray(d.sharedLibraries) ? d.sharedLibraries.map(l => `- ${l}`).join("\n") : d.sharedLibraries}\n\n`;
|
|
475
|
+
md += `## Known Complexity Hotspots\n\n${Array.isArray(d.complexityHotspots) ? d.complexityHotspots.map(h => `- ${h}`).join("\n") : d.complexityHotspots}\n`;
|
|
476
|
+
return md;
|
|
477
|
+
}
|
package/src/ai/provider.js
CHANGED
|
@@ -6,7 +6,7 @@ import { executeAIRequest } from "../utils/rate-limit.js";
|
|
|
6
6
|
const DEFAULT_TIMEOUT_MS = 60000;
|
|
7
7
|
const DEFAULT_MAX_TOKENS = 2500;
|
|
8
8
|
|
|
9
|
-
export async function generateText({ system, user, temperature, maxTokens, config }) {
|
|
9
|
+
export async function generateText({ system, user, temperature, maxTokens, config, jsonMode, jsonSchema }) {
|
|
10
10
|
// Check if AI is enabled (env var takes precedence, then config)
|
|
11
11
|
const aiConfig = config?.ai || {};
|
|
12
12
|
const enabled = process.env.REPOLENS_AI_ENABLED === "true" || aiConfig.enabled === true;
|
|
@@ -43,18 +43,58 @@ export async function generateText({ system, user, temperature, maxTokens, confi
|
|
|
43
43
|
if (!baseUrl && provider === "openai_compatible") {
|
|
44
44
|
warn("REPOLENS_AI_BASE_URL not set. Using OpenAI default.");
|
|
45
45
|
}
|
|
46
|
+
|
|
47
|
+
// Select provider adapter
|
|
48
|
+
const adapter = getProviderAdapter(provider);
|
|
46
49
|
|
|
47
50
|
try {
|
|
48
|
-
const result = await
|
|
49
|
-
baseUrl: baseUrl ||
|
|
51
|
+
const result = await adapter({
|
|
52
|
+
baseUrl: baseUrl || getDefaultBaseUrl(provider),
|
|
50
53
|
apiKey,
|
|
51
54
|
model,
|
|
52
55
|
system,
|
|
53
56
|
user,
|
|
54
57
|
temperature: resolvedTemp,
|
|
55
58
|
maxTokens: resolvedMaxTokens,
|
|
56
|
-
timeoutMs
|
|
59
|
+
timeoutMs,
|
|
60
|
+
jsonMode,
|
|
57
61
|
});
|
|
62
|
+
|
|
63
|
+
// Validate JSON schema if provided
|
|
64
|
+
if (jsonMode && jsonSchema && result) {
|
|
65
|
+
const parsed = safeParseJSON(result);
|
|
66
|
+
if (!parsed) {
|
|
67
|
+
warn("AI returned invalid JSON, re-prompting once...");
|
|
68
|
+
const retryResult = await adapter({
|
|
69
|
+
baseUrl: baseUrl || getDefaultBaseUrl(provider),
|
|
70
|
+
apiKey,
|
|
71
|
+
model,
|
|
72
|
+
system,
|
|
73
|
+
user: user + "\n\nIMPORTANT: Your previous response was not valid JSON. Respond ONLY with a valid JSON object.",
|
|
74
|
+
temperature: resolvedTemp,
|
|
75
|
+
maxTokens: resolvedMaxTokens,
|
|
76
|
+
timeoutMs,
|
|
77
|
+
jsonMode,
|
|
78
|
+
});
|
|
79
|
+
const retryParsed = safeParseJSON(retryResult);
|
|
80
|
+
if (!retryParsed) {
|
|
81
|
+
warn("AI JSON re-prompt also failed, falling back to deterministic.");
|
|
82
|
+
return { success: false, error: "Invalid JSON from AI after retry", fallback: true };
|
|
83
|
+
}
|
|
84
|
+
const schemaError = validateSchema(retryParsed, jsonSchema);
|
|
85
|
+
if (schemaError) {
|
|
86
|
+
warn(`AI JSON schema mismatch after retry: ${schemaError}`);
|
|
87
|
+
return { success: false, error: schemaError, fallback: true };
|
|
88
|
+
}
|
|
89
|
+
return { success: true, text: retryResult, parsed: retryParsed, fallback: false };
|
|
90
|
+
}
|
|
91
|
+
const schemaError = validateSchema(parsed, jsonSchema);
|
|
92
|
+
if (schemaError) {
|
|
93
|
+
warn(`AI JSON schema mismatch: ${schemaError}`);
|
|
94
|
+
return { success: false, error: schemaError, fallback: true };
|
|
95
|
+
}
|
|
96
|
+
return { success: true, text: result, parsed, fallback: false };
|
|
97
|
+
}
|
|
58
98
|
|
|
59
99
|
return {
|
|
60
100
|
success: true,
|
|
@@ -72,7 +112,59 @@ export async function generateText({ system, user, temperature, maxTokens, confi
|
|
|
72
112
|
}
|
|
73
113
|
}
|
|
74
114
|
|
|
75
|
-
|
|
115
|
+
/**
|
|
116
|
+
* Parse JSON safely, returning null on failure.
|
|
117
|
+
*/
|
|
118
|
+
function safeParseJSON(text) {
|
|
119
|
+
try {
|
|
120
|
+
return JSON.parse(text);
|
|
121
|
+
} catch {
|
|
122
|
+
// Try extracting JSON from markdown code blocks
|
|
123
|
+
const match = text?.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
124
|
+
if (match) {
|
|
125
|
+
try { return JSON.parse(match[1].trim()); } catch { /* fall through */ }
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Validate an object against a simple schema (required string fields).
|
|
133
|
+
* Returns error message or null if valid.
|
|
134
|
+
*/
|
|
135
|
+
function validateSchema(obj, schema) {
|
|
136
|
+
if (!schema || !schema.required) return null;
|
|
137
|
+
for (const field of schema.required) {
|
|
138
|
+
if (!(field in obj)) return `Missing required field: ${field}`;
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Get default base URL for a provider.
|
|
145
|
+
*/
|
|
146
|
+
function getDefaultBaseUrl(provider) {
|
|
147
|
+
switch (provider) {
|
|
148
|
+
case "anthropic": return "https://api.anthropic.com";
|
|
149
|
+
case "azure": return process.env.REPOLENS_AI_BASE_URL || "https://api.openai.com/v1";
|
|
150
|
+
case "google": return "https://generativelanguage.googleapis.com";
|
|
151
|
+
default: return "https://api.openai.com/v1";
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Select the appropriate provider adapter function.
|
|
157
|
+
*/
|
|
158
|
+
function getProviderAdapter(provider) {
|
|
159
|
+
switch (provider) {
|
|
160
|
+
case "anthropic": return callAnthropicAPI;
|
|
161
|
+
case "google": return callGoogleAPI;
|
|
162
|
+
// "openai_compatible" and "azure" both use the OpenAI format
|
|
163
|
+
default: return callOpenAICompatibleAPI;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function callOpenAICompatibleAPI({ baseUrl, apiKey, model, system, user, temperature, maxTokens, timeoutMs, jsonMode }) {
|
|
76
168
|
return await executeAIRequest(async () => {
|
|
77
169
|
const url = `${baseUrl}/chat/completions`;
|
|
78
170
|
|
|
@@ -94,6 +186,9 @@ async function callOpenAICompatibleAPI({ baseUrl, apiKey, model, system, user, t
|
|
|
94
186
|
if (temperature != null) {
|
|
95
187
|
body.temperature = temperature;
|
|
96
188
|
}
|
|
189
|
+
if (jsonMode) {
|
|
190
|
+
body.response_format = { type: "json_object" };
|
|
191
|
+
}
|
|
97
192
|
|
|
98
193
|
const response = await fetch(url, {
|
|
99
194
|
method: "POST",
|
|
@@ -132,15 +227,126 @@ async function callOpenAICompatibleAPI({ baseUrl, apiKey, model, system, user, t
|
|
|
132
227
|
});
|
|
133
228
|
}
|
|
134
229
|
|
|
230
|
+
/**
|
|
231
|
+
* Anthropic Messages API adapter.
|
|
232
|
+
*/
|
|
233
|
+
async function callAnthropicAPI({ baseUrl, apiKey, model, system, user, temperature, maxTokens, timeoutMs }) {
|
|
234
|
+
return await executeAIRequest(async () => {
|
|
235
|
+
const url = `${baseUrl}/v1/messages`;
|
|
236
|
+
|
|
237
|
+
const controller = new AbortController();
|
|
238
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
239
|
+
|
|
240
|
+
try {
|
|
241
|
+
const body = {
|
|
242
|
+
model: model || "claude-sonnet-4-20250514",
|
|
243
|
+
max_tokens: maxTokens,
|
|
244
|
+
system,
|
|
245
|
+
messages: [{ role: "user", content: user }],
|
|
246
|
+
};
|
|
247
|
+
if (temperature != null) {
|
|
248
|
+
body.temperature = temperature;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const response = await fetch(url, {
|
|
252
|
+
method: "POST",
|
|
253
|
+
headers: {
|
|
254
|
+
"Content-Type": "application/json",
|
|
255
|
+
"x-api-key": apiKey,
|
|
256
|
+
"anthropic-version": "2023-06-01",
|
|
257
|
+
},
|
|
258
|
+
body: JSON.stringify(body),
|
|
259
|
+
signal: controller.signal,
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
clearTimeout(timeoutId);
|
|
263
|
+
|
|
264
|
+
if (!response.ok) {
|
|
265
|
+
const errorText = await response.text();
|
|
266
|
+
throw new Error(`Anthropic API error (${response.status}): ${errorText}`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const data = await response.json();
|
|
270
|
+
|
|
271
|
+
if (!data.content || data.content.length === 0) {
|
|
272
|
+
throw new Error("No content returned from Anthropic API");
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return data.content[0].text;
|
|
276
|
+
} catch (error) {
|
|
277
|
+
clearTimeout(timeoutId);
|
|
278
|
+
if (error.name === "AbortError") {
|
|
279
|
+
throw new Error(`Request timeout after ${timeoutMs}ms`);
|
|
280
|
+
}
|
|
281
|
+
throw error;
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Google Gemini API adapter.
|
|
288
|
+
*/
|
|
289
|
+
async function callGoogleAPI({ baseUrl, apiKey, model, system, user, temperature, maxTokens, timeoutMs }) {
|
|
290
|
+
return await executeAIRequest(async () => {
|
|
291
|
+
const geminiModel = model || "gemini-pro";
|
|
292
|
+
const url = `${baseUrl}/v1beta/models/${geminiModel}:generateContent?key=${encodeURIComponent(apiKey)}`;
|
|
293
|
+
|
|
294
|
+
const controller = new AbortController();
|
|
295
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
296
|
+
|
|
297
|
+
try {
|
|
298
|
+
const body = {
|
|
299
|
+
contents: [{ parts: [{ text: `${system}\n\n${user}` }] }],
|
|
300
|
+
generationConfig: { maxOutputTokens: maxTokens },
|
|
301
|
+
};
|
|
302
|
+
if (temperature != null) {
|
|
303
|
+
body.generationConfig.temperature = temperature;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const response = await fetch(url, {
|
|
307
|
+
method: "POST",
|
|
308
|
+
headers: { "Content-Type": "application/json" },
|
|
309
|
+
body: JSON.stringify(body),
|
|
310
|
+
signal: controller.signal,
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
clearTimeout(timeoutId);
|
|
314
|
+
|
|
315
|
+
if (!response.ok) {
|
|
316
|
+
const errorText = await response.text();
|
|
317
|
+
throw new Error(`Google API error (${response.status}): ${errorText}`);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const data = await response.json();
|
|
321
|
+
|
|
322
|
+
if (!data.candidates || data.candidates.length === 0) {
|
|
323
|
+
throw new Error("No candidates returned from Google API");
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return data.candidates[0].content.parts[0].text;
|
|
327
|
+
} catch (error) {
|
|
328
|
+
clearTimeout(timeoutId);
|
|
329
|
+
if (error.name === "AbortError") {
|
|
330
|
+
throw new Error(`Request timeout after ${timeoutMs}ms`);
|
|
331
|
+
}
|
|
332
|
+
throw error;
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
135
337
|
export function isAIEnabled() {
|
|
136
338
|
return process.env.REPOLENS_AI_ENABLED === "true";
|
|
137
339
|
}
|
|
138
340
|
|
|
139
341
|
export function getAIConfig() {
|
|
342
|
+
const provider = process.env.REPOLENS_AI_PROVIDER || "openai_compatible";
|
|
343
|
+
const defaultModel = provider === "anthropic" ? "claude-sonnet-4-20250514"
|
|
344
|
+
: provider === "google" ? "gemini-pro"
|
|
345
|
+
: "gpt-5-mini";
|
|
140
346
|
return {
|
|
141
347
|
enabled: isAIEnabled(),
|
|
142
|
-
provider
|
|
143
|
-
model: process.env.REPOLENS_AI_MODEL ||
|
|
348
|
+
provider,
|
|
349
|
+
model: process.env.REPOLENS_AI_MODEL || defaultModel,
|
|
144
350
|
hasApiKey: !!process.env.REPOLENS_AI_API_KEY,
|
|
145
351
|
temperature: process.env.REPOLENS_AI_TEMPERATURE ? parseFloat(process.env.REPOLENS_AI_TEMPERATURE) : undefined,
|
|
146
352
|
maxTokens: parseInt(process.env.REPOLENS_AI_MAX_TOKENS || DEFAULT_MAX_TOKENS)
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// CODEOWNERS file parser
|
|
2
|
+
// Maps file paths to team/individual owners
|
|
3
|
+
|
|
4
|
+
import fs from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { info } from "../utils/logger.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Parse CODEOWNERS file and return ownership rules.
|
|
10
|
+
* Searches standard locations: CODEOWNERS, .github/CODEOWNERS, docs/CODEOWNERS
|
|
11
|
+
*/
|
|
12
|
+
export async function parseCodeowners(repoRoot) {
|
|
13
|
+
const locations = [
|
|
14
|
+
path.join(repoRoot, "CODEOWNERS"),
|
|
15
|
+
path.join(repoRoot, ".github", "CODEOWNERS"),
|
|
16
|
+
path.join(repoRoot, "docs", "CODEOWNERS"),
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
for (const loc of locations) {
|
|
20
|
+
try {
|
|
21
|
+
const content = await fs.readFile(loc, "utf8");
|
|
22
|
+
const rules = parseRules(content);
|
|
23
|
+
if (rules.length > 0) {
|
|
24
|
+
info(`CODEOWNERS loaded from ${path.relative(repoRoot, loc)} (${rules.length} rules)`);
|
|
25
|
+
return { found: true, file: path.relative(repoRoot, loc), rules };
|
|
26
|
+
}
|
|
27
|
+
} catch {
|
|
28
|
+
// File doesn't exist, try next
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return { found: false, file: null, rules: [] };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Parse CODEOWNERS content into patternโowners rules.
|
|
37
|
+
*/
|
|
38
|
+
function parseRules(content) {
|
|
39
|
+
const rules = [];
|
|
40
|
+
|
|
41
|
+
for (const line of content.split("\n")) {
|
|
42
|
+
const trimmed = line.trim();
|
|
43
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
44
|
+
|
|
45
|
+
const parts = trimmed.split(/\s+/);
|
|
46
|
+
if (parts.length < 2) continue;
|
|
47
|
+
|
|
48
|
+
const pattern = parts[0];
|
|
49
|
+
const owners = parts.slice(1).filter(o => o.startsWith("@") || o.includes("@"));
|
|
50
|
+
|
|
51
|
+
if (owners.length > 0) {
|
|
52
|
+
rules.push({ pattern, owners });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return rules;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Find owners for a given file path using CODEOWNERS rules.
|
|
61
|
+
* Rules are matched last-match-wins (same as GitHub behavior).
|
|
62
|
+
*/
|
|
63
|
+
export function findOwners(filePath, rules) {
|
|
64
|
+
let matched = [];
|
|
65
|
+
|
|
66
|
+
for (const rule of rules) {
|
|
67
|
+
if (matchPattern(filePath, rule.pattern)) {
|
|
68
|
+
matched = rule.owners;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return matched;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Match a file path against a CODEOWNERS pattern.
|
|
77
|
+
* Supports: *, **, directory patterns, exact matches.
|
|
78
|
+
*/
|
|
79
|
+
function matchPattern(filePath, pattern) {
|
|
80
|
+
const normalized = filePath.replace(/\\/g, "/");
|
|
81
|
+
|
|
82
|
+
// Remove leading slash for consistency
|
|
83
|
+
const cleanPattern = pattern.startsWith("/") ? pattern.slice(1) : pattern;
|
|
84
|
+
|
|
85
|
+
// Directory pattern (e.g., "src/")
|
|
86
|
+
if (cleanPattern.endsWith("/")) {
|
|
87
|
+
return normalized.startsWith(cleanPattern) || normalized.includes(`/${cleanPattern}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Convert glob to regex
|
|
91
|
+
let regex = cleanPattern
|
|
92
|
+
.replace(/\./g, "\\.")
|
|
93
|
+
.replace(/\*\*/g, "<<DOUBLESTAR>>")
|
|
94
|
+
.replace(/\*/g, "[^/]*")
|
|
95
|
+
.replace(/<<DOUBLESTAR>>/g, ".*");
|
|
96
|
+
|
|
97
|
+
// If pattern has no path separator, match anywhere in path
|
|
98
|
+
if (!cleanPattern.includes("/")) {
|
|
99
|
+
regex = `(^|/)${regex}($|/)`;
|
|
100
|
+
} else {
|
|
101
|
+
regex = `(^|/)${regex}$`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
return new RegExp(regex).test(normalized);
|
|
106
|
+
} catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Build an ownership summary for modules.
|
|
113
|
+
* Returns a map of modulePath โ owners[].
|
|
114
|
+
*/
|
|
115
|
+
export function buildOwnershipMap(modules, files, rules) {
|
|
116
|
+
if (!rules || rules.length === 0) return {};
|
|
117
|
+
|
|
118
|
+
const ownershipMap = {};
|
|
119
|
+
|
|
120
|
+
for (const mod of modules) {
|
|
121
|
+
const moduleFiles = files.filter(f => {
|
|
122
|
+
const normalized = f.replace(/\\/g, "/");
|
|
123
|
+
return normalized.startsWith(mod.key + "/") || normalized === mod.key;
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Find owners for representative files in this module
|
|
127
|
+
const ownerCounts = {};
|
|
128
|
+
for (const file of moduleFiles) {
|
|
129
|
+
const owners = findOwners(file, rules);
|
|
130
|
+
for (const owner of owners) {
|
|
131
|
+
ownerCounts[owner] = (ownerCounts[owner] || 0) + 1;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Primary owners are those who own the most files in this module
|
|
136
|
+
const sortedOwners = Object.entries(ownerCounts)
|
|
137
|
+
.sort((a, b) => b[1] - a[1])
|
|
138
|
+
.map(([owner]) => owner);
|
|
139
|
+
|
|
140
|
+
if (sortedOwners.length > 0) {
|
|
141
|
+
ownershipMap[mod.key] = sortedOwners;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return ownershipMap;
|
|
146
|
+
}
|
|
@@ -80,7 +80,17 @@ export function buildAIContext(scanResult, config) {
|
|
|
80
80
|
|
|
81
81
|
patterns,
|
|
82
82
|
|
|
83
|
-
repoRoots: config.module_roots || []
|
|
83
|
+
repoRoots: config.module_roots || [],
|
|
84
|
+
|
|
85
|
+
// Monorepo workspace metadata (if detected)
|
|
86
|
+
monorepo: scanResult.monorepo?.isMonorepo ? {
|
|
87
|
+
tool: scanResult.monorepo.tool,
|
|
88
|
+
packageCount: scanResult.monorepo.packages.length,
|
|
89
|
+
packages: scanResult.monorepo.packages.slice(0, 20).map(p => ({
|
|
90
|
+
name: p.name,
|
|
91
|
+
path: p.path,
|
|
92
|
+
})),
|
|
93
|
+
} : undefined,
|
|
84
94
|
};
|
|
85
95
|
}
|
|
86
96
|
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Monorepo workspace detection
|
|
2
|
+
// Detects npm/yarn workspaces, pnpm workspaces, and Lerna configurations
|
|
3
|
+
|
|
4
|
+
import fs from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { info } from "../utils/logger.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Detect monorepo workspaces in a repository.
|
|
10
|
+
* Returns { isMonorepo, tool, packages[] } where each package has { name, path, packageJson }.
|
|
11
|
+
*/
|
|
12
|
+
export async function detectMonorepo(repoRoot) {
|
|
13
|
+
const result = { isMonorepo: false, tool: null, packages: [] };
|
|
14
|
+
|
|
15
|
+
// 1. Check package.json workspaces (npm/yarn)
|
|
16
|
+
const npmWorkspaces = await detectNpmWorkspaces(repoRoot);
|
|
17
|
+
if (npmWorkspaces.length > 0) {
|
|
18
|
+
result.isMonorepo = true;
|
|
19
|
+
result.tool = "npm/yarn workspaces";
|
|
20
|
+
result.packages = npmWorkspaces;
|
|
21
|
+
info(`Monorepo detected: ${result.tool} with ${result.packages.length} packages`);
|
|
22
|
+
return result;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// 2. Check pnpm-workspace.yaml
|
|
26
|
+
const pnpmWorkspaces = await detectPnpmWorkspaces(repoRoot);
|
|
27
|
+
if (pnpmWorkspaces.length > 0) {
|
|
28
|
+
result.isMonorepo = true;
|
|
29
|
+
result.tool = "pnpm workspaces";
|
|
30
|
+
result.packages = pnpmWorkspaces;
|
|
31
|
+
info(`Monorepo detected: ${result.tool} with ${result.packages.length} packages`);
|
|
32
|
+
return result;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// 3. Check lerna.json
|
|
36
|
+
const lernaPackages = await detectLerna(repoRoot);
|
|
37
|
+
if (lernaPackages.length > 0) {
|
|
38
|
+
result.isMonorepo = true;
|
|
39
|
+
result.tool = "Lerna";
|
|
40
|
+
result.packages = lernaPackages;
|
|
41
|
+
info(`Monorepo detected: ${result.tool} with ${result.packages.length} packages`);
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function detectNpmWorkspaces(repoRoot) {
|
|
49
|
+
try {
|
|
50
|
+
const pkgPath = path.join(repoRoot, "package.json");
|
|
51
|
+
const raw = await fs.readFile(pkgPath, "utf8");
|
|
52
|
+
const pkg = JSON.parse(raw);
|
|
53
|
+
|
|
54
|
+
if (!pkg.workspaces) return [];
|
|
55
|
+
|
|
56
|
+
// workspaces can be an array or { packages: [...] }
|
|
57
|
+
const patterns = Array.isArray(pkg.workspaces)
|
|
58
|
+
? pkg.workspaces
|
|
59
|
+
: pkg.workspaces.packages || [];
|
|
60
|
+
|
|
61
|
+
return await resolveWorkspacePatterns(repoRoot, patterns);
|
|
62
|
+
} catch {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function detectPnpmWorkspaces(repoRoot) {
|
|
68
|
+
try {
|
|
69
|
+
const yamlPath = path.join(repoRoot, "pnpm-workspace.yaml");
|
|
70
|
+
const raw = await fs.readFile(yamlPath, "utf8");
|
|
71
|
+
|
|
72
|
+
// Simple YAML parsing for packages array (avoid adding js-yaml dependency for this)
|
|
73
|
+
const patterns = [];
|
|
74
|
+
let inPackages = false;
|
|
75
|
+
for (const line of raw.split("\n")) {
|
|
76
|
+
const trimmed = line.trim();
|
|
77
|
+
if (trimmed === "packages:") {
|
|
78
|
+
inPackages = true;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (inPackages) {
|
|
82
|
+
if (trimmed.startsWith("- ")) {
|
|
83
|
+
patterns.push(trimmed.slice(2).replace(/['"]/g, "").trim());
|
|
84
|
+
} else if (trimmed && !trimmed.startsWith("#")) {
|
|
85
|
+
break; // End of packages list
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return await resolveWorkspacePatterns(repoRoot, patterns);
|
|
91
|
+
} catch {
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function detectLerna(repoRoot) {
|
|
97
|
+
try {
|
|
98
|
+
const lernaPath = path.join(repoRoot, "lerna.json");
|
|
99
|
+
const raw = await fs.readFile(lernaPath, "utf8");
|
|
100
|
+
const lerna = JSON.parse(raw);
|
|
101
|
+
|
|
102
|
+
const patterns = lerna.packages || ["packages/*"];
|
|
103
|
+
return await resolveWorkspacePatterns(repoRoot, patterns);
|
|
104
|
+
} catch {
|
|
105
|
+
return [];
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Resolve workspace glob patterns to actual package directories.
|
|
111
|
+
*/
|
|
112
|
+
async function resolveWorkspacePatterns(repoRoot, patterns) {
|
|
113
|
+
const packages = [];
|
|
114
|
+
const seen = new Set();
|
|
115
|
+
|
|
116
|
+
for (const pattern of patterns) {
|
|
117
|
+
// Convert glob pattern to directory search
|
|
118
|
+
// e.g. "packages/*" โ list dirs in packages/
|
|
119
|
+
const basePath = pattern.replace(/\/?\*.*$/, "");
|
|
120
|
+
const searchDir = path.join(repoRoot, basePath);
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
const entries = await fs.readdir(searchDir, { withFileTypes: true });
|
|
124
|
+
for (const entry of entries) {
|
|
125
|
+
if (!entry.isDirectory()) continue;
|
|
126
|
+
|
|
127
|
+
const pkgDir = path.join(searchDir, entry.name);
|
|
128
|
+
const pkgJsonPath = path.join(pkgDir, "package.json");
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
const raw = await fs.readFile(pkgJsonPath, "utf8");
|
|
132
|
+
const pkg = JSON.parse(raw);
|
|
133
|
+
const relativePath = path.relative(repoRoot, pkgDir).replace(/\\/g, "/");
|
|
134
|
+
|
|
135
|
+
if (!seen.has(relativePath)) {
|
|
136
|
+
seen.add(relativePath);
|
|
137
|
+
packages.push({
|
|
138
|
+
name: pkg.name || entry.name,
|
|
139
|
+
path: relativePath,
|
|
140
|
+
version: pkg.version,
|
|
141
|
+
dependencies: Object.keys(pkg.dependencies || {}),
|
|
142
|
+
devDependencies: Object.keys(pkg.devDependencies || {}),
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
} catch {
|
|
146
|
+
// No package.json in this directory, skip
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
} catch {
|
|
150
|
+
// Directory doesn't exist, skip
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return packages;
|
|
155
|
+
}
|
package/src/core/scan.js
CHANGED
|
@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { info, warn } from "../utils/logger.js";
|
|
5
5
|
import { trackScan } from "../utils/telemetry.js";
|
|
6
|
+
import { detectMonorepo } from "../analyzers/monorepo-detector.js";
|
|
6
7
|
|
|
7
8
|
const norm = (p) => p.replace(/\\/g, "/");
|
|
8
9
|
|
|
@@ -403,6 +404,9 @@ export async function scanRepo(cfg) {
|
|
|
403
404
|
// Detect external API integrations
|
|
404
405
|
const externalApis = await detectExternalApis(files, repoRoot);
|
|
405
406
|
|
|
407
|
+
// Detect monorepo workspaces
|
|
408
|
+
const monorepo = await detectMonorepo(repoRoot);
|
|
409
|
+
|
|
406
410
|
const scanResult = {
|
|
407
411
|
filesCount: files.length,
|
|
408
412
|
modules,
|
|
@@ -410,6 +414,7 @@ export async function scanRepo(cfg) {
|
|
|
410
414
|
pages,
|
|
411
415
|
metadata,
|
|
412
416
|
externalApis,
|
|
417
|
+
monorepo,
|
|
413
418
|
_files: files
|
|
414
419
|
};
|
|
415
420
|
|
|
@@ -6,6 +6,7 @@ import { analyzeGraphQL } from "../analyzers/graphql-analyzer.js";
|
|
|
6
6
|
import { analyzeTypeScript } from "../analyzers/typescript-analyzer.js";
|
|
7
7
|
import { analyzeDependencyGraph } from "../analyzers/dependency-graph.js";
|
|
8
8
|
import { buildSnapshot, loadBaseline, saveBaseline, detectDrift } from "../analyzers/drift-detector.js";
|
|
9
|
+
import { parseCodeowners, buildOwnershipMap } from "../analyzers/codeowners.js";
|
|
9
10
|
import { getActiveDocuments } from "../ai/document-plan.js";
|
|
10
11
|
import {
|
|
11
12
|
generateExecutiveSummary,
|
|
@@ -53,6 +54,12 @@ export async function generateDocumentSet(scanResult, config, diffData = null, p
|
|
|
53
54
|
const driftResult = detectDrift(baseline, snapshot);
|
|
54
55
|
// Save current snapshot as new baseline
|
|
55
56
|
await saveBaseline(snapshot, outputDir);
|
|
57
|
+
|
|
58
|
+
// CODEOWNERS integration
|
|
59
|
+
const codeowners = await parseCodeowners(repoRoot);
|
|
60
|
+
const ownershipMap = codeowners.found
|
|
61
|
+
? buildOwnershipMap(scanResult.modules, scanFiles, codeowners.rules)
|
|
62
|
+
: {};
|
|
56
63
|
|
|
57
64
|
// Get active documents based on config
|
|
58
65
|
const activeDocuments = getActiveDocuments(config);
|
|
@@ -68,6 +75,8 @@ export async function generateDocumentSet(scanResult, config, diffData = null, p
|
|
|
68
75
|
typescript: tsResult.detected ? tsResult : undefined,
|
|
69
76
|
dependencyGraph: depGraph.stats,
|
|
70
77
|
drift: driftResult,
|
|
78
|
+
codeowners: codeowners.found ? { file: codeowners.file, ruleCount: codeowners.rules.length } : undefined,
|
|
79
|
+
ownershipMap: Object.keys(ownershipMap).length > 0 ? ownershipMap : undefined,
|
|
71
80
|
};
|
|
72
81
|
|
|
73
82
|
// Run afterScan hook
|
|
@@ -92,6 +101,7 @@ export async function generateDocumentSet(scanResult, config, diffData = null, p
|
|
|
92
101
|
tsResult,
|
|
93
102
|
depGraph,
|
|
94
103
|
driftResult,
|
|
104
|
+
ownershipMap,
|
|
95
105
|
pluginManager,
|
|
96
106
|
});
|
|
97
107
|
|
|
@@ -162,7 +172,7 @@ export async function generateDocumentSet(scanResult, config, diffData = null, p
|
|
|
162
172
|
|
|
163
173
|
async function generateDocument(docPlan, context) {
|
|
164
174
|
const { key } = docPlan;
|
|
165
|
-
const { scanResult, config, aiContext, moduleContext, flows, diffData, graphqlResult, tsResult, depGraph, driftResult, pluginManager } = context;
|
|
175
|
+
const { scanResult, config, aiContext, moduleContext, flows, diffData, graphqlResult, tsResult, depGraph, driftResult, ownershipMap, pluginManager } = context;
|
|
166
176
|
|
|
167
177
|
switch (key) {
|
|
168
178
|
case "executive_summary":
|
|
@@ -178,8 +188,8 @@ async function generateDocument(docPlan, context) {
|
|
|
178
188
|
return await generateArchitectureOverview(aiContext);
|
|
179
189
|
|
|
180
190
|
case "module_catalog":
|
|
181
|
-
// Hybrid: deterministic skeleton +
|
|
182
|
-
return renderModuleCatalogOriginal(config, scanResult);
|
|
191
|
+
// Hybrid: deterministic skeleton + ownership info
|
|
192
|
+
return renderModuleCatalogOriginal(config, scanResult, ownershipMap);
|
|
183
193
|
|
|
184
194
|
case "route_map":
|
|
185
195
|
// Hybrid: deterministic skeleton + AI enhancement (for now, just deterministic)
|
package/src/publishers/index.js
CHANGED
|
@@ -6,6 +6,7 @@ import { shouldPublishToNotion, shouldPublishToConfluence, shouldPublishToGitHub
|
|
|
6
6
|
import { info, warn } from "../utils/logger.js";
|
|
7
7
|
import { trackPublishing } from "../utils/telemetry.js";
|
|
8
8
|
import { collectMetrics } from "../utils/metrics.js";
|
|
9
|
+
import { loadDocCache, saveDocCache, filterChangedDocs, logCacheStats } from "../utils/doc-cache.js";
|
|
9
10
|
import {
|
|
10
11
|
sendDiscordNotification,
|
|
11
12
|
buildDocUpdateNotification,
|
|
@@ -24,6 +25,15 @@ export async function publishDocs(cfg, renderedPages, scanResult, pluginManager
|
|
|
24
25
|
let publishStatus = "success";
|
|
25
26
|
let notionUrl = null;
|
|
26
27
|
|
|
28
|
+
// --- Hash-based caching: skip unchanged documents ---
|
|
29
|
+
const cacheDir = path.join(process.cwd(), cfg.documentation?.output_dir || ".repolens");
|
|
30
|
+
const previousCache = await loadDocCache(cacheDir);
|
|
31
|
+
const { changedPages, unchangedKeys, newCache } = filterChangedDocs(renderedPages, previousCache);
|
|
32
|
+
logCacheStats(Object.keys(changedPages).length, unchangedKeys.length);
|
|
33
|
+
|
|
34
|
+
// Use changedPages for API publishers (Notion / Confluence / Wiki), full set for Markdown
|
|
35
|
+
const pagesForAPIs = Object.keys(changedPages).length > 0 ? changedPages : renderedPages;
|
|
36
|
+
|
|
27
37
|
// Always try Notion publishing if secrets are configured
|
|
28
38
|
if (publishers.includes("notion") || hasNotionSecrets()) {
|
|
29
39
|
if (!hasNotionSecrets()) {
|
|
@@ -32,7 +42,7 @@ export async function publishDocs(cfg, renderedPages, scanResult, pluginManager
|
|
|
32
42
|
} else if (shouldPublishToNotion(cfg, currentBranch)) {
|
|
33
43
|
info(`Publishing to Notion from branch: ${currentBranch}`);
|
|
34
44
|
try {
|
|
35
|
-
await publishToNotion(cfg,
|
|
45
|
+
await publishToNotion(cfg, pagesForAPIs);
|
|
36
46
|
publishedTo.push("notion");
|
|
37
47
|
// Build Notion URL if published
|
|
38
48
|
if (process.env.NOTION_PARENT_PAGE_ID) {
|
|
@@ -57,7 +67,7 @@ export async function publishDocs(cfg, renderedPages, scanResult, pluginManager
|
|
|
57
67
|
} else if (shouldPublishToConfluence(cfg, currentBranch)) {
|
|
58
68
|
info(`Publishing to Confluence from branch: ${currentBranch}`);
|
|
59
69
|
try {
|
|
60
|
-
await publishToConfluence(cfg,
|
|
70
|
+
await publishToConfluence(cfg, pagesForAPIs);
|
|
61
71
|
publishedTo.push("confluence");
|
|
62
72
|
} catch (err) {
|
|
63
73
|
publishStatus = "failure";
|
|
@@ -89,7 +99,7 @@ export async function publishDocs(cfg, renderedPages, scanResult, pluginManager
|
|
|
89
99
|
} else if (shouldPublishToGitHubWiki(cfg, currentBranch)) {
|
|
90
100
|
info(`Publishing to GitHub Wiki from branch: ${currentBranch}`);
|
|
91
101
|
try {
|
|
92
|
-
await publishToGitHubWiki(cfg,
|
|
102
|
+
await publishToGitHubWiki(cfg, pagesForAPIs);
|
|
93
103
|
publishedTo.push("github_wiki");
|
|
94
104
|
} catch (err) {
|
|
95
105
|
publishStatus = "failure";
|
|
@@ -119,6 +129,9 @@ export async function publishDocs(cfg, renderedPages, scanResult, pluginManager
|
|
|
119
129
|
}
|
|
120
130
|
}
|
|
121
131
|
|
|
132
|
+
// Save document hash cache for next run
|
|
133
|
+
await saveDocCache(cacheDir, newCache);
|
|
134
|
+
|
|
122
135
|
// Collect metrics and send Discord notification
|
|
123
136
|
try {
|
|
124
137
|
info("Collecting documentation metrics...");
|
package/src/renderers/render.js
CHANGED
|
@@ -76,6 +76,25 @@ export function renderSystemOverview(cfg, scan) {
|
|
|
76
76
|
lines.push(``);
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
// Monorepo workspace info
|
|
80
|
+
if (scan.monorepo?.isMonorepo && scan.monorepo.packages.length > 0) {
|
|
81
|
+
lines.push(
|
|
82
|
+
`## Monorepo Workspaces`,
|
|
83
|
+
``,
|
|
84
|
+
`This repository is organized as a **monorepo** using **${scan.monorepo.tool}** with **${scan.monorepo.packages.length} packages**.`,
|
|
85
|
+
``,
|
|
86
|
+
`| Package | Path | Version |`,
|
|
87
|
+
`|---------|------|---------|`
|
|
88
|
+
);
|
|
89
|
+
for (const pkg of scan.monorepo.packages.slice(0, 20)) {
|
|
90
|
+
lines.push(`| ${pkg.name} | \`${pkg.path}\` | ${pkg.version || "โ"} |`);
|
|
91
|
+
}
|
|
92
|
+
if (scan.monorepo.packages.length > 20) {
|
|
93
|
+
lines.push(`| ... | *${scan.monorepo.packages.length - 20} more packages* | |`);
|
|
94
|
+
}
|
|
95
|
+
lines.push(``);
|
|
96
|
+
}
|
|
97
|
+
|
|
79
98
|
lines.push(
|
|
80
99
|
`---`,
|
|
81
100
|
``,
|
|
@@ -114,7 +133,8 @@ function describeModule(key) {
|
|
|
114
133
|
return "Application module";
|
|
115
134
|
}
|
|
116
135
|
|
|
117
|
-
export function renderModuleCatalog(cfg, scan) {
|
|
136
|
+
export function renderModuleCatalog(cfg, scan, ownershipMap = {}) {
|
|
137
|
+
const hasOwnership = Object.keys(ownershipMap).length > 0;
|
|
118
138
|
const lines = [
|
|
119
139
|
`# Module Catalog`,
|
|
120
140
|
``,
|
|
@@ -136,14 +156,29 @@ export function renderModuleCatalog(cfg, scan) {
|
|
|
136
156
|
|
|
137
157
|
lines.push(
|
|
138
158
|
`## Module Inventory`,
|
|
139
|
-
|
|
140
|
-
`| Module | Files | Role |`,
|
|
141
|
-
`|--------|-------|------|`
|
|
159
|
+
``
|
|
142
160
|
);
|
|
143
161
|
|
|
162
|
+
if (hasOwnership) {
|
|
163
|
+
lines.push(
|
|
164
|
+
`| Module | Files | Role | Owners |`,
|
|
165
|
+
`|--------|-------|------|--------|`
|
|
166
|
+
);
|
|
167
|
+
} else {
|
|
168
|
+
lines.push(
|
|
169
|
+
`| Module | Files | Role |`,
|
|
170
|
+
`|--------|-------|------|`
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
144
174
|
for (const module of scan.modules.slice(0, 100)) {
|
|
145
175
|
const desc = describeModule(module.key);
|
|
146
|
-
|
|
176
|
+
const owners = ownershipMap[module.key];
|
|
177
|
+
if (hasOwnership) {
|
|
178
|
+
lines.push(`| \`${module.key}\` | ${module.fileCount} | ${desc} | ${owners ? owners.join(", ") : "โ"} |`);
|
|
179
|
+
} else {
|
|
180
|
+
lines.push(`| \`${module.key}\` | ${module.fileCount} | ${desc} |`);
|
|
181
|
+
}
|
|
147
182
|
}
|
|
148
183
|
|
|
149
184
|
if (scan.modules.length > 100) {
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hash-based document cache.
|
|
3
|
+
* Compares rendered content hashes to avoid redundant publisher API calls.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import fs from "node:fs/promises";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { info } from "./logger.js";
|
|
10
|
+
|
|
11
|
+
const CACHE_FILENAME = "doc-hashes.json";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Hash a string using SHA-256.
|
|
15
|
+
*/
|
|
16
|
+
function hashContent(content) {
|
|
17
|
+
return createHash("sha256").update(content, "utf8").digest("hex");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Load the previous cache from disk.
|
|
22
|
+
* @returns {Record<string, string>} Map of docKey โ contentHash
|
|
23
|
+
*/
|
|
24
|
+
export async function loadDocCache(cacheDir) {
|
|
25
|
+
try {
|
|
26
|
+
const raw = await fs.readFile(path.join(cacheDir, CACHE_FILENAME), "utf8");
|
|
27
|
+
return JSON.parse(raw);
|
|
28
|
+
} catch {
|
|
29
|
+
return {};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Save the cache to disk.
|
|
35
|
+
*/
|
|
36
|
+
export async function saveDocCache(cacheDir, cache) {
|
|
37
|
+
await fs.mkdir(cacheDir, { recursive: true });
|
|
38
|
+
await fs.writeFile(
|
|
39
|
+
path.join(cacheDir, CACHE_FILENAME),
|
|
40
|
+
JSON.stringify(cache, null, 2),
|
|
41
|
+
"utf8"
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Filter rendered pages to only those whose content has changed.
|
|
47
|
+
* Returns { changedPages, unchangedKeys, newCache }.
|
|
48
|
+
*/
|
|
49
|
+
export function filterChangedDocs(renderedPages, previousCache) {
|
|
50
|
+
const newCache = {};
|
|
51
|
+
const changedPages = {};
|
|
52
|
+
const unchangedKeys = [];
|
|
53
|
+
|
|
54
|
+
for (const [key, content] of Object.entries(renderedPages)) {
|
|
55
|
+
const hash = hashContent(content);
|
|
56
|
+
newCache[key] = hash;
|
|
57
|
+
|
|
58
|
+
if (previousCache[key] === hash) {
|
|
59
|
+
unchangedKeys.push(key);
|
|
60
|
+
} else {
|
|
61
|
+
changedPages[key] = content;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return { changedPages, unchangedKeys, newCache };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Log cache statistics.
|
|
70
|
+
*/
|
|
71
|
+
export function logCacheStats(changedCount, unchangedCount) {
|
|
72
|
+
const total = changedCount + unchangedCount;
|
|
73
|
+
if (unchangedCount > 0) {
|
|
74
|
+
info(`Cache: ${unchangedCount}/${total} documents unchanged, skipping. ${changedCount} to publish.`);
|
|
75
|
+
} else {
|
|
76
|
+
info(`Cache: All ${total} documents changed or new.`);
|
|
77
|
+
}
|
|
78
|
+
}
|