Skip to content

v1.0 stage1: make-option correctness - #1262

Merged
jgabry merged 14 commits into
v1.0from
v1.0-stage1-make-options
Sep 10, 2026
Merged

v1.0 stage1: make-option correctness#1262
jgabry merged 14 commits into
v1.0from
v1.0-stage1-make-options

Conversation

@jgabry

@jgabry jgabry commented Sep 8, 2026

Copy link
Copy Markdown
Member

Submission Checklist

  • Run unit tests
  • Declare copyright holder and agree to license (see below)

Summary

This PR and the summary below was written with the help of AI coding tools. I have reviewed the code myself.

Stage 1 of the v1.0 compilation-state work (#1258): every option that reaches make or stanc now arrives through one channel, is spelled the way the tool reads it, and is checked before anything runs. Targets v1.0, not master.

What changes for users (NEWS has the full list):

  • cpp_options entries must be named with a Make variable name. Names are uppercased on entry and $cpp_options() reports the Make spelling. FALSE or NULL passes an empty assignment, which turns the option off and overrides make/local. Previously stan_threads = FALSE enabled threading (cpp_options = list(stan_threads = FALSE) enables threading instead of disabling it #1251), and unnamed entries such as "STAN_THREADS=TRUE" reached make but were invisible to everything keyed on names (Unnamed raw cpp_options assignments reach make but are invisible to everything that keys on names #1250).
  • user_header is the only way to supply a user header; cpp_options = list(USER_HEADER = ...) is an error naming the argument. The new $user_header() method returns the path.
  • include_paths is the only way to give stanc include paths. include-paths in stanc_options, STANCFLAGS in cpp_options and an include path in make/local's STANCFLAGS are all errors.
  • stanc_options rejects the flags cmdstanr sets from its own arguments (warn-pedantic, allow-undefined, use-opencl, include-paths, name) in every spelling. $check_syntax() checks its list the same way.
  • $check_syntax(), $format() and $variables() always pass --allow-undefined; they read a program and link nothing.
  • make/local's STANCFLAGS are read the way the shell splits them (Quoted values in make/local STANCFLAGS are split on whitespace and break direct stanc calls #1232), and are resolved with the call's cpp_options applied, so stan_opencl = FALSE also keeps the --use-opencl that CmdStan's makefiles add from reaching stanc. A flag the call sets wins over the same flag in make/local.
  • Include paths handed to make are quoted for Make and the shell (Include paths for Make are hand-quoted rather than properly escaped #1230).
  • cmdstan_model(exe_file = ) with no stan_file rejects cpp_options, stanc_options, include_paths, user_header, force_recompile and pedantic. There is nothing to build, so the executable is used as it is.

Each rule has a sentence in dev-notes/compilation-state-contract.md (§3, §6, §7) and a test that fails if the rule is removed.

Closes #1230, #1232, #1250, #1251.

Reviewed externally before this was marked ready; the review found one real defect (the STANCFLAGS query ignored the call's cpp_options, fixed in the last commit) and a handful of message and doc corrections, all in the two final commits. One finding is deferred on purpose and recorded on #1258: the executable-only check reads the captured arguments by exact name, so an abbreviation such as force = TRUE passes it. Removing $compile() later in v1.0 makes those arguments the constructor's own formals and closes that without matching code.

Copyright and Licensing

Please list the copyright holder for the work you are submitting
(this will be you or your assignee, such as a university or company):
Jonah Gabry

By submitting this pull request, the copyright holder is agreeing to
license the submitted work under the following licenses:

…paths for Make

get_cmdstan_flags("STANCFLAGS") read the variable with `make print-STANCFLAGS`,
whose recipe echoes the value through the shell. The shell strips the quotes, so
a value like --filename-in-msg='/my dir/model.stan' came back as two words and
the direct stanc calls in compile() passed them as two arguments; stanc then
refused the second one as an extra positional argument. The value is now read
with a rule of our own that prints $(STANCFLAGS) one argument per line after
the shell has split it, so what comes back is exactly what stanc's recipe gets.
The rule goes in a temporary makefile passed with a second -f rather than in an
--eval argument, since users may have a make too old for --eval (the one Apple
ships with macOS is). Every line carries a prefix so directory-change messages
from a recursive make are ignored. The recipe needs sh, so the call puts the
toolchain on PATH the way compile() does, and the makefile is written with LF
endings so a Linux make under WSL can read it.

Since the local flags now arrive as words, the make path requotes them before
appending them to the STANCFLAGS value handed back to make.

include_paths_stanc3_args() quoted a path for Make only when it contained a
space, so a quote in the path broke the shell syntax and a dollar sign was
expanded by Make. make_shell_quote() single-quotes each path that holds a
character the shell could interpret and doubles `$` for Make. It quotes one
element at a time because shQuote() switches a whole vector to double quotes
when any element holds a single quote. Paths without such characters are
unchanged.

Tests run real make against a minimal makefile that includes a local file with
the fixtures, hand quoted words to make and check the shell delivers them back
unchanged, and compile a model with the quoted fixture in make/local without
mocking either stanc call.

closes #1230
closes #1232
Both unit test workflows only ran on pull requests against master, so a pull
request against the v1.0 branch got no Windows or WSL coverage. Add v1.0 to the
branch filter of each.
A header passed through cpp_options as USER_HEADER or user_header is now an
error that points at the user_header argument. The header still reaches make
as USER_HEADER=, but as a flag built beside the cpp_options flags, so it no
longer appears in $cpp_options(). A new $user_header() accessor returns the
recorded path.

resolve_user_header() and its conflict warnings are gone; what is left is
inlined at the two call sites. assert_valid_cpp_options() holds the rejection
and will take the rest of the cpp_options checks in the next commit. The design
note's two references to the deleted function now read in the past tense.

Part of #1258.
@jgabry jgabry changed the title v1.0 stage1 make options v1.0 stage1: make-option correctness Sep 8, 2026
`assert_valid_cpp_options()` is now the whole check for `cpp_options`. Every
entry must be named, every name must be a Make variable name
(`^[A-Za-z_][A-Za-z0-9_]*$`), and names are uppercased there so a single
spelling reaches everything downstream. `$cpp_options()` reports names in
their make spelling, so `list(stan_threads = TRUE)` comes back as
`STAN_THREADS`. `USER_HEADER` and `STANCFLAGS` are rejected as literals with
errors naming the `user_header` and `stanc_options` arguments.

Unnamed entries get an error that names the route for what the caller
wrote: `list(NAME = "value")` for a plain assignment, `cmdstan_make_local()`
for `+=` and the other makefile operators and for make's own flags, and the
owning argument when the entry assigns `USER_HEADER` or `STANCFLAGS`. A name
like `CXXFLAGS+` is rejected by the name grammar, since it would otherwise
reach make as a live `+=`.

A logical `FALSE` now reaches make as the empty assignment `NAME=`, which
disables the option and overrides `make/local`, the same as `NULL`. The
string `"FALSE"` is still passed as a value. `validate_cpp_options()`, whose
one remaining job was to warn that `FALSE` would enable an option, is
deleted with its test.

Since names are canonical on entry, `parsed_cpp_options()`,
`exe_info_reflects_cpp_options()`, `exe_info_style_cpp_options()` and
`merge_exe_info_cpp_options()` stop folding case, and the parser no longer
keeps an exclusion list or an opaque class for unnamed entries.
`STAN_VERSION` is compared like any other option. `cpp_option_value()` is
unchanged because it also reads lowercase executable metadata.

The design note passages that described the deleted code as current are
rewritten in the past tense. The generated contract is unchanged.

Part of #1258.

closes #1250
closes #1251
@codecov-commenter

codecov-commenter commented Sep 8, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 92.53%. Comparing base (9a1df8b) to head (f464989).

Additional details and impacted files
@@            Coverage Diff             @@
##             v1.0    #1262      +/-   ##
==========================================
+ Coverage   92.27%   92.53%   +0.25%     
==========================================
  Files          15       15              
  Lines        6489     6633     +144     
==========================================
+ Hits         5988     6138     +150     
+ Misses        501      495       -6     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

`assert_valid_stanc_options()` now rejects `include-paths`, `warn-pedantic`,
`allow-undefined`, `use-opencl` and `name`, each with an error naming what
owns the setting: the `include_paths` argument, `pedantic = TRUE`, the
`user_header` argument, `cpp_options = list(stan_opencl = TRUE)`, and the
name of the Stan file. The match is on where the flag name occurs, the
entry's name when it is named and its value otherwise, taking the text
before the first `=`, so every spelling of a flag is caught including the
named `FALSE` and `NA` that emit nothing. A named entry whose name contains
`=` is a shape error that shows the `list("flag" = "value")` spelling.

`$check_syntax()` validates its own `stanc_options` list the same way. It
never had, not even for a leading hyphen, so a flag with a dedicated
argument had two channels there.

With `name` rejected the `--name` injections in `$compile()` and
`$check_syntax()` run unconditionally.

Source-only operations always pass `--allow-undefined`: `$check_syntax()`,
`$format()` and `$variables()` run stanc against a program and never link
anything, so whether a function has a definition is a build's concern.
Before this a model with an external function and no header could not be
checked or formatted without passing the flag by hand. Only `$compile()`
derives the flag from `user_header` now, and with its readers gone the
private `using_user_header_` field is deleted.

The design note's §3 rejection rule now says it covers every
`stanc_options` list a method accepts, `$check_syntax()`'s included, and
the generated contract is regenerated.

Part of #1258.
The first CI run of this branch found no defect in the code and three in
the tests added with the make/local tokenizer.

The include path test predicted quotes only for a path with a space. On the
Windows runners tempdir() is a short path, `C:/Users/RUNNER~1/...`, and the
`~` is outside the safe set `make_shell_quote()` accepts, so the path is
quoted and the test disagreed. The expectation for that path now comes from
`make_shell_quote()` itself; the two hand-written paths below it still pin
the quoting rule.

The `costs $5` fixture is skipped under WSL. The wsl launcher passes
arguments through a shell, so `.wsl_check_exists()` looks for `costs ` and
reports the directory missing. That is a limit of the WSL path handling for
a `$` in a path's last component, older than this branch, and not what the
fixture tests.

The mini make/local helper in test-utils.R mocked `wsl_compatible_run()`
with a native make call, while `stancflags_from_make()` had already
converted its rule file path to `/mnt/...` for WSL, so make could not open
it. The helper now calls the real runner with the temporary directory as
its working directory, and writes its two files in binary mode so the Linux
make reads LF endings.

Part of #1258.
…s win

An include path in make/local's STANCFLAGS now stops the build with an error
that names the include_paths argument. Any element that contains
--include-paths or begins with -I counts, a substring test on what make
resolved rather than a parse of the file, so a flag arriving through a
makefile that make/local includes is caught too. Only include_paths can set
the search path now, which keeps the build and stanc --info resolving the
same files.

A flag the call emits, supplied in stanc_options or injected from pedantic,
user_header, stan_opencl or the file name, now wins over the same flag in
make/local. drop_overridden_stancflags() removes the make/local element whose
flag matches one of the call's, and when that element is the bare flag it
also takes the next element if it does not begin with a hyphen, since that
is the flag's value given as a separate word. -fno-soa after a make/local
--warn-pedantic is the next flag and stays. cmdstanr passes STANCFLAGS on the
make command line, which replaces the makefile's own value, so dropping from
the vector it appends removes the flag from make's stanc call and the two
direct calls alike. Before this a make/local --warn-pedantic beside
pedantic = TRUE reached stanc twice, which 2.37 refuses.

The include-path rejection runs first and is not subject to the drop.

Part of #1258.
cmdstan_model(exe_file = path) with no stan_file adopts an executable as it
is. Supplying cpp_options, stanc_options, include_paths, user_header,
force_recompile or pedantic to such a call is now an error, since each can
only be honoured by building or by reading a Stan program and there is
neither. The check is on whether the argument was supplied, so an explicit
NULL counts as omission and force_recompile = FALSE is refused like TRUE.
It runs before the executable is checked for existence, so a caller learns
about the bad argument even when the path is wrong too. Before this the
arguments were accepted and ignored, and include_paths was resolved and
recorded on a model that had no program to resolve includes for.

With include_paths rejected on this path, the executable branch of
initialize() takes its include paths from the precompile field alone; the
fallback to the raw argument could no longer be reached.

The cmdstanr_force_recompile option keeps its place in the $compile()
signature. The check reads the constructor's own argument list, which holds
only what the caller passed, so the option default never reaches it. The
option's help page says it has no effect on an executable-only model.

Part of #1258.
"executable metadata takes precedence over compile options" compiled with
cpp_options = list(stan_threads = FALSE) and expected the executable to have
threading anyway, so that sampling without threads_per_chain errored. It
passed only because FALSE reached make as STAN_THREADS=FALSE, a non-empty
value that ifdef treats as set. That is the #1251 bug, fixed earlier on this
branch, and CI found the test on the first run that included the fix.

The test now pins the corrected behaviour end to end: the executable built
with stan_threads = FALSE reports threading off, samples without
threads_per_chain, and warns that threads_per_chain has no effect. Its
snapshot of the old error goes with it.

Part of #1258.
Version 0.9.0.9002 becomes 0.9.0.9003. The NEWS entries cover what this
branch changed: include_paths and user_header as the only channels for their
settings, the stanc flags rejected from stanc_options, source-only
operations always allowing undefined functions, named cpp_options with Make
spellings and FALSE disabling an option, the call's stanc flags winning over
make/local, the make/local STANCFLAGS tokenizer and include path quoting,
and the arguments an executable-only model refuses. Four unreleased entries
about a user header passed through cpp_options are removed, since that
spelling is now an error.

The man pages are regenerated from the roxygen edited on this branch.

Part of #1258.
…ns messages

The rule file stancflags_from_make() hands to make appended itself to
MAKEFILE_LIST, so a make/local value that reads $(lastword $(MAKEFILE_LIST))
named the temporary file instead of local. The file's first line now removes
it from the list.

Four migration messages suggested replacements that were wrong or did not
parse. Values are now written as R literals with encodeString(), an empty
USER_HEADER points at user_header = NULL, -B points at force_recompile = TRUE
and -f at an include line in make/local.

The design note said the cpp_options name check runs after uppercasing. The
code checks the name as written and uppercases what passes, and the contract
now says so. The exe_file docs say NULL counts as omitting the build
arguments, the cpp_options docs say an empty assignment empties the variable
rather than disabling an option, and the #1230 NEWS entry names the WSL
dollar-sign limit.

Found by the external review of #1262.

Part of #1258.
CmdStan's make/program adds --use-opencl to STANCFLAGS when STAN_OPENCL is
set. The query that reads STANCFLAGS from make ran with make/local alone, so
with STAN_OPENCL=true in make/local and cpp_options = list(stan_opencl =
FALSE) both stanc calls got --use-opencl while the C++ build got
STAN_OPENCL=, and the generated code did not compile. Pre-existing for
NULL, exposed by FALSE now meaning off.

stancflags_from_make() and get_cmdstan_flags() take the call's Make
assignments and put them on make's command line, so make resolves
STANCFLAGS for this build. compile() computes the assignments once and hands
the same vector to the query and to the build. The contract sentence in §6
says so. The #1251 NEWS entry is shortened to the outcome: FALSE turns an
option off, and wins over make/local.

Two mini-makefile tests pin the helper, and a dry-run compile against a real
make/local holding STAN_OPENCL=true asserts --use-opencl reaches both stanc
calls zero times for FALSE and once for TRUE and for make/local alone. Test
mocks of get_cmdstan_flags() accept the new argument.

Found by the external review of #1262.

Part of #1258.
The message that rejects a user header in cpp_options now writes the path
with encodeString(), so on Windows the backslashes come out doubled. The test
built its expected text from the raw path and failed on the windows-latest
runner. It now encodes the path the same way.

Part of #1258.
The query that reads make/local STANCFLAGS received the call's cpp_options
but not the USER_HEADER assignment, which was built afterwards and reached
only the build. A make/local rule conditioned on USER_HEADER resolved one
way for the query and another for the build. The header assignment now
joins the cpp_options in a single vector of make variables that both the
query and the build call use, and the contract sentence in §6 names
user_header alongside cpp_options. A dry-run test with a conditional
make/local pins it.

The migration message for a -f make flag wrote its make/local example
without quoting, so a path containing a quote produced invalid R. It now
encodes the R literal the way the operator message does.

The §3 sentence saying entries are normalized ahead of validation
contradicted the later sentence that checks the name shape as written and
then uppercases. The §3 sentence no longer states an ordering; the
sentence that owns it is the one in the operator section.

Part of #1258.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants