Skip to content

Production-harden loader, multi-package dependency tracking, exhaustive tests - #15

Merged
felix-andreas-finccam merged 3 commits into
mainfrom
claude/production-readiness-testing-88icbf
Sep 22, 2026
Merged

felix-andreas-finccam merged 3 commits into
mainfrom
claude/production-readiness-testing-88icbf

Conversation

@felix-andreas-finccam

Copy link
Copy Markdown
Contributor

Summary

Brings loadfast into production shape: fixes real multi-package and lifecycle bugs found by probing, makes the package a clean installable artifact (R CMD check --as-cran: 0 errors, 0 warnings), and extends testing from 259 to 299 harness checks plus a new testthat suite that runs against the installed package during R CMD check.

Loader changes (R/loadfast.R)

Multi-package dependency tracking (the main gap): previously, after reloading a dependency, importer packages kept running against a stale snapshot and a plain load_fast() on them short-circuited with "No changes". Now:

  • Reloading a package flags every cached package that (directly or transitively) imports it.
  • A flagged package's next load_fast() auto-upgrades to a full reload, with a message explaining why.
  • Full loads recursively reload flagged dependencies first, so a chain C -> B -> A converges with a single load_fast(C) after A is reloaded.
  • No-change loads do not invalidate dependents.

Lifecycle fixes:

  • Depends: packages are attached during full loads (clear error when missing) — previously ignored, so package code relying on them crashed.
  • .onUnload runs best-effort when unloadNamespace() refuses (namespace imported by other loaded packages) before the registry entry is force-replaced.
  • A failed full load no longer leaves a half-built namespace registered/attached; recovery on the next call still works.
  • Renaming the package in DESCRIPTION in place now detaches/unloads the old identity instead of leaving it on the search path forever.
  • Incremental re-sourcing follows Collate-aware full-load order instead of alphabetical basenames.
  • Namespace spec version comes from DESCRIPTION, so getNamespaceVersion() reports correctly.
  • Re-entrance guard is now a path stack: public load_fast() stays strict, internal dependency-reload recursion is allowed.

Packaging

  • DESCRIPTION: version 1.0.0; methods/tools declared in Imports; dev deps moved from the non-standard DevDeps field to Suggests.
  • roxygen2-generated NAMESPACE and man/ pages for both exports.
  • Top-level message() moved to a suppressible .onAttach; sources are ASCII-only.
  • library("testthat") replaced with a guarded attachNamespace(); tiny methods::: internals (.hasS4MetaData/.getGenerics/.TableMetaPrefix) inlined as equivalents so --as-cran is warning-free.
  • Complete .Rbuildignore; renv settings track Suggests.
  • R CMD check --as-cran: 0 errors, 0 warnings; the only inherent NOTE is the attach() call (same NOTE pkgload carries).

Testing

  • Stage 6 (new) — multi-package dependency invalidation: direct importer invalidation, transitive chain convergence, no-change loads, whole-namespace import(), S4 class redefinition across packages, forced-unregister path incl. .onUnload.
  • Stage 7 (new) — production hardening: Depends attachment + missing-Depends error, rename-in-place cleanup, failed-full-load cleanup and recovery, namespace version fidelity, Collate order on incremental reload, re-entrance guard, verbose logs.
  • tests/testthat/ (new) — self-contained smoke/error-path tests that run against the installed package during R CMD check, so the check proves the built artifact works.
  • Harness result: 299 passed, 0 failed (verified locally on R 4.3.3/Linux).

CI

  • Harness job now also runs on macOS (Linux/Windows/macOS, R 4.5.2 via renv).
  • New R CMD check --as-cran job on Linux/Windows/macOS at R release, plus oldrel-1 on Linux.

🤖 Generated with Claude Code

https://claude.ai/code/session_01S8v6K52c7AJwuDkyyETMU7


Generated by Claude Code

claude added 3 commits July 6, 2026 12:38
…ve tests

Loader (R/loadfast.R):
- Track cross-package dependencies: reloading a package flags every cached
  package that (transitively) imports it; the flagged package's next
  load_fast() auto-upgrades to a full reload, and full loads recursively
  reload flagged dependencies first, so a chain C -> B -> A converges with a
  single load_fast(C) after A is reloaded. No-change loads do not invalidate.
- Attach DESCRIPTION Depends packages during full loads (with a clear error
  when one is missing), matching library()/load_all() semantics.
