ai-docs-toolkit 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.
Files changed (100) hide show
  1. ai_docs_toolkit/__init__.py +3 -0
  2. ai_docs_toolkit/__main__.py +4 -0
  3. ai_docs_toolkit/cli/__init__.py +1153 -0
  4. ai_docs_toolkit/context/__init__.py +13 -0
  5. ai_docs_toolkit/context/markdown_output.py +470 -0
  6. ai_docs_toolkit/core/__init__.py +360 -0
  7. ai_docs_toolkit/core/azure_devops_provider.py +569 -0
  8. ai_docs_toolkit/core/changed_files.py +235 -0
  9. ai_docs_toolkit/core/config.py +515 -0
  10. ai_docs_toolkit/core/context.py +666 -0
  11. ai_docs_toolkit/core/doctor.py +362 -0
  12. ai_docs_toolkit/core/file_lookup.py +656 -0
  13. ai_docs_toolkit/core/frontmatter.py +143 -0
  14. ai_docs_toolkit/core/graph.py +153 -0
  15. ai_docs_toolkit/core/impact.py +291 -0
  16. ai_docs_toolkit/core/init.py +837 -0
  17. ai_docs_toolkit/core/planning_operations.py +792 -0
  18. ai_docs_toolkit/core/planning_scaffold.py +334 -0
  19. ai_docs_toolkit/core/registry.py +161 -0
  20. ai_docs_toolkit/core/requirement_capture.py +152 -0
  21. ai_docs_toolkit/core/scanner.py +38 -0
  22. ai_docs_toolkit/core/schema_validation.py +222 -0
  23. ai_docs_toolkit/core/section_fix.py +169 -0
  24. ai_docs_toolkit/core/structure_validation.py +1155 -0
  25. ai_docs_toolkit/core/task_export.py +639 -0
  26. ai_docs_toolkit/docs/agent-installation.md +443 -0
  27. ai_docs_toolkit/graph/__init__.py +13 -0
  28. ai_docs_toolkit/graph/json_output.py +238 -0
  29. ai_docs_toolkit/impact/__init__.py +15 -0
  30. ai_docs_toolkit/impact/json_output.py +458 -0
  31. ai_docs_toolkit/mcp/__init__.py +8 -0
  32. ai_docs_toolkit/mcp/__main__.py +5 -0
  33. ai_docs_toolkit/mcp/server.py +345 -0
  34. ai_docs_toolkit/mcp/setup.py +107 -0
  35. ai_docs_toolkit/templates/project/.ai-docs/examples/skills/release-publication/SKILL.md +85 -0
  36. ai_docs_toolkit/templates/project/.ai-docs/reference/documentation-cascade-authoring-skill.md +157 -0
  37. ai_docs_toolkit/templates/project/.ai-docs/reference/documentation-change-review-skill.md +128 -0
  38. ai_docs_toolkit/templates/project/.ai-docs/reference/incoming-request-model.md +332 -0
  39. ai_docs_toolkit/templates/project/.ai-docs/reference/incoming-request-triage-skill.md +158 -0
  40. ai_docs_toolkit/templates/project/.ai-docs/reference/init-command-contract.md +262 -0
  41. ai_docs_toolkit/templates/project/.ai-docs/reference/planning-document-model.md +1483 -0
  42. ai_docs_toolkit/templates/project/.ai-docs/reference/toolkit-maintenance-context-routing.md +373 -0
  43. ai_docs_toolkit/templates/project/.ai-docs/schemas/document-types/acceptance.schema.yaml +17 -0
  44. ai_docs_toolkit/templates/project/.ai-docs/schemas/document-types/architecture-decision.schema.yaml +17 -0
  45. ai_docs_toolkit/templates/project/.ai-docs/schemas/document-types/asset.schema.yaml +17 -0
  46. ai_docs_toolkit/templates/project/.ai-docs/schemas/document-types/business-rule.schema.yaml +17 -0
  47. ai_docs_toolkit/templates/project/.ai-docs/schemas/document-types/contract.schema.yaml +17 -0
  48. ai_docs_toolkit/templates/project/.ai-docs/schemas/document-types/guide.schema.yaml +17 -0
  49. ai_docs_toolkit/templates/project/.ai-docs/schemas/document-types/implementation-task.schema.yaml +97 -0
  50. ai_docs_toolkit/templates/project/.ai-docs/schemas/document-types/index.schema.yaml +17 -0
  51. ai_docs_toolkit/templates/project/.ai-docs/schemas/document-types/inventory.schema.yaml +17 -0
  52. ai_docs_toolkit/templates/project/.ai-docs/schemas/document-types/module.schema.yaml +17 -0
  53. ai_docs_toolkit/templates/project/.ai-docs/schemas/document-types/operation.schema.yaml +17 -0
  54. ai_docs_toolkit/templates/project/.ai-docs/schemas/document-types/plan-stage.schema.yaml +89 -0
  55. ai_docs_toolkit/templates/project/.ai-docs/schemas/document-types/plan.schema.yaml +86 -0
  56. ai_docs_toolkit/templates/project/.ai-docs/schemas/document-types/runbook.schema.yaml +17 -0
  57. ai_docs_toolkit/templates/project/.ai-docs/schemas/document-types/scenario.schema.yaml +17 -0
  58. ai_docs_toolkit/templates/project/.ai-docs/schemas/document-types/standalone-task.schema.yaml +90 -0
  59. ai_docs_toolkit/templates/project/.ai-docs/schemas/document-types/task-registry.schema.yaml +114 -0
  60. ai_docs_toolkit/templates/project/.ai-docs/schemas/document-types/test-case.schema.yaml +23 -0
  61. ai_docs_toolkit/templates/project/.ai-docs/schemas/frontmatter.schema.yaml +181 -0
  62. ai_docs_toolkit/templates/project/.ai-docs/skills/documentation-cascade-authoring/SKILL.md +84 -0
  63. ai_docs_toolkit/templates/project/.ai-docs/skills/documentation-change-review/SKILL.md +76 -0
  64. ai_docs_toolkit/templates/project/.ai-docs/skills/incoming-request-triage/SKILL.md +90 -0
  65. ai_docs_toolkit/templates/project/.ai-docs/skills/index.md +51 -0
  66. ai_docs_toolkit/templates/project/.ai-docs/skills/planning-record-generation/SKILL.md +77 -0
  67. ai_docs_toolkit/templates/project/.ai-docs/skills/planning-task-execution/SKILL.md +106 -0
  68. ai_docs_toolkit/templates/project/.ai-docs/skills/toolkit-consumer-policy/SKILL.md +44 -0
  69. ai_docs_toolkit/templates/project/.ai-docs/skills/update-ai-docs-toolkit/SKILL.md +100 -0
  70. ai_docs_toolkit/templates/project/.ai-docs/templates/release/agent-context-architecture-beta-18.md +123 -0
  71. ai_docs_toolkit/templates/project/.ai-docs/templates/release/archive-validation-beta-12.md +123 -0
  72. ai_docs_toolkit/templates/project/.ai-docs/templates/release/cascade-impact-beta-14.md +69 -0
  73. ai_docs_toolkit/templates/project/.ai-docs/templates/release/consumer-migration-hardening.md +128 -0
  74. ai_docs_toolkit/templates/project/.ai-docs/templates/release/decision-visibility-beta-13.md +132 -0
  75. ai_docs_toolkit/templates/project/.ai-docs/templates/release/doctor-filesystem-traversal-beta-19.md +98 -0
  76. ai_docs_toolkit/templates/project/.ai-docs/templates/release/doctor-json-provenance-beta-20.md +107 -0
  77. ai_docs_toolkit/templates/project/.ai-docs/templates/release/documentation-cascade-authoring-beta-23.md +54 -0
  78. ai_docs_toolkit/templates/project/.ai-docs/templates/release/documentation-change-review-beta-21.md +105 -0
  79. ai_docs_toolkit/templates/project/.ai-docs/templates/release/incoming-request-storage-beta-17.md +70 -0
  80. ai_docs_toolkit/templates/project/.ai-docs/templates/release/incoming-request-triage-beta-22.md +52 -0
  81. ai_docs_toolkit/templates/project/.ai-docs/templates/release/migration-note.md +90 -0
  82. ai_docs_toolkit/templates/project/.ai-docs/templates/release/post-publication-checks.md +9 -0
  83. ai_docs_toolkit/templates/project/.ai-docs/templates/release/release-task.md +123 -0
  84. ai_docs_toolkit/templates/project/.ai-docs/templates/release/routing-restoration-beta-15.md +39 -0
  85. ai_docs_toolkit/templates/project/.ai-docs/templates/release/task-retirement-beta-16.md +54 -0
  86. ai_docs_toolkit/templates/project/.ai-docs/templates/release/validation-evidence.md +18 -0
  87. ai_docs_toolkit/templates/project/.ai-docs/templates/release/version-change-checklist.md +11 -0
  88. ai_docs_toolkit/templates/project/.ai-docs/workflows/documentation-cascade-adoption.md +581 -0
  89. ai_docs_toolkit/templates/project/.ai-docs/workflows/plan-stage-workflow.md +752 -0
  90. ai_docs_toolkit/templates/project/.ai-docs/workflows/toolkit-migration-workflow.md +150 -0
  91. ai_docs_toolkit/templates/project/AGENTS.md +177 -0
  92. ai_docs_toolkit/templates/project/ai-docs-index.md +39 -0
  93. ai_docs_toolkit/templates/project/ai-docs.config.yaml +56 -0
  94. ai_docs_toolkit/validation/__init__.py +16 -0
  95. ai_docs_toolkit/validation/human.py +313 -0
  96. ai_docs_toolkit-0.1.0.dist-info/METADATA +269 -0
  97. ai_docs_toolkit-0.1.0.dist-info/RECORD +100 -0
  98. ai_docs_toolkit-0.1.0.dist-info/WHEEL +5 -0
  99. ai_docs_toolkit-0.1.0.dist-info/entry_points.txt +3 -0
  100. ai_docs_toolkit-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,13 @@
1
+ from ai_docs_toolkit.context.markdown_output import (
2
+ ContextBuildResult,
3
+ context_project,
4
+ format_context_json,
5
+ format_context_markdown,
6
+ )
7
+
8
+ __all__ = [
9
+ "ContextBuildResult",
10
+ "context_project",
11
+ "format_context_json",
12
+ "format_context_markdown",
13
+ ]
@@ -0,0 +1,470 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ from dataclasses import asdict, dataclass
6
+ from pathlib import Path
7
+
8
+ from ai_docs_toolkit.core import (
9
+ ContextSelectionResult,
10
+ DocumentRegistry,
11
+ RegistryDocument,
12
+ ToolkitConfig,
13
+ build_document_graph,
14
+ build_document_registry,
15
+ load_project_config,
16
+ parse_markdown_text,
17
+ registry_document_from_parsed,
18
+ scan_markdown_files,
19
+ select_context_by_changed_ids,
20
+ select_context_by_feature,
21
+ select_context_by_ids,
22
+ select_context_by_module,
23
+ )
24
+ from ai_docs_toolkit.core.changed_files import (
25
+ ChangedFileIssue,
26
+ read_git_changed_files,
27
+ resolve_changed_document_sources,
28
+ )
29
+ from ai_docs_toolkit.core.config import context_settings_error
30
+ from ai_docs_toolkit.core.context import ContextPolicy
31
+ from ai_docs_toolkit.validation import ValidationMessage
32
+
33
+ RULE_CHANGED_FILE_NOT_DOCUMENT = "document.context.changed_file_not_document"
34
+ RULE_CHANGED_FILE_OUTSIDE_DOCS = "document.context.changed_file_outside_docs"
35
+ RULE_GIT_CHANGED_FILES_UNAVAILABLE = "document.context.git_changed_files_unavailable"
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class ContextBuildResult:
40
+ exit_code: int
41
+ selection: ContextSelectionResult | None
42
+ registry: DocumentRegistry | None = None
43
+ warnings: tuple[ValidationMessage, ...] = ()
44
+ errors: tuple[ValidationMessage, ...] = ()
45
+ snapshot: dict | None = None
46
+
47
+ @property
48
+ def ok(self) -> bool:
49
+ return self.exit_code == 0
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class _ProjectContext:
54
+ root: Path
55
+ config: ToolkitConfig
56
+ documents: tuple[RegistryDocument, ...]
57
+ errors: tuple[ValidationMessage, ...]
58
+ file_hashes: dict[str, str]
59
+ config_hash: str
60
+
61
+
62
+ def context_project(
63
+ *,
64
+ mode: str,
65
+ value: str,
66
+ project_root: str | Path = ".",
67
+ history: str | None = None,
68
+ body_budget_bytes: int | None = None,
69
+ max_depth: int | None = None,
70
+ ) -> ContextBuildResult:
71
+ context_result = _load_project_context(project_root)
72
+ if isinstance(context_result, ContextBuildResult):
73
+ return context_result
74
+ config = context_result.config
75
+ effective_history = history if history is not None else config.context.history
76
+ effective_budget = (
77
+ body_budget_bytes
78
+ if body_budget_bytes is not None
79
+ else config.context.body_budget_bytes
80
+ )
81
+ effective_depth = max_depth if max_depth is not None else config.context.max_depth
82
+ if error := context_settings_error(
83
+ effective_history, effective_budget, effective_depth
84
+ ):
85
+ return ContextBuildResult(
86
+ exit_code=2,
87
+ selection=None,
88
+ errors=(
89
+ ValidationMessage(rule_id="invalid_context_options", message=error),
90
+ ),
91
+ )
92
+ policy = ContextPolicy(
93
+ effective_history,
94
+ config.work_history.archive_root,
95
+ effective_budget or None,
96
+ effective_depth,
97
+ )
98
+ registry = build_document_registry(context_result.documents)
99
+ errors = [*context_result.errors, *_registry_errors(registry)]
100
+ warnings = []
101
+ graph = build_document_graph(registry, project_root=context_result.root)
102
+ if mode == "id":
103
+ selection = select_context_by_ids(graph, registry, [value], policy=policy)
104
+ elif mode == "module":
105
+ selection = select_context_by_module(graph, registry, value, policy=policy)
106
+ elif mode == "feature":
107
+ selection = select_context_by_feature(graph, registry, value, policy=policy)
108
+ elif mode == "changed":
109
+ changed = _git_changed_files(context_result.root)
110
+ source_result = resolve_changed_document_sources(
111
+ changed.changed_files,
112
+ config=config,
113
+ registry=registry,
114
+ root=context_result.root,
115
+ outside_docs_rule_id=RULE_CHANGED_FILE_OUTSIDE_DOCS,
116
+ not_document_rule_id=RULE_CHANGED_FILE_NOT_DOCUMENT,
117
+ )
118
+ errors.extend(_changed_file_errors(changed.issues))
119
+ warnings.extend(
120
+ _changed_file_warnings((*changed.issues, *source_result.issues))
121
+ )
122
+ selection = select_context_by_changed_ids(
123
+ graph,
124
+ registry,
125
+ {
126
+ id: source_result.changed_files_by_id[id]
127
+ for id in source_result.source_ids
128
+ },
129
+ policy=policy,
130
+ )
131
+ else:
132
+ raise ValueError(f"Unsupported context mode: {mode}")
133
+ errors.extend(_selection_errors(selection))
134
+ warnings.extend(_selection_warnings(selection))
135
+ fingerprint_inputs = {
136
+ "files": context_result.file_hashes,
137
+ "config": context_result.config_hash,
138
+ "mode": mode,
139
+ "value": value,
140
+ "history": policy.history,
141
+ "body_budget_bytes": policy.body_budget_bytes,
142
+ "max_depth": policy.max_depth,
143
+ "sources": [asdict(s) for s in selection.sources],
144
+ }
145
+ snapshot = {
146
+ "fingerprint": hashlib.sha256(
147
+ json.dumps(fingerprint_inputs, sort_keys=True, ensure_ascii=False).encode()
148
+ ).hexdigest(),
149
+ "files": {
150
+ d.path: context_result.file_hashes[d.path] for d in selection.documents
151
+ },
152
+ }
153
+ return ContextBuildResult(
154
+ exit_code=0 if not errors else 1,
155
+ selection=selection,
156
+ registry=registry,
157
+ warnings=tuple(warnings),
158
+ errors=tuple(errors),
159
+ snapshot=snapshot,
160
+ )
161
+
162
+
163
+ def format_context_markdown(result: ContextBuildResult) -> str:
164
+ lines: list[str] = ["# AI Docs Context Bundle", ""]
165
+ lines.append("Status: completed" if result.exit_code == 0 else "Status: failed")
166
+
167
+ selection = result.selection
168
+ sources = selection.sources if selection is not None else ()
169
+ documents = selection.documents if selection is not None else ()
170
+ lines.append(f"Sources: {len(sources)}")
171
+ lines.append(f"Documents: {len(documents)}")
172
+
173
+ if selection is not None:
174
+ budget = selection.policy.body_budget_bytes
175
+ lines.extend(
176
+ [
177
+ f"History: {selection.policy.history}",
178
+ f"Depth: {selection.policy.max_depth}; boundary documents: {selection.boundary_count}",
179
+ f"Body budget (UTF-8 bytes): {budget if budget else 'unlimited'} (soft)",
180
+ f"Selected body bytes: {selection.body_bytes}; excess: {selection.excess_bytes}",
181
+ "Coverage: explicit graph only; read applicable agent/local instructions separately.",
182
+ ]
183
+ )
184
+ if selection.excluded:
185
+ lines.extend(["", "## Excluded"])
186
+ lines.extend(
187
+ f"- {e.document_id} ({e.path}): {', '.join(e.reasons)}"
188
+ for e in selection.excluded
189
+ )
190
+ if selection.historical:
191
+ lines.extend(["", "## Historical Documents"])
192
+ lines.extend(
193
+ f"- {e.document_id}: {', '.join(e.reasons)}"
194
+ for e in selection.historical
195
+ )
196
+
197
+ if sources:
198
+ lines.append("")
199
+ lines.append("## Sources")
200
+ for source in sources:
201
+ changed = (
202
+ f" changed_file={source.changed_file}" if source.changed_file else ""
203
+ )
204
+ lines.append(
205
+ f"- {source.document_id} ({source.type}) {source.path} "
206
+ f"source={source.source}{changed}"
207
+ )
208
+
209
+ if result.warnings:
210
+ lines.append("")
211
+ lines.append("## Warnings")
212
+ for warning in result.warnings:
213
+ lines.append(f"- {_message_line(warning)}")
214
+
215
+ if result.errors:
216
+ lines.append("")
217
+ lines.append("## Errors")
218
+ for error in result.errors:
219
+ lines.append(f"- {_message_line(error)}")
220
+
221
+ if documents:
222
+ lines.append("")
223
+ lines.append("## Documents")
224
+ for document in documents:
225
+ lines.append("")
226
+ lines.append(f"### {document.document_id}")
227
+ lines.append("")
228
+ lines.append("```yaml")
229
+ lines.append(f"type: {document.type}")
230
+ lines.append(f"path: {document.path}")
231
+ lines.append(f"group: {document.group}")
232
+ lines.append(f"distance: {document.distance}")
233
+ lines.append(f"priority: {document.priority}")
234
+ lines.append(f"context_role: {document.context_role}")
235
+ lines.append("reasons:")
236
+ for reason in document.reasons:
237
+ path_hint = f" Path {' -> '.join(reason.path)}." if reason.path else ""
238
+ lines.append(f" - {reason.message}{path_hint}")
239
+ lines.append("```")
240
+ body = _document_body(result.registry, document.document_id)
241
+ if body:
242
+ lines.append("")
243
+ lines.extend(body.splitlines())
244
+
245
+ return "\n".join(lines)
246
+
247
+
248
+ def format_context_json(result: ContextBuildResult) -> str:
249
+ selection = result.selection
250
+ payload = {
251
+ "sources": [asdict(source) for source in selection.sources]
252
+ if selection
253
+ else [],
254
+ "documents": [_document_payload(document) for document in selection.documents]
255
+ if selection
256
+ else [],
257
+ "warnings": [_message_payload(message) for message in result.warnings],
258
+ }
259
+ if selection is not None:
260
+ payload.update(
261
+ {
262
+ "selection": {
263
+ "history": selection.policy.history,
264
+ "max_depth": selection.policy.max_depth,
265
+ "depth_limited": selection.boundary_count > 0,
266
+ "boundary_count": selection.boundary_count,
267
+ "body_budget_bytes": selection.policy.body_budget_bytes,
268
+ "body_bytes": selection.body_bytes,
269
+ "excess_bytes": selection.excess_bytes,
270
+ "protected_ids": list(selection.protected_ids),
271
+ "candidate_count": selection.candidate_count,
272
+ "coverage": "explicit_graph_only",
273
+ },
274
+ "excluded": [asdict(e) for e in selection.excluded],
275
+ "historical": [asdict(e) for e in selection.historical],
276
+ "gaps": [asdict(g) for g in selection.gaps],
277
+ "snapshot": result.snapshot,
278
+ }
279
+ )
280
+ if result.errors:
281
+ payload["errors"] = [_message_payload(message) for message in result.errors]
282
+ return json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True)
283
+
284
+
285
+ def _load_project_context(
286
+ project_root: str | Path,
287
+ ) -> _ProjectContext | ContextBuildResult:
288
+ root = Path(project_root).resolve()
289
+ config_path = root / "ai-docs.config.yaml"
290
+ config_bytes = config_path.read_bytes() if config_path.exists() else b""
291
+ config_result = load_project_config(root)
292
+ if not config_result.ok or config_result.config is None:
293
+ return ContextBuildResult(
294
+ exit_code=2,
295
+ selection=None,
296
+ errors=tuple(
297
+ ValidationMessage(
298
+ rule_id=error.code,
299
+ message=error.message,
300
+ path=error.path,
301
+ )
302
+ for error in config_result.errors
303
+ ),
304
+ )
305
+
306
+ if config_bytes != (config_path.read_bytes() if config_path.exists() else b""):
307
+ return ContextBuildResult(
308
+ exit_code=1,
309
+ selection=None,
310
+ errors=(
311
+ ValidationMessage(
312
+ rule_id="document.context.snapshot_changed",
313
+ message="Configuration changed during selection; rerun context.",
314
+ ),
315
+ ),
316
+ )
317
+ documents: list[RegistryDocument] = []
318
+ file_hashes = {}
319
+ errors: list[ValidationMessage] = []
320
+ for path in scan_markdown_files(config_result.config):
321
+ display_path = (
322
+ path.relative_to(root).as_posix()
323
+ if path.is_relative_to(root)
324
+ else str(path)
325
+ )
326
+ try:
327
+ data = path.read_bytes()
328
+ file_hashes[display_path] = hashlib.sha256(data).hexdigest()
329
+ text = data.decode("utf-8")
330
+ except (OSError, UnicodeError) as exc:
331
+ file_hashes[display_path] = "unreadable"
332
+ errors.append(
333
+ ValidationMessage(
334
+ rule_id="document.context.read_failed",
335
+ message=str(exc),
336
+ path=str(path),
337
+ )
338
+ )
339
+ continue
340
+ parse_result = parse_markdown_text(text, path=str(path))
341
+ if parse_result.document is None:
342
+ errors.extend(
343
+ ValidationMessage(
344
+ rule_id=f"frontmatter.{error.code}",
345
+ message=error.message,
346
+ path=error.path,
347
+ )
348
+ for error in parse_result.errors
349
+ )
350
+ continue
351
+
352
+ documents.append(registry_document_from_parsed(parse_result.document))
353
+
354
+ return _ProjectContext(
355
+ root=root,
356
+ config=config_result.config,
357
+ documents=tuple(documents),
358
+ errors=tuple(errors),
359
+ file_hashes=file_hashes,
360
+ config_hash=hashlib.sha256(config_bytes).hexdigest(),
361
+ )
362
+
363
+
364
+ def _registry_errors(registry: DocumentRegistry) -> tuple[ValidationMessage, ...]:
365
+ return tuple(
366
+ ValidationMessage(
367
+ rule_id=issue.rule_id,
368
+ message=issue.message,
369
+ path=", ".join(str(path) for path in issue.paths),
370
+ document_id=issue.document_id,
371
+ severity=issue.severity,
372
+ )
373
+ for issue in registry.issues
374
+ )
375
+
376
+
377
+ def _selection_errors(
378
+ selection: ContextSelectionResult,
379
+ ) -> tuple[ValidationMessage, ...]:
380
+ return tuple(
381
+ ValidationMessage(
382
+ rule_id=issue.rule_id,
383
+ message=issue.message,
384
+ document_id=issue.document_id,
385
+ severity=issue.severity,
386
+ )
387
+ for issue in selection.issues
388
+ if issue.severity == "error"
389
+ )
390
+
391
+
392
+ def _selection_warnings(
393
+ selection: ContextSelectionResult,
394
+ ) -> tuple[ValidationMessage, ...]:
395
+ return tuple(
396
+ ValidationMessage(
397
+ rule_id=issue.rule_id,
398
+ message=issue.message,
399
+ document_id=issue.document_id,
400
+ severity=issue.severity,
401
+ )
402
+ for issue in selection.issues
403
+ if issue.severity != "error"
404
+ )
405
+
406
+
407
+ def _changed_file_errors(
408
+ issues: tuple[ChangedFileIssue, ...],
409
+ ) -> tuple[ValidationMessage, ...]:
410
+ return tuple(
411
+ _changed_file_message(issue) for issue in issues if issue.severity == "error"
412
+ )
413
+
414
+
415
+ def _changed_file_warnings(
416
+ issues: tuple[ChangedFileIssue, ...],
417
+ ) -> tuple[ValidationMessage, ...]:
418
+ return tuple(
419
+ _changed_file_message(issue) for issue in issues if issue.severity != "error"
420
+ )
421
+
422
+
423
+ def _changed_file_message(issue: ChangedFileIssue) -> ValidationMessage:
424
+ return ValidationMessage(
425
+ rule_id=issue.rule_id,
426
+ message=issue.message,
427
+ path=issue.path,
428
+ document_id=issue.document_id,
429
+ severity=issue.severity,
430
+ )
431
+
432
+
433
+ def _document_body(registry: DocumentRegistry | None, document_id: str) -> str:
434
+ if registry is None:
435
+ return ""
436
+ document = registry.get_by_id(document_id)
437
+ if document is None:
438
+ return ""
439
+ return document.body.strip()
440
+
441
+
442
+ def _message_line(message: ValidationMessage) -> str:
443
+ path = f"{message.path} " if message.path else ""
444
+ document = f"[{message.document_id}] " if message.document_id else ""
445
+ return f"{path}{document}{message.rule_id}: {message.message}"
446
+
447
+
448
+ def _document_payload(document: object) -> dict[str, object]:
449
+ payload = asdict(document)
450
+ for reason in payload["reasons"]:
451
+ if not reason["path"]:
452
+ reason.pop("path")
453
+ return payload
454
+
455
+
456
+ def _message_payload(message: ValidationMessage) -> dict[str, str | None]:
457
+ return {
458
+ "path": message.path,
459
+ "document_id": message.document_id,
460
+ "rule_id": message.rule_id,
461
+ "severity": message.severity,
462
+ "message": message.message,
463
+ }
464
+
465
+
466
+ def _git_changed_files(root: Path):
467
+ return read_git_changed_files(
468
+ root,
469
+ unavailable_rule_id=RULE_GIT_CHANGED_FILES_UNAVAILABLE,
470
+ )