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

@geonosis/verify-arch

v3.2.0

Published

Whole-graph architecture scans a per-file linter cannot do — unique module names, route collisions, tier direction, dead files — as configurable packs behind one exit code.

Readme

@geonosis/verify-arch

Through the front door: geonosis verify-arch — the metapackage pins this and every other kit tool at ONE version, and passes the exit code through unchanged.

The architecture facts a per-file linter cannot see, behind one exit code.

A lint rule is handed one file and asked a question about it. That is enough for most architecture invariants and not enough for the ones that hurt most: a module name that must be unique across every module, two packages registering the same route, a tier composing something above it once the import is resolved, a file nothing reaches. Each of those is a property of the whole graph, and each of them fails silently in the frameworks where it matters — a last-write-wins map, an inferred id, a loader that skips a filename.

npx geonosis-verify-arch                      # the repo root, human output, exit 0/1
npx geonosis-verify-arch --format ratchet     # one ✗ line per finding, for a counter

The config

geonosis.verify-arch.json at the root. No packs, no run — a scanner with nothing enabled would report PASS having looked at nothing, so that is a refusal (exit 2), not a pass.

{
  "label": "verify:arch",
  "packs": [{ "pack": "medusa", "options": { "sourceRoots": ["packages", "apps"] } }]
}

label is what the pass and fail lines call themselves; a repo whose script is named verify:arch keeps its output byte-for-byte by saying so. Two worked configs ship in examples/.

remedies — what YOUR repo tells a reader to do

{
  "packs": [{ "pack": "medusa" }],
  "remedies": {
    "R5.2": "RETRY_DB | RETRY_EXTERNAL | RETRY_BACKGROUND from @shop/shared"
  }
}

Keyed by check id, appended to every finding of that check in both formats. The shipped R5.2 message says "give it a retry class"; a Medusa storefront's own prose named the three classes and the module they come from — the difference between a fix you can make and a fix you have to go and research. Those names are that repo's, so the kit carries the slot and never the text. A check with no remedy is printed exactly as before.

Exit codes

| code | meaning | |---|---| | 0 | every enabled check ran and found nothing | | 1 | findings | | 2 | the run could not measure — no config, an unknown pack, an unknown format |

2 is its own code on purpose. A scan that never happened is not a clean scan, and a gate that gave it the same exit as a clean one would go green for a repo whose config had a typo in it.

Output

The human format prints one line on success, naming every check that ran and nothing it did not:

verify:arch — PASS (R3.7 serviceName, R3.8 routes, #15442 loader-index, R4.3 query-step, …)

and on failure, on stderr, the count and then each finding marked, its message beneath it:

verify:arch — FAIL: 1 violation(s)

✗ [R5.6] packages/plugins/b2b/src/workflows/place-order.ts:118
    Mutating step 'place-order' has neither a compensation function nor noCompensation: true — …
    RETRY_DB | RETRY_EXTERNAL | RETRY_BACKGROUND from @shop/shared

The line number is there when the check found the fact on a line, and absent when the fact is about the whole file — a name collision spans two of them, and a line number nobody can open is worse than none. The third line is this repo's own remedies entry for that check.

The ✗ is part of the contract, not a decoration: a consumer's CI logs, its runbook and its reviewers all scan for it, and a drop-in that found the same things and marked none of them would be a migration wearing a drop-in's clothes.

The ratchet format prints one line per finding and nothing else:

✗ packages/a/src/modules/one/index.ts R3.7 Module serviceName 'quote' is not globally unique …

which is the shape @geonosis/ratchet's archViolations counter matches by default (^✗):

{ "counter": "archViolations", "command": "npx geonosis-verify-arch --format ratchet" }

The message is flattened onto one line, so one finding is one count however long it is.

Packs

medusa

Ten checks over a Medusa monorepo. Nine of them are also lint rules in @geonosis/oxlint-plugin-biological-architecture's backend-medusa preset; carrying both is deliberate, so one command proves the invariant repo-wide whatever the lint stack is doing.

The checks read the parsed program, not its characters. Each file is parsed once with oxc-parser, so a call is a call, a string is a string and a comment is a comment. A comment quoting updateAiConversations( is not a write, a // TODO: … subscriberId does not pass a subscriber that declared none, a ')' inside a string does not hide a step's .config({ name }), and R5.6 counts a step's arguments, so the trailing comma the house style puts after a multi-line call is not one. The annotations (arch:inline-mutation-ok, arch:route-override-ok) are read from the file's comments, and the same words inside a string excuse nothing.

A file the parser cannot read stops the run with exit 2, naming it: a file the scan cannot read is not a file it found clean. Where a scan and its lint rule disagree, the rule is right; the scan is the repo-wide backstop.

A step is a workflow source. A file is read when it declares a workflow or a step, so a workflows/steps.ts holding nothing but createStep( calls reaches R5.2 and R5.6 — which is where a retry class and a compensation function belong.

| id | check | what fails silently without it | |---|---|---| | R3.7 | serviceName | two modules claiming one serviceName last-win at config merge; a whole module's service stops existing, with no error | | R3.8 | routes | the routes-loader map is last-write-wins across packages, and plugin routes without a namespace segment are collision magnets | | #15442 | loader-index | ResourceLoader filters files named index, so a resource declared in one is registered only if something else happens to import it | | R4.3 | query-step | two un-named query steps in one workflow body collide in the step-handler map | | R5.1 | route-mutations | a route that writes directly skips the door compensation and retry live behind | | R5.2 | step-retry | maxRetries defaults to 0, so a transient failure reverts real state | | R5.3 | route-shadow | two packages on one (path, method): the loader last-wins with no warning | | R5.4 | subscriber-ids | an absent subscriberId is inferred from a name, and two that infer alike dedupe each other | | R5.5 | named-when | an unnamed when() draws a fresh ULID for its checkpoint every process | | R5.6 | compensation-or-flag | noCompensation is inferred from the ABSENCE of a compensate function, so "deliberate" and "forgotten" are one source |

Every repo literal is an option (law 6): packages (which directories hold packages, how they are named, which are namespace-checked — empty by default, so nothing is namespace-checked until a repo says so), workflowFactories, stepFactories, queryStepCalls, mutationPrefixes, retrySpreads, inlineMutationOk, routeOverrideOk, kinds, sourceRoots / moduleRoots / workflowRoots, ignoreDirs, and checks to run a subset.

kinds defaults to ["subscriber", "job"], which is what Medusa 2.19 measurably still skips: framework/dist/utils/resource-loader.js:51 filters index unless the caller passes allowIndex, and framework/dist/workflows/workflow-loader.js:34 is the only caller in the framework that does. A repo below 2.19 adds "workflow" back; examples/medusa-storefront.verify-arch.json does.

composition-tree

The tier ladder as a graph fact. atoms → molecules → compounds → organelles → cells → tissues; a tier composes its own level and below, and only atoms and cells may not compose their own kind.

| id | check | |---|---| | direction | a file composing a tier above its own, judged by the file the import RESOLVES to | | self-composition | an atom importing an atom, a cell importing a cell | | orphan | a classified file no entry point reaches — by BFS, not in-degree, so a cycle of dead files is still dead | | must-compose | a file at a named tier whose imports reach nothing classified — a leaf where the tier is arrangement |

orphan is not run and not claimed in the pass line until entryPoints is configured: with nothing to be reachable from, every file is an orphan, and a pass line naming a check that did nothing is the failure this package exists to refuse. tiers, aliases, extensions and roots are options too.

entryPoints is a list of regexes matched against /<path from the root>, or an object:

{ "entryPoints": { "patterns": ["/app/.*/page\\.tsx$"], "packageExports": true } }

packageExports reads every workspace manifest's exports block and takes each target as an entry point — true for every package.json under the roots, or a path (or list of paths) for named ones. A library workspace's public exports are reached through its manifest by whoever installs it, from outside the tree this scan can see: without this, a first run over one consumer called seven live components dead, and the workaround was 39 generated regexes that went stale the moment somebody added an export. An exports target that resolves to nothing in the graph (./dist/… in a repo that builds) contributes no entry point and no orphan.

must-compose is off until mustCompose names the tiers it means ({ "mustCompose": ["organelles", "cells", "tissues"] }). It is a candidate, not a regression: the consumer's validator this came from carried it as a warning and it never caught a defect there.

Drawing the graph: --format html|mermaid|json

The same graph the checks read can be drawn. geonosis-verify-arch --format html --output docs/architecture.html writes an interactive page (Cytoscape and ELK from a CDN; level and feature filters, search, four layouts), --format mermaid a flowchart, --format json the nodes, edges and per-level counts. It draws the first composition-tree entry of the config and runs no check.

geonosis-verify-arch --format mermaid --output docs/architecture.mmd
geonosis-verify-arch --format json                  # no --output: on stdout
geonosis-verify-arch --format html --output docs/architecture.html --check   # exit 1 when stale

--output is relative to the root. html requires it; the other two print on stdout (one newline after the bytes) without it. --check writes nothing: it exits 1 when the file is missing or is not byte for byte what would be drawn, and prints the command that rewrites it. A run that cannot draw (no composition-tree pack, no file that is a root) exits 2, like every run that cannot measure.

The drawing is a tree walk, not the checks' edge map: each root is walked depth first with its own visited set, a file the tiers do not classify is walked through and its classified imports are lifted onto the importer, and nodes are numbered n0, n1… in the order the walk first meets them.

Everything the drawing needs beyond the pack's own options is view, and all of it is optional:

| view. | default | what it decides | |---|---|---| | roots | every file a level classifies | regexes over /<path from the root>: where each walk starts | | rootExclude | ["\\.(?:spec\|test)\\."] | regexes that remove a file from the roots (it can still be reached) | | base | "." | a directory the printed paths are relative to; a file outside it is printed by the aliases entry whose to holds it | | levels | the Next.js route files as next-route-segment | { label, pattern } levels after the tiers, pattern matched like a tier's against the path from the root; [] for none | | palette | the ladder's colours | { "<label>": { dark: { fill, stroke, text }, light? } } for a level the defaults do not colour | | groups | [{ "pattern": "^features/([^/]+)/" }] | the first pattern a printed path matches names its group, by name or capture 1 | | groupOrder | { first: [], last: ["other"] } | groups listed first sort ahead of the rest, those listed last behind them | | otherGroup | "other" | the group of a path no pattern matches | | title · rootLabel | "Biological Architecture" · "workspace" | the page title; metadata.rootLabel in the JSON | | absolutePaths | false | true adds absoluteFilePath to every JSON node |

absolutePaths is off because a document that is committed and --checked cannot hold one developer's home directory. A tier takes an optional label (atoms is drawn atom); the default ladder carries the singular spellings.

Over a Workers + D1 app's tree (665 nodes, 905 files read) the three outputs are byte for byte what that app's own 1,458-line script writes, the JSON carrying absoluteFilePath under absolutePaths: true. The readers differ in two places, neither of which changes that tree's graph: the script's regex loses a side-effect import (import './a.css') that precedes a from import in the same file, and lists dynamic import() after the static ones where this reader keeps source order.

thresholds

A number that decides behaviour and can say only "me, an hour ago" when asked why. @geonosis/policy's declareThreshold({ value, authority, reason }) is what it is missing.

| id | check | |---|---| | T1.1 | a module-scope const with a threshold-shaped name and a bare number for a value | | T1.2 | one such name declared with two different values in two files — two answers to one question |

T1.2 is why this is a pack and not a lint rule: grep finds both declarations, and only something holding the whole tree can say they disagree.

The name shapes — _MS, _LIMIT, _TIMEOUT, _TTL, _SECONDS, _CAP, _RETRIES, _ATTEMPTS, _INTERVAL, _THRESHOLD, MAX_, MIN_ — are a default, on the same reasoning rails defaults its threat classes: they are the units and bounds a threshold is spelled in, not one repo's word for one repo's thing. names adds, allow excuses by exact name, helper names a repo's own wrapper, sourceRoots bounds where it looks.

A bound declared inside a function is left alone; the anchor that does that is column zero. And a module-private const is a finding — a Workers + D1 app's LIVENESS_TIMEOUT_MS is one, and a check that read only exports read that file as clean.

Writing a pack

import type { Pack } from '@geonosis/verify-arch'

export const mine: Pack = {
  checks: (options) => [{ id: 'X1', label: 'my-check' }],
  id: 'mine',
  scan: ({ files, options, root }) => [
    { file: files[0], message: 'said what is wrong and what to write instead', scanner: 'X1' },
  ],
}

checks is asked BEFORE scan runs, because the pass line has to name what was looked at. files is every file under the root that survived one shared walk, absolute; line on a violation is optional and left off when the fact is about the whole file.

Apache-2.0.