- Run .onUnload best-effort when unloadNamespace() refuses (dependency
  imported by other loaded packages) before force-replacing the registry
  entry.
- Clean up half-built namespaces when a full load fails partway (unregister
  and detach), while keeping failed-load recovery on the next call.
- Detach/unload the old identity when the Package field changes in place for
  a cached path.
- Re-source incremental changes in Collate-aware full-load order instead of
  alphabetical basename order.
- Seed the namespace spec version from DESCRIPTION so
  getNamespaceVersion() reports correctly.
- Replace the boolean re-entrance flag with a path stack (public load_fast()
  stays strict; internal recursion is allowed).
- Replace library("testthat") with a guarded attachNamespace(), inline the
  tiny methods::: metadata helpers (.hasS4MetaData/.getGenerics/
  .TableMetaPrefix equivalents), move the top-level message to .onAttach,
  and make the sources ASCII-only -- R CMD check --as-cran is now clean
  (0 errors, 0 warnings; only the inherent attach() NOTE remains).

Packaging:
- DESCRIPTION: version 1.0.0, declare methods/tools in Imports, move dev
  dependencies from the non-standard DevDeps field to Suggests.
- roxygen2-generated NAMESPACE and man/ pages for both exports.
- New tests/testthat suite that runs against the installed package during
  R CMD check (smoke, incremental, dependency invalidation, error paths).
- .Rbuildignore covers all non-package files; renv settings track Suggests.

Tests (test_loadfast.R, 299 checks total):
- Stage 6: multi-package dependency invalidation -- direct importer
  invalidation, transitive chain convergence, no-change loads, whole-
  namespace import(), S4 class redefinition across packages, forced
  unregister path incl. .onUnload.
- Stage 7: production hardening -- Depends attachment and failure, rename-
  in-place cleanup, failed-full-load cleanup and recovery, namespace version,
  Collate order on incremental reload, re-entrance guard, verbose logs.

CI: harness now also runs on macOS; new R CMD check --as-cran job on
Linux/Windows/macOS (R release) plus oldrel-1 on Linux.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S8v6K52c7AJwuDkyyETMU7
…ix CI installs

rlang::ns_registry_env() is defunct as of rlang 1.2.0 and hard-errors, which
broke every R CMD check CI job on fresh installs (the renv.lock pinned an old
rlang, masking it locally). Replace it with .loadfast.ns_register() /
.loadfast.ns_unregister(), which construct .Internal(registerNamespace(...)) /
.Internal(unregisterNamespace(...)) calls at runtime -- the same approach
current pkgload uses. loadfast now has no third-party runtime dependencies;
rlang moves to Suggests (still imported by the devpackage test fixture, which
now imports the stable rlang::abort instead of the defunct symbol).

The harness CI jobs failed separately during renv::restore(): the lockfile
drags in dev-machine packages (keyring needs libsecret on ubuntu; a package
failed to resolve on macos). Switch those jobs to setup-r-dependencies, which
installs exactly the DESCRIPTION Imports+Suggests the harness needs and
handles system requirements; renv.lock remains for local development.

Verified locally: harness 299/299, R CMD check --as-cran clean (2 expected
NOTEs), testthat suite passes under check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S8v6K52c7AJwuDkyyETMU7
normalizePath(mustWork = TRUE) wording differs by OS: "No such file or
directory" on unix vs "The system cannot find the file specified" on
Windows. Broaden the two expected-error patterns accordingly; this was the
only remaining CI failure (Windows R CMD check).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S8v6K52c7AJwuDkyyETMU7

Copy link
Copy Markdown
Contributor Author

User test: built a fresh 3-package stack and ran it against main and this branch

I did an independent user test of this branch rather than a code read: I wrote my own toy
package stack from scratch, validated it against a real R CMD INSTALL + library() first so my
expectations match actual R semantics, then ran the identical scenario against main and this
branch in separate R processes.

Verdict: this is a clear improvement and I'd merge it. The multi-package tracking works exactly
as described, and the branch also fixes something bigger than the PR description gives it credit for
(see "The most important fix" below). A handful of small findings at the end.

