@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.
@@ -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: pattern.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[pattern.severity]++;
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
- info(`\n${fmt.cyan("┌──────────────────────────────────────────────────────────────────┐")}`);
144
- info(`${fmt.cyan("│")} ${fmt.boldYellow("✨ Unlock AI-Enhanced Documentation")} ${fmt.cyan("│")}`);
145
- info(`${fmt.cyan("├──────────────────────────────────────────────────────────────────┤")}`);
146
- info(`${fmt.cyan("│")} Your docs are missing these AI-powered sections: ${fmt.cyan("│")}`);
147
- info(`${fmt.cyan("│")} ${fmt.yellow("•")} Executive Summary — plain language overview for leadership ${fmt.cyan("│")}`);
148
- info(`${fmt.cyan("│")} ${fmt.yellow("•")} Business Domains — what the system does for stakeholders ${fmt.cyan("│")}`);
149
- info(`${fmt.cyan("│")} ${fmt.yellow("•")} Architecture Overview — deeper narrative for architects ${fmt.cyan("│")}`);
150
- info(`${fmt.cyan("│")} ${fmt.yellow("•")} Data Flows — how information moves through your system ${fmt.cyan("│")}`);
151
- info(`${fmt.cyan("│")} ${fmt.yellow("•")} Developer Onboarding — getting started guide for new hires ${fmt.cyan("│")}`);
152
- info(`${fmt.cyan("│")} ${fmt.cyan("│")}`);
153
- info(`${fmt.cyan("│")} ${fmt.boldGreen("🆓 Enable for FREE with GitHub Models:")} ${fmt.cyan("│")}`);
154
- info(`${fmt.cyan("│")} Run: ${fmt.brightCyan("repolens init")} → the wizard will guide you through setup ${fmt.cyan("│")}`);
155
- info(`${fmt.cyan("│")} Or quick preview: ${fmt.green("export GITHUB_TOKEN=<your-token> && repolens demo")} ${fmt.cyan("│")}`);
156
- info(`${fmt.cyan("└──────────────────────────────────────────────────────────────────┘")}`);
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: path.basename(targetDir) },
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
- languages.add(extensionMap[ext]);
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
- if (allDeps["typescript"]) metadata.languages.add("TypeScript");
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 = path.basename(repoRoot) || "my-project";
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: path.basename(root) || "repository" },
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}"],
@@ -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 = patterns.some(p => p.toLowerCase().includes("cli"));
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
- lines.push(`| External packages | ${s.externalDeps} |`);
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 in the codebase. Changes to hub modules have the widest blast radius and should be reviewed carefully:");
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
- // Render as a compact table for cleaner output
388
- const sorted = [...depResult.externalDeps].sort();
389
- lines.push("| Package |");
390
- lines.push("|---------|");
391
- for (const dep of sorted) {
392
- lines.push(`| \`${dep}\` |`);
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
+ }