sourcebound 1.2.1__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 (71) hide show
  1. clean_docs/__init__.py +26 -0
  2. clean_docs/__main__.py +3 -0
  3. clean_docs/accessibility.py +182 -0
  4. clean_docs/adapters/__init__.py +1 -0
  5. clean_docs/adapters/event_capture.py +56 -0
  6. clean_docs/adapters/mdx_dependencies.json +714 -0
  7. clean_docs/adapters/mdx_parser.mjs +29992 -0
  8. clean_docs/applicability.py +330 -0
  9. clean_docs/audit.py +1120 -0
  10. clean_docs/bootstrap.py +507 -0
  11. clean_docs/capabilities.py +200 -0
  12. clean_docs/changed.py +452 -0
  13. clean_docs/claims.py +840 -0
  14. clean_docs/cli.py +1612 -0
  15. clean_docs/context.py +307 -0
  16. clean_docs/corpus.py +377 -0
  17. clean_docs/demo.py +369 -0
  18. clean_docs/doctor.py +184 -0
  19. clean_docs/emit/__init__.py +4 -0
  20. clean_docs/emit/llms_txt.py +102 -0
  21. clean_docs/emit/stepwise.py +168 -0
  22. clean_docs/engine.py +324 -0
  23. clean_docs/errors.py +20 -0
  24. clean_docs/evaluation.py +867 -0
  25. clean_docs/execution.py +138 -0
  26. clean_docs/explain.py +123 -0
  27. clean_docs/extractors/__init__.py +19 -0
  28. clean_docs/extractors/command.py +51 -0
  29. clean_docs/extractors/inventory.py +176 -0
  30. clean_docs/extractors/json_pointer.py +84 -0
  31. clean_docs/extractors/python_literal.py +104 -0
  32. clean_docs/extractors/static.py +111 -0
  33. clean_docs/feedback.py +1390 -0
  34. clean_docs/impact.py +1624 -0
  35. clean_docs/improvements.py +1178 -0
  36. clean_docs/inventory.py +474 -0
  37. clean_docs/isolation.py +157 -0
  38. clean_docs/manifest.py +898 -0
  39. clean_docs/mdx.py +272 -0
  40. clean_docs/migration.py +121 -0
  41. clean_docs/models.py +194 -0
  42. clean_docs/outcomes.py +296 -0
  43. clean_docs/performance.py +123 -0
  44. clean_docs/phrasing.py +448 -0
  45. clean_docs/plugins.py +249 -0
  46. clean_docs/policy.py +536 -0
  47. clean_docs/projections.py +255 -0
  48. clean_docs/regions.py +75 -0
  49. clean_docs/release.py +232 -0
  50. clean_docs/renderers.py +57 -0
  51. clean_docs/residue.py +311 -0
  52. clean_docs/review_contracts.py +862 -0
  53. clean_docs/review_ledger.py +297 -0
  54. clean_docs/review_limits.py +9 -0
  55. clean_docs/sensitivity.py +602 -0
  56. clean_docs/snapshot.py +212 -0
  57. clean_docs/standard.py +281 -0
  58. clean_docs/standards/default.json +309 -0
  59. clean_docs/standards/exemplars.md +87 -0
  60. clean_docs/standards/v0-migrations.json +26 -0
  61. clean_docs/symbols.py +47 -0
  62. clean_docs/templates.py +96 -0
  63. clean_docs/verdict.py +1397 -0
  64. clean_docs/visuals.py +346 -0
  65. clean_docs/write_gate.py +170 -0
  66. sourcebound-1.2.1.dist-info/LICENSE +21 -0
  67. sourcebound-1.2.1.dist-info/METADATA +109 -0
  68. sourcebound-1.2.1.dist-info/RECORD +71 -0
  69. sourcebound-1.2.1.dist-info/WHEEL +5 -0
  70. sourcebound-1.2.1.dist-info/entry_points.txt +2 -0
  71. sourcebound-1.2.1.dist-info/top_level.txt +1 -0
clean_docs/impact.py ADDED
@@ -0,0 +1,1624 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import fnmatch
5
+ import hashlib
6
+ import json
7
+ import re
8
+ from collections.abc import Callable
9
+ from dataclasses import asdict, dataclass, replace
10
+ from pathlib import Path
11
+ from typing import cast
12
+
13
+ import yaml
14
+
15
+ from clean_docs import __version__
16
+ from clean_docs.changed import ChangedReport, _check_changed_details, _git
17
+ from clean_docs.errors import ConfigurationError
18
+ from clean_docs.execution import ExecutionPolicy
19
+ from clean_docs.inventory import PUBLIC_SURFACE_KINDS, InventoryItem
20
+ from clean_docs.manifest import load_manifest
21
+ from clean_docs.mdx import MdxParserError, parse_mdx
22
+ from clean_docs.models import Manifest, ReviewContract, SymbolBinding
23
+ from clean_docs.projections import evaluate_projections
24
+ from clean_docs.review_contracts import (
25
+ ReviewContractResult,
26
+ evaluate_review_contracts,
27
+ )
28
+ from clean_docs.visuals import load_visual_record
29
+ from clean_docs.snapshot import RepositorySnapshot
30
+
31
+
32
+ PUBLIC_KINDS = PUBLIC_SURFACE_KINDS | {"ci-job"}
33
+ SOURCE_SUFFIXES = frozenset(
34
+ {
35
+ ".c",
36
+ ".cc",
37
+ ".cpp",
38
+ ".cs",
39
+ ".go",
40
+ ".h",
41
+ ".java",
42
+ ".js",
43
+ ".jsx",
44
+ ".kt",
45
+ ".php",
46
+ ".py",
47
+ ".rb",
48
+ ".rs",
49
+ ".scala",
50
+ ".swift",
51
+ ".ts",
52
+ ".tsx",
53
+ }
54
+ )
55
+ MEDIA_SUFFIXES = frozenset({".gif", ".ico", ".jpeg", ".jpg", ".png", ".svg", ".webp"})
56
+ STRUCTURED_SUFFIXES = frozenset({".json", ".toml", ".yaml", ".yml"})
57
+ PYTHON_TOOLING_MODULES = frozenset({"conftest.py", "noxfile.py", "setup.py"})
58
+ SCRIPT_EXPORT = re.compile(
59
+ r"^\s*export\s+(?:default\s+)?(?:async\s+)?(?:abstract\s+)?"
60
+ r"(?P<kind>function|class|const|interface|type|enum)\s+"
61
+ r"(?P<name>[A-Za-z_$][\w$]*)",
62
+ re.M,
63
+ )
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class ImpactArtifact:
68
+ id: str
69
+ path: str
70
+ change: str
71
+ base_blob: str | None
72
+ head_blob: str | None
73
+ adapter: str
74
+ decision: str
75
+ may_expose_public_surface: bool
76
+ coverage: str
77
+ graph_roots: tuple[str, ...]
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class ImpactEvent:
82
+ id: str
83
+ kind: str
84
+ path: str
85
+ item_id: str
86
+ locator: str
87
+ before_digest: str | None
88
+ after_digest: str | None
89
+ coverage: str
90
+ graph_roots: tuple[str, ...]
91
+
92
+
93
+ @dataclass(frozen=True)
94
+ class ImpactEdge:
95
+ source: str
96
+ target: str
97
+ kind: str
98
+
99
+
100
+ @dataclass(frozen=True)
101
+ class ImpactFinding:
102
+ id: str
103
+ classification: str
104
+ rule: str
105
+ message: str
106
+ paths: tuple[str, ...]
107
+ graph_roots: tuple[str, ...]
108
+ obligations: tuple[str, ...]
109
+
110
+
111
+ @dataclass(frozen=True)
112
+ class ImpactPlan:
113
+ producer_version: str
114
+ requested_base: str
115
+ merge_base: str
116
+ head: str
117
+ project: str
118
+ manifest: str
119
+ manifest_digest: str
120
+ input_digest: str
121
+ impact: str
122
+ coverage_complete: bool
123
+ artifacts: tuple[ImpactArtifact, ...]
124
+ events: tuple[ImpactEvent, ...]
125
+ review_contracts: tuple[ReviewContractResult, ...]
126
+ edges: tuple[ImpactEdge, ...]
127
+ required: tuple[ImpactFinding, ...]
128
+ recommended: tuple[ImpactFinding, ...]
129
+ unrelated: tuple[ImpactFinding, ...]
130
+ unknown: tuple[ImpactFinding, ...]
131
+
132
+ @property
133
+ def digest(self) -> str:
134
+ return _digest(self._payload())
135
+
136
+ @property
137
+ def no_impact(self) -> bool:
138
+ return self.impact == "none"
139
+
140
+ @property
141
+ def unsupported_documents(self) -> tuple[str, ...]:
142
+ return tuple(
143
+ artifact.path
144
+ for artifact in self.artifacts
145
+ if artifact.adapter.startswith("mdx-static:failed")
146
+ )
147
+
148
+ def _payload(self) -> dict[str, object]:
149
+ roots = sorted(
150
+ {
151
+ root
152
+ for artifact in self.artifacts
153
+ for root in artifact.graph_roots
154
+ }
155
+ | {
156
+ root
157
+ for finding in (
158
+ self.required
159
+ + self.recommended
160
+ + self.unrelated
161
+ + self.unknown
162
+ )
163
+ for root in finding.graph_roots
164
+ }
165
+ )
166
+ return {
167
+ "schema": "sourcebound.impact-plan.v2",
168
+ "producer": {"name": "sourcebound", "version": self.producer_version},
169
+ "read_only": True,
170
+ "requested_base": self.requested_base,
171
+ "merge_base": self.merge_base,
172
+ "head": self.head,
173
+ "project": self.project,
174
+ "manifest": self.manifest,
175
+ "manifest_digest": self.manifest_digest,
176
+ "input_digest": self.input_digest,
177
+ "impact": self.impact,
178
+ "coverage_complete": self.coverage_complete,
179
+ "no_impact": self.no_impact,
180
+ "unsupported_documents": list(self.unsupported_documents),
181
+ "artifacts": [asdict(item) for item in self.artifacts],
182
+ "events": [asdict(item) for item in self.events],
183
+ "review_contracts": [
184
+ item.as_dict() for item in self.review_contracts
185
+ ],
186
+ "graph": {
187
+ "roots": roots,
188
+ "edges": [asdict(item) for item in self.edges],
189
+ },
190
+ "findings": {
191
+ "required": [asdict(item) for item in self.required],
192
+ "recommended": [asdict(item) for item in self.recommended],
193
+ "unrelated": [asdict(item) for item in self.unrelated],
194
+ "unknown": [asdict(item) for item in self.unknown],
195
+ },
196
+ }
197
+
198
+ def as_dict(self) -> dict[str, object]:
199
+ payload = self._payload()
200
+ payload["digest"] = self.digest
201
+ return payload
202
+
203
+
204
+ @dataclass
205
+ class _ImpactPreparation:
206
+ changed: ChangedReport
207
+ prefix: str
208
+ project_changed: tuple[str, ...]
209
+ inventory_changes: list[
210
+ tuple[str, InventoryItem | None, InventoryItem | None]
211
+ ]
212
+ public_locator_changes: set[tuple[str, str]]
213
+ required_document_paths: set[str]
214
+ artifact_roots: dict[str, set[str]]
215
+ edges: set[ImpactEdge]
216
+ affected_docs: set[str]
217
+
218
+
219
+ def _digest(value: object) -> str:
220
+ encoded = json.dumps(
221
+ value, sort_keys=True, separators=(",", ":"), ensure_ascii=False
222
+ ).encode("utf-8")
223
+ return hashlib.sha256(encoded).hexdigest()
224
+
225
+
226
+ def _identifier(*parts: object) -> str:
227
+ return _digest(list(parts))
228
+
229
+
230
+ def _stable_ast_dump(node: ast.AST) -> str:
231
+ dump = cast(Callable[..., str], ast.dump)
232
+ try:
233
+ return dump(node, include_attributes=False, show_empty=True)
234
+ except TypeError:
235
+ # Python 3.11 and 3.12 include empty fields by default and do not
236
+ # expose the show_empty argument.
237
+ return dump(node, include_attributes=False)
238
+
239
+
240
+ def _python_interface_payload(node: ast.AST) -> dict[str, object]:
241
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
242
+ return {
243
+ "kind": type(node).__name__,
244
+ "name": node.name,
245
+ "arguments": _stable_ast_dump(node.args),
246
+ "returns": (
247
+ _stable_ast_dump(node.returns)
248
+ if node.returns is not None
249
+ else None
250
+ ),
251
+ "decorators": [
252
+ _stable_ast_dump(item)
253
+ for item in node.decorator_list
254
+ ],
255
+ }
256
+ if isinstance(node, ast.ClassDef):
257
+ return {
258
+ "kind": type(node).__name__,
259
+ "name": node.name,
260
+ "bases": [
261
+ _stable_ast_dump(item) for item in node.bases
262
+ ],
263
+ "keywords": [
264
+ _stable_ast_dump(item) for item in node.keywords
265
+ ],
266
+ "decorators": [
267
+ _stable_ast_dump(item)
268
+ for item in node.decorator_list
269
+ ],
270
+ }
271
+ raise TypeError(f"unsupported public declaration: {type(node).__name__}")
272
+
273
+
274
+ def _declaration_line(text: str, start: int) -> str:
275
+ line_start = text.rfind("\n", 0, start) + 1
276
+ line_end = text.find("\n", start)
277
+ return text[line_start:] if line_end == -1 else text[line_start:line_end]
278
+
279
+
280
+ def _balanced_declaration(text: str, start: int) -> str:
281
+ line_start = text.rfind("\n", 0, start) + 1
282
+ depth = 0
283
+ quote: str | None = None
284
+ escaped = False
285
+ line_comment = False
286
+ block_comment = False
287
+ opening_seen = False
288
+ index = start
289
+ while index < len(text):
290
+ character = text[index]
291
+ following = text[index + 1] if index + 1 < len(text) else ""
292
+ if line_comment:
293
+ if character == "\n":
294
+ line_comment = False
295
+ index += 1
296
+ continue
297
+ if block_comment:
298
+ if character == "*" and following == "/":
299
+ block_comment = False
300
+ index += 2
301
+ continue
302
+ index += 1
303
+ continue
304
+ if quote is not None:
305
+ if escaped:
306
+ escaped = False
307
+ elif character == "\\":
308
+ escaped = True
309
+ elif character == quote:
310
+ quote = None
311
+ index += 1
312
+ continue
313
+ if character == "/" and following == "/":
314
+ line_comment = True
315
+ index += 2
316
+ continue
317
+ if character == "/" and following == "*":
318
+ block_comment = True
319
+ index += 2
320
+ continue
321
+ if character in {"'", '"', "`"}:
322
+ quote = character
323
+ index += 1
324
+ continue
325
+ if character == "{":
326
+ opening_seen = True
327
+ depth += 1
328
+ elif character == "}" and opening_seen:
329
+ depth -= 1
330
+ if depth == 0:
331
+ return text[line_start : index + 1]
332
+ index += 1
333
+ return _declaration_line(text, start)
334
+
335
+
336
+ def _script_interface_evidence(text: str, match: re.Match[str]) -> str:
337
+ if match.group("kind") in {"interface", "type", "enum"}:
338
+ return _balanced_declaration(text, match.start())
339
+ return _declaration_line(text, match.start())
340
+
341
+
342
+ def _interface_fingerprints(
343
+ root: Path,
344
+ ref: str,
345
+ project: Path,
346
+ paths: tuple[str, ...],
347
+ ) -> tuple[dict[str, str], frozenset[str]]:
348
+ fingerprints: dict[str, str] = {}
349
+ failed: set[str] = set()
350
+ snapshot = RepositorySnapshot(root, ref)
351
+ for path in sorted(paths):
352
+ repository_path = (
353
+ path if project == Path(".") else (project / path).as_posix()
354
+ )
355
+ if _blob_id(root, ref, repository_path) is None:
356
+ continue
357
+ try:
358
+ text = snapshot.read_text(Path(repository_path))
359
+ except (OSError, UnicodeDecodeError):
360
+ failed.add(path)
361
+ continue
362
+ candidate = Path(path)
363
+ suffix = candidate.suffix.lower()
364
+ if suffix == ".py":
365
+ try:
366
+ tree = ast.parse(text, filename=path)
367
+ except SyntaxError:
368
+ failed.add(path)
369
+ continue
370
+ is_test = (
371
+ candidate.name.startswith("test_")
372
+ or "/tests/" in f"/{path}"
373
+ )
374
+ if is_test or candidate.name in PYTHON_TOOLING_MODULES:
375
+ continue
376
+ for node in tree.body:
377
+ if not isinstance(
378
+ node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
379
+ ) or node.name.startswith("_"):
380
+ continue
381
+ item_id = f"api-symbol:{path}:{node.name}"
382
+ fingerprints[item_id] = _digest(
383
+ _python_interface_payload(node)
384
+ )
385
+ elif suffix in {".ts", ".tsx", ".js", ".jsx"}:
386
+ for match in SCRIPT_EXPORT.finditer(text):
387
+ name = match.group("name")
388
+ item_id = f"api-symbol:{path}:{name}"
389
+ fingerprints[item_id] = _digest(
390
+ _script_interface_evidence(text, match)
391
+ )
392
+ return fingerprints, frozenset(failed)
393
+
394
+
395
+ def _repo_path(prefix: str, path: str) -> str:
396
+ return f"{prefix}{path}" if prefix else path
397
+
398
+
399
+ def _project_path(prefix: str, path: str) -> str:
400
+ if prefix and path.startswith(prefix):
401
+ return path.removeprefix(prefix)
402
+ return path
403
+
404
+
405
+ def _repository_review_contract(
406
+ contract: ReviewContract,
407
+ project: Path,
408
+ ) -> ReviewContract:
409
+ if project == Path("."):
410
+ return contract
411
+ return replace(
412
+ contract,
413
+ sources=tuple(
414
+ replace(locator, path=project / locator.path)
415
+ for locator in contract.sources
416
+ ),
417
+ targets=tuple(
418
+ replace(locator, path=project / locator.path)
419
+ for locator in contract.targets
420
+ ),
421
+ )
422
+
423
+
424
+ def _project_review_result(
425
+ result: ReviewContractResult,
426
+ prefix: str,
427
+ ) -> ReviewContractResult:
428
+ if not prefix:
429
+ return result
430
+ return replace(
431
+ result,
432
+ sources=tuple(
433
+ replace(item, path=_project_path(prefix, item.path))
434
+ for item in result.sources
435
+ ),
436
+ targets=tuple(
437
+ replace(item, path=_project_path(prefix, item.path))
438
+ for item in result.targets
439
+ ),
440
+ )
441
+
442
+
443
+ def _blob_id(root: Path, ref: str, path: str) -> str | None:
444
+ output = _git(root, "ls-tree", ref, "--", path).strip()
445
+ if not output:
446
+ return None
447
+ fields = output.split(None, 3)
448
+ return fields[2] if len(fields) >= 3 else None
449
+
450
+
451
+ def _change_kind(base_blob: str | None, head_blob: str | None) -> str:
452
+ if base_blob is None:
453
+ return "added"
454
+ if head_blob is None:
455
+ return "removed"
456
+ return "modified"
457
+
458
+
459
+ def _event_kind(kind: str, change: str) -> str:
460
+ subject = {
461
+ "api-endpoint": "endpoint",
462
+ "api-symbol": "public-symbol",
463
+ "cli-command": "command",
464
+ "cli-option": "option",
465
+ "ci-job": "ci-job",
466
+ "config-key": "configuration",
467
+ "doc-link": "documentation-link",
468
+ "document": "document",
469
+ "mcp-tool": "mcp-tool",
470
+ "package": "package",
471
+ "package-script": "package-script",
472
+ "runtime-constraint": "supported-runtime",
473
+ "schema": "schema",
474
+ "test-suite": "test-contract",
475
+ }.get(kind, kind)
476
+ return f"{subject}-{change}"
477
+
478
+
479
+ def _projection_inputs(manifest: Manifest) -> dict[str, tuple[str, ...]]:
480
+ if manifest.projections is None:
481
+ return {}
482
+ outputs: dict[str, tuple[str, ...]] = {}
483
+ if manifest.projections.llms_txt is not None:
484
+ llms_projection = manifest.projections.llms_txt
485
+ outputs[llms_projection.output.as_posix()] = tuple(
486
+ path.as_posix() for path in llms_projection.include
487
+ )
488
+ for bundle_projection in manifest.projections.bundles:
489
+ outputs[bundle_projection.output.as_posix()] = tuple(
490
+ path.as_posix() for path in bundle_projection.include
491
+ )
492
+ if manifest.projections.demo is not None:
493
+ demo_projection = manifest.projections.demo
494
+ outputs[demo_projection.output.as_posix()] = (
495
+ demo_projection.evidence.as_posix(),
496
+ )
497
+ for visual_projection in manifest.projections.visuals:
498
+ record = load_visual_record(
499
+ manifest.path.parent / visual_projection.source,
500
+ visual_projection.id,
501
+ )
502
+ inputs = [visual_projection.source.as_posix()]
503
+ for source in (record.src, record.src_dark):
504
+ if source is not None and not source.lower().startswith("https://"):
505
+ inputs.append(source)
506
+ dependency_set = tuple(sorted(set(inputs)))
507
+ outputs[visual_projection.human_output.as_posix()] = dependency_set
508
+ outputs[visual_projection.agent_output.as_posix()] = dependency_set
509
+ return outputs
510
+
511
+
512
+ def _evaluation_contexts(root: Path) -> dict[str, tuple[str, ...]]:
513
+ path = root / ".sourcebound/eval.yml"
514
+ if not path.is_file():
515
+ return {}
516
+ try:
517
+ raw = yaml.safe_load(path.read_text(encoding="utf-8"))
518
+ except OSError as exc:
519
+ raise ConfigurationError(f"cannot read impact evaluation graph {path}") from exc
520
+ except yaml.YAMLError as exc:
521
+ raise ConfigurationError(f"invalid impact evaluation graph {path}") from exc
522
+ if not isinstance(raw, dict) or not isinstance(raw.get("tasks"), list):
523
+ raise ConfigurationError("impact evaluation graph must contain a tasks list")
524
+ contexts: dict[str, tuple[str, ...]] = {}
525
+ for index, task in enumerate(raw["tasks"]):
526
+ if not isinstance(task, dict) or not isinstance(task.get("id"), str):
527
+ raise ConfigurationError(
528
+ f"impact evaluation graph task {index} needs a string id"
529
+ )
530
+ raw_context = task.get("context", [])
531
+ if not isinstance(raw_context, list) or not all(
532
+ isinstance(item, str) for item in raw_context
533
+ ):
534
+ raise ConfigurationError(
535
+ f"impact evaluation graph task {task['id']} needs a path context"
536
+ )
537
+ contexts[task["id"]] = tuple(sorted(raw_context))
538
+ return contexts
539
+
540
+
541
+ def _adapter_for(
542
+ path: str,
543
+ *,
544
+ event_adapters: tuple[str, ...],
545
+ projection_outputs: set[str],
546
+ manifest_path: str,
547
+ ) -> str:
548
+ candidate = Path(path)
549
+ if path in projection_outputs:
550
+ return "projection"
551
+ if path == manifest_path:
552
+ return "manifest"
553
+ if path == ".sourcebound/eval.yml":
554
+ return "evaluation"
555
+ if candidate.parts[:2] == (".github", "workflows"):
556
+ return "github-actions-static"
557
+ if event_adapters:
558
+ return "+".join(event_adapters)
559
+ if candidate.name.startswith("test_") or candidate.name.endswith(
560
+ (".test.ts", ".spec.ts", ".test.js", ".spec.js")
561
+ ):
562
+ return "test-files"
563
+ if candidate.suffix == ".md":
564
+ return "markdown"
565
+ if candidate.suffix == ".mdx":
566
+ return "mdx-static"
567
+ if candidate.suffix == ".py":
568
+ return "python-ast"
569
+ if candidate.suffix in {".ts", ".tsx"}:
570
+ return "typescript-static"
571
+ if candidate.suffix in {".js", ".jsx"}:
572
+ return "javascript-static"
573
+ if candidate.suffix in STRUCTURED_SUFFIXES:
574
+ return "structured-static"
575
+ if candidate.suffix in MEDIA_SUFFIXES:
576
+ return "documentation-media"
577
+ if candidate.name in {
578
+ ".editorconfig",
579
+ ".gitignore",
580
+ "LICENSE",
581
+ "LICENSE.txt",
582
+ "uv.lock",
583
+ } or candidate.name.endswith(".lock"):
584
+ return "repository-metadata"
585
+ return "unsupported"
586
+
587
+
588
+ def _may_expose_public_surface(
589
+ path: str, adapter: str, events: tuple[ImpactEvent, ...]
590
+ ) -> bool:
591
+ if any(event.item_id.split(":", 1)[0] in PUBLIC_KINDS for event in events):
592
+ return True
593
+ candidate = Path(path)
594
+ public_source_path = bool(
595
+ {"src", "lib", "app", "api", "cmd", "packages", "services"}
596
+ & set(candidate.parts[:-1])
597
+ )
598
+ control_surface = (
599
+ candidate.name in {
600
+ "Dockerfile",
601
+ "Makefile",
602
+ "Taskfile.yml",
603
+ "compose.yaml",
604
+ "compose.yml",
605
+ "docker-compose.yaml",
606
+ "docker-compose.yml",
607
+ }
608
+ or candidate.parts[:2] == (".github", "workflows")
609
+ )
610
+ if control_surface:
611
+ return True
612
+ if adapter != "unsupported":
613
+ return False
614
+ return candidate.suffix.lower() in SOURCE_SUFFIXES and (
615
+ public_source_path or len(candidate.parts) == 1
616
+ )
617
+
618
+
619
+ def _adapter_failed(root: Path, ref: str, path: str, adapter: str) -> bool:
620
+ try:
621
+ text = RepositorySnapshot(root, ref).read_text(Path(path))
622
+ if adapter == "python-ast":
623
+ ast.parse(text, filename=path)
624
+ elif adapter == "mdx-static":
625
+ parse_mdx(text)
626
+ except (SyntaxError, UnicodeDecodeError, MdxParserError):
627
+ return True
628
+ return False
629
+
630
+
631
+ def _workflow_jobs(
632
+ root: Path, ref: str, path: str, *, exists: bool
633
+ ) -> dict[str, str]:
634
+ if not exists:
635
+ return {}
636
+ try:
637
+ raw = yaml.safe_load(RepositorySnapshot(root, ref).read_text(Path(path)))
638
+ except yaml.YAMLError as exc:
639
+ raise ConfigurationError(f"invalid workflow {path} at {ref}") from exc
640
+ if not isinstance(raw, dict) or not isinstance(raw.get("jobs"), dict):
641
+ raise ConfigurationError(f"workflow {path} at {ref} needs a jobs mapping")
642
+ return {
643
+ str(identifier): _digest(job)
644
+ for identifier, job in sorted(raw["jobs"].items())
645
+ }
646
+
647
+
648
+ def _workflow_events(
649
+ root: Path,
650
+ *,
651
+ base: str,
652
+ head: str,
653
+ repository_path: str,
654
+ base_exists: bool,
655
+ head_exists: bool,
656
+ graph_roots: tuple[str, ...],
657
+ ) -> tuple[ImpactEvent, ...]:
658
+ base_jobs = _workflow_jobs(root, base, repository_path, exists=base_exists)
659
+ head_jobs = _workflow_jobs(root, head, repository_path, exists=head_exists)
660
+ events = []
661
+ for identifier in sorted(set(base_jobs) | set(head_jobs)):
662
+ before = base_jobs.get(identifier)
663
+ after = head_jobs.get(identifier)
664
+ if before == after:
665
+ continue
666
+ change = (
667
+ "added" if before is None else "removed" if after is None else "changed"
668
+ )
669
+ item_id = f"ci-job:{repository_path}:jobs.{identifier}"
670
+ events.append(
671
+ ImpactEvent(
672
+ id=_identifier(item_id, change, before, after),
673
+ kind=f"ci-job-{change}",
674
+ path=repository_path,
675
+ item_id=item_id,
676
+ locator=f"jobs.{identifier}",
677
+ before_digest=before,
678
+ after_digest=after,
679
+ coverage="adapter",
680
+ graph_roots=graph_roots,
681
+ )
682
+ )
683
+ return tuple(events)
684
+
685
+
686
+ def _workflow_path_filters(raw: object) -> tuple[tuple[str, tuple[str, ...]], ...]:
687
+ """Return declared event path filters without consulting the hosting provider."""
688
+ if not isinstance(raw, dict):
689
+ return ()
690
+ triggers = raw.get("on", raw.get(True))
691
+ if not isinstance(triggers, dict):
692
+ return ()
693
+ filters: list[tuple[str, tuple[str, ...]]] = []
694
+ for event, configuration in sorted(triggers.items(), key=lambda item: str(item[0])):
695
+ if str(event) not in {"pull_request", "pull_request_target"}:
696
+ continue
697
+ if not isinstance(configuration, dict):
698
+ continue
699
+ paths = configuration.get("paths")
700
+ if not isinstance(paths, list) or not all(isinstance(path, str) for path in paths):
701
+ continue
702
+ filters.append((str(event), tuple(paths)))
703
+ return tuple(filters)
704
+
705
+
706
+ def _matches_workflow_paths(path: str, patterns: tuple[str, ...]) -> bool:
707
+ """Apply GitHub's ordered include and negated-path semantics to one repository path."""
708
+ matched = False
709
+ for pattern in patterns:
710
+ negated = pattern.startswith("!")
711
+ candidate = pattern[1:] if negated else pattern
712
+ if fnmatch.fnmatchcase(path, candidate):
713
+ matched = not negated
714
+ return matched
715
+
716
+
717
+ def _workflow_path_filter_findings(
718
+ root: Path,
719
+ *,
720
+ ref: str,
721
+ workflow_paths: tuple[str, ...],
722
+ changed_paths: tuple[str, ...],
723
+ prefix: str,
724
+ ) -> tuple[ImpactFinding, ...]:
725
+ """Report potentially skipped specialized workflows as unknown until a run receipt exists."""
726
+ relevant_paths = tuple(
727
+ path for path in changed_paths if Path(path).parts[:2] != (".github", "workflows")
728
+ )
729
+ if not relevant_paths:
730
+ return ()
731
+ findings: list[ImpactFinding] = []
732
+ for workflow_path in workflow_paths:
733
+ try:
734
+ raw = yaml.safe_load(RepositorySnapshot(root, ref).read_text(Path(workflow_path)))
735
+ except (yaml.YAMLError, UnicodeDecodeError):
736
+ continue
737
+ for event, patterns in _workflow_path_filters(raw):
738
+ excluded = tuple(
739
+ path for path in relevant_paths if not _matches_workflow_paths(path, patterns)
740
+ )
741
+ if not excluded:
742
+ continue
743
+ repository_workflow = _repo_path(prefix, workflow_path)
744
+ findings.append(
745
+ _finding(
746
+ "unknown",
747
+ "ci-path-filter-unverified",
748
+ f"{repository_workflow} {event} paths may skip changed paths: "
749
+ + ", ".join(excluded),
750
+ paths=(repository_workflow, *(_repo_path(prefix, path) for path in excluded)),
751
+ obligations=("verify-specialized-ci-run",),
752
+ )
753
+ )
754
+ return tuple(findings)
755
+
756
+
757
+ def _finding(
758
+ classification: str,
759
+ rule: str,
760
+ message: str,
761
+ *,
762
+ paths: tuple[str, ...],
763
+ roots: tuple[str, ...] = (),
764
+ obligations: tuple[str, ...] = (),
765
+ ) -> ImpactFinding:
766
+ normalized_paths = tuple(sorted(set(paths)))
767
+ normalized_roots = tuple(sorted(set(roots)))
768
+ normalized_obligations = tuple(sorted(set(obligations)))
769
+ return ImpactFinding(
770
+ id=_identifier(
771
+ classification,
772
+ rule,
773
+ message,
774
+ normalized_paths,
775
+ normalized_roots,
776
+ normalized_obligations,
777
+ ),
778
+ classification=classification,
779
+ rule=rule,
780
+ message=message,
781
+ paths=normalized_paths,
782
+ graph_roots=normalized_roots,
783
+ obligations=normalized_obligations,
784
+ )
785
+
786
+
787
+ def _prepare_impact_plan(
788
+ root: Path,
789
+ manifest_path: Path,
790
+ *,
791
+ merge_base: str,
792
+ head_sha: str,
793
+ use_cache: bool,
794
+ project: Path,
795
+ head_snapshot_root: Path,
796
+ execution_policy: ExecutionPolicy,
797
+ ) -> _ImpactPreparation:
798
+ changed, base_inventory, head_inventory = _check_changed_details(
799
+ root,
800
+ manifest_path,
801
+ base=merge_base,
802
+ head=head_sha,
803
+ use_cache=use_cache,
804
+ project=project,
805
+ head_snapshot_root=head_snapshot_root,
806
+ execution_policy=execution_policy,
807
+ )
808
+ prefix = "" if project == Path(".") else project.as_posix().rstrip("/") + "/"
809
+ project_changed = tuple(
810
+ _project_path(prefix, path) for path in changed.changed_files
811
+ )
812
+ base_items = {item.id: item for item in base_inventory}
813
+ head_items = {item.id: item for item in head_inventory}
814
+ interface_paths = tuple(
815
+ path
816
+ for path in project_changed
817
+ if Path(path).suffix.lower() in {".py", ".ts", ".tsx", ".js", ".jsx"}
818
+ )
819
+ base_interfaces, base_interface_failures = _interface_fingerprints(
820
+ root, changed.base, project, interface_paths
821
+ )
822
+ head_interfaces, head_interface_failures = _interface_fingerprints(
823
+ root, changed.head, project, interface_paths
824
+ )
825
+ inventory_changes = []
826
+ for item_id in sorted(set(base_items) | set(head_items)):
827
+ before_item = base_items.get(item_id)
828
+ after_item = head_items.get(item_id)
829
+ inventory_item = after_item or before_item
830
+ assert inventory_item is not None
831
+ if (
832
+ inventory_item.kind == "api-symbol"
833
+ and inventory_item.source in interface_paths
834
+ ):
835
+ if inventory_item.source in (
836
+ base_interface_failures | head_interface_failures
837
+ ):
838
+ continue
839
+ before_interface = base_interfaces.get(item_id)
840
+ after_interface = head_interfaces.get(item_id)
841
+ if before_interface == after_interface:
842
+ continue
843
+ if before_item is not None and before_interface is not None:
844
+ before_item = replace(before_item, digest=before_interface)
845
+ if after_item is not None and after_interface is not None:
846
+ after_item = replace(after_item, digest=after_interface)
847
+ inventory_changes.append((item_id, before_item, after_item))
848
+ continue
849
+ if (
850
+ before_item is not None
851
+ and after_item is not None
852
+ and before_item.digest == after_item.digest
853
+ and before_item.coverage == after_item.coverage
854
+ ):
855
+ continue
856
+ inventory_changes.append((item_id, before_item, after_item))
857
+ public_locator_changes = {
858
+ (item.source, item.locator)
859
+ for _item_id, before_item, after_item in inventory_changes
860
+ for item in (after_item or before_item,)
861
+ if item is not None and item.kind in PUBLIC_KINDS
862
+ }
863
+ required_document_paths = {
864
+ _project_path(prefix, item.doc)
865
+ for item in changed.required
866
+ if item.doc
867
+ }
868
+ artifact_roots: dict[str, set[str]] = {
869
+ path: set() for path in project_changed
870
+ }
871
+ edges: set[ImpactEdge] = set()
872
+ affected_docs: set[str] = {
873
+ item.source
874
+ for item in head_inventory
875
+ if item.kind == "document" and item.source in project_changed
876
+ }
877
+ for path in affected_docs:
878
+ artifact_roots[path].add(f"document:{path}")
879
+ return _ImpactPreparation(
880
+ changed,
881
+ prefix,
882
+ project_changed,
883
+ inventory_changes,
884
+ public_locator_changes,
885
+ required_document_paths,
886
+ artifact_roots,
887
+ edges,
888
+ affected_docs,
889
+ )
890
+
891
+
892
+ def build_impact_plan(
893
+ root: Path,
894
+ manifest_path: Path,
895
+ *,
896
+ base: str,
897
+ head: str,
898
+ use_cache: bool = True,
899
+ project: Path = Path("."),
900
+ execution_policy: ExecutionPolicy = ExecutionPolicy.TRUSTED,
901
+ ) -> ImpactPlan:
902
+ root = root.resolve()
903
+ if not base or not head:
904
+ raise ConfigurationError("impact planning requires --base and --head")
905
+ if project.is_absolute() or ".." in project.parts:
906
+ raise ConfigurationError("impact-plan project must stay inside the repository")
907
+ project = (
908
+ Path(project.as_posix().strip("/"))
909
+ if project.as_posix() != "."
910
+ else Path(".")
911
+ )
912
+ project_root = (root / project).resolve()
913
+ try:
914
+ manifest_relative = manifest_path.resolve().relative_to(project_root)
915
+ except ValueError as exc:
916
+ raise ConfigurationError(
917
+ "impact-plan manifest must be inside the selected project"
918
+ ) from exc
919
+
920
+ requested_base = RepositorySnapshot(root, base).label
921
+ head_sha = RepositorySnapshot(root, head).label
922
+ merge_base = _git(root, "merge-base", requested_base, head_sha).strip()
923
+ if not merge_base:
924
+ raise ConfigurationError("impact planning could not resolve a merge base")
925
+ with RepositorySnapshot(root, head_sha).materialized_root(
926
+ paths=(() if project == Path(".") else (project,))
927
+ ) as snapshot:
928
+ preparation = _prepare_impact_plan(
929
+ root,
930
+ manifest_path,
931
+ merge_base=merge_base,
932
+ head_sha=head_sha,
933
+ use_cache=use_cache,
934
+ project=project,
935
+ head_snapshot_root=snapshot,
936
+ execution_policy=execution_policy,
937
+ )
938
+ changed = preparation.changed
939
+ prefix = preparation.prefix
940
+ project_changed = preparation.project_changed
941
+ inventory_changes = preparation.inventory_changes
942
+ public_locator_changes = preparation.public_locator_changes
943
+ required_document_paths = preparation.required_document_paths
944
+ artifact_roots = preparation.artifact_roots
945
+ edges = preparation.edges
946
+ affected_docs = preparation.affected_docs
947
+ head_project = snapshot / project
948
+ head_manifest_path = head_project / manifest_relative
949
+ manifest = load_manifest(head_manifest_path)
950
+ manifest_bytes = head_manifest_path.read_bytes()
951
+ manifest_digest = hashlib.sha256(manifest_bytes).hexdigest()
952
+ review_contract_results = tuple(
953
+ _project_review_result(result, prefix)
954
+ for result in evaluate_review_contracts(
955
+ root,
956
+ tuple(
957
+ _repository_review_contract(contract, project)
958
+ for contract in manifest.review_contracts
959
+ ),
960
+ base=changed.base,
961
+ head=changed.head,
962
+ )
963
+ )
964
+ for result in review_contract_results:
965
+ contract_root = f"review-contract:{result.contract_id}"
966
+ for source_evidence in result.sources:
967
+ repository_path = _repo_path(prefix, source_evidence.path)
968
+ edges.add(
969
+ ImpactEdge(
970
+ f"artifact:{repository_path}",
971
+ contract_root,
972
+ "affects",
973
+ )
974
+ )
975
+ for target_evidence in result.targets:
976
+ edges.add(
977
+ ImpactEdge(
978
+ contract_root,
979
+ f"artifact:{_repo_path(prefix, target_evidence.path)}",
980
+ "requests-review",
981
+ )
982
+ )
983
+ binding_docs = {
984
+ binding.id: binding.doc.as_posix() for binding in manifest.bindings
985
+ }
986
+ bindings_by_id = {binding.id: binding for binding in manifest.bindings}
987
+ claim_docs = {
988
+ f"source-claim:{claim.id}": claim.doc.as_posix()
989
+ for claim in manifest.source_claim_checks
990
+ }
991
+ for dependency, paths in changed.dependencies.items():
992
+ root_id = (
993
+ dependency
994
+ if dependency.startswith("source-claim:")
995
+ else f"binding:{dependency}"
996
+ )
997
+ doc = claim_docs.get(dependency) or binding_docs.get(dependency)
998
+ for path in paths:
999
+ artifact_roots.setdefault(path, set()).add(root_id)
1000
+ edges.add(ImpactEdge(f"artifact:{path}", root_id, "affects"))
1001
+ if doc is not None:
1002
+ edges.add(ImpactEdge(root_id, f"document:{doc}", "serves"))
1003
+ binding = bindings_by_id.get(dependency)
1004
+ public_symbol_changed = (
1005
+ isinstance(binding, SymbolBinding)
1006
+ and (
1007
+ binding.source.path.as_posix(),
1008
+ binding.source.symbol or binding.source.path.as_posix(),
1009
+ )
1010
+ in public_locator_changes
1011
+ )
1012
+ if (
1013
+ doc in required_document_paths
1014
+ or doc in project_changed
1015
+ or public_symbol_changed
1016
+ ):
1017
+ affected_docs.add(doc)
1018
+
1019
+ projection_inputs = _projection_inputs(manifest)
1020
+ projection_outputs = set(projection_inputs)
1021
+ affected_docs.difference_update(projection_outputs)
1022
+ manifest_changed = manifest_relative.as_posix() in project_changed
1023
+ affected_projections: set[str] = set()
1024
+ for output, inputs in projection_inputs.items():
1025
+ projection_triggering = sorted(
1026
+ set(inputs) & (affected_docs | set(project_changed))
1027
+ )
1028
+ if projection_triggering or manifest_changed:
1029
+ affected_projections.add(output)
1030
+ for source in projection_triggering:
1031
+ source_kind = (
1032
+ "document" if source in affected_docs else "artifact"
1033
+ )
1034
+ edges.add(
1035
+ ImpactEdge(
1036
+ f"{source_kind}:{source}",
1037
+ f"projection:{output}",
1038
+ "projects-to",
1039
+ )
1040
+ )
1041
+ if source in artifact_roots:
1042
+ artifact_roots[source].add(f"projection:{output}")
1043
+ for path, root_set in artifact_roots.items():
1044
+ if any(
1045
+ root.startswith(("binding:", "source-claim:"))
1046
+ and ImpactEdge(
1047
+ root, f"document:{source}", "serves"
1048
+ )
1049
+ in edges
1050
+ for source in projection_triggering
1051
+ for root in root_set
1052
+ ):
1053
+ root_set.add(f"projection:{output}")
1054
+ if output in artifact_roots:
1055
+ artifact_roots[output].add(f"projection:{output}")
1056
+
1057
+ projection_states = {
1058
+ result.doc: result.changed
1059
+ for result in (
1060
+ evaluate_projections(head_project, manifest)
1061
+ if manifest.projections is not None and affected_projections
1062
+ else []
1063
+ )
1064
+ }
1065
+ evaluation_contexts = _evaluation_contexts(head_project)
1066
+ evaluation_file_changed = ".sourcebound/eval.yml" in project_changed
1067
+ affected_evaluations: dict[str, tuple[str, ...]] = {}
1068
+ graph_documents = affected_docs | affected_projections
1069
+ for identifier, contexts in evaluation_contexts.items():
1070
+ evaluation_triggering = tuple(sorted(set(contexts) & graph_documents))
1071
+ if evaluation_triggering or evaluation_file_changed:
1072
+ affected_evaluations[identifier] = evaluation_triggering
1073
+ for context in evaluation_triggering:
1074
+ source = (
1075
+ f"projection:{context}"
1076
+ if context in projection_outputs
1077
+ else f"document:{context}"
1078
+ )
1079
+ edges.add(
1080
+ ImpactEdge(
1081
+ source,
1082
+ f"evaluation:{identifier}",
1083
+ "verified-by",
1084
+ )
1085
+ )
1086
+ for path, root_set in artifact_roots.items():
1087
+ if source in root_set or (
1088
+ source.startswith("document:")
1089
+ and source.removeprefix("document:") == path
1090
+ ):
1091
+ root_set.add(f"evaluation:{identifier}")
1092
+ if evaluation_file_changed:
1093
+ artifact_roots[".sourcebound/eval.yml"].update(
1094
+ f"evaluation:{identifier}" for identifier in affected_evaluations
1095
+ )
1096
+
1097
+ events_by_path: dict[str, list[ImpactEvent]] = {
1098
+ path: [] for path in project_changed
1099
+ }
1100
+ event_adapters: dict[str, set[str]] = {
1101
+ path: set() for path in project_changed
1102
+ }
1103
+ for item_id, before, after in inventory_changes:
1104
+ inventory_item = after or before
1105
+ assert inventory_item is not None
1106
+ if inventory_item.source not in events_by_path:
1107
+ continue
1108
+ if inventory_item.kind not in PUBLIC_KINDS:
1109
+ continue
1110
+ change = (
1111
+ "added"
1112
+ if before is None
1113
+ else "removed"
1114
+ if after is None
1115
+ else "changed"
1116
+ )
1117
+ event_roots = tuple(sorted(artifact_roots[inventory_item.source]))
1118
+ event = ImpactEvent(
1119
+ id=_identifier(item_id, change, before.digest if before else None, after.digest if after else None),
1120
+ kind=_event_kind(inventory_item.kind, change),
1121
+ path=_repo_path(prefix, inventory_item.source),
1122
+ item_id=item_id,
1123
+ locator=inventory_item.locator,
1124
+ before_digest=before.digest if before else None,
1125
+ after_digest=after.digest if after else None,
1126
+ coverage=inventory_item.coverage,
1127
+ graph_roots=event_roots,
1128
+ )
1129
+ events_by_path[inventory_item.source].append(event)
1130
+ event_adapters[inventory_item.source].add(inventory_item.adapter)
1131
+ failed_adapters: set[str] = set()
1132
+ for path in project_changed:
1133
+ if Path(path).parts[:2] != (".github", "workflows"):
1134
+ continue
1135
+ repository_path = _repo_path(prefix, path)
1136
+ base_blob = _blob_id(root, changed.base, repository_path)
1137
+ head_blob = _blob_id(root, changed.head, repository_path)
1138
+ try:
1139
+ workflow_events = _workflow_events(
1140
+ root,
1141
+ base=changed.base,
1142
+ head=changed.head,
1143
+ repository_path=repository_path,
1144
+ base_exists=base_blob is not None,
1145
+ head_exists=head_blob is not None,
1146
+ graph_roots=tuple(sorted(artifact_roots[path])),
1147
+ )
1148
+ except ConfigurationError:
1149
+ failed_adapters.add(path)
1150
+ workflow_events = ()
1151
+ events_by_path[path].extend(workflow_events)
1152
+ event_adapters[path].add("github-actions-static")
1153
+
1154
+ manifest_repo_path = _repo_path(prefix, manifest_relative.as_posix())
1155
+ artifacts: list[ImpactArtifact] = []
1156
+ for repository_path in changed.changed_files:
1157
+ path = _project_path(prefix, repository_path)
1158
+ base_blob = _blob_id(root, changed.base, repository_path)
1159
+ head_blob = _blob_id(root, changed.head, repository_path)
1160
+ path_events = tuple(sorted(events_by_path[path], key=lambda item: item.id))
1161
+ adapter = _adapter_for(
1162
+ path,
1163
+ event_adapters=tuple(sorted(event_adapters[path])),
1164
+ projection_outputs=projection_outputs,
1165
+ manifest_path=manifest_relative.as_posix(),
1166
+ )
1167
+ adapter_ref = changed.head if head_blob is not None else changed.base
1168
+ adapter_failed = path in failed_adapters or _adapter_failed(
1169
+ root, adapter_ref, repository_path, adapter
1170
+ )
1171
+ if adapter_failed:
1172
+ adapter = f"{adapter}:failed"
1173
+ may_expose = adapter_failed or _may_expose_public_surface(
1174
+ path, adapter, path_events
1175
+ )
1176
+ artifact_graph_roots = tuple(sorted(artifact_roots[path]))
1177
+ event_coverages = {event.coverage for event in path_events}
1178
+ has_public_event = any(
1179
+ event.item_id.split(":", 1)[0] in PUBLIC_KINDS
1180
+ for event in path_events
1181
+ )
1182
+ if path in projection_outputs:
1183
+ coverage = "generated"
1184
+ decision = "projection output is not a change-impact root"
1185
+ elif may_expose and (
1186
+ adapter == "unsupported"
1187
+ or adapter.endswith(":failed")
1188
+ or "standard-gap" in event_coverages
1189
+ or not has_public_event
1190
+ ):
1191
+ coverage = "unknown"
1192
+ decision = "public impact is unsupported or lacks an accepted relationship"
1193
+ elif artifact_graph_roots:
1194
+ coverage = "graph-covered"
1195
+ decision = "traversed accepted documentation relationships"
1196
+ elif adapter == "unsupported":
1197
+ coverage = "unrelated-covered"
1198
+ decision = "unsupported artifact is outside recognized public source paths"
1199
+ elif path.endswith((".md", ".mdx")):
1200
+ coverage = "document-direct"
1201
+ decision = "documentation changed directly"
1202
+ else:
1203
+ coverage = "adapter-covered"
1204
+ decision = "adapter found no public contract delta"
1205
+ artifacts.append(
1206
+ ImpactArtifact(
1207
+ id=_identifier("artifact", repository_path, base_blob, head_blob),
1208
+ path=repository_path,
1209
+ change=_change_kind(base_blob, head_blob),
1210
+ base_blob=base_blob,
1211
+ head_blob=head_blob,
1212
+ adapter=adapter,
1213
+ decision=decision,
1214
+ may_expose_public_surface=may_expose,
1215
+ coverage=coverage,
1216
+ graph_roots=tuple(
1217
+ _repo_path(prefix, root)
1218
+ if root.startswith("artifact:")
1219
+ else root
1220
+ for root in artifact_graph_roots
1221
+ ),
1222
+ )
1223
+ )
1224
+
1225
+ required: list[ImpactFinding] = []
1226
+ recommended: list[ImpactFinding] = []
1227
+ unrelated: list[ImpactFinding] = []
1228
+ unknown: list[ImpactFinding] = []
1229
+ disposition_subjects: dict[str, tuple[str, str]] = {}
1230
+
1231
+ for result in review_contract_results:
1232
+ if result.state not in {"review-recommended", "unknown"}:
1233
+ continue
1234
+ contract_root = f"review-contract:{result.contract_id}"
1235
+ message = (
1236
+ f"review contract {result.contract_id} observed a source change; "
1237
+ "its declared targets require review"
1238
+ if result.state == "review-recommended"
1239
+ else f"review contract {result.contract_id} could not resolve "
1240
+ "every declared locator"
1241
+ )
1242
+ recommended.append(
1243
+ _finding(
1244
+ "recommended",
1245
+ "review-contract",
1246
+ message,
1247
+ paths=tuple(
1248
+ _repo_path(prefix, item.path)
1249
+ for item in result.sources + result.targets
1250
+ ),
1251
+ roots=(contract_root,),
1252
+ obligations=("review-declared-targets",),
1253
+ )
1254
+ )
1255
+
1256
+ for changed_finding in changed.required:
1257
+ finding_roots = tuple(
1258
+ sorted(
1259
+ set(
1260
+ artifact_roots.get(
1261
+ _project_path(prefix, changed_finding.source), set()
1262
+ )
1263
+ )
1264
+ | set(
1265
+ artifact_roots.get(
1266
+ _project_path(prefix, changed_finding.doc), set()
1267
+ )
1268
+ )
1269
+ )
1270
+ )
1271
+ required.append(
1272
+ _finding(
1273
+ "required",
1274
+ changed_finding.rule,
1275
+ changed_finding.message,
1276
+ paths=tuple(
1277
+ path
1278
+ for path in (changed_finding.doc, changed_finding.source)
1279
+ if path
1280
+ ),
1281
+ roots=finding_roots,
1282
+ obligations=("repair-declared-contract",),
1283
+ )
1284
+ )
1285
+ for changed_finding in changed.gaps:
1286
+ finding_roots = tuple(
1287
+ sorted(
1288
+ artifact_roots.get(
1289
+ _project_path(prefix, changed_finding.source), set()
1290
+ )
1291
+ )
1292
+ )
1293
+ unknown.append(
1294
+ _finding(
1295
+ "unknown",
1296
+ changed_finding.rule,
1297
+ changed_finding.message,
1298
+ paths=tuple(
1299
+ path
1300
+ for path in (changed_finding.doc, changed_finding.source)
1301
+ if path
1302
+ ),
1303
+ roots=finding_roots,
1304
+ obligations=("declare-binding-or-reasoned-ignore",),
1305
+ )
1306
+ )
1307
+ for changed_finding in changed.ignored:
1308
+ finding_roots = tuple(
1309
+ sorted(
1310
+ artifact_roots.get(
1311
+ _project_path(prefix, changed_finding.source), set()
1312
+ )
1313
+ )
1314
+ )
1315
+ unrelated.append(
1316
+ _finding(
1317
+ "unrelated",
1318
+ changed_finding.rule,
1319
+ changed_finding.message,
1320
+ paths=tuple(
1321
+ path
1322
+ for path in (changed_finding.doc, changed_finding.source)
1323
+ if path
1324
+ ),
1325
+ roots=finding_roots,
1326
+ obligations=(),
1327
+ )
1328
+ )
1329
+
1330
+ public_event_paths: set[str] = set()
1331
+ for event in sorted(
1332
+ (event for values in events_by_path.values() for event in values),
1333
+ key=lambda item: item.id,
1334
+ ):
1335
+ item_kind = event.item_id.split(":", 1)[0]
1336
+ if item_kind not in PUBLIC_KINDS:
1337
+ continue
1338
+ public_event_paths.add(event.path)
1339
+ if event.coverage == "ignored":
1340
+ unrelated.append(
1341
+ _finding(
1342
+ "unrelated",
1343
+ "ignored-public-contract",
1344
+ f"{event.kind} is covered by a reasoned ignore",
1345
+ paths=(event.path,),
1346
+ roots=event.graph_roots,
1347
+ )
1348
+ )
1349
+ continue
1350
+ if event.coverage == "standard-gap":
1351
+ finding = _finding(
1352
+ "unknown",
1353
+ "unsupported-public-contract",
1354
+ f"{event.kind} has no accepted documentation relationship",
1355
+ paths=(event.path,),
1356
+ roots=event.graph_roots,
1357
+ obligations=("declare-binding-or-reasoned-ignore",),
1358
+ )
1359
+ unknown.append(finding)
1360
+ disposition_subjects[finding.id] = ("event", event.id)
1361
+ continue
1362
+ obligations = (
1363
+ ("review-reference", "review-migration")
1364
+ if event.kind.endswith(("changed", "removed"))
1365
+ else ("review-reference",)
1366
+ )
1367
+ target = required if event.coverage == "bound" else recommended
1368
+ classification = "required" if event.coverage == "bound" else "recommended"
1369
+ target.append(
1370
+ _finding(
1371
+ classification,
1372
+ "public-contract-change",
1373
+ f"{event.kind} reaches {event.coverage} coverage",
1374
+ paths=(event.path,),
1375
+ roots=event.graph_roots,
1376
+ obligations=obligations,
1377
+ )
1378
+ )
1379
+
1380
+ for output in sorted(affected_projections):
1381
+ root_id = f"projection:{output}"
1382
+ pending_document_repair = bool(
1383
+ set(projection_inputs[output]) & required_document_paths
1384
+ )
1385
+ if projection_states.get(output) or pending_document_repair:
1386
+ state = (
1387
+ "is stale after an affected source changed"
1388
+ if projection_states.get(output)
1389
+ else "must refresh after the planned document repair"
1390
+ )
1391
+ required.append(
1392
+ _finding(
1393
+ "required",
1394
+ "projection-refresh",
1395
+ f"projection {output} {state}",
1396
+ paths=(_repo_path(prefix, output),),
1397
+ roots=(root_id,),
1398
+ obligations=("refresh-projection",),
1399
+ )
1400
+ )
1401
+ for identifier, contexts in sorted(affected_evaluations.items()):
1402
+ recommended.append(
1403
+ _finding(
1404
+ "recommended",
1405
+ "evaluation-replay",
1406
+ f"evaluation {identifier} consumes affected documentation",
1407
+ paths=tuple(_repo_path(prefix, path) for path in contexts)
1408
+ or (_repo_path(prefix, ".sourcebound/eval.yml"),),
1409
+ roots=(f"evaluation:{identifier}",),
1410
+ obligations=("replay-evaluation",),
1411
+ )
1412
+ )
1413
+ workflow_paths = tuple(
1414
+ path
1415
+ for path in _git(root, "ls-tree", "-r", "--name-only", changed.head, "--", ".github/workflows").splitlines()
1416
+ if path.endswith((".yml", ".yaml"))
1417
+ )
1418
+ unknown.extend(
1419
+ _workflow_path_filter_findings(
1420
+ root,
1421
+ ref=changed.head,
1422
+ workflow_paths=workflow_paths,
1423
+ changed_paths=changed.changed_files,
1424
+ prefix=prefix,
1425
+ )
1426
+ )
1427
+ if manifest_relative.as_posix() in project_changed:
1428
+ recommended.append(
1429
+ _finding(
1430
+ "recommended",
1431
+ "contract-change-review",
1432
+ "the documentation control manifest changed",
1433
+ paths=(manifest_repo_path,),
1434
+ roots=tuple(
1435
+ sorted(artifact_roots[manifest_relative.as_posix()])
1436
+ ),
1437
+ obligations=("review-contract-scope",),
1438
+ )
1439
+ )
1440
+
1441
+ classified_paths = {
1442
+ path
1443
+ for finding in required + recommended + unrelated + unknown
1444
+ for path in finding.paths
1445
+ }
1446
+ for artifact in artifacts:
1447
+ if artifact.path in classified_paths or artifact.path in public_event_paths:
1448
+ continue
1449
+ if artifact.coverage == "unknown":
1450
+ unsupported_document = artifact.adapter.startswith("mdx-static:failed")
1451
+ finding = _finding(
1452
+ "unknown",
1453
+ (
1454
+ "unsupported-document-format"
1455
+ if unsupported_document
1456
+ else "unsupported-public-candidate"
1457
+ ),
1458
+ (
1459
+ f"{artifact.path} is an unsupported MDX document"
1460
+ if unsupported_document
1461
+ else f"{artifact.path} may expose a public surface, "
1462
+ "but no adapter can classify it"
1463
+ ),
1464
+ paths=(artifact.path,),
1465
+ roots=artifact.graph_roots,
1466
+ obligations=(
1467
+ ("add-mdx-adapter-or-review-manually",)
1468
+ if unsupported_document
1469
+ else ("add-adapter-or-declare-scope",)
1470
+ ),
1471
+ )
1472
+ unknown.append(finding)
1473
+ disposition_subjects[finding.id] = (
1474
+ "artifact",
1475
+ artifact.id,
1476
+ )
1477
+ elif artifact.coverage == "generated":
1478
+ unrelated.append(
1479
+ _finding(
1480
+ "unrelated",
1481
+ "generated-output",
1482
+ f"{artifact.path} is a projection output, not a recursive impact root",
1483
+ paths=(artifact.path,),
1484
+ roots=artifact.graph_roots,
1485
+ )
1486
+ )
1487
+ elif artifact.coverage == "document-direct":
1488
+ unrelated.append(
1489
+ _finding(
1490
+ "unrelated",
1491
+ "direct-document-change",
1492
+ f"{artifact.path} already contains the documentation change",
1493
+ paths=(artifact.path,),
1494
+ roots=artifact.graph_roots,
1495
+ )
1496
+ )
1497
+ else:
1498
+ unrelated.append(
1499
+ _finding(
1500
+ "unrelated",
1501
+ "no-public-contract-delta",
1502
+ f"{artifact.path} has no public documentation obligation in the affected graph",
1503
+ paths=(artifact.path,),
1504
+ roots=artifact.graph_roots,
1505
+ )
1506
+ )
1507
+
1508
+ active_dispositions = {
1509
+ (item.kind, item.subject): item
1510
+ for item in manifest.public_dispositions
1511
+ if item.base == changed.base
1512
+ }
1513
+ undisposed_unknown: list[ImpactFinding] = []
1514
+ for finding in unknown:
1515
+ subject = disposition_subjects.get(finding.id)
1516
+ disposition = active_dispositions.get(subject) if subject is not None else None
1517
+ if disposition is None:
1518
+ undisposed_unknown.append(finding)
1519
+ continue
1520
+ unrelated.append(
1521
+ _finding(
1522
+ "unrelated",
1523
+ "declared-public-disposition",
1524
+ "a historical public-surface finding is documented in "
1525
+ f"{disposition.documentation} and replaced by "
1526
+ f"{disposition.replacement}: {disposition.reason}",
1527
+ paths=tuple(sorted(set(finding.paths + (disposition.documentation.as_posix(),)))),
1528
+ roots=finding.graph_roots,
1529
+ )
1530
+ )
1531
+ unknown = undisposed_unknown
1532
+
1533
+ def _normalized(items: list[ImpactFinding]) -> tuple[ImpactFinding, ...]:
1534
+ return tuple(sorted({item.id: item for item in items}.values(), key=lambda item: item.id))
1535
+
1536
+ normalized_required = _normalized(required)
1537
+ normalized_recommended = _normalized(recommended)
1538
+ normalized_unrelated = _normalized(unrelated)
1539
+ normalized_unknown = _normalized(unknown)
1540
+ coverage_complete = not normalized_unknown
1541
+ impact = (
1542
+ "unknown"
1543
+ if normalized_unknown
1544
+ else "required"
1545
+ if normalized_required
1546
+ else "recommended"
1547
+ if normalized_recommended
1548
+ else "none"
1549
+ )
1550
+ normalized_artifacts = tuple(sorted(artifacts, key=lambda item: item.path))
1551
+ normalized_events = tuple(
1552
+ sorted(
1553
+ (event for values in events_by_path.values() for event in values),
1554
+ key=lambda item: item.id,
1555
+ )
1556
+ )
1557
+ input_digest = _digest(
1558
+ {
1559
+ "requested_base": requested_base,
1560
+ "merge_base": changed.base,
1561
+ "head": changed.head,
1562
+ "project": project.as_posix(),
1563
+ "manifest": manifest_repo_path,
1564
+ "manifest_digest": manifest_digest,
1565
+ "review_contracts": [
1566
+ result.as_dict() for result in review_contract_results
1567
+ ],
1568
+ "artifacts": [
1569
+ {
1570
+ "path": artifact.path,
1571
+ "base_blob": artifact.base_blob,
1572
+ "head_blob": artifact.head_blob,
1573
+ }
1574
+ for artifact in normalized_artifacts
1575
+ ],
1576
+ }
1577
+ )
1578
+ return ImpactPlan(
1579
+ producer_version=__version__,
1580
+ requested_base=requested_base,
1581
+ merge_base=changed.base,
1582
+ head=changed.head,
1583
+ project=project.as_posix(),
1584
+ manifest=manifest_repo_path,
1585
+ manifest_digest=manifest_digest,
1586
+ input_digest=input_digest,
1587
+ impact=impact,
1588
+ coverage_complete=coverage_complete,
1589
+ artifacts=normalized_artifacts,
1590
+ events=normalized_events,
1591
+ review_contracts=review_contract_results,
1592
+ edges=tuple(
1593
+ sorted(edges, key=lambda item: (item.source, item.target, item.kind))
1594
+ ),
1595
+ required=normalized_required,
1596
+ recommended=normalized_recommended,
1597
+ unrelated=normalized_unrelated,
1598
+ unknown=normalized_unknown,
1599
+ )
1600
+
1601
+
1602
+ def render_impact_plan(plan: ImpactPlan) -> str:
1603
+ lines = [
1604
+ f"impact: {plan.impact}",
1605
+ f"plan: sha256:{plan.digest}",
1606
+ f"diff: {plan.merge_base}..{plan.head}",
1607
+ f"coverage: {'complete' if plan.coverage_complete else 'unknown'}",
1608
+ ]
1609
+ for artifact in plan.artifacts:
1610
+ lines.append(
1611
+ f"[{artifact.coverage}] {artifact.path}: "
1612
+ f"{artifact.adapter}; {artifact.decision}"
1613
+ )
1614
+ for classification, findings in (
1615
+ ("required", plan.required),
1616
+ ("recommended", plan.recommended),
1617
+ ("unrelated", plan.unrelated),
1618
+ ("unknown", plan.unknown),
1619
+ ):
1620
+ for finding in findings:
1621
+ lines.append(f"[{classification}] {finding.rule}: {finding.message}")
1622
+ if finding.obligations:
1623
+ lines.append(f"obligations: {', '.join(finding.obligations)}")
1624
+ return "\n".join(lines) + "\n"