Setup (click to expand)
  • Ubuntu 24.04, R 4.3.3. CRAN was unreachable from my sandbox, so rlang, R6, digest,
    data.table, testthat and pkgload were built from their GitHub HEADs (rlang 1.3.0.9000,
    pkgload 1.5.3.9000).
  • Both versions installed side by side into separate libraries (loadfast 0.0.1 from main,
    1.0.0 from a3f7ed7).
  • My stack — three packages, deliberately covering every cross-package mechanism the README claims:
    • tsdata — S3 generic + methods, S4 class + generic + method, R6 class, an exported constant
    • tsstats — imports tsdata via importFrom / importClassesFrom / importMethodsFrom, plus tsdata:: calls
    • tsreport — whole-namespace import(tsstats), so tsreport -> tsstats -> tsdata is a transitive chain
  • Sanity check first: R CMD INSTALL + library() on all three, confirming every assertion I make
    below is what real R does.

Scenario results

23 assertions over a realistic edit-reload loop. main 18/23, this branch 22/23.

Behaviour main This PR
Baseline cross-package access (importFrom, import(), ::, S3, S4, R6) pass pass
Direct importer picks up a dependency edit stale — prints No changes in tsstats/R., returns stats-on-v1 Full reload of 'tsstats': dependency 'tsdata' was reloaded. → stats-on-v2
Transitive importer (tsreport) picks up the edit stale correct
S4 method redefinition visible in importer stale (3 kg, should be 3 kg!) correct
getNamespaceVersion() 0.0.0 0.1.0 (matches DESCRIPTION)
Failed full load leaves the namespace registered? yes — tsbroken stays in loadedNamespaces() no, cleaned up
Rename Package: in place both old and new on search() old detached, new attached
Depends: on a missing package silently ignored clear error naming the package
Collate-order on incremental reload alphabetical (wrong value: 30) Collate order (correct: 150)

One nuance worth recording, because it explains why main doesn't fail every cross-package test:
staleness on main depends on how the importer reaches the dependency. tsdata::series_scale(...)
resolves live through the registry, so an edit is picked up even without reloading the importer;
an importFrom-imported value like DATA_LAYER_VERSION is a snapshot and goes stale. So on main
the failure is intermittent by access style, which is the worst kind. This PR removes that.

Claims I verified independently

  • "No-change loads do not invalidate dependents" — confirmed. load_fast(tsdata) with no edits leaves tsstats on the cheap No changes path.
  • "A chain C -> B -> A converges with a single load_fast(C)" — confirmed. After editing and reloading tsdata, one load_fast(tsreport) emits Reloading dependency 'tsstats' first: ... and lands on the right value.
  • The flag is one-shot — first call after a dep edit is a forced full reload; the second and third are back to No changes. No sticky full-reload penalty.
  • Cascade is conservative — I broke tsdata on disk after flagging its dependents, then reloaded from the top. The cascade rebuilds tsstats/tsreport against the loaded tsdata namespace and never re-sources the package I didn't ask it to reload. Good call.
  • Lifecycle hooks — .onUnload then .onLoad fire correctly for the importer during a forced full reload.
  • load_fast_register_reload() still works after a forced full reload.
  • Test suites — harness: 299 passed, 0 failed on this branch vs 259 passed, 0 failed on main. The new tests/testthat/ suite runs under R CMD check: [ FAIL 0 | WARN 0 | SKIP 0 | PASS 24 ].
  • R CMD check --as-cran — the only inherent NOTE is the attach() call, as stated. (My run also showed notes/warnings for missing pandoc, no LaTeX, no CRAN index and an unavailable locale — all sandbox artifacts, not the package.)
  • Base-R-only runtime — I installed the built package into a library containing only loadfast and R6, with no rlang anywhere on .libPaths(). The full stack loads and every cross-package feature works.

The most important fix here isn't in the PR description

main calls rlang::ns_registry_env(), which rlang gates on the R version:

# rlang/R/env-special.R
ns_registry_env <- function() {
  if (getRversion() >= "4.6") {
    deprecate_stop("`ns_registry_env()` is defunct.")
  }
  .Call(ffi_ns_registry_env)
}

So on R >= 4.6 with rlang >= 1.2.0, main's load_fast() fails on every full load.

I verified this rather than inferring it: I rebuilt rlang with only that version gate flipped
(>= "4.6" -> >= "4.3"), so the function takes exactly the branch it would take on a real R 4.6,
and ran main against it:

