graphlm 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- graphlm-0.1.0/.env.example +15 -0
- graphlm-0.1.0/.github/workflows/ci.yml +58 -0
- graphlm-0.1.0/.github/workflows/release.yml +144 -0
- graphlm-0.1.0/.gitignore +23 -0
- graphlm-0.1.0/CHANGELOG.md +105 -0
- graphlm-0.1.0/CLAUDE.md +67 -0
- graphlm-0.1.0/DECISIONS.md +339 -0
- graphlm-0.1.0/DEMO.md +21 -0
- graphlm-0.1.0/LICENSE +674 -0
- graphlm-0.1.0/PKG-INFO +367 -0
- graphlm-0.1.0/README.md +335 -0
- graphlm-0.1.0/docs/plans/self-refreshing-graph.md +175 -0
- graphlm-0.1.0/graphlm/__init__.py +371 -0
- graphlm-0.1.0/graphlm/_html_template.html +297 -0
- graphlm-0.1.0/graphlm/cli.py +343 -0
- graphlm-0.1.0/graphlm/config.py +73 -0
- graphlm-0.1.0/graphlm/context.py +387 -0
- graphlm-0.1.0/graphlm/cycles.py +144 -0
- graphlm-0.1.0/graphlm/diff.py +438 -0
- graphlm-0.1.0/graphlm/html_render.py +176 -0
- graphlm-0.1.0/graphlm/llm.py +393 -0
- graphlm-0.1.0/graphlm/models.py +209 -0
- graphlm-0.1.0/graphlm/parser.py +645 -0
- graphlm-0.1.0/graphlm/prompts.py +19 -0
- graphlm-0.1.0/graphlm/provenance.py +89 -0
- graphlm-0.1.0/graphlm/render.py +330 -0
- graphlm-0.1.0/graphlm/scanner.py +637 -0
- graphlm-0.1.0/graphlm/skills.py +201 -0
- graphlm-0.1.0/innovation/DEMO.md +62 -0
- graphlm-0.1.0/innovation/feat-ast-parser/DEMO.md +58 -0
- graphlm-0.1.0/pyproject.toml +59 -0
- graphlm-0.1.0/tests/conftest.py +33 -0
- graphlm-0.1.0/tests/fixtures/cyclic_project/app/__init__.py +1 -0
- graphlm-0.1.0/tests/fixtures/cyclic_project/app/main.py +5 -0
- graphlm-0.1.0/tests/fixtures/cyclic_project/app/routes.py +5 -0
- graphlm-0.1.0/tests/fixtures/cyclic_project/app/services.py +5 -0
- graphlm-0.1.0/tests/fixtures/large_project/README.md +12 -0
- graphlm-0.1.0/tests/fixtures/large_project/app/__init__.py +1 -0
- graphlm-0.1.0/tests/fixtures/large_project/app/main.py +25 -0
- graphlm-0.1.0/tests/fixtures/large_project/app/models/item.py +21 -0
- graphlm-0.1.0/tests/fixtures/large_project/app/models/user.py +17 -0
- graphlm-0.1.0/tests/fixtures/large_project/app/routes/items.py +23 -0
- graphlm-0.1.0/tests/fixtures/large_project/app/routes/users.py +26 -0
- graphlm-0.1.0/tests/fixtures/large_project/app/services/auth.py +16 -0
- graphlm-0.1.0/tests/fixtures/large_project/app/services/item_service.py +17 -0
- graphlm-0.1.0/tests/fixtures/large_project/app/services/user_service.py +15 -0
- graphlm-0.1.0/tests/fixtures/large_project/docs/overview.md +5 -0
- graphlm-0.1.0/tests/fixtures/large_project/migrations/001_create_users.sql +8 -0
- graphlm-0.1.0/tests/fixtures/large_project/migrations/002_create_items.sql +9 -0
- graphlm-0.1.0/tests/fixtures/large_project/pyproject.toml +17 -0
- graphlm-0.1.0/tests/fixtures/large_project/static/css/app.css +15 -0
- graphlm-0.1.0/tests/fixtures/large_project/static/js/app.js +4 -0
- graphlm-0.1.0/tests/fixtures/large_project/templates/base.html +14 -0
- graphlm-0.1.0/tests/fixtures/large_project/tests/test_auth.py +14 -0
- graphlm-0.1.0/tests/fixtures/large_project/tests/test_items.py +9 -0
- graphlm-0.1.0/tests/fixtures/large_project/tests/test_users.py +9 -0
- graphlm-0.1.0/tests/fixtures/medium_project/migrations/001_initial.sql +13 -0
- graphlm-0.1.0/tests/fixtures/medium_project/pyproject.toml +17 -0
- graphlm-0.1.0/tests/fixtures/medium_project/src/__init__.py +11 -0
- graphlm-0.1.0/tests/fixtures/medium_project/src/core/__init__.py +22 -0
- graphlm-0.1.0/tests/fixtures/medium_project/src/utils/__init__.py +18 -0
- graphlm-0.1.0/tests/fixtures/medium_project/tests/test_engine.py +16 -0
- graphlm-0.1.0/tests/fixtures/medium_project/tests/test_utils.py +16 -0
- graphlm-0.1.0/tests/fixtures/small_project/main.py +9 -0
- graphlm-0.1.0/tests/fixtures/small_project/mylib/__init__.py +1 -0
- graphlm-0.1.0/tests/fixtures/small_project/mylib/helpers.py +11 -0
- graphlm-0.1.0/tests/fixtures/small_project/pyproject.toml +11 -0
- graphlm-0.1.0/tests/fixtures/small_project/test_helpers.py +12 -0
- graphlm-0.1.0/tests/test_cli.py +303 -0
- graphlm-0.1.0/tests/test_config.py +86 -0
- graphlm-0.1.0/tests/test_context.py +380 -0
- graphlm-0.1.0/tests/test_cycles.py +299 -0
- graphlm-0.1.0/tests/test_diff.py +478 -0
- graphlm-0.1.0/tests/test_html_render.py +487 -0
- graphlm-0.1.0/tests/test_integration.py +593 -0
- graphlm-0.1.0/tests/test_llm.py +471 -0
- graphlm-0.1.0/tests/test_models.py +172 -0
- graphlm-0.1.0/tests/test_parser.py +422 -0
- graphlm-0.1.0/tests/test_prompts.py +25 -0
- graphlm-0.1.0/tests/test_provenance.py +103 -0
- graphlm-0.1.0/tests/test_render.py +339 -0
- graphlm-0.1.0/tests/test_scanner.py +394 -0
- graphlm-0.1.0/tests/test_skills.py +124 -0
- graphlm-0.1.0/uv.lock +912 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# graphLM configuration — copy to .env and fill in your values
|
|
2
|
+
GRAPHLM_BASE_URL=https://studio.gracebkp.cloud/v1
|
|
3
|
+
GRAPHLM_API_KEY=sk-your-api-key-here
|
|
4
|
+
GRAPHLM_MODEL=Qwen3.6-35B
|
|
5
|
+
# Pass-2 INPUT context budget in tokens (tree + files sent to the model).
|
|
6
|
+
# Overridden by the --max-context CLI flag.
|
|
7
|
+
GRAPHLM_MAX_CONTEXT=120000
|
|
8
|
+
# Max OUTPUT tokens the model may emit for the graph. Independent of the input
|
|
9
|
+
# budget above. Defaults to the model's practical max so large graphs don't
|
|
10
|
+
# truncate; lower it only on an endpoint that bounds input+output together.
|
|
11
|
+
# Overridden by the --max-output-tokens CLI flag.
|
|
12
|
+
GRAPHLM_MAX_OUTPUT_TOKENS=128000
|
|
13
|
+
# LLM request timeout in seconds. Overridden by the --timeout CLI flag.
|
|
14
|
+
# Pass 2 is streamed; a large project's generation can take minutes.
|
|
15
|
+
GRAPHLM_TIMEOUT=300
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
strategy:
|
|
13
|
+
fail-fast: false
|
|
14
|
+
matrix:
|
|
15
|
+
python-version: ["3.11", "3.12", "3.13"]
|
|
16
|
+
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v4
|
|
19
|
+
|
|
20
|
+
- name: Set up uv
|
|
21
|
+
uses: astral-sh/setup-uv@v4
|
|
22
|
+
with:
|
|
23
|
+
enable-cache: true
|
|
24
|
+
|
|
25
|
+
- name: Set up Python ${{ matrix.python-version }}
|
|
26
|
+
run: uv python install ${{ matrix.python-version }}
|
|
27
|
+
|
|
28
|
+
- name: Install dependencies
|
|
29
|
+
run: uv sync --group dev
|
|
30
|
+
|
|
31
|
+
- name: Run tests with coverage
|
|
32
|
+
run: uv run pytest --cov=graphlm --cov-report=xml --cov-report=term-missing -v
|
|
33
|
+
|
|
34
|
+
- name: Upload coverage
|
|
35
|
+
uses: codecov/codecov-action@v5
|
|
36
|
+
if: matrix.python-version == '3.13'
|
|
37
|
+
with:
|
|
38
|
+
files: ./coverage.xml
|
|
39
|
+
fail_ci_if_error: false
|
|
40
|
+
|
|
41
|
+
typecheck:
|
|
42
|
+
runs-on: ubuntu-latest
|
|
43
|
+
steps:
|
|
44
|
+
- uses: actions/checkout@v4
|
|
45
|
+
|
|
46
|
+
- name: Set up uv
|
|
47
|
+
uses: astral-sh/setup-uv@v4
|
|
48
|
+
with:
|
|
49
|
+
enable-cache: true
|
|
50
|
+
|
|
51
|
+
- name: Set up Python
|
|
52
|
+
run: uv python install 3.12
|
|
53
|
+
|
|
54
|
+
- name: Install dependencies
|
|
55
|
+
run: uv sync --group dev
|
|
56
|
+
|
|
57
|
+
- name: Run mypy
|
|
58
|
+
run: uv run mypy graphlm --ignore-missing-imports
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
# Publishes graphlm to PyPI *and* a GitHub Release from one git tag.
|
|
4
|
+
#
|
|
5
|
+
# git tag v0.1.0 && git push origin v0.1.0
|
|
6
|
+
#
|
|
7
|
+
# The tag drives everything: build once, smoke-test the built wheel in a clean
|
|
8
|
+
# venv, attach the artifacts to a GitHub Release, and publish the same artifacts
|
|
9
|
+
# to PyPI via Trusted Publishing (OIDC — no stored API token).
|
|
10
|
+
#
|
|
11
|
+
# A manual run (workflow_dispatch) publishes to TestPyPI instead, so the whole
|
|
12
|
+
# Trusted-Publishing path can be rehearsed without burning the real name/version.
|
|
13
|
+
# This does NOT re-run the test matrix — the push/PR CI (ci.yml) already gates
|
|
14
|
+
# the commit on 3-version pytest + mypy.
|
|
15
|
+
|
|
16
|
+
on:
|
|
17
|
+
push:
|
|
18
|
+
tags:
|
|
19
|
+
- "v*"
|
|
20
|
+
workflow_dispatch:
|
|
21
|
+
inputs:
|
|
22
|
+
target:
|
|
23
|
+
description: "Publish target for a manual run"
|
|
24
|
+
type: choice
|
|
25
|
+
options:
|
|
26
|
+
- testpypi
|
|
27
|
+
- none
|
|
28
|
+
default: testpypi
|
|
29
|
+
|
|
30
|
+
permissions:
|
|
31
|
+
contents: read
|
|
32
|
+
|
|
33
|
+
jobs:
|
|
34
|
+
build:
|
|
35
|
+
name: Build & smoke-test
|
|
36
|
+
runs-on: ubuntu-latest
|
|
37
|
+
steps:
|
|
38
|
+
- uses: actions/checkout@v4
|
|
39
|
+
|
|
40
|
+
- name: Set up uv
|
|
41
|
+
uses: astral-sh/setup-uv@v4
|
|
42
|
+
with:
|
|
43
|
+
enable-cache: true
|
|
44
|
+
|
|
45
|
+
- name: Set up Python
|
|
46
|
+
run: uv python install 3.12
|
|
47
|
+
|
|
48
|
+
- name: Build sdist and wheel
|
|
49
|
+
run: uv build --out-dir dist
|
|
50
|
+
|
|
51
|
+
# The check the test suite can't do: install the *built wheel* into a
|
|
52
|
+
# fresh venv (never .venv) and run it. This catches a missing entry point,
|
|
53
|
+
# a missing data file (_html_template.html), or a missing dependency — all
|
|
54
|
+
# at once, with no network (--dry-run makes no LLM call).
|
|
55
|
+
- name: Smoke-test the built wheel in a clean venv
|
|
56
|
+
run: |
|
|
57
|
+
set -euo pipefail
|
|
58
|
+
python3 -m venv /tmp/smoke
|
|
59
|
+
/tmp/smoke/bin/pip install --quiet dist/*.whl
|
|
60
|
+
echo "--- graphlm --version ---"
|
|
61
|
+
/tmp/smoke/bin/graphlm --version
|
|
62
|
+
echo "--- graphlm <fixture> --dry-run ---"
|
|
63
|
+
/tmp/smoke/bin/graphlm tests/fixtures/small_project --dry-run
|
|
64
|
+
echo "--- HTML template ships in the wheel ---"
|
|
65
|
+
/tmp/smoke/bin/python -c "import graphlm, importlib.resources as r; \
|
|
66
|
+
p = r.files('graphlm') / '_html_template.html'; \
|
|
67
|
+
assert p.is_file(), 'MISSING _html_template.html'; print('ok:', p)"
|
|
68
|
+
|
|
69
|
+
- name: Upload build artifacts
|
|
70
|
+
uses: actions/upload-artifact@v4
|
|
71
|
+
with:
|
|
72
|
+
name: dist
|
|
73
|
+
path: dist/
|
|
74
|
+
|
|
75
|
+
github-release:
|
|
76
|
+
name: GitHub Release
|
|
77
|
+
needs: build
|
|
78
|
+
if: startsWith(github.ref, 'refs/tags/v')
|
|
79
|
+
runs-on: ubuntu-latest
|
|
80
|
+
permissions:
|
|
81
|
+
contents: write # create the release + upload assets
|
|
82
|
+
steps:
|
|
83
|
+
- uses: actions/checkout@v4
|
|
84
|
+
|
|
85
|
+
- name: Download build artifacts
|
|
86
|
+
uses: actions/download-artifact@v4
|
|
87
|
+
with:
|
|
88
|
+
name: dist
|
|
89
|
+
path: dist/
|
|
90
|
+
|
|
91
|
+
- name: Create GitHub Release
|
|
92
|
+
uses: softprops/action-gh-release@v2
|
|
93
|
+
with:
|
|
94
|
+
files: dist/*
|
|
95
|
+
generate_release_notes: true
|
|
96
|
+
body: |
|
|
97
|
+
graphlm ${{ github.ref_name }}
|
|
98
|
+
|
|
99
|
+
Install: `uv tool install graphlm` or `pipx install graphlm`
|
|
100
|
+
(or grab the wheel/sdist below). See the
|
|
101
|
+
[CHANGELOG](https://github.com/ggrace519/graphLM/blob/main/CHANGELOG.md)
|
|
102
|
+
for what's in this release.
|
|
103
|
+
|
|
104
|
+
pypi-publish:
|
|
105
|
+
name: Publish to PyPI
|
|
106
|
+
needs: build
|
|
107
|
+
if: startsWith(github.ref, 'refs/tags/v')
|
|
108
|
+
runs-on: ubuntu-latest
|
|
109
|
+
environment:
|
|
110
|
+
name: pypi
|
|
111
|
+
url: https://pypi.org/p/graphlm
|
|
112
|
+
permissions:
|
|
113
|
+
id-token: write # Trusted Publishing (OIDC) — no API token secret
|
|
114
|
+
steps:
|
|
115
|
+
- name: Download build artifacts
|
|
116
|
+
uses: actions/download-artifact@v4
|
|
117
|
+
with:
|
|
118
|
+
name: dist
|
|
119
|
+
path: dist/
|
|
120
|
+
|
|
121
|
+
- name: Publish to PyPI
|
|
122
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
123
|
+
|
|
124
|
+
testpypi-publish:
|
|
125
|
+
name: Publish to TestPyPI
|
|
126
|
+
needs: build
|
|
127
|
+
if: github.event_name == 'workflow_dispatch' && inputs.target == 'testpypi'
|
|
128
|
+
runs-on: ubuntu-latest
|
|
129
|
+
environment:
|
|
130
|
+
name: testpypi
|
|
131
|
+
url: https://test.pypi.org/p/graphlm
|
|
132
|
+
permissions:
|
|
133
|
+
id-token: write
|
|
134
|
+
steps:
|
|
135
|
+
- name: Download build artifacts
|
|
136
|
+
uses: actions/download-artifact@v4
|
|
137
|
+
with:
|
|
138
|
+
name: dist
|
|
139
|
+
path: dist/
|
|
140
|
+
|
|
141
|
+
- name: Publish to TestPyPI
|
|
142
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
143
|
+
with:
|
|
144
|
+
repository-url: https://test.pypi.org/legacy/
|
graphlm-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
.env
|
|
2
|
+
*.db
|
|
3
|
+
__pycache__/
|
|
4
|
+
*.pyc
|
|
5
|
+
.pytest_cache/
|
|
6
|
+
*.egg-info/
|
|
7
|
+
dist/
|
|
8
|
+
build/
|
|
9
|
+
.venv/
|
|
10
|
+
graphLM.db
|
|
11
|
+
|
|
12
|
+
# Coverage artifacts (generated by pytest-cov / CI)
|
|
13
|
+
.coverage
|
|
14
|
+
coverage.xml
|
|
15
|
+
|
|
16
|
+
# graphLM output artifacts (default output dir, plus legacy root-level names
|
|
17
|
+
# from before the .graphlm/ default in case any linger)
|
|
18
|
+
.graphlm/
|
|
19
|
+
GRAPH.md
|
|
20
|
+
GRAPH.json
|
|
21
|
+
GRAPH.html
|
|
22
|
+
GRAPH_DIFF.md
|
|
23
|
+
GRAPH_DIFF.json
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to Semantic Versioning.
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0] - 2026-08-30
|
|
11
|
+
|
|
12
|
+
First public release. graphlm is installable from PyPI (`uv tool install graphlm`
|
|
13
|
+
/ `pipx install graphlm`) and from the attached GitHub Release artifacts; the
|
|
14
|
+
`graphlm` command lands on your PATH. Everything below shipped in 0.1.0.
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- **Packaged for release**: published to PyPI and GitHub Releases from a single
|
|
19
|
+
git tag via a Trusted-Publishing workflow (`.github/workflows/release.yml`) —
|
|
20
|
+
no stored API token. The release build installs the wheel into a clean venv
|
|
21
|
+
and smoke-tests `graphlm --version` + a `--dry-run` before publishing, so a
|
|
22
|
+
missing entry point, data file, or dependency fails the release instead of
|
|
23
|
+
reaching users
|
|
24
|
+
- `--version` / `-V` flag: prints the installed graphlm version and exits
|
|
25
|
+
- `--install-skill <harness>` flag: drops a guide that teaches a coding agent how
|
|
26
|
+
to use graphlm and to look for its map (`.graphlm/GRAPH.md`) when loading a
|
|
27
|
+
codebase — regenerating it with `graphlm .` when absent or stale. Targets
|
|
28
|
+
`claude` (writes `~/.claude/skills/graphlm/SKILL.md`) and `codex` (writes
|
|
29
|
+
`~/.codex/graphlm.md` and prints a one-line snippet to include from your own
|
|
30
|
+
`AGENTS.md`). Installs user-global by default (`--skill-local` writes into the
|
|
31
|
+
scanned project instead); idempotent (skip-if-exists unless `--force`). It only
|
|
32
|
+
ever creates graphlm's own files — it never edits your existing `CLAUDE.md` /
|
|
33
|
+
`AGENTS.md`, and it refuses to write *through* a symlink at the target (so a
|
|
34
|
+
dotfiles-managed `~/.claude/skills/` can't be clobbered) (#33)
|
|
35
|
+
- **Self-refreshing graph**: every generated `GRAPH.md` now carries a top-of-file *provenance & refresh directive*, and `GRAPH.json` a versioned `meta` block, recording when the map was generated and against which git commit. A coding agent reading `GRAPH.md` can compare the repo's current `HEAD` to the stamped commit and regenerate (`graphlm .`) when they differ — the agent is the scheduler; graphlm adds no hook, flag, or staleness logic of its own. Non-git projects degrade gracefully (no SHA; the directive falls back to the agent's judgment). The directive is advisory. Wording is deliberately "generated against commit X" (not "reflects X") because the map is built from files on disk, which may include uncommitted changes — a graph can be SHA-fresh yet not match the working tree
|
|
36
|
+
- **Graph-vs-graph diff (`GRAPH_DIFF.md` / `GRAPH_DIFF.json`)**: every real run now also writes a structural diff of the map — modules, import edges (LLM and AST), import cycles, data flows, entry points, and file summaries **added and removed** since the prior `GRAPH.json`. This answers "what changed in the map since last time?" at a glance (a new entry point, a dropped module, a broken/added import cycle) without re-reading the whole graph. It reads graphlm's *own* prior output — which is why the `meta` block was made a versioned input contract. It is **not** a code diff (git does that better): added/removed only, so a pure prose rewrite (a description, a summary) is intentionally invisible; renames show as remove+add (no rename heuristics). Three baseline states are always distinguished so an agent can tell them apart: *first run* ("initial graph — no prior version"), *uncomparable* (a corrupt or unrecognized-`schema_version` prior file — never masqueraded as a first run), and *normal*. The diff header carries the old→new commit-SHA range. Toggling `--no-ast` between runs does not fabricate a mass edge deletion (the AST dimension reports "not compared" when either side skipped AST). On by default; `--no-diff` / `include_diff=False` opts out. `--dry-run` writes no diff (it produces no authoritative graph). No new network or LLM call — pure local computation over the two graphs (#28, ADR-002)
|
|
37
|
+
- `--timeout` CLI flag / `GRAPHLM_TIMEOUT` env var to configure the LLM request timeout (default raised to 300s). Resolves `--timeout` > `GRAPHLM_TIMEOUT` > 300, mirroring `--max-context` (#18)
|
|
38
|
+
- `--max-output-tokens` CLI flag / `GRAPHLM_MAX_OUTPUT_TOKENS` env var to configure the graph output-token ceiling. Defaults to the model's practical max (128000) so large graphs don't truncate; it is a request ceiling only (the model stops when done), independent of the input budget (#18, #25). Raise it only on an endpoint that bounds input+output together
|
|
39
|
+
- Streaming LLM responses: `call_llm` now sends `stream: true` and reassembles the SSE deltas. This keeps a long generation alive past proxy read-timeouts and adds a clear "output truncated (hit max_tokens)" error instead of a confusing parse failure. A server that ignores the flag and returns a buffered body is handled transparently (#18)
|
|
40
|
+
- Two-pass LLM strategy: pass 1 sends directory tree only to identify key files, pass 2 sends tree + selected files for full analysis
|
|
41
|
+
- CLI tool via Typer with `--dry-run`, `--no-tests`, `--exclude`, and `--max-files` options
|
|
42
|
+
- Library API via single `generate_graph()` function returning `GraphResult`
|
|
43
|
+
- Pydantic v2 models for structured graph data (import edges, modules, data flow, DB schema, test mapping, architecture notes, quick reference)
|
|
44
|
+
- Project scanner with smart file ranking (config files > package init > source > tests)
|
|
45
|
+
- Token estimation heuristic (~4 UTF-8 bytes per token)
|
|
46
|
+
- LLM client with JSON recovery (strips code fences, finds braces in text)
|
|
47
|
+
- Retry logic with exponential backoff on connection errors
|
|
48
|
+
- Markdown and JSON output rendering
|
|
49
|
+
- System prompt with injection guard (treats file content as data only)
|
|
50
|
+
- Test fixtures: small, medium, and large project directories
|
|
51
|
+
- Tree-sitter-based AST parser for deterministic import/edge extraction (Python, JS, TS)
|
|
52
|
+
- `--ast` CLI flag: enables AST-derived deterministic import edges alongside LLM analysis
|
|
53
|
+
- Import cycle detection via Tarjan's SCC algorithm with risk scoring based on module size and cycle length
|
|
54
|
+
- `--no-show-cycles` / `--cycle-threshold` CLI flags for cycle control
|
|
55
|
+
- Interactive D3 force-graph HTML visualization output with zoom/pan, hover tooltips, click highlighting, search, and dark/light mode
|
|
56
|
+
- `--no-html` CLI flag (HTML enabled by default)
|
|
57
|
+
- New `deterministic_edges` and `import_cycles` fields on `CodebaseGraph` model
|
|
58
|
+
|
|
59
|
+
### Changed
|
|
60
|
+
|
|
61
|
+
- CLI writes its output into a **`.graphlm/` subdirectory of the scanned project** by default (not the process working directory, and no longer the project root) — so `GRAPH.md` / `GRAPH.json` / `GRAPH.html` / `GRAPH_DIFF.*` stay out of the way in one tidy folder. `-o <dir>` still overrides and is honored literally (no `.graphlm` appended). The whole `.graphlm/` directory is excluded from scanning, so a re-run never ingests its own map. Note the map now lives at `.graphlm/GRAPH.md`; agents/tooling looking for `GRAPH.md` at the project root should look in `.graphlm/`. The library API is unchanged — `generate_graph(output_dir=...)` / `result.write(dir)` still write to the literal directory given
|
|
62
|
+
- Output files are `GRAPH.md`, `GRAPH.json`, and `GRAPH.html` (were `graphs.md` / `graphs.json` / `graph.html`)
|
|
63
|
+
- AST import parsing is on by default; pass `--no-ast` or `ast=False` to skip. The `--ast` flag is removed
|
|
64
|
+
- `GraphResult.write()` / `write_outputs()` now return a `WriteResult` — the `(md, json, html_or_none)` 3-tuple you can still unpack three ways, now carrying `.diff_md` / `.diff_json` attributes for the graph diff (#28). (This supersedes the earlier `tuple[Path, Path, Path | None]` return added with HTML; the positional arity is unchanged, so `md, json, html = result.write(...)` keeps working)
|
|
65
|
+
- `write_outputs()` now optionally writes HTML alongside Markdown and JSON
|
|
66
|
+
|
|
67
|
+
### Fixed
|
|
68
|
+
|
|
69
|
+
- graphlm produced **no graph at all** against a served model that needs a structured-output constraint (its own default Qwen endpoint): pass 2 relied on prompt-only instruction to make the model emit a `CodebaseGraph`, but never sent a `response_format` in the request. `Qwen3.6-35B` returned a near-empty `{"database_schema": null}` and the run failed with a schema-validation error, writing nothing. Pass 2 now sends `response_format: {type: json_schema}` (gated on a structured response being requested, so pass 1's free-form file-list request is untouched). The endpoint treats it as a guided-JSON hint, so the prompt's "return an empty directory_tree" and the locally-filled `meta`/`import_cycles`/`deterministic_edges` still come back empty — #18 is not reopened. Endpoints that reject the parameter (HTTP 400) fall back once to prompt-only, so a prompt-following endpoint still works; 401/403/404/429 still surface as errors. Verified end-to-end against the live Qwen endpoint (was invisible to CI, which mocks the LLM) (#31)
|
|
70
|
+
- graphlm ingested its own output on a re-run: `GRAPH.md` / `GRAPH.json` / `GRAPH.html` sit in the scanned directory but were never excluded, so a second run over an already-mapped project fed its own previous map into the LLM as source. These artifacts (and the new `GRAPH_DIFF.*`) are now always excluded from scanning. The exclusion is by exact name, not a broad `GRAPH*` glob, so a user's `GRAPHICS.md` / `GRAPHING.md` etc. are untouched (#28)
|
|
71
|
+
- Sensitive-file read gap: arbitrary `.env.<name>` files (e.g. `.env.qa`, `.env.test`) were scanned into LLM context because the sensitive-file check used a fixed allowlist. Any dotenv file is now treated as secret-bearing, except the non-secret templates `.env.example` / `.env.sample` / `.env.template` / `.env.dist` (#10)
|
|
72
|
+
- `GRAPHLM_MAX_CONTEXT` had no effect: it was parsed into `Settings` but never read, so the pass-2 context budget was always 120000. The budget now resolves as `--max-context` flag > `GRAPHLM_MAX_CONTEXT` env var > 120000 (#11)
|
|
73
|
+
- Pass-2 prompt could exceed `max_context` (#12): per-file admission respected the budget, but the AST-edge table was appended uncapped and the instruction block was never counted, so a project with many import edges produced an over-budget prompt (a 6000-edge table alone reached ~62k tokens regardless of the budget). Now every fixed section is reserved up front and the edge table is capped at a bounded share of the budget; when the table is truncated, its framing changes to tell the model the list is not exhaustive so it still infers the dropped edges. The full edge list is unaffected — it still reaches `graph.deterministic_edges` and cycle detection. Also removes spurious truncation of files that would have fit
|
|
74
|
+
- External symlinked *files* pointing outside the project were listed in the directory tree and consumed a `max_files` slot (only symlinked directories were guarded). Any symlink escaping the project is now skipped in the tree walk; file content was already blocked before reading
|
|
75
|
+
- The deterministic AST import graph came back **empty on real Python projects** (0 edges, 0 cycles), silently — the LLM's inferred edges masked it (#19). Two compounding causes: (a) file ranking gave documentation the same priority as source, so on a doc-heavy repo (argus: 91 `.md` vs 135 `.py`) markdown crowded source out of the `max_files` scan, and edges only resolve between *scanned* files — source now outranks non-source text; (b) src-layout projects (package under `src/`) import by package name (`from mypkg.core import X`) but the scanned file is `src/mypkg/core.py`, so no candidate matched — the resolver now derives source-root prefixes (`src/`, …) from the scan and tries each. argus went from 0 to 342 deterministic edges; root-layout projects are unchanged (root `""` is tried first)
|
|
76
|
+
- graphlm overflowed the LLM context on large / polyglot / build-heavy repos, failing with an upstream "Context size has been exceeded" error and producing no graph at all (#17). Two compounding causes, both measured against the real model server: (1) the **pass-1 directory tree was unbounded** — `max_files` capped only the files *read* in pass 2, never the tree, and `_ALWAYS_EXCLUDE` missed the big build/cache dirs (`target/`, `.hypothesis/`, `dist/`, `build/`, `.ruff_cache/`, `.next/`, `coverage/`, …), so a repo's tree alone could reach ~400 KB / ~140k tokens; (2) the **token estimate under-counted by ~28%** — `estimate_tokens` assumed 4 bytes/token but real tree+code content measures ~2.83, so a "within budget" prompt actually ran far over. Fixes: the exclude set now covers the common build/cache dirs; the tree is bounded by a per-directory cap (200 listed children, spread so nested source stays visible) plus an absolute 5000-line total ceiling; and `estimate_tokens` is recalibrated to ~2.5 bytes/token (over-estimating so the budget is a real guarantee) and unified into a single implementation (was duplicated in `scanner.py` and `context.py`). Verified end-to-end: a repo that overflowed now assembles a pass-1 prompt ~8× smaller and completes pass 1 in well under the context window
|
|
77
|
+
- Pass 2 on a large project failed to produce a graph, in two stages, both fixed under #18:
|
|
78
|
+
- **Transport (HTTP 524):** after the context-overflow fix, the model's full-graph generation legitimately took >120s and the non-streamed request died at Cloudflare's edge read-timeout while the origin was still generating. The response is now streamed, which resets that timeout on every delta, and the client timeout default is raised to 300s (configurable via `--timeout` / `GRAPHLM_TIMEOUT`). Measured: a generation that 524'd at ~125s buffered now completes in ~200s streamed.
|
|
79
|
+
- **Output truncation:** the graph was cut off mid-generation because the model's output exceeded the 16000-token `max_tokens` ceiling. Two compounding causes: (a) the pass-2 prompt told the model to *echo the entire directory tree* back inside its JSON (~20k output tokens for the argus tree — invariant to `--max-pass2-files`, since tree size doesn't depend on file count); the model is now told to return an empty `directory_tree` and `generate_graph` fills it from the scan locally (it already has it). (b) even without the echo, a real project's analysis needs more than the old 16000-token ceiling; the ceiling is now configurable (`--max-output-tokens` / `GRAPHLM_MAX_OUTPUT_TOKENS`) and defaults to the model's practical max (see #25). A response that still hits the ceiling now raises a clear "graph too large — raise --max-output-tokens" error instead of a confusing parse failure.
|
|
80
|
+
- The output ceiling was needlessly capped low **and** was double-counted against the input budget (#25). graphlm reserved `max_output_tokens` out of `max_context` on the assumption that input and output share one context window — **false on the target endpoint** (measured: 180k input + 200k `max_tokens` = 380k combined is accepted). Fixes: (1) the output ceiling defaults to the model's practical max (128000) so large graphs don't truncate — it's a request ceiling, not a reservation, so a high value is free; (2) input file admission no longer subtracts the output budget, only a small message-overhead reserve, which raises effective input capacity by ~38% (more files analyzed per run). The pass-2 output reserve and the requested `max_tokens` are no longer coupled. Endpoints that *do* bound input+output together (vLLM, Anthropic) can lower `--max-output-tokens`.
|
|
81
|
+
- `--max-output-tokens` help text was stale: it still said the default was 32000 and that the value "reserves that much of the pass-2 context" — both untrue since #25/#26 (default is 128000; the ceiling is independent of the input budget). Corrected in the CLI help and `CLAUDE.md`
|
|
82
|
+
|
|
83
|
+
### Changed
|
|
84
|
+
|
|
85
|
+
- `--max-context` CLI flag now defaults to unset (falls back to `GRAPHLM_MAX_CONTEXT`, then 120000) instead of a hardcoded 120000, so the env var is honored
|
|
86
|
+
- HTML threw `TypeError: e is not iterable` on load: D3 v7 `scaleOrdinal(null, palette)` iterates a null domain; use `scaleOrdinal(palette)` as the range
|
|
87
|
+
- HTML visualization did not initialize: `initGraph` was never called on page load, and a recursive self-call could hang the page
|
|
88
|
+
- `--ast` computed import edges then discarded them; cycle detection ran on LLM edges without SLOC-based risk scores
|
|
89
|
+
- `--no-html` still wrote `graph.html` when `-o` was set (HTML was written twice)
|
|
90
|
+
- `result.write()` returned three paths but the README unpacked two; string output paths failed
|
|
91
|
+
- AST import resolver mapped packages to non-existent `pkg.py` files and stdlib modules to `os.py`
|
|
92
|
+
- LLM copied test-fixture database schemas into the host project graph
|
|
93
|
+
|
|
94
|
+
### Infrastructure
|
|
95
|
+
|
|
96
|
+
- Python 3.11+ with hatchling build, uv dependency management
|
|
97
|
+
- pytest-httpx for mock HTTP integration testing
|
|
98
|
+
- pytest + coverage in CI
|
|
99
|
+
- `.env.example` for LLM endpoint configuration
|
|
100
|
+
- GitHub Actions CI testing on Python 3.11, 3.12, 3.13 with coverage upload to Codecov
|
|
101
|
+
- mypy type checking in CI
|
|
102
|
+
- Removed stale generated artifacts (`graphs.md`, `graphs.json`, `graph.html`) left over from before the `GRAPH.*` output rename, and the committed `.coverage` database; the repo no longer ships tool output. Added `.coverage`, `coverage.xml`, and the `GRAPH.*` output files to `.gitignore` so generated artifacts stay out of version control
|
|
103
|
+
|
|
104
|
+
[Unreleased]: https://github.com/ggrace519/graphLM/compare/v0.1.0...HEAD
|
|
105
|
+
[0.1.0]: https://github.com/ggrace519/graphLM/releases/tag/v0.1.0
|
graphlm-0.1.0/CLAUDE.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
## What this is
|
|
6
|
+
|
|
7
|
+
`graphlm` is a CLI + library that generates a codebase graph (Markdown + JSON + interactive HTML) from any project directory, using an OpenAI-compatible LLM plus deterministic Tree-sitter AST parsing. Python 3.11+, `uv`-managed, Pydantic v2, Typer CLI, `httpx` for the LLM call. No async — the LLM client is synchronous.
|
|
8
|
+
|
|
9
|
+
## Commands
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
uv sync --group dev # install (incl. pytest, pytest-cov, pytest-httpx, mypy)
|
|
13
|
+
uv run pytest -q # full suite (~350 tests, ~4s, no network — LLM is mocked via pytest-httpx)
|
|
14
|
+
uv run pytest tests/test_parser.py -q # one file
|
|
15
|
+
uv run pytest tests/test_parser.py::test_name -q # one test
|
|
16
|
+
uv run pytest --cov=graphlm --cov-report=term-missing # coverage (CI reports it; ~90%, but NOT gated — no --cov-fail-under)
|
|
17
|
+
uv run mypy graphlm --ignore-missing-imports # type check (separate CI job; keep it clean)
|
|
18
|
+
graphlm /path/to/project --dry-run # exercise scan + AST + context packing without any LLM call
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
CI (`.github/workflows/ci.yml`) runs pytest+coverage on Python 3.11/3.12/3.13 and mypy on 3.12. There is no linter/formatter configured — match surrounding style.
|
|
22
|
+
|
|
23
|
+
## Architecture — the big picture
|
|
24
|
+
|
|
25
|
+
The *analysis* pipeline lives in `graphlm/__init__.py::generate_graph()` — read that function first; it orchestrates everything below in order. (Output is separate: the library only writes when `output_dir` is set; the CLI leaves it `None` and writes itself — see below.)
|
|
26
|
+
|
|
27
|
+
**Two-pass LLM strategy** (the core design, to stay inside context windows):
|
|
28
|
+
1. **Pass 1** — `context.assemble_pass1_prompt(tree)` sends *only* the directory tree. The LLM returns `{"requested_files": [...]}` — the files it wants to read. The tree is size-bounded by **two** caps in `scan_project` so this prompt stays within context on a huge repo: (a) a *per-directory* cap of `max_tree_entries_per_dir` listed children (default 200 via `_MAX_TREE_ENTRIES_PER_DIR`), emitting a "… N more entries not shown" marker past that — per-directory rather than global so deeply nested source stays visible instead of being crowded out by an early cache dir; and (b) an absolute *total-lines* ceiling of `200 × _TREE_TOTAL_LINE_MULTIPLIER` (= 5000 lines) that stops the whole walk (the per-dir cap alone is only `per_dir × num_dirs`, so a repo with thousands of dirs still needs the total backstop). Both bound tree text only, not which files are read (`max_files`). Unlike pass 2, pass 1 has **no token-budget enforcement** — these caps plus `_ALWAYS_EXCLUDE` are what keep it in bounds. `_ALWAYS_EXCLUDE` also blocks graphlm's *own* output so a re-run over an already-mapped project never ingests its own map/diff as source: the CLI default output dir **`.graphlm/`** is excluded as a whole directory (`_should_exclude` matches any path component), and — for the case where output is redirected into the scanned tree with `-o` — the artifact *filenames* (`GRAPH.md`/`.json`/`.html`, `GRAPH_DIFF.md`/`.json`) are also excluded by exact name, not a `GRAPH*` glob, so a user's `GRAPHICS.md` survives (#28).
|
|
29
|
+
2. **Pass 2** — `context.filter_requested_files()` maps those back to scanned fragments (with a fuzzy-match fallback), then `assemble_pass2_prompt()` assembles tree + file contents + the AST-edge table + the instruction block, and asks for the full `CodebaseGraph` JSON. The *input* token budget is enforced: the tree, edge table, instruction block, and a small message-overhead reserve (`MESSAGE_OVERHEAD_TOKENS`, for the system prompt + framing) are counted up front, and files are admitted only until the running total would exceed `max_context` (over-budget files land in `truncated_paths`). So the assembled prompt stays within `max_context`. The **output** budget (`max_tokens`) is *not* reserved out of `max_context` — input and output ceilings are independent on the target endpoint (#25). **The model is told to return an empty `directory_tree`** — `generate_graph` fills `graph.directory_tree = scan.tree` locally afterward (alongside the `deterministic_edges` fill), because echoing a large tree back as output alone can blow the output-token ceiling and truncate the graph (#18). So don't reintroduce a "include the tree" instruction.
|
|
30
|
+
|
|
31
|
+
`--dry-run` skips both LLM calls. It does *not* model "all scanned files" — it selects `scan.file_fragments[:max_pass2_files]` (default cap 80), builds a `CodebaseGraph` carrying the AST edges + cycles + a dry-run note, and the CLI prints token estimates *and* graph-section counts. Tokens are estimated by a crude heuristic — `estimate_tokens` = `UTF-8 bytes * 2 // 5` (≈2.5 bytes/token). It is defined once in `scanner.py` and re-exported by `context.py`. The ratio is calibrated to over-estimate: real content measured ~2.83 bytes/token against the served model, so the old `// 4` (4 bytes/token) *under*-counted by ~28% and let graphlm pack prompts the model then rejected or timed out on — the `* 2 // 5` divisor sits safely above the real count (#17).
|
|
32
|
+
|
|
33
|
+
**AST parser is ground truth, not a replacement for the LLM** (`parser.py`, on by default; `--no-ast` disables). `build_dependency_graph()` extracts Python import edges with Tree-sitter and resolves them *only against files that exist in the scan* (stdlib/third-party/missing modules are dropped — resolution is against the `known_files` set, independent of `project_dir`). Because edges resolve only between *scanned* files, the scanner's `_rank_file` ranks source code (`_SOURCE_EXTS`) above non-source text so a doc-heavy repo can't crowd source out of the `max_files` cap and starve the graph (#19). Resolution handles **src-layout** projects: `_source_roots()` accepts a source-root prefix only when its final segment is a conventional root name (`_SOURCE_ROOT_NAMES` = `src`/`lib`/`python`) that directly contains a package, and each import candidate is tried under every accepted root (root `""` first, so root-layout projects are unchanged) — without this, a `from mypkg.x import Y` in a `src/mypkg/` project resolved to nothing and the whole AST graph came back empty (#19). The allowlist is deliberate: deriving a root from *any* directory containing an `__init__.py` let a `tests/stub/requests/__init__.py` shadow make third-party `import requests` resolve to a project-internal file — a false edge in the "do-not-contradict" ground-truth table, worse than a missing one. `project_dir` is *optional*: given, `_source_bytes` reads the untruncated/unredacted file off disk; omitted, it falls back to the (truncated) `frag.content`. Those `deterministic_edges` are (a) injected into the pass-2 prompt as a "do not contradict" table, and (b) used for cycle detection instead of the LLM's edges. The two uses diverge under a tight `max_context`: the **prompt** copy of the table is capped at a share of the budget (`EDGE_SHARE`, `context.py`) and, when capped, its framing flips to "not exhaustive — infer the rest" so a partial list isn't presented as complete; the **full** list always reaches `graph.deterministic_edges` and cycle detection. So the LLM's `import_edges` and the AST's `deterministic_edges` are separate fields on `CodebaseGraph` and can differ. Only Python is fully implemented; JS/TS are recognized by extension but return empty `ParsedFile`.
|
|
34
|
+
|
|
35
|
+
**Cycle detection** (`cycles.py`) runs Tarjan SCC over the AST edges (falling back to LLM edges if AST is off), scoring each cycle `log10(total_lines) * cycle_length`. Note the SLOC term is a *physical* line count (`frag.content.count("\n") + 1`, including blanks/comments) over the scanned fragment — which is truncated at `max_file_chars` (default 4000) — so it under-counts large files; it is not true source-lines-of-code. Also: `parser.py` *also* has its own `detect_import_cycles()` (returns bare node lists, no scores) — the live path uses `cycles.detect_cycles()`; don't confuse the two.
|
|
36
|
+
|
|
37
|
+
**Data models** (`models.py`) — `CodebaseGraph` is the Pydantic schema the LLM must emit and the single source of truth for output shape. `Cycle` is a frozen dataclass (not Pydantic). The pass-2 prompt in `context.py` hand-writes the JSON schema description for the LLM; **if you add/rename a field on `CodebaseGraph`, update that prompt text and `render.py` in the same change** — they are not auto-derived from the model. **Exception — `meta` (`GraphMeta`):** the provenance stamp is *not* LLM-emitted (like `directory_tree`/`deterministic_edges`, it's filled locally in `generate_graph`), so it is deliberately **not** in the pass-2 instruction block — don't add it. Its `schema_version` (`GRAPH_META_SCHEMA_VERSION`) is a versioned *input* contract: graphlm reads its own prior `GRAPH.json` for the fast-follow diff, so bump it if the meta shape changes (see the self-refreshing-graph ADR in `DECISIONS.md`).
|
|
38
|
+
|
|
39
|
+
**Output** (`render.py`) — writes `GRAPH.md`, `GRAPH.json`, `GRAPH.html`, and (by default) `GRAPH_DIFF.md` / `GRAPH_DIFF.json`. The `*_suffix` params default to `"GRAPH"`. HTML (`html_render.py` + `_html_template.html`) is a single self-contained *file* (string-substituted, no build step) — but it loads D3 from a CDN (`_html_template.html` → `https://d3js.org/d3.v7.min.js`), so the graph won't render offline. HTML is on by default; `--no-html` / `include_html=False` skips it. **`write_outputs` returns a `WriteResult`** — a `tuple` subclass that *is* the `(md, json, html)` 3-tuple existing callers unpack (so `md, json_, html = write_outputs(...)` still works), with `.diff_md` / `.diff_json` attributes carrying the diff paths (`None` when `diff=False`). ADR-002 requires the arity stay 3 for the ~10 positional-unpack sites; don't turn `WriteResult` into a 4-/5-field tuple. `WriteResult.__new__` accepts *either* three positional path args *or* a single 3-sequence, and it defines `__getnewargs_ex__`, so `copy`/`deepcopy`/`pickle` round-trip it with `.diff_md`/`.diff_json` intact (a bare tuple subclass loses them). Note `==` still ignores the diff attrs (inherent to the tuple-subclass choice) — that's accepted.
|
|
40
|
+
|
|
41
|
+
**Graph-vs-graph diff** (`diff.py` + the `GRAPH_DIFF.*` outputs; on by default, `--no-diff` / `include_diff=False` disables). The diff runs **in `write_outputs`, not `generate_graph`** (ADR-002 decision 1) — it's the only place holding both the graph and the output dir (the CLI passes `output_dir=None` into `generate_graph`, the #8 split). Ordering is load-bearing: `load_baseline()` reads and parses the *prior* `{json_suffix}.json` **before** `json_path.write_bytes(...)` overwrites it. `load_baseline` **never raises** — it runs on the write path *after* the paid LLM call, so an escaping exception would abort the run and discard the new graph. It classifies **three states** that must never collapse (decision 4): `FIRST_RUN` (file genuinely absent — a *broken symlink* is `UNCOMPARABLE`, not first-run), `UNCOMPARABLE` (anything that can't be safely interpreted: corrupt/truncated JSON, bad encoding — `render_json` writes `ensure_ascii=False`, so a killed/disk-full prior run can leave a mid-codepoint file, and `UnicodeDecodeError` is a `ValueError` not `OSError` — *or* an unrecognized `meta.schema_version`, checked against `_KNOWN_META_SCHEMA_VERSIONS` *before* model validation, with a `bool`-and-`int` type guard so `schema_version: true` doesn't sneak through as `1`), and `NORMAL`. The read+parse and the validate are each wrapped in a broad `except Exception` → `UNCOMPARABLE` (deliberately broad: the contract is "cannot read → uncomparable", not "cannot read in one of a few specific ways"). An old, meta-*less* `GRAPH.json` parses fine (`meta=None`) and compares as `NORMAL` with an `unknown` old SHA — only corrupt/unknown-version files are uncomparable. `compute_diff` is **added/removed only, no "changed" bucket** (decision 2): identity keys are structural (`modules`→`path`; edges→`(from,to,kind)`; `import_cycles`→`frozenset(nodes)`; `data_flow`→`(source,destination)`; `entry_points`→`(path,name)`; `file_summaries`→`path`), so a pure prose rewrite is invisible by design. Those keys **are the diff's contract** (decision 3) — changing one changes what counts as "the same entity". Renames are remove+add (no rename heuristics — locked). **`deterministic_edges` None-vs-`[]` is honored** (decision 5): if either side is `None` (AST off on one run) the AST dimension is `compared=False` ("not compared"), never a mass deletion. The "no structural changes" banner in the `.md` is gated on `GraphDiff.all_compared` — if a dimension was skipped it flips to "no changes *in the compared dimensions*", so a not-compared dimension is never read as unchanged (the same non-collapse rule as decisions 4/5, at the render layer). The `GRAPH_DIFF.json` carries its own `DIFF_SCHEMA_VERSION` (independent of the graph's `GRAPH_META_SCHEMA_VERSION`), the state label, the old→new SHA range (a `null` side reads `unknown`), and per-dimension added/removed lists. Output filenames follow the `*_suffix` convention: **`diff_suffix` defaults to `None` and resolves to `json_suffix`** (decision 6 — the diff tracks the graph's suffix, so `json_suffix="map"` → `map_DIFF.*`, read from and written beside `map.json`); pass `diff_suffix` to override. **`--dry-run` writes no diff** — the dry-run branch in `generate_graph` returns *before* any `write_outputs` call, and the CLI exits before `result.write` (both correct, intended; don't "fix"). No new network or LLM call — pure local computation. See ADR-002 in `DECISIONS.md`.
|
|
42
|
+
|
|
43
|
+
**Self-refreshing stamp** (`provenance.py` + the `meta` field). `provenance.py` is the *only* module that reads git / shells out — `git_commit_sha()` runs `git rev-parse HEAD` (argv list, no shell, `cwd=project`, failure-tolerant → `None`; a SHA is accepted only on exit 0 **and** a 40/64-hex match, which rejects the empty-repo literal `HEAD`). `generate_graph` fills `graph.meta = GraphMeta(...)` locally after pass 2 **and** in the `--dry-run` branch, overwriting any LLM-emitted `meta`. `render.py` then (a) renders a top-of-`GRAPH.md` **refresh directive** in two forms — *git form* (SHA present: "generated against commit `<sha8>`… compare `git rev-parse HEAD`… regenerate with `graphlm .`") and *non-git form* (SHA `None`: "regenerate when you believe the code changed") — and no directive at all when `meta is None` (old/library graphs); and (b) preserves `commit_sha: null` in `GRAPH.json` by re-splicing `meta` (the module-wide `exclude_none=True` would otherwise drop it, making non-git indistinguishable from old-format). Wording is "generated against", never "reflects" (dirty-tree honesty). See `docs/plans/self-refreshing-graph.md` and the ADR in `DECISIONS.md`.
|
|
44
|
+
|
|
45
|
+
**Output destination is a `.graphlm/` subdir of the scanned project, not cwd.** The CLI passes `output_dir=None` into `generate_graph` (so the library doesn't write), then writes via `result.write(output_destination(...))`. `output_destination` (`cli.py`) defaults to `project_dir / ".graphlm"` (constant `GRAPHLM_OUTPUT_DIRNAME`) unless `-o` is given; `-o` is honored *literally* (no `.graphlm` appended). This CLI-only default keeps the map out of the project root and inside the scan-excluded `.graphlm/`; the **library** API (`generate_graph(output_dir=...)` / `result.write(dir)`) writes to the literal dir given and is unchanged. This split (fixed in #8) is deliberate — don't "simplify" it by having `generate_graph` write directly.
|
|
46
|
+
|
|
47
|
+
## Security invariants (don't weaken these)
|
|
48
|
+
|
|
49
|
+
The scanner treats scanned files as hostile input and has layered defenses — preserve them when editing `scanner.py`/`prompts.py`/`context.py`:
|
|
50
|
+
- **Sensitive files are never read** (`_is_sensitive_file`): TLS/key/cert extensions (`.pem/.key/.crt/…`); *any* dotenv file (`.env`, `.env.<anything>`) except the non-secret templates `.env.example/.sample/.template/.dist` (see `_ENV_SAFE_SUFFIXES`); and name globs like `*secrets*`/`*token*`. Source extensions (`.py`, `.ts`, …) are exempted from the name globs so `token.py` still gets analyzed.
|
|
51
|
+
- **Secret redaction** (`_redact_secrets`) runs on every file's content by default (`--no-redact` disables) — regex passes for AWS keys, GitHub tokens, private-key headers, connection strings, etc.
|
|
52
|
+
- **Symlink escape prevention** (`_path_is_inside`): any symlink (file *or* directory) pointing outside the project is skipped — in the tree walk (so it never appears in the tree or consumes a `max_files` slot) and again before reading content in `os.walk`.
|
|
53
|
+
- **Prompt-injection guard**: `SYSTEM_PROMPT` (`prompts.py`) and the pass-2 prompt both instruct the model to treat all file content as data, never instructions. Keep that clause if you touch the prompts.
|
|
54
|
+
|
|
55
|
+
## Config
|
|
56
|
+
|
|
57
|
+
LLM settings come from `GRAPHLM_BASE_URL` / `GRAPHLM_API_KEY` / `GRAPHLM_MODEL`, loaded from `.env` via `python-dotenv` in `config.py`. CLI flags `-b/-k/-m` override; if you pass any of the three to `generate_graph`, you must pass all three. The context budget resolves `--max-context` flag > `GRAPHLM_MAX_CONTEXT` env var > 120000, the request timeout resolves `--timeout` flag > `GRAPHLM_TIMEOUT` env var > 300s, and the output-token budget resolves `--max-output-tokens` flag > `GRAPHLM_MAX_OUTPUT_TOKENS` env var > `LLM_MAX_OUTPUT_TOKENS` (128000, the model's practical max) (all flags and their `generate_graph(...)` params default to `None` so the env var can take effect). **`max_output_tokens` is only the `max_tokens` sent to the model — a ceiling, not a reservation.** It is *independent* of `max_context`: input and output ceilings do not share a window on the target endpoint (measured: 180k input + 200k `max_tokens` = 380k combined is accepted — #25), so it is NOT subtracted from the input budget. A high default is therefore free (the model stops when done) and prevents truncating a large graph; truncation past even it raises a clear `GraphLLErrorTruncated`. The default is sourced once from `llm.LLM_MAX_OUTPUT_TOKENS` (imported by `config.py`). NB: some other OpenAI-compatible servers (vLLM `max_model_len`, Anthropic) *do* bound prompt+generation together — lower the flag on such an endpoint. The 300s default is generous because **pass 2 is streamed** (`llm.py` sends `stream: true` and reassembles SSE deltas) — a large project's full-graph generation can take minutes, and streaming keeps it alive past proxy read-timeouts (a non-streamed pass 2 hit a Cloudflare 524 at ~125s, #18). **Pass 2 also sends `response_format: {type: json_schema}`** (derived from `CodebaseGraph.model_json_schema()`, gated on `call_llm(response_format=...)` being set, so pass 1's free-form file-list request omits it) — prompt-only instruction was not enough for every served model, and `Qwen3.6-35B` returned a near-empty `{"database_schema": null}` and produced no graph without the constraint (#31). The endpoint treats the schema as a guided-JSON hint (not strict all-required enforcement), so the prompt's "return an empty `directory_tree`" and the locally-filled `meta`/`import_cycles`/`deterministic_edges` still come back empty — #18 is not reopened. An endpoint that rejects the parameter (HTTP **400** only) triggers a one-shot fallback: the constraint is dropped and the request re-issued prompt-only; a 401/403/404/429 is not a schema rejection and still surfaces as an error. `.env.example` shows the shape (defaults point at a self-hosted `studio.gracebkp.cloud` Qwen endpoint). `.env` is gitignored — never commit it.
|
|
58
|
+
|
|
59
|
+
## Tests
|
|
60
|
+
|
|
61
|
+
`tests/fixtures/{small,medium,large,cyclic}_project/` are realistic sample trees the scanner/parser/cycle tests run against; `pyproject.toml` sets `norecursedirs = ["tests/fixtures"]` so pytest doesn't collect them as tests. `tests/test_integration.py` mocks the LLM HTTP call with `pytest-httpx` — no real network. When adding a scanner/parser feature, add or extend a fixture rather than mocking file I/O.
|
|
62
|
+
|
|
63
|
+
## CLI flags worth knowing
|
|
64
|
+
|
|
65
|
+
Beyond the config flags above: `--dry-run` (scan + AST, no LLM), `--no-ast`, `--no-html`, `--no-diff` (skip the `GRAPH_DIFF.*` graph-vs-graph diff), `--no-tests`, `--no-redact`, `--exclude <pat>` (repeatable), and the cycle controls `--no-show-cycles` (skip the import-cycle section) / `--cycle-threshold <float>` (only report cycles with risk ≥ threshold). Sizing knobs: `--max-files` (scan cap, default 200), `--max-file-chars` (per-file, 4000), `--max-pass2-files` (files in pass-2 context, 80), `--max-context` (token budget), `--max-output-tokens` (graph output ceiling, default 128000 — a request ceiling, NOT a reserve out of the input budget, see Config above), `--timeout` (LLM request seconds, default 300). Also `-V`/`--version` (eager). See `graphlm --help` for the full list.
|
|
66
|
+
|
|
67
|
+
**`--install-skill <harness>`** (`skills.py`) installs an agent guide teaching a coding harness to read `.graphlm/GRAPH.md` when it opens a repo (and regenerate with `graphlm .` when missing/stale), then exits. It is **not** an eager callback — instead `project_dir` is an *optional* argument (default `None`) and the command body short-circuits to `_do_install_skill` when `--install-skill` is set, so `graphlm --install-skill claude` runs with no `project_dir`; the analyze path then guards `project_dir is None` with a clean exit-2 message. Harnesses: `claude` → `~/.claude/skills/graphlm/SKILL.md` (real skill frontmatter); `codex` → `~/.codex/graphlm.md` **plus a printed snippet** to paste into the user's own `AGENTS.md` (Codex has no arbitrary-file include — verified; and graphlm never edits a user-owned `AGENTS.md`/`CLAUDE.md`). User-global by default; `--skill-local` writes into the project, `--skill-force` overwrites. `install_skill(..., home=...)` takes an **injectable `home`** (defaults to `Path.home()`) so tests never touch the real `~`. Idempotent (skip-if-exists unless force). It **refuses to write through a symlink** at the target (`is_symlink()` check before write, catches broken links too) so a dotfiles-managed `~/.claude/skills/` isn't clobbered (#33) — the CLI turns that `ValueError` into a clean exit-2.
|