npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@binclusive/code-graph

v0.0.2

Published

Agent-native TypeScript code-graph + smell CLI: parse a folder with ts-morph, measure every function, flag smells, and rank refactor targets as deterministic JSON.

Readme

@binclusive/code-graph

Agent-native TypeScript code-graph + smell tool. Point it at a folder; it parses every .ts/.tsx (via ts-morph), measures each function (loc, complexity, nesting depth, comment lines), groups them into modules and directories, flags smells against the thresholds in schema.ts, and ranks refactor targets. Output is deterministic JSON (sorted object keys, sorted arrays — same input, same bytes) so an agent reads a graph instead of re-reading files.

The default run is cheap (no tsconfig, syntactic getters only — fast). Call edges (calls/calledBy/importedBy/callChainDepth + their smells) are an opt-in pass triggered by --edges, --deep, or --blast.

Three further passes are opt-in on top of that, and each implies the ones below it: cross-runtime edges (--cross-runtime), node kinds (--kinds), and the path queries (--unreachable, --unguarded). Cluster analysis (--clusters) is opt-in too and needs cross-runtime edges, but not kinds — it labels no vertex. Nothing in this paragraph runs on a default invocation.

Invocation

The canonical invocation is pnpm code-graph <path> [flags] run from the repo root, where <path> is relative to the repo root (e.g. pnpm code-graph services/audit-agents --plan). Absolute paths also work. The root code-graph script runs the tool from source via tsx with cwd = repo root, so root-relative paths resolve — no build step needed. The tables below write code-graph X for brevity; read that as pnpm code-graph X from the repo root.

Usage contract

When sent to refactor folder X, the first moves are Bash calls, not Reads:

| Goal | Call | |---|---| | Orient — health + worst offenders (first call) | code-graph X | | Ranked refactor targets, re-rankable | code-graph X --plan [--by rot\|impact\|complexity\|size] | | Indented file → function tree with inline smell markers | code-graph X --tree | | One file's functions + line ranges | code-graph X --file <f> | | Blast radius before changing a signature (intra-package) | code-graph X --blast <id> | | Cross-package blast radius (before changing a shared export) | code-graph X --blast <id> --deep | | Full graph (rarely — large) | code-graph X --graph | | CI gate (fail on smells) | code-graph X --ci [--fail-on high\|warn] [--max <n>] | | Which workers call which, over service bindings / DOs | code-graph services --cross-runtime | | Classify nodes entry/auth/effect/plain | code-graph X --kinds | | Exported symbols nothing reaches | code-graph X --unreachable | | Writes reachable from an entry with no auth on the path | code-graph X --unguarded | | Imports pointing UP the declared layer stack (a gate) | code-graph . --layers | | Ranked collapse candidates (a report to read once, never a gate) | code-graph X --collapse | | Where the directory layout disagrees with how the code clusters | code-graph X --clusters | | Churn × complexity hotspots (where defects concentrate) | code-graph X --hotspots | | Packages ranked by export count, zero-external-consumer exports called out | code-graph X --interface-width | | Module-import cycles, as their participating files | code-graph X --cycles | | Env-var keys declared but never read, and read but never declared | code-graph X --env-keys | | How much of the tree is comment, of what kind, and where | code-graph X --comments | | Comment ratio ratchet — gate the tree against comment-ceilings.json | code-graph . --comments --ci | | Re-record every ceiling after a comment cleanup | code-graph . --comments --write-ceilings |

--blast requires an unambiguous target — pass the id (file:name), not a bare name. In default (package) scope, blast output is flagged incomplete for cross-package callers; re-run with --deep.

Flags

