Skip to content

feat(deps): keep a vendored copy current with deps check and deps update - #42

Draft
jbeda wants to merge 6 commits into
mainfrom
feat/vendoring-slice3
Draft

feat(deps): keep a vendored copy current with deps check and deps update#42
jbeda wants to merge 6 commits into
mainfrom
feat/vendoring-slice3

Conversation

@jbeda

@jbeda jbeda commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Issue #25, slice 3 — the vendoring lifecycle. Slice 1 (local imports) shipped in #34, slice 2 (vendoring) in #40. This adds finding out that a copy has gone stale, and refreshing it.

Design settled in a grilling interview before any code. The brief and its acceptance criteria are at .scratch/design/issue-25-slice3-brief.md (local, gitignored); the decisions that clear the ADR bar are project-docs/adr/0016-staleness-is-a-content-comparison.md.

This does not close #25. Qualified entity references in relationship.entity and subtypeOf are still deferred with a friendly error; that is slice 4, and #25 stays open as its umbrella.

What lands

modelith deps check docs/*.modelith.yaml     # which copies fell behind? writes nothing, exits 1 if any did
modelith deps update docs/*.modelith.yaml    # bring them forward
modelith deps update --ref v2.2.0 docs/payments.modelith.yaml   # re-pin one copy

The decisions worth arguing with

lint answers "has my copy been tampered with?" deps check answers "has the model changed upstream?" They do not overlap. check deliberately does not re-verify the local digest.

Staleness is a content comparison, not a commit comparison. A property of slice 2's design makes this clean: import records the digest over the upstream bytes before stamping, so # modelith-digest: is the origin's digest rather than the local file's. Comparing it against what the origin serves now is independent of local edits. Commit comparison is wrong in both directions — a merge or whitespace touch reports stale over nothing, and a SHA cannot say what differs. The commit stays as reporting, resolved only when the content actually differs, so a clean check costs one gh call per copy rather than two.

One write condition, and every case falls out of it:

write iff Digest(local) != Digest(upstream) || ref changed
upstream local update writes
unchanged unedited nothing
unchanged edited restores it
changed unedited refreshes it
changed edited refreshes it, edits discarded

A restore re-stamps the recorded header verbatim, reproducing the original import's bytes exactly, so # modelith-imported: dates the arrival of a version rather than the last check. That is what forecloses a # modelith-checked: key: recording a check would make every check a write.

Explicit file arguments only, MinimumNArgs(1) like lint. No tree walking, no zero-arg form. A file with no provenance header is skipped, and a closing summary counts both, so a glob that matched none of your copies does not read as good news.

A pinned copy reports up to date forever. Every line names the ref it checked against so the verdict reads as a statement about the pin. Newer-release detection needs an answer to "which tags count as newer" that modelith has no basis to give — filed separately.

Corrected during implementation

The interview settled on a warning when the fetched version's scope: changed. That hazard does not exist. It rested on slice 1's brief, but ADR-0012 superseded that mid-slice-1: scope: left the schema and the importer binds the slug from the import path's basename. update rewrites the same file at the same path, so the basename cannot move.

The real hazard in that shape is upstream renaming or removing an item you reference, which update cannot detect — it does not know which models import the copy. So it says to run modelith lint after writing, and why.

Verification

task check green. The unit tests build their fixtures with the real Import and were verified by mutation — removing the no-op early return, deciding staleness by commit, fetching the commit eagerly, and re-dating a restore each fail a test that should catch them.

The adversarial run against a real origin found two bugs the unit tests did not (8652eb0):

  • deps check on a machine with no gh panicked with a nil dereference instead of reporting that gh is missing. survey appended a Report for the file it abandoned, carrying neither a verdict nor a failure, and the printer fell through and dereferenced its nil State. The unit test for the abort had asserted the buggy shape.
  • A run refused before it started (--ref with several files) printed updated 0 of 0 vendored copies above the reason.

End-to-end against stacklok/modelith itself, all confirmed by hand: import → clean check → no-op update leaves the file byte-identical → hand-edit → lint names deps update <path> → update restores byte-identically → lint clean; a tag-pinned copy reporting up to date; --ref main re-pinning to genuinely different content; a stale verdict exiting 1; a glob mixing owned, vendored, missing, and broken-header files.

Review

Round one has not run. deps does network I/O and overwrites committed files, so I'd suggest /code-review high. Draft until it clears.

jbeda added 6 commits July 27, 2026 17:24
Records the two decisions behind slice 3 that clear the bar in
.claude/rules/adr.md: deps check compares the origin's bytes against the
recorded digest rather than comparing commit SHAs, and # modelith-imported:
dates the arrival of a version rather than the last check.

Extends ADR-0015 with the freshness half of the vendoring lifecycle. Nothing
in ADR-0015 becomes untrue, so this supersedes none of it — but it does narrow
one of its expectations: 0015 assumed check would diff the two files, and it
does not.

Its enforcing tests land with the implementation in the following commits.

Signed-off-by: Joe Beda <joe@stacklok.com>
Adds the shared core of deps check and deps update: one survey that reads a
copy, fetches what its origin serves now, and reports the three distinct
answers that fall out — whether the origin moved, whether the copy on disk
drifted, and whether the ref being consulted is the recorded one.

Staleness is a content comparison against the recorded digest, not a commit
comparison (ADR-0016). Because import writes that digest over the upstream
bytes before stamping, it is the origin's digest rather than the local file's,
so the verdict holds whether or not anyone edited the copy. The commit SHA
stays reporting: it is resolved only when the content actually differs, so a
clean check costs one gh call per copy instead of two.

There is one write condition — the copy does not hold what the origin serves,
or the ref changed — and every case falls out of it, including restoring a
hand-edited copy whose origin has not moved. A restore re-stamps the recorded
header verbatim, reproducing the bytes the original import wrote.

A batch continues past a per-file failure, so one unreachable copy does not
hide the state of the files after it. The single exception is gh itself being
unusable, which would fail every remaining file identically; ExecRunner marks
those with ErrToolUnavailable, matching gh's own text for the auth cases since
it reports them on stderr with no typed error to unwrap.

A refetch makes the same two refusals a first fetch does — an origin that has
itself become a copy, or has stopped being a domain model — because the file
at an address is not the file that was there last time. A header's parts are
reassembled into a blob URL and handed back to ParseSource rather than pasted
into an endpoint here, so a hand-written header gets the host check and the
dot-segment rejection that escapePath depends on.

The tests build their fixtures with the real Import and were verified by
mutation: removing the no-op early return, deciding staleness by commit,
fetching the commit eagerly, and re-dating a restore each fail a test.

Signed-off-by: Joe Beda <joe@stacklok.com>
Two commands rather than one with --dry-run, over the one survey in
internal/deps: check never writes, which is what makes it safe in CI and
against a read-only checkout, and two independent notions of "is it stale"
would be free to disagree.

Both take explicit file arguments like lint, shell-globbed. A file with no
provenance header is skipped without complaint, because the glob a user
already passes to lint holds their own models too — and a closing summary
counts both, so a user who globbed wrong is not told "all up to date" about
nothing. Verdicts go to stdout, per-file failures to stderr.

Every check line names the ref it was checked against. A copy pinned to a tag
is up to date for as long as that tag points where it did, and modelith does
not look for newer releases; naming the ref is what keeps that verdict from
reading as a statement about the world.

A stale copy exits 1, matching render --check drift, and so does a copy that
could not be reached — not being able to tell is not evidence of currency.

update prints the transition rather than a diff: git diff answers "what
changed" with the user's own tooling. When it writes anything it repeats
ADR-0014's trust warning, since an origin trusted last month says nothing
about the commit that landed yesterday, and points at modelith lint, because
an item a copy used to define may have been renamed upstream and update cannot
see the models that reference it. A no-op run prints neither.

--ref re-pins one copy and refuses several: one ref names a different version
in every other repository.

Signed-off-by: Joe Beda <joe@stacklok.com>
The remedy for an edited vendored copy was a blob URL the message assembled
from the header's parts. deps update takes the file's own path instead, reads
the header itself, and — when the origin has not moved — rewrites the copy to
exactly the bytes the original import wrote. That is a restore rather than a
re-import, which is what the user wants and what the message should say.

A model linted without a path names the file generically rather than offering
a path that would not work.

Signed-off-by: Joe Beda <joe@stacklok.com>
Pairs the new commands with the section they belong beside: keeping a copy
honest is about the copy, keeping it current is about the model it came from.
Covers the two tracking modes — a branch that a bare update follows, and a tag
that only --ref moves — and states the limitation that falls out of a content
comparison, that a pinned copy reads as up to date until its tag moves.

Says plainly that an upstream documentation-only change counts as a change,
because the digest covers the whole file, and that git diff after an update is
how you find that out.

Also updates the digest-mismatch example to the remedy lint now prints, adds
the deps check and deps update entries to the CLI reference, and points at
git grep for finding vendored copies, since neither command discovers them.

Signed-off-by: Joe Beda <joe@stacklok.com>
…ever ran

Both found by running the real binary against a real origin, which is where
they were only ever going to show up.

survey appended a Report for the file it abandoned when gh proved unusable.
That Report had neither a verdict nor a per-file failure, so the printer fell
through to its default branch and dereferenced a nil State: `deps check` on a
machine with no gh panicked instead of saying gh is not installed. The abort
error is the whole story for that file, so it gets no Report — and the
printers now treat a Report with no verdict as nothing to say, since a command
whose job is reporting calmly on other people's files must not be the thing
that crashes.

The unit test for the abort had asserted the buggy shape, expecting a Report
for the abandoned file. It now pins that there is none.

Separately, a run refused before it started — `--ref` with several files — still
printed "updated 0 of 0 vendored copies" above the reason, which reads as the
outcome of a run that happened. Nothing reached means nothing to summarise.

Signed-off-by: Joe Beda <joe@stacklok.com>
@jbeda

jbeda commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Filed the newer-release follow-up as #43 (acceptance criterion 12). #25 stays open as slice 4's umbrella.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Schema: bounded contexts — model scope slugs and cross-model references

1 participant