Skip to contents

shiny.val.tools takes a Shiny app, parses its R source statically, and produces per-feature validation artifacts for use in pharma/GxP validation packages. It does not validate the app — it produces evidence that someone else (a developer, validation engineer, auditor) uses to validate.

The unit of validation is a feature subgraph: the closed reactive subgraph rooted at one or more outputs. For each feature subgraph the package emits:

  1. The subgraph as an interactive visNetwork HTML widget.
  2. The list of R packages used inside that subgraph.
  3. The list of functions/methods called inside that subgraph (with package origin).
  4. A markdown documentation stub for the developer to fill in (intended use, risk classification, sign-off).

Install

shiny.val.tools is not yet on CRAN.

# From GitHub
remotes::install_github("Appsilon/shiny.val.tools")

A 30-second tour

Point svt_validate() at any Shiny app — a directory or a .zip archive — and it writes artifacts to out_dir:

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

Per feature/module, three files land under out_dir:

validation/
├── km_plot.html          # interactive subgraph widget
├── km_plot.md            # doc stub (intended use, risk, sign-off)
├── km_plot/
│   └── inventory.json    # function-centric inventory
├── filter_panel.html
├── filter_panel.md
├── filter_panel/
│   └── inventory.json
└── ...

Open any .html file in a browser; it is self-contained.

The testing layer

Alongside each feature, the package derives its test surface — the inputs a test must set, the outputs it can assert on, the intermediate reactives, the trusted terminals, and the app-defined helpers — and writes it as <slug>/test_surface.json plus a ## Test surface section in the doc stub. svt_validate(scaffold = TRUE) also writes shiny::testServer() harnesses with the plumbing filled in and the assertions left blank.

When the app has a tests/ tree, svt_validate() goes one step further: it discovers every test_that() block, maps each to the features, modules and helpers it exercises, and writes the traceability matrix.

validation/
├── traceability.md       # the auditor-facing matrix
├── traceability.json     # the same data, schema_version 1.0
└── km_plot/
    └── test_surface.json

A test links to a feature either 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 — a testServer(mod$server, …) block resolves to that module, a testServer() block over the app directory to the features whose outputs it reads. Each link records which mechanism produced it, so a reviewer can see how much of the matrix is inferred. A test that maps to nothing is an orphan (SVT-W304); the remedy is an annotation.

Coverage here means exercised, never correct. A feature reported 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. With test_results = "ci/junit.xml" it will ingest a report your CI already produced and stamp each mapped test pass / fail / skip / missing; with strict_verification = TRUE an untested high-risk feature fails the run.

Evidence this layer cannot read — a Cypress spec, a manual UAT record, a valtools test case — attaches through the manifest instead:

features:
  - name: km_plot
    tests:
      - external: UAT-014
        note: manual acceptance record, filed in the validation binder

The pipeline, step by step

svt_validate() is a one-shot wrapper around a few composable steps. You can stop after any of them and inspect intermediate state.

library(shiny.val.tools)

parsed    <- svt_parse(app_path = "path/to/app")
graph     <- svt_build_graph(parsed)
features  <- svt_slice(graph, manifest = "features.yml")  # NULL = default
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)

Each step returns an object with informative print() and summary() methods:

graph
#> <svt_graph>
#>   nodes:    42
#>   edges:    71
#>   warnings: 3

summary(graph)
#> svt_graph summary
#>   files:    8
#>   imports:  12
#>   sources:  2
#>   nodes:    42
#>   edges:    71
#>   warnings: 3
#>     - SVT-W004: 1
#>     - SVT-W005: 2

The default slicing rule

Without a manifest, svt_validate() produces one feature per top-level output and per top-level side-effecting observer. This is the right starting point for most apps: every user-visible behavior gets an artifact.

The default rule guarantees there are no unclaimed outputs — every behavior is captured. The cost is that grouping is per-output, not per-user-feature.

The manifest: grouping outputs into features

Real features often span multiple outputs. The Kaplan–Meier plot, its summary table, and the CSV download all belong to one feature — “survival analysis for the primary endpoint cohort”. The manifest (features.yml) lets you say so.

Generate a starter manifest from the graph:

graph <- svt_build_graph(svt_parse("path/to/app"))
svt_manifest_template(graph, out_path = "features.yml")

Then fill it in:

features:
  - name: km_plot
    intended_use: |
      Generate Kaplan–Meier survival curves for the trial primary
      endpoint cohort, stratified by treatment arm.
    risk_classification: high
    roots:
      - output: km_plot
      - output: km_summary_table
      - observer: km_export
    package_categories:
      survival: method
      dplyr:    framework
      logger:   utility

When svt_validate() finds a features.yml in the app root (or when you pass manifest = "..." explicitly), the manifest groups outputs into features and decorates each artifact with the declared metadata.

The full schema is in vignette("feature-subgraphs").

Where to go next