@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.
- package/CHANGELOG.md +31 -0
- package/README.md +8 -8
- package/package.json +1 -1
- package/src/ai/generate-sections.js +23 -4
- package/src/analyzers/complexity-analyzer.js +17 -4
- package/src/analyzers/context-builder.js +2 -1
- package/src/analyzers/dependency-graph.js +144 -21
- package/src/analyzers/flow-inference.js +10 -1
- package/src/analyzers/graphql-analyzer.js +80 -32
- package/src/analyzers/language-coverage.js +222 -0
- package/src/analyzers/workspace-packages.js +105 -0
- package/src/badge.js +59 -18
- package/src/core/scan.js +10 -4
- package/src/docs/generate-doc-set.js +11 -3
- package/src/help.js +8 -3
- package/src/renderers/render.js +14 -2
- package/src/renderers/renderAnalysis.js +12 -0
- package/src/utils/js-lexer.js +146 -0
|
@@ -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
|
-
|
|
25
|
-
|
|
26
|
+
/(?:^|[^\w$])(?:Query|Mutation|Subscription)\s*:\s*\{/m,
|
|
27
|
+
/\bresolvers?\s*=\s*\{/,
|
|
28
|
+
/\bcreateResolversMap\b/,
|
|
26
29
|
/\bresolveType\b/,
|
|
27
|
-
|
|
30
|
+
/\bfieldResolver\b/,
|
|
28
31
|
];
|
|
29
32
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
{ name: "
|
|
34
|
-
{ name: "
|
|
35
|
-
{ name: "
|
|
36
|
-
{ name: "
|
|
37
|
-
{ name: "
|
|
38
|
-
{ name: "
|
|
39
|
-
{ name: "
|
|
40
|
-
{ name: "
|
|
41
|
-
{ name: "
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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 =
|
|
151
|
+
const inlineSdlPattern = /\b(?:gql|graphql)\s*`([^`]+)`/gs;
|
|
118
152
|
let match;
|
|
119
|
-
while ((match = inlineSdlPattern.exec(
|
|
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 (
|
|
125
|
-
extractSchemaTypes(
|
|
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
|
-
//
|
|
129
|
-
|
|
130
|
-
if (
|
|
131
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
result.
|
|
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
|
-
|
|
123
|
-
|
|
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
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
167
|
+
(isScored ? scored : others).push(rel);
|
|
156
168
|
}
|
|
157
169
|
}
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
171
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
410
|
-
|
|
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);
|