Skip to content

Public Documentation

Documentation for EpiAwarePackageTools's public interface.

EpiAwarePackageTools.EpiAwarePackageTools Module
julia
EpiAwarePackageTools

Shared, package-agnostic test utilities for EpiAware Julia packages.

The helpers here are deliberately generic: they take a target module or a backend/scenario registry and run a standard check over it, so each EpiAware package can reuse one implementation rather than copying the same boilerplate.

Two groups are provided.

A scaffold helper writes the shipped standard configuration and test infrastructure into a package — root dev config, CI caller workflows + dependabot, and the QA/AD/benchmark test-infra drivers that call these helpers — so a package adopts the whole kit at once. scaffold_generate does the same for a brand-new package, laying down its Project.toml and source module first. update re-applies the managed standard files (the scheduled template-sync entry point), leaving package-owned tests, AD scenarios, and QA config values untouched.

setup_checklist prints the handful of manual, dashboard-only setup steps scaffold/scaffold_generate cannot do for us (Codecov, GitHub Pages, the DOCUMENTER_KEY deploy key, branch protection, the first registry registration), plus a ready-to-paste tracking issue body.

The AD harness + AD CI are opt-in: scaffold/scaffold_generate/update take an ad::Bool keyword (default true). A numerical package keeps ad = true; a tooling/non-numerical package passes ad = false to scaffold none of the AD infrastructure. The kit manages its own repo with ad = false.

A Benchmarks submodule supplies the generic benchmark-reporting harness: turning AirspeedVelocity or BenchmarkTools result data into a legible Markdown PR comment. A package keeps its own benchmark definitions and calls into this module to run and report them.

A DocsBuild submodule supplies the generic documentation-build machinery: build_docs runs the standard Documenter + DocumenterVitepress build (README→index, release notes, benchmark page, API split, Literate tutorials) for a package module, so the managed docs/make.jl is a thin caller.

Package-specific fixtures (the actual distributions, models, or interface checklists a package wants to exercise) stay in that package. This module only supplies the reusable scaffolding.

source

Contents

Index

Public API

EpiAwarePackageTools.ADRegistry Type
julia
abstract type ADRegistry
julia
ADRegistry

The contract a package's AD-fixture module must satisfy to drive the harness.

