quirework 0.1.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.
Files changed (138) hide show
  1. quirework/__init__.py +3 -0
  2. quirework/__main__.py +3 -0
  3. quirework/cli.py +431 -0
  4. quirework/core/__init__.py +1 -0
  5. quirework/core/artifact_paths.py +86 -0
  6. quirework/core/checks.py +235 -0
  7. quirework/core/frontmatter.py +83 -0
  8. quirework/core/globs.py +45 -0
  9. quirework/core/pinned.py +169 -0
  10. quirework/core/usage.py +604 -0
  11. quirework/delivery/__init__.py +1 -0
  12. quirework/delivery/artifacts.py +210 -0
  13. quirework/delivery/text.py +166 -0
  14. quirework/manuscript/__init__.py +1 -0
  15. quirework/manuscript/backfill.py +1972 -0
  16. quirework/manuscript/editions.py +111 -0
  17. quirework/manuscript/footprint.py +95 -0
  18. quirework/manuscript/formats.py +208 -0
  19. quirework/manuscript/lifecycle.py +191 -0
  20. quirework/manuscript/notes.py +2301 -0
  21. quirework/manuscript/pages.py +284 -0
  22. quirework/manuscript/quarto.py +416 -0
  23. quirework/manuscript/quotes.py +512 -0
  24. quirework/manuscript/rename.py +309 -0
  25. quirework/manuscript/sources.py +286 -0
  26. quirework/manuscript/trail.py +1875 -0
  27. quirework/manuscript/units.py +416 -0
  28. quirework/project.py +645 -0
  29. quirework/provenance/__init__.py +1 -0
  30. quirework/provenance/agyfilter.py +436 -0
  31. quirework/provenance/backup/__init__.py +469 -0
  32. quirework/provenance/backup/base.py +290 -0
  33. quirework/provenance/backup/command.py +107 -0
  34. quirework/provenance/backup/directory.py +142 -0
  35. quirework/provenance/backup/git.py +150 -0
  36. quirework/provenance/backup/rclone.py +153 -0
  37. quirework/provenance/capture/__init__.py +1 -0
  38. quirework/provenance/capture/commands.py +28 -0
  39. quirework/provenance/capture/extract_artifacts.py +110 -0
  40. quirework/provenance/capture/extract_files.py +118 -0
  41. quirework/provenance/capture/fetch_conversation.py +127 -0
  42. quirework/provenance/capture/fetch_gemini.py +149 -0
  43. quirework/provenance/capture/render_transcript.py +126 -0
  44. quirework/provenance/capture/transform.py +22 -0
  45. quirework/provenance/catalog.py +733 -0
  46. quirework/provenance/ccfilter.py +1126 -0
  47. quirework/provenance/codexfilter.py +883 -0
  48. quirework/provenance/coldread.py +1731 -0
  49. quirework/provenance/curate.py +67 -0
  50. quirework/provenance/humaninput.py +811 -0
  51. quirework/provenance/ingest.py +31 -0
  52. quirework/provenance/ingestion/__init__.py +1 -0
  53. quirework/provenance/ingestion/agy.py +192 -0
  54. quirework/provenance/ingestion/claude.py +493 -0
  55. quirework/provenance/ingestion/codex.py +296 -0
  56. quirework/provenance/lineage.py +148 -0
  57. quirework/provenance/metrics.py +1247 -0
  58. quirework/provenance/rulings.py +478 -0
  59. quirework/provenance/scope.py +73 -0
  60. quirework/provenance/search.py +455 -0
  61. quirework/provenance/semantic.py +475 -0
  62. quirework/provenance/show.py +1213 -0
  63. quirework/provenance/store.py +1271 -0
  64. quirework/provenance/trees.py +133 -0
  65. quirework/provenance/worktrees.py +199 -0
  66. quirework/providers/__init__.py +1 -0
  67. quirework/providers/blind.py +2192 -0
  68. quirework/providers/canary.py +685 -0
  69. quirework/providers/catalog.py +44 -0
  70. quirework/providers/claude_login.py +92 -0
  71. quirework/providers/exits.py +196 -0
  72. quirework/providers/harness.py +830 -0
  73. quirework/providers/images.py +92 -0
  74. quirework/providers/models.py +1284 -0
  75. quirework/providers/quota.py +588 -0
  76. quirework/providers/reads.py +474 -0
  77. quirework/providers/replies.py +175 -0
  78. quirework/publish/__init__.py +1 -0
  79. quirework/publish/backends.py +112 -0
  80. quirework/publish/bookstage.py +1086 -0
  81. quirework/publish/build.py +60 -0
  82. quirework/publish/editions.py +48 -0
  83. quirework/publish/epubcheck.py +287 -0
  84. quirework/publish/experiment.py +1097 -0
  85. quirework/publish/figfonts.py +234 -0
  86. quirework/publish/fignumbers.py +293 -0
  87. quirework/publish/figures.py +729 -0
  88. quirework/publish/folios.py +62 -0
  89. quirework/publish/fonts.py +307 -0
  90. quirework/publish/graybuild.py +73 -0
  91. quirework/publish/hugo.py +117 -0
  92. quirework/publish/kindle.js +411 -0
  93. quirework/publish/kindle.py +1393 -0
  94. quirework/publish/panelkit.py +918 -0
  95. quirework/publish/plans.py +651 -0
  96. quirework/publish/proof.py +792 -0
  97. quirework/publish/redpen.py +1654 -0
  98. quirework/publish/render.py +942 -0
  99. quirework/publish/targets.py +216 -0
  100. quirework/publish/variant.py +918 -0
  101. quirework/runs/__init__.py +1 -0
  102. quirework/runs/__main__.py +15 -0
  103. quirework/runs/commands.py +278 -0
  104. quirework/runs/coverage.py +86 -0
  105. quirework/runs/engine.py +2143 -0
  106. quirework/runs/feed.py +853 -0
  107. quirework/runs/observe.py +379 -0
  108. quirework/runs/storage.py +155 -0
  109. quirework/settings.py +492 -0
  110. quirework/sops/__init__.py +1 -0
  111. quirework/sops/catalog.py +386 -0
  112. quirework/sops/commands.py +567 -0
  113. quirework/sops/composition.py +382 -0
  114. quirework/sops/instruments.py +184 -0
  115. quirework/sops/personas.py +296 -0
  116. quirework/sops/review.py +2568 -0
  117. quirework/sops/room.py +2791 -0
  118. quirework/sops/roundtable.py +2578 -0
  119. quirework/sops/sections.py +169 -0
  120. quirework/sops/workshop.py +1629 -0
  121. quirework/templates/backup.toml +39 -0
  122. quirework/templates/book.toml +88 -0
  123. quirework/templates/run.toml +32 -0
  124. quirework/templates/site.toml +90 -0
  125. quirework/templates/tells.toml +64 -0
  126. quirework/templates/terminology.toml +20 -0
  127. quirework/templates/tmpfiles.conf +42 -0
  128. quirework/testing.py +345 -0
  129. quirework/text/__init__.py +1 -0
  130. quirework/text/jargon.py +545 -0
  131. quirework/text/mdwrap.py +1001 -0
  132. quirework/text/tells.py +547 -0
  133. quirework-0.1.0.dist-info/METADATA +444 -0
  134. quirework-0.1.0.dist-info/RECORD +138 -0
  135. quirework-0.1.0.dist-info/WHEEL +5 -0
  136. quirework-0.1.0.dist-info/entry_points.txt +2 -0
  137. quirework-0.1.0.dist-info/licenses/LICENSE +21 -0
  138. quirework-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,2301 @@
