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
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
Read the source files this document covers, then write its body.
|
|
2
|
+
|
|
3
|
+
An error catalogue answers *"the tool said this; what now"*. Its reader has a
|
|
4
|
+
message in front of them and no interest in how the code is arranged.
|
|
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 raises, in one paragraph: which exception types exist, what each means as
|
|
13
|
+
a class of problem, and which exit code a reader will have seen. Three to five
|
|
14
|
+
sentences.
|
|
15
|
+
|
|
16
|
+
## The catalogue
|
|
17
|
+
|
|
18
|
+
A table: message, cause, what to do. One row per message the tool can emit.
|
|
19
|
+
|
|
20
|
+
**The first cell is a verbatim fragment of the message, in backticks.** It is
|
|
21
|
+
checked against the source in both directions — every message raised must be
|
|
22
|
+
carried by some row, and every row must match a message that is still raised —
|
|
23
|
+
so choose a fragment that is literal rather than interpolated:
|
|
24
|
+
|
|
25
|
+
| `is already declared` | A module of that name exists | Edit its entry |
|
|
26
|
+
|
|
27
|
+
Not `` `Module 'src/api' is already declared` ``: the name is substituted at
|
|
28
|
+
runtime and no such string exists in the source. Take the longest run of
|
|
29
|
+
literal words you can, and no more.
|
|
30
|
+
|
|
31
|
+
Group rows under H3 subheadings when there are many — by exception type, or by
|
|
32
|
+
the command a reader was running. The check reads the first column of every
|
|
33
|
+
table wherever it sits.
|
|
34
|
+
|
|
35
|
+
## What is not an error
|
|
36
|
+
|
|
37
|
+
The places the tool deliberately says nothing: silent exits, refusals that are
|
|
38
|
+
not failures, warnings that do not change an exit code. A reader who cannot
|
|
39
|
+
tell "nothing happened" from "nothing was wrong" will conclude the tool is
|
|
40
|
+
broken, and this section is the only place that distinction is written down.
|
|
41
|
+
|
|
42
|
+
Rules:
|
|
43
|
+
|
|
44
|
+
- Every row's fragment must appear verbatim in the files given. A row that
|
|
45
|
+
matches nothing is refused, and so is a message no row carries.
|
|
46
|
+
- Write the cause as what the *user* did, not as which branch was taken.
|
|
47
|
+
- "What to do" must be an action. "Fix the configuration" is not one.
|
|
48
|
+
- When a previous body is supplied, edit it. Most changes add or remove one
|
|
49
|
+
row.
|
|
50
|
+
- Plain British English. No preamble, no summary of what you did.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Read the files this log covers, then write **one entry** describing what
|
|
2
|
+
changed since the last one.
|
|
3
|
+
|
|
4
|
+
A log is not regenerable. Everything already written stays exactly as it is,
|
|
5
|
+
and you are adding to the end. Write the entry body only — no heading, no
|
|
6
|
+
frontmatter, no restatement of earlier entries. The tool adds the heading:
|
|
7
|
+
entries sit inside the `## Entries` section, so each is an H3.
|
|
8
|
+
|
|
9
|
+
What an entry carries:
|
|
10
|
+
|
|
11
|
+
- What changed, in the terms someone reading it in six months would use.
|
|
12
|
+
- Why, when the reason is not obvious from the change itself.
|
|
13
|
+
- What it breaks, if anything, and what a reader must do about it.
|
|
14
|
+
|
|
15
|
+
Rules:
|
|
16
|
+
|
|
17
|
+
- The previous entries are supplied for context. Do not repeat them, do not
|
|
18
|
+
summarise them, and do not correct them — a correction is a new entry.
|
|
19
|
+
- Only changes you can locate in the files given. An entry that describes work
|
|
20
|
+
that did not happen is worse than no entry.
|
|
21
|
+
- Two to six sentences, or a short list. Length is not thoroughness.
|
|
22
|
+
- Plain British English. No preamble, no summary of what you did.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
Read the module's source files, then write its document body.
|
|
2
|
+
|
|
3
|
+
Write for someone who did not write this code and has to decide whether to
|
|
4
|
+
trust it. An agent will also load it, so keep sections short and independently
|
|
5
|
+
readable. Where the two conflict, favour the person.
|
|
6
|
+
|
|
7
|
+
Output the body only: three H2 sections, in this order, nothing before or
|
|
8
|
+
after, no code fences around the whole thing. Do not write an H1 — the
|
|
9
|
+
document's title is a frontmatter key, not a heading.
|
|
10
|
+
|
|
11
|
+
## Purpose
|
|
12
|
+
|
|
13
|
+
What the module owns and what callers get from it. Name what is deliberately
|
|
14
|
+
hidden behind the interface. Two to five sentences. Do not list functions.
|
|
15
|
+
|
|
16
|
+
## Correctness pillars
|
|
17
|
+
|
|
18
|
+
Rules a caller must follow that a signature does not express. EARS form, one
|
|
19
|
+
per paragraph, so that each maps to a test:
|
|
20
|
+
|
|
21
|
+
WHEN <trigger>, the module SHALL <required behaviour>.
|
|
22
|
+
|
|
23
|
+
Only rules you can point to in the source. Three to six is typical.
|
|
24
|
+
|
|
25
|
+
## Known failure modes
|
|
26
|
+
|
|
27
|
+
A table of symptom, cause, and fix. Only failures visible in this code:
|
|
28
|
+
unhandled edge cases, ordering assumptions, silent truncation, retries that
|
|
29
|
+
lose data. Leave the table empty rather than inventing entries.
|
|
30
|
+
|
|
31
|
+
Rules:
|
|
32
|
+
|
|
33
|
+
- Never restate a signature, a parameter list, or an import. The reader has
|
|
34
|
+
the source. A section that paraphrases the code is a defect.
|
|
35
|
+
- Never claim behaviour you cannot locate in the files given. Uncertainty is
|
|
36
|
+
omitted, not hedged.
|
|
37
|
+
- When a previous body is supplied, edit it. Keep wording that is still
|
|
38
|
+
accurate and change only what the source change affected.
|
|
39
|
+
- Plain British English. No preamble, no summary of what you did.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
Read the files this specification covers, then write its document body.
|
|
2
|
+
|
|
3
|
+
A specification states what a subsystem promises and why, for a reader
|
|
4
|
+
deciding whether to depend on it. It is not a description of the code as
|
|
5
|
+
written — where the two disagree, say so rather than documenting the drift
|
|
6
|
+
into permanence.
|
|
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 this subsystem promises and to whom. Name the boundary: what is inside
|
|
15
|
+
it, what is deliberately outside, and what a caller may rely on. Three to six
|
|
16
|
+
sentences.
|
|
17
|
+
|
|
18
|
+
## Design decisions
|
|
19
|
+
|
|
20
|
+
Choices that are settled, each with the reason it was settled that way. A
|
|
21
|
+
table of decision and rationale, or one short paragraph each. Record the
|
|
22
|
+
reason, not the mechanism — a reader who disagrees with a decision needs to
|
|
23
|
+
know what it bought.
|
|
24
|
+
|
|
25
|
+
Every decision carries a stable identifier, written as an HTML comment at the
|
|
26
|
+
start of the decision so it renders as nothing:
|
|
27
|
+
|
|
28
|
+
| <!-- decision: no-model-client --> Never call a model | The harness … |
|
|
29
|
+
|
|
30
|
+
**Carry every identifier the previous body had.** The tool compares the two
|
|
31
|
+
and refuses a body that drops one, naming it. Rewording, reordering,
|
|
32
|
+
sharpening and merging the *prose* are all free — the identifier is what has
|
|
33
|
+
to survive, not the sentence.
|
|
34
|
+
|
|
35
|
+
Three ways to lose one by accident, each of which is refused:
|
|
36
|
+
|
|
37
|
+
- renaming an identifier, even to a better name. Keep the old slug; it is a
|
|
38
|
+
key, not a title
|
|
39
|
+
- keeping the identifier and emptying the decision, which a table row gutted
|
|
40
|
+
to `| | |` does
|
|
41
|
+
- merging two decisions into one, which drops the identifier of whichever was
|
|
42
|
+
absorbed
|
|
43
|
+
|
|
44
|
+
If a decision genuinely no longer holds, say so and stop rather than deleting
|
|
45
|
+
it. Retiring one is a deliberate act recorded in the document, and it is the
|
|
46
|
+
caller's to make, not yours.
|
|
47
|
+
|
|
48
|
+
Give a new decision an identifier too: lowercase, hyphenated, naming the
|
|
49
|
+
decision rather than its rationale.
|
|
50
|
+
|
|
51
|
+
## Acceptance criteria
|
|
52
|
+
|
|
53
|
+
Numbered statements in EARS form, each mapping to at least one test:
|
|
54
|
+
|
|
55
|
+
WHEN <trigger>, the system SHALL <required behaviour>.
|
|
56
|
+
|
|
57
|
+
Only criteria you can point to in the files given. Number them, because they
|
|
58
|
+
are cited by number elsewhere.
|
|
59
|
+
|
|
60
|
+
Rules:
|
|
61
|
+
|
|
62
|
+
- When a previous body is supplied, edit it. A specification accumulates
|
|
63
|
+
authority; rewriting it from scratch discards the decisions it records.
|
|
64
|
+
- Never claim behaviour you cannot locate in the files given. Uncertainty is
|
|
65
|
+
omitted, not hedged.
|
|
66
|
+
- If the source contradicts a decision already written down, say so plainly in
|
|
67
|
+
the relevant section rather than quietly rewording it.
|
|
68
|
+
- Plain British English. No preamble, no summary of what you did.
|
constant_docs/state.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""The dirty set: which modules were touched, surviving a crash.
|
|
2
|
+
|
|
3
|
+
The harness marks modules dirty on write and settles at quiescence. This
|
|
4
|
+
module owns the file that carries the set between those two moments, at
|
|
5
|
+
`.constant-docs/dirty.json`, which is gitignored.
|
|
6
|
+
|
|
7
|
+
## When the set clears, and why it matters
|
|
8
|
+
|
|
9
|
+
The tempting design is for `settle` to clear the set as it reports it. That is
|
|
10
|
+
a silent hole: if the agent reads the plan and then fails to generate, the
|
|
11
|
+
staleness is gone and nothing ever notices again — which is the one guarantee
|
|
12
|
+
this tool sells.
|
|
13
|
+
|
|
14
|
+
So the set clears in exactly two places:
|
|
15
|
+
|
|
16
|
+
- On the `apply` or `append` that satisfies a module. Precise, per module, and
|
|
17
|
+
needs no cooperation: a harness that never heard of `settle` still leaves
|
|
18
|
+
the set consistent, and a partial generation clears only what it wrote
|
|
19
|
+
- On a `settle` that finds a module **provably fresh**. A file edited and
|
|
20
|
+
edited back is dirty by the mark and fresh by the hash, and dropping it
|
|
21
|
+
loses nothing because there is demonstrably no work to do
|
|
22
|
+
|
|
23
|
+
Nothing else clears it. A stale module that was read and not generated is
|
|
24
|
+
still there on the next run, which is the crash-recovery case.
|
|
25
|
+
|
|
26
|
+
## What this does not solve
|
|
27
|
+
|
|
28
|
+
Two Claude Code sessions in one repository share this file and will race. The
|
|
29
|
+
recovery reasoning above covers a dead session, not two live ones.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import json
|
|
35
|
+
import os
|
|
36
|
+
import tempfile
|
|
37
|
+
from contextlib import suppress
|
|
38
|
+
from dataclasses import dataclass, field
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
|
|
41
|
+
from constant_docs.config import Declarations, declarations, effective_globs
|
|
42
|
+
from constant_docs.document import replace_preserving_mode
|
|
43
|
+
from constant_docs.globs import matches as glob_matches
|
|
44
|
+
|
|
45
|
+
STATE_DIR = ".constant-docs"
|
|
46
|
+
DIRTY_FILE = "dirty.json"
|
|
47
|
+
FORMAT_VERSION = 1
|
|
48
|
+
|
|
49
|
+
# How many times `settle` will block a Stop hook on an unchanged set before
|
|
50
|
+
# getting out of the way. Claude Code documents no loop guard for `Stop`, so
|
|
51
|
+
# an unguarded blocking hook is a hang. One retry, then step aside.
|
|
52
|
+
MAX_BLOCKS = 2
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class Dirty:
|
|
57
|
+
"""The persisted set, plus what `settle` needs to avoid looping."""
|
|
58
|
+
|
|
59
|
+
modules: list[str] = field(default_factory=list)
|
|
60
|
+
# Signature of the last set `settle` blocked on, and how many times.
|
|
61
|
+
blocked_signature: str | None = None
|
|
62
|
+
blocked_count: int = 0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def state_path(repo_root: Path) -> Path:
|
|
66
|
+
"""Return the state file's path. Gitignored; see `.gitignore`."""
|
|
67
|
+
return repo_root / STATE_DIR / DIRTY_FILE
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def read(repo_root: Path) -> Dirty:
|
|
71
|
+
"""Return the persisted set, or an empty one.
|
|
72
|
+
|
|
73
|
+
A file that cannot be read or parsed yields an empty set rather than an
|
|
74
|
+
error. This is recovery state, not a source of truth: `verify` still sees
|
|
75
|
+
every stale module whatever this file says, so losing it costs a turn of
|
|
76
|
+
automatic regeneration and nothing more.
|
|
77
|
+
"""
|
|
78
|
+
path = state_path(repo_root)
|
|
79
|
+
try:
|
|
80
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
81
|
+
except (OSError, ValueError):
|
|
82
|
+
return Dirty()
|
|
83
|
+
if not isinstance(payload, dict) or payload.get("version") != FORMAT_VERSION:
|
|
84
|
+
return Dirty()
|
|
85
|
+
modules = payload.get("modules")
|
|
86
|
+
if not isinstance(modules, list):
|
|
87
|
+
return Dirty()
|
|
88
|
+
signature = payload.get("blocked_signature")
|
|
89
|
+
count = payload.get("blocked_count")
|
|
90
|
+
return Dirty(
|
|
91
|
+
modules=sorted({str(m) for m in modules}),
|
|
92
|
+
blocked_signature=str(signature) if isinstance(signature, str) else None,
|
|
93
|
+
blocked_count=count if isinstance(count, int) else 0,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def write(repo_root: Path, dirty: Dirty) -> None:
|
|
98
|
+
"""Persist the set atomically, in the same temp-then-rename discipline
|
|
99
|
+
documents use. A half-written state file would be read as empty on the
|
|
100
|
+
next run, which loses the turn's marks."""
|
|
101
|
+
path = state_path(repo_root)
|
|
102
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
payload = {
|
|
104
|
+
"version": FORMAT_VERSION,
|
|
105
|
+
"modules": sorted(set(dirty.modules)),
|
|
106
|
+
"blocked_signature": dirty.blocked_signature,
|
|
107
|
+
"blocked_count": dirty.blocked_count,
|
|
108
|
+
}
|
|
109
|
+
content = json.dumps(payload, indent=2) + "\n"
|
|
110
|
+
|
|
111
|
+
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=f".{DIRTY_FILE}.tmp.")
|
|
112
|
+
try:
|
|
113
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
114
|
+
f.write(content)
|
|
115
|
+
replace_preserving_mode(tmp, path)
|
|
116
|
+
except BaseException:
|
|
117
|
+
if os.path.exists(tmp):
|
|
118
|
+
os.unlink(tmp)
|
|
119
|
+
raise
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def modules_for_path(
|
|
123
|
+
decl: Declarations,
|
|
124
|
+
rel_posix: str,
|
|
125
|
+
patterns: dict[str, list[str]] | None = None,
|
|
126
|
+
) -> list[str]:
|
|
127
|
+
"""Return every module key whose glob covers *rel_posix*.
|
|
128
|
+
|
|
129
|
+
Pure string work: no filesystem access, so a file that has just been
|
|
130
|
+
deleted still marks the module that used to hold it.
|
|
131
|
+
|
|
132
|
+
Patterns come from `effective_globs`, the same function the resolver uses,
|
|
133
|
+
so a module that declares `covers` is marked when a file belonging to what
|
|
134
|
+
it covers is written. Without that, a cross-cutting document would go stale
|
|
135
|
+
and the hook would never say so — `verify` would still catch it in CI,
|
|
136
|
+
which is precisely the outcome the harness integration exists to avoid.
|
|
137
|
+
|
|
138
|
+
*patterns* lets a caller with many paths resolve `covers` once rather than
|
|
139
|
+
per path; it is the same mapping this computes when given none.
|
|
140
|
+
"""
|
|
141
|
+
if _is_tool_output(decl, rel_posix) or _is_derived(rel_posix):
|
|
142
|
+
return []
|
|
143
|
+
if patterns is None:
|
|
144
|
+
patterns = effective_globs(decl.modules)
|
|
145
|
+
return [
|
|
146
|
+
mod.key
|
|
147
|
+
for mod in decl.modules
|
|
148
|
+
if any(glob_matches(pattern, rel_posix) for pattern in patterns[mod.key])
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def mark(config_path: Path, paths: list[str]) -> list[str]:
|
|
153
|
+
"""Add every module the given paths belong to, and return those keys.
|
|
154
|
+
|
|
155
|
+
Silent and empty when a path matches nothing. Most edits in a repository
|
|
156
|
+
touch no module, and a hook that errors on those is a hook someone
|
|
157
|
+
disables.
|
|
158
|
+
"""
|
|
159
|
+
config_path = Path(config_path)
|
|
160
|
+
repo_root = config_path.resolve().parent
|
|
161
|
+
decl = declarations(config_path)
|
|
162
|
+
|
|
163
|
+
patterns = effective_globs(decl.modules)
|
|
164
|
+
hit: set[str] = set()
|
|
165
|
+
for raw in paths:
|
|
166
|
+
rel = _relative(repo_root, raw)
|
|
167
|
+
if rel is None:
|
|
168
|
+
continue
|
|
169
|
+
hit.update(modules_for_path(decl, rel, patterns))
|
|
170
|
+
|
|
171
|
+
if not hit:
|
|
172
|
+
return []
|
|
173
|
+
|
|
174
|
+
dirty = read(repo_root)
|
|
175
|
+
before = set(dirty.modules)
|
|
176
|
+
dirty.modules = sorted(before | hit)
|
|
177
|
+
if set(dirty.modules) != before:
|
|
178
|
+
write(repo_root, dirty)
|
|
179
|
+
return sorted(hit)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def discard(repo_root: Path, module_key: str) -> None:
|
|
183
|
+
"""Drop one module from the set, if it is there.
|
|
184
|
+
|
|
185
|
+
Called by the write that satisfies it. A module that was not dirty is not
|
|
186
|
+
an error: a caller may regenerate anything at any time.
|
|
187
|
+
"""
|
|
188
|
+
dirty = read(repo_root)
|
|
189
|
+
if module_key not in dirty.modules:
|
|
190
|
+
return
|
|
191
|
+
dirty.modules = [m for m in dirty.modules if m != module_key]
|
|
192
|
+
write(repo_root, dirty)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def clear(repo_root: Path) -> None:
|
|
196
|
+
"""Empty the set, keeping the block counter intact."""
|
|
197
|
+
dirty = read(repo_root)
|
|
198
|
+
if not dirty.modules:
|
|
199
|
+
return
|
|
200
|
+
dirty.modules = []
|
|
201
|
+
write(repo_root, dirty)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _relative(repo_root: Path, raw: str) -> str | None:
|
|
205
|
+
"""Return *raw* as a repository-relative POSIX path, or None if outside.
|
|
206
|
+
|
|
207
|
+
Accepts absolute and relative paths, because a hook is handed an absolute
|
|
208
|
+
one and a person at a shell types a relative one.
|
|
209
|
+
"""
|
|
210
|
+
p = Path(raw)
|
|
211
|
+
if p.is_absolute():
|
|
212
|
+
try:
|
|
213
|
+
return p.resolve().relative_to(repo_root).as_posix()
|
|
214
|
+
except ValueError:
|
|
215
|
+
return None
|
|
216
|
+
# A relative path has two honest readings and the caller does not say
|
|
217
|
+
# which. A person at a shell means it relative to where they are, so from
|
|
218
|
+
# a subdirectory `mark core.py` must reach `src/payments/core.py`; a
|
|
219
|
+
# library caller and an agent writing a `file_path` mean it relative to the
|
|
220
|
+
# repository root, so `src/ledger/entries.py` must reach that and not
|
|
221
|
+
# `<cwd>/src/ledger/entries.py`. Preferring either one unconditionally gets
|
|
222
|
+
# the other wrong, and getting it wrong is silent: `mark` says nothing when
|
|
223
|
+
# nothing matches, and marks the wrong module when something else does.
|
|
224
|
+
from_cwd: str | None = None
|
|
225
|
+
with suppress(ValueError):
|
|
226
|
+
from_cwd = (Path.cwd() / p).resolve().relative_to(repo_root).as_posix()
|
|
227
|
+
|
|
228
|
+
normalised = Path(os.path.normpath(p.as_posix())).as_posix()
|
|
229
|
+
from_root: str | None = None if normalised.startswith("..") else normalised
|
|
230
|
+
|
|
231
|
+
if from_cwd is None or from_cwd == from_root:
|
|
232
|
+
return from_root
|
|
233
|
+
if from_root is None:
|
|
234
|
+
return from_cwd
|
|
235
|
+
# Both readings land inside the repository and disagree. One stat each
|
|
236
|
+
# settles it, and only here — the common cases above never touch the disk,
|
|
237
|
+
# so a hook handed an absolute path still costs nothing and a path that
|
|
238
|
+
# exists under neither reading still marks the module that used to hold it.
|
|
239
|
+
if (repo_root / from_cwd).exists():
|
|
240
|
+
return from_cwd
|
|
241
|
+
if (repo_root / from_root).exists():
|
|
242
|
+
return from_root
|
|
243
|
+
# Neither exists: the file was deleted, which is a case `mark` is required
|
|
244
|
+
# to handle. The repository-relative reading is the one a hook and a
|
|
245
|
+
# library caller give, and they are the high-volume callers.
|
|
246
|
+
return from_root
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _is_tool_output(decl: Declarations, rel_posix: str) -> bool:
|
|
250
|
+
"""True for anything this tool writes — its own output.
|
|
251
|
+
|
|
252
|
+
Without this, writing a document marks the module that document covers,
|
|
253
|
+
and the set never empties.
|
|
254
|
+
|
|
255
|
+
Everything under the docs root qualifies, and so does any document a
|
|
256
|
+
module declares outside it. The second is named rather than pruned by
|
|
257
|
+
prefix: a declared document is one file, not a directory, and the whole
|
|
258
|
+
point of declaring one is that it sits among files the tool does track.
|
|
259
|
+
"""
|
|
260
|
+
parts = Path(rel_posix).parts
|
|
261
|
+
root = decl.docs_root.parts
|
|
262
|
+
if parts[: len(root)] == root:
|
|
263
|
+
return True
|
|
264
|
+
return any(mod.document == rel_posix for mod in decl.modules)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# Kept in step with `config.DENIED_DIRS`; imported rather than restated so a
|
|
268
|
+
# directory added there is refused here too.
|
|
269
|
+
def _is_derived(rel_posix: str) -> bool:
|
|
270
|
+
from constant_docs.config import DENIED_DIRS, _is_denied
|
|
271
|
+
|
|
272
|
+
path = Path(rel_posix)
|
|
273
|
+
return bool(DENIED_DIRS.intersection(path.parts)) or _is_denied(path)
|