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/cli.py
ADDED
|
@@ -0,0 +1,1085 @@
|
|
|
1
|
+
"""Command-line interface for constant-docs.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
constant-docs verify [--json] [<config>]
|
|
6
|
+
constant-docs plan [--json] [<config>]
|
|
7
|
+
constant-docs apply <module> <body> [<config>] [--retire <id>]...
|
|
8
|
+
constant-docs append <module> <title> <entry> [<config>]
|
|
9
|
+
constant-docs add <name> [<config>] [--glob <p>]... [--covers <m>]... [--kind <k>]
|
|
10
|
+
constant-docs prune [<config>]
|
|
11
|
+
constant-docs index [<config>]
|
|
12
|
+
constant-docs mark [<path>...]
|
|
13
|
+
constant-docs settle [--json] [--hook]
|
|
14
|
+
constant-docs prompt [<kind>]
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import hashlib
|
|
20
|
+
import json
|
|
21
|
+
import sys
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from constant_docs import __version__, state
|
|
26
|
+
from constant_docs.api import _resolve_config as discover_config
|
|
27
|
+
from constant_docs.api import append, apply, plan, prune, settle
|
|
28
|
+
from constant_docs.api import verify as verify_api
|
|
29
|
+
from constant_docs.config import MISSING_KEY, ConfigError, add_module
|
|
30
|
+
from constant_docs.config import load as load_config
|
|
31
|
+
from constant_docs.decisions import DecisionError
|
|
32
|
+
from constant_docs.document import DocumentError
|
|
33
|
+
from constant_docs.globs import GlobError
|
|
34
|
+
from constant_docs.index import write as index_write
|
|
35
|
+
from constant_docs.kinds import (
|
|
36
|
+
BUILTIN_KINDS,
|
|
37
|
+
DEFAULT_KIND,
|
|
38
|
+
KindError,
|
|
39
|
+
guide_names,
|
|
40
|
+
guide_path,
|
|
41
|
+
resolve_prompt,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
_CONFIG_PATH = Path("constant-docs.yaml")
|
|
45
|
+
|
|
46
|
+
# What `init` writes when there is nothing. `modules: {}` is explicit rather
|
|
47
|
+
# than omitted: an absent key stays an error, so that a mistyped one cannot
|
|
48
|
+
# load as a repository where every check passes and nothing is checked.
|
|
49
|
+
_BLANK_CONFIG = """version: 1
|
|
50
|
+
|
|
51
|
+
# Where documents are written. Everything under it is excluded from every glob.
|
|
52
|
+
docs_root: docs
|
|
53
|
+
|
|
54
|
+
# Module key to glob. The key is the document's path stem, so `src/payments`
|
|
55
|
+
# writes `docs/src/payments.md`. Add one with:
|
|
56
|
+
#
|
|
57
|
+
# constant-docs add src/payments --glob 'src/payments/**/*.py'
|
|
58
|
+
modules: {}
|
|
59
|
+
|
|
60
|
+
# Glob to the reason it deliberately carries no document. A reason is required:
|
|
61
|
+
# an exclusion nobody justified is one nobody decided.
|
|
62
|
+
#
|
|
63
|
+
# uncovered:
|
|
64
|
+
# "tests/fixtures/**": Test data rather than source
|
|
65
|
+
"""
|
|
66
|
+
_PROMPT_PATH = Path(__file__).parent / "prompts" / "module.md"
|
|
67
|
+
|
|
68
|
+
# Block counter sentinel: the guard has already released this exact set of
|
|
69
|
+
# stale modules, and must not block on it again until the work changes.
|
|
70
|
+
_RELEASED = -1
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _resolve_config(path: str | None) -> Path:
|
|
74
|
+
"""Return the config path, discovered from the working directory.
|
|
75
|
+
|
|
76
|
+
A hook's working directory is not guaranteed to be the repository root,
|
|
77
|
+
so discovery walks up rather than assuming.
|
|
78
|
+
|
|
79
|
+
Discovery's own `ConfigError` propagates. Catching it and substituting
|
|
80
|
+
`constant-docs.yaml` threw away the sentence that says a parent walk ran
|
|
81
|
+
and failed, and replaced it with one naming a relative path nothing had
|
|
82
|
+
looked for — indistinguishable from "you passed a path and it is missing".
|
|
83
|
+
Every subcommand already handles a `ConfigError`, and the two that must be
|
|
84
|
+
silent outside a repository test its key.
|
|
85
|
+
"""
|
|
86
|
+
if path:
|
|
87
|
+
return Path(path)
|
|
88
|
+
return discover_config(None)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _take_repeated(args: list[str], flag: str) -> list[str]:
|
|
92
|
+
"""Remove every ``--flag <value>`` pair from *args* and return the values.
|
|
93
|
+
|
|
94
|
+
Written by hand rather than reached for through `argparse`, because the
|
|
95
|
+
rest of this CLI parses positionally and mixing the two produces a help
|
|
96
|
+
text that describes neither. A flag with no value is an error rather than
|
|
97
|
+
a silently ignored trailing token: `--retire` with nothing after it means
|
|
98
|
+
the caller believes a retirement was declared.
|
|
99
|
+
"""
|
|
100
|
+
values: list[str] = []
|
|
101
|
+
index = 0
|
|
102
|
+
while index < len(args):
|
|
103
|
+
if args[index] != flag:
|
|
104
|
+
index += 1
|
|
105
|
+
continue
|
|
106
|
+
if index + 1 >= len(args):
|
|
107
|
+
raise ValueError(f"{flag} needs a value")
|
|
108
|
+
values.append(args[index + 1])
|
|
109
|
+
del args[index : index + 2]
|
|
110
|
+
return values
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _exit_ok(data: Any = None) -> None:
|
|
114
|
+
"""Print *data* as JSON if it's not None, then exit 0."""
|
|
115
|
+
if data is not None:
|
|
116
|
+
print(json.dumps(data, indent=2, default=str))
|
|
117
|
+
sys.exit(0)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _exit_drift(drift_report: Any) -> None:
|
|
121
|
+
"""Print the drift report as JSON, exit 1."""
|
|
122
|
+
print(json.dumps(drift_report, indent=2, default=str))
|
|
123
|
+
sys.exit(1)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _exit_config_error(message: str) -> None:
|
|
127
|
+
"""Print the error, exit 2."""
|
|
128
|
+
print(f"Configuration error: {message}", file=sys.stderr)
|
|
129
|
+
sys.exit(2)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# What an exception that reached `main` unhandled is worth as an exit code.
|
|
133
|
+
# 1 is drift and belongs to `verify` alone; anything that got this far is a
|
|
134
|
+
# refusal or a crash, and both are 2. A scheduler tells those apart by the
|
|
135
|
+
# code and nothing else.
|
|
136
|
+
_EXIT_CODES: tuple[tuple[type[BaseException], int], ...] = (
|
|
137
|
+
(ConfigError, 2),
|
|
138
|
+
(DecisionError, 2),
|
|
139
|
+
(DocumentError, 2),
|
|
140
|
+
(KindError, 2),
|
|
141
|
+
)
|
|
142
|
+
_UNEXPECTED_EXIT = 2
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _exit_code_for(exc: BaseException) -> int:
|
|
146
|
+
"""Return the exit code for an exception that reached the top level."""
|
|
147
|
+
for exc_type, code in _EXIT_CODES:
|
|
148
|
+
if isinstance(exc, exc_type):
|
|
149
|
+
return code
|
|
150
|
+
return _UNEXPECTED_EXIT
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def cmd_verify(
|
|
154
|
+
config_path: str | None, json_output: bool, coverage: bool = False
|
|
155
|
+
) -> None:
|
|
156
|
+
"""Run verification and report drift."""
|
|
157
|
+
try:
|
|
158
|
+
report = verify_api(_resolve_config(config_path), coverage=coverage)
|
|
159
|
+
except ConfigError as e:
|
|
160
|
+
_exit_config_error(str(e))
|
|
161
|
+
|
|
162
|
+
if json_output:
|
|
163
|
+
payload = {
|
|
164
|
+
"exit_code": report.exit_code,
|
|
165
|
+
"stale": report.stale,
|
|
166
|
+
"missing": report.missing,
|
|
167
|
+
"orphans": [
|
|
168
|
+
{
|
|
169
|
+
"doc_path": str(o.doc_path),
|
|
170
|
+
"module_key": o.module_key,
|
|
171
|
+
"probable_move": o.probable_move,
|
|
172
|
+
"probable_moves": o.probable_moves,
|
|
173
|
+
}
|
|
174
|
+
for o in report.orphans
|
|
175
|
+
],
|
|
176
|
+
"fresh": report.fresh,
|
|
177
|
+
"conformance_issues": report.conformance_issues,
|
|
178
|
+
"content_issues": report.content_issues,
|
|
179
|
+
"coverage_gaps": report.coverage_gaps,
|
|
180
|
+
}
|
|
181
|
+
if report.exit_code == 0:
|
|
182
|
+
_exit_ok(payload)
|
|
183
|
+
else:
|
|
184
|
+
_exit_drift(payload)
|
|
185
|
+
else:
|
|
186
|
+
if report.exit_code == 0:
|
|
187
|
+
print("All modules up to date.")
|
|
188
|
+
sys.exit(0)
|
|
189
|
+
if report.stale:
|
|
190
|
+
print("Stale modules:", ", ".join(report.stale))
|
|
191
|
+
if report.missing:
|
|
192
|
+
print("Missing documents:", ", ".join(report.missing))
|
|
193
|
+
if report.orphans:
|
|
194
|
+
print(f"Orphaned documents ({len(report.orphans)}):")
|
|
195
|
+
for o in report.orphans:
|
|
196
|
+
if o.probable_move:
|
|
197
|
+
print(f" {o.doc_path} → probably now {o.probable_move}")
|
|
198
|
+
elif o.probable_moves:
|
|
199
|
+
candidates = ", ".join(o.probable_moves)
|
|
200
|
+
print(f" {o.doc_path} → one of {candidates}, cannot tell")
|
|
201
|
+
else:
|
|
202
|
+
print(f" {o.doc_path}")
|
|
203
|
+
if report.conformance_issues:
|
|
204
|
+
print(f"Conformance issues ({len(report.conformance_issues)}):")
|
|
205
|
+
for issue in report.conformance_issues:
|
|
206
|
+
print(f" {issue}")
|
|
207
|
+
if report.content_issues:
|
|
208
|
+
print(f"Content issues ({len(report.content_issues)}):")
|
|
209
|
+
for issue in report.content_issues:
|
|
210
|
+
print(f" {issue}")
|
|
211
|
+
if report.coverage_gaps:
|
|
212
|
+
print(f"Uncovered source ({len(report.coverage_gaps)}):")
|
|
213
|
+
for gap in report.coverage_gaps:
|
|
214
|
+
print(f" {gap}")
|
|
215
|
+
sys.exit(report.exit_code)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def cmd_plan(config_path: str | None, json_output: bool) -> None:
|
|
219
|
+
"""Inspect and report the current state."""
|
|
220
|
+
try:
|
|
221
|
+
p = plan(_resolve_config(config_path))
|
|
222
|
+
except ConfigError as e:
|
|
223
|
+
_exit_config_error(str(e))
|
|
224
|
+
|
|
225
|
+
if json_output:
|
|
226
|
+
_exit_ok(_plan_payload(p))
|
|
227
|
+
else:
|
|
228
|
+
if p.stale:
|
|
229
|
+
for m in p.stale:
|
|
230
|
+
print(f"[{m.reason}] {m.module} ({m.kind}) → {m.doc_path}")
|
|
231
|
+
if p.fresh:
|
|
232
|
+
for f in p.fresh:
|
|
233
|
+
print(f"[fresh] {f}")
|
|
234
|
+
if p.orphans:
|
|
235
|
+
for o in p.orphans:
|
|
236
|
+
print(f"[orphan] {o.doc_path}")
|
|
237
|
+
if not p.stale and not p.orphans:
|
|
238
|
+
print("Everything up to date.")
|
|
239
|
+
sys.exit(0)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def cmd_apply(
|
|
243
|
+
config_path: str | None,
|
|
244
|
+
module_key: str,
|
|
245
|
+
body_text: str,
|
|
246
|
+
retire: list[str] | None = None,
|
|
247
|
+
) -> None:
|
|
248
|
+
"""Write a body to a module document."""
|
|
249
|
+
try:
|
|
250
|
+
config = _resolve_config(config_path)
|
|
251
|
+
apply(module_key, body_text, config_path=config, retire=retire or [])
|
|
252
|
+
print(f"Applied {module_key}.")
|
|
253
|
+
except ConfigError as e:
|
|
254
|
+
_exit_config_error(str(e))
|
|
255
|
+
except DecisionError as e:
|
|
256
|
+
# Its own clause rather than falling into the generic one, because
|
|
257
|
+
# the caller's next move depends on which decisions are at fault and
|
|
258
|
+
# a truncated message costs them a round trip.
|
|
259
|
+
print(f"Refused: {e}", file=sys.stderr)
|
|
260
|
+
sys.exit(2)
|
|
261
|
+
except ValueError as e:
|
|
262
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
263
|
+
sys.exit(2)
|
|
264
|
+
except DocumentError as e:
|
|
265
|
+
print(f"Document error: {e}", file=sys.stderr)
|
|
266
|
+
sys.exit(2)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def cmd_append(
|
|
270
|
+
config_path: str | None, module_key: str, title: str, entry: str
|
|
271
|
+
) -> None:
|
|
272
|
+
"""Add one entry to an append-mode document."""
|
|
273
|
+
try:
|
|
274
|
+
append(module_key, entry, title, config_path=_resolve_config(config_path))
|
|
275
|
+
print(f"Appended to {module_key}.")
|
|
276
|
+
except ConfigError as e:
|
|
277
|
+
_exit_config_error(str(e))
|
|
278
|
+
except DecisionError as e:
|
|
279
|
+
# The same clause `cmd_apply` has. `DuplicateDecision` is a
|
|
280
|
+
# `DecisionError` and deliberately not a `DocumentError`, so it fell
|
|
281
|
+
# past all three clauses to the blanket handler and reported a refused
|
|
282
|
+
# write as exit 1 — the code this tool defines as drift, which a
|
|
283
|
+
# harness answers by retrying.
|
|
284
|
+
print(f"Refused: {e}", file=sys.stderr)
|
|
285
|
+
sys.exit(2)
|
|
286
|
+
except ValueError as e:
|
|
287
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
288
|
+
sys.exit(2)
|
|
289
|
+
except DocumentError as e:
|
|
290
|
+
print(f"Document error: {e}", file=sys.stderr)
|
|
291
|
+
sys.exit(2)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def cmd_add(
|
|
295
|
+
config_path: str | None,
|
|
296
|
+
name: str,
|
|
297
|
+
globs: list[str],
|
|
298
|
+
covers: list[str],
|
|
299
|
+
kind: str,
|
|
300
|
+
) -> int:
|
|
301
|
+
"""Register a document in the configuration."""
|
|
302
|
+
try:
|
|
303
|
+
add_module(
|
|
304
|
+
_resolve_config(config_path),
|
|
305
|
+
name,
|
|
306
|
+
globs=globs,
|
|
307
|
+
covers=covers,
|
|
308
|
+
kind=kind,
|
|
309
|
+
)
|
|
310
|
+
except ConfigError as e:
|
|
311
|
+
print(f"Configuration error: {e}", file=sys.stderr)
|
|
312
|
+
return 2
|
|
313
|
+
print(f"Added {name}.")
|
|
314
|
+
return 0
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def cmd_auto(config_path: str | None, json_output: bool) -> int:
|
|
318
|
+
"""Settle, run the configured command, verify, and report.
|
|
319
|
+
|
|
320
|
+
Exit codes are the tool's usual three and must not collapse: 0 clean
|
|
321
|
+
afterwards, 1 still stale, 2 could not run. A command that ran and achieved
|
|
322
|
+
nothing is a working automation producing no result; one that could not run
|
|
323
|
+
is a broken automation. A scheduler that cannot tell them apart retries the
|
|
324
|
+
wrong one.
|
|
325
|
+
"""
|
|
326
|
+
from constant_docs.auto import run as run_auto
|
|
327
|
+
|
|
328
|
+
try:
|
|
329
|
+
resolved = _resolve_config(config_path)
|
|
330
|
+
cfg = load_config(resolved)
|
|
331
|
+
except ConfigError as e:
|
|
332
|
+
print(f"Configuration error: {e}", file=sys.stderr)
|
|
333
|
+
return 2
|
|
334
|
+
|
|
335
|
+
if cfg.auto is None:
|
|
336
|
+
print(
|
|
337
|
+
"No 'auto' block in the configuration. Declare the command to run:"
|
|
338
|
+
"\n\n auto:\n command: \"<your agent> -p 'Run the constant-docs "
|
|
339
|
+
"settle loop'\"\n",
|
|
340
|
+
file=sys.stderr,
|
|
341
|
+
)
|
|
342
|
+
return 2
|
|
343
|
+
|
|
344
|
+
outcome = run_auto(cfg, resolved.resolve().parent, resolved)
|
|
345
|
+
|
|
346
|
+
if json_output:
|
|
347
|
+
print(
|
|
348
|
+
json.dumps(
|
|
349
|
+
{
|
|
350
|
+
"exit_code": outcome.exit_code,
|
|
351
|
+
"attempted": outcome.attempted,
|
|
352
|
+
"ran": outcome.ran,
|
|
353
|
+
"command_exit": outcome.command_exit,
|
|
354
|
+
"selected": outcome.selected,
|
|
355
|
+
"skipped": outcome.skipped,
|
|
356
|
+
"still_stale": outcome.still_stale,
|
|
357
|
+
"clean": outcome.clean,
|
|
358
|
+
},
|
|
359
|
+
indent=2,
|
|
360
|
+
)
|
|
361
|
+
)
|
|
362
|
+
return outcome.exit_code
|
|
363
|
+
|
|
364
|
+
if not outcome.attempted:
|
|
365
|
+
if outcome.clean:
|
|
366
|
+
print("Nothing stale; the command was not run.")
|
|
367
|
+
else:
|
|
368
|
+
# Nothing to regenerate and still not passing. Saying so is the
|
|
369
|
+
# whole point: the previous wording reported a clean repository
|
|
370
|
+
# on the strength of the one check that happened to pass.
|
|
371
|
+
print(
|
|
372
|
+
"Nothing stale, but `verify` still fails. Run "
|
|
373
|
+
"`constant-docs verify` for the orphans, conformance or "
|
|
374
|
+
"content issues behind it.",
|
|
375
|
+
file=sys.stderr,
|
|
376
|
+
)
|
|
377
|
+
return outcome.exit_code
|
|
378
|
+
if not outcome.ran:
|
|
379
|
+
print(
|
|
380
|
+
f"Could not run {cfg.auto.command!r}. Nothing was regenerated.",
|
|
381
|
+
file=sys.stderr,
|
|
382
|
+
)
|
|
383
|
+
return outcome.exit_code
|
|
384
|
+
|
|
385
|
+
if outcome.command_exit:
|
|
386
|
+
print(
|
|
387
|
+
f"The command exited {outcome.command_exit}. Nothing is assumed "
|
|
388
|
+
f"about what it did.",
|
|
389
|
+
file=sys.stderr,
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
if outcome.skipped:
|
|
393
|
+
# Named, never silent: a cap nobody is told about reads as
|
|
394
|
+
# "everything was covered".
|
|
395
|
+
print(
|
|
396
|
+
f"Budget of {cfg.auto.budget} reached. Left for the next run "
|
|
397
|
+
f"({len(outcome.skipped)}): " + ", ".join(outcome.skipped)
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
if outcome.clean:
|
|
401
|
+
print(f"Regenerated {len(outcome.selected)}; everything is current.")
|
|
402
|
+
elif outcome.still_stale:
|
|
403
|
+
print("Still stale: " + ", ".join(outcome.still_stale))
|
|
404
|
+
else:
|
|
405
|
+
# Not clean, nothing stale: a non-zero exit with no diagnosis is an
|
|
406
|
+
# operator re-running `verify` by hand to find out what happened.
|
|
407
|
+
print(
|
|
408
|
+
"Nothing is stale, but `verify` still fails. Run "
|
|
409
|
+
"`constant-docs verify` for what is left.",
|
|
410
|
+
file=sys.stderr,
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
return outcome.exit_code
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def cmd_coverage(config_path: str | None, json_output: bool) -> int:
|
|
417
|
+
"""Report source that no module covers.
|
|
418
|
+
|
|
419
|
+
Reporting is not failing: this exits 0 whatever it finds. Folding it into
|
|
420
|
+
the gate is `verify --coverage`, and it is opt-in for the same reason.
|
|
421
|
+
"""
|
|
422
|
+
from constant_docs.coverage import by_directory, uncovered_files
|
|
423
|
+
|
|
424
|
+
try:
|
|
425
|
+
resolved = _resolve_config(config_path)
|
|
426
|
+
cfg = load_config(resolved)
|
|
427
|
+
except ConfigError as e:
|
|
428
|
+
print(f"Configuration error: {e}", file=sys.stderr)
|
|
429
|
+
return 2
|
|
430
|
+
|
|
431
|
+
missing = uncovered_files(cfg, resolved.resolve().parent)
|
|
432
|
+
grouped = by_directory(missing)
|
|
433
|
+
|
|
434
|
+
if json_output:
|
|
435
|
+
print(
|
|
436
|
+
json.dumps(
|
|
437
|
+
{
|
|
438
|
+
"uncovered": [p.as_posix() for p in missing],
|
|
439
|
+
"by_directory": grouped,
|
|
440
|
+
"excluded": dict(sorted(cfg.uncovered.items())),
|
|
441
|
+
},
|
|
442
|
+
indent=2,
|
|
443
|
+
)
|
|
444
|
+
)
|
|
445
|
+
return 0
|
|
446
|
+
|
|
447
|
+
if not missing:
|
|
448
|
+
print("Every file is covered by a module or declared uncovered.")
|
|
449
|
+
return 0
|
|
450
|
+
|
|
451
|
+
print(f"Source covered by no module ({len(missing)} files):")
|
|
452
|
+
for directory, files in grouped.items():
|
|
453
|
+
print(f" {directory} ({len(files)})")
|
|
454
|
+
print(
|
|
455
|
+
"\nRegister one with `constant-docs add <name> --glob <pattern>`, or "
|
|
456
|
+
"declare it uncovered in the configuration with a reason."
|
|
457
|
+
)
|
|
458
|
+
return 0
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def cmd_init(config_path: str | None, json_output: bool) -> int:
|
|
462
|
+
"""Gather what a harness needs to propose a module map.
|
|
463
|
+
|
|
464
|
+
Writes a configuration when there is none, and **never removes**. A re-run
|
|
465
|
+
is additive and proposing only: dropping a module orphans its document and
|
|
466
|
+
`prune` then deletes it, so periodic plus destructive would be data loss.
|
|
467
|
+
Removing a module stays a hand edit, because it destroys a document and
|
|
468
|
+
that should cost somebody a decision.
|
|
469
|
+
"""
|
|
470
|
+
from constant_docs.coverage import facts
|
|
471
|
+
|
|
472
|
+
target = Path(config_path) if config_path else _CONFIG_PATH
|
|
473
|
+
created = False
|
|
474
|
+
if not target.exists():
|
|
475
|
+
target.write_text(_BLANK_CONFIG, encoding="utf-8")
|
|
476
|
+
created = True
|
|
477
|
+
|
|
478
|
+
try:
|
|
479
|
+
cfg = load_config(target)
|
|
480
|
+
except ConfigError as e:
|
|
481
|
+
print(f"Configuration error: {e}", file=sys.stderr)
|
|
482
|
+
return 2
|
|
483
|
+
|
|
484
|
+
found = facts(cfg, target.resolve().parent)
|
|
485
|
+
|
|
486
|
+
if json_output:
|
|
487
|
+
print(
|
|
488
|
+
json.dumps(
|
|
489
|
+
{
|
|
490
|
+
"created": created,
|
|
491
|
+
"config": str(target),
|
|
492
|
+
"directories": [
|
|
493
|
+
{
|
|
494
|
+
"path": d.path,
|
|
495
|
+
"files": d.files,
|
|
496
|
+
"bytes": d.bytes,
|
|
497
|
+
"covered": d.covered,
|
|
498
|
+
}
|
|
499
|
+
for d in found.directories
|
|
500
|
+
],
|
|
501
|
+
"uncovered": found.uncovered,
|
|
502
|
+
"excluded": found.excluded,
|
|
503
|
+
"empty_modules": found.empty_modules,
|
|
504
|
+
},
|
|
505
|
+
indent=2,
|
|
506
|
+
)
|
|
507
|
+
)
|
|
508
|
+
return 0
|
|
509
|
+
|
|
510
|
+
if created:
|
|
511
|
+
print(f"Wrote {target}.")
|
|
512
|
+
|
|
513
|
+
for key in found.empty_modules:
|
|
514
|
+
print(
|
|
515
|
+
f"Module {key!r} matches no file. Left alone: removing it would "
|
|
516
|
+
f"orphan its document, which `prune` deletes."
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
proposals = _propose(found)
|
|
520
|
+
if not proposals:
|
|
521
|
+
print("Nothing to propose — every file is covered or declared uncovered.")
|
|
522
|
+
return 0
|
|
523
|
+
|
|
524
|
+
print(f"Source covered by no module, by directory ({len(proposals)}):\n")
|
|
525
|
+
for directory, count in proposals:
|
|
526
|
+
print(f" {directory} ({count} files)")
|
|
527
|
+
print(
|
|
528
|
+
"\nDecide what a module is here, then register each one:\n"
|
|
529
|
+
"\n constant-docs add <name> --glob '<pattern>'\n"
|
|
530
|
+
"\nA directory that should carry no document is declared in the "
|
|
531
|
+
"configuration under `uncovered:`, with a reason."
|
|
532
|
+
)
|
|
533
|
+
return 0
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _propose(found: Any) -> list[tuple[str, int]]:
|
|
537
|
+
"""Return (directory, file count) for every directory with uncovered source."""
|
|
538
|
+
counts: dict[str, int] = {}
|
|
539
|
+
for rel in found.uncovered:
|
|
540
|
+
parent = str(Path(rel).parent)
|
|
541
|
+
counts[parent] = counts.get(parent, 0) + 1
|
|
542
|
+
return sorted(counts.items())
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def cmd_prune(config_path: str | None) -> None:
|
|
546
|
+
"""Delete orphaned documents, and name every file left alone."""
|
|
547
|
+
config = _resolve_config(config_path)
|
|
548
|
+
try:
|
|
549
|
+
report = prune(config)
|
|
550
|
+
except ConfigError as e:
|
|
551
|
+
_exit_config_error(str(e))
|
|
552
|
+
return
|
|
553
|
+
root = config.resolve().parent
|
|
554
|
+
|
|
555
|
+
def shown(path: Path) -> str:
|
|
556
|
+
try:
|
|
557
|
+
return path.resolve().relative_to(root).as_posix()
|
|
558
|
+
except ValueError:
|
|
559
|
+
return str(path)
|
|
560
|
+
|
|
561
|
+
# Every deletion named, one line each. `PruneReport` carries `deleted` for
|
|
562
|
+
# this and this alone: printing "Pruned orphans." and nothing else is how
|
|
563
|
+
# this command destroyed documents it had never written without anybody
|
|
564
|
+
# noticing, and a command that just deleted files owes the reader the list.
|
|
565
|
+
if report.deleted:
|
|
566
|
+
print(f"Pruned orphans ({len(report.deleted)}):")
|
|
567
|
+
for path in report.deleted:
|
|
568
|
+
print(f" {shown(path)}")
|
|
569
|
+
else:
|
|
570
|
+
print("No orphaned documents to prune.")
|
|
571
|
+
|
|
572
|
+
if report.failed:
|
|
573
|
+
# Named on stderr, because this is the one part of the output that
|
|
574
|
+
# says the command did not finish what it set out to do.
|
|
575
|
+
print(f"\nCould not remove ({len(report.failed)}):", file=sys.stderr)
|
|
576
|
+
for problem in report.failed:
|
|
577
|
+
print(f" {problem}", file=sys.stderr)
|
|
578
|
+
|
|
579
|
+
if not report.skipped:
|
|
580
|
+
return
|
|
581
|
+
|
|
582
|
+
# Named on every run that has any, one line each. A count alone would be
|
|
583
|
+
# the same silence in a shorter form: the reader cannot tell whether the
|
|
584
|
+
# file they care about is in it. This is a command that just deleted
|
|
585
|
+
# things, and the useful sentence is which ones it did not.
|
|
586
|
+
print(
|
|
587
|
+
f"\nLeft alone — carries no constant-docs frontmatter ({len(report.skipped)}):"
|
|
588
|
+
)
|
|
589
|
+
for path in report.skipped:
|
|
590
|
+
print(f" {shown(path)}")
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def cmd_index(config_path: str | None) -> None:
|
|
594
|
+
"""Assemble and write the root index."""
|
|
595
|
+
try:
|
|
596
|
+
config = _resolve_config(config_path)
|
|
597
|
+
cfg = load_config(config)
|
|
598
|
+
target = index_write(cfg, config.resolve().parent)
|
|
599
|
+
except ConfigError as e:
|
|
600
|
+
_exit_config_error(str(e))
|
|
601
|
+
except DocumentError as e:
|
|
602
|
+
# The index refuses to overwrite a file it did not write. A refusal is
|
|
603
|
+
# exit 2, the same as every other one.
|
|
604
|
+
print(f"Refused: {e}", file=sys.stderr)
|
|
605
|
+
sys.exit(2)
|
|
606
|
+
print(f"Wrote {target}.")
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _plan_payload(p: Any) -> dict[str, Any]:
|
|
610
|
+
"""The JSON shape shared by `plan` and `settle` (criterion 30).
|
|
611
|
+
|
|
612
|
+
`unreadable` is here as well as in `verify` because these two are what a
|
|
613
|
+
regeneration loop actually drives. A module whose sources cannot be hashed
|
|
614
|
+
is in neither `stale` nor `fresh`, so leaving it out told the loop the
|
|
615
|
+
repository was fully documented while one module had dropped silently out
|
|
616
|
+
of the report.
|
|
617
|
+
"""
|
|
618
|
+
return {
|
|
619
|
+
"unreadable": list(getattr(p, "unreadable", [])),
|
|
620
|
+
"stale": [
|
|
621
|
+
{
|
|
622
|
+
"module": m.module,
|
|
623
|
+
"doc_path": str(m.doc_path),
|
|
624
|
+
"files": [str(f) for f in m.files],
|
|
625
|
+
"previous_body": m.previous_body,
|
|
626
|
+
"previous_description": m.previous_description,
|
|
627
|
+
"reason": m.reason,
|
|
628
|
+
"kind": m.kind,
|
|
629
|
+
"mode": m.mode,
|
|
630
|
+
"required_headings": m.required_headings,
|
|
631
|
+
"prompt": m.prompt,
|
|
632
|
+
}
|
|
633
|
+
for m in p.stale
|
|
634
|
+
],
|
|
635
|
+
"orphans": [
|
|
636
|
+
{"doc_path": str(o.doc_path), "module_key": o.module_key} for o in p.orphans
|
|
637
|
+
],
|
|
638
|
+
"fresh": p.fresh,
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def _paths_from_hook_stdin() -> list[str]:
|
|
643
|
+
"""Read a PostToolUse payload from stdin and return the paths it names.
|
|
644
|
+
|
|
645
|
+
Reading the hook JSON directly is why the shipped hook needs no `jq` — a
|
|
646
|
+
dependency the user may not have, whose absence they would experience as
|
|
647
|
+
this tool being broken. Anything unparseable yields nothing, silently:
|
|
648
|
+
a hook that shouts about a payload it did not understand is a hook
|
|
649
|
+
somebody switches off.
|
|
650
|
+
"""
|
|
651
|
+
try:
|
|
652
|
+
payload = json.load(sys.stdin)
|
|
653
|
+
except (ValueError, OSError):
|
|
654
|
+
return []
|
|
655
|
+
if not isinstance(payload, dict):
|
|
656
|
+
return []
|
|
657
|
+
tool_input = payload.get("tool_input")
|
|
658
|
+
if not isinstance(tool_input, dict):
|
|
659
|
+
return []
|
|
660
|
+
|
|
661
|
+
found: list[str] = []
|
|
662
|
+
for key in ("file_path", "notebook_path"):
|
|
663
|
+
value = tool_input.get(key)
|
|
664
|
+
if isinstance(value, str) and value:
|
|
665
|
+
found.append(value)
|
|
666
|
+
# Some edit tools carry a list of edits, each naming its own file.
|
|
667
|
+
edits = tool_input.get("edits")
|
|
668
|
+
if isinstance(edits, list):
|
|
669
|
+
for edit in edits:
|
|
670
|
+
if isinstance(edit, dict):
|
|
671
|
+
value = edit.get("file_path")
|
|
672
|
+
if isinstance(value, str) and value:
|
|
673
|
+
found.append(value)
|
|
674
|
+
return found
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def cmd_mark(paths: list[str]) -> int:
|
|
678
|
+
"""Add every module the given paths belong to, to the dirty set.
|
|
679
|
+
|
|
680
|
+
Runs on every file write, so it must be cheap and silent. It never reads
|
|
681
|
+
a source file and never hashes: the path is matched against the declared
|
|
682
|
+
patterns as a string.
|
|
683
|
+
"""
|
|
684
|
+
if not paths:
|
|
685
|
+
paths = _paths_from_hook_stdin()
|
|
686
|
+
if not paths:
|
|
687
|
+
return 0
|
|
688
|
+
try:
|
|
689
|
+
state.mark(_resolve_config(None), paths)
|
|
690
|
+
except (ConfigError, GlobError, OSError):
|
|
691
|
+
# No configuration here, or an unreadable one. A hook installed
|
|
692
|
+
# globally fires in every repository, including those that do not use
|
|
693
|
+
# this tool, and must be invisible in them.
|
|
694
|
+
return 0
|
|
695
|
+
return 0
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def cmd_settle(config_path: str | None, json_output: bool, hook: bool) -> int:
|
|
699
|
+
"""Report the work the dirty set implies, without clearing it."""
|
|
700
|
+
try:
|
|
701
|
+
resolved = _resolve_config(config_path)
|
|
702
|
+
p = settle(resolved)
|
|
703
|
+
except ConfigError as e:
|
|
704
|
+
# See cmd_mark: silence outside a configured repository. A Stop hook
|
|
705
|
+
# that fails everywhere else is a Stop hook nobody keeps.
|
|
706
|
+
#
|
|
707
|
+
# A configuration that exists and does not parse is the opposite case
|
|
708
|
+
# and must not borrow that silence: it would switch the gate off in
|
|
709
|
+
# the one repository that does use the tool, while reporting
|
|
710
|
+
# "Nothing outstanding." As a hook it still allows the stop, because
|
|
711
|
+
# blocking on a broken file traps the agent with no way to satisfy it.
|
|
712
|
+
if e.key != MISSING_KEY:
|
|
713
|
+
print(f"Configuration error: {e}", file=sys.stderr)
|
|
714
|
+
return 0 if hook else 2
|
|
715
|
+
return 0
|
|
716
|
+
|
|
717
|
+
if hook:
|
|
718
|
+
return _settle_as_hook(resolved.resolve().parent, p)
|
|
719
|
+
|
|
720
|
+
if json_output:
|
|
721
|
+
print(json.dumps(_plan_payload(p), indent=2, default=str))
|
|
722
|
+
return 0
|
|
723
|
+
|
|
724
|
+
if not p.stale:
|
|
725
|
+
print("Nothing outstanding.")
|
|
726
|
+
return 0
|
|
727
|
+
for m in p.stale:
|
|
728
|
+
print(f"[{m.reason}] {m.module} ({m.kind}, {m.mode}) → {m.doc_path}")
|
|
729
|
+
return 0
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def _settle_as_hook(repo_root: Path, p: Any) -> int:
|
|
733
|
+
"""The Stop-hook form: exit 2 to block the stop, 0 to allow it.
|
|
734
|
+
|
|
735
|
+
Exit 2 is what makes the specification's "regeneration runs at
|
|
736
|
+
quiescence" true rather than hoped for — Claude Code shows stderr to the
|
|
737
|
+
agent and continues the turn.
|
|
738
|
+
|
|
739
|
+
Claude Code documents no loop guard for `Stop`, so this is ours: after
|
|
740
|
+
`MAX_BLOCKS` attempts on an identical set, step aside. An agent that
|
|
741
|
+
cannot satisfy the plan must not be trapped in a loop with no exit.
|
|
742
|
+
"""
|
|
743
|
+
dirty = state.read(repo_root)
|
|
744
|
+
|
|
745
|
+
if not p.stale:
|
|
746
|
+
if dirty.blocked_signature or dirty.blocked_count:
|
|
747
|
+
dirty.blocked_signature, dirty.blocked_count = None, 0
|
|
748
|
+
state.write(repo_root, dirty)
|
|
749
|
+
return 0
|
|
750
|
+
|
|
751
|
+
signature = hashlib.sha256(
|
|
752
|
+
"\n".join(sorted(m.module for m in p.stale)).encode()
|
|
753
|
+
).hexdigest()
|
|
754
|
+
|
|
755
|
+
if signature != dirty.blocked_signature:
|
|
756
|
+
dirty.blocked_signature, dirty.blocked_count = signature, 1
|
|
757
|
+
elif dirty.blocked_count < 0:
|
|
758
|
+
# Already released for this exact set. Blocking again would be a
|
|
759
|
+
# slower loop rather than no loop; stay out of the way until the work
|
|
760
|
+
# actually changes.
|
|
761
|
+
return 0
|
|
762
|
+
else:
|
|
763
|
+
dirty.blocked_count += 1
|
|
764
|
+
|
|
765
|
+
if dirty.blocked_count > state.MAX_BLOCKS:
|
|
766
|
+
dirty.blocked_count = _RELEASED
|
|
767
|
+
state.write(repo_root, dirty)
|
|
768
|
+
print(
|
|
769
|
+
(
|
|
770
|
+
"constant-docs: documents are still stale after "
|
|
771
|
+
f"{state.MAX_BLOCKS} attempts; not blocking again. Run "
|
|
772
|
+
"`constant-docs settle --json` when you are ready to "
|
|
773
|
+
"regenerate."
|
|
774
|
+
),
|
|
775
|
+
file=sys.stderr,
|
|
776
|
+
)
|
|
777
|
+
return 0
|
|
778
|
+
|
|
779
|
+
state.write(repo_root, dirty)
|
|
780
|
+
lines = [
|
|
781
|
+
(
|
|
782
|
+
"constant-docs: these documents are stale and must be "
|
|
783
|
+
"regenerated before this turn ends."
|
|
784
|
+
),
|
|
785
|
+
"",
|
|
786
|
+
]
|
|
787
|
+
for m in p.stale:
|
|
788
|
+
lines.append(f" {m.module} ({m.kind}, {m.mode}) → {m.doc_path}")
|
|
789
|
+
lines += [
|
|
790
|
+
"",
|
|
791
|
+
(
|
|
792
|
+
"Run `constant-docs settle --json` for each module's file list, "
|
|
793
|
+
"previous body, required headings, and prompt."
|
|
794
|
+
),
|
|
795
|
+
(
|
|
796
|
+
"Then `constant-docs apply <module> <body>` for a replace-mode "
|
|
797
|
+
"document, or `constant-docs append <module> <title> <entry>` "
|
|
798
|
+
"for an append-mode one."
|
|
799
|
+
),
|
|
800
|
+
]
|
|
801
|
+
print("\n".join(lines), file=sys.stderr)
|
|
802
|
+
return 2
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
def cmd_prompt(kind_name: str | None) -> None:
|
|
806
|
+
"""Print the generation conventions for a kind, `module` by default.
|
|
807
|
+
|
|
808
|
+
Falls back to the shipped kinds when there is no configuration, or none
|
|
809
|
+
that can be read, so the command still works outside a repository — a
|
|
810
|
+
harness asking "how do I write one of these" should not first have to find
|
|
811
|
+
a config file, and a command for bootstrapping must not need the thing it
|
|
812
|
+
is bootstrapping.
|
|
813
|
+
"""
|
|
814
|
+
# The default is the `module` kind, resolved below through the
|
|
815
|
+
# configuration like any other, rather than the packaged file read
|
|
816
|
+
# directly. The two forms disagreeing would matter most in exactly the
|
|
817
|
+
# repositories that redeclare `module` — which is the reason to look.
|
|
818
|
+
kind_name = kind_name or DEFAULT_KIND
|
|
819
|
+
|
|
820
|
+
# An authoring guide is not a kind — a README is not a tracked document —
|
|
821
|
+
# but "how do I write one of these" is the same question, so it is the
|
|
822
|
+
# same command.
|
|
823
|
+
guide = guide_path(kind_name)
|
|
824
|
+
if guide.is_file():
|
|
825
|
+
print(guide.read_text(encoding="utf-8"), end="")
|
|
826
|
+
return
|
|
827
|
+
|
|
828
|
+
# The configured kinds first, the shipped ones as the fallback. Reading
|
|
829
|
+
# `BUILTIN_KINDS` alone made the one command whose job is "tell me how to
|
|
830
|
+
# write one of these" unable to answer for a kind the repository declares —
|
|
831
|
+
# and its own error text then told the reader to go and open the prompt
|
|
832
|
+
# file by hand, which is not something a harness can do.
|
|
833
|
+
kinds = dict(BUILTIN_KINDS)
|
|
834
|
+
root = Path.cwd()
|
|
835
|
+
try:
|
|
836
|
+
config = discover_config(None)
|
|
837
|
+
kinds = load_config(config).kinds
|
|
838
|
+
root = config.resolve().parent
|
|
839
|
+
except (ConfigError, OSError):
|
|
840
|
+
# `OSError` as well as `ConfigError`: a configuration the process
|
|
841
|
+
# cannot even open must not take down the one command whose job is to
|
|
842
|
+
# explain how to write a document.
|
|
843
|
+
pass
|
|
844
|
+
|
|
845
|
+
kind = kinds.get(kind_name)
|
|
846
|
+
if kind is None:
|
|
847
|
+
print(
|
|
848
|
+
f"Unknown kind: {kind_name}. Available kinds: "
|
|
849
|
+
f"{', '.join(sorted(kinds))}. Authoring guides: "
|
|
850
|
+
f"{', '.join(guide_names())}.",
|
|
851
|
+
file=sys.stderr,
|
|
852
|
+
)
|
|
853
|
+
sys.exit(2)
|
|
854
|
+
try:
|
|
855
|
+
print(resolve_prompt(kind, root), end="")
|
|
856
|
+
except KindError as e:
|
|
857
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
858
|
+
sys.exit(2)
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
def main(argv: list[str] | None = None) -> int:
|
|
862
|
+
"""Entry point for the ``constant-docs`` console script.
|
|
863
|
+
|
|
864
|
+
Returns an exit code suitable for ``sys.exit()``.
|
|
865
|
+
"""
|
|
866
|
+
if argv is None:
|
|
867
|
+
argv = sys.argv[1:]
|
|
868
|
+
|
|
869
|
+
if not argv or argv[0] in ("-h", "--help"):
|
|
870
|
+
_print_help()
|
|
871
|
+
return 0
|
|
872
|
+
|
|
873
|
+
if argv[0] in ("-V", "--version"):
|
|
874
|
+
print(f"constant-docs {__version__}")
|
|
875
|
+
return 0
|
|
876
|
+
|
|
877
|
+
subcommand = argv[0]
|
|
878
|
+
args = argv[1:]
|
|
879
|
+
|
|
880
|
+
# Handle --help on any subcommand
|
|
881
|
+
if "-h" in args or "--help" in args:
|
|
882
|
+
_print_subcommand_help(subcommand)
|
|
883
|
+
return 0
|
|
884
|
+
|
|
885
|
+
json_output = "--json" in args
|
|
886
|
+
if json_output:
|
|
887
|
+
args.remove("--json")
|
|
888
|
+
|
|
889
|
+
try:
|
|
890
|
+
if subcommand == "verify":
|
|
891
|
+
coverage = "--coverage" in args
|
|
892
|
+
if coverage:
|
|
893
|
+
args.remove("--coverage")
|
|
894
|
+
cmd_verify(args[0] if args else None, json_output, coverage)
|
|
895
|
+
elif subcommand == "plan":
|
|
896
|
+
cmd_plan(args[0] if args else None, json_output)
|
|
897
|
+
elif subcommand == "apply":
|
|
898
|
+
try:
|
|
899
|
+
retire = _take_repeated(args, "--retire")
|
|
900
|
+
except ValueError as e:
|
|
901
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
902
|
+
return 2
|
|
903
|
+
if len(args) < 2:
|
|
904
|
+
print(
|
|
905
|
+
"Usage: constant-docs apply <module> <body> [<config>] "
|
|
906
|
+
"[--retire <id>]...",
|
|
907
|
+
file=sys.stderr,
|
|
908
|
+
)
|
|
909
|
+
return 2
|
|
910
|
+
module_key = args[0]
|
|
911
|
+
body_text = args[1]
|
|
912
|
+
config_path = args[2] if len(args) > 2 else None
|
|
913
|
+
cmd_apply(config_path, module_key, body_text, retire)
|
|
914
|
+
elif subcommand == "add":
|
|
915
|
+
try:
|
|
916
|
+
globs = _take_repeated(args, "--glob")
|
|
917
|
+
covers = _take_repeated(args, "--covers")
|
|
918
|
+
kinds = _take_repeated(args, "--kind")
|
|
919
|
+
except ValueError as e:
|
|
920
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
921
|
+
return 2
|
|
922
|
+
if len(kinds) > 1:
|
|
923
|
+
print("Error: --kind may be given once", file=sys.stderr)
|
|
924
|
+
return 2
|
|
925
|
+
if not args:
|
|
926
|
+
print(
|
|
927
|
+
"Usage: constant-docs add <name> [<config>] "
|
|
928
|
+
"[--glob <pattern>]... [--covers <module>]... [--kind <kind>]",
|
|
929
|
+
file=sys.stderr,
|
|
930
|
+
)
|
|
931
|
+
return 2
|
|
932
|
+
return cmd_add(
|
|
933
|
+
args[1] if len(args) > 1 else None,
|
|
934
|
+
args[0],
|
|
935
|
+
globs,
|
|
936
|
+
covers,
|
|
937
|
+
kinds[0] if kinds else DEFAULT_KIND,
|
|
938
|
+
)
|
|
939
|
+
elif subcommand == "auto":
|
|
940
|
+
return cmd_auto(args[0] if args else None, json_output)
|
|
941
|
+
elif subcommand == "coverage":
|
|
942
|
+
return cmd_coverage(args[0] if args else None, json_output)
|
|
943
|
+
elif subcommand == "init":
|
|
944
|
+
return cmd_init(args[0] if args else None, json_output)
|
|
945
|
+
elif subcommand == "prune":
|
|
946
|
+
cmd_prune(args[0] if args else None)
|
|
947
|
+
elif subcommand == "index":
|
|
948
|
+
cmd_index(args[0] if args else None)
|
|
949
|
+
elif subcommand == "mark":
|
|
950
|
+
return cmd_mark(args)
|
|
951
|
+
elif subcommand == "settle":
|
|
952
|
+
hook = "--hook" in args
|
|
953
|
+
if hook:
|
|
954
|
+
args.remove("--hook")
|
|
955
|
+
return cmd_settle(args[0] if args else None, json_output, hook)
|
|
956
|
+
elif subcommand == "append":
|
|
957
|
+
if len(args) < 3:
|
|
958
|
+
print(
|
|
959
|
+
"Usage: constant-docs append <module> <title> <entry> [<config>]",
|
|
960
|
+
file=sys.stderr,
|
|
961
|
+
)
|
|
962
|
+
return 2
|
|
963
|
+
cmd_append(args[3] if len(args) > 3 else None, args[0], args[1], args[2])
|
|
964
|
+
elif subcommand == "prompt":
|
|
965
|
+
cmd_prompt(args[0] if args else None)
|
|
966
|
+
else:
|
|
967
|
+
print(f"Unknown command: {subcommand}", file=sys.stderr)
|
|
968
|
+
_print_help()
|
|
969
|
+
return 2
|
|
970
|
+
except SystemExit as e:
|
|
971
|
+
# The command handlers report by calling `sys.exit`, which is the
|
|
972
|
+
# right thing when the process is the caller and the wrong thing when
|
|
973
|
+
# a test or an embedder is. `main` is documented as returning an exit
|
|
974
|
+
# code, so it returns one; the console script hands it to the shell.
|
|
975
|
+
return 0 if e.code is None else int(e.code)
|
|
976
|
+
except Exception as e: # noqa: BLE001
|
|
977
|
+
# One place where an exception that reached the top level becomes a
|
|
978
|
+
# code. Two things were wrong here. A `ConfigError` was handled by
|
|
979
|
+
# `_exit_config_error`, which raises `SystemExit` from inside this very
|
|
980
|
+
# except block, where the clause above cannot catch it — so `main`
|
|
981
|
+
# raised where it is documented to return. And everything unhandled was
|
|
982
|
+
# reported as 1, which this tool defines as *drift*: a repository whose
|
|
983
|
+
# parser is crashing was answered by scheduling another regeneration
|
|
984
|
+
# against it, forever.
|
|
985
|
+
if isinstance(e, ConfigError):
|
|
986
|
+
print(f"Configuration error: {e}", file=sys.stderr)
|
|
987
|
+
else:
|
|
988
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
989
|
+
return _exit_code_for(e)
|
|
990
|
+
|
|
991
|
+
return 0
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def _print_help() -> None:
|
|
995
|
+
print(
|
|
996
|
+
"Usage: constant-docs <command> [options]\n"
|
|
997
|
+
"\n"
|
|
998
|
+
"Commands:\n"
|
|
999
|
+
" verify [--json] [<config>] Check for stale/missing/orphan documents\n"
|
|
1000
|
+
" plan [--json] [<config>] List module states\n"
|
|
1001
|
+
" apply <module> <body> [<config>] [--retire <id>]...\n"
|
|
1002
|
+
" Write a document body\n"
|
|
1003
|
+
" append <module> <title> <entry> [<config>] Add one log entry\n"
|
|
1004
|
+
" add <name> [--glob <p>]... [--covers <m>]... [--kind <k>]\n"
|
|
1005
|
+
" Register a document in the configuration\n"
|
|
1006
|
+
" auto [--json] [<config>] Run the configured regeneration command\n"
|
|
1007
|
+
" coverage [--json] [<config>] Report source no module covers\n"
|
|
1008
|
+
" init [--json] [<config>] Gather facts for a new configuration\n"
|
|
1009
|
+
" prune [<config>] Delete orphaned documents\n"
|
|
1010
|
+
" index [<config>] Rebuild the root index from descriptions\n"
|
|
1011
|
+
" mark [<path>...] Add a path's modules to the dirty set\n"
|
|
1012
|
+
" settle [--json] [--hook] Report what the dirty set implies\n"
|
|
1013
|
+
" prompt [<name>] Print a kind's conventions, or a guide\n"
|
|
1014
|
+
"\n"
|
|
1015
|
+
"Options:\n"
|
|
1016
|
+
" --json Output as JSON (verify, plan, settle, coverage, init, auto)\n"
|
|
1017
|
+
" -h, --help This help\n"
|
|
1018
|
+
" -V, --version Show version\n"
|
|
1019
|
+
)
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
def _print_subcommand_help(cmd: str) -> None:
|
|
1023
|
+
"""Print help for a specific subcommand."""
|
|
1024
|
+
helps = {
|
|
1025
|
+
"auto": (
|
|
1026
|
+
"Usage: constant-docs auto [--json] [<config>]\n\n"
|
|
1027
|
+
"Settle, run the command declared under `auto:` in the\n"
|
|
1028
|
+
"configuration, then verify the result. The tool calls no model;\n"
|
|
1029
|
+
"the command does, and its success is not taken on trust.\n\n"
|
|
1030
|
+
"Exits 0 when everything is clean afterwards, 1 when something is\n"
|
|
1031
|
+
"still stale, and 2 when the command could not run or exited\n"
|
|
1032
|
+
"non-zero. The last two must not be confused: a command that ran\n"
|
|
1033
|
+
"and achieved nothing is a working automation with no result.\n"
|
|
1034
|
+
),
|
|
1035
|
+
"coverage": (
|
|
1036
|
+
"Usage: constant-docs coverage [--json] [<config>]\n\n"
|
|
1037
|
+
"Report every file no module covers and no `uncovered:` entry\n"
|
|
1038
|
+
"names, grouped by directory. Exits 0 whatever it finds;\n"
|
|
1039
|
+
"`verify --coverage` is the form that gates.\n"
|
|
1040
|
+
),
|
|
1041
|
+
"init": (
|
|
1042
|
+
"Usage: constant-docs init [--json] [<config>]\n\n"
|
|
1043
|
+
"Gather what is needed to propose a module map, and write a\n"
|
|
1044
|
+
"configuration if there is none. Proposing only: a re-run never\n"
|
|
1045
|
+
"removes or rewrites an existing entry, because dropping a module\n"
|
|
1046
|
+
"orphans its document and `prune` then deletes it.\n"
|
|
1047
|
+
),
|
|
1048
|
+
"verify": "Usage: constant-docs verify [--json] [--coverage] [<config>]\n\nCheck for stale, missing, or orphaned documents.\nExits 0 when everything is current, 1 on drift.\n\nOptions:\n --json Output machine-readable JSON\n",
|
|
1049
|
+
"plan": "Usage: constant-docs plan [--json] [<config>]\n\nList the state of every configured module.\n\nOptions:\n --json Output machine-readable JSON\n",
|
|
1050
|
+
"apply": (
|
|
1051
|
+
"Usage: constant-docs apply <module> <body> [<config>] "
|
|
1052
|
+
"[--retire <id>]...\n\n"
|
|
1053
|
+
"Write a document body for a module.\n\n"
|
|
1054
|
+
"A body that drops a decision the previous body recorded is\n"
|
|
1055
|
+
"refused, naming it. Rewording a decision is always accepted;\n"
|
|
1056
|
+
"only losing one is not. Retire a decision deliberately with\n"
|
|
1057
|
+
"--retire <id>, which records the retirement in the document.\n"
|
|
1058
|
+
"Declaring a retirement that did not happen is refused too, so\n"
|
|
1059
|
+
"the flag cannot be used to switch the check off.\n"
|
|
1060
|
+
),
|
|
1061
|
+
"add": (
|
|
1062
|
+
"Usage: constant-docs add <name> [<config>] [--glob <pattern>]... "
|
|
1063
|
+
"[--covers <module>]... [--kind <kind>]\n\n"
|
|
1064
|
+
"Register a document in the configuration, so that adding one is a\n"
|
|
1065
|
+
"command rather than a hand edit.\n\n"
|
|
1066
|
+
"--covers names other modules whose files this document also takes\n"
|
|
1067
|
+
"in, transitively, so a cross-cutting document follows when their\n"
|
|
1068
|
+
"globs move instead of restating them. --glob and --covers compose.\n\n"
|
|
1069
|
+
"A name that already exists is refused. The result is validated by\n"
|
|
1070
|
+
"loading it, and the file is restored untouched if it does not\n"
|
|
1071
|
+
"load.\n"
|
|
1072
|
+
),
|
|
1073
|
+
"prune": "Usage: constant-docs prune [<config>]\n\nDelete orphaned documents and empty directories.\n",
|
|
1074
|
+
"mark": "Usage: constant-docs mark [<path>...]\n\nAdd every module the given paths belong to, to `.constant-docs/dirty.json`.\nPass paths as arguments from any harness. With no arguments, reads a\nPostToolUse hook payload from stdin, the shape Claude Code sends.\nSilent and exit 0 when a path matches nothing, or outside a configured\nrepository.\n",
|
|
1075
|
+
"settle": "Usage: constant-docs settle [--json] [--hook]\n\nReport the work the dirty set implies. The set is NOT cleared by being\nread; it clears on the apply or append that satisfies each module.\n\nOptions:\n --json Machine-readable plan, the same structure `plan --json` emits\n --hook Stop-hook form: exit 2 with instructions on stderr when work is\n outstanding, exit 0 when it is not\n",
|
|
1076
|
+
"index": "Usage: constant-docs index [<config>]\n\nRebuild <docs_root>/index.md from every document's description.\nLeaves the file byte-identical when nothing changed.\n",
|
|
1077
|
+
"append": "Usage: constant-docs append <module> <title> <entry> [<config>]\n\nAdd one entry to an append-mode document. The title becomes the entry's\nH2 heading. Nothing already written is rewritten or reordered.\n",
|
|
1078
|
+
"prompt": (
|
|
1079
|
+
"Usage: constant-docs prompt [<name>]\n\n"
|
|
1080
|
+
"Print a kind's generation conventions, or a shipped authoring\n"
|
|
1081
|
+
"guide. Defaults to the `module` kind.\n"
|
|
1082
|
+
),
|
|
1083
|
+
}
|
|
1084
|
+
print(helps.get(cmd, f"Unknown command: {cmd}\n"))
|
|
1085
|
+
print("See `constant-docs --help` for all commands.")
|