| Flag | Behavior | |---|---| | (none) | Summary JSON: health band, counts, worst file/function, top targets, parse failures | | --graph | Full Graph JSON (the large dump, on demand) | | --plan [--by rot\|impact\|complexity\|size] | Ranked PlanRow[] (human table, top 20). --json → full PlanRow[] | | --smells | All smells grouped by kind (human). --json → Smell[] | | --tree | Indented file → function L12-48 loc=36 cx=8 nest=3 [smell markers]. Color when a TTY, plain when piped. --json falls back to the full graph | | --file <f> | One module + its functions. Warns if <f> is in parseFailures | | --blast <id> | Direct + transitive callers of <id>. Ambiguous bare name exits non-zero. package scope flags omitted cross-package callers | | --edges | Opt-in edge pass at package scope: adds edge data + the high-fan-in / deep-call-chain smells | | --deep | Edge pass over the whole monorepo; implies --edges at deep scope | | --ci [--fail-on high\|warn] [--max <n>] | Gate: exit non-zero per policy. --fail-on high (default) fails on any high-severity smell; --fail-on warn fails on any smell; --max <n> fails if total smell count exceeds n. Cheap by default; add --edges/--deep to gate on edge smells too | | --thresholds <file> | JSON file of partial threshold overrides, parsed through ThresholdsSchema.partial() and merged over the defaults. A typo'd key or wrong-typed value exits cleanly (exit code 2, one-line message) | | --cross-runtime | Resolve env.<BINDING>.<method>() to the target worker's method (implies the edge pass). Prints the binding census + every resolved and unresolved edge | | --kinds | Classify each function entry/auth/effect/plain and print the census. plain is reported as UNCLASSIFIED. Implies --cross-runtime | | --unreachable | Exported symbols with no path from any entry, split dead vs only-called-from-tests, plus the count of candidates WITHHELD and why. Implies --kinds | | --unguarded | effect nodes reachable from an entry without crossing an auth node, each with its shortest witness path. Implies --kinds | | --clusters | Community detection over the call + import graph, compared against the directory tree: directories spanning several clusters, and clusters scattered across directories. Report, never a gate. Implies --cross-runtime | | --interface-width | Every exported symbol, grouped by its declaring package, ranked by export count; each export's consumer count OUTSIDE the package, and the zero-consumer ones called out as free deletions. Implies the edge pass (does NOT imply --kinds) | | --node-kinds <file> | JSON file of node-kind rule overrides, same boundary discipline as --thresholds. An override REPLACES a whole pattern group | | --layers | Layer gate: every import edge pointing UP the declared layer stack, plus the census and the allowlist verdict. Exits 1 on any disagreement. Runs on the cheap pass — no tsconfig, no Graph | | --layer-rules <file> | JSON file of layer-declaration overrides, same boundary discipline as --thresholds. An override REPLACES layers or allowed wholesale | | --collapse | Ranked collapse candidates: pairs of functions that may be one function, grouped into cliques, each carrying its evidence. Implies --kinds. --json emits the full report (clusters + every scored pair + the skipped blocking keys) | | --collapse-config <file> | JSON file of collapse-setting overrides, same boundary discipline as --thresholds | | --cycles | Module-import cycles reported as their participating FILE GROUPS (the same Tarjan SCC the dependency-cycle smell scores, printed as groups rather than a per-file severity). Report, never a gate. Implies the edge pass — importEdges is [] on the cheap pass | | --comments | Comment CENSUS: every comment line lands in exactly one of nine buckets, and each bucket in exactly one CLASS — mechanical (banner, commented-out-code: removable with no judgment), protected (pragma, license, marker: never touch), prose (file-header, docblock, block, inline: the volume, judgment required). Rolled up by bucket, by package scope, and by file, ranked by comment lines. Human view caps files and scopes at 20; --json emits every row. A count, not a verdict. Standalone: no Graph, no edge pass | | --comments --ci | Comment RATCHET: gate each scope's ratio against comment-ceilings.json at the repo root. Exits 1 on any violation, 2 on a malformed ceilings file | | --comments --write-ceilings | Rewrite comment-ceilings.json from the current measurement (ceilings rounded UP to 1 dp; default/slackPoints carried forward) | | --env-keys | Env-var keys declared in wrangler vars/secrets.required/.dev.vars with no recognized read, and reads with no declaration, plus the withheld count. Standalone: no Graph, no edge pass | | --hotspots [--hotspots-days <n>] [--hotspots-since <iso>] [--hotspots-limit <n>] | Churn (git commits touching a file) × complexity (sum of its functions' complexity), ranked by the product. --hotspots-days sets the window in days ending now (default 90); --hotspots-since pins an explicit ISO start instead (reproducible across runs); --hotspots-limit caps the human view (default 20; --json emits every row) | | --html | Self-contained HTML report (human view; implies the edge pass). Pair with --out to write it banner-safe | | --out <file> | Write the view payload straight to <file> instead of stdout — the banner-safe artifact path (the pnpm code-graph wrapper prints its run banner to stdout, so a bare … > file.html redirect corrupts the file; --out sidesteps stdout entirely). The wrote N bytes confirmation goes to stderr | | --pretty | Pretty-print JSON output | | --json | Force JSON output on a view command |

Capturing output to a file: prefer --out <file> (banner-safe). A bare > file redirect only stays clean when the wrapper is silent — pnpm --silent code-graph … or tsx tools/code-graph/src/index.ts … directly.

Cross-runtime edges (--cross-runtime)

env.AUDITER.getProject() used to resolve to external:getProject — a leaf. In a six-worker system that is where the real coupling lives, so the graph reported the core as decoupled precisely where it is not. This pass resolves a service-binding call or a Durable Object dispatch to the target worker's method, and the resolved id replaces the external one at the call site, so --deep, --blast and callChainDepth span workers with no parallel structure to keep in sync.

The binding → service map is derived from the committed wrangler.{json,jsonc,toml} files (services[] and durable_objects.bindings[], top-level environment only — env.staging re-declares the same binding names against -staging deployments of the same code). There is no table to maintain: delete a binding from the config and the edge leaves the graph.

A binding that cannot be resolved is stated, not dropped. Its edge carries calleeId: null plus a reason — target-service-unknown, target-not-loaded, ambiguous-target — and the call site gets the id unresolved:<service>.<class>.<method>, a namespace distinct from external:*. The census additionally lists every declared binding with zero call sites.

Scope matters. A target resolves only when the target worker's files are in the loaded set, so run the pass at a root that contains both sides — pnpm code-graph services --cross-runtime resolves 205 of 207 call sites in ~80s. A single-service root reports every edge as target-not-loaded (still naming the target service, class and method). --deep over the whole monorepo does not finish in a usable time on this repo; services is the working root.

Node kinds and the two path queries

A type cannot express a path property. No TypeScript type says "this write is only reachable after an authorization check" — that is a property of paths, which is why this repo accumulated fitness functions, ledgers and gates-on-gates to enforce things a graph can simply answer.

--kinds labels every function with exactly one NodeKind — a discriminated union, so an added kind fails to compile at every consumer:

| Kind | Meaning | |---|---| | entry | A place a real run starts: a fetch/scheduled/queue handler, a GraphQL resolver, a registered CLI command, any method of a class a wrangler config declares as a Durable Object, or an RPC method with at least one real cross-service caller (Feature A establishes that as a fact, not a guess) | | auth | An authorization check | | effect | A DB write, a network call, a VM spawn, a queue send | | plain | Nothing matched — reported as unclassified, never as a positive finding |

Precedence is entry > auth > effect > plain, and evidence keeps every rule that matched, so an entry handler that also writes is labelled entry while its db-write evidence stays visible.

Detection is declared, not clever: src/kinds/rules.ts holds named regex groups with defaults tuned for this repo, overridable wholesale with --node-kinds <file> (an override replaces a group rather than extending it). Nothing infers, scores, or guesses.

--unreachable fails toward silence

A measurement over the plain call graph found 133 zero-caller exports in this repo of which hand-verification put ~6% genuinely unreachable — a 15-20× overcount. A detector that cries wolf is worse than no detector, so this one reports a symbol only when it can affirmatively establish that nothing reaches it, and every candidate it declines to judge is counted with a reason (referenced-in-non-test-file, public-entry-surface, test-support-surface).

Three gates, in order: unreachable over the wide edge set → not on a package's declared export surface (read from its own package.json exports/main/bin, because at package scope the consumers in apps/* were never loaded) → the name does not occur in any non-test file other than its own. Survivors split by where the name does occur: only in test files → only-called-from-tests (the "clean seam, wired later" defect); nowhere → dead.

The wide edge set models seven dynamic-dispatch idioms the call graph cannot see, all of which are live code here: argument-position references (app.get(p, handler)), default-parameter fallbacks (deps.del ?? deleteGceHands), dynamic import() fan-out (the CLI's whole command registration), inline handlers and factory options (defineRpc({ execute })), module-top-level calls (export const db = createClient(env)), object-literal members including method shorthand, and a class named at module scope rooting its methods (export const X = withObservabilityDO(Impl)).

--unguarded uses the tight edge set

An effect reachable from an entry with no auth on the path. It walks calls + cross-runtime edges only — a reference edge means "this value was passed", not "this ran", and admitting one here would manufacture findings rather than suppress them. One multi-source BFS over (node, guarded) state, so every finding ships the shortest witness path and a reader can confirm or dismiss it by eye.

Layer violations (--layers)

Does any edge go the wrong way through the architecture? Layers are declared (src/layers/rules.ts), never inferred — an inferred boundary moves whenever the code moves, so it can never fail a build. Order is array order, index 0 is the top, and an edge may point down a layer or sideways within one. Never up.

| Layer | Paths | |---|---| | surface | apps/web, apps/widget, apps/docs, packages/cli | | service | services | | domain | services/*/src/domain, packages/a11y, packages/widget-engine, packages/widget-runtime, packages/hands-machine, packages/machine-auth, packages/sr-hands, packages/sr-tools | | storage | services/*/src/drizzle | | contract | packages/a11y-contract, packages/telemetry-contract, packages/audit-protocol, packages/stdlib |

The most SPECIFIC pattern wins, so services/*/src/domain claims a file services would otherwise take, and a prefix ends at a path separator — packages/a11y never claims packages/a11y-contract.

storage sits below domain deliberately: tools/fitness already enforces "domain does not import the schema" with its own exemptions for repository/ and infrastructure/, and a layer rule that contradicted it would be a second, disagreeing copy of one decision. Layering says where storage sits; that fitness function says who may touch it.

Paths not named are outside the lattice on purpose (tools/*, packages/design, the test-harness packages). Their edges are counted unlayered and never judged — the coverage gap is a number in every report, not a silence. packages/design in particular is consumed from two non-adjacent layers, so declaring it would manufacture findings about a shared UI kit rather than reveal one.

The edge set, and why it is imports

--layers reads module specifiers — static imports, export … from, and dynamic import() — from the parser's own import list, on the cheap pass. No tsconfig, no type-checker, no Graph. That is deliberate: the type-aware --deep pass does not finish on this repo in a usable time, and a gate that cannot be run over the whole architecture is not a gate on the architecture. An import is also what the rule is about ("apps/web must not reach into the engine") and it carries an exact line, which a resolved call does not.

A .d.ts, .json or .css target is still an edge and is still judged; a relative specifier that names no file on disk is reported as UNRESOLVED, by name, so a systematic resolver bug shows up as a pattern rather than as acceptable noise.

The allowlist is a ratchet, in both directions

src/layers/allowed.ts declares the upward edges that exist today — 6 causes, 44 file pairs, 45 import sites — each with its exact site count and the concrete move that closes it. --layers exits non-zero on any of three disagreements:

  • an undeclared violation — new drift cannot land;
  • a stale entry that no longer violates — a closed violation cannot keep its excuse, which is the direction that stops this becoming a baseline file;
  • a miscounted entry — a new import on a known-bad pair is still new drift, and a removed one is still progress.

It is therefore green on arrival and cannot silently tolerate. The same shape tools/fitness's KNOWN_GAPS lists use.

Not wired into CI, and why

Nothing in .github/ runs this. Whether it earns the merge path is a decision for the founder, and this repo's alarm-rationalization rule is explicit that a check needs a named failure that actually reached main or prod plus a response the person who sees it red can take.

Recommendation: promote.yml, not pr.yml. The run is ~60s over 2114 files at repo scale — 30× a day on the merge path buys ~30 minutes of runner time a day to guard a boundary that moves a few times a month. On the promotion PR it costs once per promotion, catches the same drift before it reaches prod, and the response ("point the dependency down, or declare it") is one a promoting human can actually take. It cannot live in a pre-commit hook: the gate needs the whole repo loaded, and a hook that reads 2114 files is a hook people disable.

Collapse candidates (--collapse)

A candidate generator, not a detector, and the division of labour is the whole design: the graph narrows thousands of functions to a ranked shortlist and is allowed to over-produce; a human or a model judges what survives. Getting that backwards is the usual way this fails — a precise cheap filter misses the real duplicates, and precision is not this stage's job.

Report, never a gate. Reachability is a fact and belongs in CI; similarity is a judgment and belongs in a document read once. createProject in kontrol and createProject in auditer are legitimately different layers, and name similarity will pair them every single run. Mixing the two lifecycles is how a check becomes one people skip, so there is no --ci path into this view.

Four signals, N-of-4

| Signal | Fires when | |---|---| | shape | near-identical loc, complexity and nesting depth (loc tolerance is proportional) | | callees | Jaccard over what each calls clears calleeJaccard — same downstream set, different words | | callers | Jaccard over who calls each clears callerJaccard — two alternatives that should be one | | name | overlap coefficient over stemmed, stopworded name tokens clears nameOverlap |

No single signal is sufficient; minSignals of the four must fire (default 2). minSignals is schema-floored at 2, and that is load-bearing rather than taste. The generator does not compare every pair — it blocks on the callee, caller and name-token inverted indexes and scores only pairs sharing an entry. That search is exhaustive exactly while a qualifying pair must fire at least one non-shape signal, i.e. while minSignals >= 2. Allowing 1 would turn a complete search into a silently partial one, so the schema refuses it.

The one recall loss is a blocking key with more than maxBlockSize members (get-shaped tokens, a callee everything calls). Those are reported by kind, key and size — the blind spot is a number on the screen, like every other view here.

The ranking is the point

rank = confidence / collapse_cost

confidence is the fired signals' strengths summed over the four possible signals. collapse_cost is read off the graph and is >= 1 always:

| Cost input | Why | |---|---| | union of both call sites (log2) | how many places a merge must re-point | | packages those call sites span | a merge that touches five packages is five reviews | | the two definitions cross a package boundary | at a services root that makes it a contract change, not a refactor | | either side is on a path that reaches an effect node | a DB write / network call / VM spawn downstream is riskier to touch |

Sorted by similarity alone, the two 400-line cross-service handlers sit at the top and nobody finishes the first item. Sorted by ratio, someone works down the list and quits whenever, and what they did was the highest-value subset available. Stopping early still captures the best ratio — that anti-paralysis property is the entire value, and it is why the cost half exists.

Cliques, not chains

smells/rules.ts holds ten structurally identical evaluate closures. As pairs that is one observation repeated 33 times, and it filled 9 of the top 10 rows on the first real run. Findings are therefore grouped, and the grouping is complete linkage: a cluster is a clique — every member pairs with every other member. Single linkage (a union-find over the pairs) was tried first and chained 44 unrelated small functions into one blob, because a~b and b~c says nothing about a~c. The representative is the clique's best-ranked pair, so the stop-early property survives the grouping.

Every finding ships its evidence: which signals fired with their strengths and details, both file:lines, the computed cost and each input that produced it. A ranking nobody can audit is a ranking nobody trusts.

Same architecture, second target (not built)

Fields, not functions: similar names + same type across related tables, ranked by read sites. That is the duplicate-representation detector the schema-level work needs, and it reuses this file's shape — signals, N-of-N, cost-weighted ranking — against columns instead of functions.

Cluster vs directory mismatch (--clusters)

Does the file layout match how the code actually clusters? Community detection runs over the file-level call + import graph — cross-runtime edges included, which is why the flag implies them: without those the graph stops at each worker boundary and the partition splits along it, confirming the layout by construction. The resulting communities are then compared against the directory tree, from both sides:

  • Directories spanning several clusters — one name covering more than one thing.
  • Clusters scattered across directories — one thing nobody has named yet.

Every row names the files and the cluster they actually belong to. Where the two disagree, the layout is lying about the architecture; that is the mechanical cause of "things are in surprising places," and it says where a split or a merge would follow the grain instead of fighting it.

This is a report, never a gate. A cluster boundary is a heuristic, and no build should go red because a greedy optimizer drew a line one file to the left.

Louvain, made a pure function — no seed to pass, none to forget

The published Louvain algorithm contains no randomness at all: it is greedy modularity optimization plus graph aggregation, repeated. Every implementation that is nondeterministic is so for exactly two reasons, and both are implementation choices: the order nodes are visited in the local-moving phase (igraph, networkx and the original C++ all shuffle it from an RNG), and the tie-break when two candidate communities offer the same gain. Both are pinned here:

| Source of nondeterminism | What this implementation does | |---|---| | Node visit order | Weighted degree descending, ties by node id ascending | | Equal-gain tie-break | Stay put first, then lowest community id (candidates walked in sorted order, incumbent must be beaten strictly) | | Float noise deciding a move | A gain must clear the incumbent by 1e-12 | | Sum order in the modularity total | Communities summed in sorted id order | | Termination | Bounded passes per level and levels per run |

Visit order is not id-ascending, and that is load-bearing: at file granularity the id IS the path, so walking in path order visits each directory contiguously and biases the partition toward agreeing with the directory tree — precisely the hypothesis this instrument exists to test. Degree order is independent of the layout, and is the standard heuristic for stabilising Louvain besides.

Label propagation was the alternative and is rejected. Its asynchronous update rule is order-dependent by design — that is where its quality comes from — so pinning the order changes what it computes rather than merely how it computes it; and on a graph as sparse as this repo's it degenerates into one giant label plus a dust of singletons.

src/query/clusters.test.ts asserts the partition serializes byte-identically when run twice on the same input, through the real tool on real code, and separately that the result does not depend on the caller's input array order — a regression the re-run test alone cannot see.

What is excluded, and counted

  • Test files are not nodes. A test clusters with its subject by construction, so leaving them in adds one predictable edge per source file and dilutes every finding. excludedTestFileCount says how many.
  • Files with no modelled edge are held out of the partition rather than becoming singleton clusters — otherwise a directory of ten unconnected files reports as "spans ten clusters", the degenerate row that would make the report unreadable on a graph with no fat middle. isolatedFileCount says how many, and each split directory lists its own under isolatedFiles.
  • Type-only imports are already excluded upstream by valueEdges: erased at runtime, so they couple nothing.

The human view truncates on both axes (rows per table, files per group) and says what it hid; --json always carries the full lists.

Churn × complexity hotspots (--hotspots)

"Where should effort go" answered from evidence: join git log (how often a file changed) with the graph's own complexity metric (how gnarly it is), rank by the product. This is standard hotspot analysis — where those two numbers are both high, defects concentrate; the graph already computes complexity per function, so churn is the one thing this pass leaves the source tree to ask git for.

Complexity is the sum of FunctionNode.complexity over every function in the file (a cheap-pass value — --hotspots never forces the edge pass). Churn is the count of commits that touched the file inside the stated window. Both raw numbers stay on every row; the ranking score is their product, never shown alone.

The window is always stated and pinnable. --hotspots-days <n> (default 90) sets [now − n days, now]; --hotspots-since <iso> pins an explicit ISO start instead — the reproducibility path, since git history (and "now") drifts between runs even when nothing in the repo does. The resolved window plus the repo's current HEAD sha are echoed in every report so a reader knows exactly what was measured. --hotspots-limit <n> caps the human view (default 20); --json emits every row.

Two git calls, both scoped with -- <pathspec> to the analyzed root so a package-scoped run doesn't pay for the whole monorepo's history: git ls-files (what git tracks right now) and git log --since --until --name-only (commits per file in the window). A file the graph loaded that git ls-files never named is new/untracked — its churn (and therefore its score) is null, not a measured zero, and it is still reported, split out under "no git history" rather than silently dropped. A tracked file simply absent from the window's commits is a real 0.

Generated/vendored paths need no separate exclusion here: --hotspots only ranks files already in the graph's module set, which the cheap-pass loader (project.ts) already excludes from (node_modules, dist, .next, __generated__, *.gen.ts, *.d.ts) — lockfiles and snapshot JSON never enter the graph at all, since it only parses .ts/.tsx.

Interface width (--interface-width) — NOT --unreachable

Two different questions look similar and are not:

  • --unreachable asks "can any entry point reach this at all", over the wide edge set (calls + references + dynamic dispatch). An export with plenty of callers inside its own package is never a finding there — reachability doesn't care who is calling.
  • --interface-width asks "is this export part of the package's contract with the outside world". It counts consumers outside the declaring package for every exported symbol. An export can be called from ten sites in its own package and still have zero external consumers — that export is a free deletion here, and --unreachable will never flag it, because it is reached (from inside).

Run --unreachable to ask "is this code dead". Run --interface-width to ask "does this package's public surface match what it actually needs to expose" — narrowing exports is the cheapest coupling reduction available, because every export is a promise to callers that do not exist yet.

The declaring package is the same package.json boundary --edges' coupling smells use (discoverPackageRoots, over the analyzed root) — not the package's exports/bin-declared surface --unreachable reads from package.json (publicExportPatterns). Those answer different questions: one is "what directory owns this file", the other is "what did this package's manifest promise to publish". --interface-width counts every function-level export, whether or not it is re-exported through a package's declared surface — narrowing what a package publishes starts from every export, not only the ones already on the manifest.

Consumer detection reuses identifierFiles — the same textual occurrence index --unreachable uses as its last, most conservative veto. A name collision between two same-named exports in different packages can credit a false consumer to an export; that is the SAFE direction here, since it can only shrink the zero-consumer "free deletion" list, never grow it with a false positive.

Determinism

Unchanged and non-negotiable: every array sorted by a total order and deduped, JSON keys sorted, same input → same bytes. The two parsers added for the wrangler catalog (jsonc-parser, smol-toml) are pure and deterministic; nothing here is randomized — including the community detection behind --clusters, which is pinned rather than seeded (see above). --hotspots is the one exception worth naming explicitly: its output is byte-identical for the SAME repo state and the SAME window (pin --hotspots-since for that), but git log itself is not a pure function of the source tree — history changes as commits land, so an un-pinned --hotspots-days run legitimately differs from one taken later even against unchanged code.

Self-gate

@binclusive/code-graph gates itself. src/selfcheck.test.ts runs the tool's cheap-pass analysis on its own src/ against selfcheck.thresholds.json and fails the build if any non-test function trips a structural smell — so the tool meets the standard it enforces. Test files are excluded because their fixtures are intentionally gnarly. The failure message names the offending kind, target, and value vs threshold, so a regression is obvious without re-running the CLI.

selfcheck.thresholds.json carries exactly ONE override — directorySprawl: 16, because render/ and extract/ are cohesive by design (SPEC §4) — and it has not moved. Everything else runs at the shipped defaults. When a feature does not fit, the FILE moves, not the number: schema.ts is a barrel over schema/, index.ts split its flag surface into cli.ts, and features C, E, F and H each own a directory (collapse/, layers/, hotspots/, env-keys/, comments/). Six features arrived in parallel and four of them independently reached for the threshold instead; that is the failure mode this note exists to name (#4846).

Comment census (--comments)

Three numbers, never one:

| Class | Buckets | What it means | |---|---|---| | mechanical | banner, commented-out-code | Removable with ZERO judgment — a script could do it. Small by design | | protected | pragma, license, marker | A pragma changes what the build does, a license is a legal obligation, a marker is somebody's open loop | | prose | file-header, docblock, block, inline | The volume. This repo's CLAUDE.md sanctions the comment as the home for rationale, so prose is not a defect — whether a paragraph earns its lines is a judgment a reader makes with the code in front of them |

A single "how much could go" axis was tried and deleted: on this repo it reads ~100% of comment lines on nearly every file, which ranks nothing and implies a verdict the tool has no standing to pass. commented-out-code requires a CLEAN parse of the comment body, so it stays small on purpose — a false "this is dead code" costs far more than a miss, and the confirm step is not to be loosened.

Buckets are decided first-match-wins in precedence order, so a docblock carrying a TODO is a marker (protected), not a docblock. Per-bucket LINE counts partition each file's comment lines exactly: ranges are walked in position order and each physical line is attributed to the first range covering it, so sum(buckets) === commentLines with no reconciling term. The census enumerates comments with extract/metrics.ts's collectModuleCommentRanges — the same walk ModuleNode.commentLines counts — so totals.commentLines and the graph's stats.totalCommentLines are one computation, not two that agree by luck.

The ratchet (--comments --ci)

Nothing counted comments before this, so the ratio only ever grew. --ci turns the same census into a gate over comment-ceilings.json at the repo root:

{ "default": 40.0, "slackPoints": 2.0, "scopes": { "packages/a11y": 47.0 } }

It fails in BOTH directions, and the second is the ratchet:

| Direction | Condition | Fix | |---|---|---| | EXCEEDED | the scope's ratio is above its ceiling | cut the volume back, or raise the entry and say why | | SLACK | the ratio sits more than slackPoints BELOW the ceiling | --write-ceilings — a ceiling that drifts above reality has stopped being one, and leaving it there is headroom for the volume to grow straight back |

A scope with no scopes entry inherits default and is checked for EXCEEDED only: the default is a floor for arrivals, not a claim about that scope, so it cannot be stale. Recording it is what puts it under the ratchet. The file is parsed through the same boundary as --thresholds and --layer-rules (config.ts), so a typo'd key exits 2 with one line.

The gate and the view read ONE CommentCensus — comments/gate.ts owns the load, so a ratio can never be measured two ways. pnpm gate runs it as phase 5.

Read SPEC.md for the contract.