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,1001 @@
1
+ #!/usr/bin/env python3
2
+ """Wrap Markdown according to project-selected line conventions.
3
+
4
+ Prose uses semantic line breaks, with one sentence per source line. Technical
5
+ documents use bounded columns. A configured classifier chooses the mode for a
6
+ path, so no consumer directory layout is built into this module.
7
+
8
+ Every mode also strips trailing whitespace from every line. Files that are
9
+ generated or verbatim (the provenance transcripts/indexes, the panel raw output)
10
+ are skipped entirely; other hand-authored files we don't reflow (SOPs, the
11
+ preface variants, the verbatim third-party `paper-full-text.md` dumps) still
12
+ get trailing-whitespace cleanup ("trailing" mode).
13
+
14
+ The mode is chosen by path (`classify`), so both the CLI and the pre-commit hook
15
+ just pass files and get the right treatment. A hard self-check (`_canonical`)
16
+ compares the file before and after with all whitespace collapsed; if anything
17
+ other than whitespace changed, the run aborts and writes nothing.
18
+
19
+ This module and its sibling artifact_paths helper are stdlib-only, so the file
20
+ also runs directly, without installing the package. Paths below are relative to
21
+ this repository; from a project that vendors it, prefix its checkout.
22
+
23
+ Usage:
24
+ python src/quirework/text/mdwrap.py FILE [FILE ...] # rewrap in place
25
+ python src/quirework/text/mdwrap.py --check FILE [...] # exit 1 if not wrapped
26
+ python src/quirework/text/mdwrap.py --width 72 FILE ... # column-mode width
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import argparse
32
+ import re
33
+ import sys
34
+ from pathlib import Path
35
+
36
+ if __package__:
37
+ from quirework.core import globs
38
+ from quirework.core.artifact_paths import run_evidence
39
+ else: # Direct script invocation loads the stdlib-only sibling helpers.
40
+ sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
41
+ from quirework.core import globs
42
+ from quirework.core.artifact_paths import run_evidence
43
+
44
+ DEFAULT_WIDTH = 72
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Sentence mode (semantic line breaks) — the canonical book prose.
48
+ # ---------------------------------------------------------------------------
49
+
50
+ # Long sentences are additionally broken at semicolons; short ones are left whole.
51
+ _CLAUSE_SPLIT_MIN_LEN = 100
52
+
53
+ # Abbreviations whose trailing "." must not be read as a sentence end.
54
+ _ABBREVS = [
55
+ "e.g.",
56
+ "i.e.",
57
+ "etc.",
58
+ "vs.",
59
+ "cf.",
60
+ "al.",
61
+ "esp.",
62
+ "approx.",
63
+ "Mr.",
64
+ "Mrs.",
65
+ "Ms.",
66
+ "Dr.",
67
+ "Prof.",
68
+ "St.",
69
+ "Fig.",
70
+ "Inc.",
71
+ "Ltd.",
72
+ "Jr.",
73
+ "Sr.",
74
+ "Ph.D.",
75
+ "a.m.",
76
+ "p.m.",
77
+ ]
78
+
79
+ # Matched as whole words, never as a substring. A bare `str.replace` finds the
80
+ # "al." of "et al." inside "real.", "moral.", "animal.", "final." — protecting a
81
+ # period that really does end a sentence, so the break after it silently
82
+ # disappears. In a book about morals and animals that is not an edge case.
83
+ _ABBREV_RE = re.compile(
84
+ r"(?<![A-Za-z0-9.])(?:"
85
+ + "|".join(re.escape(a) for a in sorted(_ABBREVS, key=len, reverse=True))
86
+ + ")"
87
+ )
88
+
89
+ # Abbreviations read as such only before a number: "No. 5", "ch. 4",
90
+ # "pp. 12". "No." is the numero sign only there. Listed with the others it
91
+ # also swallowed the answer "No.", so "Did the fuse hold? No. The lights went
92
+ # out." was joined onto one line, and the joined form was then what --check
93
+ # demanded. The rest are never words, but a break after "ch." carried an
94
+ # emphasized span across a source line.
95
+ _NUMBERED = ("no", "ch", "chap", "p", "pp", "vol", "pt", "sec", "fig", "eq")
96
+ _NUMBERED_RE = re.compile(
97
+ r"(?<![A-Za-z0-9.])(?i:" + "|".join(_NUMBERED) + r")\.(?=\s*[0-9#])"
98
+ )
99
+
100
+ # Single-capital-letter initials: "W. V. O. Quine", "U.S. Army". A capital
101
+ # after a slash or a hyphen is the end of a term, not an initial: "I/O."
102
+ # closes a sentence as surely as "disk." does.
103
+ _INITIAL_RE = re.compile(r"(?<![\w/-])([A-Z])\.(?=\s)")
104
+ _NEXT_INITIAL_RE = re.compile(r"\s+[A-Z]\.")
105
+
106
+ _SENT = "\x00" # sentinel standing in for a protected "."
107
+
108
+
109
+ def _initial(m: re.Match) -> str:
110
+ """Protect an initial's period, but not the pronoun's.
111
+
112
+ "so have I. Here" ends a sentence on "I". After a lowercase word, "I" is
113
+ read as the pronoun, unless another initial follows it ("the architect I.
114
+ M. Pei"). A single initial I after a lowercase word, as in "by I. Kant",
115
+ is broken after in consequence; a soft break renders as a space.
116
+ """
117
+ text, start = m.string, m.start()
118
+ if m.group(1) == "I" and not _NEXT_INITIAL_RE.match(text, m.end()):
119
+ k = start
120
+ while k > 0 and text[k - 1].isspace():
121
+ k -= 1
122
+ if 0 < k < start and text[k - 1].islower():
123
+ return m.group(0)
124
+ return m.group(1) + _SENT
125
+
126
+
127
+ def _protect(text: str) -> str:
128
+ """Replace dots that are not sentence ends with a sentinel.
129
+
130
+ The result is as long as the text, character for character, so a caller
131
+ may protect a longer text than it needs and slice the part it asks about.
132
+ """
133
+ text = _ABBREV_RE.sub(lambda m: m.group(0).replace(".", _SENT), text)
134
+ text = _NUMBERED_RE.sub(lambda m: m.group(0).replace(".", _SENT), text)
135
+ return _INITIAL_RE.sub(_initial, text)
136
+
137
+
138
+ def _restore(text: str) -> str:
139
+ return text.replace(_SENT, ".")
140
+
141
+
142
+ # A boundary is whitespace that follows .!? (optionally then a closing quote)
143
+ # and precedes something that can start a sentence.
144
+ #
145
+ # Known limitation: a quoted title ending in ? or ! that continues mid-sentence
146
+ # with a space then ( or a digit reads as a boundary here, so `"...Bat?" (1974)`
147
+ # splits and orphans the year. It is cosmetic (a soft break renders as a space),
148
+ # but write such citations in a project-selected house style --
149
+ # `"Title?", 1974` (comma butting the closing quote), or year-in-front -- which
150
+ # leaves no space+quote boundary to trip. Not fixed in the splitter on purpose:
151
+ # `?"`/`!"` before a capital is genuinely ambiguous (title mid-sentence vs. a
152
+ # quoted question ending a sentence), and any heuristic trades this cosmetic
153
+ # miss for an equally cosmetic one. The one shape read otherwise is a dialogue
154
+ # tag, `"Which one?" I said.`, below.
155
+ #
156
+ # What may trail the terminal punctuation and still belong to the sentence it
157
+ # ends: a closing quote or parenthesis, and footnote references, which
158
+ # Markdown writes as `[^label]` right after the period ("...a resource.[^intro]
159
+ # That"). A bare `]` is not on the list: a Pandoc inline note `^[...]` closes
160
+ # on one, and whether a sentence ends after the note is the author's call, so
161
+ # the text around it is left as written.
162
+ _TRAILERS = re.compile(r"""(?:\[\^[^\]\s]+\]|["”’')])*\Z""")
163
+ _STARTER = re.compile(r'[A-Z0-9"“(\[*]')
164
+ _SEMI = re.compile(r"(?<=;)\s+")
165
+
166
+
167
+ # A quoted question or exclamation and its tag are one sentence: `"Which
168
+ # one?" I said.` A tag opens on a capital only when its speaker is "I" or a
169
+ # name, so one capitalized word followed by a verb of speech is read as a tag.
170
+ _QUOTED_END = re.compile(r"""[?!]["”’']+\Z""")
171
+ _TAG = re.compile(
172
+ r"[A-Z][\w’'-]*\s+(?:"
173
+ r"say|says|said|ask|asks|asked|reply|replies|replied|answer|answers|"
174
+ r"answered|whispered|shouted|cried|called|yelled|muttered|wondered|"
175
+ r"added|repeated|write|writes|wrote|type|types|typed"
176
+ r")\b"
177
+ )
178
+
179
+
180
+ def _ends_sentence(protected: str) -> bool:
181
+ """Whether protected text ends with terminal punctuation, trailers aside.
182
+
183
+ Abbreviations and initials were turned into sentinels by `_protect`, so a
184
+ period still standing here is one a sentence ends on.
185
+ """
186
+ core = protected[: _TRAILERS.search(protected).start()]
187
+ return core[-1:] in (".", "!", "?")
188
+
189
+
190
+ def _breaks(before: str, protected: str, resume: int) -> bool:
191
+ """Whether a sentence ends after `before`, when the text goes on at
192
+ `protected[resume]`. A quoted question's dialogue tag goes on with it."""
193
+ if not _ends_sentence(before):
194
+ return False
195
+ return not (_QUOTED_END.search(before) and _TAG.match(protected, resume))
196
+
197
+
198
+ def _sentences(protected: str) -> list[str]:
199
+ """Split protected text at whitespace that ends a sentence and precedes
200
+ something that can start one."""
201
+ out, start = [], 0
202
+ for gap in re.finditer(r"\s+", protected):
203
+ after = protected[gap.end() : gap.end() + 1]
204
+ before = protected[start : gap.start()]
205
+ if _STARTER.match(after) and _breaks(before, protected, gap.end()):
206
+ out.append(before)
207
+ start = gap.end()
208
+ out.append(protected[start:])
209
+ return out
210
+
211
+
212
+ def _split_paragraph(text: str) -> list[str]:
213
+ """One prose paragraph (already joined to a single string) -> lines."""
214
+ protected = _protect(text)
215
+ sentences = _sentences(protected)
216
+ lines: list[str] = []
217
+ for s in sentences:
218
+ s = s.strip()
219
+ if not s:
220
+ continue
221
+ if len(s) > _CLAUSE_SPLIT_MIN_LEN and ";" in s:
222
+ lines.extend(part.strip() for part in _SEMI.split(s) if part.strip())
223
+ else:
224
+ lines.append(s)
225
+ return [_restore(s) for s in lines]
226
+
227
+
228
+ def _is_blank(line: str) -> bool:
229
+ return line.strip() == ""
230
+
231
+
232
+ # Lines that begin a non-prose block and must pass through verbatim (sentence
233
+ # mode leaves list items alone, unlike column mode which reflows them).
234
+ _PASSTHROUGH = re.compile(
235
+ r"""^\s*(
236
+ \# | # heading
237
+ !\[ | # block-level image (figure): caption + attrs stay verbatim
238
+ :{3,} | # pandoc/quarto fenced div (::: callout)
239
+ > | # blockquote
240
+ \| | # table row
241
+ [-*+]\s | # bullet list item
242
+ \d+[.)]\s | # ordered list item
243
+ ---+\s*$ | # thematic break / front-matter rule
244
+ \*\*\*+\s*$ | # thematic break
245
+ ___+\s*$ | # thematic break
246
+ \{\{[<%] | # a Hugo shortcode on its own line
247
+ \[[^\]]+\]: # link reference definition
248
+ )""",
249
+ re.VERBOSE,
250
+ )
251
+
252
+
253
+ def _passthrough_line(line: str) -> bool:
254
+ # Any indented line is left alone: prose paragraphs start at column 0, so an
255
+ # indented line is a list-item continuation or indented code — never prose to
256
+ # reflow. (This keeps hard-wrapped, list-heavy docs safe.)
257
+ if line[:1].isspace():
258
+ return True
259
+ return bool(_PASSTHROUGH.match(line))
260
+
261
+
262
+ def _consume_frontmatter(lines: list[str], out: list[str]) -> list[str]:
263
+ """If `lines` opens with a frontmatter block, copy it verbatim to `out`.
264
+
265
+ Either convention: YAML between `---` lines, or TOML between `+++` lines,
266
+ which is what Hugo writes by default. The block closes on its own fence.
267
+ Returns the remaining lines. Shared by both modes so a multi-key block is
268
+ never reflowed into prose. Leading single-line HTML comments (such as the
269
+ ``Added:`` stamp) are skipped when looking for the opening fence.
270
+ """
271
+ start = 0
272
+ while (
273
+ start < len(lines)
274
+ and lines[start].strip().startswith("<!--")
275
+ and lines[start].strip().endswith("-->")
276
+ ):
277
+ start += 1
278
+ if start < len(lines) and lines[start].strip() in ("---", "+++"):
279
+ fence = lines[start].strip()
280
+ for j in range(start):
281
+ out.append(lines[j])
282
+ out.append(lines[start])
283
+ i = start + 1
284
+ while i < len(lines):
285
+ out.append(lines[i])
286
+ i += 1
287
+ if lines[i - 1].strip() == fence:
288
+ break
289
+ return lines[i:]
290
+ return lines
291
+
292
+
293
+ _FENCE_OPEN = re.compile(r"^\s*(`{3,}|~{3,})")
294
+
295
+
296
+ class _Fences:
297
+ """Whether a line is in fenced code, decided as CommonMark decides it.
298
+
299
+ A fence opens on three or more backticks or tildes and closes only on a
300
+ run of the same character at least as long, with nothing after it but
301
+ space. A fence quoted inside a longer one is therefore text, not a close:
302
+ toggling on every fence-like line lost track there and reflowed the code
303
+ that followed as prose.
304
+ """
305
+
306
+ def __init__(self):
307
+ self.open = None # (character, length) of the open fence
308
+
309
+ def step(self, line):
310
+ """Whether ``line`` opens or closes a fence, updating the state."""
311
+ found = _FENCE_OPEN.match(line)
312
+ if not found:
313
+ return False
314
+ run = found.group(1)
315
+ if self.open is None:
316
+ self.open = (run[0], len(run))
317
+ return True
318
+ char, length = self.open
319
+ if run[0] == char and len(run) >= length and not line[found.end() :].strip():
320
+ self.open = None
321
+ return True
322
+ return False
323
+
324
+ @property
325
+ def inside(self):
326
+ return self.open is not None
327
+
328
+
329
+ def _find_open(s: str) -> int:
330
+ """Index of the next `<!--` that is a real delimiter, or -1.
331
+
332
+ A `<!--` inside an inline code span is text about a comment, not a comment.
333
+ """
334
+ i = s.find("<!--")
335
+ while i >= 0:
336
+ if s.count("`", 0, i) % 2 == 0:
337
+ return i
338
+ i = s.find("<!--", i + 4)
339
+ return -1
340
+
341
+
342
+ def _split_comment_lines(lines: list[str]) -> list[str]:
343
+ """Give every HTML comment whole lines of its own.
344
+
345
+ The comment machinery in both modes assumes a comment starts a line and that
346
+ the line holding `-->` ends it. Two shapes break that assumption, and both
347
+ occur in practice when an annotation is hand-deleted from a stack of them:
348
+
349
+ * `... v1.0. --><!-- @ref ...` — one comment closes and the next opens on the
350
+ same line. The scanner would end the comment there and hand the tail to
351
+ `_reflow_comment`, which cannot parse it and passes the whole mess through
352
+ verbatim; the joined line then survives `--check` untouched.
353
+ * `Some prose. <!-- @todo ... -->` — a comment opening after prose is not
354
+ seen as a comment at all. Sentence mode reflows it *as prose*, splitting
355
+ the annotation at its internal sentence breaks.
356
+
357
+ Splitting at the delimiters first makes both shapes ordinary. Fenced code is
358
+ untouched, and an unterminated `<!--` keeps the rest of the input verbatim.
359
+ """
360
+ out: list[str] = []
361
+ fences = _Fences()
362
+ in_comment = False
363
+ for line in lines:
364
+ if fences.step(line):
365
+ out.append(line)
366
+ continue
367
+ if fences.inside or not line.strip():
368
+ out.append(line)
369
+ continue
370
+
371
+ rest = line
372
+ pieces: list[str] = []
373
+ while rest:
374
+ if in_comment:
375
+ i = rest.find("-->")
376
+ if i < 0:
377
+ pieces.append(rest)
378
+ break
379
+ pieces.append(rest[: i + 3])
380
+ # Whatever follows the close starts fresh at column 0: left
381
+ # indented, sentence mode would read it as a passthrough block.
382
+ rest = rest[i + 3 :].lstrip()
383
+ in_comment = False
384
+ else:
385
+ i = _find_open(rest)
386
+ if i < 0:
387
+ pieces.append(rest)
388
+ break
389
+ if rest[:i].strip():
390
+ pieces.append(rest[:i].rstrip())
391
+ rest = rest[i:]
392
+ in_comment = True
393
+ out.extend(pieces or [line])
394
+ return out
395
+
396
+
397
+ def rewrap(text: str) -> str:
398
+ """Sentence-per-line rewrap (the canonical-prose mode)."""
399
+ lines = text.split("\n")
400
+ out: list[str] = []
401
+ para: list[str] = []
402
+ fences = _Fences()
403
+ in_comment = False
404
+ comment_acc: list[str] = []
405
+
406
+ lines = _split_comment_lines(_consume_frontmatter(lines, out))
407
+
408
+ def flush() -> None:
409
+ # A line the author already ended on a sentence end stays ended, even
410
+ # when the next one opens in lowercase or code ("virtio-blk is a block
411
+ # device.", "`cache=none` is ..."), which no capital-letter rule can
412
+ # recognize as a new sentence. Only the lines between are joined.
413
+ # Whether a line ends a sentence is judged with the next line in view,
414
+ # as it is inside a line: "ch." before "4" ends nothing, and neither
415
+ # does a quoted question before its tag.
416
+ group: list[str] = []
417
+ for k, line in enumerate(para):
418
+ here = line.strip()
419
+ group.append(here)
420
+ after = para[k + 1].strip() if k + 1 < len(para) else ""
421
+ protected = _protect(here + " " + after)
422
+ if _breaks(protected[: len(here)], protected, len(here) + 1):
423
+ out.extend(_split_paragraph(" ".join(group)))
424
+ group = []
425
+ if group:
426
+ out.extend(_split_paragraph(" ".join(group)))
427
+ para.clear()
428
+
429
+ for line in lines:
430
+ # Fenced code blocks: copy verbatim.
431
+ if fences.step(line):
432
+ flush()
433
+ out.append(line)
434
+ continue
435
+ if fences.inside:
436
+ out.append(line)
437
+ continue
438
+
439
+ # HTML comments (possibly multi-line): copy verbatim, except for @-annotations.
440
+ if in_comment:
441
+ comment_acc.append(line)
442
+ if "-->" in line:
443
+ in_comment = False
444
+ out.extend(_reflow_comment(comment_acc, DEFAULT_WIDTH))
445
+ continue
446
+ if line.lstrip().startswith("<!--"):
447
+ flush()
448
+ if "-->" not in line:
449
+ in_comment = True
450
+ comment_acc = [line]
451
+ else:
452
+ out.extend(_reflow_comment([line], DEFAULT_WIDTH))
453
+ continue
454
+
455
+ if _is_blank(line):
456
+ flush()
457
+ out.append(line)
458
+ continue
459
+
460
+ if _passthrough_line(line):
461
+ flush()
462
+ out.append(line)
463
+ continue
464
+
465
+ # Plain prose line: accumulate into the current paragraph.
466
+ para.append(line)
467
+
468
+ if in_comment: # unterminated comment: keep it verbatim, never drop it
469
+ out.extend(comment_acc)
470
+ flush()
471
+ return "\n".join(out)
472
+
473
+
474
+ # ---------------------------------------------------------------------------
475
+ # Column mode (hard wrap at N columns) — the technical docs.
476
+ # ---------------------------------------------------------------------------
477
+
478
+ # Inline spans that must never be split across a line break: a soft break inside
479
+ # them changes what Markdown renders (a link destination or code span cannot hold
480
+ # a newline). Everything else breaks at whitespace. The `_canonical` self-check
481
+ # is the backstop: any accidental break inside one of these inserts a space that
482
+ # was not there and aborts the run rather than corrupting the file.
483
+ _ATOMIC = re.compile(
484
+ r"""
485
+ `[^`\n]*` | # inline code span
486
+ !?\[[^\]]*\]\([^)]*\) | # inline link / image
487
+ !?\[[^\]]*\]\[[^\]]*\] | # reference-style link / image
488
+ <[^>\s]+> # autolink
489
+ """,
490
+ re.VERBOSE,
491
+ )
492
+
493
+ # A list item: indent, marker (bullet or ordered), then its text.
494
+ _LIST_RE = re.compile(r"^(\s*)([-*+]|\d+[.)])\s+(.*)$")
495
+
496
+ # A token that must never be the first on a wrapped line: at line-start (followed
497
+ # by a space) it would be parsed as a new block — a list marker, heading,
498
+ # blockquote, table row, or thematic/setext rule. A mid-prose "+" (as in "comments
499
+ # + tracked runs") landing at line-start would silently become a sublist. When a
500
+ # break would put one of these first, we keep it on the current line instead.
501
+ #
502
+ # This is about Markdown, so it applies to Markdown. Inside an HTML comment no
503
+ # block can start, and the guard buys nothing there but over-width lines: the
504
+ # repo's annotations use " -- " as an em-dash and cite years as "(Russell 1975)",
505
+ # and both "--" and "1975)" match here. `_reflow_comment` waives it.
506
+ _UNSAFE_START = re.compile(
507
+ r"^(#{1,6}|[-*+]|\d{1,9}[.)]|>|\|+|-{2,}|={2,}|_{3,}|\*{3,})$"
508
+ )
509
+
510
+ # Non-list blocks that pass through verbatim in column mode.
511
+ _PASS_COL = re.compile(
512
+ r"""^\s*(
513
+ \# | # heading
514
+ !\[ | # block-level image
515
+ :{3,} | # fenced div
516
+ > | # blockquote (kept verbatim: often a quote)
517
+ \| | # table row
518
+ ---+\s*$ | # thematic break
519
+ \*\*\*+\s*$ | # thematic break
520
+ ___+\s*$ | # thematic break
521
+ \{\{[<%] | # a Hugo shortcode on its own line
522
+ \[[^\]]+\]: # link reference definition
523
+ )""",
524
+ re.VERBOSE,
525
+ )
526
+
527
+
528
+ def _tokenize(text: str) -> list[str]:
529
+ """Split into whitespace-separated tokens, keeping atomic spans whole.
530
+
531
+ Whitespace *inside* an atomic span (a link title, a code span) is not a break
532
+ point; whitespace outside it is. Atomic spans stay glued to any adjacent
533
+ non-space characters (so `see [x](y).` keeps the trailing period attached).
534
+ """
535
+ tokens: list[str] = []
536
+ cur: list[str] = []
537
+ i, n = 0, len(text)
538
+ while i < n:
539
+ ch = text[i]
540
+ if ch.isspace():
541
+ if cur:
542
+ tokens.append("".join(cur))
543
+ cur = []
544
+ i += 1
545
+ continue
546
+ m = _ATOMIC.match(text, i)
547
+ if m and m.end() > i:
548
+ cur.append(m.group(0))
549
+ i = m.end()
550
+ continue
551
+ cur.append(ch)
552
+ i += 1
553
+ if cur:
554
+ tokens.append("".join(cur))
555
+ return tokens
556
+
557
+
558
+ def _wrap_tokens(
559
+ tokens: list[str],
560
+ first_prefix: str,
561
+ cont_prefix: str,
562
+ width: int,
563
+ guard_line_start: bool = True,
564
+ ) -> list[str]:
565
+ """Greedy-wrap `tokens` to `width`, with a first-line and continuation prefix.
566
+
567
+ A token that alone overflows `width` (a long URL, a long code span) still
568
+ goes on its own line rather than being broken.
569
+
570
+ `guard_line_start` keeps a block-starting token off the front of a wrapped
571
+ line (see `_UNSAFE_START`). Callers wrapping text that Markdown never parses
572
+ as blocks — the inside of an HTML comment — pass False and get the width
573
+ honored instead.
574
+ """
575
+ if not tokens:
576
+ return [first_prefix]
577
+ lines: list[str] = []
578
+ cur = first_prefix + tokens[0]
579
+ for tok in tokens[1:]:
580
+ # A break before a block-starting token would corrupt the render, so keep
581
+ # such a token on the current line (accepting an over-width line) rather
582
+ # than letting it lead a wrapped line.
583
+ unsafe = guard_line_start and _UNSAFE_START.match(tok)
584
+ if len(cur) + 1 + len(tok) > width and not unsafe:
585
+ lines.append(cur)
586
+ cur = cont_prefix + tok
587
+ else:
588
+ cur += " " + tok
589
+ lines.append(cur)
590
+ return lines
591
+
592
+
593
+ def reflow_columns(text: str, width: int = DEFAULT_WIDTH) -> str:
594
+ """Hard-wrap prose paragraphs and list items to `width` columns.
595
+
596
+ Verbatim (never reflowed): fenced code, HTML comments, YAML frontmatter,
597
+ tables, blockquotes, headings, thematic breaks, block images, fenced-div
598
+ markers, link reference definitions. List items reflow with a hanging indent
599
+ aligned under their text; nested items keep their indent.
600
+ """
601
+ lines = text.split("\n")
602
+ out: list[str] = []
603
+
604
+ acc: list[str] = []
605
+ first_prefix = ""
606
+ cont_prefix = ""
607
+ in_list = False
608
+ # The continuation indent of the list item we are inside, if any. Unlike
609
+ # `in_list`, this survives a blank line: a list item may hold several
610
+ # paragraphs, and the blank between them ends the paragraph, not the item.
611
+ list_indent = ""
612
+
613
+ def flush() -> None:
614
+ nonlocal acc, first_prefix, cont_prefix, in_list
615
+ if acc:
616
+ tokens = _tokenize(" ".join(s.strip() for s in acc))
617
+ out.extend(_wrap_tokens(tokens, first_prefix, cont_prefix, width))
618
+ acc = []
619
+ first_prefix = ""
620
+ cont_prefix = ""
621
+ in_list = False
622
+
623
+ fences = _Fences()
624
+ in_comment = False
625
+ comment_acc: list[str] = []
626
+
627
+ lines = _split_comment_lines(_consume_frontmatter(lines, out))
628
+
629
+ for line in lines:
630
+ if fences.step(line):
631
+ flush()
632
+ if not line[:1].isspace(): # a column-0 fence ends any list
633
+ list_indent = ""
634
+ out.append(line)
635
+ continue
636
+ if fences.inside:
637
+ out.append(line)
638
+ continue
639
+
640
+ if in_comment:
641
+ comment_acc.append(line)
642
+ if "-->" in line:
643
+ in_comment = False
644
+ out.extend(_reflow_comment(comment_acc, width))
645
+ continue
646
+ if line.lstrip().startswith("<!--"):
647
+ flush()
648
+ if not line[:1].isspace(): # a column-0 comment ends any list
649
+ list_indent = ""
650
+ if "-->" not in line:
651
+ in_comment = True
652
+ comment_acc = [line]
653
+ else:
654
+ out.extend(_reflow_comment([line], width))
655
+ continue
656
+
657
+ if _is_blank(line):
658
+ flush()
659
+ out.append(line)
660
+ continue
661
+
662
+ m = _LIST_RE.match(line)
663
+ if m:
664
+ # A new list item (nested items match too, by their deeper indent).
665
+ flush()
666
+ indent, marker, rest = m.group(1), m.group(2), m.group(3)
667
+ first_prefix = f"{indent}{marker} "
668
+ cont_prefix = " " * len(first_prefix)
669
+ acc = [rest]
670
+ in_list = True
671
+ list_indent = cont_prefix
672
+ continue
673
+
674
+ # A continuation line of the current list item: indented, not a marker.
675
+ if in_list and line[:1].isspace():
676
+ acc.append(line)
677
+ continue
678
+
679
+ # A further paragraph of the list item we are still inside, after the
680
+ # blank line that ended the previous one. Markdown draws the line by
681
+ # indent: content indented to the item's own text continues the item,
682
+ # and four columns beyond that is an indented code block. So reflow the
683
+ # first case with the item's hanging indent and leave the second alone.
684
+ if list_indent and not in_list and line[:1].isspace():
685
+ indent = line[: len(line) - len(line.lstrip())]
686
+ if len(list_indent) <= len(indent) < len(list_indent) + 4:
687
+ first_prefix = cont_prefix = list_indent
688
+ acc = [line]
689
+ in_list = True
690
+ continue
691
+
692
+ # Any other indented line (not a list continuation): could be indented
693
+ # code or a block we don't reflow. Keep verbatim — never dedent it. The
694
+ # `_canonical` check can't guard this (it collapses leading whitespace).
695
+ if line[:1].isspace():
696
+ flush()
697
+ out.append(line)
698
+ continue
699
+
700
+ if _PASS_COL.match(line):
701
+ flush()
702
+ list_indent = ""
703
+ out.append(line)
704
+ continue
705
+
706
+ # Column-0 prose. If we were in a list, this ends it.
707
+ if in_list:
708
+ flush()
709
+ list_indent = ""
710
+ acc.append(line)
711
+
712
+ flush()
713
+ if in_comment: # unterminated comment: keep it verbatim, never drop it
714
+ out.extend(comment_acc)
715
+ return "\n".join(out)
716
+
717
+
718
+ def _reflow_comment(lines: list[str], width: int) -> list[str]:
719
+ """Hard-wrap an HTML comment at `width` if it's an @-annotation.
720
+
721
+ Other HTML comments pass through verbatim.
722
+ """
723
+ raw = "\n".join(lines)
724
+ if not re.search(r"<!--\s*@", raw):
725
+ return lines
726
+
727
+ m = re.match(r"^(\s*<!--)(.*?)(-->\s*)$", raw, re.DOTALL)
728
+ if not m:
729
+ return lines
730
+
731
+ start_marker, inner, end_marker = m.groups()
732
+ inner = re.sub(r"\s+", " ", inner).strip()
733
+
734
+ if not inner:
735
+ return [start_marker + " " + end_marker.strip()]
736
+
737
+ tokens = _tokenize(inner)
738
+ first_prefix = start_marker + " "
739
+ cont_prefix = " " * len(first_prefix)
740
+
741
+ wrapped = _wrap_tokens(
742
+ tokens, first_prefix, cont_prefix, width, guard_line_start=False
743
+ )
744
+ if wrapped:
745
+ # The closer rides on the last line and may carry it to width + 4. That
746
+ # is deliberate (see AGENTS.md, "Markdown conventions"): `-->` is a
747
+ # delimiter, not content, and wrapping it as a token would orphan it
748
+ # onto a line of its own. Pinned by a test — don't "fix" it.
749
+ wrapped[-1] += " -->"
750
+
751
+ return wrapped
752
+
753
+
754
+ # ---------------------------------------------------------------------------
755
+ # Shared: self-check, path classification, file processing.
756
+ # ---------------------------------------------------------------------------
757
+
758
+
759
+ # Whitespace between one comment's close and the next one's open. Nothing
760
+ # renders there, and `_split_comment_lines` puts a newline into `--><!--` on
761
+ # purpose, so the self-check must not read that newline as content.
762
+ _BETWEEN_COMMENTS = re.compile(r"-->\s*<!--")
763
+
764
+
765
+ def _canonical(text: str) -> list[str]:
766
+ """Paragraph blocks with all whitespace collapsed.
767
+
768
+ Two texts with the same canonical form differ only in whitespace and so
769
+ render identically and carry identical content.
770
+ """
771
+ blocks: list[str] = []
772
+ cur: list[str] = []
773
+
774
+ def close() -> None:
775
+ block = re.sub(r"\s+", " ", " ".join(cur)).strip()
776
+ blocks.append(_BETWEEN_COMMENTS.sub("--><!--", block))
777
+ cur.clear()
778
+
779
+ for line in text.split("\n"):
780
+ if _is_blank(line):
781
+ if cur:
782
+ close()
783
+ else:
784
+ cur.append(line)
785
+ if cur:
786
+ close()
787
+ return blocks
788
+
789
+
790
+ def _strip_trailing(text: str) -> str:
791
+ return "\n".join(line.rstrip() for line in text.split("\n"))
792
+
793
+
794
+ def project_relative(path: str) -> str:
795
+ """Path relative to the selected project's root, posix.
796
+
797
+ Public because a project's own `text.classifier` needs exactly this to
798
+ decide anything by location, and the alternative is each consumer
799
+ re-deriving the project root and disagreeing with the tool about it.
800
+
801
+ Every other path setting is written relative to the directory holding
802
+ `quirework.toml`, and this one has to agree with them, because what it
803
+ produces is compared against `runs.evidence_roots`. It used to anchor on
804
+ the nearest `pyproject.toml` instead, which is the same directory for a
805
+ book that is its own repository and a different one for a book kept inside
806
+ a larger project: there, a declared evidence root matched nothing, saved
807
+ run records were reflowed, and the configuration looked right on the page.
808
+
809
+ Falls back to the path as given when it lies outside the project, so
810
+ classification still works on a bare filename.
811
+ """
812
+ from quirework.project import current_project
813
+
814
+ p = Path(path)
815
+ try:
816
+ resolved = p.resolve()
817
+ except OSError:
818
+ resolved = p
819
+ try:
820
+ return resolved.relative_to(current_project().root).as_posix()
821
+ except ValueError:
822
+ return p.as_posix()
823
+
824
+
825
+ _MODE_NAMES = ("sentence", "column", "trailing", "skip")
826
+
827
+
828
+ def _declared_mode(path: str, table) -> str | None:
829
+ """The first `[glob, mode]` row of `text.modes` matching the path, if any."""
830
+ rel = project_relative(path)
831
+ for row in table:
832
+ if not (isinstance(row, (list, tuple)) and len(row) == 2):
833
+ raise ValueError(f"text.modes rows are [glob, mode] pairs, not {row!r}")
834
+ glob, mode = row
835
+ if mode not in _MODE_NAMES:
836
+ raise ValueError(
837
+ f"text.modes: {mode!r} is not a mode ({', '.join(_MODE_NAMES)})"
838
+ )
839
+ if globs.matches(rel, glob):
840
+ return mode
841
+ return None
842
+
843
+
844
+ def classify(path: str) -> str:
845
+ """Choose a format: a saved run record is never touched, then the project's
846
+ own rule (an extension or a table of globs), then the built-in default."""
847
+ from quirework.project import ConfigurationError, current_project, extension
848
+
849
+ project = current_project()
850
+ configured = project.get("text.classifier")
851
+ table = project.get("text.modes")
852
+ if configured and table:
853
+ raise ConfigurationError(
854
+ "text.classifier and text.modes both choose wrap modes; keep one"
855
+ )
856
+ if configured:
857
+ mode = extension(configured)(path)
858
+ if mode not in _MODE_NAMES:
859
+ raise ValueError(f"text classifier returned an unknown mode: {mode}")
860
+ return mode
861
+ if run_evidence(project_relative(path)):
862
+ return "skip"
863
+ if table:
864
+ mode = _declared_mode(path, table)
865
+ if mode is not None:
866
+ return mode
867
+ if Path(path).name == "GEMINI.md":
868
+ return "trailing"
869
+ if path.endswith(".qmd"):
870
+ return "sentence"
871
+ if path.endswith(".md"):
872
+ return "column"
873
+ return "skip"
874
+
875
+
876
+ def _wrapped(path: str, width: int) -> tuple[str, str] | None:
877
+ """The file's text and what its convention makes of it; None if skipped."""
878
+ mode = classify(path)
879
+ if mode == "skip":
880
+ return None
881
+
882
+ with open(path, encoding="utf-8") as fh:
883
+ original = fh.read()
884
+ trailing_nl = original.endswith("\n")
885
+ body = original.rstrip("\n")
886
+
887
+ if mode == "sentence":
888
+ result = rewrap(body)
889
+ elif mode == "column":
890
+ result = reflow_columns(body, width)
891
+ else: # trailing
892
+ result = body
893
+ result = _strip_trailing(result)
894
+ if trailing_nl:
895
+ result += "\n"
896
+
897
+ if _canonical(original) != _canonical(result):
898
+ raise SystemExit(
899
+ f"ABORT: {path}: content would change (not just whitespace). "
900
+ "This is a bug in the wrapper; nothing written."
901
+ )
902
+ return original, result
903
+
904
+
905
+ def at_fixed_point(path: str, width: int = DEFAULT_WIDTH) -> bool:
906
+ """Whether wrapping the file would leave it exactly as it is."""
907
+ texts = _wrapped(path, width)
908
+ return texts is None or texts[0] == texts[1]
909
+
910
+
911
+ def process(path: str, check: bool, width: int = DEFAULT_WIDTH) -> bool:
912
+ """Return True if the file already matches its convention."""
913
+ texts = _wrapped(path, width)
914
+ if texts is None:
915
+ return True
916
+ original, result = texts
917
+ if result == original:
918
+ return True
919
+ if check:
920
+ print(f"needs wrapping: {path}")
921
+ return False
922
+ with open(path, "w", encoding="utf-8") as fh:
923
+ fh.write(result)
924
+ print(f"wrapped: {path}")
925
+ return False
926
+
927
+
928
+ MODES = """\
929
+ The mode is chosen by path, so just pass files and each gets the right one:
930
+
931
+ sentence one sentence per line (semantic line breaks)
932
+ chapter prose: any .qmd
933
+ column hard wrap at 72 columns
934
+ every other Markdown file, including guides, READMEs, the
935
+ spec, and the per-chapter metadata sidecars (_marks/*.md),
936
+ whose @-mark bodies are reflowed to the width with a
937
+ hanging indent
938
+ trailing strip trailing whitespace, never reflow
939
+ GEMINI.md
940
+ skip never touched
941
+ files inside a saved run record, which stay verbatim
942
+
943
+ A project chooses differently in its configuration. text.modes is a
944
+ list of [glob, mode] rows over project-relative paths, first match
945
+ wins, and anything unmatched falls back to the rules above:
946
+
947
+ [text]
948
+ modes = [["content/**/*.md", "sentence"], ["**/*.md", "column"]]
949
+
950
+ text.classifier instead selects a function that takes a path and
951
+ returns one of these four names, for rules a glob cannot say.
952
+ Files inside a saved run record are skipped under either.
953
+ """
954
+
955
+
956
+ def main(argv: list[str]) -> int:
957
+ # argparse (stdlib, so the module stays standalone for the hook) rather
958
+ # than the old hand-rolled loop: that loop silently swallowed any unknown
959
+ # --flag, so a typo like `--chek` REWROTE the files it was asked to check.
960
+ # For a tool whose safety hinges on one flag, unknown flags must be fatal.
961
+ ap = argparse.ArgumentParser(
962
+ prog="quirework text wrap",
963
+ description="Wrap Markdown to the repo's line conventions and strip "
964
+ "trailing whitespace.",
965
+ epilog=MODES,
966
+ formatter_class=argparse.RawDescriptionHelpFormatter,
967
+ )
968
+ ap.add_argument(
969
+ "files", nargs="+", metavar="FILE", help="Markdown file(s) to wrap in place"
970
+ )
971
+ ap.add_argument(
972
+ "--check",
973
+ action="store_true",
974
+ help="exit non-zero if a file is unwrapped or has trailing "
975
+ "whitespace; write nothing (what the pre-commit hook runs)",
976
+ )
977
+ ap.add_argument(
978
+ "--width",
979
+ type=int,
980
+ default=DEFAULT_WIDTH,
981
+ metavar="N",
982
+ help="override the 72-column target for column mode",
983
+ )
984
+ args = ap.parse_args(argv)
985
+
986
+ ok = True
987
+ missing = False
988
+ for path in args.files:
989
+ try:
990
+ if not process(path, args.check, args.width):
991
+ ok = False
992
+ except OSError as e:
993
+ print(f"wrap: cannot read {path}: {e.strerror or e}", file=sys.stderr)
994
+ missing = True
995
+ if missing:
996
+ return 2
997
+ return 0 if (ok or not args.check) else 1
998
+
999
+
1000
+ if __name__ == "__main__":
1001
+ raise SystemExit(main(sys.argv[1:]))