adopt-map 0.4.0__tar.gz → 0.4.2__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: adopt-map
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: adopt map: deterministic identity extraction from a repository. Build 1.
5
5
  Project-URL: Homepage, https://github.com/onboardux/onboard-core
6
6
  Project-URL: Source, https://github.com/onboardux/onboard-core
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "adopt-map"
3
- version = "0.4.0"
3
+ version = "0.4.2"
4
4
  description = "adopt map: deterministic identity extraction from a repository. Build 1."
5
5
  requires-python = ">=3.12"
6
6
  license = "Apache-2.0"
@@ -13,11 +13,12 @@ dependency.
13
13
  """
14
14
 
15
15
  import ast
16
+ import hashlib
16
17
  import json
17
18
  import re
18
19
  import tomllib
19
20
  from collections.abc import Iterator
20
- from typing import Final
21
+ from typing import Any, Final
21
22
 
22
23
  import yaml
23
24
 
@@ -400,10 +401,21 @@ class CiWorkflowExtractor:
400
401
  A workflow is a scheduled/triggered job in every sense that matters to an
401
402
  FDE: it is how the system gets built, tested and deployed, and "what runs on
402
403
  merge" is one of the first questions a handover has to answer.
404
+
405
+ **`recipe_digest` is what makes a changed pipeline a changed referent**
406
+ (version `2`). Version `1` carried only the workflow name, its job names and
407
+ its triggers, so replacing `alembic upgrade head` with a different migration
408
+ command -- the step a redeploy runbook exists to describe -- was classified
409
+ `BINDING_INTACT_RENDER_ONLY` and the runbook stayed fresh. The digest covers
410
+ each job's `needs`, `runs-on` and steps (`run`, `uses`, `with`), taken from
411
+ the *parsed* document so a comment never reaches it, with whitespace inside
412
+ a `run` collapsed so re-indenting a script is not a change (H5). Step names
413
+ are left out: renaming a step changes nothing that runs. The digest, not the
414
+ commands, is what is kept, so no line of the client's pipeline is stored.
403
415
  """
404
416
 
405
417
  name = "generic.ci"
406
- version = "1"
418
+ version = "2"
407
419
 
408
420
  def extract(self, tree: SourceTree) -> Iterator[Observation]:
409
421
  for entry in tree.files:
@@ -435,6 +447,7 @@ class CiWorkflowExtractor:
435
447
  "name": workflow,
436
448
  "jobs": job_names,
437
449
  "triggers": _trigger_names(triggers),
450
+ "recipe_digest": _ci_recipe_digest(jobs),
438
451
  },
439
452
  span=_whole_file(entry),
440
453
  )
@@ -447,11 +460,25 @@ class FilesOfInterestExtractor:
447
460
  list is deliberately short: its value is that everything on it is worth
448
461
  someone's attention, and that property is lost the moment it grows to
449
462
  include whatever happened to be present.
