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
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Public capability registry rendered into the sourcebound README."""
|
|
2
|
+
|
|
3
|
+
PRODUCT_OVERVIEW = (
|
|
4
|
+
"A stale sentence does not fail loudly. It keeps a straight face after the code has moved on, "
|
|
5
|
+
"and reviewers have no mechanical way to identify the false claim. Sourcebound gives each "
|
|
6
|
+
"protected fact a source, then checks that relationship again in CI.\n\n"
|
|
7
|
+
"Declared sources own the protected facts. A packaged policy enforces the deterministic form "
|
|
8
|
+
"floor; authored judgment still owns motivation, pedagogy, and voice. Static adapters read "
|
|
9
|
+
"common code and schema formats, while declared commands run under explicit process controls. "
|
|
10
|
+
"The engine can repair bound regions, rank static count and column candidates, enforce accepted "
|
|
11
|
+
"source-claim relationships, and project canonical text and visual records into purpose-built "
|
|
12
|
+
"human and agent surfaces with local receipts."
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
ASSURANCE_BOUNDARIES = (
|
|
16
|
+
{
|
|
17
|
+
"surface": "Generated region",
|
|
18
|
+
"sourcebound proves": "Rendered region bytes match configured source evidence",
|
|
19
|
+
"sourcebound does not prove": "Authored prose outside the region is accurate or complete",
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"surface": "Legacy command pin (`type: claim`)",
|
|
23
|
+
"sourcebound proves": "Allowlisted command output matches the configured expected value and the document anchor exists",
|
|
24
|
+
"sourcebound does not prove": "The prose under that anchor states the command result",
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"surface": "Symbol reference",
|
|
28
|
+
"sourcebound proves": "The configured source path or Python symbol exists",
|
|
29
|
+
"sourcebound does not prove": "The surrounding prose describes that symbol accurately",
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"surface": "Repository catalog",
|
|
33
|
+
"sourcebound proves": "Detected additions, removals, and replacements stay visible",
|
|
34
|
+
"sourcebound does not prove": "Every cataloged item needs or has a reader-facing explanation",
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"surface": "Accepted static source claim",
|
|
38
|
+
"sourcebound proves": "The documented count or identifier set matches its accepted source locator",
|
|
39
|
+
"sourcebound does not prove": "A ranked candidate names the right semantic relationship",
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"surface": "Binding sensitivity receipt",
|
|
43
|
+
"sourcebound proves": "One static check becomes stale after one independently frozen source fact changes",
|
|
44
|
+
"sourcebound does not prove": "The document and source describe the same concept or that the relationship should be accepted",
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"surface": "Declared review contract",
|
|
48
|
+
"sourcebound proves": "At two immutable refs, repository-declared source locators changed without every declared target locator changing",
|
|
49
|
+
"sourcebound does not prove": "The target is stale, a co-change is semantically correct, or the locators describe the same concept",
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"surface": "Pull-request verdict",
|
|
53
|
+
"sourcebound proves": "Static configured checks and changed-surface evidence produce the reported state at one pinned commit",
|
|
54
|
+
"sourcebound does not prove": "Unbound prose, skipped processes, semantic relationships, or authored judgment are correct",
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"surface": "Packaged writing policy",
|
|
58
|
+
"sourcebound proves": "Implemented deterministic rules pass",
|
|
59
|
+
"sourcebound does not prove": "Motivation, pedagogy, personality, or usefulness pass judgment",
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"surface": "Authored purpose and scope",
|
|
63
|
+
"sourcebound proves": "Declared markers and configured relationships remain intact",
|
|
64
|
+
"sourcebound does not prove": "The repository chose the right goals, audience, or priority",
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"surface": "Opt-in feedback loop",
|
|
68
|
+
"sourcebound proves": "Validated operational envelopes and improvement-state receipts satisfy their closed schemas",
|
|
69
|
+
"sourcebound does not prove": "Observed behavior establishes causality, authorizes purpose, or makes a candidate change correct",
|
|
70
|
+
},
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
SUPPORTED_BINDINGS = {
|
|
74
|
+
"command-pin": {
|
|
75
|
+
"binding": "command pin (`type: claim`)",
|
|
76
|
+
"source": "Allowlisted JSON command",
|
|
77
|
+
"output": "Configured assertion, with optional declared prose at a document anchor",
|
|
78
|
+
"check": "Compare typed expected and observed values; verify declared anchored prose",
|
|
79
|
+
},
|
|
80
|
+
"region": {
|
|
81
|
+
"binding": "region",
|
|
82
|
+
"source": "Static Python, structured data, text, or paths",
|
|
83
|
+
"output": "Table, list, scalar, or fenced text",
|
|
84
|
+
"check": "Re-render and compare",
|
|
85
|
+
},
|
|
86
|
+
"symbol": {
|
|
87
|
+
"binding": "symbol",
|
|
88
|
+
"source": "Static path or Python symbol",
|
|
89
|
+
"output": "Reference at a document anchor",
|
|
90
|
+
"check": "Resolve the cited locator",
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
CLI_REFERENCE = (
|
|
95
|
+
{
|
|
96
|
+
"command": "audit",
|
|
97
|
+
"job": "Assess documentation and enforce adopted scopes",
|
|
98
|
+
"writes": "with --update-baseline",
|
|
99
|
+
"example": "sourcebound audit --format json",
|
|
100
|
+
},
|
|
101
|
+
{"command": "residue", "job": "Manage private cross-project residue matching", "writes": "with init-local", "example": "sourcebound residue status"},
|
|
102
|
+
{"command": "residue status", "job": "Report whether private residue matching is active", "writes": "no", "example": "sourcebound residue status"},
|
|
103
|
+
{"command": "residue init-local", "job": "Create a permission-restricted private residue template", "writes": "yes", "example": "sourcebound residue init-local"},
|
|
104
|
+
{"command": "inventory", "job": "List detected repository surfaces and coverage", "writes": "no", "example": "sourcebound inventory --format json"},
|
|
105
|
+
{"command": "claims", "job": "Rank and verify static count and column claims", "writes": "no", "example": "sourcebound claims --format json"},
|
|
106
|
+
{"command": "binding", "job": "Inspect one proposed source relationship", "writes": "no", "example": "sourcebound binding --help"},
|
|
107
|
+
{
|
|
108
|
+
"command": "binding sensitivity",
|
|
109
|
+
"job": "Test whether one static check depends on a frozen source fact",
|
|
110
|
+
"writes": "no",
|
|
111
|
+
"example": "sourcebound binding sensitivity --help",
|
|
112
|
+
},
|
|
113
|
+
{"command": "context", "job": "Compile provider-neutral evidence packets", "writes": "no", "example": "sourcebound context --help"},
|
|
114
|
+
{"command": "context compile", "job": "Compile a bounded source-addressed context bundle", "writes": "no", "example": "sourcebound context compile --request context-request.json"},
|
|
115
|
+
{"command": "review", "job": "Turn review observations into testable improvement candidates", "writes": "with --out", "example": "sourcebound review --help"},
|
|
116
|
+
{"command": "review candidates", "job": "Compile documentation and product test candidates from one review", "writes": "with --out", "example": "sourcebound review candidates --input review-observations.json"},
|
|
117
|
+
{"command": "review ledger", "job": "Initialize an append-only review denominator", "writes": "with init", "example": "sourcebound review ledger --help"},
|
|
118
|
+
{"command": "review ledger init", "job": "Initialize a review ledger before it reaches a protected branch", "writes": "yes", "example": "sourcebound review ledger init --input review.json --out events.json"},
|
|
119
|
+
{"command": "review lifecycle", "job": "Track assessment-only candidate status with typed evidence", "writes": "with init or transition", "example": "sourcebound review lifecycle --help"},
|
|
120
|
+
{"command": "review lifecycle init", "job": "Initialize a lifecycle record for one candidate set", "writes": "yes", "example": "sourcebound review lifecycle init --input review.json --out lifecycle.json"},
|
|
121
|
+
{"command": "review lifecycle transition", "job": "Apply one evidence-backed candidate transition", "writes": "yes", "example": "sourcebound review lifecycle transition --help"},
|
|
122
|
+
{"command": "review lifecycle check", "job": "Check a lifecycle record against its candidate set", "writes": "no", "example": "sourcebound review lifecycle check --input review.json --state lifecycle.json"},
|
|
123
|
+
{"command": "init", "job": "Write a source-bound documentation baseline", "writes": "yes", "example": "sourcebound init --no-model"},
|
|
124
|
+
{"command": "explain", "job": "Explain a finding or coverage state", "writes": "no", "example": "sourcebound explain purpose-contract --format json"},
|
|
125
|
+
{"command": "doctor", "job": "Check repository and integration readiness", "writes": "with --bundle", "example": "sourcebound doctor --bundle doctor.json"},
|
|
126
|
+
{"command": "verify", "job": "Write a local deterministic outcome receipt", "writes": "with --out", "example": "sourcebound verify --out outcome.json"},
|
|
127
|
+
{"command": "benchmark", "job": "Measure changed-check time and memory budgets", "writes": "with --out", "example": "sourcebound benchmark --base HEAD~1 --head HEAD"},
|
|
128
|
+
{"command": "derive", "job": "Preview or write generated region changes", "writes": "with --write", "example": "sourcebound derive --check"},
|
|
129
|
+
{"command": "drive", "job": "Repair bound regions after deterministic policy checks", "writes": "yes", "example": "sourcebound drive"},
|
|
130
|
+
{"command": "plan", "job": "Build an immutable read-only documentation impact plan", "writes": "no", "example": "sourcebound plan --base origin/main --head HEAD --format json"},
|
|
131
|
+
{
|
|
132
|
+
"command": "verdict",
|
|
133
|
+
"job": "Compose one coverage-stating static PR verdict",
|
|
134
|
+
"writes": "no",
|
|
135
|
+
"example": "sourcebound verdict --base origin/main --head HEAD --format json",
|
|
136
|
+
},
|
|
137
|
+
{"command": "check", "job": "Fail on binding drift or uncovered changed surface", "writes": "no", "example": "sourcebound check --changed --base origin/main --head HEAD"},
|
|
138
|
+
{"command": "project", "job": "Regenerate configured documentation projections", "writes": "unless --check", "example": "sourcebound project --check"},
|
|
139
|
+
{"command": "eval", "job": "Score human tasks and replayable agent round trips", "writes": "with --history or live recording", "example": "sourcebound eval --fixtures .sourcebound/eval.yml"},
|
|
140
|
+
{"command": "release", "job": "Render typed release facts between immutable refs", "writes": "no", "example": "sourcebound release --from v0.9.0 --to HEAD"},
|
|
141
|
+
{"command": "migrate", "job": "Upgrade a prior manifest with rollback backup", "writes": "with --write or --rollback", "example": "sourcebound migrate --write"},
|
|
142
|
+
{"command": "feedback", "job": "Manage opt-in operational feedback", "writes": "yes", "example": "sourcebound feedback status"},
|
|
143
|
+
{"command": "feedback enable", "job": "Consent to a named feedback sink", "writes": "yes", "example": "sourcebound feedback enable --sink local"},
|
|
144
|
+
{"command": "feedback status", "job": "Show feedback consent and pending counts", "writes": "no", "example": "sourcebound feedback status"},
|
|
145
|
+
{"command": "feedback preview", "job": "Print exact pending envelope bytes", "writes": "no", "example": "sourcebound feedback preview"},
|
|
146
|
+
{"command": "feedback flush", "job": "Deliver pending feedback envelopes", "writes": "yes", "example": "sourcebound feedback flush"},
|
|
147
|
+
{"command": "feedback disable", "job": "Remove feedback delivery authority", "writes": "yes", "example": "sourcebound feedback disable"},
|
|
148
|
+
{"command": "feedback rotate", "job": "Replace the feedback installation identifier", "writes": "yes", "example": "sourcebound feedback rotate"},
|
|
149
|
+
{"command": "feedback purge", "job": "Delete local feedback state", "writes": "yes", "example": "sourcebound feedback purge"},
|
|
150
|
+
{"command": "feedback signal", "job": "Validate or ingest aggregate behavior signals", "writes": "varies", "example": "sourcebound feedback signal validate --input signal.json"},
|
|
151
|
+
{"command": "feedback signal prepare", "job": "Add a canonical content-derived signal ID", "writes": "no", "example": "sourcebound feedback signal prepare --input signal-body.json"},
|
|
152
|
+
{"command": "feedback signal validate", "job": "Validate one aggregate behavior signal", "writes": "no", "example": "sourcebound feedback signal validate --input signal.json"},
|
|
153
|
+
{"command": "feedback signal ingest", "job": "Create an observed improvement case", "writes": "yes", "example": "sourcebound feedback signal ingest --input signal.json"},
|
|
154
|
+
{"command": "feedback case", "job": "Advance a verified improvement case", "writes": "yes", "example": "sourcebound feedback case transition --case ID --to reproduced --receipt receipt.json"},
|
|
155
|
+
{"command": "feedback case transition", "job": "Apply one adjacent evidence-backed state transition", "writes": "yes", "example": "sourcebound feedback case transition --case ID --to reproduced --receipt receipt.json"},
|
|
156
|
+
{"command": "emit", "job": "Project the manifest into another format", "writes": "yes", "example": "sourcebound emit --help"},
|
|
157
|
+
{
|
|
158
|
+
"command": "emit stepwise-skill",
|
|
159
|
+
"job": "Write a manifest-derived stepwise skill package",
|
|
160
|
+
"writes": "yes",
|
|
161
|
+
"example": "sourcebound emit stepwise-skill --out skill",
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
"command": "emit llms-txt",
|
|
165
|
+
"job": "Write an index of source-bound documents",
|
|
166
|
+
"writes": "yes",
|
|
167
|
+
"example": "sourcebound emit llms-txt --out llms.txt",
|
|
168
|
+
},
|
|
169
|
+
{"command": "standard", "job": "Build or verify the bundled policy pack", "writes": "varies", "example": "sourcebound standard --help"},
|
|
170
|
+
{"command": "standard build", "job": "Compile the canonical standard", "writes": "yes", "example": "sourcebound standard build"},
|
|
171
|
+
{"command": "standard check", "job": "Fail when the policy pack is stale", "writes": "no", "example": "sourcebound standard check"},
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
EVALUATION_SCORERS = (
|
|
175
|
+
{
|
|
176
|
+
"scorer": "command",
|
|
177
|
+
"input": "Allowlisted command and documented excerpt",
|
|
178
|
+
"passes when": "Exit code and required output match",
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
"scorer": "configuration",
|
|
182
|
+
"input": "Recorded manifest and fixture repository",
|
|
183
|
+
"passes when": "Schema validation and check pass",
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
"scorer": "structured-output",
|
|
187
|
+
"input": "Recorded JSON and expected value",
|
|
188
|
+
"passes when": "Parsed values match exactly",
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
"scorer": "cited-limit",
|
|
192
|
+
"input": "Recorded answer, canonical citation, and forbidden inferences",
|
|
193
|
+
"passes when": "The answer cites the declared limit without inferring support",
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
"scorer": "mutation-red",
|
|
197
|
+
"input": "Provider proposal, frozen fact, and disposable static repository",
|
|
198
|
+
"passes when": "The sensitivity state matches without authorizing the relationship",
|
|
199
|
+
},
|
|
200
|
+
)
|
clean_docs/changed.py
ADDED
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import subprocess
|
|
6
|
+
from contextlib import nullcontext
|
|
7
|
+
from dataclasses import asdict, dataclass
|
|
8
|
+
from fnmatch import fnmatch
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from clean_docs.engine import evaluate
|
|
12
|
+
from clean_docs.errors import ConfigurationError, ExtractionError
|
|
13
|
+
from clean_docs.execution import ExecutionPolicy
|
|
14
|
+
from clean_docs.claims import scan_source_claims
|
|
15
|
+
from clean_docs.inventory import (
|
|
16
|
+
PUBLIC_SURFACE_KINDS,
|
|
17
|
+
InventoryItem,
|
|
18
|
+
scan_inventory,
|
|
19
|
+
)
|
|
20
|
+
from clean_docs.manifest import load_manifest
|
|
21
|
+
from clean_docs.models import Binding, ClaimBinding, RegionBinding
|
|
22
|
+
from clean_docs.regions import atomic_write
|
|
23
|
+
from clean_docs.plugins import discover_plugin_items, merge_plugin_inventory
|
|
24
|
+
from clean_docs.snapshot import RepositorySnapshot
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
CHANGED_CHECK_BUDGET_SECONDS = 5.0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class ChangedFinding:
|
|
32
|
+
id: str
|
|
33
|
+
rule: str
|
|
34
|
+
doc: str
|
|
35
|
+
source: str
|
|
36
|
+
locator: str
|
|
37
|
+
message: str
|
|
38
|
+
repair: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class ChangedReport:
|
|
43
|
+
base: str
|
|
44
|
+
head: str
|
|
45
|
+
project: str
|
|
46
|
+
changed_files: tuple[str, ...]
|
|
47
|
+
required: tuple[ChangedFinding, ...]
|
|
48
|
+
gaps: tuple[ChangedFinding, ...]
|
|
49
|
+
ignored: tuple[ChangedFinding, ...]
|
|
50
|
+
dependencies: dict[str, tuple[str, ...]]
|
|
51
|
+
cache_hits: int = 0
|
|
52
|
+
cache_misses: int = 0
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def ok(self) -> bool:
|
|
56
|
+
return not self.required and not self.gaps
|
|
57
|
+
|
|
58
|
+
def as_dict(self) -> dict[str, object]:
|
|
59
|
+
return {
|
|
60
|
+
"schema": "sourcebound.changed.v1",
|
|
61
|
+
"ok": self.ok,
|
|
62
|
+
"base": self.base,
|
|
63
|
+
"head": self.head,
|
|
64
|
+
"project": self.project,
|
|
65
|
+
"changed_files": list(self.changed_files),
|
|
66
|
+
"required": [asdict(item) for item in self.required],
|
|
67
|
+
"gaps": [asdict(item) for item in self.gaps],
|
|
68
|
+
"ignored": [asdict(item) for item in self.ignored],
|
|
69
|
+
"dependencies": {
|
|
70
|
+
binding: list(paths) for binding, paths in sorted(self.dependencies.items())
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _finding_id(rule: str, doc: str, source: str, locator: str) -> str:
|
|
76
|
+
value = json.dumps([rule, doc, source, locator], separators=(",", ":"))
|
|
77
|
+
return hashlib.sha256(value.encode()).hexdigest()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _affected_paths(
|
|
81
|
+
binding: Binding,
|
|
82
|
+
changed: tuple[str, ...],
|
|
83
|
+
manifest_path: str,
|
|
84
|
+
) -> tuple[str, ...]:
|
|
85
|
+
matched = {path for path in changed if path in {binding.doc.as_posix(), manifest_path}}
|
|
86
|
+
if isinstance(binding, ClaimBinding):
|
|
87
|
+
matched.update(changed)
|
|
88
|
+
else:
|
|
89
|
+
source = binding.source
|
|
90
|
+
if (
|
|
91
|
+
isinstance(binding, RegionBinding)
|
|
92
|
+
and binding.extractor in {"repository-inventory", "repository-overview"}
|
|
93
|
+
):
|
|
94
|
+
matched.update(changed)
|
|
95
|
+
elif source.glob:
|
|
96
|
+
matched.update(path for path in changed if fnmatch(path, source.glob))
|
|
97
|
+
elif source.path.as_posix() in changed:
|
|
98
|
+
matched.add(source.path.as_posix())
|
|
99
|
+
return tuple(sorted(matched))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _git(root: Path, *args: str) -> str:
|
|
103
|
+
proc = subprocess.run(
|
|
104
|
+
["git", "-C", str(root), *args],
|
|
105
|
+
text=True,
|
|
106
|
+
capture_output=True,
|
|
107
|
+
timeout=30,
|
|
108
|
+
check=False,
|
|
109
|
+
)
|
|
110
|
+
if proc.returncode != 0:
|
|
111
|
+
detail = proc.stderr.strip() or proc.stdout.strip() or "unknown git error"
|
|
112
|
+
raise ExtractionError(f"git {' '.join(args)} failed: {detail}")
|
|
113
|
+
return proc.stdout
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _cache_root(root: Path) -> Path:
|
|
117
|
+
git_dir = _git(root, "rev-parse", "--git-dir").strip()
|
|
118
|
+
path = Path(git_dir)
|
|
119
|
+
if not path.is_absolute():
|
|
120
|
+
path = root / path
|
|
121
|
+
return path.resolve() / "sourcebound-cache"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _inventory(
|
|
125
|
+
root: Path,
|
|
126
|
+
ref: str,
|
|
127
|
+
project: Path,
|
|
128
|
+
*,
|
|
129
|
+
use_cache: bool,
|
|
130
|
+
materialized_root: Path | None = None,
|
|
131
|
+
execution_policy: ExecutionPolicy = ExecutionPolicy.TRUSTED,
|
|
132
|
+
) -> tuple[tuple[InventoryItem, ...], bool]:
|
|
133
|
+
key_payload = json.dumps(
|
|
134
|
+
{
|
|
135
|
+
"extractor": "repository-inventory@1",
|
|
136
|
+
"parameters": {"project": project.as_posix()},
|
|
137
|
+
"source": ref,
|
|
138
|
+
"execution_policy": execution_policy.value,
|
|
139
|
+
},
|
|
140
|
+
sort_keys=True,
|
|
141
|
+
separators=(",", ":"),
|
|
142
|
+
)
|
|
143
|
+
key = hashlib.sha256(key_payload.encode()).hexdigest()
|
|
144
|
+
cache_path = _cache_root(root) / f"inventory-{key}.json"
|
|
145
|
+
if use_cache and cache_path.is_file():
|
|
146
|
+
try:
|
|
147
|
+
raw = json.loads(cache_path.read_text(encoding="utf-8"))
|
|
148
|
+
if (
|
|
149
|
+
isinstance(raw, dict)
|
|
150
|
+
and raw.get("key") == key
|
|
151
|
+
and isinstance(raw.get("items"), list)
|
|
152
|
+
):
|
|
153
|
+
return tuple(InventoryItem(**item) for item in raw["items"]), True
|
|
154
|
+
except (OSError, json.JSONDecodeError, TypeError):
|
|
155
|
+
pass
|
|
156
|
+
repository_snapshot = RepositorySnapshot(root, ref)
|
|
157
|
+
snapshot_context = (
|
|
158
|
+
nullcontext(materialized_root)
|
|
159
|
+
if materialized_root is not None
|
|
160
|
+
else repository_snapshot.materialized_root(
|
|
161
|
+
paths=(() if project == Path(".") else (project,))
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
with snapshot_context as snapshot:
|
|
165
|
+
project_root = snapshot / project
|
|
166
|
+
if not project_root.is_dir():
|
|
167
|
+
raise ConfigurationError(f"project does not exist at {ref}: {project}")
|
|
168
|
+
items = list(scan_inventory(project_root).items)
|
|
169
|
+
manifest_path = project_root / ".sourcebound.yml"
|
|
170
|
+
plugins = (
|
|
171
|
+
load_manifest(manifest_path).plugins
|
|
172
|
+
if manifest_path.is_file()
|
|
173
|
+
and execution_policy is ExecutionPolicy.TRUSTED
|
|
174
|
+
else ()
|
|
175
|
+
)
|
|
176
|
+
items = list(
|
|
177
|
+
merge_plugin_inventory(
|
|
178
|
+
tuple(items), discover_plugin_items(repository_snapshot, plugins)
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
if use_cache:
|
|
182
|
+
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
|
183
|
+
atomic_write(cache_path, json.dumps({
|
|
184
|
+
"schema": "sourcebound.inventory-cache.v1",
|
|
185
|
+
"key": key,
|
|
186
|
+
"items": [asdict(item) for item in items],
|
|
187
|
+
}, sort_keys=True, separators=(",", ":")) + "\n")
|
|
188
|
+
return tuple(items), False
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _check_changed_details(
|
|
192
|
+
root: Path,
|
|
193
|
+
manifest_path: Path,
|
|
194
|
+
*,
|
|
195
|
+
base: str,
|
|
196
|
+
head: str,
|
|
197
|
+
use_cache: bool = True,
|
|
198
|
+
project: Path = Path("."),
|
|
199
|
+
head_snapshot_root: Path | None = None,
|
|
200
|
+
execution_policy: ExecutionPolicy = ExecutionPolicy.TRUSTED,
|
|
201
|
+
) -> tuple[ChangedReport, tuple[InventoryItem, ...], tuple[InventoryItem, ...]]:
|
|
202
|
+
root = root.resolve()
|
|
203
|
+
if not base or not head:
|
|
204
|
+
raise ConfigurationError("check --changed requires --base and --head")
|
|
205
|
+
if project.is_absolute() or ".." in project.parts:
|
|
206
|
+
raise ConfigurationError("check --changed project must stay inside the repository")
|
|
207
|
+
project = Path(project.as_posix().strip("/")) if project.as_posix() != "." else Path(".")
|
|
208
|
+
project_root = (root / project).resolve()
|
|
209
|
+
try:
|
|
210
|
+
manifest_relative = manifest_path.resolve().relative_to(project_root)
|
|
211
|
+
except ValueError as exc:
|
|
212
|
+
raise ConfigurationError("changed-check manifest must be inside the selected project") from exc
|
|
213
|
+
base_sha = RepositorySnapshot(root, base).label
|
|
214
|
+
head_sha = RepositorySnapshot(root, head).label
|
|
215
|
+
all_changed_files = tuple(sorted(
|
|
216
|
+
line for line in _git(
|
|
217
|
+
root, "diff", "--name-only", base_sha, head_sha
|
|
218
|
+
).splitlines()
|
|
219
|
+
if line
|
|
220
|
+
))
|
|
221
|
+
prefix = "" if project == Path(".") else project.as_posix().rstrip("/") + "/"
|
|
222
|
+
changed_files = tuple(
|
|
223
|
+
path for path in all_changed_files if not prefix or path.startswith(prefix)
|
|
224
|
+
)
|
|
225
|
+
project_changed_files = tuple(
|
|
226
|
+
path.removeprefix(prefix) if prefix else path for path in changed_files
|
|
227
|
+
)
|
|
228
|
+
base_inventory, base_hit = _inventory(
|
|
229
|
+
root,
|
|
230
|
+
base_sha,
|
|
231
|
+
project,
|
|
232
|
+
use_cache=use_cache,
|
|
233
|
+
execution_policy=execution_policy,
|
|
234
|
+
)
|
|
235
|
+
snapshot_context = (
|
|
236
|
+
nullcontext(head_snapshot_root)
|
|
237
|
+
if head_snapshot_root is not None
|
|
238
|
+
else RepositorySnapshot(root, head_sha).materialized_root()
|
|
239
|
+
)
|
|
240
|
+
with snapshot_context as snapshot:
|
|
241
|
+
head_inventory, head_hit = _inventory(
|
|
242
|
+
root,
|
|
243
|
+
head_sha,
|
|
244
|
+
project,
|
|
245
|
+
use_cache=use_cache,
|
|
246
|
+
materialized_root=snapshot,
|
|
247
|
+
execution_policy=execution_policy,
|
|
248
|
+
)
|
|
249
|
+
base_items = {item.id: item for item in base_inventory}
|
|
250
|
+
head_items = {item.id: item for item in head_inventory}
|
|
251
|
+
required: list[ChangedFinding] = []
|
|
252
|
+
dependencies: dict[str, tuple[str, ...]] = {}
|
|
253
|
+
head_project = snapshot / project
|
|
254
|
+
head_manifest = head_project / manifest_relative
|
|
255
|
+
loaded = load_manifest(head_manifest)
|
|
256
|
+
for binding in loaded.bindings:
|
|
257
|
+
paths = _affected_paths(
|
|
258
|
+
binding, project_changed_files, manifest_relative.as_posix()
|
|
259
|
+
)
|
|
260
|
+
if not paths:
|
|
261
|
+
continue
|
|
262
|
+
dependencies[binding.id] = paths
|
|
263
|
+
for result in evaluate(
|
|
264
|
+
head_project,
|
|
265
|
+
head_manifest,
|
|
266
|
+
binding_id=binding.id,
|
|
267
|
+
execution_policy=execution_policy,
|
|
268
|
+
inventory_items=head_inventory,
|
|
269
|
+
):
|
|
270
|
+
if not result.changed:
|
|
271
|
+
continue
|
|
272
|
+
rule = (
|
|
273
|
+
"execution-skipped"
|
|
274
|
+
if result.state == "skipped-untrusted-execution"
|
|
275
|
+
else "binding-drift"
|
|
276
|
+
)
|
|
277
|
+
doc = prefix + result.doc
|
|
278
|
+
source = prefix + result.provenance.path
|
|
279
|
+
root_arg = (
|
|
280
|
+
"" if project == Path(".") else f" --root {project.as_posix()}"
|
|
281
|
+
)
|
|
282
|
+
required.append(ChangedFinding(
|
|
283
|
+
_finding_id(rule, doc, source, result.provenance.locator),
|
|
284
|
+
rule,
|
|
285
|
+
doc,
|
|
286
|
+
source,
|
|
287
|
+
result.provenance.locator,
|
|
288
|
+
(
|
|
289
|
+
f"binding {result.binding_id} is unknown because "
|
|
290
|
+
"static-only mode skipped repository-declared execution"
|
|
291
|
+
if result.state == "skipped-untrusted-execution"
|
|
292
|
+
else f"binding {result.binding_id} changed behind {doc}"
|
|
293
|
+
),
|
|
294
|
+
(
|
|
295
|
+
"run the binding in a separately configured trusted environment"
|
|
296
|
+
if result.state == "skipped-untrusted-execution"
|
|
297
|
+
else f"sourcebound{root_arg} drive --binding {result.binding_id}"
|
|
298
|
+
),
|
|
299
|
+
))
|
|
300
|
+
affected_claim_checks = []
|
|
301
|
+
for claim_check in loaded.source_claim_checks:
|
|
302
|
+
paths = tuple(
|
|
303
|
+
sorted(
|
|
304
|
+
set(project_changed_files)
|
|
305
|
+
& {
|
|
306
|
+
claim_check.doc.as_posix(),
|
|
307
|
+
claim_check.source.as_posix(),
|
|
308
|
+
manifest_relative.as_posix(),
|
|
309
|
+
}
|
|
310
|
+
)
|
|
311
|
+
)
|
|
312
|
+
if not paths:
|
|
313
|
+
continue
|
|
314
|
+
dependencies[f"source-claim:{claim_check.id}"] = paths
|
|
315
|
+
affected_claim_checks.append(claim_check)
|
|
316
|
+
if affected_claim_checks:
|
|
317
|
+
claim_report = scan_source_claims(
|
|
318
|
+
head_project,
|
|
319
|
+
tuple(affected_claim_checks),
|
|
320
|
+
discover=False,
|
|
321
|
+
)
|
|
322
|
+
for claim_result in claim_report.results:
|
|
323
|
+
if claim_result.status != "drift":
|
|
324
|
+
continue
|
|
325
|
+
rule = "source-claim-drift"
|
|
326
|
+
doc = prefix + claim_result.doc
|
|
327
|
+
source = prefix + claim_result.source
|
|
328
|
+
required.append(
|
|
329
|
+
ChangedFinding(
|
|
330
|
+
_finding_id(rule, doc, source, claim_result.locator),
|
|
331
|
+
rule,
|
|
332
|
+
doc,
|
|
333
|
+
source,
|
|
334
|
+
claim_result.locator,
|
|
335
|
+
claim_result.detail,
|
|
336
|
+
"update the documented value or accepted relationship, "
|
|
337
|
+
"then run sourcebound claims",
|
|
338
|
+
)
|
|
339
|
+
)
|
|
340
|
+
for missing_claim in claim_report.missing:
|
|
341
|
+
rule = "source-claim-contract-missing"
|
|
342
|
+
doc = prefix + missing_claim.doc
|
|
343
|
+
source = prefix + missing_claim.source
|
|
344
|
+
required.append(
|
|
345
|
+
ChangedFinding(
|
|
346
|
+
_finding_id(rule, doc, source, missing_claim.locator),
|
|
347
|
+
rule,
|
|
348
|
+
doc,
|
|
349
|
+
source,
|
|
350
|
+
missing_claim.locator,
|
|
351
|
+
f"accepted source claim {missing_claim.id} cannot be verified: "
|
|
352
|
+
f"{missing_claim.detail}",
|
|
353
|
+
"restore the documented claim and source locator or remove "
|
|
354
|
+
"the obsolete relationship from .sourcebound.yml",
|
|
355
|
+
)
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
gaps: list[ChangedFinding] = []
|
|
359
|
+
ignored: list[ChangedFinding] = []
|
|
360
|
+
for identifier in sorted(set(head_items) - set(base_items)):
|
|
361
|
+
inventory_item = head_items[identifier]
|
|
362
|
+
if inventory_item.kind not in PUBLIC_SURFACE_KINDS:
|
|
363
|
+
continue
|
|
364
|
+
rule = "new-public-surface"
|
|
365
|
+
source = prefix + inventory_item.source
|
|
366
|
+
finding = ChangedFinding(
|
|
367
|
+
_finding_id(rule, "", source, inventory_item.locator),
|
|
368
|
+
rule,
|
|
369
|
+
"",
|
|
370
|
+
source,
|
|
371
|
+
inventory_item.locator,
|
|
372
|
+
f"new {inventory_item.kind} {inventory_item.name!r} has coverage "
|
|
373
|
+
f"state {inventory_item.coverage}",
|
|
374
|
+
"add a source binding or a specific .sourcebound-ignore.yml record",
|
|
375
|
+
)
|
|
376
|
+
if inventory_item.coverage == "ignored":
|
|
377
|
+
ignored.append(finding)
|
|
378
|
+
elif inventory_item.coverage not in {"bound", "cataloged"}:
|
|
379
|
+
gaps.append(finding)
|
|
380
|
+
report = ChangedReport(
|
|
381
|
+
base_sha,
|
|
382
|
+
head_sha,
|
|
383
|
+
project.as_posix(),
|
|
384
|
+
changed_files,
|
|
385
|
+
tuple(sorted(required, key=lambda item: item.id)),
|
|
386
|
+
tuple(sorted(gaps, key=lambda item: item.id)),
|
|
387
|
+
tuple(sorted(ignored, key=lambda item: item.id)),
|
|
388
|
+
dependencies,
|
|
389
|
+
int(base_hit) + int(head_hit),
|
|
390
|
+
int(not base_hit) + int(not head_hit),
|
|
391
|
+
)
|
|
392
|
+
return report, base_inventory, head_inventory
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def check_changed(
|
|
396
|
+
root: Path,
|
|
397
|
+
manifest_path: Path,
|
|
398
|
+
*,
|
|
399
|
+
base: str,
|
|
400
|
+
head: str,
|
|
401
|
+
use_cache: bool = True,
|
|
402
|
+
project: Path = Path("."),
|
|
403
|
+
execution_policy: ExecutionPolicy = ExecutionPolicy.TRUSTED,
|
|
404
|
+
) -> ChangedReport:
|
|
405
|
+
report, _, _ = _check_changed_details(
|
|
406
|
+
root,
|
|
407
|
+
manifest_path,
|
|
408
|
+
base=base,
|
|
409
|
+
head=head,
|
|
410
|
+
use_cache=use_cache,
|
|
411
|
+
project=project,
|
|
412
|
+
execution_policy=execution_policy,
|
|
413
|
+
)
|
|
414
|
+
return report
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def render_sarif(report: ChangedReport) -> str:
|
|
418
|
+
findings = [
|
|
419
|
+
(finding, "error") for finding in report.required
|
|
420
|
+
] + [
|
|
421
|
+
(finding, "warning") for finding in report.gaps
|
|
422
|
+
]
|
|
423
|
+
payload = {
|
|
424
|
+
"$schema": "https://json.schemastore.org/sarif-2.1.0.json",
|
|
425
|
+
"version": "2.1.0",
|
|
426
|
+
"runs": [{
|
|
427
|
+
"tool": {
|
|
428
|
+
"driver": {
|
|
429
|
+
"name": "sourcebound",
|
|
430
|
+
"rules": [
|
|
431
|
+
{"id": rule, "shortDescription": {"text": rule.replace("-", " ")}}
|
|
432
|
+
for rule in sorted({finding.rule for finding, _level in findings})
|
|
433
|
+
],
|
|
434
|
+
}
|
|
435
|
+
},
|
|
436
|
+
"results": [
|
|
437
|
+
{
|
|
438
|
+
"ruleId": finding.rule,
|
|
439
|
+
"level": level,
|
|
440
|
+
"message": {"text": f"{finding.message}. Repair: {finding.repair}"},
|
|
441
|
+
"partialFingerprints": {"cleanDocsFindingId": finding.id},
|
|
442
|
+
"locations": [{
|
|
443
|
+
"physicalLocation": {
|
|
444
|
+
"artifactLocation": {"uri": finding.doc or finding.source},
|
|
445
|
+
}
|
|
446
|
+
}],
|
|
447
|
+
}
|
|
448
|
+
for finding, level in findings
|
|
449
|
+
],
|
|
450
|
+
}],
|
|
451
|
+
}
|
|
452
|
+
return json.dumps(payload, indent=2, sort_keys=True) + "\n"
|