@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.
@@ -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 packageNameByDir = new Map();
195
- async function ownPackageName(file) {
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 (packageNameByDir.has(dir)) {
200
- const cached = packageNameByDir.get(dir);
201
- for (const d of visited) packageNameByDir.set(d, cached);
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 name = null;
291
+ let found = null;
208
292
  try {
209
293
  const parsed = JSON.parse(raw);
210
- if (typeof parsed?.name === "string") name = parsed.name;
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 (name) {
215
- for (const d of visited) packageNameByDir.set(d, name);
216
- return name;
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) packageNameByDir.set(d, null);
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 selfName = await ownPackageName(file);
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
- addExternal(fromKey, name);
266
- continue;
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 (!imported.typeOnly) {
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
- const typeOnlyAdj = new Map();
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
- if (!typeOnlyAdj.has(edge.from)) typeOnlyAdj.set(edge.from, new Set());
357
- if (!typeOnlyAdj.has(edge.to)) typeOnlyAdj.set(edge.to, new Set());
358
- typeOnlyAdj.get(edge.from).add(edge.to);
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 = detectCycles(typeOnlyAdj).map((path) => typeOnlyReport(path));
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
- const orphans = result.nodes.filter(n => n.imports.length === 0 && n.importedBy.length === 0);
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("✓ no circular dependencies");
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.split("/").pop();
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
- /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);