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 +86 -0
- adopt_map/diff.py +449 -0
- adopt_map/digest.py +63 -0
- adopt_map/expected.py +96 -0
- adopt_map/filestate.py +73 -0
- adopt_map/keys.py +132 -0
- adopt_map/moves.py +235 -0
- adopt_map/observation.py +83 -0
- adopt_map/observe.py +147 -0
- adopt_map/packs/__init__.py +60 -0
- adopt_map/packs/ai.py +699 -0
- adopt_map/packs/generic.py +539 -0
- adopt_map/packs/web.py +391 -0
- adopt_map/py.typed +0 -0
- adopt_map/report.py +152 -0
- adopt_map/runner.py +376 -0
- adopt_map/tree.py +181 -0
- adopt_map-0.4.0.dist-info/METADATA +19 -0
- adopt_map-0.4.0.dist-info/RECORD +22 -0
- adopt_map-0.4.0.dist-info/WHEEL +4 -0
- adopt_map-0.4.0.dist-info/licenses/LICENSE +201 -0
- adopt_map-0.4.0.dist-info/licenses/NOTICE +39 -0
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}"
|