From 8af0b2326a9c84fa20351908104d1687af1d10a0 Mon Sep 17 00:00:00 2001 From: Nihar Shah Date: Fri, 10 Jul 2026 13:55:26 -0400 Subject: [PATCH 1/2] feat: workspace package-name resolution for JS/TS imports (issue-3, PR 2/3) (#35) --- src/repokg/deps.py | 98 +++++++++++++++++++++++++++++++++-- tests/test_deps_js.py | 116 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 208 insertions(+), 6 deletions(-) diff --git a/src/repokg/deps.py b/src/repokg/deps.py index 4ba61ee..22c5bb1 100644 --- a/src/repokg/deps.py +++ b/src/repokg/deps.py @@ -5,6 +5,7 @@ """ import ast +import fnmatch import json import os import re @@ -23,6 +24,9 @@ # tsconfig wins over jsconfig when a dir carries both (jsconfig is the # JS-only subset of the same format). JS_CONFIG_FILES = ("tsconfig.json", "jsconfig.json") +# `- 'packages/*'` list item under the packages: key of pnpm-workspace.yaml +# (flat list of quoted-or-bare globs; a full YAML parser is not needed). +PNPM_ITEM_RE = re.compile(r"^\s*-\s*['\"]?([^'\"#\n]+?)['\"]?\s*$") # [package] section of a Cargo.toml, up to the next table header. CARGO_PACKAGE_RE = re.compile(r"^\[package\]\s*$(.*?)(?=^\[|\Z)", re.M | re.S) 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): def _js_edges(repo, tree, dirs, counter): """Relative imports resolve directly; non-relative specifiers go through - the nearest tsconfig/jsconfig's `paths` aliases and `baseUrl` (bare - third-party imports match nothing there and drop out).""" + the nearest tsconfig/jsconfig's `paths` aliases and `baseUrl`, then + workspace package names (bare third-party imports match nothing in + either and drop out).""" configs = _js_configs(repo, tree) + workspaces = _js_workspaces(repo, tree) for rel, files in tree.items(): cfg = _owning_root(rel, configs) if configs else None for f in files: @@ -178,10 +184,13 @@ def _js_edges(repo, tree, dirs, counter): for imp in JS_IMPORT_RE.findall(_read(repo, rel, f)): if imp.startswith("."): target = _existing_dir(_norm(os.path.join(rel, imp)), dirs) - elif cfg is not None and not imp.startswith("/"): - target = _js_alias_resolve(imp, configs[cfg], dirs) - else: + elif imp.startswith("/"): target = None + else: + target = (_js_alias_resolve(imp, configs[cfg], dirs) + if cfg is not None else None) + if target is None and workspaces: + target = _js_workspace_resolve(imp, workspaces, dirs) if target is not None and target != rel: counter[(rel, target, "JS/TS")] += 1 @@ -260,6 +269,85 @@ def _js_alias_resolve(imp, cfg, dirs): return None +def _js_workspaces(repo, tree): + """{package name: workspace dir} for monorepo workspaces. + + Globs come from package.json `workspaces` (npm/yarn; array or + {packages: [...]}) and pnpm-workspace.yaml `packages:` lists — any dir + may declare them, so nested workspace roots work too. A matched dir + counts only if its own package.json carries a `name`; that name is what + `import '@scope/pkg'` specifiers resolve against. + """ + names = {} + for rel, files in tree.items(): + globs = [] + if "package.json" in files: + data = _jsonc_loads(_read(repo, rel, "package.json")) + ws = data.get("workspaces") if isinstance(data, dict) else None + if isinstance(ws, dict): + ws = ws.get("packages") + if isinstance(ws, list): + globs.extend(g for g in ws if isinstance(g, str)) + if "pnpm-workspace.yaml" in files: + globs.extend(_pnpm_globs(_read(repo, rel, "pnpm-workspace.yaml"))) + for g in globs: + if g.startswith("!"): # negation globs: rare, not modeled + continue + pat = _norm(os.path.join(rel, g)).split("/") + for wdir, wfiles in tree.items(): + if not wdir or "package.json" not in wfiles: + continue + if not _segments_match(pat, wdir.split("/")): + continue + pkg = _jsonc_loads(_read(repo, wdir, "package.json")) + name = pkg.get("name") if isinstance(pkg, dict) else None + if isinstance(name, str) and name: + names[name] = wdir + return names + + +def _pnpm_globs(text): + """Glob items of the top-level packages: list in pnpm-workspace.yaml.""" + globs, in_packages = [], False + for line in text.splitlines(): + if not line.strip() or line.lstrip().startswith("#"): + continue + if not line[0].isspace() and line[0] != "-": + in_packages = line.split(":")[0].strip() == "packages" + continue + if in_packages: + m = PNPM_ITEM_RE.match(line) + if m: + globs.append(m.group(1)) + return globs + + +def _segments_match(pat, segs): + """Segment-wise glob match: `*` spans one path segment (unlike fnmatch + on the whole string), `**` spans any number.""" + if not pat: + return not segs + if pat[0] == "**": + return any(_segments_match(pat[1:], segs[i:]) + for i in range(len(segs) + 1)) + return (bool(segs) and fnmatch.fnmatchcase(segs[0], pat[0]) + and _segments_match(pat[1:], segs[1:])) + + +def _js_workspace_resolve(imp, workspaces, dirs): + """Workspace package name -> its dir; a subpath import grounds inside + the package dir when it exists there, else falls back to the dir itself + (real subpaths map through package `exports`, which is not modeled).""" + for name, wdir in workspaces.items(): + if imp == name: + return wdir + if imp.startswith(name + "/"): + sub = imp[len(name) + 1:] + target = _existing_dir(_norm(os.path.join(wdir, sub)), dirs) + return target if target is not None else wdir + return None + + def _jsonc_loads(text): """json.loads for the JSONC dialect tsconfig uses: // and /* */ comments and trailing commas are stripped (string-aware, so comment-lookalikes diff --git a/tests/test_deps_js.py b/tests/test_deps_js.py index c9cac19..1ac914c 100644 --- a/tests/test_deps_js.py +++ b/tests/test_deps_js.py @@ -3,7 +3,7 @@ import unittest from repokg.code import walk -from repokg.deps import _js_configs, _jsonc_loads, collect +from repokg.deps import _js_configs, _js_workspaces, _jsonc_loads, _pnpm_globs, collect def write(root, rel, text): @@ -205,5 +205,119 @@ def test_jsonc_config_with_comments_and_trailing_commas(self): self.assertEqual(self.edges(), {("app", "src"): 1}) +class TestJsWorkspaces(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.repo = self.tmp.name + + def tearDown(self): + self.tmp.cleanup() + + def tree(self): + return dict(walk(self.repo)) + + def test_npm_array_and_scoped_names(self): + write(self.repo, "package.json", + '{"name": "root", "workspaces": ["packages/*"]}') + write(self.repo, "packages/core/package.json", + '{"name": "@acme/core"}') + write(self.repo, "packages/ui/package.json", + '{"name": "@acme/ui"}') + self.assertEqual(_js_workspaces(self.repo, self.tree()), + {"@acme/core": "packages/core", + "@acme/ui": "packages/ui"}) + + def test_yarn_object_form(self): + write(self.repo, "package.json", + '{"workspaces": {"packages": ["libs/*"], "nohoist": ["**"]}}') + write(self.repo, "libs/log/package.json", '{"name": "log"}') + self.assertEqual(_js_workspaces(self.repo, self.tree()), + {"log": "libs/log"}) + + def test_pnpm_workspace_yaml(self): + write(self.repo, "pnpm-workspace.yaml", + "# workspace layout\n" + "packages:\n" + " - 'packages/*'\n" + ' - "apps/*"\n' + " - tools\n" + "catalog:\n" + " - not-a-glob\n") + write(self.repo, "packages/core/package.json", '{"name": "@acme/core"}') + write(self.repo, "apps/web/package.json", '{"name": "web"}') + write(self.repo, "tools/package.json", '{"name": "tools"}') + self.assertEqual(_js_workspaces(self.repo, self.tree()), + {"@acme/core": "packages/core", "web": "apps/web", + "tools": "tools"}) + + def test_pnpm_globs_parser(self): + self.assertEqual( + _pnpm_globs("packages:\n - 'a/*'\n - b\nother:\n - c\n"), + ["a/*", "b"]) + self.assertEqual(_pnpm_globs("onlyOther:\n - c\n"), []) + + def test_star_glob_does_not_cross_segments(self): + write(self.repo, "package.json", '{"workspaces": ["packages/*"]}') + write(self.repo, "packages/core/package.json", '{"name": "core"}') + write(self.repo, "packages/core/examples/demo/package.json", + '{"name": "demo"}') + self.assertEqual(_js_workspaces(self.repo, self.tree()), + {"core": "packages/core"}) + + def test_double_star_glob_crosses_segments(self): + write(self.repo, "package.json", '{"workspaces": ["libs/**"]}') + write(self.repo, "libs/a/deep/pkg/package.json", '{"name": "deep"}') + self.assertEqual(_js_workspaces(self.repo, self.tree()), + {"deep": "libs/a/deep/pkg"}) + + def test_workspace_without_name_ignored(self): + write(self.repo, "package.json", '{"workspaces": ["packages/*"]}') + write(self.repo, "packages/anon/package.json", '{"private": true}') + self.assertEqual(_js_workspaces(self.repo, self.tree()), {}) + + +class TestJsWorkspaceEdges(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.repo = self.tmp.name + write(self.repo, "package.json", '{"workspaces": ["packages/*"]}') + write(self.repo, "packages/core/package.json", '{"name": "@acme/core"}') + write(self.repo, "packages/core/src/index.ts", "export const c = 1\n") + + def tearDown(self): + self.tmp.cleanup() + + def edges(self): + return {(e["from"], e["to"]): e["count"] + for e in collect(self.repo) if e["lang"] == "JS/TS"} + + def test_workspace_name_import(self): + write(self.repo, "packages/app/package.json", '{"name": "@acme/app"}') + write(self.repo, "packages/app/main.ts", + "import { c } from '@acme/core'\n" + "import React from 'react'\n") + self.assertEqual(self.edges(), + {("packages/app", "packages/core"): 1}) + + def test_workspace_subpath_grounds_or_falls_back(self): + write(self.repo, "packages/app/package.json", '{"name": "@acme/app"}') + write(self.repo, "packages/app/main.ts", + "import { c } from '@acme/core/src/index'\n" # grounds in src/ + "import { d } from '@acme/core/dist/util'\n") # falls back to pkg dir + self.assertEqual(self.edges(), { + ("packages/app", "packages/core/src"): 1, + ("packages/app", "packages/core"): 1, + }) + + def test_paths_alias_takes_precedence_over_workspace(self): + write(self.repo, "shim/core.ts", "export const c = 2\n") + write(self.repo, "packages/app/tsconfig.json", + '{"compilerOptions": {"paths": {"@acme/core": ["../../shim/core.ts"]}}}') + write(self.repo, "packages/app/package.json", '{"name": "@acme/app"}') + write(self.repo, "packages/app/main.ts", + "import { c } from '@acme/core'\n") + self.assertEqual(self.edges(), {("packages/app", "shim"): 1}) + + if __name__ == "__main__": unittest.main() From dfef4c0f83e797bc6d0b7a7324f1ccda907a686e Mon Sep 17 00:00:00 2001 From: Nihar Shah Date: Fri, 10 Jul 2026 13:55:34 -0400 Subject: [PATCH 2/2] feat: JS/TS alias fixtures, unresolved-alias uncertainty note, findings/README update (issue-3, PR 3/3) (#36) --- README.md | 12 +++++--- src/repokg/cli.py | 4 ++- src/repokg/deps.py | 37 +++++++++++++++++-------- src/repokg/findings.py | 12 ++++++-- tests/test_deps_js.py | 63 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 110 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 8f101c8..1331e45 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ on the knowledge graph discovers your rules — and vice versa. | Branch classification | `git for-each-ref` + `--merged` ancestry vs the integration branch (auto-detects `staging`/`develop`), cross-referenced with every PR's head ref via `gh` — distinguishes true merges from squash-merges from abandoned work | | PR catalog | `gh pr list --state all` — open / merged / closed-unmerged, full appendix table | | Module inventory | Filesystem walk with LOC per directory, language detection, generated-code flagging | -| Import graph | Go: `import` blocks resolved against `go.mod` module paths · Python: stdlib `ast` incl. relative imports · JS/TS: relative `import`/`require` resolution · Rust: `use` declarations resolved against Cargo crate names (cross-crate) and `src/` module trees (intra-crate) · Java/Kotlin: imports resolved by longest prefix against `package` declarations. Directory→directory edges with counts | +| Import graph | Go: `import` blocks resolved against `go.mod` module paths · Python: stdlib `ast` incl. relative imports · JS/TS: `import`/`require` resolution — relative paths, tsconfig/jsconfig `paths` + `baseUrl` aliases (nearest config wins), npm/yarn/pnpm workspace package names · Rust: `use` declarations resolved against Cargo crate names (cross-crate) and `src/` module trees (intra-crate) · Java/Kotlin: imports resolved by longest prefix against `package` declarations. Directory→directory edges with counts | | Ops surface | CI workflow names, Dockerfiles, compose files, Helm charts, Makefile targets, config/docs/test/migration dirs | | Timeline | Merged PRs grouped by month with conventional-commit scope frequencies (replaced by agent-written eras after enrichment) | @@ -134,8 +134,12 @@ schema — everything else stays deterministic and reproducible. ## Known limitations -- **JS/TS**: only relative imports are resolved; alias imports (`@/…`, - tsconfig `paths`) are ignored. +- **JS/TS**: relative imports, tsconfig/jsconfig `paths`/`baseUrl` aliases and + workspace package names are resolved; `extends` chains are not followed (a + leaf config without its own aliases is skipped rather than shadowing the + root's), and package `exports` maps are not modeled — subpath imports fall + back to the package dir. Alias imports whose targets ground nowhere are + counted in an uncertainty note. - **Fork PRs**: a fork PR whose head branch name matches a local branch will be linked to it (GitHub's API reports bare head refs). - **Python**: packages are discovered at the repo root and under `src/`; @@ -155,7 +159,7 @@ schema — everything else stays deterministic and reproducible. - [x] Java / Kotlin import graphs - [ ] `--exclude` glob patterns - [ ] `llms.txt` emission alongside KNOWLEDGE_GRAPH.md -- [ ] tsconfig `paths` alias resolution +- [x] tsconfig `paths` alias + workspace package resolution - [ ] PyPI release + prebuilt GitHub Action ## Development diff --git a/src/repokg/cli.py b/src/repokg/cli.py index 060005e..8bd47fe 100644 --- a/src/repokg/cli.py +++ b/src/repokg/cli.py @@ -20,13 +20,15 @@ def scan(repo, out, no_github, pr_limit): github.classify(branches, prs, info["trunk"], info["integration"]) tree = dict(code.walk(repo)) # single filesystem walk, shared by all collectors languages, modules = code.collect(repo, tree) + edge_stats = {} kg = { "repokg_version": 1, "generated_at": datetime.date.today().isoformat(), "repo": info, "languages": languages, "modules": modules, - "edges": deps.collect(repo, tree), + "edges": deps.collect(repo, tree, edge_stats), + "edge_stats": edge_stats, "branches": branches, "prs": prs, "github_note": note, diff --git a/src/repokg/deps.py b/src/repokg/deps.py index 22c5bb1..8dd596a 100644 --- a/src/repokg/deps.py +++ b/src/repokg/deps.py @@ -51,19 +51,24 @@ JVM_EXTS = (".java", ".kt") -def collect(repo, tree=None): +def collect(repo, tree=None, stats=None): """Return [{"from": dir, "to": dir, "lang": lang, "count": n}] sorted by count. tree: optional pre-built {rel_dir: filenames} to avoid re-walking the repo. + stats: optional dict populated with extraction statistics — + js_alias_unresolved: imports that matched a tsconfig/jsconfig paths + pattern but whose targets exist nowhere in the tree (dropped edges). """ counter = Counter() if tree is None: tree = {rel: files for rel, files in walk(repo)} + if stats is None: + stats = {} dirs = set(tree) _go_edges(repo, tree, counter) _py_edges(repo, tree, dirs, counter) - _js_edges(repo, tree, dirs, counter) + _js_edges(repo, tree, dirs, counter, stats) _rust_edges(repo, tree, counter) _jvm_edges(repo, tree, counter) @@ -169,11 +174,13 @@ def _py_edge(rel, module, level, pkg_map, dirs, counter): # -- JS / TS ----------------------------------------------------------------- -def _js_edges(repo, tree, dirs, counter): +def _js_edges(repo, tree, dirs, counter, stats): """Relative imports resolve directly; non-relative specifiers go through the nearest tsconfig/jsconfig's `paths` aliases and `baseUrl`, then workspace package names (bare third-party imports match nothing in - either and drop out).""" + either and drop out). Alias imports whose pattern matched but whose + targets ground nowhere are counted in stats — that is silent coverage + loss otherwise.""" configs = _js_configs(repo, tree) workspaces = _js_workspaces(repo, tree) for rel, files in tree.items(): @@ -187,10 +194,14 @@ def _js_edges(repo, tree, dirs, counter): elif imp.startswith("/"): target = None else: - target = (_js_alias_resolve(imp, configs[cfg], dirs) - if cfg is not None else None) + target, matched = ( + _js_alias_resolve(imp, configs[cfg], dirs) + if cfg is not None else (None, False)) if target is None and workspaces: target = _js_workspace_resolve(imp, workspaces, dirs) + if target is None and matched: + stats["js_alias_unresolved"] = \ + stats.get("js_alias_unresolved", 0) + 1 if target is not None and target != rel: counter[(rel, target, "JS/TS")] += 1 @@ -238,9 +249,12 @@ def _js_configs(repo, tree): def _js_alias_resolve(imp, cfg, dirs): """Resolve a non-relative specifier through `paths` patterns (first - existing substitution wins), then baseUrl-relative lookup; None when - nothing grounds in the repo tree.""" + existing substitution wins), then baseUrl-relative lookup. + + Returns (target dir or None, whether a paths pattern matched) — a match + with no grounded target is disclosed as an uncertainty note upstream.""" paths_base, patterns, bare_base = cfg + matched = False for pat, vals in patterns: star = pat.find("*") if star == -1: @@ -253,20 +267,21 @@ def _js_alias_resolve(imp, cfg, dirs): and imp.startswith(pre) and imp.endswith(suf)): continue stem = imp[len(pre):len(imp) - len(suf)] + matched = True for val in vals: target = _existing_dir( _norm(os.path.join(paths_base, val.replace("*", stem, 1))), dirs) if target is not None: - return target + return target, True if bare_base is not None: target = _existing_dir(_norm(os.path.join(bare_base, imp)), dirs) # _existing_dir's parent fallback would resolve any bare specifier # whose first segment is missing ('react', 'lodash') to bare_base # itself — those are third-party packages, not internal edges. if target is not None and target != bare_base: - return target - return None + return target, matched + return None, matched def _js_workspaces(repo, tree): diff --git a/src/repokg/findings.py b/src/repokg/findings.py index 2d74128..0501215 100644 --- a/src/repokg/findings.py +++ b/src/repokg/findings.py @@ -18,8 +18,10 @@ "Go": ("high", "imports resolved against go.mod module paths (exact)"), "Python": ("high", "stdlib ast parse; note: package discovery covers repo " "root and src/ only"), - "JS/TS": ("medium", "regex over relative imports only; aliases and " - "tsconfig paths are not resolved"), + "JS/TS": ("medium", "regex import extraction; relative imports, " + "tsconfig/jsconfig paths + baseUrl aliases and " + "workspace package names all resolved; `extends` " + "chains and package `exports` maps are not"), "Rust": ("medium", "regex `use` parsing resolved against Cargo [package] " "names and src/ module dirs; macros, re-exports and " "path-dependencies are not resolved"), @@ -84,6 +86,12 @@ def build(kg): "verify before excluding from review", [m["path"] for m in gen], "low")) + unresolved = kg.get("edge_stats", {}).get("js_alias_unresolved", 0) + if unresolved: + notes.append("%d JS/TS alias imports matched a tsconfig/jsconfig " + "paths pattern but their targets exist nowhere in the " + "tree; those edges were dropped." % unresolved) + if kg.get("github_note"): notes.append("PR layer incomplete: %s — branch statuses degrade to " "merged/stale only." % kg["github_note"]) diff --git a/tests/test_deps_js.py b/tests/test_deps_js.py index 1ac914c..f10b5b3 100644 --- a/tests/test_deps_js.py +++ b/tests/test_deps_js.py @@ -319,5 +319,68 @@ def test_paths_alias_takes_precedence_over_workspace(self): self.assertEqual(self.edges(), {("packages/app", "shim"): 1}) +class TestJsFixtureParity(unittest.TestCase): + """Issue-3 acceptance: a fixture with Next.js-style `@/…` aliases and a + pnpm workspace `@scope/…` import produces exactly the edges the + equivalent relative imports produce, and unresolved aliases are counted + for the uncertainty note.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.repo = self.tmp.name + write(self.repo, "pnpm-workspace.yaml", + "packages:\n - 'packages/*'\n - 'apps/*'\n") + write(self.repo, "apps/web/tsconfig.json", + '{\n' + ' "compilerOptions": {\n' + ' "baseUrl": ".", // Next.js default\n' + ' "paths": {"@/*": ["./src/*"]},\n' + ' },\n' + '}\n') + write(self.repo, "apps/web/package.json", '{"name": "web"}') + write(self.repo, "packages/core/package.json", '{"name": "@acme/core"}') + write(self.repo, "packages/core/src/engine.ts", "export const e = 1\n") + write(self.repo, "apps/web/src/components/button.tsx", + "export const B = 1\n") + + def tearDown(self): + self.tmp.cleanup() + + def collect_edges(self, stats=None): + from repokg.deps import collect + return {(e["from"], e["to"]): e["count"] + for e in collect(self.repo, stats=stats) + if e["lang"] == "JS/TS"} + + def test_alias_edges_equal_relative_edges(self): + write(self.repo, "apps/web/pages/index.tsx", + "import { B } from '@/components/button'\n" + "import { e } from '@acme/core'\n") + alias_edges = self.collect_edges() + write(self.repo, "apps/web/pages/index.tsx", + "import { B } from '../src/components/button'\n" + "import { e } from '../../../packages/core'\n") + self.assertEqual(alias_edges, self.collect_edges()) + self.assertEqual(alias_edges, { + ("apps/web/pages", "apps/web/src/components"): 1, + ("apps/web/pages", "packages/core"): 1, + }) + + def test_unresolved_alias_counted_in_stats(self): + write(self.repo, "apps/web/pages/index.tsx", + "import { gone } from '@/deleted/widget'\n" + "import React from 'react'\n") # non-alias miss: not counted + stats = {} + self.assertEqual(self.collect_edges(stats=stats), {}) + self.assertEqual(stats, {"js_alias_unresolved": 1}) + + def test_uncertainty_note_rendered_from_stats(self): + from repokg.findings import build + _, notes = build({"edge_stats": {"js_alias_unresolved": 3}}) + self.assertTrue(any("3 JS/TS alias imports" in n for n in notes)) + _, notes = build({"edge_stats": {}}) + self.assertFalse(any("alias imports" in n for n in notes)) + + if __name__ == "__main__": unittest.main()