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/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