55"""
66
77import ast
8+ import fnmatch
89import json
910import os
1011import re
2324# tsconfig wins over jsconfig when a dir carries both (jsconfig is the
2425# JS-only subset of the same format).
2526JS_CONFIG_FILES = ("tsconfig.json" , "jsconfig.json" )
27+ # `- 'packages/*'` list item under the packages: key of pnpm-workspace.yaml
28+ # (flat list of quoted-or-bare globs; a full YAML parser is not needed).
29+ PNPM_ITEM_RE = re .compile (r"^\s*-\s*['\"]?([^'\"#\n]+?)['\"]?\s*$" )
2630# [package] section of a Cargo.toml, up to the next table header.
2731CARGO_PACKAGE_RE = re .compile (r"^\[package\]\s*$(.*?)(?=^\[|\Z)" , re .M | re .S )
2832CARGO_NAME_RE = re .compile (r'^\s*name\s*=\s*"([^"]+)"' , re .M )
4751JVM_EXTS = (".java" , ".kt" )
4852
4953
50- def collect (repo , tree = None ):
54+ def collect (repo , tree = None , stats = None ):
5155 """Return [{"from": dir, "to": dir, "lang": lang, "count": n}] sorted by count.
5256
5357 tree: optional pre-built {rel_dir: filenames} to avoid re-walking the repo.
58+ stats: optional dict populated with extraction statistics —
59+ js_alias_unresolved: imports that matched a tsconfig/jsconfig paths
60+ pattern but whose targets exist nowhere in the tree (dropped edges).
5461 """
5562 counter = Counter ()
5663 if tree is None :
5764 tree = {rel : files for rel , files in walk (repo )}
65+ if stats is None :
66+ stats = {}
5867 dirs = set (tree )
5968
6069 _go_edges (repo , tree , counter )
6170 _py_edges (repo , tree , dirs , counter )
62- _js_edges (repo , tree , dirs , counter )
71+ _js_edges (repo , tree , dirs , counter , stats )
6372 _rust_edges (repo , tree , counter )
6473 _jvm_edges (repo , tree , counter )
6574
@@ -165,11 +174,15 @@ def _py_edge(rel, module, level, pkg_map, dirs, counter):
165174
166175# -- JS / TS -----------------------------------------------------------------
167176
168- def _js_edges (repo , tree , dirs , counter ):
177+ def _js_edges (repo , tree , dirs , counter , stats ):
169178 """Relative imports resolve directly; non-relative specifiers go through
170- the nearest tsconfig/jsconfig's `paths` aliases and `baseUrl` (bare
171- third-party imports match nothing there and drop out)."""
179+ the nearest tsconfig/jsconfig's `paths` aliases and `baseUrl`, then
180+ workspace package names (bare third-party imports match nothing in
181+ either and drop out). Alias imports whose pattern matched but whose
182+ targets ground nowhere are counted in stats — that is silent coverage
183+ loss otherwise."""
172184 configs = _js_configs (repo , tree )
185+ workspaces = _js_workspaces (repo , tree )
173186 for rel , files in tree .items ():
174187 cfg = _owning_root (rel , configs ) if configs else None
175188 for f in files :
@@ -178,10 +191,17 @@ def _js_edges(repo, tree, dirs, counter):
178191 for imp in JS_IMPORT_RE .findall (_read (repo , rel , f )):
179192 if imp .startswith ("." ):
180193 target = _existing_dir (_norm (os .path .join (rel , imp )), dirs )
181- elif cfg is not None and not imp .startswith ("/" ):
182- target = _js_alias_resolve (imp , configs [cfg ], dirs )
183- else :
194+ elif imp .startswith ("/" ):
184195 target = None
196+ else :
197+ target , matched = (
198+ _js_alias_resolve (imp , configs [cfg ], dirs )
199+ if cfg is not None else (None , False ))
200+ if target is None and workspaces :
201+ target = _js_workspace_resolve (imp , workspaces , dirs )
202+ if target is None and matched :
203+ stats ["js_alias_unresolved" ] = \
204+ stats .get ("js_alias_unresolved" , 0 ) + 1
185205 if target is not None and target != rel :
186206 counter [(rel , target , "JS/TS" )] += 1
187207
@@ -229,9 +249,12 @@ def _js_configs(repo, tree):
229249
230250def _js_alias_resolve (imp , cfg , dirs ):
231251 """Resolve a non-relative specifier through `paths` patterns (first
232- existing substitution wins), then baseUrl-relative lookup; None when
233- nothing grounds in the repo tree."""
252+ existing substitution wins), then baseUrl-relative lookup.
253+
254+ Returns (target dir or None, whether a paths pattern matched) — a match
255+ with no grounded target is disclosed as an uncertainty note upstream."""
234256 paths_base , patterns , bare_base = cfg
257+ matched = False
235258 for pat , vals in patterns :
236259 star = pat .find ("*" )
237260 if star == - 1 :
@@ -244,19 +267,99 @@ def _js_alias_resolve(imp, cfg, dirs):
244267 and imp .startswith (pre ) and imp .endswith (suf )):
245268 continue
246269 stem = imp [len (pre ):len (imp ) - len (suf )]
270+ matched = True
247271 for val in vals :
248272 target = _existing_dir (
249273 _norm (os .path .join (paths_base , val .replace ("*" , stem , 1 ))),
250274 dirs )
251275 if target is not None :
252- return target
276+ return target , True
253277 if bare_base is not None :
254278 target = _existing_dir (_norm (os .path .join (bare_base , imp )), dirs )
255279 # _existing_dir's parent fallback would resolve any bare specifier
256280 # whose first segment is missing ('react', 'lodash') to bare_base
257281 # itself — those are third-party packages, not internal edges.
258282 if target is not None and target != bare_base :
259- return target
283+ return target , matched
284+ return None , matched
285+
286+
287+ def _js_workspaces (repo , tree ):
288+ """{package name: workspace dir} for monorepo workspaces.
289+
290+ Globs come from package.json `workspaces` (npm/yarn; array or
291+ {packages: [...]}) and pnpm-workspace.yaml `packages:` lists — any dir
292+ may declare them, so nested workspace roots work too. A matched dir
293+ counts only if its own package.json carries a `name`; that name is what
294+ `import '@scope/pkg'` specifiers resolve against.
295+ """
296+ names = {}
297+ for rel , files in tree .items ():
298+ globs = []
299+ if "package.json" in files :
300+ data = _jsonc_loads (_read (repo , rel , "package.json" ))
301+ ws = data .get ("workspaces" ) if isinstance (data , dict ) else None
302+ if isinstance (ws , dict ):
303+ ws = ws .get ("packages" )
304+ if isinstance (ws , list ):
305+ globs .extend (g for g in ws if isinstance (g , str ))
306+ if "pnpm-workspace.yaml" in files :
307+ globs .extend (_pnpm_globs (_read (repo , rel , "pnpm-workspace.yaml" )))
308+ for g in globs :
309+ if g .startswith ("!" ): # negation globs: rare, not modeled
310+ continue
311+ pat = _norm (os .path .join (rel , g )).split ("/" )
312+ for wdir , wfiles in tree .items ():
313+ if not wdir or "package.json" not in wfiles :
314+ continue
315+ if not _segments_match (pat , wdir .split ("/" )):
316+ continue
317+ pkg = _jsonc_loads (_read (repo , wdir , "package.json" ))
318+ name = pkg .get ("name" ) if isinstance (pkg , dict ) else None
319+ if isinstance (name , str ) and name :
320+ names [name ] = wdir
321+ return names
322+
323+
324+ def _pnpm_globs (text ):
325+ """Glob items of the top-level packages: list in pnpm-workspace.yaml."""
326+ globs , in_packages = [], False
327+ for line in text .splitlines ():
328+ if not line .strip () or line .lstrip ().startswith ("#" ):
329+ continue
330+ if not line [0 ].isspace () and line [0 ] != "-" :
331+ in_packages = line .split (":" )[0 ].strip () == "packages"
332+ continue
333+ if in_packages :
334+ m = PNPM_ITEM_RE .match (line )
335+ if m :
336+ globs .append (m .group (1 ))
337+ return globs
338+
339+
340+ def _segments_match (pat , segs ):
341+ """Segment-wise glob match: `*` spans one path segment (unlike fnmatch
342+ on the whole string), `**` spans any number."""
343+ if not pat :
344+ return not segs
345+ if pat [0 ] == "**" :
346+ return any (_segments_match (pat [1 :], segs [i :])
347+ for i in range (len (segs ) + 1 ))
348+ return (bool (segs ) and fnmatch .fnmatchcase (segs [0 ], pat [0 ])
349+ and _segments_match (pat [1 :], segs [1 :]))
350+
351+
352+ def _js_workspace_resolve (imp , workspaces , dirs ):
353+ """Workspace package name -> its dir; a subpath import grounds inside
354+ the package dir when it exists there, else falls back to the dir itself
355+ (real subpaths map through package `exports`, which is not modeled)."""
356+ for name , wdir in workspaces .items ():
357+ if imp == name :
358+ return wdir
359+ if imp .startswith (name + "/" ):
360+ sub = imp [len (name ) + 1 :]
361+ target = _existing_dir (_norm (os .path .join (wdir , sub )), dirs )
362+ return target if target is not None else wdir
260363 return None
261364
262365
0 commit comments