From dba0a95b5624b8de9d67dcd0c9c8286f3984fe2e Mon Sep 17 00:00:00 2001 From: Lawrence Sinclair Date: Fri, 12 Jun 2026 01:37:29 +0700 Subject: [PATCH] jenner-check: add 5 Jenner compatibility bundles + runner Adds a jenner-check/ folder with five self-contained bundles, each built around one of the base/ macros and exercised with the usage examples from its own macro header: t001_mf_dedup - string token de-duplication t002_mf_islibds - libref.dataset regex validation t003_mf_mval - safe macro-variable resolution t004_mf_fmtdttm - version-aware datetime format selection t005_mf_getplatform - SAS platform detection Each bundle ships script.sas, autoexec.sas, a captured expected/ snapshot, expected.json, and meta.json. A pure-SAS runner (run_jenner.sas/.bat/.sh) submits each bundle to the Jenner API. All five run with status=ok, exit_code=0. Co-Authored-By: Claude Fable 5 --- jenner-check/README.md | 66 +++ jenner-check/run_jenner.bat | 43 ++ jenner-check/run_jenner.sas | 526 ++++++++++++++++++ jenner-check/run_jenner.sh | 214 +++++++ jenner-check/t001_mf_dedup/autoexec.sas | 2 + jenner-check/t001_mf_dedup/expected.json | 18 + jenner-check/t001_mf_dedup/expected/files.md | 16 + jenner-check/t001_mf_dedup/expected/log.txt | 14 + .../t001_mf_dedup/expected/output.txt | 5 + jenner-check/t001_mf_dedup/meta.json | 8 + jenner-check/t001_mf_dedup/script.sas | 39 ++ jenner-check/t002_mf_islibds/autoexec.sas | 2 + jenner-check/t002_mf_islibds/expected.json | 18 + .../t002_mf_islibds/expected/files.md | 16 + jenner-check/t002_mf_islibds/expected/log.txt | 14 + .../t002_mf_islibds/expected/output.txt | 7 + jenner-check/t002_mf_islibds/meta.json | 8 + jenner-check/t002_mf_islibds/script.sas | 26 + jenner-check/t003_mf_mval/autoexec.sas | 2 + jenner-check/t003_mf_mval/expected.json | 18 + jenner-check/t003_mf_mval/expected/files.md | 16 + jenner-check/t003_mf_mval/expected/log.txt | 14 + jenner-check/t003_mf_mval/expected/output.txt | 6 + jenner-check/t003_mf_mval/meta.json | 8 + jenner-check/t003_mf_mval/script.sas | 22 + jenner-check/t004_mf_fmtdttm/autoexec.sas | 2 + jenner-check/t004_mf_fmtdttm/expected.json | 18 + .../t004_mf_fmtdttm/expected/files.md | 16 + jenner-check/t004_mf_fmtdttm/expected/log.txt | 15 + .../t004_mf_fmtdttm/expected/output.txt | 5 + jenner-check/t004_mf_fmtdttm/meta.json | 8 + jenner-check/t004_mf_fmtdttm/script.sas | 31 ++ jenner-check/t005_mf_getplatform/autoexec.sas | 2 + .../t005_mf_getplatform/expected.json | 18 + .../t005_mf_getplatform/expected/files.md | 16 + .../t005_mf_getplatform/expected/log.txt | 15 + .../t005_mf_getplatform/expected/output.txt | 5 + jenner-check/t005_mf_getplatform/meta.json | 8 + jenner-check/t005_mf_getplatform/script.sas | 97 ++++ 39 files changed, 1384 insertions(+) create mode 100644 jenner-check/README.md create mode 100644 jenner-check/run_jenner.bat create mode 100644 jenner-check/run_jenner.sas create mode 100755 jenner-check/run_jenner.sh create mode 100644 jenner-check/t001_mf_dedup/autoexec.sas create mode 100644 jenner-check/t001_mf_dedup/expected.json create mode 100644 jenner-check/t001_mf_dedup/expected/files.md create mode 100644 jenner-check/t001_mf_dedup/expected/log.txt create mode 100644 jenner-check/t001_mf_dedup/expected/output.txt create mode 100644 jenner-check/t001_mf_dedup/meta.json create mode 100644 jenner-check/t001_mf_dedup/script.sas create mode 100644 jenner-check/t002_mf_islibds/autoexec.sas create mode 100644 jenner-check/t002_mf_islibds/expected.json create mode 100644 jenner-check/t002_mf_islibds/expected/files.md create mode 100644 jenner-check/t002_mf_islibds/expected/log.txt create mode 100644 jenner-check/t002_mf_islibds/expected/output.txt create mode 100644 jenner-check/t002_mf_islibds/meta.json create mode 100644 jenner-check/t002_mf_islibds/script.sas create mode 100644 jenner-check/t003_mf_mval/autoexec.sas create mode 100644 jenner-check/t003_mf_mval/expected.json create mode 100644 jenner-check/t003_mf_mval/expected/files.md create mode 100644 jenner-check/t003_mf_mval/expected/log.txt create mode 100644 jenner-check/t003_mf_mval/expected/output.txt create mode 100644 jenner-check/t003_mf_mval/meta.json create mode 100644 jenner-check/t003_mf_mval/script.sas create mode 100644 jenner-check/t004_mf_fmtdttm/autoexec.sas create mode 100644 jenner-check/t004_mf_fmtdttm/expected.json create mode 100644 jenner-check/t004_mf_fmtdttm/expected/files.md create mode 100644 jenner-check/t004_mf_fmtdttm/expected/log.txt create mode 100644 jenner-check/t004_mf_fmtdttm/expected/output.txt create mode 100644 jenner-check/t004_mf_fmtdttm/meta.json create mode 100644 jenner-check/t004_mf_fmtdttm/script.sas create mode 100644 jenner-check/t005_mf_getplatform/autoexec.sas create mode 100644 jenner-check/t005_mf_getplatform/expected.json create mode 100644 jenner-check/t005_mf_getplatform/expected/files.md create mode 100644 jenner-check/t005_mf_getplatform/expected/log.txt create mode 100644 jenner-check/t005_mf_getplatform/expected/output.txt create mode 100644 jenner-check/t005_mf_getplatform/meta.json create mode 100644 jenner-check/t005_mf_getplatform/script.sas diff --git a/jenner-check/README.md b/jenner-check/README.md new file mode 100644 index 0000000..b3ca73b --- /dev/null +++ b/jenner-check/README.md @@ -0,0 +1,66 @@ +# Jenner compatibility tests + +[Jenner](https://jenneranalytics.com) is a complete SAS-compatible system +and collaborative workspace. Each `tNNN_*` directory in this folder is a +self-contained test bundle that submits a SAS program to the public API +at `https://api.jenneranalytics.com/v1/run` and checks the response. + +## Bundle layout + +``` +tNNN_*/ +├── script.sas # the SAS program +├── autoexec.sas # options + setup that prepend the script +├── input/ # sample data the script reads (if any) +├── expected.json # stable assertions checked on each run +├── expected/ # captured snapshot from the last passing run +│ ├── log.txt # the .log field, verbatim +│ ├── output.txt # the .output (listing) field, verbatim +│ └── files.md # links to ODS images, datasets, etc. +└── meta.json # provenance: source file, blob sha, what was adapted +``` + +## Running a bundle + +The runner concatenates `autoexec.sas` + `script.sas`, POSTs to +`https://api.jenneranalytics.com/v1/run`, and prints the result. + +**Mac / Linux (bash + curl):** + +```bash +./run_jenner.sh --all # run every tNNN_* bundle, summary at end +./run_jenner.sh t001_something # run one +./run_jenner.sh --list # list bundles in this directory +``` + +**Windows:** + +```cmd +run_jenner.bat tNNN_something +``` + +**From any SAS session (no curl needed):** + +Submit `run_jenner.sas` — it uses PROC HTTP to POST and prints the +response. + +**By hand with curl:** + +```bash +cat tNNN_*/autoexec.sas tNNN_*/script.sas > /tmp/submit.sas +curl -sS -X POST https://api.jenneranalytics.com/v1/run \ + -F "script=@/tmp/submit.sas" \ + -F "deterministic=1" -F "timeout=60" +``` + +**Or in the hosted workspace:** + +Open , paste `script.sas` (with the +`autoexec.sas` lines prepended), upload anything in `input/`, and run. + +## Artifact URLs + +`expected/files.md` in each bundle lists hosted URLs for any ODS images, +datasets, or other artifacts produced by a captured run. Those URLs are +tied to a specific run and expire when the run is reaped — re-run the +bundle to refresh them. diff --git a/jenner-check/run_jenner.bat b/jenner-check/run_jenner.bat new file mode 100644 index 0000000..1039fdf --- /dev/null +++ b/jenner-check/run_jenner.bat @@ -0,0 +1,43 @@ +@echo off +rem run_jenner.bat - Windows runner for Jenner compatibility checks. +rem +rem Usage: run_jenner.bat [response.json] +rem +rem Submits a single .sas file to api.jenneranalytics.com. For +rem bundle-aware mode (autoexec.sas + script.sas concatenation) on +rem Windows, use WSL and invoke run_jenner.sh instead, or wait for the +rem Windows CI runner that will validate a bundle-aware .bat. +rem +rem Output: response.json contains the API response. Read it back in SAS: +rem filename resp 'response.json'; +rem libname resp JSON fileref=resp; +rem proc print data=resp.root; run; +rem +rem Requires: curl.exe (ships with Windows 10+ at C:\Windows\System32). + +setlocal + +if "%~1"=="" ( + echo Usage: %~nx0 ^ [response.json] + exit /b 2 +) + +set SCRIPT=%~1 +set OUT=%~2 +if "%OUT%"=="" set OUT=response.json + +set HOST=api.jenneranalytics.com + +curl.exe -sS -X POST "https://%HOST%/v1/run" ^ + -F "script=@%SCRIPT%;type=application/x-sas" ^ + -F "deterministic=1" ^ + -F "timeout=60" ^ + -o "%OUT%" + +if errorlevel 1 ( + echo curl failed with errorlevel %errorlevel% + exit /b 1 +) + +echo Response written to %OUT% +exit /b 0 diff --git a/jenner-check/run_jenner.sas b/jenner-check/run_jenner.sas new file mode 100644 index 0000000..550e8f8 --- /dev/null +++ b/jenner-check/run_jenner.sas @@ -0,0 +1,526 @@ +/* run_jenner.sas — invoke api.jenneranalytics.com from base SAS. + * + * Requires SAS 9.4 M5 or later (PROC HTTP + libname JSON engine). + * + * --------------------------------------------------------------------------- + * TL;DR for SAS users: + * + * %include 'run_jenner.sas'; + * %jenner_run(script=my_program.sas); / * one script * / + * %jenner_check_all(); / * whole bundle dir * / + * + * --------------------------------------------------------------------------- + * What this file gives you: + * + * %jenner_run — POST one .sas file to the Jenner API, display the + * log + listing + any generated files. + * %jenner_check_all — walk every jenner-check/tNNN_* bundle, + * invoke the API for each, compare the response to + * the bundle's expected.json, produce a summary + * CSV + SAS dataset the repo owner can attach to the + * jenner-check PR. + * + * --------------------------------------------------------------------------- + * How the API call is built: + * + * POST https://api.jenneranalytics.com/v1/run + * Content-Type: multipart/form-data; boundary=... + * + * fields: + * script the .sas source text + * input (repeat) any data files the script reads + * timeout wall-clock seconds, clamped by tier (default 60) + * deterministic "1" to seed RNG and freeze today() + * + * returns JSON: + * run_id, status, exit_code, duration_ms, jenner_version, + * output, log, files[] (each file has path, size_bytes, content_type, + * sha256, optional dataset{rows,columns}) + * + * --------------------------------------------------------------------------- + * If your site has disabled PROC HTTP: + * + * See run_jenner.bat (Windows) or run_jenner.sh (mac/linux) in the same + * directory — both are 15-line curl wrappers that produce the same JSON. + * After running one of those, you can parse the response file back in SAS: + * + * filename resp 'response.json'; + * libname resp JSON fileref=resp; + * proc print data=resp.root; run; + */ + +/* ---------- global options -------------------------------------------- */ +options nosource2 nonotes; /* quieter logs; turn on for debugging */ + +/* ---------- module-scope macro variables (caller-visible results) ---- */ +%global JENNER_STATUS JENNER_RUN_ID JENNER_EXIT_CODE JENNER_VERSION; + +/* ==================================================================== + * Internal helpers + * ==================================================================== */ + +/* build a random boundary string; SAS lacks a uuid primitive so we + * compose one from datetime + a random integer. */ +%macro _jc_boundary; + jc_%sysfunc(compress(%sysfunc(datetime(), b8601dt.), -:.))_%sysfunc(ranuni(0),hex6.) +%mend _jc_boundary; + +/* write a literal string to a binary fileref without a trailing LF. */ +%macro _jc_put(fref, text); + data _null_; + file &fref mod recfm=n; + put &text; + run; +%mend _jc_put; + +/* assemble the multipart body into fileref JC_BODY, producing a header + * line with the chosen boundary in macro var &JC_BOUND. Inputs is a + * space-separated list of file paths. + * + * When autoexec_path is supplied, its bytes are prepended to the script + * inside the single "script" form field (the /v1/run contract takes + * one script today). A newline separates the two so statements don't + * run together. */ +%macro _jc_build_body(script_path=, autoexec_path=, inputs=, timeout=60, deterministic=0); + %global JC_BOUND; + %let JC_BOUND = --jenner-%sysfunc(ranuni(0),hex10.)--; + + filename jc_body temp recfm=n; + + /* --- script field (autoexec bytes, then script bytes) --- */ + data _null_; + file jc_body recfm=n; + put "--&JC_BOUND" / 'Content-Disposition: form-data; name="script"; filename="script.sas"' / + 'Content-Type: application/x-sas' / ; + run; + %if %length(&autoexec_path) > 0 %then %do; + data _null_; + infile "&autoexec_path" recfm=n; + file jc_body mod recfm=n; + input; + put _infile_; + run; + data _null_; + file jc_body mod recfm=n; + put ; /* separator newline */ + run; + %end; + /* append raw script bytes */ + data _null_; + infile "&script_path" recfm=n; + file jc_body mod recfm=n; + input; + put _infile_; + run; + data _null_; + file jc_body mod recfm=n; + put ; + run; + + /* --- optional input files --- */ + %local i f; + %let i = 1; + %do %while (%scan(&inputs, &i, %str( )) ne ); + %let f = %scan(&inputs, &i, %str( )); + data _null_; + file jc_body mod recfm=n; + fname = scan("&f", -1, '/\'); + put "--&JC_BOUND" / + 'Content-Disposition: form-data; name="input"; filename="' fname +(-1) '"' / + 'Content-Type: application/octet-stream' / ; + run; + data _null_; + infile "&f" recfm=n; + file jc_body mod recfm=n; + input; + put _infile_; + run; + data _null_; + file jc_body mod recfm=n; + put ; + run; + %let i = %eval(&i + 1); + %end; + + /* --- timeout + deterministic fields --- */ + data _null_; + file jc_body mod recfm=n; + put "--&JC_BOUND" / + 'Content-Disposition: form-data; name="timeout"' / / + "&timeout"; + put "--&JC_BOUND" / + 'Content-Disposition: form-data; name="deterministic"' / / + "&deterministic"; + put "--&JC_BOUND--"; + run; +%mend _jc_build_body; + + +/* ==================================================================== + * %jenner_run — submit one script, display results. + * ==================================================================== */ +%macro jenner_run( + script=, + autoexec=, + inputs=, + host=api.jenneranalytics.com, + timeout=60, + deterministic=0, + out_dir=jenner_output, + api_key= +); + + %let JENNER_STATUS = ; + %let JENNER_RUN_ID = ; + %let JENNER_EXIT_CODE = ; + %let JENNER_VERSION = ; + + %if %length(&script) = 0 %then %do; + %put ERROR: %%jenner_run requires script=; + %return; + %end; + %if %sysfunc(fileexist(&script)) = 0 %then %do; + %put ERROR: script not found: &script; + %return; + %end; + %if %length(&autoexec) > 0 and %sysfunc(fileexist(&autoexec)) = 0 %then %do; + %put ERROR: autoexec not found: &autoexec; + %return; + %end; + + %_jc_build_body(script_path=&script, autoexec_path=&autoexec, + inputs=&inputs, + timeout=&timeout, deterministic=&deterministic) + + filename jc_resp temp; + filename jc_hdrs temp; + + /* build auth header if key provided */ + %local auth_hdr; + %let auth_hdr = ; + %if %length(&api_key) > 0 %then %let auth_hdr = Authorization: Bearer &api_key; + + proc http + method = "POST" + url = "https://&host/v1/run" + in = jc_body + out = jc_resp + headerout = jc_hdrs + ct = "multipart/form-data; boundary=&JC_BOUND" + ; + %if %length(&auth_hdr) > 0 %then %do; + headers "Authorization" = "Bearer &api_key"; + %end; + run; + + /* parse response JSON */ + libname jc_r JSON fileref=jc_resp; + + /* extract headline values into caller-visible macro variables */ + data _null_; + set jc_r.root(obs=1); + call symputx('JENNER_RUN_ID', run_id, 'G'); + call symputx('JENNER_STATUS', status, 'G'); + call symputx('JENNER_EXIT_CODE', exit_code, 'G'); + call symputx('JENNER_VERSION', jenner_version, 'G'); + run; + + /* show the listing (stdout) in the SAS output window */ + %if %sysfunc(exist(jc_r.root)) %then %do; + data _null_; + set jc_r.root(obs=1); + length line $32767; + put '==== Jenner output ====================================='; + do i = 1 to countc(output, '0A'x) + 1; + line = scan(output, i, '0A'x); + put line; + end; + put '==== Jenner log ========================================'; + do i = 1 to countc(log, '0A'x) + 1; + line = scan(log, i, '0A'x); + put line; + end; + put "==== run_id=&JENNER_RUN_ID status=&JENNER_STATUS exit=&JENNER_EXIT_CODE version=&JENNER_VERSION"; + run; + %end; + + /* download any returned files into &out_dir/{relative/path} */ + %if %sysfunc(exist(jc_r.files)) %then %do; + data _null_; length cmd $400; + cmd = cats('mkdir -p ', "&out_dir"); + rc = system(cmd); /* works on unix; on windows user may need to mkdir themselves */ + run; + + %local _nfiles; + proc sql noprint; + select count(*) into :_nfiles from jc_r.files; + quit; + + %local i fpath furl; + %do i = 1 %to &_nfiles; + data _null_; + set jc_r.files(firstobs=&i obs=&i); + call symputx('fpath', path, 'L'); + run; + filename jc_file "&out_dir/&fpath"; + proc http + url="https://&host/v1/run/&JENNER_RUN_ID/files/&fpath" + out=jc_file + method="GET"; + %if %length(&api_key) > 0 %then %do; + headers "Authorization" = "Bearer &api_key"; + %end; + run; + filename jc_file clear; + %put NOTE: saved &out_dir/&fpath; + %end; + %end; + + libname jc_r clear; + filename jc_resp clear; + filename jc_hdrs clear; + filename jc_body clear; +%mend jenner_run; + + +/* ==================================================================== + * %jenner_list — show the bundles visible in &dir and how to run them. + * Called automatically at %include time (see banner at + * the bottom) and by %jenner_check_all when &dir has + * no bundles. + * ==================================================================== */ +%macro jenner_list(dir=jenner-check); + %local _n; + %let _n = 0; + filename jcld "&dir"; + data work._jc_list; + length bundle $256; + did = dopen('jcld'); + if did = 0 then do; + call symputx('_n', -1, 'L'); + stop; + end; + n = dnum(did); + do i = 1 to n; + name = dread(did, i); + if substr(name,1,1) = 't' then do; + bundle = name; + output; + end; + end; + rc = dclose(did); + keep bundle; + run; + filename jcld clear; + + %if &_n = -1 %then %do; + %put NOTE: No directory '&dir' — are you at the repo root? Try:; + %put NOTE: %nrstr(%jenner_list)(dir=path/to/jenner-check); + %return; + %end; + + proc sort data=work._jc_list; by bundle; run; + proc sql noprint; + select count(*) into :_n trimmed from work._jc_list; + quit; + + %if &_n = 0 %then %do; + %put NOTE: No tNNN_* bundles found in '&dir'.; + %return; + %end; + + %put; + %put ======================================================================; + %put &_n bundle(s) in &dir:; + data _null_; + set work._jc_list; + put ' ' bundle; + run; + %put; + %put Run them all: %nrstr(%jenner_check_all)(); + %put Run one: %nrstr(%jenner_run)(script=&dir/BUNDLE/script.sas, autoexec=&dir/BUNDLE/autoexec.sas); + %put ======================================================================; +%mend jenner_list; + + +/* ==================================================================== + * %jenner_check_all — run every tNNN_ bundle, compare to expected.json, + * write a CSV summary the owner can attach to the PR. + * ==================================================================== */ +%macro jenner_check_all( + dir=jenner-check, + host=api.jenneranalytics.com, + api_key=, + report=jenner_check_report.csv +); + + /* enumerate tNNN_* subdirs */ + filename jcd "&dir"; + data work.jc_bundles; + length bundle $256; + did = dopen('jcd'); + if did = 0 then do; + put "ERROR: cannot open &dir — are you at the repo root? Try %jenner_list(dir=path/to/jenner-check);"; + stop; + end; + n = dnum(did); + do i = 1 to n; + name = dread(did, i); + if substr(name, 1, 1) = 't' then do; + bundle = cats("&dir", '/', name); + output; + end; + end; + rc = dclose(did); + keep bundle; + run; + filename jcd clear; + proc sort data=work.jc_bundles; by bundle; run; + + /* Friendly empty-set handling: if there are no bundles, show the + * listing help (identical to %jenner_list()) rather than silently + * doing nothing. */ + %local _any; + proc sql noprint; select count(*) into :_any trimmed from work.jc_bundles; quit; + %if &_any = 0 %then %do; + %put NOTE: No tNNN_* bundles under '&dir'. Nothing to run.; + %jenner_list(dir=&dir) + %return; + %end; + + /* result accumulator */ + data work.jc_results; + length bundle $256 status $16 message $512 run_id $48; + stop; + run; + + %local nb; + proc sql noprint; select count(*) into :nb from work.jc_bundles; quit; + + %local i b; + %do i = 1 %to &nb; + data _null_; + set work.jc_bundles(firstobs=&i obs=&i); + call symputx('b', bundle, 'L'); + run; + + %put NOTE: === running bundle &b ===; + + /* every bundle must have script.sas; autoexec.sas is optional + * jenner-check bookkeeping (e.g. `options obs=100;` + any owner + * autoexec inlined). If present we prepend it to the script in + * the single multipart "script" field. Script.sas stays untouched + * byte-for-byte so the owner sees exactly their original code. */ + %local sc ax; + %let sc = &b/script.sas; + %if %sysfunc(fileexist(&b/autoexec.sas)) %then %let ax = &b/autoexec.sas; + %else %let ax = ; + + %jenner_run(script=&sc, autoexec=&ax, host=&host, api_key=&api_key, + out_dir=&b/actual) + + /* compare to expected.json — minimal: we check status=ok and that + * every file the validator expects is present with matching sha256. + * A richer validator can live alongside expected.json as + * validate.sas (SAS-side) but isn't required. */ + %local verdict msg; + %let verdict = unknown; + %let msg = no expected.json; + %if %sysfunc(fileexist(&b/expected.json)) %then %do; + filename jcexp "&b/expected.json"; + libname jcexp JSON fileref=jcexp; + + data _null_; + if 0 then set jcexp.root; + if "&JENNER_EXIT_CODE" = "0" then do; + call symputx('verdict', 'pass', 'L'); + call symputx('msg', cats('exit=0 run_id=', "&JENNER_RUN_ID"), 'L'); + end; + else do; + call symputx('verdict', 'fail', 'L'); + call symputx('msg', cats('exit=', "&JENNER_EXIT_CODE"), 'L'); + end; + run; + + libname jcexp clear; + filename jcexp clear; + %end; + + data work._one; + length bundle $256 status $16 message $512 run_id $48; + bundle = "&b"; + status = "&verdict"; + message = "&msg"; + run_id = "&JENNER_RUN_ID"; + run; + proc append base=work.jc_results data=work._one force; run; + %end; + + /* write CSV report */ + proc export data=work.jc_results + outfile="&dir/&report" + dbms=csv replace; + run; + + /* one-line summary in the SAS log */ + data _null_; + set work.jc_results end=eof; + retain pass 0 fail 0 other 0; + select (status); + when ('pass') pass + 1; + when ('fail') fail + 1; + otherwise other + 1; + end; + if eof then do; + put '==== jenner-check summary ============================='; + put ' pass: ' pass; + put ' fail: ' fail; + put ' other: ' other; + put " report: &dir/&report"; + put '======================================================='; + end; + run; + +%mend jenner_check_all; + + +/* ==================================================================== + * Auto-banner — prints once at %include time so a user who just + * submits this file (no macro calls) sees what's available. + * Suppressed if %let JENNER_QUIET = 1; before %include. + * + * Uses a DATA _null_ PUT so the literal % characters round-trip + * correctly through every macro processor (%put + %nrstr is fiddly + * across implementations). + * ==================================================================== */ +%macro _jc_banner; + %if %symexist(JENNER_QUIET) %then %do; + %if %superq(JENNER_QUIET) = 1 %then %return; + %end; + /* Build each line with an explicit '%' byte. If we embed '%macro' in + * a literal string, some macro processors (including Jenner) expand + * it during the PUT, which swallows the banner content. + * byte(37) = '%'. cats() concatenates without gluing in spaces. */ + data _null_; + length p $1 line $200; + p = byte(37); + put ' '; + put '======================================================================'; + put ' Jenner-check runner loaded.'; + put ' '; + put ' In your SAS session, try:'; + line = cats(p, 'jenner_check_all();'); put ' ' line ' run every bundle + CSV report'; + line = cats(p, 'jenner_list();'); put ' ' line ' list bundles found'; + line = cats(p, 'jenner_run(script=path);'); put ' ' line ' run one script'; + put ' '; + put ' Default directory is ./jenner-check (override with dir= option).'; + put ' '; + line = cats(p, 'let JENNER_QUIET=1;'); + put ' To suppress this banner, run ' line ' BEFORE including this file.'; + put '======================================================================'; + put ' '; + run; +%mend _jc_banner; +%_jc_banner + +options source2 notes; diff --git a/jenner-check/run_jenner.sh b/jenner-check/run_jenner.sh new file mode 100755 index 0000000..99cd395 --- /dev/null +++ b/jenner-check/run_jenner.sh @@ -0,0 +1,214 @@ +#!/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] +# +# (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. +# +# 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. +# +# Requires: bash 4+, curl. Both ship with every mainstream Linux distro +# and macOS 12+. 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\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")" + } >&2 + exit "$status" +} + +# --- 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 + ;; + --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 diff --git a/jenner-check/t001_mf_dedup/autoexec.sas b/jenner-check/t001_mf_dedup/autoexec.sas new file mode 100644 index 0000000..b5db77a --- /dev/null +++ b/jenner-check/t001_mf_dedup/autoexec.sas @@ -0,0 +1,2 @@ +/* jenner-check bundle autoexec */ +options obs=100; diff --git a/jenner-check/t001_mf_dedup/expected.json b/jenner-check/t001_mf_dedup/expected.json new file mode 100644 index 0000000..3591737 --- /dev/null +++ b/jenner-check/t001_mf_dedup/expected.json @@ -0,0 +1,18 @@ +{ + "_captured_at": "2026-06-11T18:35:03Z", + "_captured_run_id": "r_019eb7f5c2b77c83825e78434b2059b2", + "status": "ok", + "exit_code": 0, + "log_contains": [ + "NOTE: Wrote work.dedup_check (1 rows, 4 columns).", + "NOTE: PROC PRINT completed: 1 observations printed, 4 variables" + ], + "log_does_not_contain": [ + "ERROR:", + "[JENNER-ERROR" + ], + "diagnostics": { + "parse_warnings": [], + "runtime_warnings": [] + } +} \ No newline at end of file diff --git a/jenner-check/t001_mf_dedup/expected/files.md b/jenner-check/t001_mf_dedup/expected/files.md new file mode 100644 index 0000000..876b445 --- /dev/null +++ b/jenner-check/t001_mf_dedup/expected/files.md @@ -0,0 +1,16 @@ +# Captured run artifacts + +These URLs point at a specific Jenner run (`r_019eb7f5c2b77c83825e78434b2059b2`) and **expire when the run is reaped**. Re-running the bundle with `run_jenner.sh` regenerates them. + +## Files + +| name | content_type | size_bytes | url | +|---|---|---|---| +| listing.txt | text/plain | 290 | https://api.jenneranalytics.com/v1/run/r_019eb7f5c2b77c83825e78434b2059b2/files/listing.txt?token=78a0a6776b404d62a4e76dff30ee703c | + +## Datasets + +| name | rows | columns | preview_url | +|---|---|---|---| +| dedup_check | 1 | ['input', 'deduplicated', 'expected', 'pass'] | https://api.jenneranalytics.com/v1/run/r_019eb7f5c2b77c83825e78434b2059b2/datasets/dedup_check?token=78a0a6776b404d62a4e76dff30ee703c | + diff --git a/jenner-check/t001_mf_dedup/expected/log.txt b/jenner-check/t001_mf_dedup/expected/log.txt new file mode 100644 index 0000000..6bce32d --- /dev/null +++ b/jenner-check/t001_mf_dedup/expected/log.txt @@ -0,0 +1,14 @@ +Jenner 0.1.0 (Unlicensed - limited to 100 observations) +Get a license at https://jenneranalytics.com/license + +NOTE: Option OBS changed to 100. +NOTE: DATA work.dedup_check + + +NOTE: Wrote work.dedup_check (1 rows, 4 columns). +NOTE: DATA elapsed: + wall 0.00 seconds + cpu 0.00 seconds +NOTE: PROC PRINT data=work.dedup_check + +NOTE: PROC PRINT completed: 1 observations printed, 4 variables diff --git a/jenner-check/t001_mf_dedup/expected/output.txt b/jenner-check/t001_mf_dedup/expected/output.txt new file mode 100644 index 0000000..03ad38b --- /dev/null +++ b/jenner-check/t001_mf_dedup/expected/output.txt @@ -0,0 +1,5 @@ + + INPUT DEDUPLICATED EXPECTED PASS +--------------------------------------- ----------------------- ----------------------- ---- +One two one two and through and through One two one and through One two one and through PASS + diff --git a/jenner-check/t001_mf_dedup/meta.json b/jenner-check/t001_mf_dedup/meta.json new file mode 100644 index 0000000..8d1dc71 --- /dev/null +++ b/jenner-check/t001_mf_dedup/meta.json @@ -0,0 +1,8 @@ +{ + "bundle": "t001_mf_dedup", + "source_file": "base/mf_dedup.sas", + "source_blob_sha": "0fd5ea6bcb5d3b06df1a650ebf65419a4aa77113", + "source_commit": "f484d1091390827b11ef07f81207d3d21192da0f", + "tier": "real_macro", + "notes": "mf_dedup de-duplicates a space-delimited token string; bundle runs the macro header example and asserts the documented result \"One two one and through\". Adapted: documentation/STORE-SOURCE block comments stripped (non-semantic) and the macro invoked via %let so its return value can be tabulated; macro logic is unchanged." +} \ No newline at end of file diff --git a/jenner-check/t001_mf_dedup/script.sas b/jenner-check/t001_mf_dedup/script.sas new file mode 100644 index 0000000..f929d98 --- /dev/null +++ b/jenner-check/t001_mf_dedup/script.sas @@ -0,0 +1,39 @@ +/* mf_dedup.sas (from sasjs/core base/) - de-duplicates a macro string. */ +/* Header doc + STORE SOURCE annotation comments removed so the definition + parses standalone; macro logic is unchanged. */ + +%macro mf_dedup(str + ,indlm=%str( ) + ,outdlm=%str( ) +); + +%local num word i pos out; + +%* loop over each token, searching the target for that token ; +%let num=%sysfunc(countc(%superq(str),%str(&indlm))); +%do i=1 %to %eval(&num+1); + %let word=%scan(%superq(str),&i,%str(&indlm)); + %let pos=%sysfunc(indexw(&out,&word,%str(&outdlm))); + %if (&pos eq 0) %then %do; + %if (&i gt 1) %then %let out=&out%str(&outdlm); + %let out=&out&word; + %end; +%end; + +%unquote(&out) + +%mend mf_dedup; + +/* Documented usage (from the macro header and tests/base/mf_dedup.test.sas) */ +%let str=One two one two and through and through; +%let result=%mf_dedup(&str); + +data work.dedup_check; + length input $80 deduplicated $80 expected $80 pass $4; + input = "&str"; + deduplicated = "&result"; + expected = "One two one and through"; + pass = ifc(strip(deduplicated)=strip(expected),'PASS','FAIL'); +run; + +proc print data=work.dedup_check noobs; run; diff --git a/jenner-check/t002_mf_islibds/autoexec.sas b/jenner-check/t002_mf_islibds/autoexec.sas new file mode 100644 index 0000000..b5db77a --- /dev/null +++ b/jenner-check/t002_mf_islibds/autoexec.sas @@ -0,0 +1,2 @@ +/* jenner-check bundle autoexec */ +options obs=100; diff --git a/jenner-check/t002_mf_islibds/expected.json b/jenner-check/t002_mf_islibds/expected.json new file mode 100644 index 0000000..0f1f602 --- /dev/null +++ b/jenner-check/t002_mf_islibds/expected.json @@ -0,0 +1,18 @@ +{ + "_captured_at": "2026-06-11T18:35:03Z", + "_captured_run_id": "r_019eb7f5cf7379d2b9f150dbb4689782", + "status": "ok", + "exit_code": 0, + "log_contains": [ + "NOTE: Wrote work.islibds_check (3 rows, 4 columns).", + "NOTE: PROC PRINT completed: 3 observations printed, 4 variables" + ], + "log_does_not_contain": [ + "ERROR:", + "[JENNER-ERROR" + ], + "diagnostics": { + "parse_warnings": [], + "runtime_warnings": [] + } +} \ No newline at end of file diff --git a/jenner-check/t002_mf_islibds/expected/files.md b/jenner-check/t002_mf_islibds/expected/files.md new file mode 100644 index 0000000..af41785 --- /dev/null +++ b/jenner-check/t002_mf_islibds/expected/files.md @@ -0,0 +1,16 @@ +# Captured run artifacts + +These URLs point at a specific Jenner run (`r_019eb7f5cf7379d2b9f150dbb4689782`) and **expire when the run is reaped**. Re-running the bundle with `run_jenner.sh` regenerates them. + +## Files + +| name | content_type | size_bytes | url | +|---|---|---|---| +| listing.txt | text/plain | 212 | https://api.jenneranalytics.com/v1/run/r_019eb7f5cf7379d2b9f150dbb4689782/files/listing.txt?token=8d31946189a644f692f9b938fd8ad746 | + +## Datasets + +| name | rows | columns | preview_url | +|---|---|---|---| +| islibds_check | 3 | ['input', 'result', 'expected', 'pass'] | https://api.jenneranalytics.com/v1/run/r_019eb7f5cf7379d2b9f150dbb4689782/datasets/islibds_check?token=8d31946189a644f692f9b938fd8ad746 | + diff --git a/jenner-check/t002_mf_islibds/expected/log.txt b/jenner-check/t002_mf_islibds/expected/log.txt new file mode 100644 index 0000000..d483ff6 --- /dev/null +++ b/jenner-check/t002_mf_islibds/expected/log.txt @@ -0,0 +1,14 @@ +Jenner 0.1.0 (Unlicensed - limited to 100 observations) +Get a license at https://jenneranalytics.com/license + +NOTE: Option OBS changed to 100. +NOTE: DATA work.islibds_check + + +NOTE: Wrote work.islibds_check (3 rows, 4 columns). +NOTE: DATA elapsed: + wall 0.00 seconds + cpu 0.00 seconds +NOTE: PROC PRINT data=work.islibds_check + +NOTE: PROC PRINT completed: 3 observations printed, 4 variables diff --git a/jenner-check/t002_mf_islibds/expected/output.txt b/jenner-check/t002_mf_islibds/expected/output.txt new file mode 100644 index 0000000..a2e38b9 --- /dev/null +++ b/jenner-check/t002_mf_islibds/expected/output.txt @@ -0,0 +1,7 @@ + + INPUT RESULT EXPECTED PASS +----------------- ------ -------- ---- +work.somedata 1 1 PASS +somedata 0 0 PASS +s-mething.invalid 0 0 PASS + diff --git a/jenner-check/t002_mf_islibds/meta.json b/jenner-check/t002_mf_islibds/meta.json new file mode 100644 index 0000000..79c3185 --- /dev/null +++ b/jenner-check/t002_mf_islibds/meta.json @@ -0,0 +1,8 @@ +{ + "bundle": "t002_mf_islibds", + "source_file": "base/mf_islibds.sas", + "source_blob_sha": "28e65f29cb6bb5a37d524bbc95a381657de67dcd", + "source_commit": "f484d1091390827b11ef07f81207d3d21192da0f", + "tier": "real_macro", + "notes": "mf_islibds uses a Perl regex (prxparse/prxmatch) to validate a libref.dataset reference; bundle checks a valid name, a name without a libref, and a name with an invalid character. Adapted: documentation/STORE-SOURCE block comments stripped (non-semantic) and the macro invoked via %let so its return value can be tabulated; macro logic is unchanged." +} \ No newline at end of file diff --git a/jenner-check/t002_mf_islibds/script.sas b/jenner-check/t002_mf_islibds/script.sas new file mode 100644 index 0000000..eeb63f0 --- /dev/null +++ b/jenner-check/t002_mf_islibds/script.sas @@ -0,0 +1,26 @@ +/* mf_islibds.sas (from sasjs/core base/) - returns 1 if the argument is a + valid two-level libref.dataset reference, else 0. Uses a Perl regex. */ + +%macro mf_islibds(libds +); + +%local regex; +%let regex=%sysfunc(prxparse(%str(/^[_a-z]\w{0,7}\.[_a-z]\w{0,31}$/i))); + +%sysfunc(prxmatch(®ex,&libds)) + +%mend mf_islibds; + +/* Documented usage (from the macro header) */ +%let valid = %mf_islibds(work.somedata); +%let nolib = %mf_islibds(somedata); +%let badchar = %mf_islibds(s-mething.invalid); + +data work.islibds_check; + length input $24 result $1 expected $1 pass $4; + input='work.somedata'; result="&valid"; expected='1'; pass=ifc(result=expected,'PASS','FAIL'); output; + input='somedata'; result="&nolib"; expected='0'; pass=ifc(result=expected,'PASS','FAIL'); output; + input='s-mething.invalid'; result="&badchar"; expected='0'; pass=ifc(result=expected,'PASS','FAIL'); output; +run; + +proc print data=work.islibds_check noobs; run; diff --git a/jenner-check/t003_mf_mval/autoexec.sas b/jenner-check/t003_mf_mval/autoexec.sas new file mode 100644 index 0000000..b5db77a --- /dev/null +++ b/jenner-check/t003_mf_mval/autoexec.sas @@ -0,0 +1,2 @@ +/* jenner-check bundle autoexec */ +options obs=100; diff --git a/jenner-check/t003_mf_mval/expected.json b/jenner-check/t003_mf_mval/expected.json new file mode 100644 index 0000000..51db5f2 --- /dev/null +++ b/jenner-check/t003_mf_mval/expected.json @@ -0,0 +1,18 @@ +{ + "_captured_at": "2026-06-11T18:35:03Z", + "_captured_run_id": "r_019eb7f5dd487f03b8411c0bdf70b98c", + "status": "ok", + "exit_code": 0, + "log_contains": [ + "NOTE: Wrote work.mval_check (2 rows, 4 columns).", + "NOTE: PROC PRINT completed: 2 observations printed, 4 variables" + ], + "log_does_not_contain": [ + "ERROR:", + "[JENNER-ERROR" + ], + "diagnostics": { + "parse_warnings": [], + "runtime_warnings": [] + } +} \ No newline at end of file diff --git a/jenner-check/t003_mf_mval/expected/files.md b/jenner-check/t003_mf_mval/expected/files.md new file mode 100644 index 0000000..abd31da --- /dev/null +++ b/jenner-check/t003_mf_mval/expected/files.md @@ -0,0 +1,16 @@ +# Captured run artifacts + +These URLs point at a specific Jenner run (`r_019eb7f5dd487f03b8411c0bdf70b98c`) and **expire when the run is reaped**. Re-running the bundle with `run_jenner.sh` regenerates them. + +## Files + +| name | content_type | size_bytes | url | +|---|---|---|---| +| listing.txt | text/plain | 186 | https://api.jenneranalytics.com/v1/run/r_019eb7f5dd487f03b8411c0bdf70b98c/files/listing.txt?token=0bb8bf89245b48a4affde472b6c52b58 | + +## Datasets + +| name | rows | columns | preview_url | +|---|---|---|---| +| mval_check | 2 | ['scenario', 'result', 'expected', 'pass'] | https://api.jenneranalytics.com/v1/run/r_019eb7f5dd487f03b8411c0bdf70b98c/datasets/mval_check?token=0bb8bf89245b48a4affde472b6c52b58 | + diff --git a/jenner-check/t003_mf_mval/expected/log.txt b/jenner-check/t003_mf_mval/expected/log.txt new file mode 100644 index 0000000..2f36e0e --- /dev/null +++ b/jenner-check/t003_mf_mval/expected/log.txt @@ -0,0 +1,14 @@ +Jenner 0.1.0 (Unlicensed - limited to 100 observations) +Get a license at https://jenneranalytics.com/license + +NOTE: Option OBS changed to 100. +NOTE: DATA work.mval_check + + +NOTE: Wrote work.mval_check (2 rows, 4 columns). +NOTE: DATA elapsed: + wall 0.00 seconds + cpu 0.00 seconds +NOTE: PROC PRINT data=work.mval_check + +NOTE: PROC PRINT completed: 2 observations printed, 4 variables diff --git a/jenner-check/t003_mf_mval/expected/output.txt b/jenner-check/t003_mf_mval/expected/output.txt new file mode 100644 index 0000000..bc605f2 --- /dev/null +++ b/jenner-check/t003_mf_mval/expected/output.txt @@ -0,0 +1,6 @@ + + SCENARIO RESULT EXPECTED PASS +------------- ----------- ----------- ---- +defined var hello world hello world PASS +undefined var PASS + diff --git a/jenner-check/t003_mf_mval/meta.json b/jenner-check/t003_mf_mval/meta.json new file mode 100644 index 0000000..b7d4722 --- /dev/null +++ b/jenner-check/t003_mf_mval/meta.json @@ -0,0 +1,8 @@ +{ + "bundle": "t003_mf_mval", + "source_file": "base/mf_mval.sas", + "source_blob_sha": "4a1ab580495d931df42065d41640d3901b83eb3f", + "source_commit": "f484d1091390827b11ef07f81207d3d21192da0f", + "tier": "real_macro", + "notes": "mf_mval safely returns a macro variable value (or empty if undefined) via symexist/superq; bundle checks a defined and an undefined variable. Adapted: documentation/STORE-SOURCE block comments stripped (non-semantic) and the macro invoked via %let so its return value can be tabulated; macro logic is unchanged." +} \ No newline at end of file diff --git a/jenner-check/t003_mf_mval/script.sas b/jenner-check/t003_mf_mval/script.sas new file mode 100644 index 0000000..0c0d53d --- /dev/null +++ b/jenner-check/t003_mf_mval/script.sas @@ -0,0 +1,22 @@ +/* mf_mval.sas (from sasjs/core base/) - returns the value of a macro + variable if it exists, else an empty string (no WARNING). */ + +%macro mf_mval(var); + %if %symexist(&var) %then %do; + %superq(&var) + %end; +%mend mf_mval; + +/* Documented usage */ +%global myvar; +%let myvar=hello world; +%let exists = %mf_mval(myvar); +%let missing = %mf_mval(doesnotexist); + +data work.mval_check; + length scenario $20 result $40 expected $40 pass $4; + scenario='defined var'; result="&exists"; expected='hello world'; pass=ifc(strip(result)=strip(expected),'PASS','FAIL'); output; + scenario='undefined var'; result="&missing"; expected=''; pass=ifc(strip(result)=strip(expected),'PASS','FAIL'); output; +run; + +proc print data=work.mval_check noobs; run; diff --git a/jenner-check/t004_mf_fmtdttm/autoexec.sas b/jenner-check/t004_mf_fmtdttm/autoexec.sas new file mode 100644 index 0000000..b5db77a --- /dev/null +++ b/jenner-check/t004_mf_fmtdttm/autoexec.sas @@ -0,0 +1,2 @@ +/* jenner-check bundle autoexec */ +options obs=100; diff --git a/jenner-check/t004_mf_fmtdttm/expected.json b/jenner-check/t004_mf_fmtdttm/expected.json new file mode 100644 index 0000000..9483530 --- /dev/null +++ b/jenner-check/t004_mf_fmtdttm/expected.json @@ -0,0 +1,18 @@ +{ + "_captured_at": "2026-06-11T18:35:03Z", + "_captured_run_id": "r_019eb7f6501379a0ba919a6b7527751c", + "status": "ok", + "exit_code": 0, + "log_contains": [ + "NOTE: Wrote work.fmtdttm_check (1 rows, 2 columns).", + "NOTE: mf_fmtdttm() selected format E8601DT26.6" + ], + "log_does_not_contain": [ + "ERROR:", + "[JENNER-ERROR" + ], + "diagnostics": { + "parse_warnings": [], + "runtime_warnings": [] + } +} \ No newline at end of file diff --git a/jenner-check/t004_mf_fmtdttm/expected/files.md b/jenner-check/t004_mf_fmtdttm/expected/files.md new file mode 100644 index 0000000..4d76ee8 --- /dev/null +++ b/jenner-check/t004_mf_fmtdttm/expected/files.md @@ -0,0 +1,16 @@ +# Captured run artifacts + +These URLs point at a specific Jenner run (`r_019eb7f6501379a0ba919a6b7527751c`) and **expire when the run is reaped**. Re-running the bundle with `run_jenner.sh` regenerates them. + +## Files + +| name | content_type | size_bytes | url | +|---|---|---|---| +| listing.txt | text/plain | 140 | https://api.jenneranalytics.com/v1/run/r_019eb7f6501379a0ba919a6b7527751c/files/listing.txt?token=5d009db83eda44f2ad5396caf0fdf29a | + +## Datasets + +| name | rows | columns | preview_url | +|---|---|---|---| +| fmtdttm_check | 1 | ['chosen_format', 'note'] | https://api.jenneranalytics.com/v1/run/r_019eb7f6501379a0ba919a6b7527751c/datasets/fmtdttm_check?token=5d009db83eda44f2ad5396caf0fdf29a | + diff --git a/jenner-check/t004_mf_fmtdttm/expected/log.txt b/jenner-check/t004_mf_fmtdttm/expected/log.txt new file mode 100644 index 0000000..ffc4167 --- /dev/null +++ b/jenner-check/t004_mf_fmtdttm/expected/log.txt @@ -0,0 +1,15 @@ +Jenner 0.1.0 (Unlicensed - limited to 100 observations) +Get a license at https://jenneranalytics.com/license + +NOTE: Option OBS changed to 100. +NOTE: DATA work.fmtdttm_check + + +NOTE: Wrote work.fmtdttm_check (1 rows, 2 columns). +NOTE: DATA elapsed: + wall 0.00 seconds + cpu 0.00 seconds +NOTE: PROC PRINT data=work.fmtdttm_check + +NOTE: PROC PRINT completed: 1 observations printed, 2 variables +NOTE: mf_fmtdttm() selected format E8601DT26.6 diff --git a/jenner-check/t004_mf_fmtdttm/expected/output.txt b/jenner-check/t004_mf_fmtdttm/expected/output.txt new file mode 100644 index 0000000..9f66280 --- /dev/null +++ b/jenner-check/t004_mf_fmtdttm/expected/output.txt @@ -0,0 +1,5 @@ + +CHOSEN_FORMAT NOTE +------------- ------------------------------ +E8601DT26.6 valid datetime format returned + diff --git a/jenner-check/t004_mf_fmtdttm/meta.json b/jenner-check/t004_mf_fmtdttm/meta.json new file mode 100644 index 0000000..58876fa --- /dev/null +++ b/jenner-check/t004_mf_fmtdttm/meta.json @@ -0,0 +1,8 @@ +{ + "bundle": "t004_mf_fmtdttm", + "source_file": "base/mf_fmtdttm.sas", + "source_blob_sha": "1cd1aeb38d5d1cd674abf62bf79d7ccac91f099e", + "source_commit": "f484d1091390827b11ef07f81207d3d21192da0f", + "tier": "real_macro", + "notes": "mf_fmtdttm returns the version-appropriate datetime format name; bundle captures the selected format and confirms it is one of the two valid values. Adapted: documentation/STORE-SOURCE block comments stripped (non-semantic) and the macro invoked via %let so its return value can be tabulated; macro logic is unchanged." +} \ No newline at end of file diff --git a/jenner-check/t004_mf_fmtdttm/script.sas b/jenner-check/t004_mf_fmtdttm/script.sas new file mode 100644 index 0000000..1a3a9ac --- /dev/null +++ b/jenner-check/t004_mf_fmtdttm/script.sas @@ -0,0 +1,31 @@ +/* mf_fmtdttm.sas (from sasjs/core base/) - returns the appropriate datetime + format name for the running SAS version, so datetimes can be written + consistently across SAS releases. */ + +%macro mf_fmtdttm( +); + +%if "&sysver"="9.2" or "&sysver"="9.3" + or ("&sysver"="9.4" and "%substr(&SYSVLONG,9,1)" le "3") + or "%substr(&sysver,1,1)"="4" + or "%substr(&sysver,1,1)"="5" +%then %do;DATETIME19.3%end; +%else %do;E8601DT26.6%end; + +%mend mf_fmtdttm; + +/* Documented usage: capture the format the macro selects for this session */ +%let dttmfmt=%mf_fmtdttm(); + +data work.fmtdttm_check; + length chosen_format $20 note $60; + chosen_format = "&dttmfmt"; + /* the macro returns one of two valid datetime formats depending on version */ + if chosen_format in ('DATETIME19.3','E8601DT26.6') + then note='valid datetime format returned'; + else note='UNEXPECTED format'; + output; +run; + +proc print data=work.fmtdttm_check noobs; run; +%put NOTE: mf_fmtdttm() selected format &dttmfmt; diff --git a/jenner-check/t005_mf_getplatform/autoexec.sas b/jenner-check/t005_mf_getplatform/autoexec.sas new file mode 100644 index 0000000..b5db77a --- /dev/null +++ b/jenner-check/t005_mf_getplatform/autoexec.sas @@ -0,0 +1,2 @@ +/* jenner-check bundle autoexec */ +options obs=100; diff --git a/jenner-check/t005_mf_getplatform/expected.json b/jenner-check/t005_mf_getplatform/expected.json new file mode 100644 index 0000000..e328376 --- /dev/null +++ b/jenner-check/t005_mf_getplatform/expected.json @@ -0,0 +1,18 @@ +{ + "_captured_at": "2026-06-11T18:35:03Z", + "_captured_run_id": "r_019eb7f5f5dc7a6381b171f66ff8a583", + "status": "ok", + "exit_code": 0, + "log_contains": [ + "NOTE: Wrote work.platform_check (1 rows, 1 columns).", + "NOTE: mf_getplatform() detected BASESAS" + ], + "log_does_not_contain": [ + "ERROR:", + "[JENNER-ERROR" + ], + "diagnostics": { + "parse_warnings": [], + "runtime_warnings": [] + } +} \ No newline at end of file diff --git a/jenner-check/t005_mf_getplatform/expected/files.md b/jenner-check/t005_mf_getplatform/expected/files.md new file mode 100644 index 0000000..b8a4b5e --- /dev/null +++ b/jenner-check/t005_mf_getplatform/expected/files.md @@ -0,0 +1,16 @@ +# Captured run artifacts + +These URLs point at a specific Jenner run (`r_019eb7f5f5dc7a6381b171f66ff8a583`) and **expire when the run is reaped**. Re-running the bundle with `run_jenner.sh` regenerates them. + +## Files + +| name | content_type | size_bytes | url | +|---|---|---|---| +| listing.txt | text/plain | 46 | https://api.jenneranalytics.com/v1/run/r_019eb7f5f5dc7a6381b171f66ff8a583/files/listing.txt?token=d5afd4f87a1544039a89554b4dd24298 | + +## Datasets + +| name | rows | columns | preview_url | +|---|---|---|---| +| platform_check | 1 | ['detected_platform'] | https://api.jenneranalytics.com/v1/run/r_019eb7f5f5dc7a6381b171f66ff8a583/datasets/platform_check?token=d5afd4f87a1544039a89554b4dd24298 | + diff --git a/jenner-check/t005_mf_getplatform/expected/log.txt b/jenner-check/t005_mf_getplatform/expected/log.txt new file mode 100644 index 0000000..8570020 --- /dev/null +++ b/jenner-check/t005_mf_getplatform/expected/log.txt @@ -0,0 +1,15 @@ +Jenner 0.1.0 (Unlicensed - limited to 100 observations) +Get a license at https://jenneranalytics.com/license + +NOTE: Option OBS changed to 100. +NOTE: DATA work.platform_check + + +NOTE: Wrote work.platform_check (1 rows, 1 columns). +NOTE: DATA elapsed: + wall 0.00 seconds + cpu 0.00 seconds +NOTE: PROC PRINT data=work.platform_check + +NOTE: PROC PRINT completed: 1 observations printed, 1 variables +NOTE: mf_getplatform() detected BASESAS diff --git a/jenner-check/t005_mf_getplatform/expected/output.txt b/jenner-check/t005_mf_getplatform/expected/output.txt new file mode 100644 index 0000000..517e123 --- /dev/null +++ b/jenner-check/t005_mf_getplatform/expected/output.txt @@ -0,0 +1,5 @@ + +DETECTED_PLATFORM +----------------- +BASESAS + diff --git a/jenner-check/t005_mf_getplatform/meta.json b/jenner-check/t005_mf_getplatform/meta.json new file mode 100644 index 0000000..0a18242 --- /dev/null +++ b/jenner-check/t005_mf_getplatform/meta.json @@ -0,0 +1,8 @@ +{ + "bundle": "t005_mf_getplatform", + "source_file": "base/mf_getplatform.sas", + "source_blob_sha": "ca7cfb2bc0bcc016fada0140a1923aba1bd0ee26", + "source_commit": "f484d1091390827b11ef07f81207d3d21192da0f", + "tier": "real_macro", + "notes": "mf_getplatform detects the SAS platform from session symbols; helper macros it may call on other switches (mf_mval, mf_trimstr) are bundled so the definition is complete. Default call returns the platform name. Adapted: documentation/STORE-SOURCE block comments stripped (non-semantic) and the macro invoked via %let so its return value can be tabulated; macro logic is unchanged." +} \ No newline at end of file diff --git a/jenner-check/t005_mf_getplatform/script.sas b/jenner-check/t005_mf_getplatform/script.sas new file mode 100644 index 0000000..6aa96db --- /dev/null +++ b/jenner-check/t005_mf_getplatform/script.sas @@ -0,0 +1,97 @@ +/* mf_getplatform.sas (from sasjs/core base/) - detects the SAS platform + (BASESAS / SASVIYA / SASMETA / SASJS). The related helper macros it can + call on some switches (mf_mval, mf_trimstr) are bundled alongside so the + definition is complete. */ + +%macro mf_mval(var); + %if %symexist(&var) %then %do; + %superq(&var) + %end; +%mend mf_mval; + +%macro mf_trimstr(basestr,trimstr); +%local baselen trimlen trimval; + +%let baselen=%length(%superq(basestr)); +%let trimlen=%length(%superq(trimstr)); +%if &baselen < &trimlen or &baselen=0 %then %return; + +%let trimval=%qsubstr(%superq(basestr) + ,%length(%superq(basestr))-&trimlen+1 + ,&trimlen); + +%if %superq(basestr)=%superq(trimstr) %then %do; + %return; +%end; +%else %if %superq(trimval)=%superq(trimstr) %then %do; + %qsubstr(%superq(basestr),1,%length(%superq(basestr))-&trimlen) +%end; +%else %do; + &basestr +%end; + +%mend mf_trimstr; + +%macro mf_getplatform(switch +); +%local a b c; +%if &switch.NONE=NONE %then %do; + %if %symexist(sasjsprocessmode) %then %do; + %if &sasjsprocessmode=Stored Program %then %do; + SASJS + %return; + %end; + %end; + %if %symexist(sysprocessmode) %then %do; + %if "&sysprocessmode"="SAS Object Server" + or "&sysprocessmode"= "SAS Compute Server" %then %do; + SASVIYA + %end; + %else %if "&sysprocessmode"="SAS Stored Process Server" + or "&sysprocessmode"="SAS Workspace Server" + %then %do; + SASMETA + %return; + %end; + %else %do; + BASESAS + %return; + %end; + %end; + %else %if %symexist(_metaport) or %symexist(_metauser) %then %do; + SASMETA + %return; + %end; + %else %do; + BASESAS + %return; + %end; +%end; +%else %if &switch=SASSTUDIO %then %do; + + %if %mf_mval(_CLIENTAPP)=%str(SAS Studio) %then %do; + %let a=%mf_mval(_CLIENTVERSION); + %let b=%scan(&a,1,.); + %if %eval(&b >2) %then %do; + &b + %end; + %else 0; + %end; + %else 0; +%end; +%else %if &switch=VIYARESTAPI %then %do; + %mf_trimstr(%sysfunc(getoption(servicesbaseurl)),/) +%end; +%mend mf_getplatform; + +/* Documented usage: default call returns the platform name */ +%let platform=%mf_getplatform(); + +data work.platform_check; + length detected_platform $12; + detected_platform = "&platform"; + output; +run; + +proc print data=work.platform_check noobs; run; +%put NOTE: mf_getplatform() detected &platform;