Per-feature validation evidence for Shiny apps in pharma/GxP contexts.
Why shiny.val.tools?
Validating a Shiny app for a regulated submission means answering, feature by feature: what does this do, what code does it reach, which packages does it depend on, and what proves it works? Answering that by hand is slow, and it goes stale the moment the app changes.
shiny.val.tools derives those answers statically from the app’s source. It parses the R code, builds the reactive graph, slices it into feature subgraphs — the closed reactive subgraph rooted at one or more outputs — and emits a self-contained artifact set per feature.
It reads both traditional Shiny (library() + source()) and rhino/box (box::use()) apps, including hybrids that mix the two.
The package does not validate your app, and it does not write your tests. It produces the evidence a human uses to validate. That boundary is deliberate — a generated assertion would be a guess about the analysis, and worse than no test at all.
Why not reactlog?
reactlog instruments a running app. That means credentials, data, side effects, and infrastructure — and it only captures the code paths that actually fired. shiny.val.tools is static, output-rooted and feature-scoped: it runs at parse time, never launches the app, and produces narrow subgraphs centred on each documentable feature.
Installation
Not yet on CRAN. Install the development version:
remotes::install_github("Appsilon/shiny.val.tools")Quick start
Point svt_validate() at any Shiny app — a directory or a .zip archive:
library(shiny.val.tools)
result <- svt_validate(
app_path = "path/to/your/shiny/app",
out_dir = "validation"
)
result
#> <svt_validation>
#> out_dir: validation
#> features: 7
#> modules: 2
#> doc stubs: 9
#> inventories: 9
#> widgets: 9
#> surfaces: 9
#> traceability:1
#> scaffolds: 0
#> index: 2
#> manifest: 1No app handy? The package ships example apps you can analyse straight away — and svt_run_example() launches one if you want to see it first:
app <- system.file("extdata", "rhino_multi_module", package = "shiny.val.tools")
svt_validate(app, out_dir = tempfile())
svt_run_example("rhino_multi_module") # launch it in a browserWhat it produces
For each feature and module subgraph:
validation/
├── validation-report.md # the signable summary artifact
├── index.md / index.html # whole-app index linking every artifact
├── km_plot.html # interactive visNetwork subgraph widget
├── km_plot.md # doc stub: intended use, risk, sign-off
├── km_plot/
│ ├── inventory.json # function-centric inventory (schema 1.0)
│ └── test_surface.json # derived test surface (schema 1.0)
└── ...
Each .html widget is self-contained and opens in any browser without a server. Artifacts are deterministic: the same source, manifest and package version produce byte-identical output, so they diff cleanly in a validation binder.
By default you get one feature per top-level output — every user-visible behaviour gets an artifact, and nothing is left unclaimed. Real features often span several outputs, so a features.yml manifest lets you group them:
features:
- name: survival_analysis
intended_use: |
Generate Kaplan-Meier survival curves for the trial primary endpoint
cohort, stratified by treatment arm.
risk_classification: high # high | medium | low | not_required
roots:
- output: km_plot
- output: km_summary_table
- observer: km_exportGenerate a starter manifest with svt_manifest_template().
The testing layer
Alongside each feature, the package derives its test surface — the inputs a test must set, the observables it can assert on, the intermediate reactives, the trusted terminals and the app-defined helpers.
-
svt_validate(scaffold = TRUE)writesshiny::testServer()harnesses with the plumbing filled in and the assertions left blank. - When the app has a
tests/tree, everytest_that()block is mapped to the features it exercises and written into a traceability matrix (traceability.md+traceability.json).
A test links to a feature by annotation, which is the contract:
# @covers feature: km_plot
test_that("km plot renders for the ITT cohort", { ... })or by inference, which fills in the rest. Each link records which mechanism produced it, so a reviewer can see how much of the matrix is inferred.
Coverage here means exercised, never correct. A feature reported as covered has tests that reach its observables. Whether those tests assert the right thing is a reviewer’s judgment.
The package never runs the app or its tests. Pass test_results = "ci/junit.xml" to ingest a report your CI already produced and stamp each mapped test pass / fail / skip / missing.
The pipeline
svt_validate() is a one-shot wrapper around composable steps. Stop after any of them and inspect the intermediate state:
parsed <- svt_parse(app_path = "path/to/app")
graph <- svt_build_graph(parsed)
features <- svt_slice(graph, manifest = "features.yml")
inventory <- svt_inventory(graph, features)
surface <- svt_test_surface(features, inventory)
coverage <- svt_test_coverage(surface, features)
artifacts <- svt_render(features, inventory, out_dir = "validation",
surface = surface, coverage = coverage)Every object has informative print() and summary() methods, and svt_warnings(), svt_unclaimed() and svt_inspect() let you interrogate what the analysis did and did not resolve.
Documentation
Full documentation lives at appsilon.github.io/shiny.val.tools:
- Get started — the 30-second tour.
- Concepts — validation framing, the graph model, feature subgraphs, the inventory, rendering.
- Reference — every exported function, plus the full warning-code catalogue.
What this package does not do
Stated plainly, because the boundary is the point:
-
It does not validate your app or its packages. It produces evidence;
riskmetric/val.meterscore; humans validate. - It does not write your tests. It derives the surface, scaffolds the harness and maps existing tests. Assertions and expected values are human work, permanently.
- It does not run anything — not the app, not its tests. Static analysis only.
-
It does not trace beyond R — no JS handlers, no
www/, nohtmlwidgetsinternals. - It does not diff across versions or analyse multiple apps at once.
Developed with ❤️ at Appsilon. Get in touch: opensource@appsilon.com.
Explore the Rhinoverse — a family of R packages built around Rhino!
Appsilon is a Posit (formerly RStudio) Full Service Certified Partner.