463
+
464
+ **Two kinds of file live on this list, and only one of them is a recipe.**
465
+ A README, a licence or a changelog says *that* something exists; a reworded
466
+ one changes no fact about the system, so those stay name-only. A Dockerfile,
467
+ a compose file, a Makefile or a Procfile *is* how the system is built and
468
+ started: replacing `python:3.12-slim` with `node:20-alpine` is the change a
469
+ redeploy runbook most needs to hear about, and until version `3` it was
470
+ classified `BINDING_INTACT_RENDER_ONLY` because the identity carried only
471
+ the file's name. Those four now carry `recipe_digest` -- a digest of their
472
+ *instructions*, with comments and blank lines dropped and whitespace
473
+ collapsed (compose is parsed, so YAML comments never reach it), which keeps
474
+ H5's promise that a comment-only edit manufactures no staleness. The digest
475
+ is stored, never the content.
450
476
  """
451
477
 
452
478
  name = "generic.files_of_interest"
453
479
  #: Bumped to `2` in S1.2: the attribute set lost `path`. See `extract`.
454
- version = "2"
480
+ #: Bumped to `3`: build/deploy recipes gained `recipe_digest`. See the class.
481
+ version = "3"
455
482
 
456
483
  _WELL_KNOWN: Final[frozenset[str]] = frozenset(
457
484
  {
@@ -469,18 +496,31 @@ class FilesOfInterestExtractor:
469
496
  }
470
497
  )
471
498
 
499
+ #: The recipes among them: files whose instructions are how the system is
500
+ #: built or started. See the class docstring.
501
+ _RECIPES: Final[frozenset[str]] = frozenset(
502
+ {"Dockerfile", "docker-compose.yml", "docker-compose.yaml", "Makefile", "Procfile"}
503
+ )
504
+
472
505
  def extract(self, tree: SourceTree) -> Iterator[Observation]:
473
506
  for entry in tree.files:
474
507
  if entry.name not in self._WELL_KNOWN:
475
508
  continue
509
+ attributes: dict[str, object] = {"name": entry.name}
510
+ if entry.name in self._RECIPES:
511
+ text = tree.text(entry)
512
+ if text is not None:
513
+ attributes["recipe_digest"] = _recipe_digest(entry.name, text)
476
514
  yield Observation(
477
515
  kind="metadata_component",
478
516
  key=path_key(entry.path),
479
517
  namespace=FILE_NAMESPACE,
480
- # Deliberately **not** the file's content or its hash: this
481
- # identity says "this repository has a README", and a reworded
482
- # README is not a change to that fact. Content-bearing knowledge
483
- # is Build 2's `adopt ingest`, not Build 1's inventory.
518
+ # Deliberately **not** the file's content: this identity says
519
+ # "this repository has a README", and a reworded README is not a
520
+ # change to that fact. Content-bearing knowledge is Build 2's
521
+ # `adopt ingest`, not Build 1's inventory. The exception is the
522
+ # recipes, which carry a digest of their instructions (class
523
+ # docstring) -- a digest, so the content still never lands here.
484
524
  #
485
525
  # **And deliberately not the path**, which this carried until
486
526
  # S1.2. The path is where the referent sits, and `Observation`'s
@@ -492,11 +532,80 @@ class FilesOfInterestExtractor:
492
532
  # keeps the old URI resolvable is never written. An extractor
493
533
  # that puts location in its attributes silently opts its
494
534
  # referents out of the one mechanism protecting them.
495
- attributes={"name": entry.name},
535
+ attributes=attributes,
496
536
  span=_whole_file(entry),
497
537
  )
498
538
 
499
539
 
540
+ def _sha256_of(value: object) -> str:
541
+ """`sha256:<hex>` over a canonical JSON rendering of `value`."""
542
+ rendered = json.dumps(
543
+ value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, default=str
544
+ )
545
+ return "sha256:" + hashlib.sha256(rendered.encode("utf-8")).hexdigest()
546
+
547
+
548
+ def _instruction_lines(text: str) -> list[str]:
549
+ """The lines that do something: comments and blank lines gone, spacing collapsed.
550
+
551
+ A line whose first non-blank character is `#` is a comment in a Dockerfile,
552
+ a Makefile and a Procfile alike. A trailing `# note` after an instruction is
553
+ deliberately *kept*: in a Makefile recipe it reaches the shell, and in a
554
+ Dockerfile `#` is only a comment at the start of a line -- so stripping it
555
+ would discard real arguments to keep a cosmetic edit quiet, which is the
556
+ wrong side to err on for a recovery claim.
557
+ """
558
+ lines: list[str] = []
559
+ for raw in text.splitlines():
560
+ line = " ".join(raw.split())
561
+ if not line or line.startswith("#"):
562
+ continue
563
+ lines.append(line)
564
+ return lines
565
+
566
+
567
+ def _recipe_digest(name: str, text: str) -> str:
568
+ """The digest of a build/deploy recipe's instructions. See `FilesOfInterestExtractor`."""
569
+ if name.startswith("docker-compose."):
570
+ try:
571
+ document = yaml.safe_load(text)
572
+ except yaml.YAMLError:
573
+ document = None
574
+ if isinstance(document, dict):
575
+ return _sha256_of(document)
576
+ return _sha256_of(_instruction_lines(text))
577
+
578
+
579
+ def _ci_recipe_digest(jobs: object) -> str:
580
+ """The digest of what a workflow's jobs actually run. See `CiWorkflowExtractor`."""
581
+ if not isinstance(jobs, dict):
582
+ return _sha256_of({})
583
+ recipe: dict[str, Any] = {}
584
+ for job_name, job in jobs.items():
585
+ if not isinstance(job, dict):
586
+ recipe[str(job_name)] = {}
587
+ continue
588
+ needs: Any = job.get("needs")
589
+ steps: list[dict[str, Any]] = []
590
+ for step in job.get("steps") or []:
591
+ if not isinstance(step, dict):
592
+ continue
593
+ entry: dict[str, Any] = {}
594
+ if isinstance(step.get("run"), str):
595
+ entry["run"] = " ".join(str(step["run"]).split())
596
+ if step.get("uses") is not None:
597
+ entry["uses"] = str(step["uses"])
598
+ if isinstance(step.get("with"), dict):
599
+ entry["with"] = step["with"]
600
+ steps.append(entry)
601
+ recipe[str(job_name)] = {
602
+ "needs": sorted(str(n) for n in needs) if isinstance(needs, list) else needs,
603
+ "runs-on": job.get("runs-on"),
604
+ "steps": steps,
605
+ }
606
+ return _sha256_of(recipe)
607
+
608
+
500
609
  def _flatten(data: dict[str, object], prefix: str = "") -> Iterator[tuple[str, object]]:
501
610
  """Leaf keys of a nested mapping, rendered dotted."""
502
611
  for key, value in data.items():
@@ -141,9 +141,18 @@ class MapReport:
141
141
  #: platform, lowcode and data are pulled by a real engagement, never pushed).
142
142
  #: `generic` runs for every archetype -- config, dependencies and jobs exist in
143
143
  #: every system, whatever it is built from.
144
+ #:
145
+ #: **`ai` includes `web`.** An AI system that people depend on is reached through
146
+ #: an API: the agent sits behind an endpoint, and the endpoint is what a recovery
147
+ #: runbook and an exit test name. With `ai` mapping only `generic` + `ai`, an
148
+ #: AI service's routes were never extracted -- and once a human had mapped them
149
+ #: with `--packs`, every later `refresh` or `ci-sense` without the same flag
150
+ #: marked them exempt ("pack not selected"), so a route rename went undetected
151
+ #: with exit 0. The web pack on a repository with no web framework simply finds
152
+ #: nothing, which costs one walk and claims nothing.
144
153
  PACKS_FOR_ARCHETYPE: Mapping[Archetype, tuple[str, ...]] = {
145
154
  "web": ("generic", "web"),
146
- "ai": ("generic", "ai"),
155
+ "ai": ("generic", "web", "ai"),
147
156
  "platform": ("generic",),
148
157
  "lowcode": ("generic",),
149
158
  "data": ("generic",),
File without changes
File without changes
File without changes