bookwright-cli 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. bookwright/__init__.py +3 -0
  2. bookwright/__main__.py +6 -0
  3. bookwright/cli.py +19 -0
  4. bookwright/commands/__init__.py +0 -0
  5. bookwright/commands/_envelope.py +36 -0
  6. bookwright/commands/check.py +75 -0
  7. bookwright/commands/graph/__init__.py +23 -0
  8. bookwright/commands/graph/build.py +157 -0
  9. bookwright/commands/graph/envelope.py +26 -0
  10. bookwright/commands/graph/query.py +98 -0
  11. bookwright/commands/init/__init__.py +5 -0
  12. bookwright/commands/init/conflict.py +107 -0
  13. bookwright/commands/init/envelope.py +322 -0
  14. bookwright/commands/init/git.py +96 -0
  15. bookwright/commands/init/main.py +263 -0
  16. bookwright/commands/init/resolve.py +193 -0
  17. bookwright/commands/init/scaffold.py +242 -0
  18. bookwright/commands/init/validate.py +172 -0
  19. bookwright/commands/integration/__init__.py +22 -0
  20. bookwright/commands/integration/use.py +120 -0
  21. bookwright/commands/validate.py +160 -0
  22. bookwright/commands/version.py +35 -0
  23. bookwright/core/__init__.py +35 -0
  24. bookwright/core/_blocks.py +239 -0
  25. bookwright/core/_build.py +154 -0
  26. bookwright/core/_research_block.py +56 -0
  27. bookwright/core/_translate.py +90 -0
  28. bookwright/core/errors.py +127 -0
  29. bookwright/core/iso639_1.py +200 -0
  30. bookwright/core/manifest.py +343 -0
  31. bookwright/errors.py +47 -0
  32. bookwright/golem/__init__.py +71 -0
  33. bookwright/golem/base.py +200 -0
  34. bookwright/golem/errors.py +29 -0
  35. bookwright/golem/modules/__init__.py +1 -0
  36. bookwright/golem/modules/character.py +109 -0
  37. bookwright/golem/modules/event.py +91 -0
  38. bookwright/golem/modules/feature.py +161 -0
  39. bookwright/golem/modules/inference.py +41 -0
  40. bookwright/golem/modules/narrative.py +55 -0
  41. bookwright/golem/modules/provenance.py +197 -0
  42. bookwright/golem/modules/relationship.py +38 -0
  43. bookwright/golem/modules/setting.py +30 -0
  44. bookwright/golem/namespaces.py +332 -0
  45. bookwright/golem/serialize.py +25 -0
  46. bookwright/golem/slug.py +22 -0
  47. bookwright/indexers/__init__.py +47 -0
  48. bookwright/indexers/base.py +55 -0
  49. bookwright/indexers/errors.py +80 -0
  50. bookwright/indexers/rdflib_indexer.py +89 -0
  51. bookwright/integrations/__init__.py +155 -0
  52. bookwright/integrations/base.py +117 -0
  53. bookwright/integrations/claude/__init__.py +29 -0
  54. bookwright/integrations/constants.py +38 -0
  55. bookwright/integrations/descriptions.py +48 -0
  56. bookwright/integrations/errors.py +170 -0
  57. bookwright/integrations/generic/__init__.py +56 -0
  58. bookwright/integrations/lint.py +160 -0
  59. bookwright/integrations/materialize.py +202 -0
  60. bookwright/integrations/options.py +203 -0
  61. bookwright/io/__init__.py +1 -0
  62. bookwright/io/bible.py +500 -0
  63. bookwright/io/errors.py +98 -0
  64. bookwright/io/frontmatter.py +61 -0
  65. bookwright/io/fs.py +226 -0
  66. bookwright/io/manuscript.py +15 -0
  67. bookwright/io/project.py +21 -0
  68. bookwright/io/report.py +107 -0
  69. bookwright/io/research.py +427 -0
  70. bookwright/resources/__init__.py +1 -0
  71. bookwright/resources/commands/bookwright-analyze.md +66 -0
  72. bookwright/resources/commands/bookwright-bible.md +96 -0
  73. bookwright/resources/commands/bookwright-checklist.md +67 -0
  74. bookwright/resources/commands/bookwright-clarify.md +65 -0
  75. bookwright/resources/commands/bookwright-constitution.md +79 -0
  76. bookwright/resources/commands/bookwright-continuity.md +70 -0
  77. bookwright/resources/commands/bookwright-draft.md +74 -0
  78. bookwright/resources/commands/bookwright-outline.md +71 -0
  79. bookwright/resources/commands/bookwright-research.md +107 -0
  80. bookwright/resources/commands/bookwright-scenes.md +66 -0
  81. bookwright/resources/commands/bookwright-synopsis.md +67 -0
  82. bookwright/resources/commands/bookwright-verify.md +136 -0
  83. bookwright/resources/commands/references/golem-character.md +65 -0
  84. bookwright/resources/commands/references/golem-events-timeline.md +56 -0
  85. bookwright/resources/commands/references/golem-relationships.md +53 -0
  86. bookwright/resources/commands/references/greimas-actants.md +57 -0
  87. bookwright/resources/commands/references/pending-protocol.md +72 -0
  88. bookwright/resources/commands/references/propp-functions.md +54 -0
  89. bookwright/resources/commands/references/research-format.md +136 -0
  90. bookwright/resources/project/.bookwright/cache/.gitkeep +0 -0
  91. bookwright/resources/project/.bookwright/schema/.gitkeep +0 -0
  92. bookwright/resources/project/.bookwright/templates/.gitkeep +0 -0
  93. bookwright/resources/project/.gitignore +23 -0
  94. bookwright/resources/project/README.md.j2 +40 -0
  95. bookwright/resources/project/__init__.py +6 -0
  96. bookwright/resources/project/bible/characters/.gitkeep +0 -0
  97. bookwright/resources/project/bible/constitution.md.j2 +74 -0
  98. bookwright/resources/project/bible/glossary.md +36 -0
  99. bookwright/resources/project/bible/locations/.gitkeep +0 -0
  100. bookwright/resources/project/bible/pov-structure.md +43 -0
  101. bookwright/resources/project/bible/relationships.md +36 -0
  102. bookwright/resources/project/bible/research/_index.md +28 -0
  103. bookwright/resources/project/bible/research/sources.md +23 -0
  104. bookwright/resources/project/bible/settings/.gitkeep +0 -0
  105. bookwright/resources/project/bible/subplots.md +35 -0
  106. bookwright/resources/project/bible/themes.md +36 -0
  107. bookwright/resources/project/bible/timeline.md +38 -0
  108. bookwright/resources/project/manuscript/.gitkeep +0 -0
  109. bookwright/resources/project/outline/arcs.md +34 -0
  110. bookwright/resources/project/outline/scenes.md +31 -0
  111. bookwright/resources/project/outline/structure.md +35 -0
  112. bookwright/resources/project/outline/synopsis.md +25 -0
  113. bookwright/resources/schemas/__init__.py +19 -0
  114. bookwright/resources/schemas/golem-1.1/VERSION +1 -0
  115. bookwright/resources/schemas/golem-1.1/golem.ttl +1947 -0
  116. bookwright/resources/schemas/golem-1.1/version.json +8 -0
  117. bookwright/resources/templates/__init__.py +1 -0
  118. bookwright/resources/templates/bible/character.md.tmpl +63 -0
  119. bookwright/resources/templates/bible/location.md.tmpl +37 -0
  120. bookwright/resources/templates/bible/research/_index.md.tmpl +25 -0
  121. bookwright/resources/templates/bible/research/sources.md.tmpl +21 -0
  122. bookwright/resources/templates/bible/research/tema.md.tmpl +37 -0
  123. bookwright/resources/templates/bible/setting.md.tmpl +38 -0
  124. bookwright/resources/templates/manifest.template.toml +79 -0
  125. bookwright/resources/templates/manuscript/chapter.md.tmpl +36 -0
  126. bookwright/resources/templates/scenes/scene.md.tmpl +37 -0
  127. bookwright/resources/vocabularies/__init__.py +6 -0
  128. bookwright/resources/vocabularies/greimas.ttl +4 -0
  129. bookwright/resources/vocabularies/propp.ttl +4 -0
  130. bookwright/resources/vocabularies/sources.ttl +82 -0
  131. bookwright/validation/__init__.py +33 -0
  132. bookwright/validation/anchor_queries.py +223 -0
  133. bookwright/validation/base.py +233 -0
  134. bookwright/validation/queries.py +197 -0
  135. bookwright/validation/registry.py +185 -0
  136. bookwright/validation/report.py +106 -0
  137. bookwright/validation/runner.py +65 -0
  138. bookwright/validation/validators/__init__.py +9 -0
  139. bookwright/validation/validators/character_presence.py +202 -0
  140. bookwright/validation/validators/factual_anchor.py +291 -0
  141. bookwright/validation/validators/focalization.py +152 -0
  142. bookwright/validation/validators/setting_continuity.py +100 -0
  143. bookwright/validation/validators/temporal.py +277 -0
  144. bookwright_cli-0.2.0.dist-info/METADATA +218 -0
  145. bookwright_cli-0.2.0.dist-info/RECORD +149 -0
  146. bookwright_cli-0.2.0.dist-info/WHEEL +4 -0
  147. bookwright_cli-0.2.0.dist-info/entry_points.txt +2 -0
  148. bookwright_cli-0.2.0.dist-info/licenses/LICENSE +202 -0
  149. bookwright_cli-0.2.0.dist-info/licenses/NOTICE +14 -0
