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,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())
|