constant-docs 0.4.0__py3-none-any.whl
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.
- constant_docs/__init__.py +18 -0
- constant_docs/__main__.py +15 -0
- constant_docs/api.py +968 -0
- constant_docs/auto.py +239 -0
- constant_docs/checks.py +494 -0
- constant_docs/cli.py +1085 -0
- constant_docs/config.py +1158 -0
- constant_docs/coverage.py +165 -0
- constant_docs/decisions.py +303 -0
- constant_docs/document.py +438 -0
- constant_docs/fingerprint.py +27 -0
- constant_docs/globs.py +154 -0
- constant_docs/guides/quickstart.md +124 -0
- constant_docs/guides/readme.md +198 -0
- constant_docs/house-style.md +70 -0
- constant_docs/index.py +210 -0
- constant_docs/kinds.py +304 -0
- constant_docs/paths.py +246 -0
- constant_docs/prompts/architecture.md +61 -0
- constant_docs/prompts/cli-reference.md +40 -0
- constant_docs/prompts/config-reference.md +38 -0
- constant_docs/prompts/errors.md +50 -0
- constant_docs/prompts/log.md +22 -0
- constant_docs/prompts/module.md +39 -0
- constant_docs/prompts/spec.md +68 -0
- constant_docs/state.py +273 -0
- constant_docs-0.4.0.dist-info/METADATA +380 -0
- constant_docs-0.4.0.dist-info/RECORD +31 -0
- constant_docs-0.4.0.dist-info/WHEEL +4 -0
- constant_docs-0.4.0.dist-info/entry_points.txt +3 -0
- constant_docs-0.4.0.dist-info/licenses/LICENSE +15 -0
constant_docs/auto.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Unattended regeneration, without putting a model in the package.
|
|
2
|
+
|
|
3
|
+
The hooks this tool ships need a live agent session. The case `auto` is bought
|
|
4
|
+
for is a fleet where changes arrive from a chat client, a timer, and a person
|
|
5
|
+
editing files directly — three write paths, none of which fires a hook. A tool
|
|
6
|
+
whose automation only works inside one harness is not automated for that.
|
|
7
|
+
|
|
8
|
+
So `auto` runs a **command declared in configuration**, and then checks what it
|
|
9
|
+
left behind:
|
|
10
|
+
|
|
11
|
+
```yaml
|
|
12
|
+
auto:
|
|
13
|
+
command: "<your agent> -p 'Run the constant-docs settle loop'"
|
|
14
|
+
when: stale
|
|
15
|
+
budget: 5
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Three properties follow, and each is why this shape rather than a built-in
|
|
19
|
+
client:
|
|
20
|
+
|
|
21
|
+
- **No credential in the tool, and none needed in CI.** The property that makes
|
|
22
|
+
`verify` safe to run anywhere survives untouched
|
|
23
|
+
- **A new harness is a configuration line.** The command is split with `shlex`
|
|
24
|
+
and run directly, so an agent CLI, a script and a Makefile target all work
|
|
25
|
+
- **The command's success is not taken on trust.** `auto` re-runs `verify`
|
|
26
|
+
afterwards and fails if the documents are still stale. An automation that
|
|
27
|
+
fires a command and assumes it worked reports success without checking, which
|
|
28
|
+
is the failure this project has now found repeatedly
|
|
29
|
+
|
|
30
|
+
## The exit codes, which must not collapse
|
|
31
|
+
|
|
32
|
+
| Code | Meaning |
|
|
33
|
+
|---|---|
|
|
34
|
+
| 0 | The command ran and everything is clean afterwards |
|
|
35
|
+
| 1 | The command ran and something is still stale |
|
|
36
|
+
| 2 | The command could not run, or exited non-zero |
|
|
37
|
+
|
|
38
|
+
A command that ran and achieved nothing is a working automation producing no
|
|
39
|
+
result. A command that could not run is a broken one. A scheduler that cannot
|
|
40
|
+
tell them apart retries the wrong one.
|
|
41
|
+
|
|
42
|
+
> [!warning] This executes a command from the repository's configuration
|
|
43
|
+
> `auto` is the only thing here that runs anything, and what it runs is whatever
|
|
44
|
+
> `constant-docs.yaml` says. That is the same trust model as a Makefile or a
|
|
45
|
+
> package script — but it is worth stating, because nothing else in this tool
|
|
46
|
+
> has it. Three things bound it: it never fires from a hook, it must be invoked
|
|
47
|
+
> deliberately, and the command is split with `shlex` rather than handed to a
|
|
48
|
+
> shell, so there are no pipes, no redirection and no expansion.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
from __future__ import annotations
|
|
52
|
+
|
|
53
|
+
import json
|
|
54
|
+
import os
|
|
55
|
+
import shlex
|
|
56
|
+
import subprocess
|
|
57
|
+
from dataclasses import dataclass
|
|
58
|
+
from datetime import UTC, datetime
|
|
59
|
+
from pathlib import Path
|
|
60
|
+
from typing import Any
|
|
61
|
+
|
|
62
|
+
from constant_docs import state
|
|
63
|
+
from constant_docs.state import STATE_DIR
|
|
64
|
+
|
|
65
|
+
# One JSON object per line, appended. The evidence that automation works, and
|
|
66
|
+
# without it "the docs update themselves" is a claim with nothing behind it.
|
|
67
|
+
# Local rather than committed: it lives beside the dirty set, under a directory
|
|
68
|
+
# that is gitignored, because it is a record of what happened on one machine.
|
|
69
|
+
LOG_FILE = "auto.jsonl"
|
|
70
|
+
|
|
71
|
+
# The selected module keys, space-separated, so a command need parse nothing to
|
|
72
|
+
# know what it was asked for.
|
|
73
|
+
MODULES_ENV = "CONSTANT_DOCS_MODULES"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class Outcome:
|
|
78
|
+
"""What one `auto` run did, and what it left behind."""
|
|
79
|
+
|
|
80
|
+
# Three states, not two, because "did not need to run" and "could not run"
|
|
81
|
+
# are opposite outcomes that a two-way flag would collapse into one.
|
|
82
|
+
attempted: bool # the command was reached for
|
|
83
|
+
ran: bool # the command actually executed
|
|
84
|
+
command_exit: int | None
|
|
85
|
+
selected: list[str]
|
|
86
|
+
skipped: list[str]
|
|
87
|
+
still_stale: list[str]
|
|
88
|
+
clean: bool
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def exit_code(self) -> int:
|
|
92
|
+
"""0 clean, 1 still stale, 2 could not run. Never collapsed."""
|
|
93
|
+
if not self.attempted:
|
|
94
|
+
# Nothing was stale — which is not the same as nothing being
|
|
95
|
+
# wrong. `verify` also fails on an orphan, a conformance issue or
|
|
96
|
+
# a refused diagram, and reporting 0 for a repository it exits 1
|
|
97
|
+
# on is an automation reporting success without having looked.
|
|
98
|
+
return 0 if self.clean else 1
|
|
99
|
+
if not self.ran:
|
|
100
|
+
return 2 # the command could not be started
|
|
101
|
+
if self.command_exit:
|
|
102
|
+
return 2 # it ran and failed
|
|
103
|
+
return 0 if self.clean else 1
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def log_path(repo_root: Path) -> Path:
|
|
107
|
+
"""Where the record of automated runs is kept."""
|
|
108
|
+
return repo_root / STATE_DIR / LOG_FILE
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def record(repo_root: Path, outcome: Outcome) -> None:
|
|
112
|
+
"""Append one run to the record.
|
|
113
|
+
|
|
114
|
+
Best effort: a record that cannot be written must not fail a run that
|
|
115
|
+
otherwise worked. Losing a line of evidence is worse than nothing and much
|
|
116
|
+
better than losing the regeneration.
|
|
117
|
+
"""
|
|
118
|
+
entry = {
|
|
119
|
+
"when": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
120
|
+
"modules": list(outcome.selected),
|
|
121
|
+
"skipped": list(outcome.skipped),
|
|
122
|
+
"command_exit": outcome.command_exit,
|
|
123
|
+
"still_stale": list(outcome.still_stale),
|
|
124
|
+
"clean": outcome.clean,
|
|
125
|
+
}
|
|
126
|
+
path = log_path(repo_root)
|
|
127
|
+
try:
|
|
128
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
129
|
+
with path.open("a", encoding="utf-8") as f:
|
|
130
|
+
f.write(json.dumps(entry) + "\n")
|
|
131
|
+
except OSError:
|
|
132
|
+
pass
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def run(cfg: Any, repo_root: Path, config_path: Path | None = None) -> Outcome:
|
|
136
|
+
"""Settle, run the configured command, verify, and report.
|
|
137
|
+
|
|
138
|
+
The tool calls no model. It runs what configuration names, waits for it,
|
|
139
|
+
and then checks the repository rather than believing the exit code.
|
|
140
|
+
|
|
141
|
+
*config_path* is the file *cfg* was loaded from. Passed in rather than
|
|
142
|
+
rebuilt from *repo_root*, because rebuilding it meant hardcoding the
|
|
143
|
+
default filename: `auto other.yaml` then ran one repository's command and
|
|
144
|
+
reported another's cleanliness, or died naming a file nobody had asked
|
|
145
|
+
for. It defaults to the conventional name only so an older caller keeps
|
|
146
|
+
working.
|
|
147
|
+
"""
|
|
148
|
+
from constant_docs.api import verify
|
|
149
|
+
|
|
150
|
+
settings = cfg.auto
|
|
151
|
+
resolved = Path(config_path) if config_path else repo_root / "constant-docs.yaml"
|
|
152
|
+
before = verify(resolved)
|
|
153
|
+
stale = sorted(set(before.stale) | set(before.missing))
|
|
154
|
+
|
|
155
|
+
if settings.when == "stale" and not stale:
|
|
156
|
+
outcome = Outcome(
|
|
157
|
+
attempted=False,
|
|
158
|
+
ran=False,
|
|
159
|
+
command_exit=None,
|
|
160
|
+
selected=[],
|
|
161
|
+
skipped=[],
|
|
162
|
+
still_stale=[],
|
|
163
|
+
# Not simply True: nothing being stale says nothing about the
|
|
164
|
+
# orphans, conformance issues and content issues `verify` also
|
|
165
|
+
# gates on, and a scheduler told "clean" stops looking.
|
|
166
|
+
clean=before.exit_code == 0,
|
|
167
|
+
)
|
|
168
|
+
# Recorded even so: "nothing to do" is evidence that the automation
|
|
169
|
+
# ran, and an empty log is indistinguishable from one that never fired.
|
|
170
|
+
record(repo_root, outcome)
|
|
171
|
+
return outcome
|
|
172
|
+
|
|
173
|
+
selected = stale if settings.budget is None else stale[: settings.budget]
|
|
174
|
+
skipped = [] if settings.budget is None else stale[settings.budget :]
|
|
175
|
+
|
|
176
|
+
# Seed the dirty set with exactly what this run is asking for.
|
|
177
|
+
#
|
|
178
|
+
# This is what makes `auto` compose with everything else. In the case it
|
|
179
|
+
# exists for, no hook ever fired — the change arrived from a timer, a chat
|
|
180
|
+
# client, or somebody editing files directly — so the dirty set is empty
|
|
181
|
+
# and a command whose loop is `settle` would find no work at all. `auto`
|
|
182
|
+
# discovers staleness by hash and records it, so the ordinary loop then
|
|
183
|
+
# works unchanged.
|
|
184
|
+
#
|
|
185
|
+
# Replacing rather than adding is what makes the budget real: the command
|
|
186
|
+
# sees the capped set, whatever the shape of its loop. Nothing is lost —
|
|
187
|
+
# the dirty set is recovery state, and `verify` sees every stale module
|
|
188
|
+
# whatever it says.
|
|
189
|
+
# Read, modify, write — like every other caller. Building a fresh
|
|
190
|
+
# `Dirty` reset `blocked_signature` and `blocked_count` to their
|
|
191
|
+
# defaults, which is the Stop-hook loop guard: one `auto` run wiped a
|
|
192
|
+
# released set's sentinel and the next `settle --hook` blocked the
|
|
193
|
+
# identical unchanged plan all over again.
|
|
194
|
+
dirty = state.read(repo_root)
|
|
195
|
+
dirty.modules = list(selected)
|
|
196
|
+
state.write(repo_root, dirty)
|
|
197
|
+
|
|
198
|
+
environment = dict(os.environ)
|
|
199
|
+
environment[MODULES_ENV] = " ".join(selected)
|
|
200
|
+
|
|
201
|
+
try:
|
|
202
|
+
completed = subprocess.run(
|
|
203
|
+
shlex.split(settings.command),
|
|
204
|
+
cwd=str(repo_root),
|
|
205
|
+
env=environment,
|
|
206
|
+
check=False,
|
|
207
|
+
timeout=settings.timeout,
|
|
208
|
+
)
|
|
209
|
+
command_exit: int | None = completed.returncode
|
|
210
|
+
except (OSError, ValueError, subprocess.TimeoutExpired):
|
|
211
|
+
# Cannot run: a missing executable, an unparseable command, a run that
|
|
212
|
+
# never returned. Distinct from a command that ran and failed only in
|
|
213
|
+
# that both are exit 2 — which is the honest answer, because in neither
|
|
214
|
+
# case did the automation do its job.
|
|
215
|
+
outcome = Outcome(
|
|
216
|
+
attempted=True,
|
|
217
|
+
ran=False,
|
|
218
|
+
command_exit=None,
|
|
219
|
+
selected=selected,
|
|
220
|
+
skipped=skipped,
|
|
221
|
+
still_stale=stale,
|
|
222
|
+
clean=False,
|
|
223
|
+
)
|
|
224
|
+
record(repo_root, outcome)
|
|
225
|
+
return outcome
|
|
226
|
+
|
|
227
|
+
after = verify(resolved)
|
|
228
|
+
still_stale = sorted(set(after.stale) | set(after.missing))
|
|
229
|
+
outcome = Outcome(
|
|
230
|
+
attempted=True,
|
|
231
|
+
ran=True,
|
|
232
|
+
command_exit=command_exit,
|
|
233
|
+
selected=selected,
|
|
234
|
+
skipped=skipped,
|
|
235
|
+
still_stale=still_stale,
|
|
236
|
+
clean=after.exit_code == 0,
|
|
237
|
+
)
|
|
238
|
+
record(repo_root, outcome)
|
|
239
|
+
return outcome
|