From 9ca133319c5f358b37bb80321628958f270e1ed7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Sun, 6 Sep 2026 15:06:46 +0200 Subject: [PATCH 1/8] feat: add the schema-check module Every extension that checks its `_schema.yml` at render time needs the same wiring. It reads the schema once, checks the document configuration, checks a shortcode call, and reports through `logging`. The module holds that wiring, so an extension does not copy it. The validator arrives as an argument rather than through `require`. A vendored copy of this module then knows nothing about where the validator was vendored, and the two sources stay independent. The level of each finding lives in one table. Nothing the module reports stops a render. --- CHANGELOG.md | 2 + README.md | 9 ++ modules/schema-check.lua | 271 +++++++++++++++++++++++++++++++++++++++ tests/run.lua | 185 ++++++++++++++++++++++++++ 4 files changed, 467 insertions(+) create mode 100644 modules/schema-check.lua diff --git a/CHANGELOG.md b/CHANGELOG.md index 01655ed..64af61d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- feat: Add the `schema-check` module. It checks a document and a shortcode call against the extension's `_schema.yml`, and it reports what it finds. + ## 2.0.0 (2026-09-05) - refactor: Move `has_extension` and `is_markdown` from the `lookup` module into the `paths` module. Both functions ask a question about a path, so `paths` is where they belong. A consumer that called `lookup.has_extension` or `lookup.is_markdown` must call the same function on `paths`. diff --git a/README.md b/README.md index 2a7bf14..28fd6f8 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ They are meant to be vendored. Quarto ships no package manager for Lua, so an ex | `metadata.lua` | Reads extension configuration out of document metadata. | | `pandoc-helpers.lua` | Builds Pandoc elements and detects the output format. | | `paths.lua` | Resolves a path relative to the project and checks a URI's file type. | +| `schema-check.lua` | Checks a document and a shortcode call against the extension's `_schema.yml`. | | `string.lua` | Splits, trims, and escapes for HTML, LaTeX, Typst, JavaScript, and Lua. | ## Usage @@ -29,6 +30,14 @@ local str = require(quarto.utils.resolve_path('_modules/string.lua'):gsub('%.lua A module that needs another one finds it in the same directory, so copy them together. +`schema-check.lua` takes its validator as an argument rather than loading one, so an extension can vendor the two from different sources: + +```lua +local checker = check.new(schema, 'iconify') +local defaults = checker:options(meta) +checker:call('iconify', args, kwargs) +``` + ## Development The modules run inside Quarto's Lua, and several of them call `pandoc.*`, so the tests run there too: diff --git a/modules/schema-check.lua b/modules/schema-check.lua new file mode 100644 index 0000000..36d200d --- /dev/null +++ b/modules/schema-check.lua @@ -0,0 +1,271 @@ +--- MC Schema Check - Runtime schema checks for Quarto extensions +--- @module "schema-check" +--- @license MIT +--- @copyright 2026 Mickaël Canouil +--- @author Mickaël Canouil +--- @version 2.0.0 +--- +--- Holds the wiring that every extension would otherwise copy: read +--- `_schema.yml` once, check the document configuration against it, check one +--- shortcode call against it, and report what it finds through `logging`. +--- +--- The validator arrives as an argument rather than through `require`. A +--- vendored copy of this module then knows nothing about where the validator +--- was vendored, so the two sources stay independent. +--- +--- Nothing here stops a render. A schema is configuration, and a fault in the +--- configuration must not remove the document. + +local M = {} + +--- Load a sibling module from the same directory as this file. +--- @param filename string The sibling module filename (e.g., 'string.lua') +--- @return table The loaded module +local function load_sibling(filename) + local source = debug.getinfo(1, 'S').source:sub(2) + local dir = source:match('(.*[/\\])') or '' + return require((dir .. filename):gsub('%.lua$', '')) +end + +--- Load required modules +local log = load_sibling('logging.lua') +local str = load_sibling('string.lua') + +-- ============================================================================ +-- SEVERITY +-- ============================================================================ + +--- The level each kind of finding is reported at. This is the only place a +--- severity is decided, so a later change to what an extension must correct is +--- a change to this table and nothing else. +--- +--- The current policy reports and never stops a render. A finding is a warning, +--- with two exceptions. An unreadable schema is an error, because no check runs +--- after it. A missing required argument is an error, because the shortcode +--- renders nothing without it, which is the one finding that changes the +--- document. +--- +--- A rejected document option keeps the error level it has today: it names a +--- value the extension cannot use, and the author has to correct it. +--- @type table +local SEVERITY = { + schema = 'error', + option_error = 'error', + option_warning = 'warning', + call_error = 'warning', + call_warning = 'warning', + missing_argument = 'error', +} + +--- The reporting function for each level. +--- @type table +local REPORTERS = { + error = log.log_error, + warning = log.log_warning, +} + +-- ============================================================================ +-- PRIVATE HELPERS +-- ============================================================================ + +--- Read an attribute value with a surrounding quote pair removed. +--- Quarto's body parser strips those quotes before the value reaches the +--- shortcode, but the parser it uses for a text or attribute string hands the +--- raw token over instead, so `aria-hidden='true'` arrives as the five +--- character string `'true'`. Stripping here is what makes a quoted and an +--- unquoted value check as the same thing. +--- @param kwargs table Key-value options for the call +--- @param key string The attribute name to read +--- @return string +local function attr_value(kwargs, key) + --- @type string + local value = str.stringify(kwargs[key]) + --- @type string + local quote = value:sub(1, 1) + if #value > 1 and (quote == '"' or quote == "'") and value:sub(-1) == quote then + return value:sub(2, -2) + end + return value +end + +--- Flatten a call's named options to plain strings for the validator. +--- @param kwargs table Key-value options for the call +--- @return table +local function plain_kwargs(kwargs) + --- @type table + local plain = {} + for key in pairs(kwargs) do + plain[tostring(key)] = attr_value(kwargs, key) + end + return plain +end + +-- ============================================================================ +-- CHECKER +-- ============================================================================ + +--- @class Checker +--- @field validator table The validator the caller injected +--- @field extension string The extension name every message carries +--- @field schema table|nil The parsed schema, nil when it could not be read +--- @field defaults table The defaults the schema declares +--- @field options_checked boolean Whether the configuration was already checked +local Checker = {} +Checker.__index = Checker + +--- Report one finding at the level its kind is mapped to. +--- @param kind string A key of `SEVERITY` +--- @param message string The message to report +--- @return nil +function Checker:_report(kind, message) + --- @type function + local reporter = REPORTERS[SEVERITY[kind]] or log.log_warning + reporter(self.extension, message) +end + +--- Check the document configuration and return the defaults the schema +--- declares. The check runs once, so an extension can ask for the defaults on +--- every shortcode without repeating the messages. +--- +--- The defaults come from a second pass over an empty table, which yields the +--- declared defaults alone. An extension needs them anyway, and reading them +--- back from the schema keeps `_schema.yml` the one place they are written. +--- @param meta table Document metadata +--- @return table The defaults, empty when there is no schema +function Checker:options(meta) + if self.options_checked then + return self.defaults + end + self.options_checked = true + + --- @type table|nil + local loaded = self.schema + if loaded == nil or loaded.options == nil or next(loaded.options) == nil then + return self.defaults + end + + --- @type table + local provided = self.validator.extract_meta_options(meta, self.extension) + local valid, errors, warnings = self.validator.validate(provided, loaded.options) + + for _, message in ipairs(warnings) do + self:_report('option_warning', message) + end + if not valid then + for _, message in ipairs(errors) do + self:_report('option_error', message) + end + end + + local _, _, _, defaults = self.validator.validate({}, loaded.options, { unknown = 'ignore' }) + self.defaults = defaults or {} + + return self.defaults +end + +--- Check one shortcode call against its entry in the schema. +--- This reports only. Nothing about the rendered output changes, so an +--- unrecognised attribute is surfaced rather than dropped. +--- @param name string Shortcode name +--- @param args table Positional arguments +--- @param kwargs table Key-value options for the call +--- @return nil +function Checker:call(name, args, kwargs) + --- @type table|nil + local loaded = self.schema + if loaded == nil then return end + + --- @type table|nil + local entry = loaded.shortcodes and loaded.shortcodes[name] + if entry == nil then return end + + args = args or {} + kwargs = kwargs or {} + + --- @type table + local positional = {} + for index, value in ipairs(args) do + positional[index] = str.stringify(value) + end + + local _, errors, warnings = self.validator.validate_shortcode( + name, positional, plain_kwargs(kwargs), entry) + + for _, message in ipairs(warnings) do + self:_report('call_warning', message) + end + + -- A required argument that is absent is read here rather than out of the + -- validator's findings, because the validator reports a nested argument + -- fault under one `arguments` entry, which cannot tell a missing argument + -- from a malformed one. + --- @type table + local missing = {} + for index, argument in ipairs(entry.arguments or {}) do + if argument.required == true and str.is_empty(positional[index]) then + missing[#missing + 1] = argument + end + end + + if #missing > 0 then + -- The only message about the missing argument: the schema's own `required` + -- wording says the same thing, and reporting both would state one fault + -- twice at two severities. Attribute warnings above still stand, and every + -- other finding about this call is secondary to there being no output. + for _, argument in ipairs(missing) do + --- The example comes from the schema, so that each shortcode carries its + --- own rather than this message naming one shortcode for all of them. + --- @type string + local advice = '' + local example = type(argument.examples) == 'table' and argument.examples[1] or nil + if example ~= nil then + advice = string.format(' For example: {{< %s %s >}}.', name, tostring(example)) + end + self:_report('missing_argument', string.format( + 'The "%s" shortcode needs its "%s" argument.%s', name, argument.name, advice)) + end + return + end + + for _, message in ipairs(errors) do + self:_report('call_error', message) + end +end + +-- ============================================================================ +-- PUBLIC API +-- ============================================================================ + +--- Build a checker for one extension, reading `_schema.yml` once. +--- A schema that cannot be read is reported, and the checker it returns does +--- nothing: `options` gives an empty table and `call` gives no message. +--- @param validator table The validator, with `load_schema`, `validate`, +--- `validate_shortcode` and `extract_meta_options` +--- @param extension_name string The extension name every message carries +--- @return Checker +--- @usage local checker = M.new(schema, 'iconify') +function M.new(validator, extension_name) + --- @type Checker + local checker = setmetatable({ + validator = validator, + extension = extension_name, + schema = nil, + defaults = {}, + options_checked = false, + }, Checker) + + local loaded, err = validator.load_schema(quarto.utils.resolve_path('_schema.yml')) + if err then + checker:_report('schema', err) + else + checker.schema = loaded + end + + return checker +end + +-- ============================================================================ +-- MODULE EXPORT +-- ============================================================================ + +return M diff --git a/tests/run.lua b/tests/run.lua index 446604a..ff54260 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -78,6 +78,7 @@ local EXPECTED = { 'is_object_empty', 'is_type_simple', 'is_function_userdata', 'get_value', 'attributes_to_table' }, paths = { 'resolve_project_path', 'has_extension', 'is_markdown' }, + ['schema-check'] = { 'new' }, string = { 'stringify', 'is_empty', 'escape_pattern', 'split', 'trim', 'to_string', 'strip_surrounding', 'strip_edges', 'find_bracketed_content', 'escape_latex', 'escape_typst', 'escape_typst_string', 'escape_js_string', 'escape_lua_pattern', 'escape_html', @@ -250,6 +251,190 @@ do _G.quarto = previous end +io.stdout:write('# schema-check\n') + +do + -- The module reports through `logging`, reads `_schema.yml` through + -- `quarto.utils.resolve_path`, and takes its validator as an argument. The + -- first two are stood in for here, and the third is a stub, so the checks + -- below need neither a render nor a schema file on disk. + local check_mod = modules['schema-check'] + + --- Messages the module reported, in order, with the level it chose. + local recorded = {} + + local previous_quarto = _G.quarto + local previous_exit = os.exit + + --- Records an `os.exit` call rather than ending the run, so that a module + --- which stops a render is a failed check instead of a truncated report. + local exits = 0 + + local function install_stubs() + recorded = {} + _G.quarto = { + log = { + error = function(m) recorded[#recorded + 1] = { level = 'error', message = m } end, + warning = function(m) recorded[#recorded + 1] = { level = 'warning', message = m } end, + output = function(m) recorded[#recorded + 1] = { level = 'output', message = m } end, + debug = function(m) recorded[#recorded + 1] = { level = 'debug', message = m } end, + }, + utils = { + resolve_path = function(path) return path end, + }, + } + end + + --- A validator with the three functions the module calls, plus the option + --- extraction it needs to read a document. `spec` decides what each returns. + --- @param spec table {err, schema, provided, valid, errors, warnings, defaults, call} + local function stub_validator(spec) + return { + load_schema = function() return spec.schema, spec.err end, + extract_meta_options = function() return spec.provided or {} end, + validate = function(values, _, options) + -- The module resolves the defaults with a second pass over an empty + -- table, which the real validator answers with the declared defaults + -- alone. The two passes are told apart by that empty table. + if next(values) == nil and options ~= nil and options.unknown == 'ignore' then + return true, {}, {}, spec.defaults or {} + end + return spec.valid ~= false, spec.errors or {}, spec.warnings or {}, spec.merged or {} + end, + validate_shortcode = function() + local call = spec.call or {} + return call.valid ~= false, call.errors or {}, call.warnings or {}, + { arguments = {}, attributes = {} } + end, + } + end + + --- The one shortcode entry every call check below is made against. + local ICONIFY_ENTRY = { + arguments = { + { name = 'icon', required = true, examples = { 'fa6-brands:github' } }, + }, + attributes = { + size = { type = 'string' }, + }, + } + + local function schema_with(options, shortcodes) + return { options = options or {}, shortcodes = shortcodes or {} } + end + + os.exit = function() exits = exits + 1 end + + -- An unreadable schema is reported once, and the checker then does nothing. + do + install_stubs() + local ok, checker = pcall(check_mod.new, + stub_validator({ err = 'Could not open schema file: _schema.yml' }), 'demo') + check(ok, 'an unreadable schema does not raise', not ok and tostring(checker) or nil) + if ok then + equal(#recorded, 1, 'an unreadable schema is reported once') + equal(recorded[1] and recorded[1].level, 'error', 'an unreadable schema is an error') + equal(recorded[1] and recorded[1].message, + '[demo] Could not open schema file: _schema.yml', + 'the validator message is reported unchanged, with the extension name') + + local defaults_ok, defaults = pcall(checker.options, checker, {}) + check(defaults_ok and type(defaults) == 'table' and next(defaults) == nil, + 'without a schema `options` returns an empty table', + defaults_ok and tostring(defaults) or ('raised: ' .. tostring(defaults))) + + local call_ok, err = pcall(checker.call, checker, 'iconify', {}, {}) + check(call_ok, 'without a schema `call` does not raise', + not call_ok and tostring(err) or nil) + equal(#recorded, 1, 'without a schema nothing further is reported') + end + end + + -- `options` hands back what the schema declares as its defaults. + do + install_stubs() + local checker = check_mod.new(stub_validator({ + schema = schema_with({ set = { type = 'string', default = 'octicon' } }), + defaults = { set = 'octicon' }, + }), 'demo') + local defaults = checker:options({}) + equal(defaults.set, 'octicon', '`options` returns the resolved defaults') + equal(#recorded, 0, 'a valid configuration reports nothing') + + -- The second call must not repeat the checks, so an extension may ask for + -- the defaults on every shortcode without filling the log. + local again = checker:options({}) + equal(again.set, 'octicon', '`options` returns the same defaults when asked again') + equal(#recorded, 0, '`options` checks the configuration once per render') + end + + -- An unknown option is advice, not a failure. + do + install_stubs() + local checker = check_mod.new(stub_validator({ + schema = schema_with({ set = { type = 'string' } }), + warnings = { 'bogus: is not a recognised key and was ignored.' }, + defaults = {}, + }), 'demo') + local ok, err = pcall(checker.options, checker, {}) + check(ok, '`options` does not raise on an unknown option', not ok and tostring(err) or nil) + equal(#recorded, 1, 'an unknown option is reported once') + equal(recorded[1] and recorded[1].level, 'warning', 'an unknown option is a warning') + equal(recorded[1] and recorded[1].message, + '[demo] bogus: is not a recognised key and was ignored.', + 'the unknown option message is reported unchanged') + end + + -- An unknown shortcode attribute is advice too. + do + install_stubs() + local checker = check_mod.new(stub_validator({ + schema = schema_with({}, { iconify = ICONIFY_ENTRY }), + call = { warnings = { 'iconify.bogus: is not a recognised key and was ignored.' } }, + }), 'demo') + checker:call('iconify', { 'fa6-brands:github' }, { bogus = 'x' }) + equal(#recorded, 1, 'an unknown attribute is reported once') + equal(recorded[1] and recorded[1].level, 'warning', 'an unknown attribute is a warning') + equal(recorded[1] and recorded[1].message, + '[demo] iconify.bogus: is not a recognised key and was ignored.', + 'the unknown attribute message is reported unchanged') + end + + -- A missing required argument is the one call finding that is an error, + -- because without it the shortcode renders nothing. + do + install_stubs() + local checker = check_mod.new(stub_validator({ + schema = schema_with({}, { iconify = ICONIFY_ENTRY }), + call = { valid = false, errors = { 'iconify argument 1 ("icon") is required but was not provided.' } }, + }), 'demo') + checker:call('iconify', {}, {}) + equal(#recorded, 1, 'a missing required argument is reported once') + equal(recorded[1] and recorded[1].level, 'error', 'a missing required argument is an error') + equal(recorded[1] and recorded[1].message, + '[demo] The "iconify" shortcode needs its "icon" argument. ' .. + 'For example: {{< iconify fa6-brands:github >}}.', + 'the missing argument message names the shortcode, the argument and an example') + end + + -- A shortcode the schema does not declare is not this extension's business. + do + install_stubs() + local checker = check_mod.new(stub_validator({ + schema = schema_with({}, { iconify = ICONIFY_ENTRY }), + call = { warnings = { 'should not be reached' } }, + }), 'demo') + checker:call('elsewhere', {}, {}) + equal(#recorded, 0, 'a shortcode absent from the schema is not checked') + end + + -- Nothing on any path ends the render. + equal(exits, 0, 'no path calls `os.exit`') + + os.exit = previous_exit + _G.quarto = previous_quarto +end + io.stdout:write(string.format('\n%d checks, %d failed\n', passed + failed, failed)) io.stdout:flush() os.exit(failed == 0 and 0 or 1, true) From a78e934bf6679e4f431048192560c87f1b92d40d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Sun, 6 Sep 2026 15:13:35 +0200 Subject: [PATCH 2/8] feat: return the resolved configuration from schema-check options `options` returned the schema defaults alone. An extension needs more than that: a default is always present once declared, so the defaults cannot tell a value the document wrote from a key it never set. It now returns the defaults first, and a table holding `provided`, `merged` and `defaults` second. A caller that wants the defaults alone is unchanged. --- README.md | 5 ++++- modules/schema-check.lua | 39 +++++++++++++++++++++++++----------- tests/run.lua | 43 +++++++++++++++++++++++++++++++++++++++- 3 files changed, 73 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 28fd6f8..b9873d4 100644 --- a/README.md +++ b/README.md @@ -34,10 +34,13 @@ A module that needs another one finds it in the same directory, so copy them tog ```lua local checker = check.new(schema, 'iconify') -local defaults = checker:options(meta) +local defaults, resolved = checker:options(meta) checker:call('iconify', args, kwargs) ``` +`options` returns the schema defaults first. +It returns `provided`, `merged` and `defaults` second, for an extension that has to tell a value the document wrote from a key it never set. + ## Development The modules run inside Quarto's Lua, and several of them call `pandoc.*`, so the tests run there too: diff --git a/modules/schema-check.lua b/modules/schema-check.lua index 36d200d..53f5b8c 100644 --- a/modules/schema-check.lua +++ b/modules/schema-check.lua @@ -11,7 +11,9 @@ --- --- The validator arrives as an argument rather than through `require`. A --- vendored copy of this module then knows nothing about where the validator ---- was vendored, so the two sources stay independent. +--- was vendored, so the two sources stay independent. The validator must +--- provide `load_schema`, `validate`, `validate_shortcode` and +--- `extract_meta_options`. --- --- Nothing here stops a render. A schema is configuration, and a fault in the --- configuration must not remove the document. @@ -109,6 +111,7 @@ end --- @field extension string The extension name every message carries --- @field schema table|nil The parsed schema, nil when it could not be read --- @field defaults table The defaults the schema declares +--- @field resolved table|nil The three tables the configuration resolves to --- @field options_checked boolean Whether the configuration was already checked local Checker = {} Checker.__index = Checker @@ -123,30 +126,40 @@ function Checker:_report(kind, message) reporter(self.extension, message) end ---- Check the document configuration and return the defaults the schema ---- declares. The check runs once, so an extension can ask for the defaults on ---- every shortcode without repeating the messages. +--- Check the document configuration and return what it resolves to. The check +--- runs once, so an extension can ask on every shortcode without repeating the +--- messages. --- ---- The defaults come from a second pass over an empty table, which yields the ---- declared defaults alone. An extension needs them anyway, and reading them ---- back from the schema keeps `_schema.yml` the one place they are written. +--- The defaults come first, because that is what most extensions want, and +--- because the schema is the one place they are written. They come from a +--- second pass over an empty table, which yields the declared defaults alone. +--- +--- The second return holds three tables, because they answer different +--- questions: +--- provided what the document actually set, which is the only way to tell +--- a deliberate `false` or `0` from an absent key, +--- merged the same values with coercion and defaults applied, +--- defaults the schema defaults on their own. +--- It is nil when there is nothing to resolve against, so an extension can +--- tell an unreadable schema from a document that set nothing. --- @param meta table Document metadata ---- @return table The defaults, empty when there is no schema +--- @return table defaults The defaults, empty when there is no schema +--- @return table|nil resolved {provided, merged, defaults}, nil when there is no schema function Checker:options(meta) if self.options_checked then - return self.defaults + return self.defaults, self.resolved end self.options_checked = true --- @type table|nil local loaded = self.schema if loaded == nil or loaded.options == nil or next(loaded.options) == nil then - return self.defaults + return self.defaults, self.resolved end --- @type table local provided = self.validator.extract_meta_options(meta, self.extension) - local valid, errors, warnings = self.validator.validate(provided, loaded.options) + local valid, errors, warnings, merged = self.validator.validate(provided, loaded.options) for _, message in ipairs(warnings) do self:_report('option_warning', message) @@ -159,8 +172,9 @@ function Checker:options(meta) local _, _, _, defaults = self.validator.validate({}, loaded.options, { unknown = 'ignore' }) self.defaults = defaults or {} + self.resolved = { provided = provided, merged = merged, defaults = self.defaults } - return self.defaults + return self.defaults, self.resolved end --- Check one shortcode call against its entry in the schema. @@ -251,6 +265,7 @@ function M.new(validator, extension_name) extension = extension_name, schema = nil, defaults = {}, + resolved = nil, options_checked = false, }, Checker) diff --git a/tests/run.lua b/tests/run.lua index ff54260..5077726 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -338,10 +338,11 @@ do '[demo] Could not open schema file: _schema.yml', 'the validator message is reported unchanged, with the extension name') - local defaults_ok, defaults = pcall(checker.options, checker, {}) + local defaults_ok, defaults, resolved = pcall(checker.options, checker, {}) check(defaults_ok and type(defaults) == 'table' and next(defaults) == nil, 'without a schema `options` returns an empty table', defaults_ok and tostring(defaults) or ('raised: ' .. tostring(defaults))) + equal(resolved, nil, 'without a schema `options` resolves nothing') local call_ok, err = pcall(checker.call, checker, 'iconify', {}, {}) check(call_ok, 'without a schema `call` does not raise', @@ -368,6 +369,46 @@ do equal(#recorded, 0, '`options` checks the configuration once per render') end + -- The second return carries what the document set as well as what it + -- resolved to. An extension needs `provided` to tell a value the author + -- wrote from a key they never set, which no default can answer: a default is + -- always present once declared, so `merged` alone cannot tell the two apart. + do + install_stubs() + local checker = check_mod.new(stub_validator({ + schema = schema_with({ + inline = { type = 'boolean', default = true }, + set = { type = 'string', default = 'octicon' }, + }), + provided = { inline = false }, + merged = { inline = false, set = 'octicon' }, + defaults = { inline = true, set = 'octicon' }, + }), 'demo') + + local defaults, resolved = checker:options({}) + equal(defaults.inline, true, '`options` still returns the defaults first') + check(type(resolved) == 'table', '`options` returns a resolved table second', + tostring(resolved)) + equal(resolved.defaults.inline, true, 'the resolved table carries the defaults') + equal(resolved.merged.inline, false, 'the resolved table carries the merged values') + equal(resolved.provided.inline, false, '`provided` holds a value the document wrote') + equal(resolved.provided.set, nil, '`provided` omits a key the document never set') + + -- Without `provided` these two cases are the same table entry, which is + -- the fault this return exists to prevent. + check(resolved.provided.inline ~= nil and resolved.provided.set == nil, + '`provided` tells a written value from an absent key', + string.format('inline=%s set=%s', tostring(resolved.provided.inline), + tostring(resolved.provided.set))) + equal(resolved.merged.set, 'octicon', + 'an absent key still resolves to its default in `merged`') + + local again_defaults, again_resolved = checker:options({}) + equal(again_defaults.inline, true, 'the cached call returns the same defaults') + check(again_resolved == resolved, 'the cached call returns the same resolved table', + tostring(again_resolved)) + end + -- An unknown option is advice, not a failure. do install_stubs() From b00df9884648729b1470e20ab65969b3a89d26c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Sun, 6 Sep 2026 15:18:51 +0200 Subject: [PATCH 3/8] test: cover every severity, the once per render guard and the quote strip Two levels in the severity table had no test that reached them. `option_error` needed an options pass the schema rejects, and `call_error` needed a call that gives its required argument, so that the missing argument path does not take over the reporting. The two once per render checks passed whether or not the guard was there, because the stub they used reported nothing. The stub now reports a finding on every pass, so a second check shows as a second message. `_report` no longer reports an unmapped kind as a warning. It says which kind it could not report, at error level, and the module now checks at load that every level has a reporting function. --- README.md | 2 +- modules/schema-check.lua | 26 ++++++++-- tests/run.lua | 101 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 119 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index b9873d4..81e152c 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ A module that needs another one finds it in the same directory, so copy them tog `schema-check.lua` takes its validator as an argument rather than loading one, so an extension can vendor the two from different sources: ```lua -local checker = check.new(schema, 'iconify') +local checker = check.new(validator, 'iconify') local defaults, resolved = checker:options(meta) checker:call('iconify', args, kwargs) ``` diff --git a/modules/schema-check.lua b/modules/schema-check.lua index 53f5b8c..1b14de3 100644 --- a/modules/schema-check.lua +++ b/modules/schema-check.lua @@ -66,6 +66,14 @@ local REPORTERS = { warning = log.log_warning, } +-- A level with no reporter is the mistake a change to `SEVERITY` makes, and it +-- is caught here, at load, rather than at the one render that reaches that kind +-- of finding. +for kind, level in pairs(SEVERITY) do + assert(REPORTERS[level] ~= nil, + string.format('schema-check: "%s" maps to the unknown level "%s"', kind, tostring(level))) +end + -- ============================================================================ -- PRIVATE HELPERS -- ============================================================================ @@ -117,13 +125,21 @@ local Checker = {} Checker.__index = Checker --- Report one finding at the level its kind is mapped to. +--- A kind with no entry in `SEVERITY` is a fault in this module, and it says so +--- rather than reporting the finding at a level nobody chose. It is reported +--- rather than raised, because a fault here must not remove a document. --- @param kind string A key of `SEVERITY` --- @param message string The message to report --- @return nil function Checker:_report(kind, message) - --- @type function - local reporter = REPORTERS[SEVERITY[kind]] or log.log_warning - reporter(self.extension, message) + --- @type string|nil + local level = SEVERITY[kind] + if level == nil then + log.log_error(self.extension, string.format( + 'schema-check has no severity for "%s": %s', tostring(kind), message)) + return + end + REPORTERS[level](self.extension, message) end --- Check the document configuration and return what it resolves to. The check @@ -153,7 +169,7 @@ function Checker:options(meta) --- @type table|nil local loaded = self.schema - if loaded == nil or loaded.options == nil or next(loaded.options) == nil then + if loaded == nil or next(loaded.options) == nil then return self.defaults, self.resolved end @@ -257,7 +273,7 @@ end --- `validate_shortcode` and `extract_meta_options` --- @param extension_name string The extension name every message carries --- @return Checker ---- @usage local checker = M.new(schema, 'iconify') +--- @usage local checker = M.new(validator, 'iconify') function M.new(validator, extension_name) --- @type Checker local checker = setmetatable({ diff --git a/tests/run.lua b/tests/run.lua index 5077726..a797e10 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -301,7 +301,10 @@ do end return spec.valid ~= false, spec.errors or {}, spec.warnings or {}, spec.merged or {} end, - validate_shortcode = function() + validate_shortcode = function(name, args, kwargs, entry) + -- Kept so a check can read what the module handed over, not only what + -- it did with the answer. + spec.seen = { name = name, args = args, kwargs = kwargs, entry = entry } local call = spec.call or {} return call.valid ~= false, call.errors or {}, call.warnings or {}, { arguments = {}, attributes = {} } @@ -361,12 +364,46 @@ do local defaults = checker:options({}) equal(defaults.set, 'octicon', '`options` returns the resolved defaults') equal(#recorded, 0, 'a valid configuration reports nothing') + end + + -- The configuration is checked once per render, so an extension may ask for + -- the defaults on every shortcode without filling the log. The stub reports + -- a finding on every pass, so a second check would show as a second message. + do + install_stubs() + local checker = check_mod.new(stub_validator({ + schema = schema_with({ set = { type = 'string', default = 'octicon' } }), + provided = { bogus = 'x' }, + warnings = { 'bogus: is not a recognised key and was ignored.' }, + defaults = { set = 'octicon' }, + }), 'demo') + + local first = checker:options({}) + equal(first.set, 'octicon', '`options` returns the defaults on the first call') + equal(#recorded, 1, 'the first call reports what it finds') - -- The second call must not repeat the checks, so an extension may ask for - -- the defaults on every shortcode without filling the log. local again = checker:options({}) equal(again.set, 'octicon', '`options` returns the same defaults when asked again') - equal(#recorded, 0, '`options` checks the configuration once per render') + equal(#recorded, 1, '`options` checks the configuration once per render') + end + + -- An option the schema rejects is an error: it names a value the extension + -- cannot use, and the author has to correct it. + do + install_stubs() + local checker = check_mod.new(stub_validator({ + schema = schema_with({ inline = { type = 'boolean' } }), + provided = { inline = 'sometimes' }, + valid = false, + errors = { 'inline: must be of type "boolean", got "string".' }, + defaults = {}, + }), 'demo') + checker:options({}) + equal(#recorded, 1, 'a rejected option is reported once') + equal(recorded[1] and recorded[1].level, 'error', 'a rejected option is an error') + equal(recorded[1] and recorded[1].message, + '[demo] inline: must be of type "boolean", got "string".', + 'the rejected option message is reported unchanged') end -- The second return carries what the document set as well as what it @@ -441,6 +478,62 @@ do 'the unknown attribute message is reported unchanged') end + -- An attribute the schema rejects is a warning, not an error, because the + -- rendered output does not change because of it. The call gives its required + -- argument, so the missing argument path does not take over the reporting. + do + install_stubs() + local checker = check_mod.new(stub_validator({ + schema = schema_with({}, { iconify = ICONIFY_ENTRY }), + call = { + valid = false, + errors = { 'iconify.size: must be one of: 1x, 2x, got 3z.' }, + }, + }), 'demo') + checker:call('iconify', { 'fa6-brands:github' }, { size = '3z' }) + equal(#recorded, 1, 'a rejected attribute is reported once') + equal(recorded[1] and recorded[1].level, 'warning', + 'a rejected attribute is a warning, because the output does not change') + equal(recorded[1] and recorded[1].message, + '[demo] iconify.size: must be one of: 1x, 2x, got 3z.', + 'the rejected attribute message is reported unchanged') + end + + -- A quoted value checks as the same thing as an unquoted one. Quarto's + -- metadata parser hands `aria-hidden='true'` over with its quotes attached, + -- and without the strip the value is checked with them. + do + install_stubs() + local spec = { + schema = schema_with({}, { iconify = ICONIFY_ENTRY }), + call = {}, + } + local checker = check_mod.new(stub_validator(spec), 'demo') + checker:call('iconify', { 'fa6-brands:github' }, + { ['aria-hidden'] = "'true'", size = '2x' }) + + equal(spec.seen and spec.seen.kwargs['aria-hidden'], 'true', + 'a surrounding quote pair is stripped before the check') + equal(spec.seen and spec.seen.kwargs.size, '2x', + 'an unquoted value reaches the validator unchanged') + equal(spec.seen and spec.seen.args[1], 'fa6-brands:github', + 'a positional argument reaches the validator as a string') + equal(#recorded, 0, 'a call the schema accepts reports nothing') + end + + -- A kind with no severity is a fault in the module, and it says so rather + -- than passing the finding off at a level nobody chose. + do + install_stubs() + local checker = check_mod.new(stub_validator({ schema = schema_with({}) }), 'demo') + local ok, err = pcall(checker._report, checker, 'not-a-kind', 'a finding') + check(ok, 'an unmapped severity does not raise', not ok and tostring(err) or nil) + equal(recorded[1] and recorded[1].level, 'error', 'an unmapped severity is an error') + equal(recorded[1] and recorded[1].message, + '[demo] schema-check has no severity for "not-a-kind": a finding', + 'an unmapped severity names the kind it could not report') + end + -- A missing required argument is the one call finding that is an error, -- because without it the shortcode renders nothing. do From b9f999bbb1a4e67c8f415b7d86978eea87dc8b0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Sun, 6 Sep 2026 15:33:50 +0200 Subject: [PATCH 4/8] fix: do not trust the injected schema shape, and copy the defaults out The validator comes from an independent source, so `options` no longer assumes the schema it returns carries an `options` key. `call` already made the same allowance for `shortcodes`, and the two methods now agree on how much they trust the injected table. `options` returned the checker's own defaults table. One caller writing into what it received changed the defaults for the whole render, and for every later reader. It now returns a copy on every call. The second return still holds the checker's own tables, and the docstring says they must not be written to. --- modules/schema-check.lua | 32 +++++++++++++++++++++++++++----- tests/run.lua | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 5 deletions(-) diff --git a/modules/schema-check.lua b/modules/schema-check.lua index 1b14de3..366ed7f 100644 --- a/modules/schema-check.lua +++ b/modules/schema-check.lua @@ -98,6 +98,21 @@ local function attr_value(kwargs, key) return value end +--- Copy a table one level deep. +--- A schema default is a scalar, so one level is the whole value. The copy +--- exists so that a caller writing into what it received cannot change what +--- every later reader of the same checker sees. +--- @param source table The table to copy +--- @return table +local function shallow_copy(source) + --- @type table + local copy = {} + for key, value in pairs(source) do + copy[key] = value + end + return copy +end + --- Flatten a call's named options to plain strings for the validator. --- @param kwargs table Key-value options for the call --- @return table @@ -158,19 +173,26 @@ end --- defaults the schema defaults on their own. --- It is nil when there is nothing to resolve against, so an extension can --- tell an unreadable schema from a document that set nothing. +--- +--- The defaults are a fresh copy on every call, so a caller may treat them as +--- its own. The tables inside the second return are the checker's, and every +--- later reader of the same checker sees them, so they must not be written to. --- @param meta table Document metadata ---- @return table defaults The defaults, empty when there is no schema +--- @return table defaults A copy of the defaults, empty when there is no schema --- @return table|nil resolved {provided, merged, defaults}, nil when there is no schema function Checker:options(meta) if self.options_checked then - return self.defaults, self.resolved + return shallow_copy(self.defaults), self.resolved end self.options_checked = true --- @type table|nil local loaded = self.schema - if loaded == nil or next(loaded.options) == nil then - return self.defaults, self.resolved + -- The validator is injected from an independent source, so the shape of what + -- it returns is not this module's to assume. `call` makes the same allowance + -- for `shortcodes` one field over. + if loaded == nil or next(loaded.options or {}) == nil then + return shallow_copy(self.defaults), self.resolved end --- @type table @@ -190,7 +212,7 @@ function Checker:options(meta) self.defaults = defaults or {} self.resolved = { provided = provided, merged = merged, defaults = self.defaults } - return self.defaults, self.resolved + return shallow_copy(self.defaults), self.resolved end --- Check one shortcode call against its entry in the schema. diff --git a/tests/run.lua b/tests/run.lua index a797e10..d95881c 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -387,6 +387,41 @@ do equal(#recorded, 1, '`options` checks the configuration once per render') end + -- The defaults are handed over as a copy. This module is vendored into many + -- extensions, and it publishes the defaults on a convenient path, so a + -- caller writing a computed fallback into what it received is an easy and + -- quiet mistake. The write must stay with the caller that made it. + do + install_stubs() + local checker = check_mod.new(stub_validator({ + schema = schema_with({ set = { type = 'string', default = 'octicon' } }), + defaults = { set = 'octicon' }, + }), 'demo') + + local first = checker:options({}) + first.set = 'poisoned' + first.added = 'poisoned' + + local second, resolved = checker:options({}) + check(second ~= first, 'each call returns its own defaults table', + 'the same table came back twice') + equal(second.set, 'octicon', 'writing to the returned defaults leaves the checker unchanged') + equal(second.added, nil, 'a key added to the returned defaults does not reach the checker') + equal(resolved.defaults.set, 'octicon', 'the resolved defaults are unchanged as well') + end + + -- The validator is injected from an independent source, so a schema without + -- every section this module reads must not end the render. + do + install_stubs() + local ok, err = pcall(function() + local checker = check_mod.new(stub_validator({ schema = {} }), 'demo') + checker:options({}) + checker:call('iconify', {}, {}) + end) + check(ok, 'a schema with no sections does not raise', not ok and tostring(err) or nil) + end + -- An option the schema rejects is an error: it names a value the extension -- cannot use, and the author has to correct it. do From f4e98d8ca3df3b40a1bd963eee412f872fc6a69f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Sun, 6 Sep 2026 15:35:17 +0200 Subject: [PATCH 5/8] test: drop the inert valid field from the shortcode stub The module discards the first return of `validate_shortcode`, so a `valid` on the call stub said nothing while inviting the next reader to believe it gated the errors loop. The stub now returns true there, and a comment says why, next to the options pass where `valid` does gate the reporting. --- tests/run.lua | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/run.lua b/tests/run.lua index d95881c..da8fa3e 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -301,12 +301,17 @@ do end return spec.valid ~= false, spec.errors or {}, spec.warnings or {}, spec.merged or {} end, + -- The module discards the first return here, unlike on the options pass, + -- where `valid` gates whether the errors are reported at all. A finding + -- about a call is reported whatever the verdict, and the level it gets + -- comes from the kind of finding rather than from the validator. So + -- `spec.call` carries no `valid`: setting one would say nothing. validate_shortcode = function(name, args, kwargs, entry) -- Kept so a check can read what the module handed over, not only what -- it did with the answer. spec.seen = { name = name, args = args, kwargs = kwargs, entry = entry } local call = spec.call or {} - return call.valid ~= false, call.errors or {}, call.warnings or {}, + return true, call.errors or {}, call.warnings or {}, { arguments = {}, attributes = {} } end, } @@ -520,10 +525,7 @@ do install_stubs() local checker = check_mod.new(stub_validator({ schema = schema_with({}, { iconify = ICONIFY_ENTRY }), - call = { - valid = false, - errors = { 'iconify.size: must be one of: 1x, 2x, got 3z.' }, - }, + call = { errors = { 'iconify.size: must be one of: 1x, 2x, got 3z.' } }, }), 'demo') checker:call('iconify', { 'fa6-brands:github' }, { size = '3z' }) equal(#recorded, 1, 'a rejected attribute is reported once') @@ -575,7 +577,7 @@ do install_stubs() local checker = check_mod.new(stub_validator({ schema = schema_with({}, { iconify = ICONIFY_ENTRY }), - call = { valid = false, errors = { 'iconify argument 1 ("icon") is required but was not provided.' } }, + call = { errors = { 'iconify argument 1 ("icon") is required but was not provided.' } }, }), 'demo') checker:call('iconify', {}, {}) equal(#recorded, 1, 'a missing required argument is reported once') From 283869c80131f62ef166838f4dc45259dbcf6a28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Sun, 6 Sep 2026 15:41:56 +0200 Subject: [PATCH 6/8] fix: copy the schema defaults all the way down A default is not always a scalar. One extension declares `default: []` for an array option, and another declares a mapping default, so a copy one level deep handed two callers the same inner table and left the aliasing one level down. The copy is now recursive. A table already copied on the walk is reused, which keeps shared structure shared and makes a cycle terminate. --- modules/schema-check.lua | 45 ++++++++++++++++++++++++++++------------ tests/run.lua | 35 +++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 13 deletions(-) diff --git a/modules/schema-check.lua b/modules/schema-check.lua index 366ed7f..25d7d11 100644 --- a/modules/schema-check.lua +++ b/modules/schema-check.lua @@ -98,17 +98,36 @@ local function attr_value(kwargs, key) return value end ---- Copy a table one level deep. ---- A schema default is a scalar, so one level is the whole value. The copy ---- exists so that a caller writing into what it received cannot change what ---- every later reader of the same checker sees. ---- @param source table The table to copy ---- @return table -local function shallow_copy(source) - --- @type table +--- Copy a value, and every table inside it, all the way down. +--- The copy exists so that a caller writing into what it received cannot +--- change what every later reader of the same checker sees. +--- +--- A default is whatever the schema declares, to any depth, and not only a +--- scalar. An extension declares `default: []` for an array option, and +--- another declares a mapping default, so a copy one level deep would hand two +--- callers the same inner table and leave the fault one level down. +--- +--- A table already copied on this walk is reused rather than copied again, +--- which keeps shared structure shared and makes a cycle terminate. A schema +--- file cannot express a cycle, because the validator's parser refuses anchors +--- and aliases, but the validator is injected and what it returns is not this +--- module's to assume. +--- @param value any The value to copy +--- @param seen table|nil Tables already copied on this walk +--- @return any +local function deep_copy(value, seen) + if type(value) ~= 'table' then + return value + end + seen = seen or {} + if seen[value] ~= nil then + return seen[value] + end + --- @type table local copy = {} - for key, value in pairs(source) do - copy[key] = value + seen[value] = copy + for key, item in pairs(value) do + copy[key] = deep_copy(item, seen) end return copy end @@ -182,7 +201,7 @@ end --- @return table|nil resolved {provided, merged, defaults}, nil when there is no schema function Checker:options(meta) if self.options_checked then - return shallow_copy(self.defaults), self.resolved + return deep_copy(self.defaults), self.resolved end self.options_checked = true @@ -192,7 +211,7 @@ function Checker:options(meta) -- it returns is not this module's to assume. `call` makes the same allowance -- for `shortcodes` one field over. if loaded == nil or next(loaded.options or {}) == nil then - return shallow_copy(self.defaults), self.resolved + return deep_copy(self.defaults), self.resolved end --- @type table @@ -212,7 +231,7 @@ function Checker:options(meta) self.defaults = defaults or {} self.resolved = { provided = provided, merged = merged, defaults = self.defaults } - return shallow_copy(self.defaults), self.resolved + return deep_copy(self.defaults), self.resolved end --- Check one shortcode call against its entry in the schema. diff --git a/tests/run.lua b/tests/run.lua index da8fa3e..bbd4cfb 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -415,6 +415,41 @@ do equal(resolved.defaults.set, 'octicon', 'the resolved defaults are unchanged as well') end + -- A default is not always a scalar. One extension in the fleet declares + -- `default: []` for an array option, and another a mapping default, so the + -- copy has to go all the way down. A caller inserting into the array it + -- received must not change what the checker holds. + do + install_stubs() + local checker = check_mod.new(stub_validator({ + schema = schema_with({ + ['page-exclude'] = { type = 'array', default = {} }, + ['slide-change-cue'] = { type = 'object', default = { visual = true } }, + }), + defaults = { + ['page-exclude'] = { '/drafts/*' }, + ['slide-change-cue'] = { visual = true, audio = false }, + }, + }), 'demo') + + local first = checker:options({}) + table.insert(first['page-exclude'], 'poisoned') + first['slide-change-cue'].visual = 'poisoned' + + local second, resolved = checker:options({}) + check(second['page-exclude'] ~= first['page-exclude'], + 'each call returns its own array default', 'the same array came back twice') + equal(#second['page-exclude'], 1, + 'inserting into a returned array leaves the checker unchanged') + equal(second['page-exclude'][1], '/drafts/*', 'the array default keeps its own entry') + equal(second['slide-change-cue'].visual, true, + 'writing into a returned mapping default leaves the checker unchanged') + equal(resolved.defaults['page-exclude'][1], '/drafts/*', + "the checker's own array default is unchanged") + equal(#resolved.defaults['page-exclude'], 1, + "the checker's own array default gained no entry") + end + -- The validator is injected from an independent source, so a schema without -- every section this module reads must not end the render. do From 9515e297ad100da1ca15e0dc223a966477a0526f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Sun, 6 Sep 2026 15:43:13 +0200 Subject: [PATCH 7/8] docs: argue the copy depth from the schema format The docstring justified the recursive copy with the two schemas that need it today. That is the weaker claim: the vocabulary allows `type: array` and `type: object`, and the validator returns a declared default untouched once it matches its type, so any option with a collection type and a literal default resolves to a table. The docstring now says that instead. --- modules/schema-check.lua | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/modules/schema-check.lua b/modules/schema-check.lua index 25d7d11..536a950 100644 --- a/modules/schema-check.lua +++ b/modules/schema-check.lua @@ -102,10 +102,13 @@ end --- The copy exists so that a caller writing into what it received cannot --- change what every later reader of the same checker sees. --- ---- A default is whatever the schema declares, to any depth, and not only a ---- scalar. An extension declares `default: []` for an array option, and ---- another declares a mapping default, so a copy one level deep would hand two ---- callers the same inner table and leave the fault one level down. +--- The depth comes from the schema format, not from the schemas written so +--- far. The vocabulary allows `type: array` and `type: object`, and the +--- validator compiles a declared `default` by coercing it against that type +--- and returning it untouched once it already matches. Any option declaring a +--- collection type with a literal default therefore resolves to a Lua table, +--- at whatever depth the schema nests it, so a copy one level deep would hand +--- two callers the same inner table and leave the fault one level down. --- --- A table already copied on this walk is reused rather than copied again, --- which keeps shared structure shared and makes a cycle terminate. A schema From 891980fbc7285c0f7388b205bff0a725babcd7e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Sun, 6 Sep 2026 15:55:27 +0200 Subject: [PATCH 8/8] test: make the copy depth check discriminate Both nested defaults in the previous check sit one level below the top container and hold scalars, so they pass against a copy that recurses exactly one level further and stops. The new check nests a default three levels down, as an array of objects whose properties are objects, and writes through the returned table at each level. Nothing in the fleet declares that shape today, so it covers the format rather than a schema in play, which is the reasoning that set the depth. --- tests/run.lua | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/run.lua b/tests/run.lua index bbd4cfb..181fcb9 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -450,6 +450,41 @@ do "the checker's own array default gained no entry") end + -- The recursion is unbounded, not one level below the top container. The + -- two defaults above both sit one level down and hold scalars, so they + -- cannot tell "recurse once more" from "recurse all the way". + -- + -- Nothing in the fleet nests a default three deep today, so this covers the + -- format rather than a schema in play. That is deliberate, and it is the + -- same argument that settled the depth: the vocabulary allows an array of + -- objects whose properties are objects, so the shape is reachable by a + -- schema nobody has written yet. + do + install_stubs() + local checker = check_mod.new(stub_validator({ + schema = schema_with({ entries = { type = 'array' } }), + defaults = { + entries = { { name = 'first', options = { colour = 'blue' } } }, + }, + }), 'demo') + + local first = checker:options({}) + first.entries[1].name = 'poisoned' + first.entries[1].options.colour = 'poisoned' + + local second, resolved = checker:options({}) + check(second.entries[1] ~= first.entries[1], + 'each call returns its own array element', 'the same element came back twice') + check(second.entries[1].options ~= first.entries[1].options, + 'each call returns its own nested mapping', 'the same mapping came back twice') + equal(second.entries[1].name, 'first', + 'a write two levels down leaves the checker unchanged') + equal(second.entries[1].options.colour, 'blue', + 'a write three levels down leaves the checker unchanged') + equal(resolved.defaults.entries[1].options.colour, 'blue', + "the checker's own value three levels down is unchanged") + end + -- The validator is injected from an independent source, so a schema without -- every section this module reads must not end the render. do