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

project-map-cli

v3.0.0

Published

CLI that builds a deterministic PROJECT_MAP.md architectural map and a byte-reproducible detection-facts artifact for a single repository (Python, JS/TS, Go, Java, Kotlin)

Readme

project-map

CLI that generates a deterministic PROJECT_MAP.md — a flat, AST-derived architectural map of a single repository (bounded contexts, domain entities, enums, HTTP endpoints, persistence schemas, external-service clients, workers). Designed to be read by agents as the first step of any cross-cutting question.

It optionally emits a second artifact, .project-map/facts.json: the same repository's inbound endpoints and outbound calls as canonical, join-ready facts, so a separate tool can wire services together across repositories. The map document is for humans and agents; the facts artifact is for machines and compares byte for byte.

Supported languages

| Language | tree-sitter grammar | | ---------- | ----------------------------------------------- | | Python | tree-sitter-python | | TypeScript | tree-sitter-typescript (includes .tsx) | | JavaScript | tree-sitter-javascript (includes .jsx/.mjs) | | Go | tree-sitter-go | | Java | tree-sitter-java | | Kotlin | @tree-sitter-grammars/tree-sitter-kotlin |

Extractor coverage per language (MVP)

| Extractor | Python | TS/JS | Go | Java | Kotlin | | -------------- | -------------------------------------------------- | ----------------------------------- | ---- | ---- | ------ | | contexts | full | full | full | full | full | | entities | full | full | full | full | full | | enums | full | full | full | full | full | | storage (ORM) | SQLAlchemy declarative | TypeORM @Entity | — | — | — | | storage (migr) | Alembic | — | — | — | — | | workers | *Worker classes + @celery.task/@dramatiq.actor | *Worker/Processor/Handler classes | — | — | — |

Slots that are "—" are implemented as ports — adding a new adapter is a drop-in in the relevant slice.

Structural detection coverage

The reworked detection behind endpoints / interactions recognises code by import provenance and declared configuration, never by identifier names. It replaced the prior name-shaped extractors in 1.0.0.

| Mechanism | Python | Go | | ------------------------ | ------------------------------------------ | ------------------------------------------ | | Inbound, from a spec | openapi.serves[] — any language | same | | Inbound, from code | declaration DSL (detect.inbound.routers) | router values: chi, net/http.ServeMux | | Inbound, serve anchor | derived from aiohttp.web.run_app | declared in detect.inbound.serve_roots[] | | Outbound, generated | openapi.consumes[] modules | — | | Outbound, transport | requests, aiohttp, httpx, urllib3 | — | | Outbound, declared sink | detect.outbound.sinks[] | detect.outbound.sinks[] | | Outbound, shared library | detect.outbound.module_ids[] | — |

Architecture

Vertical Slice + Hexagonal.

src/
  core/                                # Domain + ports (no infra deps)
    domain/                            # ProjectMap, SourceLocation, Language, …
      facts/                           # Fact schema, value IR, anchors, diagnostics
    ports/                             # ISourceParser, IFileWalker, IConfigLoader, …
  features/                            # One vertical slice per use-case
    init/
      init.use-case.ts
    detect/                            # Structural detection → facts.json
      detect.use-case.ts
      canonical/                       # RFC 8785 JCS, array order, fact identity
      index/                           # Import, declaration, package and hierarchy indexes
      value/                           # Value normalizer (literal, config_ref, …)
      openapi/                         # Inventory ingest + canonical path grammar
      inbound/                         # Route registration adapters, per language
        go/                            #   chi, net/http.ServeMux, serve roots, router scope
        python/                        #   serve anchors derived from the serving call
      outbound/                        # Classification ladder, per language
      merge/                           # Semantic core, merge table, coverage, cross-check
      registry/                        # Generated build digests (emit-build-digest.mjs)
      render/                          # Artifact and sidecar emission
    build/
      build.use-case.ts                # Composition of all extractor slices
      extraction-context.ts
      extractor.port.ts
      symbol-index.ts                  # Shared preprocessor (import graph, inbound refs)
      slices/                          # One vertical slice per extractor kind
        contexts/                      #   extract.ts + render.ts (+ language adapters)
        entities/                      #     adapters/{python,typescript,go,java,kotlin}.ts
        enums/
        endpoints/
        storage/
        interactions/
        workers/
      rendering/
        markdown.ts                    # mdast → GFM
        json.ts
        detection-sections.ts          # The two opt-in detection sections
    version/
  infrastructure/                      # Adapter implementations for each port
    parser/
      tree-sitter.ts                   # ISourceParser implementation
      grammars.ts                      # per-language grammar loading
      ts-utils.ts                      # tree traversal helpers reused by adapters
    filesystem/
      globby-walker.ts
      node-fs.ts
    config/
      schema.ts                        # zod
      loader.ts                        # cosmiconfig
      defaults.ts
    analysis-unit/
      materializer.ts                  # The only filesystem read detection sees
    openapi/
      yaml-openapi-reader.ts
    revision/
      git.ts                           # git rev-parse HEAD
    clock/system.ts
    logger/console.ts
  cli/
    container.ts                       # Composition root (DI wiring)
    commands.ts                        # commander wiring
    index.ts                           # entry point

