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.
- bookwright/__init__.py +3 -0
- bookwright/__main__.py +6 -0
- bookwright/cli.py +19 -0
- bookwright/commands/__init__.py +0 -0
- bookwright/commands/_envelope.py +36 -0
- bookwright/commands/check.py +75 -0
- bookwright/commands/graph/__init__.py +23 -0
- bookwright/commands/graph/build.py +157 -0
- bookwright/commands/graph/envelope.py +26 -0
- bookwright/commands/graph/query.py +98 -0
- bookwright/commands/init/__init__.py +5 -0
- bookwright/commands/init/conflict.py +107 -0
- bookwright/commands/init/envelope.py +322 -0
- bookwright/commands/init/git.py +96 -0
- bookwright/commands/init/main.py +263 -0
- bookwright/commands/init/resolve.py +193 -0
- bookwright/commands/init/scaffold.py +242 -0
- bookwright/commands/init/validate.py +172 -0
- bookwright/commands/integration/__init__.py +22 -0
- bookwright/commands/integration/use.py +120 -0
- bookwright/commands/validate.py +160 -0
- bookwright/commands/version.py +35 -0
- bookwright/core/__init__.py +35 -0
- bookwright/core/_blocks.py +239 -0
- bookwright/core/_build.py +154 -0
- bookwright/core/_research_block.py +56 -0
- bookwright/core/_translate.py +90 -0
- bookwright/core/errors.py +127 -0
- bookwright/core/iso639_1.py +200 -0
- bookwright/core/manifest.py +343 -0
- bookwright/errors.py +47 -0
- bookwright/golem/__init__.py +71 -0
- bookwright/golem/base.py +200 -0
- bookwright/golem/errors.py +29 -0
- bookwright/golem/modules/__init__.py +1 -0
- bookwright/golem/modules/character.py +109 -0
- bookwright/golem/modules/event.py +91 -0
- bookwright/golem/modules/feature.py +161 -0
- bookwright/golem/modules/inference.py +41 -0
- bookwright/golem/modules/narrative.py +55 -0
- bookwright/golem/modules/provenance.py +197 -0
- bookwright/golem/modules/relationship.py +38 -0
- bookwright/golem/modules/setting.py +30 -0
- bookwright/golem/namespaces.py +332 -0
- bookwright/golem/serialize.py +25 -0
- bookwright/golem/slug.py +22 -0
- bookwright/indexers/__init__.py +47 -0
- bookwright/indexers/base.py +55 -0
- bookwright/indexers/errors.py +80 -0
- bookwright/indexers/rdflib_indexer.py +89 -0
- bookwright/integrations/__init__.py +155 -0
- bookwright/integrations/base.py +117 -0
- bookwright/integrations/claude/__init__.py +29 -0
- bookwright/integrations/constants.py +38 -0
- bookwright/integrations/descriptions.py +48 -0
- bookwright/integrations/errors.py +170 -0
- bookwright/integrations/generic/__init__.py +56 -0
- bookwright/integrations/lint.py +160 -0
- bookwright/integrations/materialize.py +202 -0
- bookwright/integrations/options.py +203 -0
- bookwright/io/__init__.py +1 -0
- bookwright/io/bible.py +500 -0
- bookwright/io/errors.py +98 -0
- bookwright/io/frontmatter.py +61 -0
- bookwright/io/fs.py +226 -0
- bookwright/io/manuscript.py +15 -0
- bookwright/io/project.py +21 -0
- bookwright/io/report.py +107 -0
- bookwright/io/research.py +427 -0
- bookwright/resources/__init__.py +1 -0
- bookwright/resources/commands/bookwright-analyze.md +66 -0
- bookwright/resources/commands/bookwright-bible.md +96 -0
- bookwright/resources/commands/bookwright-checklist.md +67 -0
- bookwright/resources/commands/bookwright-clarify.md +65 -0
- bookwright/resources/commands/bookwright-constitution.md +79 -0
- bookwright/resources/commands/bookwright-continuity.md +70 -0
- bookwright/resources/commands/bookwright-draft.md +74 -0
- bookwright/resources/commands/bookwright-outline.md +71 -0
- bookwright/resources/commands/bookwright-research.md +107 -0
- bookwright/resources/commands/bookwright-scenes.md +66 -0
- bookwright/resources/commands/bookwright-synopsis.md +67 -0
- bookwright/resources/commands/bookwright-verify.md +136 -0
- bookwright/resources/commands/references/golem-character.md +65 -0
- bookwright/resources/commands/references/golem-events-timeline.md +56 -0
- bookwright/resources/commands/references/golem-relationships.md +53 -0
- bookwright/resources/commands/references/greimas-actants.md +57 -0
- bookwright/resources/commands/references/pending-protocol.md +72 -0
- bookwright/resources/commands/references/propp-functions.md +54 -0
- bookwright/resources/commands/references/research-format.md +136 -0
- bookwright/resources/project/.bookwright/cache/.gitkeep +0 -0
- bookwright/resources/project/.bookwright/schema/.gitkeep +0 -0
- bookwright/resources/project/.bookwright/templates/.gitkeep +0 -0
- bookwright/resources/project/.gitignore +23 -0
- bookwright/resources/project/README.md.j2 +40 -0
- bookwright/resources/project/__init__.py +6 -0
- bookwright/resources/project/bible/characters/.gitkeep +0 -0
- bookwright/resources/project/bible/constitution.md.j2 +74 -0
- bookwright/resources/project/bible/glossary.md +36 -0
- bookwright/resources/project/bible/locations/.gitkeep +0 -0
- bookwright/resources/project/bible/pov-structure.md +43 -0
- bookwright/resources/project/bible/relationships.md +36 -0
- bookwright/resources/project/bible/research/_index.md +28 -0
- bookwright/resources/project/bible/research/sources.md +23 -0
- bookwright/resources/project/bible/settings/.gitkeep +0 -0
- bookwright/resources/project/bible/subplots.md +35 -0
- bookwright/resources/project/bible/themes.md +36 -0
- bookwright/resources/project/bible/timeline.md +38 -0
- bookwright/resources/project/manuscript/.gitkeep +0 -0
- bookwright/resources/project/outline/arcs.md +34 -0
- bookwright/resources/project/outline/scenes.md +31 -0
- bookwright/resources/project/outline/structure.md +35 -0
- bookwright/resources/project/outline/synopsis.md +25 -0
- bookwright/resources/schemas/__init__.py +19 -0
- bookwright/resources/schemas/golem-1.1/VERSION +1 -0
- bookwright/resources/schemas/golem-1.1/golem.ttl +1947 -0
- bookwright/resources/schemas/golem-1.1/version.json +8 -0
- bookwright/resources/templates/__init__.py +1 -0
- bookwright/resources/templates/bible/character.md.tmpl +63 -0
- bookwright/resources/templates/bible/location.md.tmpl +37 -0
- bookwright/resources/templates/bible/research/_index.md.tmpl +25 -0
- bookwright/resources/templates/bible/research/sources.md.tmpl +21 -0
- bookwright/resources/templates/bible/research/tema.md.tmpl +37 -0
- bookwright/resources/templates/bible/setting.md.tmpl +38 -0
- bookwright/resources/templates/manifest.template.toml +79 -0
- bookwright/resources/templates/manuscript/chapter.md.tmpl +36 -0
- bookwright/resources/templates/scenes/scene.md.tmpl +37 -0
- bookwright/resources/vocabularies/__init__.py +6 -0
- bookwright/resources/vocabularies/greimas.ttl +4 -0
- bookwright/resources/vocabularies/propp.ttl +4 -0
- bookwright/resources/vocabularies/sources.ttl +82 -0
- bookwright/validation/__init__.py +33 -0
- bookwright/validation/anchor_queries.py +223 -0
- bookwright/validation/base.py +233 -0
- bookwright/validation/queries.py +197 -0
- bookwright/validation/registry.py +185 -0
- bookwright/validation/report.py +106 -0
- bookwright/validation/runner.py +65 -0
- bookwright/validation/validators/__init__.py +9 -0
- bookwright/validation/validators/character_presence.py +202 -0
- bookwright/validation/validators/factual_anchor.py +291 -0
- bookwright/validation/validators/focalization.py +152 -0
- bookwright/validation/validators/setting_continuity.py +100 -0
- bookwright/validation/validators/temporal.py +277 -0
- bookwright_cli-0.2.0.dist-info/METADATA +218 -0
- bookwright_cli-0.2.0.dist-info/RECORD +149 -0
- bookwright_cli-0.2.0.dist-info/WHEEL +4 -0
- bookwright_cli-0.2.0.dist-info/entry_points.txt +2 -0
- bookwright_cli-0.2.0.dist-info/licenses/LICENSE +202 -0
- bookwright_cli-0.2.0.dist-info/licenses/NOTICE +14 -0
bookwright/io/fs.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Shared transactional-filesystem layer (extracted from ``init/scaffold.py``).
|
|
2
|
+
|
|
3
|
+
The :class:`BackupLedger` is the atomic-or-nothing primitive: every filesystem
|
|
4
|
+
mutation a caller performs is recorded here BEFORE the bytes hit disk; on success
|
|
5
|
+
the backups are unlinked, on any exception the ledger is replayed in reverse to
|
|
6
|
+
restore the tree to byte-for-byte its pre-mutation state. The writer goes through
|
|
7
|
+
``os.replace`` for local atomicity.
|
|
8
|
+
|
|
9
|
+
Two consumers depend on this module: ``bookwright.commands.init`` (the original
|
|
10
|
+
home) and ``bookwright.integrations`` (the iteration-9 skills materializer). To
|
|
11
|
+
keep them decoupled from the concrete ledger, this module also defines the narrow
|
|
12
|
+
:class:`FileLedger` ``Protocol`` (structurally satisfied by :class:`BackupLedger`)
|
|
13
|
+
and a no-op :class:`NullLedger` for standalone callers.
|
|
14
|
+
|
|
15
|
+
Dependency direction is acyclic by design: this module imports **only** stdlib —
|
|
16
|
+
it imports neither ``commands`` nor ``integrations``.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import contextlib
|
|
22
|
+
import os
|
|
23
|
+
import secrets
|
|
24
|
+
import shutil
|
|
25
|
+
import tempfile
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Protocol, runtime_checkable
|
|
29
|
+
|
|
30
|
+
_BACKUP_SUBDIR = Path(".bookwright/cache/backup")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class BackupCreationError(Exception):
|
|
34
|
+
"""Raised when a pre-overwrite backup copy could not be created (FR-030 last sentence)."""
|
|
35
|
+
|
|
36
|
+
code = "backup_creation_error"
|
|
37
|
+
|
|
38
|
+
def __init__(self, *, target: Path, reason: str) -> None:
|
|
39
|
+
self.target = target
|
|
40
|
+
self.reason = reason
|
|
41
|
+
super().__init__(f"could not create backup for {target}: {reason}")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class TargetOutsideProjectRootError(Exception):
|
|
45
|
+
"""Raised when a writer would touch a path outside ``project_root`` (FR-014)."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, *, target: Path, project_root: Path) -> None:
|
|
48
|
+
self.target = target
|
|
49
|
+
self.project_root = project_root
|
|
50
|
+
super().__init__(f"target {target} is outside project root {project_root}")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@runtime_checkable
|
|
54
|
+
class FileLedger(Protocol):
|
|
55
|
+
"""Narrow rollback-recording surface the materializer depends on.
|
|
56
|
+
|
|
57
|
+
Structurally satisfied by :class:`BackupLedger` (and any future ledger).
|
|
58
|
+
The integrations layer depends on this Protocol, never on the concrete
|
|
59
|
+
ledger, so it stays decoupled from ``init`` internals (Principle V).
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def record_new_file(self, target: Path) -> None: ...
|
|
63
|
+
|
|
64
|
+
def record_new_directory(self, target: Path) -> None: ...
|
|
65
|
+
|
|
66
|
+
def record_overwrite(self, target: Path) -> Path: ...
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class NullLedger:
|
|
70
|
+
"""No-op :class:`FileLedger` for standalone callers (no rollback needed)."""
|
|
71
|
+
|
|
72
|
+
def record_new_file(self, target: Path) -> None:
|
|
73
|
+
del target
|
|
74
|
+
|
|
75
|
+
def record_new_directory(self, target: Path) -> None:
|
|
76
|
+
del target
|
|
77
|
+
|
|
78
|
+
def record_overwrite(self, target: Path) -> Path:
|
|
79
|
+
return target
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class BackupEntry:
|
|
84
|
+
"""One ledger entry (data-model §3)."""
|
|
85
|
+
|
|
86
|
+
target: Path
|
|
87
|
+
backup_path: Path | None
|
|
88
|
+
was_directory: bool
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class BackupLedger:
|
|
92
|
+
"""In-memory rollback record for one ``init`` invocation."""
|
|
93
|
+
|
|
94
|
+
def __init__(self, project_root: Path) -> None:
|
|
95
|
+
self._project_root = project_root.resolve()
|
|
96
|
+
self._entries: list[BackupEntry] = []
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def project_root(self) -> Path:
|
|
100
|
+
return self._project_root
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def entries(self) -> tuple[BackupEntry, ...]:
|
|
104
|
+
return tuple(self._entries)
|
|
105
|
+
|
|
106
|
+
def _ensure_under_root(self, target: Path) -> Path:
|
|
107
|
+
resolved = target.resolve() if target.exists() else (target.parent.resolve() / target.name)
|
|
108
|
+
if not resolved.is_relative_to(self._project_root):
|
|
109
|
+
raise TargetOutsideProjectRootError(target=resolved, project_root=self._project_root)
|
|
110
|
+
return resolved
|
|
111
|
+
|
|
112
|
+
def record_new_file(self, target: Path) -> None:
|
|
113
|
+
resolved = self._ensure_under_root(target)
|
|
114
|
+
self._entries.append(BackupEntry(target=resolved, backup_path=None, was_directory=False))
|
|
115
|
+
|
|
116
|
+
def record_new_directory(self, target: Path) -> None:
|
|
117
|
+
resolved = self._ensure_under_root(target)
|
|
118
|
+
self._entries.append(BackupEntry(target=resolved, backup_path=None, was_directory=True))
|
|
119
|
+
|
|
120
|
+
def record_overwrite(self, target: Path) -> Path:
|
|
121
|
+
"""Copy ``target`` into the cache before allowing the overwrite.
|
|
122
|
+
|
|
123
|
+
Raises ``BackupCreationError`` on copy failure — caller must abort
|
|
124
|
+
before any byte hits the target (FR-030 last sentence).
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
resolved = self._ensure_under_root(target)
|
|
128
|
+
token = secrets.token_hex(6)
|
|
129
|
+
relative = resolved.relative_to(self._project_root)
|
|
130
|
+
backup_path = self._project_root / _BACKUP_SUBDIR / token / relative
|
|
131
|
+
try:
|
|
132
|
+
backup_path.parent.mkdir(parents=True, exist_ok=True)
|
|
133
|
+
shutil.copy2(resolved, backup_path)
|
|
134
|
+
except OSError as exc:
|
|
135
|
+
raise BackupCreationError(target=resolved, reason=str(exc)) from exc
|
|
136
|
+
self._entries.append(
|
|
137
|
+
BackupEntry(target=resolved, backup_path=backup_path, was_directory=False)
|
|
138
|
+
)
|
|
139
|
+
return backup_path
|
|
140
|
+
|
|
141
|
+
def commit(self) -> None:
|
|
142
|
+
"""Success path — delete every backup file and prune empty parents."""
|
|
143
|
+
|
|
144
|
+
for entry in self._entries:
|
|
145
|
+
if entry.backup_path is not None:
|
|
146
|
+
with contextlib.suppress(OSError):
|
|
147
|
+
entry.backup_path.unlink()
|
|
148
|
+
# Prune the per-invocation backup root if it ended up empty.
|
|
149
|
+
backup_root = self._project_root / _BACKUP_SUBDIR
|
|
150
|
+
if backup_root.exists():
|
|
151
|
+
with contextlib.suppress(OSError):
|
|
152
|
+
shutil.rmtree(backup_root)
|
|
153
|
+
|
|
154
|
+
def rollback(self) -> None:
|
|
155
|
+
"""Failure path — walk in reverse, restore overwrites, unlink new entries."""
|
|
156
|
+
|
|
157
|
+
for entry in reversed(self._entries):
|
|
158
|
+
if entry.backup_path is not None:
|
|
159
|
+
with contextlib.suppress(OSError):
|
|
160
|
+
shutil.move(str(entry.backup_path), str(entry.target))
|
|
161
|
+
continue
|
|
162
|
+
if entry.was_directory:
|
|
163
|
+
if entry.target.exists():
|
|
164
|
+
with contextlib.suppress(OSError):
|
|
165
|
+
shutil.rmtree(entry.target)
|
|
166
|
+
continue
|
|
167
|
+
if entry.target.exists():
|
|
168
|
+
with contextlib.suppress(OSError):
|
|
169
|
+
entry.target.unlink()
|
|
170
|
+
# Always try to clear the backup cache directory itself.
|
|
171
|
+
backup_root = self._project_root / _BACKUP_SUBDIR
|
|
172
|
+
if backup_root.exists():
|
|
173
|
+
with contextlib.suppress(OSError):
|
|
174
|
+
shutil.rmtree(backup_root)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _register_target(target: Path, ledger: FileLedger) -> None:
|
|
178
|
+
"""Record ``target`` with the ledger: new file or overwrite."""
|
|
179
|
+
|
|
180
|
+
if target.exists():
|
|
181
|
+
ledger.record_overwrite(target)
|
|
182
|
+
else:
|
|
183
|
+
ledger.record_new_file(target)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def write_bytes_atomic(target: Path, payload: bytes, ledger: FileLedger) -> None:
|
|
187
|
+
"""Atomic file write via ``tempfile.mkstemp`` + ``os.fsync`` + ``os.replace``.
|
|
188
|
+
|
|
189
|
+
Registers the target with the ledger BEFORE any byte hits disk
|
|
190
|
+
(so a copy failure during overwrite-backup aborts cleanly).
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
_register_target(target, ledger)
|
|
194
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
195
|
+
tmp_fd, tmp_path = tempfile.mkstemp(
|
|
196
|
+
dir=str(target.parent),
|
|
197
|
+
prefix=f".{target.name}.",
|
|
198
|
+
suffix=".tmp",
|
|
199
|
+
)
|
|
200
|
+
try:
|
|
201
|
+
with os.fdopen(tmp_fd, "wb") as handle:
|
|
202
|
+
handle.write(payload)
|
|
203
|
+
handle.flush()
|
|
204
|
+
os.fsync(handle.fileno())
|
|
205
|
+
os.replace(tmp_path, target)
|
|
206
|
+
except BaseException:
|
|
207
|
+
with contextlib.suppress(OSError):
|
|
208
|
+
os.unlink(tmp_path)
|
|
209
|
+
raise
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def mkdir_tracked(target: Path, ledger: FileLedger) -> None:
|
|
213
|
+
"""``mkdir(parents=True, exist_ok=True)`` while recording the new directory."""
|
|
214
|
+
|
|
215
|
+
if target.exists():
|
|
216
|
+
return
|
|
217
|
+
# Walk up to find the first non-existent parent — every newly created
|
|
218
|
+
# directory must be registered so rollback can prune them in reverse.
|
|
219
|
+
to_create: list[Path] = []
|
|
220
|
+
cursor = target
|
|
221
|
+
while not cursor.exists():
|
|
222
|
+
to_create.append(cursor)
|
|
223
|
+
cursor = cursor.parent
|
|
224
|
+
for path in reversed(to_create):
|
|
225
|
+
ledger.record_new_directory(path)
|
|
226
|
+
path.mkdir(exist_ok=True)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Manuscript presence check (FR-012).
|
|
2
|
+
|
|
3
|
+
v0 does **no** prose mining — extraction is bible-frontmatter-driven. This module
|
|
4
|
+
only confirms the manuscript directory exists so ``graph build`` can fail fast on
|
|
5
|
+
a malformed project layout.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def manuscript_present(manuscript_dir: Path) -> bool:
|
|
14
|
+
"""Return whether the manuscript directory exists."""
|
|
15
|
+
return manuscript_dir.is_dir()
|
bookwright/io/project.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Locate the project root by walking up for ``manifest.toml`` (R8)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .errors import ProjectNotFoundError
|
|
8
|
+
|
|
9
|
+
MANIFEST_NAME = "manifest.toml"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def find_project_root(start: Path | None = None) -> Path:
|
|
13
|
+
"""Return the nearest ancestor of ``start`` (default cwd) holding ``manifest.toml``.
|
|
14
|
+
|
|
15
|
+
Raises :class:`ProjectNotFoundError` when no ancestor contains one (R8).
|
|
16
|
+
"""
|
|
17
|
+
origin = (start or Path.cwd()).resolve()
|
|
18
|
+
for candidate in (origin, *origin.parents):
|
|
19
|
+
if (candidate / MANIFEST_NAME).is_file():
|
|
20
|
+
return candidate
|
|
21
|
+
raise ProjectNotFoundError(str(origin))
|
bookwright/io/report.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""The ``graph build`` report models (data-model § 5).
|
|
2
|
+
|
|
3
|
+
``BuildReport`` and its three soft-warning collections are frozen Pydantic
|
|
4
|
+
models. ``skipped`` drives the exit code (≥ 1 skip → exit 4); ``unknown_keys``
|
|
5
|
+
and ``unresolved_participants`` are soft warnings that never change it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, ConfigDict
|
|
13
|
+
|
|
14
|
+
EXIT_OK = 0
|
|
15
|
+
EXIT_SKIPPED = 4
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SkippedFile(BaseModel):
|
|
19
|
+
"""A source file skipped because its frontmatter was unusable (FR-013)."""
|
|
20
|
+
|
|
21
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
22
|
+
|
|
23
|
+
path: str
|
|
24
|
+
reason: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class UnknownKey(BaseModel):
|
|
28
|
+
"""A frontmatter key not recognised for its concept — recorded, not fatal."""
|
|
29
|
+
|
|
30
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
31
|
+
|
|
32
|
+
path: str
|
|
33
|
+
key: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class UnresolvedParticipant(BaseModel):
|
|
37
|
+
"""A ``participants:`` reference matching no built character (FR-019).
|
|
38
|
+
|
|
39
|
+
The owning event/relationship is still constructed; only that participation
|
|
40
|
+
edge is omitted.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
44
|
+
|
|
45
|
+
path: str
|
|
46
|
+
entity: str
|
|
47
|
+
name: str
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ResearchTargetWarning(BaseModel):
|
|
51
|
+
"""A research ``bears_on``/``constrains`` target that did not resolve (D12).
|
|
52
|
+
|
|
53
|
+
The link triple was omitted and the build still succeeds (exit code unchanged) —
|
|
54
|
+
existence/kind enforcement is the iteration-15 ``factual_anchor`` validator's job.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
58
|
+
|
|
59
|
+
path: str
|
|
60
|
+
field: str
|
|
61
|
+
name: str
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class BuildReport(BaseModel):
|
|
65
|
+
"""The full outcome of a ``graph build`` (data-model § 5)."""
|
|
66
|
+
|
|
67
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
68
|
+
|
|
69
|
+
files_processed: int
|
|
70
|
+
entities: int
|
|
71
|
+
triples: int
|
|
72
|
+
graph_path: str
|
|
73
|
+
skipped: tuple[SkippedFile, ...] = ()
|
|
74
|
+
unknown_keys: tuple[UnknownKey, ...] = ()
|
|
75
|
+
unresolved_participants: tuple[UnresolvedParticipant, ...] = ()
|
|
76
|
+
# Optional research metrics (iteration 012). Absent/zero on a research-free build
|
|
77
|
+
# so existing build/`--json` output is byte-stable (research D8). Research warnings
|
|
78
|
+
# never change the exit code (D12).
|
|
79
|
+
sources: int = 0
|
|
80
|
+
findings: int = 0
|
|
81
|
+
anchors: int = 0
|
|
82
|
+
research_warnings: tuple[ResearchTargetWarning, ...] = ()
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def exit_code(self) -> int:
|
|
86
|
+
"""Exit 4 when any file was skipped, else exit 0 (R7).
|
|
87
|
+
|
|
88
|
+
Research warnings (D12) are deliberately **not** part of the exit code.
|
|
89
|
+
"""
|
|
90
|
+
return EXIT_SKIPPED if self.skipped else EXIT_OK
|
|
91
|
+
|
|
92
|
+
def to_json(self) -> dict[str, Any]:
|
|
93
|
+
"""The contract success envelope (cli-graph.md): ``status:"ok"`` + metrics."""
|
|
94
|
+
return {
|
|
95
|
+
"status": "ok",
|
|
96
|
+
"files_processed": self.files_processed,
|
|
97
|
+
"entities": self.entities,
|
|
98
|
+
"triples": self.triples,
|
|
99
|
+
"skipped": [s.model_dump() for s in self.skipped],
|
|
100
|
+
"unknown_keys": [u.model_dump() for u in self.unknown_keys],
|
|
101
|
+
"unresolved_participants": [u.model_dump() for u in self.unresolved_participants],
|
|
102
|
+
"sources": self.sources,
|
|
103
|
+
"findings": self.findings,
|
|
104
|
+
"anchors": self.anchors,
|
|
105
|
+
"research_warnings": [w.model_dump() for w in self.research_warnings],
|
|
106
|
+
"graph_path": self.graph_path,
|
|
107
|
+
}
|