Capstone.Baseline (Capstone v0.33.3)

Copy Markdown View Source

Provenance and drift checking for the checked-in generator baselines.

Every function is pure over a directory — nothing here shells out to a generator, so the drift check runs offline on every commit and 100% coverage never depends on having phx_new installed.

This module must NOT share a normaliser with Capstone.Hash. They require opposite semantics: the manifest hash ignores an added comment (a # credo:disable-for-next-line must not lock a project out of updates), while a comment added to a baseline IS drift.

Summary

Functions

Names an archive <type>_<version>_<last 8 of sha>.tar.gz.

A single digest over the whole tree.

Selects one baseline's archive members.

Replaces phx.new's four crypto:strong_rand_bytes values with placeholders.

Reads the provenance record.

Recomputes every entry's derived fields from disk.

Builds one baseline's self-verifying snapshot.

Builds reproducible, uncompressed tar bytes from in-memory members.

Maps every relative path under dir to the sha256 of its normalised bytes.

Functions

archive_name(type, version, sha)

@spec archive_name(atom() | binary(), binary(), binary()) :: binary()

Names an archive <type>_<version>_<last 8 of sha>.tar.gz.

type is the manifest KEY, passed through verbatim rather than checked against a closed otp | api | web set — priv/baselines.exs already carries a fourth (:web_layer), and a list here would have to be edited in lockstep with that file, where forgetting yields a mislabelled archive rather than an error.

version is an argument and never read from anywhere here, so this module stays a pure function of what it is given — which is also what keeps it clear of the ambient-state ban Capstone.BoundaryGuard enforces. Mix.Tasks.Capstone.Baseline.Record supplies it, from the .version file that is the project's source of truth. That is the version the release tag names, so an archive and the release it is attached to agree by construction.

Only the last 8 characters of the sha: the full digest is recorded as archive_sha256 in the manifest and again inside the archive itself, so the filename only has to be short and distinguishing. What the prefix adds is legibility — a directory of release assets says which baseline and which release each file belongs to without opening any of them.

digest(dir)

@spec digest(Path.t()) :: binary()

A single digest over the whole tree.

members(entry)

@spec members(map()) :: [{charlist(), binary()}]

Selects one baseline's archive members.

Contents come from disk but membership comes from the entry's files map, never from a fresh wildcard — that is what makes it structurally impossible for the archive and the manifest to disagree about the baseline.

Names are project-relative, identical to the files keys, so each archive unpacks as a standalone snapshot of that generated project and a member name can be looked up in files directly.

normalise_secrets(source)

@spec normalise_secrets(binary()) :: binary()

Replaces phx.new's four crypto:strong_rand_bytes values with placeholders.

Anchors on the key name, never on a bare length match: the alphabet is unpadded base64, so values contain + and / and may start with /.

read!(path)

@spec read!(Path.t()) :: %{required(atom()) => map()}

Reads the provenance record.

Not every entry is a generator baseline. derived_from: marks one produced by applying a plugin — mix capstone.baseline.compose — and such an entry has no argv:, generator: or generator_version:. A test that drives a generator must select entries that carry them rather than iterating all.

record(manifest)

@spec record(%{required(atom()) => map()}) :: %{required(atom()) => map()}

Recomputes every entry's derived fields from disk.

files and tree_digest are recomputed; every other field is passed through untouched. generator_version above all must never be sourced from the running toolchain — it is the pin that makes the toolchain test fail with one line on a phx_new bump instead of a 45-file hash diff.

:file_count is dropped: files supersedes it and map_size/1 is exact.

archive_sha256 is recorded here rather than in a sidecar file. It is not circular: the archive holds only the project's files, so the manifest is not an input to its own recorded hash and one pass converges.

snapshot(entry)

@spec snapshot(map()) :: {binary(), binary()}

Builds one baseline's self-verifying snapshot.

The .tar.gz is an OUTER tar holding two members: baseline.tar — the project's files — and baseline.sha256, its checksum. The checksum sits beside the inner tar rather than inside it, which is the only placement that terminates: a file recording a tar's hash cannot also be a member of it.

Returns {sha, gzipped} where sha is the hex sha256 of the INNER tar.

tar(members)

@spec tar([{charlist(), binary()}]) :: binary()

Builds reproducible, uncompressed tar bytes from in-memory members.

Members are {name_charlist, contents} and are sorted here rather than by the caller, so member order cannot vary with map iteration.

tree(dir)

@spec tree(Path.t()) :: %{required(Path.t()) => binary()}

Maps every relative path under dir to the sha256 of its normalised bytes.