redhat-et / redhat-et/ripwire

`--deps` draws no edge for a TypeScript tsconfig alias or workspace package, and reports the resulting partial graph as `cycles=0`

Open
#220 1 comment 0 reactions 0 assignees View on GitHub
Dominant language
C++
Stars
2.1k
Forks
125
Avg merge
5h 42m
Merged PRs (30d)
136

Description

## Summary

In a TypeScript workspace, `--deps` (and everything built on its file graph: `--arch`, `--communities`, `cycles`, `propagation_cost`, `--impact`'s importer tier, `--cochange surprising=`) resolves only relative specifiers. Two specifier forms that TypeScript itself resolves deterministically from config files produce a target row with no edge:

1. **tsconfig path aliases** — `import { b } from '@/b'` with `"paths": { "@/*": ["./src/*"] }`
2. **workspace packages** — `import { helper } from '@acme/shared'` where `packages/shared/package.json` declares `"name": "@acme/shared"` and the root `pnpm-workspace.yaml` lists `packages/*`

A cycle spelled through an alias is not reported, and nothing on the `` root or `` element discloses how many directives went unresolved, so `cycles=0` and `propagation_cost=0.001` read as confident answers. On a repository where aliases are the mandated import style this makes the whole file-graph family answer a different (much smaller) graph than the one the compiler sees.

This is the same disclosure shape as #66: the zero is indistinguishable from "no cycles exist".

## Environment

- `ripwire 0.6.0 (Release, AppleClang 16.0.0.16000026, emit=std::print, built_from=2d2f10e62)`, release tarball via `scripts/install.sh`
- macOS 15 (Darwin 25.6.0), arm64
- Fixture run with `--no-cache` in a fresh `git init` directory

## Reproduce

Minimal fixture: one pnpm workspace, two packages, two 2-file cycles (one aliased, one relative), one cross-package import.

```
pnpm-workspace.yaml packages:\n - 'packages/*'
packages/shared/package.json { "name": "@acme/shared", "main": "dist/index.js",
"exports": { ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" } } }
packages/shared/tsconfig.json { "compilerOptions": { "rootDir": "src", "outDir": "dist", "module": "node20", "moduleResolution": "node16" } }
packages/shared/src/index.ts export { helper } from './helper';
packages/shared/src/helper.ts export function helper(): number { return 1; }
packages/app/package.json { "name": "@acme/app", "dependencies": { "@acme/shared": "workspace:*" } }
packages/app/tsconfig.json { "compilerOptions": { "rootDir": "src", "outDir": "dist", "module": "node20", "moduleResolution": "node16",
"paths": { "@/*": ["./src/*"] } } }
packages/app/src/a.ts import { b } from '@/b'; import { helper } from '@acme/shared'; export function a() { return b() + helper(); }
packages/app/src/b.ts import { a } from '@/a'; export function b() { return typeof a === 'function' ? 1 : 0; }
packages/app/src/c.ts import { d } from './d'; export function c() { return d(); }
packages/app/src/d.ts import { c } from './c'; export function d() { return typeof c === 'function' ? 1 : 0; }
```

```sh
ripwire . --deps --no-cache
```

Output (XML comments stripped):

```xml








```

- `c <-> d` (relative) is found. `a <-> b` (aliased) is not: `a.ts` and `b.ts` carry `afferent="0"`.
- `@/b`, `@/a`, `@acme/shared` appear as `inc t=` rows with no edge, as documented ("a directive that did not resolve to an indexed file"). Nothing on the root says three of five directives went unresolved.
- Control: `--verify='contains(packages/app/src, "@acme/shared")'` → `verdict="confirmed"`, so the text is indexed; it is the edge that is missing.

### On a real repository

pnpm monorepo, ~2,700 files, 15 packages, every package's `tsconfig.json` declares `"@/*": ["./src/*"]` and the packages import each other as `@package/` (five workspace packages, `exports` maps with 1–6 subpaths). Import-style census over `*.ts`/`*.tsx`:

| specifier form | import lines |
|---|---|
| `from '@/…'` (tsconfig alias) | 3,123 |
| `from '@package/…'` (workspace package) | 3,459 |
| `from './…'` | 1,073 |
| `from '../…'` | 354 |

`ripwire backend/src --deps` on that repository: 114 `inc t="@/…"` rows and 97 `inc t="@package/…"` rows with no edge, against 69 resolved relative rows. It reports **0 cycles**; the repository's own `require.resolve`-based cycle checker reports **2** (one spanning 11 files, one spanning 10). `--arch` module metrics show `ca="2" ce="1"` for a 108-file services directory that the route layer imports dozens of times, so a layering rules file passes vacuously.

## What TypeScript resolution needs, and why it fits the tool's own posture

Both forms are declared in config files that are plain JSON/YAML; resolving them needs no type engine and no build, and each rule can stay unique-or-degrade. The 0.5.0 notes already model per-language specifier conventions for Lua (`package.path`), Ruby (`lib/`, `app/`), and Elixir (the corpus's own `defmodule` index); TypeScript's convention is written down in two files rather than implied by layout.

**1. tsconfig `paths` (intra-package aliases).**
- For an importing file, use the nearest `tsconfig.json` walking up from its directory; honour `extends` (relative path or a package), merging `compilerOptions.paths` and `baseUrl`.
- A `paths` key is a pattern with at most one `*`. Match the specifier against keys (longest prefix wins), substitute the `*` capture into each target in order, resolve targets relative to `baseUrl` if set, else the tsconfig's directory.
- Probe each candidate the way `moduleResolution: node16/bundler` does: exact file; then `+ .ts / .tsx / .d.ts / .mts / .cts`; then `/index.ts` etc. Under `node16`/`nodenext` a specifier ending in `.js`/`.mjs`/`.cjs` maps to the `.ts`/`.mts`/`.cts` source (`./foo.js` → `foo.ts`) — this also applies to *relative* specifiers in such projects and is worth handling in the same pass.
- First target that exists wins (that is TypeScript's rule); if none exists, no edge, counted as unresolved.

**2. Workspace packages (cross-package bare specifiers).**
- Enumerate workspace members: `pnpm-workspace.yaml` `packages:` globs, or root `package.json` `workspaces` (array or `{ packages: [] }`). Each member's `package.json` `name` is a bare-specifier prefix.
- `name` alone → the package's entry; `name/sub` → `exports["./sub"]` (take the `types`, `import`, `default`, or string form, in that order). Fall back to `main`/`types`, then `src/index.ts`, then `index.ts`.
- The declared entry points into `outDir` (`dist/index.js`). Map it back to source through that package's own tsconfig (`outDir` → `rootDir`, `.js` → `.ts`), or, when the mapped file is missing, probe `src/` for the same relative path. If neither exists (unbuilt tree, no `src/`), no edge, counted.
- Only names that are workspace members should resolve; anything else is third-party and stays an external target row, as today.

**3. Unique-or-degrade is preserved.** A `paths` entry whose several targets all exist is still first-match by the language's rule, so it is not the "two files answer one specifier" case; the genuinely ambiguous case (two workspace members declaring the same `name`) resolves to neither.

**4. Disclosure, independent of the fix.** Even before any resolution lands, the file-graph root should carry the count it currently hides, in the shape #66 established for `--callers`: e.g. `` and, when the unresolved share is high, a marker on `` / `propagation_cost` that they are computed over a partial graph. That alone turns a wrong-looking confident zero into a disclosed floor, which is the rule `CLAUDE.md` states.

## Scope of impact

Affected: `--deps`, `--arch`, `--communities`, `cycles`, `propagation_cost`, `--impact` importer tier, `--cochange surprising=`, `--doctor`'s picture of the dependency graph.

Not affected (verified on the same repository): `--uses`, `--callers`, `--expand`, `--grep`, `--verify contains()` — these match by symbol name, not by import path, and answered correctly.

`--scip` does not cover this today: its help says "precise call edges replace name-based guesses", so the file graph is out of its reach even with a `scip-typescript` index.

## Size

Medium. Two small readers (a tsconfig chain with `extends`/`baseUrl`/`paths`; workspace globs plus `package.json` `name`/`exports`/`main`), one resolver arm per specifier form, and the disclosure attributes. The measurement is the larger part, as in #163: the change should be reported as "N directives moved from unresolved to resolved, M new cycles, on ", and the fixture above can pin both the alias cycle and the relative control.

Happy to test a build against the real repository described above and report the before/after numbers.

Contributor guide

Open the contributing guide

Research direction

Start by reproducing the minimal workspace fixture from the issue with `ripwire . --deps --no-cache`, after reviewing `scripts/install.sh` and the repository guidance in `CLAUDE.md`. Trace the dependency-graph resolution path for tsconfig aliases and workspace packages, then verify the fixture's aliased cycle, cross-package edge, and unresolved-directive disclosure. Done means the relative control still works, both TypeScript forms produce edges where resolvable, and partial-graph metrics disclose unresolved directives.

Written by the indexing model from the issue text.

Assessment

Tech stack
cpp, typescript
Domain
cli, devtools, tooling
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
65/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.