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/bootstrap.py
ADDED
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import difflib
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import asdict, dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from clean_docs.applicability import classify_document
|
|
12
|
+
from clean_docs.audit import AUDIT_BASELINE_PATH, audit, write_audit_baseline
|
|
13
|
+
from clean_docs.corpus import list_documents
|
|
14
|
+
from clean_docs.emit import render_llms_txt
|
|
15
|
+
from clean_docs.engine import evaluate
|
|
16
|
+
from clean_docs.errors import ConfigurationError, PolicyError
|
|
17
|
+
from clean_docs.extractors.inventory import INCLUDED_KINDS, extract_repository_overview
|
|
18
|
+
from clean_docs.inventory import InventoryItem, scan_inventory
|
|
19
|
+
from clean_docs.manifest import load_manifest
|
|
20
|
+
from clean_docs.models import (
|
|
21
|
+
LlmsTxtProjection,
|
|
22
|
+
Manifest,
|
|
23
|
+
ProjectionConfig,
|
|
24
|
+
RegionBinding,
|
|
25
|
+
Source,
|
|
26
|
+
)
|
|
27
|
+
from clean_docs.phrasing import GroundedDraft, ModelRecord, PhrasingProvider, build_model_record
|
|
28
|
+
from clean_docs.policy import REGISTER_PROFILE, ensure_purpose_contract
|
|
29
|
+
from clean_docs.projections import evaluate_projections
|
|
30
|
+
from clean_docs.regions import atomic_write, replace_region
|
|
31
|
+
from clean_docs.renderers import render
|
|
32
|
+
from clean_docs.snapshot import RepositorySnapshot
|
|
33
|
+
from clean_docs.write_gate import redact_secrets
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
REFERENCE_REGION = "repository-surface"
|
|
37
|
+
GENERATED_REFERENCE = ".sourcebound/repository-surface.md"
|
|
38
|
+
PLAN_FACT_LIMIT = 100
|
|
39
|
+
PLAN_DIFF_LIMIT = 4000
|
|
40
|
+
REFERENCE_SECTION = re.compile(
|
|
41
|
+
r"^## Repository surface\s*\n.*?(?=^## |\Z)", re.MULTILINE | re.DOTALL
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class PlannedWrite:
|
|
47
|
+
path: str
|
|
48
|
+
content: str
|
|
49
|
+
reason: str
|
|
50
|
+
diff: str
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class PlannedMove:
|
|
55
|
+
source: str
|
|
56
|
+
path: str
|
|
57
|
+
reason: str
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class BootstrapPlan:
|
|
62
|
+
facts: tuple[InventoryItem, ...]
|
|
63
|
+
writes: tuple[PlannedWrite, ...]
|
|
64
|
+
moves: tuple[PlannedMove, ...]
|
|
65
|
+
gaps: tuple[str, ...]
|
|
66
|
+
model: ModelRecord | None
|
|
67
|
+
digest: str
|
|
68
|
+
canonical_documents: tuple[str, ...] = ()
|
|
69
|
+
accept_hygiene_baseline: bool = False
|
|
70
|
+
|
|
71
|
+
def as_dict(self) -> dict[str, object]:
|
|
72
|
+
facts = []
|
|
73
|
+
for item in self.facts:
|
|
74
|
+
facts.append({
|
|
75
|
+
field: redact_secrets(value)[0] if isinstance(value, str) else value
|
|
76
|
+
for field, value in asdict(item).items()
|
|
77
|
+
})
|
|
78
|
+
serialized_facts = facts[:PLAN_FACT_LIMIT]
|
|
79
|
+
return {
|
|
80
|
+
"schema": "sourcebound.content-plan.v1",
|
|
81
|
+
"ok": not self.gaps,
|
|
82
|
+
"digest": self.digest,
|
|
83
|
+
"fact_count": len(facts),
|
|
84
|
+
"facts": serialized_facts,
|
|
85
|
+
"facts_omitted": len(facts) - len(serialized_facts),
|
|
86
|
+
"canonical_documents": list(self.canonical_documents),
|
|
87
|
+
"operations": [
|
|
88
|
+
{
|
|
89
|
+
"action": "write",
|
|
90
|
+
"path": write.path,
|
|
91
|
+
"content": None,
|
|
92
|
+
"reason": write.reason,
|
|
93
|
+
"diff": redact_secrets(write.diff[:PLAN_DIFF_LIMIT])[0],
|
|
94
|
+
"diff_truncated": len(write.diff) > PLAN_DIFF_LIMIT,
|
|
95
|
+
}
|
|
96
|
+
for write in self.writes
|
|
97
|
+
] + [
|
|
98
|
+
{"action": "move", **asdict(move)} for move in self.moves
|
|
99
|
+
] + ([{
|
|
100
|
+
"action": "write",
|
|
101
|
+
"path": AUDIT_BASELINE_PATH.as_posix(),
|
|
102
|
+
"content": None,
|
|
103
|
+
"reason": "record exact existing documentation debt after bootstrap",
|
|
104
|
+
"diff": None,
|
|
105
|
+
}] if self.accept_hygiene_baseline else []),
|
|
106
|
+
"gaps": list(self.gaps),
|
|
107
|
+
"model": self.model.as_dict() if self.model else None,
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _readme_path(root: Path) -> str:
|
|
112
|
+
names = [path.name for path in root.iterdir() if path.is_file()]
|
|
113
|
+
if "README.md" in names:
|
|
114
|
+
return "README.md"
|
|
115
|
+
candidates = sorted(
|
|
116
|
+
name for name in names if name.lower() == "readme.md"
|
|
117
|
+
)
|
|
118
|
+
return candidates[0] if candidates else "README.md"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _manifest_text(
|
|
122
|
+
document: str = "README.md", canonical_documents: tuple[str, ...] = ()
|
|
123
|
+
) -> str:
|
|
124
|
+
declared = tuple(path for path in canonical_documents if path != document)
|
|
125
|
+
include = ""
|
|
126
|
+
if declared:
|
|
127
|
+
include = " include:\n" + "".join(
|
|
128
|
+
f" - {json.dumps(path)}\n" for path in declared
|
|
129
|
+
)
|
|
130
|
+
return f"""\
|
|
131
|
+
version: 2
|
|
132
|
+
bindings:
|
|
133
|
+
- id: repository-surface
|
|
134
|
+
type: region
|
|
135
|
+
doc: {document}
|
|
136
|
+
region: repository-surface
|
|
137
|
+
extractor: repository-overview
|
|
138
|
+
source: {{path: .}}
|
|
139
|
+
renderer: markdown-fragment
|
|
140
|
+
projections:
|
|
141
|
+
llms_txt:
|
|
142
|
+
output: llms.txt
|
|
143
|
+
title: Repository documentation
|
|
144
|
+
summary: Bound facts and explicitly declared canonical repository context.
|
|
145
|
+
{include}
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _binding(document: str = "README.md") -> RegionBinding:
|
|
150
|
+
return RegionBinding(
|
|
151
|
+
id="repository-surface",
|
|
152
|
+
doc=Path(document),
|
|
153
|
+
region=REFERENCE_REGION,
|
|
154
|
+
extractor="repository-overview",
|
|
155
|
+
source=Source(Path(".")),
|
|
156
|
+
renderer="markdown-fragment",
|
|
157
|
+
columns=(),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _reference_document(
|
|
162
|
+
root: Path, document: str, drafts: tuple[GroundedDraft, ...] = ()
|
|
163
|
+
) -> str:
|
|
164
|
+
readme = root / document
|
|
165
|
+
existing = readme.exists()
|
|
166
|
+
title = "# Repository surface\n" if document == GENERATED_REFERENCE else "# Repository\n"
|
|
167
|
+
current = readme.read_text(encoding="utf-8") if existing else title
|
|
168
|
+
current, _redaction_rules = redact_secrets(current)
|
|
169
|
+
registered = REGISTER_PROFILE in current
|
|
170
|
+
if registered:
|
|
171
|
+
current = ensure_purpose_contract(current, fallback=False)
|
|
172
|
+
if not existing and document != GENERATED_REFERENCE:
|
|
173
|
+
lines = current.splitlines()
|
|
174
|
+
heading = next((index for index, line in enumerate(lines) if line.startswith("# ")), 0)
|
|
175
|
+
purpose = [
|
|
176
|
+
REGISTER_PROFILE,
|
|
177
|
+
"<!-- sourcebound:purpose -->",
|
|
178
|
+
"Use this repository guide when you need to run or change this project. Without a "
|
|
179
|
+
"source-bound overview, entry points and public surfaces can drift from the "
|
|
180
|
+
"implementation; after reading, you can locate the detected surfaces and verify "
|
|
181
|
+
"their current sources.",
|
|
182
|
+
"<!-- sourcebound:end purpose -->",
|
|
183
|
+
]
|
|
184
|
+
lines[heading + 1:heading + 1] = ["", *purpose]
|
|
185
|
+
current = "\n".join(lines).rstrip() + "\n"
|
|
186
|
+
binding = _binding(document)
|
|
187
|
+
evidence = extract_repository_overview(RepositorySnapshot(root), binding)
|
|
188
|
+
generated = render(evidence, binding)
|
|
189
|
+
highlights = ""
|
|
190
|
+
if drafts:
|
|
191
|
+
highlights = "### Grounded highlights\n\n" + "\n".join(
|
|
192
|
+
f"- {draft.text}" for draft in drafts
|
|
193
|
+
) + "\n\n"
|
|
194
|
+
section = (
|
|
195
|
+
"## Repository surface\n\n"
|
|
196
|
+
"This summary is a static catalog of detected package, CLI, API, schema, and test "
|
|
197
|
+
"surfaces. It does not validate existing prose claims. Direct manifest bindings are "
|
|
198
|
+
"the accuracy boundary; run `sourcebound inventory` for coverage state and the full "
|
|
199
|
+
"catalog.\n\n"
|
|
200
|
+
f"{highlights}"
|
|
201
|
+
f"<!-- sourcebound:begin {REFERENCE_REGION} -->\n"
|
|
202
|
+
f"{generated}\n"
|
|
203
|
+
f"<!-- sourcebound:end {REFERENCE_REGION} -->\n"
|
|
204
|
+
)
|
|
205
|
+
if f"<!-- sourcebound:begin {REFERENCE_REGION} -->" in current:
|
|
206
|
+
return replace_region(current, REFERENCE_REGION, generated)
|
|
207
|
+
if REFERENCE_SECTION.search(current):
|
|
208
|
+
return REFERENCE_SECTION.sub(section + "\n", current, count=1).rstrip() + "\n"
|
|
209
|
+
return current.rstrip() + "\n\n" + section
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _diff(path: str, before: str, after: str) -> str:
|
|
213
|
+
return "".join(difflib.unified_diff(
|
|
214
|
+
before.splitlines(keepends=True),
|
|
215
|
+
after.splitlines(keepends=True),
|
|
216
|
+
fromfile=path,
|
|
217
|
+
tofile=f"{path} (sourcebound init)",
|
|
218
|
+
))
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _planned_write(root: Path, path: str, content: str, reason: str) -> PlannedWrite | None:
|
|
222
|
+
target = root / path
|
|
223
|
+
before = target.read_text(encoding="utf-8") if target.exists() else ""
|
|
224
|
+
if before == content:
|
|
225
|
+
return None
|
|
226
|
+
return PlannedWrite(path, content, reason, _diff(path, before, content))
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _archive_moves(root: Path) -> list[PlannedMove]:
|
|
230
|
+
# Filename and text-overlap heuristics cannot prove that a record is
|
|
231
|
+
# obsolete or that two files have the same owner. Init only plans
|
|
232
|
+
# reversible generated writes; archival remains an explicit operator act.
|
|
233
|
+
del root
|
|
234
|
+
return []
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _canonical_documents(
|
|
238
|
+
root: Path, readme_path: str, excluded: set[str]
|
|
239
|
+
) -> tuple[str, ...]:
|
|
240
|
+
del root, excluded
|
|
241
|
+
return (readme_path,)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def build_bootstrap_plan(
|
|
245
|
+
root: Path,
|
|
246
|
+
provider: PhrasingProvider | None = None,
|
|
247
|
+
*,
|
|
248
|
+
accept_hygiene_baseline: bool = False,
|
|
249
|
+
) -> BootstrapPlan:
|
|
250
|
+
root = root.resolve()
|
|
251
|
+
if not root.is_dir():
|
|
252
|
+
raise ConfigurationError(f"repository root does not exist or is not a directory: {root}")
|
|
253
|
+
readme_path = _readme_path(root)
|
|
254
|
+
readme = root / readme_path
|
|
255
|
+
binding_document = readme_path
|
|
256
|
+
if readme.exists() and REGISTER_PROFILE not in readme.read_text(encoding="utf-8"):
|
|
257
|
+
binding_document = GENERATED_REFERENCE
|
|
258
|
+
generated_reference = root / binding_document
|
|
259
|
+
if (
|
|
260
|
+
generated_reference.exists()
|
|
261
|
+
and f"<!-- sourcebound:begin {REFERENCE_REGION} -->"
|
|
262
|
+
not in generated_reference.read_text(encoding="utf-8")
|
|
263
|
+
):
|
|
264
|
+
raise ConfigurationError(
|
|
265
|
+
f"init cannot replace the reserved generated document: {binding_document}"
|
|
266
|
+
)
|
|
267
|
+
archive_candidates = _archive_moves(root)
|
|
268
|
+
moves = [] if accept_hygiene_baseline else archive_candidates
|
|
269
|
+
canonical_documents = _canonical_documents(
|
|
270
|
+
root, readme_path, {item.source for item in archive_candidates}
|
|
271
|
+
)
|
|
272
|
+
existing_manifest = root / ".sourcebound.yml"
|
|
273
|
+
manifest_text = _manifest_text(binding_document, canonical_documents)
|
|
274
|
+
if existing_manifest.exists() and existing_manifest.read_text(encoding="utf-8") != manifest_text:
|
|
275
|
+
raise ConfigurationError(
|
|
276
|
+
"init cannot replace an existing manifest; remove it or run inventory and add bindings"
|
|
277
|
+
)
|
|
278
|
+
report = scan_inventory(root)
|
|
279
|
+
facts = tuple(item for item in report.items if item.kind in INCLUDED_KINDS)
|
|
280
|
+
model = build_model_record(root, facts, provider) if provider else None
|
|
281
|
+
reference = _reference_document(
|
|
282
|
+
root,
|
|
283
|
+
binding_document,
|
|
284
|
+
model.drafts if model else (),
|
|
285
|
+
)
|
|
286
|
+
moved = {item.source for item in moves}
|
|
287
|
+
manifest = Manifest(
|
|
288
|
+
path=root / ".sourcebound.yml",
|
|
289
|
+
version=1,
|
|
290
|
+
bindings=(_binding(binding_document),),
|
|
291
|
+
projections=ProjectionConfig(
|
|
292
|
+
llms_txt=LlmsTxtProjection(
|
|
293
|
+
Path("llms.txt"),
|
|
294
|
+
"Repository documentation",
|
|
295
|
+
"Bound facts and explicitly declared canonical repository context.",
|
|
296
|
+
tuple(Path(path) for path in canonical_documents if path != binding_document),
|
|
297
|
+
)
|
|
298
|
+
),
|
|
299
|
+
)
|
|
300
|
+
assert manifest.projections is not None
|
|
301
|
+
llms_config = manifest.projections.llms_txt
|
|
302
|
+
assert llms_config is not None
|
|
303
|
+
indexed_documents = {
|
|
304
|
+
path: (
|
|
305
|
+
reference.encode("utf-8")
|
|
306
|
+
if path == binding_document
|
|
307
|
+
else (root / path).read_bytes()
|
|
308
|
+
)
|
|
309
|
+
for path in canonical_documents
|
|
310
|
+
}
|
|
311
|
+
indexed_documents[binding_document] = reference.encode("utf-8")
|
|
312
|
+
llms = render_llms_txt(
|
|
313
|
+
manifest,
|
|
314
|
+
title=llms_config.title,
|
|
315
|
+
summary=llms_config.summary,
|
|
316
|
+
documents=indexed_documents,
|
|
317
|
+
output_path=root / llms_config.output,
|
|
318
|
+
)
|
|
319
|
+
writes = [
|
|
320
|
+
item for item in (
|
|
321
|
+
_planned_write(
|
|
322
|
+
root,
|
|
323
|
+
binding_document,
|
|
324
|
+
reference,
|
|
325
|
+
"bind detected repository surfaces",
|
|
326
|
+
),
|
|
327
|
+
_planned_write(root, ".sourcebound.yml", manifest_text, "declare the generated binding"),
|
|
328
|
+
_planned_write(root, "llms.txt", llms, "index the source-bound documentation"),
|
|
329
|
+
) if item is not None
|
|
330
|
+
]
|
|
331
|
+
purpose_gaps: list[str] = []
|
|
332
|
+
for path in list_documents(root):
|
|
333
|
+
relative = path.relative_to(root).as_posix()
|
|
334
|
+
if relative == binding_document or relative in moved:
|
|
335
|
+
continue
|
|
336
|
+
current = path.read_text(encoding="utf-8")
|
|
337
|
+
if REGISTER_PROFILE not in current:
|
|
338
|
+
continue
|
|
339
|
+
profile = classify_document(path.relative_to(root), current)
|
|
340
|
+
if not profile.applies("purpose-contract"):
|
|
341
|
+
continue
|
|
342
|
+
updated = ensure_purpose_contract(current, fallback=False)
|
|
343
|
+
if "<!-- sourcebound:purpose -->" not in updated:
|
|
344
|
+
purpose_gaps.append(
|
|
345
|
+
f"purpose contract needs authored judgment: {relative}"
|
|
346
|
+
)
|
|
347
|
+
continue
|
|
348
|
+
planned = _planned_write(
|
|
349
|
+
root,
|
|
350
|
+
relative,
|
|
351
|
+
updated,
|
|
352
|
+
"mark the document-level purpose contract",
|
|
353
|
+
)
|
|
354
|
+
if planned is not None:
|
|
355
|
+
writes.append(planned)
|
|
356
|
+
planned_content = {write.path: write.content for write in writes}
|
|
357
|
+
indexed_documents = {
|
|
358
|
+
path: (
|
|
359
|
+
planned_content[path]
|
|
360
|
+
if path in planned_content
|
|
361
|
+
else (root / path).read_text(encoding="utf-8")
|
|
362
|
+
).encode("utf-8")
|
|
363
|
+
for path in canonical_documents
|
|
364
|
+
}
|
|
365
|
+
indexed_documents[binding_document] = (
|
|
366
|
+
planned_content.get(binding_document, reference).encode("utf-8")
|
|
367
|
+
)
|
|
368
|
+
final_llms = render_llms_txt(
|
|
369
|
+
manifest,
|
|
370
|
+
title=llms_config.title,
|
|
371
|
+
summary=llms_config.summary,
|
|
372
|
+
documents=indexed_documents,
|
|
373
|
+
output_path=root / llms_config.output,
|
|
374
|
+
)
|
|
375
|
+
writes = [write for write in writes if write.path != "llms.txt"]
|
|
376
|
+
llms_write = _planned_write(root, "llms.txt", final_llms, "index the canonical documentation")
|
|
377
|
+
if llms_write is not None:
|
|
378
|
+
writes.append(llms_write)
|
|
379
|
+
supported = {"Python", "TypeScript", "JavaScript"}
|
|
380
|
+
gaps = tuple(
|
|
381
|
+
f"language adapter missing: {language}"
|
|
382
|
+
for language in report.languages
|
|
383
|
+
if language not in supported
|
|
384
|
+
)
|
|
385
|
+
if not facts and not report.languages:
|
|
386
|
+
gaps += (
|
|
387
|
+
"no supported source facts detected; use a complete source checkout or add a binding manually",
|
|
388
|
+
)
|
|
389
|
+
if not accept_hygiene_baseline:
|
|
390
|
+
gaps += tuple(purpose_gaps)
|
|
391
|
+
introduced_secret = False
|
|
392
|
+
for item in writes:
|
|
393
|
+
target = root / item.path
|
|
394
|
+
before = target.read_text(encoding="utf-8") if target.exists() else ""
|
|
395
|
+
before_rules = set(redact_secrets(before)[1])
|
|
396
|
+
after_rules = set(redact_secrets(item.content)[1])
|
|
397
|
+
if after_rules - before_rules:
|
|
398
|
+
introduced_secret = True
|
|
399
|
+
break
|
|
400
|
+
if introduced_secret:
|
|
401
|
+
gaps += ("secret detected in generated documentation",)
|
|
402
|
+
payload = json.dumps({
|
|
403
|
+
"facts": [item.id + ":" + item.digest for item in facts],
|
|
404
|
+
"writes": [(item.path, hashlib.sha256(item.content.encode()).hexdigest()) for item in writes],
|
|
405
|
+
"moves": [(item.source, item.path) for item in moves],
|
|
406
|
+
"gaps": gaps,
|
|
407
|
+
"model": model.as_dict() if model else None,
|
|
408
|
+
"accept_hygiene_baseline": accept_hygiene_baseline,
|
|
409
|
+
"canonical_documents": canonical_documents,
|
|
410
|
+
}, sort_keys=True, separators=(",", ":"))
|
|
411
|
+
return BootstrapPlan(
|
|
412
|
+
facts,
|
|
413
|
+
tuple(writes),
|
|
414
|
+
tuple(moves),
|
|
415
|
+
gaps,
|
|
416
|
+
model,
|
|
417
|
+
hashlib.sha256(payload.encode()).hexdigest(),
|
|
418
|
+
canonical_documents,
|
|
419
|
+
accept_hygiene_baseline,
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def apply_bootstrap_plan(root: Path, plan: BootstrapPlan) -> None:
|
|
424
|
+
root = root.resolve()
|
|
425
|
+
if plan.gaps:
|
|
426
|
+
raise ConfigurationError("cannot initialize unsupported surfaces: " + "; ".join(plan.gaps))
|
|
427
|
+
originals = {
|
|
428
|
+
write.path: (root / write.path).read_bytes() if (root / write.path).exists() else None
|
|
429
|
+
for write in plan.writes
|
|
430
|
+
}
|
|
431
|
+
parent_existed = {
|
|
432
|
+
(root / write.path).parent: (root / write.path).parent.exists()
|
|
433
|
+
for write in plan.writes
|
|
434
|
+
}
|
|
435
|
+
baseline_key = AUDIT_BASELINE_PATH.as_posix()
|
|
436
|
+
if plan.accept_hygiene_baseline and baseline_key not in originals:
|
|
437
|
+
baseline_path = root / AUDIT_BASELINE_PATH
|
|
438
|
+
originals[baseline_key] = (
|
|
439
|
+
baseline_path.read_bytes() if baseline_path.exists() else None
|
|
440
|
+
)
|
|
441
|
+
completed_moves: list[PlannedMove] = []
|
|
442
|
+
try:
|
|
443
|
+
for move in plan.moves:
|
|
444
|
+
source = root / move.source
|
|
445
|
+
destination = root / move.path
|
|
446
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
447
|
+
if destination.exists():
|
|
448
|
+
raise ConfigurationError(f"archive destination already exists: {move.path}")
|
|
449
|
+
os.replace(source, destination)
|
|
450
|
+
completed_moves.append(move)
|
|
451
|
+
for write in plan.writes:
|
|
452
|
+
atomic_write(root / write.path, write.content)
|
|
453
|
+
results = evaluate(root, root / ".sourcebound.yml")
|
|
454
|
+
if any(result.changed for result in results):
|
|
455
|
+
raise ConfigurationError("init wrote a baseline that does not pass binding checks")
|
|
456
|
+
if plan.accept_hygiene_baseline:
|
|
457
|
+
write_audit_baseline(root)
|
|
458
|
+
report = audit(root)
|
|
459
|
+
if not report.ok:
|
|
460
|
+
details = "; ".join(
|
|
461
|
+
f"{finding.path}:{finding.line} {finding.rule}" for finding in report.findings[:5]
|
|
462
|
+
)
|
|
463
|
+
if report.stale_baseline:
|
|
464
|
+
details = (details + "; " if details else "") + "; ".join(
|
|
465
|
+
f"{finding.path}:{finding.line} stale-baseline"
|
|
466
|
+
for finding in report.stale_baseline[:5]
|
|
467
|
+
)
|
|
468
|
+
raise PolicyError(f"init baseline has policy findings: {details}")
|
|
469
|
+
manifest = load_manifest(root / ".sourcebound.yml")
|
|
470
|
+
if any(result.changed for result in evaluate_projections(root, manifest)):
|
|
471
|
+
raise ConfigurationError("init wrote a baseline with stale projections")
|
|
472
|
+
except Exception:
|
|
473
|
+
for path, content in originals.items():
|
|
474
|
+
target = root / path
|
|
475
|
+
if content is None:
|
|
476
|
+
target.unlink(missing_ok=True)
|
|
477
|
+
else:
|
|
478
|
+
atomic_write(target, content.decode("utf-8"))
|
|
479
|
+
if plan.accept_hygiene_baseline:
|
|
480
|
+
try:
|
|
481
|
+
(root / AUDIT_BASELINE_PATH).parent.rmdir()
|
|
482
|
+
except OSError:
|
|
483
|
+
pass
|
|
484
|
+
for parent, existed in sorted(
|
|
485
|
+
parent_existed.items(),
|
|
486
|
+
key=lambda item: len(item[0].parts),
|
|
487
|
+
reverse=True,
|
|
488
|
+
):
|
|
489
|
+
if not existed:
|
|
490
|
+
try:
|
|
491
|
+
parent.rmdir()
|
|
492
|
+
except OSError:
|
|
493
|
+
pass
|
|
494
|
+
for move in reversed(completed_moves):
|
|
495
|
+
source = root / move.source
|
|
496
|
+
destination = root / move.path
|
|
497
|
+
if destination.exists():
|
|
498
|
+
source.parent.mkdir(parents=True, exist_ok=True)
|
|
499
|
+
os.replace(destination, source)
|
|
500
|
+
parent = destination.parent
|
|
501
|
+
while parent != root:
|
|
502
|
+
try:
|
|
503
|
+
parent.rmdir()
|
|
504
|
+
except OSError:
|
|
505
|
+
break
|
|
506
|
+
parent = parent.parent
|
|
507
|
+
raise
|