livedocs 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.
Files changed (36) hide show
  1. livedocs-0.1.0/.gitignore +27 -0
  2. livedocs-0.1.0/.pre-commit-hooks.yaml +9 -0
  3. livedocs-0.1.0/LICENSE +21 -0
  4. livedocs-0.1.0/PKG-INFO +163 -0
  5. livedocs-0.1.0/README.md +146 -0
  6. livedocs-0.1.0/pyproject.toml +41 -0
  7. livedocs-0.1.0/src/livedocs/__init__.py +3 -0
  8. livedocs-0.1.0/src/livedocs/affected.py +69 -0
  9. livedocs-0.1.0/src/livedocs/astdiff.py +186 -0
  10. livedocs-0.1.0/src/livedocs/check.py +234 -0
  11. livedocs-0.1.0/src/livedocs/cli.py +278 -0
  12. livedocs-0.1.0/src/livedocs/config.py +38 -0
  13. livedocs-0.1.0/src/livedocs/coverage.py +72 -0
  14. livedocs-0.1.0/src/livedocs/drift_io.py +71 -0
  15. livedocs-0.1.0/src/livedocs/gitx.py +53 -0
  16. livedocs-0.1.0/src/livedocs/grade.py +276 -0
  17. livedocs-0.1.0/src/livedocs/hooks/__init__.py +69 -0
  18. livedocs-0.1.0/src/livedocs/hooks/bypass_log.py +36 -0
  19. livedocs-0.1.0/src/livedocs/hooks/pre_commit.py +139 -0
  20. livedocs-0.1.0/src/livedocs/hooks/read_gate.py +36 -0
  21. livedocs-0.1.0/src/livedocs/hooks/stop_heads_up.py +38 -0
  22. livedocs-0.1.0/src/livedocs/install.py +130 -0
  23. livedocs-0.1.0/src/livedocs/mentions.py +107 -0
  24. livedocs-0.1.0/src/livedocs/render.py +55 -0
  25. livedocs-0.1.0/src/livedocs/replay.py +235 -0
  26. livedocs-0.1.0/src/livedocs/scaffold.py +189 -0
  27. livedocs-0.1.0/src/livedocs/stamp.py +134 -0
  28. livedocs-0.1.0/src/livedocs/stamps.py +114 -0
  29. livedocs-0.1.0/src/livedocs/symbols.py +296 -0
  30. livedocs-0.1.0/tests/conftest.py +70 -0
  31. livedocs-0.1.0/tests/test_gitx.py +30 -0
  32. livedocs-0.1.0/tests/test_members_moves.py +48 -0
  33. livedocs-0.1.0/tests/test_mentions.py +42 -0
  34. livedocs-0.1.0/tests/test_stamp_check.py +88 -0
  35. livedocs-0.1.0/tests/test_stamps_astdiff.py +69 -0
  36. livedocs-0.1.0/tests/test_symbols.py +78 -0
