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.
@@ -0,0 +1,124 @@
1
+ # Writing a quickstart
2
+
3
+ A quickstart answers **how do I get this working right now?** Its reader has
4
+ already decided to try it. They want to reach something that works and then
5
+ stop reading.
6
+
7
+ The measure of a quickstart is time to first success, and the way it is failed
8
+ is by being complete. Every branch, caveat and alternative costs every reader
9
+ time, and the ones who needed the caveat were going to read the reference
10
+ anyway.
11
+
12
+ ## The shape
13
+
14
+ 1. **What they will have at the end**, in one sentence. A reader who does not
15
+ know where they are going cannot tell whether a step went wrong.
16
+ 2. **Prerequisites, as a short list**, each checkable in one command. Say what
17
+ version, and how to find out.
18
+ 3. **Numbered steps, each one command and one result.** Show what appears.
19
+ Where a step fails in a way a beginner will hit, say what that looks like
20
+ once, in a sentence, not as a troubleshooting section.
21
+ 4. **The result, shown.** The file that now exists, the output that now
22
+ appears. Something they can compare against.
23
+ 5. **One next step**, and one only. A link to the reference, or the single
24
+ thing most people do second.
25
+
26
+ ## Rules
27
+
28
+ - **Every command copyable and runnable in order**, from a clean checkout,
29
+ with nothing skipped. Test it by doing exactly that.
30
+ - **One path.** No "or, if you prefer". Choose for them; the reference carries
31
+ the alternatives.
32
+ - **Real values, not placeholders.** If a placeholder is unavoidable, make it
33
+ obviously fake and say what to put there.
34
+ - **Show the output of anything that produces one.** A reader whose output
35
+ differs needs to know at that step, not three steps later.
36
+ - **Keep it to one screen if you can**, and two at the very most.
37
+ - **Link the reference for commands.** A quickstart that documents flags has
38
+ become a reference with a friendly tone, and it will drift from the real
39
+ one.
40
+
41
+ ## What loses the reader
42
+
43
+ Certain vocabulary marks a page as machine-written, and a reader who decides
44
+ that nobody wrote this stops believing what it claims. There is no tolerance
45
+ to spend here — one of these in the opening costs more than the rest of the
46
+ document can earn back. Every entry is a word that sounds like value and
47
+ carries none.
48
+
49
+ - **Significance inflation** — *powerful*, *robust*, *seamless*, *crucial*,
50
+ *transformative*, *game-changing*, *blazingly fast*, *production-ready*.
51
+ Each asserts the conclusion the evidence was there to earn
52
+ - **Vocabulary tells** — *unlock*, *elevate*, *streamline*, *empower*,
53
+ *leverage*, *delve*, *journey*, *landscape*. If it would not be said aloud
54
+ across a desk, it does not get written
55
+ - **Adjective lists** — *fast, secure and developer-friendly*, an emoji on
56
+ each. Claims with no mechanism behind them. Say what the thing does, or cut
57
+ the line
58
+ - **Contrastive negation** — defining a thing by what it is not. *"Not just a
59
+ linter, but a formatter"*. *"A property rather than a roadmap"*. *"It
60
+ proposes instead of removing"*. The shape flatters the writer and makes the
61
+ reader wait a clause for the meaning. Worse, it spreads: once it is in the
62
+ ear, every sentence grows a foil. Say what is true and delete the half that
63
+ says what is not. **Ban `rather than`, `instead of`, `not X but Y` and `X,
64
+ not Y` outright.** A sentence that needs a real contrast survives being
65
+ written as two
66
+ - **Negative definition** — describing a thing by its absences. *"Never calls a model, never reads your
67
+ source, no AST, no import graph."* A list of absences draws the shape of a
68
+ hole. The reader still has to guess the object. Say what it does. Limits earn their own section, where somebody
69
+ hunting for them will find them
70
+ - **Rhetorical triplets** — three parallel clauses that buy rhythm and no
71
+ content. Use three when there are three
72
+ - **The universal address** — *"whether you are a solo developer or a large
73
+ team"*. It is written to nobody, and nobody is who it persuades
74
+ - **Minimisers** — *simply*, *just*, *easily* in front of an instruction. If
75
+ the step is easy, the word adds nothing. If it is hard, the word lies, and
76
+ your reader has already hit the error
77
+ - **Meta-signposting** — *"let us dive in"*, *"it is worth noting"*, *"here is
78
+ the thing"*. A document has no host
79
+
80
+ **Sentences.** The words above are the easy half. What follows makes a page
81
+ tiring to read even when every word in it is right.
82
+
83
+ - **One idea per sentence.** A semicolon or an em-dash joining two complete
84
+ thoughts gives you two sentences wearing one coat. Split them. Aim under
85
+ twenty words and stop at about twenty-five
86
+ - **Verbs, not `is`.** *"Modules are globs and files are bytes, which is what
87
+ makes any language a property here"* hangs on three copulas and lands
88
+ nothing. Find the action and put it in the verb. Where half the sentences on
89
+ a page lean on *is*, *are* or *was*, the page describes states where it
90
+ should describe what happens
91
+ - **Say who does what, and say it first.** Name the actor, then the action.
92
+ *"The tool hashes every file"* beats *"every file is hashed"*. A sentence
93
+ with no actor makes your reader guess one
94
+ - **Subject first.** *"On every write, the tool marks the module"* holds a
95
+ phrase in the air before the reader learns what it modifies. *"The tool
96
+ marks the module on every write"* lands immediately. Anything opening on a
97
+ preposition or a stray clause was usually written backwards, so turn it
98
+ round
99
+ - **Nothing that sounds quotable.** Balanced clauses, an aphorism, a line that
100
+ would fit on a slide: all of it puts rhythm where content belongs. Write the
101
+ sentence that tells somebody the thing
102
+ - **The reader's word for it.** *Prose*, *narrative* and *copy* are what
103
+ writers call writing. Everyone else says *the docs*, *the writing*, or names
104
+ the document. Take the short word over the formal one every time: *use* over
105
+ *utilise*, *buy* over *purchase*, *about* over *approximately*
106
+
107
+ Three mechanical tests, all cheap. Delete the word and read the sentence
108
+ again: if nothing was lost, the word was there to sound like something. Count
109
+ the words against what the sentence actually says and cut until the two match.
110
+ A first draft carries about a third more than it needs, and that third always
111
+ sounds the most like writing. Then read it aloud. Anything you would not say
112
+ to somebody at a desk gets rewritten as what you would have said.
113
+
114
+ ## What does not belong
115
+
116
+ - Explanation of why the tool works the way it does. That is the specification
117
+ or the README, and it stops the reader mid-flow
118
+ - Configuration options beyond the one or two the path needs
119
+ - Troubleshooting sections. A reader who has hit an error is no longer doing
120
+ the quickstart; send them to the error catalogue
121
+ - Alternative installation methods, platform variations, or "on Windows,
122
+ instead…" branches. Pick one and link the rest
123
+ - Anything that cannot be run. A quickstart is a script written out in
124
+ sentences, and a step nobody can run is a step nobody trusts
@@ -0,0 +1,198 @@
1
+ # Writing a README
2
+
3
+ A README answers one question: **should I use this?** How to drive it, and
4
+ what every option does, belong in a reference. A README that tries to be a
5
+ reference becomes the document nobody updates.
6
+
7
+ Your reader decides in about ninety seconds whether to keep going. They
8
+ arrived from a search or a link. They know nothing about the project, and they
9
+ are hunting for a reason to leave. Most of them find one. Everything below
10
+ closes that exit.
11
+
12
+ Persuading that reader and being straight with them are the same job, because
13
+ their scepticism is the audience. Marketing language spends trust: an
14
+ adjective, an exclamation, a decoration. Evidence earns it: a number, a
15
+ mechanism, an admitted limit.
16
+
17
+ ## Above the fold
18
+
19
+ Everything that persuades happens in the first screenful. A reader who scrolls
20
+ has already decided to keep going, so the space above that scroll carries
21
+ three things:
22
+
23
+ - **The claim** — what this is, in one line, at its strongest and still true
24
+ - **What it is** — the kind of thing, and where it runs
25
+ - **The problem it removes** — one line, because the reader already feels it
26
+ - **One piece of evidence** — a number, an output, or a demonstration
27
+
28
+ Put a table of contents, a badge row, a history or an architecture note there
29
+ and you spend attention you have not earned.
30
+
31
+ ## The shape
32
+
33
+ 1. **The claim, in one line.** The strongest true thing the project does,
34
+ written so that someone could disagree with it. Most people read this line.
35
+ Many read only this line, so it carries the whole product: *"documentation
36
+ that writes and maintains itself"*. Name the category and you have
37
+ described a shelf; make a claim and you have described a reason.
38
+ 2. **What kind of thing it is, in one line.** A command-line tool, a library,
39
+ an editor extension, a hosted service, a plugin for something. Say which,
40
+ and say where it runs. A reader who cannot tell what they would be installing
41
+ cannot judge any of what follows. It is also the fact a claim carries
42
+ worst: *"documentation that writes and maintains itself"* could be any of
43
+ the six.
44
+ 3. **The problem it removes, in one line.** One line. The reader already feels
45
+ the pain, so naming it confirms they are in the right place and nothing
46
+ more. Most READMEs skip it. Usage instructions appear in roughly nine files
47
+ in ten, a statement of purpose in one in four. That gap is what makes one
48
+ sentence here worth so much. Spend two and you bury the claim above it.
49
+ 4. **How it works, in three sentences.** What it does, how, and the constraint
50
+ that matters: no key, no network, any language, whatever yours is.
51
+ 5. **Proof, before instructions.** Show the thing working before you explain
52
+ how to work it. Use a recorded terminal session, a before and after, or a
53
+ measurement that names what it beats. One is enough. Skip it and you are
54
+ asking for an install on the strength of a paragraph.
55
+ 6. **How to install it.** One line, copyable.
56
+ 7. **The shortest path to something working.** From nothing to a result they
57
+ can see, in about a minute. One command if possible. Show the output.
58
+ 8. **The thing that makes it worth having**, demonstrated. One realistic
59
+ example, complete enough to run.
60
+ 9. **What it does not do.** Genuinely — the limits, the cases it will not
61
+ catch. Readers trust this section most, and it is the only one that costs
62
+ you anything to write.
63
+ 10. **Links out**: the specification, the reference, the examples, the
64
+ licence.
65
+
66
+ ## Rules
67
+
68
+ - **Cut the draft by a third, and cut the opening twice.** Padding hides in
69
+ the first paragraph, where the writer is still warming up and the reader has
70
+ agreed to nothing. Three sentences doing one sentence's work is the
71
+ commonest failure in a README. It is also the most expensive, because you
72
+ spend it on the one screenful everybody sees. Say it once, at its shortest,
73
+ and stop.
74
+ - **A heading says what is in the section.** *"Sixty seconds"* names a
75
+ measurement, so somebody scanning the page learns nothing from it. Name what
76
+ they get or what they will do: *"Install"*, *"Try it"*, *"What it does not
77
+ do"*. People read headings far more often than the paragraphs under them.
78
+ - **Every command example must be runnable as written.** A placeholder that
79
+ looks like a real value is worse than one that obviously is not.
80
+ - **Replace every adjective with a number, a mechanism, or nothing.** *"Fast"*
81
+ is a word the reader has discounted a thousand times. *"Three seconds where
82
+ the tool it replaces takes four minutes"* can be checked, and being
83
+ checkable is what makes it land. Where no number exists, name the mechanism:
84
+ *"no network call"* explains a speed claim without making one. An adjective
85
+ that survives neither test is decoration.
86
+ - **Show output.** A command with no output is a claim; a command with output
87
+ is evidence.
88
+ - **Show it running.** For anything with a terminal or a screen, a recording
89
+ does in five seconds what a paragraph does in thirty. Generate it from a
90
+ script, so a change in behaviour is a re-render. A demo nobody can
91
+ regenerate is a document that goes stale in public. Keep every fact in the
92
+ text as well as the image. A reader with images off, a search index and an
93
+ agent all see the text alone.
94
+ - **Attribute your proof, or drop it.** A named person saying a specific thing
95
+ carries weight. A wall of logos and a count of stars carry none, and readers
96
+ discount them: bought stars move no downloads at all. Ask whether a sceptic
97
+ could check it.
98
+ - **Link to the reference; never restate it.** A command list in a README is a
99
+ second home for the thing that changes most, and it goes stale first because
100
+ nothing checks it.
101
+ - **State limits plainly.** *"It cannot catch X"* buys more trust than any
102
+ amount of enthusiasm, and a reader who finds the limit themselves stops
103
+ believing the rest.
104
+ - **Do not narrate the project's history.** What it used to be is a changelog
105
+ entry.
106
+
107
+ ## What loses the reader
108
+
109
+ Certain vocabulary marks a page as machine-written, and a reader who decides
110
+ that nobody wrote this stops believing what it claims. There is no tolerance
111
+ to spend here — one of these in the opening costs more than the rest of the
112
+ document can earn back. Every entry is a word that sounds like value and
113
+ carries none.
114
+
115
+ - **Significance inflation** — *powerful*, *robust*, *seamless*, *crucial*,
116
+ *transformative*, *game-changing*, *blazingly fast*, *production-ready*.
117
+ Each asserts the conclusion the evidence was there to earn
118
+ - **Vocabulary tells** — *unlock*, *elevate*, *streamline*, *empower*,
119
+ *leverage*, *delve*, *journey*, *landscape*. If it would not be said aloud
120
+ across a desk, it does not get written
121
+ - **Adjective lists** — *fast, secure and developer-friendly*, an emoji on
122
+ each. Claims with no mechanism behind them. Say what the thing does, or cut
123
+ the line
124
+ - **Contrastive negation** — defining a thing by what it is not. *"Not just a
125
+ linter, but a formatter"*. *"A property rather than a roadmap"*. *"It
126
+ proposes instead of removing"*. The shape flatters the writer and makes the
127
+ reader wait a clause for the meaning. Worse, it spreads: once it is in the
128
+ ear, every sentence grows a foil. Say what is true and delete the half that
129
+ says what is not. **Ban `rather than`, `instead of`, `not X but Y` and `X,
130
+ not Y` outright.** A sentence that needs a real contrast survives being
131
+ written as two
132
+ - **Negative definition** — describing a thing by its absences. *"Never calls a model, never reads your
133
+ source, no AST, no import graph."* A list of absences draws the shape of a
134
+ hole. The reader still has to guess the object. Say what it does. Limits earn their own section, where somebody
135
+ hunting for them will find them
136
+ - **Rhetorical triplets** — three parallel clauses that buy rhythm and no
137
+ content. Use three when there are three
138
+ - **The universal address** — *"whether you are a solo developer or a large
139
+ team"*. It is written to nobody, and nobody is who it persuades
140
+ - **Minimisers** — *simply*, *just*, *easily* in front of an instruction. If
141
+ the step is easy, the word adds nothing. If it is hard, the word lies, and
142
+ your reader has already hit the error
143
+ - **Meta-signposting** — *"let us dive in"*, *"it is worth noting"*, *"here is
144
+ the thing"*. A document has no host
145
+
146
+ **Sentences.** The words above are the easy half. What follows makes a page
147
+ tiring to read even when every word in it is right.
148
+
149
+ - **One idea per sentence.** A semicolon or an em-dash joining two complete
150
+ thoughts gives you two sentences wearing one coat. Split them. Aim under
151
+ twenty words and stop at about twenty-five
152
+ - **Verbs, not `is`.** *"Modules are globs and files are bytes, which is what
153
+ makes any language a property here"* hangs on three copulas and lands
154
+ nothing. Find the action and put it in the verb. Where half the sentences on
155
+ a page lean on *is*, *are* or *was*, the page describes states where it
156
+ should describe what happens
157
+ - **Say who does what, and say it first.** Name the actor, then the action.
158
+ *"The tool hashes every file"* beats *"every file is hashed"*. A sentence
159
+ with no actor makes your reader guess one
160
+ - **Subject first.** *"On every write, the tool marks the module"* holds a
161
+ phrase in the air before the reader learns what it modifies. *"The tool
162
+ marks the module on every write"* lands immediately. Anything opening on a
163
+ preposition or a stray clause was usually written backwards, so turn it
164
+ round
165
+ - **Nothing that sounds quotable.** Balanced clauses, an aphorism, a line that
166
+ would fit on a slide: all of it puts rhythm where content belongs. Write the
167
+ sentence that tells somebody the thing
168
+ - **The reader's word for it.** *Prose*, *narrative* and *copy* are what
169
+ writers call writing. Everyone else says *the docs*, *the writing*, or names
170
+ the document. Take the short word over the formal one every time: *use* over
171
+ *utilise*, *buy* over *purchase*, *about* over *approximately*
172
+
173
+ Three mechanical tests, all cheap. Delete the word and read the sentence
174
+ again: if nothing was lost, the word was there to sound like something. Count
175
+ the words against what the sentence actually says and cut until the two match.
176
+ A first draft carries about a third more than it needs, and that third always
177
+ sounds the most like writing. Then read it aloud. Anything you would not say
178
+ to somebody at a desk gets rewritten as what you would have said.
179
+
180
+ ## What does not belong
181
+
182
+ - An options table, a flag list, or a full command reference — that is a
183
+ reference document, and duplicating it here guarantees the two disagree
184
+ - Installation instructions for six package managers. Pick the one your users
185
+ have; link the rest
186
+ - A roadmap. It ages into a list of the things that did not happen, and it
187
+ helps nobody's ninety-second decision
188
+ - Unattributable proof — a logo wall, a user count, a comparison table scored
189
+ by the author. Named users and named quotations outperform all of it and
190
+ belong high up; an anonymous aggregate belongs nowhere
191
+ - Badges beyond the two that mean something: does it build, and what licence
192
+ - An architecture explanation. A reader deciding whether to adopt does not
193
+ care yet, and one who does care has a specification to read
194
+ - Emoji in headings or feature rows. Decoration is the cheapest thing to fake,
195
+ which is exactly why restraint now reads as a trust signal
196
+ - Apologies, "work in progress" notices, or anything hedging what it does.
197
+ Either it does the thing or the README should say it does not
198
+
@@ -0,0 +1,70 @@
1
+ Certain vocabulary marks a page as machine-written, and a reader who decides
2
+ that nobody wrote this stops believing what it claims. There is no tolerance
3
+ to spend here — one of these in the opening costs more than the rest of the
4
+ document can earn back. Every entry is a word that sounds like value and
5
+ carries none.
6
+
7
+ - **Significance inflation** — *powerful*, *robust*, *seamless*, *crucial*,
8
+ *transformative*, *game-changing*, *blazingly fast*, *production-ready*.
9
+ Each asserts the conclusion the evidence was there to earn
10
+ - **Vocabulary tells** — *unlock*, *elevate*, *streamline*, *empower*,
11
+ *leverage*, *delve*, *journey*, *landscape*. If it would not be said aloud
12
+ across a desk, it does not get written
13
+ - **Adjective lists** — *fast, secure and developer-friendly*, an emoji on
14
+ each. Claims with no mechanism behind them. Say what the thing does, or cut
15
+ the line
16
+ - **Contrastive negation** — defining a thing by what it is not. *"Not just a
17
+ linter, but a formatter"*. *"A property rather than a roadmap"*. *"It
18
+ proposes instead of removing"*. The shape flatters the writer and makes the
19
+ reader wait a clause for the meaning. Worse, it spreads: once it is in the
20
+ ear, every sentence grows a foil. Say what is true and delete the half that
21
+ says what is not. **Ban `rather than`, `instead of`, `not X but Y` and `X,
22
+ not Y` outright.** A sentence that needs a real contrast survives being
23
+ written as two
24
+ - **Negative definition** — describing a thing by its absences. *"Never calls a model, never reads your
25
+ source, no AST, no import graph."* A list of absences draws the shape of a
26
+ hole. The reader still has to guess the object. Say what it does. Limits earn their own section, where somebody
27
+ hunting for them will find them
28
+ - **Rhetorical triplets** — three parallel clauses that buy rhythm and no
29
+ content. Use three when there are three
30
+ - **The universal address** — *"whether you are a solo developer or a large
31
+ team"*. It is written to nobody, and nobody is who it persuades
32
+ - **Minimisers** — *simply*, *just*, *easily* in front of an instruction. If
33
+ the step is easy, the word adds nothing. If it is hard, the word lies, and
34
+ your reader has already hit the error
35
+ - **Meta-signposting** — *"let us dive in"*, *"it is worth noting"*, *"here is
36
+ the thing"*. A document has no host
37
+
38
+ **Sentences.** The words above are the easy half. What follows makes a page
39
+ tiring to read even when every word in it is right.
40
+
41
+ - **One idea per sentence.** A semicolon or an em-dash joining two complete
42
+ thoughts gives you two sentences wearing one coat. Split them. Aim under
43
+ twenty words and stop at about twenty-five
44
+ - **Verbs, not `is`.** *"Modules are globs and files are bytes, which is what
45
+ makes any language a property here"* hangs on three copulas and lands
46
+ nothing. Find the action and put it in the verb. Where half the sentences on
47
+ a page lean on *is*, *are* or *was*, the page describes states where it
48
+ should describe what happens
49
+ - **Say who does what, and say it first.** Name the actor, then the action.
50
+ *"The tool hashes every file"* beats *"every file is hashed"*. A sentence
51
+ with no actor makes your reader guess one
52
+ - **Subject first.** *"On every write, the tool marks the module"* holds a
53
+ phrase in the air before the reader learns what it modifies. *"The tool
54
+ marks the module on every write"* lands immediately. Anything opening on a
55
+ preposition or a stray clause was usually written backwards, so turn it
56
+ round
57
+ - **Nothing that sounds quotable.** Balanced clauses, an aphorism, a line that
58
+ would fit on a slide: all of it puts rhythm where content belongs. Write the
59
+ sentence that tells somebody the thing
60
+ - **The reader's word for it.** *Prose*, *narrative* and *copy* are what
61
+ writers call writing. Everyone else says *the docs*, *the writing*, or names
62
+ the document. Take the short word over the formal one every time: *use* over
63
+ *utilise*, *buy* over *purchase*, *about* over *approximately*
64
+
65
+ Three mechanical tests, all cheap. Delete the word and read the sentence
66
+ again: if nothing was lost, the word was there to sound like something. Count
67
+ the words against what the sentence actually says and cut until the two match.
68
+ A first draft carries about a third more than it needs, and that third always
69
+ sounds the most like writing. Then read it aloud. Anything you would not say
70
+ to somebody at a desk gets rewritten as what you would have said.
constant_docs/index.py ADDED
@@ -0,0 +1,210 @@
1
+ """Root index assembly for constant-docs.
2
+
3
+ Builds ``index.md`` from module descriptions, enforces token budget,
4
+ and conformance-checks every document under the docs root.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import sys
11
+ import tempfile
12
+ from pathlib import Path, PurePosixPath
13
+
14
+ import yaml
15
+
16
+ from constant_docs.config import Config, is_tool_owned
17
+ from constant_docs.document import (
18
+ DocumentCache,
19
+ DocumentError,
20
+ extension_block,
21
+ replace_preserving_mode,
22
+ )
23
+ from constant_docs.document import load as load_document
24
+ from constant_docs.paths import doc_path_for
25
+
26
+ # The index carries no frontmatter, so this note is how a later run recognises
27
+ # a file as its own before overwriting it. Changing the wording orphans every
28
+ # index already on disk, which then reads as somebody else's file.
29
+ _GENERATED_NOTE = "Auto-generated by `constant-docs`."
30
+
31
+ # Every wording this note has ever had, because recognition has to reach back
32
+ # further than generation does. A 0.1-era repository — the upgrade path
33
+ # `tests/test_compat.py` exists to keep working — carries an index written
34
+ # under the tool's former name, and refusing to overwrite it would tell a user
35
+ # to delete a file the tool itself generated.
36
+ _GENERATED_NOTES = (_GENERATED_NOTE, "Auto-generated by `module-docs`.")
37
+
38
+ REQUIRED_FM_KEYS = [
39
+ "module",
40
+ "source_glob",
41
+ "source_files",
42
+ "source_hash",
43
+ "hash_method",
44
+ "hash_covers",
45
+ "generator",
46
+ "generator_spec",
47
+ ]
48
+
49
+
50
+ def _estimate_tokens(text: str) -> int:
51
+ """Rough token estimate — 1 token per 4 characters."""
52
+ return len(text) // 4
53
+
54
+
55
+ def build(config: Config, repo_root: Path) -> str:
56
+ """Assemble the root index from module descriptions.
57
+
58
+ Returns the markdown content for ``index.md`` as a string.
59
+ """
60
+ lines: list[str] = [
61
+ "# Module Index\n",
62
+ (f"{_GENERATED_NOTE} Each entry links to the corresponding module document.\n"),
63
+ ]
64
+
65
+ current_estimate = 2 # for the heading + blank line
66
+
67
+ for mod in config.modules:
68
+ desc = ""
69
+ doc_file = doc_path_for(config, mod.key)
70
+ doc_abs = repo_root / doc_file
71
+ if doc_abs.exists():
72
+ try:
73
+ doc = load_document(doc_abs)
74
+ desc = doc.frontmatter.get("description", "") or ""
75
+ except DocumentError:
76
+ desc = "*(malformed)*"
77
+
78
+ # Relative to the index itself, which lives at <docs_root>/index.md.
79
+ # A repository-relative link here reads as `docs/modules/docs/modules/…`
80
+ # when followed from the index, which resolves nowhere and stays
81
+ # invisible until someone clicks it.
82
+ #
83
+ # A document declared outside the docs root cannot be made relative to
84
+ # it by stripping a prefix it does not have, so the link is computed
85
+ # as a walk between the two and comes out as `../README.md`. The index
86
+ # still lists it: a reader asking what is documented here wants the
87
+ # answer whatever directory the answer lives in.
88
+ rel_link = PurePosixPath(os.path.relpath(doc_file, config.docs_root)).as_posix()
89
+ entry = (
90
+ f"- [`{mod.key}`]({rel_link}): {desc}"
91
+ if desc
92
+ else f"- [`{mod.key}`]({rel_link})"
93
+ )
94
+ lines.append(entry)
95
+ current_estimate += _estimate_tokens(entry)
96
+
97
+ lines.append("") # trailing newline
98
+
99
+ if config.index_token_budget is not None:
100
+ budget = config.index_token_budget
101
+ if current_estimate > budget:
102
+ print(
103
+ f"Warning: index token estimate ({current_estimate}) exceeds "
104
+ f"configured budget ({budget})",
105
+ file=sys.stderr,
106
+ )
107
+
108
+ return "\n".join(lines) + "\n"
109
+
110
+
111
+ def _is_generated_index(target: Path) -> bool:
112
+ """Return whether *target* is an index this tool assembled.
113
+
114
+ The index is the one file under the docs root that deliberately carries no
115
+ frontmatter, so `paths.is_ours` cannot recognise it and the generated note
116
+ stands in as the mark. Read conservatively: a file we cannot read is not
117
+ ours, which is the same direction `is_ours` takes and for the same reason.
118
+ """
119
+ try:
120
+ head = target.read_text(encoding="utf-8")[:512]
121
+ except (OSError, UnicodeDecodeError):
122
+ return False
123
+ return any(note in head for note in _GENERATED_NOTES)
124
+
125
+
126
+ def write(config: Config, repo_root: Path) -> Path:
127
+ """Assemble the index and write it to ``<docs_root>/index.md``.
128
+
129
+ Returns the path written. The file is left byte-identical when the
130
+ assembled content already matches, for the same reason document writes
131
+ are: a run that changes nothing must not move a timestamp or an mtime.
132
+ """
133
+ content = build(config, repo_root)
134
+ target = repo_root / config.docs_root / "index.md"
135
+ target.parent.mkdir(parents=True, exist_ok=True)
136
+ if target.exists() and target.read_bytes() == content.encode():
137
+ return target
138
+
139
+ # The same question `prune` asks before it deletes, asked before a write
140
+ # that is just as unrecoverable. Pointing `docs_root` at an existing
141
+ # `docs/` folder is the documented first step and `index.md` is the most
142
+ # common file in one, so overwriting it lands on first adoption — and no
143
+ # scan would have reported it, because all three skip `index.md` by name.
144
+ if target.exists() and not _is_generated_index(target):
145
+ raise DocumentError(
146
+ f"{target} exists and was not written by constant-docs. The index "
147
+ f"is assembled from scratch on every run, so writing it would "
148
+ f"discard what is there. Move or delete that file, or point "
149
+ f"'docs_root' somewhere the tool owns."
150
+ )
151
+
152
+ fd, tmp_path = tempfile.mkstemp(dir=str(target.parent), prefix=".index.md.tmp.")
153
+ try:
154
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
155
+ f.write(content)
156
+ replace_preserving_mode(tmp_path, target)
157
+ except BaseException:
158
+ if os.path.exists(tmp_path):
159
+ os.unlink(tmp_path)
160
+ raise
161
+ return target
162
+
163
+
164
+ def conformance_check(
165
+ config: Config, repo_root: Path, cache: DocumentCache | None = None
166
+ ) -> list[str]:
167
+ """AC 25 — Verify every ``.md`` under the docs root has valid frontmatter.
168
+
169
+ Returns a list of problematic document paths (relative to repo root).
170
+
171
+ *cache* lets `verify` hand in the documents it has already parsed. Every
172
+ document under the docs root was read once by `plan` and again here, and a
173
+ parse is a read plus a DOTALL regex plus a YAML load.
174
+ """
175
+ read = (cache or DocumentCache()).load
176
+ docs_root_abs = repo_root / config.docs_root
177
+ issues: list[str] = []
178
+
179
+ if not docs_root_abs.is_dir():
180
+ return issues
181
+
182
+ for md_file in sorted(docs_root_abs.rglob("*.md")):
183
+ if is_tool_owned(md_file, docs_root_abs):
184
+ continue
185
+ try:
186
+ doc = read(md_file)
187
+ doc_type = doc.frontmatter.get("type", "")
188
+ if not doc_type or not isinstance(doc_type, str) or not doc_type.strip():
189
+ issues.append(str(md_file.relative_to(repo_root)))
190
+ continue
191
+ # AC 35 — the keys live inside the namespaced producer-extension
192
+ # block, so a host schema and this tool cannot collide.
193
+ block = extension_block(doc.frontmatter)
194
+ if not block:
195
+ issues.append(
196
+ f"{md_file.relative_to(repo_root)} — missing the "
197
+ "'constant_docs' frontmatter block"
198
+ )
199
+ continue
200
+ for key in REQUIRED_FM_KEYS:
201
+ if key not in block:
202
+ issues.append(
203
+ f"{md_file.relative_to(repo_root)} — missing required "
204
+ f"frontmatter key: {key!r}"
205
+ )
206
+ break
207
+ except (OSError, yaml.YAMLError, DocumentError, ValueError):
208
+ issues.append(str(md_file.relative_to(repo_root)))
209
+
210
+ return issues