@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_costconfidence 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.
excludedTestFileCountsays 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.
isolatedFileCountsays how many, and each split directory lists its own underisolatedFiles. - 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:
--unreachableasks "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-widthasks "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--unreachablewill 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.
