Skip to content

Commit b607673

Browse files
authored
feat: workspace package-name resolution for JS/TS imports (issue-3, PR 2/3) (#35)
1 parent 1b8b5f2 commit b607673

2 files changed

Lines changed: 208 additions & 6 deletions

File tree

src/repokg/deps.py

Lines changed: 93 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"""
66

77
import ast
8+
import fnmatch
89
import json
910
import os
1011
import re
@@ -23,6 +24,9 @@
2324
# tsconfig wins over jsconfig when a dir carries both (jsconfig is the
2425
# JS-only subset of the same format).
2526
JS_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.
2731
CARGO_PACKAGE_RE = re.compile(r"^\[package\]\s*$(.*?)(?=^\[|\Z)", re.M | re.S)
2832
CARGO_NAME_RE = re.compile(r'^\s*name\s*=\s*"([^"]+)"', re.M)
@@ -167,9 +171,11 @@ def _py_edge(rel, module, level, pkg_map, dirs, counter):
167171

168172
def _js_edges(repo, tree, dirs, counter):
169173
"""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)."""
174+
the nearest tsconfig/jsconfig's `paths` aliases and `baseUrl`, then
175+
workspace package names (bare third-party imports match nothing in
176+
either and drop out)."""
172177
configs = _js_configs(repo, tree)
178+
workspaces = _js_workspaces(repo, tree)
173179
for rel, files in tree.items():
174180
cfg = _owning_root(rel, configs) if configs else None
175181
for f in files:
@@ -178,10 +184,13 @@ def _js_edges(repo, tree, dirs, counter):
178184
for imp in JS_IMPORT_RE.findall(_read(repo, rel, f)):
179185
if imp.startswith("."):
180186
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:
187+
elif imp.startswith("/"):
184188
target = None
189+
else:
190+
target = (_js_alias_resolve(imp, configs[cfg], dirs)
191+
if cfg is not None else None)
192+
if target is None and workspaces:
193+
target = _js_workspace_resolve(imp, workspaces, dirs)
185194
if target is not None and target != rel:
186195
counter[(rel, target, "JS/TS")] += 1
187196

@@ -260,6 +269,85 @@ def _js_alias_resolve(imp, cfg, dirs):
260269
return None
261270

262271

