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:
- tsconfig path aliases —
import { b } from '@/b' with "paths": { "@/*": ["./src/*"] }
- 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 <deps> root or <health> 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; }
ripwire . --deps --no-cache
Output (XML comments stripped):
<deps files="4" shown="4" capped="0" root=".">
<health files="11" dep_files="6" ccd="8" acd="1.3" nccd="0.59" shape="horizontal" dep_langs="…,ts,…,js,…"/>
<godfiles total="2" shown="2" capped="0"><f p="packages/app/src/c.ts" afferent="1"/><f p="packages/app/src/d.ts" afferent="1"/></godfiles>
<cycles><cycle size="2" cost="4" cut="packages/app/src/c.ts -> packages/app/src/d.ts" cutrefs="1">
<f p="packages/app/src/d.ts"/><f p="packages/app/src/c.ts"/></cycle></cycles>
<f p="packages/app/src/c.ts" includes="1" afferent="1" instab="0.50" transitive="2"><inc t="./d"/></f>
<f p="packages/app/src/d.ts" includes="1" afferent="1" instab="0.50" transitive="2"><inc t="./c"/></f>
<f p="packages/app/src/a.ts" includes="2" afferent="0" instab="0.00" transitive="1"><inc t="@/b"/><inc t="@acme/shared"/></f>
<f p="packages/app/src/b.ts" includes="1" afferent="0" instab="0.00" transitive="1"><inc t="@/a"/></f>
</deps>
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/<pkg> (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. <health unresolved_directives="211" unresolved_by_form="alias:114,bare:97" resolved_directives="69"> and, when the unresolved share is high, a marker on <cycles> / 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.
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:import { b } from '@/b'with"paths": { "@/*": ["./src/*"] }import { helper } from '@acme/shared'wherepackages/shared/package.jsondeclares"name": "@acme/shared"and the rootpnpm-workspace.yamllistspackages/*A cycle spelled through an alias is not reported, and nothing on the
<deps>root or<health>element discloses how many directives went unresolved, socycles=0andpropagation_cost=0.001read 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 viascripts/install.sh--no-cachein a freshgit initdirectoryReproduce
Minimal fixture: one pnpm workspace, two packages, two 2-file cycles (one aliased, one relative), one cross-package import.
ripwire . --deps --no-cacheOutput (XML comments stripped):
c <-> d(relative) is found.a <-> b(aliased) is not:a.tsandb.tscarryafferent="0".@/b,@/a,@acme/sharedappear asinc 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.--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.jsondeclares"@/*": ["./src/*"]and the packages import each other as@package/<pkg>(five workspace packages,exportsmaps with 1–6 subpaths). Import-style census over*.ts/*.tsx:from '@/…'(tsconfig alias)from '@package/…'(workspace package)from './…'from '../…'ripwire backend/src --depson that repository: 114inc t="@/…"rows and 97inc t="@package/…"rows with no edge, against 69 resolved relative rows. It reports 0 cycles; the repository's ownrequire.resolve-based cycle checker reports 2 (one spanning 11 files, one spanning 10).--archmodule metrics showca="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 owndefmoduleindex); TypeScript's convention is written down in two files rather than implied by layout.1. tsconfig
paths(intra-package aliases).tsconfig.jsonwalking up from its directory; honourextends(relative path or a package), mergingcompilerOptions.pathsandbaseUrl.pathskey 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 tobaseUrlif set, else the tsconfig's directory.moduleResolution: node16/bundlerdoes: exact file; then+ .ts / .tsx / .d.ts / .mts / .cts; then/index.tsetc. Undernode16/nodenexta specifier ending in.js/.mjs/.cjsmaps to the.ts/.mts/.ctssource (./foo.js→foo.ts) — this also applies to relative specifiers in such projects and is worth handling in the same pass.2. Workspace packages (cross-package bare specifiers).
pnpm-workspace.yamlpackages:globs, or rootpackage.jsonworkspaces(array or{ packages: [] }). Each member'spackage.jsonnameis a bare-specifier prefix.namealone → the package's entry;name/sub→exports["./sub"](take thetypes,import,default, or string form, in that order). Fall back tomain/types, thensrc/index.ts, thenindex.ts.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, probesrc/for the same relative path. If neither exists (unbuilt tree, nosrc/), no edge, counted.3. Unique-or-degrade is preserved. A
pathsentry 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 samename) 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.<health unresolved_directives="211" unresolved_by_form="alias:114,bare:97" resolved_directives="69">and, when the unresolved share is high, a marker on<cycles>/propagation_costthat they are computed over a partial graph. That alone turns a wrong-looking confident zero into a disclosed floor, which is the ruleCLAUDE.mdstates.Scope of impact
Affected:
--deps,--arch,--communities,cycles,propagation_cost,--impactimporter 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.--scipdoes 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 ascip-typescriptindex.Size
Medium. Two small readers (a tsconfig chain with
extends/baseUrl/paths; workspace globs pluspackage.jsonname/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.