adopt-map 0.4.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.
adopt_map/__init__.py ADDED
@@ -0,0 +1,86 @@
1
+ """`adopt map` -- deterministic identity extraction from a repository. Build 1.
2
+
3
+ This package is the answer to Build 0's known gap: a complete, tested identity
4
+ substrate that nothing filled (`identity: 0` after a full `init` was the as-built
5
+ fact). Everything every later build queries starts here.
6
+
7
+ **Four properties, each a refusal rather than an intention:**
8
+
9
+ 1. **No model call, anywhere on this path.** v6.1 §4 R3 makes the deterministic
10
+ mode complete, not a fallback. `adopt map --glue` is *reserved* and is built
11
+ only if measured deterministic coverage on the reference repositories leaves a
12
+ material long tail. There is no call site for a model in this package.
13
+ 2. **Nothing in the target tree is executed or written.** Extractors receive a
14
+ read-only `SourceTree` and have no capability to do either.
15
+ 3. **Idempotent by the write path, not by a check.** `IdentityFacade.observe` is
16
+ keyed on the URI, so a re-run over an unchanged tree writes nothing. Build 1
17
+ *records* attribute digests; Build 6 *compares* them and owns what a change
18
+ means.
19
+ 4. **A failing extractor is loud.** Every outcome is recorded with its exception
20
+ type. A silent extractor failure and a genuinely smaller system look identical
21
+ from outside, which is exactly how B-08 stayed undiagnosed.
22
+
23
+ The pack layout is one distribution with packs as modules (v6.1 §6), so
24
+ `pip install adopt-cli` always yields a working `adopt map`.
25
+ """
26
+
27
+ from adopt_map.diff import ChangeEntry, DiffOutcome, Exemption, FileDelta, Rebaseline
28
+ from adopt_map.diff import compute as compute_diff
29
+ from adopt_map.digest import attribute_digest, canonical_attributes
30
+ from adopt_map.expected import load_expected, missing_identities
31
+ from adopt_map.filestate import FileState, changed_paths, hash_file
32
+ from adopt_map.moves import (
33
+ MoveCandidate,
34
+ MoveOutcome,
35
+ ObservedIdentity,
36
+ StoredIdentity,
37
+ detect_moves,
38
+ )
39
+ from adopt_map.observation import Extractor, Observation, Span
40
+ from adopt_map.packs import registry
41
+ from adopt_map.report import StoredRevision, build_report, digest_summary
42
+ from adopt_map.runner import (
43
+ ExtractorOutcome,
44
+ IdentityWriter,
45
+ MapReport,
46
+ Pack,
47
+ run_map,
48
+ select_packs,
49
+ )
50
+ from adopt_map.tree import SourceTree, TreeFile
51
+
52
+ __all__ = [
53
+ "ChangeEntry",
54
+ "DiffOutcome",
55
+ "Exemption",
56
+ "Extractor",
57
+ "ExtractorOutcome",
58
+ "FileDelta",
59
+ "FileState",
60
+ "IdentityWriter",
61
+ "MapReport",
62
+ "MoveCandidate",
63
+ "MoveOutcome",
64
+ "Observation",
65
+ "ObservedIdentity",
66
+ "Pack",
67
+ "Rebaseline",
68
+ "SourceTree",
69
+ "Span",
70
+ "StoredIdentity",
71
+ "StoredRevision",
72
+ "TreeFile",
73
+ "attribute_digest",
74
+ "build_report",
75
+ "canonical_attributes",
76
+ "changed_paths",
77
+ "compute_diff",
78
+ "detect_moves",
79
+ "digest_summary",
80
+ "hash_file",
81
+ "load_expected",
82
+ "missing_identities",
83
+ "registry",
84
+ "run_map",
85
+ "select_packs",
86
+ ]
adopt_map/diff.py ADDED
@@ -0,0 +1,449 @@
1
+ """The five-class cascade -- v6.1 §6 Build 6, and the guards that keep it honest.
2
+
3
+ **Pure.** Stored state and observed state in, a `DiffOutcome` out; no store, no
4
+ clock, no id. That is what lets Build 8 run this same function server-side
5
+ against a Postgres realization: the judgement travels because it touches
6
+ nothing. Everything that writes is the caller's, and everything that decides is
7
+ here.
8
+
9
+ ## The cascade
10
+
11
+ Four steps, matching `decided_by`'s four values, evaluated per referent:
12
+
13
+ | Step | Condition | Class |
14
+ |---|---|---|
15
+ | 1 | a live identity this run did not see, and could not pair | `BINDING_DEAD` |
16
+ | 2 | paired with an appeared referent by digest | `BINDING_MOVED` |
17
+ | 3 | same URI, digest differs **at the same extractor version** | `BINDING_INTACT_SEMANTICS_CHANGED` |
18
+ | 4 | an observed URI with no stored identity | `UNBOUND_NEW` |
19
+ | 4 | a walked file whose content changed while every identity in it kept its digest | `BINDING_INTACT_RENDER_ONLY` |
20
+
21
+ Steps 1 and 2 are decided by what `detect_moves` already concluded -- this module
22
+ does not re-pair, because two pairing implementations would eventually disagree
23
+ and the disagreement would be a permanent false alias.
24
+
25
+ ## Absence is not death, and this is where that is enforced
26
+
27
+ `adopt_map.moves` reports absence and refuses to write it, saying retirement
28
+ "belongs to Build 6". It does -- but Build 6 does not get to be careless with
29
+ it either. A referent can be missing from a run for four reasons and only one of
30
+ them is death:
31
+
32
+ * **its extractor crashed** -- caught, recorded `failed`, and its identities are
33
+ exempt. A broken extractor that retired its own inventory is the worst
34
+ available outcome: the retirement is append-only, and every bound piece of
35
+ knowledge stales at once.
36
+ * **its pack did not run** -- `--packs generic` on a web system must not kill
37
+ every endpoint. Exempt by extractor membership.
38
+ * **its file was skipped** -- the walk's size cap means the file was never read.
39
+ Exempt by path.
40
+ * **it is genuinely gone** -- the remaining case, and the only one that is
41
+ `BINDING_DEAD`.
42
+
43
+ Each exemption is reported rather than silently applied, because "we did not
44
+ look" is a fact a reviewer needs and a silent exemption is indistinguishable
45
+ from a clean run.
46
+
47
+ ## The version fence (H5)
48
+
49
+ `attribute_digest` mixes the extractor version into the digest, so across an
50
+ upgrade **every** digest differs by construction. Comparing across versions
51
+ would therefore report every identity as semantically changed the day an
52
+ extractor is improved -- a change storm caused entirely by the instrument. When
53
+ versions differ the referent is **re-baselined**: the new digest is recorded, no
54
+ event is produced, and the run reports "instrument changed, system not
55
+ re-judged".
56
+ """
57
+
58
+ from collections.abc import Iterable, Mapping, Sequence
59
+ from dataclasses import dataclass
60
+ from typing import Final
61
+
62
+ from adopt_map.moves import MoveOutcome, ObservedIdentity, StoredIdentity
63
+
64
+ __all__ = [
65
+ "CLASS_DEAD",
66
+ "CLASS_MOVED",
67
+ "CLASS_NEW",
68
+ "CLASS_RENDER_ONLY",
69
+ "CLASS_SEMANTICS",
70
+ "STEP_DEAD",
71
+ "STEP_MOVED",
72
+ "STEP_NEW",
73
+ "STEP_SEMANTICS",
74
+ "ChangeEntry",
75
+ "DiffOutcome",
76
+ "Exemption",
77
+ "FileDelta",
78
+ "Rebaseline",
79
+ "compute",
80
+ ]
81
+
82
+ #: The manifest's `impact_class` values. v6.1's prose spells them `BINDING-DEAD`
83
+ #: and `SEMANTICS-CHANGED`; the machine-gated enum is the authority (§0
84
+ #: precedence) and the CLI renders the short forms.
85
+ CLASS_DEAD: Final[str] = "BINDING_DEAD"
86
+ CLASS_MOVED: Final[str] = "BINDING_MOVED"
87
+ CLASS_SEMANTICS: Final[str] = "BINDING_INTACT_SEMANTICS_CHANGED"
88
+ CLASS_NEW: Final[str] = "UNBOUND_NEW"
89
+ CLASS_RENDER_ONLY: Final[str] = "BINDING_INTACT_RENDER_ONLY"
90
+
91
+ #: `decided_by`. The cascade step that reached the conclusion, so a reader can
92
+ #: tell "we could not find it" from "we found it and it differs".
93
+ STEP_DEAD: Final[str] = "cascade_step_1"
94
+ STEP_MOVED: Final[str] = "cascade_step_2"
95
+ STEP_SEMANTICS: Final[str] = "cascade_step_3"
96
+ STEP_NEW: Final[str] = "cascade_step_4"
97
+
98
+ _INACTIVE: Final[frozenset[str]] = frozenset({"moved", "dead"})
99
+
100
+
101
+ @dataclass(frozen=True, slots=True)
102
+ class FileDelta:
103
+ """What the file-content snapshot says changed since the last refresh.
104
+
105
+ `available` is `False` on the first refresh of a store, or when the annex
106
+ was lost. RENDER-ONLY is then **not reported at all** rather than reported
107
+ as empty: "no cosmetic changes" and "we could not tell" are different
108
+ statements, and printing the first when the second is true is how a reader
109
+ learns to trust a number that was never measured.
110
+ """
111
+
112
+ changed_paths: frozenset[str] = frozenset()
113
+ available: bool = True
114
+
115
+
116
+ @dataclass(frozen=True, slots=True)
117
+ class ChangeEntry:
118
+ """One classified referent: what it is, what happened, and the evidence.
119
+
120
+ `identity_id` is `None` for `UNBOUND_NEW`, whose identity the caller is
121
+ about to create, and for RENDER-ONLY entries that name a file rather than a
122
+ referent. The caller resolves those before recording, because
123
+ `classification.identity_id` is NOT NULL -- and an entry that could invent
124
+ an id here would be a diff writing to a store.
125
+ """
126
+
127
+ uri: str
128
+ impact_class: str
129
+ decided_by: str
130
+ evidence: str
131
+ identity_id: str | None = None
132
+ #: For a move: where the referent went. For a semantics change: the digest
133
+ #: now observed, which the caller records so the next run compares against it.
134
+ digest: str | None = None
135
+ successor_uri: str | None = None
136
+ extractor: str | None = None
137
+ extractor_version: str | None = None
138
+ source_path: str | None = None
139
+
140
+
141
+ @dataclass(frozen=True, slots=True)
142
+ class Rebaseline:
143
+ """A referent whose extractor version changed: re-recorded, never judged."""
144
+
145
+ uri: str
146
+ identity_id: str
147
+ from_version: str | None
148
+ to_version: str | None
149
+ digest: str
150
+ extractor: str | None = None
151
+ source_path: str | None = None
152
+
153
+
154
+ @dataclass(frozen=True, slots=True)
155
+ class Exemption:
156
+ """A referent that was absent but not examined, and why. Reported, never written."""
157
+
158
+ uri: str
159
+ identity_id: str
160
+ reason: str
161
+
162
+
163
+ @dataclass(frozen=True, slots=True)
164
+ class DiffOutcome:
165
+ """Everything one refresh concluded. The caller writes; this decides."""
166
+
167
+ changes: tuple[ChangeEntry, ...] = ()
168
+ rebaselines: tuple[Rebaseline, ...] = ()
169
+ exemptions: tuple[Exemption, ...] = ()
170
+ #: Reported by `detect_moves` and carried through: several referents share a
171
+ #: digest, so no pairing was made. Not an error and not a change -- the tool
172
+ #: declining to guess, which a reader has to be told about.
173
+ ambiguous: tuple[tuple[str, ...], ...] = ()
174
+ render_only_available: bool = True
175
+
176
+ @property
177
+ def actionable(self) -> tuple[ChangeEntry, ...]:
178
+ """Everything but RENDER-ONLY -- the classes that name a referent."""
179
+ return tuple(entry for entry in self.changes if entry.impact_class != CLASS_RENDER_ONLY)
180
+
181
+ def by_class(self) -> Mapping[str, int]:
182
+ """Counts per class, every class present as a key only if non-zero."""
183
+ counts: dict[str, int] = {}
184
+ for entry in self.changes:
185
+ counts[entry.impact_class] = counts.get(entry.impact_class, 0) + 1
186
+ return dict(sorted(counts.items()))
187
+
188
+
189
+ def compute(
190
+ *,
191
+ stored: Sequence[StoredIdentity],
192
+ observed: Iterable[ObservedIdentity],
193
+ moves: MoveOutcome,
194
+ file_delta: FileDelta | None = None,
195
+ failed_extractors: Iterable[str] = (),
196
+ ran_extractors: Iterable[str] | None = None,
197
+ oversized_paths: Iterable[str] = (),
198
+ ) -> DiffOutcome:
199
+ """Classify one refresh run. Pure.
200
+
201
+ Args:
202
+ stored: Scope identities **as they were before the run**, each carrying
203
+ the digest, extractor and extractor version of its latest
204
+ digest-bearing revision.
205
+ observed: What this run saw.
206
+ moves: `detect_moves`' conclusion for this run. Its pairs become step 2
207
+ and its unpaired absences become step-1 candidates.
208
+ file_delta: The content snapshot's verdict. `None` means unavailable.
209
+ failed_extractors: Names of extractors that raised this run. Their
210
+ identities are exempt from death.
211
+ ran_extractors: Names of extractors that ran at all. `None` means "every
212
+ extractor ran", which is the honest reading only when no pack was
213
+ filtered; the caller passes the real set.
214
+ oversized_paths: Repo-relative paths the walk skipped for size.
215
+
216
+ Returns:
217
+ A `DiffOutcome`. Ordering is total and declared: entries are sorted by
218
+ (class, URI), so two runs over one tree produce the same batch order and
219
+ a reviewer's queue does not reshuffle between sessions.
220
+ """
221
+ seen = {entry.uri: entry for entry in observed}
222
+ stored_by_uri = {entry.uri: entry for entry in stored}
223
+ delta = file_delta if file_delta is not None else FileDelta(available=False)
224
+
225
+ failed = frozenset(failed_extractors)
226
+ ran = None if ran_extractors is None else frozenset(ran_extractors)
227
+ oversized = frozenset(oversized_paths)
228
+ moved_ids = {candidate.identity_id for candidate in moves.moves}
229
+
230
+ changes: list[ChangeEntry] = []
231
+ rebaselines: list[Rebaseline] = []
232
+ exemptions: list[Exemption] = []
233
+
234
+ # -- step 2: moves, taken from detection rather than re-derived ----------
235
+ for candidate in moves.moves:
236
+ source = stored_by_uri.get(candidate.from_uri)
237
+ changes.append(
238
+ ChangeEntry(
239
+ uri=candidate.from_uri,
240
+ impact_class=CLASS_MOVED,
241
+ decided_by=STEP_MOVED,
242
+ evidence=f"referent moved to {candidate.to.uri} (matched by attribute digest)",
243
+ identity_id=candidate.identity_id,
244
+ digest=candidate.to.digest,
245
+ successor_uri=candidate.to.uri,
246
+ extractor=source.extractor if source else None,
247
+ extractor_version=source.extractor_version if source else None,
248
+ source_path=source.source_path if source else None,
249
+ )
250
+ )
251
+
252
+ # -- steps 1 and 3: what the store already knew about ---------------------
253
+ for entry in stored:
254
+ if entry.status in _INACTIVE or entry.identity_id in moved_ids:
255
+ continue
256
+
257
+ arrival = seen.get(entry.uri)
258
+ if arrival is None:
259
+ exemption = _exemption_for(entry, failed=failed, ran=ran, oversized=oversized)
260
+ if exemption is not None:
261
+ exemptions.append(exemption)
262
+ continue
263
+ changes.append(
264
+ ChangeEntry(
265
+ uri=entry.uri,
266
+ impact_class=CLASS_DEAD,
267
+ decided_by=STEP_DEAD,
268
+ evidence="the referent was looked for and not found, and no appeared "
269
+ "referent shares its attribute digest",
270
+ identity_id=entry.identity_id,
271
+ extractor=entry.extractor,
272
+ extractor_version=entry.extractor_version,
273
+ source_path=entry.source_path,
274
+ )
275
+ )
276
+ continue
277
+
278
+ if entry.digest is None:
279
+ # Observed, but nothing to compare against: record the digest so the
280
+ # next run can. Not a change -- claiming one would mean every
281
+ # identity written before digests existed changed the day we started
282
+ # recording them.
283
+ rebaselines.append(
284
+ Rebaseline(
285
+ uri=entry.uri,
286
+ identity_id=entry.identity_id,
287
+ from_version=None,
288
+ to_version=arrival.extractor_version,
289
+ digest=arrival.digest,
290
+ extractor=arrival.extractor,
291
+ source_path=arrival.source_path,
292
+ )
293
+ )
294
+ continue
295
+
296
+ if _versions_differ(entry.extractor_version, arrival.extractor_version):
297
+ # The fence. Every digest differs across an upgrade by construction,
298
+ # so this is the one comparison that must never be made.
299
+ rebaselines.append(
300
+ Rebaseline(
301
+ uri=entry.uri,
302
+ identity_id=entry.identity_id,
303
+ from_version=entry.extractor_version,
304
+ to_version=arrival.extractor_version,
305
+ digest=arrival.digest,
306
+ extractor=arrival.extractor,
307
+ source_path=arrival.source_path,
308
+ )
309
+ )
310
+ continue
311
+
312
+ if arrival.digest != entry.digest:
313
+ changes.append(
314
+ ChangeEntry(
315
+ uri=entry.uri,
316
+ impact_class=CLASS_SEMANTICS,
317
+ decided_by=STEP_SEMANTICS,
318
+ evidence=f"attribute digest changed at extractor version "
319
+ f"{arrival.extractor_version or 'unknown'}",
320
+ identity_id=entry.identity_id,
321
+ digest=arrival.digest,
322
+ extractor=arrival.extractor,
323
+ extractor_version=arrival.extractor_version,
324
+ source_path=arrival.source_path,
325
+ )
326
+ )
327
+
328
+ # -- step 4a: referents this run saw for the first time -------------------
329
+ for uri, arrival in seen.items():
330
+ if uri in stored_by_uri:
331
+ continue
332
+ if any(candidate.to.uri == uri for candidate in moves.moves):
333
+ # Already accounted for as the destination of a move; reporting it
334
+ # again as new would double-count one referent under two classes.
335
+ continue
336
+ changes.append(
337
+ ChangeEntry(
338
+ uri=uri,
339
+ impact_class=CLASS_NEW,
340
+ decided_by=STEP_NEW,
341
+ evidence="observed for the first time; no knowledge covers it yet",
342
+ digest=arrival.digest,
343
+ extractor=arrival.extractor,
344
+ extractor_version=arrival.extractor_version,
345
+ source_path=arrival.source_path,
346
+ )
347
+ )
348
+
349
+ # -- step 4b: mapped territory that changed without changing meaning ------
350
+ #
351
+ # **Reported per identity, not per file**, because the class is spelled
352
+ # `BINDING_INTACT_RENDER_ONLY`: it is a statement that *this referent's*
353
+ # binding survived an edit to the file it lives in, and `classification`
354
+ # requires an identity to say it about. A file-shaped entry would have
355
+ # nowhere to be recorded and would leave the one class v6.1 calls
356
+ # "informational" as the only class with no row -- silent, in the build
357
+ # whose promise is that nothing is.
358
+ if delta.available:
359
+ semantic_paths = {
360
+ entry.source_path for entry in changes if entry.source_path is not None
361
+ } | {entry.source_path for entry in rebaselines if entry.source_path is not None}
362
+ by_path: dict[str, list[StoredIdentity]] = {}
363
+ for entry in stored:
364
+ if entry.source_path is not None and entry.status not in _INACTIVE:
365
+ by_path.setdefault(entry.source_path, []).append(entry)
366
+ for path in sorted(delta.changed_paths):
367
+ if path in semantic_paths or path not in by_path:
368
+ continue
369
+ for entry in sorted(by_path[path], key=lambda row: row.uri):
370
+ if entry.uri not in seen:
371
+ # Absent this run: it is a death, an exemption or a move, and
372
+ # each has already been decided above. Calling it cosmetic
373
+ # too would classify one referent twice in one run, which the
374
+ # UNIQUE index on (event, identity) would refuse anyway.
375
+ continue
376
+ changes.append(
377
+ ChangeEntry(
378
+ uri=entry.uri,
379
+ impact_class=CLASS_RENDER_ONLY,
380
+ decided_by=STEP_NEW,
381
+ evidence=f"{path} changed; this referent's attribute digest did not",
382
+ identity_id=entry.identity_id,
383
+ source_path=path,
384
+ )
385
+ )
386
+
387
+ return DiffOutcome(
388
+ changes=tuple(sorted(changes, key=lambda entry: (entry.impact_class, entry.uri))),
389
+ rebaselines=tuple(sorted(rebaselines, key=lambda entry: entry.uri)),
390
+ exemptions=tuple(sorted(exemptions, key=lambda entry: entry.uri)),
391
+ ambiguous=moves.ambiguous,
392
+ render_only_available=delta.available,
393
+ )
394
+
395
+
396
+ def _versions_differ(stored: str | None, observed: str | None) -> bool:
397
+ """Whether the fence should fire.
398
+
399
+ Two unknowns are **not** treated as differing: a store whose revisions
400
+ predate version recording would otherwise re-baseline on every run and never
401
+ compare anything, which is a diff that silently stops working.
402
+ """
403
+ if stored is None and observed is None:
404
+ return False
405
+ return stored != observed
406
+
407
+
408
+ def _exemption_for(
409
+ entry: StoredIdentity,
410
+ *,
411
+ failed: frozenset[str],
412
+ ran: frozenset[str] | None,
413
+ oversized: frozenset[str],
414
+ ) -> Exemption | None:
415
+ """Why this absent referent was not examined, or `None` if it truly was.
416
+
417
+ Ordered most-specific first so the reported reason is the most informative
418
+ one: a crashed extractor explains an absence better than "its pack ran".
419
+ """
420
+ if entry.extractor is not None and entry.extractor in failed:
421
+ return Exemption(
422
+ uri=entry.uri,
423
+ identity_id=entry.identity_id,
424
+ reason=f"extractor {entry.extractor!r} failed this run, so this referent was "
425
+ "never looked for",
426
+ )
427
+ if ran is not None and entry.extractor is not None and entry.extractor not in ran:
428
+ return Exemption(
429
+ uri=entry.uri,
430
+ identity_id=entry.identity_id,
431
+ reason=f"extractor {entry.extractor!r} did not run this time (pack not selected)",
432
+ )
433
+ if entry.source_path is not None and entry.source_path in oversized:
434
+ return Exemption(
435
+ uri=entry.uri,
436
+ identity_id=entry.identity_id,
437
+ reason=f"{entry.source_path} exceeded the read cap and was skipped by the walk",
438
+ )
439
+ if entry.extractor is None and ran is not None:
440
+ # No recorded extractor and a filtered run: we cannot tell whether the
441
+ # thing that would have found it ran. Exempt, because a death we cannot
442
+ # justify is a death we must not write.
443
+ return Exemption(
444
+ uri=entry.uri,
445
+ identity_id=entry.identity_id,
446
+ reason="no extractor recorded on its latest observation, so its absence "
447
+ "cannot be attributed to a look that happened",
448
+ )
449
+ return None
adopt_map/digest.py ADDED
@@ -0,0 +1,63 @@
1
+ """The attribute digest -- v6.1 §6 Build 1, H5.
2
+
3
+ **Never a raw file hash.** The digest covers the *extracted attributes* of an
4
+ identity and nothing else, which is what makes a formatting change, a reordered
5
+ import, an added comment or a moved line incapable of manufacturing staleness.
6
+ That is not a nicety: Build 6 maps a digest change to `SEMANTICS-CHANGED`, and
7
+ false staleness is the failure that makes a reviewer stop trusting the queue.
8
+
9
+ The rendering rule is the exporter's, deliberately reused rather than reinvented:
10
+ sorted keys, no spaces, no ASCII escaping, UTF-8. A digest is a pure function of
11
+ (attributes, extractor version) on every machine.
12
+
13
+ **Comparisons are valid only within one extractor version.** The version is
14
+ mixed into the digest input rather than merely stored beside it, so two digests
15
+ from different extractor versions cannot collide into looking equal. Build 6
16
+ compares only within a matching version and re-baselines across one -- "we
17
+ changed how we look", not "the system changed".
18
+ """
19
+
20
+ import hashlib
21
+ import json
22
+ from collections.abc import Mapping
23
+
24
+ __all__ = ["DIGEST_PREFIX", "attribute_digest", "canonical_attributes"]
25
+
26
+ #: Named so a stored value is self-describing: a reader seeing `sha256:` knows
27
+ #: what produced it without consulting a document, and a future algorithm change
28
+ #: is a new prefix rather than a silent reinterpretation of old rows.
29
+ DIGEST_PREFIX = "sha256"
30
+
31
+
32
+ def canonical_attributes(attributes: Mapping[str, object]) -> str:
33
+ """The attribute map as one canonical string.
34
+
35
+ Sorted keys and the tightest separators, at every nesting level -- the same
36
+ rule `adopt_export` applies, so a tool that can read one can read the other.
37
+ `default=str` is a deliberate backstop rather than a licence to pass exotic
38
+ objects: an extractor should hand over primitives, and anything else is
39
+ rendered rather than crashing a run late in a large repository.
40
+ """
41
+ return json.dumps(
42
+ attributes,
43
+ sort_keys=True,
44
+ separators=(",", ":"),
45
+ ensure_ascii=False,
46
+ default=str,
47
+ )
48
+
49
+
50
+ def attribute_digest(attributes: Mapping[str, object], *, extractor_version: str) -> str:
51
+ """`sha256:<hex>` over the canonical attributes and the extractor version.
52
+
53
+ Args:
54
+ attributes: The digest input. Whatever a pack puts here defines what a
55
+ semantic change *is* for that kind, permanently -- a line number or
56
+ a file path in this map would reintroduce exactly the false
57
+ staleness H5 exists to remove.
58
+ extractor_version: Mixed in, so digests from two versions are never
59
+ comparable by accident.
60
+ """
61
+ payload = f"{extractor_version}\x00{canonical_attributes(attributes)}"
62
+ digest = hashlib.sha256(payload.encode("utf-8")).hexdigest()
63
+ return f"{DIGEST_PREFIX}:{digest}"