sourcelock 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,395 @@
1
+ """The adapter interface.
2
+
3
+ This module is the contract between SourceLock's core and the route adapters.
4
+ It is deliberately small: four types and one base class.
5
+
6
+ from hc_source.adapters import (
7
+ Canary, CanaryObservation, SourceAdapter, ToolResult, ToolSpec,
8
+ )
9
+
10
+ A route adapter is a single module in ``hc_source/adapters/`` that ends with::
11
+
12
+ ADAPTER = MyAdapter()
13
+
14
+ Nothing else in the repository needs to change for it to be discovered, listed,
15
+ called, canaried, locked, and served over MCP.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from dataclasses import dataclass, field
21
+ from typing import Any, Callable, Mapping
22
+
23
+ from pydantic import BaseModel, ConfigDict, Field
24
+
25
+ from pydantic import ValidationError
26
+
27
+ from .guard import PHI_NON_CLAIM, ParamsRejected, assert_public_params, safe_validation_reasons
28
+ from .schemas import CanarySeverity, CanaryStatus, Receipt, SourceContract
29
+
30
+ __all__ = [
31
+ "AdapterError",
32
+ "Canary",
33
+ "CanaryObservation",
34
+ "CanarySeverity",
35
+ "SourceAdapter",
36
+ "ToolResult",
37
+ "ToolSpec",
38
+ "validate_adapter",
39
+ ]
40
+
41
+
42
+ class AdapterError(Exception):
43
+ """An adapter module is malformed. Raised at discovery time, never later."""
44
+
45
+
46
+ class CanaryObservation(BaseModel):
47
+ """What a canary saw upstream, reduced to something comparable.
48
+
49
+ ``value`` is the token the lockfile pins -- a release id, a row count, a
50
+ version string. Keep it short, deterministic, and free of payload data.
51
+ ``schema_hash`` lets doctor distinguish "the data moved" (drift) from "the
52
+ shape moved" (schema_changed), which need different fixes.
53
+ """
54
+
55
+ model_config = ConfigDict(extra="forbid")
56
+
57
+ value: str = Field(min_length=1, description="Comparable observation token.")
58
+ schema_hash: str | None = Field(
59
+ default=None, description="Stable hash of the upstream shape, if the source exposes one."
60
+ )
61
+ upstream_status: int | None = None
62
+ note: str | None = Field(
63
+ default=None,
64
+ description=(
65
+ "One short line for humans. No payloads. Doctor copies this onto the "
66
+ "result's warnings, so it reaches the CLI, the JSON report, and the "
67
+ "GitHub annotations -- it used to be dropped on the floor."
68
+ ),
69
+ )
70
+ stale: bool = Field(
71
+ default=False,
72
+ description=(
73
+ "True when the canary read something real but out of date: a weekly "
74
+ "snapshot that has not moved in a month, a cross-check it had to skip. "
75
+ "Doctor downgrades an otherwise-matching canary to STALE, because an "
76
+ "`ok` computed from data the canary itself calls stale is not "
77
+ "assurance. Say why in `note`."
78
+ ),
79
+ )
80
+ fallback_used: bool = Field(
81
+ default=False,
82
+ description=(
83
+ "True when this observation came from a mirror because the pinned authority "
84
+ "was unreachable. Pass `FetchResult.fallback_used` straight through: doctor "
85
+ "cannot tell 'the authority agrees with the pin' from 'the authority is dark "
86
+ "and a mirror agrees with the pin' unless the canary says so."
87
+ ),
88
+ )
89
+ fallback_name: str | None = Field(
90
+ default=None, description="Which mirror answered, when fallback_used is True."
91
+ )
92
+
93
+
94
+ @dataclass(frozen=True)
95
+ class Canary:
96
+ """One cheap, deterministic, unauthenticated check against a source.
97
+
98
+ ``observe`` must either return a :class:`CanaryObservation` or raise
99
+ :class:`hc_source.http.SourceUnreachable`. Any other exception is caught by
100
+ doctor and reported as unreachable with a warning -- it is never allowed to
101
+ take the run down.
102
+ """
103
+
104
+ canary_id: str
105
+ source_id: str
106
+ description: str
107
+ observe: Callable[[], CanaryObservation]
108
+ #: Exact human-readable fix, shown on any non-ok status. Required.
109
+ remediation: str
110
+ #: Optional override producing status-specific remediation text.
111
+ remediation_for: Callable[[CanaryStatus, str | None, str | None], str] | None = None
112
+ #: Whether a finding here may fail somebody's build.
113
+ #:
114
+ #: ``BLOCKING`` (the default) is the honest default: a canary exists because
115
+ #: a change upstream would make an answer wrong. ``ADVISORY`` is for the
116
+ #: checks that cannot support that weight -- an HTML scrape of a page CMS
117
+ #: restyles at will, a cross-check whose disagreement means "look at this",
118
+ #: not "your data is wrong". An advisory finding is reported with its real
119
+ #: status, annotated in CI, and left out of the exit code. Do not reach for
120
+ #: it to quiet a canary that is telling the truth: a gate that cries wolf
121
+ #: gets uninstalled, and a gate that never fires gets uninstalled too.
122
+ severity: CanarySeverity = CanarySeverity.BLOCKING
123
+ #: Retries for a TRANSIENT upstream failure, or ``None`` for the adapter's
124
+ #: default. Never retries a bug in our own code, a body over the ceiling, or
125
+ #: a 4xx that is not 408/425/429 -- retrying those just spends the build's
126
+ #: time to reach the same answer.
127
+ retries: int | None = None
128
+ #: Per-request timeout in seconds for fetches made during this observation,
129
+ #: or ``None`` for the adapter's default. A canary is supposed to be cheap;
130
+ #: this is where you say what cheap means for a source that is slow.
131
+ timeout: float | None = None
132
+
133
+ def resolve_remediation(
134
+ self, status: CanaryStatus, observed: str | None, expected: str | None
135
+ ) -> str:
136
+ if self.remediation_for is not None:
137
+ text = self.remediation_for(status, observed, expected)
138
+ if text and text.strip():
139
+ return text
140
+ return self.remediation
141
+
142
+
143
+ class ToolResult(BaseModel):
144
+ """A tool's answer and the evidence behind it."""
145
+
146
+ model_config = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
147
+
148
+ data: Any = Field(description="The answer. JSON-serialisable.")
149
+ receipt: Receipt
150
+
151
+
152
+ @dataclass(frozen=True)
153
+ class ToolSpec:
154
+ """One callable route.
155
+
156
+ ``params_model`` must be a pydantic model with ``extra='forbid'``: SourceLock
157
+ accepts public typed parameters only, and silently ignoring an unexpected
158
+ parameter is how a caller ends up believing it filtered something.
159
+
160
+ ``handler`` receives the VALIDATED model instance and returns a
161
+ :class:`ToolResult`. Call the tool through :meth:`invoke`, never the handler
162
+ directly -- ``invoke`` is where the zero-PHI guard runs.
163
+ """
164
+
165
+ name: str
166
+ description: str
167
+ params_model: type[BaseModel]
168
+ handler: Callable[[Any], ToolResult]
169
+ tags: tuple[str, ...] = field(default=())
170
+
171
+ @property
172
+ def source_id(self) -> str:
173
+ return self.name.split(".", 1)[0]
174
+
175
+ def params_schema(self) -> dict[str, Any]:
176
+ schema = self.params_model.model_json_schema()
177
+ schema.setdefault("type", "object")
178
+ return schema
179
+
180
+ def invoke(self, raw_params: Mapping[str, Any] | None = None) -> ToolResult:
181
+ """Guard, validate, call, and stamp the receipt.
182
+
183
+ Raises :class:`hc_source.guard.PHIRejected` for patient-shaped input and
184
+ :class:`hc_source.guard.ParamsRejected` (a ``ValueError``) for anything
185
+ that does not fit the declared parameter model.
186
+
187
+ The ``ValidationError`` is deliberately not allowed to escape. Its
188
+ ``str()`` contains ``input_value=<the raw value>``, and a caller that
189
+ logs the exception would then have written the value it asked us to
190
+ refuse. Converting it here means every consumer of ``invoke`` -- CLI,
191
+ MCP server, or someone else's agent framework -- is value-safe by
192
+ construction rather than by remembering to be careful.
193
+ """
194
+ params = dict(raw_params or {})
195
+ assert_public_params(params, tool=self.name)
196
+ try:
197
+ model = self.params_model.model_validate(params)
198
+ except ValidationError as exc:
199
+ # `from None`: chaining would put the raw ValidationError, and with
200
+ # it the input value, into any printed traceback.
201
+ raise ParamsRejected(self.name, safe_validation_reasons(exc)) from None
202
+
203
+ # The result cache sits AFTER the guard and the model, never before. A
204
+ # cache consulted on raw input would answer a question nobody validated,
205
+ # and a patient-shaped parameter would become a cache key.
206
+ cached = _cache_lookup(self.name, model)
207
+ if cached is not None:
208
+ return cached
209
+
210
+ result = self.handler(model)
211
+
212
+ if not isinstance(result, ToolResult):
213
+ raise AdapterError(f"{self.name}: handler returned {type(result).__name__}, expected ToolResult")
214
+ receipt = result.receipt
215
+ _assert_receipt_is_bound(self.name, result.receipt)
216
+ if PHI_NON_CLAIM not in receipt.non_claims:
217
+ receipt.non_claims.insert(0, PHI_NON_CLAIM)
218
+ _cache_store(self.name, model, result)
219
+ return result
220
+
221
+
222
+ def _assert_receipt_is_bound(tool_name: str, receipt: Receipt) -> None:
223
+ """A receipt may only speak for the route and the source that produced it.
224
+
225
+ ``route`` was checked from the start. ``source_id`` was not, and it is the
226
+ field a reader trusts to know WHOSE data an answer came from: the lockfile
227
+ pins by source id, doctor grades by source id, and every "this came from
228
+ CMS" claim in the product is that string. An installed plugin's
229
+ ``acme.lookup`` could return a receipt saying ``source_id='codes'`` and the
230
+ answer would be filed under a source it never touched -- silently, since
231
+ the two fields are read in different places. Discovery already refuses to
232
+ let a plugin CLAIM a built-in source id; this refuses to let one borrow the
233
+ name on the way out.
234
+ """
235
+ if receipt.route != tool_name:
236
+ raise AdapterError(
237
+ f"{tool_name}: receipt.route is {receipt.route!r}; it must equal the tool name"
238
+ )
239
+ expected = tool_name.split(".", 1)[0]
240
+ if receipt.source_id != expected:
241
+ raise AdapterError(
242
+ f"{tool_name}: receipt.source_id is {receipt.source_id!r}, but this tool "
243
+ f"belongs to {expected!r}. A receipt names the source an answer came from; "
244
+ "one adapter may not issue evidence under another's name."
245
+ )
246
+
247
+
248
+ #: Warning added to a receipt served out of the result cache. It is a warning
249
+ #: rather than a silent flag because the result cache, unlike the HTTP cache,
250
+ #: did not ask upstream anything -- the operator opted into that tradeoff by
251
+ #: setting a TTL, and every answer that took it says so.
252
+ CACHE_HIT_WARNING = (
253
+ "CACHED_RESULT: this answer was replayed from the local result cache "
254
+ "(HC_SOURCE_TOOL_CACHE_TTL) and no source was contacted. retrieved_at, "
255
+ "source_version and raw_sha256 describe the ORIGINAL read; clear the cache "
256
+ "with `hc-source cache clear` to force a fresh one."
257
+ )
258
+
259
+
260
+ def _cache_lookup(tool: str, model: BaseModel) -> "ToolResult | None":
261
+ """A stored result for this exact call, or ``None``. Off unless a TTL is set."""
262
+ from . import cache
263
+ from .lockfile import lockfile_fingerprint
264
+
265
+ ttl = cache.tool_ttl()
266
+ if ttl <= 0:
267
+ return None
268
+ key = cache.tool_key(
269
+ tool, model.model_dump(mode="json"), lockfile_hash=lockfile_fingerprint()
270
+ )
271
+ stored = cache.read_tool(key, ttl)
272
+ if stored is None:
273
+ return None
274
+ data, receipt_payload = stored
275
+ try:
276
+ receipt = Receipt.model_validate(receipt_payload)
277
+ except ValidationError:
278
+ # An entry we cannot parse is a miss. Serving a receipt we could not
279
+ # validate would be handing back evidence nobody checked.
280
+ return None
281
+ try:
282
+ # The same binding the live path enforces. A stored entry passed it once
283
+ # when it was written, so reaching this is a cache directory that is not
284
+ # what this build thinks it is -- and the safe reading of that is "miss",
285
+ # not "raise": the caller asked a question that has a perfectly good
286
+ # fresh answer.
287
+ _assert_receipt_is_bound(tool, receipt)
288
+ except AdapterError:
289
+ return None
290
+ receipt.cache_hit = True
291
+ if CACHE_HIT_WARNING not in receipt.warnings:
292
+ receipt.warnings.append(CACHE_HIT_WARNING)
293
+ return ToolResult(data=data, receipt=receipt)
294
+
295
+
296
+ def _cache_store(tool: str, model: BaseModel, result: "ToolResult") -> None:
297
+ from . import cache
298
+ from .lockfile import lockfile_fingerprint
299
+
300
+ if cache.tool_ttl() <= 0:
301
+ return
302
+ key = cache.tool_key(
303
+ tool, model.model_dump(mode="json"), lockfile_hash=lockfile_fingerprint()
304
+ )
305
+ cache.write_tool(key, result.data, result.receipt.model_dump(mode="json"))
306
+
307
+
308
+ class SourceAdapter:
309
+ """Base class for a route adapter.
310
+
311
+ Subclasses set ``source_id`` and ``contract`` and override ``canaries`` and
312
+ ``tools``. Both are methods rather than attributes so an adapter can build
313
+ them lazily from configuration without import-time work.
314
+ """
315
+
316
+ source_id: str = ""
317
+ contract: SourceContract
318
+
319
+ #: Retries doctor allows each of this adapter's canaries on a TRANSIENT
320
+ #: upstream failure. Zero is right for a source read from vendored data --
321
+ #: there is no network to be flaky. Set 2 when the canaries talk to a live
322
+ #: service: CMS returns the occasional 502, and a red build that goes green
323
+ #: on re-run teaches people to press re-run.
324
+ canary_retries: int = 0
325
+
326
+ #: Per-request timeout in seconds for this adapter's canaries. ``None``
327
+ #: means the transport default.
328
+ canary_timeout: float | None = None
329
+
330
+ def canaries(self) -> list[Canary]:
331
+ return []
332
+
333
+ def tools(self) -> list[ToolSpec]:
334
+ return []
335
+
336
+ def __repr__(self) -> str: # pragma: no cover - debugging aid
337
+ return f"<{type(self).__name__} source_id={self.source_id!r}>"
338
+
339
+
340
+ def validate_adapter(adapter: Any, *, origin: str = "<unknown>") -> SourceAdapter:
341
+ """Check an ADAPTER object against the interface. Raises :class:`AdapterError`.
342
+
343
+ Discovery runs this on every adapter, so a malformed adapter fails at load
344
+ with a message naming its module -- not halfway through a doctor run.
345
+ """
346
+ where = f"{origin}: ADAPTER"
347
+
348
+ if not isinstance(adapter, SourceAdapter):
349
+ raise AdapterError(f"{where} must subclass hc_source.adapters.SourceAdapter")
350
+
351
+ source_id = getattr(adapter, "source_id", "")
352
+ if not isinstance(source_id, str) or not source_id:
353
+ raise AdapterError(f"{where} must set a non-empty source_id")
354
+
355
+ contract = getattr(adapter, "contract", None)
356
+ if not isinstance(contract, SourceContract):
357
+ raise AdapterError(f"{where}.contract must be a SourceContract")
358
+ if contract.source_id != source_id:
359
+ raise AdapterError(
360
+ f"{where}.contract.source_id is {contract.source_id!r} but source_id is {source_id!r}"
361
+ )
362
+
363
+ canaries = adapter.canaries()
364
+ if not isinstance(canaries, list) or not all(isinstance(c, Canary) for c in canaries):
365
+ raise AdapterError(f"{where}.canaries() must return a list[Canary]")
366
+ seen_canaries: set[str] = set()
367
+ for c in canaries:
368
+ if c.source_id != source_id:
369
+ raise AdapterError(f"{where}: canary {c.canary_id!r} has source_id {c.source_id!r}")
370
+ if not c.canary_id.startswith(f"{source_id}."):
371
+ raise AdapterError(f"{where}: canary id {c.canary_id!r} must start with '{source_id}.'")
372
+ if c.canary_id in seen_canaries:
373
+ raise AdapterError(f"{where}: duplicate canary id {c.canary_id!r}")
374
+ if not (c.remediation.strip() or c.remediation_for):
375
+ raise AdapterError(f"{where}: canary {c.canary_id!r} needs remediation text")
376
+ seen_canaries.add(c.canary_id)
377
+
378
+ tools = adapter.tools()
379
+ if not isinstance(tools, list) or not all(isinstance(t, ToolSpec) for t in tools):
380
+ raise AdapterError(f"{where}.tools() must return a list[ToolSpec]")
381
+ seen_tools: set[str] = set()
382
+ for t in tools:
383
+ if not t.name.startswith(f"{source_id}."):
384
+ raise AdapterError(f"{where}: tool name {t.name!r} must start with '{source_id}.'")
385
+ if t.name in seen_tools:
386
+ raise AdapterError(f"{where}: duplicate tool name {t.name!r}")
387
+ if not (isinstance(t.params_model, type) and issubclass(t.params_model, BaseModel)):
388
+ raise AdapterError(f"{where}: tool {t.name!r} params_model must be a pydantic model")
389
+ if t.params_model.model_config.get("extra") != "forbid":
390
+ raise AdapterError(
391
+ f"{where}: tool {t.name!r} params_model must set ConfigDict(extra='forbid')"
392
+ )
393
+ seen_tools.add(t.name)
394
+
395
+ return adapter
hc_source/lockfile.py ADDED
@@ -0,0 +1,236 @@
1
+ """Reading, writing, and building ``source-lock.json``.
2
+
3
+ The lockfile is what turns "the data moved" from a silent production surprise
4
+ into a failing build. It is meant to be committed and reviewed like any other
5
+ lockfile.
6
+
7
+ Two rules govern everything in this module, both learned the hard way:
8
+
9
+ * **A failed observation never erases a pin.** ``build_lockfile`` used to
10
+ ``continue`` past any canary that raised, then emit whatever it managed to
11
+ see. An offline ``hc-source lock init --source codes`` therefore pinned the
12
+ three vendored canaries, silently dropped the two live release-train pins, and
13
+ exited 0 -- destroying the only artifact that can detect drift, in the one
14
+ situation (no network) where the operator is least likely to notice.
15
+ * **Nothing is written until the whole build validates.** The write is a
16
+ temp-file-plus-rename, so a crash or a full disk leaves the previous lockfile
17
+ intact rather than a truncated one.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import hashlib
23
+ import os
24
+ import tempfile
25
+ from dataclasses import dataclass, field
26
+ from pathlib import Path
27
+
28
+ from .interfaces import SourceAdapter
29
+ from .schemas import ExpectedCanary, Lockfile, SourceLockEntry, utcnow
30
+
31
+ __all__ = [
32
+ "DEFAULT_LOCK_FILENAME",
33
+ "CanaryFailure",
34
+ "LockfileBuild",
35
+ "build_lockfile",
36
+ "default_lock_path",
37
+ "load_lockfile",
38
+ "lockfile_fingerprint",
39
+ "save_lockfile",
40
+ ]
41
+
42
+ DEFAULT_LOCK_FILENAME = "source-lock.json"
43
+
44
+
45
+ def default_lock_path() -> Path:
46
+ return Path.cwd() / DEFAULT_LOCK_FILENAME
47
+
48
+
49
+ def lockfile_fingerprint(path: Path | None = None) -> str:
50
+ """SHA-256 of the lockfile on disk, or ``""`` when there is none.
51
+
52
+ Used as part of the tool-result cache key. Re-pinning a source must not
53
+ leave yesterday's answers reachable under today's pins: a cached answer
54
+ citing a release it was never derived from is precisely the failure this
55
+ product exists to prevent, and a cache is a fast way to build one.
56
+ """
57
+ path = Path(path) if path is not None else default_lock_path()
58
+ try:
59
+ return hashlib.sha256(path.read_bytes()).hexdigest()
60
+ except OSError:
61
+ return ""
62
+
63
+
64
+ def load_lockfile(path: Path) -> Lockfile:
65
+ """Load and validate a lockfile. Raises FileNotFoundError with the fix."""
66
+ path = Path(path)
67
+ if not path.exists():
68
+ raise FileNotFoundError(
69
+ f"no lockfile at {path}. Create one with `hc-source lock init`."
70
+ )
71
+ return Lockfile.model_validate_json(path.read_text(encoding="utf-8"))
72
+
73
+
74
+ def save_lockfile(lockfile: Lockfile, path: Path) -> Path:
75
+ """Write a lockfile as stable, diff-friendly JSON, atomically.
76
+
77
+ Serialise first, then write a temp file in the destination directory, then
78
+ ``os.replace``. A half-written ``source-lock.json`` is worse than a missing
79
+ one: it fails validation on the next run, which reads as "regenerate it",
80
+ which is how a partial write becomes a total loss.
81
+ """
82
+ path = Path(path)
83
+ payload = lockfile.model_dump_json(indent=2) + "\n"
84
+ path.parent.mkdir(parents=True, exist_ok=True)
85
+
86
+ handle, tmp_name = tempfile.mkstemp(
87
+ dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp"
88
+ )
89
+ try:
90
+ with os.fdopen(handle, "w", encoding="utf-8") as fh:
91
+ fh.write(payload)
92
+ fh.flush()
93
+ os.fsync(fh.fileno())
94
+ os.replace(tmp_name, path)
95
+ except BaseException:
96
+ try:
97
+ os.unlink(tmp_name)
98
+ except OSError: # pragma: no cover - best effort cleanup
99
+ pass
100
+ raise
101
+ return path
102
+
103
+
104
+ @dataclass(frozen=True)
105
+ class CanaryFailure:
106
+ """One canary that could not be observed during a ``lock init``."""
107
+
108
+ canary_id: str
109
+ source_id: str
110
+ reason: str
111
+ #: True when a previous pin for this canary was carried forward untouched.
112
+ pin_preserved: bool = False
113
+
114
+ def __str__(self) -> str:
115
+ kept = "previous pin kept" if self.pin_preserved else "NOT PINNED"
116
+ return f"{self.canary_id}: {self.reason} ({kept})"
117
+
118
+
119
+ @dataclass
120
+ class LockfileBuild:
121
+ """The result of observing every selected canary once.
122
+
123
+ ``lockfile`` is only safe to write when ``complete`` is true, or when the
124
+ operator has explicitly accepted a partial pin.
125
+ """
126
+
127
+ lockfile: Lockfile
128
+ failures: list[CanaryFailure] = field(default_factory=list)
129
+ #: Canary ids observed fresh from upstream in this run.
130
+ observed: list[str] = field(default_factory=list)
131
+
132
+ @property
133
+ def complete(self) -> bool:
134
+ return not self.failures
135
+
136
+ @property
137
+ def preserved(self) -> list[str]:
138
+ return [f.canary_id for f in self.failures if f.pin_preserved]
139
+
140
+ @property
141
+ def unpinned(self) -> list[str]:
142
+ return [f.canary_id for f in self.failures if not f.pin_preserved]
143
+
144
+
145
+ def build_lockfile(
146
+ adapters: list[SourceAdapter], previous: Lockfile | None = None
147
+ ) -> LockfileBuild:
148
+ """Observe every canary once and pin what it saw.
149
+
150
+ A canary that cannot be observed is recorded as a :class:`CanaryFailure` and
151
+ its PREVIOUS pin, if there is one, is carried through unchanged. The caller
152
+ decides what to do about it; this function never silently narrows a lockfile.
153
+
154
+ When ``previous`` is supplied and a source's expectations are unchanged, its
155
+ timestamps are carried over so that re-running ``lock init`` on an unchanged
156
+ world produces a byte-identical file. A lockfile that churns on every run is
157
+ a lockfile nobody reads in review.
158
+ """
159
+ now = utcnow()
160
+ sources: dict[str, SourceLockEntry] = {}
161
+ failures: list[CanaryFailure] = []
162
+ observed_ids: list[str] = []
163
+
164
+ for adapter in adapters:
165
+ prior = previous.sources.get(adapter.source_id) if previous else None
166
+ canaries = adapter.canaries()
167
+ expected: dict[str, ExpectedCanary] = {}
168
+ #: Canary ids in declaration order, so release_id/schema_hash stay stable.
169
+ order: list[str] = []
170
+
171
+ for canary in canaries:
172
+ order.append(canary.canary_id)
173
+ try:
174
+ observation = canary.observe()
175
+ except Exception as exc: # noqa: BLE001 - unreachable or broken; do not pin a guess
176
+ kept = prior.expected_canaries.get(canary.canary_id) if prior else None
177
+ if kept is not None:
178
+ expected[canary.canary_id] = kept
179
+ failures.append(
180
+ CanaryFailure(
181
+ canary_id=canary.canary_id,
182
+ source_id=adapter.source_id,
183
+ reason=_reason(exc),
184
+ pin_preserved=kept is not None,
185
+ )
186
+ )
187
+ continue
188
+ expected[canary.canary_id] = ExpectedCanary(
189
+ value=observation.value, schema_hash=observation.schema_hash
190
+ )
191
+ observed_ids.append(canary.canary_id)
192
+
193
+ if not expected:
194
+ # Nothing observed and nothing to preserve. Keep the previous entry
195
+ # rather than deleting a source because today's run could not reach it.
196
+ if prior is not None:
197
+ sources[adapter.source_id] = prior
198
+ continue
199
+
200
+ release_value: str | None = None
201
+ schema_hash: str | None = None
202
+ for canary_id in order:
203
+ pin = expected.get(canary_id)
204
+ if pin is None:
205
+ continue
206
+ if release_value is None:
207
+ release_value = pin.value
208
+ if schema_hash is None and pin.schema_hash:
209
+ schema_hash = pin.schema_hash
210
+
211
+ expected = dict(sorted(expected.items()))
212
+ unchanged = prior is not None and prior.expected_canaries == expected
213
+
214
+ sources[adapter.source_id] = SourceLockEntry(
215
+ source_id=adapter.source_id,
216
+ pinned_version=release_value or "unknown",
217
+ release_id=release_value,
218
+ schema_hash=schema_hash,
219
+ checked_at=prior.checked_at if unchanged else now,
220
+ expected_canaries=expected,
221
+ )
222
+
223
+ sources = dict(sorted(sources.items()))
224
+ if previous is not None and previous.sources == sources:
225
+ lockfile = Lockfile(generated_at=previous.generated_at, sources=sources)
226
+ else:
227
+ lockfile = Lockfile(generated_at=now, sources=sources)
228
+ return LockfileBuild(lockfile=lockfile, failures=failures, observed=observed_ids)
229
+
230
+
231
+ def _reason(exc: BaseException) -> str:
232
+ """A short, payload-free reason. Never the exception's own str() blindly."""
233
+ reason = getattr(exc, "reason", None)
234
+ if isinstance(reason, str) and reason.strip():
235
+ return reason.strip()
236
+ return type(exc).__name__