@chappibunny/repolens 1.16.2 → 1.16.3

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.
@@ -3,6 +3,8 @@
3
3
  import fs from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { info } from "../utils/logger.js";
6
+ import { extractJsImports, externalPackageName } from "./import-parsers.js";
7
+ import { stripJsComments } from "../utils/js-lexer.js";
6
8
 
7
9
  const GRAPHQL_EXTENSIONS = [".graphql", ".gql"];
8
10
 
@@ -19,28 +21,57 @@ const TYPE_PATTERNS = {
19
21
  directive: /directive\s+@(\w+)/g,
20
22
  };
21
23
 
24
+ // Word boundaries matter: `useQuery: {` (a React Query hook map) is not `Query: {`.
22
25
  const RESOLVER_PATTERNS = [
23
- /(?:Query|Mutation|Subscription)\s*:\s*\{/,
24
- /resolvers?\s*=\s*\{/,
25
- /createResolversMap/,
26
+ /(?:^|[^\w$])(?:Query|Mutation|Subscription)\s*:\s*\{/m,
27
+ /\bresolvers?\s*=\s*\{/,
28
+ /\bcreateResolversMap\b/,
26
29
  /\bresolveType\b/,
27
- /fieldResolver/,
30
+ /\bfieldResolver\b/,
28
31
  ];
29
32
 
30
- const SCHEMA_LIBRARY_PATTERNS = [
31
- { name: "Apollo Server", pattern: /ApolloServer|@apollo\/server|apollo-server/ },
32
- { name: "GraphQL Yoga", pattern: /graphql-yoga|createYoga/ },
33
- { name: "Mercurius", pattern: /mercurius/ },
34
- { name: "graphql-js", pattern: /graphql\b.*\bbuildSchema\b|\bGraphQLSchema\b|\bGraphQLObjectType\b/ },
35
- { name: "type-graphql", pattern: /type-graphql|@Resolver|@Query|@Mutation/ },
36
- { name: "Nexus", pattern: /nexus|makeSchema|objectType\(/ },
37
- { name: "Pothos", pattern: /pothos|SchemaBuilder/ },
38
- { name: "Hasura", pattern: /hasura/ },
39
- { name: "Relay", pattern: /relay-runtime|RelayEnvironment/ },
40
- { name: "urql", pattern: /urql|@urql/ },
41
- { name: "Apollo Client", pattern: /ApolloClient|@apollo\/client|apollo-client|useQuery|useMutation/ },
33
+ // Libraries count only when a file imports them. A comment that mentions Apollo or urql,
34
+ // or a React Query `useQuery` hook, is not GraphQL.
35
+ const SCHEMA_LIBRARY_PACKAGES = [
36
+ { name: "Apollo Server", match: (pkg) => pkg === "@apollo/server" || pkg === "@apollo/subgraph" || pkg === "apollo-server" || pkg.startsWith("apollo-server-") },
37
+ { name: "GraphQL Yoga", match: (pkg) => pkg === "graphql-yoga" || pkg.startsWith("@graphql-yoga/") },
38
+ { name: "Mercurius", match: (pkg) => pkg === "mercurius" },
39
+ { name: "graphql-js", match: (pkg) => pkg === "graphql" },
40
+ { name: "type-graphql", match: (pkg) => pkg === "type-graphql" },
41
+ { name: "Nexus", match: (pkg) => pkg === "nexus" || pkg === "@nexus/schema" },
42
+ { name: "Pothos", match: (pkg) => pkg.startsWith("@pothos/") },
43
+ { name: "Relay", match: (pkg) => pkg === "relay-runtime" || pkg === "react-relay" },
44
+ { name: "urql", match: (pkg) => pkg === "urql" || pkg.startsWith("@urql/") },
45
+ { name: "Apollo Client", match: (pkg) => pkg === "@apollo/client" || pkg === "apollo-client" || pkg === "react-apollo" || pkg === "apollo-boost" },
46
+ { name: "graphql-request", match: (pkg) => pkg === "graphql-request" },
47
+ { name: "graphql-tag", match: (pkg) => pkg === "graphql-tag" },
48
+ // Hasura has no server SDK; its client and tooling packages are the grounded signal.
49
+ // A bare "hasura" mention in text is no longer enough (see CHANGELOG).
50
+ { name: "Hasura", match: (pkg) => pkg.startsWith("@hasura/") || pkg.startsWith("hasura-") },
42
51
  ];
43
52
 
53
+ export function graphqlLibrariesImported(source) {
54
+ const names = [];
55
+ let imports = [];
56
+ try { imports = extractJsImports(source); } catch { imports = []; }
57
+ for (const imported of imports) {
58
+ const pkg = externalPackageName(imported.specifier);
59
+ if (!pkg) continue;
60
+ for (const { name, match } of SCHEMA_LIBRARY_PACKAGES) {
61
+ if (match(pkg) && !names.includes(name)) names.push(name);
62
+ }
63
+ }
64
+ return names;
65
+ }
66
+
67
+ async function hasHasuraProject(repoRoot) {
68
+ for (const rel of ["hasura/config.yaml", "config.yaml"]) {
69
+ const text = await readFileSafe(path.join(repoRoot, rel));
70
+ if (/^\s*metadata_directory\s*:/m.test(text)) return true;
71
+ }
72
+ return false;
73
+ }
74
+
44
75
  function parseFieldsFromBlock(block) {
45
76
  const fields = [];
46
77
  const lines = block.split("\n").map(l => l.trim()).filter(l => l && !l.startsWith("#"));
@@ -102,40 +133,57 @@ export async function analyzeGraphQL(files, repoRoot) {
102
133
  }
103
134
 
104
135
  // Scan code files for inline schemas, resolvers, and libraries
136
+ const resolverCandidates = [];
105
137
  for (const file of codeFiles) {
106
138
  const content = await readFileSafe(path.join(repoRoot, file));
107
139
  if (!content) continue;
108
140
 
109
- // Detect GraphQL libraries
110
- for (const { name, pattern } of SCHEMA_LIBRARY_PATTERNS) {
111
- if (pattern.test(content) && !result.libraries.includes(name)) {
112
- result.libraries.push(name);
113
- }
141
+ // Detect GraphQL libraries from real imports
142
+ const fileLibraries = graphqlLibrariesImported(content);
143
+ for (const name of fileLibraries) {
144
+ if (!result.libraries.includes(name)) result.libraries.push(name);
114
145
  }
115
146
 
147
+ const code = stripJsComments(content);
148
+ const typesBefore = result.types.length + result.queries.length + result.mutations.length + result.subscriptions.length;
149
+
116
150
  // Detect inline SDL (template literals with gql tag or type definitions)
117
- const inlineSdlPattern = /(?:gql|graphql)\s*`([^`]+)`/gs;
151
+ const inlineSdlPattern = /\b(?:gql|graphql)\s*`([^`]+)`/gs;
118
152
  let match;
119
- while ((match = inlineSdlPattern.exec(content)) !== null) {
153
+ while ((match = inlineSdlPattern.exec(code)) !== null) {
120
154
  extractSchemaTypes(match[1], file, result);
121
155
  }
122
156
 
123
157
  // Also detect raw type definitions in string literals
124
- if (/type\s+(?:Query|Mutation|Subscription)\s*\{/.test(content)) {
125
- extractSchemaTypes(content, file, result);
158
+ if (/\btype\s+(?:Query|Mutation|Subscription)\s*\{/.test(code)) {
159
+ extractSchemaTypes(code, file, result);
126
160
  }
161
+ const hasSdl = result.types.length + result.queries.length + result.mutations.length + result.subscriptions.length > typesBefore;
127
162
 
128
- // Detect resolver files
129
- const isResolver = RESOLVER_PATTERNS.some(p => p.test(content));
130
- if (isResolver && !result.resolverFiles.includes(file)) {
131
- result.resolverFiles.push(file);
163
+ // Resolver candidates are confirmed after the whole scan: a resolver map needs
164
+ // GraphQL evidence in the file or somewhere in the repository.
165
+ if (RESOLVER_PATTERNS.some(p => p.test(code))) {
166
+ // `resolvers = { Query: { ... } }` together is a resolver map on its own.
167
+ const resolverMap = RESOLVER_PATTERNS[0].test(code) && RESOLVER_PATTERNS[1].test(code);
168
+ resolverCandidates.push({ file, local: fileLibraries.length > 0 || hasSdl || resolverMap });
132
169
  }
133
170
  }
134
171
 
135
- result.detected = result.schemaFiles.length > 0 ||
136
- result.types.length > 0 ||
137
- result.resolverFiles.length > 0 ||
172
+ // Hasura projects are recognized by their CLI project file, not by the word "hasura".
173
+ if (!result.libraries.includes("Hasura") && await hasHasuraProject(repoRoot)) {
174
+ result.libraries.push("Hasura");
175
+ }
176
+
177
+ const repoHasGraphQL = result.schemaFiles.length > 0 ||
178
+ result.types.length > 0 || result.queries.length > 0 || result.mutations.length > 0 ||
138
179
  result.libraries.length > 0;
180
+ for (const candidate of resolverCandidates) {
181
+ if ((candidate.local || repoHasGraphQL) && !result.resolverFiles.includes(candidate.file)) {
182
+ result.resolverFiles.push(candidate.file);
183
+ }
184
+ }
185
+
186
+ result.detected = repoHasGraphQL || result.resolverFiles.length > 0;
139
187
 
140
188
  if (result.detected) {
141
189
  result.summary = buildSummary(result);
@@ -0,0 +1,222 @@
1
+ // Which scanned source files the import graph and code health can read.
2
+ // RepoLens detects and scans many languages, but it builds import graphs and scores code
3
+ // health only for JavaScript/TypeScript, Python, and Go. A C# or Java repository must not
4
+ // get "no circular dependencies" or "health 100/100" from one incidental docs script.
5
+
6
+ import path from "node:path";
7
+ import { LANGUAGE_PROFILES } from "../core/languages.js";
8
+ import { isNonProductPath } from "../utils/path-roles.js";
9
+
10
+ export const ANALYZED_LANGUAGES_TEXT = "JavaScript/TypeScript, Python, and Go";
11
+
12
+ const ANALYZED_EXTENSIONS = new Set([".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".py", ".pyw", ".go"]);
13
+
14
+ // A code-health badge is one number for the whole repository. Below this share of
15
+ // product source files in analyzed languages, `repolens badge` refuses to print it.
16
+ // The docs still show the partial graph and scores with a coverage note.
17
+ export const BADGE_MIN_SHARE = 0.1;
18
+
19
+ // Vendored or third-party trees are not the repository's own code, in any language.
20
+ export const VENDORED_DIRECTORIES = new Set([
21
+ "vendor", "vendored", "third_party", "third-party", "thirdparty", "3rdparty",
22
+ "deps", "external", "extern", "node_modules", "bower_components",
23
+ ]);
24
+
25
+ const LABEL_BY_EXTENSION = new Map();
26
+ LABEL_BY_EXTENSION.set(".vue", "Vue");
27
+ LABEL_BY_EXTENSION.set(".svelte", "Svelte");
28
+ const COMPANION_LABELS_LIST = ["Vue", "Svelte"];
29
+ for (const profile of LANGUAGE_PROFILES) {
30
+ const label = profile.id === "java" ? "Java/Kotlin" : profile.id === "cpp" ? "C/C++" : profile.label;
31
+ for (const ext of profile.extensions) {
32
+ if (!LABEL_BY_EXTENSION.has(ext)) LABEL_BY_EXTENSION.set(ext, label);
33
+ }
34
+ }
35
+
36
+ function isDeclarationFile(file) {
37
+ return /\.d\.[cm]?ts$/.test(file);
38
+ }
39
+
40
+ export function isVendoredPath(file) {
41
+ const parts = String(file || "").replace(/\\/g, "/").toLowerCase().split("/");
42
+ parts.pop();
43
+ return parts.some((segment) => VENDORED_DIRECTORIES.has(segment));
44
+ }
45
+
46
+ /**
47
+ * Product source = not a test, docs, tooling, example, or vendored path.
48
+ *
49
+ * - "none": no source files in any known language
50
+ * - "full": every product source file is in an analyzed language
51
+ * - "partial": analyzed-language product code exists next to other languages; analysis
52
+ * runs and every page says what it did not cover
53
+ * - "unsupported": no analyzed-language product code at all (only a docs script, a test
54
+ * helper, or vendored files); graph and health are not built
55
+ *
56
+ * @param {string[]} files - scanned file paths
57
+ */
58
+ export function computeLanguageCoverage(files) {
59
+ let analyzedFiles = 0;
60
+ let analyzedProductFiles = 0;
61
+ let unanalyzedFiles = 0;
62
+ let unanalyzedProductFiles = 0;
63
+ let vendoredFiles = 0;
64
+ let vendoredAnalyzedFiles = 0;
65
+ const byLabelProduct = new Map();
66
+ const byLabelAll = new Map();
67
+
68
+ for (const raw of files || []) {
69
+ const file = String(raw).replace(/\\/g, "/");
70
+ if (isDeclarationFile(file)) continue;
71
+ const ext = path.posix.extname(file).toLowerCase();
72
+ const analyzed = ANALYZED_EXTENSIONS.has(ext);
73
+ const label = analyzed ? null : LABEL_BY_EXTENSION.get(ext);
74
+ if (!analyzed && !label) continue;
75
+ if (isVendoredPath(file)) {
76
+ vendoredFiles += 1;
77
+ if (analyzed) vendoredAnalyzedFiles += 1;
78
+ continue;
79
+ }
80
+ const product = !isNonProductPath(file);
81
+ if (analyzed) {
82
+ analyzedFiles += 1;
83
+ if (product) analyzedProductFiles += 1;
84
+ continue;
85
+ }
86
+ unanalyzedFiles += 1;
87
+ byLabelAll.set(label, (byLabelAll.get(label) || 0) + 1);
88
+ if (product) {
89
+ unanalyzedProductFiles += 1;
90
+ byLabelProduct.set(label, (byLabelProduct.get(label) || 0) + 1);
91
+ }
92
+ }
93
+
94
+ // Product files decide everything; a tree with no product files at all (only tests,
95
+ // docs, or tooling) falls back to every non-vendored file.
96
+ const useProduct = analyzedProductFiles + unanalyzedProductFiles > 0;
97
+ const analyzedBase = useProduct ? analyzedProductFiles : analyzedFiles;
98
+ const unanalyzedBase = useProduct ? unanalyzedProductFiles : unanalyzedFiles;
99
+ const byLabel = useProduct ? byLabelProduct : byLabelAll;
100
+ const totalBase = analyzedBase + unanalyzedBase;
101
+ const share = totalBase > 0 ? analyzedBase / totalBase : null;
102
+ // Vue and Svelte components sit next to analyzed JavaScript/TypeScript. They are not
103
+ // analyzed, but they do not count against the badge share: a component library with
104
+ // two .ts files and forty .vue files keeps its (partial) score.
105
+ const companionBase = COMPANION_LABELS_LIST.reduce((sum, label) => sum + (byLabel.get(label) || 0), 0);
106
+ const badgeShare = totalBase - companionBase > 0 ? analyzedBase / (totalBase - companionBase) : null;
107
+
108
+ const unanalyzedLanguages = [...byLabel.entries()]
109
+ .map(([label, count]) => ({ label, files: count }))
110
+ .sort((a, b) => b.files - a.files || a.label.localeCompare(b.label));
111
+
112
+ let status;
113
+ if (totalBase === 0) status = "none";
114
+ else if (unanalyzedBase === 0) status = "full";
115
+ else if (analyzedBase === 0) status = "unsupported";
116
+ else status = "partial";
117
+
118
+ return {
119
+ status,
120
+ analyzedFiles,
121
+ analyzedProductFiles,
122
+ unanalyzedFiles,
123
+ unanalyzedProductFiles,
124
+ vendoredFiles,
125
+ vendoredAnalyzedFiles,
126
+ productBased: useProduct,
127
+ analyzedBase,
128
+ totalBase,
129
+ unanalyzedLanguages,
130
+ share,
131
+ companionBase,
132
+ badgeShare,
133
+ };
134
+ }
135
+
136
+ /** Whether one code-health number may stand for the repository. */
137
+ export function badgeCoverageOk(coverage) {
138
+ if (!coverage) return true;
139
+ if (coverage.status === "full" || coverage.status === "none") return true;
140
+ if (coverage.status === "unsupported") return false;
141
+ const share = coverage.badgeShare ?? coverage.share ?? 0;
142
+ return share >= BADGE_MIN_SHARE;
143
+ }
144
+
145
+ /**
146
+ * Short qualifier for claims made from the graph ("no circular dependencies"), or "".
147
+ * Partial: " (analyzed files only: 50 of 650 product source files; C/C++ not analyzed)".
148
+ */
149
+ export function coverageScope(coverage) {
150
+ if (!coverage || coverage.status !== "partial") return "";
151
+ return ` (analyzed files only: ${coverage.analyzedBase} of ${coverage.totalBase} ${coverage.productBased ? "product " : ""}source files; ${unanalyzedLanguageNames(coverage)} not analyzed)`;
152
+ }
153
+
154
+ /** "C# (780 files)" / "Java/Kotlin (247 files) and C (3 files)". */
155
+ export function describeUnanalyzedLanguages(coverage, limit = 3) {
156
+ const list = (coverage?.unanalyzedLanguages || []).slice(0, limit)
157
+ .map(({ label, files }) => `${label} (${files} ${coverage?.productBased ? "product " : ""}file${files === 1 ? "" : "s"})`);
158
+ if (list.length === 0) return "";
159
+ if (list.length === 1) return list[0];
160
+ return `${list.slice(0, -1).join(", ")} and ${list[list.length - 1]}`;
161
+ }
162
+
163
+ /** Short language names only: "C#", "Java/Kotlin and Scala". */
164
+ export function unanalyzedLanguageNames(coverage, limit = 3) {
165
+ const list = (coverage?.unanalyzedLanguages || []).slice(0, limit).map(({ label }) => label);
166
+ if (list.length <= 1) return list[0] || "";
167
+ return `${list.slice(0, -1).join(", ")} and ${list[list.length - 1]}`;
168
+ }
169
+
170
+ /**
171
+ * Files an analyzer should read. When the analyzed languages are only incidental
172
+ * (status "unsupported"), none: the graph or score would describe a docs script,
173
+ * not the repository.
174
+ */
175
+ export function filesForAnalysis(files, coverage = computeLanguageCoverage(files)) {
176
+ if (coverage.status === "unsupported") return [];
177
+ return files;
178
+ }
179
+
180
+ function verbFor(coverage) {
181
+ return (coverage?.unanalyzedLanguages || []).length > 1 ? "are" : "is";
182
+ }
183
+
184
+ /**
185
+ * Markdown lines that say what a page did not analyze.
186
+ * `what` is "import analysis" or "code health".
187
+ * Returns [] for full coverage or when there is nothing to say.
188
+ */
189
+ export function coverageNotice(coverage, what) {
190
+ if (!coverage) return [];
191
+ const langs = describeUnanalyzedLanguages(coverage);
192
+ if (coverage.status === "unsupported") {
193
+ const n = (coverage.analyzedFiles || 0) + (coverage.vendoredAnalyzedFiles || 0);
194
+ const incidental = n > 0
195
+ ? ` ${n === 1 ? "One file" : `${n} files`} in a supported language ${n === 1 ? "was" : "were"} found only in docs, tests, tooling, examples, or vendored directories, so ${n === 1 ? "it was" : "they were"} not ${what === "code health" ? "scored" : "graphed"} as if ${n === 1 ? "it were" : "they were"} the codebase.`
196
+ : "";
197
+ return [
198
+ `> ${langs} ${verbFor(coverage)} not supported for ${what} yet. RepoLens ${what === "code health" ? "scores" : "builds import graphs for"} ${ANALYZED_LANGUAGES_TEXT} only.`,
199
+ "",
200
+ `Modules and file structure for this repository are still documented in the other pages. This is a language limitation, not a problem with your \`scan.include\` patterns.${incidental}`,
201
+ "",
202
+ ];
203
+ }
204
+ if (coverage.status === "partial") {
205
+ const pct = coverage.share != null ? Math.round(coverage.share * 100) : null;
206
+ return [
207
+ `> **Partial coverage:** ${what} covers ${coverage.analyzedBase} of ${coverage.totalBase} ${coverage.productBased ? "product " : ""}source files${pct != null ? ` (${pct}%)` : ""}, the ${ANALYZED_LANGUAGES_TEXT} ones. ${langs} ${verbFor(coverage)} not analyzed, so results below say nothing about ${coverage.unanalyzedLanguages.length > 1 ? "those languages" : "that language"}.`,
208
+ "",
209
+ ];
210
+ }
211
+ return [];
212
+ }
213
+
214
+ /** One plain-text line for logs and the badge CLI. */
215
+ export function coverageLine(coverage, what) {
216
+ if (!coverage || (coverage.status !== "unsupported" && coverage.status !== "partial")) return "";
217
+ const langs = describeUnanalyzedLanguages(coverage);
218
+ if (coverage.status === "unsupported") {
219
+ return `${langs} ${verbFor(coverage)} not supported for ${what} yet (${ANALYZED_LANGUAGES_TEXT} only).`;
220
+ }
221
+ return `${what} covers ${ANALYZED_LANGUAGES_TEXT} only; ${langs} ${verbFor(coverage)} not analyzed.`;
222
+ }
@@ -0,0 +1,105 @@
1
+ // Workspace members declared by the repository: root package.json `workspaces`
2
+ // (npm / yarn / bun), pnpm-workspace.yaml `packages`, and lerna.json `packages`.
3
+ // Only declared members are internal packages for the dependency graph. A stray
4
+ // package.json (a test stub named `express`, a fixture, a vendored copy) is not.
5
+
6
+ import fs from "node:fs/promises";
7
+ import path from "node:path";
8
+ import fg from "fast-glob";
9
+ import { loadYaml } from "../utils/yaml.js";
10
+
11
+ async function readJson(file) {
12
+ try {
13
+ return JSON.parse(await fs.readFile(file, "utf8"));
14
+ } catch {
15
+ return null;
16
+ }
17
+ }
18
+
19
+ function cleanPattern(raw) {
20
+ if (typeof raw !== "string") return null;
21
+ let p = raw.trim();
22
+ if (!p || p.includes("\0")) return null;
23
+ const negated = p.startsWith("!");
24
+ if (negated) p = p.slice(1).trim();
25
+ p = p.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
26
+ if (!p || p.startsWith("/") || /^[A-Za-z]:/.test(p)) return null;
27
+ if (p.split("/").includes("..")) return null;
28
+ return { pattern: p, negated };
29
+ }
30
+
31
+ async function declaredPatterns(repoRoot) {
32
+ const sources = [];
33
+ const patterns = [];
34
+
35
+ const rootPkg = await readJson(path.join(repoRoot, "package.json"));
36
+ if (rootPkg?.workspaces) {
37
+ const list = Array.isArray(rootPkg.workspaces) ? rootPkg.workspaces : rootPkg.workspaces.packages;
38
+ if (Array.isArray(list) && list.length > 0) {
39
+ sources.push("package.json workspaces");
40
+ patterns.push(...list);
41
+ }
42
+ }
43
+
44
+ try {
45
+ const doc = loadYaml(await fs.readFile(path.join(repoRoot, "pnpm-workspace.yaml"), "utf8"));
46
+ if (Array.isArray(doc?.packages) && doc.packages.length > 0) {
47
+ sources.push("pnpm-workspace.yaml");
48
+ patterns.push(...doc.packages);
49
+ }
50
+ } catch {
51
+ // no pnpm workspace
52
+ }
53
+
54
+ const lerna = await readJson(path.join(repoRoot, "lerna.json"));
55
+ if (lerna) {
56
+ const list = Array.isArray(lerna.packages) && lerna.packages.length > 0 ? lerna.packages : ["packages/*"];
57
+ sources.push("lerna.json");
58
+ patterns.push(...list);
59
+ }
60
+
61
+ return { sources, patterns };
62
+ }
63
+
64
+ /**
65
+ * @returns {Promise<{ sources: string[], packages: { name: string, dir: string, manifest: object }[] }>}
66
+ * `dir` is repository-relative with forward slashes ("" never occurs: the root is not a member).
67
+ */
68
+ export async function listWorkspacePackages(repoRoot) {
69
+ const { sources, patterns } = await declaredPatterns(repoRoot);
70
+ const positive = [];
71
+ const negative = [];
72
+ for (const raw of patterns) {
73
+ const cleaned = cleanPattern(raw);
74
+ if (!cleaned) continue;
75
+ (cleaned.negated ? negative : positive).push(cleaned.pattern);
76
+ }
77
+ if (positive.length === 0) return { sources, packages: [] };
78
+
79
+ let manifests = [];
80
+ try {
81
+ manifests = await fg(positive.map((p) => `${p}/package.json`), {
82
+ cwd: repoRoot,
83
+ dot: true,
84
+ onlyFiles: true,
85
+ followSymbolicLinks: false,
86
+ unique: true,
87
+ ignore: ["**/node_modules/**", ...negative.map((p) => `${p}/package.json`), ...negative.map((p) => `${p}/**`)],
88
+ });
89
+ } catch {
90
+ manifests = [];
91
+ }
92
+
93
+ const byName = new Map();
94
+ for (const rel of manifests.sort()) {
95
+ const dir = path.posix.dirname(rel.replace(/\\/g, "/"));
96
+ if (!dir || dir === ".") continue;
97
+ const manifest = await readJson(path.join(repoRoot, rel));
98
+ if (!manifest || typeof manifest.name !== "string" || !manifest.name) continue;
99
+ const existing = byName.get(manifest.name);
100
+ if (!existing || dir.split("/").length < existing.dir.split("/").length) {
101
+ byName.set(manifest.name, { name: manifest.name, dir, manifest });
102
+ }
103
+ }
104
+ return { sources, packages: [...byName.values()] };
105
+ }
package/src/badge.js CHANGED
@@ -2,11 +2,17 @@ import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { analyzeComplexity, computeCodeHealth } from "./analyzers/complexity-analyzer.js";
4
4
  import { analyzeDependencyGraph } from "./analyzers/dependency-graph.js";
5
+ import { badgeCoverageOk, coverageLine } from "./analyzers/language-coverage.js";
5
6
  import { analyzeJSDoc } from "./analyzers/jsdoc-analyzer.js";
6
7
  import { analyzeSecurityPatterns } from "./analyzers/security-patterns.js";
7
8
  import { GALLERY_URL, REPO_URL } from "./delivery/comment-constants.js";
8
9
 
9
10
  const SOURCE_EXT = new Set([".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx", ".py", ".pyw", ".go"]);
11
+ const OTHER_SOURCE_EXT = new Set([
12
+ ".rs", ".java", ".kt", ".kts", ".scala", ".rb", ".php", ".cs", ".swift",
13
+ ".c", ".cc", ".cpp", ".h", ".hpp", ".vue", ".svelte",
14
+ ]);
15
+ const MAX_SOURCE_FILES = 20000;
10
16
  const SKIP_DIR = new Set([
11
17
  "node_modules", ".git", "dist", "build", "vendor", "target", "coverage",
12
18
  ".repolens", "__pycache__", ".cache",
@@ -119,15 +125,19 @@ export function compactHealth(health) {
119
125
  };
120
126
  }
121
127
 
122
- export async function listScoreFiles(root, { maxFiles = 2500 } = {}) {
123
- const out = [];
128
+ /**
129
+ * Walk the tree once. Up to `maxFiles` scored files are analyzed, but every source file
130
+ * (scored languages and others alike) is counted up to `maxSourceFiles`, with the same
131
+ * skip rules, so the language-coverage decision never compares a capped sample of one
132
+ * language against an uncapped count of another. `coverageTruncated` is true when that
133
+ * shared limit was hit, and the ratio is then an estimate.
134
+ */
135
+ export async function listScoreFiles(root, { maxFiles = 2500, maxSourceFiles = MAX_SOURCE_FILES } = {}) {
136
+ const scored = [];
137
+ const others = [];
124
138
  const stack = [root];
125
- let capped = false;
126
- while (stack.length) {
127
- if (out.length >= maxFiles) {
128
- capped = true;
129
- break;
130
- }
139
+ let coverageTruncated = false;
140
+ walk: while (stack.length) {
131
141
  const dir = stack.pop();
132
142
  let entries;
133
143
  try {
@@ -146,33 +156,47 @@ export async function listScoreFiles(root, { maxFiles = 2500 } = {}) {
146
156
  }
147
157
  if (!entry.isFile()) continue;
148
158
  if (entry.name.endsWith(".snap")) continue;
149
- if (!SOURCE_EXT.has(path.extname(entry.name))) continue;
150
- if (out.length >= maxFiles) {
151
- capped = true;
152
- break;
159
+ const ext = path.extname(entry.name);
160
+ const isScored = SOURCE_EXT.has(ext);
161
+ if (!isScored && !OTHER_SOURCE_EXT.has(ext.toLowerCase())) continue;
162
+ if (scored.length + others.length >= maxSourceFiles) {
163
+ coverageTruncated = true;
164
+ break walk;
153
165
  }
154
166
  const rel = path.relative(root, full).replace(/\\/g, "/");
155
- out.push(rel);
167
+ (isScored ? scored : others).push(rel);
156
168
  }
157
169
  }
158
- out.sort();
159
- return { files: out, capped };
170
+ const capped = scored.length > maxFiles || coverageTruncated;
171
+ const files = scored.slice(0, maxFiles).sort();
172
+ return {
173
+ files,
174
+ capped,
175
+ others: others.sort(),
176
+ coverageFiles: [...scored, ...others],
177
+ coverageTruncated,
178
+ };
160
179
  }
161
180
 
162
181
  export async function scoreRepository(root, { files, maxFiles = 2500 } = {}) {
163
182
  let list = files;
164
183
  let capped = false;
184
+ let coverageFiles = files;
185
+ let coverageTruncated = false;
165
186
  if (!list) {
166
187
  const walked = await listScoreFiles(root, { maxFiles });
167
188
  list = walked.files;
168
189
  capped = walked.capped;
190
+ coverageFiles = walked.coverageFiles;
191
+ coverageTruncated = walked.coverageTruncated;
169
192
  }
170
- const complexity = await analyzeComplexity(list, root);
171
- const depGraph = await analyzeDependencyGraph(list, root);
193
+ // Coverage is judged on every counted source file; only `list` is analyzed.
194
+ const complexity = await analyzeComplexity(list, root, { coverageFiles });
195
+ const depGraph = await analyzeDependencyGraph(list, root, { coverageFiles });
172
196
  const jsdoc = await analyzeJSDoc(list, root);
173
197
  const security = await analyzeSecurityPatterns(list, root);
174
198
  const health = computeCodeHealth(complexity, depGraph, jsdoc, security);
175
- return { health, depGraph, files: list, capped };
199
+ return { health, depGraph, files: list, capped, coverageTruncated };
176
200
  }
177
201
 
178
202
  function resolveOutPaths(outPath) {
@@ -228,6 +252,23 @@ export async function runBadgeCli({
228
252
  return 1;
229
253
  }
230
254
  const total = scored.health?.stats?.totalFiles || 0;
255
+ const coverage = scored.health?.coverage;
256
+ if (scored.coverageTruncated) {
257
+ console.error(`Note: language coverage was estimated from the first ${MAX_SOURCE_FILES} source files.`);
258
+ }
259
+ if (coverage && !badgeCoverageOk(coverage)) {
260
+ if (coverage.status === "unsupported") {
261
+ console.error(`Not scored: ${coverageLine(coverage, "code health")}`);
262
+ } else {
263
+ const pct = Math.round((coverage.share || 0) * 100);
264
+ console.error(`Not scored: only ${coverage.analyzedBase} of ${coverage.totalBase} product source files (${pct}%) are ${"JavaScript/TypeScript, Python, or Go"}; ${coverageLine(coverage, "code health")}`);
265
+ }
266
+ console.error("No badge was printed: one score would not stand for this repository.");
267
+ return 2;
268
+ }
269
+ if (coverage?.status === "partial") {
270
+ console.error(`Partial coverage: ${coverageLine(coverage, "code health")}`);
271
+ }
231
272
  if (!total) {
232
273
  console.error("No source files analyzed.");
233
274
  console.error("Code health covers JavaScript, TypeScript, Python, and Go. Empty documentation was not scored.");
package/src/core/scan.js CHANGED
@@ -186,6 +186,7 @@ function packageEntryFiles(pkg) {
186
186
  */
187
187
  function detectLanguagesFromFiles(files) {
188
188
  const languages = new Set();
189
+ const counts = new Map();
189
190
 
190
191
  // Extension to language mapping
191
192
  const extensionMap = {
@@ -241,9 +242,12 @@ function detectLanguagesFromFiles(files) {
241
242
  // types. They do not make the library a TypeScript project.
242
243
  if (ext === ".ts" && /\.d\.ts$/i.test(file)) continue;
243
244
  if (extensionMap[ext] === "TypeScript" && isTestPath(file)) continue;
244
- languages.add(extensionMap[ext]);
245
+ const label = extensionMap[ext];
246
+ counts.set(label, (counts.get(label) || 0) + 1);
245
247
  }
246
248
 
249
+ // Most files first, so a C# repository with one docs script reads "C#, JavaScript".
250
+ for (const [label] of [...counts.entries()].sort((a, b) => b[1] - a[1])) languages.add(label);
247
251
  return languages;
248
252
  }
249
253
 
@@ -406,9 +410,8 @@ async function extractRepoMetadata(repoRoot, files = []) {
406
410
 
407
411
  // Merge languages detected from file extensions
408
412
  const fileLanguages = detectLanguagesFromFiles(files);
409
- for (const lang of fileLanguages) {
410
- metadata.languages.add(lang);
411
- }
413
+ const manifestLanguages = [...metadata.languages];
414
+ metadata.languages = new Set([...fileLanguages, ...manifestLanguages]);
412
415
 
413
416
  // De-duplicate frameworks (in case detected from multiple sources)
414
417
  metadata.frameworks = [...new Set(metadata.frameworks)];
@@ -589,6 +592,7 @@ export async function scanRepo(cfg) {
589
592
  }
590
593
 
591
594
  const moduleCounts = new Map();
595
+ const moduleTestCounts = new Map();
592
596
  const flatKeys = new Set();
593
597
  const rootLabel = rootModuleLabel(repoRoot);
594
598
 
@@ -600,12 +604,14 @@ export async function scanRepo(cfg) {
600
604
  const key = moduleKeyForFile(file, cfg.module_roots || [], rootLabel);
601
605
  if (!norm(file).includes("/")) flatKeys.add(key);
602
606
  moduleCounts.set(key, (moduleCounts.get(key) || 0) + 1);
607
+ if (isTestPath(file)) moduleTestCounts.set(key, (moduleTestCounts.get(key) || 0) + 1);
603
608
  }
604
609
 
605
610
  const modules = [...moduleCounts.entries()]
606
611
  .map(([key, fileCount]) => ({
607
612
  key,
608
613
  fileCount,
614
+ ...(moduleTestCounts.get(key) ? { testFileCount: moduleTestCounts.get(key) } : {}),
609
615
  ...(flatKeys.has(key) ? { flat: true } : {}),
610
616
  }))
611
617
  .sort((a, b) => b.fileCount - a.fileCount);