@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.
- package/CHANGELOG.md +57 -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/cycle-priority.js +3 -0
- package/src/analyzers/dependency-graph.js +265 -34
- package/src/analyzers/flow-inference.js +10 -1
- package/src/analyzers/graphql-analyzer.js +80 -32
- package/src/analyzers/import-parsers.js +47 -14
- package/src/analyzers/language-coverage.js +222 -0
- package/src/analyzers/ts-elision.js +921 -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 +34 -2
- package/src/utils/js-lexer.js +146 -0
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
// Dependency graph analysis with cycle detection.
|
|
2
2
|
// JavaScript, TypeScript, Python, and Go imports become edges.
|
|
3
|
-
// Comments and strings are ignored. Type-only TypeScript imports are not runtime edges
|
|
3
|
+
// Comments and strings are ignored. Type-only TypeScript imports are not runtime edges:
|
|
4
|
+
// `import type` / `export type` / all-`type` specifier lists (syntax), and imports the
|
|
5
|
+
// TypeScript compiler removes because every binding is used only as a type or names
|
|
6
|
+
// an interface or type alias (inferred, see ts-elision.js).
|
|
4
7
|
// Cycle priority is in cycle-priority.js: mutual recursion and type-only cycles are
|
|
5
8
|
// not "Priority: High refactor". Large multi-module cycles stay high.
|
|
6
9
|
|
|
@@ -19,6 +22,9 @@ import {
|
|
|
19
22
|
parseGoModule,
|
|
20
23
|
} from "./import-parsers.js";
|
|
21
24
|
import { isNonProductPath } from "../utils/path-roles.js";
|
|
25
|
+
import { computeLanguageCoverage, coverageLine } from "./language-coverage.js";
|
|
26
|
+
import { listWorkspacePackages } from "./workspace-packages.js";
|
|
27
|
+
import { usageByName, declaredKinds, createElisionSettings, isTypeScriptFile } from "./ts-elision.js";
|
|
22
28
|
import {
|
|
23
29
|
classifyRuntimeCycles,
|
|
24
30
|
priorityCounts,
|
|
@@ -72,6 +78,77 @@ function normalizeFileKey(file) {
|
|
|
72
78
|
return key;
|
|
73
79
|
}
|
|
74
80
|
|
|
81
|
+
// Source-tree candidates for `pkg` or `pkg/subpath` inside a workspace package.
|
|
82
|
+
// `exports`/`main` usually point at build output (dist/, lib/, build/), so those
|
|
83
|
+
// paths are mapped back to src/ as well as tried as written.
|
|
84
|
+
function workspaceCandidates(pkg, subpath) {
|
|
85
|
+
const base = pkg.dir ? `${pkg.dir}/` : "";
|
|
86
|
+
const out = [];
|
|
87
|
+
const push = (rel) => {
|
|
88
|
+
if (typeof rel !== "string" || !rel) return;
|
|
89
|
+
let clean = rel.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
|
|
90
|
+
// exports/main targets must stay inside the package: no absolute paths, no `..`,
|
|
91
|
+
// no wildcards or URLs.
|
|
92
|
+
if (clean.startsWith("/") || /^[A-Za-z]:/.test(clean) || clean.includes("*") || clean.includes(":")) return;
|
|
93
|
+
if (clean.split("/").includes("..")) return;
|
|
94
|
+
clean = clean.replace(/\.d\.[cm]?ts$/, "").replace(/\.(js|ts|jsx|tsx|mjs|cjs)$/, "");
|
|
95
|
+
if (!clean || clean === ".") {
|
|
96
|
+
out.push(pkg.dir || ".");
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
out.push(`${base}${clean}`);
|
|
100
|
+
const mapped = clean.replace(/^(dist|lib|build|out|esm|cjs)(\/(esm|cjs|es|src))?\//, "src/");
|
|
101
|
+
if (mapped !== clean) out.push(`${base}${mapped}`);
|
|
102
|
+
};
|
|
103
|
+
const manifest = pkg.manifest || {};
|
|
104
|
+
const exportKey = subpath ? `./${subpath}` : ".";
|
|
105
|
+
const exportsField = manifest.exports;
|
|
106
|
+
let target = null;
|
|
107
|
+
if (typeof exportsField === "string" && !subpath) target = exportsField;
|
|
108
|
+
else if (exportsField && typeof exportsField === "object") {
|
|
109
|
+
const entry = exportKey in exportsField ? exportsField[exportKey] : (!subpath && !Object.keys(exportsField).some((k) => k.startsWith(".")) ? exportsField : undefined);
|
|
110
|
+
target = pickExportTarget(entry);
|
|
111
|
+
}
|
|
112
|
+
if (target) push(target);
|
|
113
|
+
if (subpath) {
|
|
114
|
+
push(`src/${subpath}`);
|
|
115
|
+
push(subpath);
|
|
116
|
+
push(`lib/${subpath}`);
|
|
117
|
+
} else {
|
|
118
|
+
for (const field of ["source", "module", "main"]) {
|
|
119
|
+
if (typeof manifest[field] === "string") push(manifest[field]);
|
|
120
|
+
}
|
|
121
|
+
push("src/index");
|
|
122
|
+
push("index");
|
|
123
|
+
push("lib/index");
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function pickExportTarget(entry) {
|
|
129
|
+
if (typeof entry === "string") return entry;
|
|
130
|
+
if (Array.isArray(entry)) {
|
|
131
|
+
for (const item of entry) {
|
|
132
|
+
const found = pickExportTarget(item);
|
|
133
|
+
if (found) return found;
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
if (entry && typeof entry === "object") {
|
|
138
|
+
for (const condition of ["source", "development", "import", "module", "default", "require", "node", "types"]) {
|
|
139
|
+
if (condition in entry) {
|
|
140
|
+
const found = pickExportTarget(entry[condition]);
|
|
141
|
+
if (found) return found;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
for (const value of Object.values(entry)) {
|
|
145
|
+
const found = pickExportTarget(value);
|
|
146
|
+
if (found) return found;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
|
|
75
152
|
function emptyResult() {
|
|
76
153
|
return {
|
|
77
154
|
nodes: [],
|
|
@@ -79,6 +156,7 @@ function emptyResult() {
|
|
|
79
156
|
cycles: [],
|
|
80
157
|
cycleReports: [],
|
|
81
158
|
typeOnlyReports: [],
|
|
159
|
+
typeOnlyEdges: [],
|
|
82
160
|
externalDeps: [],
|
|
83
161
|
externalRuntime: [],
|
|
84
162
|
externalDev: [],
|
|
@@ -151,8 +229,14 @@ function goFilesInDir(goFiles, dir) {
|
|
|
151
229
|
});
|
|
152
230
|
}
|
|
153
231
|
|
|
154
|
-
export async function analyzeDependencyGraph(files, repoRoot) {
|
|
232
|
+
export async function analyzeDependencyGraph(files, repoRoot, { coverageFiles = files } = {}) {
|
|
155
233
|
const result = emptyResult();
|
|
234
|
+
const coverage = computeLanguageCoverage(coverageFiles || files);
|
|
235
|
+
result.coverage = coverage;
|
|
236
|
+
if (coverage.status === "unsupported") {
|
|
237
|
+
info(`Dependency graph: not built — ${coverageLine(coverage, "import analysis")}`);
|
|
238
|
+
return result;
|
|
239
|
+
}
|
|
156
240
|
|
|
157
241
|
const jsFiles = files.filter((file) => JS_EXTENSIONS.has(path.extname(file)));
|
|
158
242
|
const pyFiles = files.filter((file) => PY_EXTENSIONS.has(path.extname(file)));
|
|
@@ -191,38 +275,66 @@ export async function analyzeDependencyGraph(files, repoRoot) {
|
|
|
191
275
|
|
|
192
276
|
// A package importing its own name (examples, docs, tests written like user code)
|
|
193
277
|
// is not a dependency. Resolve the nearest package.json for each file.
|
|
194
|
-
const
|
|
195
|
-
async function
|
|
196
|
-
let dir = path.dirname(file);
|
|
278
|
+
const packageByDir = new Map();
|
|
279
|
+
async function ownPackage(file) {
|
|
280
|
+
let dir = path.posix.dirname(String(file).replace(/\\/g, "/"));
|
|
197
281
|
const visited = [];
|
|
198
282
|
for (;;) {
|
|
199
|
-
if (
|
|
200
|
-
const cached =
|
|
201
|
-
for (const d of visited)
|
|
283
|
+
if (packageByDir.has(dir)) {
|
|
284
|
+
const cached = packageByDir.get(dir);
|
|
285
|
+
for (const d of visited) packageByDir.set(d, cached);
|
|
202
286
|
return cached;
|
|
203
287
|
}
|
|
204
288
|
visited.push(dir);
|
|
205
289
|
const raw = await readFileSafe(path.join(repoRoot, dir, "package.json"));
|
|
206
290
|
if (raw) {
|
|
207
|
-
let
|
|
291
|
+
let found = null;
|
|
208
292
|
try {
|
|
209
293
|
const parsed = JSON.parse(raw);
|
|
210
|
-
if (typeof parsed?.name === "string"
|
|
294
|
+
if (typeof parsed?.name === "string" && parsed.name) found = { name: parsed.name, manifest: parsed };
|
|
211
295
|
} catch {
|
|
212
296
|
// invalid package.json: keep walking up
|
|
213
297
|
}
|
|
214
|
-
if (
|
|
215
|
-
for (const d of visited)
|
|
216
|
-
return
|
|
298
|
+
if (found) {
|
|
299
|
+
for (const d of visited) packageByDir.set(d, found);
|
|
300
|
+
return found;
|
|
217
301
|
}
|
|
218
302
|
}
|
|
219
|
-
if (dir === "." || dir === "" || dir === path.dirname(dir)) break;
|
|
220
|
-
dir = path.dirname(dir);
|
|
303
|
+
if (dir === "." || dir === "" || dir === path.posix.dirname(dir)) break;
|
|
304
|
+
dir = path.posix.dirname(dir);
|
|
221
305
|
}
|
|
222
|
-
for (const d of visited)
|
|
306
|
+
for (const d of visited) packageByDir.set(d, null);
|
|
223
307
|
return null;
|
|
224
308
|
}
|
|
225
309
|
|
|
310
|
+
// Declared workspace members only (package.json workspaces, pnpm-workspace.yaml,
|
|
311
|
+
// lerna.json). In a monorepo, `@scope/pkg` naming a member is an internal edge.
|
|
312
|
+
const workspacePackages = new Map();
|
|
313
|
+
if (jsFiles.length > 0) {
|
|
314
|
+
const { packages } = await listWorkspacePackages(repoRoot);
|
|
315
|
+
for (const pkg of packages) workspacePackages.set(pkg.name, pkg);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function resolveWorkspaceImport(specifier, importer) {
|
|
319
|
+
const name = externalPackageName(specifier);
|
|
320
|
+
if (!name) return null;
|
|
321
|
+
const pkg = workspacePackages.get(name);
|
|
322
|
+
if (!pkg) return null;
|
|
323
|
+
// `"name": "npm:other@1"` (or a registry version of a same-named package installed
|
|
324
|
+
// under an alias) means the importer uses the registry package, not the member.
|
|
325
|
+
const manifest = importer?.manifest || {};
|
|
326
|
+
for (const field of ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"]) {
|
|
327
|
+
const spec = manifest[field]?.[name];
|
|
328
|
+
if (typeof spec === "string" && spec.startsWith("npm:")) return null;
|
|
329
|
+
}
|
|
330
|
+
const subpath = specifier.slice(name.length).replace(/^\/+/, "");
|
|
331
|
+
for (const candidate of workspaceCandidates(pkg, subpath)) {
|
|
332
|
+
const key = normalizeFileKey(candidate);
|
|
333
|
+
if (allFileKeys.has(key)) return { name, key };
|
|
334
|
+
}
|
|
335
|
+
return { name, key: null };
|
|
336
|
+
}
|
|
337
|
+
|
|
226
338
|
const pyIndex = indexPythonModules(pyFiles);
|
|
227
339
|
const goModulePath = goFiles.length > 0 ? await loadGoModule(repoRoot) : null;
|
|
228
340
|
|
|
@@ -230,7 +342,7 @@ export async function analyzeDependencyGraph(files, repoRoot) {
|
|
|
230
342
|
// and are classified on their own so they are never "Priority: High refactor".
|
|
231
343
|
const edgeRecords = new Map();
|
|
232
344
|
|
|
233
|
-
function rememberEdge(fromKey, toKey, fromFile, typeOnly, calls) {
|
|
345
|
+
function rememberEdge(fromKey, toKey, fromFile, typeOnly, calls, reason = null) {
|
|
234
346
|
if (!toKey || toKey === fromKey || !allFileKeys.has(toKey)) return;
|
|
235
347
|
const id = `${fromKey}->${toKey}`;
|
|
236
348
|
let record = edgeRecords.get(id);
|
|
@@ -241,46 +353,122 @@ export async function analyzeDependencyGraph(files, repoRoot) {
|
|
|
241
353
|
fromFile,
|
|
242
354
|
toFile: fileKeyToFile.get(toKey) || toKey,
|
|
243
355
|
typeOnly: true,
|
|
356
|
+
typeOnlyReason: null,
|
|
244
357
|
calls: 0,
|
|
245
358
|
};
|
|
246
359
|
edgeRecords.set(id, record);
|
|
247
360
|
}
|
|
248
361
|
if (!typeOnly) {
|
|
249
362
|
record.typeOnly = false;
|
|
363
|
+
record.typeOnlyReason = null;
|
|
250
364
|
record.calls += calls;
|
|
365
|
+
} else if (record.typeOnly) {
|
|
366
|
+
// "syntax" (`import type`) wins over "inferred" when both reach the same file.
|
|
367
|
+
if (reason === "syntax" || !record.typeOnlyReason) record.typeOnlyReason = reason || "syntax";
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// TypeScript import elision. Contents and declared kinds are cached per file.
|
|
372
|
+
const elisionFor = createElisionSettings(repoRoot);
|
|
373
|
+
const contentByKey = new Map();
|
|
374
|
+
const kindsByKey = new Map();
|
|
375
|
+
async function contentOf(key) {
|
|
376
|
+
if (!contentByKey.has(key)) {
|
|
377
|
+
const file = fileKeyToFile.get(key);
|
|
378
|
+
contentByKey.set(key, file ? await readFileSafe(path.join(repoRoot, file)) : "");
|
|
251
379
|
}
|
|
380
|
+
return contentByKey.get(key);
|
|
381
|
+
}
|
|
382
|
+
async function kindsOf(key) {
|
|
383
|
+
if (!kindsByKey.has(key)) {
|
|
384
|
+
const file = fileKeyToFile.get(key);
|
|
385
|
+
kindsByKey.set(key, file && isTypeScriptFile(file) ? declaredKinds(await contentOf(key)) : null);
|
|
386
|
+
}
|
|
387
|
+
return kindsByKey.get(key);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// True when the compiler drops this import or re-export from the JavaScript output.
|
|
391
|
+
async function elidedByCompiler(imported, toKey, file, content, state) {
|
|
392
|
+
// A declaration file (`.d.ts`) never runs: none of its imports load a module.
|
|
393
|
+
if (/\.d\.[cm]?ts$/.test(file)) return true;
|
|
394
|
+
if (!isTypeScriptFile(file)) return false;
|
|
395
|
+
if (imported.kind !== "import" && imported.kind !== "reexport") return false;
|
|
396
|
+
const bindings = imported.bindings || [];
|
|
397
|
+
if (bindings.length === 0) return false;
|
|
398
|
+
if (!state.settings) state.settings = await elisionFor(file);
|
|
399
|
+
if (!state.settings.elide) return false;
|
|
400
|
+
// `emitDecoratorMetadata` turns annotations on decorated members into runtime
|
|
401
|
+
// references (`design:paramtypes`); isolated transpilers (swc, esbuild) keep
|
|
402
|
+
// even interface imports there. A decorated file under it keeps every import.
|
|
403
|
+
if (!state.usage) state.usage = usageByName(content, { jsx: /\.[jt]sx$/.test(file) });
|
|
404
|
+
if (state.settings.decoratorMetadata && state.usage.decorators) return false;
|
|
405
|
+
const kinds = await kindsOf(toKey);
|
|
406
|
+
const declaredType = (name) => Boolean(kinds) && name !== "default" && name !== "*" && kinds.types.has(name);
|
|
407
|
+
if (imported.kind === "reexport") return bindings.every((item) => declaredType(item.imported));
|
|
408
|
+
// Usage-based inference: .ts/.mts/.cts only. In .tsx, JSX text can look like code
|
|
409
|
+
// (`list as they're posted`), so only interface / type alias names count there.
|
|
410
|
+
// When part of the tsconfig `extends` chain is not in the checkout, only
|
|
411
|
+
// interface / type alias names count (`inferUsage: false`).
|
|
412
|
+
// A file the tokenizer misread (`malformed`) gets the same treatment.
|
|
413
|
+
const useInference = !/\.tsx$/.test(file) && state.settings.inferUsage !== false && !state.usage.malformed;
|
|
414
|
+
return bindings.every((item) => {
|
|
415
|
+
if (declaredType(item.imported)) return true;
|
|
416
|
+
if (!useInference || !item.local) return false;
|
|
417
|
+
const use = state.usage.uses.get(item.local);
|
|
418
|
+
return !use || use.value === 0;
|
|
419
|
+
});
|
|
252
420
|
}
|
|
253
421
|
|
|
254
422
|
for (const file of jsFiles) {
|
|
255
423
|
const content = await readFileSafe(path.join(repoRoot, file));
|
|
256
424
|
if (!content) continue;
|
|
257
425
|
const fromKey = normalizeFileKey(file);
|
|
258
|
-
const
|
|
426
|
+
const importer = await ownPackage(file);
|
|
427
|
+
const selfName = importer ? importer.name : null;
|
|
259
428
|
const grouped = new Map();
|
|
429
|
+
const state = { settings: null, usage: null };
|
|
260
430
|
for (const imported of extractJsImports(content)) {
|
|
431
|
+
let toKey = null;
|
|
261
432
|
if (!isRelativeImport(imported.specifier)) {
|
|
262
|
-
if (imported.typeOnly) continue;
|
|
263
433
|
const name = externalPackageName(imported.specifier);
|
|
264
434
|
if (name && name === selfName) continue;
|
|
265
|
-
|
|
266
|
-
|
|
435
|
+
const workspace = resolveWorkspaceImport(imported.specifier, importer);
|
|
436
|
+
if (workspace) {
|
|
437
|
+
// Another package in this repository is internal: an edge when it resolves
|
|
438
|
+
// to a scanned file, and never an external dependency either way.
|
|
439
|
+
if (!workspace.key) continue;
|
|
440
|
+
toKey = workspace.key;
|
|
441
|
+
} else {
|
|
442
|
+
if (imported.typeOnly) continue;
|
|
443
|
+
addExternal(fromKey, name);
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
} else {
|
|
447
|
+
toKey = resolveImportPath(file, imported.specifier);
|
|
267
448
|
}
|
|
268
|
-
const toKey = resolveImportPath(file, imported.specifier);
|
|
269
449
|
if (!toKey) continue;
|
|
450
|
+
let typeOnly = imported.typeOnly;
|
|
451
|
+
let reason = typeOnly ? "syntax" : null;
|
|
452
|
+
if (!typeOnly && allFileKeys.has(toKey) && toKey !== fromKey && await elidedByCompiler(imported, toKey, file, content, state)) {
|
|
453
|
+
typeOnly = true;
|
|
454
|
+
reason = "inferred";
|
|
455
|
+
}
|
|
270
456
|
const id = `${fromKey}->${toKey}`;
|
|
271
457
|
let slot = grouped.get(id);
|
|
272
458
|
if (!slot) {
|
|
273
|
-
slot = { toKey, typeOnly: true, names: new Set() };
|
|
459
|
+
slot = { toKey, typeOnly: true, reason: null, names: new Set() };
|
|
274
460
|
grouped.set(id, slot);
|
|
275
461
|
}
|
|
276
|
-
if (!
|
|
462
|
+
if (!typeOnly) {
|
|
277
463
|
slot.typeOnly = false;
|
|
278
464
|
for (const binding of imported.names || []) slot.names.add(binding);
|
|
465
|
+
} else if (reason === "syntax" || !slot.reason) {
|
|
466
|
+
slot.reason = reason;
|
|
279
467
|
}
|
|
280
468
|
}
|
|
281
469
|
for (const slot of grouped.values()) {
|
|
282
470
|
const calls = slot.typeOnly ? 0 : countBindingCalls(content, [...slot.names]);
|
|
283
|
-
rememberEdge(fromKey, slot.toKey, file, slot.typeOnly, calls);
|
|
471
|
+
rememberEdge(fromKey, slot.toKey, file, slot.typeOnly, calls, slot.reason);
|
|
284
472
|
}
|
|
285
473
|
}
|
|
286
474
|
|
|
@@ -350,20 +538,49 @@ export async function analyzeDependencyGraph(files, repoRoot) {
|
|
|
350
538
|
const runtimeEdgeList = [...edgeRecords.values()].filter((edge) => !edge.typeOnly);
|
|
351
539
|
result.cycleReports = classifyRuntimeCycles(result.cycles, runtimeEdgeList, fileForKey);
|
|
352
540
|
|
|
353
|
-
|
|
541
|
+
// Type-only cycles: cycles of runtime + type-only edges that need at least one
|
|
542
|
+
// type-only edge. They are reported on their own and never as runtime cycles.
|
|
543
|
+
const combinedAdj = new Map();
|
|
544
|
+
for (const [from, deps] of adjacency) combinedAdj.set(from, new Set(deps));
|
|
545
|
+
const typeOnlyEdgeById = new Map();
|
|
354
546
|
for (const edge of edgeRecords.values()) {
|
|
355
547
|
if (!edge.typeOnly) continue;
|
|
356
|
-
|
|
357
|
-
if (!
|
|
358
|
-
|
|
548
|
+
typeOnlyEdgeById.set(`${edge.from}->${edge.to}`, edge);
|
|
549
|
+
if (!combinedAdj.has(edge.from)) combinedAdj.set(edge.from, new Set());
|
|
550
|
+
if (!combinedAdj.has(edge.to)) combinedAdj.set(edge.to, new Set());
|
|
551
|
+
combinedAdj.get(edge.from).add(edge.to);
|
|
359
552
|
}
|
|
360
|
-
result.typeOnlyReports =
|
|
553
|
+
result.typeOnlyReports = typeOnlyEdgeById.size === 0 ? [] : detectCycles(combinedAdj)
|
|
554
|
+
.map((cyclePath) => {
|
|
555
|
+
const links = [];
|
|
556
|
+
for (let i = 0; i + 1 < cyclePath.length; i += 1) {
|
|
557
|
+
const from = cyclePath[i];
|
|
558
|
+
const to = cyclePath[i + 1];
|
|
559
|
+
if (adjacency.get(from)?.has(to)) continue;
|
|
560
|
+
const edge = typeOnlyEdgeById.get(`${from}->${to}`);
|
|
561
|
+
if (edge) links.push({ from, to, reason: edge.typeOnlyReason || "syntax" });
|
|
562
|
+
}
|
|
563
|
+
if (links.length === 0) return null;
|
|
564
|
+
return { ...typeOnlyReport(cyclePath), typeOnlyLinks: links };
|
|
565
|
+
})
|
|
566
|
+
.filter(Boolean);
|
|
567
|
+
result.typeOnlyEdges = [...typeOnlyEdgeById.values()].map((edge) => ({
|
|
568
|
+
from: edge.from,
|
|
569
|
+
to: edge.to,
|
|
570
|
+
reason: edge.typeOnlyReason || "syntax",
|
|
571
|
+
}));
|
|
361
572
|
|
|
362
573
|
const priorities = priorityCounts(result.cycleReports);
|
|
363
574
|
|
|
364
575
|
// Stats
|
|
365
576
|
const totalImports = result.edges.length;
|
|
366
|
-
|
|
577
|
+
// A file connected only by type-only imports (a types module) is not dead code.
|
|
578
|
+
const typeConnected = new Set();
|
|
579
|
+
for (const edge of result.typeOnlyEdges) {
|
|
580
|
+
typeConnected.add(edge.from);
|
|
581
|
+
typeConnected.add(edge.to);
|
|
582
|
+
}
|
|
583
|
+
const orphans = result.nodes.filter(n => n.imports.length === 0 && n.importedBy.length === 0 && !typeConnected.has(n.key));
|
|
367
584
|
const sourceNodeCount = result.nodes.filter((n) => !isNonProductPath(n.file || n.key)).length;
|
|
368
585
|
const hubThreshold = Math.max(5, Math.round(sourceNodeCount * 0.07));
|
|
369
586
|
const hubs = aggregateHubs(result.nodes, fileKeyToFile, goModulePath, hubThreshold);
|
|
@@ -384,16 +601,22 @@ export async function analyzeDependencyGraph(files, repoRoot) {
|
|
|
384
601
|
highCycles: priorities.high,
|
|
385
602
|
mediumCycles: priorities.medium,
|
|
386
603
|
lowCycles: priorities.low,
|
|
604
|
+
typeOnlyCycles: result.typeOnlyReports.length,
|
|
605
|
+
typeOnlyEdges: result.typeOnlyEdges.length,
|
|
606
|
+
inferredTypeOnlyEdges: result.typeOnlyEdges.filter((edge) => edge.reason === "inferred").length,
|
|
387
607
|
orphanFiles: orphans.length,
|
|
388
608
|
hubs,
|
|
389
609
|
};
|
|
390
610
|
|
|
391
611
|
result.summary = buildSummary(result);
|
|
392
612
|
|
|
613
|
+
const typeOnlyNote = result.typeOnlyReports.length > 0
|
|
614
|
+
? ` (${result.typeOnlyReports.length} type-only cycle(s) not counted)`
|
|
615
|
+
: "";
|
|
393
616
|
if (result.cycles.length > 0) {
|
|
394
|
-
warn(`Dependency graph: ${result.cycles.length} circular dependency cycle(s) detected`);
|
|
617
|
+
warn(`Dependency graph: ${result.cycles.length} circular dependency cycle(s) detected${typeOnlyNote}`);
|
|
395
618
|
} else {
|
|
396
|
-
info(`Dependency graph: ${totalImports} edges across ${codeFiles.length} files, no cycles`);
|
|
619
|
+
info(`Dependency graph: ${totalImports} edges across ${codeFiles.length} files, no cycles${typeOnlyNote}`);
|
|
397
620
|
}
|
|
398
621
|
|
|
399
622
|
return result;
|
|
@@ -509,7 +732,15 @@ function buildSummary(result) {
|
|
|
509
732
|
if (result.stats.cycles > 0) {
|
|
510
733
|
parts.push(`⚠️ ${result.stats.cycles} circular dependency cycle(s)`);
|
|
511
734
|
} else {
|
|
512
|
-
parts.push(
|
|
735
|
+
parts.push(result.coverage?.status === "partial"
|
|
736
|
+
? "✓ no circular dependencies among analyzed files"
|
|
737
|
+
: "✓ no circular dependencies");
|
|
738
|
+
}
|
|
739
|
+
if (result.stats.typeOnlyCycles > 0) {
|
|
740
|
+
parts.push(`${result.stats.typeOnlyCycles} type-only cycle(s), not runtime`);
|
|
741
|
+
}
|
|
742
|
+
if (result.coverage?.status === "partial") {
|
|
743
|
+
parts.push(`partial coverage: ${result.coverage.analyzedBase} of ${result.coverage.totalBase} source files`);
|
|
513
744
|
}
|
|
514
745
|
if (result.stats.orphanFiles > 0) {
|
|
515
746
|
parts.push(`${result.stats.orphanFiles} orphan file(s)`);
|
|
@@ -114,7 +114,7 @@ function inferFlowsFromEntryPoints(scanResult, depGraph) {
|
|
|
114
114
|
// Classify each step in the chain
|
|
115
115
|
const steps = chain.map((nodeKey, i) => {
|
|
116
116
|
const node = depGraph.nodes.find(n => n.key === nodeKey);
|
|
117
|
-
const name = nodeKey
|
|
117
|
+
const name = stepName(nodeKey);
|
|
118
118
|
const fanIn = node?.importedBy?.length || 0;
|
|
119
119
|
const fanOut = node?.imports?.length || 0;
|
|
120
120
|
|
|
@@ -342,3 +342,12 @@ function inferExternalDependencies(flow) {
|
|
|
342
342
|
|
|
343
343
|
return external;
|
|
344
344
|
}
|
|
345
|
+
|
|
346
|
+
// A package entry is keyed by its folder (`packages/server/src` for src/index.ts), so
|
|
347
|
+
// "src" or "lib" alone says nothing. Name it after the package folder instead.
|
|
348
|
+
function stepName(nodeKey) {
|
|
349
|
+
const parts = String(nodeKey).split("/");
|
|
350
|
+
const last = parts.pop();
|
|
351
|
+
if (/^(src|lib|source)$/i.test(last) && parts.length > 0) return `${parts[parts.length - 1]}/${last}`;
|
|
352
|
+
return last;
|
|
353
|
+
}
|
|
@@ -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);
|