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.
- quirework/__init__.py +3 -0
- quirework/__main__.py +3 -0
- quirework/cli.py +431 -0
- quirework/core/__init__.py +1 -0
- quirework/core/artifact_paths.py +86 -0
- quirework/core/checks.py +235 -0
- quirework/core/frontmatter.py +83 -0
- quirework/core/globs.py +45 -0
- quirework/core/pinned.py +169 -0
- quirework/core/usage.py +604 -0
- quirework/delivery/__init__.py +1 -0
- quirework/delivery/artifacts.py +210 -0
- quirework/delivery/text.py +166 -0
- quirework/manuscript/__init__.py +1 -0
- quirework/manuscript/backfill.py +1972 -0
- quirework/manuscript/editions.py +111 -0
- quirework/manuscript/footprint.py +95 -0
- quirework/manuscript/formats.py +208 -0
- quirework/manuscript/lifecycle.py +191 -0
- quirework/manuscript/notes.py +2301 -0
- quirework/manuscript/pages.py +284 -0
- quirework/manuscript/quarto.py +416 -0
- quirework/manuscript/quotes.py +512 -0
- quirework/manuscript/rename.py +309 -0
- quirework/manuscript/sources.py +286 -0
- quirework/manuscript/trail.py +1875 -0
- quirework/manuscript/units.py +416 -0
- quirework/project.py +645 -0
- quirework/provenance/__init__.py +1 -0
- quirework/provenance/agyfilter.py +436 -0
- quirework/provenance/backup/__init__.py +469 -0
- quirework/provenance/backup/base.py +290 -0
- quirework/provenance/backup/command.py +107 -0
- quirework/provenance/backup/directory.py +142 -0
- quirework/provenance/backup/git.py +150 -0
- quirework/provenance/backup/rclone.py +153 -0
- quirework/provenance/capture/__init__.py +1 -0
- quirework/provenance/capture/commands.py +28 -0
- quirework/provenance/capture/extract_artifacts.py +110 -0
- quirework/provenance/capture/extract_files.py +118 -0
- quirework/provenance/capture/fetch_conversation.py +127 -0
- quirework/provenance/capture/fetch_gemini.py +149 -0
- quirework/provenance/capture/render_transcript.py +126 -0
- quirework/provenance/capture/transform.py +22 -0
- quirework/provenance/catalog.py +733 -0
- quirework/provenance/ccfilter.py +1126 -0
- quirework/provenance/codexfilter.py +883 -0
- quirework/provenance/coldread.py +1731 -0
- quirework/provenance/curate.py +67 -0
- quirework/provenance/humaninput.py +811 -0
- quirework/provenance/ingest.py +31 -0
- quirework/provenance/ingestion/__init__.py +1 -0
- quirework/provenance/ingestion/agy.py +192 -0
- quirework/provenance/ingestion/claude.py +493 -0
- quirework/provenance/ingestion/codex.py +296 -0
- quirework/provenance/lineage.py +148 -0
- quirework/provenance/metrics.py +1247 -0
- quirework/provenance/rulings.py +478 -0
- quirework/provenance/scope.py +73 -0
- quirework/provenance/search.py +455 -0
- quirework/provenance/semantic.py +475 -0
- quirework/provenance/show.py +1213 -0
- quirework/provenance/store.py +1271 -0
- quirework/provenance/trees.py +133 -0
- quirework/provenance/worktrees.py +199 -0
- quirework/providers/__init__.py +1 -0
- quirework/providers/blind.py +2192 -0
- quirework/providers/canary.py +685 -0
- quirework/providers/catalog.py +44 -0
- quirework/providers/claude_login.py +92 -0
- quirework/providers/exits.py +196 -0
- quirework/providers/harness.py +830 -0
- quirework/providers/images.py +92 -0
- quirework/providers/models.py +1284 -0
- quirework/providers/quota.py +588 -0
- quirework/providers/reads.py +474 -0
- quirework/providers/replies.py +175 -0
- quirework/publish/__init__.py +1 -0
- quirework/publish/backends.py +112 -0
- quirework/publish/bookstage.py +1086 -0
- quirework/publish/build.py +60 -0
- quirework/publish/editions.py +48 -0
- quirework/publish/epubcheck.py +287 -0
- quirework/publish/experiment.py +1097 -0
- quirework/publish/figfonts.py +234 -0
- quirework/publish/fignumbers.py +293 -0
- quirework/publish/figures.py +729 -0
- quirework/publish/folios.py +62 -0
- quirework/publish/fonts.py +307 -0
- quirework/publish/graybuild.py +73 -0
- quirework/publish/hugo.py +117 -0
- quirework/publish/kindle.js +411 -0
- quirework/publish/kindle.py +1393 -0
- quirework/publish/panelkit.py +918 -0
- quirework/publish/plans.py +651 -0
- quirework/publish/proof.py +792 -0
- quirework/publish/redpen.py +1654 -0
- quirework/publish/render.py +942 -0
- quirework/publish/targets.py +216 -0
- quirework/publish/variant.py +918 -0
- quirework/runs/__init__.py +1 -0
- quirework/runs/__main__.py +15 -0
- quirework/runs/commands.py +278 -0
- quirework/runs/coverage.py +86 -0
- quirework/runs/engine.py +2143 -0
- quirework/runs/feed.py +853 -0
- quirework/runs/observe.py +379 -0
- quirework/runs/storage.py +155 -0
- quirework/settings.py +492 -0
- quirework/sops/__init__.py +1 -0
- quirework/sops/catalog.py +386 -0
- quirework/sops/commands.py +567 -0
- quirework/sops/composition.py +382 -0
- quirework/sops/instruments.py +184 -0
- quirework/sops/personas.py +296 -0
- quirework/sops/review.py +2568 -0
- quirework/sops/room.py +2791 -0
- quirework/sops/roundtable.py +2578 -0
- quirework/sops/sections.py +169 -0
- quirework/sops/workshop.py +1629 -0
- quirework/templates/backup.toml +39 -0
- quirework/templates/book.toml +88 -0
- quirework/templates/run.toml +32 -0
- quirework/templates/site.toml +90 -0
- quirework/templates/tells.toml +64 -0
- quirework/templates/terminology.toml +20 -0
- quirework/templates/tmpfiles.conf +42 -0
- quirework/testing.py +345 -0
- quirework/text/__init__.py +1 -0
- quirework/text/jargon.py +545 -0
- quirework/text/mdwrap.py +1001 -0
- quirework/text/tells.py +547 -0
- quirework-0.1.0.dist-info/METADATA +444 -0
- quirework-0.1.0.dist-info/RECORD +138 -0
- quirework-0.1.0.dist-info/WHEEL +5 -0
- quirework-0.1.0.dist-info/entry_points.txt +2 -0
- quirework-0.1.0.dist-info/licenses/LICENSE +21 -0
- quirework-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
"""Do the phrases a mark quotes still exist in the book?
|
|
2
|
+
|
|
3
|
+
Some metadata bodies assert things about text living elsewhere, often through
|
|
4
|
+
verbatim quotations: the line a chapter prints, the caption a figure carries,
|
|
5
|
+
the wording a guard pins. Nothing checked them, so they rotted silently.
|
|
6
|
+
|
|
7
|
+
The check exists because the failure is exactly the kind a reading pass is
|
|
8
|
+
worst at and a string comparison is perfect at. What it cannot do is judge,
|
|
9
|
+
so it never gates: an absent phrase is a defect or a working guard
|
|
10
|
+
depending on what the mark is doing with it, and only a reader can say
|
|
11
|
+
which.
|
|
12
|
+
|
|
13
|
+
**Why it is narrow on purpose.** Checking every quoted span flags 185 of
|
|
14
|
+
410, which is not a report anybody reads. The misses sort into four kinds
|
|
15
|
+
and three are correct:
|
|
16
|
+
|
|
17
|
+
1. wording quoted precisely to FORBID it, which is most of them. A
|
|
18
|
+
guard mark pins the variant that must not return, by convention with
|
|
19
|
+
the offending words in capitals, and those phrases are supposed to be
|
|
20
|
+
absent.
|
|
21
|
+
2. quotations of things outside the book -- a person quoted, a paper,
|
|
22
|
+
the author's own conversation -- which live in @source and @ref.
|
|
23
|
+
3. spans carrying an elision, where the quotation is deliberately partial.
|
|
24
|
+
4. real drift.
|
|
25
|
+
|
|
26
|
+
So only spans introduced by an explicit verbatim cue are considered, from
|
|
27
|
+
kinds that assert presence, with the forbidden-variant conventions filtered
|
|
28
|
+
out. On a book-length manuscript that cut reported six candidates, of
|
|
29
|
+
which two were real: quotations off by a single word, which is the shape a
|
|
30
|
+
reading pass misses every time.
|
|
31
|
+
|
|
32
|
+
**Where it also asks WHICH chapter.** Existing-in-the-book is the weaker
|
|
33
|
+
half of the question. A mark says `@unit(a-chapter)` prints a line; the line
|
|
34
|
+
exists, so the check passed, while the chapter naming it was wrong -- which
|
|
35
|
+
is precisely what a chapter SPLIT produces, because the prose moves and the
|
|
36
|
+
quotation does not. Two consecutive rounds had that as their dominant
|
|
37
|
+
defect, each time found by hand, each time after the split had run
|
|
38
|
+
unchecked for dozens of commits.
|
|
39
|
+
|
|
40
|
+
So a span whose quotation is attributed to a named chapter is checked
|
|
41
|
+
against *that chapter*, and a quotation that resolves in some other chapter
|
|
42
|
+
is reported as a misattribution, naming where the words actually live. The
|
|
43
|
+
attribution is only trusted when it is unambiguous: an `@unit(slug)` within
|
|
44
|
+
ATTR_WINDOW characters of the quotation, or a mark whose body names no
|
|
45
|
+
chapter at all and therefore speaks about its own. A body that names
|
|
46
|
+
chapters but none near the quotation falls back to the book-wide check,
|
|
47
|
+
because guessing the referent is how an advisory earns its way into being
|
|
48
|
+
ignored.
|
|
49
|
+
|
|
50
|
+
**When the narrowing is the wrong trade.** After a term retirement or a
|
|
51
|
+
sentence rewrite, the stale quotations are exactly the ones the narrow scan
|
|
52
|
+
skips: short (a coinage with its article falls under the twelve-character
|
|
53
|
+
floor) or uncued (a note recording a rewrite writes `now "…"`, and "now" is
|
|
54
|
+
no cue). `--all` adds every other quoted span in every mark, reported in a
|
|
55
|
+
group of its own so the high-precision set stays readable, and `--grep`
|
|
56
|
+
narrows that group to the spans a retired term appears in. Most of what it
|
|
57
|
+
lists is correct -- guards, outside sources, elisions -- and the author
|
|
58
|
+
reads it once, after the edit it was run for.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
from __future__ import annotations
|
|
62
|
+
|
|
63
|
+
from quirework.project import current_project
|
|
64
|
+
import re
|
|
65
|
+
import sys
|
|
66
|
+
from dataclasses import dataclass, replace
|
|
67
|
+
from pathlib import Path
|
|
68
|
+
|
|
69
|
+
from quirework.manuscript import notes, units
|
|
70
|
+
|
|
71
|
+
# Kinds whose bodies assert that something IS in the book. @source and @ref
|
|
72
|
+
# quote the world outside it; @todo is working state and may quote anything,
|
|
73
|
+
# including wording being proposed and not yet written.
|
|
74
|
+
ASSERTING_KINDS = ("figure", "guidance", "variant", "see")
|
|
75
|
+
|
|
76
|
+
# The quotation has to be introduced as a quotation. Without a cue the span is
|
|
77
|
+
# as likely to be a scare quote, a coined term, or a variant being named.
|
|
78
|
+
# `caption:` is the LABEL form and not the bare noun, which is the whole of why
|
|
79
|
+
# it is safe. A @figure mark transcribes the plate's caption as `Caption: "…"`,
|
|
80
|
+
# and that is a citation; the bare noun appears in sentences ABOUT a caption,
|
|
81
|
+
# including the ones recording wording a pass took back out of it, where the
|
|
82
|
+
# absence is correct. Requiring the colon caught the live defect three separate
|
|
83
|
+
# review passes had to find by hand, and reported nothing else across the book.
|
|
84
|
+
_CUE = re.compile(
|
|
85
|
+
r"(?:\b(verbatim|reads?|prints?|printed|says?|in-?prose|quotes?|stated|"
|
|
86
|
+
r"carries|the line|the sentence|the phrase|the wording|word for word)\b"
|
|
87
|
+
r"|\b(caption)\s*:)"
|
|
88
|
+
r"\W{0,4}$"
|
|
89
|
+
# A sentence named by where it stands. "the sentence after it, Q" and
|
|
90
|
+
# "the paragraph's last line (Q)" cite Q as surely as "the sentence Q"
|
|
91
|
+
# does, but the qualifier between the noun and the quotation left the cue
|
|
92
|
+
# short of the quotation it introduces. Only a position may stand there:
|
|
93
|
+
# a free run of words would read "the closing sentence is now Q", the
|
|
94
|
+
# record of a rewrite that `--all` exists for, as a citation.
|
|
95
|
+
r"|\b(?:the|its|last|closing|opening|final|first|next)\s+"
|
|
96
|
+
r"(?:line|sentence|phrase)"
|
|
97
|
+
r"(?:\s+(?:after|before|above|below)\s+(?:it|this|that|them))?\W{0,4}$",
|
|
98
|
+
re.I,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# ... and the negation has to attach to the CUE, not merely share the window
|
|
102
|
+
# with it. Scanning the whole window loses real drift: "the earlier chapter
|
|
103
|
+
# refuses the dismissal, so this chapter may say the sentence X" is a positive
|
|
104
|
+
# citation whose window happens to contain "refuses", and X is off by a word.
|
|
105
|
+
_NEGATED = re.compile(
|
|
106
|
+
r"\b(never|not|nobody|none|banned|forbid\w*|retired|refus\w*|avoid\w*|"
|
|
107
|
+
r"may not|does not|do not|stale|wrong|former|replaced|"
|
|
108
|
+
r"instead of|rather than|used to)\b\W*\w*\W*$",
|
|
109
|
+
re.I,
|
|
110
|
+
)
|
|
111
|
+
NEG_WINDOW = 34
|
|
112
|
+
|
|
113
|
+
# Pairs every straight quotation mark, however short the span between, and
|
|
114
|
+
# leaves the length floor to `MIN_CHARS`. Putting the floor in the pattern
|
|
115
|
+
# lost the pairing instead: a short span such as "it" failed to match, the
|
|
116
|
+
# scan moved one character on, and from there each closing mark was read as
|
|
117
|
+
# an opening one. Every later quotation in that mark went unchecked while the
|
|
118
|
+
# prose between quotations was scanned in its place.
|
|
119
|
+
_QUOTED = re.compile(r'"([^"\n]{1,400})"')
|
|
120
|
+
MIN_CHARS = 12
|
|
121
|
+
# `--all`: any quoted span at all, straight or curly, however short.
|
|
122
|
+
_ANY_QUOTED = re.compile(r'"([^"\n]{1,400})"|“([^”\n]{1,400})”')
|
|
123
|
+
_ELISION = re.compile(r"\s*(?:\.\.\.|…)\s*")
|
|
124
|
+
|
|
125
|
+
CUE_WINDOW = 70
|
|
126
|
+
MIN_WORDS = 4
|
|
127
|
+
# A span mostly in capitals is the house convention for a forbidden variant.
|
|
128
|
+
CAPS_RATIO = 0.30
|
|
129
|
+
|
|
130
|
+
# How far back from the cue verb to look for the `@unit(slug)` that is its
|
|
131
|
+
# subject. This is a search window, NOT the adjacency test -- `_SUBJECT_BREAK`
|
|
132
|
+
# is that -- so it has to be wide enough to contain the whole token, and a
|
|
133
|
+
# descriptive slug readily makes an `@unit(...)` citation thirty characters or
|
|
134
|
+
# more. Sizing this as if it were the gap silently blinded the check to
|
|
135
|
+
# every long-slug citation, which is most of the ones worth catching.
|
|
136
|
+
ATTR_GAP = 96
|
|
137
|
+
|
|
138
|
+
# What may stand between the cited chapter and the cue verb without displacing
|
|
139
|
+
# it as the subject. A possessive noun phrase does NOT displace it -- the
|
|
140
|
+
# caption in "@unit(x)'s own caption reads Q" is x's, so Q is being attributed to
|
|
141
|
+
# x exactly as in the bare "@unit(x) reads Q". What displaces it is a new subject,
|
|
142
|
+
# and the tells for that are a comma, a preposition, or a relativizer:
|
|
143
|
+
# "@unit(x)'s own positions, on the page that says Q" cites the page, not x.
|
|
144
|
+
_SUBJECT_HOLDS = re.compile(r"(?:'s)?(?:\s+\w+){0,3}\s*\Z")
|
|
145
|
+
_SUBJECT_LOST = re.compile(
|
|
146
|
+
r"[,;:]|\b(on|in|at|from|for|to|by|with|of|that|which|who|where|when|"
|
|
147
|
+
r"and|but|so|because|though|while)\b",
|
|
148
|
+
re.I,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _subject_survives(gap: str) -> bool:
|
|
153
|
+
return bool(_SUBJECT_HOLDS.fullmatch(gap)) and not _SUBJECT_LOST.search(gap)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass
|
|
157
|
+
class Hit:
|
|
158
|
+
source: str # the sidecar the mark lives in
|
|
159
|
+
line: int
|
|
160
|
+
kind: str
|
|
161
|
+
slug: str
|
|
162
|
+
quote: str
|
|
163
|
+
# Chapter-attribution verdict. `target` is the chapter the mark attributes
|
|
164
|
+
# the quotation to (None when the check was book-wide); `found_in` names
|
|
165
|
+
# the chapters the words actually appear in, and is non-empty exactly when
|
|
166
|
+
# this is a misattribution rather than an absence.
|
|
167
|
+
target: str | None = None
|
|
168
|
+
found_in: tuple[str, ...] = ()
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def misattributed(self) -> bool:
|
|
172
|
+
return bool(self.found_in)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _prose_corpus(docs: Path) -> str:
|
|
176
|
+
"""What a mark may be quoting, MINUS the marks themselves.
|
|
177
|
+
|
|
178
|
+
notes._canon_corpus folds the sidecars in, which is right for the
|
|
179
|
+
companion check (an entry may quote a mark) and fatal here: every span
|
|
180
|
+
would match the very mark it was lifted from.
|
|
181
|
+
|
|
182
|
+
The illustrations are in, because a @figure mark quotes the plate as
|
|
183
|
+
readily as the caption ("and not all of it" is a label on ch2's dial and
|
|
184
|
+
appears in no chapter), and their text is what the reader sees.
|
|
185
|
+
"""
|
|
186
|
+
parts = [f.read_text() for f in units.prose_files(docs)]
|
|
187
|
+
parts += [f.read_text() for f in _register(docs)]
|
|
188
|
+
for svg in sorted(docs.rglob("illustrations/*.svg")):
|
|
189
|
+
# element text only; the geometry would match anything by accident
|
|
190
|
+
parts.extend(re.findall(r">([^<>]+)<", svg.read_text()))
|
|
191
|
+
return notes._searchable("\n".join(parts))
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _register(docs: Path) -> list[Path]:
|
|
195
|
+
"""The spec files and the book-wide notes: text that belongs to no unit."""
|
|
196
|
+
try:
|
|
197
|
+
files = notes.spec_files(docs)
|
|
198
|
+
except ValueError:
|
|
199
|
+
files = [] # a project that keeps no spec has none to quote
|
|
200
|
+
notes_file = notes._configured_book_file(docs, "manuscript.review_notes")
|
|
201
|
+
if notes_file is not None and notes_file.exists():
|
|
202
|
+
files.append(notes_file)
|
|
203
|
+
return files
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _chapter_corpora(docs: Path) -> tuple[dict[str, str], str]:
|
|
207
|
+
"""(slug -> that chapter's searchable prose, everything a quotation may
|
|
208
|
+
carry that belongs to NO chapter).
|
|
209
|
+
|
|
210
|
+
The second half is what keeps the attribution check honest. A `@figure`
|
|
211
|
+
mark quotes the plate as readily as the caption, and an SVG's label lives
|
|
212
|
+
in `illustrations/` under a filename that names no chapter; the spec and
|
|
213
|
+
the book-wide manifest are likewise chapter-less. Words found there are
|
|
214
|
+
correct wherever they are cited from, so they are matched before any
|
|
215
|
+
misattribution is declared.
|
|
216
|
+
"""
|
|
217
|
+
per: dict[str, str] = {}
|
|
218
|
+
for f in units.prose_files(docs):
|
|
219
|
+
per[units.slug_of(f)] = notes._searchable(f.read_text())
|
|
220
|
+
loose = [f.read_text() for f in _register(docs)]
|
|
221
|
+
for svg in sorted(docs.rglob("illustrations/*.svg")):
|
|
222
|
+
loose.extend(re.findall(r">([^<>]+)<", svg.read_text()))
|
|
223
|
+
return per, notes._searchable("\n".join(loose))
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _known_slugs(docs: Path, per_chapter: dict[str, str]) -> set[str]:
|
|
227
|
+
"""The slugs an `@unit()` may name, narrowed to the book where we can tell.
|
|
228
|
+
|
|
229
|
+
`_quarto.yml` is the sole source of what is in the book, so it is the right
|
|
230
|
+
filter -- but it is absent from any tree that is not the live one, and the
|
|
231
|
+
attribution check must not be the reason a synthetic source tree raises. Without
|
|
232
|
+
it every `.qmd` stem is treated as nameable, which is looser and harmless:
|
|
233
|
+
the check compares where words ARE against where a mark SAYS they are, and
|
|
234
|
+
a stem that is not really a chapter fails both halves alike.
|
|
235
|
+
"""
|
|
236
|
+
try:
|
|
237
|
+
return units.slugs(docs) & set(per_chapter)
|
|
238
|
+
except (FileNotFoundError, OSError):
|
|
239
|
+
return set(per_chapter)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _attributed_chapter(
|
|
243
|
+
body: str, cue_start: int, own: str | None, known: set[str]
|
|
244
|
+
) -> str | None:
|
|
245
|
+
"""Which chapter this quotation is being attributed to, or None if there
|
|
246
|
+
is no chapter to check it against.
|
|
247
|
+
|
|
248
|
+
The attribution is grammatical, not positional: `_CUE` has already found
|
|
249
|
+
the verb introducing the quotation, so the chapter being cited is the one
|
|
250
|
+
that is the SUBJECT of that verb -- an `@unit(slug)` sitting immediately
|
|
251
|
+
before it, separated by nothing but a possessive or whitespace.
|
|
252
|
+
|
|
253
|
+
Nearness alone is not a usable proxy, and the four candidates the first
|
|
254
|
+
cut produced were all it getting this wrong. Two shapes defeat it. The
|
|
255
|
+
citation may read "@unit(x) says Q" with an unrelated "@unit(y)" a few words
|
|
256
|
+
the other side of Q, so the nearest chapter is the one NOT doing the
|
|
257
|
+
saying. And the subject may be something else entirely -- "@unit(x)'s own
|
|
258
|
+
positions, on the page that says Q" cites *this* mark's page, not x, and
|
|
259
|
+
the intervening "the page" is the whole signal.
|
|
260
|
+
|
|
261
|
+
So: the adjacent chapter if there is one, otherwise the chapter the mark
|
|
262
|
+
annotates, which is what a mark is talking about when it names nobody.
|
|
263
|
+
"""
|
|
264
|
+
gap_start = max(0, cue_start - ATTR_GAP)
|
|
265
|
+
for m in reversed(list(notes._UNIT_REF.finditer(body, gap_start, cue_start))):
|
|
266
|
+
if not _subject_survives(body[m.end() : cue_start]):
|
|
267
|
+
continue
|
|
268
|
+
slug = m.group(1).strip()
|
|
269
|
+
return slug if slug in known else None
|
|
270
|
+
# Naming no chapter, a mark speaks about the one it annotates.
|
|
271
|
+
return own if own in known else None
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _longest_fragment(span: str) -> str:
|
|
275
|
+
"""An elided quotation cannot match whole; measure its longest piece."""
|
|
276
|
+
return max(_ELISION.split(span), key=len)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _is_forbidden_variant(span: str) -> bool:
|
|
280
|
+
letters = [c for c in span if c.isalpha()]
|
|
281
|
+
if not letters:
|
|
282
|
+
return True
|
|
283
|
+
return sum(c.isupper() for c in letters) / len(letters) > CAPS_RATIO
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def scan(docs: Path | None = None) -> list[Hit]:
|
|
287
|
+
docs = (
|
|
288
|
+
Path(docs)
|
|
289
|
+
if docs is not None
|
|
290
|
+
else notes.current_project().path("manuscript.source")
|
|
291
|
+
)
|
|
292
|
+
corpus = _prose_corpus(docs)
|
|
293
|
+
per_chapter, loose = _chapter_corpora(docs)
|
|
294
|
+
known = _known_slugs(docs, per_chapter)
|
|
295
|
+
hits: list[Hit] = []
|
|
296
|
+
for anno in notes.scan():
|
|
297
|
+
if anno.kind not in ASSERTING_KINDS:
|
|
298
|
+
continue
|
|
299
|
+
own = Path(anno.file).stem
|
|
300
|
+
body = notes._norm(anno.body)
|
|
301
|
+
for m in _QUOTED.finditer(body):
|
|
302
|
+
span = m.group(1)
|
|
303
|
+
if len(span) < MIN_CHARS:
|
|
304
|
+
continue
|
|
305
|
+
base = max(0, m.start() - CUE_WINDOW)
|
|
306
|
+
before = body[base : m.start()]
|
|
307
|
+
cue = _CUE.search(before)
|
|
308
|
+
if not cue:
|
|
309
|
+
continue
|
|
310
|
+
# Bare past-tense "read" is how this corpus cites wording it has
|
|
311
|
+
# RETIRED ("It read X and was fixed"; "Its sub-label read X, which
|
|
312
|
+
# sits crosswise to ..."), and that wording is supposed to be gone.
|
|
313
|
+
# A live citation uses the present tense, "reads". Every standing
|
|
314
|
+
# false positive the first cut produced was this construction.
|
|
315
|
+
# group(1) is None on the `Caption:` branch, which has no tense to
|
|
316
|
+
# read and is never the retired-wording form.
|
|
317
|
+
if (cue.group(1) or "").lower() == "read":
|
|
318
|
+
continue
|
|
319
|
+
# the negation must sit just before the cue verb ("never reads X"),
|
|
320
|
+
# not anywhere in the run-up to it
|
|
321
|
+
if _NEGATED.search(before[max(0, cue.start() - NEG_WINDOW) : cue.end()]):
|
|
322
|
+
continue
|
|
323
|
+
if _is_forbidden_variant(span):
|
|
324
|
+
continue
|
|
325
|
+
fragment = _longest_fragment(span)
|
|
326
|
+
if len(fragment.split()) < MIN_WORDS:
|
|
327
|
+
continue
|
|
328
|
+
needle = notes._searchable(fragment)
|
|
329
|
+
present = needle in corpus
|
|
330
|
+
target = _attributed_chapter(body, base + cue.start(), own, known)
|
|
331
|
+
mark = Hit(
|
|
332
|
+
anno.source or anno.file,
|
|
333
|
+
anno.line,
|
|
334
|
+
anno.kind,
|
|
335
|
+
anno.args[0] if anno.args else "",
|
|
336
|
+
span,
|
|
337
|
+
)
|
|
338
|
+
if present:
|
|
339
|
+
# The words exist. The remaining question is whether they exist
|
|
340
|
+
# where the mark says they do -- unanswerable without a target,
|
|
341
|
+
# and not asked of a quotation that belongs to no chapter.
|
|
342
|
+
if target is None or needle in loose:
|
|
343
|
+
continue
|
|
344
|
+
if needle in per_chapter.get(target, ""):
|
|
345
|
+
continue
|
|
346
|
+
elsewhere = tuple(
|
|
347
|
+
s for s in sorted(per_chapter) if needle in per_chapter[s]
|
|
348
|
+
)
|
|
349
|
+
if not elsewhere:
|
|
350
|
+
continue # lives in some other non-chapter corner; not ours to judge
|
|
351
|
+
hits.append(replace(mark, target=target, found_in=elsewhere))
|
|
352
|
+
else:
|
|
353
|
+
hits.append(replace(mark, target=target))
|
|
354
|
+
return hits
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _present(needle: str, corpus: str) -> bool:
|
|
358
|
+
"""Whether the words stand in the prose as words, not inside longer ones.
|
|
359
|
+
|
|
360
|
+
A retired "fuse" is not still present because the book says "fuses".
|
|
361
|
+
"""
|
|
362
|
+
return re.search(rf"(?<!\w){re.escape(needle)}(?!\w)", corpus) is not None
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def scan_all(docs: Path | None = None, grep: str | None = None, skip=()) -> list[Hit]:
|
|
366
|
+
"""Every quoted span in every mark that the prose no longer contains.
|
|
367
|
+
|
|
368
|
+
No cue, no length floor, no filter for guards or outside sources: this is
|
|
369
|
+
the coverage the default scan gives up for precision. `grep` is a
|
|
370
|
+
case-insensitive regular expression a span must match, which is how a
|
|
371
|
+
term retirement checks every quotation that carried the term. Spans the
|
|
372
|
+
default scan already reported (`skip`) are left to that report.
|
|
373
|
+
"""
|
|
374
|
+
docs = (
|
|
375
|
+
Path(docs)
|
|
376
|
+
if docs is not None
|
|
377
|
+
else notes.current_project().path("manuscript.source")
|
|
378
|
+
)
|
|
379
|
+
corpus = _prose_corpus(docs)
|
|
380
|
+
pattern = re.compile(grep, re.I) if grep else None
|
|
381
|
+
reported = {(h.source, h.line, h.quote) for h in skip}
|
|
382
|
+
hits: list[Hit] = []
|
|
383
|
+
for anno in notes.scan():
|
|
384
|
+
body = notes._norm(anno.body)
|
|
385
|
+
source = anno.source or anno.file
|
|
386
|
+
for m in _ANY_QUOTED.finditer(body):
|
|
387
|
+
span = m.group(1) or m.group(2)
|
|
388
|
+
if pattern and not pattern.search(span):
|
|
389
|
+
continue
|
|
390
|
+
if (source, anno.line, span) in reported:
|
|
391
|
+
continue
|
|
392
|
+
needle = notes._searchable(_longest_fragment(span))
|
|
393
|
+
if not needle or _present(needle, corpus):
|
|
394
|
+
continue
|
|
395
|
+
slug = anno.args[0] if anno.args else ""
|
|
396
|
+
hits.append(Hit(source, anno.line, anno.kind, slug, span))
|
|
397
|
+
return hits
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _group(hits: list[Hit], stream, header: str, tail: str) -> None:
|
|
401
|
+
print(
|
|
402
|
+
f"\nadvisory ({header} — warning, not gated):",
|
|
403
|
+
file=stream,
|
|
404
|
+
)
|
|
405
|
+
by_file: dict[str, list[Hit]] = {}
|
|
406
|
+
for h in hits:
|
|
407
|
+
by_file.setdefault(h.source, []).append(h)
|
|
408
|
+
for src, items in by_file.items():
|
|
409
|
+
print(f"\n {src}", file=stream)
|
|
410
|
+
for h in items:
|
|
411
|
+
tag = f"@{h.kind}" + (f"({h.slug})" if h.slug else "")
|
|
412
|
+
print(f" L{h.line:<4} {tag}", file=stream)
|
|
413
|
+
print(
|
|
414
|
+
f' "{h.quote[:110]}{"…" if len(h.quote) > 110 else ""}"',
|
|
415
|
+
file=stream,
|
|
416
|
+
)
|
|
417
|
+
if h.misattributed:
|
|
418
|
+
print(
|
|
419
|
+
f" cited as @unit({h.target}) — but the words are in "
|
|
420
|
+
f"{', '.join('@unit(%s)' % s for s in h.found_in)}",
|
|
421
|
+
file=stream,
|
|
422
|
+
)
|
|
423
|
+
print(tail, file=stream)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def report(hits: list[Hit], stream=None) -> None:
|
|
427
|
+
stream = stream or sys.stderr
|
|
428
|
+
if not hits:
|
|
429
|
+
print(
|
|
430
|
+
f"✓ quotes: every cited phrase resolves, in the {units.noun()} "
|
|
431
|
+
"that cites it.",
|
|
432
|
+
file=stream,
|
|
433
|
+
)
|
|
434
|
+
return
|
|
435
|
+
wrong = [h for h in hits if h.misattributed]
|
|
436
|
+
absent = [h for h in hits if not h.misattributed]
|
|
437
|
+
if wrong:
|
|
438
|
+
_group(
|
|
439
|
+
wrong,
|
|
440
|
+
stream,
|
|
441
|
+
f"{len(wrong)} quotation(s) attributed to the wrong {units.noun()}",
|
|
442
|
+
"\n The words are in the work, but not where the mark says. This is\n"
|
|
443
|
+
" what a split leaves behind: the prose moved and the citation\n"
|
|
444
|
+
" did not. Repoint the mark, or fix the unit it names.",
|
|
445
|
+
)
|
|
446
|
+
if absent:
|
|
447
|
+
_group(
|
|
448
|
+
absent,
|
|
449
|
+
stream,
|
|
450
|
+
f"{len(absent)} cited phrase(s) not found in the work",
|
|
451
|
+
"\n Each is a quotation the prose no longer matches, a phrase the\n"
|
|
452
|
+
" mark is pinning as forbidden, or a quotation of something outside\n"
|
|
453
|
+
" the work. Only the first is a defect. The author rules.",
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def report_all(hits: list[Hit], grep: str | None, stream=None) -> None:
|
|
458
|
+
stream = stream or sys.stderr
|
|
459
|
+
scope = f" matching {grep!r}" if grep else ""
|
|
460
|
+
if not hits:
|
|
461
|
+
print(
|
|
462
|
+
f"✓ quotes --all: every other quoted phrase{scope} is in the work.",
|
|
463
|
+
file=stream,
|
|
464
|
+
)
|
|
465
|
+
return
|
|
466
|
+
_group(
|
|
467
|
+
hits,
|
|
468
|
+
stream,
|
|
469
|
+
f"{len(hits)} other quoted phrase(s){scope} not found in the work",
|
|
470
|
+
"\n Every quoted span, with no filter for guards, outside sources or\n"
|
|
471
|
+
" short coinages, so most of these are correct. The ones to fix are\n"
|
|
472
|
+
" quotations of wording the prose has since changed.",
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def main(argv: list[str] | None = None) -> int:
|
|
477
|
+
import argparse
|
|
478
|
+
|
|
479
|
+
p = argparse.ArgumentParser(
|
|
480
|
+
prog="quirework spec quotes",
|
|
481
|
+
description="Check that phrases the marks quote still exist in the work.",
|
|
482
|
+
)
|
|
483
|
+
p.add_argument(
|
|
484
|
+
"--source",
|
|
485
|
+
type=Path,
|
|
486
|
+
default=None,
|
|
487
|
+
help="manuscript source root (default: configured)",
|
|
488
|
+
)
|
|
489
|
+
p.add_argument(
|
|
490
|
+
"--all",
|
|
491
|
+
action="store_true",
|
|
492
|
+
help="also check every other quoted span, however short or uncued, "
|
|
493
|
+
"in a separate group",
|
|
494
|
+
)
|
|
495
|
+
p.add_argument(
|
|
496
|
+
"--grep",
|
|
497
|
+
metavar="PATTERN",
|
|
498
|
+
default=None,
|
|
499
|
+
help="with --all (implied), only spans matching this case-insensitive "
|
|
500
|
+
"regular expression, such as a retired term",
|
|
501
|
+
)
|
|
502
|
+
args = p.parse_args(argv)
|
|
503
|
+
if args.grep is not None:
|
|
504
|
+
try:
|
|
505
|
+
re.compile(args.grep)
|
|
506
|
+
except re.error as exc:
|
|
507
|
+
p.error(f"--grep: {exc}")
|
|
508
|
+
hits = scan(args.source)
|
|
509
|
+
report(hits)
|
|
510
|
+
if args.all or args.grep is not None:
|
|
511
|
+
report_all(scan_all(args.source, args.grep, hits), args.grep)
|
|
512
|
+
return 0
|