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/kinds.py
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""Document kinds — what shape a document is, and how a regeneration writes it.
|
|
2
|
+
|
|
3
|
+
A kind decides four things: the sections a body must carry, the OKF ``type``
|
|
4
|
+
the document declares, the prompt a generator is handed, and whether a
|
|
5
|
+
regeneration replaces the body or appends an entry to it.
|
|
6
|
+
|
|
7
|
+
Seven kinds ship. Any of them may be redeclared in configuration, and new ones
|
|
8
|
+
declared alongside. ``module`` is the default, so a configuration that has
|
|
9
|
+
never heard of kinds loads unchanged.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Literal, get_args
|
|
17
|
+
|
|
18
|
+
from constant_docs.document import FRONTMATTER_STYLES, YAML_FRONTMATTER
|
|
19
|
+
|
|
20
|
+
Mode = Literal["replace", "append"]
|
|
21
|
+
MODES: tuple[str, ...] = get_args(Mode)
|
|
22
|
+
|
|
23
|
+
_PROMPT_DIR = Path(__file__).parent / "prompts"
|
|
24
|
+
# Authoring conventions for documents this tool does not track. A README and a
|
|
25
|
+
# quickstart are written once and revised by judgement — what to lead with,
|
|
26
|
+
# what to leave out, who the reader is — so making them tracked documents would
|
|
27
|
+
# regenerate prose whose quality never depended on the code. The conventions
|
|
28
|
+
# are still worth shipping, on the same shelf as the per-kind prompts.
|
|
29
|
+
#
|
|
30
|
+
# A skill makes a document good. It does not make it fresh: nothing fires when
|
|
31
|
+
# the CLI moves underneath a README written six months ago. That is what the
|
|
32
|
+
# CLI reference is for, and why these sit beside it rather than replacing it.
|
|
33
|
+
_GUIDE_DIR = Path(__file__).parent / "guides"
|
|
34
|
+
# One home for the vocabulary rules every generated document obeys. Appended to
|
|
35
|
+
# the built-in prompts rather than written into each of the seven, because
|
|
36
|
+
# seven copies of a house style is the drift this tool exists to prevent: the
|
|
37
|
+
# copy nobody edits is the one that goes stale, and here that failure is silent
|
|
38
|
+
# because a prompt has no source to fall behind.
|
|
39
|
+
#
|
|
40
|
+
# A project naming its own prompt file gets exactly what it wrote. Carrying
|
|
41
|
+
# your own conventions without forking the tool has to mean your conventions,
|
|
42
|
+
# or the escape hatch is not one.
|
|
43
|
+
_HOUSE_STYLE = Path(__file__).parent / "house-style.md"
|
|
44
|
+
_BUILTIN_PREFIX = "builtin:"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class KindError(Exception):
|
|
48
|
+
"""A kind is declared wrongly, or its prompt cannot be resolved."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# The content checks a kind's documents can be subject to. Named here so a
|
|
52
|
+
# kind can carry one as data; `checks` implements them.
|
|
53
|
+
ARCHITECTURE_CHECK = "architecture"
|
|
54
|
+
ERRORS_CHECK = "errors"
|
|
55
|
+
CHECKS = (ARCHITECTURE_CHECK, ERRORS_CHECK)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class Kind:
|
|
60
|
+
"""One document shape."""
|
|
61
|
+
|
|
62
|
+
name: str
|
|
63
|
+
sections: list[str] # H2 headings, as written in configuration: "## Purpose"
|
|
64
|
+
mode: Mode
|
|
65
|
+
prompt: str # "builtin:<name>", or a repository-relative path
|
|
66
|
+
type: str # the OKF `type` value this kind's documents declare
|
|
67
|
+
# The content check these documents must pass, if any.
|
|
68
|
+
#
|
|
69
|
+
# A field rather than the kind's *name* compared in three places. The name
|
|
70
|
+
# was the earlier reading and it made the check stop following the kind: a
|
|
71
|
+
# project declaring `house-architecture` with `prompt: builtin:architecture`
|
|
72
|
+
# got the prompt and the type and silently lost the diagram guard, in both
|
|
73
|
+
# the writer and the reader, while `verify` went on passing.
|
|
74
|
+
check: str | None = None
|
|
75
|
+
# How the tool's block is written into this kind's documents: as YAML
|
|
76
|
+
# frontmatter, or as an HTML comment a Markdown host renders as nothing.
|
|
77
|
+
#
|
|
78
|
+
# A field on the kind rather than a rule inferred from where the document
|
|
79
|
+
# lands. Switching a file's format because it moved is the kind of thing
|
|
80
|
+
# that surprises somebody a year later, and the two forms are not equally
|
|
81
|
+
# good: YAML frontmatter is what makes the docs tree an Open Knowledge
|
|
82
|
+
# Format bundle, so it stays the default and the comment form is asked
|
|
83
|
+
# for by name.
|
|
84
|
+
frontmatter: str = YAML_FRONTMATTER
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# The shipped kinds. Each carries defaults rather than being abstract, so
|
|
88
|
+
# `kind: spec` works without anyone writing a prompt first; a project that
|
|
89
|
+
# wants different sections redeclares the kind in configuration.
|
|
90
|
+
#
|
|
91
|
+
# Two kinds have their *content* checked rather than only hashed, and both
|
|
92
|
+
# checks live in `checks`. An `architecture` diagram may name only modules the
|
|
93
|
+
# configuration declares. An `errors` catalogue must agree with the source in
|
|
94
|
+
# both directions: every message raised is listed, and every entry listed is
|
|
95
|
+
# still raised.
|
|
96
|
+
BUILTIN_KINDS: dict[str, Kind] = {
|
|
97
|
+
"module": Kind(
|
|
98
|
+
name="module",
|
|
99
|
+
sections=["## Purpose", "## Correctness pillars", "## Known failure modes"],
|
|
100
|
+
mode="replace",
|
|
101
|
+
prompt="builtin:module",
|
|
102
|
+
type="Code Module",
|
|
103
|
+
),
|
|
104
|
+
"spec": Kind(
|
|
105
|
+
name="spec",
|
|
106
|
+
sections=["## Purpose", "## Design decisions", "## Acceptance criteria"],
|
|
107
|
+
mode="replace",
|
|
108
|
+
prompt="builtin:spec",
|
|
109
|
+
type="Specification",
|
|
110
|
+
),
|
|
111
|
+
"architecture": Kind(
|
|
112
|
+
name="architecture",
|
|
113
|
+
sections=["## The parts", "## How they meet", "## Boundaries"],
|
|
114
|
+
mode="replace",
|
|
115
|
+
prompt="builtin:architecture",
|
|
116
|
+
type="Architecture",
|
|
117
|
+
check=ARCHITECTURE_CHECK,
|
|
118
|
+
),
|
|
119
|
+
"errors": Kind(
|
|
120
|
+
name="errors",
|
|
121
|
+
sections=["## Purpose", "## The catalogue", "## What is not an error"],
|
|
122
|
+
mode="replace",
|
|
123
|
+
prompt="builtin:errors",
|
|
124
|
+
type="Error Catalogue",
|
|
125
|
+
check=ERRORS_CHECK,
|
|
126
|
+
),
|
|
127
|
+
"config-reference": Kind(
|
|
128
|
+
name="config-reference",
|
|
129
|
+
sections=["## Purpose", "## Keys", "## What is refused"],
|
|
130
|
+
mode="replace",
|
|
131
|
+
prompt="builtin:config-reference",
|
|
132
|
+
type="Configuration Reference",
|
|
133
|
+
),
|
|
134
|
+
"cli-reference": Kind(
|
|
135
|
+
name="cli-reference",
|
|
136
|
+
sections=["## Purpose", "## Commands", "## Flags"],
|
|
137
|
+
mode="replace",
|
|
138
|
+
prompt="builtin:cli-reference",
|
|
139
|
+
type="CLI Reference",
|
|
140
|
+
),
|
|
141
|
+
"log": Kind(
|
|
142
|
+
name="log",
|
|
143
|
+
sections=["## Entries"],
|
|
144
|
+
mode="append",
|
|
145
|
+
prompt="builtin:log",
|
|
146
|
+
type="Log",
|
|
147
|
+
),
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
DEFAULT_KIND = "module"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def load_kinds(raw: Any) -> dict[str, Kind]:
|
|
154
|
+
"""Merge a configuration's ``kinds`` block over the shipped defaults.
|
|
155
|
+
|
|
156
|
+
Raises :exc:`KindError` naming the offending kind. The caller turns that
|
|
157
|
+
into a `ConfigError`, because from the outside this is a configuration
|
|
158
|
+
problem and criterion 21 requires the offending key to be named.
|
|
159
|
+
"""
|
|
160
|
+
kinds = dict(BUILTIN_KINDS)
|
|
161
|
+
if raw is None:
|
|
162
|
+
return kinds
|
|
163
|
+
if not isinstance(raw, dict):
|
|
164
|
+
raise KindError(
|
|
165
|
+
f"'kinds' must be a mapping of kind name to declaration, "
|
|
166
|
+
f"got {type(raw).__name__}"
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
for name, decl in raw.items():
|
|
170
|
+
name = str(name)
|
|
171
|
+
if not isinstance(decl, dict):
|
|
172
|
+
raise KindError(
|
|
173
|
+
f"Kind {name!r} must be a mapping with 'sections' and 'mode', "
|
|
174
|
+
f"got {type(decl).__name__}"
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
base = kinds.get(name)
|
|
178
|
+
|
|
179
|
+
sections_raw = decl.get("sections", base.sections if base else None)
|
|
180
|
+
if not isinstance(sections_raw, list) or not sections_raw:
|
|
181
|
+
raise KindError(
|
|
182
|
+
f"Kind {name!r} needs 'sections': a non-empty list of H2 "
|
|
183
|
+
f"headings, e.g. ['## Purpose', '## Entries']"
|
|
184
|
+
)
|
|
185
|
+
sections = [_normalise_heading(name, s) for s in sections_raw]
|
|
186
|
+
|
|
187
|
+
mode = str(decl.get("mode", base.mode if base else "replace"))
|
|
188
|
+
if mode not in MODES:
|
|
189
|
+
raise KindError(
|
|
190
|
+
f"Kind {name!r} has mode {mode!r}; it must be one of "
|
|
191
|
+
f"{', '.join(sorted(MODES))}"
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
prompt = str(decl.get("prompt", base.prompt if base else f"builtin:{name}"))
|
|
195
|
+
# Title case rather than the bare name, because this becomes the OKF
|
|
196
|
+
# `type` a reader sees at the top of the document.
|
|
197
|
+
fallback_type = base.type if base else name.replace("_", " ").title()
|
|
198
|
+
doc_type = str(decl.get("type", fallback_type))
|
|
199
|
+
if not doc_type.strip():
|
|
200
|
+
raise KindError(f"Kind {name!r} declares an empty 'type'")
|
|
201
|
+
|
|
202
|
+
# Inherited from the kind being redeclared, so overriding an
|
|
203
|
+
# `architecture` kind's sections does not quietly drop its diagram
|
|
204
|
+
# guard, and declarable outright so a project can name its own.
|
|
205
|
+
check = decl.get("check", base.check if base else None)
|
|
206
|
+
if check is not None:
|
|
207
|
+
check = str(check)
|
|
208
|
+
if check not in CHECKS:
|
|
209
|
+
raise KindError(
|
|
210
|
+
f"Kind {name!r} declares check {check!r}, which this "
|
|
211
|
+
f"version does not implement. Available: "
|
|
212
|
+
f"{', '.join(sorted(CHECKS))}"
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
frontmatter = str(
|
|
216
|
+
decl.get("frontmatter", base.frontmatter if base else YAML_FRONTMATTER)
|
|
217
|
+
)
|
|
218
|
+
if frontmatter not in FRONTMATTER_STYLES:
|
|
219
|
+
raise KindError(
|
|
220
|
+
f"Kind {name!r} declares frontmatter {frontmatter!r}, which is "
|
|
221
|
+
f"not a form this version writes. Available: "
|
|
222
|
+
f"{', '.join(FRONTMATTER_STYLES)}"
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
kinds[name] = Kind(
|
|
226
|
+
name=name,
|
|
227
|
+
sections=sections,
|
|
228
|
+
mode=mode, # type: ignore[arg-type]
|
|
229
|
+
prompt=prompt,
|
|
230
|
+
type=doc_type,
|
|
231
|
+
check=check,
|
|
232
|
+
frontmatter=frontmatter,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
return kinds
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _normalise_heading(kind_name: str, raw: Any) -> str:
|
|
239
|
+
"""Return a section as ``"## Heading"``, whichever form was written.
|
|
240
|
+
|
|
241
|
+
Configuration examples show the hashes, and a heading written with the
|
|
242
|
+
wrong number of them — or none — is a typo rather than a different
|
|
243
|
+
heading. Accepting every form costs one line and removes a class of silent
|
|
244
|
+
mismatch where a body is rejected for a section its author believes they
|
|
245
|
+
declared. Sections are always H2: the title is a frontmatter key, so a
|
|
246
|
+
body carries no H1.
|
|
247
|
+
"""
|
|
248
|
+
text = str(raw).strip()
|
|
249
|
+
if not text:
|
|
250
|
+
raise KindError(f"Kind {kind_name!r} declares an empty section heading")
|
|
251
|
+
if text.startswith("#"):
|
|
252
|
+
text = text.lstrip("#").strip()
|
|
253
|
+
if not text:
|
|
254
|
+
raise KindError(f"Kind {kind_name!r} declares a section heading with no text")
|
|
255
|
+
return f"## {text}"
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def guide_names() -> list[str]:
|
|
259
|
+
"""Return the authoring guides this release ships, sorted."""
|
|
260
|
+
return sorted(p.stem for p in _GUIDE_DIR.glob("*.md"))
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def guide_path(name: str) -> Path:
|
|
264
|
+
"""Return the path to a shipped authoring guide, whether or not it exists."""
|
|
265
|
+
return _GUIDE_DIR / f"{name}.md"
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _with_house_style(prompt: str) -> str:
|
|
269
|
+
"""Append the shared vocabulary rules to a built-in prompt.
|
|
270
|
+
|
|
271
|
+
Separated by a rule and carrying no heading of its own, because every
|
|
272
|
+
built-in prompt tells the generator to emit an exact set of H2 sections
|
|
273
|
+
and an H2 here would read as one more of them.
|
|
274
|
+
"""
|
|
275
|
+
house = _HOUSE_STYLE.read_text(encoding="utf-8")
|
|
276
|
+
return f"{prompt.rstrip()}\n\n---\n\n{house.strip()}\n"
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def resolve_prompt(kind: Kind, repo_root: Path) -> str:
|
|
280
|
+
"""Return the generation conventions for *kind*.
|
|
281
|
+
|
|
282
|
+
``builtin:<name>`` reads the file shipped inside the package and appends
|
|
283
|
+
the shared house style; anything else is a repository-relative path,
|
|
284
|
+
returned verbatim, so a project can carry its own conventions without
|
|
285
|
+
forking the tool.
|
|
286
|
+
"""
|
|
287
|
+
if kind.prompt.startswith(_BUILTIN_PREFIX):
|
|
288
|
+
name = kind.prompt[len(_BUILTIN_PREFIX) :]
|
|
289
|
+
path = _PROMPT_DIR / f"{name}.md"
|
|
290
|
+
if not path.is_file():
|
|
291
|
+
raise KindError(
|
|
292
|
+
f"Kind {kind.name!r} names built-in prompt {kind.prompt!r}, "
|
|
293
|
+
f"which this version does not ship. Available: "
|
|
294
|
+
f"{', '.join(sorted(p.stem for p in _PROMPT_DIR.glob('*.md')))}"
|
|
295
|
+
)
|
|
296
|
+
return _with_house_style(path.read_text(encoding="utf-8"))
|
|
297
|
+
|
|
298
|
+
path = repo_root / kind.prompt
|
|
299
|
+
if not path.is_file():
|
|
300
|
+
raise KindError(
|
|
301
|
+
f"Kind {kind.name!r} names prompt file {kind.prompt!r}, "
|
|
302
|
+
f"which does not exist under {repo_root}"
|
|
303
|
+
)
|
|
304
|
+
return path.read_text(encoding="utf-8")
|
constant_docs/paths.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""Module-to-document path mapping, orphan detection, and ownership.
|
|
2
|
+
|
|
3
|
+
The path mapping is pure structure. Orphan detection is not: deciding whether
|
|
4
|
+
a document is orphaned means opening it, because the question *"should this be
|
|
5
|
+
deleted"* now depends on whether this tool wrote it in the first place.
|
|
6
|
+
|
|
7
|
+
That is the deletion guard. `prune` deletes what :func:`orphans` reports, and
|
|
8
|
+
before 0.3.1 that was every markdown file under the docs root no live module
|
|
9
|
+
claimed — including files the tool had never touched.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import yaml
|
|
18
|
+
|
|
19
|
+
from constant_docs.config import Config, document_path, is_tool_owned
|
|
20
|
+
from constant_docs.document import DocumentError, extension_block, is_tracked
|
|
21
|
+
from constant_docs.document import load as load_document
|
|
22
|
+
from constant_docs.fingerprint import compute
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class OrphanReport:
|
|
27
|
+
"""A document whose module glob no longer matches any files."""
|
|
28
|
+
|
|
29
|
+
# Repository-relative, like `ModulePlan.doc_path` and every other path this
|
|
30
|
+
# package hands across a module boundary. The caller joins it onto the
|
|
31
|
+
# repository root at the moment it needs a real file.
|
|
32
|
+
#
|
|
33
|
+
# Held absolute once, and it leaked: this is the value `verify --json` and
|
|
34
|
+
# `plan --json` serialise, so a report carried the machine's own directory
|
|
35
|
+
# layout and two runs of the same repository on different machines were not
|
|
36
|
+
# comparable — while `stale[].doc_path` beside it stayed relative. The rule
|
|
37
|
+
# is the fix, not a formatter at each of the six places that print it: a
|
|
38
|
+
# path that travels is relative, an absolute one is built where it is used
|
|
39
|
+
# and does not outlive the call.
|
|
40
|
+
doc_path: Path
|
|
41
|
+
module_key: str
|
|
42
|
+
# Set only when exactly one candidate matched. Several equally-good
|
|
43
|
+
# candidates is not a weaker answer, it is a different one: naming any of
|
|
44
|
+
# them would be a guess presented as a fact.
|
|
45
|
+
probable_move: str | None = None
|
|
46
|
+
probable_moves: list[str] = field(default_factory=list)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def is_ours(doc: Path) -> bool:
|
|
50
|
+
"""Return whether *doc* is a document this tool wrote.
|
|
51
|
+
|
|
52
|
+
A file that cannot be opened or cannot be parsed is **not** ours. That is
|
|
53
|
+
the safe direction and it is also the honest one: a file we cannot read is
|
|
54
|
+
the last thing that should be deleted on our say-so.
|
|
55
|
+
|
|
56
|
+
`ValueError` is in the tuple for `UnicodeDecodeError`, which subclasses it
|
|
57
|
+
rather than `OSError`. A single non-UTF-8 `.md` under the docs root used
|
|
58
|
+
to raise straight out of here, and out of `prune` after the deletions had
|
|
59
|
+
already happened.
|
|
60
|
+
"""
|
|
61
|
+
try:
|
|
62
|
+
return is_tracked(load_document(doc).frontmatter)
|
|
63
|
+
except (OSError, DocumentError, yaml.YAMLError, ValueError):
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def foreign(config: Config, repo_root: Path | None = None) -> list[Path]:
|
|
68
|
+
"""Return every markdown file under the docs root this tool did not write.
|
|
69
|
+
|
|
70
|
+
Reported, never deleted. `prune` names each one, because silence there
|
|
71
|
+
reads as *"there was nothing else"* — and the thing being claimed is that
|
|
72
|
+
a command which just deleted files left these alone.
|
|
73
|
+
|
|
74
|
+
The tool's own furniture is excluded — `index.md` carries no block by
|
|
75
|
+
design — and `config.is_tool_owned` is the one place that says so, for
|
|
76
|
+
all of the scanners that need to know.
|
|
77
|
+
"""
|
|
78
|
+
docs_root = (repo_root or Path.cwd()) / config.docs_root
|
|
79
|
+
if not docs_root.is_dir():
|
|
80
|
+
return []
|
|
81
|
+
return [
|
|
82
|
+
doc
|
|
83
|
+
for doc in sorted(docs_root.rglob("*.md"))
|
|
84
|
+
if not is_tool_owned(doc, docs_root) and not is_ours(doc)
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def doc_path(module_key: str, docs_root: Path, document: str | None = None) -> Path:
|
|
89
|
+
"""Return the document path for a module.
|
|
90
|
+
|
|
91
|
+
``doc_path("src/payments", Path("docs"))`` → ``Path("docs/src/payments.md")``
|
|
92
|
+
|
|
93
|
+
One definition, in `config`, because the loader refuses two keys that
|
|
94
|
+
resolve to one path and that check must agree with this mapping exactly.
|
|
95
|
+
|
|
96
|
+
Prefer :func:`doc_path_for` where a configuration is in hand. This
|
|
97
|
+
spelling cannot see a module's declared `document`, so passing only a key
|
|
98
|
+
silently writes to the derived path — which is the whole failure the
|
|
99
|
+
declaration exists to avoid.
|
|
100
|
+
"""
|
|
101
|
+
return document_path(module_key, docs_root, document)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def doc_path_for(config: Config, module_key: str) -> Path:
|
|
105
|
+
"""Return the document path for *module_key* under *config*.
|
|
106
|
+
|
|
107
|
+
The spelling to reach for. A module may declare where its document is
|
|
108
|
+
written, and a caller holding only the key and the docs root cannot know
|
|
109
|
+
that — so every path the tool reads or writes resolves through here, and
|
|
110
|
+
a module the configuration does not declare falls back to the derived
|
|
111
|
+
path rather than raising, because callers already handle a missing file.
|
|
112
|
+
"""
|
|
113
|
+
for mod in config.modules:
|
|
114
|
+
if mod.key == module_key:
|
|
115
|
+
return document_path(module_key, config.docs_root, mod.document)
|
|
116
|
+
return document_path(module_key, config.docs_root)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def module_for(doc: Path, docs_root: Path) -> str | None:
|
|
120
|
+
"""Inverse of :func:`doc_path`.
|
|
121
|
+
|
|
122
|
+
Returns the module key, or ``None`` if *doc* is not under *docs_root*.
|
|
123
|
+
"""
|
|
124
|
+
try:
|
|
125
|
+
rel = doc.relative_to(docs_root)
|
|
126
|
+
except ValueError:
|
|
127
|
+
return None
|
|
128
|
+
if rel.suffix != ".md":
|
|
129
|
+
return None
|
|
130
|
+
return rel.with_suffix("").as_posix()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def orphans(config: Config, repo_root: Path | None = None) -> list[OrphanReport]:
|
|
134
|
+
"""Return orphan reports for documents whose module is no longer active.
|
|
135
|
+
|
|
136
|
+
A module is active when its globs still match at least one file.
|
|
137
|
+
*repo_root* is the parent of the config file; defaults to CWD.
|
|
138
|
+
|
|
139
|
+
When two orphan documents carry the same ``source_hash``, they are
|
|
140
|
+
reported as a probable move (AC 15).
|
|
141
|
+
"""
|
|
142
|
+
reports: list[OrphanReport] = []
|
|
143
|
+
root = repo_root or Path.cwd()
|
|
144
|
+
docs_root = root / config.docs_root
|
|
145
|
+
if not docs_root.is_dir():
|
|
146
|
+
return reports
|
|
147
|
+
|
|
148
|
+
# Every document belonging to a live module, by path rather than by key.
|
|
149
|
+
#
|
|
150
|
+
# The key-to-path mapping appends `.md` and its inverse strips it, so a
|
|
151
|
+
# module keyed `SPEC.md` writes `docs/SPEC.md` and reads back as `SPEC` —
|
|
152
|
+
# a key that is not configured, so its own fresh document was reported
|
|
153
|
+
# orphaned and deleted by `prune`. Stating the guard as "this path belongs
|
|
154
|
+
# to a live module" rather than trusting the round trip closes it for every
|
|
155
|
+
# key shape, including ones nobody has thought of yet.
|
|
156
|
+
# Joined onto the repository root, not `docs_root.parent`: `docs_root` is
|
|
157
|
+
# already `root / config.docs_root`, so `.parent` strips only its last
|
|
158
|
+
# segment and a docs root of `docs/modules` produced
|
|
159
|
+
# `<repo>/docs/docs/modules/…`, a path nothing can match. The guard was
|
|
160
|
+
# then inert for every nested docs root — silently, and in the direction
|
|
161
|
+
# that deletes.
|
|
162
|
+
live_documents = {
|
|
163
|
+
root / doc_path_for(config, key)
|
|
164
|
+
for key, files in config.module_files.items()
|
|
165
|
+
if files
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
# Collect basic orphan reports
|
|
169
|
+
for doc_file in sorted(docs_root.rglob("*.md")):
|
|
170
|
+
if is_tool_owned(doc_file, docs_root):
|
|
171
|
+
continue
|
|
172
|
+
if doc_file in live_documents:
|
|
173
|
+
continue
|
|
174
|
+
mod_key = module_for(doc_file, docs_root)
|
|
175
|
+
if mod_key is None:
|
|
176
|
+
continue
|
|
177
|
+
# A document is ours only if it carries our frontmatter block.
|
|
178
|
+
#
|
|
179
|
+
# This is the deletion guard, and it is one predicate because the bug
|
|
180
|
+
# was one missing question. `prune` deletes what this function reports,
|
|
181
|
+
# and this function used to report every `.md` under the docs root that
|
|
182
|
+
# no live module claimed — so a hand-written decision record sitting in
|
|
183
|
+
# somebody's existing `docs/` folder was, to this tool, refuse.
|
|
184
|
+
#
|
|
185
|
+
# Separate from the conformance rule. Everything under the docs root is
|
|
186
|
+
# still *checked* as a tracked document, and a foreign file still makes
|
|
187
|
+
# `verify` exit 1. Refusing to check a file and deleting it are
|
|
188
|
+
# different acts, and only one of them is recoverable.
|
|
189
|
+
if not is_ours(doc_file):
|
|
190
|
+
continue
|
|
191
|
+
if mod_key not in config.module_files or not config.module_files[mod_key]:
|
|
192
|
+
reports.append(
|
|
193
|
+
OrphanReport(
|
|
194
|
+
doc_path=doc_file.relative_to(root),
|
|
195
|
+
module_key=mod_key,
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
# AC 15 — a document is orphaned and an *undocumented module* carries the
|
|
200
|
+
# same source_hash: that pair is a probable move.
|
|
201
|
+
#
|
|
202
|
+
# Matching orphans against other orphans was the earlier reading and is
|
|
203
|
+
# not what the criterion says: two dead documents agreeing tells you
|
|
204
|
+
# nothing, because neither module exists any more. The useful signal is an
|
|
205
|
+
# orphaned document whose content hash matches a module that is configured
|
|
206
|
+
# but has no document yet — the module was renamed and its prose can follow.
|
|
207
|
+
#
|
|
208
|
+
# Note the reach of this: the digest covers file paths, so moving files to
|
|
209
|
+
# a new directory changes the hash and is NOT detectable here. What this
|
|
210
|
+
# catches is a module key or docs-root rename, where the files stay put.
|
|
211
|
+
#
|
|
212
|
+
# Keyed by digest to a LIST, not to one key. Two configured modules can
|
|
213
|
+
# legitimately cover the same files — a module document and the
|
|
214
|
+
# specification that governs it — and a dict would silently keep whichever
|
|
215
|
+
# was iterated last, returning it as though it were the only answer.
|
|
216
|
+
undocumented: dict[str, list[str]] = {}
|
|
217
|
+
for mod_key, files in sorted(config.module_files.items()):
|
|
218
|
+
if not files:
|
|
219
|
+
continue
|
|
220
|
+
if (root / doc_path_for(config, mod_key)).exists():
|
|
221
|
+
continue
|
|
222
|
+
try:
|
|
223
|
+
undocumented.setdefault(compute(files, root=root), []).append(mod_key)
|
|
224
|
+
except OSError:
|
|
225
|
+
continue
|
|
226
|
+
|
|
227
|
+
final_reports: list[OrphanReport] = []
|
|
228
|
+
for orphan in reports:
|
|
229
|
+
candidates: list[str] = []
|
|
230
|
+
try:
|
|
231
|
+
doc = load_document(root / orphan.doc_path)
|
|
232
|
+
stored = str(extension_block(doc.frontmatter).get("source_hash", ""))
|
|
233
|
+
if stored:
|
|
234
|
+
candidates = sorted(undocumented.get(stored, []))
|
|
235
|
+
except (OSError, DocumentError):
|
|
236
|
+
pass
|
|
237
|
+
final_reports.append(
|
|
238
|
+
OrphanReport(
|
|
239
|
+
doc_path=orphan.doc_path,
|
|
240
|
+
module_key=orphan.module_key,
|
|
241
|
+
probable_move=candidates[0] if len(candidates) == 1 else None,
|
|
242
|
+
probable_moves=candidates,
|
|
243
|
+
)
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
return final_reports
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
Read the source files this document covers, then write its body.
|
|
2
|
+
|
|
3
|
+
An architecture document answers *"what are the parts and how do they meet"*.
|
|
4
|
+
It is not a specification: a specification says what is promised, this says what
|
|
5
|
+
exists. Someone should be able to read it in five minutes and know where to go
|
|
6
|
+
next, without reading the specification at all.
|
|
7
|
+
|
|
8
|
+
Output the body only: three H2 sections, in this order, nothing before or
|
|
9
|
+
after, no code fences around the whole thing. Do not write an H1 — the
|
|
10
|
+
document's title is a frontmatter key, not a heading.
|
|
11
|
+
|
|
12
|
+
## The parts
|
|
13
|
+
|
|
14
|
+
A table of module, responsibility, and public interface — one row per module,
|
|
15
|
+
the responsibility in one line, the interface as the names a caller actually
|
|
16
|
+
uses. Then a Mermaid diagram of how they depend on one another.
|
|
17
|
+
|
|
18
|
+
Write the diagram inside a fenced block tagged `mermaid`, as a `graph` or
|
|
19
|
+
`flowchart` with a direction:
|
|
20
|
+
|
|
21
|
+
```mermaid
|
|
22
|
+
graph TD
|
|
23
|
+
cli["cli"] --> api["api"]
|
|
24
|
+
api --> config["config"]
|
|
25
|
+
harness(A coding agent) --> cli
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
**A quoted node label names a module and is checked against the
|
|
29
|
+
configuration.** Use the module's configured key, or its last path segment
|
|
30
|
+
when that is unambiguous. Anything that is not a module — a harness, a
|
|
31
|
+
filesystem, a person — takes an unquoted label, so an honest picture can show
|
|
32
|
+
things the configuration has never heard of.
|
|
33
|
+
|
|
34
|
+
Naming a module that does not exist is refused, which is the one guard
|
|
35
|
+
available here and the way these documents actually rot: a module is renamed
|
|
36
|
+
and the picture still shows the old name.
|
|
37
|
+
|
|
38
|
+
## How they meet
|
|
39
|
+
|
|
40
|
+
The paths through the system, in prose. Which module calls which, what crosses
|
|
41
|
+
each boundary, and where a rule is enforced exactly once. Name anything that
|
|
42
|
+
must have a single implementation and say what breaks if it gets a second.
|
|
43
|
+
|
|
44
|
+
## Boundaries
|
|
45
|
+
|
|
46
|
+
What each part refuses to know. Layering that is deliberate, direction of
|
|
47
|
+
dependency, and what is hidden behind an interface on purpose. A reader
|
|
48
|
+
deciding where to add something needs this more than anything else in the
|
|
49
|
+
document.
|
|
50
|
+
|
|
51
|
+
Rules:
|
|
52
|
+
|
|
53
|
+
- The diagram is your reading of the code, not something derived from it. This
|
|
54
|
+
tool parses no source — no AST, no import graph — so the picture is prose in
|
|
55
|
+
another form. Do not imply otherwise, and do not draw an edge you cannot
|
|
56
|
+
point to in the files given.
|
|
57
|
+
- Never restate a signature or a parameter list. Name the interface; the
|
|
58
|
+
reader has the source.
|
|
59
|
+
- When a previous body is supplied, edit it. The module map changes only when
|
|
60
|
+
a boundary moves, so most regenerations should change very little.
|
|
61
|
+
- Plain British English. No preamble, no summary of what you did.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
Read the source files this document covers, then write its body.
|
|
2
|
+
|
|
3
|
+
A CLI reference answers *"what can I run, and what will it print"*. It is **the
|
|
4
|
+
single home for commands**: the README and the skill point here rather than
|
|
5
|
+
restating them, so this is the only place a command example is allowed to go
|
|
6
|
+
stale.
|
|
7
|
+
|
|
8
|
+
Output the body only: three H2 sections, in this order, nothing before or
|
|
9
|
+
after, no code fences around the whole thing. Do not write an H1 — the
|
|
10
|
+
document's title is a frontmatter key, not a heading.
|
|
11
|
+
|
|
12
|
+
## Purpose
|
|
13
|
+
|
|
14
|
+
What the command is called, how it is invoked, and how it finds its
|
|
15
|
+
configuration. Two to four sentences.
|
|
16
|
+
|
|
17
|
+
## Commands
|
|
18
|
+
|
|
19
|
+
Every subcommand: what it does, its arguments in order, and what it writes to
|
|
20
|
+
standard output. One H3 per command, with a fenced example that would actually
|
|
21
|
+
run. Say which commands write to the repository and which do not, because that
|
|
22
|
+
is the first thing a reader wants and the last thing a usage string tells them.
|
|
23
|
+
|
|
24
|
+
## Flags
|
|
25
|
+
|
|
26
|
+
Every flag, which commands accept it, and what it changes. Include the ones
|
|
27
|
+
that only matter to a harness — machine-readable output, hook forms — and say
|
|
28
|
+
so, because a person reading top to bottom will otherwise try them.
|
|
29
|
+
|
|
30
|
+
Rules:
|
|
31
|
+
|
|
32
|
+
- Work from the argument parsing, not from the help text. A flag the parser
|
|
33
|
+
accepts and the help omits is exactly the drift this document exists to
|
|
34
|
+
catch.
|
|
35
|
+
- Every example must be runnable as written. No placeholders that are not
|
|
36
|
+
marked as such.
|
|
37
|
+
- Exit codes belong to the specification; link to it rather than restating the
|
|
38
|
+
table.
|
|
39
|
+
- When a previous body is supplied, edit it.
|
|
40
|
+
- Plain British English. No preamble, no summary of what you did.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
Read the source files this document covers, then write its body.
|
|
2
|
+
|
|
3
|
+
A configuration reference answers *"what can I put in this file, and what will
|
|
4
|
+
it do"*. It is read with the file open, so completeness matters more than prose.
|
|
5
|
+
|
|
6
|
+
Output the body only: three H2 sections, in this order, nothing before or
|
|
7
|
+
after, no code fences around the whole thing. Do not write an H1 — the
|
|
8
|
+
document's title is a frontmatter key, not a heading.
|
|
9
|
+
|
|
10
|
+
## Purpose
|
|
11
|
+
|
|
12
|
+
What the file is, where it lives, and what reads it. Two to four sentences.
|
|
13
|
+
Then a short complete example that would actually load.
|
|
14
|
+
|
|
15
|
+
## Keys
|
|
16
|
+
|
|
17
|
+
Every key the loader accepts: name, type, default, and what it does. A table
|
|
18
|
+
per level of nesting, or a subheading per top-level key when a key needs more
|
|
19
|
+
than a line. Give the default exactly as the code has it, and say when a key is
|
|
20
|
+
required rather than leaving the default blank.
|
|
21
|
+
|
|
22
|
+
This is the section that rots: keys get added in code and documented never.
|
|
23
|
+
Work from the loader, key by key, and do not describe one you cannot find
|
|
24
|
+
there.
|
|
25
|
+
|
|
26
|
+
## What is refused
|
|
27
|
+
|
|
28
|
+
The shapes the loader rejects, and why each is refused rather than ignored. A
|
|
29
|
+
mistyped key that is silently accepted is a setting its author believes is in
|
|
30
|
+
force, so the refusals are a feature and belong in the reference.
|
|
31
|
+
|
|
32
|
+
Rules:
|
|
33
|
+
|
|
34
|
+
- Never document a key the source does not read.
|
|
35
|
+
- Give types as a reader would write them — "a string, or a list of strings" —
|
|
36
|
+
not as Python annotations.
|
|
37
|
+
- When a previous body is supplied, edit it. A new key is a new row.
|
|
38
|
+
- Plain British English. No preamble, no summary of what you did.
|