@chappibunny/repolens 1.15.3 → 1.15.5
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 +55 -0
- package/README.md +13 -9
- package/llms-install.md +2 -0
- package/package.json +2 -2
- package/src/ai/generate-sections.js +202 -80
- package/src/analyzers/context-builder.js +120 -63
- package/src/analyzers/dependency-graph.js +97 -2
- package/src/analyzers/domain-inference.js +55 -4
- package/src/analyzers/flow-inference.js +48 -9
- package/src/analyzers/maturity.js +151 -0
- package/src/analyzers/security-patterns.js +29 -3
- package/src/cli.js +21 -17
- package/src/core/scan.js +50 -4
- package/src/docs/generate-doc-set.js +28 -0
- package/src/init.js +2 -4
- package/src/mcp/tools.js +2 -1
- package/src/renderers/render.js +3 -3
- package/src/renderers/renderAnalysis.js +28 -9
- package/src/utils/ai-notice.js +20 -0
- package/src/utils/path-roles.js +58 -0
- package/src/utils/project-name.js +24 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Project maturity from observable signals (release version, history, tests, CI, docs, dependency health)
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
6
|
+
|
|
7
|
+
function exists(root, rel) {
|
|
8
|
+
try {
|
|
9
|
+
return fs.existsSync(path.join(root, rel));
|
|
10
|
+
} catch {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function readText(root, rel) {
|
|
16
|
+
try {
|
|
17
|
+
return fs.readFileSync(path.join(root, rel), "utf8");
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Declared project version from package.json, Cargo.toml, or pyproject.toml. */
|
|
24
|
+
export function detectDeclaredVersion(root) {
|
|
25
|
+
const pkg = readText(root, "package.json");
|
|
26
|
+
if (pkg) {
|
|
27
|
+
try {
|
|
28
|
+
const version = JSON.parse(pkg)?.version;
|
|
29
|
+
if (typeof version === "string") return version;
|
|
30
|
+
} catch {
|
|
31
|
+
// ignore invalid JSON
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
for (const file of ["Cargo.toml", "pyproject.toml"]) {
|
|
35
|
+
const text = readText(root, file);
|
|
36
|
+
const match = text && text.match(/^\s*version\s*=\s*["']([^"']+)["']/m);
|
|
37
|
+
if (match) return match[1];
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function majorVersion(version) {
|
|
43
|
+
const match = String(version || "").match(/^v?(\d+)\./);
|
|
44
|
+
return match ? Number(match[1]) : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function git(root, args) {
|
|
48
|
+
try {
|
|
49
|
+
return execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }).trim();
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Git facts, or null when `root` is not a git checkout. Shallow clones report `shallow: true`. */
|
|
56
|
+
export function detectGitHistory(root) {
|
|
57
|
+
if (git(root, ["rev-parse", "--is-inside-work-tree"]) !== "true") return null;
|
|
58
|
+
const shallow = git(root, ["rev-parse", "--is-shallow-repository"]) === "true";
|
|
59
|
+
const tagOutput = git(root, ["tag", "--list"]);
|
|
60
|
+
const tags = tagOutput ? tagOutput.split("\n").filter(Boolean).length : 0;
|
|
61
|
+
let firstCommitMs = null;
|
|
62
|
+
if (!shallow) {
|
|
63
|
+
const roots = git(root, ["log", "--max-parents=0", "--format=%ct", "HEAD"]);
|
|
64
|
+
const stamps = (roots || "").split("\n").map(Number).filter((n) => Number.isFinite(n) && n > 0);
|
|
65
|
+
if (stamps.length) firstCommitMs = Math.min(...stamps) * 1000;
|
|
66
|
+
}
|
|
67
|
+
return { shallow, tags, firstCommitMs };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function detectCI(root) {
|
|
71
|
+
if (exists(root, ".github/workflows")) {
|
|
72
|
+
try {
|
|
73
|
+
if (fs.readdirSync(path.join(root, ".github/workflows")).some((f) => /\.ya?ml$/.test(f))) return true;
|
|
74
|
+
} catch {
|
|
75
|
+
// fall through
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return [".gitlab-ci.yml", ".travis.yml", "appveyor.yml", ".circleci/config.yml", "azure-pipelines.yml", "Jenkinsfile", ".drone.yml", "bitbucket-pipelines.yml"]
|
|
79
|
+
.some((file) => exists(root, file));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function detectDocs(root) {
|
|
83
|
+
const hasReadme = ["README.md", "README.rst", "README.txt", "README"].some((f) => exists(root, f));
|
|
84
|
+
const hasDocsDir = ["docs", "doc"].some((d) => exists(root, d));
|
|
85
|
+
const hasContributing = exists(root, "CONTRIBUTING.md");
|
|
86
|
+
return hasReadme && (hasDocsDir || hasContributing);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Build maturity signals. A signal with `met: null` could not be measured
|
|
91
|
+
* (for example commit age in a shallow clone) and is left out of the total.
|
|
92
|
+
*/
|
|
93
|
+
export function collectMaturitySignals(root, { hasTests = false, cycles = null, now = Date.now(), version, git: gitInfo } = {}) {
|
|
94
|
+
const declared = version === undefined ? detectDeclaredVersion(root) : version;
|
|
95
|
+
const major = majorVersion(declared);
|
|
96
|
+
const history = gitInfo === undefined ? detectGitHistory(root) : gitInfo;
|
|
97
|
+
|
|
98
|
+
const signals = [];
|
|
99
|
+
signals.push({
|
|
100
|
+
key: "version",
|
|
101
|
+
label: "Stable release version",
|
|
102
|
+
met: major === null ? null : major >= 1,
|
|
103
|
+
detail: declared ? `v${declared}` : "no version declared",
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const tags = history?.tags ?? 0;
|
|
107
|
+
signals.push({
|
|
108
|
+
key: "releases",
|
|
109
|
+
label: "Release history",
|
|
110
|
+
met: major === null && !history ? null : tags >= 5 || (major !== null && major >= 5),
|
|
111
|
+
detail: tags ? `${tags} tags` : major !== null ? `major version ${major}` : "no tags",
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
let ageMet = null;
|
|
115
|
+
let ageDetail = history?.shallow ? "shallow clone, history unavailable" : "no git history";
|
|
116
|
+
if (history?.firstCommitMs) {
|
|
117
|
+
const years = (now - history.firstCommitMs) / (365.25 * 24 * 3600 * 1000);
|
|
118
|
+
ageMet = years >= 1;
|
|
119
|
+
ageDetail = `${years.toFixed(1)} years of history`;
|
|
120
|
+
}
|
|
121
|
+
signals.push({ key: "history", label: "Commit history over a year", met: ageMet, detail: ageDetail });
|
|
122
|
+
|
|
123
|
+
signals.push({ key: "tests", label: "Automated tests", met: Boolean(hasTests), detail: hasTests ? "test framework detected" : "none detected" });
|
|
124
|
+
const ci = detectCI(root);
|
|
125
|
+
signals.push({ key: "ci", label: "Continuous integration", met: ci, detail: ci ? "CI config found" : "none found" });
|
|
126
|
+
const docs = detectDocs(root);
|
|
127
|
+
signals.push({ key: "docs", label: "Documentation", met: docs, detail: docs ? "README plus docs" : "README only or missing" });
|
|
128
|
+
signals.push({
|
|
129
|
+
key: "deps",
|
|
130
|
+
label: "No circular dependencies",
|
|
131
|
+
met: cycles === null || cycles === undefined ? null : cycles === 0,
|
|
132
|
+
detail: cycles ? `${cycles} cycle${cycles === 1 ? "" : "s"}` : "none",
|
|
133
|
+
});
|
|
134
|
+
return signals;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function scoreMaturity(signals) {
|
|
138
|
+
const measured = signals.filter((s) => s.met !== null);
|
|
139
|
+
const score = measured.filter((s) => s.met).length;
|
|
140
|
+
const max = measured.length;
|
|
141
|
+
const ratio = max ? score / max : 0;
|
|
142
|
+
const declaredVersion = signals.find((s) => s.key === "version");
|
|
143
|
+
let label = ratio >= 0.7 ? "Production-grade" : ratio >= 0.45 ? "Well-structured" : "Early-stage";
|
|
144
|
+
// A project with a stable major version and several release signals is never "early-stage".
|
|
145
|
+
if (label === "Early-stage" && declaredVersion?.met && score >= 3) label = "Well-structured";
|
|
146
|
+
return { score, max, label, signals };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function detectMaturity(root, options = {}) {
|
|
150
|
+
return scoreMaturity(collectMaturitySignals(root, options));
|
|
151
|
+
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import fs from "node:fs/promises";
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import { info, warn } from "../utils/logger.js";
|
|
8
|
+
import { isBuildOrExamplePath } from "../utils/path-roles.js";
|
|
8
9
|
|
|
9
10
|
const JS_EXTENSIONS = [".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx"];
|
|
10
11
|
|
|
@@ -205,6 +206,27 @@ const SKIP_PATTERNS = [
|
|
|
205
206
|
/\.repolens\//,
|
|
206
207
|
];
|
|
207
208
|
|
|
209
|
+
const HTML_ASSIGNMENT_IDS = new Set(["innerhtml-assignment", "outerhtml-assignment"]);
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Is the right-hand side of an innerHTML/outerHTML assignment a fixed string?
|
|
213
|
+
* `el.innerHTML = ''` clears an element and `el.innerHTML = '<b>ok</b>'` writes a constant.
|
|
214
|
+
* Neither can carry injected markup, so neither is an XSS finding. A template literal
|
|
215
|
+
* with `${...}` or any concatenation still is.
|
|
216
|
+
*/
|
|
217
|
+
export function isStaticHtmlAssignment(restOfSource) {
|
|
218
|
+
const rest = String(restOfSource || "");
|
|
219
|
+
const quoted = rest.match(/^\s*(['"])((?:(?!\1)[^\\\n]|\\.)*)\1\s*(?=[;,)}\n]|$)/);
|
|
220
|
+
if (quoted) return true;
|
|
221
|
+
const template = rest.match(/^\s*`([^`]*)`\s*(?=[;,)}\n]|$)/);
|
|
222
|
+
if (template && !template[1].includes("${")) return true;
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Build scripts and example pages run on a maintainer's machine or in a demo page. They are
|
|
227
|
+
// listed, but never as high severity.
|
|
228
|
+
const SEVERITY_CAPPED_IDS_EXEMPT = new Set(["hardcoded-password"]);
|
|
229
|
+
|
|
208
230
|
function shouldSkipFile(filePath) {
|
|
209
231
|
return SKIP_PATTERNS.some(pattern => pattern.test(filePath));
|
|
210
232
|
}
|
|
@@ -255,7 +277,11 @@ export async function analyzeSecurityPatterns(files, repoRoot) {
|
|
|
255
277
|
|
|
256
278
|
while ((match = regex.exec(stripped)) !== null) {
|
|
257
279
|
// Get line number
|
|
280
|
+
if (HTML_ASSIGNMENT_IDS.has(pattern.id) && isStaticHtmlAssignment(stripped.slice(match.index + match[0].length))) continue;
|
|
281
|
+
|
|
258
282
|
const lineNumber = stripped.slice(0, match.index).split("\n").length;
|
|
283
|
+
const capped = isBuildOrExamplePath(file) && !SEVERITY_CAPPED_IDS_EXEMPT.has(pattern.id);
|
|
284
|
+
const severity = capped ? "low" : pattern.severity;
|
|
259
285
|
|
|
260
286
|
// Get the surrounding code snippet
|
|
261
287
|
const lines = stripped.split("\n");
|
|
@@ -267,8 +293,8 @@ export async function analyzeSecurityPatterns(files, repoRoot) {
|
|
|
267
293
|
result.findings.push({
|
|
268
294
|
id: pattern.id,
|
|
269
295
|
category: pattern.category,
|
|
270
|
-
name: pattern.name,
|
|
271
|
-
severity
|
|
296
|
+
name: capped ? `${pattern.name} (build/example code)` : pattern.name,
|
|
297
|
+
severity,
|
|
272
298
|
file,
|
|
273
299
|
line: lineNumber,
|
|
274
300
|
snippet: snippet.length > 200 ? snippet.slice(0, 200) + "..." : snippet,
|
|
@@ -276,7 +302,7 @@ export async function analyzeSecurityPatterns(files, repoRoot) {
|
|
|
276
302
|
cwe: pattern.cwe,
|
|
277
303
|
});
|
|
278
304
|
|
|
279
|
-
result.bySeverity[
|
|
305
|
+
result.bySeverity[severity]++;
|
|
280
306
|
result.filesWithFindings.add(file);
|
|
281
307
|
|
|
282
308
|
if (!result.byCategory[pattern.category]) {
|
package/src/cli.js
CHANGED
|
@@ -24,6 +24,8 @@ import { runWatch } from "./watch.js";
|
|
|
24
24
|
import { info, error, warn, fmt } from "./utils/logger.js";
|
|
25
25
|
import { formatError } from "./utils/errors.js";
|
|
26
26
|
import { checkForUpdates } from "./utils/update-check.js";
|
|
27
|
+
import { detectProjectName } from "./utils/project-name.js";
|
|
28
|
+
import { aiUpgradeSections } from "./utils/ai-notice.js";
|
|
27
29
|
import { generateDocumentSet } from "./docs/generate-doc-set.js";
|
|
28
30
|
import { writeDocumentSet } from "./docs/write-doc-set.js";
|
|
29
31
|
import { loadPlugins } from "./plugins/loader.js";
|
|
@@ -139,21 +141,23 @@ async function promptForAI(cfg) {
|
|
|
139
141
|
/**
|
|
140
142
|
* Show the post-generation AI notice for users without GITHUB_TOKEN
|
|
141
143
|
*/
|
|
142
|
-
function showPostGenerationAINotice() {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
info(
|
|
148
|
-
info(`${fmt.cyan("│")}
|
|
149
|
-
info(
|
|
150
|
-
info(
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
info(
|
|
155
|
-
info(`${fmt.cyan("│")}
|
|
156
|
-
info(
|
|
144
|
+
function showPostGenerationAINotice(documents) {
|
|
145
|
+
const sections = aiUpgradeSections(documents);
|
|
146
|
+
if (sections.length === 0) return;
|
|
147
|
+
const bar = "─".repeat(66);
|
|
148
|
+
const row = (text) => `${fmt.cyan("│")}${text.padEnd(66)}${fmt.cyan("│")}`;
|
|
149
|
+
info(`\n${fmt.cyan(`┌${bar}┐`)}`);
|
|
150
|
+
info(`${fmt.cyan("│")} ${fmt.boldYellow("✨ Unlock AI-Enhanced Documentation")}${" ".repeat(66 - 36)}${fmt.cyan("│")}`);
|
|
151
|
+
info(fmt.cyan(`├${bar}┤`));
|
|
152
|
+
info(row(" These sections used rule-based text. AI can rewrite them as:"));
|
|
153
|
+
for (const section of sections) {
|
|
154
|
+
info(row(` • ${section.title} — ${section.ai}`));
|
|
155
|
+
}
|
|
156
|
+
info(row(""));
|
|
157
|
+
info(`${fmt.cyan("│")} ${fmt.boldGreen("🆓 Enable for FREE with GitHub Models:")}${" ".repeat(66 - 39)}${fmt.cyan("│")}`);
|
|
158
|
+
info(row(" Run: repolens init → the wizard will guide you through setup"));
|
|
159
|
+
info(row(" Or preview: export GITHUB_TOKEN=<your-token> && repolens demo"));
|
|
160
|
+
info(fmt.cyan(`└${bar}┘`));
|
|
157
161
|
}
|
|
158
162
|
|
|
159
163
|
function printPerformanceSummary() {
|
|
@@ -597,7 +601,7 @@ async function main() {
|
|
|
597
601
|
: "No .repolens.yml found — using multi-language scan patterns");
|
|
598
602
|
cfg = {
|
|
599
603
|
configVersion: 1,
|
|
600
|
-
project: { name:
|
|
604
|
+
project: { name: detectProjectName(targetDir) },
|
|
601
605
|
publishers: ["markdown"],
|
|
602
606
|
scan: {
|
|
603
607
|
include: detected.include,
|
|
@@ -655,7 +659,7 @@ async function main() {
|
|
|
655
659
|
info(" To keep AI enabled permanently, run: repolens init");
|
|
656
660
|
} else if (aiResult.noToken) {
|
|
657
661
|
// No GITHUB_TOKEN - show instructions
|
|
658
|
-
showPostGenerationAINotice();
|
|
662
|
+
showPostGenerationAINotice(docSet.documents);
|
|
659
663
|
}
|
|
660
664
|
|
|
661
665
|
printPerformanceSummary();
|
package/src/core/scan.js
CHANGED
|
@@ -5,6 +5,7 @@ import { info, warn } from "../utils/logger.js";
|
|
|
5
5
|
import { trackScan } from "../utils/telemetry.js";
|
|
6
6
|
import { detectMonorepo } from "../analyzers/monorepo-detector.js";
|
|
7
7
|
import { isInsideRoot } from "../utils/safe-path.js";
|
|
8
|
+
import { isTestPath } from "../utils/path-roles.js";
|
|
8
9
|
|
|
9
10
|
const norm = (p) => p.replace(/\\/g, "/");
|
|
10
11
|
|
|
@@ -101,6 +102,9 @@ function moduleKeyForFile(file, moduleRoots) {
|
|
|
101
102
|
if (normalized === root) return root;
|
|
102
103
|
if (normalized.startsWith(`${root}/`)) {
|
|
103
104
|
const remainder = normalized.slice(root.length + 1);
|
|
105
|
+
// A file directly under the root belongs to the root itself. Only a sub-directory
|
|
106
|
+
// is its own module, so a flat `src/` is one module and not one module per file.
|
|
107
|
+
if (!remainder.includes("/")) return root;
|
|
104
108
|
const nextSegment = remainder.split("/")[0];
|
|
105
109
|
return nextSegment ? `${root}/${nextSegment}` : root;
|
|
106
110
|
}
|
|
@@ -149,6 +153,26 @@ function routePathFromFile(file) {
|
|
|
149
153
|
return file;
|
|
150
154
|
}
|
|
151
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Source files a package.json names as its public entry: `main`, `module`, `types`, and every
|
|
158
|
+
* string under `exports`. Paths are repo-relative with no leading `./`.
|
|
159
|
+
*/
|
|
160
|
+
function packageEntryFiles(pkg) {
|
|
161
|
+
const found = new Set();
|
|
162
|
+
const add = (value) => {
|
|
163
|
+
if (typeof value !== "string" || !/\.(c|m)?[jt]sx?$/.test(value) || value.endsWith(".d.ts")) return;
|
|
164
|
+
found.add(value.replace(/^\.\//, ""));
|
|
165
|
+
};
|
|
166
|
+
const walk = (value) => {
|
|
167
|
+
if (typeof value === "string") add(value);
|
|
168
|
+
else if (value && typeof value === "object") Object.values(value).forEach(walk);
|
|
169
|
+
};
|
|
170
|
+
add(pkg.main);
|
|
171
|
+
add(pkg.module);
|
|
172
|
+
walk(pkg.exports);
|
|
173
|
+
return [...found];
|
|
174
|
+
}
|
|
175
|
+
|
|
152
176
|
/**
|
|
153
177
|
* Detect languages from file extensions
|
|
154
178
|
*/
|
|
@@ -204,9 +228,12 @@ function detectLanguagesFromFiles(files) {
|
|
|
204
228
|
|
|
205
229
|
for (const file of files) {
|
|
206
230
|
const ext = path.extname(file).toLowerCase();
|
|
207
|
-
if (extensionMap[ext])
|
|
208
|
-
|
|
209
|
-
|
|
231
|
+
if (!extensionMap[ext]) continue;
|
|
232
|
+
// Type declarations (`index.d.ts`) and type tests describe a JavaScript library's
|
|
233
|
+
// types. They do not make the library a TypeScript project.
|
|
234
|
+
if (ext === ".ts" && /\.d\.ts$/i.test(file)) continue;
|
|
235
|
+
if (extensionMap[ext] === "TypeScript" && isTestPath(file)) continue;
|
|
236
|
+
languages.add(extensionMap[ext]);
|
|
210
237
|
}
|
|
211
238
|
|
|
212
239
|
return languages;
|
|
@@ -268,7 +295,9 @@ async function extractRepoMetadata(repoRoot, files = []) {
|
|
|
268
295
|
if (allDeps["parcel"]) metadata.buildTools.push("Parcel");
|
|
269
296
|
|
|
270
297
|
// Detect TypeScript from dependencies (supplements file detection)
|
|
271
|
-
|
|
298
|
+
// A `typescript` devDependency alone does not make the source TypeScript: plain JavaScript
|
|
299
|
+
// libraries use it to check their `.d.ts` files. Trust it only when no files were scanned.
|
|
300
|
+
if (allDeps["typescript"] && files.length === 0) metadata.languages.add("TypeScript");
|
|
272
301
|
|
|
273
302
|
// Detect Node.js runtime indicators
|
|
274
303
|
const hasNodeEngines = pkg.engines && pkg.engines.node;
|
|
@@ -279,6 +308,23 @@ async function extractRepoMetadata(repoRoot, files = []) {
|
|
|
279
308
|
if (hasNodeEngines || hasBin || hasNodeDeps || pkg.type === "module") {
|
|
280
309
|
metadata.languages.add("Node.js");
|
|
281
310
|
}
|
|
311
|
+
|
|
312
|
+
// A package with a library entry point (main, module, exports, types) is a library even
|
|
313
|
+
// when it also ships a `bin/` helper. Only a package with a bin and no library entry is a CLI.
|
|
314
|
+
// When it has both, the description decides: "Library that parses..." is a library,
|
|
315
|
+
// while a package that calls itself a CLI keeps that label.
|
|
316
|
+
const hasLibraryEntry = Boolean(pkg.main || pkg.module || pkg.exports || pkg.types || pkg.typings || pkg.browser);
|
|
317
|
+
const isShippedApp = Boolean(allDeps["electron"] || allDeps["next"]);
|
|
318
|
+
metadata.packageEntryFiles = packageEntryFiles(pkg);
|
|
319
|
+
if (hasLibraryEntry && !isShippedApp) {
|
|
320
|
+
const description = String(pkg.description || "");
|
|
321
|
+
const keywords = Array.isArray(pkg.keywords) ? pkg.keywords.join(" ") : "";
|
|
322
|
+
const saysLibrary = /\b(library|sdk|parser|toolkit|framework)\b/i.test(description);
|
|
323
|
+
const saysCli = /\bcli\b|command[- ]line/i.test(`${description} ${keywords}`);
|
|
324
|
+
metadata.projectKind = hasBin && !saysLibrary && saysCli ? "cli" : "library";
|
|
325
|
+
} else if (hasBin && !hasLibraryEntry) {
|
|
326
|
+
metadata.projectKind = "cli";
|
|
327
|
+
}
|
|
282
328
|
} catch {
|
|
283
329
|
// No package.json or invalid JSON
|
|
284
330
|
}
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
} from "../renderers/renderAnalysis.js";
|
|
35
35
|
import { info, warn } from "../utils/logger.js";
|
|
36
36
|
import path from "node:path";
|
|
37
|
+
import { detectMaturity } from "../analyzers/maturity.js";
|
|
37
38
|
import { applyBrandingFooter } from "./branding.js";
|
|
38
39
|
|
|
39
40
|
export async function generateDocumentSet(scanResult, config, diffData = null, pluginManager = null) {
|
|
@@ -79,6 +80,12 @@ export async function generateDocumentSet(scanResult, config, diffData = null, p
|
|
|
79
80
|
aiContext = buildAIContext(scanResult, config, depGraph);
|
|
80
81
|
moduleContext = buildModuleContext(scanResult.modules, config, depGraph);
|
|
81
82
|
flows = inferDataFlows(scanResult, config, depGraph);
|
|
83
|
+
try {
|
|
84
|
+
aiContext.maturity = detectMaturity(repoRoot, {
|
|
85
|
+
hasTests: (aiContext.techStack?.testFrameworks || []).length > 0 || scanResult.modules.some((m) => /(^|\/)(tests?|__tests__|spec)(\/|$)/i.test(m.key)),
|
|
86
|
+
cycles: depGraph?.stats?.cycles ?? null,
|
|
87
|
+
});
|
|
88
|
+
} catch (e) { warn(`Maturity detection failed: ${e.message}`); }
|
|
82
89
|
|
|
83
90
|
// CODEOWNERS integration
|
|
84
91
|
const codeowners = await parseCodeowners(repoRoot);
|
|
@@ -123,6 +130,11 @@ export async function generateDocumentSet(scanResult, config, diffData = null, p
|
|
|
123
130
|
|
|
124
131
|
for (const docPlan of activeDocuments) {
|
|
125
132
|
docIndex++;
|
|
133
|
+
const emptyReason = emptyDocumentReason(docPlan, { config, scanResult, graphqlResult });
|
|
134
|
+
if (emptyReason) {
|
|
135
|
+
info(`[${docIndex}/${totalDocs}] Skipping ${docPlan.key}: ${emptyReason}`);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
126
138
|
info(`[${docIndex}/${totalDocs}] Generating ${docPlan.key}...`);
|
|
127
139
|
let content = null;
|
|
128
140
|
|
|
@@ -230,6 +242,22 @@ export async function generateDocumentSet(scanResult, config, diffData = null, p
|
|
|
230
242
|
};
|
|
231
243
|
}
|
|
232
244
|
|
|
245
|
+
/**
|
|
246
|
+
* Documents that would only say "nothing found" are not written. A library has no GraphQL
|
|
247
|
+
* schema and no routes, so those two pages would be noise. A document the config lists
|
|
248
|
+
* explicitly in `documentation.sections` is always written.
|
|
249
|
+
*/
|
|
250
|
+
export function emptyDocumentReason(docPlan, { config, scanResult, graphqlResult }) {
|
|
251
|
+
if (config?.documentation?.sections?.includes(docPlan.key)) return null;
|
|
252
|
+
if (docPlan.key === "graphql_schema" && !graphqlResult?.detected) {
|
|
253
|
+
return "no GraphQL schema detected";
|
|
254
|
+
}
|
|
255
|
+
if (docPlan.key === "route_map" && !(scanResult?.pages?.length) && !(scanResult?.api?.length)) {
|
|
256
|
+
return "no pages or API routes detected";
|
|
257
|
+
}
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
|
|
233
261
|
async function generateDocument(docPlan, context) {
|
|
234
262
|
const { key } = docPlan;
|
|
235
263
|
const { scanResult, config, aiContext, moduleContext, flows, diffData, graphqlResult, tsResult, jsdocResult, depGraph, driftResult, securityResult, codeHealthResult, ownershipMap, pluginManager } = context;
|
package/src/init.js
CHANGED
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { createInterface } from "node:readline/promises";
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
5
|
import { info, warn } from "./utils/logger.js";
|
|
6
|
+
import { detectProjectName } from "./utils/project-name.js";
|
|
6
7
|
import { isNonInteractiveEnvironment } from "./utils/interactive.js";
|
|
7
8
|
import { detectRepoLanguages } from "./core/languages.js";
|
|
8
9
|
import { repolensCommand } from "./utils/invocation.js";
|
|
@@ -814,9 +815,6 @@ function buildRepoLensConfig(projectName, detected, options = {}) {
|
|
|
814
815
|
return lines.join("\n");
|
|
815
816
|
}
|
|
816
817
|
|
|
817
|
-
function detectProjectName(repoRoot) {
|
|
818
|
-
return path.basename(repoRoot) || "my-project";
|
|
819
|
-
}
|
|
820
818
|
|
|
821
819
|
async function promptNotionCredentials() {
|
|
822
820
|
if (isNonInteractiveEnvironment()) {
|
|
@@ -914,7 +912,7 @@ async function runInteractiveWizard(repoRoot) {
|
|
|
914
912
|
info("Press Enter to accept defaults shown in parentheses.\n");
|
|
915
913
|
|
|
916
914
|
// 1. Project name
|
|
917
|
-
const defaultName =
|
|
915
|
+
const defaultName = detectProjectName(repoRoot);
|
|
918
916
|
const projectName = (await ask(`📦 Project name (${defaultName}): `)).trim() || defaultName;
|
|
919
917
|
|
|
920
918
|
// 2. Publishers
|
package/src/mcp/tools.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { loadConfig } from "../core/config.js";
|
|
4
|
+
import { detectProjectName } from "../utils/project-name.js";
|
|
4
5
|
import { scanRepo } from "../core/scan.js";
|
|
5
6
|
import { getGitDiff, isSafeGitRef } from "../core/diff.js";
|
|
6
7
|
import { analyzeDependencyGraph } from "../analyzers/dependency-graph.js";
|
|
@@ -41,7 +42,7 @@ function projectRootFromArgs(args) {
|
|
|
41
42
|
function defaultConfig(root) {
|
|
42
43
|
return {
|
|
43
44
|
configVersion: 1,
|
|
44
|
-
project: { name:
|
|
45
|
+
project: { name: detectProjectName(root, "repository") },
|
|
45
46
|
publishers: ["markdown"],
|
|
46
47
|
scan: {
|
|
47
48
|
include: ["**/*.{js,ts,jsx,tsx,mjs,cjs,py,go,rs,java,rb,php,cs,swift,kt}"],
|
package/src/renderers/render.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { computeModuleLevelMetrics, describeModuleDepRole } from "../analyzers/context-builder.js";
|
|
1
|
+
import { computeModuleLevelMetrics, describeModuleDepRole, isCliProject, isLibraryProject } from "../analyzers/context-builder.js";
|
|
2
2
|
import { moduleEvidence, pathHasKeyword } from "../analyzers/domain-inference.js";
|
|
3
3
|
|
|
4
4
|
export function renderSystemOverview(cfg, scan, depGraph = null) {
|
|
@@ -447,8 +447,8 @@ export function renderRouteMap(cfg, scan, aiContext = null) {
|
|
|
447
447
|
if (!scan.pages?.length && !scan.api?.length) {
|
|
448
448
|
// Determine project type for context-aware messaging
|
|
449
449
|
const patterns = aiContext?.patterns || [];
|
|
450
|
-
const isCLI =
|
|
451
|
-
const isLibrary = patterns.some(p => p.toLowerCase().includes("library") || p.toLowerCase().includes("shared"));
|
|
450
|
+
const isCLI = isCliProject(aiContext);
|
|
451
|
+
const isLibrary = isLibraryProject(aiContext) || patterns.some(p => p.toLowerCase().includes("library") || p.toLowerCase().includes("shared"));
|
|
452
452
|
const techStack = aiContext?.techStack || {};
|
|
453
453
|
const hasWebFramework = (techStack.frameworks || []).some(f =>
|
|
454
454
|
/next|react|vue|angular|express|fastify|hono|koa|django|flask|rails|spring/i.test(f)
|
|
@@ -343,7 +343,11 @@ export function renderDependencyGraph(depResult) {
|
|
|
343
343
|
lines.push(`|--------|-------|`);
|
|
344
344
|
lines.push(`| Source files | ${s.totalFiles} |`);
|
|
345
345
|
lines.push(`| Import edges | ${s.totalEdges} |`);
|
|
346
|
-
|
|
346
|
+
if (Array.isArray(depResult.externalDev) && depResult.externalDev.length > 0) {
|
|
347
|
+
lines.push(`| External packages | ${s.externalDeps} (${depResult.externalRuntime.length} runtime, ${depResult.externalDev.length} dev/test tooling) |`);
|
|
348
|
+
} else {
|
|
349
|
+
lines.push(`| External packages | ${s.externalDeps} |`);
|
|
350
|
+
}
|
|
347
351
|
lines.push(`| Circular dependencies | ${s.cycles} |`);
|
|
348
352
|
lines.push(`| Orphan files (no imports or importers) | ${s.orphanFiles} |`);
|
|
349
353
|
lines.push("");
|
|
@@ -352,7 +356,7 @@ export function renderDependencyGraph(depResult) {
|
|
|
352
356
|
if (s.hubs.length > 0) {
|
|
353
357
|
lines.push("## Hub Modules");
|
|
354
358
|
lines.push("");
|
|
355
|
-
lines.push("These are the most-imported modules
|
|
359
|
+
lines.push("These are the most-imported product modules (tests, examples, and build scripts are not counted). Changes to hub modules have the widest blast radius and should be reviewed carefully:");
|
|
356
360
|
lines.push("");
|
|
357
361
|
lines.push("| Module | Imported By |");
|
|
358
362
|
lines.push("|--------|-------------|");
|
|
@@ -384,14 +388,29 @@ export function renderDependencyGraph(depResult) {
|
|
|
384
388
|
lines.push("");
|
|
385
389
|
lines.push(`The codebase imports **${depResult.externalDeps.length}** external package${depResult.externalDeps.length === 1 ? "" : "s"}. These are third-party modules (not the language standard library):`);
|
|
386
390
|
lines.push("");
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
391
|
+
const split = Array.isArray(depResult.externalDev) && depResult.externalDev.length > 0;
|
|
392
|
+
if (split) {
|
|
393
|
+
const renderTable = (title, names) => {
|
|
394
|
+
if (names.length === 0) return;
|
|
395
|
+
lines.push(`### ${title}`);
|
|
396
|
+
lines.push("");
|
|
397
|
+
lines.push("| Package |");
|
|
398
|
+
lines.push("|---------|");
|
|
399
|
+
for (const dep of [...names].sort()) lines.push(`| \`${dep}\` |`);
|
|
400
|
+
lines.push("");
|
|
401
|
+
};
|
|
402
|
+
renderTable(`Runtime (${depResult.externalRuntime.length})`, depResult.externalRuntime);
|
|
403
|
+
renderTable(`Development and test tooling (${depResult.externalDev.length})`, depResult.externalDev);
|
|
404
|
+
} else {
|
|
405
|
+
// Render as a compact table for cleaner output
|
|
406
|
+
const sorted = [...depResult.externalDeps].sort();
|
|
407
|
+
lines.push("| Package |");
|
|
408
|
+
lines.push("|---------|");
|
|
409
|
+
for (const dep of sorted) {
|
|
410
|
+
lines.push(`| \`${dep}\` |`);
|
|
411
|
+
}
|
|
412
|
+
lines.push("");
|
|
393
413
|
}
|
|
394
|
-
lines.push("");
|
|
395
414
|
}
|
|
396
415
|
|
|
397
416
|
lines.push("---");
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// The "unlock AI" notice shown after a run without AI
|
|
2
|
+
|
|
3
|
+
export const AI_SECTIONS = [
|
|
4
|
+
{ key: "executive_summary", title: "Executive Summary", ai: "plain language overview for leadership" },
|
|
5
|
+
{ key: "business_domains", title: "Business Domains", ai: "what the system does for stakeholders" },
|
|
6
|
+
{ key: "architecture_overview", title: "Architecture Overview", ai: "deeper narrative for architects" },
|
|
7
|
+
{ key: "data_flows", title: "Data Flows", ai: "how information moves through your system" },
|
|
8
|
+
{ key: "developer_onboarding", title: "Developer Onboarding", ai: "getting started guide for new hires" },
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Sections the notice can offer to upgrade: the ones that were generated in this run.
|
|
13
|
+
* Their rule-based text exists. The notice never calls a written document "missing".
|
|
14
|
+
* Without a document list (older callers), every AI-capable section is offered.
|
|
15
|
+
*/
|
|
16
|
+
export function aiUpgradeSections(documents) {
|
|
17
|
+
if (!Array.isArray(documents)) return AI_SECTIONS;
|
|
18
|
+
const written = new Set(documents.filter((doc) => doc && !doc.error).map((doc) => doc.key));
|
|
19
|
+
return AI_SECTIONS.filter((section) => written.has(section.key));
|
|
20
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Path roles: is a file test code, or tooling/example code rather than the product?
|
|
2
|
+
// Shared by the dependency graph, flow inference, pattern inference, and security scan
|
|
3
|
+
// so tests and examples never drive hub lists, coupling advice, or severity.
|
|
4
|
+
|
|
5
|
+
const TEST_DIRECTORIES = new Set([
|
|
6
|
+
"test", "tests", "spec", "specs", "__tests__", "__mocks__", "fixtures", "testdata", "e2e", "cypress",
|
|
7
|
+
]);
|
|
8
|
+
|
|
9
|
+
// Directories holding tooling, samples, and docs. Code in here describes a build script
|
|
10
|
+
// or an example, not how the product works.
|
|
11
|
+
export const TOOLING_DIRECTORIES = new Set([
|
|
12
|
+
"doc", "docs", "doc-gen", "docgen", "scripts", "script", "tools", "tooling",
|
|
13
|
+
"example", "examples", "demo", "demos", "sample", "samples",
|
|
14
|
+
"benchmark", "benchmarks", "bench", "fixtures", "tasks",
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
function segments(filePath) {
|
|
18
|
+
return String(filePath || "").replace(/\\/g, "/").toLowerCase().split("/");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Directory part of a path. A last segment without an extension (`test`, `examples/amd`)
|
|
22
|
+
// is a directory key such as a module, so it counts too.
|
|
23
|
+
function directorySegments(filePath) {
|
|
24
|
+
const parts = segments(filePath);
|
|
25
|
+
const last = parts[parts.length - 1] || "";
|
|
26
|
+
return /\.[a-z0-9]+$/.test(last) ? parts.slice(0, -1) : parts;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** True for files in a test directory or named like a test (`a.test.js`, `a_test.go`, `test_a.py`). */
|
|
30
|
+
export function isTestPath(filePath) {
|
|
31
|
+
const parts = segments(filePath);
|
|
32
|
+
if (directorySegments(filePath).some((segment) => TEST_DIRECTORIES.has(segment))) return true;
|
|
33
|
+
const base = parts[parts.length - 1] || "";
|
|
34
|
+
if (/^(test_|spec_)/.test(base)) return true;
|
|
35
|
+
if (/(_test|_spec)\.[a-z0-9]+$/.test(base)) return true;
|
|
36
|
+
if (/\.(test|spec)\.[a-z0-9]+$/.test(base)) return true;
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** True when a directory above the file is a tooling, example, or docs directory. */
|
|
41
|
+
export function isToolingPath(filePath) {
|
|
42
|
+
return directorySegments(filePath).some((segment) => TOOLING_DIRECTORIES.has(segment));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Build-time or example code: runs on a maintainer's machine or in a browser demo, not in
|
|
47
|
+
* the shipped library. Includes `build*.js` scripts and anything under a tooling directory.
|
|
48
|
+
*/
|
|
49
|
+
export function isBuildOrExamplePath(filePath) {
|
|
50
|
+
if (isToolingPath(filePath)) return true;
|
|
51
|
+
const base = segments(filePath).pop() || "";
|
|
52
|
+
return /^build([-_.]|$)/.test(base);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Test code or tooling/example code: not part of the product's own architecture. */
|
|
56
|
+
export function isNonProductPath(filePath) {
|
|
57
|
+
return isTestPath(filePath) || isToolingPath(filePath);
|
|
58
|
+
}
|