Hexagonal rules this repo enforces:

  • core/ has no imports from features/, infrastructure/, or cli/.
  • features/<slice>/*.extract.ts depends only on core/ ports and its own slice's language adapters. No direct infrastructure imports.
  • Language adapters inside a slice are interchangeable; new languages land as new files under adapters/ and registrations in the slice's extract.ts.
  • infrastructure/ implements core/ports/*.port.ts. Swappable without touching domain or use-cases.
  • cli/container.ts is the only place where ports are bound to concrete implementations.

Install

pnpm install                  # or npm install --legacy-peer-deps
pnpm build                    # tsc → dist/
pnpm test                     # vitest

Native tree-sitter grammars need a C/C++ toolchain and Python 3 at install time (Xcode Command Line Tools on macOS; build-essential on Linux). If you need to skip native builds locally, pass --ignore-scripts — the relevant .node binaries are shipped as prebuilds.

Usage

# Scaffold a default .project-map.yaml for your project.
project-map init --lang python --framework aiohttp

# Build PROJECT_MAP.md + project-map.json.
project-map build --json

# Only a subset of sections.
project-map build --only contexts,enums,endpoints

# Run in CI: exit 1 if the committed PROJECT_MAP.md is out of date.
project-map build --check

# Print the analysis-unit digest; writes nothing.
project-map facts --unit-digest

# Print version and which tree-sitter grammars loaded.
project-map version

Exit codes

| Code | Meaning | | ---- | ----------------------------------------------------------------------------- | | 0 | success | | 1 | the requested effect did not occur — including a --check mismatch | | 2 | no discoverable configuration file | | 3 | the committed facts artifact names another analyzer build or adapter registry | | 4 | the build raised a mandatory check diagnostic | | 5 | a config-time error, raised before any build runs | | 6 | --strict found a diagnostic the unclassified baseline does not cover | | 7 | the installed CLI is below the repository's min_tool_version |

3 outranks 1: a fingerprint difference accounts for every byte difference downstream of it. 4 is independent of the committed bytes. 7 is raised before anything beyond the configuration is read.

Adopting detection on a repository that already has diagnostics

--strict ratchets: it fails on a diagnostic the baseline does not list, and on a baseline entry that matches nothing. Record the starting point once —

project-map build --strict > .project-map/unclassified-baseline.json

— then point detect.unclassified_baseline at that file. The baseline holds diagnostic cores rather than source anchors, so editing a line above a covered site does not churn it, and it never reaches the emitted artifact.

Configuration (.project-map.yaml)

project:
  name: my-service
  language: python # python|typescript|javascript|go|java|kotlin
  frameworks: [aiohttp, sqlalchemy, alembic]

root: .
exclude:
  - "tests/**"
  - "**/__pycache__/**"

sections:
  - overview
  - contexts
  - entities
  - enums
  - endpoints
  - storage
  - interactions
  - workers

overview:
  path: .project-map/overview.md # optional prose preamble

contexts:
  custom: [] # [{path, role}] overrides
  auto:
    min_files: 10 # contexts under this file count are dropped
    known_roles:
      actions: Business actions / use-cases
      handlers: HTTP handlers
      storage: Persistence layer

entities:
  top_n: 30
  include_fields: true
  include_private_methods: false
  importance:
    method_count: 0.5
    field_count: 0.3
    inbound_references: 1.0

enums:
  base_classes: [Enum, IntEnum, StrEnum] # Python: recognised enum bases

endpoints:
  framework: aiohttp # aiohttp|fastapi|flask|express|fastify|gin|spring|…
  routes_module: null # aiohttp: best-effort discovery when null
  app_var: null # FastAPI/Express: name of the app instance

storage:
  base_class: Base # SQLAlchemy declarative base name
  migrations_dir: src/storage/migrations # relative to root; null to skip
  last_n: 10

interactions:
  dir: src/interactions # one depth-1 subdir per external service

workers:
  patterns:
    - "class *Worker"
    - "@celery.task"
    - "@dramatiq.actor"

output:
  markdown: PROJECT_MAP.md
  json: project-map.json # omit or `null` to skip
  facts: null # .project-map/facts.json to emit the facts artifact

Unknown top-level keys are rejected: a typo exits 5 rather than being ignored.

Structural detection

endpoints and interactions render the reworked detection. There is no built-in adapter: every fact comes from openapi.serves, detect.inbound.routers or detect.outbound.sinks, so a repository that configures none renders both sections empty.

repository_identity: example-org/my-service # required once detection is on

sections:
  - endpoints # H2 "HTTP endpoints" — inbound facts
  - interactions # H2 "External dependencies" — outbound operations

analysis_unit: # everything detection is allowed to observe
  sources:
    include: ["**/*.py"]
    exclude: ["**/tests/**"]