@@ -0,0 +1,27 @@
1
+ # macOS
2
+ .DS_Store
3
+
4
+ # Obsidian: per-machine UI state (shared vault config in .obsidian/ is versioned)
5
+ live-docs-vault/.obsidian/workspace.json
6
+ live-docs-vault/.obsidian/workspace-mobile.json
7
+ live-docs-vault/.trash/
8
+
9
+ # Claude Code: local session state
10
+ .claude/scheduled_tasks.lock
11
+ .claude/settings.local.json
12
+
13
+ # Phase 1 testbench: detached clone of a real project, never committed here
14
+ testbench/
15
+
16
+ # Python
17
+ .venv/
18
+ __pycache__/
19
+ *.egg-info/
20
+ .pytest_cache/
21
+
22
+ # 1a run artefacts: keep the compressed copies, regenerate the rest
23
+ results/*/rows.jsonl
24
+ results/*/items.jsonl
25
+ results/*/batches/
26
+ results/*.log
27
+ dist/
@@ -0,0 +1,9 @@
1
+ # For repositories using the pre-commit framework (https://pre-commit.com).
2
+ # Requires `livedocs` and `drift` on PATH (see README). The hook itself is the same gate as
3
+ # `livedocs init` installs through .githooks.
4
+ - id: livedocs-gate
5
+ name: livedocs gate (notes reconciled with the code they mention)
6
+ entry: livedocs hook pre-commit
7
+ language: system
8
+ pass_filenames: false
9
+ always_run: true
livedocs-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gus Ellerm
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,163 @@
1
+ Metadata-Version: 2.5
2
+ Name: livedocs
3
+ Version: 0.1.0
4
+ Summary: Notes that know when the code moved on: hash-anchored live docs with a git commit gate, over Fiberplane drift.
5
+ Project-URL: Repository, https://github.com/GusEllerm/vault-drift
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Keywords: agents,documentation,drift,git-hooks,obsidian
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Topic :: Documentation
14
+ Classifier: Topic :: Software Development :: Version Control :: Git
15
+ Requires-Python: >=3.12
16
+ Description-Content-Type: text/markdown
17
+
18
+ # livedocs
19
+
20
+ Notes that know when the code moved on.
21
+
22
+ `livedocs` binds Markdown notes (an Obsidian vault, or any folder of `.md` files inside a repo) to the
23
+ code they mention, and makes the git commit the place where notes and code are reconciled. It is a
24
+ thin layer over [Fiberplane drift](https://github.com/fiberplane/drift) (which fingerprints symbols)
25
+ that adds what an agent-written vault needs:
26
+
27
+ - **Anchors derived from the note's own backtick mentions** — nothing to declare by hand.
28
+ - **A note hash and member-level hashes in append-only stamps**, so "this note was verified against
29
+ this code" is a recorded fact, and only changes to what the note actually mentions count.
30
+ - **A git pre-commit gate**: a commit that changes code a note mentions is blocked until the note is
31
+ updated or acked with a reason. Provably benign changes (comment-only edits, pure moves, changes to
32
+ members the note doesn't mention) are acked mechanically. `unknown` never blocks.
33
+ - **A read-time report** for agents: `CHANGED` with *was / now* pinned to the note lines that mention
34
+ the symbol — a fact, not a verdict. The agent that made the change decides what the note should say.
35
+ - **Coverage**: which of a note's code claims are anchored, and which names it uses that no longer exist.
36
+
37
+ The guarantee is a mechanism of the development framework (git), not a capability of any agent:
38
+ it holds for Claude Code, Codex, a human at a terminal, or anything else that commits.
39
+
40
+ ## New project? One command
41
+
42
+ ```sh
43
+ livedocs new-vault docs/vault --scaffold-modules
44
+ ```
45
+
46
+ creates an Obsidian vault (`Home.md`, `Modules/ Concepts/ Reference/ Sessions/ Templates/`, a minimal
47
+ `.obsidian/`), one empty note per source module, the livedocs config (sessions, reviews and templates
48
+ are snapshots), the git gate, the Claude Code adapter, and an `AGENTS.md` block — then commit. Open the
49
+ folder in Obsidian as a vault. From there: write notes, commit, reconcile when the gate says so.
50
+
51
+ ## Install
52
+
53
+ ```sh
54
+ brew install fiberplane/tap/drift # or: curl -fsSL https://drift.fp.dev/install.sh | sh
55
+ ```
56
+
57
+ Then `livedocs` itself (stdlib only, no Python dependencies). While this repository is private, install
58
+ from a checkout or the built wheel; `git+https://…` needs GitHub credentials (`gh auth setup-git` makes
59
+ git use your `gh` login):
60
+
61
+ ```sh
62
+ uv tool install /path/to/vault-drift # from a local checkout (add --editable to hack on it)
63
+ uv tool install /path/to/livedocs-0.1.0-py3-none-any.whl # from `uv build` output
64
+ uv tool install git+https://github.com/GusEllerm/vault-drift # with GitHub access
65
+ ```
66
+
67
+ Then, in the repository that holds the vault:
68
+
69
+ ```sh
70
+ livedocs init --vault docs/vault # git gate (.githooks + core.hooksPath), .livedocs/config.json,
71
+ # Claude Code Stop heads-up (settings.local.json), AGENTS.md block
72
+ ```
73
+
74
+ That is the whole setup. Notes are stamped by the commit that adds or edits them: write a note, commit
75
+ it, and the gate binds its code mentions and records the stamp in the same commit. For an existing vault,
76
+ `git add` the notes and commit once (or run `livedocs stamp <note>` per note to see what each binds).
77
+ Each clone activates the versioned hook with `git config core.hooksPath .githooks` (`init` does it).
78
+ `--repo` defaults to the current git repository and `--vault` to the configured one, so inside the repo
79
+ the commands need no arguments.
80
+
81
+ ## The commit flow
82
+
83
+ ```
84
+ edit code ──► git commit
85
+ │ gate: notes that mention the changed code, not yet reconciled?
86
+ ├─ none / benign ────────────────────────────────► commit passes (benign ones auto-acked;
87
+ │ new or edited notes stamped)
88
+ └─ some ──► "CHANGED: <note> … was: … now: … note lines 13, 30"
89
+ edit the note and commit again (the commit stamps it)
90
+ or, still correct: livedocs stamp <note> --ack --reason "…"; git add -A; commit
91
+ ```
92
+
93
+ `livedocs affected` lists the notes your uncommitted changes touch before you get there.
94
+
95
+ ## Commands
96
+
97
+ | Command | Does |
98
+ |---|---|
99
+ | `livedocs new-vault <path> [--scaffold-modules]` | scaffold an Obsidian vault and wire everything in |
100
+ | `livedocs init --vault <path> [--read-gate] [--shared]` | install the gate, config and harness adapter for an existing vault |
101
+ | `livedocs stamp <note> [--ack --reason R]` | bind a note's mentions with drift and record a stamp |
102
+ | `livedocs check <note> [--json]` | one note's state: `fresh`, `changed`, `broken`, `unknown`, `snapshot` |
103
+ | `livedocs affected [--cached]` | notes bound to files changed in the working tree (or the index) |
104
+ | `livedocs verify` | CI: every stamped note against the checked-out tree; exit 1 on changed/broken |
105
+ | `livedocs coverage` | per-note anchored / dangling mentions |
106
+ | `livedocs survey` | resolution statistics over a vault (diagnostics) |
107
+
108
+ ## Snapshot notes
109
+
110
+ Dated records — reviews, session logs, reports — describe the code as it *was*. Mark them with
111
+ `livedocs: snapshot` in the frontmatter, or list globs in `.livedocs/config.json`:
112
+
113
+ ```json
114
+ { "vault": "docs/vault", "snapshot_globs": ["Reference/Review *", "Sessions/*"] }
115
+ ```
116
+
117
+ They bind nothing, report `snapshot`, and never block.
118
+
119
+ ## CI
120
+
121
+ Pre-commit is skipped by `--no-verify`, merges and rebases. Run the same check in CI:
122
+
123
+ ```yaml
124
+ # .github/workflows/livedocs.yml
125
+ name: livedocs
126
+ on: [push, pull_request]
127
+ jobs:
128
+ verify:
129
+ runs-on: ubuntu-latest
130
+ steps:
131
+ - uses: actions/checkout@v4
132
+ - run: curl -fsSL https://drift.fp.dev/install.sh | sh && echo "$HOME/.local/bin" >> "$GITHUB_PATH"
133
+ - uses: astral-sh/setup-uv@v5
134
+ - run: uv tool install git+https://github.com/GusEllerm/vault-drift
135
+ - run: livedocs verify --repo . --vault docs/vault
136
+ ```
137
+
138
+ Or with the [pre-commit](https://pre-commit.com) framework, add to `.pre-commit-config.yaml`:
139
+
140
+ ```yaml
141
+ - repo: https://github.com/GusEllerm/vault-drift
142
+ rev: v0.1.0
143
+ hooks: [{ id: livedocs-gate }]
144
+ ```
145
+
146
+ ## What it can and cannot guarantee
147
+
148
+ - It guarantees that a *committed* note has been vouched for against the code it mentions, at that
149
+ commit. A read-time `changed` means the working tree has moved since.
150
+ - It sees code the note names in backticks. A prose claim with nothing to anchor ("tokens rotate every
151
+ 15 minutes") is reported `unknown`, never `fresh`. `livedocs coverage` shows how much of each note that is.
152
+ - It does not follow the call graph: a note's claim about what a function *does* can go stale when a
153
+ callee changes. That is the residual miss class, measured at about 1 in 20 real changes on the
154
+ reference history.
155
+ - Python only, for now (symbol resolution uses `ast`; drift fingerprints six languages).
156
+
157
+ ## Background
158
+
159
+ Design, decisions and the measurements behind every choice live in the vault at `live-docs-vault/`
160
+ (start at `Start Here.md`). The short version: on a real 141-commit history, the hash flagged 22 of the
161
+ 23 note-invalidating changes an anchor covered; only ~5% of flags marked a change that made a note wrong
162
+ — which is why the signal is delivered as a fact at commit time, to the agent that has the context, and
163
+ never as a verdict.
@@ -0,0 +1,146 @@
1
+ # livedocs
2
+
3
+ Notes that know when the code moved on.
4
+
5
+ `livedocs` binds Markdown notes (an Obsidian vault, or any folder of `.md` files inside a repo) to the
6
+ code they mention, and makes the git commit the place where notes and code are reconciled. It is a
7
+ thin layer over [Fiberplane drift](https://github.com/fiberplane/drift) (which fingerprints symbols)
8
+ that adds what an agent-written vault needs:
9
+
10
+ - **Anchors derived from the note's own backtick mentions** — nothing to declare by hand.
11
+ - **A note hash and member-level hashes in append-only stamps**, so "this note was verified against
12
+ this code" is a recorded fact, and only changes to what the note actually mentions count.
13
+ - **A git pre-commit gate**: a commit that changes code a note mentions is blocked until the note is
14
+ updated or acked with a reason. Provably benign changes (comment-only edits, pure moves, changes to
15
+ members the note doesn't mention) are acked mechanically. `unknown` never blocks.
16
+ - **A read-time report** for agents: `CHANGED` with *was / now* pinned to the note lines that mention
17
+ the symbol — a fact, not a verdict. The agent that made the change decides what the note should say.
18
+ - **Coverage**: which of a note's code claims are anchored, and which names it uses that no longer exist.
19
+
20
+ The guarantee is a mechanism of the development framework (git), not a capability of any agent:
21
+ it holds for Claude Code, Codex, a human at a terminal, or anything else that commits.
22
+
23
+ ## New project? One command
24
+
25
+ ```sh
26
+ livedocs new-vault docs/vault --scaffold-modules
27
+ ```
28
+
29
+ creates an Obsidian vault (`Home.md`, `Modules/ Concepts/ Reference/ Sessions/ Templates/`, a minimal
30
+ `.obsidian/`), one empty note per source module, the livedocs config (sessions, reviews and templates
31
+ are snapshots), the git gate, the Claude Code adapter, and an `AGENTS.md` block — then commit. Open the
32
+ folder in Obsidian as a vault. From there: write notes, commit, reconcile when the gate says so.
33
+
34
+ ## Install
35
+
36
+ ```sh
37
+ brew install fiberplane/tap/drift # or: curl -fsSL https://drift.fp.dev/install.sh | sh
38
+ ```
39
+
40
+ Then `livedocs` itself (stdlib only, no Python dependencies). While this repository is private, install
41
+ from a checkout or the built wheel; `git+https://…` needs GitHub credentials (`gh auth setup-git` makes
42
+ git use your `gh` login):
43
+
44
+ ```sh
45
+ uv tool install /path/to/vault-drift # from a local checkout (add --editable to hack on it)
46
+ uv tool install /path/to/livedocs-0.1.0-py3-none-any.whl # from `uv build` output
47
+ uv tool install git+https://github.com/GusEllerm/vault-drift # with GitHub access
48
+ ```
49
+
50
+ Then, in the repository that holds the vault:
51
+
52
+ ```sh
53
+ livedocs init --vault docs/vault # git gate (.githooks + core.hooksPath), .livedocs/config.json,
54
+ # Claude Code Stop heads-up (settings.local.json), AGENTS.md block
55
+ ```
56
+
57
+ That is the whole setup. Notes are stamped by the commit that adds or edits them: write a note, commit
58
+ it, and the gate binds its code mentions and records the stamp in the same commit. For an existing vault,
59
+ `git add` the notes and commit once (or run `livedocs stamp <note>` per note to see what each binds).
60
+ Each clone activates the versioned hook with `git config core.hooksPath .githooks` (`init` does it).
61
+ `--repo` defaults to the current git repository and `--vault` to the configured one, so inside the repo
62
+ the commands need no arguments.
63
+
64
+ ## The commit flow
65
+
66
+ ```
67
+ edit code ──► git commit
68
+ │ gate: notes that mention the changed code, not yet reconciled?
69
+ ├─ none / benign ────────────────────────────────► commit passes (benign ones auto-acked;
70
+ │ new or edited notes stamped)
71
+ └─ some ──► "CHANGED: <note> … was: … now: … note lines 13, 30"
72
+ edit the note and commit again (the commit stamps it)
73
+ or, still correct: livedocs stamp <note> --ack --reason "…"; git add -A; commit
74
+ ```
75
+
76
+ `livedocs affected` lists the notes your uncommitted changes touch before you get there.
77
+
78
+ ## Commands
79
+
80
+ | Command | Does |
81
+ |---|---|
82
+ | `livedocs new-vault <path> [--scaffold-modules]` | scaffold an Obsidian vault and wire everything in |
83
+ | `livedocs init --vault <path> [--read-gate] [--shared]` | install the gate, config and harness adapter for an existing vault |
84
+ | `livedocs stamp <note> [--ack --reason R]` | bind a note's mentions with drift and record a stamp |
85
+ | `livedocs check <note> [--json]` | one note's state: `fresh`, `changed`, `broken`, `unknown`, `snapshot` |
86
+ | `livedocs affected [--cached]` | notes bound to files changed in the working tree (or the index) |
87
+ | `livedocs verify` | CI: every stamped note against the checked-out tree; exit 1 on changed/broken |
88
+ | `livedocs coverage` | per-note anchored / dangling mentions |
89
+ | `livedocs survey` | resolution statistics over a vault (diagnostics) |
90
+
91
+ ## Snapshot notes
92
+
93
+ Dated records — reviews, session logs, reports — describe the code as it *was*. Mark them with
94
+ `livedocs: snapshot` in the frontmatter, or list globs in `.livedocs/config.json`:
95
+
96
+ ```json
97
+ { "vault": "docs/vault", "snapshot_globs": ["Reference/Review *", "Sessions/*"] }
98
+ ```
99
+
100
+ They bind nothing, report `snapshot`, and never block.
101
+
102
+ ## CI
103
+
104
+ Pre-commit is skipped by `--no-verify`, merges and rebases. Run the same check in CI:
105
+
106
+ ```yaml
107
+ # .github/workflows/livedocs.yml
108
+ name: livedocs
109
+ on: [push, pull_request]
110
+ jobs:
111
+ verify:
112
+ runs-on: ubuntu-latest
113
+ steps:
114
+ - uses: actions/checkout@v4
115
+ - run: curl -fsSL https://drift.fp.dev/install.sh | sh && echo "$HOME/.local/bin" >> "$GITHUB_PATH"
116
+ - uses: astral-sh/setup-uv@v5
117
+ - run: uv tool install git+https://github.com/GusEllerm/vault-drift
118
+ - run: livedocs verify --repo . --vault docs/vault
119
+ ```
120
+
121
+ Or with the [pre-commit](https://pre-commit.com) framework, add to `.pre-commit-config.yaml`:
122
+
123
+ ```yaml
124
+ - repo: https://github.com/GusEllerm/vault-drift
125
+ rev: v0.1.0
126
+ hooks: [{ id: livedocs-gate }]
127
+ ```
128
+
129
+ ## What it can and cannot guarantee
130
+
131
+ - It guarantees that a *committed* note has been vouched for against the code it mentions, at that
132
+ commit. A read-time `changed` means the working tree has moved since.
133
+ - It sees code the note names in backticks. A prose claim with nothing to anchor ("tokens rotate every
134
+ 15 minutes") is reported `unknown`, never `fresh`. `livedocs coverage` shows how much of each note that is.
135
+ - It does not follow the call graph: a note's claim about what a function *does* can go stale when a
136
+ callee changes. That is the residual miss class, measured at about 1 in 20 real changes on the
137
+ reference history.
138
+ - Python only, for now (symbol resolution uses `ast`; drift fingerprints six languages).
139
+
140
+ ## Background
141
+
142
+ Design, decisions and the measurements behind every choice live in the vault at `live-docs-vault/`
143
+ (start at `Start Here.md`). The short version: on a real 141-commit history, the hash flagged 22 of the
144
+ 23 note-invalidating changes an anchor covered; only ~5% of flags marked a change that made a note wrong
145
+ — which is why the signal is delivered as a fact at commit time, to the agent that has the context, and
146
+ never as a verdict.
@@ -0,0 +1,41 @@
1
+ [project]
2
+ name = "livedocs"
3
+ version = "0.1.0"
4
+ description = "Notes that know when the code moved on: hash-anchored live docs with a git commit gate, over Fiberplane drift."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.12"
9
+ dependencies = []
10
+ keywords = ["documentation", "drift", "agents", "obsidian", "git-hooks"]
11
+ classifiers = [
12
+ "Development Status :: 3 - Alpha",
13
+ "Environment :: Console",
14
+ "Intended Audience :: Developers",
15
+ "Programming Language :: Python :: 3 :: Only",
16
+ "Topic :: Documentation",
17
+ "Topic :: Software Development :: Version Control :: Git",
18
+ ]
19
+
20
+ [project.urls]
21
+ Repository = "https://github.com/GusEllerm/vault-drift"
22
+
23
+ [project.scripts]
24
+ livedocs = "livedocs.cli:main"
25
+
26
+ [dependency-groups]
27
+ dev = ["pytest>=8"]
28
+
29
+ [build-system]
30
+ requires = ["hatchling"]
31
+ build-backend = "hatchling.build"
32
+
33
+ [tool.hatch.build.targets.wheel]
34
+ packages = ["src/livedocs"]
35
+
36
+ [tool.hatch.build.targets.sdist]
37
+ # The package is the tool; the vault, results and testbench stay in the repository.
38
+ only-include = ["src", "tests", "README.md", "LICENSE", "pyproject.toml", ".pre-commit-hooks.yaml"]
39
+
40
+ [tool.pytest.ini_options]
41
+ testpaths = ["tests"]
@@ -0,0 +1,3 @@
1
+ """livedocs: a thin wrapper over drift that binds notes to code and tells agents when a note is out of date."""
2
+
3
+ __version__ = "0.0.1"
@@ -0,0 +1,69 @@
1
+ """Which notes does a code change touch? Changed files → bindings in drift.lock → notes → check."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from . import check as ck, drift_io, gitx, stamps as st
8
+
9
+
10
+ def affected_notes(repo: str | Path, vault_rel: str, changed: list[str]) -> list[str]:
11
+ """Notes (vault-relative) with a binding whose path is in `changed`."""
12
+ repo = Path(repo)
13
+ changed_set = set(changed)
14
+ notes: set[str] = set()
15
+ prefix = f"{vault_rel}/" if vault_rel else ""
16
+ for (doc, target), _sig in drift_io.lock(repo).items():
17
+ if target.split("#", 1)[0] in changed_set and doc.startswith(prefix):
18
+ notes.add(doc[len(prefix):])
19
+ return sorted(notes)
20
+
21
+
22
+ def affected(repo: str | Path, vault_rel: str, *, cached: bool = False, base: str = "HEAD",
23
+ changed: list[str] | None = None, snapshot: str | Path | None = None,
24
+ refine: bool = False) -> list[ck.Report]:
25
+ """Reports for every note bound to a changed file. `snapshot` runs the checks against a
26
+ checkout-index copy (the pre-commit gate) while git operations use `repo`."""
27
+ repo = Path(repo)
28
+ if changed is None:
29
+ changed = gitx.changed_files(repo, cached=cached, base=base)
30
+ files_root = Path(snapshot) if snapshot else repo
31
+ notes = affected_notes(files_root, vault_rel, changed)
32
+ if not notes:
33
+ return []
34
+ try:
35
+ dj = drift_io.check_json(files_root)
36
+ except drift_io.DriftError as e:
37
+ return [ck.Report(n, ck.UNKNOWN, [f"checker-error: {e}"]) for n in notes]
38
+ all_stamps = st.load(files_root / vault_rel)
39
+ return [ck.check(files_root, vault_rel, n, drift_json=dj, all_stamps=all_stamps, refine=refine, git_repo=repo) for n in notes]
40
+
41
+
42
+ def verify(repo: str | Path, vault_rel: str, *, refine: bool = False) -> list[ck.Report]:
43
+ """CI check: every stamped, non-snapshot note against the tree as checked out. Catches commits
44
+ made with --no-verify, merges and rebases, which pre-commit never sees."""
45
+ repo = Path(repo)
46
+ vault = repo / vault_rel
47
+ all_stamps = st.load(vault)
48
+ notes = sorted({s.note for s in all_stamps})
49
+ if not notes:
50
+ return []
51
+ try:
52
+ dj = drift_io.check_json(repo)
53
+ except drift_io.DriftError as e:
54
+ return [ck.Report(n, ck.UNKNOWN, [f"checker-error: {e}"]) for n in notes]
55
+ return [ck.check(repo, vault_rel, n, drift_json=dj, all_stamps=all_stamps, refine=refine) for n in notes]
56
+
57
+
58
+ def summary_lines(reports: list[ck.Report]) -> list[str]:
59
+ out = []
60
+ for r in reports:
61
+ if r.state in (ck.FRESH, ck.SNAPSHOT):
62
+ continue
63
+ if ck.mechanically_benign(r):
64
+ extra = " (benign: the members it mentions are unchanged — will be auto-acked at commit)"
65
+ else:
66
+ kinds = sorted({f.kind for f in r.findings} - {"unchanged"})
67
+ extra = f" ({', '.join(kinds)})" if kinds else (f" ({'; '.join(r.reasons)})" if r.reasons else "")
68
+ out.append(f"{r.state.upper():8} {r.note}{extra}")
69
+ return out
@@ -0,0 +1,186 @@
1
+ """Symbol source at two versions of a file, and what kind of change happened.
2
+
3
+ Kinds: signature | decorator | body | comment-or-docstring-only | unchanged | removed | added
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import ast
9
+ import hashlib
10
+ from dataclasses import dataclass
11
+
12
+ SIGNATURE, DECORATOR, BODY, COMMENT_ONLY, UNCHANGED, REMOVED, ADDED, UNPARSEABLE = (
13
+ "signature", "decorator", "body", "comment-or-docstring-only", "unchanged", "removed", "added", "unparseable",
14
+ )
15
+
16
+
17
+ def parses(source: str | None) -> bool:
18
+ if source is None:
19
+ return False
20
+ try:
21
+ ast.parse(source)
22
+ return True
23
+ except SyntaxError:
24
+ return False
25
+
26
+
27
+ def _find(tree: ast.Module, qualname: str) -> ast.AST | None:
28
+ parts = qualname.split(".")
29
+ scope: list[ast.stmt] = tree.body
30
+ node: ast.AST | None = None
31
+ for i, part in enumerate(parts):
32
+ node = None
33
+ for n in scope:
34
+ if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) and n.name == part:
35
+ node = n
36
+ break
37
+ if isinstance(n, ast.Assign) and any(isinstance(t, ast.Name) and t.id == part for t in n.targets):
38
+ node = n
39
+ break
40
+ if isinstance(n, ast.AnnAssign) and isinstance(n.target, ast.Name) and n.target.id == part:
41
+ node = n
42
+ break
43
+ if node is None:
44
+ return None
45
+ if i < len(parts) - 1:
46
+ if not isinstance(node, ast.ClassDef):
47
+ return None
48
+ scope = node.body
49
+ return node
50
+
51
+
52
+ def symbol_source(source: str, qualname: str) -> str | None:
53
+ try:
54
+ tree = ast.parse(source)
55
+ except SyntaxError:
56
+ return None
57
+ node = _find(tree, qualname)
58
+ if node is None:
59
+ return None
60
+ seg = ast.get_source_segment(source, node, padded=True)
61
+ if seg is None:
62
+ return None
63
+ decos = getattr(node, "decorator_list", ())
64
+ if decos: # get_source_segment starts at `def`; include decorator lines
65
+ lines = source.splitlines()
66
+ start = min(d.lineno for d in decos) - 1
67
+ end = (node.end_lineno or node.lineno)
68
+ seg = "\n".join(lines[start:end])
69
+ return seg
70
+
71
+
72
+ def decorator_hash(source: str, top_level_name: str) -> str:
73
+ """Hash of a top-level symbol's decorators (empty string if none or unparseable)."""
74
+ try:
75
+ tree = ast.parse(source)
76
+ except SyntaxError:
77
+ return ""
78
+ node = _find(tree, top_level_name)
79
+ decos = getattr(node, "decorator_list", ()) if node is not None else ()
80
+ if not decos:
81
+ return ""
82
+ return hashlib.sha256("\n".join(ast.unparse(d) for d in decos).encode()).hexdigest()[:16]
83
+
84
+
85
+ def _strip_docstrings(node: ast.AST) -> ast.AST:
86
+ for n in ast.walk(node):
87
+ body = getattr(n, "body", None)
88
+ if isinstance(body, list) and body and isinstance(body[0], ast.Expr) and isinstance(getattr(body[0], "value", None), ast.Constant) and isinstance(body[0].value.value, str):
89
+ n.body = body[1:] or [ast.Pass()]
90
+ return node
91
+
92
+
93
+ def _dump(node: ast.AST) -> str:
94
+ return ast.dump(node, include_attributes=False)
95
+
96
+
97
+ def _sig_dump(node: ast.AST) -> str:
98
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
99
+ # sync→async is a signature change: callers must await it
100
+ return type(node).__name__ + node.name + _dump(node.args) + (_dump(node.returns) if node.returns else "")
101
+ if isinstance(node, ast.ClassDef):
102
+ return node.name + "".join(_dump(b) for b in node.bases) + "".join(_dump(k) for k in node.keywords)
103
+ return ""
104
+
105
+
106
+ @dataclass(frozen=True)
107
+ class Change:
108
+ kind: str
109
+ was: str | None
110
+ now: str | None
111
+
112
+
113
+ def classify(old_source: str | None, new_source: str | None, qualname: str) -> Change:
114
+ if new_source is not None and not parses(new_source):
115
+ return Change(UNPARSEABLE, symbol_source(old_source, qualname) if old_source else None, None)
116
+ old = symbol_source(old_source, qualname) if old_source is not None else None
117
+ new = symbol_source(new_source, qualname) if new_source is not None else None
118
+ if old is None and new is None:
119
+ return Change(REMOVED, None, None)
120
+ if old is None:
121
+ return Change(ADDED, None, new)
122
+ if new is None:
123
+ return Change(REMOVED, old, None)
124
+ if old == new:
125
+ return Change(UNCHANGED, old, new)
126
+ on, nn = _find(ast.parse(old_source), qualname), _find(ast.parse(new_source), qualname)
127
+ if _sig_dump(on) != _sig_dump(nn):
128
+ return Change(SIGNATURE, old, new)
129
+ if [ast.unparse(d) for d in getattr(on, "decorator_list", ())] != [ast.unparse(d) for d in getattr(nn, "decorator_list", ())]:
130
+ return Change(DECORATOR, old, new)
131
+ if _dump(_strip_docstrings(on)) == _dump(_strip_docstrings(nn)):
132
+ return Change(COMMENT_ONLY, old, new)
133
+ return Change(BODY, old, new)
134
+
135
+
136
+ def member_hash(source: str | None, qualname: str) -> str | None:
137
+ """Content hash of what a note mentions, insensitive to comments, docstrings and formatting.
138
+
139
+ function/method: the whole node (args, returns, decorators, body) with docstrings stripped.
140
+ class: its shell — bases, keywords, decorators, member names, and field/attribute definitions —
141
+ but not method bodies (a note that mentions the class, not the method, doesn't depend on them).
142
+ attribute/constant: the assignment node.
143
+ None if the source doesn't parse or the symbol isn't there.
144
+ """
145
+ if source is None:
146
+ return None
147
+ try:
148
+ tree = ast.parse(source)
149
+ except SyntaxError:
150
+ return None
151
+ node = _find(tree, qualname)
152
+ if node is None:
153
+ return None
154
+ import copy
155
+ node = copy.deepcopy(node)
156
+ if isinstance(node, ast.ClassDef):
157
+ parts = [
158
+ "class", node.name,
159
+ *(ast.unparse(b) for b in node.bases), *(ast.unparse(k) for k in node.keywords),
160
+ *(ast.unparse(d) for d in node.decorator_list),
161
+ ]
162
+ for n in node.body:
163
+ if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)):
164
+ parts.append(f"def {n.name}")
165
+ elif isinstance(n, ast.ClassDef):
166
+ parts.append(f"class {n.name}")
167
+ elif isinstance(n, (ast.Assign, ast.AnnAssign)):
168
+ parts.append(_dump(n))
169
+ payload = "\n".join(parts)
170
+ else:
171
+ payload = _dump(_strip_docstrings(node))
172
+ decos = getattr(node, "decorator_list", ())
173
+ if decos:
174
+ payload += "\n" + "\n".join(ast.unparse(d) for d in decos)
175
+ return hashlib.sha256(payload.encode()).hexdigest()[:16]
176
+
177
+
178
+ def first_line(src: str | None) -> str:
179
+ """The def/class line (after decorators), for compact was/now output."""
180
+ if not src:
181
+ return ""
182
+ for l in src.splitlines():
183
+ s = l.strip()
184
+ if s and not s.startswith("@"):
185
+ return s
186
+ return src.splitlines()[0].strip()