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,1158 @@
1
+ """Configuration loading for constant-docs.
2
+
3
+ Loads, validates, and resolves ``constant-docs.yaml`` into a typed Config
4
+ object. All env/glob/validation logic is here; callers work with the
5
+ returned dataclass and never re-parse the file.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import re
12
+ import subprocess
13
+ from contextlib import suppress
14
+ from dataclasses import dataclass, field
15
+ from fnmatch import fnmatch
16
+ from pathlib import Path, PurePosixPath
17
+ from typing import Any
18
+
19
+ import yaml
20
+
21
+ from constant_docs.globs import GlobError
22
+ from constant_docs.globs import matches as glob_matches
23
+ from constant_docs.kinds import (
24
+ BUILTIN_KINDS,
25
+ DEFAULT_KIND,
26
+ Kind,
27
+ KindError,
28
+ load_kinds,
29
+ )
30
+
31
+ # The `key` a ConfigError carries when the file itself does not parse. The
32
+ # commands that stay silent outside a configured repository test for it: a
33
+ # missing configuration is somebody else's repository, a broken one is this
34
+ # repository with a typo in it, and treating the second as the first turns a
35
+ # gate off while reporting that everything is fine.
36
+ SYNTAX_KEY = "(syntax)"
37
+
38
+ # The `key` for "there is no configuration here at all". The hook commands
39
+ # are silent for this one and only this one: a globally installed hook fires
40
+ # in every repository, including those that do not use the tool.
41
+ MISSING_KEY = "(file)"
42
+
43
+
44
+ class ConfigError(Exception):
45
+ """The configuration file is invalid.
46
+
47
+ ``key`` names the offending top-level key, so callers (especially the
48
+ CLI) can format a precise error message.
49
+ """
50
+
51
+ def __init__(self, message: str, key: str | None = None) -> None:
52
+ super().__init__(message)
53
+ self.key = key
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class ModuleEntry:
58
+ """One configured module."""
59
+
60
+ key: str # e.g. "src/payments" — also the document path stem
61
+ globs: list[str] # one or more glob expressions; may be empty when covering
62
+ kind: str = DEFAULT_KIND # names an entry in Config.kinds
63
+ # Other module keys whose files this one also takes in, transitively.
64
+ #
65
+ # A cross-cutting document could always restate another module's glob. What
66
+ # it could not do is name the module, so two places had to agree with
67
+ # nothing keeping them in step. `covers` makes the dependency a reference:
68
+ # the covering document follows when the covered module's glob moves.
69
+ covers: list[str] = field(default_factory=list, hash=False, compare=False)
70
+ # Where this module's document is written, when it does not belong under
71
+ # the docs root.
72
+ #
73
+ # The key is the document's path stem for every ordinary module, which
74
+ # makes the docs root a bundle and publishing a copy. Some documents are
75
+ # fixed at the repository root by convention that predates any of this —
76
+ # `README.md`, `CONTRIBUTING.md` — and a tool that cannot write them
77
+ # leaves the most-read file in the repository outside the mechanism.
78
+ document: str | None = None
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class AutoSettings:
83
+ """What `constant-docs auto` runs, and how much it asks for at a time."""
84
+
85
+ command: str
86
+ # Named `when` rather than `on`, because `on` is a YAML 1.1 boolean and a
87
+ # block written with it parses to `{True: "stale"}` — a key nothing looks
88
+ # for, in a file that plainly reads `on: stale`.
89
+ when: str = "stale" # "stale" or "always"
90
+ budget: int | None = None # modules per run; None means no cap
91
+ timeout: float | None = None # seconds; None means wait indefinitely
92
+
93
+
94
+ @dataclass(frozen=True)
95
+ class Config:
96
+ """Validated and resolved configuration."""
97
+
98
+ version: int
99
+ docs_root: Path
100
+ modules: list[ModuleEntry]
101
+ index_token_budget: int | None
102
+
103
+ # Resolved mapping: module_key -> sorted list of repository-relative Paths
104
+ module_files: dict[str, list[Path]] = field(hash=False, compare=False)
105
+
106
+ # Every kind available to this configuration: the shipped ones, plus
107
+ # anything the `kinds` block declared or overrode.
108
+ kinds: dict[str, Kind] = field(
109
+ hash=False, compare=False, default_factory=lambda: dict(BUILTIN_KINDS)
110
+ )
111
+
112
+ # Glob pattern to the reason it is deliberately undocumented. Declared
113
+ # rather than inferred: an exclusion nobody justified is one nobody
114
+ # decided, and it outlives whatever made it sensible.
115
+ uncovered: dict[str, str] = field(hash=False, compare=False, default_factory=dict)
116
+
117
+ # What `auto` runs, when the repository declares it. `None` means the
118
+ # repository has not opted into unattended regeneration, which is the
119
+ # default: this is the only thing here that executes anything.
120
+ auto: AutoSettings | None = field(hash=False, compare=False, default=None)
121
+
122
+
123
+ # Refused before any glob resolves, always, whatever the configuration says.
124
+ # This tool hands source files to a model to be described in prose, so a file
125
+ # swept up by a careless glob is not merely hashed — its contents reach a
126
+ # generator and can end up written into a committed document. A deny list in
127
+ # the tool beats one in the config of whoever remembers.
128
+ # Directory names that are refused wherever they appear in a path.
129
+ #
130
+ # Two groups, for two reasons. `Secret`, `.ssh` and `.git` hold material that
131
+ # must never reach a generator. The rest is derived output: a cache is built
132
+ # from source this tool already hashes, so covering it counts the same
133
+ # information twice and makes a test run look like a source change. That is
134
+ # the same argument that excludes the docs root, and it matters because a
135
+ # catch-all glob is exactly what an append-mode document wants.
136
+ #
137
+ # A closed list, not a rule about dotted names: `.github` and `.githooks` are
138
+ # source a build log may well want to cover.
139
+ DENIED_DIRS = frozenset(
140
+ {
141
+ "Secret",
142
+ ".ssh",
143
+ ".git",
144
+ "__pycache__",
145
+ ".venv",
146
+ "node_modules",
147
+ ".mypy_cache",
148
+ ".ruff_cache",
149
+ ".pytest_cache",
150
+ ".tox",
151
+ ".nox",
152
+ ".ipynb_checkpoints",
153
+ # This tool's own state directory — the same string as
154
+ # `state.STATE_DIR`, restated because `state` imports this module and
155
+ # the dependency cannot run the other way. Excluded because it is
156
+ # derived output: hashing it means a catch-all glob such as `**/*`
157
+ # never converges, since writing a document rewrites the dirty set,
158
+ # which marks the document stale again on the very next run.
159
+ ".constant-docs",
160
+ }
161
+ )
162
+
163
+ # Filename patterns that are refused wherever they appear.
164
+ DENIED_NAMES = (
165
+ ".env",
166
+ ".env.*",
167
+ "*.env",
168
+ "id_rsa*",
169
+ "id_ed25519*",
170
+ "*.pem",
171
+ "*.key",
172
+ "credentials",
173
+ "credentials.*",
174
+ )
175
+
176
+
177
+ def _is_denied(rel: Path) -> bool:
178
+ """True when a resolved file must never be read.
179
+
180
+ This tool hands source files to a model to be described in prose, so a
181
+ file swept up by a careless glob is not merely hashed — its contents reach
182
+ a generator and can end up written into a committed document. The refusal
183
+ lives in the tool rather than in the config of whoever remembers.
184
+
185
+ Matching is on path parts and filename rather than a recursive glob,
186
+ because `Path.full_match` is Python 3.13 and this supports 3.11.
187
+ """
188
+ if DENIED_DIRS.intersection(rel.parts[:-1]):
189
+ return True
190
+ return any(fnmatch(rel.name, pat) for pat in DENIED_NAMES)
191
+
192
+
193
+ def _is_denied_target(path: Path) -> bool:
194
+ """True when *path* is a symlink the deny list refuses on its target.
195
+
196
+ The deny list matches a name and the directories above it, and a symlink
197
+ has a name of its own: `src/helper.py` pointing at `~/.ssh/id_rsa` is
198
+ refused by neither `DENIED_NAMES` nor `DENIED_DIRS` while still handing a
199
+ private key to a generator to read and describe. What gets read is the
200
+ target, so that is what is judged. A link that cannot be resolved is
201
+ refused, which is the same safe direction taken everywhere else here.
202
+ """
203
+ try:
204
+ if not path.is_symlink():
205
+ return False
206
+ target = path.resolve()
207
+ # `resolve()` is non-strict, so a broken link resolves happily to a
208
+ # path that is not there. Tested separately, because a link with no
209
+ # target is not source: hashing it dies in `fingerprint.compute`'s
210
+ # `read_bytes`, taking the whole command with it.
211
+ if not target.exists():
212
+ return True
213
+ except OSError:
214
+ return True
215
+ return bool(DENIED_DIRS.intersection(target.parts[:-1])) or any(
216
+ fnmatch(target.name, pat) for pat in DENIED_NAMES
217
+ )
218
+
219
+
220
+ # Files under the docs root this tool owns but does not track as documents.
221
+ # `index.md` is assembled by `constant-docs index` and carries no frontmatter
222
+ # by design, so `is_ours` cannot speak for it. Named once rather than compared
223
+ # as a literal in each scanner: two of those comparisons are the last check
224
+ # before a delete and before a refused write, and the next such file — the
225
+ # test suite already names `log.md` — must not depend on somebody finding
226
+ # every site.
227
+ TOOL_OWNED_DOCS: tuple[str, ...] = ("index.md",)
228
+
229
+
230
+ def is_tool_owned(doc: Path, docs_root: Path) -> bool:
231
+ """True when *doc* is a file this tool owns but does not track."""
232
+ try:
233
+ rel = doc.relative_to(docs_root).as_posix()
234
+ except ValueError:
235
+ return False
236
+ return rel in TOOL_OWNED_DOCS
237
+
238
+
239
+ def repo_root(config_path: str | Path) -> Path:
240
+ """Return the repository root for a configuration file.
241
+
242
+ One spelling, because there were three — `config_path.parent`,
243
+ `resolved.parent` and `config_path.resolve().parent` — and only the
244
+ resolving one normalises `.` and follows a symlinked checkout. They
245
+ agree under a fixed working directory, so this is not a bug fix; it is
246
+ so the next site is not a coin flip, and so `Path('.')` stops turning up
247
+ as a repository root in `relative_to` calls and error messages.
248
+ """
249
+ return Path(config_path).resolve().parent
250
+
251
+
252
+ def validate_module_key(module_key: str) -> None:
253
+ """Refuse a module key whose document would land outside the docs root.
254
+
255
+ ``Path.__truediv__`` discards its left operand when the right is absolute,
256
+ so a key of ``/etc/x`` drops the docs root entirely, and ``..`` walks out
257
+ of it. Either one writes a document — and creates the directories to hold
258
+ it, because `Document.save` makes parents — anywhere the invoking user can
259
+ write, while reporting an ordinary success. A key is a path stem inside
260
+ the docs root, and that is the whole of what it is allowed to be.
261
+ """
262
+ key = str(module_key).strip()
263
+ if not key:
264
+ raise ConfigError("A module key cannot be empty.", key="modules")
265
+ candidate = Path(key)
266
+ # `Path(".").parts` is empty, so a key of `.` or `./` reaches
267
+ # `document_path` as nothing at all and `(docs_root / ".").with_suffix()`
268
+ # yields `docs.md` — a sibling of the docs root rather than a file inside
269
+ # it, outside every scan and outside the walk exclusion. Refused with the
270
+ # rest, because it is the same escape by a quieter route.
271
+ if (
272
+ candidate.is_absolute()
273
+ or key.startswith(("/", "\\"))
274
+ or ".." in candidate.parts
275
+ or not candidate.parts
276
+ ):
277
+ raise ConfigError(
278
+ f"Module key {key!r} must be a relative path inside the docs "
279
+ f"root. A key is the document's path stem, so an absolute key or "
280
+ f"one containing '..' writes outside the documentation folder — "
281
+ f"and outside the repository.",
282
+ key="modules",
283
+ )
284
+
285
+
286
+ def validate_document_path(module_key: str, value: str) -> str:
287
+ """Return *value* normalised, or raise if it cannot name a document.
288
+
289
+ Refused rather than clamped. A path that escapes the repository, or one
290
+ the tool would not recognise as a document when it reads the tree back,
291
+ is a configuration mistake and silently rewriting it hides the mistake
292
+ until somebody wonders where their file went.
293
+ """
294
+ if not isinstance(value, str) or not value.strip():
295
+ raise ConfigError(
296
+ f"Module {module_key!r} declares an empty 'document'. Give it a "
297
+ f"repository-relative path such as `document: README.md`, or "
298
+ f"remove the key to write under the docs root.",
299
+ key="modules",
300
+ )
301
+ raw = value.strip()
302
+ path = PurePosixPath(raw.replace("\\", "/"))
303
+ if path.is_absolute() or raw.startswith("/") or ":" in path.parts[0]:
304
+ raise ConfigError(
305
+ f"Module {module_key!r} declares 'document: {raw}', which is an "
306
+ f"absolute path. Documents are named relative to the repository "
307
+ f"root, so that a checkout anywhere resolves the same.",
308
+ key="modules",
309
+ )
310
+ if ".." in path.parts:
311
+ raise ConfigError(
312
+ f"Module {module_key!r} declares 'document: {raw}', which climbs "
313
+ f"out of the repository. A document the tool writes outside the "
314
+ f"checkout is one no clone of this repository would carry.",
315
+ key="modules",
316
+ )
317
+ if path.suffix != ".md":
318
+ raise ConfigError(
319
+ f"Module {module_key!r} declares 'document: {raw}', which is not "
320
+ f"a Markdown file. Every document this tool writes carries "
321
+ f"frontmatter and a Markdown body; the suffix is how a reader and "
322
+ f"`prune` both tell one from a source file.",
323
+ key="modules",
324
+ )
325
+ return path.as_posix()
326
+
327
+
328
+ def document_path(
329
+ module_key: str, docs_root: Path, document: str | None = None
330
+ ) -> Path:
331
+ """Return the document path for a module key.
332
+
333
+ Defined here rather than in `paths`, which imports this module, so that the
334
+ load-time collision check and the runtime mapping cannot drift apart.
335
+ `paths.doc_path` is the public spelling and delegates to this.
336
+
337
+ *document* overrides the mapping entirely, and is already validated by the
338
+ loader. It is repository-relative rather than docs-root-relative, because
339
+ the reason to reach for it is that the document does not belong under the
340
+ docs root at all.
341
+ """
342
+ if document is not None:
343
+ return Path(document)
344
+ return (docs_root / module_key).with_suffix(".md")
345
+
346
+
347
+ def _detect_document_collisions(
348
+ modules: list[ModuleEntry], docs_root: Path
349
+ ) -> list[tuple[str, str, Path]]:
350
+ """Return (key_a, key_b, path) for module keys sharing a document path.
351
+
352
+ `doc_path` appends `.md`, so `SPEC` and `SPEC.md` both resolve to
353
+ `<docs_root>/SPEC.md`. Whichever is written last wins and the loser reports
354
+ permanently stale, because the document on disk carries the other's hash.
355
+
356
+ A declared `document` is compared here too, and against the same set: it
357
+ can collide with another declaration, with an ordinary module's derived
358
+ path, or with the generated index, and all three fail the same way.
359
+ """
360
+ # Seeded with the tool's own index, which is written by `constant-docs
361
+ # index` and is not a module document. A module keyed `index` resolves to
362
+ # the same file, so the two writers clobbered each other on every run
363
+ # while `verify` reported it permanently missing — and every scan that
364
+ # could have named the file skips `index.md` by design.
365
+ seen: dict[Path, str] = {
366
+ docs_root / owned: "the generated index" for owned in TOOL_OWNED_DOCS
367
+ }
368
+ conflicts: list[tuple[str, str, Path]] = []
369
+ for mod in modules:
370
+ target = document_path(mod.key, docs_root, mod.document)
371
+ previous = seen.get(target)
372
+ if previous is not None:
373
+ conflicts.append((previous, mod.key, target))
374
+ else:
375
+ seen[target] = mod.key
376
+ return conflicts
377
+
378
+
379
+ def _as_name_list(mod_key: str, raw: Any) -> list[str]:
380
+ """Return `covers` as a list of module keys, accepting a bare string."""
381
+ if isinstance(raw, str):
382
+ return [raw]
383
+ if isinstance(raw, list) and all(isinstance(name, str) for name in raw):
384
+ return list(raw)
385
+ raise ConfigError(
386
+ f"'covers' for module {mod_key!r} must be a module key or a list of "
387
+ f"module keys, got {type(raw).__name__}",
388
+ key="modules",
389
+ )
390
+
391
+
392
+ def _validate_covers(modules: list[ModuleEntry]) -> None:
393
+ """Refuse a `covers` naming nothing, and refuse a cycle, naming both.
394
+
395
+ Structural rather than resolved, so it runs in `declarations` and `mark`
396
+ gets the same guarantee the loader does.
397
+
398
+ Silence on an unknown name would reproduce the bug `covers` exists to fix:
399
+ a document that meant *"whatever `api` covers"* and quietly covered nothing
400
+ would hash an empty file set, report as an orphan, and never go stale
401
+ again.
402
+ """
403
+ known = {m.key for m in modules}
404
+ by_key = {m.key: m for m in modules}
405
+
406
+ for mod in modules:
407
+ unknown = [name for name in mod.covers if name not in known]
408
+ if unknown:
409
+ raise ConfigError(
410
+ f"Module {mod.key!r} covers {', '.join(repr(u) for u in unknown)}, "
411
+ f"which {'is' if len(unknown) == 1 else 'are'} not declared. "
412
+ f"Available: {', '.join(sorted(known))}",
413
+ key="modules",
414
+ )
415
+
416
+ # Depth-first, carrying the path so the cycle can be named rather than
417
+ # merely detected. A recursion guard that reported "a cycle exists"
418
+ # leaves the reader to find it in a file with forty modules in it.
419
+ settled: set[str] = set()
420
+ stack: list[str] = []
421
+ on_stack: set[str] = set()
422
+
423
+ def walk(key: str) -> None:
424
+ if key in settled:
425
+ return
426
+ if key in on_stack:
427
+ cycle = [*stack[stack.index(key) :], key]
428
+ raise ConfigError(
429
+ "A module cannot cover itself, directly or through others: "
430
+ + " covers ".join(repr(k) for k in cycle),
431
+ key="modules",
432
+ )
433
+ stack.append(key)
434
+ on_stack.add(key)
435
+ for name in by_key[key].covers:
436
+ walk(name)
437
+ stack.pop()
438
+ on_stack.discard(key)
439
+ settled.add(key)
440
+
441
+ for mod in modules:
442
+ walk(mod.key)
443
+
444
+
445
+ def effective_globs(modules: list[ModuleEntry]) -> dict[str, list[str]]:
446
+ """Return each module's own globs plus those of everything it covers.
447
+
448
+ One definition, used by glob resolution and by `mark`, for the reason
449
+ there is one digest and one matcher: two answers to "which files belong to
450
+ this module" that agree today will disagree eventually, and the symptom
451
+ would be a hook marking the wrong document dirty while `verify` insists the
452
+ right one is fine.
453
+
454
+ Unioning *patterns* rather than resolved files is what keeps `mark` pure
455
+ string work. The file set is identical either way, because resolution is
456
+ per-file matching against patterns.
457
+
458
+ Call only after :func:`_validate_covers`; a cycle here would not terminate.
459
+ """
460
+ by_key = {m.key: m for m in modules}
461
+ resolved: dict[str, list[str]] = {}
462
+
463
+ def gather(key: str) -> list[str]:
464
+ if key in resolved:
465
+ return resolved[key]
466
+ entry = by_key[key]
467
+ out = list(entry.globs)
468
+ for name in entry.covers:
469
+ for pattern in gather(name):
470
+ if pattern not in out:
471
+ out.append(pattern)
472
+ resolved[key] = out
473
+ return out
474
+
475
+ return {m.key: gather(m.key) for m in modules}
476
+
477
+
478
+ def _under(rel: Path, folder: Path) -> bool:
479
+ """True when *rel* lies inside *folder*, both repository-relative."""
480
+ return rel.parts[: len(folder.parts)] == folder.parts
481
+
482
+
483
+ # A read-only `git` call has no business hanging, and a walk that never
484
+ # returns is worse than one that ignores nothing.
485
+ _GIT_TIMEOUT = 10.0
486
+
487
+
488
+ def _git_ignored(abs_root: Path, candidates: list[str]) -> set[str]:
489
+ """Return whichever candidates git is configured to ignore.
490
+
491
+ Asked of git rather than parsed here. `.gitignore` has nested files,
492
+ negation rules, a global excludes file and per-repository excludes, and a
493
+ half-implementation of that is worse than none: it would disagree with what
494
+ the repository actually holds, quietly, in the direction of hashing files
495
+ nobody committed.
496
+
497
+ Empty when git is missing or this is not a repository. The tool has never
498
+ required git and does not start now; it simply cannot answer the question
499
+ there, and an unanswered question excludes nothing.
500
+
501
+ Read-only, and the paths go over stdin rather than the argument list, so no
502
+ filename can arrive as an argument.
503
+ """
504
+ if not candidates:
505
+ return set()
506
+ try:
507
+ done = subprocess.run(
508
+ ["git", "-C", str(abs_root), "check-ignore", "--stdin"],
509
+ input="\n".join(candidates),
510
+ capture_output=True,
511
+ text=True,
512
+ timeout=_GIT_TIMEOUT,
513
+ check=False,
514
+ )
515
+ except (OSError, subprocess.SubprocessError):
516
+ return set()
517
+ # 0 means some path is ignored, 1 means none is. Anything else — not a
518
+ # repository, a broken install — is not an answer, so nothing is excluded.
519
+ if done.returncode not in (0, 1):
520
+ return set()
521
+ return {line for line in done.stdout.splitlines() if line}
522
+
523
+
524
+ def _walk(abs_root: Path, docs_root: Path) -> list[str]:
525
+ """Return every candidate file, repository-relative and POSIX-separated.
526
+
527
+ Denied directories are pruned during the walk rather than filtered after,
528
+ so a repository with a large `.venv` or `node_modules` is not paid for.
529
+
530
+ A file git ignores is not a candidate. Build output, a coverage report or a
531
+ local scratch file under a module's glob would otherwise feed that module's
532
+ digest, so the digest would describe the machine it was computed on and the
533
+ module could never settle. Filtered after the walk rather than pruned
534
+ during it, because one `git` call answers for every path at once; the
535
+ directories big enough to be worth pruning are in `DENIED_DIRS` already.
536
+
537
+ Untracked files that git does *not* ignore stay in. A source file written a
538
+ moment ago and not yet added is exactly what a document should cover, and
539
+ excluding it would break the loop this tool exists for.
540
+ """
541
+ out: list[str] = []
542
+ docs_parts = docs_root.parts
543
+ for dirpath, dirnames, filenames in os.walk(abs_root):
544
+ rel_dir = Path(dirpath).relative_to(abs_root)
545
+ dirnames[:] = [
546
+ d
547
+ for d in dirnames
548
+ if d not in DENIED_DIRS
549
+ and (rel_dir / d).parts[: len(docs_parts)] != docs_parts
550
+ ]
551
+ dirnames.sort()
552
+ for name in sorted(filenames):
553
+ rel = rel_dir / name if rel_dir != Path(".") else Path(name)
554
+ if _is_denied(rel) or _is_denied_target(Path(dirpath) / name):
555
+ continue
556
+ out.append(rel.as_posix())
557
+ ignored = _git_ignored(abs_root, out)
558
+ return [rel for rel in out if rel not in ignored]
559
+
560
+
561
+ def _resolve_globs(
562
+ root: Path, modules: list[ModuleEntry], docs_root: Path
563
+ ) -> dict[str, list[Path]]:
564
+ """Resolve each module's globs against *root* and return file sets.
565
+
566
+ The tree is walked once and every file matched against every pattern,
567
+ rather than each pattern being resolved separately. That is what lets
568
+ `mark` share the matcher: a hook is handed one path and must decide which
569
+ module it belongs to without touching the disk, and two glob
570
+ implementations that agree today will disagree eventually.
571
+
572
+ Patterns come from :func:`effective_globs`, so a module that covers
573
+ another resolves to the union of both — and a file that two routes reach
574
+ is hashed once, because the match is over a set.
575
+
576
+ Files under *docs_root* are excluded whatever the glob says. The docs root
577
+ holds this tool's own output, so a glob that sweeps it up cannot converge:
578
+ writing the document changes a file the document hashes, which marks it
579
+ stale again on the next run. A catch-all glob such as `**/*` — which is
580
+ exactly what a build log wants — hits this immediately.
581
+
582
+ A document declared outside the docs root is excluded for the same reason
583
+ and by name, because the walk has no directory to prune. Without it, a
584
+ `README.md` written by the tool and swept up by another module's glob
585
+ makes that module stale on every run, for ever.
586
+ """
587
+ abs_root = root.resolve()
588
+ declared = {mod.document for mod in modules if mod.document is not None}
589
+ candidates = [rel for rel in _walk(abs_root, docs_root) if rel not in declared]
590
+ patterns = effective_globs(modules)
591
+ result: dict[str, list[Path]] = {}
592
+ for mod in modules:
593
+ # A pattern the matcher cannot compile is a configuration problem, and
594
+ # naming the module is the whole difference between a fixable message
595
+ # and `bad character range z-a at position 9` with no idea where it
596
+ # came from. Criterion 21 requires the offending key.
597
+ try:
598
+ files = {
599
+ Path(rel)
600
+ for rel in candidates
601
+ if any(glob_matches(pattern, rel) for pattern in patterns[mod.key])
602
+ }
603
+ except GlobError as e:
604
+ raise ConfigError(
605
+ f"Module {mod.key!r} declares a glob that cannot be used: {e}",
606
+ key="modules",
607
+ ) from e
608
+ result[mod.key] = sorted(files)
609
+ return result
610
+
611
+
612
+ def _detect_ambiguity(
613
+ modules: list[ModuleEntry],
614
+ module_files: dict[str, list[Path]],
615
+ ) -> list[tuple[str, str, Path]]:
616
+ """Return (module_a, module_b, file) for every overlap *within a kind*.
617
+
618
+ Criterion 22 refuses two globs matching the same file, and its reason is
619
+ that a reader cannot tell which document describes that file. Kinds change
620
+ what "ambiguous" means: a source file is legitimately covered by its module
621
+ document, by the specification that governs it, and by the build log that
622
+ records changes to it. Those are three views of one file, not three claims
623
+ of ownership, and the plan's own configuration example depends on it.
624
+
625
+ So the check is scoped to a kind. Two `module` entries matching one file is
626
+ still an error, and still exits 2.
627
+ """
628
+ kind_of = {m.key: m.kind for m in modules}
629
+ seen: dict[tuple[str, Path], str] = {}
630
+ conflicts: list[tuple[str, str, Path]] = []
631
+ for mod_key, files in module_files.items():
632
+ kind = kind_of.get(mod_key, DEFAULT_KIND)
633
+ for f in files:
634
+ previous = seen.get((kind, f))
635
+ if previous is not None:
636
+ conflicts.append((previous, mod_key, f))
637
+ else:
638
+ seen[(kind, f)] = mod_key
639
+ return conflicts
640
+
641
+
642
+ @dataclass(frozen=True)
643
+ class Declarations:
644
+ """What the file says, before any glob touches the filesystem.
645
+
646
+ `mark` runs on every file write and needs only this: the patterns, so it
647
+ can decide which module a path belongs to, and the docs root, so it can
648
+ ignore the tool's own output. Resolving globs walks the whole tree, which
649
+ is the difference between a hook nobody notices and one a user turns off.
650
+ """
651
+
652
+ version: int
653
+ docs_root: Path
654
+ modules: list[ModuleEntry]
655
+ index_token_budget: int | None
656
+ kinds: dict[str, Kind]
657
+ uncovered: dict[str, str]
658
+ auto: AutoSettings | None
659
+
660
+
661
+ def declarations(path: str | Path) -> Declarations:
662
+ """Parse and structurally validate a ``constant-docs.yaml`` file.
663
+
664
+ Everything except glob resolution and the ambiguity check, both of which
665
+ need the filesystem.
666
+ """
667
+ path = Path(path)
668
+ if not path.exists():
669
+ raise ConfigError(f"Config file not found: {path}", key=MISSING_KEY)
670
+
671
+ # Wrapped so an unparseable configuration is a configuration error rather
672
+ # than PyYAML's own exception, which is not a `ConfigError` and so escaped
673
+ # every handler that exists to catch one — including the `mark` hook, which
674
+ # then failed loudly on every file write while the file was mid-edit.
675
+ try:
676
+ raw: dict[str, Any] = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
677
+ except yaml.YAMLError as e:
678
+ # A distinct key, because "there is no configuration here" and "the
679
+ # configuration is broken" are opposite facts that the hooks treat
680
+ # differently. Both are `ConfigError`; only the first may be silent.
681
+ raise ConfigError(f"{path} is not valid YAML: {e}", key=SYNTAX_KEY) from e
682
+ except UnicodeDecodeError as e:
683
+ # The one read in the package that used to take the platform
684
+ # locale encoding. On Windows it silently mojibakes a glob or an
685
+ # `uncovered:` reason rather than raising, and `add_module` writes
686
+ # this same file as UTF-8 — so the writer could produce bytes the
687
+ # reader could not take back.
688
+ raise ConfigError(f"{path} is not valid UTF-8: {e}", key=SYNTAX_KEY) from e
689
+
690
+ # --- version -----------------------------------------------------------
691
+ version = raw.get("version")
692
+ if version != 1:
693
+ raise ConfigError(
694
+ f"Expected version=1 but got {version!r}",
695
+ key="version",
696
+ )
697
+
698
+ # --- docs_root ---------------------------------------------------------
699
+ docs_root_raw = raw.get("docs_root")
700
+ if not docs_root_raw:
701
+ raise ConfigError(
702
+ "'docs_root' is required — set it to the directory where "
703
+ "module documents should be stored (e.g. 'docs/modules')",
704
+ key="docs_root",
705
+ )
706
+ docs_root = Path(str(docs_root_raw))
707
+ # The docs root has to be a real folder inside the repository, because
708
+ # excluding it from the source walk is a path-prefix test and a prefix
709
+ # that is empty or non-relative matches either everything or nothing.
710
+ # `docs_root: "."` has no parts at all, so the walk pruned every
711
+ # directory, every module resolved to no files, and `prune` reported the
712
+ # tool's own documents as orphans and deleted them. The same empty prefix
713
+ # makes `state._is_tool_output` true for every path, which silently stops
714
+ # `mark` marking anything.
715
+ normalised = Path(os.path.normpath(docs_root))
716
+ if docs_root.is_absolute() or not normalised.parts or normalised.parts[0] == "..":
717
+ raise ConfigError(
718
+ f"'docs_root' must be a relative directory inside the repository "
719
+ f"(e.g. 'docs' or 'docs/modules'), got {str(docs_root_raw)!r}. "
720
+ f"Documents need a folder of their own: a docs root that is the "
721
+ f"repository root, or outside it, cannot be excluded from the "
722
+ f"source walk.",
723
+ key="docs_root",
724
+ )
725
+ # The normalised form is what is kept, not just what was checked. Both
726
+ # `_walk`'s prune test and `state._is_tool_output` compare `.parts` as a
727
+ # prefix, so a value like `docs/../notes` would validate on its normalised
728
+ # form and then never match the `notes/` it actually resolves to — putting
729
+ # the tool's own output back into the source walk.
730
+ docs_root = normalised
731
+
732
+ # --- modules -----------------------------------------------------------
733
+ # An explicitly empty mapping is allowed: a repository part-way through
734
+ # adopting the tool has nothing tracked yet, and `init` has to be able to
735
+ # write a file that loads. A *missing* key stays an error, and so does a
736
+ # null one — `modules:` with nothing after it is a half-written file, and a
737
+ # mistyped key that loaded with nothing tracked would be a repository where
738
+ # every check passes and nothing is checked.
739
+ if "modules" not in raw:
740
+ raise ConfigError(
741
+ "'modules' is required and must be a mapping of module key to "
742
+ "glob pattern(s), e.g.\n src/payments: src/payments/**/*.py",
743
+ key="modules",
744
+ )
745
+ raw_modules = raw["modules"]
746
+ if not isinstance(raw_modules, dict):
747
+ raise ConfigError(
748
+ "'modules' is required and must be a mapping of module key to "
749
+ "glob pattern(s), e.g.\n src/payments: src/payments/**/*.py",
750
+ key="modules",
751
+ )
752
+
753
+ # --- kinds ---------------------------------------------------------
754
+ # Loaded before modules, because a module entry names one.
755
+ try:
756
+ kinds = load_kinds(raw.get("kinds"))
757
+ except KindError as e:
758
+ raise ConfigError(str(e), key="kinds") from e
759
+
760
+ modules: list[ModuleEntry] = []
761
+ for mod_key, entry_value in raw_modules.items():
762
+ # Three accepted shapes, and the first two are 0.1's:
763
+ # src/payments: <glob>
764
+ # src/payments: [<glob>, <glob>]
765
+ # src/payments: {glob: <glob or list>, kind: <name>}
766
+ covers_list: list[str] = []
767
+ document: str | None = None
768
+ if isinstance(entry_value, dict):
769
+ unknown = set(entry_value) - {"glob", "kind", "covers", "document"}
770
+ if unknown:
771
+ raise ConfigError(
772
+ f"Module {mod_key!r} declares unknown key(s): "
773
+ f"{', '.join(sorted(unknown))}. Only 'glob', 'kind', "
774
+ f"'covers' and 'document' are accepted.",
775
+ key="modules",
776
+ )
777
+ if "glob" not in entry_value and "covers" not in entry_value:
778
+ raise ConfigError(
779
+ f"Module {mod_key!r} is declared as a mapping with neither "
780
+ f"'glob' nor 'covers'. Give it files of its own with "
781
+ f"`glob: <pattern>`, or name the modules whose files it "
782
+ f"takes in with `covers: [<module>]`.",
783
+ key="modules",
784
+ )
785
+ glob_value = entry_value.get("glob", [])
786
+ kind_name = str(entry_value.get("kind", DEFAULT_KIND))
787
+ covers_list = _as_name_list(mod_key, entry_value.get("covers", []))
788
+ if "document" in entry_value:
789
+ document = validate_document_path(str(mod_key), entry_value["document"])
790
+ else:
791
+ glob_value = entry_value
792
+ kind_name = DEFAULT_KIND
793
+
794
+ if isinstance(glob_value, str):
795
+ glob_list = [glob_value]
796
+ elif isinstance(glob_value, list):
797
+ glob_list = [str(g) for g in glob_value]
798
+ else:
799
+ raise ConfigError(
800
+ f"Glob for module {mod_key!r} must be a string or a "
801
+ f"list of strings, got {type(glob_value).__name__}",
802
+ key="modules",
803
+ )
804
+
805
+ if kind_name not in kinds:
806
+ raise ConfigError(
807
+ f"Module {mod_key!r} names kind {kind_name!r}, which is not "
808
+ f"declared. Available: {', '.join(sorted(kinds))}",
809
+ key="modules",
810
+ )
811
+
812
+ validate_module_key(str(mod_key))
813
+
814
+ modules.append(
815
+ ModuleEntry(
816
+ key=str(mod_key),
817
+ globs=glob_list,
818
+ kind=kind_name,
819
+ covers=covers_list,
820
+ document=document,
821
+ )
822
+ )
823
+
824
+ # Both refusals are structural, so they run here rather than in `load`:
825
+ # `mark` parses declarations and nothing else, and it must not silently
826
+ # cover a module that does not exist.
827
+ _validate_covers(modules)
828
+
829
+ # --- index -------------------------------------------------------------
830
+ raw_index = raw.get("index", {}) or {}
831
+ index_token_budget: int | None = None
832
+ if "token_budget" in raw_index:
833
+ t = raw_index["token_budget"]
834
+ if isinstance(t, bool) or not isinstance(t, int) or t <= 0:
835
+ raise ConfigError(
836
+ f"'index.token_budget' must be a positive integer, got {t!r}",
837
+ key="index.token_budget",
838
+ )
839
+ index_token_budget = t
840
+
841
+ # --- uncovered ---------------------------------------------------------
842
+ raw_uncovered = raw.get("uncovered", {}) or {}
843
+ if not isinstance(raw_uncovered, dict):
844
+ raise ConfigError(
845
+ "'uncovered' must be a mapping of glob pattern to the reason that "
846
+ "pattern is deliberately undocumented, e.g.\n"
847
+ ' "tests/fixtures/**": Test data rather than source',
848
+ key="uncovered",
849
+ )
850
+ uncovered: dict[str, str] = {}
851
+ for pattern, reason in raw_uncovered.items():
852
+ if not isinstance(reason, str) or not reason.strip():
853
+ raise ConfigError(
854
+ f"'uncovered' entry {str(pattern)!r} has no reason. Every "
855
+ f"exclusion needs one: an exclusion nobody justified is one "
856
+ f"nobody decided, and it will outlive whatever made it "
857
+ f"sensible.",
858
+ key="uncovered",
859
+ )
860
+ uncovered[str(pattern)] = reason.strip()
861
+
862
+ # --- auto ---------------------------------------------------------------
863
+ auto = _load_auto(raw.get("auto"))
864
+
865
+ return Declarations(
866
+ version=version,
867
+ docs_root=docs_root,
868
+ modules=modules,
869
+ index_token_budget=index_token_budget,
870
+ kinds=kinds,
871
+ uncovered=uncovered,
872
+ auto=auto,
873
+ )
874
+
875
+
876
+ def _scalar(value: str) -> str:
877
+ """Return *value* quoted exactly as much as YAML requires, and no more.
878
+
879
+ Reached for rather than written by hand because the failure is quiet and
880
+ specific: a glob beginning `*` is a YAML alias indicator, so `- *.md`
881
+ is a parse error, and `add` would leave a configuration nothing can load.
882
+ Dumping a one-item list and stripping the bullet is the shortest way to
883
+ borrow the emitter's own judgement about when a quote is needed.
884
+ """
885
+ return yaml.safe_dump(
886
+ [value], default_flow_style=False, allow_unicode=True
887
+ ).strip()[2:]
888
+
889
+
890
+ def _render_entry(name: str, globs: list[str], covers: list[str], kind: str) -> str:
891
+ """Return the YAML text for one module entry, indented for the block."""
892
+ lines = [f" {_scalar(name)}:"]
893
+ if globs:
894
+ lines.append(" glob:")
895
+ lines.extend(f" - {_scalar(g)}" for g in globs)
896
+ if covers:
897
+ lines.append(" covers:")
898
+ lines.extend(f" - {_scalar(c)}" for c in covers)
899
+ lines.append(f" kind: {_scalar(kind)}")
900
+ return "\n".join(lines) + "\n"
901
+
902
+
903
+ _EMPTY_MODULES_RE = re.compile(r"^modules:\s*\{\s*\}\s*$")
904
+
905
+
906
+ def _modules_block_end(lines: list[str]) -> int:
907
+ """Return the line index at which the `modules:` block ends.
908
+
909
+ Text surgery rather than a load-and-dump round trip, because this
910
+ repository's own configuration is more comment than configuration and the
911
+ reasons recorded there are not recoverable from the keys. A YAML emitter
912
+ would drop every one of them, which is data loss dressed up as
913
+ normalisation.
914
+ """
915
+ start = next(
916
+ (i for i, line in enumerate(lines) if line.rstrip() == "modules:"),
917
+ None,
918
+ )
919
+ if start is None:
920
+ raise ConfigError(
921
+ "No 'modules:' block found to add to. The key must be at the top "
922
+ "level and on a line of its own.",
923
+ key="modules",
924
+ )
925
+ end = len(lines)
926
+ for i in range(start + 1, len(lines)):
927
+ stripped = lines[i].strip()
928
+ if not stripped or stripped.startswith("#"):
929
+ continue
930
+ if not lines[i][:1].isspace():
931
+ end = i
932
+ break
933
+ # Blank lines *and a trailing comment block* before the next top-level key
934
+ # belong to the gap, not to the block, so the new entry lands inside
935
+ # `modules:` rather than after it. The comments matter as much as the
936
+ # blanks: a comment at the end of the block is documenting whatever comes
937
+ # next — `init` writes a commented-out `uncovered:` template exactly there
938
+ # — and an entry inserted below it reads as belonging to that key. It
939
+ # loads today only because YAML cannot see comments, and stops loading the
940
+ # moment somebody uncomments the template the tool invited them to use.
941
+ while end > start + 1 and (
942
+ not lines[end - 1].strip() or lines[end - 1].lstrip().startswith("#")
943
+ ):
944
+ end -= 1
945
+ return end
946
+
947
+
948
+ def add_module(
949
+ path: str | Path,
950
+ name: str,
951
+ *,
952
+ globs: list[str] | None = None,
953
+ covers: list[str] | None = None,
954
+ kind: str = DEFAULT_KIND,
955
+ ) -> None:
956
+ """Add one module entry to a configuration file.
957
+
958
+ Registering a document becomes a command rather than a hand edit, which is
959
+ the point: editing structured files in place is what agents do worst, and
960
+ a mistake surfaces as the whole repository failing to load.
961
+
962
+ The result is validated by loading it. If the loader refuses — a duplicate
963
+ document path, a glob colliding with another module of the same kind, a
964
+ cycle — the file is restored byte for byte and the loader's own error is
965
+ raised. Half a configuration is worse than none, because it breaks the
966
+ command that would explain why.
967
+ """
968
+ path = Path(path)
969
+ globs = list(globs or [])
970
+ covers = list(covers or [])
971
+ if not globs and not covers:
972
+ raise ConfigError(
973
+ f"Module {name!r} needs files. Give it --glob <pattern>, or name "
974
+ f"the modules whose files it takes in with --covers <module>.",
975
+ key="modules",
976
+ )
977
+
978
+ decl = declarations(path)
979
+ if any(m.key == name for m in decl.modules):
980
+ raise ConfigError(
981
+ f"Module {name!r} is already declared. Edit its entry rather than "
982
+ f"adding a second one; two entries under one key is a document "
983
+ f"whose boundary depends on which was read last.",
984
+ key="modules",
985
+ )
986
+ if kind not in decl.kinds:
987
+ raise ConfigError(
988
+ f"Kind {kind!r} is not declared. Available: "
989
+ f"{', '.join(sorted(decl.kinds))}",
990
+ key="kinds",
991
+ )
992
+
993
+ original = path.read_text(encoding="utf-8")
994
+ lines = original.splitlines(keepends=True)
995
+ # `modules: {}` is what `init` writes into a repository with nothing
996
+ # tracked yet. It is a mapping the loader accepts and not a block anything
997
+ # can be inserted into, so the first `add` turns it into one.
998
+ for i, line in enumerate(lines):
999
+ if _EMPTY_MODULES_RE.match(line):
1000
+ lines[i] = "modules:\n"
1001
+ break
1002
+ end = _modules_block_end(lines)
1003
+ entry = _render_entry(name, globs, covers, kind)
1004
+ if end > 0 and lines[end - 1].strip():
1005
+ entry = "\n" + entry
1006
+ updated = "".join(lines[:end]) + entry + "".join(lines[end:])
1007
+
1008
+ # The write is inside the try, and the restore covers everything, because
1009
+ # the failure that actually corrupts the file was the one not covered: a
1010
+ # rendered entry the loader cannot parse arrives as `yaml.YAMLError`,
1011
+ # which is not a `ConfigError`, so the rollback never fired in precisely
1012
+ # the case it was written for. A rollback that only undoes the errors
1013
+ # somebody predicted is not a rollback.
1014
+ try:
1015
+ path.write_text(updated, encoding="utf-8")
1016
+ load(path)
1017
+ except Exception:
1018
+ with suppress(OSError):
1019
+ path.write_text(original, encoding="utf-8")
1020
+ raise
1021
+
1022
+
1023
+ _AUTO_TRIGGERS = ("stale", "always")
1024
+
1025
+
1026
+ def _load_auto(raw: Any) -> AutoSettings | None:
1027
+ """Parse the `auto:` block, or return None when there is none.
1028
+
1029
+ Refused rather than tolerated, key by key, for the usual reason: a mistyped
1030
+ key that is silently ignored is a setting its author believes is in force —
1031
+ and here that setting decides whether a command runs at all.
1032
+ """
1033
+ if raw is None:
1034
+ return None
1035
+ if not isinstance(raw, dict):
1036
+ raise ConfigError(
1037
+ "'auto' must be a mapping with a 'command', e.g.\n"
1038
+ " auto:\n command: \"<your agent> -p 'Run the settle loop'\"",
1039
+ key="auto",
1040
+ )
1041
+ # `on:` is a YAML 1.1 boolean, so a block written with it arrives here as
1042
+ # `{True: "stale"}` and every key lookup misses. Caught by name, because
1043
+ # the alternative is a user staring at a configuration that plainly says
1044
+ # `on: stale` and a tool insisting there is no such key.
1045
+ if any(isinstance(key, bool) for key in raw):
1046
+ raise ConfigError(
1047
+ "'auto' has a key YAML read as a boolean. `on`, `off`, `yes` and "
1048
+ "`no` are booleans in YAML, so `on: stale` becomes `true: stale`. "
1049
+ "The key is 'when'.",
1050
+ key="auto.when",
1051
+ )
1052
+ unknown = set(raw) - {"command", "when", "budget", "timeout"}
1053
+ if unknown:
1054
+ raise ConfigError(
1055
+ f"'auto' declares unknown key(s): {', '.join(sorted(unknown))}. "
1056
+ f"Only 'command', 'when', 'budget' and 'timeout' are accepted.",
1057
+ key="auto",
1058
+ )
1059
+
1060
+ command = raw.get("command")
1061
+ if not isinstance(command, str) or not command.strip():
1062
+ raise ConfigError(
1063
+ "'auto.command' is required and must be the command to run, e.g.\n"
1064
+ " command: \"<your agent> -p 'Run the constant-docs settle loop'\"",
1065
+ key="auto.command",
1066
+ )
1067
+
1068
+ trigger = str(raw.get("when", "stale"))
1069
+ if trigger not in _AUTO_TRIGGERS:
1070
+ raise ConfigError(
1071
+ f"'auto.when' is {trigger!r}; it must be one of "
1072
+ f"{', '.join(_AUTO_TRIGGERS)}",
1073
+ key="auto.when",
1074
+ )
1075
+
1076
+ budget = raw.get("budget")
1077
+ # `isinstance(budget, bool)` first: `bool` subclasses `int`, so the
1078
+ # YAML 1.1 booleans `yes`, `on` and `true` all arrive as `True`, pass a
1079
+ # positive-integer test, and slice as a silent cap of one module per
1080
+ # run. The same trap the boolean-key guard above catches for `on:`,
1081
+ # arriving as a value instead of a key.
1082
+ if budget is not None and (
1083
+ isinstance(budget, bool) or not isinstance(budget, int) or budget <= 0
1084
+ ):
1085
+ raise ConfigError(
1086
+ f"'auto.budget' must be a positive integer of modules per run, "
1087
+ f"got {budget!r}",
1088
+ key="auto.budget",
1089
+ )
1090
+
1091
+ timeout = raw.get("timeout")
1092
+ if timeout is not None and (
1093
+ isinstance(timeout, bool)
1094
+ or not isinstance(timeout, (int, float))
1095
+ or timeout <= 0
1096
+ ):
1097
+ raise ConfigError(
1098
+ f"'auto.timeout' must be a positive number of seconds, got {timeout!r}",
1099
+ key="auto.timeout",
1100
+ )
1101
+
1102
+ return AutoSettings(
1103
+ command=command.strip(),
1104
+ when=trigger,
1105
+ budget=budget,
1106
+ timeout=float(timeout) if timeout is not None else None,
1107
+ )
1108
+
1109
+
1110
+ def load(path: str | Path) -> Config:
1111
+ """Parse, validate, and resolve a ``constant-docs.yaml`` file.
1112
+
1113
+ Raises :exc:`ConfigError` on structural problems (bad version, missing
1114
+ keys, ambiguous globs). Returns a fully resolved :class:`Config`.
1115
+ """
1116
+ path = Path(path)
1117
+ decl = declarations(path)
1118
+
1119
+ # --- resolve globs -----------------------------------------------------
1120
+ root = path.resolve().parent
1121
+ module_files = _resolve_globs(root, decl.modules, decl.docs_root)
1122
+
1123
+ # --- document-path collisions -------------------------------------------
1124
+ # Checked before the glob ambiguity, because a collision here means two
1125
+ # modules cannot both have a document at all, whatever their globs match.
1126
+ doc_conflicts = _detect_document_collisions(decl.modules, decl.docs_root)
1127
+ if doc_conflicts:
1128
+ lines = [
1129
+ f" {a!r} and {b!r} both write {p.as_posix()}" for a, b, p in doc_conflicts
1130
+ ]
1131
+ raise ConfigError(
1132
+ "Two module keys resolve to the same document path. A key is the "
1133
+ "document's path stem, so a key ending in '.md' collides with the "
1134
+ "same key without it, and a declared 'document' collides with "
1135
+ "whatever else writes there:\n" + "\n".join(lines),
1136
+ key="modules",
1137
+ )
1138
+
1139
+ # --- ambiguity check ---------------------------------------------------
1140
+ conflicts = _detect_ambiguity(decl.modules, module_files)
1141
+ if conflicts:
1142
+ lines = [f" {a!r} and {b!r} both match {f}" for a, b, f in conflicts]
1143
+ raise ConfigError(
1144
+ "Glob ambiguity — the following files are matched by more "
1145
+ "than one module of the same kind:\n" + "\n".join(lines),
1146
+ key="modules",
1147
+ )
1148
+
1149
+ return Config(
1150
+ version=decl.version,
1151
+ docs_root=decl.docs_root,
1152
+ modules=decl.modules,
1153
+ index_token_budget=decl.index_token_budget,
1154
+ kinds=decl.kinds,
1155
+ uncovered=decl.uncovered,
1156
+ auto=decl.auto,
1157
+ module_files=module_files,
1158
+ )