openapi:
  serves:
    - spec: repo:openapi/orders.yaml # the specification this service serves
      contract_id: orders.v1 # the logical identity consumers join on
      mount: /v1
  consumes:
    - generated_module: gen.orders_api # calls into it are one operation
      spec: repo:openapi/orders.yaml
      contract_id: orders.v1

detect:
  inbound:
    serve_roots: # Go only; Python derives its anchor, see below
      - function: "example-org/my-service/internal/api.NewRouter"
        result: 0 # which returned value is the router
        mount: "/" # the absolute prefix it is exposed under
    routers:
      - dsl: "routing_dsl.PrefixedUrl" # matched by import origin, not name
        path_arg: { kind: arg, selector: 0 }
        prefix_from: { kind: class_const, selector: PREFIX }
        verb_from:
          { kind: handler_methods, handler: { kind: arg, selector: 1 } }
      - dsl: "github.com/go-chi/chi/v5"
        path_arg: { kind: arg, selector: 0 }
        identity_preserving: # members a router value survives
          - { member: withStats, from: arg, index: 0 }
  outbound:
    sinks:
      - base_type: "service_client.AbstractInteractionClient"
        call: [get, post, put, patch, delete]
        path_arg: { kind: arg, selector: url }
        path_via: { member: endpoint_url, arg: 0 } # helper joining path to target
        target: { kind: class_const, selector: BASE_URL }
    registry: # how business code reaches a client
      - container_type: "interactions.InteractionClients"
        access: "self.clients"
    module_ids: # a client type whose operations live in a library
      - type: "acme.lib.interactions.billing.client.AbstractBillingClient"
        module_id: "acme.lib.interactions.billing"

output:
  facts: .project-map/facts.json

Selectors are a closed set of steps — arg, field, class_const, receiver and a dotted property-path. A glob or a name pattern in a selector is a config-time error: matching by name is what this rework exists to remove.

An absolute route needs an anchor

A router that no mount record reaches is not a composition root; it is a sub-router whose mount the analysis cannot see, and the two are one syntactic form. Publishing the second at a bare path is a fact that reads as proven and names a route the service does not serve, so it is refused: an absolute route is composed only downward from a serve anchor.

  • Go declares the anchor. serve_roots[] names the declaration a router reaches the outside through, the index of the returned value that is the router, and the absolute prefix it is exposed under. The returned value is followed through up to three statically resolved call edges, so a root built two calls below the declared symbol still anchors.
  • Python needs no configuration. A call resolving by import provenance to aiohttp.web.run_app is the anchor; the application classes each branch of the binding constructs are resolved, and the route collections their class attributes hold — through tuples, splats, + concatenation, module constants across imports and Class.attr references, first binding in MRO order — are anchored at the root.

A registration reaching no anchor is still emitted, with its path typed unknown(unanchored_router) and its source anchor, and raises the diagnostic of the same name. The partial prefix the analysis did prove is evidence and is not published: a suffix match is ambiguous wherever two mounts end in the same segments, and moving that ambiguity to the consumer does not remove it.

serve_root_unresolved is raised where a declared symbol names no single declaration, where the result index lies outside the declaration's arity, or where the followed value is not a router. It is a mandatory check code: build --check exits 4 on it, on the code alone, without waiting for the bytes to drift.

Diagnostics the detection raises

| Code | Mandatory | Names | | ------------------------------------ | --------- | -------------------------------------------------------------- | | serve_root_unresolved | yes | a declared or derived anchor that resolved to no router | | unanchored_router | no | a registration whose router reached no anchor | | router_mount_unresolved | no | a mount whose sub-router did not resolve | | router_route_not_in_openapi | no | a route the code proved and the inventory does not declare | | openapi_route_not_in_code | no | a served route the code half never registered | | external_registration_unclassified | no | a registration inside the candidate universe nothing claimed | | external_call_unclassified | no | an outbound call inside the candidate universe nothing claimed |

