Skip to content

Repository files navigation

project-map-cli

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.

About

CLI that builds a deterministic PROJECT_MAP.md architectural map for a single repository (Python, JS/TS, Go, Java, Kotlin)

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages