<p align="center">
  <img src="https://raw.githubusercontent.com/cyberash-dev/project-map-cli/main/assets/banner.png" alt="project-map-cli" width="100%" />
</p>

# 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

```sh
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

```sh
# 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 —

```sh
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`)

```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.

```yaml
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

```jsonc
{
	"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:

```yaml
# 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:

```markdown
## 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:

```sh
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:

```sh
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.