The two cross-check codes are computed after the merge, one route at a time, so a route proven on both sides raises neither. No configuration suppresses them: a suppression flag that goes stale silently disables the cross-check. Use --strict and the unclassified baseline to ratchet instead.

The facts artifact

{
	"schema_version": "1",
	"repository_identity": "example-org/my-service",
	"analysis_unit_digest": "sha256:…", // what was read
	"analyzer_build_digest": "sha256:…", // which analyzer read it
	"adapter_registry_digest": "sha256:…", // with which adapters
	"coverage": { "inbound_declared": 165, "inbound_registered_in_code": 0 },
	"facts": [
		/* endpoint and outbound_operation records */
	],
	"diagnostics": [
		/* unclassified sites inside the candidate universe */
	],
}

Every unproven value is typed rather than guessed: a path the analyzer could not fold is unknown(dynamic), a destination the repository does not bind is unknown(operation_in_library_root), and each fact carries a resolution of resolved, ambiguous, unresolved or conflicting. Coverage denominators are honest — an axis nothing declares reports unmeasured rather than a completed fraction. The grade and the measures live in the artifact alone; the document renders neither, because a coverage tally moves without the structure moving and a row's grade only restates the unknown(...) cell beside it.

The artifact is the complete record; the document is the readable subset. Since 3.0.0 the HTTP endpoints table renders only a route the analysis proved — a path that is a literal — because a row whose route is a hole names no route a reader can look up or compare against a specification. A row whose route is proven and whose method is not still renders: the route is what the section is read for. Everything filtered out stays in facts.json with its provenance, its evidence and its diagnostics, which is where --check and a linker read it. External dependencies is unfiltered — a dependency row names an owner and a call worth looking at whatever its route resolved to.

The artifact carries no timestamp; the generation time and build duration live in .project-map/facts.meta.json, which --check never opens. Add the sidecar to .gitignore and commit the artifact.

Output determinism

  • Alphabetical sort everywhere (secondary by source location).
  • PROJECT_MAP.md carries no timestamp, no tool version, no revision and no file count. Running build twice on an unchanged tree produces byte-identical output, and --check compares the bytes with nothing normalized away.
  • Config hash is deterministic (sha256 of canonicalized config JSON).
  • The facts artifact holds to the same rule and adds one: building the same tree from a different absolute path produces the same bytes.
  • No network access and no model inference in the build path.

Everything a rebuild alone would change lives outside the document a repository merges by hand. project-map.json carries the tool version, the timestamp, the revision, the config hash and the file counts; .project-map/facts.meta.json carries the timestamp and the build duration for the facts artifact. Both are opt-in, and --check reads neither.

Versioning

The npm version is the CLI's release version. Four published surfaces carry their own semver, and CHANGELOG.md lists them per release:

| Surface | Covers | | ----------------------------- | ------------------------------------------------------ | | project-map/cli | command names, option names, exit codes, config schema | | project-map/map-document | PROJECT_MAP.md structure and its JSON companion | | project-map/detection-facts | facts.json schema, identity and serialization | | project-map/package | bin entry point, tarball contents, engines.node |

facts.json additionally embeds its own schema_version, so a consumer can pin against the artifact without reading the package version.

The version floor

Developers install the CLI separately, so an older install can rebuild a map a newer one produced and silently revert both its format and its content. One config key stops that:

# The lowest project-map version allowed to rebuild this map.
# `project-map build` raises this to its own version.
# If your CLI reports `Unrecognized key: "min_tool_version"`, your install
# predates the key: npm i -g project-map-cli@latest
min_tool_version: "2.0.0"

init writes it. A build or facts run below it is refused with exit 7 before anything is read beyond the configuration, naming the remedy rather than only the mismatch:

You need to update project-map. Installed 2.0.0, this repository requires 3.1.0.

  Update it:
    npm i -g project-map-cli@latest

  /repo/.project-map.yaml declares min_tool_version: 3.1.0.
  If staying on 2.0.0 is deliberate, lower that line instead.

A build that finishes at exit 0 raises the line to its own <major>.<minor>.0 — a patch carries the format of its minor, so raising to the patch would let one invocation lock a team out over a difference no emission reflects. The rewrite replaces the bytes of the value alone; comments, line endings and every other key survive.

Adding the key to an existing repository is what makes this work against installs that will never receive this release: the schema has rejected unknown top-level keys since v0.1.0, so every published version refuses a configuration carrying it. What those versions print is a raw schema error naming the key, so carry the comment over — it is the only explanation that reaches their reader.