A registry reg is any object (commonly a package's ADFixtures module) responding to:

  • scenarios(reg; with_reference = true, kwargs...) returning a vector of scenarios. Each scenario s exposes s.name::String, s.f, s.x, s.contexts (a tuple, possibly empty), and s.res1 (the ForwardDiff reference gradient, or nothing). This matches a DifferentiationInterfaceTest scenario. Extra keyword arguments (e.g. a package's own scenario-group selector) are forwarded from the runners' scenario_kwargs.

  • backends(reg) returning a vector of named-tuples (; name, backend), where backend is an ADTypes backend.

The remaining bookkeeping accessors are optional: a registry that owns no broken or skipped scenarios may omit them, and the harness treats the missing accessor as "none". Define them only when a package actually has such scenarios.

  • broken_scenario_names(reg) (optional) returning a collection of scenario names broken on every backend. Default: empty.

  • backend_broken_scenarios(reg) (optional) returning a Dict{String, Set{String}} of per-backend broken scenario names. Default: empty.

  • backend_skip_scenarios(reg) (optional) returning a Dict{String, Set{String}} of per-backend scenario names too unstable to run at all. Default: empty.

A package may implement these as plain functions taking the registry, or (the common case) expose them as zero-argument functions on a module and pass the module as reg; the harness calls reg.f(...) either way via property access.

This is a documentation-only marker; the harness duck-types on the methods above.


Fields

source
EpiAwarePackageTools.BANNED_README_WORDS Constant
julia
BANNED_README_WORDS

Words and phrases the EpiAware writing standard keeps out of prose, used as the default banned list by test_readme_prose.

Each entry is matched case-insensitively at a word boundary by stem, with any suffix allowed, so listing leverage also catches leverages and leveraging, and practitioner catches practitioners. A few entries carry a hand-written stem instead, where the general one either overreaches (novel must not match novelist) or falls short (synergy has to reach synergies and synergistic, current approaches the singular current approach).

framework and harness are the two context-sensitive entries: both are legitimate when they name a specific thing (a test harness, a named framework) and padding when they stand in for one. A package that uses either as a domain term drops it from its own banned list rather than dropping the check.

source
EpiAwarePackageTools.MANAGED_README_SECTIONS Constant
julia
MANAGED_README_SECTIONS

The standard sections the kit manages, in the order update renders them between the standard-sections markers (see scaffold).

The managed block is appended to a README that carries none of these sections yet, so a package-owned section (commonly ## License) can end up above, between, or below the sections the kit writes. Only the order within the block is the kit's to guarantee, and that is what test_readme_sections checks when the markers are present (#236).

source
EpiAwarePackageTools.STALE_README_HEADINGS Constant
julia
STALE_README_HEADINGS

Retired README headings, each paired with the heading that replaced it, reported as drift by test_readme_sections.

A renamed section would otherwise be caught only as a missing section, which says what is absent but not what to rename. Each entry is a Regex => replacement pair; the regex is matched against every ##-level (and deeper) heading text, so a heading that carries the package name still matches.

The one entry today is the pre-#292 What packages work well with X?, replaced by ## Related packages.

source
EpiAwarePackageTools.STANDARD_README_SECTIONS Constant
julia
STANDARD_README_SECTIONS

The standard EpiAware README section structure, in order, used as the default required set by test_readme_sections.

The order mirrors the sections the kit itself renders into a managed README — Contributing, then the citation section (## How to cite), then Code of conduct — so a freshly scaffolded package passes the order = true check out of the box. The Contributing group therefore precedes the citing/license group by design; a README that hand-places a ## License or ## Supporting and citing section above Contributing must move it below to conform, rather than this order being flipped (flipping it would fail every fresh scaffold, whose managed block renders Contributing first).

Each entry is a tuple of accepted ##-heading texts (case-insensitive, substring match), and the check passes if any variant is present; the H1 title and the badge block (between the markers, refreshed by update) precede these and are checked separately. A package may title the equivalent section differently (e.g. "Getting started" vs "Usage"), so a tuple lists the accepted alternatives. Extend or relax it per package via the required keyword of test_readme_sections.

source
EpiAwarePackageTools.ad_backend_support_table Function
julia
ad_backend_support_table(reg; scenario_kwargs) -> String
julia
ad_backend_support_table(reg; scenario_kwargs = (;)) -> String

Render a Markdown table summarising, per registry backend, how many of the registry's scenarios it supports and which are declared broken or skipped.

One row per backends(reg) entry, with the scenario coverage count (supported/total) and the sorted names from the optional bookkeeping accessors (broken_scenario_names, backend_broken_scenarios, backend_skip_scenarios; a missing accessor means none — see ADRegistry). Call it from a package's own docs page to publish the table: reading the registry at docs-build time keeps the broken-scenario declarations in one place, so the published table cannot drift from what the gradient tests mark broken. No managed page calls it — the kit reports per-backend coverage in the README badge row instead.

scenario_kwargs is forwarded to the registry's scenarios call, as in test_working_backend.

source
EpiAwarePackageTools.DocsBuild.ad_benchmark_note Function
julia
ad_benchmark_note(r::ADBenchmarkResults) -> String

Markdown prose describing what r is missing, or "" when it is complete.

The page renders this above its tables so a gap is stated rather than showing as a silently short table. Two things get their own sentence: no gradient measurements at all, and backends the published run carried no numbers for.

Plain paragraphs rather than an admonition, because the page emits this through Literate as captured text/markdown output.

source
EpiAwarePackageTools.DocsBuild.build_docs Function
julia
build_docs(mod; repo, authors, pages, deploy_url=nothing,
           skip_notebooks=false, tutorials_subdir, light_tutorials=[],
           heavy_tutorials=[], tutorial_stubs=[], force_stub_tutorials=[],
           tutorial_environments=[], heavy_tutorial_workers=1,
           heavy_benchmarks=[], benchmark_stubs=[],
           ad_benchmark_results=nothing,
           linkcheck_ignore=[], index_rewrites=[], readme_execute=true,
           index_strip_sections=[], benchmark_page=true,
           history_suites=[], history_commits=5,
           history_regression_threshold=1.1, extra_modules=[],
           extra_remotes=Dict(), build_vitepress=true, deploy=true)

Run the standard EpiAware documentation build for package module mod. All paths derive from pkgdir(mod), so the managed docs/make.jl only forwards the package-owned config. Generates the home page, release notes, benchmark page and API pages, processes the Literate tutorials, then renders with DocumenterVitepress and (when deploy) deploys. The release-notes page is fetched from repo's GitHub releases at build time and degrades to a link when they cannot be read (see build_release_notes).

Under skip_notebooks the light tutorials still render in-process and only the heavy ones fall back to tutorial_stubs headings. Independent of that, any heavy_tutorials entry named in force_stub_tutorials never executes: for one with a problem of its own (e.g. a model that does not terminate), so it need not block its siblings.

tutorial_environments is a list of "file.jl" => "environment/dir" pairs naming heavy tutorials that resolve against their own environment rather than the shared docs/ one, for a dependency that cannot co-resolve with the rest of the docs environment. The directory is relative to docs/ unless absolute, is package-owned (the kit never writes it, as it never writes docs/Project.toml), and must declare Literate alongside the tutorial's own dependencies. It is instantiated before the tutorial runs; a missing, Literate-less or unresolvable environment fails the build rather than quietly stubbing the page. Every tutorial not named here is built against docs/ exactly as before.

heavy_tutorial_workers runs that many heavy tutorials concurrently, each still in its own subprocess. It defaults to 1, one after another as before: memory rather than cores is what bounds a sampling tutorial, so raising it is a judgement about one package's own tutorials. The thread budget is divided rather than multiplied — each worker is launched with JULIA_NUM_THREADS ÷ workers threads (at least one), so the total stays what a serial build already asked for. Under it, each tutorial's output is captured and printed as one block when it finishes rather than interleaved, and every failure is reported by name at the end rather than the first masking the rest.

heavy_benchmarks/benchmark_stubs drive the same pipeline again over src/benchmarks/, so a benchmark report renders under its own top-level "Benchmarks" nav group rather than under Tutorials.

ad_benchmark_results names where the scaffolded AD-comparison page's gradient numbers are, so it renders what the package's benchmark run already measured rather than measuring every (backend, scenario) pair again during the build. It is a results file or a directory of them; a relative path resolves against docs/, and the AD_BENCHMARK_RESULTS environment variable overrides it so CI can name a checkout without the package editing its config. See ad_benchmark_results_path and load_ad_benchmarks.

It defaults to nothing, which falls back to the results the package's benchmark run deploys to its benchmarks branch (see published_ad_benchmark_results), and leaves the page measuring live only where that branch carries no gradient numbers.

deploy=false builds without deploying and build_vitepress=false runs Documenter without the final npm pass; both are used by tests and fast local builds. On the benchmark page, history_suites (when non-empty) restricts the summary and detail to the named headline suites, history_commits caps both to that many most-recent revisions, and history_regression_threshold sets the regression-flag cutoff (see _embed_benchmark_history).

The owning modules of mod's re-exported docstrings are auto-discovered (see api_owning_modules) and folded into Documenter's modules so those @docs blocks resolve (#175); extra_modules adds any owner auto-discovery cannot reach. Documenter drives its missing-docstring completeness check off the same list, with no way to scope one without the other, so widening the resolution set disables the check — a package is never held responsible for a dependency's docstring hygiene, and mod's own completeness is guaranteed by build_api_pages rendering every docstring it owns (#313).

Each owning module also needs a source remote, which Documenter cannot derive for a dependency installed from a git URL (#190). api_remotes derives one from the recorded git source URL; extra_remotes supplies the rest, e.g. Dict(SomeDep => "EpiAware/SomeDep.jl").

source
EpiAwarePackageTools.check_broken Function
julia
check_broken(scenarios_list, backend; rtol, atol)
julia
check_broken(scenarios_list, backend; rtol = 5e-2, atol = 1e-6)

Run each scenario through plain DifferentiationInterface.gradient and record whether it matches its reference.

A scenario passes (@test true) when the gradient is a finite vector matching scen.res1 within tolerance, and is marked @test_broken otherwise. This lets a partial backend record the coverage it does have without an all-or-nothing result. DifferentiationInterface must be loaded by the caller.

source
EpiAwarePackageTools.dynamicppl_model_filter Function
julia
dynamicppl_model_filter(report) -> Any
julia
dynamicppl_model_filter(report) -> Bool

A report_filter for test_jet that drops reports arising inside a DynamicPPL @model-generated method (matched on the model-evaluator signature (::Model, ::AbstractVarInfo, ...)), and keeps every other report.

Use this for a Turing/DynamicPPL package whose public surface is @model functions: test_jet(MyPkg; report_filter = dynamicppl_model_filter). Without it, JET reports a false UndefVarErrorReport for every ~-assigned variable (and MethodErrorReports through the := tracker), none of which is a real defect.

source
EpiAwarePackageTools.DocsBuild.load_ad_benchmarks Function
julia
load_ad_benchmarks(source, expected_backends; group = "AD gradients")
    -> ADBenchmarkResults

Read the AD gradient measurements published by the package's benchmark run.

source is a benchmark results JSON file, or a directory holding one or more of them, in which case a latest.json wins and otherwise the most recently written file does. group is the benchmark group the gradient benchmarks live under, the same convention EpiAwarePackageTools.Benchmarks.compare_comment folds into its AD matrix, so one suite definition feeds both the pull request comment and this page.

expected_backends is the registry's backend labels, in the order the page wants them. It fixes the row order and is what "missing" is measured against, so a benchmark's last key segment must be the registry label exactly.

Each row's time is the minimum of the benchmark's samples, the estimator DifferentiationInterfaceTest.benchmark_differentiation reports, so a page rendering these numbers states the same statistic as one measuring its own.

Nothing here is fatal. A missing file, one that is not valid JSON, and a suite with no gradient group all produce an ADBenchmarkResults describing what was found; the page renders that and reports the gap in prose. Measurements for a backend outside expected_backends are kept and listed after the expected ones.

source
EpiAwarePackageTools.on_surface_ambiguities Function
julia
on_surface_ambiguities(
    mod::Module,
    extname::Symbol;
    prefixes
) -> Vector{Tuple{Method, Method}}
julia
on_surface_ambiguities(mod, extname; prefixes = (string(nameof(mod)),))

The ambiguous method pairs over (mod, ext) that mod or its extension owns: both methods live in a module whose name starts with one of prefixes. This drops pairs owned by an unrelated third party (e.g. a ::Num overload from a Symbolics integration that collides with every concrete f(::Dist, ::Real)), keeping only ambiguities the package or its extension actually introduces.

prefixes defaults to the package name (which also covers its extensions, since extension modules are named <Package>...Ext); pass extra prefixes for trigger packages whose methods participate in a legitimate pair (e.g. ("MyPkg", "Distributions")).

source
EpiAwarePackageTools.raw_ambiguity_count Function
julia
raw_ambiguity_count(mod::Module, extname::Symbol) -> Int64
julia
raw_ambiguity_count(mod, extname)

Total (unfiltered) method-ambiguity count over (mod, ext), where ext is the loaded extension named extname. Useful as a sanity check that third-party phantom ambiguities are present before test_ext_ambiguities filters them out (so the on-surface filter is doing real work, not trivially empty).

source
EpiAwarePackageTools.run_selected Function
julia
run_selected(
    reg;
    backends,
    scenarios,
    rtol,
    atol,
    scenario_kwargs,
    verbose
) -> Vector{NamedTuple}
julia
run_selected(reg; backends = String[], scenarios = String[],
    rtol = 5e-2, atol = 1e-6, scenario_kwargs = (;), verbose = true)

Run a named subset of scenarios against a named subset of backends, for fast diagnosis of a single scenario/backend combination outside the full per-backend suite.

backends and scenarios are repeatable, case-insensitive substring filters against backends(reg)/scenarios(reg) names; an empty filter (the default) selects everything. Errors when a filter matches nothing, so a typo'd name fails loudly rather than silently running zero cases.

For each selected (scenario, backend) pair already declared in backend_skip_scenarios(reg) for that backend, records :skipped without attempting the call — those skips exist for combinations that can crash the process rather than merely throw, so a diagnostic tool must not attempt them either. Otherwise calls DifferentiationInterface.gradient on scen.f, the backend, scen.x, and scen.contexts... (the same call check_broken makes) inside a try/catch, classifying the result :pass when the gradient is a finite vector matching scen.res1 within tolerance, :mismatch when it is not, and :error on an exception.

Returns a Vector{<:NamedTuple} of (scenario, backend, status, detail) so a caller can assert on results directly; the padded PASS/MISMATCH/ ERROR/SKIPPED table is only printed when verbose = true.

scenario_kwargs is forwarded to the registry's scenarios call, as in test_working_backend. DifferentiationInterface must be loaded by the caller.

source
EpiAwarePackageTools.scaffold Function
julia
scaffold(
    target_dir::AbstractString;
    force,
    ad,
    benchmarks,
    downgrade_compat,
    unregistered_sources,
    freshen_reusable_refs,
    ref_source,
    kwargs...
) -> @NamedTuple{created::Vector{String}, updated::Vector{String}, preserved::Vector{String}, removed::Vector{String}, readme::Symbol, license::Symbol, workspace::Symbol, gitignore::Symbol, git_blame_ignore::Symbol, agents::Symbol, logo::Symbol, standard_sections::Symbol, citation::Symbol, org_branding::Symbol, extension_pages::@NamedTuple{created::Vector{String}, preserved::Vector{String}}, pages::Symbol, warnings::Vector{String}}
julia
scaffold(target_dir; force = false, ad = true, benchmarks = nothing,
    freshen_reusable_refs = false, kwargs...)

Adopt the standard EpiAware package tooling in target_dir (a package root).

Writes the shipped standard configuration and test infrastructure so a package adopts the whole kit in one call. Two kinds of file are written:

  • managed standard infra — always written (overwriting any existing copy): root dev config (Taskfile.yml, .pre-commit-config.yaml, .gitattributes, .secrets.baseline, codecov.yml), CI caller workflows + .github/dependabot.yml (which invoke the org reusables, including the opt-in per-backend ad.yaml matrix), and the test-infra drivers and isolated-env manifests (test/package/quality.jl, test/jet/runtests.jl + test/jet/Project.toml, test/formatter/runtests.jl + test/formatter/Project.toml, test/ad/setup.jl, test/ad/runtests.jl, benchmark/run.jl, benchmark/compare.jl).

  • package-owned skeletons — written only when absent, never overwritten: test/runtests.jl, test/Project.toml (the test env), test/package/ qa_config.jl (the QA config values the managed testset reads), LICENSE (the license-selected licence text — see below), docs/src/assets/logo.svg (a placeholder logo — see the logo return value below), test/ad/scenarios.jl + test/ad/Project.toml, an ADFixtures registry skeleton implementing the ADRegistry contract (test/ADFixtures/Project.toml + src/ADFixtures.jl), benchmark/benchmarks.jl (the SUITE), and CITATION.cff (the citation metadata the managed "How to cite" README section points at — see the citation return value below). These are where a package's own unit tests, AD scenarios, registry, citation, and config values live.

Placeholders (, , , , , , ) are filled by scaffold_inputs: each defaults from the target Project.toml or a sensible org default and is overridable by keyword (e.g. scaffold(dir; org = "MyOrg")). No person, org, or repo name is hardcoded in any template.

LICENSE is package-owned and write-once: the license keyword (one of MIT, Apache-2.0, GPL-2.0-or-later, default "MIT") selects the bundled licence text, written with / filled only when the repo carries no licence file yet under any of its conventional names (LICENSE, LICENCE, COPYING, with or without a .md/.txt suffix). update never rewrites it, so a package that deliberately changes its licence is not reverted on a sync.

The managed .github/workflows/Register.yml triggers General Registry registration from a /register comment or a workflow_dispatch run, gated on the actor having write access. See setup_checklist for the rest of the one-off manual setup a fresh repo needs.

ad controls whether the AD CI caller and AD test infrastructure are scaffolded, so a numerical package opts in and a tooling package opts out. It defaults to true. When ad = true one managed docs page is written, the AD-comparison report under docs/src/benchmarks/, which benchmarks what each backend costs and carries the choosing/debugging narrative. Its body stays kit-current across syncs, while the scenarios, backends and broken/skip declarations it reports are read at docs-build time from the package-owned test/ADFixtures registry, and its registration plus docs-env deps are seeded into the package-owned docs seeds. When ad = false none of the AD infra is written, and the files whose content depends on AD (Taskfile.yml, codecov.yml, test/Project.toml, the docs seeds) are emitted in their no-AD variants. Pass the same ad value to update.

benchmarks controls the opt-in benchmark suite: the benchmark CI callers, the benchmark/ suite + compare script, and the docs performance-over-time page (docs/src/benchmarks/over-time.md) with its nav entry and the two package-owned prose hooks. It defaults to nothing, which detects the target's current state from the benchmark workflows so re-scaffolding preserves an opt-in; a fresh package has none, so the default is opt-out. update detects and preserves the state.

downgrade_compat controls the opt-in downgrade-compat CI job in test.yaml, which resolves the oldest compatible dep versions. A package pinned to a Julia floor, or one depending on an unregistered [sources]-pinned package the downgrade resolver cannot see, can never pass it. It defaults to nothing, detecting the state from the committed test.yaml so a resync preserves the choice; a fresh package keeps the job. The julia_versions inputs are separately preserved as a package-owned with: override (#121, see _preserve_caller_with_inputs).

unregistered_sources declares that the package pins a dependency of its own by git [sources] — an ecosystem sibling awaiting registration — and so is held to the Julia 1.11.0 floor, because [sources] is silently ignored before then. It defaults to nothing, detecting the state from the committed environments (any [sources] entry with a url, other than one on the kit itself), so a resync preserves an adopter's position rather than resetting it. On (or detected), a generated package is seeded at julia = "1.11, 1.12" and a [compat]/CI matrix reaching below the floor is warned about; off, neither happens and the package is free to support 1.10, 1.11, 1.12. The kit itself no longer needs the floor: it is registered, so managed environments bound it in [compat] (#361, #410). The tests.yml matrix drops its lts leg either way — the managed test environment depends on the kit, whose own compat starts at 1.11.0, so that leg cannot resolve regardless of what the package declares.

The README body is package-owned, but the standard badge set is managed: a block between <!-- badges:start --> / <!-- badges:end --> markers carries the docs/CI/coverage/ quality/license badges (plus per-backend AD CI + coverage badges when ad = true), parameterised from / (no owner/repo hardcoded). The block is injected after the README's # title when the markers are absent and refreshed in place when present; nothing outside the markers is touched. A missing README is created with a title and the block.

.gitignore follows the same managed-block pattern: the standard ignore rules live between # managed:start / # managed:end markers and are (re)rendered on every scaffold/update, but anything after the end marker is a package-owned tail that is never touched — add your own ignore rules there. A pre-existing .gitignore with no markers (e.g. one written by a kit version before this behaviour existed) is treated the same way a legacy README is: the managed block is inserted at the top and the whole existing file is kept below as the tail, so nothing a package added is ever silently dropped.

.git-blame-ignore-revs follows the same managed-block pattern between # managed:start / # managed:end, but only the explanatory header is managed: the SHA list below the closing marker is package-owned, one entry per repo's own formatting-only reformat commit (e.g. the Runic migration's style: commit), so it is never rendered or touched by scaffold/update.

AGENTS.md works the same way. The managed block between <!-- epiaware-standards:start MANAGED by EpiAwarePackageTools.scaffold --> and <!-- epiaware-standards:end --> points at the human-facing docs rather than restating them, and CLAUDE.md points at AGENTS.md. Both files reach an agent's context in full on every session, so the block spends one word on saying it is managed and leaves the rest to the infrastructure docs page. Package-specific notes go after the end marker and survive every sync.

docs_subdomain selects how the docs site is hosted. The default (nothing) is a project-pages deploy: deploy_url = nothing, so DocumenterVitepress derives the base from the repo name and the site renders at epiaware.org/<Repo>.jl/ with no DNS to wire. Pass docs_subdomain = true for the conventional <pkg>.epiaware.org, or a host string for a bespoke domain, which also needs a DNS record and the repo's Pages custom domain set; until both exist the site will not resolve. With no explicit choice the hosting is recovered from the repo's existing deploy_url, so update preserves a subdomain-hosted package and self-heals a drifted one (#123). Only a never-scaffolded target falls back to the default, and the kit itself dogfoods the opt-in path.

The three managed README sections (Contributing, How to cite, Code of conduct) follow the same managed-block pattern between <!-- standard-sections:start --> / <!-- standard-sections:end -->: appended to a freshly seeded README and refreshed in place thereafter. A marker-less README that already carries bespoke prose for one of them is left untouched (#67). CITATION.cff is package-owned and write-once, seeded when absent by both scaffold and update and never rewritten, so the real author list and DOI stand (#322).

docs/pages.jl (the docs nav tree) is a MANAGED base, not a package-owned skeleton: it is regenerated in full on every scaffold/update, owning group labels, ordering and placement, with four optional extension points a package fills in via docs/docs_config.jl (PACKAGE_TUTORIALS, PACKAGE_SECTIONS, DEVELOPMENT_EXTEND_PAGE, GETTING_STARTED_FAQ) rather than editing the generated file (#170/#328/#354). The one exception in the whole kit: unlike every other managed file, this is never reset by force either. A committed file is only ever regenerated when it already carries _MANAGED_PAGES_MARKER in its header (what a kit-generated file always has) or does not exist yet; otherwise — a bespoke, forked pages.jl from before this redesign — it is preserved untouched and a warning names which of its existing top-level nav groups the generated base would not reproduce, with the PACKAGE_SECTIONS snippet to carry them across. See update for the same rule on a resync.

The CI callers are seeded from _REUSABLE_SEED_REFS, each the newest commit touching the shared workflow that caller wraps when the seeds were last refreshed. freshen_reusable_refs = true resolves them live instead, over the GitHub API; see update, which is where it earns its keep.

force = true overwrites the package-owned skeletons too, and lays every managed file down fresh regardless of any EPIAWARE_MANAGED_OVERRIDE marker (see update), so a new package always starts fully managed. docs/pages.jl is the one file force does not reset — see above. target_dir must exist. Use update to re-apply only the managed files later.

Returns a (created, updated, preserved, removed, readme, license, workspace, gitignore, git_blame_ignore, logo, standard_sections, citation, org_branding, extension_pages, pages, warnings) named tuple: destination paths newly written, managed files overwritten, package-owned files left in place, retired managed paths deleted (RETIRED_PATHS, #185), then the action taken by each of the region appliers (:created/:injected/:refreshed/:preserved/:skipped, as each docstring records), the seeded per-extension docs pages as a (created, preserved) pair of path vectors (#319), the docs/pages.jl action (:created/:refreshed/:unchanged/:preserved, see above), and non-fatal warnings.

source
EpiAwarePackageTools.scaffold_generate Function
julia
scaffold_generate(
    target_dir::AbstractString,
    package::AbstractString;
    authors,
    uuid,
    ad,
    benchmarks,
    unregistered_sources,
    kwargs...
) -> @NamedTuple{created::Vector{String}, updated::Vector{String}, preserved::Vector{String}, removed::Vector{String}, readme::Symbol, license::Symbol, workspace::Symbol, gitignore::Symbol, git_blame_ignore::Symbol, agents::Symbol, logo::Symbol, standard_sections::Symbol, citation::Symbol, org_branding::Symbol, extension_pages::@NamedTuple{created::Vector{String}, preserved::Vector{String}}, pages::Symbol, warnings::Vector{String}}
julia
scaffold_generate(target_dir, package; authors = String[], uuid = <fresh>,
    ad = true, benchmarks = false, unregistered_sources = false, kwargs...)

Generate a fresh package at target_dir and adopt the standard tooling.

Creates the target directory if needed, writes a minimal package skeleton (a Project.toml naming package with a fresh UUID, and a src/<package>.jl module stub), then runs scaffold over it so the new package starts fully managed. Unlike scaffold, which adopts the tooling into an existing package, this also lays down the package's own Project.toml and source module, so it works from an empty or non-existent directory.

  • package — the package name (no .jl suffix).

  • authors — author entries (a Vector{String}); written to the new Project.toml and used for / substitution.

  • uuid — the package UUID; a fresh uuid4() by default.

  • ad — forwarded to scaffold: true (default) scaffolds the AD infra, false opts out. See scaffold for the full AD-opt-in behaviour.

  • benchmarks — forwarded to scaffold: opt into the benchmark CI + suite + docs page. A fresh package has no benchmark workflows to detect, so this defaults to false (opt-out); pass benchmarks = true to enable.

  • unregistered_sources — forwarded to scaffold, and also seeds the new Project.toml's [compat] julia: julia = "1.10, 1.11, 1.12" by default, or julia = "1.11, 1.12" when true, because [sources] is silently ignored below 1.11.0 (#410). A fresh package has no environments to detect a pin from, so this defaults to false rather than nothing.

Remaining keyword arguments (org, repo, reviewer, year, license, ...) are forwarded to scaffold_inputs; e.g. license = "Apache-2.0" writes the Apache licence. Returns the scaffold manifest.

source
EpiAwarePackageTools.scaffold_inputs Function
julia
scaffold_inputs(
    target_dir::AbstractString;
    package,
    authors,
    holder,
    org,
    repo,
    reviewer,
    year,
    license,
    docs_subdomain,
    doi,
    zenodo_badge,
    docs_timeout,
    ad_timeout
) -> NamedTuple{(:PACKAGE, :UUID, :ADFIXTURES_UUID, :AUTHORS, :HOLDER, :ORG, :REPO, :REVIEWER, :YEAR, :LICENSE, :DOCS_DEPLOY_URL, :DOCS_URL, :DOCS_TIMEOUT_WITH, :AD_TIMEOUT_LINE, :DOI, :ZENODO_BADGE, :TUTORIALS_SUBDIR, :AD_BUILD_COUNT, :AD_CODECOV_FLAGS, :AD_BACKENDS_JSON, :AD_BACKEND_PACKAGES, :AD_BACKEND_ENTRIES, :AD_SCENARIO_TESTITEMS, :CODEOWNERS_LINE, :DEPENDABOT_REVIEWERS, :ASSIGNEE_DEFAULT, :KIT_DEP_LINE, :KIT_COMPAT_LINE, :KIT_COMPAT_SECTION, :SYNC_INSTALL, :RUNIC_VERSION, :RUNIC_PRE_COMMIT_REV, :JULIA_TEST_VERSIONS, :LOGO_INITIAL), <:Tuple{Union{Nothing, SubString{String}}, Union{Nothing, SubString{String}}, String, Union{Nothing, String}, Union{Nothing, String}, String, Union{Nothing, String}, String, String, String, String, Union{Nothing, String}, String, String, Union{Nothing, String}, Union{Nothing, String}, String, String, Union{Base.AnnotatedString{String}, String}, String, String, String, String, String, String, Union{Nothing, String}, Vararg{String, 8}}}
julia
scaffold_inputs(target_dir; package = nothing, authors = nothing,
    holder = nothing, org = "EpiAware", repo = nothing,
    reviewer = nothing, year = <current year>,
    license = nothing) -> NamedTuple

Resolve the placeholder substitution values for scaffold / update.

Every value defaults from the target Project.toml (or a sensible org default) and is overridable by keyword, so no person, org, or repository name is baked into a template:

  • package — the package name (); default the Project.toml name. The package UUID () is read from Project.toml uuid.

  • authors; default the joined Project.toml authors.

  • holder — copyright holder (); default authors.

  • org — GitHub org (); default "EpiAware". Names the package's own repo: badges, docs links, the repo slug, and the Code of Conduct link. Pass the owning org for a package hosted elsewhere; the managed template-sync.yaml carries it into the scheduled update so it survives a resync. The reusable workflows the CI callers uses: are not among them: those always come from "EpiAware"/.github.

  • repoowner/name slug (); default "{org}/{package}.jl".

  • reviewer — the GitHub handle () that drives every place a real reviewer/code-owner is needed: the .github/CODEOWNERS rule (* @), the Dependabot reviewers, the version-bump assignee, and the Claude bot's actor gate. A username or org/team slug — GitHub cannot assign a bare org. When omitted (nothing), no owner is written (CODEOWNERS ships a commented placeholder, Dependabot gets no reviewers) so a bare org is never hardcoded.

  • year — copyright year (); default the current year.

  • license — the SPDX licence identifier selecting which README badge is rendered and which bundled text a new LICENSE gets. One of MIT, Apache-2.0, GPL-2.0-or-later. Default nothing, in which case the licence the repo declares is recovered and kept, falling back to "MIT" when it declares none. A Project.toml license field outside the supported set is an error rather than a relabelling, so a sync cannot badge a package with a licence it did not choose. The LICENSE text itself is written once and never overwritten by update.

  • doi / zenodo_badge — an optional Zenodo DOI and badge id; when both are given a DOI badge is added to the README "License & DOI" cell. Both default to nothing, in which case any DOI badge already committed to the README is recovered and preserved (#161). Passing either supplies or overrides it.

  • docs_timeout — an optional docs-build job timeout in minutes for the managed document.yaml caller. Default nothing, which renders no with: block so the reusable's own default (45 min) applies. A package-owned with: block hand-added to document.yaml survives a resync (see _preserve_caller_with_inputs).

  • ad_timeout — an optional per-backend job timeout in minutes for the managed ad.yaml caller. Default nothing, which passes no timeout_minutes so the reusable's own cap applies. Set it when a package's slowest AD backend runs close to that cap. The value written into the caller's with: block survives a later resync that does not re-pass it (see _preserve_caller_with_inputs). The reusable ref the caller is pinned to must declare a timeout_minutes input.

Returns a NamedTuple of placeholder => value pairs (plus LICENSE, the resolved SPDX identifier).

source
EpiAwarePackageTools.scaffold_update Function
julia
scaffold_update

Transitional alias for update, which was called scaffold_update until #294, so an existing qualified caller keeps working across the rename. public like update itself, so neither can cause a Main-binding collision. New code should call update; this alias is removed in a future cleanup once adopters have moved off it.

source
EpiAwarePackageTools.setup_checklist Function
julia
setup_checklist(; ...)
setup_checklist(
    target_dir::AbstractString;
    package,
    repo,
    org,
    io
)
julia
setup_checklist(target_dir = "."; package = nothing, repo = nothing,
    org = "EpiAware", io = stdout)

Print the manual setup steps left after scaffold/scaffold_generate.

scaffold writes every file-based standard, but a handful of one-off steps need a human with dashboard access and no file-writer can do them for us: enabling Codecov and adding its CODECOV_TOKEN secret, wiring a docs custom domain (when one was chosen), enabling GitHub Pages, adding the DOCUMENTER_KEY deploy key that lets TagBot push release tags over SSH, protecting main, and running the first Julia General Registry registration (via the managed Register.yml workflow — see its docstring in scaffold). This prints that checklist, followed by a ready-to-paste tracking-issue body.

package/repo/org resolve exactly as in scaffold_inputs (defaulting from target_dir's Project.toml), so the checklist reads naturally for the target package with no arguments in the common case (setup_checklist() from the package root).

This prints only: it never shells out to the gh CLI or calls the GitHub API, so it has no extra dependency and works offline. The suggested issue body is meant to be copied into a new issue by hand, or piped straight through, e.g. gh issue create --body-file -, if the gh CLI happens to be installed — setup_checklist itself makes no such assumption.

Returns nothing; everything is written to io.

Example

julia
setup_checklist()
source
EpiAwarePackageTools.test_aqua Function
julia
test_aqua(
    mod::Module;
    ambiguities,
    unbound_args,
    undefined_exports,
    project_extras,
    stale_deps,
    deps_compat,
    undocumented_names,
    piracies
) -> Test.DefaultTestSet
julia
test_aqua(mod; kwargs...)

Run the standard Aqua.jl quality suite over mod.

Wraps the individual Aqua.test_* checks (unbound args, undefined exports, project extras, stale deps, deps compat, undocumented names, piracies, ambiguities) in one @testset. Keyword arguments forward to each check that accepts them, so a package can relax a single check without re-listing the rest (e.g. test_aqua(MyPkg; ambiguities = false) to skip the ambiguity check).

stale_deps also accepts a NamedTuple of keywords forwarded to Aqua.test_stale_deps (e.g. stale_deps = (; ignore = [:LinearAlgebra])), so a package that deliberately keeps a dependency ahead of using it (#217) can allow just that one rather than disabling the whole check with false.

Aqua must be a dependency of the calling test environment.

source
EpiAwarePackageTools.test_docstring_format Function
julia
test_docstring_format(
    mod::Module;
    exported_only_examples,
    require_field_docs,
    require_arg_sections,
    require_examples,
    crossref_ignore
) -> Test.DefaultTestSet
julia
test_docstring_format(mod; exported_only_examples = true,
    require_field_docs = true, crossref_ignore = ())

Check the docstrings of every exported and public symbol in mod against the EpiAware docstring conventions.

For each documented symbol with a meaningful docstring the checks are:

  • structs document each field name somewhere in the docstring (when require_field_docs);

  • functions with positional arguments include an # Arguments section, and functions with keyword arguments include a # Keyword Arguments section (both skipped when require_arg_sections = false, for a package whose API docs are reference-style rather than sectioned);

  • exported (and public) functions include an @example block (skipped when exported_only_examples is false, which requires examples of every function instead; set require_examples = false to drop the @example requirement entirely, e.g. for a tooling package whose helpers need external fixtures to exemplify);

  • the docstring carries either a TYPEDSIGNATURES directive or the symbol's own name (i.e. a signature is shown);

  • [name](@ref) cross-references resolve to another exported/public symbol; names in crossref_ignore (a tuple of Symbols for upstream names a package legitimately links to, e.g. :pdf, :cdf) are allowed.

Symbols without a docstring are skipped here (leave existence to Aqua's undocumented_names check). The cross-reference check warns rather than fails, matching the original package-level check.

The documentation standard this check enforces is Package standards.

source
EpiAwarePackageTools.test_doctest Function
julia
test_doctest(mod::Module) -> Test.DefaultTestSet
julia
test_doctest(mod)

Run Documenter's doctest over mod.

A thin wrapper that runs the package doctests in one @testset. Documenter must be a dependency of the calling test environment.

Documenter.doctest evaluates each docs page's @meta CurrentModule = <mod> block against Main. Under TestItemRunner the @testitem body runs in a sandbox module, so Main has no binding for the package and every @meta block fails with UndefVarError: <mod> not defined in Main. To make the standard @meta CurrentModule idiom work under TestItemRunner, the module is bound into Main (under its own name) before doctesting, so the @meta blocks resolve.

source
EpiAwarePackageTools.test_explicit_imports Function
julia
test_explicit_imports(
    mod::Module;
    ignore,
    implicit_ignore
) -> Test.DefaultTestSet
julia
test_explicit_imports(mod; ignore = (), implicit_ignore = ignore)

Run the ExplicitImports.jl conformance checks over mod.

Asserts there are no stale explicit imports, no implicit imports, that every explicit import is public in its source module, and that imports come from their owning module.

  • ignore — a tuple of Symbols for unavoidable non-public explicit imports (e.g. an upstream internal used by an extension); forwarded to check_all_explicit_imports_are_public.

  • implicit_ignore — a tuple of names that are legitimately implicit and must not fail check_no_implicit_imports; defaults to ignore. The common case is a @reexport using SomePkg, which makes the bare module name SomePkg an implicit import that no amount of explicit listing removes — pass implicit_ignore = (:SomePkg,) so a reexporting package conforms.

Package extensions are handled automatically: an extension imports its parent's (and its trigger's) internals by design, and ExplicitImports walks an extension only when it is loaded, so the verdict used to flip with extension-load order (#189). The names a loaded extension imports are folded into every check's ignore here, so the verdict is independent of whether extensions are loaded and adopters no longer need to enumerate their extensions' import lists by hand.

ExplicitImports must be a dependency of the calling test environment.

source
EpiAwarePackageTools.test_ext_ambiguities Function
julia
test_ext_ambiguities(
    mod::Module,
    extname::Symbol;
    prefixes,
    expect_phantoms,
    broken
) -> Test.DefaultTestSet
julia
test_ext_ambiguities(mod, extname; prefixes = (string(nameof(mod)),),
    expect_phantoms = false, broken = false)

Assert the loaded extension extname of mod introduces no method ambiguity on the package's own surface.

Aqua.test_ambiguities runs in a subprocess with no extensions loaded, so it never sees an extension's method table; this check loads in-process and filters to ambiguities mod or its extension owns (see on_surface_ambiguities). The caller is responsible for import-ing the extension's trigger package(s) before calling so the extension is loaded.

  • prefixes is the set of allowed surface module-name prefixes (see on_surface_ambiguities).

  • expect_phantoms = true additionally asserts the raw count is positive, proving third-party phantom pairs exist and the on-surface filter is doing real work (use for an extension pulling in e.g. a Symbolics integration).

  • broken = true records the no-ambiguity assertion as @test_broken, for quarantining a known, issue-tracked extension-only ambiguity without silencing it; the test flips green when the bug is fixed.

source
EpiAwarePackageTools.test_formatting Function
julia
test_formatting(dirs; verbose, env) -> Test.DefaultTestSet
julia
test_formatting(dirs; verbose = true)
test_formatting(mod; ...)

Check that the given source trees are Runic-clean.

dirs is a collection of directory paths; non-existent entries are skipped, and each existing directory is checked without modification. Passing a Module defaults to checking the src, test, docs, benchmark, and ext directories of the package that owns mod. Runic is unconfigurable — there is one canonical style, so no style argument and no per-package config file.

The test passes when every directory is already formatted. Runic must be a dependency of the calling environment; to keep its JuliaSyntax pin from clashing with JET, run this from an isolated formatter environment (see the templates/Taskfile.yml test-formatting target).

Pass env (the path to an isolated formatter project directory holding Runic) to run the check in a subprocess via that project's runtests.jl, exactly as test_jet isolates JET. The test then passes when the subprocess exits zero, and Runic need not be a dependency of the calling environment — the recommended layout when the test items share an environment with JET. verbose/dirs are ignored in env mode (the isolated runtests.jl owns that configuration).

The formatting standard is in Package standards.

source
EpiAwarePackageTools.test_import_centralisation Function
julia
test_import_centralisation(
    mod::Module
) -> Union{Nothing, Test.DefaultTestSet}
julia
test_import_centralisation(mod::Module)

Assert every genuine using/import in mod's package sits in the top-level module file, not scattered across included source files (kit issue #105).

Walks every .jl file under mod's package src/ directory (as located via pathof(mod)) and parses it looking for a using/import that shares the file's own top-level scope — exactly the scope an included file's statements run in once spliced into the parent module. The main module file itself is exempt (that is precisely where imports should live). A nested module/baremodule block defined inside an included file (e.g. a Benchmarks- or DocsBuild-style helper submodule) starts its own fresh scope, so its own top-level using/import is exempt too.

Lazy, call-time dependency loads (_require_pkg(...), Base.require(...) inside a function body) are ordinary function calls, not using/import syntax — and Julia disallows using/import inside a function entirely — so they never trigger this check.

source
EpiAwarePackageTools.test_jet Function
julia
test_jet(
    mod::Module;
    target_modules,
    env,
    skip_experimental,
    report_filter
) -> Union{Nothing, Test.DefaultTestSet}
julia
test_jet(mod; target_modules = (mod,), env = nothing,
    skip_experimental = true, report_filter = nothing)

Run JET over mod.

JET is run in an isolated environment to keep its JuliaSyntax / dependency pins from clashing with the rest of the test environment. Pass env as the path to a project directory holding JET plus the package; that project's runtests.jl is run in a subprocess and the test passes if it exits zero. When env is nothing JET is loaded into the current environment and run directly (simpler, but only safe when JET coexists with the test deps).

report_filter is an optional predicate report -> Bool: when supplied, JET is run via report_package and the test asserts that no report for which the predicate returns true survives (a report is kept when the predicate returns true). This lets a package suppress known false positives without silencing the whole check. For a DynamicPPL @model package, pass dynamicppl_model_filter, which drops the macro's spurious ~/:= reports. When report_filter is nothing (default), JET runs via test_package and fails on any report. report_filter is ignored in env mode (the isolated runtests.jl owns that configuration).

By default JET is skipped on experimental / pre-release Julia (and when JULIA_CI_EXPERIMENTAL=true), where JET often lags the compiler.

source
EpiAwarePackageTools.test_linting Function
julia
test_linting(
    mod::Module;
    kwargs...
) -> Union{Nothing, Test.DefaultTestSet}
julia
test_linting(mod; kwargs...)

Run JET static analysis (code linting) over mod.

An alias for test_jet under the "linting" name used by the standard package test layout; all keyword arguments forward unchanged. Prefer running JET in an isolated environment via env = joinpath(@__DIR__, "jet") to keep JET's dependency pins from clashing with the rest of the test environment.

source
EpiAwarePackageTools.test_option_validation Function
julia
test_option_validation(
    f,
    valid;
    n,
    rng
) -> Test.DefaultTestSet
julia
test_option_validation(f, valid; n = 50, rng = Random.default_rng())

Fuzz f's eager validation of a named option.

Calls f(bad) with n random names outside valid and asserts each call throws, with an error message naming the rejected value and listing every entry of valid (the convention scaffold's own licence check follows) — so a caller who mistypes an option name gets an immediate, self-explaining failure rather than a value silently ignored and the mistake surfacing later, far from its cause.

f is any single-argument callable performing the validation itself (and throwing on rejection). A function that accepts a whole bag of named options (keyword arguments, a scenario/backend registry, a set of sweep axes) is exercised by wrapping it so one bad key reaches it, e.g. test_option_validation(k -> configure(; Dict(k => true)...), VALID_KEYS).

valid is the collection of legitimate values f accepts (Symbols or AbstractStrings); fuzzed names are drawn from the same flavour so they round-trip through f's own formatting.

julia
test_option_validation(
    lic -> EpiAwarePackageTools._validate_license(lic),
    EpiAwarePackageTools.SUPPORTED_LICENSES)
source
EpiAwarePackageTools.test_partial_backend Function
julia
test_partial_backend(
    reg,
    name::AbstractString;
    rtol,
    atol,
    scenario_kwargs
)
julia
test_partial_backend(reg, name; rtol = 5e-2, atol = 1e-6)

Test a partially-supported backend by running every scenario through [check_broken].

Each scenario the backend supports passes; the rest are marked @test_broken. Use this for a backend that cannot run the full test_differentiation sweep without crashing.

source
EpiAwarePackageTools.test_readme_bullets Function
julia
test_readme_bullets(
    path::AbstractString;
    heading,
    min_bullets,
    max_bullets
) -> Union{Nothing, Test.DefaultTestSet}
julia
test_readme_bullets(path; heading = first(STANDARD_README_SECTIONS),
    min_bullets = 3, max_bullets = 6)

Assert the README's Why section is a short list of motivation sentences.

path is a README file or the directory containing a README.md. The check reads the bullets under the Why (or Overview) heading, folding each bullet's wrapped continuation lines into it, and asserts three things (#292).

  • No bullet opens with a bold label followed by a colon (**Primary event censoring**: ...). That form is a feature inventory; the standard asks for a sentence saying why a reader needs the package.

  • The bullet count is within min_bullets:max_bullets. Too few does not justify the package; too many is a feature list again.

  • No bullet runs to more than one sentence. Detail beyond the first sentence belongs in the documentation, not the pitch.

When the README has no Why section at all the check skips: that absence is test_readme_sections' report to make, and failing here too would report one drift twice.

Keyword Arguments

  • heading: the accepted heading texts of the section to read, as a tuple matched case-insensitively by substring; default the standard set's first group (Why / Overview / Features / About).

  • min_bullets, max_bullets: the accepted bullet count range.

julia
test_readme_bullets(pkgdir(MyPackage))
source
EpiAwarePackageTools.test_readme_placeholders Function
julia
test_readme_placeholders(
    path::AbstractString;
    patterns
) -> Union{Nothing, Test.DefaultTestSet}
julia
test_readme_placeholders(path; patterns = _seed_readme_placeholders())

Assert the README at path carries no unfilled scaffold placeholder text.

path is a README file or the directory containing a README.md. A freshly scaffolded README is a skeleton with the package-specific wording left as italic placeholders (_One-line description of MyPkg._ and friends); shipping one unfilled publishes the template rather than the package, which is what this check catches.

The patterns are derived from the seeded skeleton itself rather than listed here, so adding a placeholder to the scaffold extends the check with it. A placeholder must therefore be written as an italic _..._ span to be tracked.

Keyword Arguments

  • patterns: the placeholder regexes to search for; default the ones derived from the scaffolded skeleton.
julia
test_readme_placeholders(pkgdir(MyPackage))
source
EpiAwarePackageTools.test_readme_prose Function
julia
test_readme_prose(
    path::AbstractString;
    banned,
    max_sentence_words
) -> Union{Nothing, Test.DefaultTestSet}
julia
test_readme_prose(path; banned = BANNED_README_WORDS,
    max_sentence_words = 40)

Assert the README at path reads as plain prose: no banned word, no overlong sentence.

path is a README file or the directory containing a README.md. Only prose is in scope — fenced code blocks, tables (the managed badge table included), HTML comments, inline code spans, link URLs, and bare URLs are all removed before the check runs, so a banned word inside an identifier or a URL is not a failure while the same word in a sentence is. Link text is kept: a reader reads it.

Sentence length is measured in words over the prose lines of a block (a run of lines a wrapped sentence can span), so a sentence split across three source lines is measured once, whole.

Keyword Arguments

  • banned: the words and phrases to reject; default BANNED_README_WORDS. Each is matched case-insensitively at a word boundary with any suffix allowed.

  • max_sentence_words: the longest sentence accepted, in words.

julia
test_readme_prose(pkgdir(MyPackage))
# a package whose domain term is on the default list:
test_readme_prose(pkgdir(MyPackage);
    banned = filter(!=("harness"), EpiAwarePackageTools.BANNED_README_WORDS))
source
EpiAwarePackageTools.test_readme_sections Function
julia
test_readme_sections(
    path::AbstractString;
    required,
    order,
    stale
) -> Union{Nothing, Test.DefaultTestSet}
julia
test_readme_sections(path; required = STANDARD_README_SECTIONS,
    order = true, stale = STALE_README_HEADINGS)

Assert the README at path carries the standard EpiAware section structure.

path is a README file or the directory containing a README.md. The check reads the ##-level (and deeper) headings, skipping the H1 title and any heading inside a fenced code block, then asserts each entry of required is present and (when order = true) that the present sections appear as an ordered subsequence of the headings.

Ordering is a subsequence, not an exact sequence: a package-owned section may sit anywhere, including one whose heading also matches a required group (a ## License above the managed standard-sections block does not stand in for the managed ## How to cite below it, #236). When the README carries the managed markers, the sections inside the block are additionally required to be all present and in the order the kit renders them (MANAGED_README_SECTIONS) — the block's internal order is the only section order the kit itself guarantees, since it appends the block to a README whose own sections it does not move.

required is a vector of heading groups; each group is a tuple of accepted heading texts matched case-insensitively as a substring, so a package may title the section to taste (e.g. ("Getting started", "Usage")). The default is the standard structure (STANDARD_README_SECTIONS): a Why/Overview section, a Getting started / Usage section, a Related packages section, a Documentation section, a Contributing section, and a Citing / License section. A package overrides or extends the list via its qa_config.jl (pass its own required).

A heading retired by a design change is reported as drift, naming the heading that replaced it, rather than only as a missing section (STALE_README_HEADINGS) — today the pre-#292 What packages work well with X? against ## Related packages.

The H1 title and the managed badge block are checked here too: the README must open with a single # title and contain the badge markers the scaffolder manages (see scaffold).

The README standards this check exists to hold a package to, and the ones no check covers, are listed in Package standards.

Keyword Arguments

  • required: the ordered heading groups to require; default the standard set.

  • order: when true, also assert the present sections are in order.

  • stale: Regex => replacement pairs for retired headings; default STALE_README_HEADINGS. Pass [] to skip the drift report.

julia
test_readme_sections(pkgdir(MyPackage))
# extend the standard set with a package-specific section:
test_readme_sections(pkgdir(MyPackage);
    required = vcat(EpiAwarePackageTools.STANDARD_README_SECTIONS,
        [("Benchmarks",)]))
source
EpiAwarePackageTools.test_working_backend Function
julia
test_working_backend(
    reg,
    name::AbstractString;
    rtol,
    atol,
    scenario_intact,
    scenario_kwargs
)
julia
test_working_backend(reg, name; rtol = 5e-2, atol = 1e-6,
    scenario_intact = false)

Hard-test a working backend on the scenarios it supports.

Looks up the backend named name in reg, runs DifferentiationInterfaceTest.test_differentiation (correctness only) over the scenarios not listed as globally or per-backend broken and not in the backend's skip set, then runs the broken scenarios through [check_broken] so they record as @test_broken.

scenario_intact is forwarded to test_differentiation; it defaults to false because a scenario carrying a Missing-bearing context trips DIT's default post-run equality check (comparing a missing-containing vector with == errors in a boolean context), while the gradients themselves stay correct.

scenario_kwargs is a NamedTuple of extra keyword arguments forwarded to the registry's scenarios call, e.g. a package's own scenario-group selector (scenario_kwargs = (; category = :latent)).

DifferentiationInterface and DifferentiationInterfaceTest must be loaded.

source
EpiAwarePackageTools.update Function
julia
update(
    target_dir::AbstractString;
    ad,
    benchmarks,
    downgrade_compat,
    unregistered_sources,
    freshen_reusable_refs,
    ref_source,
    kwargs...
) -> @NamedTuple{created::Vector{String}, updated::Vector{String}, preserved::Vector{String}, removed::Vector{String}, readme::Symbol, license::Symbol, workspace::Symbol, gitignore::Symbol, git_blame_ignore::Symbol, agents::Symbol, logo::Symbol, standard_sections::Symbol, citation::Symbol, org_branding::Symbol, extension_pages::@NamedTuple{created::Vector{String}, preserved::Vector{String}}, pages::Symbol, warnings::Vector{String}}
julia
update(target_dir; ad = true, benchmarks = nothing,
    downgrade_compat = nothing, unregistered_sources = nothing,
    freshen_reusable_refs = false, kwargs...)

Re-apply only the managed standard files to an already-adopted package and report the drift.

update is public, not exported (#294): call it qualified (EpiAwarePackageTools.update(...)) or with an explicit using EpiAwarePackageTools: update. An exported generic verb collides with a package's own same-named export (#173), and a public-not-exported name is never brought into scope by a bare using, so it cannot. The old name is kept reachable as scaffold_update.

This is the entry point the scheduled template-sync workflow calls: it rewrites every managed standard file from the bundled templates, leaving package-owned files (unit tests, qa_config.jl, AD scenarios, benchmarks.jl, LICENSE) untouched, so a deliberate licence change is never reverted. CITATION.cff is the one exception: it is seeded when absent, because the managed "How to cite" section links to it on every sync and an adopter predating citation seeding would otherwise carry a link update could never make resolve (#322). Placeholder inputs resolve exactly as in scaffold; pass the same overrides to keep substitution stable across a sync. org is the one that cannot be left to its default by a package hosted elsewhere, so the managed template-sync.yaml writes the scaffolded value into its own update call and the scheduled sync re-passes it. Scaffold a package outside the EpiAware org with org = "<owner>" and every later sync keeps that owner.

ad must match the value the package was scaffolded with (default true). benchmarks, downgrade_compat and unregistered_sources all default to nothing, detecting the package's current state from the committed workflows and environments so a resync preserves an adopter's opt-in rather than stripping it, or reintroducing a job the package deliberately removed (#121). Pass true/false to force any of them. See scaffold for what unregistered_sources holds to the Julia 1.11.0 floor and why the standard itself no longer does (#410).

A package whose environments still pin EpiAwarePackageTools by git [sources] is warned: the kit is registered, so a managed environment depends on the release through [compat] instead (#361). The managed test/jet/Project.toml is fixed by the sync itself; the package-owned environments (test, test/ad, docs, benchmark) update cannot rewrite, so it names them.

freshen_reusable_refs = true additionally moves each managed CI caller's EpiAware/.github reusable-workflow ref forwards to the newest commit that touched the workflow it wraps, resolved over the GitHub API (#425). It is opt-in because it is the one thing update does that needs the network: the default keeps update hermetic, so a local run offline, the kit's own self-drift check and the test suite all behave identically whatever the API says. The scheduled template-sync workflow, which runs with a token, passes it. Freshening only ever moves a ref forwards — a ref that already contains the workflow's newest commit (as a Dependabot bump to the .github head does), a branch or tag ref, and any ref that cannot be resolved or compared are all left exactly as committed, with a warning rather than an error. So it composes with Dependabot instead of fighting it, and needs neither network nor credentials to be correct.

The README badge block, the managed .gitignore block, the .git-blame-ignore-revs header, the standard-sections block and the README logo title are all refreshed as in scaffold, without the package-owned parts of those files being touched.

Every managed file written from a template has a package-owned opt-out (#224): EPIAWARE_MANAGED_OVERRIDE in a comment tells update() to preserve it (reporting it in preserved) instead of resyncing. Remove the marker to hand management back. Use it sparingly — an overridden file no longer tracks the standard, which is the point of the kit. Three deliberate limits:

  • It covers whole template-emitted files, not the marker-delimited regions the kit injects into package-owned files. Those are refreshed regardless; customise them by editing outside their markers.

  • It opts a file out of resyncing, not of retirement: a marked file on a retired path (RETIRED_PATHS) is still deleted.

  • It must appear in a comment, so the two managed JSON files (docs/package.json, .secrets.baseline) cannot carry it. The match is case-sensitive.

The AD-harness driver test/ad/setup.jl is where this began (#162) and still honours its original marker EPIAWARE_AD_SETUP_OWNED as well; either preserves it. A committed driver that has diverged but carries no marker is still overwritten, with a message in warnings, rather than clobbered silently. That warning is scoped to this one file, whose clobber is silently fatal: divergence is the normal state of a managed file on an adopter running an older kit, so a generic check would fire on every sync and mean nothing.

Managed files the kit has retired (RETIRED_PATHS) are deleted, so a sync converges on the current standard instead of leaving dead infra behind (#185).

docs/pages.jl is regenerated here too, in full, from the same managed base plus the package's docs/docs_config.jl extension points as scaffold (#170/#328/#354) — the fix for docs nav that used to only ever apply at first scaffold (three orphaned AD-backends tutorials, a Benchmarks nav stale since #305, _extensions_nav unreachable after the fact). The migration-safety rule applies here too, unweakened by anything update normally does more freely than scaffold: a committed pages.jl without _MANAGED_PAGES_MARKER in its header is preserved untouched and warned about rather than regenerated, exactly as under scaffold.

Returns the same named tuple as scaffold. license is always :skipped here, citation is :created or :preserved (#322), and extension_pages is always empty: those pages are package-owned and only scaffold seeds them (#319). pages can still be :created here: a missing docs/pages.jl (e.g. deleted by hand) is written fresh rather than left absent, self-healing rather than requiring a full scaffold re-run.

source