Skip to content

Commit 20bce54

Browse files
authored
feat: JS/TS workspace resolution + alias fixtures, findings, docs (re-land 2/3 + 3/3) (#37)
* feat: workspace package-name resolution for JS/TS imports (issue-3, PR 2/3) (#35) * feat: JS/TS alias fixtures, unresolved-alias uncertainty note, findings/README update (issue-3, PR 3/3) (#36)
1 parent 5577391 commit 20bce54

5 files changed

Lines changed: 314 additions & 20 deletions

File tree

README.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ on the knowledge graph discovers your rules — and vice versa.
120120
| 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 |
121121
| PR catalog | `gh pr list --state all` — open / merged / closed-unmerged, full appendix table |
122122
| Module inventory | Filesystem walk with LOC per directory, language detection, generated-code flagging |
123-
| 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 |
123+
| 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 |
124124
| Ops surface | CI workflow names, Dockerfiles, compose files, Helm charts, Makefile targets, config/docs/test/migration dirs |
125125
| Timeline | Merged PRs grouped by month with conventional-commit scope frequencies (replaced by agent-written eras after enrichment) |
126126

@@ -134,8 +134,12 @@ schema — everything else stays deterministic and reproducible.
134134

135135
## Known limitations
136136

137-
- **JS/TS**: only relative imports are resolved; alias imports (`@/…`,
138-
tsconfig `paths`) are ignored.
137+
- **JS/TS**: relative imports, tsconfig/jsconfig `paths`/`baseUrl` aliases and
138+
workspace package names are resolved; `extends` chains are not followed (a
139+
leaf config without its own aliases is skipped rather than shadowing the
140+
root's), and package `exports` maps are not modeled — subpath imports fall
141+
back to the package dir. Alias imports whose targets ground nowhere are
142+
counted in an uncertainty note.
139143
- **Fork PRs**: a fork PR whose head branch name matches a local branch will be
140144
linked to it (GitHub's API reports bare head refs).
141145
- **Python**: packages are discovered at the repo root and under `src/`;
@@ -155,7 +159,7 @@ schema — everything else stays deterministic and reproducible.
155159
- [x] Java / Kotlin import graphs
156160
- [ ] `--exclude` glob patterns
157161
- [ ] `llms.txt` emission alongside KNOWLEDGE_GRAPH.md
158-
- [ ] tsconfig `paths` alias resolution
162+
- [x] tsconfig `paths` alias + workspace package resolution
159163
- [ ] PyPI release + prebuilt GitHub Action
160164

161165
## Development

src/repokg/cli.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,15 @@ def scan(repo, out, no_github, pr_limit):
2020
github.classify(branches, prs, info["trunk"], info["integration"])
2121
tree = dict(code.walk(repo)) # single filesystem walk, shared by all collectors
2222
languages, modules = code.collect(repo, tree)
23+
edge_stats = {}
2324
kg = {
2425
"repokg_version": 1,
2526
"generated_at": datetime.date.today().isoformat(),
2627
"repo": info,
2728
"languages": languages,
2829
"modules": modules,
29-
"edges": deps.collect(repo, tree),
30+
"edges": deps.collect(repo, tree, edge_stats),
31+
"edge_stats": edge_stats,
3032
"branches": branches,
3133
"prs": prs,
3234
"github_note": note,

src/repokg/deps.py

Lines changed: 115 additions & 12 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)
@@ -47,19 +51,24 @@
4751
JVM_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

230250
def _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

src/repokg/findings.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,10 @@
1818
"Go": ("high", "imports resolved against go.mod module paths (exact)"),
1919
"Python": ("high", "stdlib ast parse; note: package discovery covers repo "
2020
"root and src/ only"),
21-
"JS/TS": ("medium", "regex over relative imports only; aliases and "
22-
"tsconfig paths are not resolved"),
21+
"JS/TS": ("medium", "regex import extraction; relative imports, "
22+
"tsconfig/jsconfig paths + baseUrl aliases and "
23+
"workspace package names all resolved; `extends` "
24+
"chains and package `exports` maps are not"),
2325
"Rust": ("medium", "regex `use` parsing resolved against Cargo [package] "
2426
"names and src/ module dirs; macros, re-exports and "
2527
"path-dependencies are not resolved"),
@@ -84,6 +86,12 @@ def build(kg):
8486
"verify before excluding from review",
8587
[m["path"] for m in gen], "low"))
8688

89+
unresolved = kg.get("edge_stats", {}).get("js_alias_unresolved", 0)
90+
if unresolved:
91+
notes.append("%d JS/TS alias imports matched a tsconfig/jsconfig "
92+
"paths pattern but their targets exist nowhere in the "
93+
"tree; those edges were dropped." % unresolved)
94+
8795
if kg.get("github_note"):
8896
notes.append("PR layer incomplete: %s — branch statuses degrade to "
8997
"merged/stale only." % kg["github_note"])

0 commit comments

Comments
 (0)