ns_registry_env():  STOPS -> `ns_registry_env()` is defunct.
load_fast():        ERROR -> `ns_registry_env()` is defunct.

The same run on this branch works, because commit e973af6 replaced it with runtime .Internal()
calls. That's a "loadfast stops working entirely on the next R release" fix, currently buried in the
second commit's message. I'd promote it to the top of the PR description and add a NEWS entry —
it's the strongest reason to merge, and users on main will want to know.

What the new correctness costs

Synthetic package: 401 R files, 10,002 functions, 202 exports (the realistic shape — exporting all
10k made parseNamespaceFile superlinear and skewed my first measurement).

Operation main This PR pkgload::load_all
Cold full load 0.367s 0.347s 1.00s
Reload, one own file changed 0.012s 0.012s 0.85s
Reload after a dependency edit 0.007s — stale 0.317s — correct 0.80s — correct

So the honest tradeoff: a leaf reload after touching a base package goes from ~7ms to ~317ms,
roughly a cold load and ~26x the incremental path. That is the right default — a 7ms wrong answer
is worth nothing — and it is still ~2.7x faster than load_all on the same package, with the common
case (editing the package you're in) untouched at 0.012s. Worth one line in the README so nobody is
surprised by the cliff.


Findings

1. NAMESPACE changes are still not detected (pre-existing; affects both versions).

Adding a function and its export() and reloading:

| Incremental reload: 1 changed [aaa_constants.R]
-> tsdata::series_range : ERROR: 'series_range' is not an exported object from 'namespace:tsdata'

full = TRUE fixes it. This isn't a regression, but it lands squarely on this PR's audience —
adding an export to a base package is routine multi-package work, and the README's full = TRUE
guidance only mentions deleted files and removed functions, so there's nothing pointing users at it.

Suggested fix: hash NAMESPACE (and DESCRIPTION) alongside the R/ files and force a full load
when either changes. One extra md5sum per call, it makes the existing rename-in-place detection
fall out naturally, and it closes the gap rather than documenting it.

2. The README overstates the new guarantee.

a plain load_fast() on an importer never silently reports "No changes" while it is running
against a stale dependency snapshot.

It does, if the dependency was edited but not itself reloaded — which is the natural thing to do
when your cursor is already in the importer:

(tsdata edited on disk, load_fast(tsdata) NOT called)
| No changes in tsstats/R.
-> tsstats::stats_stack_version(): stats-on-vX      # correct value: stats-on-vTRAP

The very next paragraph states the correct workflow ("reload the package you edited, then reload
whatever package you are working in"), so this is just a wording fix — something like
"...never reports No changes after you have reloaded a dependency it imports from". Worth
getting right, since it's the sentence a user will rely on.

(Genuinely fixing it looks cheap, if you want it: the cache already holds every loaded package's
path and per-file MD5s, so a forced load could re-hash cached dependencies and flag them. Happy to
leave that for a follow-up.)

3. The comment at R/loadfast.R:6-7 is imprecise about the trigger.

rlang's ns_registry_env() is defunct as of rlang 1.2.0

It reads as unconditional, but it's defunct in R >= 4.6 — rlang 1.2.0 is where the version gate
was added; on older R it still works (which is why main still passes its suite on R 4.3/4.5).
Suggest: "defunct in R >= 4.6 (rlang >= 1.2.0)". Given this is the load-bearing justification for
the .Internal() approach, it's worth being exact.

4. The import-failure error could carry the same hint the new Depends error does.

The new Depends error is genuinely helpful:

Failed to attach Depends package 'foo': there is no package called 'foo'
If it is a source package, load it with load_fast() first.

The import path has no equivalent, and it's the error a multi-package user actually hits first:

Import failed for "cyca": there is no package called 'cyca'

Adding the same second line there would help exactly the audience this PR targets. (Reached via a
cyclic import() in my test, but any not-yet-loaded source dependency produces it.)

Related and worth noting as an improvement: on main that cycle appeared to load, because the
failed first load left a half-built namespace registered for the second one to import from. This
branch's failed-load cleanup means both packages now fail cleanly instead. Correct, if initially
surprising.


None of these block merging — 1 and 2 are the two I'd want addressed, and 2 is a one-line doc edit.


Generated by Claude Code

@felix-andreas-finccam
felix-andreas-finccam merged commit 4d8ecc3 into main Sep 22, 2026
7 checks passed
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.

2 participants