Metadata-Version: 2.5
Name: pythograph
Version: 0.3.0
Summary: Static facts for Python (Django, Django REST framework, Flask, SQLAlchemy) services for the isthmus bridge-facts exchange: HTTP route declarations and persistence relation uses from the standard-library ast, without importing the analyzed project.
Project-URL: Homepage, https://github.com/ictechgy/pythograph
Project-URL: Repository, https://github.com/ictechgy/pythograph
Project-URL: Issues, https://github.com/ictechgy/pythograph/issues
Project-URL: Changelog, https://github.com/ictechgy/pythograph/blob/main/CHANGELOG.md
Author: ictechgy
License-Expression: MIT
License-File: LICENSE
Keywords: django,django-rest-framework,flask,http,isthmus,persistence,routes,sqlalchemy,static-analysis
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Console
Classifier: Framework :: Django
Classifier: Framework :: Flask
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Quality Assurance
Classifier: Typing :: Typed
Requires-Python: >=3.10
Description-Content-Type: text/markdown

# pythograph

<img src="https://raw.githubusercontent.com/ictechgy/pythograph/main/icon.png" alt="pythograph's blue tit mascot" width="112" height="112" align="right">

[한국어](https://github.com/ictechgy/pythograph/blob/main/README.ko.md)

Static facts for Python services (Django, Django REST framework, Flask, SQLAlchemy), emitted in the
[isthmus](https://github.com/ictechgy/isthmus) bridge-facts exchange format.

pythograph is the Python member of a family of static-analysis CLIs (tsograph for
TypeScript/JavaScript, cartograph for Swift, kartograph for Kotlin, dartograph for Dart, gartograph for
Go, rustograph for Rust, schemagraph for SQL). Each tool reports only what it observes in its own language;
isthmus joins the documents.

The analyzed project is parsed with the standard-library `ast` only. It is never imported or executed,
and pythograph has no runtime dependencies and uses no network. The only program it starts is git at the project root,
to read `revision` for `graph`, `reach`, and `impact`; git runs without the filter commands that the repository's git
configuration defines, never fetches the objects that a partial clone lacks, and never runs inside submodules (see
[Security](#security)).

## Status

| Area | State |
|---|---|
| `pythograph routes --role server`: Django URLconf, Django REST framework routers and views, Flask/Werkzeug rules → `route-decl` facts | Implemented |
| `pythograph schema`: Django models and QuerySets, SQLAlchemy 2.x / Flask-SQLAlchemy 3 mappings and queries, SQL text → persistence `relation-use` facts | Implemented |
| `pythograph graph` / `reach` / `impact`: Python call graph → isthmus `language-traversal` v1 (evidence tiers `direct`/`bound`/`candidate`, `unresolvedCalls`, Django/DRF/Flask dispatch) | Implemented |
| `pythograph routes --role client`: requests, httpx, aiohttp, and urllib calls plus declared HTTP wrappers → `route-call` facts | Implemented |

isthmus-cli 0.10.0 (npm) is the first release that accepts `platform: "python"` http documents (server `route-decl`
including `registration-order`, and client `route-call`), persistence documents, and python `language-traversal`
analyses (see [isthmus compatibility](#isthmus-compatibility)).

## Requirements and installation

- Python 3.10 or newer (Django 5.x needs 3.10+, and pythograph parses the analyzed code with the running
  interpreter, so run it with the same or a newer Python than the project).

Install from [PyPI](https://pypi.org/project/pythograph/):

```sh
uv tool install pythograph
# or
pipx install pythograph

pythograph --version
```

To try unreleased changes, install from GitHub (`uv tool install git+https://github.com/ictechgy/pythograph`, or pin a
release tag such as `@v0.1.0`). A wheel built from a checkout (`uv build`) installs the same way:
`uv tool install dist/pythograph-<version>-py3-none-any.whl`. The release steps are in
[RELEASING.md](https://github.com/ictechgy/pythograph/blob/main/RELEASING.md) (Korean).

## `pythograph routes --role server`

```sh
pythograph routes --role server --project <root> [--service <name>] [--include-tests]
                  [--framework auto|django|flask] [--settings <module>]
                  [--dispatch specificity] [--generated-at <timestamp>] [--format json]
```

Writes a bridge-facts v1 document to stdout: `platform: "python"`, `target: "http"`, `roles: ["server"]`,
`dispatch`, `sourceSets`, and one `route-decl` fact per (route, HTTP method).

- `--project` (required): the project root. `project` is its POSIX realpath and every `location.path` is
  relative to it.
- `--framework`: `auto` (default) detects Django (a `DJANGO_SETTINGS_MODULE` default in `manage.py`,
  `wsgi.py`, or `asgi.py`, or `--settings`) and Flask (a `Flask(...)` or `Blueprint(...)` object). A project
  with both is a usage error until you pick one.
- `--settings`: the Django settings module when the entry files do not name it.
- `--include-tests`: also emit routes declared in test sources (`test_*.py`, `*_test.py`, `tests.py`,
  `conftest.py`, files under `tests/` or `test/`) with `testSource: true` and `sourceSets.tests: "included"`.
- `--dispatch specificity`: declare `specificity` for a Django project and omit `order` (see
  [Decisions](#decisions)).
- `--generated-at`: a fixed `generatedAt` for byte-identical output.
- Exit codes: `0` success (zero facts is still success, not proof of completeness), `2` unreadable project,
  more than 100,000 facts, output over 16 Mi characters, or an internal error (the message states the cause
  and a fix, never source text or absolute paths), `64` usage error. `1` is reserved.

Example (synthetic, compacted; the real output is key-sorted JSON with two-space indentation):

```json
{
  "dispatch": "registration-order",
  "facts": [
    {
      "channel": "/catalog/items/{}/edit/",
      "dynamic": false,
      "kind": "route-decl",
      "location": { "column": 10, "line": 20, "path": "catalog/urls.py" },
      "method": "POST",
      "order": { "group": "django:shop.urls", "index": 7 },
      "paramConstraints": [{ "kind": "int", "segment": 2 }],
      "pathAnchor": "root",
      "symbol": { "qualifiedName": "catalog/views.py#ItemEditView.post", "usr": "catalog/views.py#ItemEditView.post" },
      "trailingSlash": "strict"
    }
  ],
  "format": "bridge-facts",
  "platform": "python",
  "roles": ["server"],
  "target": "http",
  "tool": { "name": "pythograph", "version": "0.2.0" },
  "version": 1
}
```

### What is modeled

Every rule was checked against the installed package sources (Django 5.2.17, djangorestframework 3.18.1,
Flask 3.1.3, Werkzeug 3.1.9). The full table with source files is in [docs/HTTP-ROUTES.md](https://github.com/ictechgy/pythograph/blob/main/docs/HTTP-ROUTES.md)
(Korean).

- **Django**: `ROOT_URLCONF` from the settings module (star imports of project settings modules are
  followed), `urlpatterns` built with lists, `+`, `+=`, `.append`, `.extend`, `.insert`, `path()`, `re_path()`,
  `include()` (module strings, module objects, lists, `(patterns, app_name)` tuples, nested), default and
  registered path converters, `re_path` regular expressions converted from their syntax tree when possible,
  function views (`ANY`, narrowed by `require_http_methods`/`require_GET`/`require_POST`/`require_safe`/
  `api_view`), class views (`as_view()`, handlers along the class chain, `http_method_names`), and the
  registration order (first match wins).
- **Django REST framework**: `SimpleRouter`/`DefaultRouter` (`trailing_slash`, `use_regex_path`, lookup
  settings, `@action` with `detail`, `methods`, `url_path`, and `.mapping`), the `DefaultRouter` API root and
  format-suffix variants, `format_suffix_patterns`, `APIView`, generic views, and viewsets.
- **Flask**: `Flask(...)` and `Blueprint(...)` objects at module level or in app factories, `@route`,
  `@get`/`@post`/`@put`/`@delete`/`@patch`, `add_url_rule`, `register_blueprint` (nested, `url_prefix`),
  `MethodView`/`View`, Werkzeug converters (`string`, `int`, `float`, `uuid`, `path`, `any`, custom),
  `strict_slashes`, and `merge_slashes`.

### How facts are built

- **channel**: the canonical path template. A parameter filling a whole segment is `{}`, a partial segment
  keeps its literal skeleton (`/files/{}.json`), a final parameter that can match `/` is `{**}`, and anything
  else that cannot be proven (a segment with two parameters, a middle catch-all, lookarounds, unanchored
  regular expressions) is `dynamic` with a `route-coverage:` limitation. Literals are in the decoded path
  space, so non-pchar characters are UTF-8 percent-encoded and `%` becomes `%25`.
- **method**: uppercase verbs or `ANY`. `HEAD` next to `GET` and automatic `OPTIONS` are not emitted (isthmus
  matches them with `head-as-get` and `options-any`); an explicitly declared `OPTIONS` is. Verbs outside the
  exchange method set (`GET`, `HEAD`, `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS`, `TRACE`), such as `PURGE` in
  Flask `methods` or Django `http_method_names`, are not emitted and not turned into `ANY`; the registration gets
  a `route-coverage:` limitation scoped to its templates when they are proven.
- **paramConstraints**: `int`, `slug`, `uuid`, `path` (for `{**}`), or `regex` with the pattern. The
  informational `pattern` is left out when the regular expression text contains characters the exchange format
  forbids (control characters, U+2028/U+2029, or an unpaired surrogate).
- **trailingSlash**: Django and strict Flask rules are `strict`; `/?` in a regex and Flask
  `strict_slashes=False` are `optional`; omitted after `{**}`.
- **order** (Django): `{group: "django:<ROOT_URLCONF>", index: <depth-first position>}`.
- **location**: the route string argument (Django `path()`/`re_path()`, Flask decorator or `add_url_rule`), the
  `router.register()` prefix for DRF routes, or the `@action` decorator for extra actions; 1-based line and
  1-based UTF-8 byte column.

### Symbol ids

`symbol.usr` is `<project-relative POSIX path>#<lexical dotted name>`, outermost declaration first and
without `<locals>`: `catalog/views.py#item_list`, `blog/__init__.py#create_app.index`,
`catalog/views.py#ItemEditView.get`, `orders/views.py#OrderViewSet.list`. Class handlers are named after the
class registered in the URL even when the method is inherited; the call graph (`pythograph graph`) has an
inherited-member node with the same id. Views defined outside the project have no usr and are counted under
`missing-route-usrs:`.

### Limitations

When a value cannot be proven, pythograph does not guess: it emits `dynamic`, `pathAnchor: "base"`, or a
limitation with one of the contract's prefixes, and adds `limitationScopes` only when it can prove an upper
bound. Examples: conditional registrations (`if settings.DEBUG:`) become `route-coverage:` scoped to their
templates; unresolved includes and third-party URL modules are scoped to their include prefix; the Django admin,
`static()`, `django.contrib.staticfiles`, and Flask static files are `framework-provided-routes:` with prefix
scopes (Flask static also with `GET`/`HEAD`); `FORCE_SCRIPT_NAME`, `i18n_patterns`, and blueprints whose
registration is not visible use a `base` anchor with `unresolved-route-prefix:`; a project that does not pin
Django 5, DRF 3, or Flask 3 gets `route-framework-version-unknown:`. A module constant or setting that is not
provable (see [Bounded source scanning](#bounded-source-scanning): bound twice, `+=`, assigned inside an `if`, loop,
`try`, `with`, or `match` block, rebound through `global`, or changed in place such as `INSTALLED_APPS.append(...)`)
is unknown: a route string built from it is `dynamic`, a method list from it leaves the view with its known methods
or `ANY` and a scoped `route-coverage:`, a conditional `FORCE_SCRIPT_NAME` gives the `base` anchor, and an
`INSTALLED_APPS` changed this way gives an unscoped `framework-provided-routes:` limitation. A Flask app or blueprint
bound to such a name may be a different object at run time, so the rules and blueprint registrations that go through
that name get the `base` anchor with `unresolved-route-prefix:`.

### Decisions

- **Django is `registration-order`, Flask is `specificity`**, as verified from the sources. isthmus releases
  before `f9dcd1d` reject `registration-order`; `--dispatch specificity` declares specificity for Django and omits
  `order`. That approximation can only produce false matches (a shadowed pattern matched), never false errors,
  because isthmus filters by method first and reports a method mismatch only when no candidate accepts the
  method, which is also when Django answers 405.
- **Shadowed patterns are still declarations**; shadowing is the consumer's judgement from `order`.
- **Conditional registrations are scoped limitations**, not declarations.

## `pythograph routes --role client`

```sh
pythograph routes --role client --project <root> [--wrappers <file>] [--service <name>]
                  [--include-tests] [--generated-at <timestamp>] [--format json]
```

Writes a bridge-facts v1 document with `platform: "python"`, `target: "http"`, `roles: ["client"]`, and one
`route-call` fact per HTTP request expression. The full rule table with the verified library sources, the oracle
recording, and the end-to-end trace is in
[docs/HTTP-CLIENTS.md](https://github.com/ictechgy/pythograph/blob/main/docs/HTTP-CLIENTS.md) (Korean).

- **Libraries** (resolved by name, never by a same-named project function): requests 2.34 (top-level functions and
  `Session`), httpx 0.28 (top-level functions, `Client`/`AsyncClient` with `base_url`), aiohttp 3.x (`ClientSession`
  with `base_url`, `aiohttp.request`), and `urllib.request.urlopen` (with `Request(method=)` and `data`). Clients are
  followed through single-assignment locals, `with`/`async with`, module variables, instance fields and class
  attributes whose every assignment is a client, client-typed annotations, and project subclasses of a client class.
- **Base joins** use the isthmus style names: `httpx-base-url` (the base always ends in `/` and every leading `/` of
  the path is stripped, so `/x` stays under the base path), `aiohttp-base-url` (RFC 3986: `/x` replaces the base path;
  path-bearing bases and relative paths need aiohttp 3.11+, absolute URLs on a base session need 3.12+, proven from
  the project's lock/requirement files), and none for requests and urllib. `urllib.parse.urljoin` has no vector yet,
  so only its absolute-URL and `/`-rooted forms are claimed.
- **URL strings**: f-strings, `+`, `%` formatting, `str.format`, provable module constants (bound exactly once, never
  rebound, no `global` or module-attribute writes, no same-named attribute write on an unknown receiver such as
  `sys.modules[__name__].API_ROOT = …`, and not in a module that calls `globals()` or module-level `vars()`/`locals()`),
  class attributes and `__init__` fields with one literal value, and proven query-tail locals. An interpolation
  becomes `{}` only when it fills a whole segment; otherwise the fact is `dynamic` with a masked `channelPrefix`
  (`channel` is `null`, so no raw URL text leaves the tool). A literal host gives `root` plus `authority`; a dynamic
  host or an unknown base gives `base`.
- **Wrappers** (`--wrappers`, isthmus `http-wrappers` v1): `"language": "python"` entries; `owner` is a pythograph class
  id (`api/client.py#Gateway`) for methods and constructors (`name: "__init__"`, dataclasses included) or a module path
  (`api/net.py`) for module functions. `label` is a keyword argument, `index` a positional argument (receiver excluded).
  Unknown fields and malformed entries exit 64; declarations that match nothing report `http-wrapper-unresolved:`.
  A wrapper's `service` goes on its facts in place of `--service`. When it differs from `--service`, the document-level
  `service` is omitted (isthmus rejects a fact service that differs from the document's) and `--service` stays on the
  other facts.
- **symbol.usr** is the enclosing function, method, class body, or module id — the same ids as `graph`/`reach`/`impact`.
- **Limitations**: `route-call-coverage:` (unmodeled request APIs such as urllib3, `http.client`, `send`/`build_request`,
  URL literals passed to receivers whose client type is unknown, scan gaps), `ambiguous-base-join:`,
  `http-wrapper-undeclared:` (functions that pipe a parameter into a request URL), and `http-wrapper-unresolved:`.

## `pythograph schema`

```sh
pythograph schema --project <root> [--include-tests] [--settings <module>] [--generated-at <timestamp>] [--format json]
```

Writes a bridge-facts v1 document with `platform: "python"`, `target: "persistence"` (or `null` when there are no
facts), and one `relation-use` fact per observed relation or column reference. isthmus joins it with a
`platform: "sql"` document (schemagraph `facts --document <catalog>`) under the persistence rules of
`docs/GRAPH-EXCHANGE.md`. The full rule table with source files is in [docs/PERSISTENCE.md](https://github.com/ictechgy/pythograph/blob/main/docs/PERSISTENCE.md)
(Korean).

- **Django**: model classes (abstract, proxy, multi-table inheritance, `Meta` inheritance) → tables
  (`<app_label>_<model>` truncated by `truncate_name` for the backend's `max_name_length`, or `Meta.db_table`), fields →
  columns (`db_column`, `<name>_id` for foreign keys, many-to-many tables and their columns), app labels from
  `INSTALLED_APPS`/`AppConfig`, the `DATABASES` backend, and django.contrib models. Uses: managers and QuerySet chains,
  lookups (`author__profile__city`, reverse relations, `attname`, `pk`), `values`/`order_by`/`F`/`Q`/aggregates,
  `create`/`update` keywords, related managers and forward relations on proven instances, `raw()`, `RawSQL`,
  `extra(tables=)`, and cursor SQL.
- **SQLAlchemy 2.x / Flask-SQLAlchemy 3**: Declarative classes (`DeclarativeBase`, `declarative_base()`, `db.Model`
  with its snake_case names), mixins, single- and joined-table inheritance, `__table_args__`/`MetaData` schemas, Core
  `Table`, `ForeignKey("t.c")`, `relationship(secondary=)`. Uses: statement entities (`select`, `insert`, `update`,
  `delete`, `session.query`, `session.get`, `join`), `Model.column`, `Model.relationship`, `Model.query`, `filter_by`,
  constructors, `table.c.name`, and `text()`.
- **SQL text**: the family's shared lexical extractor (the same vectors as tsograph, dartograph, cartograph, and
  kartograph) for explicit SQL arguments, and uppercase SQL literals elsewhere (docstrings are skipped). Following the
  rules shared by the persistence producers, an unqualified name that is a CTE of the same `;`-separated statement, the
  operand of a `FROM` inside function arguments (`EXTRACT(YEAR FROM created_at)`, `TRIM`, `SUBSTRING`; a subquery there
  still counts), and the operand of `IS [NOT] DISTINCT FROM` are not relation uses. The rules apply as written: a CTE
  named like a real table also hides that table in the same statement (the `users` read by
  `WITH users AS (SELECT * FROM users …)`, an `INSERT INTO users` target), and a DML `FROM` in parentheses after a word
  that is not a clause keyword (`COPY (DELETE FROM t …)`) reads as a function argument.
- **channel** is the relation name as written or mapped (`schema.table` only when qualified; no default schema is
  guessed), **method** is the column, and **symbol.usr** is the enclosing function, method, or model class with the
  same ids as `routes`. A name that depends on an unknown backend, app label, or Flask-SQLAlchemy version, an
  unresolved model or lookup, a module constant that is not provable (`db_table = TABLE` after `TABLE += ...`, a
  `__tablename__` built from a conditionally reassigned prefix), a Core `Table` object reached through a name that is
  not provable (`select(users)`, `__table__ = users`, or `secondary=users` when `users` is reassigned), and SQL built
  at runtime become `dynamic` facts with a `dynamic-relation-names:` limitation instead of guesses; such a constant
  passed to `execute()` is counted under `unresolved-sql-arguments:`.
- Test sources (unless `--include-tests`) and migrations (Django `migrations/`, Alembic `versions/`) are not scanned;
  migrations describe past schemas.

## `pythograph graph` / `reach` / `impact`

```sh
pythograph graph  --project <root> [--include-tests] [--revision <id>] [--generated-at <timestamp>]
pythograph reach  --project <root> [--dispatch direct|bound|candidates] [--max-depth <n>] [--max-reached <n>]
                  [--roots-from <file|->] [--include-tests] [--revision <id>] [--generated-at <timestamp>] [--] <id>...
pythograph impact (same options as reach)
```

Builds the project's Python call graph with the standard-library `ast`. `graph` writes pythograph's own snapshot
(`pythograph-graph` v1); `reach` writes the symbols the roots depend on (`dependencies`) and `impact` the symbols that
depend on them (`dependents`) as isthmus
[`language-traversal` v1](https://github.com/ictechgy/isthmus/blob/main/docs/LANGUAGE-TRAVERSAL.md). Ids are the same
strings as `symbol.usr` in `routes` and `schema`. The full rules are in [docs/GRAPH.md](https://github.com/ictechgy/pythograph/blob/main/docs/GRAPH.md) (Korean).

- **Nodes**: modules (`<path>#<module>`), functions, methods, classes, nested definitions, and inherited members
  (`<registered class>.<member>`, for view handlers and for inherited members called on exact receivers).
- **Edges**: `call`, `new` (the project `__init__`, else the class), `callback`, `reference`, `decorator`, `attribute`
  (class-body attributes), `inherit`, and `dispatch`/`framework` (framework dispatch). Resolution follows imports
  (absolute, relative, aliases, `__init__` re-exports, `*`), module attributes, constructors, `self` and `super()`
  through the C3 MRO of project classes, annotated and return-annotated receivers, module-level instances, and
  properties.
- **Evidence tiers**: statically resolved edges are `direct`; overrides in project subclasses for `self`, `cls`, and
  annotated receivers are `candidate`. A method call on a receiver of unknown or overridable type gets `bound` edges
  (followed by `--dispatch bound`) only when every value observed flowing into the receiver is a project class
  instance: constructors, module-level instances, attributes assigned in `__init__` from constructor parameters (DI),
  function parameters whose call sites are all in the project, and factory return values. Open slots get no `bound`
  edge: public functions, classes, and module globals of a library (a project root with `setup.py`/`setup.cfg` or a
  `pyproject.toml` `[project]`/`[tool.poetry]` table), framework-invoked entry points (functions passed as values,
  decorated functions, classes with framework bases), computed `getattr`/`setattr` and module namespaces, `*args`/
  `**kwargs` spreads, wrapping decorators, monkeypatching writes whose value is unknown, `self`/`cls`, and test sources
  (a separate program). Values that leave through library code and computed-name writes to receivers of unknown type
  are not modeled (reported under `bound-assumptions:`). The default stays `direct`: bound linked no calls in the four
  dogfood apps. Module globals and class-body attributes are exact receivers only when nothing rewrites them.
- **No guessing**: calls without a known target are counted by reason (`parameter`, `untyped-receiver`,
  `dynamic-attribute`, `getattr`, `dynamic-callee`, `unresolved-import`, `framework-callback`, …) as each symbol's
  `unresolvedCalls`. A method call on an untyped receiver is proven external only when no project class, module, or
  attribute write defines that name.
- **Framework dispatch**: a table read with `ast` from the installed Django 5.2.17, DRF 3.18.1, and Flask 3.1.3
  sources links the project hooks that the `as_view()` dispatch path (`dispatch`, `initial`, permission checks,
  `__init__`) and framework implementations (`ModelViewSet.retrieve` → `get_object` → `get_queryset`,
  `ModelSerializer.save` → `create`) call. Objects the framework builds from class attributes (`serializer_class`,
  `permission_classes`) count as `framework-callback` unresolved calls.
- **Snapshot size**: the `graph` snapshot is not an isthmus input, so it may be up to 256 Mi characters (about 2 bytes
  of extra memory per output character while encoding); `reach` and `impact` keep the 16 Mi isthmus input limit.
  The encoding is unchanged, so raising the cap alone changes no snapshot under 16 Mi characters.
- **Traversal documents**: a `dispatch` declaration, per-root lower-bound `evidence`, `unresolvedCalls`, one
  multi-root pass (compared with a per-root oracle on random graphs), `--max-depth`/`--max-reached` truncation, and
  `rootsTruncated`. Roots that are not graph nodes are listed without `symbol`; the document is written and the
  command exits `64`. `revision` is `--revision` or git `HEAD` when the work tree is clean (omitted, with a
  `revision-unverified:` limitation in the snapshot and the traversal documents, when a clean tree cannot be checked
  without running the repository's git configuration: a work tree with submodules, or a partial clone that lacks
  objects the check reads, see [Security](#security));
  `graphRevision` is a SHA-256 of the graph content.

## Validation

The oracle harness in `experiments/oracle/` imports the synthetic fixtures in a scratch virtual environment
and compares pythograph's facts with Django's resolver traversal, DRF routers, and Flask's `url_map`
(`tests/test_fixtures.py` replays the recorded results offline):

| Target | Precision | Recall |
|---|---|---|
| `fixtures/django/drf-shop` | 69/69 | 58/59 (one intentional dynamic lookahead pattern) |
| `fixtures/flask/blog-app` | 28/28 | 27/27 |
| HackSoftware/Django-Styleguide-Example `a70ef43` (MIT, scratch clone) | 21/21 | 21/22 (the DEBUG-only `static()` route) |

Dogfooding on four public apps (Django-Styleguide-Example, babybuddy, microblog, and netbox, cloned only into a scratch
directory) measured route precision against each framework's resolver, relation-use join rates through schemagraph and
isthmus, handler-to-relation reachability, unresolved-call reasons, and runtime; the results and the fixed issues are in
[DOGFOOD.md](https://github.com/ictechgy/pythograph/blob/main/DOGFOOD.md) (Korean).

The isthmus shared conformance vectors (`conformance/`, vendored from isthmus `2954375` and locked in
`conformance.lock`) pass 100% of the `producer` and `producer:pythograph` cases (135: `template.grammar`,
`template.normalize`, `scope.validate`, `scope.applies`, `dispatch.validate`, and 57 url-compose cases for query tails,
interpolation, normalization, stripping, masking, the `rfc3986`/`httpx-base-url`/`aiohttp-base-url` joins, and wrapper
argument binding); the `dispatch.validate` checker also runs on the routes golden output.

**Client mock-server oracle.** `experiments/client_oracle/` runs the synthetic client `fixtures/client/shop-client`
in a scratch environment against a local `http.server` on 127.0.0.1 (name resolution and connects are redirected, no
external traffic) and records what requests, httpx, aiohttp, and urllib actually send. Recorded 2026-09-30: 35
scenarios, 31 matches, 4 dynamic, 0 mismatches (`tests/test_client_oracle.py` replays it offline).

**Python client × Django server trace.** `experiments/client_e2e/` joins a synthetic Python client
(`fixtures/e2e/py-client`) with the Phase 6 Django server recording through isthmus `trace` (main `3a45450`): all four
selected routes attach the Python call site (exact match) to the server handler, reach the calling screen functions,
and continue to the relation uses and tables (`tests/test_client_e2e.py`).

**Phase 6 exit criterion (Django backend × iOS/Android chain).** `experiments/e2e/` joins a synthetic Django + DRF
server (`fixtures/e2e/shop-api`), a schemagraph catalog of its Django DDL, and route-calls plus reverse traversals of
synthetic iOS (cartograph) and Android (kartograph) clients with isthmus `trace` (workspace). The expected paths of the
three questions match: (a) API → DB tables + DB dependents, (b) API → client call sites → affected client symbols, and
(c) table → API → client. The Android recording needs kartograph `4c09d91` or later (Retrofit route-call usrs and
`baseUrl` joins), which attaches the Android order and checkout calls to the chain. `tests/test_e2e_trace.py` re-checks
the recorded inputs and outputs offline (the table is in
[docs/GRAPH.md](https://github.com/ictechgy/pythograph/blob/main/docs/GRAPH.md#phase-6-종료-조건-django-백엔드--iosandroid-체인)).

Persistence naming vectors (`fixtures/persistence-naming/vectors.json`) are recorded by importing synthetic models
with the real ORMs in a scratch environment (`experiments/persistence/run_naming.py`): Django 5.2.17 `_meta` names
quoted by each backend's `connection.ops` (sqlite3, postgresql, mysql, oracle), and SQLAlchemy 2.0.54 /
Flask-SQLAlchemy 3.1.1 mappers. pythograph matches 100% (Django 136/136 model-backend pairs, SQLAlchemy 9/9 and
Flask-SQLAlchemy 10/10 classes, all tables and columns). Joining `pythograph schema` output for the two persistence
fixtures with schemagraph catalogs of the DDL the ORMs create (`experiments/persistence/run_e2e.py`) gives no isthmus
errors (41 and 20 matches).

## isthmus compatibility

isthmus-cli 0.10.0 (npm, `npm install --global isthmus-cli@0.10.0`) is the released version that accepts
`platform: "python"`: http `route-decl` facts (Django's `registration-order` and `order`, with shadowing diagnostics),
client `route-call` facts (`routes --role client`), persistence `relation-use` facts, and python `forward`/`reverse`
analyses (`language-traversal` v1) in `trace`. Earlier isthmus releases reject python route-decl and route-call
documents as input errors. `--dispatch specificity` remains for those older releases.

## Bounded source scanning

Source reads request at most 4 MiB plus one overflow byte. Directory enumeration consumes at most 200,000 entries
plus one overflow entry, with a maximum depth of 64. An oversized directory is omitted as a whole rather than selecting
an arbitrary filesystem-order subset; files from previously completed directories remain available. Unreadable directory
entries are counted in limitations for routes, schema, and graph output, and prevent closed-world `bound` proofs that
would otherwise rely on missing modules. A successful command with these limitations is still an incomplete analysis.

File paths become every fact's `location.path` and symbol id, so a Python file whose project-relative path is not
valid UTF-8 or contains characters the exchange format forbids (C0/C1 control characters including NEL, U+2028, or
U+2029) is not analyzed. It is counted in limitations instead (`scan-incomplete:` for graph, `route-coverage:` or
`route-call-coverage:` for routes, `unreadable-sources:` for schema) rather than producing a document that isthmus
rejects as a whole.

Directories that contain `manage.py` are module roots, because Django puts them on `sys.path`: imports resolve against
them (shallowest first), then the project root, then `src/`, and a module is named from the deepest of these roots
that contains it. At most 256 `manage.py` directories, the shallowest by depth and then path, become module roots.
Imports that only the remaining directories would resolve are treated as external, so those directories are counted in
limitations (`scan-incomplete:` for graph, `route-coverage:` or `route-call-coverage:` for routes, `scan-truncated:`
for schema) and prevent closed-world `bound` proofs; pass a narrower `--project` to analyze them. Each module root's
entries are read once, so resolving a name touches the filesystem only under roots that contain its first component.

Modules whose syntax tree nests deeper than 128 levels for `graph`, `reach`, and `impact`, or 256 levels for `routes`
and `schema` (whose analysis recurses less), for example very long operator, attribute, call, or subscript chains, or
long `elif` ladders, are not analyzed, so that one generated or crafted file cannot exhaust Python's recursion limit
and abort the whole command. They are reported like files that fail to parse — `unparsed-files:` in
graph output, `route-coverage:` for routes, `unreadable-sources:` for schema — with the reason "source nesting too
deep to parse". Public packages rarely nest beyond 100 levels. Python 3.10's parser crashes the process on extremely
long chains instead of raising an error, so on 3.10 a file with a logical line of more than 50,000 chain tokens (binary
operators, `.`, `(`, `[`) is reported the same way without being parsed, even when the line is a flat list. Chains
inside f-string replacement fields count toward their line (every chain character of the f-string is counted). String
annotations (forward references such as `"Mapped[str]"` or `"Client | None"`) are parsed under the same limits; one
that cannot be parsed (including one with a NUL character) or that nests too deeply is treated as an unknown
annotation.

One run parses at most 64 MiB of Python source in total, counting only the modules it parses successfully. A module
that would push the total past this budget is not parsed and is reported like a file that fails to parse
(`unparsed-files:` in graph output, `route-coverage:` or `route-call-coverage:` for routes, `unreadable-sources:` for
schema) with the reason "the run's total parsed source would exceed its 64 MiB budget; pass a narrower --project";
the command still succeeds with the other modules. Each command parses in a fixed order (path order, with modules
reached through imports parsed when first needed), so the same input skips the same modules on every run. Parsed syntax
trees stay in memory until the command ends, because name resolution can return to any module: ordinary code peaks
at about 30–55 bytes of memory per source byte (`graph` on sympy, 26 MB of source in 1,532 files, takes about 1 GB),
so a run at the budget needs about 2–4 GB. A module whose syntax tree has more than 1,000,000 nodes (counting context
and operator leaves) is not kept either: it is reported the same way with the reason "syntax tree larger than
1,000,000 nodes". Among the large files measured in installed packages (sympy, torch, NumPy, SciPy, faker, and
others) and the standard library, the largest real module has about 200,000 nodes, while a 4 MiB module of `x = 1`
lines has about 2.8 million and kept about 0.9 GB. So one module keeps at most about 0.45 GB, and parsing one module
peaks at about 2–3 GB, bounded by the 4 MiB file limit. Literal-heavy modules below the node limit (large list
literals, generated tables, thousands of `x = 1` lines) still keep about 220–350 bytes per source byte, so a crafted
repository that spreads such data over many modules can still use 15–25 GB within the budget; pythograph does not
bound that case yet. Analyze untrusted repositories under an operating-system memory limit (a cgroup or container
memory limit, or `ulimit -v` on Linux).

Constant evaluation follows f-strings, `+`, list literals, and module constants for route strings, settings, SQL text,
and client URLs. A short chain that references the same constant many times (`B = f"{A}{A}…"`, `C = f"{B}{B}…"`)
would otherwise grow exponentially, so repeated references are expanded once, values built this way are capped at
64 KiB (characters for strings; elements plus their characters for lists), and client URL expressions at 4,096 parts.
A larger value is treated as unknown: a dynamic route or route-call fact, an unread setting, or for SQL text a
`skipped-sql-literals:` limitation. A single string literal is limited only by the 4 MiB file limit. Route strings
longer than 8 KiB (4 times the 2,048-character template limit; Flask rules after merging slashes, Django `path()` and
`re_path()` strings joined with their include prefixes), and shorter ones whose literal characters alone (outside Flask
converters, outside Django `path()` converters, outside `re_path` groups) exceed the template limit, are not converted
and become dynamic facts like any over-long template.

Route strings, settings, SQL text, and schema names follow only provable module constants, by the same binding rule as
the call graph's stable globals and `routes --role client`: a name bound more than once at module level (`+=`, a second
assignment, a loop variable, an import), bound inside an `if`/`try`/`for`/`while`/`with`/`match` block, declared
`global` in a function, or defined in a module that calls `globals()` (or module-level `vars()`/`locals()`) is unknown
even when it also has an unconditional assignment. They also treat a name changed in place anywhere in its module
(`NAME.append(...)`, `NAME[k] = ...`) as unknown and check every module on an import, star-import, or module-alias
chain, so a settings module that changes a star-imported setting (`from .base import *` followed by
`INSTALLED_APPS += [...]`) makes that setting unknown.

Class hierarchies (view classes for routes and graph, SQLAlchemy mappings for schema) are followed at most 32 levels
deep, and each class is analyzed once per depth, so a diamond-shaped hierarchy costs time proportional to its classes
rather than to its exponentially many paths. A static inheritance cycle, which Python itself cannot build, makes the
result depend on the path taken; when one view class would need more than 1,024 such re-linearizations, its remaining
bases are treated as unknown. Flask blueprint registrations that close a cycle (Flask fails to register them) are not
followed and count as `route-coverage:`. Registering the same blueprints along many paths is expanded per path as
Flask does, up to 200,000 nested registrations (`route-coverage:` beyond that); the expansion stops early once more
than 100,000 route-decl facts make the document impossible, ending with the usual fact-limit error (exit code 2).

Flask-SQLAlchemy 2's automatic table name rule (`([A-Z]+)(?=[a-z0-9])`, kept exactly as Flask-SQLAlchemy writes it)
backtracks over a long run of capitals in time quadratic in the class name's length, so it is not applied to class
names longer than 1,000 characters. When major version 2 is a candidate (declared, or the version is not limited to one
major), such a class's automatic table name becomes a `dynamic` fact with a `flask-sqlalchemy-naming-unverified:`
limitation; set `__tablename__` to name it. The linear Flask-SQLAlchemy 3 rule applies at any length.

Every project-relative path component is checked for symbolic links, including module/package resolution and dependency
manifests. Links to locations inside the project are rejected too; the skipped-link count counts each encountered path
once. The directory walk counts only links whose name could hide sources it would otherwise read, judged from the
name alone without touching the target: links named `*.py` (including broken ones) and extensionless links with a
name the walk would enter. Links such as `CLAUDE.md -> AGENTS.md`, `README.md`, or `.venv` therefore do not become
limitations, and the count never depends on what exists on the analyzing machine. A directory link with an extension
(`conf.d`) is not counted and an extensionless file link (`LICENSE`) is. Links met during module resolution or
manifest reads always count.
On systems supporting descriptor-relative `O_NOFOLLOW`, directory scans and source reads open each component
without following links, and verify the opened file before reading. Other systems recheck components and file identity
before reading, but those checks are not atomic. Analyze a stable project tree; this is not a filesystem snapshot or a
sandbox against concurrent changes to the filesystem. The analyzed Python code is never imported or executed.

## Security

pythograph starts one external program: `git`, only to read the `revision` of `graph`, `reach`, and `impact` when
`--revision` is not given and the project root has `.git` (`git config`, `git ls-files`, `git status`, and
`git rev-parse`, run at the project root). It runs the first `git` in an absolute `PATH` entry and skips one that
lives inside the project or directly in the current directory. A `.git` directory that arrives with an archive or a
copy can define commands that git runs while it reads the work tree (see "SECURITY" in git(1)). pythograph closes the
ways it knows these git calls can reach such commands:

- Right before each call, pythograph lists the filter drivers and promisor settings git sees
  (`git config -z --includes --get-regexp` for `filter.<driver>.clean`, `smudge`, and `process`,
  `extensions.partialClone`, and `remote.<name>.promisor` and `partialCloneFilter`, which covers the repository, its
  includes, your own configuration, and the system configuration) and overrides each driver with empty `clean`,
  `smudge`, and `process` commands and `required=false`, so git runs none of them. This holds for drivers named in
  `.gitattributes` and in `.git/info/attributes`.
- It never lets git fetch missing objects. A partial clone (a repository with a promisor remote) fetches objects it
  lacks on demand, and that fetch follows the remote's configuration (`remote.<name>.uploadpack`, `core.sshCommand`,
  and so on). Every call sets `GIT_NO_LAZY_FETCH=1`, and when the listing shows a promisor setting, every call except
  the listing itself also passes `--no-lazy-fetch`. Both need git 2.45 or later: an older git ignores the variable
  but rejects the option before it starts, so it reads no partial clone.
- It turns off `core.fsmonitor`, the untracked cache, `log.showSignature`, and optional locks
  (`GIT_OPTIONAL_LOCKS=0`, so the index is never rewritten). pythograph runs no `git diff` or `git log`.
- It never starts git inside a submodule: `git status` runs with `--ignore-submodules=all`, and does not run at all
  when the index has a submodule.
- When these settings cannot be listed, or a driver name contains `=`, a control character, or bytes that are not
  UTF-8 (git's `-c` splits at the first `=`, so such a driver cannot be overridden), no other git command runs.

What this changes:

- A file that a filter normalizes (git-lfs and the like) is compared as raw content. When git's stat cache is stale
  for it (a copied or extracted tree, a touched file), the work tree counts as changed and no `revision` is recorded,
  rather than claiming a clean `HEAD` that could not be verified. In a checkout you trust, run `git status` first to
  refresh git's stat cache.
- A work tree with submodules gets no automatic `revision`: pythograph also analyzes the sources inside them, and git
  cannot tell whether they are clean without following each submodule's own configuration. A driver name that cannot
  be overridden stops git altogether. Both cases add a `revision-unverified:` limitation to the `graph` snapshot and
  to the `reach` and `impact` documents.
- A partial clone is read only from the objects it already has. When git needs a missing object to check the work
  tree, or is older than 2.45, no `revision` is recorded and the same `revision-unverified:` limitation says why. A
  partial clone that has every object the check reads keeps its `revision` with git 2.45 or later.
- The rest of the git configuration still decides what git reports (for example `core.worktree`), and pythograph
  trusts every setting outside the paths above, so an automatic `revision` is only as trustworthy as the `.git`
  directory. For a tree whose `.git` you do not trust, pass `--revision <id>`: git then does not run at all.

## Development

```sh
uv sync
uv run ruff check src tests && uv run ruff format --check src tests
uv run mypy
uv run pytest --cov          # line and branch coverage gate: 90%
uv run python scripts/verify_cli_contract.py
```

The framework hook table is regenerated from a scratch virtual environment with Django 5.2.17, DRF 3.18.1, and
Flask 3.1.3 installed: `python experiments/graph/dump_framework_hooks.py --site-packages <path>` (`--check` compares
only).

## Analyze an actual change

Run `pythograph change-impact --project . --base HEAD~1 --head HEAD`, or omit `--head` for working-tree
edits. Before/after source captures retain deleted callers and separate propagation paths, with content
digests, file seed reasons, connected review/test candidates, unmapped files and limits. Results are always
incomplete; they do not predict failures or authorize skipping tests. Existing `impact <id>` is unchanged.
Nested traversals preserve the original project's canonical identity. Test candidates use the same filename
policy as ordinary graphs; capture omissions keep source uncertainty open, and working reads and Git output
are checked while reading. These boundaries and explicit omissions are documented below.
Captures use Git-selected inputs; `bound` assumes those selected sources form the whole program. Ignored
runtime writers may therefore change ordinary graph results. Materialization rejects paths that alias on
the destination filesystem instead of merging their identities.
See [change analysis](docs/CHANGE-IMPACT.md) for the capture budget and remaining framework gaps.

## License

MIT
