@chappibunny/repolens 1.16.2 → 1.16.4

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.
@@ -164,6 +164,23 @@ function localNames(inner) {
164
164
  return names;
165
165
  }
166
166
 
167
+ /** `{ a, b as c, type D }` → [{ imported: "a", local: "a" }, { imported: "b", local: "c" }]. */
168
+ function braceBindings(inner) {
169
+ const out = [];
170
+ for (const part of splitTopCommas(inner)) {
171
+ const trimmed = part.trim().replace(/\s+/g, " ");
172
+ if (!trimmed || /^type\s+/.test(trimmed)) continue;
173
+ const asMatch = trimmed.match(/^([A-Za-z_$][\w$]*|"[^"]*"|'[^']*')\s+as\s+([A-Za-z_$][\w$]*)$/);
174
+ if (asMatch) {
175
+ out.push({ imported: asMatch[1].replace(/^["']|["']$/g, ""), local: asMatch[2] });
176
+ continue;
177
+ }
178
+ const id = trimmed.match(/^([A-Za-z_$][\w$]*)$/);
179
+ if (id) out.push({ imported: id[1], local: id[1] });
180
+ }
181
+ return out;
182
+ }
183
+
167
184
  function readAlias(source, i) {
168
185
  let j = skipWs(source, i);
169
186
  const word = readWord(source, j);
@@ -206,7 +223,7 @@ function readFromClause(source, i) {
206
223
  }
207
224
 
208
225
  /**
209
- * @returns {{ specifier: string, typeOnly: boolean, names: string[] }[]}
226
+ * @returns {{ specifier: string, typeOnly: boolean, names: string[], kind: string, bindings: { imported: string, local: string|null }[] }[]}
210
227
  * `names` are the local bindings (`import { a as b }` → `b`) so callers can
211
228
  * count `b(` as a use of this import. Type-only imports still list names.
212
229
  */
@@ -215,7 +232,12 @@ export function extractJsImports(source) {
215
232
  const n = source.length;
216
233
  let i = 0;
217
234
 
218
- function add(specifier, typeOnly, names = []) {
235
+ // `kind`: "import" (has local bindings), "reexport" (`export { a } from`),
236
+ // "reexport-all" (`export * from`), "side-effect" (`import "x"`), or "dynamic"
237
+ // (`import()` / `require()`). `bindings` pair each imported name with its local
238
+ // name (`*` for a namespace import, `default` for a default import); for a
239
+ // re-export, `local` is null.
240
+ function add(specifier, typeOnly, names = [], kind = "import", bindings = []) {
219
241
  if (typeof specifier !== "string") return;
220
242
  const locals = [];
221
243
  const seen = new Set();
@@ -224,7 +246,7 @@ export function extractJsImports(source) {
224
246
  seen.add(name);
225
247
  locals.push(name);
226
248
  }
227
- imports.push({ specifier, typeOnly: Boolean(typeOnly), names: locals });
249
+ imports.push({ specifier, typeOnly: Boolean(typeOnly), names: locals, kind, bindings: typeOnly ? [] : bindings });
228
250
  }
229
251
 
230
252
  function parseRequire(at) {
@@ -234,7 +256,7 @@ export function extractJsImports(source) {
234
256
  if (!lit) return at;
235
257
  const after = skipWs(source, lit.next);
236
258
  if (source[after] !== ")") return at;
237
- add(lit.value, false);
259
+ add(lit.value, false, [], "dynamic");
238
260
  return after + 1;
239
261
  }
240
262
 
@@ -245,7 +267,7 @@ export function extractJsImports(source) {
245
267
  if (!lit) return at;
246
268
  const after = skipWs(source, lit.next);
247
269
  if (source[after] !== ")") return at;
248
- add(lit.value, false);
270
+ add(lit.value, false, [], "dynamic");
249
271
  return after + 1;
250
272
  }
251
273
  if (source[j] === ".") return at;
@@ -264,22 +286,26 @@ export function extractJsImports(source) {
264
286
  const clause = readFromClause(source, close + 1);
265
287
  if (!clause) return close + 1;
266
288
  const onlyTypes = typeOnly || braceIsTypeOnly(inner);
267
- add(clause.value, onlyTypes, typeOnly ? [] : localNames(inner));
289
+ add(clause.value, onlyTypes, typeOnly ? [] : localNames(inner), "import", typeOnly ? [] : braceBindings(inner));
268
290
  return clause.next;
269
291
  }
270
292
 
271
293
  if (source[j] === "*" || isIdentStart(source[j]) || source[j] === "'" || source[j] === '"') {
272
294
  if (source[j] === "'" || source[j] === '"') {
273
295
  const lit = readJsString(source, j);
274
- if (lit) add(lit.value, typeOnly);
296
+ if (lit) add(lit.value, typeOnly, [], "side-effect");
275
297
  return lit ? lit.next : j;
276
298
  }
277
299
  // default binding, namespace, or `name, { ... }`
278
300
  let sawValue = !typeOnly;
279
301
  const names = [];
302
+ const bindings = [];
280
303
  if (source[j] !== "*") {
281
304
  const binding = readWord(source, j);
282
- if (binding.word && !typeOnly) names.push(binding.word);
305
+ if (binding.word && !typeOnly) {
306
+ names.push(binding.word);
307
+ bindings.push({ imported: "default", local: binding.word });
308
+ }
283
309
  j = skipWs(source, binding.next);
284
310
  if (source[j] === ",") {
285
311
  sawValue = !typeOnly;
@@ -288,7 +314,10 @@ export function extractJsImports(source) {
288
314
  } else {
289
315
  j = skipWs(source, j + 1);
290
316
  const alias = readAlias(source, j);
291
- if (alias.name && !typeOnly) names.push(alias.name);
317
+ if (alias.name && !typeOnly) {
318
+ names.push(alias.name);
319
+ bindings.push({ imported: "*", local: alias.name });
320
+ }
292
321
  j = skipWs(source, alias.next);
293
322
  }
294
323
  if (source[j] === "{") {
@@ -298,13 +327,16 @@ export function extractJsImports(source) {
298
327
  const clause = readFromClause(source, close + 1);
299
328
  if (!clause) return close + 1;
300
329
  const onlyTypes = typeOnly || (!sawValue && braceIsTypeOnly(inner));
301
- if (!onlyTypes) names.push(...localNames(inner));
302
- add(clause.value, onlyTypes, names);
330
+ if (!onlyTypes) {
331
+ names.push(...localNames(inner));
332
+ bindings.push(...braceBindings(inner));
333
+ }
334
+ add(clause.value, onlyTypes, names, "import", bindings);
303
335
  return clause.next;
304
336
  }
305
337
  const clause = readFromClause(source, j);
306
338
  if (!clause) return at;
307
- add(clause.value, typeOnly, typeOnly ? [] : names);
339
+ add(clause.value, typeOnly, typeOnly ? [] : names, "import", bindings);
308
340
  return clause.next;
309
341
  }
310
342
 
@@ -322,7 +354,7 @@ export function extractJsImports(source) {
322
354
  if (source[j] === "*") {
323
355
  const clause = readFromClause(source, j + 1);
324
356
  if (!clause) return at;
325
- add(clause.value, typeOnly);
357
+ add(clause.value, typeOnly, [], "reexport-all");
326
358
  return clause.next;
327
359
  }
328
360
  if (source[j] === "{") {
@@ -331,7 +363,8 @@ export function extractJsImports(source) {
331
363
  const inner = source.slice(j + 1, close);
332
364
  const clause = readFromClause(source, close + 1);
333
365
  if (!clause) return close + 1;
334
- add(clause.value, typeOnly || braceIsTypeOnly(inner));
366
+ const bindings = braceBindings(inner).map((item) => ({ imported: item.imported, local: null }));
367
+ add(clause.value, typeOnly || braceIsTypeOnly(inner), [], "reexport", bindings);
335
368
  return clause.next;
336
369
  }
337
370
  return at;
@@ -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
+ }