272+
def _js_workspaces(repo, tree):
273+
"""{package name: workspace dir} for monorepo workspaces.
274+
275+
Globs come from package.json `workspaces` (npm/yarn; array or
276+
{packages: [...]}) and pnpm-workspace.yaml `packages:` lists — any dir
277+
may declare them, so nested workspace roots work too. A matched dir
278+
counts only if its own package.json carries a `name`; that name is what
279+
`import '@scope/pkg'` specifiers resolve against.
280+
"""
281+
names = {}
282+
for rel, files in tree.items():
283+
globs = []
284+
if "package.json" in files:
285+
data = _jsonc_loads(_read(repo, rel, "package.json"))
286+
ws = data.get("workspaces") if isinstance(data, dict) else None
287+
if isinstance(ws, dict):
288+
ws = ws.get("packages")
289+
if isinstance(ws, list):
290+
globs.extend(g for g in ws if isinstance(g, str))
291+
if "pnpm-workspace.yaml" in files:
292+
globs.extend(_pnpm_globs(_read(repo, rel, "pnpm-workspace.yaml")))
293+
for g in globs:
294+
if g.startswith("!"): # negation globs: rare, not modeled
295+
continue
296+
pat = _norm(os.path.join(rel, g)).split("/")
297+
for wdir, wfiles in tree.items():
298+
if not wdir or "package.json" not in wfiles:
299+
continue
300+
if not _segments_match(pat, wdir.split("/")):
301+
continue
302+
pkg = _jsonc_loads(_read(repo, wdir, "package.json"))
303+
name = pkg.get("name") if isinstance(pkg, dict) else None
304+
if isinstance(name, str) and name:
305+
names[name] = wdir
306+
return names
307+
308+
309+
def _pnpm_globs(text):
310+
"""Glob items of the top-level packages: list in pnpm-workspace.yaml."""
311+
globs, in_packages = [], False
312+
for line in text.splitlines():
313+
if not line.strip() or line.lstrip().startswith("#"):
314+
continue
315+
if not line[0].isspace() and line[0] != "-":
316+
in_packages = line.split(":")[0].strip() == "packages"
317+
continue
318+
if in_packages:
319+
m = PNPM_ITEM_RE.match(line)
320+
if m:
321+
globs.append(m.group(1))
322+
return globs
323+
324+
325+
def _segments_match(pat, segs):
326+
"""Segment-wise glob match: `*` spans one path segment (unlike fnmatch
327+
on the whole string), `**` spans any number."""
328+
if not pat:
329+
return not segs
330+
if pat[0] == "**":
331+
return any(_segments_match(pat[1:], segs[i:])
332+
for i in range(len(segs) + 1))
333+
return (bool(segs) and fnmatch.fnmatchcase(segs[0], pat[0])
334+
and _segments_match(pat[1:], segs[1:]))
335+
336+
337+
def _js_workspace_resolve(imp, workspaces, dirs):
338+
"""Workspace package name -> its dir; a subpath import grounds inside
339+
the package dir when it exists there, else falls back to the dir itself
340+
(real subpaths map through package `exports`, which is not modeled)."""
341+
for name, wdir in workspaces.items():
342+
if imp == name:
343+
return wdir
344+
if imp.startswith(name + "/"):
345+
sub = imp[len(name) + 1:]
346+
target = _existing_dir(_norm(os.path.join(wdir, sub)), dirs)
347+
return target if target is not None else wdir
348+
return None
349+
350+
263351
def _jsonc_loads(text):
264352
"""json.loads for the JSONC dialect tsconfig uses: // and /* */ comments
265353
and trailing commas are stripped (string-aware, so comment-lookalikes

tests/test_deps_js.py

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import unittest
44

55
from repokg.code import walk
6-
from repokg.deps import _js_configs, _jsonc_loads, collect
6+
from repokg.deps import _js_configs, _js_workspaces, _jsonc_loads, _pnpm_globs, collect
77

88

99
def write(root, rel, text):
@@ -205,5 +205,119 @@ def test_jsonc_config_with_comments_and_trailing_commas(self):
205205
self.assertEqual(self.edges(), {("app", "src"): 1})
206206

207207

208+
class TestJsWorkspaces(unittest.TestCase):
209+
def setUp(self):
210+
self.tmp = tempfile.TemporaryDirectory()
211+
self.repo = self.tmp.name
212+
213+
def tearDown(self):
214+
self.tmp.cleanup()
215+
216+
def tree(self):
217+
return dict(walk(self.repo))
218+
219+
def test_npm_array_and_scoped_names(self):
220+
write(self.repo, "package.json",
221+
'{"name": "root", "workspaces": ["packages/*"]}')
222+
write(self.repo, "packages/core/package.json",
223+
'{"name": "@acme/core"}')
224+
write(self.repo, "packages/ui/package.json",
225+
'{"name": "@acme/ui"}')
226+
self.assertEqual(_js_workspaces(self.repo, self.tree()),
227+
{"@acme/core": "packages/core",
228+
"@acme/ui": "packages/ui"})
229+
230+
def test_yarn_object_form(self):
231+
write(self.repo, "package.json",
232+
'{"workspaces": {"packages": ["libs/*"], "nohoist": ["**"]}}')
233+
write(self.repo, "libs/log/package.json", '{"name": "log"}')
234+
self.assertEqual(_js_workspaces(self.repo, self.tree()),
235+
{"log": "libs/log"})
236+
237+
def test_pnpm_workspace_yaml(self):
238+
write(self.repo, "pnpm-workspace.yaml",
239+
"# workspace layout\n"
240+
"packages:\n"
241+
" - 'packages/*'\n"
242+
' - "apps/*"\n'
243+
" - tools\n"
244+
"catalog:\n"
245+
" - not-a-glob\n")
246+
write(self.repo, "packages/core/package.json", '{"name": "@acme/core"}')
247+
write(self.repo, "apps/web/package.json", '{"name": "web"}')
248+
write(self.repo, "tools/package.json", '{"name": "tools"}')
249+
self.assertEqual(_js_workspaces(self.repo, self.tree()),
250+
{"@acme/core": "packages/core", "web": "apps/web",
251+
"tools": "tools"})
252+
253+
def test_pnpm_globs_parser(self):
254+
self.assertEqual(
255+
_pnpm_globs("packages:\n - 'a/*'\n - b\nother:\n - c\n"),
256+
["a/*", "b"])
257+
self.assertEqual(_pnpm_globs("onlyOther:\n - c\n"), [])
258+
259+
def test_star_glob_does_not_cross_segments(self):
260+
write(self.repo, "package.json", '{"workspaces": ["packages/*"]}')
261+
write(self.repo, "packages/core/package.json", '{"name": "core"}')
262+
write(self.repo, "packages/core/examples/demo/package.json",
263+
'{"name": "demo"}')
264+
self.assertEqual(_js_workspaces(self.repo, self.tree()),
265+
{"core": "packages/core"})
266+
267+
def test_double_star_glob_crosses_segments(self):
268+
write(self.repo, "package.json", '{"workspaces": ["libs/**"]}')
269+
write(self.repo, "libs/a/deep/pkg/package.json", '{"name": "deep"}')
270+
self.assertEqual(_js_workspaces(self.repo, self.tree()),
271+
{"deep": "libs/a/deep/pkg"})
272+
273+
def test_workspace_without_name_ignored(self):
274+
write(self.repo, "package.json", '{"workspaces": ["packages/*"]}')
275+
write(self.repo, "packages/anon/package.json", '{"private": true}')
276+
self.assertEqual(_js_workspaces(self.repo, self.tree()), {})
277+
278+
279+
class TestJsWorkspaceEdges(unittest.TestCase):
280+
def setUp(self):
281+
self.tmp = tempfile.TemporaryDirectory()
282+
self.repo = self.tmp.name
283+
write(self.repo, "package.json", '{"workspaces": ["packages/*"]}')
284+
write(self.repo, "packages/core/package.json", '{"name": "@acme/core"}')
285+
write(self.repo, "packages/core/src/index.ts", "export const c = 1\n")
286+
287+
def tearDown(self):
288+
self.tmp.cleanup()
289+
290+
def edges(self):
291+
return {(e["from"], e["to"]): e["count"]
292+
for e in collect(self.repo) if e["lang"] == "JS/TS"}
293+
294+
def test_workspace_name_import(self):
295+
write(self.repo, "packages/app/package.json", '{"name": "@acme/app"}')
296+
write(self.repo, "packages/app/main.ts",
297+
"import { c } from '@acme/core'\n"
298+
"import React from 'react'\n")
299+
self.assertEqual(self.edges(),
300+
{("packages/app", "packages/core"): 1})
301+
302+
def test_workspace_subpath_grounds_or_falls_back(self):
303+
write(self.repo, "packages/app/package.json", '{"name": "@acme/app"}')
304+
write(self.repo, "packages/app/main.ts",
305+
"import { c } from '@acme/core/src/index'\n" # grounds in src/
306+
"import { d } from '@acme/core/dist/util'\n") # falls back to pkg dir
307+
self.assertEqual(self.edges(), {
308+
("packages/app", "packages/core/src"): 1,
309+
("packages/app", "packages/core"): 1,
310+
})
311+
312+
def test_paths_alias_takes_precedence_over_workspace(self):
313+
write(self.repo, "shim/core.ts", "export const c = 2\n")
314+
write(self.repo, "packages/app/tsconfig.json",
315+
'{"compilerOptions": {"paths": {"@acme/core": ["../../shim/core.ts"]}}}')
316+
write(self.repo, "packages/app/package.json", '{"name": "@acme/app"}')
317+
write(self.repo, "packages/app/main.ts",
318+
"import { c } from '@acme/core'\n")
319+
self.assertEqual(self.edges(), {("packages/app", "shim"): 1})
320+
321+
208322
if __name__ == "__main__":
209323
unittest.main()

0 commit comments

Comments
 (0)