Known costs, all deliberate:

  • Two developers on different minors both build and commit: a one-line merge conflict on the floor. Take the higher.
  • The slowest upgrader is blocked by the fastest. That is the mechanism.
  • A deliberate downgrade is impossible from inside the tool, because an older install fails on the unknown key before it reads any flag. Lower the line.
  • build inside a pre-commit hook leaves an unstaged edit to the config: the commit keeps the old floor and the working tree stays dirty. Use --check there, which never writes.
  • A project-map.config.ts, .js or package.json configuration is read and enforced but never rewritten: the first two are inside the scanned source set for a TypeScript or JavaScript project, so writing to one would move the map's own inputs. Raise those by hand; the run names the version to set.

Adding a new language adapter to an existing slice

  1. Create src/features/build/slices/<slice>/adapters/<lang>.ts implementing ILanguageAdapter<T>.
  2. Register it in that slice's extract.ts constructor.
  3. That's it — tests and rendering work unchanged.

Adding a new slice

  1. Create src/features/build/slices/<slice>/{extract.ts,render.ts,adapters/}.
  2. Register the extractor in features/build/build.use-case.ts.
  3. Call renderSection(...) on it from features/build/rendering/markdown.ts.
  4. Add a section id to core/domain/project-map.ts::SECTION_IDS and zod schema.

The project is spec-driven: spec/ holds the normative records, and a change to observable behaviour starts there. See CLAUDE.md for the workflow.

Agent integration

The point of PROJECT_MAP.md is that an agent reads it instead of firing Explore/Grep across the repo for a cross-cutting question. Three layers of enforcement, in order of subtlety → aggressiveness.

1. CLAUDE.md directive (soft, always-on)

Drop this into the project's CLAUDE.md so every Claude Code session loads it automatically:

## Cross-cutting questions

PROJECT_MAP.md at the repo root is the authoritative structural reference.
Before running Explore, Grep, or Glob for "how does X work across services /
modules", read PROJECT_MAP.md first. It lists contexts, domain entities with
inheritance, enums with members, HTTP endpoints, storage tables, migrations,
external-service clients, and workers — deterministic, AST-derived, no LLM
guesses. Fall back to Explore/LSP only if the map does not answer.

2. Claude Code integration — hook + /project-map skill

A single command installs both a UserPromptSubmit hook and a /project-map slash-command skill that walks a fresh repo through install → init → first build → optional git hooks:

project-map claude install                     # project scope, hook + skill
project-map claude install --scope user        # write to ~/.claude/
project-map claude install --force             # reinstall / update both
project-map claude install --no-skill          # hook only
project-map claude install --no-hook           # skill only

Targets per scope:

| Component | --scope project | --scope user | | --------- | ------------------------------------- | --------------------------------------- | | Hook | .claude/settings.json | ~/.claude/settings.json | | Skill | .claude/skills/project-map/SKILL.md | ~/.claude/skills/project-map/SKILL.md |

The command writes directly into those files, merging with any existing hooks and preserving the rest of settings.json. Idempotent: a second run without --force detects the existing hook/skill and exits without duplicating. The skill is refused (without --force) if the existing SKILL.md was hand-edited — no silent overwrites of user changes.

Effects:

  • Hook — every turn the agent sees "PROJECT_MAP.md exists — read it before broad searches", gated only on the file being present.
  • Skill — typing /project-map in any repo triggers the onboarding flow: preflight, language/framework detection, CLI install via the detected package manager, init, first build --json, and a multi-select prompt for git hooks.

3. Git hook (hardest, gates commits or pushes)

A hook runs project-map build --check and fails with a friendly message if PROJECT_MAP.md is stale. Install with:

project-map install-git-hook --type pre-push      # recommended
project-map install-git-hook --type pre-commit    # slower, stricter

The installer drops a script into .git/hooks/. Hook properties:

  • Opt-in per repo: no .project-map.yaml present → hook exits 0 silently, so it's safe to install in user-wide hook directories.
  • Resolves binary: prefers node_modules/.bin/project-map, falls back to PATH, exits 0 if neither is found (so fresh clones don't fail pushes).
  • Emergency bypass: SKIP_PROJECT_MAP_HOOK=1 git push lets a one-off through without touching the hook.

Typical developer workflow with the hook:

git commit -m "…"
git push
# fails: "PROJECT_MAP.md is out of date"
project-map build
git add PROJECT_MAP.md
git commit --amend          # or new commit
git push

Non-goals

  • Not an index for find-definition / find-references — that's LSP.
  • Not a graph — Graphify does that.
  • No LLM calls in the build path. Ever.
  • Not a linter.
  • Not a cross-repository linker. project-map never crosses a repository boundary; it emits facts that carry the keys a linker joins on.