diff --git a/.Rbuildignore b/.Rbuildignore index 118d5bd0..55653d2b 100644 --- a/.Rbuildignore +++ b/.Rbuildignore @@ -13,3 +13,4 @@ ^scratch\.R$ ^\.claude$ ^AGENTS\.md$ +^\.positai$ diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md deleted file mode 100644 index f32c5c65..00000000 --- a/.claude/CLAUDE.md +++ /dev/null @@ -1,170 +0,0 @@ -# stbl - -## Package overview - -**stbl** provides a consistent, opinionated set of functions to validate, coerce, and stabilize function arguments. The philosophy follows Postel's Law: be liberal in what you accept (coerce when safe), conservative in what you return (ensure inputs have expected classes and features). - -**Key goals:** -- Fail fast with clear error messages before expensive operations -- Intelligently coerce compatible types (e.g., `"1"` to `1L`) -- Provide detailed validation of argument structure and content - -## Function families - -| Family | Purpose | Speed | Example | -|--------|---------|-------|---------| -| `to_*()` | Fast type coercion | Fast | `to_int("1")` → `1L` | -| `is_*_ish()` | Check if coercible (single logical) | Fast | `is_int_ish(1.0)` → `TRUE` | -| `are_*_ish()` | Check if coercible (element-wise) | Fast | `are_int_ish(c(1, 1.5))` → `c(TRUE, FALSE)` | -| `stabilize_*()` | Comprehensive validation | Slower | `stabilize_int(x, min_value = 0)` | -| `specify_*()` | Create pre-configured validators | N/A | `specify_int(min_value = 0)` | - -**Supported types:** `chr`/`character`, `dbl`/`double`, `int`/`integer`, `lgl`/`logical`, `fct`/`factor`, `lst`/`list` - -**Variants:** -- `*_scalar()` — optimized for length-1 values -- British spellings — `stabilise_*()` as synonyms - -## Architecture - -### S3 dispatch - -The `to_*()` and related functions use S3 dispatch based on input type. Methods live in `R/to_*.R` files with naming pattern `to_{type}.{input_class}`. - -### Error handling - -Use `.stbl_abort()` (defined in `R/aaa-conditions.R`) for all errors within this package. This creates classed errors with hierarchy `stbl-error-{subclass}` that can be caught selectively with `tryCatch()`. - -```r -.stbl_abort( - message = "{.arg {arg_name}} must be positive.", - subclass = "bad_value", - call = caller_env(), # Often passes a call arg - message_env = current_env(), # Or wherever variables are defined for message - parent = NULL # Rarely anything else, but might pass a parent through -) -``` - -Note: `pkg_abort()` is the exported generic version for use by other packages; `.stbl_abort()` is our internal wrapper. - -### Internal vs exported functions - -- Internal function names are prefixed with `.` (e.g., `.check_size()`) -- All user-facing functions should be exported with roxygen2 `@export` - -## Adding new functionality - -### New type (e.g., `to_date()`) - -1. Create `R/to_date.R` with the generic and S3 methods -2. Create `R/are_date.R` and `R/is_date.R` for predicates -3. Create `R/stabilize_date.R` for comprehensive validation -4. Add shared parameters to `R/aaa-shared_params.R` if needed -5. Add tests in `tests/testthat/test-to_date.R`, etc. -6. Update `_pkgdown.yml` with new topics -7. Add entry to `NEWS.md` - -### New S3 method for existing type - -Add the method to the appropriate `R/to_*.R` file, following existing patterns for error handling and list flattening. - -## Project skills - -Load these project skills as needed from `.claude/skills/{skill-name}/SKILL.md`: - -- **`implement-feature`** — When implementing new features, refactoring code, or writing tests. Includes coding philosophy, testing patterns, and development wrap-up steps. -- **`create-github-issue`** — When creating GitHub issues. Uses user-story format and posts via `gh::gh()` with issue type support. - -## R package development - -### Key commands - -``` -# To run code -Rscript -e "devtools::load_all(); code" - -# To run all tests -Rscript -e "devtools::test()" - -# To run all tests for files starting with {name} -Rscript -e "devtools::test(filter = '^{name}')" - -# To run all tests for R/{name}.R -Rscript -e "devtools::test_active_file('R/{name}.R')" - -# To run a single test "blah" for R/{name}.R -Rscript -e "devtools::test_active_file('R/{name}.R', desc = 'blah')" - -# To redocument the package -Rscript -e "devtools::document()" - -# To check pkgdown documentation -Rscript -e "pkgdown::check_pkgdown()" - -# To check the package with R CMD check -Rscript -e "devtools::check()" - -# To format code -air format . -``` - -### Coding - -* Always run `air format .` after generating code -* Use the base pipe operator (`|>`) not the magrittr pipe (`%>%`) -* Don't use `_$x` or `_$[["x"]]` since this package must work on R 4.1. -* Use `\() ...` for single-line anonymous functions. For all other cases, use `function() {...}` -* Internal function names should be prefixed with `.`. - -### Namespacing - -Use `pkg::fn()` for all external package functions, with these exceptions: -- Functions from the `base` package -- Functions from `stbl` itself (including explicitly imported functions) -- Functions from `testthat` (and related packages like `httptest2`, `shinytest2`) when writing tests -- Functions from `shiny` when working within a Shiny app - -### Testing - -- Tests for `R/{name}.R` go in `tests/testthat/test-{name}.R`. -- All new code should have an accompanying test. -- If there are existing tests, place new tests next to similar existing tests. -- Strive to keep your tests minimal with few comments. - -### Documentation - -- Every user-facing function should be exported and have roxygen2 documentation. -- Wrap roxygen comments at 80 characters. -- Reused parameters should be documented in `R/aaa-shared_params.R`, almost always in the `.shared-params` topic. -- If a function uses reused parameters, use `@importParams .shared-params` (or whatever topic holds the shared params) *after* function-specific `@param` definitions. -- Internal functions should have roxygen documentation, with `@keywords internal` instead of `@export`. -- Whenever you add a new (non-internal) documentation topic, also add the topic to `_pkgdown.yml`. -- Always re-document the package after changing a roxygen2 comment. -- Use `pkgdown::check_pkgdown()` to check that all topics are included in the reference index. - -### `NEWS.md` - -- Every user-facing change should be given a bullet in `NEWS.md`. Do not add bullets for small documentation changes or internal refactorings. -- Each bullet should briefly describe the change to the end user and mention the related issue in parentheses. -- A bullet can consist of multiple sentences but should not contain any new lines (i.e. DO NOT line wrap). -- If the change is related to a function, put the name of the function early in the bullet. -- Order bullets alphabetically by function name. Put all bullets that don't mention function names at the beginning. - -### GitHub - -- If you use `gh` to retrieve information about an issue, always use `--comments` to read all the comments. - -### Writing - -- Use sentence case for headings. -- Use US English. - -### Proofreading - -If the user asks you to proofread a file, act as an expert proofreader and editor with a deep understanding of clear, engaging, and well-structured writing. - -Work paragraph by paragraph, always starting by making a TODO list that includes individual items for each top-level heading. - -Fix spelling, grammar, and other minor problems without asking the user. Label any unclear, confusing, or ambiguous sentences with a FIXME comment. - -Only report what you have changed. diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index 8f6af20d..00000000 --- a/.claude/settings.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - - "permissions": { - "$schema": "https://json.schemastore.org/claude-code-settings.json", - "allow": [ - "Bash(air:*)", - "Bash(cat:*)", - "Bash(find:*)", - "Bash(gh issue list:*)", - "Bash(gh issue view:*)", - "Bash(gh pr diff:*)", - "Bash(gh pr view:*)", - "Bash(git checkout:*)", - "Bash(git grep:*)", - "Bash(grep:*)", - "Bash(ls:*)", - "Bash(R:*)", - "Bash(rm:*)", - "Bash(Rscript:*)", - "Bash(sed:*)", - "Skill(*)", - "WebFetch(domain:stbl.wrangle.zone)", - "WebFetch(domain:cran.r-project.org)", - "WebFetch(domain:github.com)", - "WebFetch(domain:raw.githubusercontent.com)" - ], - "deny": [ - "Read(.Renviron)", - "Read(.env)" - ] - } -} diff --git a/.claude/skills/create-github-issue/SKILL.md b/.claude/skills/create-github-issue/SKILL.md deleted file mode 100644 index f6ac3f22..00000000 --- a/.claude/skills/create-github-issue/SKILL.md +++ /dev/null @@ -1,93 +0,0 @@ ---- -name: create-github-issue -description: Creates GitHub issues following a user-story format and posts them via the GitHub API. Use when the user asks to create, file, or open a GitHub issue, or when discussing a feature that should be tracked as an issue. ---- - -# Create GitHub Issue - -## Issue format - -Every issue should follow this structure: - -```markdown -*Issue created with the assistance of an AI agent.* - -> As a {role}, in order to [achieve a goal], I would like [this feature or change]. - -[Technical description of the proposed changes or implementation details.] -``` - -Common roles: `package developer`, `{packagename} user`, `data analyst`, `maintainer` - -## Example - -For an issue in the `stbl` package: - -```markdown -*Issue created with the assistance of an AI agent.* - -> As a package developer, in order to validate date inputs in my functions, I would like a `stabilize_date()` function. - -Add `to_date()`, `is_date_ish()`, `are_date_ish()`, and `stabilize_date()` functions following the existing patterns for other types. Should support coercion from character strings in ISO 8601 format and from numeric values (days since epoch). -``` - -## Creating the issue - -Use `gh::gh()` to post the issue via the GitHub API. This supports setting issue types directly. - -### Basic example - -```r -result <- gh::gh( - -"POST /repos/{owner}/{repo}/issues", -owner = "wranglezone", -repo = "stbl", -title = "Add stabilize_date() for date validation", -body = "*Issue created with the assistance of an AI agent.* - -> As a package developer, in order to validate date inputs in my functions, I would like a `stabilize_date()` function. - -Add `to_date()`, `is_date_ish()`, `are_date_ish()`, and `stabilize_date()` functions following the existing patterns for other types." -) -result$html_url -``` - -### With issue type - -```r -result <- gh::gh( -"POST /repos/{owner}/{repo}/issues", -owner = "wranglezone", -repo = "stbl", -title = "Set up AI assistant configuration", -body = "Issue body here...", -type = "Task" # Can be: Feature, Task, Bug, Documentation -) -result$html_url -``` - -### Available parameters - -- `title`: Issue title (required) -- `body`: Issue body -- `type`: Issue type name (e.g., "Feature", "Task", "Bug", "Documentation") -- `labels`: Character vector of label names -- `assignees`: Character vector of usernames -- `milestone`: Milestone number (integer) - -### Checking an existing issue's type - -```r -issue <- gh::gh("GET /repos/{owner}/{repo}/issues/{issue_number}", -owner = "wranglezone", repo = "stbl", issue_number = 182) -issue$type$name -``` - -## Workflow - -1. Discuss the feature or bug with the user to understand the goal -2. Draft the issue in the user-story format -3. Show the draft to the user for approval -4. Post with `gh::gh()` -5. Report the issue URL back to the user diff --git a/.claude/skills/implement-feature/SKILL.md b/.claude/skills/implement-feature/SKILL.md deleted file mode 100644 index 3e36c49b..00000000 --- a/.claude/skills/implement-feature/SKILL.md +++ /dev/null @@ -1,102 +0,0 @@ ---- -name: implement-feature -description: Guides implementation of new features or refactoring in R packages. Use when working on code changes, writing tests, or completing development tasks. Includes coding philosophy, testing patterns, and wrap-up checklist. ---- - -# Implement Feature - -## Code philosophy - -### Functional core, imperative shell - -- **Functional core**: Pure, testable functions that accept data and return data with minimal side effects. -- **Imperative shell**: Orchestrates program flow, manages state, and calls the functional core (e.g., R6 methods, Shiny server logic). - -### Key principles - -**Single responsibility & small functions** -- Strive for small, single-purpose functions, aiming for around five lines of code if possible. -- Refactoring technique: Extract Method. - -**Single level of abstraction** -- A function should either orchestrate calls to other functions OR perform a direct operation on data, but not mix these two levels. - -**Simplify control flow** -- Prefer guard clauses and returning early over complex if/else structures. -- Refactoring technique: Combine if statements. - -**Pure conditionals** -- The expression inside a conditional check should not cause side effects. -- Refactoring technique: Extract Method to separate the pure check from the impure action. - -**Favor composition** -- Break down complex classes into smaller, specialized helper classes. -- Refactoring technique: Introduce Strategy Pattern. - -**Use polymorphism over conditional dispatch** -- When encountering switch or if/else chains based on type, consider replacing with different classes sharing a common interface. -- Refactoring technique: Replace Type Code with Classes. - -## Comments - -Comments should be sparse. They explain *why*, not *what*. If code is hard to understand, abstract it into a well-named helper function rather than adding a comment. - -**Bad comment:** -```r -# Paste together x and y. -paste(x, y) -``` - -**Good comment:** -```r -# This is defined outside of bootstrapLib() because registerThemeDependency() -# wants a non-anonymous function with a single argument. -``` - -**Do not remove:** -- Comments ending in `----` (RStudio document outline markers) -- `# nocov` comments (code coverage exclusions)—but suggest tests if you can think of them - -## Testing - -### General rules - -- Every file in `R/` should have a corresponding `tests/testthat/test-*.R` file. -- All new code should have an accompanying test. -- Strive for 100% code coverage (necessary but not always sufficient). -- Keep tests minimal and focused. - -### Test file structure - -- Do not create objects outside of `test_that()` blocks. Each block should be executable on its own after `devtools::load_all()`. -- If you need shared setup, suggest creating a `tests/testthat/helper-*.R` file. -- Place new tests next to similar existing tests. - -### Test style - -- Strive for the simplest possible test that verifies the behavior. -- Avoid complex testing harnesses or mocks when a direct function call would suffice. -- Use `testthat::expect_snapshot()` for complex outputs like error messages or generated UI. - -### TDD workflow - -If the user specifies TDD, implement red-green-refactor: - -1. **Red**: Write the simplest failing test (often just calling the function with no arguments). Run it to confirm it fails. -2. **Green**: Write the minimum code to pass the test. -3. **Refactor**: Clean up while keeping tests passing. - -## Development wrap-up - -When the user signals a task is nearing completion ("I think we're done", "anything else?", "time to push"), work through these steps: - -1. Run tests for the specific feature -2. Check code coverage for the specific feature -3. Document the new feature or fix -4. Rebuild package documentation (`devtools::document()`) -5. Run all tests (`devtools::test()`) -6. Run R CMD check (`devtools::check()`) -7. Check overall code coverage -8. Verify DESCRIPTION and README.Rmd are still valid -9. Update NEWS.md -10. **Ask the user** before bumping the version number with `usethis::use_version("dev")` diff --git a/.github/scripts/check-timestamp.sh b/.github/scripts/check-timestamp.sh deleted file mode 100644 index 473cbae6..00000000 --- a/.github/scripts/check-timestamp.sh +++ /dev/null @@ -1,64 +0,0 @@ -#!/bin/bash - -# Exit immediately if a command exits with a non-zero status. -set -e - -WORKFLOW_START_TIME="$1" -# Define a temp file name and ensure it's cleaned up on exit -TEMP_JSON_FILE=".github-ai-issues.tmp.json" -trap 'rm -f "$TEMP_JSON_FILE"' EXIT - -if [ -z "$WORKFLOW_START_TIME" ]; then - echo "Error: WORKFLOW_START_TIME argument is missing. Running update." >&2 - echo "changes=true" - exit 0 -fi - -# Try to fetch the 'ai' branch. -# If it fails (e.g., branch doesn't exist), we definitely need to run the update. -if ! git fetch origin ai >/dev/null 2>&1; then - echo "Could not fetch 'ai' branch (might not exist yet). Running update." >&2 - echo "changes=true" - exit 0 -fi - -# Default to running the update -CHANGES="true" - -# Check if the remote branch origin/ai actually exists -# We've successfully fetched, so we can check origin/ai -if git rev-parse --verify origin/ai >/dev/null 2>&1; then - # Branch exists. Try to get the file from it. - # '2>/dev/null' hides errors if the file doesn't exist on the branch. - if git show origin/ai:.github/ai/issues.json > "$TEMP_JSON_FILE" 2>/dev/null; then - # File exists on the 'ai' branch and we've copied it to our temp file. - JSON_TIMESTAMP=$(jq -r '._metadata.updated_at // empty' "$TEMP_JSON_FILE") - - if [ -z "$JSON_TIMESTAMP" ]; then - echo "No timestamp in issues.json on 'ai' branch. Running update." >&2 - CHANGES="true" - else - # Compare ISO 8601 timestamps as strings - if [[ "$JSON_TIMESTAMP" < "$WORKFLOW_START_TIME" ]]; then - echo "issues.json ($JSON_TIMESTAMP) is older than workflow start time ($WORKFLOW_START_TIME). Running update." >&2 - CHANGES="true" - else - echo "issues.json ($JSON_TIMESTAMP) is up-to-date (workflow start: $WORKFLOW_START_TIME). No changes needed." >&2 - CHANGES="false" # This is the only path where we skip the update - fi - fi - else - # File .github/ai/issues.json does not exist on 'ai' branch. - echo "issues.json not found on 'ai' branch. Running update." >&2 - CHANGES="true" - fi -else - # This case should be redundant now because of the fetch check, - # but we'll keep it as a safeguard. - echo "Branch 'ai' not found on remote. Running update." >&2 - CHANGES="true" -fi - -# This is the string that will be captured by $GITHUB_OUTPUT -# It is the *only* line that should go to stdout. -echo "changes=$CHANGES" diff --git a/.github/scripts/check-workflow-runs.sh b/.github/scripts/check-workflow-runs.sh deleted file mode 100644 index 1f1b6f74..00000000 --- a/.github/scripts/check-workflow-runs.sh +++ /dev/null @@ -1,42 +0,0 @@ -#!/bin/bash - -# This script checks if any *other* runs of this same workflow -# were created *after* the start time of the current run. -# This is used to safely stop redundant runs during an event flurry. - -# Exit immediately if a command exits with a non-zero status. -set -e - -# GITHUB_RUN_ID is provided by the GitHub Actions environment -if [ -z "$GITHUB_RUN_ID" ]; then - echo "Error: GITHUB_RUN_ID env var not set. Running update." >&2 - echo "changes=true" - exit 0 -fi - -# GITHUB_TOKEN must be passed as an env var to the 'gh' command -echo "Fetching recent workflow runs..." >&2 -RAW_RUNS_JSON=$(gh run list --workflow=update-ai.yaml --json databaseId,createdAt --limit 10) - -# Find the 'createdAt' time for the *current* run ID -CURRENT_RUN_CREATED_AT=$(echo "$RAW_RUNS_JSON" | jq -r ".[] | select(.databaseId == $GITHUB_RUN_ID) | .createdAt") - -if [ -z "$CURRENT_RUN_CREATED_AT" ]; then - echo "Error: Could not find current run ID $GITHUB_RUN_ID in API response." >&2 - echo "changes=true" - exit 0 -fi - -JQ_QUERY=".[] | select(.createdAt > \"$CURRENT_RUN_CREATED_AT\" and .databaseId != $GITHUB_RUN_ID)" -NEWER_RUNS=$(echo "$RAW_RUNS_JSON" | jq -c "$JQ_QUERY") # -c for compact output - -if [ -n "$NEWER_RUNS" ]; then - # Found a newer run. This current run is obsolete and should stop. - echo "Found newer workflow run(s) started after $CURRENT_RUN_CREATED_AT. Stopping this run." >&2 - echo "changes=false" -else - # No newer runs found. This run should proceed. - echo "No newer runs found. Proceeding." >&2 - echo "changes=true" -fi - diff --git a/.github/scripts/fetch-issues.R b/.github/scripts/fetch-issues.R deleted file mode 100644 index cd109e10..00000000 --- a/.github/scripts/fetch-issues.R +++ /dev/null @@ -1,95 +0,0 @@ -library(gh) -library(glue) -library(purrr) -library(jsonlite) - -repo_full_name <- Sys.getenv("GITHUB_REPOSITORY") -if (isTRUE(grepl("/", repo_full_name))) { - repo_parts <- strsplit(repo_full_name, "/")[[1]] -} else { - repo_parts <- unname(gh::gh_tree_remote()) -} - -if (length(repo_parts) != 2) { - stop( - "Error: GITHUB_REPOSITORY environment variable not set correctly.\n", - "Please set it for local testing (e.g., Sys.setenv(GITHUB_REPOSITORY = 'wranglezone/stbl')).", - call. = FALSE - ) -} - -org_name <- repo_parts[[1]] -repo_name <- repo_parts[[2]] - -output_path <- ".github/ai/issues.json" - -check_timestamp <- format(Sys.time(), tz = "UTC", "%Y-%m-%dT%H:%M:%SZ") -issues_raw <- gh::gh( - "/repos/{owner}/{repo}/issues", - owner = org_name, - repo = repo_name, - state = "open", - .limit = Inf -) - -max_issue_number <- if (length(issues_raw)) { - max(purrr::map_int(issues_raw, "number")) -} else { - 0 -} - -all_issues <- list() - -if (max_issue_number > 0) { - all_issues <- stats::setNames( - replicate(max_issue_number, list(), simplify = FALSE), - seq_len(max_issue_number) - ) - - for (issue in issues_raw) { - comments <- if (issue$comments > 0) { - tryCatch( - { - gh::gh(issue$comments_url, .limit = Inf) |> - purrr::map_chr("body") - }, - error = function(e) { - character(0) - } - ) - } else { - character(0) - } - - all_issues[[issue$number]] <- list( - title = issue$title, - type = issue$type, - milestone = issue$milestone$number, - body = issue$body, - comments = comments - ) - } -} - -issue_collection <- list( - `_metadata` = list( - description = glue::glue( - "A collection of GitHub issues for the {repo_name} repository." - ), - lookup_key = "issue_number", - comment = "Each key in the 'issues' object is a string representation of the GitHub issue number. Empty objects are placeholders so that positions and ids match. Empty objects should be ignored.", - updated_at = check_timestamp - ), - issues = all_issues -) - -if (!dir.exists(dirname(output_path))) { - dir.create(dirname(output_path), recursive = TRUE) -} - -jsonlite::write_json( - issue_collection, - output_path, - auto_unbox = TRUE, - pretty = TRUE -) diff --git a/.github/skills/create-issue/SKILL.md b/.github/skills/create-issue/SKILL.md new file mode 100644 index 00000000..7d08d406 --- /dev/null +++ b/.github/skills/create-issue/SKILL.md @@ -0,0 +1,122 @@ +--- +name: create-issue +trigger: create GitHub issues +description: Creates GitHub issues for the package repository. Use when asked to create, file, or open a GitHub issue, or when planning new features or functions that need to be tracked. +compatibility: Requires the `gh` CLI and an authenticated GitHub session. +--- + +# Create a GitHub issue + +Use `gh api graphql` with the `createIssue` mutation to create issues. This sets the issue type in a single step. Write the body to a temp file first, then pass it via `$(cat ...)`. + +If `gh` is not authenticated, stop and ask the user to authenticate before continuing. + +## Looking up IDs + +The hardcoded IDs below are correct for this repo as of 2026-03-24 11:11:01 UTC. If they ever change, or if you're working in a fork, re-run these queries to get fresh values: + +```bash +# Repository node ID +gh api graphql -f query='{ repository(owner: "wranglezone", name: "stbl") { id } }' + +# Available issue type IDs +gh api graphql -f query='{ repository(owner: "wranglezone", name: "stbl") { issueTypes(first: 20) { nodes { id name description } } } }' +``` + +## Issue type + +Choose the type that best fits the issue: + +| Type | ID | Use for | +|---|---|---| +| Task | `IT_kwDODjbzj84BwJRU` | A specific piece of work | +| Bug | `IT_kwDODjbzj84BwJRV` | An unexpected problem or behavior | +| Feature | `IT_kwDODjbzj84BwJRW` | A request, idea, or new functionality | +| Documentation | `IT_kwDODjbzj84BwprI` | An update to help, vignettes, etc. | +| Infrastructure | `IT_kwDODjbzj84B5OM3` | Infrastructure of a project, like GitHub Actions | + +## Issue title + +Titles use conventional commit prefixes: + +- `feat: my_function()` — new exported function or feature +- `fix: short description` — bug fix +- `docs: short description` — documentation +- `chore: short description` — maintenance or task + +## Issue body structure + +Which sections to include depends on the issue type: + +| Section | Feature | Bug | Documentation | Task | +|---|---|---|---|---| +| `## Summary` | ✓ | ✓ | ✓ | ✓ | +| `## Details` | optional | optional | optional | optional | +| `## Proposed signature` | ✓ | — | — | — | +| `## Behavior` | ✓ | ✓ | — | — | +| `## References` | optional | optional | optional | optional | + +### `## Summary` (all types) + +A single user story sentence (no other content in this section): + +```markdown +> As a [role], in order to [goal], I would like to [feature]. +``` + +Example: + +```markdown +## Summary + +> As a package developer, in order to set up agent skills quickly, I would like to generate a skill template from a single function call. +``` + +### `## Details` (optional, all types) + +For information that's important to capture but doesn't fit naturally into any other section, including implementation details such as packages to add to `Imports` in `DESCRIPTION` or files to add to `inst`. Use sparingly — if the content belongs in `## Behavior`, `## Proposed signature`, or `## References`, put it there instead. + +### `## Proposed signature` (Feature only) + +The proposed R function signature, arguments table, and return value description: + +````markdown +## Proposed signature + +```r +function_name(arg1, arg2) +``` + +**Arguments** + +- `arg1` (`TYPE`) — Description. +- `arg2` (`TYPE`) — Description. + +**Returns** a `TYPE` with description. +```` + +### `## Behavior` (Feature and Bug) + +- **Feature**: bullet points describing expected behavior, edge cases, and any internal helpers to implement as part of this issue. +- **Bug**: describe the current (broken) behavior, the expected behavior, and steps to reproduce if known. + +### `## References` (optional, all types) + +Only include when there are specific reference implementations, external URLs, or related code to link to. Omit it entirely when there are none. + +## Creating the issue + +Use the `repoId` and the `typeId` for the chosen issue type from the table above. + +```bash +gh api graphql \ + -f query='mutation($repoId:ID!, $title:String!, $body:String!, $typeId:ID!) { + createIssue(input:{repositoryId:$repoId, title:$title, body:$body, issueTypeId:$typeId}) { + issue { url } + } + }' \ + -f repoId="R_kgDOKC_ILg" \ + -f title="feat: my_function()" \ + -f body="$(cat /tmp/issue_body.md)" \ + -f typeId="{typeId}" +``` diff --git a/.github/skills/document/SKILL.md b/.github/skills/document/SKILL.md new file mode 100644 index 00000000..a069ee05 --- /dev/null +++ b/.github/skills/document/SKILL.md @@ -0,0 +1,171 @@ +--- +name: document +trigger: document functions +description: Document package functions. Use when asked to document functions. +--- + +# Document functions + +*All* R functions in `R/` should be documented in roxygen2 `#'` style, including internal/unexported functions. + +- Run `air format .` then `devtools::document()` after changing any roxygen2 docs. +- Use sentence case for all headings. +- Wrap roxygen comments at 80 characters. +- Files matching `R/import-standalone-*.R` are imported from other packages and have their own conventions. Do not modify their documentation. +- After documenting functions, run `devtools::document(roclets = c('rd', 'collate', 'namespace'))`. +- Whenever you add a new (non-internal) documentation topic, also add the topic to `_pkgdown.yml`. +- Use `pkgdown::check_pkgdown()` to check that all topics are included in the reference index. + +## Shared parameters + +**Parameters used in more than one function** go in `R/aaa-shared_params.R` under `@name .shared-params`. Functions inherit them with `@inheritParams .shared-params`. See `R/aaa-shared_params.R` for current definitions (if it exists). + +Shared params blocks: alphabetize parameters, use `@name .shared-params` (with leading dot), include `@keywords internal`, end with `NULL`. + +Multiple shared-params groups (e.g. `.shared-params-io`, `.shared-params-parsing`) are appropriate when parameters are only shared within a file and closely related files. + +## Parameter documentation format + +```r +#' @param param_name (`TYPE`) Brief description (usually 1-3 sentences. Can +#' include [cross_references()]. Additional details on continuation lines if +#' needed. +``` + +Function-specific `@param` definitions always appear *before* any `@inheritParams` lines. If all parameters are defined locally, omit `@inheritParams` entirely. + +### Type notation + +| Notation | Meaning | +|----------|---------| +| ``(`character`)`` | Character vector | +| ``(`character(1)`)`` | Single string | +| ``(`logical(1)`)`` | Single logical | +| ``(`integer`)`` | Integer vector | +| ``(`integer(1)`)`` | Single integer | +| ``(`double`)`` | Double vector | +| ``(`vector(0)`)`` | A prototype (zero-length vector) | +| ``(`vector`)`` | A vector of unspecified type | +| ``(`list`)`` | List | +| ``(`data.frame`)`` | Data frame or tibble | +| ``(`function` or `NULL`)`` | A function or NULL | +| ``(`my_class`)`` | A class-specific type (use the actual class name) | +| ``(`any`)`` | Any type | + +### Enumerated values + +When a parameter takes one of a fixed set of values, document them with a bullet list: + +```r +#' @param method (`character(1)`) The aggregation method. Can be one of: +#' * `"mean"`: Arithmetic mean. +#' * `"median"`: Median value. +#' * `"sum"`: Total sum. +``` + +## Returns + +Use `@returns` (not `@return`). Include a type when it's informative: + +```r +#' @returns A summary tibble. +#' @returns (`logical(1)`) `TRUE` if `x` is a valid record. +#' @returns Either a tibble or a list, depending on the input. +#' @returns `NULL` (invisibly). +``` + +**Structured returns with columns:** + +```r +#' @returns A [tibble::tibble()] with columns: +#' - `name`: Record name. +#' - `value`: Numeric value. +#' - `status`: Status (`"active"` or `"inactive"`). +``` + +## Exported functions + +```r +#' Title in sentence case +#' +#' Description paragraph providing context and details. +#' +#' @param param_name (`TYPE`) Description. +#' @inheritParams .shared-params +#' +#' @returns Description of return value. +#' @seealso [related_function()] +#' @export +#' +#' @examples +#' example_code() +``` + +- Blank `#'` lines separate: title/description, description/params, and `@export`/`@examples`. +- `@seealso` (optional) goes between `@returns` and `@export`. +- `@details` can supplement the description when needed. + +## Internal (unexported) functions + +Internal helpers (identified by a dot prefix, e.g. `.parse_response()`) use abbreviated documentation. Mark them with `@keywords internal` and omit `@export`: + +```r +#' Title in sentence case +#' +#' @param one_off_param (`TYPE`) Description. +#' @inheritParams .shared-params +#' @returns (`TYPE`) What it returns. +#' @keywords internal +``` + +Description paragraph is optional (only include when usage isn't obvious), fewer blank `#'` lines, and no `@examples`. + +## S3 methods and `@rdname` grouping + +Use `@rdname` to group related functions under one help page. This applies to: +- **S3 methods we own** (generic defined in this package): generic gets full docs, methods get `@rdname` + `@export`. +- **Related exported functions** (e.g. multiple variants of the same operation): primary function gets full docs, variants get `@rdname` + `@export`. + +```r +#' Format a summary object +#' +#' @param x (`any`) The object to format. +#' @param ... Additional arguments passed to methods. +#' @returns A formatted character string. +#' @keywords internal +.format_summary <- function(x, ...) { + UseMethod(".format_summary") +} + +#' @rdname .format_summary +#' @export +.format_summary.data_summary <- function(x, ...) { + # method implementation +} +``` + +**S3 methods we don't own** (generic from another package) need standalone documentation: + +```r +#' Title describing the method +#' +#' @param x (`TYPE`) Description. +#' @param ... Additional arguments (ignored). +#' @returns Description. +#' @exportS3Method pkg::generic +method.class <- function(x, ...) { ... } +``` + +## Style notes + +**Cross-references:** Use square brackets — `[fetch_records()]` (internal), `[tibble::tibble()]` (external), `[topic_name]` (topics). + +**Section comment headers** optionally organize code within a file, lowercase with dashes to column 80: + +```r +# helpers ---------------------------------------------------------------------- +``` + +Only use such headers in complex files. The need for section comment headers might indicate that the file should be split into multiple files. + +**Examples:** Exported functions include `@examples`. Use `@examplesIf interactive()` for network-dependent or slow functions. Use section-style comments (`# Section ---`) to organize longer example blocks. Internal functions do not get examples. diff --git a/.github/skills/github/SKILL.md b/.github/skills/github/SKILL.md new file mode 100644 index 00000000..03e55705 --- /dev/null +++ b/.github/skills/github/SKILL.md @@ -0,0 +1,24 @@ +--- +name: github +trigger: from github +description: GitHub workflows using the `gh` CLI, including viewing issues/PRs and commit message conventions. Use when interacting with GitHub in any way, such as viewing, creating, or editing issues and pull requests, making commits, or running any `gh` command. +compatibility: Requires the `gh` CLI and an authenticated GitHub session. +--- + +# GitHub + +Use `gh` CLI, not web URLs: `gh issue view 123`, `gh issue list`, `gh pr view 456`, `gh pr list`. + +When viewing an issue, always use `--comments` to read all the comments. + +## Commit messages + +Conventional commits; backtick-quote function names; close issues in body with `- Closes #N`. + +``` +feat: add `create_skill()` + +Generates a new skill directory with a SKILL.md template. + +- Closes #3 +``` diff --git a/.github/skills/implement-issue/SKILL.md b/.github/skills/implement-issue/SKILL.md new file mode 100644 index 00000000..79607c70 --- /dev/null +++ b/.github/skills/implement-issue/SKILL.md @@ -0,0 +1,55 @@ +--- +name: implement-issue +trigger: implement issue / work on #NNN +description: Implements a GitHub issue end-to-end. Use when asked to implement, work on, or fix a specific issue number. +compatibility: Requires the `gh` CLI and an authenticated GitHub session. +--- + +# Implement a GitHub issue + +This skill wraps the Standard Workflow defined in `AGENTS.md`. Run the steps below before and after that workflow. + +## Before the standard workflow + +**A. Read the issue in full:** + +```bash +gh issue view {number} +``` + +If `gh` is not authenticated, stop and ask the user to authenticate before continuing. + +**B. Check/create the branch:** + +- If on `main`: `usethis::pr_init("fix-{number}-{description}")` +- Branch format: `fix-{number}-{description}` + - Parts separated by hyphens; `{description}` uses snake_case + - Example: `fix-42-validate_input` +- If a branch already exists for this issue, check it out instead + +## Run the Standard Workflow from AGENTS.md + +Steps 1–9 of the Standard Workflow are the core development loop. + +After the standard workflow passes, also verify code coverage for the edited files. Ask the user before bumping the version number with `usethis::use_version("dev")`. + +## After the standard workflow + +**C. Commit and push:** + +1. Review commits already on this branch (not on `main`) — these are all part of the same PR and should inform the PR description: + ```bash + git log main..HEAD --oneline + ``` +2. Stage and commit all changes: + ```bash + git add -A + git commit -m "{short imperative summary}" + ``` +3. Push and open the PR: + ```bash + gh pr create --fill + ``` + Use `--title` and `--body` explicitly if `--fill` produces an inadequate description. + +This step may be overridden — the user may ask you to stop before committing, handle the push themselves, or complete only part of the workflow. Always follow explicit user instructions over these defaults. diff --git a/.github/skills/r-code/SKILL.md b/.github/skills/r-code/SKILL.md new file mode 100644 index 00000000..a708bced --- /dev/null +++ b/.github/skills/r-code/SKILL.md @@ -0,0 +1,250 @@ +--- +name: r-code +trigger: writing R functions / API design / error handling +description: Guide for writing R code. Use when writing new functions, designing APIs, or reviewing/modifying existing R code. +--- + +# R code + +This skill covers how to design and write R functions — including naming conventions, signatures, API conventions, input validation, error handling, and common pitfalls. For documenting functions, use the `document` skill. For tests, use the `tdd-workflow` skill. + +## Naming conventions + +### Functions + +Functions use `snake_case` and should be **verbs or verb phrases** that describe what the function does: + +```r +fetch_records() +build_summary() +validate_input() +``` + +A function name should be descriptive enough to make its purpose clear without a comment. Prefer clarity over brevity — don't abbreviate unless there is a widely understood convention (e.g. `df` for data frame, `dir` for directory). + +Internal helpers use a dot prefix: + +```r +.parse_response() +.validate_columns() +``` + +### Parameters + +Parameters use `snake_case` and should generally be **nouns**, occasionally adjectives. The same rule applies: clarity over brevity. + +```r +# Good +fetch_records(file_path, page_size, overwrite) + +# Bad — unclear abbreviations +fetch_records(fp, ps, ow) +``` + +## Coding style + +- Always run `air format .` after generating code. +- Use the base pipe operator (`|>`) not the magrittr pipe (`%>`). +- Don't use `_$x` or `_$[["x"]]` since this package must work on R 4.1. +- Use `\() ...` for single-line anonymous functions. For all other cases, use `function() {...}`. + +## File organization + +One exported function per file: `R/{function_name}.R` (e.g. `fetch_records()` → `R/fetch_records.R`). Internal helpers used exclusively by that function live in the same file. Shared helpers go in `R/utils.R` or `R/utils-{topic}.R` (e.g. `R/utils-parsing.R`). + +## Function design + +**Functional core, imperative shell** — pure, testable functions that accept data and return data form the core. The imperative shell orchestrates program flow, manages state, and calls the functional core (e.g., R6 methods, Shiny server logic). + +Functions should be **small and single-purpose**. Each function should operate at a **single level of abstraction**: it either orchestrates calls to other functions, or performs a direct operation on data, but does not mix the two. + +```r +# Orchestrator — delegates to focused helpers +build_report <- function(data, output_path) { + data <- .clean_data(data) + summary <- .compute_summary(data) + .write_report(summary, output_path) +} + +# Worker — performs one direct operation +.clean_data <- function(data) { + data |> + dplyr::filter(!is.na(value)) |> + dplyr::mutate(value = round(value, 2)) +} +``` + +Name functions well enough that their purpose is obvious from the call site. When reading the orchestrator above, each step is self-documenting — no comments needed. + +**Simplify control flow** — prefer guard clauses and returning early over complex if/else structures. + +**Pure conditionals** — the expression inside a conditional check should not cause side effects. Extract the pure check from the impure action into separate functions if needed. + +## General API design patterns + +**Enum-like arguments** — declare choices as the default vector; resolve with `rlang::arg_match()` at the top of the function: + +```r +summarize_data <- function(x, method = c("mean", "median")) { + method <- rlang::arg_match(method) + # method is now guaranteed to be "mean" or "median" +} +``` + +**`NULL` as "not provided"** — use `NULL` as the default for optional arguments where there is no sensible scalar fallback; check with `is.null()`: + +```r +fetch_records <- function(x, output_column = NULL) { + if (!is.null(output_column)) { ... } +} +``` + +**S3 object construction** — build as a named list, set class explicitly: + +```r +.new_summary <- function(values, method) { + out <- list(values = values, method = method) + class(out) <- c(paste0("summary_", method), "data_summary") + out +} +``` + +**`call` propagation in internal validators** — helpers that validate arguments and may throw errors should accept and forward `call`: + +```r +.check_non_empty <- function(x, call = rlang::caller_env()) { + if (length(x) == 0L) { + .pkg_abort("Input {.arg x} cannot be empty.", "empty_input", call = call) + } +} + +process_data <- function(x, call = rlang::caller_env()) { + .check_non_empty(x, call = call) + ... +} +``` + +**Return tibbles, not data frames:** + +```r +summarize_data <- function(x) { + result |> tibble::as_tibble() +} +``` + +## Input validation + +Use `to_*()` and `stabilize_*()` to validate parameters. These functions coerce when safe and fail with clear error messages when not. + +- **`to_*()`** — simple type coercion. Use when you need to ensure a parameter is the right type but don't need additional constraints. +- **`stabilize_*()`** — coercion plus content validation (regex, ranges, etc.). Use when simple type coercion isn't enough. + +**Validate in the function that uses the parameter**, not in a caller that passes it through. This preserves R's lazy evaluation — if a parameter is never used on a code path, it is never evaluated or validated. + +```r +# Good — validation happens where the parameter is used +build_report <- function(data, title, page_size) { + data <- .clean_data(data) + summary <- .compute_summary(data, page_size) + .write_report(summary, title) +} + +.compute_summary <- function(data, page_size, call = rlang::caller_env()) { + page_size <- to_int_scalar(page_size, call = call) + ... +} + +.write_report <- function(summary, title, call = rlang::caller_env()) { + title <- to_chr_scalar(title, call = call) + ... +} +``` + +```r +# Bad — validates everything eagerly, breaking lazy evaluation +build_report <- function(data, title, page_size) { + title <- to_chr_scalar(title) + page_size <- to_int_scalar(page_size) + ... +} +``` + +When `call` is available (because the function accepts it), always pass it to `to_*()` / `stabilize_*()` calls so error messages point to the user's call frame. + +## Internal vs. exported functions + +Export a function when: +- Users will call it directly +- Other packages may want to extend it +- It is a stable, intentional part of the API + +Keep a function internal when: +- It is an implementation detail that may change +- It is only used within the package +- Exporting it would clutter the user-facing API + +Internal helpers use a dot prefix (e.g. `.parse_response()`). + +## Error handling + +Use `.stbl_abort()` (defined in `R/aaa-conditions.R`) for all errors within this package. This is the internal wrapper around the exported `pkg_abort()` function. It creates classed errors with hierarchy `stbl-error-{subclass}` that can be caught selectively with `tryCatch()`. + +```r +.stbl_abort( + message = "{.arg {arg_name}} must be positive.", + subclass = "bad_value", + call = caller_env(), + message_env = current_env(), + parent = NULL +) +``` + +Always pass `call = call` (or `call = rlang::caller_env()`) so errors point to the user's call frame, not an internal helper. + +## S3 dispatch + +The `to_*()` and related functions use S3 dispatch based on input type. Methods live in `R/to_*.R` files with naming pattern `to_{type}.{input_class}`. + +## Adding new functionality + +### New type (e.g., `to_date()`) + +1. Create `R/to_date.R` with the generic and S3 methods +2. Create `R/are_date.R` and `R/is_date.R` for predicates +3. Create `R/stabilize_date.R` for comprehensive validation +4. Add shared parameters to `R/aaa-shared_params.R` if needed +5. Add tests in `tests/testthat/test-to_date.R`, etc. +6. Update `_pkgdown.yml` with new topics + +### New S3 method for existing type + +Add the method to the appropriate `R/to_*.R` file, following existing patterns for error handling and list flattening. + +## Common package mistakes + +```r +# Never use library() inside package code +library(dplyr) # Wrong +dplyr::filter(...) # Right +# or `@importFrom dplyr filter` if used extensively + +# Never modify global state without restoring it +options(my_option = TRUE) # Wrong +withr::local_options(list(my_option = TRUE)) # Right + +# Use system.file() for package data, not hardcoded paths +read.csv("/home/user/data.csv") # Wrong +system.file("extdata", "data.csv", package = "mypkg") # Right +``` + +## Dependencies + +### Use existing imports first + +Packages already in `Imports` in `DESCRIPTION` should be preferred over base R equivalents: `purrr::map()` over `lapply()`, `rlang::is_*()` predicates over `is.*()`, and `withr::local_*()` over manual `on.exit()` state management. + +### When to add a new dependency + +Add a dependency when it provides significant functionality that would be complex or brittle to reimplement — date parsing, web requests, complex string manipulation. Stick with base R or existing imports when the solution is straightforward. + +**Adding a new dependency requires explicit discussion with the developer.** diff --git a/.github/skills/search-code/SKILL.md b/.github/skills/search-code/SKILL.md new file mode 100644 index 00000000..c5cf9783 --- /dev/null +++ b/.github/skills/search-code/SKILL.md @@ -0,0 +1,84 @@ +--- +name: search-code +trigger: search / rewrite code +description: Search and rewrite R source code by syntax using astgrepr. Use when asked to find patterns in code, search for function calls, identify usage of specific arguments, locate structural patterns across R files, or perform find-and-replace on code structure. +--- + +# Search and rewrite code with astgrepr + +`{astgrepr}` enables AST-based code search — structural queries that regex can't express cleanly. If not installed: `install.packages("astgrepr")` + +```r +library(astgrepr) + +src <- " +add <- function(x, y) x + y +greet <- function(name, greeting, sep) paste0(greeting, sep, name) +square <- function(x) x^2 +" +root <- src |> tree_new() |> tree_root() # or tree_new(file = "R/my_file.R") + +# µNAME/µA/µB capture matched nodes; only `add` matches (2 params) +matches <- node_find_all(root, + ast_rule(id = "two_arg", pattern = "µNAME <- function(µA, µB) µBODY") +) +matches #> |--two_arg: 1 nodes +node_text_all(matches) # source text of each match +lapply(matches$two_arg, \(n) node_get_match(n, "NAME") |> node_text()) #> "add" +``` + +## Reference + +Metavariables: `µVAR` captures one node (uppercase only); `µµµ` captures zero or more (ellipsis). In *replacement* strings, refer to captures as `~~VAR~~`. + +`ast_rule()` — see `?ast_rule`. Key args: + +- `pattern` — code pattern with metavariables +- `kind` — tree-sitter node kind; see the [R grammar](https://github.com/r-lib/tree-sitter-r/blob/main/src/grammar.json) +- `regex` — match node text with a Rust regex +- `id` — names the rule; results accessible as `matches$id` +- `inside`, `has`, `precedes`, `follows` — relational rules, each takes another `ast_rule()` +- `all`, `any`, `not` — boolean combinators, each takes a list of `ast_rule()`s + +For advanced pattern syntax: [ast-grep pattern docs](https://ast-grep.github.io/guide/pattern-syntax.html). + +## Searching across files + +```r +lapply(list.files("R", pattern = "\\.R$", full.names = TRUE), \(f) { + root <- tree_root(tree_new(file = f)) + texts <- node_find_all(root, ast_rule(id = "r", pattern = "YOUR_PATTERN")) |> + node_text_all() |> _$r + if (length(texts) > 0) list(file = basename(f), matches = texts) +}) |> Filter(Negate(is.null), x = _) +``` + +## Patterns + +```r +ast_rule(pattern = "if (µµµ) { return(µµµ) } else µµµ") # if-else with return() +ast_rule(pattern = "util_fun(debug = µµµ)") # named argument +ast_rule(kind = "while_statement") # by node kind +ast_rule(pattern = "df$µCOL") # df$col, any column +ast_rule(pattern = "print(µA)", # relational: inside loop + inside = ast_rule(any = ast_rule(kind = c("for_statement", "while_statement")))) +ast_rule(any = list(ast_rule(pattern = "any(is.na(µµµ))"), # boolean OR + ast_rule(pattern = "any(duplicated(µµµ))"))) +``` + +## Find and replace + +See `?node_replace_all`, `?tree_rewrite`. + +```r +root <- tree_root(tree_new(file = "R/my_file.R")) +fixes <- root |> + node_find_all( + ast_rule(id = "any_na", pattern = "any(is.na(µVAR))"), + ast_rule(id = "any_dup", pattern = "any(duplicated(µVAR))") + ) |> + node_replace_all(any_na = "anyNA(~~VAR~~)", + any_dup = "anyDuplicated(~~VAR~~) > 0") +tree_rewrite(root, fixes) # preview +writeLines(tree_rewrite(root, fixes), con = "R/my_file.R") # write back +``` diff --git a/.github/skills/tdd-workflow/SKILL.md b/.github/skills/tdd-workflow/SKILL.md new file mode 100644 index 00000000..efa41728 --- /dev/null +++ b/.github/skills/tdd-workflow/SKILL.md @@ -0,0 +1,205 @@ +--- +name: tdd-workflow +trigger: writing or reviewing tests +description: Test-driven development workflow. Use when writing any R code (writing new features, fixing bugs, refactoring, or reviewing tests). +--- + +# TDD workflow + +## Core principle + +Write a failing test first, then implement the minimal code to make it pass, then refactor. Never write implementation code without a failing test driving it. + +## File naming + +Tests for `R/{name}.R` go in `tests/testthat/test-{name}.R`. Place new tests next to similar existing ones. + +## Running tests + +```r +# Full suite +devtools::test(reporter = "check") + +# Single file +devtools::test(filter = "name", reporter = "check") +``` + +Testing functions load code automatically. You do not need to call `library()` or `devtools::load_all()` separately. + +## Coverage + +Goal: **100%** for every edited file. After editing `R/file_name.R`, verify: + +```r +covr_res <- devtools:::test_coverage_active_file("R/file_name.R") +which(purrr::map_int(covr_res, "value") == 0) +``` + +Files excluded from the coverage requirement: +- `R/*-package.R` +- `R/aaa-shared_params.R` +- Files matching `R/import-standalone-*.R` + +## Test types + +### Unit tests + +Test individual functions in isolation: + +```r +test_that("fetch_records() returns a tibble (#2)", { + result <- fetch_records(sample_input) + expect_s3_class(result, "tbl_df") +}) +``` + +### Integration tests + +Test end-to-end pipelines through multiple functions: + +```r +test_that("build_report() produces expected output (#15)", { + input <- data.frame(value = c(1.123, 2.456, NA)) + result <- build_report(input, tempfile()) + expect_equal(nrow(result), 2L) +}) +``` + +### Snapshot tests + +For complex outputs that are hard to specify with equality assertions: + +```r +test_that("build_summary print method is stable (#123)", { + expect_snapshot(print(build_summary(sample_data))) +}) +``` + +When snapshots change intentionally, check the content of the file corresponding to the edited test file, then accept: + +```r +testthat::snapshot_accept("test_name") +``` + +Snapshots are stored in `tests/testthat/_snaps/`. The filename corresponds to the R file being tested, ending with `.md`. + +## Test design principles + +- **Self-sufficient:** each test contains its own setup, execution, and assertion. Tests must be runnable in isolation. +- **Duplication over factoring:** repeat setup code rather than extracting it. Clarity beats DRY in tests. +- **One concept per test:** a failing test should tell you exactly what broke. +- **Minimal with few comments:** keep tests lean. Avoid over-commenting. +- **Issue reference in description:** the `desc` of every new `test_that()` call should end with one or more parenthetical issue references for the issue(s) *verified by those tests* — typically the issue currently being solved. Never guess or invent issue numbers; if no tracked issue applies, use `#noissue`: + ```r + test_that("fetch_records() returns correct columns (#1)", { ... }) + test_that("build_summary() returns correct columns (#2, #3)", { ... }) + test_that(".check_record() errors on empty input (#noissue)", { ... }) + ``` + +## testthat Edition 3 — deprecated patterns + +```r +# Deprecated → Modern +context("Data validation") # Remove — filename serves this purpose +expect_equivalent(x, y) # expect_equal(x, y, ignore_attr = TRUE) +with_mock(...) # local_mocked_bindings(...) +expect_is(x, "data.frame") # expect_s3_class(x, "data.frame") +``` + +## Essential expectations + +### Equality & identity + +```r +expect_equal(x, y) # with numeric tolerance +expect_equal(x, y, tolerance = 0.001) +expect_equal(x, y, ignore_attr = TRUE) +expect_identical(x, y) # exact match +``` + +### Conditions + +**Errors thrown by this package** (via `.stbl_abort()`) should always be tested +with `expect_pkg_error_snapshot()` (defined in +`tests/testthat/helper-expectations.R`), which captures both the error class +hierarchy and the user-facing message in one snapshot: + +```r +test_that("process_data() errors on empty input (#42)", { + expect_pkg_error_snapshot( + process_data(data.frame()), + "empty_input" + ) +}) +``` + +Pass `transform = .transform_path(path)` to scrub volatile values (e.g. temp +paths) from the snapshot before comparison. + +**Errors from other packages** can be tested with `expect_error()`. + +```r +expect_warning(code) +expect_no_warning(code) +expect_message(code) +expect_no_message(code) +``` + +### Collections + +```r +expect_setequal(x, y) # same elements, any order +expect_in(element, set) +expect_named(x, c("a", "b")) +``` + +### Type & structure + +```r +expect_type(x, "double") +expect_s3_class(x, "tbl_df") +expect_length(x, 10) +expect_null(x) +``` + +### Logical + +These expectations are a last resort when more-specific checks aren't available. + +```r +expect_true(x) +expect_false(x) +``` + +## `withr` patterns for temporary state + +```r +withr::local_options(list(stbl.verbose = TRUE)) +withr::local_envvar(MY_VAR = "value") +withr::local_tempfile(lines = c("a", "b")) +``` + +## Fixtures + +Store static test data in `tests/testthat/fixtures/` and access via: + +```r +test_path("fixtures", "sample.rds") +``` + +## Mocking + +Mock functions that might have unstable output, hit external servers, etc. + +```r +local_mocked_bindings( + .other_fn = function(...) "mocked_result" +) +result <- my_function_that_calls_other_fn() +``` + +## Common mistakes + +- **Do not modify tests to make them pass.** Fix the implementation. +- **Do not write tests that depend on other tests' state.** Each test must be independently runnable. +- **Ask for help if test is bad.** If you think a test might be invalid, do not loop through trying to make impossible tests pass. Ask for help if possible. diff --git a/.github/workflows/R-CMD-check.yaml b/.github/workflows/R-CMD-check.yaml index 692d3dcc..3f1cc98b 100644 --- a/.github/workflows/R-CMD-check.yaml +++ b/.github/workflows/R-CMD-check.yaml @@ -3,12 +3,8 @@ on: push: branches: [main, master] - paths-ignore: - - '.github/ai/issues.json' pull_request: branches: [main, master] - paths-ignore: - - '.github/ai/issues.json' name: R-CMD-check @@ -28,25 +24,17 @@ jobs: - {os: ubuntu-latest, r: 'release'} - {os: ubuntu-latest, r: 'oldrel-1'} - env: - GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} - R_KEEP_PKG_SOURCE: yes - steps: - - uses: actions/checkout@v4 - - - uses: r-lib/actions/setup-pandoc@v2 + - uses: actions/checkout@v6 - - uses: r-lib/actions/setup-r@v2 + - uses: ./.github/workflows/install with: + token: ${{ secrets.GITHUB_TOKEN }} r-version: ${{ matrix.config.r }} http-user-agent: ${{ matrix.config.http-user-agent }} - use-public-rspm: true - - - uses: r-lib/actions/setup-r-dependencies@v2 - with: - extra-packages: any::rcmdcheck needs: check + extra-packages: any::rcmdcheck + cache-version: "1" - uses: r-lib/actions/check-r-package@v2 with: diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml new file mode 100644 index 00000000..2fca7101 --- /dev/null +++ b/.github/workflows/copilot-setup-steps.yml @@ -0,0 +1,50 @@ +name: "Copilot Setup Steps" + +# Automatically run when the file changes to allow for easy validation, +# and allow manual testing through the repository's "Actions" tab. +on: + workflow_dispatch: + push: + paths: + - .github/workflows/copilot-setup-steps.yml + pull_request: + paths: + - .github/workflows/copilot-setup-steps.yml + +jobs: + # The job MUST be called `copilot-setup-steps` or it will not be picked up by Copilot. + copilot-setup-steps: + runs-on: ubuntu-latest + + permissions: + contents: read + + steps: + - uses: actions/checkout@v6 + + - uses: ./.github/workflows/install + with: + token: ${{ secrets.GITHUB_TOKEN }} + cache-version: copilot + needs: build, check, website + # Extra packages include things referenced in skills, to make sure the + # agent has them available. + extra-packages: >- + any::astgrepr + any::cli + any::covr + any::devtools + any::magick + any::pak + any::pkgdown + any::purrr + any::rcmdcheck + any::rlang + any::roxygen2 + any::testthat + any::usethis + any::withr + local::. + + - name: Install air + uses: posit-dev/setup-air@v1 diff --git a/.github/workflows/format-suggest.yaml b/.github/workflows/format-suggest.yaml new file mode 100644 index 00000000..af502100 --- /dev/null +++ b/.github/workflows/format-suggest.yaml @@ -0,0 +1,46 @@ +# Workflow derived from https://github.com/posit-dev/setup-air/tree/main/examples + +on: + # Using `pull_request_target` over `pull_request` for elevated `GITHUB_TOKEN` + # privileges, otherwise we can't set `pull-requests: write` when the pull + # request comes from a fork, which is our main use case (external contributors). + # + # `pull_request_target` runs in the context of the target branch (`main`, usually), + # rather than in the context of the pull request like `pull_request` does. Due + # to this, we must explicitly checkout `ref: ${{ github.event.pull_request.head.sha }}`. + # This is typically frowned upon by GitHub, as it exposes you to potentially running + # untrusted code in a context where you have elevated privileges, but they explicitly + # call out the use case of reformatting and committing back / commenting on the PR + # as a situation that should be safe (because we aren't actually running the untrusted + # code, we are just treating it as passive data). + # https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/ + pull_request_target: + +name: format-suggest.yaml + +jobs: + format-suggest: + name: format-suggest + runs-on: ubuntu-latest + + permissions: + # Required to push suggestion comments to the PR + pull-requests: write + + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - name: Install + uses: posit-dev/setup-air@v1 + + - name: Format + run: air format . + + - name: Suggest + uses: reviewdog/action-suggester@v1 + with: + level: error + fail_level: error + tool_name: air diff --git a/.github/workflows/install/action.yml b/.github/workflows/install/action.yml new file mode 100644 index 00000000..95c460b9 --- /dev/null +++ b/.github/workflows/install/action.yml @@ -0,0 +1,45 @@ +name: "Install R and dependencies" +description: "Set up pandoc, R, and package dependencies" +inputs: + token: + description: "GitHub token, set to secrets.GITHUB_TOKEN" + required: true + r-version: + description: "R version, passed to r-lib/actions/setup-r@v2" + required: false + default: release + http-user-agent: + description: "HTTP user agent, passed to r-lib/actions/setup-r@v2" + required: false + default: "" + needs: + description: "Config/Needs tag(s), passed to r-lib/actions/setup-r-dependencies@v2" + required: false + default: "" + extra-packages: + description: "Extra packages, passed to r-lib/actions/setup-r-dependencies@v2" + required: false + default: any::rcmdcheck + cache-version: + description: "Cache version for r-lib/actions/setup-r-dependencies@v2" + required: false + default: "1" + +runs: + using: "composite" + steps: + - uses: r-lib/actions/setup-pandoc@v2 + + - uses: r-lib/actions/setup-r@v2 + with: + r-version: ${{ inputs.r-version }} + http-user-agent: ${{ inputs.http-user-agent }} + use-public-rspm: true + + - uses: r-lib/actions/setup-r-dependencies@v2 + env: + GITHUB_PAT: ${{ inputs.token }} + with: + needs: ${{ inputs.needs }} + extra-packages: ${{ inputs.extra-packages }} + cache-version: ${{ inputs.cache-version }} diff --git a/.github/workflows/pkgdown.yaml b/.github/workflows/pkgdown.yaml index af1ef450..34d15bf5 100644 --- a/.github/workflows/pkgdown.yaml +++ b/.github/workflows/pkgdown.yaml @@ -2,13 +2,9 @@ # Need help debugging build failures? Start at https://github.com/r-lib/actions#where-to-find-help on: push: - branches: [main, master] - paths-ignore: - - '.github/ai/issues.json' + branches: [main] pull_request: - branches: [main, master] - paths-ignore: - - '.github/ai/issues.json' + branches: [main] release: types: [published] workflow_dispatch: @@ -21,23 +17,17 @@ jobs: # Only restrict concurrency for non-PR jobs concurrency: group: pkgdown-${{ github.event_name != 'pull_request' || github.run_id }} - env: - GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} permissions: contents: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - uses: r-lib/actions/setup-pandoc@v2 - - - uses: r-lib/actions/setup-r@v2 - with: - use-public-rspm: true - - - uses: r-lib/actions/setup-r-dependencies@v2 + - uses: ./.github/workflows/install with: - extra-packages: any::pkgdown, local::. + token: ${{ secrets.GITHUB_TOKEN }} needs: website + extra-packages: any::pkgdown local::. + cache-version: "1" - name: Build site run: pkgdown::build_site_github_pages(new_process = FALSE, install = FALSE) @@ -45,7 +35,7 @@ jobs: - name: Deploy to GitHub pages 🚀 if: github.event_name != 'pull_request' - uses: JamesIves/github-pages-deploy-action@v4.4.1 + uses: JamesIves/github-pages-deploy-action@v4.8.0 with: clean: false branch: gh-pages diff --git a/.github/workflows/pr-commands.yaml b/.github/workflows/pr-commands.yaml index eea58c5c..1881e2a9 100644 --- a/.github/workflows/pr-commands.yaml +++ b/.github/workflows/pr-commands.yaml @@ -11,23 +11,21 @@ jobs: if: ${{ github.event.issue.pull_request && (github.event.comment.author_association == 'MEMBER' || github.event.comment.author_association == 'OWNER') && startsWith(github.event.comment.body, '/document') }} name: document runs-on: ubuntu-latest - env: - GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} + permissions: + contents: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: r-lib/actions/pr-fetch@v2 with: repo-token: ${{ secrets.GITHUB_TOKEN }} - - uses: r-lib/actions/setup-r@v2 + - uses: ./.github/workflows/install with: - use-public-rspm: true - - - uses: r-lib/actions/setup-r-dependencies@v2 - with: - extra-packages: any::roxygen2 + token: ${{ secrets.GITHUB_TOKEN }} needs: pr-document + extra-packages: any::roxygen2 + cache-version: "1" - name: Document run: roxygen2::roxygenise() @@ -43,37 +41,3 @@ jobs: - uses: r-lib/actions/pr-push@v2 with: repo-token: ${{ secrets.GITHUB_TOKEN }} - - style: - if: ${{ github.event.issue.pull_request && (github.event.comment.author_association == 'MEMBER' || github.event.comment.author_association == 'OWNER') && startsWith(github.event.comment.body, '/style') }} - name: style - runs-on: ubuntu-latest - env: - GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} - steps: - - uses: actions/checkout@v4 - - - uses: r-lib/actions/pr-fetch@v2 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - - - uses: r-lib/actions/setup-r@v2 - - - name: Install dependencies - run: install.packages("styler") - shell: Rscript {0} - - - name: Style - run: styler::style_pkg() - shell: Rscript {0} - - - name: commit - run: | - git config --local user.name "$GITHUB_ACTOR" - git config --local user.email "$GITHUB_ACTOR@users.noreply.github.com" - git add \*.R - git commit -m 'Style' - - - uses: r-lib/actions/pr-push@v2 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/qcthat.yaml b/.github/workflows/qcthat.yaml new file mode 100644 index 00000000..16695bdd --- /dev/null +++ b/.github/workflows/qcthat.yaml @@ -0,0 +1,155 @@ +# Workflow derived from +# https://github.com/Gilead-BioStats/qcthat/tree/v1.1.1/inst/workflows/qcthat.yaml. +on: + pull_request: + types: [opened, edited, reopened, synchronize, milestoned] + release: + types: [released] + issues: + types: [closed] + workflow_dispatch: + inputs: + pr: + description: PR number to which reports should be added (leave blank for none). + required: false + milestone: + description: Milestone name to use for the milestone report (leave blank for none). + required: false + tag: + description: Release tag to which the report should be attached (leave blank for none). + required: false + issueNumber: + description: The closed issue number to process to update user acceptance testing information. + required: false + +name: qcthat Quality Control + +permissions: + # read: Required for generating reports and updating UAT status. + # write: Required for initiating the UAT process. + issues: write + # read: Required for updating UAT status. + # write: Required for adding reports to pull requests. + pull-requests: write + # write: Required for attaching reports to releases. + contents: write + # write: Required for updating UAT status. + actions: write + +# Configuration variables for controlling workflow behavior +env: + qcthat_UAT: true + qcthat_PR_REPORT: true + qcthat_COMPLETED_REPORT: true + qcthat_MILESTONE_REPORT: true + qcthat_RELEASE_REPORT: true + qcthat_FAIL_FOR_TEST_FAILURES: true + +jobs: + qcthat: + runs-on: ubuntu-latest + if: >- + (github.event_name == 'issues' && contains(github.event.issue.labels.*.name, 'qcthat-uat')) || + github.event_name != 'issues' + env: + GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v6 + + - uses: ./.github/workflows/install + with: + token: ${{ secrets.GITHUB_TOKEN }} + extra-packages: Gilead-BioStats/qcthat@main local::. + cache-version: "1" + + - name: Manage User Acceptance Testing + if: >- + env.qcthat_UAT == 'true' && ( + (github.event_name == 'issues' && contains(github.event.issue.labels.*.name, 'qcthat-uat')) || + (github.event_name == 'workflow_dispatch' && inputs.issueNumber != '') + ) + run: | + Rscript -e "qcthat::TriggerUAT()" + + - name: Generate Issue-Test Matrix + if: >- + (env.qcthat_PR_REPORT == 'true' || env.qcthat_RELEASE_REPORT == 'true' || env.qcthat_FAIL_FOR_TEST_FAILURES == 'true') && ( + github.event_name == 'pull_request' || + github.event_name == 'release' || + (github.event_name == 'workflow_dispatch' && inputs.issueNumber == '') + ) + run: | + # Generate the full matrix for the package + IssueTestMatrix <- qcthat::QCPackage() + print(IssueTestMatrix) + + # Save the matrix and UAT data for subsequent steps + saveRDS(IssueTestMatrix, "ITM.rds") + qcthat::SaveUATIssues() + shell: Rscript {0} + + - name: Update PR Reports + if: >- + env.qcthat_PR_REPORT == 'true' && ( + github.event_name == 'pull_request' || + (github.event_name == 'workflow_dispatch' && inputs.pr != '') + ) + run: | + issueTestMatrix <- readRDS("ITM.rds") + qcthat::LoadUATIssues() + qcthat::CommentAllReports( + dfITM = issueTestMatrix, + lglPR = as.logical("${{ env.qcthat_PR_REPORT }}"), + lglMilestone = as.logical("${{ env.qcthat_MILESTONE_REPORT }}"), + lglCompleted = as.logical("${{ env.qcthat_COMPLETED_REPORT }}"), + lglUAT = as.logical("${{ env.qcthat_UAT }}") + ) + shell: Rscript {0} + + - name: Update Release Reports + if: >- + env.qcthat_RELEASE_REPORT == 'true' && ( + github.event_name == 'release' || inputs.tag != '' + ) + run: | + issueTestMatrix <- readRDS("ITM.rds") + qcthat::LoadUATIssues() + qcthat::AttachReleaseReports( + dfITM = issueTestMatrix, + lglCompleted = as.logical("${{ env.qcthat_COMPLETED_REPORT }}"), + lglMilestone = as.logical("${{ env.qcthat_MILESTONE_REPORT }}") + ) + shell: Rscript {0} + + - name: Flag failure for PR + if: >- + env.qcthat_FAIL_FOR_TEST_FAILURES == 'true' && ( + github.event_name == 'pull_request' || + (github.event_name == 'workflow_dispatch' && inputs.pr != '') + ) + run: | + issueTestMatrix <- readRDS("ITM.rds") + dfPR <- qcthat::QCPR(dfITM = issueTestMatrix) + if (any(dfPR$Disposition == "fail", na.rm = TRUE)) { + cli::cli_abort( + "One or more tests failed or were skipped for PR-associated issues." + ) + } + shell: Rscript {0} + + - name: Flag failure for completed + if: >- + env.qcthat_FAIL_FOR_TEST_FAILURES == 'true' && ( + github.event_name == 'pull_request' || + github.event_name == 'release' || + (github.event_name == 'workflow_dispatch' && inputs.issueNumber == '') + ) + run: | + issueTestMatrix <- readRDS("ITM.rds") + dfCompleted = qcthat::QCCompletedIssues(dfITM = issueTestMatrix) + if (any(dfCompleted$Disposition == "fail", na.rm = TRUE)) { + cli::cli_abort( + "One or more tests failed or were skipped for completed issues." + ) + } + shell: Rscript {0} diff --git a/.github/workflows/qcthis.yaml b/.github/workflows/qcthis.yaml deleted file mode 100644 index 96bf5da6..00000000 --- a/.github/workflows/qcthis.yaml +++ /dev/null @@ -1,49 +0,0 @@ -on: - push: - branches: [main] - pull_request: - workflow_dispatch: - -name: Package QC Report - -permissions: read-all - -jobs: - qc-report: - runs-on: ubuntu-latest - env: - GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} - - steps: - - uses: actions/checkout@v4 - - - uses: r-lib/actions/setup-r@v2 - with: - use-public-rspm: true - - - uses: r-lib/actions/setup-r-dependencies@v2 - with: - extra-packages: local::., any::pak, any::dplyr - - - name: Install qcthat - run: | - # This is separated out to make it easy to update the tag/PR/etc - pak::pak("Gilead-BioStats/qcthat@dev") - shell: Rscript {0} - - - name: QC report - run: | - IssueTestMatrix <- qcthat::CompileIssueTestMatrix( - qcthat::FetchRepoIssues() |> - dplyr::filter( - .data$State == "closed", - .data$StateReason == "completed", - .data$Type %in% c("Feature", "Bug") - ), - qcthat::CompileTestResults( - testthat::test_local(stop_on_failure = FALSE, reporter = "silent") - ) |> - dplyr::filter(lengths(.data$Issues) > 0) - ) - print(IssueTestMatrix) - shell: Rscript {0} diff --git a/.github/workflows/rhub.yaml b/.github/workflows/rhub.yaml deleted file mode 100644 index 74ec7b05..00000000 --- a/.github/workflows/rhub.yaml +++ /dev/null @@ -1,95 +0,0 @@ -# R-hub's generic GitHub Actions workflow file. It's canonical location is at -# https://github.com/r-hub/actions/blob/v1/workflows/rhub.yaml -# You can update this file to a newer version using the rhub2 package: -# -# rhub::rhub_setup() -# -# It is unlikely that you need to modify this file manually. - -name: R-hub -run-name: "${{ github.event.inputs.id }}: ${{ github.event.inputs.name || format('Manually run by {0}', github.triggering_actor) }}" - -on: - workflow_dispatch: - inputs: - config: - description: 'A comma separated list of R-hub platforms to use.' - type: string - default: 'linux,windows,macos' - name: - description: 'Run name. You can leave this empty now.' - type: string - id: - description: 'Unique ID. You can leave this empty now.' - type: string - -jobs: - - setup: - runs-on: ubuntu-latest - outputs: - containers: ${{ steps.rhub-setup.outputs.containers }} - platforms: ${{ steps.rhub-setup.outputs.platforms }} - - steps: - # NO NEED TO CHECKOUT HERE - - uses: r-hub/actions/setup@v1 - with: - config: ${{ github.event.inputs.config }} - id: rhub-setup - - linux-containers: - needs: setup - if: ${{ needs.setup.outputs.containers != '[]' }} - runs-on: ubuntu-latest - name: ${{ matrix.config.label }} - strategy: - fail-fast: false - matrix: - config: ${{ fromJson(needs.setup.outputs.containers) }} - container: - image: ${{ matrix.config.container }} - - steps: - - uses: r-hub/actions/checkout@v1 - - uses: r-hub/actions/platform-info@v1 - with: - token: ${{ secrets.RHUB_TOKEN }} - job-config: ${{ matrix.config.job-config }} - - uses: r-hub/actions/setup-deps@v1 - with: - token: ${{ secrets.RHUB_TOKEN }} - job-config: ${{ matrix.config.job-config }} - - uses: r-hub/actions/run-check@v1 - with: - token: ${{ secrets.RHUB_TOKEN }} - job-config: ${{ matrix.config.job-config }} - - other-platforms: - needs: setup - if: ${{ needs.setup.outputs.platforms != '[]' }} - runs-on: ${{ matrix.config.os }} - name: ${{ matrix.config.label }} - strategy: - fail-fast: false - matrix: - config: ${{ fromJson(needs.setup.outputs.platforms) }} - - steps: - - uses: r-hub/actions/checkout@v1 - - uses: r-hub/actions/setup-r@v1 - with: - job-config: ${{ matrix.config.job-config }} - token: ${{ secrets.RHUB_TOKEN }} - - uses: r-hub/actions/platform-info@v1 - with: - token: ${{ secrets.RHUB_TOKEN }} - job-config: ${{ matrix.config.job-config }} - - uses: r-hub/actions/setup-deps@v1 - with: - job-config: ${{ matrix.config.job-config }} - token: ${{ secrets.RHUB_TOKEN }} - - uses: r-hub/actions/run-check@v1 - with: - job-config: ${{ matrix.config.job-config }} - token: ${{ secrets.RHUB_TOKEN }} diff --git a/.github/workflows/test-coverage.yaml b/.github/workflows/test-coverage.yaml index c823058d..f4be0183 100644 --- a/.github/workflows/test-coverage.yaml +++ b/.github/workflows/test-coverage.yaml @@ -3,52 +3,54 @@ on: push: branches: [main, master] - paths-ignore: - - '.github/ai/issues.json' pull_request: branches: [main, master] - paths-ignore: - - '.github/ai/issues.json' name: test-coverage jobs: test-coverage: runs-on: ubuntu-latest - env: - GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} - steps: - - uses: actions/checkout@v4 - - - uses: r-lib/actions/setup-r@v2 - with: - use-public-rspm: true + - uses: actions/checkout@v6 - - uses: r-lib/actions/setup-r-dependencies@v2 + - uses: ./.github/workflows/install with: - extra-packages: any::covr + token: ${{ secrets.GITHUB_TOKEN }} needs: coverage + extra-packages: any::covr any::xml2 + cache-version: "1" - name: Test coverage run: | - covr::codecov( + cov <- covr::package_coverage( quiet = FALSE, clean = FALSE, - install_path = file.path(Sys.getenv("RUNNER_TEMP"), "package") + install_path = file.path(normalizePath(Sys.getenv("RUNNER_TEMP"), winslash = "/"), "package") ) + print(cov) + covr::to_cobertura(cov) shell: Rscript {0} + - uses: codecov/codecov-action@v5 + with: + # Fail if error if not on PR, or if on PR and token is given + fail_ci_if_error: ${{ github.event_name != 'pull_request' || secrets.CODECOV_TOKEN }} + files: ./cobertura.xml + plugins: noop + disable_search: true + token: ${{ secrets.CODECOV_TOKEN }} + - name: Show testthat output if: always() run: | ## -------------------------------------------------------------------- - find ${{ runner.temp }}/package -name 'testthat.Rout*' -exec cat '{}' \; || true + find '${{ runner.temp }}/package' -name 'testthat.Rout*' -exec cat '{}' \; || true shell: bash - name: Upload test results if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: coverage-test-failures path: ${{ runner.temp }}/package diff --git a/.github/workflows/update-ai.yaml b/.github/workflows/update-ai.yaml deleted file mode 100644 index fcc6ad0b..00000000 --- a/.github/workflows/update-ai.yaml +++ /dev/null @@ -1,124 +0,0 @@ -name: update-ai - -on: - workflow_dispatch: - push: - branches: [main] - issues: - types: - - opened - - edited - - deleted - - transferred - - closed - - reopened - - labeled - - unlabeled - - milestoned - - demilestoned - - typed - - untyped - -permissions: - contents: write - issues: read - -jobs: - update-ai: - runs-on: ubuntu-latest - steps: - - name: Set workflow start time - id: start_time - run: echo "timestamp=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_OUTPUT - - - name: Initial pause for event flurry - run: sleep 30s - shell: bash - - - name: Checkout main branch to get scripts - uses: actions/checkout@v5 - with: - token: ${{ secrets.ADMIN_PAT }} - ref: main - - - name: Make check scripts executable - run: | - if [ -f ".github/scripts/check-timestamp.sh" ]; then - chmod +x .github/scripts/check-timestamp.sh - fi - if [ -f ".github/scripts/check-workflow-runs.sh" ]; then - chmod +x .github/scripts/check-workflow-runs.sh - fi - shell: bash - - - name: Install jq - run: | - sudo apt-get install -y jq - - - name: Check for newer workflow runs - id: check_changes_1 - env: - GITHUB_TOKEN: ${{ secrets.ADMIN_PAT }} # Use PAT for gh API calls - GITHUB_RUN_ID: ${{ github.run_id }} - run: | - bash .github/scripts/check-workflow-runs.sh >> $GITHUB_OUTPUT - shell: bash - - - name: Setup R - id: setup-r - if: steps.check_changes_1.outputs.changes == 'true' - uses: r-lib/actions/setup-r@v2 - - - name: Cache R packages - if: steps.check_changes_1.outputs.changes == 'true' - uses: actions/cache@v4 - with: - path: ${{ env.R_LIBS_USER }} - key: ${{ runner.os }}-r-${{ steps.setup-r.outputs.r-version }}-pkgs-v1 - restore-keys: | - ${{ runner.os }}-r-${{ steps.setup-r.outputs.r-version }}-pkgs- - - - name: Install system dependencies - if: steps.check_changes_1.outputs.changes == 'true' - run: | - sudo apt-get update - sudo apt-get install -y libcurl4-openssl-dev libssl-dev jq - - - name: Install R script dependencies - if: steps.check_changes_1.outputs.changes == 'true' - run: | - install.packages("pak", repos = "https://r-lib.github.io/p/pak/stable") - pak::pak(c("gh", "glue", "purrr", "jsonlite")) - shell: Rscript {0} - - - name: Check for changes after setup - if: steps.check_changes_1.outputs.changes == 'true' - id: check_changes_2 - run: | - bash .github/scripts/check-timestamp.sh ${{ steps.start_time.outputs.timestamp }} >> $GITHUB_OUTPUT - shell: bash - - - name: Checkout latest main before running script - if: steps.check_changes_2.outputs.changes == 'true' - uses: actions/checkout@v5 - with: - token: ${{ secrets.ADMIN_PAT }} - ref: main - - - name: Run script to fetch issues - if: steps.check_changes_2.outputs.changes == 'true' - run: Rscript .github/scripts/fetch-issues.R - env: - GITHUB_TOKEN: ${{ secrets.ADMIN_PAT }} - - - name: Commit and push changes to ai branch - if: steps.check_changes_2.outputs.changes == 'true' - run: | - git config --global user.name 'github-actions[bot]' - git config --global user.email 'github-actions[bot]@users.noreply.github.com' - # Create a new 'ai' branch from the current HEAD (main) or reset the existing one. - git checkout -B ai - git add .github/ai/issues.json - git diff --staged --quiet || git commit -m "chore: Update issues.json" - # Force push the 'ai' branch to the remote, overwriting its history. - git push --force origin ai diff --git a/.gitignore b/.gitignore index dc5e38b7..1469a5d6 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ docs exploration inst/doc scratch.R +.positai diff --git a/AGENTS.md b/AGENTS.md index de8cdb56..073566b7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,3 +1,120 @@ -# Agent instructions +# AGENTS.md -Load `.claude/CLAUDE.md`. +## Repository overview + +**stbl** — Stabilize Function Arguments + +A set of consistent, opinionated functions to quickly check + function arguments, coerce them to the desired configuration, or + deliver informative error messages when that is not possible. + +https://stbl.wrangle.zone/, https://github.com/wranglezone/stbl + +### Philosophy + +stbl follows [Postel's Law](https://en.wikipedia.org/wiki/Robustness_principle): be liberal in what you accept (coerce when safe), conservative in what you return (ensure inputs have the expected classes and features). The goal is to fail fast with clear error messages before expensive operations, and to intelligently coerce compatible types (e.g., `"1"` to `1L`). + +### Function families + +| Family | Purpose | Speed | Example | +|--------|---------|-------|---------| +| `to_*()` | Fast type coercion | Fast | `to_int("1")` → `1L` | +| `is_*_ish()` | Check if coercible (single logical) | Fast | `is_int_ish(1.0)` → `TRUE` | +| `are_*_ish()` | Check if coercible (element-wise) | Fast | `are_int_ish(c(1, 1.5))` → `c(TRUE, FALSE)` | +| `stabilize_*()` | Comprehensive validation | Slower | `stabilize_int(x, min_value = 0)` | +| `specify_*()` | Create pre-configured validators | N/A | `specify_int(min_value = 0)` | + +**Supported types (but future development may add more):** `chr`/`character`, `dbl`/`double`, `int`/`integer`, `lgl`/`logical`, `fct`/`factor`, `lst`/`list` + +**Variants:** +- `*_scalar()` — optimized for length-1 values +- British spellings — `stabilise_*()` as synonyms + +### Overall structure + +The project follows standard R package conventions with these key directories: + +stbl/ +├── R/ # R source code +│ ├── aaa-conditions.R # Error helpers (.stbl_abort, .stop_cant_coerce, etc.) +│ ├── aaa-shared_params.R # Shared roxygen parameter definitions +│ ├── stbl-package.R # Auto-generated package docs +│ └── *.R # Function definitions, 1 file ~= 1 exported function +├── .github/ +│ ├── ISSUE_TEMPLATE/ # GitHub issue templates +│ ├── skills/ # Agent skill definitions +│ └── workflows/ # CI/CD configurations +├── tests/testthat/ # Test suite +├── man/ # Generated documentation +├── AGENTS.md # Main agent setup file +├── DESCRIPTION # Package metadata +├── NAMESPACE # Auto-generated export information +├── NEWS.md # Changelog +└── Various config files # .gitignore, codecov.yml, etc. + +--- + +## Standard workflow + +For any feature, fix, or refactor: + +1. **Update packages**: `pak::pak()` +2. **Run tests** — confirm passing before changes: `devtools::test(reporter = "check")`. If any fail, stop and ask. +3. **Plan** — identify affected R files; check if new exports are needed. +4. **Test first** — write failing test, then implement: `devtools::test(filter = "name", reporter = "check")`. +5. **Implement** — minimal code to pass tests. +6. **Refactor** — clean up, keep tests green. +7. **Document** — document any new or changed exports. +8. **Verify**: `devtools::check(error_on = "warning")`. Resolve warnings, errors, and NOTEs. +9. **News** — add a bullet at the top of `NEWS.md` for user-facing changes. + +--- + +## General + +- R console: use `--quiet --vanilla`. +- Comments explain *why*, not *what*. +- Do not remove comments ending in `----` (RStudio document outline markers) or `# nocov` comments (code coverage exclusions). + +### Namespacing + +Use `pkg::fn()` for all external package functions, with these exceptions: +- Functions from the `base` package +- Functions from `stbl` itself (including explicitly imported functions) +- Functions from `testthat` (and related packages like `httptest2`, `shinytest2`) when writing tests +- Functions from `shiny` when working within a Shiny app + +### `NEWS.md` + +- Every user-facing change should be given a bullet in `NEWS.md`. Do not add bullets for small documentation changes or internal refactorings. +- Each bullet should briefly describe the change to the end user and mention the related issue in parentheses. +- A bullet can consist of multiple sentences but should not contain any new lines (i.e. DO NOT line wrap). +- If the change is related to a function, put the name of the function early in the bullet. +- Order bullets alphabetically by function name. Put all bullets that don't mention function names at the beginning. + +### Writing + +- Use sentence case for headings. +- Use US English. + +### Proofreading + +If the user asks you to proofread a file, act as an expert proofreader and editor with a deep understanding of clear, engaging, and well-structured writing. + +Work paragraph by paragraph, always starting by making a TODO list that includes individual items for each top-level heading. + +Fix spelling, grammar, and other minor problems without asking the user. Label any unclear, confusing, or ambiguous sentences with a FIXME comment. + +Only report what you have changed. + +## Skills + +| Triggers | Path | +|----------|------| +| create GitHub issues | @.github/skills/create-issue/SKILL.md | +| document functions | @.github/skills/document/SKILL.md | +| from github | @.github/skills/github/SKILL.md | +| implement issue / work on #NNN | @.github/skills/implement-issue/SKILL.md | +| writing R functions / API design / error handling | @.github/skills/r-code/SKILL.md | +| search / rewrite code | @.github/skills/search-code/SKILL.md | +| writing or reviewing tests | @.github/skills/tdd-workflow/SKILL.md | diff --git a/DESCRIPTION b/DESCRIPTION index d5e35853..5420f56c 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -18,6 +18,7 @@ Imports: rlang (>= 1.0.2.9003), vctrs Suggests: + astgrepr, knitr, rmarkdown, stringi, diff --git a/tests/testthat/helper-expectations.R b/tests/testthat/helper-expectations.R new file mode 100644 index 00000000..9aa7e7ec --- /dev/null +++ b/tests/testthat/helper-expectations.R @@ -0,0 +1,46 @@ +# `rlang::enexpr()` is used instead of `rlang::enquo()` because `inject()` +# splices a quosure as `^(expr)`, which breaks `expect_snapshot()`'s internal +# `parse(deparse(x))` round-trip. `enexpr()` captures only the bare expression, +# so the snapshot's Code section shows the full inner call transparently. +# +# The `call` parameter (defaulting to `caller_env()`) is forwarded to `inject()` +# as `env =` so that `expect_snapshot()` is evaluated in the test's environment. +# Without this, local variables in the expression (e.g. `tmp`) would be out of +# scope. +# +# The `transform` parameter is forwarded to `expect_snapshot()` to allow callers +# to scrub volatile values (e.g. temp paths) before snapshot comparison. +expect_pkg_error_snapshot <- function( + object, + error_class_component, + package = "stbl", + transform = NULL, + call = caller_env() +) { + obj_expr <- rlang::enexpr(object) + transform_expr <- rlang::enexpr(transform) + rlang::inject( + expect_snapshot( + { + (expect_pkg_error_classes( + !!obj_expr, + !!package, + !!error_class_component + )) + }, + transform = !!transform_expr + ), + env = call + ) +} + +# Used to scrub temp paths from snapshots. +.transform_path <- function(path) { + function(x) { + stringr::str_replace_all( + x, + stringr::fixed(as.character(path)), + "PATH" + ) + } +}