SvEx.Plugin.Record (SvEx v0.4.2)

Records an applied plugin in the target's plugin.exs (SDD 8.2, R5, R6).

SvEx.Plugin.Apply installs; this records what was installed and with which content hashes, so a later update can tell an untouched file from an edited one. Nothing else under lib/ writes a manifest.

Only into a project that declares itself

SvEx.Manifest enforces six top-level keys, four of which describe a project the scaffolder generated. Apply deliberately works against ANY project — its round trip installs meta_cache into a bare copy of baseline_otp that has no target.exs at all — so recording is conditional:

RootApplyThis module
has target.exsinstallswrites plugin.exs
has noneinstalls, and says sodoes nothing

Inferring the missing fields was rejected: a manifest that asserts an inferred base is worse than no manifest, because the update flow trusts it. Creating a target.exs was rejected too — installing a plugin would silently convert someone's project into a SvEx project.

What a plugin changed in mix.exs, and what is still not hashed

deps, aliases and project are recorded on the entry. Until they were, SvEx.Plugin.Apply wrote a plugin's dependencies into the target's mix.exs and nothing said WHICH plugin had put them there, so an update flow could neither attribute a dependency nor notice that a user had since hand-edited it.

They are recorded as DECLARATIONS, not as hashes. Derive extracts mix.exs out of files and into these three keys, so file_entry/3 never sees the file and no content hash of mix.exs exists in the manifest. That is the right shape — mix.exs is a file every plugin and the project itself writes to, so a whole-file hash would report drift on every unrelated edit — but it means drift detection for these three is a structural comparison an update flow has to make against the target's CURRENT mix.exs, not a hash comparison like every other entry. That comparison is D7's work and is not written yet.

What is re-read and what is carried across

base and config_digest come from the target's CURRENT target.exs on every write, so the two always describe the same file. generated_at is MINTED on the first write — when no plugin.exs exists yet — and carried unchanged from the existing manifest on every write after that: it is a fact about the past, and a recorder rewriting it would erase the only record of when the project was FIRST RECORDED. For a project the archive generated weeks earlier, that first-write instant is when a plugin was first applied, not when the project itself was laid down — generated_at means "first recorded", not "generated", and no better source exists: target.exs carries no timestamp of its own, while plugin.exs requires the key. applied_at is per plugin and is always this instant.

sv_ex_version sits in tension with that argument, and deliberately so: it is RECOMPUTED on every write, from the running scaffolder's own Application.spec/2, never carried forward the way generated_at is. It means "last written by version X", not "the version that generated this project" — a project laid down under 1.0.0 and later touched by a plugin applied under 1.2.0 shows sv_ex_version: "1.2.0" in its plugin.exs, same treatment as applied_at and unlike generated_at.

config_digest is COMPUTED here. target.exs carries no such field and SvEx.Manifest checks it for format only — its moduledoc states that nothing produces one until the generator exists. This is that producer, and it goes through SvEx.Hash rather than a raw sha256 deliberately: a comment added to target.exs is not a change of intent, and must not read as one when the update flow compares digests. SvEx.Hash.content_hash/2 already emits exactly the "sha256:" <> 64 lowercase hex the format requires.

Hashes come from SvEx.Hash, never SvEx.Baseline

Three normalisers in this repository, three jobs. SvEx.Hash treats an added comment as NOT a change, because comments are user-owned; SvEx.Baseline treats one as drift, which is right for a generator baseline and wrong here. SDD 4.1 records Fireside reading one added # note as "diverged, aborting", and a # credo:disable-for-next-line must never lock a project out of updates.

The one exception is a file SvEx.Plugin.Apply left conflict-marked: that file is not the language its name promises, the parser refuses it, and SvEx.Hash.text_hash/1 hashes it as text instead. The marker is read off DISK rather than reported by apply, because a second apply short-circuits on the marker already being there and would report nothing.

The check is for the GENERIC marker prefix — Apply.marker_prefix(""), the same one mix sv_ex.check scans for — not this entry's own key. A marker another key left behind still makes the whole file unparseable, and checking only this entry's key would fall through to content_hash/2 and raise after every file has already been written, with no manifest to show for it. Whichever key wrote a marker, its presence anywhere in the file is what matters, never which key.

This is not a defence against every unparseable file. A target file that was already broken before apply ran — the user's own syntax error, or a conflict marker left by their own VCS rather than by this module — still raises SyntaxError from content_hash/2, naming the file. That project was already uncompilable; recording could not have fixed it, and doing so is out of scope here.

Summary

Functions

Pre-flights the reads run/4 needs, so a malformed target.exs or plugin.exs raises BEFORE SvEx.Plugin.Apply writes a single file.

Records plugin in target_dir's plugin.exs, if it has a target.exs.

Reports whether target_dir declares itself a SvEx project.

Functions

preflight!(target_dir)

@spec preflight!(Path.t()) :: :ok

Pre-flights the reads run/4 needs, so a malformed target.exs or plugin.exs raises BEFORE SvEx.Plugin.Apply writes a single file.

A no-op for an untracked target: tracked?/1 is the same predicate run/4 gates on, so an untracked target takes on no new read and no new failure mode. Reused rather than reimplemented, so the two can never drift.

run(component_dir, target_dir, plugin, names)

@spec run(Path.t(), Path.t(), map(), SvEx.Template.names()) :: :ok

Records plugin in target_dir's plugin.exs, if it has a target.exs.

component_dir becomes the entry's {:path, _} origin, made relative because a manifest naming one machine's checkout is not portable to another machine or to CI (SDD 4.1 defect 13). The base point is the SCAFFOLDER's own working directory — where the plugin lives — never the target directory that stores the manifest; that is the same base point a project's own target.exs plugins: entry already uses for a path: plugin (SDD 8.1). A plugin directory outside the working directory stays absolute and is refused by SvEx.Manifest, which is the correct answer: it could not be recorded portably.

names is the triple the files were rendered with, and is what resolves each declared path to the one actually on disk.

tracked?(target_dir)

@spec tracked?(Path.t()) :: boolean()

Reports whether target_dir declares itself a SvEx project.

ONE definition, read by run/4 to decide and by mix sv_ex.plugin.apply to report — so the behaviour and the message about it cannot drift apart.

Raises SvEx.Root.InvalidRootError if target_dir has no mix.exs — it is not a project directory at all, tracked or otherwise.