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.
| 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 | 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.
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[] |
— |
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 fromfeatures/,infrastructure/, orcli/.features/<slice>/*.extract.tsdepends only oncore/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'sextract.ts. infrastructure/implementscore/ports/*.port.ts. Swappable without touching domain or use-cases.cli/container.tsis the only place where ports are bound to concrete implementations.
pnpm install # or npm install --legacy-peer-deps
pnpm build # tsc → dist/
pnpm test # vitestNative 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.
# 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| 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.
--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.
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 artifactUnknown top-level keys are rejected: a typo exits 5 rather than being ignored.
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.jsonSelectors 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.
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_appis 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 andClass.attrreferences, 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.
| 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.
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.
- Alphabetical sort everywhere (secondary by source location).
PROJECT_MAP.mdcarries no timestamp, no tool version, no revision and no file count. Runningbuildtwice on an unchanged tree produces byte-identical output, and--checkcompares the bytes with nothing normalized away.- Config hash is deterministic (
sha256of 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.
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.
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.
buildinside a pre-commit hook leaves an unstaged edit to the config: the commit keeps the old floor and the working tree stays dirty. Use--checkthere, which never writes.- A
project-map.config.ts,.jsorpackage.jsonconfiguration 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.
- Create
src/features/build/slices/<slice>/adapters/<lang>.tsimplementingILanguageAdapter<T>. - Register it in that slice's
extract.tsconstructor. - That's it — tests and rendering work unchanged.
- Create
src/features/build/slices/<slice>/{extract.ts,render.ts,adapters/}. - Register the extractor in
features/build/build.use-case.ts. - Call
renderSection(...)on it fromfeatures/build/rendering/markdown.ts. - Add a section id to
core/domain/project-map.ts::SECTION_IDSand 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.
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.
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.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 onlyTargets 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-mapin any repo triggers the onboarding flow: preflight, language/framework detection, CLI install via the detected package manager,init, firstbuild --json, and a multi-select prompt for git hooks.
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, stricterThe installer drops a script into .git/hooks/. Hook properties:
- Opt-in per repo: no
.project-map.yamlpresent → 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 pushlets 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
- 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-mapnever crosses a repository boundary; it emits facts that carry the keys a linker joins on.

{ "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 */ ], }