Changelog
Source:NEWS.md
saros.base 1.2.1.9001
Documentation
- Corrected the documented default of 24 arguments across 6 functions (#251). Every
@paramblock in this package states the argument’s default on its second line, and that line is the only thing most users read before calling a function; it had drifted away from the signature everywhere it was not being actively edited. No actual default changed — this is a documentation-only fix, and every one of the package’s 618 formals deparses identically to before. Three of the corrections reverse the reader’s expectation outright:draft_report(combined_report = ),draft_report(attach_chapter_dataset = )(and its counterpart ingen_qmd_chapters()) anddraft_report(require_common_categories = )were each documented asFALSE/NULLwhile defaulting toTRUE, so three features documented as off are in fact on.refine_chapter_overview()accounted for eleven:max_width_obj/max_width_chunk/max_width_file/max_width_folder_namewere documentedNULL— unlimited — while actually truncating at128/128/64/12;sep_chunkandsep_filewere documented"_"while defaulting to"-", contradicting the prose two lines below them, which already said “hyphen for chunk labels and file names”;label_separator,organize_byandarrange_section_bywere documentedNULLwhile carrying real values.gen_qmd_chapters(path = )was documentedtempdir()while defaulting toNULL, andn_range_glue_template_2documented a comma where the template uses a hyphen. Three arguments —insert_chunk(grouping_structure = ),read_default_draft_report_args(path = )andwrite_default_draft_report_args(path = )— were given a documented default despite having none at all; they now read// Required, the form this package already uses fordataandchapter_structure.write_default_draft_report_args(ignore_args = )was missing"path"from its listed vector. -
draft_report(qmd_engine = )is deliberately not part of the above, and the check added below encodes that. Its formal isc("recursion", "loop")— the whole menurlang::arg_match()picks from — but only the first element is ever the default, so the documented"recursion"is both correct and the more useful thing to state. The same reasoning coverscase,numbering_prefix,password_inputandengine, the package’s four otherarg_match()arguments; none of them currently states a default in this format, but they are exempt on the same terms if they ever do. - Four
@paramtags that named several arguments at once have been split, because a tag can carry only one*default:*line and the arguments sharing it no longer agree:max_width_obj,max_width_chunk,max_width_file,sep_obj,sep_chunk,sep_file,n_range_glue_template_1,n_range_glue_template_2andreport_includes_prefix,report_includes_suffix. This is what had let themax_width_*andsep_*errors above hide — oneNULLand one"_"standing in for three arguments each.gen_qmd_chapters()likewise now documentspathandreplace_heading_for_groupitself rather than inheriting them fromdraft_report()via@inheritParams, since its own defaults for both areNULLwheredraft_report()’s aretempdir()and a three-entry vector;draft_report()always passes both explicitly, so the difference is only visible to a direct call. - Two smaller consistency fixes in the same blocks, both found by review of the above.
draft_report()’s combined@paramtag for the six*_qmd_start/end_section_filepatharguments ended in a stray comma, so the generated.Rdlisted an empty seventh argument name alongside them. Andwrite_default_draft_report_args(ignore_args = )wrote its optional marker as// Optional.where the other 40 occurrences in the package use// *default:* `x` (`optional`); it now matches. - Fixed a stray backslash in four rendered defaults.
report_includes_prefix,report_includes_suffixand bothn_range_glue_template_*values contain braces, which the roxygen sources escaped by hand as\{. roxygen2 then escaped the backslash, so?draft_reportdisplayed"\{\{< include "rather than"{{< include "— a value no user could copy. Inline code spans need no manual brace escaping; what they do need is balanced braces within the tag, which is why each of the tworeport_includes_*blocks now also names its counterpart’s value. - Added four conceptual vignettes, porting the orientation material that previously existed only as Norwegian prose on the Saros website (#185).
vig_00_about_saroscovers what Saros is, its goals and success criteria, the macro/mesos/micro levels, the three-phase production process and the PISVEEP pass, the technology stack, and the analysis of the traditional report format that motivates generating it.vig_08_adopting_sarosis written for project leads: what adoption costs, what the package can generate, and the ethics of offering institution-specific reports as an incentive to participate.vig_09_projects_using_saroslists the projects the system is built for.vig_10_objections_and_limitationscollects the standard objections with their answers, plus the limitations expected to persist. All four are text-only — no figures, no executed code — and are picked up automatically by pkgdown, which has noarticles:key. - The “possible element types” table is no longer accurate and has been rewritten rather than translated. It documented an
element_namesargument enumerating compound names such asuni_cat_prop_plotandbi_catcat_freq_plot2, in which the number of variables involved, the variable type, the output form and the statistic were all encoded in one string. No such argument exists in saros.base —git log -S"element_names" -- R/returns nothing, so it never did. The successor isrefine_chapter_overview(chunk_templates = ), whose rows carry.template_nameplus.template_variable_type_dep/.template_variable_type_indep; univariate is nowNAin the indep column rather than auni_prefix, and proportions versus frequencies moved into asaros::makeme()argument.vig_08_adopting_sarosdocuments the five default variants as they actually are, and includes a mapping table for anyone arriving from the old vocabulary. Worth noting for future readers:.template_nameis not a validated, fixed set of permitted values —chunk_templatesaccepts arbitrary names — so the only genuinely closed vocabulary issaros::get_makeme_types(). -
inst/WORDLISTgained the Norwegian project and process names the new vignettes introduce (Kompetansebarometeret,Spørringene,Plukk,Sammenfatt, …), the acronyms (PISVEEP,AGU,KYU,SSN), and the tool names (Typst,Pandoc,renv,nifutypst, …). Note thattests/spelling.Rruns witherror = FALSEand there is no spelling job in CI, so the check reports without failing; the pre-existing British spellings inNEWS.md(behaviour,serialised,normalised) are left as they are.
Bug fixes
-
Reports using
setup_mesos_structure(mesos_var_subfolder = )could not be rendered at all, and now can (#272).write_subfolder_metadata()created a_metadata.ymlat every intermediate subfolder level withcat(file = f, append = TRUE)— a zero-byte file. Quarto validates directory metadata for the whole project before executing anything, and rejects a zero-byte_metadata.ymlwithDirectory metadata validation failed ... (line 1, column 1) YAML value is missing. The render therefore died at project level, whichever document was asked for, before a line of R ran. Confirmed by rendering two otherwise identical website projects differing only in whether that helper was called: without it the project renders, with it the render fails.The intermediate files now hold a literal
{}under a comment saying why. Which content works is not obvious and was measured rather than reasoned about: no file at all is accepted, a zero-byte file is rejected,{}is accepted, a comment on its own is rejected, and — the trap —yaml::write_yaml(list(), f)emits[], a sequence rather than a mapping, which Quarto also rejects. The obvious one-line fix does not work, so the placeholder is written literally rather than serialised.Two behaviours are preserved deliberately. A
_metadata.ymla project has filled in at an intermediate level is still left untouched, which is what the previousappend = TRUEbought. A zero-byte one, by contrast, is now overwritten: it is the artifact of this bug and leaves the project unrenderable, so re-runningsetup_mesos()repairs an existing project rather than preserving the breakage. The reader added in #270 goes on tolerating zero-byte files regardless, since every project generated before this fix has them on disk. aggregate_metadata_yml(), as it existed outside this package, aborted on the empty_metadata.ymlfiles this package itself wrote at the time (#270; the writer was fixed in #272).write_subfolder_metadata()then created a zero-byte_metadata.ymlat every intermediate level ofsetup_mesos_structure(mesos_var_subfolder = ).yaml::read_yaml()returnsNULLfor such a file andutils::modifyList(x, NULL)errors withis.list(val) is not TRUE, so the walk died partway up before reaching the group’s own file, meaningparams$mesos_group— the one parameter the mesos templates exist to consume — was never merged either. Any report usingmesos_var_subfolderwas affected. The reader that now ships here treats an empty or non-mapping_metadata.ymlas contributing nothing, which is what the writer always meant by one.tests/testthat/test-aggregate_metadata_yml.Rpins this against a fixture built by the package’s own writers rather than by hand, with a positive control asserting the zero-byte files are actually present. Tolerating them in the reader is necessary but not sufficient: Quarto rejects a zero-byte_metadata.ymloutright (Directory metadata validation failed ... YAML value is missing), so a project containing one fails before any R runs. That is a defect in the writer rather than the reader and is filed separately as #272.parameters$saveset in a_metadata.ymlis now honoured rather than discarded (#270, finding 1). The external file assignsparameters$save <- TRUEunconditionally after aggregating, so of all the keys the inheritance chain carries,savewas the one it could not — asave:in any_metadata.ymlwas read and immediately overwritten. The chunk this package emits floors the value instead of assigning it (if (is.null(parameters$save)) parameters$save <- TRUE), so the chain wins and the default applies only when nothing set one. The documented project-level override, assigningparameters$savein the project’s owngeneral_formatting.R, is unaffected: it runs afterwards and is unconditional. Note a consequence for reports that supply noparametersat all:savewas previously passed tosarosas an unforced promise over an undefined symbol, which silently behaved as “do not save”, so chapters generated by this package alone never wrote figure or table files. They now do, and gain the[CSV]/[PNG]download links thatsaveexists to produce. Setdraft_report(chapter_setup_parameters = FALSE), orsave: falsein a_metadata.yml, to keep the old behaviour.An eleventh template site, found by review of the fix above: variant 4’s
chr_tableemitted an inline`r x`while assigning nox(#269). It is that variant’scat_table_htmlwith thenrange/link/xlines removed and the inline expression left behind; variants 2 and 5’schr_tableemit no`r x`at all, which settles the fix as removing the orphan rather than restoring the computation. In a shared knitr environment this is not merelyobject 'x' not found: an earlier chunk may still havexbound, in which case the table renders another section’s caption. Neither existing guard reached it —r_chunks()matches only fenced blocks, and an inline use is not a subscript — so a third check now parses inline`r ...`and`{r} ...`expressions and reports any variable the template never assigns.Ten more default chunk template sites emitted code that could not run (#269). Nine passed a bare
datawhere the chapter’s own dataset was meant — seven assaros::makeme(data = data, ...)in variant 4, two asdata |> saros::makeme(...)in variant 5. A generated chapter bindsdata_<chapter>and never bindsdata, sodataresolved toutils::data, the function, and the chunk died with`x` must be a vector, not a function.The tenth is variant 4’schr_table, which assignedtbland then readtbls—vapply(tbls, ...),names(tbls),tbls[[.x]]— givingobject 'tbls' not found; the correct spelling is the plural, as its owncat_table_htmlsiblings use at four other sites, becausemakeme()returns a list of tables there. That one is worse than a plain error in a mixed chapter: an earlier chunk does assigntbls, and knitr shares one environment across chunks, so the chr table would have silently rendered the previous, unrelated table rather than failing. Found by adversarial review of #267 rather than by the guards that PR added, which is the point of the two below. A variant 5 chapter now renders end-to-end; before this it aborted in its first table chunk, which is why render coverage had never extended past variant 1.Six default chunk template rows emitted code that could not run (#266). Four of them — the univariate and bivariate
cat_table_htmlof variants 2 and 4 — emittedx <- I(paste0(c(nrange, link), collapse=', 'with the closing parenthesis missing, which is a parse error in the generated document. The other two, variant 3’s univariate and bivariatecat_table_html, computedtableandnrangeand then referencedlinkinpaste0(c(nrange, link), ...)without ever assigning it, givingobject 'link' not foundat render. Both arecat_table_html, the most-used table template, and between them they affect three of the five variants. The missing assignment is restored in the sibling form the other variants already use — variant 1 writeslink <- saros::make_link(data={.obj_name})and variant 2link <- saros::make_link(data = tbls[[.x]]), so this one islink <- saros::make_link(data = table); per the repo’s own rule, the neighbours settle it rather than it being an open design question. Note where the parse error sat, and why it survived: in variants 2 and 4 the broken line is a string insideknitr::knit_child(text = c(...)), so the parent chunk parses perfectly well and the fault only exists once knitr assembles the child document at render time.tests/testthat/_snaps/qmd_snapshots.mdhad been pinning the broken line as correct output since the snapshots were introduced, which is worth recording: a snapshot proves output has not changed, never that it was right to begin with.draft_report(format = )now sets the Quarto output format of every generated file (#264).process_yaml()has always taken aformatargument, defaulting to"html", and neither of its two call sites —gen_qmd_chapters.Randgen_qmd_file.R— ever passed it. So every chapter,index.qmdand the combined report declaredformat: html, and no argument anywhere ondraft_report()could change it; the only escape was to hand-write a complete YAML file. That matters more than it sounds, because non-HTML output is plainly in scope for the project:gen_qmd_file()takesoutput_formatsto build download links, andinst/templates/PowerShell/shipsConvert_docx_to_pdf_with_msword.ps1. The default remains"html", so no existing caller’s output moves. A suppliedchapter_yaml_file/index_yaml_file/report_yaml_filestill wins, since that path takes the whole front matter from the file — the argument does not merge into it. Note thatfig-dpisits in the same hardcoded list inprocess_yaml()and remains unreachable at800, roughly eight times Quarto’s default of 96; that is left alone here deliberately rather than overlooked.draft_report(report_filename = NULL)no longer aborts before writing anything (#265). The behaviour is documented — “If NULL, will generate a filename based on the report title, prefixed with0_” — and the validator acceptsNULL, andgen_qmd_file()implements it correctly. The run never reached it:output_filenamefor the index was built withstringi::stri_replace_first_regex(str = args$report_filename, ...), which returnscharacter(0)forNULL, andcheck_string(null.ok = TRUE)rejects an empty character vector as distinct fromNULL. So a documented, validated input was converted into an invalid one on the way to the check, and the abort namedoutput_filename— an internal argument of an internal function — rather than the argument the caller actually set. The index’s link target is now taken from the filegen_qmd_file()wrote, rather than re-derived from the argument. That is deliberate rather than merely keepingNULLasNULL: when the name is title-derived the argument does not carry it, so the index would otherwise have no way to name the report that exists. The string case is byte-identical to before. This is the fourth argument in this package found declared, documented and inert — after the four fixed in #232,log_filein #245, andprocess_yaml(format=)in the entry above — which is frequent enough that a systematic sweep of every formal, asking whether it is read and whether it is reachable, would now be worth more than continuing to find them one at a time.Generated chapters now render (#119). The five default
chunk_templatesvariants calledsarosandgtfunctions unqualified —makeme(),make_link(),n_range()/n_range2(),girafe(),ggsaver,fig_height_h_barchart()/fig_height_h_barchart2(),get_fig_title_suffix_from_ggplot()andgt()— and nothing attached either package: not the generated.qmd, not this package, not the project templates ininst/. A chapter produced with the documented defaults therefore aborted at render withcould not find function "fig_height_h_barchart". Note where it aborted: that call sits in a chunk header (fig.height=), which knitr evaluates before the chunk body, so the chapter died before executing a line of its own code — the reason the failure looked unrelated to the template that caused it. All 130 such references are now namespace-qualified, and every generated chapter additionally opens with a setup chunk attachingsarosandgt. Both, deliberately: qualifying fixes the defaults, while the setup chunk also covers a project supplying its ownchunk_templateswritten the way the defaults used to be. That chunk is controlled by the newdraft_report(chapter_setup_packages = ), defaulting toc("saros", "gt");NULLorcharacter()emits no setup chunk at all. It is configurable rather than fixed because neither package is in this package’sImportsorSuggests— attaching them unconditionally would not merely add a dependency, it would widen the blast radius, taking a project whose own templates never touchgtfrom “chapters using a gt template fail” to “every chapter fails”. Note that empty means no chunk rather than an empty one: knitr executes an emptyrblock and renders a cell for it, so the absence has to be total. The setup chunk is emitted unconditionally rather than beside the dataset import, whichattach_chapter_dataset = FALSEskips entirely. Note thatsarosandgtare deliberately not added toSuggests: this package never calls them — the references are text inside template strings, executed by Quarto in a separate process — and declaring them would obligeR/to guardpkg::uses withcheck_installed(), which is impossible for a string.draft_report()now reports that a combined report will be empty (#119).combined_reportdefaults toTRUEandreport_includes_filestoFALSE, so out of the box the function always wrote areport.qmdand always left it with no chapters in it;index.qmdlikewise. This is not a render failure — Quarto renders that file happily, into an HTML document whose entire body is the title — which is exactly why it went unnoticed for so long. Neither default is changed, since flipping either would alter the generated output of every existing caller; only the silence is fixed. It is acli_inform()rather than a warning on purpose: the pairing that triggers it is the default pairing, so a warning would fire on essentially every call — 55 times across this package’s own test suite — which is the shape that trains people to ignore warnings. A message is also howdraft_report()already reports a defaulted argument.Documented that
*_qmd_start_section_filepathand*_qmd_end_section_filepathfiles are processed asgluetemplates (#119). This was not stated anywhere, and it is not a detail a caller can afford to discover by accident: braces must be doubled, so an R chunk in a snippet has to open with```{{r}}. A perfectly ordinary```{r}fence aborts the run withTemplate is invalid/object 'r' not found, naming glue rather than the snippet. Since these snippets were the only mechanism by which a caller could attach the packages the templates needed, the two defects compounded: the fix for the first bug was booby-trapped by the second.validate_chunk_templates()now checks its input (#242). Its core-column loop wasfor (col in core_columns) if (!col %in% core_columns)— the list compared against itself, so the condition wasFALSEon every iteration andchunk_templateswas never inspected at all. The function has never rejected a malformed template set in any version of this package; the string dates to the first commit. The naive repair would have been worse than the bug:core_columnsnamed.variable_type_dep, which nochunk_templateshas ever had, so changing the condition to!col %in% names(chunk_templates)would have madedraft_report()abort onget_chunk_template_defaults()— the package’s own defaults, and the default value of the argument. That name is dropped rather than renamed, which is the third possibility the issue did not list:.variable_type_depis achapter_structurecolumn, documented onrefine_chapter_overview(), consumed byvalidate_chapter_structure()andremove_from_chapter_structure_if_no_type_match(), and named indraft_report(ignore_heading_for_group = ). It was a column from the neighbouring schema sitting in the wrong validator, not a stale spelling of.template_variable_type_dep. The required set is now the four columns the defaults actually carry:.template_name,.template,.template_variable_type_depand.template_variable_type_indep. All four are required because all four already failed when absent — the check adds no new restriction, it only moves an existing failure to the point wherechunk_templatescan be named. Dropping.template_namegave atidyselect::all_of()error from insiderefine_chapter_overview(); either type column gaveMust select at least one item, from a tidyselect call inremove_from_chapter_structure_if_no_type_match()that the caller never made. The worst was.template, which passedrefine_chapter_overview()outright and surfaced one function later, insidedraft_report(), aschapter_structure is missing .template— blaming an object the caller may never have touched. Not one of the four mentionedchunk_templates. This also settles the issue’s second question, abort versus warn for a cycle: it aborts, because no caller can be relying on an omission that has never worked. Missing columns are reported together in one message rather than one abort per column, since a hand-built template set typically misses more than one. The half of the test that would have caught the original bug — every default variant must pass — enumerates the variants from.saros.envrather than hardcoding, as there are five; it was verified by reintroducing.variable_type_depinto the required set, which breaks 15 tests.draft_report(log_file = )now writes the log it documents, and both functions’ documented default forlog_filehas been corrected from"_log.txt"toNULL(#245). The argument was documented, declared as a formal and validated, but never read —draft_report(log_file = "run.log")produced no file and no error. It was not always inert: it once wrote a run-time entry, and c73000f (“Remove timing in draft_report as it takes short time now”) deleted the timing without removing the argument. Note thatdraft_report()does not callrefine_chapter_overview()— it receives an already-refinedchapter_structure— so none of the removal helpers that accept alog_fileare on its path, and there was nothing to thread through; a new call site was required. The entry it now writes is the set of columns indatathat the report does not use, via the existinglog_unused_variables(). That helper has always had anauxiliary_variablesparameter which no caller ever supplied, andrefine_chapter_overview()has no such argument, so its own list reports auxiliary columns as unused even though they are deliberately carried into the chapter datasets;draft_report()is the only function able to supply it, which is what makes this entry worth having alongside the existing one rather than a duplicate of it. The call is guarded onlog_filebeing a string rather than made unconditional, becauselog_unused_variables()also informs via cli irrespective oflog_file— calling it always would add a message to every existingdraft_report()call. With the default (NULL) behaviour is therefore byte-identical to before. The default deliberately remainsNULL: the fix is to the documentation, not to the default, so no run starts writing a_log.txtinto the user’s working directory. The examples for both functions now demonstratelog_filewithtempfile()for the same reason — an example writing a relative path would write into the user’s filespace, which CRAN policy forbids.log_file = ""is no longer accepted by eitherdraft_report()orrefine_chapter_overview()(#245).cat(file = "")writes to stdout rather than to a file, and this package’sis_string()isis.character(x) && length(x) == 1, so an empty string passed validation and the log was printed to the console while no file was created anywhere — the one input for which the argument silently did something other than what it says. Both validators now require a non-empty string. They report it differently, which is each function’s pre-existing pattern and is left alone:draft_report()warns and falls back toNULL,refine_chapter_overview()aborts. Found by review of the fix above rather than by #245 itself; it predates that fix and affectedrefine_chapter_overview(), whoselog_filealready worked, just as much.Two default chunk templates now open the div they close (#246). Variant 1’s univariate
int_table_htmland variant 5’s univariateint_plot_htmlboth ended with a bare:::and never emitted an opening fence. Both read as a bivariate sibling copied with the opener dropped rather than as a spurious close: the caption line survived intact, and the shape is otherwise identical to the sibling. The unmatched close reached generated.qmd, where Pandoc renders it as literal text or silently absorbs it depending on context — the benign direction, which is why it went unnoticed. An unmatched open is the dangerous one: it swallows subsequent content into the div and drops it from the table of contents, warning only in the render log. The templates now open::: {#tbl-{.chunk_name}}and::: {#fig-{.chunk_name}}respectively, each matching what the body actually emits — the first is a table, the second a plot. Neither template is reachable without a numericdep, and no snapshot fixture had one, which is why nothing caught this;tests/testthat/test-qmd_snapshots.Rnow carries a numeric fixture and pins both bodies.Variant 1’s univariate
int_table_htmlnow returns its summary table instead of callinggirafe()on it (#246). This is the same template as the missing#tbl-fence above and is fixed with it, because a table cross-reference anchor on a body emitting a ggiraph widget would be a knowingly-broken cross-reference. The template was in fact not renderable at all:makeme(type = 'int_table_html')returns a plain tibble, so{.obj_name}$datawasNULLand the download link silently resolved to nothing, and the followingmake_link(..., save_fn = ggsaver)aborted the whole chapter withno applicable method for 'grid.draw' applied to an object of class "tbl_df"— reported as a misleadingDo you have write access to '.'?. The render never reached thegirafe()call. The body now takes its download link from the table itself and wraps the result ingt(), following thecat_table_htmlsiblings (make_link(data = {.obj_name}),gt({.obj_name})).gt()matters here rather than returning the bare tibble: a data frame printed by knitr becomes a verbatim console dump inside the table float, and tibble’s print method drops trailing columns at the default width — theMaxcolumn vanished from the rendered report. Withgt()the chapter renders a real HTML table carrying all eleven columns, and@tbl-resolves to “Table 1”. The[PNG]download link is gone from this template, deliberately: there is no plot to save. It is the only default template that calledgirafe()on a table, and there is no bivariateint_table_htmlanywhere — it exists once, in variant 1, univariate.A mesos group whose name sanitizes to an empty string no longer overwrites
<mesos_var>/_metadata.yml(#244).filename_sanitizer()returned""for a group name in which every character was illegal —"***"became separators only, andavoid_ending_with_specials()then removed those — andfs::path()drops an empty segment silently, so the group’s_metadata.ymlwas written to the mesos variable’s metadata file instead of to a folder of its own. That destroyedparams$mesos_varandparams$mesos_var_prettyfor every sibling group, and the group’s own folder was never created; its stub also landed at<mesos_var>/<file>.qmd.setup_mesos_structure()reportedMesos structure created successfullythroughout. Note which group was hit:make.unique()disambiguated the second and later collisions into_1,_2, so the first group with an unsanitizable name got the empty name and the ones after it were merely renamed oddly. Same failure shape as #212 — a path computation that silently escapes its intended directory, with a success message. Two independent guards now:filename_sanitizer()substitutes"unnamed"for any element that would come back empty, andextract_mesos_metadata()aborts if any abbreviation is empty or duplicated. The substitute is one fixed word rather than anything derived from the element’s position, because the function must map equal inputs to equal outputs:add_chapter_foldername_to_chapter_structure()sanitizes the wholechaptercolumn — one element per row, so a chapter repeats — withmake_unique = FALSE, and a positional substitute would give a single chapter a different folder name in each of its rows. Two different names that both sanitize away therefore collapse onto each other, which is ordinary behaviour for this function ("a b"and"a-b"already both give"a_b") and is whatmake_uniqueexists to resolve."unnamed"is purely alphanumeric, so it survivesvalid_obj,to_lower, the trailing-separator trim and anysep; it is deliberately not truncated tomax_chars, since a name a few characters too long is harmless where an empty one is not.NAis still passed through asNA, which callers distinguish from a name that sanitized away.setup_mesos()andsetup_mesos_structure()now abort when two mesos groups share an abbreviation (#244).make.unique()is applied to generated abbreviations only, never to a user-supplied abbreviation column, so two groups given the same explicit abbreviation collapsed into one folder and the last one written won — again silently, and again with a success message. The check is inextract_mesos_metadata(), so it covers both entry points and any future route to a bad abbreviation, and metadata for every mesos variable is now extracted before the writing loop begins, so a fault in the second mesos variable no longer leaves the first one half-written. This turns two previously silent cases into errors, which is the intended change: both destroy files that already exist, so continuing is worse than stopping. An all-NAabbreviation column is rejected too, by the entry immediately below (#253); this check did not reach it, because such a column was filtered to length zero upstream and a length-zero vector has nothing empty or duplicated in it. One caveat on the wording of the error: reached throughsetup_mesos_structure()’s legacy two-column path, an empty explicit abbreviation is reported as a duplicate one, becausehandle_legacy_format()drops the empty string and[[<-.data.framerecycles the shortened column, fabricating a copy of the neighbouring group’s abbreviation before the check ever sees it (#248). The abort still happens and nothing is written, which is what matters here; the fabrication itself, and that mis-wording with it, is fixed by the entry immediately below.setup_mesos_structure()no longer copies one mesos group’s value onto another when a legacymesos_groupsdata frame needs cleaning (#248).handle_legacy_format()cleaned the two columns of such a frame independently and in place —df[[1]] <- clean_group_data(df[[1]]), and the same fordf[[2]].clean_group_data()dropsNAand"", so it can hand back a vector shorter than the column it replaces, and[[<-.data.framethen recycles that vector back over the original number of rows.data.frame(Skole = c("Skole A", "Skole B"), abbr = c("SK", ""))came back withabbrofc("SK", "SK"): the empty abbreviation was not rejected, it was replaced by the previous group’s. Where the shortened length did not divide evenly the conversion died instead, with the raw base R messagereplacement has 2 rows, data has 3, which names an internal assignment rather than anything the caller supplied. Rows are now filtered as rows, so a group name and its abbreviation stay on the same row. The abbreviation case had been contained since #244 — the fabricated duplicate collided, sovalidate_mesos_groups_abbr()aborted before anything was written — but only because it collided, and it was reported as a uniqueness fault when it was an emptiness one. The shape that containment did not cover is a fabricated group name:data.frame(Skole = c("Skole A", ""), abbr = c("A", "B"))recycled"Skole A"onto the second row while the abbreviations stayed distinct and non-empty, so nothing objected — the run wroteSkole/AandSkole/B, both recordingparams$mesos_group: Skole A, and reportedMesos structure created successfully. One group was addressable under two folders, and the row that should have been dropped was not. An empty abbreviation is now left in the column rather than dropped, sovalidate_mesos_groups_abbr()rejects it by naming the group it belongs to;NAis normalised to""alongside it, which matters precisely because the column is no longer filtered — anNAleft as it is would have been dropped further downstream byextract_mesos_metadata(), which filtered the group names and the abbreviations separately until #253 below, and the run would then have got as far as writing the stubs before aborting on the resulting length mismatch.clean_group_data()’sGroup data must be a non-empty character vector.abort stays where it is and keeps servinghandle_named_list()andhandle_data_frame(), which build a data frame from a bare vector and so have no second column to fall out of step with; the legacy path carries its own emptiness check instead, which can say which of the supplied data frames was empty. Group names of only whitespace are unaffected —nzchar()is the test, nottrimws(), so" "remains a usable if odd group name, as #244 pinned.setup_mesos()no longer pairs a mesos group with another group’s abbreviation (#253). This is #248 at the second entry point.extract_mesos_metadata()filtered two parallel vectors forNAindependently —mesos_groups_pretty <- mesos_groups_pretty[!is.na(mesos_groups_pretty)], and the same formesos_groups_abbr— and nothing held them in step, so anNAin one column but not the other shifted every later element of that vector relative to the other.data.frame(Skole = c("Skole A", "Skole B"), abbr = c(NA, "B"))came back with two group names and the single abbreviation"B", giving"Skole A"— the group with no abbreviation — the folder belonging to"Skole B".validate_mesos_groups_abbr()(#244) did not catch it: a borrowed abbreviation is neither empty nor duplicated. End to end the misalignment did not stay silent, but it never reached a guard in this package either — the shortened vector was recycled into adata.frame()beside a full-length one and the run died in base R witharguments imply differing number of rows: 1, 2, which names neither the mesos variable, nor the group, nor the fact that an abbreviation was what was missing. A singlekeepmask, derived from the group names, now indexes both vectors, so they are aligned by construction rather than by coincidence; the group names are what it is derived from because a missing group name is what makes a row unusable, which is the rulehandle_legacy_format()already applies. An abbreviation column of nothing butNAis now an error rather than a silent length-zero vector, which is the intended change and the reason the pinned test attests/testthat/test-setup_mesos.Rwas rewritten. That length-zero vector looked like “no abbreviations supplied, so generate them” and was not: it madecreate_includes_content_path_df()skip the group-folder level andcreate_metadata_yml()abort on its length guard, after<mesos_var>/_metadata.yml,index.qmdand the stubs had been written — a failed run leaving a directory standing that a retry would have to overwrite, reported with a message namingmesos_groups_prettyandmesos_groups_abbr, two internal variables the caller never supplied. The legacy route had reported that same input properly since #248, so the two entry points disagreed; both now abort by naming the groups, and neither writes anything. Only an absent column still means “generate them”.setup_mesos_structure()now keeps the label on a legacymesos_groupscolumn, so both entry points title the mesos variable the same way (#254).handle_legacy_format()row-subsets with[.data.frame, which subsets each column with[and so keeps names/dim/dimnames and drops everything else — alabelamong them.extract_mesos_metadata()reads that label throughget_raw_labels(col_pos = 1)and falls back to the column name (#188), so a labelled data frame produced a human-readablemesos_var_prettythroughsetup_mesos()and the bare column name throughsetup_mesos_structure()— the same asymmetry between the two routes that #188 closed. Not a regression from #248: theclean_group_data()call the row filter replaced began withas.character(), which drops the label just as thoroughly, so the label had never survived this path; #248 changed the mechanism, not the outcome. Labels belong here, and the question did not need a judgement call — the two sibling handlers in the same file both set one explicitly,handle_named_list()assigning the variable name andhandle_data_frame()carrying the source label through with a fallback to the column name, andhandle_legacy_format()was the only one of the three that did not. The attribute is captured before the filter and restored after the abbreviation column has been rebuilt, so that replacing that column does not undo the restoration. Onlylabelis restored, because nothing downstream reads any other attribute, and columns are addressed by position rather than by name, because a legacy data frame may repeat a column name. No column-name fallback is added here, sinceextract_mesos_metadata()already has one and duplicating it would put the same fallback in two places. One divergence of the same class is left open deliberately:handle_legacy_format()drops rows whose group name is""whileextract_mesos_metadata()keeps them, and?setup_mesosdocuments only thatNAis ignored — closing it would renegotiate #244’s pin that a whitespace-only group name stays usable.refine_chapter_overview()now warns when a.templaterepeats the sameinsert_text()call (#210). Projects inject auxiliary text by wrapping every template with abefore=TRUE/before=FALSEpair, and that wrap is written out longhand in two places — the generation script andapply_template_mutations(). Neither knows about the other, so applying both wraps each template twice and every inserted passage is emitted twice in the generated qmd. Neitherinsert_text()norapply_template_mutations()lives in saros.base, so nothing here can prevent the doubling; the package only ever sees the already-doubled string arriving inchunk_templates$.template. This is therefore a lint on the incoming data: it names the affected templates and the repeated call, and returns the templates unchanged. This adds a warning to existing calls that pass doubly-wrapped templates — which are already producing doubled output. The check keys on an identical repeated call rather than a count ofinsert_text()calls, because a template may legitimately address several insertion points; those calls differ in their arguments, whereas re-wrapping reproduces one verbatim. Whitespace is ignored when comparing, since the two copies of the wrap are separately authored and drift in spacing.A section that matches no rows no longer emits the previous sibling’s chunk under its own heading (#239).
new_outwas threaded through the sibling loop ingen_qmd_node()and reassigned only when a section was non-empty, so an empty section kept whatever the sibling before it produced — a figure or table appearing under a heading it does not belong to, with no error and no warning, and looking entirely plausible in the rendered report. This was latent rather than live:grouped_dataisdistinct()over the grouping columns ofchapter_structure, so every traversal path corresponds to at least one real row and no empty section arises today (instrumenting 66 deepest-level calls across five report shapes found none). But that is an invariant held elsewhere, not a local guarantee — a change to howgrouped_datais derived, toNAhandling inprepare_chapter_structure_section()’s filter, or a new grouping column whose values do not round-trip throughas.character()would have made it live.new_outis now local to each node, so an empty section contributes nothing, not even its heading. Bothqmd_enginevalues were affected identically and both are fixed by the one change; the now-deadnew_outstate has been dropped from the recursion’s loop variable and the loop engine’s stack frame.tests/testthat/test-qmd_empty_section.Rconstructs the empty section directly, since the integration path cannot produce one.draft_report(require_common_categories = TRUE)now performs the check it documents (#232). The argument was validated but never read, and thecheck_category_pairs()helper implementing it had no caller. Dependent variables within a section — the set that ends up in one figure — are now checked for at least one shared response category, before any files are written. Only factor columns are compared, since a “common category” is not meaningful for numeric or free-text variables. Setrequire_common_categories = FALSEto skip.refine_chapter_overview(keep_dep_indep_if_no_overlap = FALSE)now removes bivariate entries whose dependent and independent variables never co-occur (#232). The call site was short-circuited withif (FALSE && ...), so the argument had no effect. Enabling it exposed a latent crash inremove_from_chapter_structure_if_no_overlap():.variable_name_dep/_indepare factors that may carryNAas an explicit level, for whichis.na()on the factor isFALSE, so such rows reacheddata[[NA]]and aborted. The comparison now runs on the character form.create_r_files(r_add_file_scope = FALSE)now actually omits thefile_scopecolumn from the generated placeholder files (#232). The flag was accepted and ignored, so the scope was written either way. The placeholder file is still created in both cases, and the default (TRUE) is unchanged.create_email_credentials(ignore_missing_emails = FALSE)now warns about usernames that exist in the password file but have no email address (#232). This is the direction the argument documents; the function previously only warned about the opposite case, and never read the flag. Such accounts silently received no credentials. This adds a warning to existing calls where the password file contains accounts absent fromemail_data_frame; passignore_missing_emails = TRUEto silence it.setup_mesos()no longer writes.na.characteras the title of<mesos_var>/index.qmd(#188).extract_mesos_metadata()guarded its fallback withis.null(), butget_raw_labels()returnsNA_character_for an unlabelled column, so the display name stayedNAand was serialised into the site.setup_mesos_structure()was unaffected because it always attaches a label internally — which is what made the two entry points produce different output. They now agree on every generated file except the_metadata.ymlsubtitle, which legitimately includes themain_directoryfolder name only when one is supplied.Generated mesos stub and
index.qmdfiles now end with a newline. Their absence madereadLines()and other text tools warn about an incomplete final line.draft_report()is now reproducible (#213). Heading anchors carried two RNG-drawn digits, so identical inputs produced different.qmdfiles on every run. Quarto’sfreezecache keys on file content, so it missed on every chapter after every regeneration — a one-line change in data preparation forced a full re-render of the entire site. The suffix is now a short hash of the heading’s position in the grouping tree, which is stable across runs and a stronger disambiguator than two digits (which collided for 1% of colliding pairs).draft_report()no longer draws from the session RNG at all.Chapter files no longer contain two first-level headings (#207).
.chapter_numberhas been added to theignore_heading_for_groupdefault. The default listed"chapter", but the column grouped on is.chapter_number, so the guard never fired and the chapter title was emitted both directly and by the grouping machinery. Remove.chapter_numberfrom the argument to restore the previous grouping-generated heading, which carries a{#sec-}anchor.setup_mesos()andsetup_mesos_structure()no longer overwrite the authored_*.qmdchapter sources inmain_directory(#212). A stub was emitted at the top level, replacing each source file with an include pointing outsidemain_directory. The failure was silent and repeated on every run, so restoring the files from version control was not sufficient.Mesos
{{< include >}}paths now resolve (#212). The relative path scaled with the directory level (rep("../", path_lvl)), but consecutive levels always differ by exactly one component, so every level above the innermost skipped a directory and eventually escapedmain_directory. Quarto does not error on an unresolvable include, so affected group pages rendered as empty documents with correct titles and_metadata.yml.A multi-component
mesos_var_subfoldersuch as"Rapport/Del1"now nests instead of erroring (#212).write_subfolder_metadata()vectorised over the components rather than nesting them, addressing a non-existent sibling directory and failing withcannot open the connectionafter stub files had already been written. This affected the documented example in?setup_mesos_structure, which usedmesos_var_subfolder = "reports/Q1".Removed the stray
'#\newpage'element from the tabset chunk templates (#214). The single backslash was re-parsed by R as a newline escape when the generated qmd was rendered, so the textewpageappeared above every tabset on every page. Affected 6 of 7 templates inget_chunk_template_defaults(2)and 3 of 7 in variant 4. A page break was meaningless in these HTML templates in any case.delete_freeze()is now actually exported (#219). It was documented with@exportand had a generatedman/delete_freeze.Rd, butNAMESPACEhad not been regenerated, sosaros.base::delete_freeze()failed with “not an exported object”.draft_report(title = )is no longer a silent no-op (#208, #184).process_yaml()only assigned the title when an explicityaml_filewas supplied, soindex.qmdandreport.qmdwere written without atitlefield in the default case.Chapter qmd-files now receive their
chaptername as the YAMLtitle(#208, #184). Previouslygen_qmd_chapters()passedtitle = NULL, leaving Quarto to infer the page title from the first body heading — which is why titles varied across Quarto versions, and why projects post-processed the heading into the header with regexes that truncated at hyphens.Mesos group
_metadata.ymlfiles now get thetitlefield that?setup_mesosdocuments forsubtitle_separator(#184). The assignment was commented out, and referred to an out-of-scope variable..variable_label_suffixis now whitespace-normalised like the prefix (#216).refine_chapter_overview()passed.variable_label_prefixtotrim_columns()twice and never passed the suffix, so label suffixes kept leading/trailing spaces and internal runs of spaces. These suffixes become section headings, where leading whitespace is significant in Markdown. Only visible with alabel_separatorthat does not itself include surrounding spaces, e.g.":".delete_freeze()no longer warnsno non-missing arguments to maxwhen a_freezeentry contains no files (#220). Such an entry is stale and is still deleted; only the spurious warning is gone. Staleness now also ignores directory mtimes, and_freezeitself is excluded when discovering.qmdfiles.Suggested packages are now used conditionally, per R-exts (#215).
srvyr(inungroup_data()) andwritexl/readr/haven(intabular_write()) are guarded withrlang::check_installed(), which reports an actionable install prompt instead of “there is no package called …”. The singlepurrr::compact()call was replaced with base R.
New features
-
aggregate_metadata_yml()reads the_metadata.ymlinheritance chain this package has always written, anddraft_report(chapter_setup_parameters = )makes every generated chapter build itsparametersobject from it (#270). Until nowsaros.baseemitted_metadata.ymlfiles carryingparams:at the mesos variable and group levels (setup_mesos.R) while the only function able to read them back lived in an organization’sgeneral_formatting.R, outside any repository — so the two ends of the chain could disagree, and did. The stated reason for the split, avoiding ayamldependency, had already lapsed:yamlis inImports. The new function collects every_metadata.ymlfrom the Quarto project root down topathand merges them withutils::modifyList(), so a deeper folder overrides a shallower one andparams$mesos_groupset beside a chapter beats aparams:set for the whole wave. Generated chapters now open with a second setup chunk — separate from thelibrary()one, becausechapter_setup_packages = NULLis the documented escape hatch for a project needing neithersarosnorgtand must not silently takeparameterswith it — which assignsparametersonly when nothing has assigned it already. That keeps it a floor rather than an override: a project sourcing its owngeneral_formatting.Rfirst keeps its object, and one sourcing it afterwards overwrites this exactly as before. What this does not do is unblock the mesos variants, and an earlier draft of this entry claimed otherwise. Counted rather than assumed: variants 4 and 5 readparameters$at four sites each, variants 2 and 3 read only Quarto’sparams, and variant 1 neither. A standalone variant 4 chapter fails identically before and after this change, insidesaros::makeme()with `mesos_varandmesos_groupmust be specified (as strings), because a generated chapter's YAML declares noparams:— that is Quarto's mechanism, not this one, and reaching it needs a_metadata.ymlinside a project, which #272 currently prevents. Variant 5 already rendered before this change too, sinceparameters$savewas an unforced promise over an undefined symbol; what changes for it is thatsave` now carries a real value and therefore actually saves. -
Two deliberate differences from the external function that is being replaced, both visible to any project that switches to this one. First, the walk is bounded by the Quarto project root — the nearest ancestor holding a
_quarto.ymlor a_quarto.yaml, both spellings being live in the wild — rather than by the first ancestor lacking a_metadata.yml. The original broke at the first gap, so a project-level_metadata.ymlwas unreachable from any chapter with an intervening folder that had none; the new rule reaches it. A_metadata.ymlabove the project root is not read, and when no project file is found at all onlypathitself is, so the walk can never escape into unrelated folders. Second, it takes apath(default".", correct at render time because Quarto executes a document with the working directory set to that document’s own folder) instead of callingknitr::current_input();knitris in neitherImportsnorSuggestshere, and a function that only works inside a knit cannot be tested at all. Both_metadata.ymland_metadata.yamlare read, the latter winning if a folder somehow holds both. - Added
default_chunk_templates_5: a new simplified template set for single crowd reports without mesos structure. Uses cleaner helper functions likeget_fig_title_suffix_from_ggplot()for more streamlined code generation. -
draft_report(qmd_engine = )selects how the grouping tree is traversed when assembling each chapter (#19)."recursion"(the default, and the original implementation) makes one R call per node, so a deeporganize_byis bounded byoptions("expressions")and the C stack."loop"walks the same tree with an explicit stack, bounded by heap instead. The two produce byte-identical output;tests/testthat/test-qmd_engines.Rasserts that across five report shapes, including the bundled example, andtests/testthat/test-qmd_engines_ordering.Radditionally pins ordering, grouping and sorting across fiveorganize_byshapes, threearrange_section_bydirections, bothna_first_in_sectionsettings, reversed chapter declaration and a degenerate single-value tree. Measured on the bundled example the two are within noise of each other (5.3s vs 4.8s at the default depth, 18.1s vs 18.2s with one extra grouping level), so this is about depth headroom and having a fallback, not speed. -
draft_report(glue_heading_for_group = )rewrites the text of the headings at one level of the grouping tree, which nothing could previously reach. Names are grouping columns, as inignore_heading_for_group; values aregluetemplates in which{heading}is the label that would otherwise have been written.c(.variable_name_indep = "By {tolower(heading)}")turns### Genderinto### By gender, so a reader who sees the sub-heading and the figure without the parent heading above them still knows the section is about self-efficacy. Note the key:.variable_name_indepis the column grouped on, whilereplace_heading_for_groupmakes.variable_label_suffix_indepsupply the label — the four*_heading_for_grouparguments all key on the former, and this one follows them rather than inventing a second convention. Because each grouping column occupies a fixed position inorganize_by, naming a column is how a heading level is targeted. Arbitrary R runs inside the braces, so{sub("^(.)", "\\L\\1", heading, perl = TRUE)}lowercases only the first letter and a template with no placeholder at all gives a fixed heading; a malformed template aborts through the package’s existingglue_err(), naming the argument. The existingprefix_heading_for_group/suffix_heading_for_groupare not this and are unchanged: they emit whole lines above and below the heading —stri_c(prefix, "\n", "##", ...)— so they can add a paragraph around a section but cannot alter the heading itself. The two compose. The template is applied afterreplace_heading_for_grouphas chosen the source column and before the{#sec-}anchor is appended, so the anchor stays derived from the group’s value: editing a template moves no cross-reference and invalidates no Quartofreezecache, the property #213 established. A group listed inignore_heading_for_groupemits no heading and is therefore unaffected. The default isNULLand output is byte-identical to before when it is not set. Bothqmd_engineengines hand the argument togen_qmd_node()separately, sotest-qmd_engines.Rpins them equal with a template in force — threading it into only one would otherwise have left every other test green, since the integration test runs the default engine alone.
Testing
-
tests/testthat/test-setup_mesos_subfolder_metadata.R(#272), whose central guard is a Quarto render rather than a file-existence check. The previous coverage asserted only that the intermediate files were written, which is exactly what the defect did — the files existed and were fatal. The render needs only Quarto, notsarosorgt, because the failure is Quarto refusing the project’s directory metadata before any R executes; that makes it substantially cheaper than the render tests intest-generated_qmd_renders.R. Verified by mutation: reverting to the zero-byte writer, and separately switching toyaml::write_yaml(list(), f), each break it. The second is worth noting — that mutant still passes a “the file is not empty” assertion, so without the render guard the failing implementation would have looked correct. - The
_metadata.ymlcoverage intest-aggregate_metadata_yml.Rwas split in two rather than repointed (#272). One test keeps the reader’s tolerance of a genuinely zero-byte file, now on a hand-built fixture, because projects generated before this fix still contain them and that tolerance is all that stands between such a project and an abort. The other asserts the writer/reader agreement against the package’s own writers, with a positive control that the intermediate file exists and is non-empty. Splitting them avoids the failure mode where fixing a writer quietly hollows out the test that was covering the reader. -
tests/testthat/test-aggregate_metadata_yml.Randtests/testthat/test-chapter_parameters_chunk.R(#270). Every assertion in both was watched failing first, and the ones that matter were then verified by mutation rather than trusted: dropping_quarto.yamlfrom the project-root markers, dropping_metadata.yaml, merging deepest-first, removing theexists()guard, gating the parameters chunk onchapter_setup_packages, and emitting it after the templates instead of before — each killed exactly one test and no others. The three behavioural claims made against the previous implementation were checked against that implementation itself, run verbatim over the same fixtures, rather than against a synthetic stand-in: it loses the project-level file across a gap, aborts on the package’s own empty_metadata.yml, and merges a_metadata.ymlfrom above the project root. The chunk tests run the emitted code rather than matching its text, sosavedefaulting toTRUE, asave: falsein the chain beating that default, and a pre-existingparameterssurviving untouched are all asserted on the resulting object. - A guard that
parametersis assigned above the first template that reads it, in a generated chapter (#270). #270 predicted theparametersentry intest-generated_code_parses.R’s allowlist could be deleted once the package supplied the object itself. It cannot: that check asks whether a template assigns what that template subscripts, and the assignment lives in the chapter, above every template in the file. The entry stays with a corrected justification — the same one thedata_names already carry — and the ordering property it cannot express is pinned end-to-end instead. - Two broader guards in
tests/testthat/test-generated_code_parses.R, alongside the narrow one added in #267 rather than replacing it (#269). That check stays because it is not subsumed: it looks for a variable used as a bare value, aslinkis inpaste0(c(nrange, link), ...), where the new check below looks for subscripted variables. The first rejects any template that uses a baredata, walking the parsed chunk fordataas a value — argument names come free, since inf(data = x)the string"data"is a name of the call rather than an element of it, and$/@/::right-hand sides are skipped so thatmake_link(data = plot$data)is not misread as a baredata. The second is the variable-agnostic form of #267’s used-but-never-assigned check, which was hard-coded tolinkandlink_plot— the reason it did not seetbls. It now flags any subscripted variable a template never assigns. Both were written before the fix and both reported exactly the defects above and nothing else, after two false positives in the checks themselves were removed. -
parametersandparamsare on an explicit allowlist in that second check, because they are legitimately supplied from outside the template:paramsby Quarto from each file’s YAML,parametersby an external formatting file sourced into every generated qmd, which aggregates the_metadata.ymlinheritance chain. Two independent reviews have now misreadparameters$saveas an undefined symbol, so the allowlist carries the explanation and an anti-vacuity test assertsparametersreally does appear in the templates — otherwise the allowlist would be inert and its coverage untested. See #270, which proposes moving that aggregation into this package; the allowlist entry forparametersgoes when it does. - The render tests now cover variant 5 as well as variant 1 (#269). Variant 5 could not render before this fix, so the test is new coverage rather than a gap being filled. Variants 2, 3 and 4 remain out of reach: they are the mesos variants and need a fixture supplying both
paramsandparameters(#270). Verified by revertingR/zzz.Rto its previous state, against which the new test fails on all three of its assertions. - Added
tests/testthat/test-generated_code_parses.R, which parses every R chunk of a chapter generated from each of the five default variants (#266). This is the cheap half of the guard #263 was missing: it needs no Quarto, nosarosand nogt, and covers all five variants in seconds, where the render tests are slow enough that their fixture reaches only variant 1. It descends intoknitr::knit_child(text = c(...))rather than stopping at the parent chunk, which is essential rather than thorough — the parse error it was written for lives inside child text, so a guard that stopped at the parent would have reported all-clear on exactly the case that motivated it. The child’s string literals are read off the parse tree rather than evaluated, so nothing in a template is executed. Two anti-vacuity controls sit alongside: one asserting chunks were found at all, one asserting child text was found, since every other assertion in the file would pass trivially on an empty set. - A companion static check that no template references
linkorlink_plotwithout assigning it (#266). Deliberately static rather than a render test, and not for speed: variants 2, 3 and 4 are the mesos variants, carrying 14 to 19params$references each, 7 to 9 of themparams$mesos_var, so they cannot render standalone without a mesos fixture — and variant 3 is precisely where the unassignedlinklived. A render test would not have covered it at any price. Verified by mutation, by removing the four restored assignments and confirming the check names variant 3 rows 3 and 4. - Added
tests/testthat/test-generated_qmd_renders.R, which actually renders a generated chapter with Quarto (#119). Nothing in this package had ever done so. The suite’s onlyquarto_render()call, intest-draft_report.R, was guarded byif (FALSE && ...)— dead from commit72d9487, the first commit, in July 2024. #119 was filed four months into that gap and stayed unreproduced for the twenty-one months after it. The dead block is removed and replaced by tests that run: a chapter generated with no start section must render, and the rendered HTML must contain the section heading and a figure div, the latter as a positive control so that templates silently emitting nothing cannot pass. The file is necessarily heavy — it shells out to Quarto, which executes R callingsarosandgt— so it guards onskip_on_cran()plusskip_if_not_installed()forquarto,sarosandgt, and on the Quarto CLI being present. Contributing nothing on CRAN is the intent rather than a defect. - Added
tests/testthat/test-documented_defaults.R, which reads the*default:*value out of every documented argument and compares it againstformals()(#251). Verified to report exactly the 24 corrections listed above, and nothing else, when run against the pre-fix sources. The comparison is made on R code rather than on text — both sides are parsed and re-deparsed — soc("a" = 1)andc(a = 1)count as equal. It takes the value from the parsedRdnode rather than from the raw.Rdbytes, so it compares what?topicactually displays, which is what makes it catch the stray-backslash class above. Two things are deliberately outside it: a block stating*default:* see Usagein prose rather than as a value states nothing to compare and is skipped, which is howignore_heading_for_groupstays as it is; and thearg_match()exemption applies only to arguments genuinely handed torlang::arg_match()/match.arg(), discovered by deparsing the package’s own function bodies rather than listed in the test, so it cannot quietly widen to any argument that merely happens to default to a character vector —organize_byandignore_heading_for_groupare such arguments, and a second test pins that they do not qualify.qmd_enginereachesarg_match()under the nameengine, so it is mapped across explicitly, and the mapping is written so the exemption lapses ifgen_qmd_structure()ever stops callingarg_match(). The test reads the installed help and the installed function bodies when package sources are absent, so unlike the other source-scanning tests in this suite it runs underR CMD checkrather than skipping there — which is where CI would otherwise have missed it — and it asserts a floor on how many arguments it matched, so a future roxygen2 that renders*default:*differently fails the test rather than silently disarming it. This supersedes the narrowerlog_filecheck added in #245, which is kept because it reads the roxygen inR/rather than the generatedman/. - Added snapshot tests of the
.qmdtextdraft_report()writes (tests/testthat/test-qmd_snapshots.R). Nothing previously asserted anything about the generated content — the existing test checks file counts and file sizes — which is why #207, #208/#184 and #216 all shipped. Each of those was re-introduced and confirmed to fail the new tests. Only possible now that #213 made the output deterministic;test-anchor_determinism.Rpins that property separately.
Code quality improvements
- The roxygen2 pin moved from 7.3.3 to 8.0.0, in
DESCRIPTIONand in theroxygen-driftworkflow, which must agree or the job aborts before it checks anything. No generated documentation changed:roxygen2::roxygenise()under 8.0.0 reproduces the checked-inman/andNAMESPACEbyte for byte, so the entire upgrade is the pin plus a field rename — 8.0.0 records its version asConfig/roxygen2/versionwhere 7.x usedRoxygenNote, and appends it at the end of the file rather than writing it in place. The workflow needed nothing beyond the pin, because it already read whichever of the two fields is present and already excludedDESCRIPTIONfrom its diff. The pin remains deliberately notlatest: 8.1.0 reflows multipleimportFrom()entries from one package into a multi-line call, so it would disagree with the checked-inNAMESPACEwithout either version being wrong, which is the reason the job pins at all (#219). This retires the last of the recurring working-tree artifacts #257 addressed — with a local roxygen2 newer than the pin, everydevtools::document()rewroteDESCRIPTIONas a side effect of documenting something else, and the rewrite had to be reverted by hand before staging or it rode along in an unrelated commit. - Moved
tibblefromSuggeststoImports(#215)..onLoad()builds the default chunk templates withtibble::add_row(), and R-exts requires a package to declare what its own code uses directly. This corrects the declaration; it does not change observable behaviour.tibbleis a hard dependency ofdplyr,tidyrandforcats— all already inImports— so it has always been installed alongside saros.base, and no installation could have lacked it. - CI now fails when
man/orNAMESPACEdiffer from whatroxygen2::roxygenise()produces from the roxygen comments inR/(#219). This is the drift that hiddelete_freeze():R CMD checkaccepts a package whoseNAMESPACEis missing an export — it is simply a package without that function — and pkgdown indexes.Rdtopics rather than exports, so neither caught it. -
.saros.envis now an actual environment (#218). A package-level.saros.env <- NULLmadeexists(".saros.env")inside.onLoad()always true, so thenew.env()branch never ran; the first$<-coercedNULLto a list, and each of the ~50 subsequent assignments copied the whole accumulating list — including the large chunk-template tables — instead of mutating in place. The superassignments (<<-) are no longer needed and have been replaced with ordinary$<-. - Removed the empty file
R/utils_qmd.R(#220), a leftover of the refactor that moved the QMD helpers intoR/qmd_utils.R. - Removed the unused and broken
create_text_collapse()(#217). It readformals(draft_report)$translations, butdraft_report()has notranslationsargument, so the last separator resolved toNULLandc("a", "b", "c")collapsed to"a, bc"rather than erroring. A new test asserts that everyformals(fn)$namereference inR/names a real argument. - Improved code formatting and readability in
.onLoad()function for better maintainability. - Updated template references in
default_chunk_templates_4, addingsave = parameters$save. The other half of this change was a regression and is corrected below. Switchingdata_{.chapter_foldername}to a baredatawas described here as consistency; it is not. A generated chapter bindsdata_<chapter>and never bindsdata, so a baredataresolves toutils::data— the function — and the chunk dies with`x` must be a vector, not a function.Nine sites across variants 4 and 5 are affected; tracked in #269 and not fixed here. Thesave = parameters$savehalf is correct and stays:parametersis supplied by an external formatting file sourced into every generated qmd, which is how one location controls settings across all of them — Quarto’sparamscannot, since it resolves to each file’s own YAML. - Better structured code blocks with consistent indentation and spacing.
saros.base 1.2.1
Bug fixes
- Fixed bug in
setup_mesos()where an incorrect assignment tofiles_to_processwas causing the search and replace functionality to fail.
saros.base 1.2.0
CRAN release: 2025-11-12
New features
- Added file logging for excluded/ignored variables via
log_fileparameter inrefine_chapter_overview(). All removal functions now log which variables/entries are excluded and why (all NA, low n, non-significant, no overlap, type mismatch). - Added
detect_malformed_quarto_project(): exported function to diagnose malformed Quarto website projects (missing index.qmd, missing title in .qmd files, extensible for future checks). - New function
check_variable_labels()to validate variable labels for saros compatibility. - New function
sanitize_chr_vecfor ensuring that character vectors are clean:- Normalized Unicode strings to NFC form.
- Removed non-printable characters.
- Replaced common encoding artifacts (e.g.,
’to').
- Added chunk template variant 4 (
get_chunk_template_defaults(4)) for mesos reports using the new saros package functionscrowd_plots_as_tabset()andtxt_from_cat_mesos_plots(). This provides a more streamlined approach for generating mesos-specific plots and tables.
Performance improvements
- Vectorized password lookup in
refer_main_password_file()for better performance.
Bug fixes
- Fixed critical sorting bug in
refine_chapter_overview()where output was incorrectly sorted by variable labels instead of variable positions when using default arguments. The fix includes:- Corrected
arrange_expr_producer()to properly name arrange expressions with column names instead of logical values. - Added ungrouping before sorting in
arrange_arrangers_and_groups()to prevent grouped data from interfering with global sort order. - Made chapter reordering stable to preserve within-chapter sorting.
- Corrected
- Fixed regex bugs in
check_variable_labels(). - Fixed tidyselect warnings in
look_for_extended(). - Improved robustness of
setup_mesos(). - Added validation checks for email and username columns in
create_email_credentials().
Code quality improvements
- Refactored long functions by extracting helper functions:
validate_refine_chapter_overview_args()validate_draft_report_args()create_mesos_stubs_from_main_files()gen_qmd_file()create_includes_content_path_df()validate_chapter_structure()look_for_extended()process_yaml()
- Removed broken and unused
create_heading()function. - Removed commented-out and unused code.
- Refactored
convert_mesos_groups_to_dfand its helper functions to ensure consistent handling ofmesos_groups. - Added a
clean_group_datainternal helper function to:- Drop unused levels for factors.
- Remove
NAand blank strings.
Testing
- Added 169 comprehensive tests across multiple modules (from 331 to 500+ tests).
- Added 16 tests for
refine_chapter_overview(). - Added comprehensive sorting tests in
test-arrange2.Rto verify position-based sorting with intentionally mismatched variable names, labels, and positions. - Added 27 tests for logging functionality.
- Added tests for setup_mesos helper functions, utility functions, access restriction setup, and directory structure helpers.
Documentation
- Added
check_variable_labels()to pkgdown reference. - Added Copilot instructions for testing and git workflows.
saros.base 1.1.0
CRAN release: 2025-06-01
-
create_directory_structure()example does not create files and folders on disk to save time. - Templates for mesos output now include newlines between target and others. Thanks to Jon Furuholt for the suggestion.
-
draft_report()now has argumentwrite_qmdto toggle the creation of qmd-files. - Attempted fix of internal arrange2 sorting function. Very hard to get right.
saros.base 1.0.0
CRAN release: 2025-01-10
Major changes
- Total revision of the entire architecture for maximum flexibility, stability and performance.
- Uses glue templates for creating chunks, see
refine_chapter_structure(). draft_report()- Breaking changes for mesos setup, now uses
setup_mesos()as well for creating stub files referring to a smaller set of main files created bydraft_report(). - Countless bugfixes.
Minor changes
- Helper function
remove_entry_from_sidebar()for post-processing HTML-files - Many more validations of arguments and better error messages.