wiki-studio 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.
- wiki_studio/__init__.py +5 -0
- wiki_studio/__main__.py +5 -0
- wiki_studio/api.py +410 -0
- wiki_studio/audit.py +276 -0
- wiki_studio/audit_flush.py +243 -0
- wiki_studio/audit_lint.py +210 -0
- wiki_studio/backends/__init__.py +170 -0
- wiki_studio/backends/copilot_cli.py +371 -0
- wiki_studio/backends/json_extract.py +170 -0
- wiki_studio/backends/test.py +208 -0
- wiki_studio/cli.py +525 -0
- wiki_studio/errors.py +68 -0
- wiki_studio/health_lint.py +461 -0
- wiki_studio/ingestor.py +90 -0
- wiki_studio/ingestor_multi.py +208 -0
- wiki_studio/lint.py +269 -0
- wiki_studio/paths.py +197 -0
- wiki_studio/promoter.py +611 -0
- wiki_studio/prompt.py +425 -0
- wiki_studio/proposal.py +463 -0
- wiki_studio/querier.py +379 -0
- wiki_studio/schema/WIKI_MAINTAINER.md +121 -0
- wiki_studio/schema/audit-rules.md +132 -0
- wiki_studio/schema/citation-rules.md +129 -0
- wiki_studio/schema/ingest-rules.md +59 -0
- wiki_studio/schema/lint-rules.md +96 -0
- wiki_studio/schema/page-types.md +177 -0
- wiki_studio/wiki_state.py +208 -0
- wiki_studio/yaml_lite.py +150 -0
- wiki_studio-0.1.0.dist-info/METADATA +239 -0
- wiki_studio-0.1.0.dist-info/RECORD +35 -0
- wiki_studio-0.1.0.dist-info/WHEEL +4 -0
- wiki_studio-0.1.0.dist-info/entry_points.txt +3 -0
- wiki_studio-0.1.0.dist-info/licenses/LICENSE +201 -0
- wiki_studio-0.1.0.dist-info/licenses/NOTICE +19 -0
wiki_studio/__init__.py
ADDED
wiki_studio/__main__.py
ADDED
wiki_studio/api.py
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
# Copyright (c) 2026 EGOGE
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""wiki_studio.api — the library surface: ``ingest``, ``promote``, ``query``, ``lint``, ``audit_flush``.
|
|
4
|
+
|
|
5
|
+
Each function takes a :class:`~wiki_studio.paths.WikiPaths`, returns a typed result
|
|
6
|
+
and raises the exceptions in :mod:`wiki_studio.errors`. Nothing here prints; the
|
|
7
|
+
CLI in :mod:`wiki_studio.cli` is a thin parser over these functions that renders
|
|
8
|
+
results and maps exceptions to exit codes.
|
|
9
|
+
|
|
10
|
+
Example::
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from wiki_studio import api
|
|
14
|
+
from wiki_studio.paths import WikiPaths
|
|
15
|
+
|
|
16
|
+
paths = WikiPaths.for_root(Path("."))
|
|
17
|
+
result = api.ingest(Path("notes/incident.md"), paths, backend="test")
|
|
18
|
+
api.promote(result.written, paths)
|
|
19
|
+
print(api.query("what caused the outage?", paths, backend="test").answer.answer)
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import datetime
|
|
25
|
+
import os
|
|
26
|
+
import shutil
|
|
27
|
+
from dataclasses import dataclass, replace
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import TYPE_CHECKING
|
|
30
|
+
|
|
31
|
+
from .audit import append_ingest_log, build_log_record
|
|
32
|
+
from .audit_flush import FlushResult, flush_state
|
|
33
|
+
from .audit_lint import validate_audit_tree
|
|
34
|
+
from .backends import resolve_backend
|
|
35
|
+
from .backends.copilot_cli import CopilotCliBackend
|
|
36
|
+
from .errors import OutputWriteError, PathOutsideRootError, SourceMissingError
|
|
37
|
+
from .health_lint import lint_health
|
|
38
|
+
from .ingestor import DefaultIngestor
|
|
39
|
+
from .ingestor_multi import DefaultMultiIngestor, write_patch_set_dir
|
|
40
|
+
from .lint import lint_wiki, list_pages
|
|
41
|
+
from .promoter import PromoteResult
|
|
42
|
+
from .promoter import promote as _promote
|
|
43
|
+
from .proposal import IngestLogRecord, IngestProposal, IngestRequest, QueryAnswer, WikiPatchSet, render_proposal_file
|
|
44
|
+
from .querier import DefaultQuerier
|
|
45
|
+
|
|
46
|
+
if TYPE_CHECKING:
|
|
47
|
+
from .health_lint import HealthLintResult
|
|
48
|
+
from .ingestor import Backend
|
|
49
|
+
from .paths import WikiPaths
|
|
50
|
+
|
|
51
|
+
# The env var the legacy single-source ingestor reads its task hint from.
|
|
52
|
+
_TASK_ENV = "WIKI_INGEST_TASK"
|
|
53
|
+
_GATE_ONLY_TASK = "gate-only"
|
|
54
|
+
_SNAPSHOT_DIR_NAME = ".ingest-snapshot"
|
|
55
|
+
_FILE_BACK_SLUG = "query-fileback"
|
|
56
|
+
|
|
57
|
+
ALLOW_OUT_OF_REPO_HINT = "Pass --allow-out-of-repo to override (only when ingesting external artifacts deliberately)."
|
|
58
|
+
ALLOW_OUT_OF_REPO_PROMOTE_HINT = (
|
|
59
|
+
"Pass --allow-out-of-repo to override (only when the proposal dir comes from a trusted source you've audited)."
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ── Results ───────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class IngestResult:
|
|
68
|
+
"""What ``ingest`` produced.
|
|
69
|
+
|
|
70
|
+
``written`` is the proposal file (legacy single-source) or the patch-set
|
|
71
|
+
directory (multi-page); None in check mode. ``edits`` lists the
|
|
72
|
+
wiki-relative pages the proposal touches.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
disposition: str
|
|
76
|
+
written: Path | None
|
|
77
|
+
edits: tuple[str, ...]
|
|
78
|
+
rationale: str
|
|
79
|
+
dry_run: bool
|
|
80
|
+
check: bool
|
|
81
|
+
patch: WikiPatchSet | None
|
|
82
|
+
proposal: IngestProposal | None
|
|
83
|
+
record: IngestLogRecord | None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True)
|
|
87
|
+
class QueryResult:
|
|
88
|
+
"""An answer with citations; with ``file_back`` also the patch-set and where it was written."""
|
|
89
|
+
|
|
90
|
+
answer: QueryAnswer
|
|
91
|
+
patch: WikiPatchSet | None
|
|
92
|
+
file_back_dir: Path | None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class LintResult:
|
|
97
|
+
"""Structural violations, audit-format errors and (optionally) knowledge-health findings."""
|
|
98
|
+
|
|
99
|
+
violations: tuple[str, ...]
|
|
100
|
+
pages_checked: int
|
|
101
|
+
audit_errors: tuple[str, ...]
|
|
102
|
+
health: HealthLintResult | None
|
|
103
|
+
strict: bool
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def passed(self) -> bool:
|
|
107
|
+
"""Structural violations always fail; audit and health findings fail only under ``strict``."""
|
|
108
|
+
if self.violations:
|
|
109
|
+
return False
|
|
110
|
+
if not self.strict:
|
|
111
|
+
return True
|
|
112
|
+
if self.audit_errors:
|
|
113
|
+
return False
|
|
114
|
+
return not (self.health is not None and self.health.findings)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
AuditFlushResult = FlushResult
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ── Backends ──────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def prepare_backend(
|
|
124
|
+
backend: str | Backend | None,
|
|
125
|
+
paths: WikiPaths,
|
|
126
|
+
*,
|
|
127
|
+
redact_output: bool = True,
|
|
128
|
+
) -> Backend:
|
|
129
|
+
"""Resolve a backend by name (or env/auto-detect when None) and apply per-run settings.
|
|
130
|
+
|
|
131
|
+
A :class:`Backend` instance is used as given; a copilot-CLI backend gets a
|
|
132
|
+
copy carrying the stderr redaction setting and the wiki label used in
|
|
133
|
+
rendered prompts.
|
|
134
|
+
"""
|
|
135
|
+
resolved: Backend = backend if backend is not None and not isinstance(backend, str) else resolve_backend(backend)
|
|
136
|
+
if isinstance(resolved, CopilotCliBackend):
|
|
137
|
+
return resolved.with_options(redact_output=redact_output, wiki_label=paths.wiki_label)
|
|
138
|
+
return resolved
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def backend_display_name(backend: object, requested: str | None) -> str:
|
|
142
|
+
"""Best-effort name for the resolved backend, recorded in proposal frontmatter."""
|
|
143
|
+
if requested:
|
|
144
|
+
return requested
|
|
145
|
+
cli_name = getattr(backend, "_cli_name", None)
|
|
146
|
+
if cli_name:
|
|
147
|
+
return str(cli_name)
|
|
148
|
+
cls_name = backend.__class__.__name__
|
|
149
|
+
return cls_name.removesuffix("Backend").lower() or "unknown"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# ── ingest ────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _proposal_filename(proposal: IngestProposal) -> str:
|
|
156
|
+
"""``<YYYY-MM-DD>-<slug>.md``."""
|
|
157
|
+
today = datetime.date.today().isoformat()
|
|
158
|
+
base = proposal.slug or f"unslugged-{proposal.disposition}"
|
|
159
|
+
return f"{today}-{base}.md"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _write_proposal_file(proposal: IngestProposal, request: IngestRequest, backend_name: str, output_dir: Path) -> Path:
|
|
163
|
+
proposal_path = output_dir / _proposal_filename(proposal)
|
|
164
|
+
try:
|
|
165
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
166
|
+
proposal_path.write_text(render_proposal_file(proposal, request, backend_name), encoding="utf-8")
|
|
167
|
+
except OSError as exc:
|
|
168
|
+
raise OutputWriteError(f"Could not write proposal file at {proposal_path}: {exc}") from exc
|
|
169
|
+
return proposal_path.resolve()
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _write_ingest_snapshot(proposal_dir: Path) -> None:
|
|
173
|
+
"""Immutable copy of plan.json + preview/ so promote can diff the curator's edits (never on dry-run or check)."""
|
|
174
|
+
snapshot_dir = proposal_dir / _SNAPSHOT_DIR_NAME
|
|
175
|
+
snapshot_dir.mkdir(parents=True, exist_ok=True)
|
|
176
|
+
plan = proposal_dir / "plan.json"
|
|
177
|
+
if plan.is_file():
|
|
178
|
+
shutil.copy2(str(plan), str(snapshot_dir / "plan.json"))
|
|
179
|
+
preview = proposal_dir / "preview"
|
|
180
|
+
if preview.is_dir():
|
|
181
|
+
preview_snapshot = snapshot_dir / "preview"
|
|
182
|
+
if preview_snapshot.exists():
|
|
183
|
+
shutil.rmtree(str(preview_snapshot))
|
|
184
|
+
shutil.copytree(str(preview), str(preview_snapshot))
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _require_within_root(kind: str, path: Path, paths: WikiPaths, allow: bool, hint: str) -> None:
|
|
188
|
+
if allow or paths.within_root(path):
|
|
189
|
+
return
|
|
190
|
+
raise PathOutsideRootError(f"{kind} is outside the repository tree: {path}\n Repo root: {paths.root}\n {hint}")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def ingest(
|
|
194
|
+
source: Path,
|
|
195
|
+
paths: WikiPaths,
|
|
196
|
+
*,
|
|
197
|
+
backend: str | Backend | None = None,
|
|
198
|
+
legacy_single_source: bool = False,
|
|
199
|
+
dry_run: bool = False,
|
|
200
|
+
check: bool = False,
|
|
201
|
+
output_dir: Path | None = None,
|
|
202
|
+
allow_out_of_repo: bool = False,
|
|
203
|
+
redact_output: bool = True,
|
|
204
|
+
) -> IngestResult:
|
|
205
|
+
"""Run the ingest gate over ``source`` and write the proposal.
|
|
206
|
+
|
|
207
|
+
Multi-page by default (a patch-set directory under the proposals dir);
|
|
208
|
+
``legacy_single_source`` runs the v1 single-page generator. ``check``
|
|
209
|
+
runs the gate with zero filesystem side effects; ``dry_run`` skips the
|
|
210
|
+
draft body (legacy) or marks the audit record (multi).
|
|
211
|
+
|
|
212
|
+
Raises ``ValueError`` when ``check`` and ``dry_run`` are combined, and the
|
|
213
|
+
:mod:`wiki_studio.errors` exceptions for everything else.
|
|
214
|
+
"""
|
|
215
|
+
if check and dry_run:
|
|
216
|
+
raise ValueError("--check and --dry-run are mutually exclusive")
|
|
217
|
+
source_path = Path(source)
|
|
218
|
+
if not source_path.exists():
|
|
219
|
+
raise SourceMissingError(f"source file not found: {source_path}")
|
|
220
|
+
_require_within_root("source path", source_path, paths, allow_out_of_repo, ALLOW_OUT_OF_REPO_HINT)
|
|
221
|
+
|
|
222
|
+
requested = backend if isinstance(backend, str) else None
|
|
223
|
+
resolved = prepare_backend(backend, paths, redact_output=redact_output)
|
|
224
|
+
backend_name = backend_display_name(resolved, requested)
|
|
225
|
+
request = IngestRequest(source_path=source_path, source_kind="file", backend_name=backend_name)
|
|
226
|
+
proposals_root = output_dir if output_dir is not None else paths.proposals_dir
|
|
227
|
+
|
|
228
|
+
if legacy_single_source:
|
|
229
|
+
return _ingest_legacy(request, paths, resolved, backend_name, proposals_root, dry_run, check, allow_out_of_repo)
|
|
230
|
+
return _ingest_multi(request, paths, resolved, backend_name, proposals_root, dry_run, check, allow_out_of_repo)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _ingest_legacy(
|
|
234
|
+
request: IngestRequest,
|
|
235
|
+
paths: WikiPaths,
|
|
236
|
+
backend: Backend,
|
|
237
|
+
backend_name: str,
|
|
238
|
+
output_dir: Path,
|
|
239
|
+
dry_run: bool,
|
|
240
|
+
check: bool,
|
|
241
|
+
allow_out_of_repo: bool,
|
|
242
|
+
) -> IngestResult:
|
|
243
|
+
ingestor = DefaultIngestor(backend=backend, paths=paths)
|
|
244
|
+
# The legacy prompt reads the dry-run intent from the environment; the
|
|
245
|
+
# render side still strips the body as a safety net for stubs that
|
|
246
|
+
# ignore the task hint.
|
|
247
|
+
env_backup = os.environ.get(_TASK_ENV)
|
|
248
|
+
if dry_run:
|
|
249
|
+
os.environ[_TASK_ENV] = _GATE_ONLY_TASK
|
|
250
|
+
try:
|
|
251
|
+
proposal = ingestor.ingest(request)
|
|
252
|
+
finally:
|
|
253
|
+
if env_backup is None:
|
|
254
|
+
os.environ.pop(_TASK_ENV, None)
|
|
255
|
+
else:
|
|
256
|
+
os.environ[_TASK_ENV] = env_backup
|
|
257
|
+
|
|
258
|
+
edits = (proposal.slug,) if proposal.slug else ()
|
|
259
|
+
if check:
|
|
260
|
+
return IngestResult(proposal.disposition, None, edits, proposal.reason, False, True, None, proposal, None)
|
|
261
|
+
if dry_run:
|
|
262
|
+
proposal = replace(proposal, draft_markdown=None)
|
|
263
|
+
|
|
264
|
+
proposal_path = _write_proposal_file(proposal, request, backend_name, output_dir)
|
|
265
|
+
if not dry_run:
|
|
266
|
+
_write_ingest_snapshot(proposal_path.parent)
|
|
267
|
+
record = build_log_record(
|
|
268
|
+
source_file=request.source_path,
|
|
269
|
+
paths=paths,
|
|
270
|
+
backend_name=backend_name,
|
|
271
|
+
proposal_dir_path=proposal_path.parent,
|
|
272
|
+
allow_out_of_repo=allow_out_of_repo,
|
|
273
|
+
legacy_proposal=proposal,
|
|
274
|
+
dry_run=dry_run,
|
|
275
|
+
)
|
|
276
|
+
append_ingest_log(paths, record)
|
|
277
|
+
return IngestResult(
|
|
278
|
+
proposal.disposition, proposal_path, edits, proposal.reason, dry_run, False, None, proposal, record
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _ingest_multi(
|
|
283
|
+
request: IngestRequest,
|
|
284
|
+
paths: WikiPaths,
|
|
285
|
+
backend: Backend,
|
|
286
|
+
backend_name: str,
|
|
287
|
+
proposals_root: Path,
|
|
288
|
+
dry_run: bool,
|
|
289
|
+
check: bool,
|
|
290
|
+
allow_out_of_repo: bool,
|
|
291
|
+
) -> IngestResult:
|
|
292
|
+
multi = DefaultMultiIngestor(backend=backend, paths=paths)
|
|
293
|
+
patch = multi.ingest_multi(request)
|
|
294
|
+
disposition = "accept" if patch.edits else "reject"
|
|
295
|
+
edits = tuple(e.path for e in patch.edits)
|
|
296
|
+
if check:
|
|
297
|
+
return IngestResult(disposition, None, edits, patch.rationale, False, True, patch, None, None)
|
|
298
|
+
|
|
299
|
+
today = datetime.date.today().isoformat()
|
|
300
|
+
output_dir = proposals_root / f"{today}-{request.source_path.stem or 'source'}"
|
|
301
|
+
try:
|
|
302
|
+
written = write_patch_set_dir(patch, output_dir)
|
|
303
|
+
except OSError as exc:
|
|
304
|
+
raise OutputWriteError(f"could not write patch-set dir at {output_dir}: {exc}") from exc
|
|
305
|
+
if not dry_run and patch.edits:
|
|
306
|
+
_write_ingest_snapshot(written)
|
|
307
|
+
record = build_log_record(
|
|
308
|
+
source_file=request.source_path,
|
|
309
|
+
paths=paths,
|
|
310
|
+
backend_name=backend_name,
|
|
311
|
+
proposal_dir_path=written,
|
|
312
|
+
allow_out_of_repo=allow_out_of_repo,
|
|
313
|
+
patch=patch,
|
|
314
|
+
dry_run=dry_run,
|
|
315
|
+
)
|
|
316
|
+
append_ingest_log(paths, record)
|
|
317
|
+
return IngestResult(disposition, written, edits, patch.rationale, dry_run, False, patch, None, record)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
# ── promote ───────────────────────────────────────────────────
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def promote(
|
|
324
|
+
proposal_dir: Path,
|
|
325
|
+
paths: WikiPaths,
|
|
326
|
+
*,
|
|
327
|
+
dry_run: bool = False,
|
|
328
|
+
allow_out_of_repo: bool = False,
|
|
329
|
+
) -> PromoteResult:
|
|
330
|
+
"""Apply a patch-set directory to the wiki atomically (the only write into the wiki).
|
|
331
|
+
|
|
332
|
+
Raises ``SourceMissingError`` when the directory does not exist,
|
|
333
|
+
``PathOutsideRootError`` when it lies outside the root without
|
|
334
|
+
``allow_out_of_repo``, and the promoter's validation/apply errors.
|
|
335
|
+
"""
|
|
336
|
+
proposal_path = Path(proposal_dir)
|
|
337
|
+
if not proposal_path.exists():
|
|
338
|
+
raise SourceMissingError(f"proposals dir not found: {proposal_path}")
|
|
339
|
+
_require_within_root("proposal dir", proposal_path, paths, allow_out_of_repo, ALLOW_OUT_OF_REPO_PROMOTE_HINT)
|
|
340
|
+
return _promote(proposals_dir=proposal_path, paths=paths, dry_run=dry_run)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
# ── query ─────────────────────────────────────────────────────
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def query(
|
|
347
|
+
question: str,
|
|
348
|
+
paths: WikiPaths,
|
|
349
|
+
*,
|
|
350
|
+
backend: str | Backend | None = None,
|
|
351
|
+
file_back: bool = False,
|
|
352
|
+
max_pages: int | None = None,
|
|
353
|
+
output_dir: Path | None = None,
|
|
354
|
+
redact_output: bool = True,
|
|
355
|
+
) -> QueryResult:
|
|
356
|
+
"""Answer ``question`` index-first over the wiki; with ``file_back`` also write a patch-set for the answer."""
|
|
357
|
+
resolved = prepare_backend(backend, paths, redact_output=redact_output)
|
|
358
|
+
querier = (
|
|
359
|
+
DefaultQuerier(backend=resolved, paths=paths)
|
|
360
|
+
if max_pages is None
|
|
361
|
+
else DefaultQuerier(resolved, paths, max_pages)
|
|
362
|
+
)
|
|
363
|
+
if not file_back:
|
|
364
|
+
return QueryResult(answer=querier.query(question), patch=None, file_back_dir=None)
|
|
365
|
+
|
|
366
|
+
answer, patch = querier.query_with_file_back(question)
|
|
367
|
+
if not patch.edits:
|
|
368
|
+
return QueryResult(answer=answer, patch=patch, file_back_dir=None)
|
|
369
|
+
today = datetime.date.today().isoformat()
|
|
370
|
+
out = (output_dir if output_dir is not None else paths.proposals_dir) / f"{today}-{_FILE_BACK_SLUG}"
|
|
371
|
+
try:
|
|
372
|
+
written = write_patch_set_dir(patch, out)
|
|
373
|
+
except OSError as exc:
|
|
374
|
+
raise OutputWriteError(f"could not write file-back dir: {exc}") from exc
|
|
375
|
+
return QueryResult(answer=answer, patch=patch, file_back_dir=written)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
# ── lint ──────────────────────────────────────────────────────
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def lint(
|
|
382
|
+
paths: WikiPaths,
|
|
383
|
+
*,
|
|
384
|
+
health: bool = False,
|
|
385
|
+
strict: bool = False,
|
|
386
|
+
only: list[str] | None = None,
|
|
387
|
+
backend: str | Backend | None = None,
|
|
388
|
+
redact_output: bool = True,
|
|
389
|
+
) -> LintResult:
|
|
390
|
+
"""Structural lint plus the audit-format pass; ``health`` adds the knowledge-health checks.
|
|
391
|
+
|
|
392
|
+
``backend`` is only consulted with ``health`` (for the contradiction
|
|
393
|
+
check); when it is None, contradictions are skipped.
|
|
394
|
+
"""
|
|
395
|
+
violations = tuple(lint_wiki(paths.wiki_dir))
|
|
396
|
+
pages_checked = len(list_pages(paths.wiki_dir))
|
|
397
|
+
audit_errors = tuple(validate_audit_tree(paths))
|
|
398
|
+
health_result: HealthLintResult | None = None
|
|
399
|
+
if health:
|
|
400
|
+
resolved = prepare_backend(backend, paths, redact_output=redact_output) if backend is not None else None
|
|
401
|
+
health_result = lint_health(paths, only=only, backend=resolved)
|
|
402
|
+
return LintResult(violations, pages_checked, audit_errors, health_result, strict)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
# ── audit-flush ───────────────────────────────────────────────
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def audit_flush(paths: WikiPaths, *, dry_run: bool = False) -> AuditFlushResult:
|
|
409
|
+
"""Commit pending ingest-log lines (or report them with ``dry_run``); raises ``AuditFlushError``."""
|
|
410
|
+
return flush_state(paths, dry_run)
|
wiki_studio/audit.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
# Copyright (c) 2026 EGOGE
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
# wiki-studio.audit — the ONLY module that writes ingest-log.md.
|
|
4
|
+
#
|
|
5
|
+
# Contains the helpers for computing source_sha, proposal_hash, and
|
|
6
|
+
# truncate_reason, plus build_log_record (derives a patch-set-oriented
|
|
7
|
+
# IngestLogRecord from a WikiPatchSet or IngestProposal) and
|
|
8
|
+
# append_ingest_log (the fail-closed, marker-prefixed writer).
|
|
9
|
+
#
|
|
10
|
+
# append_ingest_log is called from cli.py ingest handlers AFTER the
|
|
11
|
+
# proposal dir is materialized + dry-run stripped (Design Decision 8), NOT
|
|
12
|
+
# from ingestor.py / ingestor_multi.py.
|
|
13
|
+
#
|
|
14
|
+
# See schema/audit-rules.md for the normative format spec.
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import datetime
|
|
19
|
+
import hashlib
|
|
20
|
+
import json
|
|
21
|
+
import re
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import TYPE_CHECKING
|
|
24
|
+
|
|
25
|
+
from .audit_lint import INGEST_LOG_MARKER
|
|
26
|
+
from .errors import OutputWriteError
|
|
27
|
+
from .proposal import IngestLogRecord, IngestProposal, WikiPatchSet
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from .paths import WikiPaths
|
|
31
|
+
|
|
32
|
+
# ── Helpers ────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def source_sha(source_file: Path) -> str:
|
|
36
|
+
"""Return the SHA-256 lowercase-hex digest of source_file's raw bytes."""
|
|
37
|
+
return hashlib.sha256(source_file.read_bytes()).hexdigest()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def proposal_hash(proposal_dir: Path) -> str:
|
|
41
|
+
"""Return the SHA-256 canonical hash of the proposal payload.
|
|
42
|
+
|
|
43
|
+
Canonicalization recipe (from audit-rules.md / spec.md DD3):
|
|
44
|
+
1. Collect plan.json + every file under preview/ recursively.
|
|
45
|
+
Exclude .ingest-snapshot/ and any path component beginning with '.'.
|
|
46
|
+
2. Sort by repo-relative POSIX path under LC_ALL=C (byte order).
|
|
47
|
+
3. For each file, append:
|
|
48
|
+
<rel_path UTF-8> + b'\\n' + raw bytes + b'\\n--\\n'
|
|
49
|
+
4. SHA-256 the concatenated buffer; return lowercase hex.
|
|
50
|
+
"""
|
|
51
|
+
files: list[Path] = []
|
|
52
|
+
|
|
53
|
+
plan = proposal_dir / "plan.json"
|
|
54
|
+
if plan.is_file():
|
|
55
|
+
files.append(plan)
|
|
56
|
+
|
|
57
|
+
preview = proposal_dir / "preview"
|
|
58
|
+
if preview.is_dir():
|
|
59
|
+
for p in preview.rglob("*"):
|
|
60
|
+
if p.is_file():
|
|
61
|
+
files.append(p)
|
|
62
|
+
|
|
63
|
+
def _is_excluded(p: Path) -> bool:
|
|
64
|
+
"""True if any path component begins with '.' (dotfile/dotdir)."""
|
|
65
|
+
# Check each component relative to proposal_dir.
|
|
66
|
+
try:
|
|
67
|
+
rel = p.relative_to(proposal_dir)
|
|
68
|
+
except ValueError:
|
|
69
|
+
return True
|
|
70
|
+
return any(part.startswith(".") for part in rel.parts)
|
|
71
|
+
|
|
72
|
+
included = [f for f in files if not _is_excluded(f)]
|
|
73
|
+
|
|
74
|
+
# Sort by repo-relative POSIX path, byte order (LC_ALL=C).
|
|
75
|
+
def _sort_key(p: Path) -> bytes:
|
|
76
|
+
return p.relative_to(proposal_dir).as_posix().encode("utf-8")
|
|
77
|
+
|
|
78
|
+
included.sort(key=_sort_key)
|
|
79
|
+
|
|
80
|
+
buf = bytearray()
|
|
81
|
+
for f in included:
|
|
82
|
+
rel_posix = f.relative_to(proposal_dir).as_posix()
|
|
83
|
+
buf += rel_posix.encode("utf-8")
|
|
84
|
+
buf += b"\n"
|
|
85
|
+
buf += f.read_bytes()
|
|
86
|
+
buf += b"\n--\n"
|
|
87
|
+
|
|
88
|
+
return hashlib.sha256(bytes(buf)).hexdigest()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def truncate_reason(s: str) -> str:
|
|
92
|
+
"""Collapse whitespace/newlines to single spaces; truncate to 240 codepoints.
|
|
93
|
+
|
|
94
|
+
If the result exceeds 240 codepoints, it is truncated to 239 codepoints
|
|
95
|
+
followed by U+2026 (HORIZONTAL ELLIPSIS) — total exactly 240.
|
|
96
|
+
"""
|
|
97
|
+
# Collapse runs of whitespace (including newlines) to a single space.
|
|
98
|
+
collapsed = re.sub(r"\s+", " ", s).strip()
|
|
99
|
+
if len(collapsed) <= 240:
|
|
100
|
+
return collapsed
|
|
101
|
+
return collapsed[:239] + "…"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ── Record builder ─────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _page_type_from_path(path: str) -> str:
|
|
108
|
+
"""Derive a page type from a wiki-relative path (best-effort)."""
|
|
109
|
+
# Most wiki paths are <page_type_dir>/<slug>.md; the directory name
|
|
110
|
+
# is the canonical page type string. E.g. "concepts/foo.md" → "concept"
|
|
111
|
+
# but the directory is "concepts" not "concept". We want the
|
|
112
|
+
# *directory name* as the type label for audit purposes.
|
|
113
|
+
parts = Path(path).parts
|
|
114
|
+
if len(parts) >= 2:
|
|
115
|
+
return parts[0]
|
|
116
|
+
return "unknown"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def build_log_record(
|
|
120
|
+
*,
|
|
121
|
+
source_file: Path,
|
|
122
|
+
paths: WikiPaths,
|
|
123
|
+
backend_name: str,
|
|
124
|
+
proposal_dir_path: Path | None,
|
|
125
|
+
allow_out_of_repo: bool = False,
|
|
126
|
+
patch: WikiPatchSet | None = None,
|
|
127
|
+
legacy_proposal: IngestProposal | None = None,
|
|
128
|
+
dry_run: bool = False,
|
|
129
|
+
) -> IngestLogRecord:
|
|
130
|
+
"""Derive an IngestLogRecord from a WikiPatchSet or IngestProposal.
|
|
131
|
+
|
|
132
|
+
Exactly one of ``patch`` or ``legacy_proposal`` must be supplied.
|
|
133
|
+
|
|
134
|
+
Parameters
|
|
135
|
+
----------
|
|
136
|
+
source_file : Path
|
|
137
|
+
The source file on disk (used to compute source_sha and derive
|
|
138
|
+
source_path).
|
|
139
|
+
paths : WikiPaths
|
|
140
|
+
The resolved layout; ``paths.root`` anchors the repo-relative POSIX source_path.
|
|
141
|
+
backend_name : str
|
|
142
|
+
Backend display name (``"test"``, ``"claude"``, etc.).
|
|
143
|
+
proposal_dir_path : Path | None
|
|
144
|
+
Path to the materialized proposal directory (used as the basename
|
|
145
|
+
for ``proposal_dir`` and to compute ``proposal_hash``). None only
|
|
146
|
+
when the dir was not materialized (impossible for a normal or
|
|
147
|
+
--dry-run run; --check never calls this).
|
|
148
|
+
allow_out_of_repo : bool
|
|
149
|
+
When True, source_path is recorded verbatim and
|
|
150
|
+
source_repo_relative is False.
|
|
151
|
+
patch : WikiPatchSet | None
|
|
152
|
+
Multi-page ingest result. Disposition is "accept" iff edits
|
|
153
|
+
non-empty; reason is patch.rationale.
|
|
154
|
+
legacy_proposal : IngestProposal | None
|
|
155
|
+
Legacy single-source result. Disposition and reason taken directly.
|
|
156
|
+
dry_run : bool
|
|
157
|
+
When True, proposal_hash is forced to None (body was stripped).
|
|
158
|
+
"""
|
|
159
|
+
if patch is None and legacy_proposal is None:
|
|
160
|
+
raise ValueError("build_log_record: must supply patch or legacy_proposal")
|
|
161
|
+
if patch is not None and legacy_proposal is not None:
|
|
162
|
+
raise ValueError("build_log_record: supply exactly one of patch/legacy_proposal")
|
|
163
|
+
|
|
164
|
+
# source_path and source_repo_relative.
|
|
165
|
+
try:
|
|
166
|
+
rel = source_file.resolve().relative_to(paths.root.resolve())
|
|
167
|
+
src_path = rel.as_posix()
|
|
168
|
+
src_repo_relative = True
|
|
169
|
+
except ValueError:
|
|
170
|
+
src_path = str(source_file)
|
|
171
|
+
src_repo_relative = False
|
|
172
|
+
|
|
173
|
+
sha = source_sha(source_file)
|
|
174
|
+
|
|
175
|
+
now_ts = datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
176
|
+
|
|
177
|
+
if patch is not None:
|
|
178
|
+
# Multi-page (WikiPatchSet).
|
|
179
|
+
disp = "accept" if patch.edits else "reject"
|
|
180
|
+
raw_reason = patch.rationale or ""
|
|
181
|
+
if patch.edits:
|
|
182
|
+
tgt_paths = sorted({e.path for e in patch.edits})
|
|
183
|
+
pg_types = sorted({_page_type_from_path(e.path) for e in patch.edits})
|
|
184
|
+
else:
|
|
185
|
+
tgt_paths = []
|
|
186
|
+
pg_types = []
|
|
187
|
+
else:
|
|
188
|
+
# Legacy (IngestProposal).
|
|
189
|
+
assert legacy_proposal is not None
|
|
190
|
+
disp = legacy_proposal.disposition
|
|
191
|
+
raw_reason = legacy_proposal.reason or ""
|
|
192
|
+
if legacy_proposal.slug:
|
|
193
|
+
page_type = legacy_proposal.page_type or "unknown"
|
|
194
|
+
page_dir = _LEGACY_PAGE_TYPE_DIR.get(page_type, page_type + "s")
|
|
195
|
+
tgt_paths = [f"{page_dir}/{legacy_proposal.slug}.md"]
|
|
196
|
+
pg_types = [page_type]
|
|
197
|
+
else:
|
|
198
|
+
tgt_paths = []
|
|
199
|
+
pg_types = []
|
|
200
|
+
|
|
201
|
+
reason = truncate_reason(raw_reason)
|
|
202
|
+
|
|
203
|
+
prop_dir_name: str | None = proposal_dir_path.name if proposal_dir_path is not None else None
|
|
204
|
+
|
|
205
|
+
# proposal_hash: None on reject (empty edits), --dry-run, or no proposal dir.
|
|
206
|
+
p_hash: str | None = None
|
|
207
|
+
if not dry_run and disp == "accept" and proposal_dir_path is not None and proposal_dir_path.is_dir():
|
|
208
|
+
p_hash = proposal_hash(proposal_dir_path)
|
|
209
|
+
|
|
210
|
+
return IngestLogRecord(
|
|
211
|
+
v=1,
|
|
212
|
+
ts=now_ts,
|
|
213
|
+
source_path=src_path,
|
|
214
|
+
source_repo_relative=src_repo_relative,
|
|
215
|
+
source_sha=sha,
|
|
216
|
+
backend=backend_name,
|
|
217
|
+
disposition=disp,
|
|
218
|
+
reason=reason,
|
|
219
|
+
proposal_dir=prop_dir_name,
|
|
220
|
+
target_paths=tgt_paths,
|
|
221
|
+
page_types=pg_types,
|
|
222
|
+
proposal_hash=p_hash,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# Map legacy page_type values to their wiki directory names.
|
|
227
|
+
_LEGACY_PAGE_TYPE_DIR: dict[str, str] = {
|
|
228
|
+
"concept": "concepts",
|
|
229
|
+
"workflow": "workflows",
|
|
230
|
+
"incident": "incidents",
|
|
231
|
+
"decision": "decisions",
|
|
232
|
+
"playbook": "playbooks",
|
|
233
|
+
"glossary": "glossary",
|
|
234
|
+
"open-question": "open-questions",
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# ── Append writer (the only writer of ingest-log.md) ──────────────────────
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def append_ingest_log(paths: WikiPaths, record: IngestLogRecord) -> None:
|
|
242
|
+
"""Append one NDJSON line to the ingest log (``paths.audit_log``).
|
|
243
|
+
|
|
244
|
+
Creates the file with the marker preamble if it does not exist.
|
|
245
|
+
Fail-closed: any OSError is re-raised so the caller can exit via
|
|
246
|
+
OutputWriteError (exit code 6).
|
|
247
|
+
"""
|
|
248
|
+
log_path = paths.audit_log
|
|
249
|
+
try:
|
|
250
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
251
|
+
|
|
252
|
+
# Serialize the record to a JSON object (one line, no trailing newline yet).
|
|
253
|
+
# We use the dataclass fields in definition order to match the spec table.
|
|
254
|
+
import dataclasses
|
|
255
|
+
|
|
256
|
+
obj = dataclasses.asdict(record)
|
|
257
|
+
line = json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
|
|
258
|
+
|
|
259
|
+
if not log_path.exists():
|
|
260
|
+
# First write: create with marker + blank line.
|
|
261
|
+
content = INGEST_LOG_MARKER + "\n\n" + line + "\n"
|
|
262
|
+
log_path.write_text(content, encoding="utf-8")
|
|
263
|
+
return
|
|
264
|
+
|
|
265
|
+
# Append a single line. We open in binary append mode to avoid
|
|
266
|
+
# any implicit re-encoding of the existing content.
|
|
267
|
+
existing = log_path.read_bytes()
|
|
268
|
+
# Ensure the file ends with a newline before we append.
|
|
269
|
+
if existing and existing[-1:] != b"\n":
|
|
270
|
+
existing += b"\n"
|
|
271
|
+
new_content = existing + (line + "\n").encode("utf-8")
|
|
272
|
+
# Write atomically by replacing the file contents; this avoids a
|
|
273
|
+
# partial-line race (the curator-driven pipeline is single-process).
|
|
274
|
+
log_path.write_bytes(new_content)
|
|
275
|
+
except OSError as exc:
|
|
276
|
+
raise OutputWriteError(f"audit log append failed for {log_path}: {exc}") from exc
|