Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

Your coding agent writes code faster than you can read it. You review the
diff, you merge, and one day you notice you are no longer sure how the pieces
of your own Python project fit together.
of your own Python or TypeScript project fit together.

**systemap gives you one page that shows how they fit.** Your agent draws it
from your code. A checker then refuses to let that page go out of date, and
Expand Down Expand Up @@ -77,6 +77,10 @@ your reviewer.
uv tool install systemap # or: uv add --dev systemap
systemap init # --no-ci to skip the workflow

For a TypeScript repository, install the parser extra:

uv tool install 'systemap[typescript]'

`init` writes a configuration file, an empty map for your agent to fill in,
the instructions your agent will follow, and a CI workflow. Then it prints the
one sentence you hand to your agent:
Expand Down Expand Up @@ -186,8 +190,10 @@ features that failed their test were written down rather than shipped

## What is systemap not?

It reads Python and only Python. It is not a call graph: the map shows the
lines your agent declared and defended, not every function call. It is not a
It reads Python, TypeScript and TSX. TypeScript support reads exported names,
imports, tests, package binaries and configured `tsconfig.json` path aliases;
framework-specific routes are not read yet. It is not a call graph: the map shows the lines
your agent declared and defended, not every function call. It is not a
dependency diagram: modules are not parts, and the map shows parts. It is not
a UML tool: one picture, one layout, and nothing to learn beyond card, line
and journey.
Expand Down
6 changes: 3 additions & 3 deletions docs/map/index.html

Large diffs are not rendered by default.

16 changes: 9 additions & 7 deletions docs/map/map.json

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -461,8 +461,9 @@ Reinstalls the skill directory that `init` writes: `SKILL.md` and

| key | default | meaning |
|---|---|---|
| `language` | `python` | source language: `python` or `typescript`; `init` detects an unambiguous TypeScript repository; TypeScript needs the `systemap[typescript]` extra |
| `name` | `[project] name`, then the git repository's directory, then the directory name | the page title |
| `[package_roots]` | every top-level package or `src/<pkg>`, in the root and in every `[tool.uv.workspace]` member | `"path" = "import name"` |
| `[package_roots]` | Python packages; for TypeScript, `src` then the repository root | `"path" = "module name"` |
| `tests_dir` | every directory named `tests` or `test` | one directory or a list; tests that import a module count as its guards |
| `model` | `map/model.py` | the module exporting `MODEL` and `MEANING`; its own directory is on the path while it runs, so a long map can keep its journeys or a region's cards in a module beside it (`import journeys`) and nothing it imports is kept between runs |
| `out_dir` | `docs/map` | where the facts, the page and the figures go |
Expand Down
8 changes: 7 additions & 1 deletion map/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,13 @@
id="FactsExtractor",
does="Walks the package's syntax tree and writes the facts: every module, its public surface and every public name, what it imports inside and outside the package, the tests that import it, and where a run can start. Nothing anyone writes changes what it finds; systemap facts reads them back one view at a time.",
interface="build(cfg) -> facts; drift(fresh, stored) -> what no longer matches",
implemented_by=("systemap.extract", "systemap.facts", "systemap.ways_in"),
implemented_by=(
"systemap.extract",
"systemap.facts",
"systemap.language",
"systemap.typescript",
"systemap.ways_in",
),
entry="build",
region="gather",
x=698,
Expand Down
9 changes: 9 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ classifiers = [
]
dependencies = []

[project.optional-dependencies]
typescript = [
"tree-sitter>=0.25,<0.27",
"tree-sitter-typescript>=0.23,<0.24",
]

[project.urls]
Homepage = "https://github.com/0xfauzi/systemap"
Changelog = "https://github.com/0xfauzi/systemap/blob/main/CHANGELOG.md"
Expand All @@ -40,8 +46,11 @@ packages = ["src/systemap"]
[dependency-groups]
dev = [
"mypy>=1.10",
"pre-commit>=4,<5",
"pytest>=8",
"ruff>=0.5",
"tree-sitter>=0.25,<0.27",
"tree-sitter-typescript>=0.23,<0.24",
]

[tool.pytest.ini_options]
Expand Down
58 changes: 20 additions & 38 deletions src/systemap/change.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,10 @@
from pathlib import Path
from typing import Any

from systemap import extract
from systemap.config import Config
from systemap.extract import (
WHOLE_MODULE,
internal_uses,
module_of,
parse_surface,
test_names,
)
from systemap.extract import WHOLE_MODULE
from systemap.language import LanguageAdapter
from systemap.model import Model, module_matches

