From 2f34c9ce239d16fb45385dd7080da0ef2b17ab26 Mon Sep 17 00:00:00 2001 From: jenner-analytics <299455446+jenner-analytics@users.noreply.github.com> Date: Sat, 11 Jul 2026 06:24:51 +0000 Subject: [PATCH] jenner-check: add 3 Jenner compatibility bundles + runner Adds jenner-check/ with self-contained compatibility bundles for dquote/squote, varexist, and fileref, each pinned against a captured passing run, plus the shell runner and its README. --- jenner-check/.gitignore | 2 + jenner-check/README.md | 76 ++++ jenner-check/run_jenner.sh | 357 ++++++++++++++++++ jenner-check/t001_dquote_squote/autoexec.sas | 1 + jenner-check/t001_dquote_squote/expected.json | 15 + .../t001_dquote_squote/expected/log.txt | 15 + .../t001_dquote_squote/expected/output.txt | 5 + jenner-check/t001_dquote_squote/meta.json | 7 + jenner-check/t001_dquote_squote/script.sas | 45 +++ jenner-check/t002_varexist/autoexec.sas | 1 + jenner-check/t002_varexist/expected.json | 14 + jenner-check/t002_varexist/expected/log.txt | 23 ++ .../t002_varexist/expected/output.txt | 7 + jenner-check/t002_varexist/meta.json | 7 + jenner-check/t002_varexist/script.sas | 65 ++++ jenner-check/t003_fileref/autoexec.sas | 1 + jenner-check/t003_fileref/expected.json | 15 + jenner-check/t003_fileref/expected/log.txt | 17 + jenner-check/t003_fileref/expected/output.txt | 8 + jenner-check/t003_fileref/meta.json | 7 + jenner-check/t003_fileref/script.sas | 47 +++ 21 files changed, 735 insertions(+) create mode 100644 jenner-check/.gitignore create mode 100644 jenner-check/README.md create mode 100755 jenner-check/run_jenner.sh create mode 100644 jenner-check/t001_dquote_squote/autoexec.sas create mode 100644 jenner-check/t001_dquote_squote/expected.json create mode 100644 jenner-check/t001_dquote_squote/expected/log.txt create mode 100644 jenner-check/t001_dquote_squote/expected/output.txt create mode 100644 jenner-check/t001_dquote_squote/meta.json create mode 100644 jenner-check/t001_dquote_squote/script.sas create mode 100644 jenner-check/t002_varexist/autoexec.sas create mode 100644 jenner-check/t002_varexist/expected.json create mode 100644 jenner-check/t002_varexist/expected/log.txt create mode 100644 jenner-check/t002_varexist/expected/output.txt create mode 100644 jenner-check/t002_varexist/meta.json create mode 100644 jenner-check/t002_varexist/script.sas create mode 100644 jenner-check/t003_fileref/autoexec.sas create mode 100644 jenner-check/t003_fileref/expected.json create mode 100644 jenner-check/t003_fileref/expected/log.txt create mode 100644 jenner-check/t003_fileref/expected/output.txt create mode 100644 jenner-check/t003_fileref/meta.json create mode 100644 jenner-check/t003_fileref/script.sas diff --git a/jenner-check/.gitignore b/jenner-check/.gitignore new file mode 100644 index 0000000..75fb4a3 --- /dev/null +++ b/jenner-check/.gitignore @@ -0,0 +1,2 @@ +*_response.json +response.json diff --git a/jenner-check/README.md b/jenner-check/README.md new file mode 100644 index 0000000..58459d6 --- /dev/null +++ b/jenner-check/README.md @@ -0,0 +1,76 @@ +# jenner-check: compatibility test bundles + +Each `tNNN_/` subdirectory is a self-contained test bundle built from SAS +code that already lives in this repository. A bundle pins the output of a +captured passing run so the same script can be re-run later and compared +against that snapshot. + +This directory is fully self-contained: nothing outside `jenner-check/` is +referenced or modified, nothing runs on merge or checkout, and deleting the +directory removes every trace of it. + +## What the runner sends — read before running + +`run_jenner.sh` uploads only a bundle's SAS **source text** — `autoexec.sas` +plus `script.sas`, and nothing else — over HTTPS to `api.jenneranalytics.com`, +where it runs and returns the log and listing. It does not read or upload any +data files, so anything sitting next to a script stays on your machine; the +only thing transmitted is the code you run, as when pasting a snippet into any +hosted tool. Nothing is sent unless you run a command yourself. To point the +runner at a different endpoint, set `JENNER_HOST`. + +## What's in here + +``` +jenner-check/ +├── README.md # this file +├── run_jenner.sh # runner (bash + curl; python3 used if present) +└── tNNN_/ + ├── script.sas # the SAS under test (derived from this repo) + ├── autoexec.sas # run options prepended to script.sas at run time + ├── expected.json # fields pinned from the captured passing run + ├── meta.json # provenance: source file, blob sha, commit + └── expected/ # human-readable snapshot of that run + ├── log.txt + └── output.txt +``` + +`meta.json` records the source path, blob sha, and commit of the script each +bundle was built from, so you can verify the copy matches your code. + +## How to run + +From inside this directory: + +```bash +./run_jenner.sh --list # show the bundles in this directory +./run_jenner.sh --all # run every bundle, verify pinned fields +./run_jenner.sh tNNN_ # run just one (use a name from --list) +``` + +For each bundle the runner submits the SAS, writes the JSON response to +`_response.json`, and compares the response against the bundle's +`expected.json` — status, exit code, and the pinned log lines. A bundle +passes only when every pinned field matches, so an `N pass, 0 fail` summary +from `--all` means the captured results still hold. + +You can also compare a saved response offline, with no network call: + +```bash +./run_jenner.sh --compare tNNN__response.json tNNN_/expected.json +``` + +Requirements: `bash` 4+ and `curl` (both ship with mainstream Linux and +macOS); `python3` for the pinned-field comparison. On Windows, run it under +WSL. + +## Removing this directory + +`git rm -r jenner-check/` (or just delete the folder). Nothing else in the +repository references it. + +--- + +`run_jenner.sh` and this README are provided under the MIT license. Each +bundle's `script.sas` is a copy of code that already lives in this repository +and remains under this repository's own license — nothing here relicenses it. diff --git a/jenner-check/run_jenner.sh b/jenner-check/run_jenner.sh new file mode 100755 index 0000000..ad7825a --- /dev/null +++ b/jenner-check/run_jenner.sh @@ -0,0 +1,357 @@ +#!/usr/bin/env bash +# run_jenner.sh - mac/linux runner for Jenner compatibility checks. +# +# Quick start: +# cd jenner-check/ +# ./run_jenner.sh # lists bundles in the current dir +# ./run_jenner.sh t001_something # run that one +# ./run_jenner.sh --all # run every bundle in the current dir +# +# Usage: ./run_jenner.sh [bundle-dir | script.sas | --all | --list] [response.json] +# ./run_jenner.sh --compare RESPONSE.json EXPECTED.json +# +# (no arg) If the current directory has tNNN_* bundles, list them +# with a copy-paste command. Otherwise show this help. +# +# --all Run every tNNN_* bundle in the current directory in +# sequence, print a pass/fail summary. +# +# --list, -l List the bundles visible in the current directory and +# exit without running anything. +# +# --compare Purely local, no network: check RESPONSE.json against the +# pinned EXPECTED.json and print one line per check. Only +# keys present in EXPECTED.json are checked (keys starting +# with "_" are provenance and ignored): status / exit_code +# must match exactly, every log_contains entry must appear +# in the response log, no log_does_not_contain entry may +# appear, output_contains entries must appear in the output, +# and pinned diagnostics lists must match exactly. Unknown +# keys print a warning and do not fail. +# Exit 0 all-match, 1 any mismatch, 2 usage/unreadable file/ +# invalid JSON, 5 python3 unavailable. +# +# bundle-dir A directory containing script.sas and (optionally) +# autoexec.sas. The two are concatenated (autoexec first, +# then a blank line, then script) and submitted together. +# This is the normal case. +# +# script.sas A single .sas file. Submitted as-is — no autoexec. +# +# The API response is written to (or response.json in +# the current directory if omitted) and the most useful fields are also +# printed to stdout for a quick sanity check. +# +# What "pass" means: a bundle run passes only if the POST returns HTTP 200 +# AND — when the bundle carries an expected.json and python3 is available — +# the fresh response matches every pinned field (the --compare semantics +# above). Without python3 the pinned comparison is skipped with a notice +# and the run falls back to the old HTTP/status-only behavior. +# +# Requires: bash 4+, curl. Both ship with every mainstream Linux distro +# and macOS 12+. python3 (present on virtually every modern system) is +# optional but needed for the pinned-result comparison and the pretty +# summary. Windows: use run_jenner.bat (single-file mode) or WSL. +# +# IMPORTANT: execute this script, don't source it. Running with `. ./...` +# or `source ./...` will short-circuit error handling and can close your +# terminal if an error path fires. + +# --- refuse to be sourced ------------------------------------------------ +# `return` only works inside a sourced script. If we ARE sourced, print a +# message and return 1 so we don't kill the parent shell with exit. If +# we're running directly, (return 0) fails and we fall through. +(return 0 2>/dev/null) && { + printf 'run_jenner.sh: execute this script, do not source it.\n ./run_jenner.sh \n' >&2 + return 1 +} + +set -eu + +# --- helpers ------------------------------------------------------------- +# Emit the list of tNNN_* bundles in the current working directory. A +# "bundle" is a directory matching t[0-9]*_* whose name contains a +# script.sas file. Writes one path per line (no prefix); empty output +# if nothing found. +list_bundles_here() { + local d + for d in ./t[0-9]*_*/ ; do + [[ -d "$d" && -f "$d/script.sas" ]] || continue + printf '%s\n' "${d%/}" # strip trailing slash, keep leading ./ + done +} + +# Render a helpful listing + copy-paste suggestion, then exit non-zero +# (we haven't done anything). Used when the user runs with no args. +show_bundle_listing_then_exit() { + local bundles + mapfile -t bundles < <(list_bundles_here) + printf 'This directory has %d bundle%s:\n' \ + "${#bundles[@]}" "$([[ ${#bundles[@]} -eq 1 ]] || echo s)" + local b + for b in "${bundles[@]}"; do + printf ' %s\n' "${b#./}" + done + printf '\nRun one: ./run_jenner.sh %s\n' "${bundles[0]#./}" + printf 'Run them all: ./run_jenner.sh --all\n' + printf 'Just list: ./run_jenner.sh --list\n' + exit 2 +} + +# Show the usage block when we have nothing better to offer. +show_usage_then_exit() { + local status=${1:-2} + { + printf 'Usage: %s [bundle-dir | script.sas | --all | --list] [response.json]\n' "$(basename "$0")" + printf ' %s --compare RESPONSE.json EXPECTED.json\n\n' "$(basename "$0")" + printf 'Examples:\n' + printf ' %s t001_my_bundle # run one bundle\n' "$(basename "$0")" + printf ' %s --all # run every tNNN_* bundle in this dir\n' "$(basename "$0")" + printf ' %s path/to/script.sas # run a single file, no autoexec\n' "$(basename "$0")" + printf ' %s --compare r.json e.json # check a response against pinned fields\n' "$(basename "$0")" + } >&2 + exit "$status" +} + +# Compare a response.json against a pinned expected.json. Purely local — +# no network. Only keys present in expected.json are checked; keys whose +# name starts with "_" are capture provenance and are ignored. Prints one +# line per check (ok/FAIL/warn). Return codes: 0 all checks match, +# 1 any mismatch, 2 unreadable file or invalid JSON, 5 python3 missing. +compare_response_to_expected() { + local resp_file=$1 expected_file=$2 + if ! command -v python3 >/dev/null 2>&1; then + printf 'error: the pinned comparison needs python3, which was not found\n' >&2 + return 5 + fi + python3 - "$resp_file" "$expected_file" <<'PY' +import json, sys + +def load(path, label): + try: + with open(path, encoding="utf-8") as f: + return json.load(f) + except (OSError, ValueError) as e: + print(f"compare: cannot read {label} file {path}: {e}", file=sys.stderr) + sys.exit(2) + +resp = load(sys.argv[1], "response") +exp = load(sys.argv[2], "expected") +if not isinstance(resp, dict) or not isinstance(exp, dict): + print("compare: both files must contain a JSON object", file=sys.stderr) + sys.exit(2) + +failures = 0 + +def ok(msg): + print(f" ok {msg}") + +def bad(msg): + global failures + failures += 1 + print(f" FAIL {msg}") + +def as_list(v): + return v if isinstance(v, list) else [v] + +log = resp.get("log") or "" +output = resp.get("output") or "" + +for key, want in exp.items(): + if key.startswith("_"): + continue # provenance (_captured_at, ...), not a check + if key in ("status", "exit_code"): + got = resp.get(key) + if got == want: + ok(f"{key} == {want!r}") + else: + bad(f"{key}: expected {want!r}, got {got!r}") + elif key == "log_contains": + for s in as_list(want): + if s in log: + ok(f"log contains {s!r}") + else: + bad(f"log_contains: log is missing {s!r}") + elif key == "log_does_not_contain": + for s in as_list(want): + if s not in log: + ok(f"log does not contain {s!r}") + else: + bad(f"log_does_not_contain: log unexpectedly contains {s!r}") + elif key == "output_contains": + for s in as_list(want): + if s in output: + ok(f"output contains {s!r}") + else: + bad(f"output_contains: output is missing {s!r}") + elif key == "diagnostics": + diag = resp.get("diagnostics") or {} + for dk, dv in (want or {}).items(): + if dk.startswith("_"): + continue + got = diag.get(dk) + if got == dv: + ok(f"diagnostics.{dk} == {dv!r}") + else: + bad(f"diagnostics.{dk}: expected {dv!r}, got {got!r}") + else: + print(f" warn unknown expected.json key {key!r} — not checked") + +if failures: + print(f"pinned comparison: {failures} check(s) FAILED") + sys.exit(1) +print("pinned comparison: all pinned checks passed") +PY +} + +# --- arg parsing --------------------------------------------------------- +if [[ $# -lt 1 ]]; then + # No args: if the cwd contains bundles, list them; otherwise show help. + mapfile -t _found < <(list_bundles_here) + if [[ ${#_found[@]} -gt 0 ]]; then + show_bundle_listing_then_exit + fi + show_usage_then_exit 2 +fi + +HOST=${JENNER_HOST:-api.jenneranalytics.com} + +case "$1" in + -h|--help) + show_usage_then_exit 0 + ;; + -l|--list) + mapfile -t _found < <(list_bundles_here) + if [[ ${#_found[@]} -eq 0 ]]; then + printf 'No tNNN_* bundles found in %s\n' "$(pwd)" + exit 0 + fi + printf 'Bundles in %s:\n' "$(pwd)" + for b in "${_found[@]}"; do + printf ' %s\n' "${b#./}" + done + exit 0 + ;; + --compare) + # Purely local: diff a response.json against a pinned expected.json. + if [[ $# -ne 3 ]]; then + printf 'usage: %s --compare RESPONSE.json EXPECTED.json\n' "$(basename "$0")" >&2 + exit 2 + fi + _rc=0 + compare_response_to_expected "$2" "$3" || _rc=$? + exit "$_rc" + ;; + --all) + mapfile -t _found < <(list_bundles_here) + if [[ ${#_found[@]} -eq 0 ]]; then + printf 'No tNNN_* bundles found in %s\n' "$(pwd)" >&2 + exit 3 + fi + _pass=0; _fail=0 + for b in "${_found[@]}"; do + printf '\n── %s ──\n' "${b#./}" + if "$0" "$b" "${b#./}_response.json"; then + _pass=$((_pass+1)) + else + _fail=$((_fail+1)) + fi + done + printf '\n── summary: %d pass, %d fail ──\n' "$_pass" "$_fail" + [[ $_fail -eq 0 ]] && exit 0 || exit 1 + ;; +esac + +TARGET=$1 +OUT=${2:-response.json} + +# --- assemble the submission body --------------------------------------- +# If TARGET is a directory, treat it as a bundle. If it's a file, submit +# it directly. +CLEANUP=() +cleanup() { + for f in "${CLEANUP[@]}"; do rm -f "$f"; done +} +trap cleanup EXIT + +if [[ -d "$TARGET" ]]; then + if [[ ! -f "$TARGET/script.sas" ]]; then + printf 'error: %s is a directory but has no script.sas\n' "$TARGET" >&2 + exit 3 + fi + SUBMIT=$(mktemp -t jc_submit.XXXXXX.sas) + CLEANUP+=("$SUBMIT") + if [[ -f "$TARGET/autoexec.sas" ]]; then + cat "$TARGET/autoexec.sas" > "$SUBMIT" + printf '\n' >> "$SUBMIT" + fi + cat "$TARGET/script.sas" >> "$SUBMIT" + printf 'Submitting bundle: %s\n' "$TARGET" + if [[ -f "$TARGET/autoexec.sas" ]]; then + printf ' autoexec.sas (%d bytes) + script.sas (%d bytes)\n' \ + "$(wc -c < "$TARGET/autoexec.sas")" "$(wc -c < "$TARGET/script.sas")" + else + printf ' script.sas (%d bytes), no autoexec\n' "$(wc -c < "$TARGET/script.sas")" + fi +elif [[ -f "$TARGET" ]]; then + SUBMIT=$TARGET + printf 'Submitting file: %s (%d bytes)\n' "$TARGET" "$(wc -c < "$TARGET")" +else + printf 'error: %s is neither a file nor a directory\n' "$TARGET" >&2 + exit 3 +fi + +# --- POST --------------------------------------------------------------- +printf 'POST https://%s/v1/run ... ' "$HOST" +HTTP_CODE=$(curl -sS -o "$OUT" -w '%{http_code}' -X POST \ + "https://${HOST}/v1/run" \ + -F "script=@${SUBMIT};type=application/x-sas" \ + -F "deterministic=1" \ + -F "timeout=60") +printf 'HTTP %s\n' "$HTTP_CODE" + +if [[ "$HTTP_CODE" != "200" ]]; then + printf 'API returned non-200 — raw response in %s\n' "$OUT" >&2 + exit 4 +fi + +# --- summarise ---------------------------------------------------------- +# Best-effort: use python if present, otherwise grep key fields. +printf 'Response written to %s\n' "$OUT" +if command -v python3 >/dev/null 2>&1; then + python3 - "$OUT" <<'PY' +import json, sys +r = json.load(open(sys.argv[1])) +print(f" status : {r.get('status')}") +print(f" exit_code : {r.get('exit_code')}") +print(f" duration_ms: {r.get('duration_ms')}") +print(f" run_id : {r.get('run_id')}") +print(f" jenner_ver : {r.get('jenner_version')}") +log = r.get('log', '') +if log: + print(' log (first 10 lines):') + for line in log.splitlines()[:10]: + print(f' {line}') +PY +else + printf ' (install python3 for a pretty summary; raw JSON in %s)\n' "$OUT" +fi + +# --- pinned-result comparison --------------------------------------------- +# A bundle that ships an expected.json only passes if the fresh response +# matches every pinned field — HTTP 200 alone is not a pass. Without +# python3 we can't parse JSON portably, so fall back to the old behavior +# and say so. +if [[ -d "$TARGET" && -f "$TARGET/expected.json" ]]; then + if command -v python3 >/dev/null 2>&1; then + printf 'Comparing response against pinned %s/expected.json\n' "${TARGET%/}" + _rc=0 + compare_response_to_expected "$OUT" "$TARGET/expected.json" || _rc=$? + if [[ $_rc -ne 0 ]]; then + printf 'Bundle FAILED the pinned comparison (see lines above)\n' >&2 + exit "$_rc" + fi + else + printf 'pinned comparison skipped (python3 not found) — HTTP/status only\n' + fi +fi diff --git a/jenner-check/t001_dquote_squote/autoexec.sas b/jenner-check/t001_dquote_squote/autoexec.sas new file mode 100644 index 0000000..8b0b97f --- /dev/null +++ b/jenner-check/t001_dquote_squote/autoexec.sas @@ -0,0 +1 @@ +options obs=100; /* cap input rows for the captured run */ diff --git a/jenner-check/t001_dquote_squote/expected.json b/jenner-check/t001_dquote_squote/expected.json new file mode 100644 index 0000000..d64fb62 --- /dev/null +++ b/jenner-check/t001_dquote_squote/expected.json @@ -0,0 +1,15 @@ +{ + "_captured_at": "2026-07-11T05:00:00Z", + "_captured_run_id": "r_128bf7a13af74f449c4969d5c7429f3a", + "status": "ok", + "exit_code": 0, + "log_contains": [ + "dquote-plain=\"hello\"", + "dquote-with-embedded-dquote=\"say \"hi\"\"", + "squote-plain='hello'", + "squote-with-embedded-squote='O''Brien'", + "NOTE: PROC PRINT completed: 2 observations printed, 4 variables" + ], + "log_does_not_contain": ["ERROR:", "[JENNER-ERROR"], + "diagnostics": {"parse_warnings": [], "runtime_warnings": []} +} diff --git a/jenner-check/t001_dquote_squote/expected/log.txt b/jenner-check/t001_dquote_squote/expected/log.txt new file mode 100644 index 0000000..0aca0c4 --- /dev/null +++ b/jenner-check/t001_dquote_squote/expected/log.txt @@ -0,0 +1,15 @@ +dquote-plain="hello" +dquote-with-embedded-dquote="say "hi"" +squote-plain='hello' +squote-with-embedded-squote='O''Brien' +NOTE: Option OBS changed to 100. +NOTE: DATA check + + +NOTE: Wrote check (2 rows, 4 columns). +NOTE: DATA elapsed: + wall 0.00 seconds + cpu 0.00 seconds +NOTE: PROC PRINT data=check + +NOTE: PROC PRINT completed: 2 observations printed, 4 variables diff --git a/jenner-check/t001_dquote_squote/expected/output.txt b/jenner-check/t001_dquote_squote/expected/output.txt new file mode 100644 index 0000000..df9ff91 --- /dev/null +++ b/jenner-check/t001_dquote_squote/expected/output.txt @@ -0,0 +1,5 @@ + raw quoted dequoted matches +------- ------- -------- ------- +hello hello hello 1 +O'Brien O'Brien O'Brien 1 + diff --git a/jenner-check/t001_dquote_squote/meta.json b/jenner-check/t001_dquote_squote/meta.json new file mode 100644 index 0000000..e363e46 --- /dev/null +++ b/jenner-check/t001_dquote_squote/meta.json @@ -0,0 +1,7 @@ +{ + "bundle": "t001_dquote_squote", + "source_file": "dquote.sas, squote.sas", + "source_blob_sha": "dquote.sas=1092347d8e3c931fb039758a15db3af0791c9d13; squote.sas=e15b6ea07d8e20ca4a62e84edaf161a1b60bbcb7", + "source_commit": "c1f45370cbf72e7bd35806712fb2752a62f3e6cb", + "notes": "Both macros wrap SAS QUOTE() to quote a value with double or single quotes, doubling any embedded quote of the same kind. Caller exercises the documented examples plus a round-trip through DEQUOTE() to confirm the quoted text is valid SAS string-literal syntax." +} diff --git a/jenner-check/t001_dquote_squote/script.sas b/jenner-check/t001_dquote_squote/script.sas new file mode 100644 index 0000000..b7b4e3e --- /dev/null +++ b/jenner-check/t001_dquote_squote/script.sas @@ -0,0 +1,45 @@ +/* Caller for %dquote() and %squote() from sasutils/macros + (dquote.sas, squote.sas). Both are tiny function-style macros that + quote a value with double or single quote characters respectively, + handling embedded quote characters and SAS version differences + (squote.sas branches on &sysver for pre-9.3 compatibility). This + bundle drives both directly with plain values and values containing + an embedded quote of their own kind, matching the doubling behavior + SAS's QUOTE() function documents, then confirms the quoted text + round-trips through DEQUOTE() back to the original raw value. */ + +%macro dquote(value); +%sysfunc(quote(%superq(value))) +%mend dquote; + +%macro squote(value); +%if %sysevalf(&sysver < 9.3) %then +%unquote(%str(%')%qsysfunc(tranwrd(%superq(value),%str(%'),''))%str(%')) +; +%else %sysfunc(quote(%superq(value),%str(%'))) ; +%mend squote; + +%put dquote-plain=%dquote(hello); +%put dquote-with-embedded-dquote=%dquote(say %str(%")hi%str(%")); +%put squote-plain=%squote(hello); +%put squote-with-embedded-squote=%squote(O%str(%')Brien); + +data check; + length raw $20 quoted $30 dequoted $20 matches 8; + + raw = 'hello'; + quoted = %dquote(hello); + dequoted = dequote(quoted); + matches = (dequoted = raw); + output; + + raw = "O'Brien"; + quoted = %squote(O%str(%')Brien); + dequoted = dequote(quoted); + matches = (dequoted = raw); + output; +run; + +proc print data=check noobs; + var raw quoted dequoted matches; +run; diff --git a/jenner-check/t002_varexist/autoexec.sas b/jenner-check/t002_varexist/autoexec.sas new file mode 100644 index 0000000..8b0b97f --- /dev/null +++ b/jenner-check/t002_varexist/autoexec.sas @@ -0,0 +1 @@ +options obs=100; /* cap input rows for the captured run */ diff --git a/jenner-check/t002_varexist/expected.json b/jenner-check/t002_varexist/expected.json new file mode 100644 index 0000000..302ab5b --- /dev/null +++ b/jenner-check/t002_varexist/expected.json @@ -0,0 +1,14 @@ +{ + "_captured_at": "2026-07-11T06:30:00Z", + "_captured_run_id": "r_e0e65433292743e5b41c5abd9e10d5c8", + "status": "ok", + "exit_code": 0, + "log_contains": [ + "case1: patients contains SEX (varnum=2)", + "case2: NOSUCHVAR correctly not found", + "case3: NOSUCHDATASET correctly failed to open", + "NOTE: PROC PRINT completed: 4 observations printed, 3 variables" + ], + "log_does_not_contain": ["ERROR:", "[JENNER-ERROR"], + "diagnostics": {"parse_warnings": [], "runtime_warnings": []} +} diff --git a/jenner-check/t002_varexist/expected/log.txt b/jenner-check/t002_varexist/expected/log.txt new file mode 100644 index 0000000..10cdf0c --- /dev/null +++ b/jenner-check/t002_varexist/expected/log.txt @@ -0,0 +1,23 @@ +NOTE: Option OBS changed to 100. +NOTE: DATA patients + +NOTE: Processing inline DATALINES (3 lines) + +NOTE: Read 3 rows from DATALINES. +NOTE: Wrote patients (3 rows, 3 columns). +NOTE: DATA elapsed: + wall 0.00 seconds + cpu 0.00 seconds +case1: patients contains SEX (varnum=2) +case2: NOSUCHVAR correctly not found +case3: NOSUCHDATASET correctly failed to open +NOTE: DATA check + + +NOTE: Wrote check (4 rows, 3 columns). +NOTE: DATA elapsed: + wall 0.00 seconds + cpu 0.00 seconds +NOTE: PROC PRINT data=check + +NOTE: PROC PRINT completed: 4 observations printed, 3 variables diff --git a/jenner-check/t002_varexist/expected/output.txt b/jenner-check/t002_varexist/expected/output.txt new file mode 100644 index 0000000..1e7de2e --- /dev/null +++ b/jenner-check/t002_varexist/expected/output.txt @@ -0,0 +1,7 @@ +variable varnum present +-------- ------ ------- +SEX 2 1 +AGE 3 1 +ID 1 1 +BOGUS 0 0 + diff --git a/jenner-check/t002_varexist/meta.json b/jenner-check/t002_varexist/meta.json new file mode 100644 index 0000000..0bc01ed --- /dev/null +++ b/jenner-check/t002_varexist/meta.json @@ -0,0 +1,7 @@ +{ + "bundle": "t002_varexist", + "source_file": "varexist.sas", + "source_blob_sha": "4d4957bcaff096bc1b17f4880017397c4a67d6d7", + "source_commit": "c1f45370cbf72e7bd35806712fb2752a62f3e6cb", + "notes": "Checks for the existence of a named variable in a dataset via OPEN()/VARNUM()/CLOSE(), returning the variable number or 0. Caller exercises the macro's own documented no-INFO usage pattern (an %IF test for variable presence) against a small inline patient table, plus a nonexistent-dataset case." +} diff --git a/jenner-check/t002_varexist/script.sas b/jenner-check/t002_varexist/script.sas new file mode 100644 index 0000000..62e111f --- /dev/null +++ b/jenner-check/t002_varexist/script.sas @@ -0,0 +1,65 @@ +/* Caller for %varexist() from sasutils/macros (varexist.sas). + %varexist checks whether a named variable exists in a dataset, + returning its variable number (or 0 when the variable or dataset + doesn't exist) via OPEN()/VARNUM()/CLOSE(). This bundle exercises + the default no-INFO usage from the macro's own header example: + testing for the presence of a variable that exists and one that + doesn't, against a small inline patient table. */ + +%macro varexist +(ds +,var +,info +); +%local dsid rc varnum; + +%let dsid = %sysfunc(open(&ds)); + +%if (&dsid) %then %do; + %let varnum = %sysfunc(varnum(&dsid,&var)); + + %if (&varnum) & %length(&info) %then + %sysfunc(var&info(&dsid,&varnum)) + ; + %else + &varnum + ; + + %let rc = %sysfunc(close(&dsid)); +%end; + +%else 0; + +%mend varexist; + +data patients; + length id 8 sex $1 age 8; + input id sex $ age; + datalines; +101 M 34 +102 F 29 +103 M 51 +; +run; + +/* Documented usage from the macro's own header: + %if %varexist(&data,NAME) %then %put input data set contains variable NAME; */ +%if %varexist(patients,sex) %then %put case1: patients contains SEX (varnum=%varexist(patients,sex)); +%else %put case1: SEX not found (unexpected); + +%if %varexist(patients,nosuchvar) %then %put case2: unexpectedly found NOSUCHVAR; +%else %put case2: NOSUCHVAR correctly not found; + +%if %varexist(nosuchdataset,sex) %then %put case3: unexpectedly opened NOSUCHDATASET; +%else %put case3: NOSUCHDATASET correctly failed to open; + +data check; + length variable $20 varnum 8 present 8; + variable='SEX'; varnum=%varexist(patients,sex); present=(varnum>0); output; + variable='AGE'; varnum=%varexist(patients,age); present=(varnum>0); output; + variable='ID'; varnum=%varexist(patients,id); present=(varnum>0); output; + variable='BOGUS'; varnum=%varexist(patients,bogus); present=(varnum>0); output; +run; + +proc print data=check noobs; +run; diff --git a/jenner-check/t003_fileref/autoexec.sas b/jenner-check/t003_fileref/autoexec.sas new file mode 100644 index 0000000..8b0b97f --- /dev/null +++ b/jenner-check/t003_fileref/autoexec.sas @@ -0,0 +1 @@ +options obs=100; /* cap input rows for the captured run */ diff --git a/jenner-check/t003_fileref/expected.json b/jenner-check/t003_fileref/expected.json new file mode 100644 index 0000000..a02b420 --- /dev/null +++ b/jenner-check/t003_fileref/expected.json @@ -0,0 +1,15 @@ +{ + "_captured_at": "2026-07-11T06:40:00Z", + "_captured_run_id": "r_25f31ec0ae084d74b3233141418dda5a", + "status": "ok", + "exit_code": 0, + "log_contains": [ + "case1 (expect 1, blank fileref name)=1", + "case2 (expect 1, name over 8 chars)=1", + "case3 (expect 1, invalid SAS name)=1", + "case5 (expect 0, assigned and exists)=0", + "NOTE: PROC PRINT completed: 5 observations printed, 3 variables" + ], + "log_does_not_contain": ["ERROR:", "[JENNER-ERROR"], + "diagnostics": {"parse_warnings": [], "runtime_warnings": []} +} diff --git a/jenner-check/t003_fileref/expected/log.txt b/jenner-check/t003_fileref/expected/log.txt new file mode 100644 index 0000000..c366ec5 --- /dev/null +++ b/jenner-check/t003_fileref/expected/log.txt @@ -0,0 +1,17 @@ +case1 (expect 1, blank fileref name)=1 +case2 (expect 1, name over 8 chars)=1 +case3 (expect 1, invalid SAS name)=1 +case4 (expect positive, never assigned)=1 +NOTE: Option OBS changed to 100. +NOTE: Fileref MYREF2 assigned to temp file. +case5 (expect 0, assigned and exists)=0 +NOTE: DATA results + + +NOTE: Wrote results (5 rows, 3 columns). +NOTE: DATA elapsed: + wall 0.00 seconds + cpu 0.00 seconds +NOTE: PROC PRINT data=results + +NOTE: PROC PRINT completed: 5 observations printed, 3 variables diff --git a/jenner-check/t003_fileref/expected/output.txt b/jenner-check/t003_fileref/expected/output.txt new file mode 100644 index 0000000..7353d12 --- /dev/null +++ b/jenner-check/t003_fileref/expected/output.txt @@ -0,0 +1,8 @@ + case value as_expected +------------------- ----- ----------- +blank name 1 1 +name over 8 chars 1 1 +invalid SAS name 1 1 +never assigned 1 1 +assigned and exists 0 1 + diff --git a/jenner-check/t003_fileref/meta.json b/jenner-check/t003_fileref/meta.json new file mode 100644 index 0000000..8608382 --- /dev/null +++ b/jenner-check/t003_fileref/meta.json @@ -0,0 +1,7 @@ +{ + "bundle": "t003_fileref", + "source_file": "fileref.sas", + "source_blob_sha": "25ed1cc644a6f37d56c31728c8fbc0ffbae06992", + "source_commit": "c1f45370cbf72e7bd35806712fb2752a62f3e6cb", + "notes": "Verifies whether a fileref has been assigned, validating the fileref name itself (blank, over 8 chars, or not a valid SAS name all short-circuit to 1) before delegating to the native FILEREF() function. Caller exercises all four short-circuit paths plus the real delegation for an assigned vs. never-assigned fileref." +} diff --git a/jenner-check/t003_fileref/script.sas b/jenner-check/t003_fileref/script.sas new file mode 100644 index 0000000..59871f8 --- /dev/null +++ b/jenner-check/t003_fileref/script.sas @@ -0,0 +1,47 @@ +/* Caller for %fileref() from sasutils/macros (fileref.sas). + %fileref verifies whether a fileref has been assigned, validating + the fileref NAME itself (blank, too long, or not a valid SAS name + all short-circuit to 1) before delegating to the native FILEREF() + function. This bundle exercises all four short-circuit paths from + the macro's own header, plus the real FILEREF() delegation for a + fileref that has been assigned via a FILENAME statement versus one + that never was. Results are captured into macro variables first + (rather than used directly as %PUT arguments) since a bare + %sysfunc(fileref(...)) return value is best read that way. */ + +%macro fileref +(fileref +); +%if %bquote(&fileref)= %then 1; +%else %if %length(&fileref) > 8 %then 1; +%else %if 0 = %sysfunc(nvalid(&fileref,v7)) %then 1; +%else %sysfunc(fileref(&fileref)) ; +%mend fileref; + +%let r1 = %fileref(); +%put case1 (expect 1, blank fileref name)=&r1; + +%let r2 = %fileref(reallytoolongname); +%put case2 (expect 1, name over 8 chars)=&r2; + +%let r3 = %fileref(9bad); +%put case3 (expect 1, invalid SAS name)=&r3; + +%let r4 = %fileref(myref99); +%put case4 (expect positive, never assigned)=&r4; + +filename myref2 temp; +%let r5 = %fileref(myref2); +%put case5 (expect 0, assigned and exists)=&r5; + +data results; + length case $40 value 8 as_expected 8; + case='blank name'; value=&r1; as_expected=(value=1); output; + case='name over 8 chars'; value=&r2; as_expected=(value=1); output; + case='invalid SAS name'; value=&r3; as_expected=(value=1); output; + case='never assigned'; value=&r4; as_expected=(value>0); output; + case='assigned and exists'; value=&r5; as_expected=(value=0); output; +run; + +proc print data=results noobs; +run;