@@ -0,0 +1,291 @@
1
+ """``factual_anchor`` — structural + chronological audit of research anchors (§ 20.6).
2
+
3
+ The fifth built-in validator. A pure graph consumer (FR-003): it reads the anchor
4
+ sub-graph through :mod:`bookwright.validation.anchor_queries` and the manifest
5
+ through the :class:`ValidationContext`, and emits
6
+
7
+ * **warnings** for structural defects — an unsourced anchor (R1/FR-006), a source
8
+ missing a mandatory provenance facet (R2/FR-007), under-reliable support
9
+ (R3/FR-008), and a missing promoted finding or constrained entity (R4/FR-009);
10
+ * an **error** for a hard anachronism between an anchor's time-span and the
11
+ interval of the event (or timeline) it constrains (R5/FR-010), decided by the
12
+ shared :func:`~bookwright.validation.queries.intervals_disjoint` predicate so the
13
+ contradiction logic is never forked from ``temporal`` (FR-011).
14
+
15
+ It is inert — returns ``[]`` immediately — when ``[research].enabled`` is false
16
+ (FR-015) or the graph carries no anchor (FR-016), so it costs nothing on a
17
+ non-research project. No rdflib here: all SPARQL lives in the projection modules.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from dataclasses import dataclass
23
+ from typing import ClassVar
24
+
25
+ from bookwright.golem.namespaces import (
26
+ BW_CONSTRAINS,
27
+ BW_PROMOTES,
28
+ BW_SUPPORTED_BY,
29
+ RELIABILITY_IRI,
30
+ timeline_uri,
31
+ )
32
+ from bookwright.indexers import Indexer
33
+ from bookwright.validation.anchor_queries import (
34
+ FACETS,
35
+ AnchorRecord,
36
+ SourceRecord,
37
+ entity_present,
38
+ load_anchors,
39
+ load_sources_by_anchor,
40
+ )
41
+ from bookwright.validation.base import Severity, ValidationContext, Violation
42
+ from bookwright.validation.queries import (
43
+ EventInterval,
44
+ intervals_disjoint,
45
+ load_intervals,
46
+ resolve_source,
47
+ timeline_bounds,
48
+ )
49
+
50
+ # The reliability scale, lowest → highest. The rating NAMES are the single
51
+ # vocabulary source (``RELIABILITY_IRI`` keys); only the domain ordering
52
+ # (``baja < media < alta``) lives here. The membership guard below trips if the
53
+ # vocabulary ever gains or renames a rating, so the scale can never silently drift.
54
+ _RELIABILITY_ORDER: tuple[str, ...] = ("baja", "media", "alta")
55
+ _RELIABILITY_RANK: dict[str, int] = {name: rank for rank, name in enumerate(_RELIABILITY_ORDER)}
56
+ assert set(_RELIABILITY_RANK) == set(RELIABILITY_IRI), (
57
+ "reliability scale drifted from RELIABILITY_IRI"
58
+ )
59
+
60
+
61
+ def _label(uri: str) -> str:
62
+ """A short, readable name from a URI (its final path segment)."""
63
+ return uri.rstrip("/").rsplit("/", 1)[-1]
64
+
65
+
66
+ def _range(interval: EventInterval) -> str:
67
+ """A human ``begin-end`` year range, an open bound shown as ``?``."""
68
+ begin = interval.begin if interval.begin is not None else "?"
69
+ end = interval.end if interval.end is not None else "?"
70
+ return f"{begin}-{end}"
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class _IntervalView:
75
+ """The run-wide interval data the anachronism rule needs (loaded once).
76
+
77
+ ``events`` maps every ``G5_Narrative_Event`` URI to its interval; ``timeline``
78
+ is the overall bounds for a timeline-targeting anchor (``None`` when no anchor
79
+ carries a span, so the data was never loaded).
80
+ """
81
+
82
+ events: dict[str, EventInterval]
83
+ timeline: EventInterval | None
84
+
85
+
86
+ class FactualAnchor:
87
+ """Audits each research anchor's structural integrity and chronology."""
88
+
89
+ name: ClassVar[str] = "factual_anchor"
90
+ severity_default: ClassVar[Severity] = Severity.warning
91
+
92
+ def validate(self, project: ValidationContext, indexer: Indexer) -> list[Violation]:
93
+ if project.manifest.research.enabled is False:
94
+ return [] # FR-015: the research system is turned off
95
+ anchors = load_anchors(indexer)
96
+ if not anchors:
97
+ return [] # FR-016: nothing to audit on a non-research project
98
+ sources_by_anchor = load_sources_by_anchor(indexer)
99
+ # Interval data is only loaded when at least one anchor carries a time-span —
100
+ # a non-temporal research project pays nothing for the anachronism rule.
101
+ spanned = any(a.span.begin is not None or a.span.end is not None for a in anchors)
102
+ events = load_intervals(indexer) if spanned else {}
103
+ intervals = _IntervalView(
104
+ events=events,
105
+ timeline=timeline_bounds(events) if spanned else None,
106
+ )
107
+ out: list[Violation] = []
108
+ for anchor in anchors: # already sorted by URI (deterministic, FR-003)
109
+ sources = sources_by_anchor.get(anchor.uri, [])
110
+ out.extend(self._audit(anchor, sources, intervals, project, indexer))
111
+ return out
112
+
113
+ def _audit(
114
+ self,
115
+ anchor: AnchorRecord,
116
+ sources: list[SourceRecord],
117
+ intervals: _IntervalView,
118
+ project: ValidationContext,
119
+ indexer: Indexer,
120
+ ) -> list[Violation]:
121
+ """Run every rule against one anchor, collecting its violations in order."""
122
+ finding_present = entity_present(indexer, anchor.promotes, project.uri_base)
123
+ out: list[Violation] = []
124
+ out.extend(self._unsourced(anchor, sources, finding_present, indexer))
125
+ out.extend(self._incomplete(anchor, sources, project, indexer))
126
+ out.extend(self._under_reliable(anchor, sources, project, indexer))
127
+ out.extend(self._missing_entity(anchor, finding_present, project, indexer))
128
+ out.extend(self._anachronism(anchor, intervals, project, indexer))
129
+ return out
130
+
131
+ def _violation(
132
+ self, anchor: AnchorRecord, indexer: Indexer, message: str, triple: tuple[str, str, str]
133
+ ) -> Violation:
134
+ """A ``warning`` carrying the anchor's locator (``None`` today) + one edge."""
135
+ return Violation(
136
+ validator=self.name,
137
+ severity=Severity.warning,
138
+ message=message,
139
+ source=resolve_source(indexer, anchor.uri),
140
+ triples=(triple,),
141
+ )
142
+
143
+ # --- R1 unsourced (FR-006) ----------------------------------------------
144
+
145
+ def _unsourced(
146
+ self,
147
+ anchor: AnchorRecord,
148
+ sources: list[SourceRecord],
149
+ finding_present: bool,
150
+ indexer: Indexer,
151
+ ) -> list[Violation]:
152
+ # Suppressed when the finding is absent — R4 reports that once (no double-label).
153
+ if not finding_present or sources:
154
+ return []
155
+ message = f"anchor '{_label(anchor.uri)}' promotes a finding with no supporting source"
156
+ triple = (anchor.uri, str(BW_PROMOTES), anchor.promotes)
157
+ return [self._violation(anchor, indexer, message, triple)]
158
+
159
+ # --- R2 provenance-incomplete (FR-007) ----------------------------------
160
+
161
+ def _incomplete(
162
+ self,
163
+ anchor: AnchorRecord,
164
+ sources: list[SourceRecord],
165
+ project: ValidationContext,
166
+ indexer: Indexer,
167
+ ) -> list[Violation]:
168
+ book_language = project.manifest.book.language
169
+ out: list[Violation] = []
170
+ for source in sources:
171
+ # The implicated edge is the real finding→source link that locates the
172
+ # source; a missing facet has no object, so it is never a fabricated triple.
173
+ located = (anchor.promotes, str(BW_SUPPORTED_BY), source.uri)
174
+ for facet in FACETS:
175
+ if str(facet.predicate) in source.present_predicates:
176
+ continue
177
+ # ``translation`` is mandatory only for a foreign-language source; if
178
+ # the language itself is unknown it is already flagged, so skip here.
179
+ if facet.foreign_only and (
180
+ source.original_language is None or source.original_language == book_language
181
+ ):
182
+ continue
183
+ message = (
184
+ f"source '{_label(source.uri)}' backing anchor '{_label(anchor.uri)}' "
185
+ f"is missing its {facet.label}"
186
+ )
187
+ out.append(self._violation(anchor, indexer, message, located))
188
+ return out
189
+
190
+ # --- R3 under-reliable (FR-008) -----------------------------------------
191
+
192
+ def _under_reliable(
193
+ self,
194
+ anchor: AnchorRecord,
195
+ sources: list[SourceRecord],
196
+ project: ValidationContext,
197
+ indexer: Indexer,
198
+ ) -> list[Violation]:
199
+ if not sources: # an unsourced anchor is R1's concern, not R3's (no double-label)
200
+ return []
201
+ minimum = project.manifest.research.min_reliability_for_anchor
202
+ rated = [_RELIABILITY_RANK[s.reliability] for s in sources if s.reliability is not None]
203
+ # No rated source at all → below every threshold; else compare the best.
204
+ if rated and max(rated) >= _RELIABILITY_RANK[minimum]:
205
+ return []
206
+ if rated: # a rating is present, it is just too low
207
+ message = (
208
+ f"anchor '{_label(anchor.uri)}' is backed only by sources below the "
209
+ f"minimum reliability '{minimum}'"
210
+ )
211
+ else: # sources exist but none carries a rating at all — not "below", unrated
212
+ message = (
213
+ f"anchor '{_label(anchor.uri)}' is backed by sources but none carries a "
214
+ f"reliability rating (minimum required: '{minimum}')"
215
+ )
216
+ triple = (anchor.uri, str(BW_PROMOTES), anchor.promotes)
217
+ return [self._violation(anchor, indexer, message, triple)]
218
+
219
+ # --- R4 missing entity (FR-009) -----------------------------------------
220
+
221
+ def _missing_entity(
222
+ self,
223
+ anchor: AnchorRecord,
224
+ finding_present: bool,
225
+ project: ValidationContext,
226
+ indexer: Indexer,
227
+ ) -> list[Violation]:
228
+ out: list[Violation] = []
229
+ if not finding_present:
230
+ message = f"anchor '{_label(anchor.uri)}' promotes a finding not present in the graph"
231
+ triple = (anchor.uri, str(BW_PROMOTES), anchor.promotes)
232
+ out.append(self._violation(anchor, indexer, message, triple))
233
+ target = anchor.constrains
234
+ target_present = target is not None and entity_present(indexer, target, project.uri_base)
235
+ if not target_present:
236
+ message = (
237
+ f"anchor '{_label(anchor.uri)}' constrains a narrative entity "
238
+ "that is not present in the graph"
239
+ )
240
+ # The dropped-link case has no constrains triple → cite the promotes edge.
241
+ triple = (
242
+ (anchor.uri, str(BW_CONSTRAINS), target)
243
+ if target is not None
244
+ else (anchor.uri, str(BW_PROMOTES), anchor.promotes)
245
+ )
246
+ out.append(self._violation(anchor, indexer, message, triple))
247
+ return out
248
+
249
+ # --- R5 anachronism (FR-010/FR-012) -------------------------------------
250
+
251
+ def _anachronism(
252
+ self,
253
+ anchor: AnchorRecord,
254
+ intervals: _IntervalView,
255
+ project: ValidationContext,
256
+ indexer: Indexer,
257
+ ) -> list[Violation]:
258
+ span = anchor.span
259
+ if span.begin is None and span.end is None:
260
+ return [] # no time-span → nothing to compare against
261
+ target = anchor.constrains
262
+ if target is None:
263
+ return [] # dropped link — already R4's concern, no interval to compare
264
+ target_interval = self._target_interval(target, intervals, project.uri_base)
265
+ if target_interval is None:
266
+ return [] # non-temporal / non-event target → no comparable interval (FR-012)
267
+ if not intervals_disjoint(span, target_interval):
268
+ return []
269
+ message = (
270
+ f"anchor '{_label(anchor.uri)}' ({_range(span)}) constrains "
271
+ f"'{_label(target)}' ({_range(target_interval)}), but their year ranges "
272
+ "are disjoint (anachronism)"
273
+ )
274
+ return [
275
+ Violation(
276
+ validator=self.name,
277
+ severity=Severity.error,
278
+ message=message,
279
+ source=resolve_source(indexer, anchor.uri),
280
+ triples=((anchor.uri, str(BW_CONSTRAINS), target),),
281
+ )
282
+ ]
283
+
284
+ def _target_interval(
285
+ self, target: str, intervals: _IntervalView, uri_base: str
286
+ ) -> EventInterval | None:
287
+ """The interval to compare the span against: timeline bounds, an event's
288
+ interval, or ``None`` for a non-temporal / non-event target (D3)."""
289
+ if target == str(timeline_uri(uri_base)):
290
+ return intervals.timeline
291
+ return intervals.events.get(target)
@@ -0,0 +1,152 @@
1
+ """``focalization`` — prose vs. the declared narrative voice (FR-018, research D5).
2
+
3
+ Reads the constitution's narrative-voice declaration (Spanish "Voz narrativa" or
4
+ English "Narrative voice", case-insensitive) for the declared grammatical person and,
5
+ if a bible character is named there, the focal character. Then flags two heuristic
6
+ breaks, LLM-free, defaulting to ``warning``:
7
+
8
+ * first-person pronouns outside dialogue when **third person** is declared,
9
+ * interiority verbs attached to a **non-focal** bible character (head-hopping) under
10
+ **third-person-limited**.
11
+
12
+ No parsable declaration → zero findings (edge case).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from dataclasses import dataclass
19
+ from typing import ClassVar
20
+
21
+ from bookwright.indexers import Indexer
22
+ from bookwright.validation.base import Severity, ValidationContext, Violation
23
+
24
+ _DECLARATION = re.compile(r"(?im)^\s*(?:voz narrativa|narrative voice)\s*:\s*(?P<body>.+)$")
25
+ _THIRD = re.compile(r"(?i)\b(tercera|third)\b")
26
+ _FIRST = re.compile(r"(?i)\b(primera|first)\b")
27
+ _LIMITED = re.compile(r"(?i)\b(limitada|limitado|limited)\b")
28
+
29
+ # First-person markers we treat as a voice break outside dialogue (conservative).
30
+ _FIRST_PERSON = re.compile(r"(?i)(?<![\wáéíóúñ])(yo|nosotros|nosotras|i|we)(?![\wáéíóúñ])")
31
+ # Lines that are dialogue (Spanish em-dash openers or quotation marks) are exempt.
32
+ # Spanish typography (en/em dashes, angle + curly quotes) is intentional here.
33
+ _DIALOGUE_PREFIX = ("—", "–", "-", '"', "«", "“", "'", "‘") # noqa: RUF001
34
+ # Interiority verbs — third-person reports of a character's inner life.
35
+ _INTERIORITY = re.compile(
36
+ r"(?i)(?<![\wáéíóúñ])"
37
+ r"(pensó|sintió|supo|recordó|creyó|temió|imaginó|comprendió|deseó|"
38
+ r"thought|felt|knew|remembered|believed|feared|wondered|realized|realised|wished)"
39
+ r"(?![\wáéíóúñ])"
40
+ )
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class _Declaration:
45
+ person: str | None # "first" | "third" | None
46
+ limited: bool
47
+ focal: str | None # bible character named in the declaration, if any
48
+
49
+
50
+ class Focalization:
51
+ """Flags prose that breaks the declared narrative person / focal character."""
52
+
53
+ name: ClassVar[str] = "focalization"
54
+ severity_default: ClassVar[Severity] = Severity.warning
55
+
56
+ def validate(self, project: ValidationContext, indexer: Indexer) -> list[Violation]:
57
+ constitution = project.constitution_text()
58
+ if constitution is None:
59
+ return []
60
+ character_names = [name for name, _ in project.character_names()]
61
+ declaration = _parse_declaration(constitution, character_names)
62
+ if declaration is None or declaration.person is None:
63
+ return []
64
+
65
+ files = project.manuscript_files()
66
+ out: list[Violation] = []
67
+ if declaration.person == "third":
68
+ out.extend(self._first_person_breaks(files))
69
+ if declaration.limited:
70
+ out.extend(self._head_hopping(files, character_names, declaration.focal))
71
+ return out
72
+
73
+ def _first_person_breaks(self, files: tuple[tuple[str, str], ...]) -> list[Violation]:
74
+ out: list[Violation] = []
75
+ for relpath, text in files:
76
+ for lineno, line in enumerate(text.splitlines(), start=1):
77
+ if _is_dialogue(line):
78
+ continue
79
+ match = _FIRST_PERSON.search(line)
80
+ if match:
81
+ out.append(
82
+ Violation(
83
+ validator=self.name,
84
+ severity=Severity.warning,
85
+ message=(
86
+ f"first-person marker '{match.group(1)}' outside dialogue, but "
87
+ "the constitution declares a third-person narrative voice"
88
+ ),
89
+ source=f"{relpath}:{lineno}",
90
+ triples=(),
91
+ )
92
+ )
93
+ break # one finding per file (citing the first break)
94
+ return out
95
+
96
+ def _head_hopping(
97
+ self,
98
+ files: tuple[tuple[str, str], ...],
99
+ character_names: list[str],
100
+ focal: str | None,
101
+ ) -> list[Violation]:
102
+ non_focal = sorted(n for n in character_names if n != focal)
103
+ seen: set[str] = set()
104
+ out: list[Violation] = []
105
+ for relpath, text in files:
106
+ for lineno, line in enumerate(text.splitlines(), start=1):
107
+ if not _INTERIORITY.search(line):
108
+ continue
109
+ for name in non_focal:
110
+ if name in seen:
111
+ continue
112
+ if re.search(rf"\b{re.escape(name)}\b", line):
113
+ seen.add(name)
114
+ out.append(
115
+ Violation(
116
+ validator=self.name,
117
+ severity=Severity.warning,
118
+ message=(
119
+ f"interiority attributed to '{name}', a non-focal character, "
120
+ "under a third-person-limited narrative voice (head-hopping)"
121
+ ),
122
+ source=f"{relpath}:{lineno}",
123
+ triples=(),
124
+ )
125
+ )
126
+ return out
127
+
128
+
129
+ def _parse_declaration(text: str, character_names: list[str]) -> _Declaration | None:
130
+ match = _DECLARATION.search(text)
131
+ if match is None:
132
+ return None
133
+ body = match.group("body")
134
+ person: str | None = None
135
+ if _THIRD.search(body):
136
+ person = "third"
137
+ elif _FIRST.search(body):
138
+ person = "first"
139
+ focal = next(
140
+ (
141
+ name
142
+ for name in sorted(character_names, key=len, reverse=True)
143
+ if re.search(rf"\b{re.escape(name)}\b", body)
144
+ ),
145
+ None,
146
+ )
147
+ return _Declaration(person=person, limited=bool(_LIMITED.search(body)), focal=focal)
148
+
149
+
150
+ def _is_dialogue(line: str) -> bool:
151
+ stripped = line.lstrip()
152
+ return stripped.startswith(_DIALOGUE_PREFIX)
@@ -0,0 +1,100 @@
1
+ """``setting_continuity`` — contradicting descriptors for one setting (FR-017, D4).
2
+
3
+ A small built-in **contradiction lexicon** of antonym groups (e.g. *coastal* /
4
+ *inland*). When the same setting is described with two terms from one group in
5
+ **different files**, that is a continuity warning citing both locations. Heuristic
6
+ and LLM-free, so it defaults to ``warning`` — it never gates CI.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from typing import ClassVar
13
+
14
+ from bookwright.indexers import Indexer
15
+ from bookwright.validation.base import Severity, ValidationContext, Violation
16
+
17
+ # Antonym groups: two terms from one group on one setting, in different files, clash.
18
+ _LEXICON: tuple[tuple[str, ...], ...] = (
19
+ ("coastal", "inland"),
20
+ ("costera", "costero", "interior"),
21
+ ("urban", "rural"),
22
+ ("urbana", "urbano", "rural"),
23
+ ("mountainous", "flat"),
24
+ ("montañosa", "montañoso", "llana", "llano"),
25
+ ("tropical", "arctic"),
26
+ ("desert", "forest"),
27
+ ("desierto", "bosque"),
28
+ )
29
+ # Word-boundary matcher per lexicon term, compiled once at import (not per line).
30
+ _TERM_PATTERNS: dict[str, re.Pattern[str]] = {
31
+ term: re.compile(rf"\b{re.escape(term)}\b", re.IGNORECASE)
32
+ for group in _LEXICON
33
+ for term in group
34
+ }
35
+
36
+
37
+ class SettingContinuity:
38
+ """Flags a setting tagged with mutually-exclusive descriptors across files."""
39
+
40
+ name: ClassVar[str] = "setting_continuity"
41
+ severity_default: ClassVar[Severity] = Severity.warning
42
+
43
+ def validate(self, project: ValidationContext, indexer: Indexer) -> list[Violation]:
44
+ files = project.manuscript_files()
45
+ out: list[Violation] = []
46
+ for setting_name, _ in project.setting_names():
47
+ out.extend(self._check_setting(setting_name, files))
48
+ return out
49
+
50
+ def _check_setting(
51
+ self, setting_name: str, files: tuple[tuple[str, str], ...]
52
+ ) -> list[Violation]:
53
+ name_re = re.compile(rf"\b{re.escape(setting_name)}\b", re.IGNORECASE)
54
+ # term → (relpath, line) of its first occurrence in a file mentioning the setting.
55
+ occurrences: dict[str, tuple[str, int]] = {}
56
+ for relpath, text in files:
57
+ if not name_re.search(text):
58
+ continue
59
+ for lineno, line in enumerate(text.splitlines(), start=1):
60
+ for term, pattern in _TERM_PATTERNS.items():
61
+ if term in occurrences:
62
+ continue
63
+ if pattern.search(line):
64
+ occurrences[term] = (relpath, lineno)
65
+
66
+ out: list[Violation] = []
67
+ for group in _LEXICON:
68
+ present = [term for term in group if term in occurrences]
69
+ clash = _first_cross_file_pair(present, occurrences)
70
+ if clash is None:
71
+ continue
72
+ (term_a, loc_a), (term_b, loc_b) = clash
73
+ src_a, line_a = loc_a
74
+ src_b, line_b = loc_b
75
+ out.append(
76
+ Violation(
77
+ validator=self.name,
78
+ severity=Severity.warning,
79
+ message=(
80
+ f"setting '{setting_name}' is described as '{term_a}' "
81
+ f"({src_a}:{line_a}) and '{term_b}' ({src_b}:{line_b}) — "
82
+ "contradicting descriptors across files"
83
+ ),
84
+ source=f"{src_a}:{line_a}",
85
+ triples=(),
86
+ )
87
+ )
88
+ return out
89
+
90
+
91
+ def _first_cross_file_pair(
92
+ present: list[str], occurrences: dict[str, tuple[str, int]]
93
+ ) -> tuple[tuple[str, tuple[str, int]], tuple[str, tuple[str, int]]] | None:
94
+ """The first pair of present terms whose recorded files differ (sorted, D8)."""
95
+ for i in range(len(present)):
96
+ for j in range(i + 1, len(present)):
97
+ a, b = present[i], present[j]
98
+ if occurrences[a][0] != occurrences[b][0]:
99
+ return (a, occurrences[a]), (b, occurrences[b])
100
+ return None