# `gained` carries only the buckets the schematic's segmented bar draws
Expand Down Expand Up @@ -81,28 +77,6 @@ def _changed_files(repo: Path, merge_base: str, head: str) -> list[str]:
return [f for f in out.split("\0") if f]


def _path_module(repo: Path, path: str, roots: list[tuple[Path, str]]) -> str | None:
"""The dotted module a repo-relative path implements, or None.

Derived from the path alone so a file DELETED by the branch still maps:
the facts are built from the head tree and cannot know a module that is
gone from it.
"""
if not path.endswith(".py"):
return None
absolute = repo / path
for pkg_dir, pkg_name in roots:
if absolute.is_relative_to(pkg_dir):
return module_of(absolute, pkg_dir, pkg_name)
return None


def _is_test_file(path: str, tests_dirs: tuple[str, ...]) -> bool:
if not path.endswith(".py") or not path.split("/")[-1].startswith("test_"):
return False
return any(rel and path.startswith(rel.rstrip("/") + "/") for rel in tests_dirs)


def _empty_surface() -> dict[str, Any]:
return {"functions": [], "classes": [], "errors": [], "constants": []}

Expand All @@ -117,14 +91,19 @@ def _identity(surface: dict[str, Any]) -> dict[str, dict[str, str]]:
}


