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.
- livedocs-0.1.0/.gitignore +27 -0
- livedocs-0.1.0/.pre-commit-hooks.yaml +9 -0
- livedocs-0.1.0/LICENSE +21 -0
- livedocs-0.1.0/PKG-INFO +163 -0
- livedocs-0.1.0/README.md +146 -0
- livedocs-0.1.0/pyproject.toml +41 -0
- livedocs-0.1.0/src/livedocs/__init__.py +3 -0
- livedocs-0.1.0/src/livedocs/affected.py +69 -0
- livedocs-0.1.0/src/livedocs/astdiff.py +186 -0
- livedocs-0.1.0/src/livedocs/check.py +234 -0
- livedocs-0.1.0/src/livedocs/cli.py +278 -0
- livedocs-0.1.0/src/livedocs/config.py +38 -0
- livedocs-0.1.0/src/livedocs/coverage.py +72 -0
- livedocs-0.1.0/src/livedocs/drift_io.py +71 -0
- livedocs-0.1.0/src/livedocs/gitx.py +53 -0
- livedocs-0.1.0/src/livedocs/grade.py +276 -0
- livedocs-0.1.0/src/livedocs/hooks/__init__.py +69 -0
- livedocs-0.1.0/src/livedocs/hooks/bypass_log.py +36 -0
- livedocs-0.1.0/src/livedocs/hooks/pre_commit.py +139 -0
- livedocs-0.1.0/src/livedocs/hooks/read_gate.py +36 -0
- livedocs-0.1.0/src/livedocs/hooks/stop_heads_up.py +38 -0
- livedocs-0.1.0/src/livedocs/install.py +130 -0
- livedocs-0.1.0/src/livedocs/mentions.py +107 -0
- livedocs-0.1.0/src/livedocs/render.py +55 -0
- livedocs-0.1.0/src/livedocs/replay.py +235 -0
- livedocs-0.1.0/src/livedocs/scaffold.py +189 -0
- livedocs-0.1.0/src/livedocs/stamp.py +134 -0
- livedocs-0.1.0/src/livedocs/stamps.py +114 -0
- livedocs-0.1.0/src/livedocs/symbols.py +296 -0
- livedocs-0.1.0/tests/conftest.py +70 -0
- livedocs-0.1.0/tests/test_gitx.py +30 -0
- livedocs-0.1.0/tests/test_members_moves.py +48 -0
- livedocs-0.1.0/tests/test_mentions.py +42 -0
- livedocs-0.1.0/tests/test_stamp_check.py +88 -0
- livedocs-0.1.0/tests/test_stamps_astdiff.py +69 -0
- 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.
|
livedocs-0.1.0/PKG-INFO
ADDED
|
@@ -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.
|
livedocs-0.1.0/README.md
ADDED
|
@@ -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,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()
|