1
+ """Extract in-source annotations from the document source.
2
+
3
+ The volatile half of the old map (to-dos, guidance, genesis, conversation
4
+ references, spec bindings) lives as structured HTML comments in a per-unit
5
+ sidecar: beside a book's chapter, or wherever a site keeps its pages' marks
6
+ (`units` owns the layout). They never reach the rendered text and survive a
7
+ semantic rewrap, but a tool can harvest them into the grouped views the map
8
+ used to hold -- always in sync, because they are read from the text itself.
9
+
10
+ Convention (one tag per comment; the parenthetical and body are optional):
11
+
12
+ <!-- @guidance how this chapter should read (voice, length, depth, goals) -->
13
+ <!-- @genesis where it came from: why this subject, the inspiration behind it -->
14
+ <!-- @bind(kettle-signal) -->
15
+ <!-- @todo make the kettle example concrete -->
16
+ <!-- @variant(audio) describe the signal for the audio edition -->
17
+ <!-- @omit(kettle-signal) a derived edition's reason to leave a section out -->
18
+ <!-- @ref source:field-notes -- the interview that seeded this -->
19
+ <!-- @see(em-dashes) the long dialogue here is the worst offender -->
20
+
21
+ Comments may span multiple lines; the body is whitespace-collapsed. Any tag name
22
+ is accepted. The list above is convention, not a closed set.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import argparse
28
+ import difflib
29
+ import os
30
+ import re
31
+ import subprocess
32
+ import sys
33
+ from collections import Counter
34
+ from dataclasses import dataclass
35
+ from pathlib import Path
36
+ from typing import NamedTuple
37
+
38
+ from quirework.core import frontmatter
39
+ from quirework.manuscript import editions, formats, units
40
+ from quirework.project import ConfigurationError, current_project
41
+
42
+ # <!-- @kind(args) body --> , possibly multi-line.
43
+ _ANNO = re.compile(
44
+ r"<!--\s*@([a-zA-Z][\w-]*)\s*(?:\(([^)]*)\))?\s*(.*?)\s*-->", re.DOTALL
45
+ )
46
+ # One heading regex for chapters, sidecars and the spec: level captured, then
47
+ # the text, then an optional Pandoc attribute block. The attrs are captured
48
+ # whole ({#id .class}, {.unnumbered}) and the {#id} extracted by _ATTR_ID, so
49
+ # an anchor with classes beside it still resolves and a class-only block never
50
+ # leaks into the heading text.
51
+ _HEADING = re.compile(r"(?m)^(#{1,6})\s+(.*?)\s*(?:\{([^{}]*)\})?\s*$")
52
+ # A `/` is admitted so a trail heading can carry a unit's section by its full
53
+ # name (`{#the-kettle/thesis}`); a spec anchor that contains one is refused,
54
+ # since `/` is what separates the unit from its section.
55
+ _ATTR_ID = re.compile(r"#([\w:./-]+)")
56
+ _WS = re.compile(r"\s+")
57
+ # Heading detection must not read non-prose regions: a `# ...` line inside a
58
+ # multi-line HTML comment (a sidecar banner) or a YAML comment inside
59
+ # frontmatter is not a heading, but the line-anchored regex cannot tell.
60
+ # Masking preserves length and newlines, so offsets stay comparable.
61
+ _COMMENT = re.compile(r"(?s)<!--.*?-->")
62
+ # Fenced code blocks: annotation *examples* in documentation live in fences and
63
+ # must not be harvested as real annotations.
64
+ _FENCE = re.compile(r"(?ms)^[ \t]*(`{3,}|~{3,}).*?^[ \t]*\1[ \t]*$")
65
+
66
+ # Three or more consecutive all-caps words: the AI-register tell `check()` lints
67
+ # BLOCK_KINDS for. Three, not two, so ordinary emphasis ("DO NOT", "NEVER") and
68
+ # initialisms in plain prose pass; contract shorthand runs far longer than three.
69
+ _CAPS_RUN = re.compile(r"\b[A-Z][A-Z'-]{2,}(\s+[A-Z][A-Z'-]{2,}){2,}")
70
+
71
+ # Decision-journaling tells `check()` lints CONTRACT_KINDS for: day-precision
72
+ # dates, authority citations, review-round references, mark-about-mark
73
+ # narration, commit shas. A mark records current state, never history
74
+ # (AGENTS.md rule 1); "who settled it, and when" reads as authority and chills
75
+ # the re-discussion that discuss-before-change is meant to allow. Deliberately
76
+ # NOT matched, each a live false positive in the corpus: bare years and
77
+ # year-ranges ("a 2025-2026 frontier run" is a world-fact, not a stamp);
78
+ # "retired / refused / withdrawn" (the legitimate present-tense guard idiom);
79
+ # "ratified" (a word the prose itself uses); "caught" (chapter vocabulary
80
+ # -- real journaling hits co-occur with a date); bare "panel" (quoted book
81
+ # prose: "when we opened the panel"); `conv:`-prefixed ids (corpus pointers,
82
+ # the @ref convention -- content, not a commit sha).
83
+ _JOURNALING = re.compile(
84
+ r"""
85
+ \b20\d\d-\d\d-\d\d\b # day-precision ISO date
86
+ | \bauthor'?s?\s+(call|ruling|direction|point|brief)\b
87
+ | \bred-?pen\s+V\d+\b # review-round citation
88
+ | \bmultidisc\b | \bblind\s+(draft|panel|alternative)\b
89
+ | \b(this|the)\s+(mark|note|claim|guard)\s+(previously|formerly|used\s+to)\b
90
+ | \bpreviously\s+(said|read|known\s+only)\b
91
+ | \bformerly\s+(said|read)\b
92
+ | \bused\s+to\s+(say|read|carry)\b
93
+ | (?<!conv:)\b(?=[0-9a-f]*\d)(?=[0-9a-f]*[a-f])[0-9a-f]{7,40}\b # commit sha
94
+ """,
95
+ re.IGNORECASE | re.VERBOSE,
96
+ )
97
+
98
+ # Line-distance pins `check()` lints every mark and spec section for: "thirty
99
+ # lines above", "at line 22", "142-143 says". AGENTS.md bans them outright --
100
+ # the next insertion anywhere above makes the number wrong, and until this
101
+ # advisory nothing checked it, so twenty-six went live. Name the scene or the
102
+ # phrase instead ("at the train-platform passage"), which survives every edit
103
+ # that does not retire the thing itself. The match runs on the whitespace-
104
+ # collapsed body on purpose: the sidecars hard-wrap at 72 columns, so the
105
+ # phrase routinely spans a line break and a line-based grep cannot see it,
106
+ # which is exactly why the first hand count came back at seven. Deliberately
107
+ # NOT matched: a line COUNT with no direction ("in one line", "three lines: a
108
+ # scene", "the paragraph runs to four lines"), which is a budget rather than a
109
+ # locator and is legitimate throughout.
110
+ #
111
+ # Three forms were added after the first cut shipped and six live pins walked
112
+ # straight past it. "thirteen lines later" slipped because the number-word list
113
+ # stopped at twelve, which is the whole failure: a word list is a hole with an
114
+ # edge. And the colon address -- `preface:3`, `@unit(the-sidecar):69`,
115
+ # `the-sidecar.qmd:71` -- carries no "line" keyword at all, so nothing in
116
+ # the original pattern could see it; the one live instance was already wrong by
117
+ # two. The left side is required to look like a slug, a chapter pointer or a
118
+ # filename precisely so "Reader panel: 5 of 6" and a "15:40" timestamp do not
119
+ # match, and it reports zero false positives across every mark and the spec.
120
+ #
121
+ # A BARE "at 7-8" IS STILL INVISIBLE, and that is a measured decision rather
122
+ # than an oversight. Matching `at N` or `at N-M` was tried over the whole
123
+ # corpus and returned nine hits of which eight were legitimate -- figure needle
124
+ # readings ("the program at 0.08"), a ratio ("rings at 100/81/62/43"), a speed,
125
+ # a token rate, a percentage. One advisory that is 89% noise teaches the author
126
+ # to skip the whole report, which is the failure mode this lint exists to
127
+ # avoid. The hole is real and stays open on purpose.
128
+ _LINE_PIN = re.compile(
129
+ r"""
130
+ \b(?: one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve
131
+ | thirteen|fourteen|fifteen|sixteen|seventeen|eighteen|nineteen
132
+ | twenty|thirty|forty|fifty|sixty|\d+ )
133
+ [-\s]lines?\s+
134
+ (?: above|below|earlier|later|before|after|back|down|up
135
+ | apart|further|past|ahead|from|on )\b
136
+ | \blines?\s+\d+(?:\s*[-–]\s*\d+)?\b # "line 22", "lines 8-9"
137
+ | \b\d+\s*[-–]\s*\d+\s+(?: says|reads|prints|carries )\b
138
+ | (?: @unit\([a-z0-9-]+\) # the colon-address forms:
139
+ | \b[a-z][a-z0-9-]*\.(?:qmd|md|svg) # the-sidecar.qmd:71
140
+ | \b[a-z]+(?:-[a-z0-9]+)+ # @unit(the-sidecar):69
141
+ | \bpreface ) # preface:3
142
+ \s*:\s*\d+(?:\s*[-–]\s*\d+)?\b
143
+ """,
144
+ re.IGNORECASE | re.VERBOSE,
145
+ )
146
+
147
+ # The structural `@bind(slug)` token, used by bodyless sidecar relationships and
148
+ # by companion `Anchors:` joins. Semantic prose cites the spec as `#slug`.
149
+ _BIND_REF = re.compile(r"@bind\(([\w-]+(?:/[\w-]+)?)\)")
150
+ _BIND_TOKEN = re.compile(r"@bind\(([^)]*)\)")
151
+ _OMIT_TOKEN = re.compile(r"@omit\(([^)]*)\)")
152
+ # A semantic citation of the spec section itself. Exclude Pandoc
153
+ # ``{#anchor}`` attributes; those name document locations, not commitments.
154
+ _SPEC_REF = re.compile(r"(?<![\w{/])#([A-Za-z][\w-]*(?:/[A-Za-z][\w-]*)?)\b")
155
+
156
+ # `@unit(slug)`: the metadata layer's pointer to a chapter or a post,
157
+ # resolved by units.py. A reference form only, never a mark kind -- `<!--
158
+ # @unit(x) -->` would be harvested as a new kind and would join the vocabulary
159
+ # by accident, so `check()` rejects it as invariant 9.
160
+ _UNIT_REF = units.UNIT_REF
161
+
162
+
163
+ # Tokens in an **Anchors:** run that are unmistakably unit pointers use
164
+ # `@unit(slug)` or the unit's file, as the format writes it
165
+ # (`the-sidecar.qmd`, `posts/the-kettle/index.md`). Other tokens are prose.
166
+ def _bare(tok: str) -> str:
167
+ """A run's token reduced to the slug it names: its file's slug when it is
168
+ written as a prose file, else the token itself."""
169
+ return formats.current().prose_token(tok) or tok
170
+
171
+
172
+ def _unit_shaped(tok: str) -> bool:
173
+ return formats.current().prose_token(tok) is not None
174
+
175
+
176
+ # The imperative register the spec must not use: a commitment is stated
177
+ # ("the archive records source hashes"), never ordered ("NEVER omit hashes") —
178
+ # a well-stated commitment makes its own instruction derivable. All-caps forms
179
+ # only: indicative prose legitimately says "never".
180
+ _IMPERATIVE = re.compile(r"\b(NEVER|DO NOT|DON'T|MUST NOT|MAY NOT|BANNED)\b")
181
+
182
+
183
+ # Companion documents are a declared exception to canon self-containment. They
184
+ # can cite the spec by slug and quote the canon by passage, so the check follows
185
+ # both directions without assuming their directory names.
186
+ def companion_dirs() -> tuple[str, ...]:
187
+ """The reader-facing companion collections this project declares, if any.
188
+
189
+ `manuscript.companion_dirs` names them. The default is none: a work with no
190
+ companion collection has nothing here to check, and naming another
191
+ project's directories would check a tree this one does not have.
192
+ """
193
+ return tuple(current_project().get("manuscript.companion_dirs", ()) or ())
194
+
195
+
196
+ def _is_entry(path: Path) -> bool:
197
+ """Whether a file in a companion collection is one of its entries.
198
+
199
+ A README indexes the collection, and a `_`-prefixed file is its apparatus
200
+ (a template carrying placeholder citations, a partial), so neither is
201
+ held to the gate about entries. It is the same rule a site's pages
202
+ follow."""
203
+ return path.name != "README.md" and not path.name.startswith("_")
204
+
205
+
206
+ # A quotation attributed to the book. Both forms are used by the entries and
207
+ # both have failed: a double-quoted span lifted from a chapter, and a bolded
208
+ # span attributed to the spec. The length floor keeps single-word emphasis
209
+ # (**resolved**) out;
210
+ # the ceiling keeps a runaway span from swallowing a paragraph.
211
+ # Whitespace-only differences are never misquotes and _norm already collapses
212
+ # them: the book wraps one sentence per line and the entries wrap at 72
213
+ # columns, so a quotation spanning a sentence boundary is wrapped differently
214
+ # on each side.
215
+ # An entry's header paragraph carries a bold-labelled run naming the units
216
+ # and commitments its position rests on, "**Anchors:**" unless the project
217
+ # names its own label (`manuscript.companion_anchors`). It is READER-FACING
218
+ # prose and stays that way: minting a parallel @-mark would put the same fact in
219
+ # two places, and a duplicated fact is the drift this whole layer exists to
220
+ # prevent. So the visible run is the single owner and the check reads it where
221
+ # it stands.
222
+ def _anchors_label() -> str:
223
+ return str(current_project().get("manuscript.companion_anchors", "Anchors"))
224
+
225
+
226
+ def _anchors_run(text: str) -> re.Match | None:
227
+ label = re.escape(_anchors_label())
228
+ return re.search(rf"\*\*{label}:\*\*(.*?)(?:\n[ \t]*\n|\Z)", text, re.S)
229
+
230
+
231
+ _BACKTICKED = re.compile(r"`([^`]+)`")
232
+
233
+
234
+ def _code_spec_refs(text: str) -> list[tuple[str, int]]:
235
+ """Spec ``#slug`` citations inside Markdown code spans.
236
+
237
+ Companion prose uses code spans to distinguish spec slugs from local
238
+ Markdown anchors and URL fragments. Return each slug with its absolute
239
+ offset in ``text`` so diagnostics retain correct line numbers.
240
+ """
241
+ out: list[tuple[str, int]] = []
242
+ for span in _BACKTICKED.finditer(text):
243
+ content = span.group(1)
244
+ if "://" in content:
245
+ continue
246
+ for ref in _SPEC_REF.finditer(content):
247
+ out.append((ref.group(1), span.start(1) + ref.start()))
248
+ return out
249
+
250
+
251
+ # Only the curly form can be matched by a delimiter pair, because the marks are
252
+ # the only place curly quotes appear. Straight quotes are the entries'
253
+ # convention and open and close identically, so they are paired by ALTERNATION
254
+ # in `_quoted_spans` instead. The pair is directional: a class taking either
255
+ # mark at either end let the close of a short span such as “it”, too short to
256
+ # match, open the next span, which then ran to the following quotation's open
257
+ # and read the prose between as a quotation.
258
+ _QUOTED_SPAN = re.compile(r"“((?:[^“”]|\n(?!\s*\n)){25,400})”")
259
+ # A bold span stays single-line, and the asymmetry is load-bearing rather than
260
+ # an oversight. A quotation wraps, because these files hard-wrap at 72 columns;
261
+ # a bold label never does. Letting `**` cross a newline pairs one label's close
262
+ # with the next one's open and swallows the prose between, which was a quarter
263
+ # of the advisories in the run that tried it.
264
+ _BOLD_SPAN = re.compile(r"\*\*([^*\n]{25,400})\*\*")
265
+ _ELIDED = re.compile(r"\s*(?:\.\.\.|…)\s*")
266
+
267
+
268
+ def _quoted_spans(block: str) -> list[tuple[str, int]]:
269
+ """Quotations in one block, INCLUDING the ones that wrap.
270
+
271
+ A straight quote is the same character opening and closing, so a regex with
272
+ a delimiter pair cannot tell one from the other and has to be told where a
273
+ span may not run to. Excluding the newline was that fence, and it is the
274
+ wrong one: these files hard-wrap at 72 columns, so it silently skipped
275
+ every quotation longer than about ten words -- which is the length at which
276
+ a span stops being a coined term and starts being a citation. The check saw
277
+ 10 spans across the 24 entries where it should see 38, and both live
278
+ defects it was missing were wrapped canon sentences an edit had moved out
279
+ from under.
280
+
281
+ The honest fence is that straight quotes ALTERNATE: pair them 1-2, 3-4,
282
+ within a block. A pair enclosing a paragraph break is a mispairing rather
283
+ than a long quotation, and dropping those is what stops one quotation's
284
+ close being read as the next one's open.
285
+ """
286
+ out: list[tuple[str, int]] = []
287
+ marks = [m.start() for m in re.finditer(r'"', block)]
288
+ for a, b in zip(marks[0::2], marks[1::2]):
289
+ span = block[a + 1 : b]
290
+ if re.search(r"\n\s*\n", span) or not 25 <= len(span) <= 400:
291
+ continue
292
+ out.append((span, a))
293
+ for m in _QUOTED_SPAN.finditer(block):
294
+ out.append((m.group(1), m.start()))
295
+ return out
296
+
297
+
298
+ def _blank(m: re.Match) -> str:
299
+ """The matched span with every non-newline blanked (length-preserving)."""
300
+ return "".join(c if c == "\n" else " " for c in m.group(0))
301
+
302
+
303
+ def mask_fences(text: str) -> str:
304
+ """Blank fenced code (keeping length + newlines, so offsets/lines are stable).
305
+
306
+ A fenced block holds examples, never live marks or references, which is
307
+ how a guide can show the form it documents. An extension scanning the
308
+ same files for a guard of its own masks them here, so the two agree on
309
+ what counts as an example."""
310
+ return _FENCE.sub(_blank, text)
311
+
312
+
313
+ def _mask_nonprose(text: str) -> str:
314
+ """Blank HTML comments and frontmatter, for heading detection only.
315
+
316
+ Frontmatter is either convention, YAML or TOML: a TOML comment line starts
317
+ with `#` exactly like a heading. Length-preserving like mask_fences, so
318
+ heading offsets computed on the masked text index straight into the
319
+ unmasked one."""
320
+ found = frontmatter.block(text)
321
+ if found:
322
+ text = _blank(found) + text[found.end() :]
323
+ return _COMMENT.sub(_blank, text)
324
+
325
+
326
+ DEFAULT_GLOBS = ("*.qmd", "*.md")
327
+
328
+ # Developer-guide files carry annotation *examples* in prose (e.g. the literal
329
+ # `<!-- @see(slug) rider -->` in AGENTS.md), not book content. Skip them when
330
+ # globbing a directory, so the harvest is the book's marks, not the repo's own
331
+ # guidance. An explicitly named --path still reads them (nothing is masked).
332
+ SKIP_NAMES = units.SKIP_NAMES
333
+
334
+ # A unit's metadata marks live in a sidecar, never inline in the prose. scan()
335
+ # attributes a sidecar's marks to the unit it belongs to (`units.attributed`);
336
+ # `notes --check` verifies the layout stays honest. A configured book-wide
337
+ # review manifest is the target a unit's @see(slug) resolves against.
338
+ MARKS_DIR = units.MARKS_DIR
339
+
340
+ # The spec is the central commitments register. Its
341
+ # level-2 sections (`## Title {#slug}`) are the binding namespace — a chapter's
342
+ # @bind(slug) resolves against these anchors, and any number of chapters may
343
+ # bind one section. A slug the spec does not declare is a hard error: there is
344
+ # no independent claim layer.
345
+
346
+ # The audience split, and the single place it is declared. These three kinds are
347
+ # HUMAN-FACING and are surfaced in the red-pen review block; @bind is structural
348
+ # and @todo is working state, so both deliberately stay out of it.
349
+ # redpen imports this rather than restating the list, because the split has to
350
+ # hold in two places at once: the kinds a block prints directly, and the kinds a
351
+ # chapter's @see(slug) pointer is allowed to resolve to. It once held only in the
352
+ # first, so @see became a back door that echoed binding shorthand into the
353
+ # author's review copy — twelve of eighteen pointers, naming notes the .docx
354
+ # never printed. `check()` also lints these three for AI-register prose.
355
+ BLOCK_KINDS = ("guidance", "genesis", "variant")
356
+
357
+ # The discuss-before-change layers `check()` lints for decision-journaling.
358
+ # Guidance carries current state, so "author call, dated" in one reads as
359
+ # settled authority a later session must not question -- the opposite of the
360
+ # intended openness. @genesis is deliberately absent: an origin story is
361
+ # history by design, and its dates are content. The working and provenance
362
+ # kinds (@todo, @ref, @source) legitimately carry dates and stay out.
363
+ CONTRACT_KINDS = ("guidance",)
364
+
365
+ # One-line gloss per kind, for `notes --list`. The tag set is open and `--list`
366
+ # only shows kinds actually present. A new kind lists without a gloss until one
367
+ # is added here.
368
+ KIND_DESCRIPTIONS = {
369
+ "bind": "unit-to-spec bindings",
370
+ "guidance": "how a unit should read (the author's intent)",
371
+ "genesis": "where a unit came from (origin, not direction)",
372
+ "todo": "open working state / the agent's worklist",
373
+ "variant": "what a derived edition should do differently here",
374
+ "figure": "built illustrations",
375
+ "ref": "prior-art / conversation pointers",
376
+ "source": "a citation; @source(slug) names one in the sources register",
377
+ "see": "pointer to a work-wide review note",
378
+ "omit": "a spec section a derived edition leaves out, and why",
379
+ }
380
+
381
+ # Other names a kind answers to on the command line. A harness that guards a
382
+ # worktree by refusing any command containing "bind" made `spec notes bind`
383
+ # unrunnable, and `--kind bind` did not help because the word was still in
384
+ # argv. The alias spells the same filter without it.
385
+ KIND_ALIASES = {"relations": "bind"}
386
+
387
+ # The `--primer` preset: what a fresh session must act from or stay consistent
388
+ # with -- the bindings and the constraints on how the book reads
389
+ # (@guidance). The spec is printed first and owns what is true.
390
+ # Not @genesis: that is provenance a session reaches for deliberately.
391
+ # Absent members are skipped silently (a preset is not a typo).
392
+ PRIMER_KINDS = ("bind", "guidance")
393
+
394
+
395
+ @dataclass
396
+ class Annotation:
397
+ kind: str
398
+ args: list[str]
399
+ body: str
400
+ file: str
401
+ line: int
402
+ section: str | None
403
+ # The file the mark was actually read from, which is the sidecar and not
404
+ # `file` (the chapter it is attributed to). `line` counts within THIS file,
405
+ # so `source:line` is the only pair that resolves; `file:line` names a
406
+ # chapter and a line inside its sidecar, and the chapter is usually shorter.
407
+ source: str = ""
408
+
409
+
410
+ def _headings(text: str) -> list[tuple[int, str]]:
411
+ """(char offset, label) for each heading; label prefers the {#id}.
412
+
413
+ Comments and frontmatter are masked first, so a `# ...` line inside a
414
+ sidecar banner or a YAML comment is never taken for a heading."""
415
+ out = []
416
+ for m in _HEADING.finditer(_mask_nonprose(text)):
417
+ anchor = _ATTR_ID.search(m.group(3)) if m.group(3) else None
418
+ out.append((m.start(), anchor.group(1) if anchor else m.group(2).strip()))
419
+ return out
420
+
421
+
422
+ def _section_for(headings: list[tuple[int, str]], pos: int) -> str | None:
423
+ label = None
424
+ for off, lbl in headings:
425
+ if off <= pos:
426
+ label = lbl
427
+ else:
428
+ break
429
+ return label
430
+
431
+
432
+ def scan_text(text: str, rel: str, source: str | None = None) -> list[Annotation]:
433
+ """Harvest one file's marks. `rel` is what the marks are attributed to (the
434
+ chapter); `source` is where they were read from (the sidecar), and defaults
435
+ to `rel` for a file that indexes itself."""
436
+ text = mask_fences(text) # offsets/line numbers preserved; fenced examples blanked
437
+ headings = _headings(text)
438
+ src = source or rel
439
+ out = []
440
+ for m in _ANNO.finditer(text):
441
+ kind = m.group(1).lower()
442
+ args = [a.strip() for a in (m.group(2) or "").split(",") if a.strip()]
443
+ body = _WS.sub(" ", m.group(3)).strip()
444
+ line = text.count("\n", 0, m.start()) + 1
445
+ out.append(
446
+ Annotation(
447
+ kind, args, body, rel, line, _section_for(headings, m.start()), src
448
+ )
449
+ )
450
+ return out
451
+
452
+
453
+ def _chapter_for(f: Path) -> Path:
454
+ """The prose file a source path is attributed to: a sidecar's unit, and
455
+ every other file itself. The harvest reads the sidecar's text (so its
456
+ mirrored headings drive section attribution) but stamps every mark with the
457
+ unit's path, so `--by unit`, reading order, and the title all resolve
458
+ exactly as they would if the marks were inline. `units` owns the mapping."""
459
+ return units.attributed(f)
460
+
461
+
462
+ def _in_docs(docs, configured):
463
+ """A configured file read from ``docs`` when it lies under the manuscript
464
+ source, so a check run on a staged copy reads the copy, not the tree."""
465
+ source = current_project().path("manuscript.source")
466
+ if configured.is_relative_to(source):
467
+ return Path(docs) / configured.relative_to(source)
468
+ return configured
469
+
470
+
471
+ def _configured_book_file(docs, key):
472
+ project = current_project()
473
+ if project.get(key) is None:
474
+ return None
475
+ return _in_docs(docs, project.path(key))
476
+
477
+
478
+ def _configured_book_files(docs, key):
479
+ """The files a key names, one path or an ordered list of them."""
480
+ project = current_project()
481
+ if project.get(key) is None:
482
+ return []
483
+ return [_in_docs(docs, configured) for configured in project.paths(key)]
484
+
485
+
486
+ class Section(NamedTuple):
487
+ """One commitment: a level-2 section of a spec file.
488
+
489
+ A section of the shared spec is named by its anchor. A section of a unit's
490
+ own spec is named `unit/anchor`, so two posts can each have a `#thesis`
491
+ and a binding still names exactly one of them.
492
+ """
493
+
494
+ slug: str | None # the full name; None is a hard `check()` problem
495
+ title: str
496
+ body: str
497
+ path: Path # the spec file that holds it
498
+ anchor: str | None = None # the {#anchor} as written in that file
499
+
500
+
501
+ def anchor_of(slug: str) -> str:
502
+ """The anchor a section's full name carries in its own file."""
503
+ return slug.split("/", 1)[1] if "/" in slug else slug
504
+
505
+
506
+ def resolve(slug: str, unit: str | None, known: set[str]) -> str:
507
+ """The section a name means where it is written.
508
+
509
+ In a unit's own marks, a bare name finds that unit's own section first,
510
+ so a post's sidecar can bind `thesis` rather than spell out its own slug.
511
+ Everywhere else a name means what it says, and a unit's section is named
512
+ in full."""
513
+ if unit and "/" not in slug and f"{unit}/{slug}" in known:
514
+ return f"{unit}/{slug}"
515
+ return slug
516
+
517
+
518
+ def _unit_spec_pattern() -> str | None:
519
+ """`manuscript.unit_spec`, a path pattern with {slug}, or None."""
520
+ return formats.slug_pattern("manuscript.unit_spec")
521
+
522
+
523
+ def unit_spec_path(slug: str) -> Path | None:
524
+ """Where the unit ``slug`` keeps its own spec, or would; None if units keep none."""
525
+ pattern = _unit_spec_pattern()
526
+ if pattern is None:
527
+ return None
528
+ return current_project().root / pattern.replace("{slug}", slug)
529
+
530
+
531
+ def spec_owner(path: Path) -> str | None:
532
+ """The unit whose own spec ``path`` is, or None for the shared spec."""
533
+ pattern = _unit_spec_pattern()
534
+ if pattern is None:
535
+ return None
536
+ root = current_project().root.resolve()
537
+ try:
538
+ rel = Path(path).resolve().relative_to(root).as_posix()
539
+ except ValueError:
540
+ return None
541
+ m = formats.pattern_regex(pattern).match(rel)
542
+ return m.group("slug") if m else None
543
+
544
+
545
+ def spec_files(docs=None) -> list[Path]:
546
+ """Every file of the register, in the order its sections are read.
547
+
548
+ A project states its commitments in shared spec files
549
+ (`manuscript.spec`, one path or an ordered list), in one spec per unit
550
+ (`manuscript.unit_spec`), or both. The shared files come first, in the
551
+ order they are listed, because they hold what several units lean on; then
552
+ each unit's own, in reading order; then the specs of units that do not
553
+ exist yet, by slug, since a post is often specified before it is drafted.
554
+ Every file is one register: a shared anchor is unique across all of them.
555
+ """
556
+ docs = (
557
+ Path(docs) if docs is not None else current_project().path("manuscript.source")
558
+ )
559
+ shared = _configured_book_files(docs, "manuscript.spec")
560
+ pattern = _unit_spec_pattern()
561
+ if not shared and pattern is None:
562
+ raise ConfigurationError(
563
+ "configure manuscript.spec or manuscript.unit_spec in quirework.toml"
564
+ )
565
+ files = [f for f in shared if f.exists()]
566
+ if pattern is not None:
567
+ root = current_project().root
568
+ found = {}
569
+ for path in root.glob(pattern.replace("{slug}", "*")):
570
+ owner = spec_owner(path)
571
+ if path.is_file() and owner is not None:
572
+ found[owner] = path
573
+ try:
574
+ order = [u.slug for u in units.registry(docs)]
575
+ except (OSError, ValueError):
576
+ order = []
577
+ files += [found.pop(slug) for slug in order if slug in found]
578
+ files += [found[slug] for slug in sorted(found)]
579
+ return files
580
+
581
+
582
+ def public_spec_files(docs=None) -> list[Path] | None:
583
+ """The shared spec files a reader of the published work can see, or None
584
+ where the project draws no boundary and every spec file is published.
585
+
586
+ `manuscript.spec_public` lists them, each one also listed in
587
+ `manuscript.spec`. A spec file it leaves out, a unit's own spec included,
588
+ is the project's working register: what is published may not cite it,
589
+ and it may cite anything.
590
+ """
591
+ project = current_project()
592
+ if project.get("manuscript.spec_public") is None:
593
+ return None
594
+ docs = Path(docs) if docs is not None else project.path("manuscript.source")
595
+ return _configured_book_files(docs, "manuscript.spec_public")
596
+
597
+
598
+ def _unpublished(slug: str, home: dict[str, Path], public) -> Path | None:
599
+ """The file holding ``slug`` when the project publishes only some spec
600
+ files and this one is not among them; None when the section is visible."""
601
+ if public is None or slug not in home or home[slug] in public:
602
+ return None
603
+ return home[slug]
604
+
605
+
606
+ def spec_text_refs(text: str) -> list[tuple[str, int]]:
607
+ """``(slug, line)`` for each section reference written in a spec file's
608
+ prose: a ``#slug`` or a ``](#slug)`` link. A heading's ``{#anchor}`` is the
609
+ section's own name, not a reference, and fenced code, code spans, HTML
610
+ comments and frontmatter hold examples and notes rather than citations,
611
+ so none of those is read."""
612
+ masked = _BACKTICKED.sub(_blank, _mask_nonprose(mask_fences(text)))
613
+ return [
614
+ (m.group(1), masked.count("\n", 0, m.start()) + 1)
615
+ for m in _SPEC_REF.finditer(masked)
616
+ ]
617
+
618
+
619
+ def _spec_name(docs=None) -> str:
620
+ """How messages name the register: its file when there is one, else "the spec"."""
621
+ files = spec_files(docs)
622
+ return files[0].name if len(files) == 1 else "the spec"
623
+
624
+
625
+ def _spec_names(docs=None) -> tuple[str, ...]:
626
+ """Every register file's name, which is how an entry cites the spec."""
627
+ return tuple(f.name for f in spec_files(docs))
628
+
629
+
630
+ def section_stops(text: str) -> list[int]:
631
+ """Where a section of a spec file can end: at the next section, or at a
632
+ level-1 heading that groups the sections into parts. A deeper heading
633
+ sits inside a section and ends nothing. ``text`` is already masked."""
634
+ return [m.start() for m in _HEADING.finditer(text) if len(m.group(1)) <= 2]
635
+
636
+
637
+ def _sections_of(spec: Path) -> list[Section]:
638
+ text = mask_fences(spec.read_text())
639
+ stops = section_stops(_mask_nonprose(text))
640
+ lvl2 = []
641
+ for m in _HEADING.finditer(_mask_nonprose(text)):
642
+ if len(m.group(1)) != 2:
643
+ continue
644
+ anchor = _ATTR_ID.search(m.group(3)) if m.group(3) else None
645
+ lvl2.append(
646
+ (
647
+ m.start(),
648
+ m.end(),
649
+ m.group(2).strip(),
650
+ anchor.group(1) if anchor else None,
651
+ )
652
+ )
653
+ owner = spec_owner(spec)
654
+ out = []
655
+ for start, end, title, anchor in lvl2:
656
+ stop = next((s for s in stops if s > start), len(text))
657
+ slug = f"{owner}/{anchor}" if owner and anchor else anchor
658
+ out.append(Section(slug, title, text[end:stop].strip(), spec, anchor))
659
+ return out
660
+
661
+
662
+ def spec_sections(docs: Path | None = None) -> list[Section]:
663
+ """Every level-2 section of the register, file by file in `spec_files`
664
+ order. A section's slug is None where it is missing its {#slug} (a hard
665
+ `check()` problem). Empty while no spec file exists."""
666
+ return [s for spec in spec_files(docs) for s in _sections_of(spec)]
667
+
668
+
669
+ def spec_slugs(docs: Path | None = None) -> set[str]:
670
+ """The binding namespace: every anchored level-2 section of the register."""
671
+ return {s.slug for s in spec_sections(docs) if s.slug}
672
+
673
+
674
+ def spec_file_of(slug: str, docs: Path | None = None) -> Path | None:
675
+ """The spec file whose section is anchored ``slug``."""
676
+ for s in spec_sections(docs):
677
+ if s.slug == slug:
678
+ return s.path
679
+ return None
680
+
681
+
682
+ def default_roots(docs: Path | None = None) -> list[Path]:
683
+ """What `scan` reads by default: the manuscript's own files, then every
684
+ derived edition's marks sidecars."""
685
+ return [*units.harvest(docs), *editions.sidecars()]
686
+
687
+
688
+ def mark_files(paths: list[Path] | None = None) -> list[Path]:
689
+ """Every file `scan` reads marks from, below the given paths or the
690
+ manuscript's own and its editions'."""
691
+ out: list[Path] = []
692
+ for root in paths or default_roots():
693
+ root = Path(root)
694
+ files = (
695
+ [root]
696
+ if root.is_file()
697
+ else [
698
+ f
699
+ for g in DEFAULT_GLOBS
700
+ for f in root.rglob(g)
701
+ if f.name not in SKIP_NAMES
702
+ ]
703
+ )
704
+ out += sorted(set(files))
705
+ return out
706
+
707
+
708
+ def scan(paths: list[Path] | None = None) -> list[Annotation]:
709
+ """Harvest annotations below the given paths or the manuscript's own files.
710
+
711
+ An edition's sidecar is attributed to the edition's document beside it,
712
+ whatever the manuscript's own format pairs its sidecars by.
713
+ """
714
+ annos: list[Annotation] = []
715
+ for f in mark_files(paths):
716
+ prose = editions.prose_for(f) if editions.is_sidecar(f) else _chapter_for(f)
717
+ annos.extend(scan_text(f.read_text(), _rel(prose), _rel(f)))
718
+ return annos
719
+
720
+
721
+ def unit_name(a: Annotation) -> str:
722
+ """The unit a mark belongs to: an edition's `KIND/STEM`, or the canon's slug.
723
+
724
+ It is also the unit a bare spec name is resolved for. No section is named
725
+ `KIND/STEM/anchor`, so an edition's marks name sections in full.
726
+ """
727
+ return editions.unit_of(a.file) or units.slug_of(a.file)
728
+
729
+
730
+ def _reading_order() -> dict[str, int]:
731
+ """Map each unit's relative path to its position in the manuscript.
732
+
733
+ Lets `--by unit` follow the reading order (a book's welcome landing
734
+ first, part dividers in place; a site's pages by date) instead of a lexical
735
+ filename sort. Empty when the order cannot be read (e.g. a custom --path
736
+ scan of a tree with no Quarto file), in which case the caller falls back to
737
+ sorting by path.
738
+ """
739
+ try:
740
+ return {u.rel: i for i, u in enumerate(units.registry())}
741
+ except (OSError, ValueError):
742
+ return {}
743
+
744
+
745
+ def _chapter_title(rel: str) -> str:
746
+ """A human label for a source file: a frontmatter title, else its first
747
+ heading, else the stem."""
748
+ try:
749
+ text = mask_fences((current_project().root / rel).read_text())
750
+ except OSError:
751
+ return Path(rel).stem
752
+ try:
753
+ title = frontmatter.as_text(frontmatter.fields(text).get("title"))
754
+ except ValueError:
755
+ title = None
756
+ if title:
757
+ return title
758
+ m = _HEADING.search(_mask_nonprose(text))
759
+ if m:
760
+ return m.group(2).strip()
761
+ return Path(rel).stem
762
+
763
+
764
+ def _print_spec_toc(docs: Path | None = None, only: set[str] | None = None) -> None:
765
+ """The register's table of contents, printed at the top of the primer:
766
+ the commitments are the work's skeleton, so a fresh session reads them
767
+ before the per-unit marks. Silent while the register has no sections.
768
+
769
+ With ``only``, a set of unit slugs, the listing is the part of the
770
+ register those units touch: their own spec files whole, and the shared
771
+ sections they bind. A site with a spec per post would otherwise hand a
772
+ session every post's commitments to change one.
773
+ """
774
+ sections = spec_sections(docs)
775
+ if only is not None:
776
+ bound = set().union(
777
+ *(v for k, v in chapter_bindings(docs).items() if k in only)
778
+ )
779
+ sections = [
780
+ s
781
+ for s in sections
782
+ if spec_owner(s.path) in only
783
+ or (spec_owner(s.path) is None and s.slug in bound)
784
+ ]
785
+ if not sections:
786
+ return
787
+ # Where a trail is kept, each section says how many breadcrumbs lead back
788
+ # to the conversations that shaped it. Counting reads only the sidecar, so
789
+ # the primer stays as cheap as it was; following them is `spec trail`.
790
+ from quirework.manuscript import trail
791
+
792
+ held = trail.counts()
793
+ by_file: dict[Path, list[Section]] = {}
794
+ for s in sections:
795
+ by_file.setdefault(s.path, []).append(s)
796
+ width = max((len(s.slug or "") + 3 for s in sections), default=0)
797
+ width = max(width, 26)
798
+ for path, group in by_file.items():
799
+ owner = spec_owner(path)
800
+ whose = f"{units.noun()} {owner}" if owner else "shared"
801
+ print(
802
+ f"spec — {_rel(path)} ({whose}; {len(group)} section(s); "
803
+ f"read it before the marks):"
804
+ )
805
+ for s in group:
806
+ anchor = f"#{s.slug}" if s.slug else "(no anchor)"
807
+ mark = f" [trail ×{held[s.slug]}]" if held.get(s.slug) else ""
808
+ print(f" {anchor:<{width}} {s.title}{mark}")
809
+ if trail.configured():
810
+ with_trail = sum(1 for s in sections if held.get(s.slug))
811
+ print(
812
+ f" {with_trail} of {len(sections)} section(s) have a trail of the "
813
+ f"sessions that shaped them: quirework spec trail SLUG"
814
+ )
815
+
816
+
817
+ def _trail_marker(a: Annotation, held: dict[str, int], known: set[str]) -> str:
818
+ """`` [trail ×N]`` after a binding whose section has breadcrumbs.
819
+
820
+ A binding is how a reader gets from prose to a commitment, so it is where
821
+ they should learn that the commitment's history can be read. A section
822
+ with no breadcrumbs carries no marker: the hint appears where there is
823
+ something to follow, and a trail still being filled never teaches a
824
+ reader to skip it.
825
+ """
826
+ if a.kind != "bind" or not a.args:
827
+ return ""
828
+ slug = resolve(a.args[0], unit_name(a), known)
829
+ return f" [trail ×{held[slug]}]" if held.get(slug) else ""
830
+
831
+
832
+ def _held():
833
+ """Breadcrumb counts per section, and the names a binding resolves against.
834
+ Both empty where no trail is kept, which is also where no spec need be."""
835
+ from quirework.manuscript import trail
836
+
837
+ held = trail.counts()
838
+ return held, (spec_slugs() if held else set())
839
+
840
+
841
+ def _render_by_kind(annos: list[Annotation]) -> None:
842
+ held, known = _held()
843
+ by_kind: dict[str, list[Annotation]] = {}
844
+ for a in annos:
845
+ by_kind.setdefault(a.kind, []).append(a)
846
+ for kind in sorted(by_kind):
847
+ items = by_kind[kind]
848
+ print(f"\n@{kind} ({len(items)})")
849
+ print("-" * 60)
850
+ for a in items:
851
+ argstr = f"({', '.join(a.args)}) " if a.args else ""
852
+ sec = f" [{a.section}]" if a.section else ""
853
+ # source, not file: the mark lives in the sidecar, and the sidecar's
854
+ # name carries the chapter stem, so nothing is lost by naming it.
855
+ payload = f"{argstr}{a.body}".rstrip() + _trail_marker(a, held, known)
856
+ print(f" {a.source or a.file}:{a.line}{sec}\n {payload}")
857
+ print(f"\ntotal: {len(annos)} mark(s) across {len(by_kind)} kind(s)")
858
+
859
+
860
+ def _render_by_chapter(annos: list[Annotation]) -> None:
861
+ held, known = _held()
862
+ by_file: dict[str, list[Annotation]] = {}
863
+ for a in annos:
864
+ by_file.setdefault(a.file, []).append(a)
865
+ order = _reading_order()
866
+ # book order where known (welcome landing first, part dividers in place); path sort
867
+ # for anything the config doesn't list (a stray file, or a custom --path scan).
868
+ for rel in sorted(by_file, key=lambda r: (order.get(r, len(order)), r)):
869
+ items = sorted(by_file[rel], key=lambda a: a.line)
870
+ print(f"\n{rel} — {_chapter_title(rel)} ({len(items)})")
871
+ # The L-numbers below count within the sidecar, not the chapter, so name
872
+ # the file they index whenever the two differ.
873
+ srcs = {a.source for a in items if a.source and a.source != rel}
874
+ for src in sorted(srcs):
875
+ print(f" marks: {src}")
876
+ print("-" * 60)
877
+ for a in items:
878
+ argstr = f"({', '.join(a.args)})" if a.args else ""
879
+ suffix = f" {a.body}" if a.body else ""
880
+ suffix += _trail_marker(a, held, known)
881
+ print(f" L{a.line:<4} @{a.kind}{argstr}{suffix}")
882
+ print(f"\ntotal: {len(annos)} mark(s) across {len(by_file)} {units.noun()}(s)")
883
+
884
+
885
+ def _rel(p: Path) -> str:
886
+ try:
887
+ return str(p.relative_to(current_project().root))
888
+ except ValueError:
889
+ return str(p)
890
+
891
+
892
+ def _norm(text: str) -> str:
893
+ """Collapse whitespace, so a quotation matches across two wrap styles."""
894
+ return _WS.sub(" ", text).strip()
895
+
896
+
897
+ def _searchable(quote: str) -> str:
898
+ """A quotation reduced to what must actually exist in the work.
899
+
900
+ Case is dropped and trailing punctuation trimmed, because both differ
901
+ legitimately in a correct quotation: lifting a sentence into the middle of
902
+ another lowercases its initial capital, and the book is written in
903
+ American English, where the comma or period goes inside the closing quote
904
+ whether or not it belongs to the quoted sentence. Keeping either strict
905
+ flagged three correct quotations for every real one, which is how an
906
+ advisory teaches the author to ignore it."""
907
+ return _norm(quote).rstrip(".,;:").lower()
908
+
909
+
910
+ def companion_files(root: Path | None = None) -> list[Path]:
911
+ """Every entry in the reader-facing companion collections."""
912
+ base = Path(root) if root is not None else current_project().root
913
+ out: list[Path] = []
914
+ for d in companion_dirs():
915
+ if (base / d).is_dir():
916
+ out += [p for p in sorted((base / d).glob("*.md")) if _is_entry(p)]
917
+ return out
918
+
919
+
920
+ def _canon_corpus(docs: Path) -> str:
921
+ """Everything an entry may legitimately be quoting: the prose, the marks,
922
+ the spec, the curated index and the book-wide notes, whitespace-collapsed
923
+ into one searchable string. Built from raw file text rather than parsed
924
+ bodies so a quotation that straddles a comment or a heading still matches;
925
+ the collapse is what makes a sidecar's hanging-indented mark body
926
+ searchable as the single sentence it is."""
927
+ files: set[Path] = set()
928
+ for pat in ("*.qmd", "*.md"):
929
+ files.update(docs.rglob(pat))
930
+ # A site keeps its marks and its spec outside the source it renders.
931
+ files.update(units.sidecars(docs))
932
+ files.update(spec_files(docs))
933
+ return _searchable("\n".join(f.read_text() for f in sorted(files)))
934
+
935
+
936
+ def _blocks(text: str) -> list[tuple[str, int]]:
937
+ """(block, offset) for each blank-line-separated block.
938
+
939
+ Offsets are taken from the separator matches themselves, so a gap of
940
+ several blank lines cannot drift the line numbers reported for every
941
+ block after it (a fixed `+ 2` once assumed the separator was exactly
942
+ one blank line)."""
943
+ out, pos = [], 0
944
+ for m in re.finditer(r"\n\s*\n", text):
945
+ out.append((text[pos : m.start()], pos))
946
+ pos = m.end()
947
+ out.append((text[pos:], pos))
948
+ return out
949
+
950
+
951
+ def _attributed_spans(
952
+ block: str, stems: set[str], spec_names: tuple[str, ...]
953
+ ) -> list[tuple[str, int]]:
954
+ """Quotations in one block that are attributed to the work, with offsets.
955
+
956
+ Two filters do the discriminating, and both were forced by real false
957
+ positives on the live collections. A bold span opening a block or a list
958
+ item is one of the entries' own subheadings ("**It costs the reader
959
+ first.**"), never a quotation. And attribution is read from the 200
960
+ characters *before* the span rather than from the block as a whole: an
961
+ entry quotes an outside source, an objector's voice, and its own wording in
962
+ the same paragraph that cites a chapter, so block-level attribution flags
963
+ all three."""
964
+ out: list[tuple[str, int]] = []
965
+ candidates = [(sp, i, False) for sp, i in _quoted_spans(block)]
966
+ candidates += [(m.group(1), m.start(), True) for m in _BOLD_SPAN.finditer(block)]
967
+ for span, start, is_bold in candidates:
968
+ before = block[:start]
969
+ # Block-start or list-marker only. NOT a bare line start: the entries
970
+ # wrap at 72 columns, so a mid-sentence quotation lands at the head of
971
+ # a line about as often as not, and excluding those loses the exact
972
+ # shape this check exists for.
973
+ if is_bold and re.search(
974
+ r"(?:\A\s*|(?:\A|\n)[ \t]*(?:[-*>]|\d+\.)[ \t]+)$", before
975
+ ):
976
+ continue # the entry's own heading
977
+ window = before[-200:]
978
+ # `@unit()` is read alongside the bare stems: attribution is what decides
979
+ # whether a span is measured against the book at all, so a form this
980
+ # predicate does not know makes the advisory quietly NARROWER -- fewer
981
+ # quotations checked, and no message saying so. That is the same silent
982
+ # degradation anchor_pairs had, in a second place.
983
+ if not (
984
+ _BIND_REF.search(window)
985
+ or _code_spec_refs(window)
986
+ or _UNIT_REF.search(window)
987
+ or any(name in window for name in spec_names)
988
+ or any(s in window for s in stems)
989
+ ):
990
+ continue
991
+ out.append((span, start))
992
+ return out
993
+
994
+
995
+ def chapter_bindings(docs: Path | None = None) -> dict[str, set[str]]:
996
+ """unit slug -> the spec slugs it expresses.
997
+
998
+ A unit binds what its sidecar's `@bind` marks name, and, where units keep
999
+ their own spec files, every section of its own: a post expresses its own
1000
+ commitments by being the post they describe. An explicit `@bind` of an
1001
+ own section is still how a sidecar says which heading carries it.
1002
+ """
1003
+ out: dict[str, set[str]] = {}
1004
+ roots = units.harvest(docs) if docs is not None else None
1005
+ own = _unit_spec_pattern() is not None
1006
+ sections = spec_sections(docs) if own else []
1007
+ known = {x.slug for x in sections if x.slug}
1008
+ for a in scan(roots):
1009
+ # An edition retells the canon and is no chapter of it: its bindings
1010
+ # are `edition_bindings`.
1011
+ if a.kind == "bind" and a.args and not editions.unit_of(a.file):
1012
+ unit = units.slug_of(a.file)
1013
+ out.setdefault(unit, set()).add(resolve(a.args[0], unit, known))
1014
+ if own:
1015
+ present = units.names(docs)
1016
+ for s in sections:
1017
+ owner = spec_owner(s.path)
1018
+ if owner in present and s.slug:
1019
+ out.setdefault(owner, set()).add(s.slug)
1020
+ return out
1021
+
1022
+
1023
+ def edition_bindings() -> dict[str, dict[str, set[str]]]:
1024
+ """edition kind -> {"bind": what it carries, "omit": what it leaves out}.
1025
+
1026
+ Each is a set of spec sections, gathered over every document of the
1027
+ edition. A section that is both bound and omitted is in both, which the
1028
+ check reports.
1029
+ """
1030
+ try:
1031
+ known = spec_slugs()
1032
+ except ConfigurationError:
1033
+ known = set()
1034
+ out: dict[str, dict[str, set[str]]] = {}
1035
+ sidecars = editions.sidecars()
1036
+ # An empty list would scan the default roots, the whole manuscript.
1037
+ for a in scan(sidecars) if sidecars else []:
1038
+ if a.kind not in ("bind", "omit") or len(a.args) != 1:
1039
+ continue
1040
+ unit = editions.unit_of(a.file)
1041
+ if unit is None:
1042
+ continue
1043
+ kind = unit.split("/", 1)[0]
1044
+ slots = out.setdefault(kind, {"bind": set(), "omit": set()})
1045
+ slots[a.kind].add(resolve(a.args[0], unit, known))
1046
+ return out
1047
+
1048
+
1049
+ def anchor_pairs(
1050
+ text: str, stems: set[str], spec: set[str]
1051
+ ) -> tuple[list[tuple[str | None, str]], list[str]]:
1052
+ """(pairs, unresolved) for the anchors in an entry's anchors run.
1053
+
1054
+ The run is prose, so it is read positionally: a chapter reference sets the
1055
+ chapter that the `@bind(slug)` tokens after it belong to, until the next
1056
+ one. A bare `#slug` names a spec
1057
+ section directly, with no chapter claimed, so it yields None.
1058
+
1059
+ A token is a chapter reference, a spec slug, or inert prose. An explicit
1060
+ chapter pointer that resolves to nothing is returned in `unresolved`.
1061
+ """
1062
+ m = _anchors_run(text)
1063
+ if not m:
1064
+ return [], []
1065
+ run = _WS.sub(" ", m.group(1))
1066
+ pairs: list[tuple[str | None, str]] = []
1067
+ unresolved: list[str] = []
1068
+ current: str | None = None
1069
+ for t in _BACKTICKED.finditer(run):
1070
+ tok = t.group(1).strip()
1071
+ binding = _BIND_REF.search(tok)
1072
+ ref = _UNIT_REF.search(tok)
1073
+ if binding:
1074
+ pairs.append((current, binding.group(1)))
1075
+ elif ref:
1076
+ if ref.group(1) in stems:
1077
+ current = ref.group(1)
1078
+ else:
1079
+ current = None
1080
+ unresolved.append(tok)
1081
+ elif tok.startswith("#") and tok[1:] in spec:
1082
+ pairs.append((None, tok[1:]))
1083
+ elif _bare(tok) in stems:
1084
+ # A run may name a file with its extension. The stem is
1085
+ # what the binding map is keyed by.
1086
+ current = _bare(tok)
1087
+ elif _unit_shaped(tok):
1088
+ current = None
1089
+ unresolved.append(tok)
1090
+ return pairs, unresolved
1091
+
1092
+
1093
+ def coverage(
1094
+ docs: Path | None = None, root: Path | None = None
1095
+ ) -> tuple[dict[str, list[str]], list[tuple[str, str]]]:
1096
+ """Which commitments the companion collections actually exercise.
1097
+
1098
+ Returns (slug -> the entries anchored to it, unexercised (anchor, title)
1099
+ pairs in spec order). The marks say which units *express* a commitment,
1100
+ and this says which ones have been *worked* in a companion entry.
1101
+
1102
+ It is a map and never a scorecard. A section with no entry is not a gap by
1103
+ itself: a section about the spec's own organization or vocabulary belongs
1104
+ to no unit and to no entry either.
1105
+ """
1106
+ docs = (
1107
+ Path(docs) if docs is not None else current_project().path("manuscript.source")
1108
+ )
1109
+ spec = spec_slugs(docs)
1110
+ stems = units.names(docs)
1111
+ by_slug: dict[str, list[str]] = {}
1112
+ for f in companion_files(root if root is not None else docs.parent):
1113
+ pairs, _ = anchor_pairs(mask_fences(f.read_text()), stems, spec)
1114
+ for _, slug in pairs:
1115
+ if slug in spec:
1116
+ by_slug.setdefault(slug, []).append(f.stem)
1117
+ unexercised = [
1118
+ (s.slug, s.title)
1119
+ for s in spec_sections(docs)
1120
+ if s.slug and s.slug not in by_slug
1121
+ ]
1122
+ return by_slug, unexercised
1123
+
1124
+
1125
+ def companion_check(
1126
+ docs: Path, spec: set[str], root: Path | None = None
1127
+ ) -> tuple[list[str], list[str], list[str]]:
1128
+ """Resolve the companion collections against the work, in both directions.
1129
+
1130
+ Returns (problems, advisories). A `@bind(slug)` citation that names no
1131
+ spec section is a hard problem: it is a broken pointer with one right
1132
+ answer, the same defect invariant 5 gates inside the book, and the entries
1133
+ are cited from canon marks in turn.
1134
+
1135
+ A quotation that appears nowhere in canon is an advisory, because the
1136
+ detection is necessarily a heuristic. An entry may quote outside sources
1137
+ as well as the book, so only a paragraph that also cites canon is
1138
+ considered. What it catches is the failure that has actually happened:
1139
+ prose or a mark body edited out from under a live quotation."""
1140
+ problems: list[str] = []
1141
+ advisories: list[str] = []
1142
+ anchors: list[str] = []
1143
+ corpus = _canon_corpus(docs)
1144
+ stems = units.names(docs)
1145
+ binds = chapter_bindings(docs)
1146
+ label = f"**{_anchors_label()}:**"
1147
+ # An entry is published, so a section it cites must be one its reader
1148
+ # can open: where only some spec files are published, the others are out
1149
+ # of its reach, however well the name resolves.
1150
+ public = public_spec_files(docs)
1151
+ home = {s.slug: s.path for s in spec_sections(docs) if s.slug}
1152
+
1153
+ def hidden(slug, pos):
1154
+ held = _unpublished(slug, home, public)
1155
+ if held is not None:
1156
+ ln = text.count("\n", 0, pos) + 1
1157
+ problems.append(
1158
+ f"{rel}:{ln}: cites '#{slug}', a section of {_rel(held)}, which "
1159
+ f"is not published (manuscript.spec_public). A published entry "
1160
+ f"cites only what its reader can open: cite the published "
1161
+ f"section this one serves, or publish its file."
1162
+ )
1163
+
1164
+ for f in companion_files(root):
1165
+ text = mask_fences(f.read_text())
1166
+ rel = _rel(f)
1167
+ anchor_run = _anchors_run(text)
1168
+ for m in _BIND_TOKEN.finditer(text):
1169
+ if not re.fullmatch(r"\s*[\w-]+\s*", m.group(1)):
1170
+ ln = text.count("\n", 0, m.start()) + 1
1171
+ problems.append(
1172
+ f"{rel}:{ln}: @bind takes exactly one slug. Write one "
1173
+ f"relationship token per spec section."
1174
+ )
1175
+ pairs, unresolved = anchor_pairs(text, stems, spec)
1176
+ for tok in unresolved:
1177
+ problems.append(
1178
+ f"{rel}: {label} names `{tok}`, which is no {units.noun()}. "
1179
+ f"The anchors after it are attributed to nothing, so the join "
1180
+ f"that holds this entry to the work is silently broken."
1181
+ )
1182
+ if not pairs:
1183
+ # An entry with no resolving anchors has nothing holding it to
1184
+ # the work.
1185
+ anchors.append(
1186
+ f" {rel}: no {label} run resolves, so nothing holds this "
1187
+ f"entry to a commitment when the work moves"
1188
+ )
1189
+ for chapter, slug in pairs:
1190
+ if chapter is None or slug not in spec:
1191
+ continue # the slug half is invariant 7's to report
1192
+ if slug not in binds.get(chapter, set()):
1193
+ holder = sorted(k for k, v in binds.items() if slug in v)
1194
+ problems.append(
1195
+ f"{rel}: anchored to `{chapter}` `@bind({slug})`, which "
1196
+ f"{chapter} does not bind. Bound by: "
1197
+ f"{', '.join(holder) or f'no {units.noun()}'}. Repoint the "
1198
+ f"anchor at the {units.noun()} that expresses it."
1199
+ )
1200
+ for m in _BIND_REF.finditer(text):
1201
+ if m.group(1) in spec:
1202
+ hidden(m.group(1), m.start())
1203
+ if (
1204
+ anchor_run is None
1205
+ or not anchor_run.start() <= m.start() < anchor_run.end()
1206
+ ):
1207
+ ln = text.count("\n", 0, m.start()) + 1
1208
+ problems.append(
1209
+ f"{rel}:{ln}: @bind({m.group(1)}) appears outside the "
1210
+ f"{label} run. Use '#{m.group(1)}' in semantic "
1211
+ f"prose; @bind is only the relationship join."
1212
+ )
1213
+ continue
1214
+ ln = text.count("\n", 0, m.start()) + 1
1215
+ problems.append(
1216
+ f"{rel}:{ln}: @bind('{m.group(1)}') names no section of "
1217
+ f"{_spec_name(docs)}. The entry cites a missing commitment; "
1218
+ f"point it at the section that "
1219
+ f"holds the content now."
1220
+ )
1221
+ for slug, pos in _code_spec_refs(text):
1222
+ if slug in spec:
1223
+ hidden(slug, pos)
1224
+ continue
1225
+ ln = text.count("\n", 0, pos) + 1
1226
+ problems.append(
1227
+ f"{rel}:{ln}: spec section '#{slug}' does not exist in "
1228
+ f"{_spec_name(docs)}. The entry cites a commitment that has been "
1229
+ f"missing."
1230
+ )
1231
+ for block, offset in _blocks(text):
1232
+ for span, pos in _attributed_spans(block, stems, _spec_names(docs)):
1233
+ quote = _norm(span)
1234
+ # An elided quotation is deliberately partial; measure its
1235
+ # longest piece, or the ellipsis alone reports it as absent.
1236
+ probe = max(_ELIDED.split(quote), key=len)
1237
+ if len(probe.split()) < 5 or _searchable(probe) in corpus:
1238
+ continue
1239
+ ln = text.count("\n", 0, offset + pos) + 1
1240
+ advisories.append(
1241
+ f" {rel}:{ln}: {quote[:96]}{'…' if len(quote) > 96 else ''}"
1242
+ )
1243
+ return problems, advisories, anchors
1244
+
1245
+
1246
+ def references_check(docs: Path, spec: set[str]) -> list[str]:
1247
+ """Resolve the references register's spec citations, the `#slug`s it
1248
+ writes in code spans as a companion entry does.
1249
+
1250
+ Each one names a spec section. Where `manuscript.references_public`
1251
+ publishes the register beside the spec, each also names a published
1252
+ section, since its reader cannot open the others. The register's own
1253
+ `](#anchor)` links point inside it and are not read, and a code span
1254
+ naming one of its own headings, rather than a spec section, is a pointer
1255
+ inside it too."""
1256
+ project = current_project()
1257
+ # Read from the tree being checked, as the spec files are, so a check of
1258
+ # a staged copy reads the copy's register and never the live one's
1259
+ # against the copy's spec.
1260
+ path = _configured_book_file(docs, "manuscript.references")
1261
+ if path is None or not path.exists():
1262
+ return []
1263
+ text = mask_fences(path.read_text())
1264
+ rel = _rel(path)
1265
+ public = (
1266
+ public_spec_files(docs)
1267
+ if project.get("manuscript.references_public", False)
1268
+ else None
1269
+ )
1270
+ home = {s.slug: s.path for s in spec_sections(docs) if s.slug}
1271
+ own = {label for _, label in _headings(text)}
1272
+ problems: list[str] = []
1273
+ for slug, pos in _code_spec_refs(text):
1274
+ ln = text.count("\n", 0, pos) + 1
1275
+ if slug not in spec and slug in own:
1276
+ continue
1277
+ if slug not in spec:
1278
+ problems.append(
1279
+ f"{rel}:{ln}: cites '#{slug}', which names no spec section. The "
1280
+ f"register points at a commitment that has moved or gone: name "
1281
+ f"the section that holds it now."
1282
+ )
1283
+ elif (held := _unpublished(slug, home, public)) is not None:
1284
+ problems.append(
1285
+ f"{rel}:{ln}: cites '#{slug}', a section of {_rel(held)}, which "
1286
+ f"is not published (manuscript.spec_public). The register is "
1287
+ f"published beside it (manuscript.references_public), so it "
1288
+ f"cites only what its reader can open."
1289
+ )
1290
+ return problems
1291
+
1292
+
1293
+ def _findings(docs: Path) -> tuple[list[str], list[tuple[str, list[str]]]]:
1294
+ """Lint the split-metadata layout; replaces physical co-location as the
1295
+ drift guard. Fourteen hard invariants (any failure → exit 1):
1296
+
1297
+ 1. no prose file carries an @-mark (marks live in sidecars), a derived
1298
+ edition's documents included;
1299
+ 2. every sidecar belongs to a unit on disk (no orphan sidecar), and an
1300
+ edition's sidecar to a document of that edition;
1301
+ 3. every heading in a sidecar resolves to a real heading/anchor in its
1302
+ unit (the mirror can never drift from the prose it indexes);
1303
+ 4. the spec's level-2 sections each carry an {#slug} anchor,
1304
+ unique across the spec (they are the binding namespace);
1305
+ 5. every @bind is bodyless and its slug names a spec section (any
1306
+ number of chapters may bind the same one);
1307
+ 6. every @see(slug) resolves to a work-wide note in
1308
+ `manuscript.review_notes` (pointers into the spec are bindings or
1309
+ inline #slug refs, never @see);
1310
+ 7. every @bind(slug) cited in the companion collections
1311
+ (`manuscript.companion_dirs`) names a spec section — the entries are
1312
+ the work's one licensed outward dependency, and the citation runs
1313
+ back in.
1314
+ 8. every anchor in an entry's anchors run (`**Anchors:**`, or the label
1315
+ `manuscript.companion_anchors` names) names a unit that really binds
1316
+ that slug. Invariant 7 gates the slug; this gates the unit
1317
+ attributed to it, which nothing checked, so an anchor could name a
1318
+ unit that had never expressed the commitment.
1319
+ 9. every unit reference in an anchors run resolves, and `@unit` is
1320
+ never used as a mark kind. A unit-shaped token that resolves to
1321
+ nothing used to be skipped in silence, which turned invariant 8 into
1322
+ a no-op rather than a failure.
1323
+ 10. the units on disk are the manuscript's units: for a book, the Quarto
1324
+ file and the chapter files are a bijection (every listed file
1325
+ exists, every chapter on disk is listed); for a site, no two pages
1326
+ share a slug. The dual of invariant 2.
1327
+ 11. where `manuscript.spec_trail` is set, the trail sidecar parses and each of
1328
+ its headings names a spec section. It points into the sessions that
1329
+ shaped the spec, and `spec trail --check` follows those pointers.
1330
+ 12. where `manuscript.sources` is set, every @source(slug) names a source
1331
+ in that register, and every source has a slug of its own.
1332
+ 13. every @omit sits in a derived edition's sidecar, names one spec
1333
+ section, and gives its reason; no edition both binds and omits one.
1334
+ 14. every section reference written in a spec file (`#slug`, or a
1335
+ `](#slug)` link), and every `#slug` the references register cites in
1336
+ a code span, names a spec section. Where `manuscript.spec_public`
1337
+ publishes only some spec files, a reference in a published file,
1338
+ every citation in a companion entry, and every citation in a
1339
+ references register published beside the spec
1340
+ (`manuscript.references_public`), names a published section.
1341
+
1342
+ Plus seven advisories, printed but never fatal. The seventh names each
1343
+ section the canon binds that a composed edition neither binds nor omits.
1344
+
1345
+ Quotations in the companion collections that appear nowhere in canon.
1346
+ Heuristic by necessity (entries quote outside sources too, so only a
1347
+ paragraph that also cites canon is read), and an advisory for that reason;
1348
+ what it catches is a chapter or other metadata edited out from under a live
1349
+ quotation, which no gate saw before companion documents were configured.
1350
+
1351
+ Spec sections in the imperative or contract register (a
1352
+ NEVER / DO NOT / BANNED order, or an all-caps run). The spec states and
1353
+ prices — "the archive records source hashes" — and never orders, because a
1354
+ well-stated commitment makes its own instruction derivable; an imperative
1355
+ is almost always an under-stated commitment.
1356
+
1357
+ Marks that reference declared non-canonical working material. Retaining
1358
+ evidence does not make it a semantic owner. Stable reader-facing companion
1359
+ documents remain permitted. The check stays advisory.
1360
+
1361
+ And block-surfaced marks (`BLOCK_KINDS`) written in the AI register, detected
1362
+ by a run of three or more consecutive all-caps words — contract shorthand
1363
+ that human-facing prose never uses. These three kinds are what the author
1364
+ reads in the review .docx, so such a body is miscast: it is almost always a
1365
+ preservation contract or a decision
1366
+ record filed as authorial voice. Advisory rather than fatal because the
1367
+ register is a judgment call and the fix is a recast, not a mechanical edit.
1368
+
1369
+ And decision-journaling in the contract layers (`CONTRACT_KINDS` and the
1370
+ spec's section bodies): dates, authority citations ("author call", a
1371
+ red-pen round, a panel), commit shas, and mark-about-mark narration. A
1372
+ mark — and a spec section — records current state, never history
1373
+ (AGENTS.md rule 1): the story of who settled a call and when belongs in
1374
+ git, and inside guidance it reads as authority that chills the
1375
+ re-discussion discuss-before-change is meant to allow. The pattern set and
1376
+ its recorded false-positive exclusions live at `_JOURNALING`; @genesis is
1377
+ exempt by design (an origin story is history).
1378
+
1379
+ And dangling inline spec `#slug` references in any mark body. Inline
1380
+ refs are not part of the binding graph, and a section merge or rename is
1381
+ exactly the edit that dangles them; they resolve against the spec anchors.
1382
+
1383
+ Figure sources are deliberately NOT checked here: a @figure mark may be
1384
+ drafted before its SVG is built, and
1385
+ `figures --check`
1386
+ already gates every rendered embed against its source."""
1387
+ docs = (
1388
+ Path(docs) if docs is not None else current_project().path("manuscript.source")
1389
+ )
1390
+ problems: list[str] = []
1391
+
1392
+ # 1. every prose file is mark-free.
1393
+ for prose in units.prose_files(docs):
1394
+ text = mask_fences(prose.read_text())
1395
+ for m in _ANNO.finditer(text):
1396
+ ln = text.count("\n", 0, m.start()) + 1
1397
+ home = units.marks_path(units.slug_of(prose), docs)
1398
+ where = _rel(home) if home is not None else "a marks sidecar"
1399
+ problems.append(
1400
+ f"{_rel(prose)}:{ln}: @{m.group(1).lower()} mark left in prose — "
1401
+ f"move it to {where}"
1402
+ )
1403
+
1404
+ for kind in editions.composed():
1405
+ try:
1406
+ prose_files = editions.documents(kind)
1407
+ except ValueError as exc:
1408
+ problems.append(f"{_rel(editions.root() / kind)}: {exc}")
1409
+ continue
1410
+ for prose in prose_files:
1411
+ text = mask_fences(prose.read_text())
1412
+ for m in _ANNO.finditer(text):
1413
+ ln = text.count("\n", 0, m.start()) + 1
1414
+ home = prose.parent / units.MARKS_DIR / f"{prose.stem}.md"
1415
+ problems.append(
1416
+ f"{_rel(prose)}:{ln}: @{m.group(1).lower()} mark left in prose — "
1417
+ f"move it to {_rel(home)}"
1418
+ )
1419
+
1420
+ # 2. no orphan sidecars; 3. mirror integrity.
1421
+ paired = [
1422
+ (side, units.prose_for_sidecar(side, docs)) for side in units.sidecars(docs)
1423
+ ]
1424
+ paired += [(side, editions.prose_for(side)) for side in editions.sidecars()]
1425
+ for side, unit in paired:
1426
+ if unit is None or not unit.exists():
1427
+ named = _rel(unit) if unit is not None else f"'{units.sidecar_slug(side)}'"
1428
+ problems.append(
1429
+ f"{_rel(side)}: orphan sidecar — no {units.noun()} {named} for it"
1430
+ )
1431
+ continue
1432
+ ch_labels = {lbl for _, lbl in _headings(mask_fences(unit.read_text()))}
1433
+ for _, lbl in _headings(mask_fences(side.read_text())):
1434
+ if lbl not in ch_labels:
1435
+ problems.append(
1436
+ f"{_rel(side)}: heading '{lbl}' has no matching heading or "
1437
+ f"{{#anchor}} in {_rel(unit)} (the mirror has drifted)"
1438
+ )
1439
+
1440
+ annos = scan(default_roots(docs))
1441
+
1442
+ # 4. the register's binding namespace: anchored level-2 sections, unique
1443
+ # by full name, since a binding names a section and not a file. A unit's
1444
+ # own sections are named `unit/anchor`, so they only need to be unique in
1445
+ # their file; the shared spec's are unique across the register.
1446
+ sections = spec_sections(docs)
1447
+ spec_seen: dict[str, Section] = {}
1448
+ shared = {s.slug for s in sections if s.slug and spec_owner(s.path) is None}
1449
+ for s in sections:
1450
+ if not s.slug:
1451
+ problems.append(
1452
+ f"{_rel(s.path)}: section '{s.title}' has no {{#slug}} "
1453
+ f"anchor (level-2 sections are the binding namespace)"
1454
+ )
1455
+ elif "/" in (s.anchor or ""):
1456
+ problems.append(
1457
+ f"{_rel(s.path)}: anchor '#{s.anchor}' contains '/', which "
1458
+ f"separates a {units.noun()} from its section; drop it"
1459
+ )
1460
+ elif spec_owner(s.path) is not None and s.anchor in shared:
1461
+ # In the unit's own marks a bare name finds its own section first,
1462
+ # so the shared one would be out of that unit's reach.
1463
+ problems.append(
1464
+ f"{_rel(s.path)}: anchor '#{s.anchor}' is also a shared section; "
1465
+ f"in this {units.noun()}'s marks the name could mean either, so "
1466
+ f"rename one"
1467
+ )
1468
+ elif s.slug in spec_seen:
1469
+ first = spec_seen[s.slug]
1470
+ also = (
1471
+ f"'{first.title}'"
1472
+ if first.path == s.path
1473
+ else f"'{first.title}' in {_rel(first.path)}"
1474
+ )
1475
+ problems.append(
1476
+ f"{_rel(s.path)}: duplicate section anchor '#{s.slug}' (also on {also})"
1477
+ )
1478
+ else:
1479
+ spec_seen[s.slug] = s
1480
+ spec = set(spec_seen)
1481
+
1482
+ # 14. a section reference written in a spec file names a section of the
1483
+ # register, as a binding does, and one written in a published spec file
1484
+ # names a published section, since its reader cannot open the others.
1485
+ public = public_spec_files(docs)
1486
+ home = {s.slug: s.path for s in sections if s.slug}
1487
+ for f in spec_files(docs):
1488
+ owner = spec_owner(f)
1489
+ shown = public is None or f in public
1490
+ for written, ln in spec_text_refs(f.read_text()):
1491
+ slug = resolve(written, owner, spec)
1492
+ if slug not in spec:
1493
+ problems.append(
1494
+ f"{_rel(f)}:{ln}: '#{written}' names no spec section. A "
1495
+ f"reference inside the spec resolves like a binding: name "
1496
+ f"the section that holds the content now."
1497
+ )
1498
+ elif shown and (held := _unpublished(slug, home, public)) is not None:
1499
+ problems.append(
1500
+ f"{_rel(f)}:{ln}: '#{written}' names a section of "
1501
+ f"{_rel(held)}, which is not published "
1502
+ f"(manuscript.spec_public), from a published spec file its "
1503
+ f"reader would follow it out of."
1504
+ )
1505
+ # The references register cites the spec as a companion entry does, and
1506
+ # where it is published beside the spec it is held to the same boundary.
1507
+ problems += references_check(docs, spec)
1508
+
1509
+ # 11. the trail sidecar, where the project keeps one, mirrors the spec. A
1510
+ # renamed or retired section breaks its trail in the same edit that breaks
1511
+ # its bindings, so the two are reported together. Following a breadcrumb
1512
+ # into its session is `spec trail --check`'s job: that reads the corpus.
1513
+ if current_project().get("manuscript.spec_trail") is not None:
1514
+ from quirework.manuscript import trail
1515
+
1516
+ problems.extend(trail.structure_problems(spec))
1517
+
1518
+ # 5. every @bind slug resolves to a spec section. Duplicates across
1519
+ # units are the point: any number of units may express one section.
1520
+ for a in annos:
1521
+ if a.kind != "bind":
1522
+ continue
1523
+ slug = resolve(a.args[0], unit_name(a), spec) if a.args else None
1524
+ # The sidecar and line the mark actually lives at, so a gate failure
1525
+ # under pre-commit pressure points at the editable file (a.file is the
1526
+ # chapter, whose line numbers do not apply).
1527
+ at = f"{a.source or a.file}:{a.line}"
1528
+ if len(a.args) != 1:
1529
+ problems.append(
1530
+ f"{at}: @bind takes exactly one slug (got {len(a.args)}). "
1531
+ f"Write one bodyless relationship per spec section."
1532
+ )
1533
+ if not slug:
1534
+ continue
1535
+ if a.body:
1536
+ problems.append(
1537
+ f"{at}: @bind('{slug}') has a body. Bindings are relationships, "
1538
+ f"not preservation contracts; route the content to the spec, "
1539
+ f"guidance, a todo, the prose, or git."
1540
+ )
1541
+ if slug in spec:
1542
+ continue
1543
+ problems.append(
1544
+ f"{at}: @bind('{slug}') binds no section of "
1545
+ f"{_spec_name(docs)}. Every @bind names a spec section: "
1546
+ f"name the section the {units.noun()} expresses, or add it."
1547
+ )
1548
+
1549
+ # 13. @omit is an edition's reason to leave one spec section out: it
1550
+ # belongs to an edition, names one section, and says why.
1551
+ for a in annos:
1552
+ if a.kind != "omit":
1553
+ continue
1554
+ at = f"{a.source or a.file}:{a.line}"
1555
+ if not editions.unit_of(a.file):
1556
+ problems.append(
1557
+ f"{at}: @omit belongs to a derived edition's sidecar; the canon "
1558
+ f"states what it carries by binding it"
1559
+ )
1560
+ continue
1561
+ if len(a.args) != 1:
1562
+ problems.append(
1563
+ f"{at}: @omit takes exactly one slug (got {len(a.args)}), "
1564
+ f"one mark per section left out"
1565
+ )
1566
+ continue
1567
+ slug = resolve(a.args[0], unit_name(a), spec)
1568
+ if slug not in spec:
1569
+ problems.append(
1570
+ f"{at}: @omit('{slug}') names no section of {_spec_name(docs)}"
1571
+ )
1572
+ if not a.body:
1573
+ problems.append(
1574
+ f"{at}: @omit('{slug}') gives no reason. Say why the edition "
1575
+ f"leaves the section out, so a reader can tell a choice from a gap"
1576
+ )
1577
+ for kind, slots in sorted(edition_bindings().items()):
1578
+ for slug in sorted(slots["bind"] & slots["omit"]):
1579
+ problems.append(
1580
+ f"{_rel(editions.root() / kind)}: binds and omits '{slug}'; an "
1581
+ f"edition carries a section or leaves it out"
1582
+ )
1583
+
1584
+ # 6. @see resolves to a book-wide note.
1585
+ rn = _configured_book_file(docs, "manuscript.review_notes")
1586
+ rn_slugs = (
1587
+ {a.args[0] for a in scan_text(rn.read_text(), rn.name) if a.args}
1588
+ if rn is not None and rn.exists()
1589
+ else set()
1590
+ )
1591
+ for a in annos:
1592
+ if a.kind != "see":
1593
+ continue
1594
+ slug = a.args[0] if a.args else None
1595
+ at = f"{a.source or a.file}:{a.line}"
1596
+ if not slug:
1597
+ problems.append(f"{at}: @see with no slug")
1598
+ elif rn is None:
1599
+ problems.append(
1600
+ f"{at}: @see('{slug}') requires configured manuscript.review_notes"
1601
+ )
1602
+ elif slug not in rn_slugs:
1603
+ problems.append(
1604
+ f"{at}: @see('{slug}') resolves to no work-wide note in {rn.name}"
1605
+ )
1606
+
1607
+ # 9. `@unit` is a reference form, never a mark kind. A comment that opens
1608
+ # with it (`<!-- @unit(a-chapter) ... -->`) is harvested as a new kind by
1609
+ # _ANNO and would quietly join the vocabulary, which is how the tag set
1610
+ # grows by accident. The pointer belongs inside a mark's body.
1611
+ for a in annos:
1612
+ if a.kind == "unit":
1613
+ problems.append(
1614
+ f"{a.source or a.file}:{a.line}: @unit is a *reference* to a "
1615
+ f"{units.noun()}, not a mark kind — put the pointer in a mark's body"
1616
+ )
1617
+
1618
+ # 12. where a sources register is declared, every @source(slug) names a
1619
+ # source in it, and its slugs are an identity, as the spec's are.
1620
+ from quirework.manuscript import sources
1621
+
1622
+ if sources.configured():
1623
+ problems += sources.problems()
1624
+
1625
+ # 10. the units on disk are the manuscript's units. The dual of invariant
1626
+ # 2: under slug-only, the remaining way to orphan a book chapter is to
1627
+ # rename the file and forget the config. Owned by units, which owns the
1628
+ # registry.
1629
+ problems += units.order_problems(docs)
1630
+
1631
+ # 7/8. the companion collections resolve back into the spec, their
1632
+ # quotations still exist in the book they quote, and each entry's
1633
+ # **Anchors:** run names chapters that really bind those slugs. Computed
1634
+ # here, before the problems are printed; the quotation half and the
1635
+ # missing-run half are advisories and print below with the others.
1636
+ # Companion paths resolve from the project root, so a caller pointing check() at a
1637
+ # temporary tree (the tests do) gets that tree's companions or none, never
1638
+ # the live repo's measured against a synthetic spec.
1639
+ comp_problems, comp_quotes, comp_anchors = companion_check(
1640
+ docs, spec, root=docs.parent
1641
+ )
1642
+ problems += comp_problems
1643
+
1644
+ for a in annos:
1645
+ if a.kind == "bind" or not a.body:
1646
+ continue
1647
+ for m in _BIND_REF.finditer(a.body):
1648
+ problems.append(
1649
+ f"{a.source or a.file}:{a.line}: @{a.kind} cites "
1650
+ f"@bind({m.group(1)}). Use '#{m.group(1)}' for the spec "
1651
+ f"section; @bind is reserved for bodyless relationships."
1652
+ )
1653
+
1654
+ def at(a) -> str:
1655
+ return f"{a.source or a.file}:{a.line}: @{a.kind}" + (
1656
+ f"({a.args[0]})" if a.args else ""
1657
+ )
1658
+
1659
+ # Each advisory is a (header, lines) group, printed by `check`. The header
1660
+ # carries `{n}` for its count, so a subset of the lines (what `--since`
1661
+ # prints) is announced with its own number.
1662
+ groups: list[tuple[str, list[str]]] = []
1663
+
1664
+ # A unit's own spec written before the unit is legitimate, which is why
1665
+ # this is an advisory. It is also exactly what a mistyped slug looks like.
1666
+ present = units.names(docs)
1667
+ groups.append(
1668
+ (
1669
+ f"{{n}} spec file(s) belong to no {units.noun()} yet; a planned "
1670
+ f"{units.noun()} is fine, a mistyped slug is not",
1671
+ [
1672
+ f" {_rel(f)}"
1673
+ for f in spec_files(docs)
1674
+ if (owner := spec_owner(f)) is not None and owner not in present
1675
+ ],
1676
+ )
1677
+ )
1678
+
1679
+ # An edition retells what the canon commits to. A section the canon binds
1680
+ # that an edition neither binds nor omits is the gap a reader cannot tell
1681
+ # from a choice, so it is named until the edition says which it is.
1682
+ carried = edition_bindings()
1683
+ canon = set().union(*chapter_bindings(docs).values())
1684
+ groups.append(
1685
+ (
1686
+ "{n} section(s) the canon binds are neither bound nor omitted by an "
1687
+ "edition; bind each where the edition carries it, or @omit it with "
1688
+ "the reason",
1689
+ [
1690
+ f" {_rel(editions.root() / kind)}: {slug}"
1691
+ for kind in editions.composed()
1692
+ for slug in sorted(
1693
+ canon
1694
+ - carried.get(kind, {}).get("bind", set())
1695
+ - carried.get(kind, {}).get("omit", set())
1696
+ )
1697
+ ],
1698
+ )
1699
+ )
1700
+
1701
+ # Directories whose contents are working material rather than authority. A
1702
+ # mark that rests on one has put a commitment somewhere the book does not
1703
+ # carry. Which directories those are is the book's own placement, so an
1704
+ # undeclared project gets no advisory rather than a report about a tree it
1705
+ # does not have.
1706
+ noncanonical = tuple(
1707
+ current_project().get("manuscript.noncanonical_roots", ()) or ()
1708
+ )
1709
+ groups.append(
1710
+ (
1711
+ f"{{n}} mark(s) reference {', '.join(noncanonical)}; a mark states "
1712
+ f"its own commitment — advisory, not yet gated",
1713
+ [
1714
+ f" {at(a)}"
1715
+ for a in annos
1716
+ # An origin story names where the material came from, which
1717
+ # is often a brief in working material. That records history
1718
+ # rather than resting a commitment on it, the same reason
1719
+ # @genesis is exempt from the journaling lint.
1720
+ if a.kind != "genesis" and any(root in a.body for root in noncanonical)
1721
+ ],
1722
+ )
1723
+ )
1724
+
1725
+ groups.append(
1726
+ (
1727
+ "{n} block-surfaced mark(s) in AI register; these are what the "
1728
+ "author reads in the .docx",
1729
+ [
1730
+ f" {at(a)} — '{m.group(0)}'"
1731
+ for a in annos
1732
+ if a.kind in BLOCK_KINDS and a.body
1733
+ for m in [_CAPS_RUN.search(a.body)]
1734
+ if m
1735
+ ],
1736
+ )
1737
+ )
1738
+
1739
+ journ: list[str] = []
1740
+ for a in annos:
1741
+ if a.kind in CONTRACT_KINDS and a.body:
1742
+ m = _JOURNALING.search(a.body)
1743
+ if m:
1744
+ journ.append(f" {at(a)} — '{m.group(0)}'")
1745
+ for s in sections:
1746
+ m = _JOURNALING.search(s.body)
1747
+ if m:
1748
+ journ.append(f" {_rel(s.path)}#{s.slug or s.title} — '{m.group(0)}'")
1749
+ groups.append(
1750
+ (
1751
+ "{n} contract mark(s)/spec section(s) carry decision-journaling; "
1752
+ "current state, never history — the story belongs in git",
1753
+ journ,
1754
+ )
1755
+ )
1756
+
1757
+ # Every pin, not the first per mark: a mark carrying two of them is exactly
1758
+ # where reporting one is worst, because the author fixes what is printed
1759
+ # and the survivor reads as cleared. The live instance had two in one
1760
+ # sentence, and only the second was wrong.
1761
+ pins: list[str] = []
1762
+ for a in annos:
1763
+ if a.body:
1764
+ for m in _LINE_PIN.finditer(a.body):
1765
+ pins.append(f" {at(a)} — '{m.group(0)}'")
1766
+ for s in sections:
1767
+ for m in _LINE_PIN.finditer(s.body):
1768
+ pins.append(f" {_rel(s.path)}#{s.slug or s.title} — '{m.group(0)}'")
1769
+ groups.append(
1770
+ (
1771
+ "{n} mark(s)/spec section(s) pin a line distance; the next "
1772
+ "insertion above makes the number wrong — name the scene or the "
1773
+ "phrase instead",
1774
+ pins,
1775
+ )
1776
+ )
1777
+
1778
+ groups.append(
1779
+ (
1780
+ "{n} companion quotation(s) appear nowhere in the work; a quote is "
1781
+ "primary evidence and is never paraphrased into existence",
1782
+ comp_quotes,
1783
+ )
1784
+ )
1785
+ groups.append(
1786
+ (
1787
+ f"{{n}} companion entry/entries anchor nothing; an entry with no "
1788
+ f"resolving **{_anchors_label()}:** run is invisible to the join "
1789
+ f"that keeps the collection in step",
1790
+ comp_anchors,
1791
+ )
1792
+ )
1793
+
1794
+ groups.append(
1795
+ (
1796
+ "{n} inline spec #slug reference(s) resolve to no spec section",
1797
+ [
1798
+ f" {at(a)} — #{slug}"
1799
+ for a in annos
1800
+ if a.body
1801
+ for slug in _SPEC_REF.findall(a.body)
1802
+ if resolve(slug, unit_name(a), spec) not in spec
1803
+ ],
1804
+ )
1805
+ )
1806
+
1807
+ # The spec is prose and is held to both tells. Bindings are bodyless
1808
+ # relationships, so only the spec can carry an imperative here.
1809
+ spec_register: list[str] = []
1810
+ for s in sections:
1811
+ m = _IMPERATIVE.search(s.body) or _CAPS_RUN.search(s.body)
1812
+ if m:
1813
+ spec_register.append(
1814
+ f" {_rel(s.path)}#{s.slug or s.title} — '{m.group(0)}'"
1815
+ )
1816
+ groups.append(
1817
+ (
1818
+ "{n} spec section(s) in the imperative or contract register; the "
1819
+ "spec states and prices, never orders",
1820
+ spec_register,
1821
+ )
1822
+ )
1823
+ return problems, [(header, lines) for header, lines in groups if lines]
1824
+
1825
+
1826
+ def _advise(groups: list[tuple[str, list[str]]]) -> None:
1827
+ for header, lines in groups:
1828
+ print(
1829
+ f"\nadvisory ({header.replace('{n}', str(len(lines)))}):", file=sys.stderr
1830
+ )
1831
+ for line in lines:
1832
+ print(line, file=sys.stderr)
1833
+
1834
+
1835
+ # A line number is where an advisory sits, not what it says: an edit above a
1836
+ # mark moves it without changing it, and must not make it read as new.
1837
+ _LINE_NO = re.compile(r":\d+:")
1838
+
1839
+
1840
+ def introduced(groups, before):
1841
+ """The advisory lines in `groups` that `before` did not have.
1842
+
1843
+ Compared per group, as text with line numbers taken out, and counted: a
1844
+ second identical advisory in the same file is new even though the first
1845
+ was not.
1846
+ """
1847
+ had = {
1848
+ header: Counter(_LINE_NO.sub(":", line) for line in lines)
1849
+ for header, lines in before
1850
+ }
1851
+ new, standing = [], 0
1852
+ for header, lines in groups:
1853
+ left = Counter(had.get(header, {}))
1854
+ fresh = []
1855
+ for line in lines:
1856
+ key = _LINE_NO.sub(":", line)
1857
+ if left[key]:
1858
+ left[key] -= 1
1859
+ standing += 1
1860
+ else:
1861
+ fresh.append(line)
1862
+ if fresh:
1863
+ new.append((header, fresh))
1864
+ return new, standing
1865
+
1866
+
1867
+ def _extract(top: Path, ref: str, prefix: str, dest: Path) -> None:
1868
+ """Write the files `ref` holds under `prefix` into `dest`, byte for byte.
1869
+
1870
+ Not `git archive`, which honours export-ignore and export-subst and so can
1871
+ leave out or rewrite a file the check reads, making its advisories look
1872
+ new. Not a checkout either, which runs smudge filters and can reach the
1873
+ network. The committed blobs are the revision. They are read one at a
1874
+ time, so memory is bounded by the largest file rather than the tree.
1875
+ """
1876
+ listing = subprocess.run(
1877
+ ["git", "-C", str(top), "ls-tree", "-r", "-z", "--full-tree", ref]
1878
+ + (["--", prefix] if prefix else []),
1879
+ capture_output=True,
1880
+ check=True,
1881
+ )
1882
+ blobs = []
1883
+ for record in listing.stdout.split(b"\0"):
1884
+ meta, _, path = record.partition(b"\t")
1885
+ if not record or meta.split()[1] != b"blob":
1886
+ continue # a submodule entry is another repository's commit
1887
+ mode, _, sha = meta.split()
1888
+ blobs.append((mode, sha, Path(os.fsdecode(path))))
1889
+ with subprocess.Popen(
1890
+ ["git", "-C", str(top), "cat-file", "--batch"],
1891
+ stdin=subprocess.PIPE,
1892
+ stdout=subprocess.PIPE,
1893
+ ) as batch:
1894
+ for mode, sha, path in blobs:
1895
+ batch.stdin.write(sha + b"\n")
1896
+ batch.stdin.flush()
1897
+ size = int(batch.stdout.readline().split()[2])
1898
+ body = batch.stdout.read(size)
1899
+ batch.stdout.read(1) # the newline after each object
1900
+ target = dest / (path.relative_to(prefix) if prefix else path)
1901
+ target.parent.mkdir(parents=True, exist_ok=True)
1902
+ if mode == b"120000":
1903
+ target.symlink_to(os.fsdecode(body))
1904
+ else:
1905
+ target.write_bytes(body)
1906
+ batch.stdin.close()
1907
+ if batch.returncode:
1908
+ raise ValueError(f"--since {ref}: git cat-file failed")
1909
+
1910
+
1911
+ def _findings_at(ref: str, docs: Path):
1912
+ """The same findings for the book as it stood at a git revision.
1913
+
1914
+ The tree at `ref` is written into a private directory that does not
1915
+ outlive the call, and checked in a project rooted there with today's
1916
+ settings, so both trees answer to the same rules and name their files
1917
+ relative to their own root. Nothing touches the index or adds a worktree.
1918
+
1919
+ The project is rebuilt from its settings rather than copied with a new
1920
+ root: a copy keeps each setting's origin, so every configured path would
1921
+ still resolve into the live checkout. A site's marks and spec sit outside
1922
+ its source, and would then be read from today's tree while its pages came
1923
+ from the revision.
1924
+ """
1925
+ import tempfile
1926
+
1927
+ from quirework.project import Project, private_tmp_parent, project_scope
1928
+
1929
+ project = current_project()
1930
+ try:
1931
+ inside = Path(docs).resolve().relative_to(project.root)
1932
+ except ValueError:
1933
+ raise ValueError(
1934
+ f"--since needs the manuscript source inside the project: {docs}"
1935
+ ) from None
1936
+
1937
+ def git(*args):
1938
+ return subprocess.run(
1939
+ ["git", "-C", str(project.root), *args], capture_output=True, text=True
1940
+ )
1941
+
1942
+ if git("rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}").returncode:
1943
+ raise ValueError(f"--since {ref}: not a commit in this repository")
1944
+ top = Path(git("rev-parse", "--show-toplevel").stdout.strip()).resolve()
1945
+ prefix = project.root.relative_to(top).as_posix()
1946
+ prefix = "" if prefix == "." else prefix
1947
+ with tempfile.TemporaryDirectory(
1948
+ prefix="quirework-notes-since-", dir=private_tmp_parent()
1949
+ ) as tmp:
1950
+ root = Path(tmp).resolve()
1951
+ _extract(top, ref, prefix, root)
1952
+ then = Project.from_settings(root, project.describe()["settings"])
1953
+ with project_scope(then):
1954
+ return _findings(root / inside)
1955
+
1956
+
1957
+ def check(docs: Path | None = None, since: str | None = None) -> int:
1958
+ """Run `_findings`, print them, and exit non-zero on a hard problem.
1959
+
1960
+ With `since`, the advisories the book did not have at that revision are
1961
+ printed first, then only a count of the ones it already had: a change's
1962
+ own advisories were otherwise found by grepping a long standing list.
1963
+ """
1964
+ docs = (
1965
+ Path(docs) if docs is not None else current_project().path("manuscript.source")
1966
+ )
1967
+ problems, groups = _findings(docs)
1968
+ for p in problems:
1969
+ print(p, file=sys.stderr)
1970
+ if since is None:
1971
+ _advise(groups)
1972
+ else:
1973
+ new, standing = introduced(groups, _findings_at(since, docs)[1])
1974
+ if new:
1975
+ print(f"\nnew since {since}:", file=sys.stderr)
1976
+ _advise(new)
1977
+ else:
1978
+ print(f"\nno advisory new since {since}.", file=sys.stderr)
1979
+ print(
1980
+ f"\n{standing} standing advisory line(s) the work already had at "
1981
+ f"{since}; run without --since to list them.",
1982
+ file=sys.stderr,
1983
+ )
1984
+ if problems:
1985
+ print(
1986
+ f"\n✗ notes --check: {len(problems)} problem(s); the sidecar "
1987
+ f"layout is not clean.",
1988
+ file=sys.stderr,
1989
+ )
1990
+ return 1
1991
+ print("✓ notes --check: sidecar layout clean.")
1992
+ return 0
1993
+
1994
+
1995
+ def main(argv=None) -> int:
1996
+ ap = argparse.ArgumentParser(
1997
+ prog="quirework spec notes",
1998
+ description="Harvest the manuscript's @-annotations from its sidecars. "
1999
+ "Positional args filter by kind (bind, guidance, todo, ...); "
2000
+ "run `quirework spec notes --list` to see the kinds and their counts.",
2001
+ epilog="examples:\n"
2002
+ " quirework spec notes all marks, grouped by kind\n"
2003
+ " quirework spec notes bind only @bind relationships\n"
2004
+ " quirework spec notes --primer the fresh-session primer\n"
2005
+ " (bind+guidance, by unit)\n"
2006
+ " quirework spec notes bind --slug authorship-by-log\n"
2007
+ " who binds that spec section\n"
2008
+ " quirework spec notes relations --slug authorship-by-log\n"
2009
+ " the same, without the word bind\n"
2010
+ " quirework spec notes --by unit every unit's marks, in order\n"
2011
+ " quirework spec notes --list the kind vocabulary + counts",
2012
+ formatter_class=argparse.RawDescriptionHelpFormatter,
2013
+ )
2014
+ ap.add_argument(
2015
+ "kinds",
2016
+ nargs="*",
2017
+ metavar="KIND",
2018
+ help="kind(s) to include, unioned (default: all)",
2019
+ )
2020
+ ap.add_argument(
2021
+ "--kind",
2022
+ action="append",
2023
+ default=[],
2024
+ metavar="KIND",
2025
+ help="another kind to include (repeatable; comma-separated ok)",
2026
+ )
2027
+ ap.add_argument(
2028
+ "--by",
2029
+ choices=("kind", "unit"),
2030
+ default=None,
2031
+ help="group output by kind (default) or by unit, in reading order",
2032
+ )
2033
+ ap.add_argument(
2034
+ "-c",
2035
+ "--unit",
2036
+ metavar="SUBSTR",
2037
+ help="only marks of units whose path matches this substring "
2038
+ "(e.g. the-sidecar, sidecar)",
2039
+ )
2040
+ ap.add_argument(
2041
+ "--section",
2042
+ metavar="SUBSTR",
2043
+ help="only marks under a heading matching this substring",
2044
+ )
2045
+ ap.add_argument(
2046
+ "--slug",
2047
+ metavar="SUBSTR",
2048
+ help="only marks whose parenthetical slug matches this substring "
2049
+ "(`notes bind --slug X` = which units bind spec section X)",
2050
+ )
2051
+ ap.add_argument(
2052
+ "--primer",
2053
+ action="store_true",
2054
+ help="shortcut: @bind + @guidance, by unit (fresh-session context)",
2055
+ )
2056
+ ap.add_argument(
2057
+ "--list",
2058
+ dest="list_kinds",
2059
+ action="store_true",
2060
+ help="list the kinds present, with counts, and exit",
2061
+ )
2062
+ ap.add_argument(
2063
+ "--check",
2064
+ action="store_true",
2065
+ help="lint the sidecar metadata layout and exit (for the pre-commit hook)",
2066
+ )
2067
+ ap.add_argument(
2068
+ "--since",
2069
+ metavar="REF",
2070
+ default=None,
2071
+ help="with --check, print first the advisories the work did not have "
2072
+ "at this git revision, then only a count of the standing ones",
2073
+ )
2074
+ ap.add_argument(
2075
+ "--coverage",
2076
+ action="store_true",
2077
+ help="which spec sections a companion entry exercises, and which none does",
2078
+ )
2079
+ ap.add_argument(
2080
+ "--path",
2081
+ action="append",
2082
+ default=[],
2083
+ metavar="PATH",
2084
+ help="scan this file/dir instead of manuscript.source (repeatable)",
2085
+ )
2086
+ args = ap.parse_args(argv)
2087
+
2088
+ if args.check:
2089
+ # --check is the layout gate, not a view: the filters below do not
2090
+ # apply to it, and silently ignoring one would return a verdict about
2091
+ # a different question than the one asked. A single --path forwards
2092
+ # as the source root to lint (what the tests do via check(docs=...)).
2093
+ ignored = [
2094
+ name
2095
+ for name, val in (
2096
+ ("KIND", args.kinds or args.kind),
2097
+ ("--by", args.by),
2098
+ ("-c/--unit", args.unit),
2099
+ ("--section", args.section),
2100
+ ("--slug", args.slug),
2101
+ ("--primer", args.primer),
2102
+ ("--list", args.list_kinds),
2103
+ )
2104
+ if val
2105
+ ]
2106
+ if ignored:
2107
+ print(
2108
+ f"--check runs the layout gate and takes no filters "
2109
+ f"(got: {', '.join(ignored)}).",
2110
+ file=sys.stderr,
2111
+ )
2112
+ return 2
2113
+ if len(args.path) > 1:
2114
+ print(
2115
+ "--check takes at most one --path (the source root to lint).",
2116
+ file=sys.stderr,
2117
+ )
2118
+ return 2
2119
+ docs = Path(args.path[0]) if args.path else None
2120
+ try:
2121
+ return check(docs, since=args.since)
2122
+ except (ValueError, subprocess.CalledProcessError) as exc:
2123
+ print(f"notes --check: {exc}", file=sys.stderr)
2124
+ return 2
2125
+
2126
+ if args.since:
2127
+ print("--since compares advisories, so it needs --check.", file=sys.stderr)
2128
+ return 2
2129
+
2130
+ if args.coverage:
2131
+ by_slug, unexercised = coverage()
2132
+ total = len(spec_slugs())
2133
+ print(
2134
+ f"companion coverage: {len(by_slug)}/{total} spec section(s) "
2135
+ f"exercised by an entry\n"
2136
+ )
2137
+ for slug in sorted(by_slug):
2138
+ entries = sorted(set(by_slug[slug]))
2139
+ print(f" {slug}")
2140
+ print(f" {', '.join(entries)}")
2141
+ if unexercised:
2142
+ print(f"\n not exercised by any entry ({len(unexercised)}):")
2143
+ for anchor, title in unexercised:
2144
+ print(f" {anchor:<34} {title}")
2145
+ print(
2146
+ "\n A map, not a scorecard. A section about the spec's own "
2147
+ "organization or\n vocabulary belongs to no entry, and that "
2148
+ "is not a gap."
2149
+ )
2150
+ return 0
2151
+
2152
+ paths = [Path(p) for p in args.path] or None
2153
+ annos = scan(paths)
2154
+ present = Counter(a.kind for a in annos)
2155
+
2156
+ if args.list_kinds:
2157
+ total = sum(present.values())
2158
+ print(f"kinds present ({total} mark(s)):")
2159
+ width = max((len(k) for k in present), default=0)
2160
+ for kind, n in sorted(present.items(), key=lambda kv: (-kv[1], kv[0])):
2161
+ gloss = KIND_DESCRIPTIONS.get(kind, "")
2162
+ print(f" {kind:<{width}} {n:>3} {gloss}".rstrip())
2163
+ return 0
2164
+
2165
+ # Explicitly requested kinds: positional + --kind (comma-splittable), unioned.
2166
+ # A positional kind may be an alias; --kind is always the literal name.
2167
+ positional = [k.lower() for k in args.kinds]
2168
+ clash = [k for k in positional if k in KIND_ALIASES and k in present]
2169
+ if clash:
2170
+ # The tag set is open, so a book may have its own kind by that name.
2171
+ # Guessing which one was meant would answer a different question.
2172
+ for k in clash:
2173
+ print(
2174
+ f"'{k}' is both a kind in this project and an alias for "
2175
+ f"'{KIND_ALIASES[k]}'; name the one you mean with --kind",
2176
+ file=sys.stderr,
2177
+ )
2178
+ return 2
2179
+ explicit: list[str] = [KIND_ALIASES.get(k, k) for k in positional]
2180
+ for spec in args.kind:
2181
+ explicit += [p.strip().lower() for p in spec.split(",") if p.strip()]
2182
+ explicit = list(dict.fromkeys(explicit)) # de-dup, preserve order
2183
+
2184
+ by = args.by or ("unit" if args.primer else "kind")
2185
+
2186
+ # An explicitly named kind the corpus doesn't hold is a typo -- error loudly
2187
+ # (the tag set is open, so "valid" == "present"). A preset (--primer) is not a
2188
+ # typo, so its absent members are dropped silently below, not validated here.
2189
+ unknown = [k for k in explicit if k not in present]
2190
+ if unknown:
2191
+ avail = ", ".join(
2192
+ f"{k}({present[k]})"
2193
+ for k, _ in sorted(present.items(), key=lambda kv: (-kv[1], kv[0]))
2194
+ )
2195
+ for k in unknown:
2196
+ sugg = difflib.get_close_matches(k, list(present), n=1)
2197
+ hint = f" did you mean '{sugg[0]}'?" if sugg else ""
2198
+ if "/" in k or Path(k).exists():
2199
+ hint += " (a path is scanned with --path PATH)"
2200
+ print(f"unknown kind '{k}'.{hint}", file=sys.stderr)
2201
+ print(f"available kinds: {avail}", file=sys.stderr)
2202
+ return 2
2203
+
2204
+ if explicit:
2205
+ requested = explicit
2206
+ elif args.primer:
2207
+ requested = [k for k in PRIMER_KINDS if k in present]
2208
+ else:
2209
+ requested = [] # no kind filter: every kind
2210
+
2211
+ # Validate the unit filter against the scanned files, likewise. A unit
2212
+ # with no marks yet still matches when the registry has it: a post is
2213
+ # specified before its sidecar exists, and the primer is how a session
2214
+ # reads that spec.
2215
+ all_files = sorted({a.file for a in annos})
2216
+ scoped: set[str] | None = None
2217
+ if args.unit:
2218
+ sub = args.unit.lower()
2219
+ try:
2220
+ listed = units.registry()
2221
+ except (OSError, ValueError):
2222
+ listed = []
2223
+ scoped = {u.slug for u in listed if sub in u.rel.lower()}
2224
+ scoped |= {units.slug_of(f) for f in all_files if sub in f.lower()}
2225
+ if not scoped:
2226
+ kind = units.noun()
2227
+ print(f"no {kind} matches '{args.unit}'. {kind}s:", file=sys.stderr)
2228
+ for f in all_files:
2229
+ print(f" {f}", file=sys.stderr)
2230
+ return 2
2231
+
2232
+ if requested:
2233
+ want = set(requested)
2234
+ annos = [a for a in annos if a.kind in want]
2235
+ if args.unit:
2236
+ sub = args.unit.lower()
2237
+ annos = [a for a in annos if sub in a.file.lower()]
2238
+ # --slug and --section are validated against what the earlier filters
2239
+ # left, so the error is scoped to the kinds/chapter actually asked for
2240
+ # (a typo errors with rc 2 like -c; an empty *combination* stays rc 0).
2241
+ if args.slug:
2242
+ sub = args.slug.lower()
2243
+ # A bare name in a unit's own marks means that unit's section, so the
2244
+ # filter matches the full name a binding resolves to as well.
2245
+ try:
2246
+ known = spec_slugs()
2247
+ except ConfigurationError:
2248
+ known = set()
2249
+
2250
+ def names(a):
2251
+ return [
2252
+ *a.args,
2253
+ *(resolve(x, unit_name(a), known) for x in a.args),
2254
+ ]
2255
+
2256
+ matching = [a for a in annos if any(sub in n.lower() for n in names(a))]
2257
+ if not matching and annos:
2258
+ slugs = sorted({arg for a in annos for arg in a.args})
2259
+ sugg = difflib.get_close_matches(args.slug, slugs, n=3)
2260
+ hint = f" close: {', '.join(sugg)}." if sugg else ""
2261
+ print(f"no mark slug matches '{args.slug}'.{hint}", file=sys.stderr)
2262
+ return 2
2263
+ annos = matching
2264
+ if args.section:
2265
+ sub = args.section.lower()
2266
+ matching = [a for a in annos if a.section and sub in a.section.lower()]
2267
+ if not matching and annos:
2268
+ secs = sorted({a.section for a in annos if a.section})
2269
+ sugg = difflib.get_close_matches(args.section, secs, n=3)
2270
+ hint = f" close: {', '.join(sugg)}." if sugg else ""
2271
+ print(f"no section matches '{args.section}'.{hint}", file=sys.stderr)
2272
+ return 2
2273
+ annos = matching
2274
+
2275
+ if args.primer and not args.path:
2276
+ _print_spec_toc(only=scoped) # a custom --path scan has no spec of its own
2277
+ if not annos:
2278
+ # Every filter here was already validated as matching *something*, so an
2279
+ # empty result means the combination is empty -- say which combination.
2280
+ active = []
2281
+ if requested:
2282
+ active.append("kind(s) " + ", ".join(requested))
2283
+ if args.unit:
2284
+ active.append(f"{units.noun()} ~ '{args.unit}'")
2285
+ if args.slug:
2286
+ active.append(f"slug ~ '{args.slug}'")
2287
+ if args.section:
2288
+ active.append(f"section ~ '{args.section}'")
2289
+ where = "; ".join(active) if active else "the scanned sources"
2290
+ print(f"no marks match ({where}).")
2291
+ return 0
2292
+
2293
+ if by == "unit":
2294
+ _render_by_chapter(annos)
2295
+ else:
2296
+ _render_by_kind(annos)
2297
+ return 0
2298
+
2299
+
2300
+ if __name__ == "__main__":
2301
+ raise SystemExit(main())