def surface_delta(base_raw: str, head_raw: str) -> dict[str, Any] | None:
def surface_delta(
base_raw: str,
head_raw: str,
language: LanguageAdapter = extract.PYTHON,
path: str = "",
) -> dict[str, Any] | None:
"""What one module's public surface gained, lost, and changed.

None means a side had source that does not parse, which is "cannot tell",
never "nothing changed".
"""
base = parse_surface(base_raw) if base_raw else _empty_surface()
head = parse_surface(head_raw) if head_raw else _empty_surface()
base = language.parse_surface(base_raw, path) if base_raw else _empty_surface()
head = language.parse_surface(head_raw, path) if head_raw else _empty_surface()
if base is None or head is None:
return None
before, after = _identity(base), _identity(head)
Expand Down Expand Up @@ -170,6 +149,7 @@ def compute(
"""
repo = cfg.root
roots = cfg.roots
language = extract.language_for(cfg)
if artifact_owner is None:
artifact_owner = {}

Expand Down Expand Up @@ -198,10 +178,12 @@ def compute(
deltas: dict[str, dict[str, Any]] = {}
unparsed: list[str] = []
for path in files:
module = _path_module(repo, path, roots)
if not module or _is_test_file(path, cfg.test_dirs):
module = language.module_for_path(repo, path, roots)
if not module or language.is_test_file(path, cfg.test_dirs):
continue
delta = surface_delta(_show(repo, merge_base, path), _show(repo, head, path))
delta = surface_delta(
_show(repo, merge_base, path), _show(repo, head, path), language, path
)
if delta is None:
unparsed.append(module)
continue
Expand All @@ -215,16 +197,16 @@ def compute(
tests_added: dict[str, set[str]] = {}
tests_removed: dict[str, set[str]] = {}
for path in files:
if not _is_test_file(path, cfg.test_dirs):
if not language.is_test_file(path, cfg.test_dirs):
continue
base_raw = _show(repo, merge_base, path)
head_raw = _show(repo, head, path)
before_t, after_t = set(test_names(base_raw)), set(test_names(head_raw))
before_t, after_t = set(language.test_names(base_raw)), set(language.test_names(head_raw))
if before_t == after_t:
continue
targets: set[str] = set()
for raw in (base_raw, head_raw):
targets |= set(internal_uses(raw, prefixes, known))
targets |= set(language.internal_uses(raw, prefixes, known))
for target in targets:
tests_added.setdefault(target, set()).update(after_t - before_t)
tests_removed.setdefault(target, set()).update(before_t - after_t)
Expand Down
14 changes: 12 additions & 2 deletions src/systemap/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,10 +133,15 @@ def _project(args: argparse.Namespace) -> Project:

def cmd_init(args: argparse.Namespace) -> int:
root = Path(args.root).resolve() if args.root else Path.cwd().resolve()
roots = config.discover_roots(root)
python_roots = config.discover_roots(root)
typescript_roots = config.discover_typescript_roots(root)
if python_roots and typescript_roots:
raise ConfigError("both Python and TypeScript source found; set language in systemap.toml")
language = "typescript" if typescript_roots and not python_roots else "python"
roots = typescript_roots if language == "typescript" else python_roots
package = roots[0][1] if roots else "mypackage"
name = args.name or config.default_name(root)
say(*scaffold.write(root, name, package, roots, ci=not args.no_ci))
say(*scaffold.write(root, name, package, roots, ci=not args.no_ci, language=language))
skill_path = skill.write(root / skill.DEFAULT_DIR)
references = len(skill.files()) - 1
say(
Expand All @@ -153,6 +158,11 @@ def cmd_init(args: argparse.Namespace) -> int:

def _require_roots(p: Project) -> None:
if not p.cfg.roots:
if p.cfg.language == "typescript":
raise ConfigError(
"no package roots found; set [package_roots] in systemap.toml "
'("path" = "module name"); no .ts or .tsx source in src or the repository root'
)
found = config.candidate_packages(p.cfg.root)
where = (
"directories holding an __init__.py: " + ", ".join(found)
Expand Down
55 changes: 51 additions & 4 deletions src/systemap/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@
are, where the tests are, where the model module is, where the output
goes, and the theme.

language source language: "python" (default) or "typescript"
name the page title; defaults to [project] name in
pyproject.toml, then the name of the directory holding
the git repository (the main checkout, even from a
worktree), then the directory's name
package_roots table of path = import name; default: every top-level
directory (or src/<dir>) that holds an __init__.py, in
the root and in every [tool.uv.workspace] member
package_roots table of path = import name; Python defaults to every
package directory; TypeScript defaults to src, then root
tests_dir where test_*.py files live: one directory or a list;
default: every directory named tests or test under the
root, outside the skipped directories
Expand Down Expand Up @@ -66,6 +66,7 @@

import contextlib
import dataclasses
import json
import os
import subprocess
import sys
Expand All @@ -87,6 +88,7 @@
CANDIDATE_DEPTH = 4

KNOWN_KEYS = {
"language",
"name",
"package_roots",
"tests_dir",
Expand Down Expand Up @@ -206,6 +208,7 @@ class Config:
root: Path
name: str
package_roots: tuple[tuple[str, str], ...]
language: str = "python"
tests_dirs: tuple[str, ...] = ()
model: str = "map/model.py"
out_dir: str = "docs/map"
Expand Down Expand Up @@ -304,6 +307,42 @@ def _pyproject(root: Path) -> dict[str, Any]:
return {}


def _package_json(root: Path) -> dict[str, Any]:
path = root / "package.json"
if not path.is_file():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
return dict(data) if isinstance(data, dict) else {}


def _typescript_name(root: Path) -> str:
name = _package_json(root).get("name")
if not isinstance(name, str) or not name.strip():
return root.name.replace("-", "_")
return name.strip().removeprefix("@").replace("/", ".").replace("-", "_")


def discover_typescript_roots(root: Path) -> list[tuple[str, str]]:
"""The conventional TypeScript source root for a configured TS project."""
if not (root / "tsconfig.json").is_file():
return []
for candidate in (root / "src", root):
sources = (
path
for path in candidate.rglob("*")
if path.is_file()
and path.suffix in {".ts", ".tsx"}
and not path.name.endswith(".d.ts")
and not any(part in SKIP_DIRS for part in path.parts)
)
if next(sources, None) is not None:
return [(candidate.relative_to(root).as_posix() or ".", _typescript_name(root))]
return []


def workspace_members(root: Path) -> list[Path]:
"""The directories `[tool.uv.workspace] members` names, by its globs."""
workspace = _pyproject(root).get("tool", {}).get("uv", {}).get("workspace", {})
Expand Down Expand Up @@ -458,9 +497,16 @@ def load(root: Path) -> Config:
f"{where}: unknown key{'s' if len(unknown) > 1 else ''}: {', '.join(unknown)}"
)

language = _str(raw, "language", "python", where)
if language not in ("python", "typescript"):
raise ConfigError(f'{where}: language must be "python" or "typescript"')

roots_raw = raw.get("package_roots")
if roots_raw is None:
package_roots = tuple(discover_roots(root))
discovered = (
discover_typescript_roots(root) if language == "typescript" else discover_roots(root)
)
package_roots = tuple(discovered)
elif isinstance(roots_raw, dict) and all(
isinstance(k, str) and isinstance(v, str) for k, v in roots_raw.items()
):
Expand Down Expand Up @@ -522,6 +568,7 @@ def load(root: Path) -> Config:
root=root,
name=_str(raw, "name", "", where) or default_name(root),
package_roots=package_roots,
language=language,
tests_dirs=tests_dirs,
model=_str(raw, "model", "map/model.py", where),
out_dir=_str(raw, "out_dir", "docs/map", where),
Expand Down
Loading