Skip to content

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

Description

@srinchow

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 aliasesimport { b } from '@/b' with "paths": { "@/*": ["./src/*"] }
  2. workspace packagesimport { 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.jsfoo.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/subexports["./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 (outDirrootDir, .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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions