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.
- clean_docs/__init__.py +26 -0
- clean_docs/__main__.py +3 -0
- clean_docs/accessibility.py +182 -0
- clean_docs/adapters/__init__.py +1 -0
- clean_docs/adapters/event_capture.py +56 -0
- clean_docs/adapters/mdx_dependencies.json +714 -0
- clean_docs/adapters/mdx_parser.mjs +29992 -0
- clean_docs/applicability.py +330 -0
- clean_docs/audit.py +1120 -0
- clean_docs/bootstrap.py +507 -0
- clean_docs/capabilities.py +200 -0
- clean_docs/changed.py +452 -0
- clean_docs/claims.py +840 -0
- clean_docs/cli.py +1612 -0
- clean_docs/context.py +307 -0
- clean_docs/corpus.py +377 -0
- clean_docs/demo.py +369 -0
- clean_docs/doctor.py +184 -0
- clean_docs/emit/__init__.py +4 -0
- clean_docs/emit/llms_txt.py +102 -0
- clean_docs/emit/stepwise.py +168 -0
- clean_docs/engine.py +324 -0
- clean_docs/errors.py +20 -0
- clean_docs/evaluation.py +867 -0
- clean_docs/execution.py +138 -0
- clean_docs/explain.py +123 -0
- clean_docs/extractors/__init__.py +19 -0
- clean_docs/extractors/command.py +51 -0
- clean_docs/extractors/inventory.py +176 -0
- clean_docs/extractors/json_pointer.py +84 -0
- clean_docs/extractors/python_literal.py +104 -0
- clean_docs/extractors/static.py +111 -0
- clean_docs/feedback.py +1390 -0
- clean_docs/impact.py +1624 -0
- clean_docs/improvements.py +1178 -0
- clean_docs/inventory.py +474 -0
- clean_docs/isolation.py +157 -0
- clean_docs/manifest.py +898 -0
- clean_docs/mdx.py +272 -0
- clean_docs/migration.py +121 -0
- clean_docs/models.py +194 -0
- clean_docs/outcomes.py +296 -0
- clean_docs/performance.py +123 -0
- clean_docs/phrasing.py +448 -0
- clean_docs/plugins.py +249 -0
- clean_docs/policy.py +536 -0
- clean_docs/projections.py +255 -0
- clean_docs/regions.py +75 -0
- clean_docs/release.py +232 -0
- clean_docs/renderers.py +57 -0
- clean_docs/residue.py +311 -0
- clean_docs/review_contracts.py +862 -0
- clean_docs/review_ledger.py +297 -0
- clean_docs/review_limits.py +9 -0
- clean_docs/sensitivity.py +602 -0
- clean_docs/snapshot.py +212 -0
- clean_docs/standard.py +281 -0
- clean_docs/standards/default.json +309 -0
- clean_docs/standards/exemplars.md +87 -0
- clean_docs/standards/v0-migrations.json +26 -0
- clean_docs/symbols.py +47 -0
- clean_docs/templates.py +96 -0
- clean_docs/verdict.py +1397 -0
- clean_docs/visuals.py +346 -0
- clean_docs/write_gate.py +170 -0
- sourcebound-1.2.1.dist-info/LICENSE +21 -0
- sourcebound-1.2.1.dist-info/METADATA +109 -0
- sourcebound-1.2.1.dist-info/RECORD +71 -0
- sourcebound-1.2.1.dist-info/WHEEL +5 -0
- sourcebound-1.2.1.dist-info/entry_points.txt +2 -0
- 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"
|