Repository navigation
Use JSON schema to validate incoming tasks - #212
kevinchannon wants to merge 129 commits into
Conversation
ReviewThis PR is explicitly a WIP checkpoint (slices 0–3 of the schema-validation-pipeline plan, issue #43), so I focused the review on the code that's actually landed rather than on the plan's not-yet-built slices. Summary of the change
Code quality / best practices
Potential bugs / issues
Performance
Security
Test coverage
Action items
|
- collect_template_refs keeps numeric continuation segments, so
positional references like {{ self.inputs.0 }} retain their index
(mattered for slice 5/7 reuse, not the current prefix check).
- The double check_runner_template_refs walk (whole runner config in
build_recipe_runner, interpreter subtree again in
_parse_inline_interpreter) is documented as intentional.
- test_builtin_vars_in_runner_volumes pins os.getlogin and asserts the
exact substituted USER_NAME_VAR value. Reference gate: passes on
v1.3.2 (parity).
Co-Authored-By: Claude Fable 5 <[email protected]>
|
Review action items addressed in d2b44e4:
Full pyramid run after the changes: unit+integration 1647 passed / 4 conditional skips; e2e 71 passed / 5 conditional skips. |
Review of PR #212 — "Use JSON schema to validate incoming tasks" (slices 0-3, WIP)Thanks for the clear per-slice commit history and the self-documenting plan doc - it made this easy to follow even without the originating conversation. This is explicitly an in-progress WIP PR per the description, so I focused on the substance of slices 1-3 that has landed rather than the overall roadmap. Summary of what's in this PR
Correctness issue: the "runners can't see per-task values" guarantee doesn't actually hold everywhereThe core safety property this slice is building towards is that runner/interpreter fields must not depend on per-task state, because a runner is shared across tasks. Two gaps undermine that today:
Suggest: for (1), either call Smaller notes
Performance / securityNo concerns - Action items
|
Both verifications pass: the Jinja renderer covers every namespace the unified path needs, and no fixture or test uses arg./dep./self. templates in runner or interpreter fields. Slice 1's field list is expanded to match the regex path's true coverage (dockerfile, context, build/run args and interpreter cmd/preamble, in addition to the originally listed fields). Co-Authored-By: Claude Fable 5 <[email protected]>
Volumes now go through rendering.render with a runner-scoped context (env + tt only, via the new build_runner_config) instead of the chained regex substitutions. Per-task namespaces (arg/dep/self) in a volume are now an error instead of being silently left in the mount string. Reference gate: parity test passes on v1.3.2; the arg-rejection test fails there as expected (documented in the plan's divergences list). Co-Authored-By: Claude Fable 5 <[email protected]>
Same move as volumes: env_vars values go through the runner-scoped Jinja context (env + tt), so per-task namespaces in an env_vars value now error instead of silently reaching the container as literal text. Reference gate: parity test passes on v1.3.2; arg-rejection test fails there as expected (covered by the documented divergence). Co-Authored-By: Claude Fable 5 <[email protected]>
Same move as volumes and env_vars. Reference gate: parity passes on v1.3.2; arg-rejection fails there as expected (documented divergence). Co-Authored-By: Claude Fable 5 <[email protected]>
Same move as volumes/env_vars/ports. Reference gate: parity passes on v1.3.2; arg-rejection fails there as expected (documented divergence). Co-Authored-By: Claude Fable 5 <[email protected]>
The shared subst() closure now uses the runner-scoped Jinja render, migrating docker build/run args, interpreter cmd/preamble, dockerfile and context in one move. Pre-existing parity tests for these fields pass unchanged; new rejection tests pin the arg.* error behaviour. Reference gate: parity via the existing (unchanged) tests; both new rejection tests fail on v1.3.2 as expected (documented divergence). Co-Authored-By: Claude Fable 5 <[email protected]>
_substitute_builtin and _substitute_env have no callers now that all runner fields render through the Jinja engine. The underlying substitution.py functions remain (still used on the parse-time path). No behaviour change, so no reference-gate run (no behaviour-level test added or changed). Co-Authored-By: Claude Fable 5 <[email protected]>
Co-Authored-By: Claude Fable 5 <[email protected]>
Slice 2 of the schema validation pipeline (issue #43): the generic reference walker, starting with regex extraction from a single string. Purely additive - nothing calls it yet. Co-Authored-By: Claude Fable 5 <[email protected]>
Dict keys are walked too, per the over-matching bias: a template in a key position (e.g. env_vars) must not be silently missed. Co-Authored-By: Claude Fable 5 <[email protected]>
Chases referenced variables' definitions to a fixpoint so consumers see everything a subtree transitively depends on. Undefined names are kept but not chased - discovery is not validation. Co-Authored-By: Claude Fable 5 <[email protected]>
The generic walker already sees templates inside eval commands, read
paths and env defaults; the { env: NAME } form needs a special case
because it names its env var as a bare string. Cyclic definitions
terminate via the chased set.
Co-Authored-By: Claude Fable 5 <[email protected]>
Co-Authored-By: Claude Fable 5 <[email protected]>
Slice 3 of the schema validation pipeline (decision 4): runners and interpreters are shared across tasks and render once, so arg/dep/self and per-task tt builtins may not appear in their definitions. The check uses the slice-2 walker; nothing calls it yet. Co-Authored-By: Claude Fable 5 <[email protected]>
build_recipe_runner now runs check_runner_template_refs on the raw config, covering section runners and inline task runners. The builtin_vars_runner_volumes fixture drops its tt.task_name env var (now-forbidden per-task builtin) in favour of tt.user_name. Reference gate (v1.3.2): the four rejection tests fail there with 'ValueError not raised' - the recipes were silently accepted - matching the expected-divergences entries; the per-task tt divergence is added to the list. The acceptance test and the updated volumes test pass on the reference. Co-Authored-By: Claude Fable 5 <[email protected]>
_parse_inline_interpreter runs check_runner_template_refs, covering the interpreters section, task-level inline interpreters and runner interpreter fields. Error wording generalised to name interpreters. Reference gate (v1.3.2): all six rejection tests fail there with 'ValueError not raised' (recipes silently accepted), matching the expected-divergences entries; both acceptance tests pass there. Co-Authored-By: Claude Fable 5 <[email protected]>
Co-Authored-By: Claude Fable 5 <[email protected]>
Status header updated (slices 0-3 done), the full-pyramid test bar and per-increment commit permission recorded in process decisions, gate practicalities from slices 2-3 added to the reference-arbiter section, and slice 4 gets the parser entry points surveyed this session. Co-Authored-By: Claude Fable 5 <[email protected]>
- collect_template_refs keeps numeric continuation segments, so
positional references like {{ self.inputs.0 }} retain their index
(mattered for slice 5/7 reuse, not the current prefix check).
- The double check_runner_template_refs walk (whole runner config in
build_recipe_runner, interpreter subtree again in
_parse_inline_interpreter) is documented as intentional.
- test_builtin_vars_in_runner_volumes pins os.getlogin and asserts the
exact substituted USER_NAME_VAR value. Reference gate: passes on
v1.3.2 (parity).
Co-Authored-By: Claude Fable 5 <[email protected]>
Co-Authored-By: Claude Fable 5 <[email protected]>
CircularImportError moves to raw_merge (its natural home once the merge phase owns imports); parser re-exports it so existing imports keep working. Co-Authored-By: Claude Fable 5 <[email protected]>
Co-Authored-By: Claude Fable 5 <[email protected]>
Same rules as _parse_file: undotted deps get the namespace prefix; dotted deps are prefixed only when their root segment is one of the importing file's own import namespaces, otherwise kept as absolute references. Co-Authored-By: Claude Fable 5 <[email protected]>
Co-Authored-By: Claude Fable 5 <[email protected]>
Mirrors _parse_file: an imported file's runners come along only when a pinned task references them; imported 'default' declarations are dropped. Co-Authored-By: Claude Fable 5 <[email protected]>
Var refs are rewritten by a generic tree walk over each imported file's local content (values only), deliberately broader than the old per-field rewrite: dep argument templates and inline definitions are covered too. VAR_REFERENCE_REWRITE_PATTERN moves to raw_merge; parser imports it. Co-Authored-By: Claude Fable 5 <[email protected]>
Co-Authored-By: Claude Opus 5 <[email protected]>
An input may name another of the task's inputs, or an output one of its
inputs: inputs: [{src: a.txt}, {backup: "{{ self.inputs.src }}.bak"}]. Such
a pattern was left verbatim since 10a, so it matched nothing, which reads
as a task with no changed inputs rather than as an error.
There is nothing circular about it. By the time patterns are rendered the
invocation's arguments are known, so the only ordering needed is between
the patterns themselves - which is what field_order decides, handed a
render callback because rendering belongs to the executor. A genuine loop
is reported with both ends named and the advice to write one path out in
full.
Positions count paths rather than YAML entries, matching the indexed view
Task.__post_init__ builds, so {{ self.inputs.1 }} means the same thing to
the resolver as it does to the renderer. A reference to something the task
does not have makes no edge, so the render is still reached and reports it
with the names that do exist.
The freshness path now reads the indexed views from that one ordered
render instead of flattening the pattern lists itself, so the duplicated
named-or-anonymous walk in _get_all_inputs and _expand_output_paths is
gone, and so is the deferral that stood in for this work.
Co-Authored-By: Claude Opus 5 <[email protected]>
Co-Authored-By: Claude Opus 5 <[email protected]>
Two halves with a seam between them: the values given on the command line are matched first, then the defaults are supplied for whatever is left. Both go through one conversion helper, so there is still a single place that knows how a value becomes its declared type - the duplication that once made three hash call sites disagree is not worth repeating here. The seam exists for the next change. An argument default may name one of the task's own inputs, and a pattern may name an argument, so the given values have to be known before the patterns can be rendered and the remaining defaults settled. parse_task_args takes an optional resolve_specs hook that runs in the seam; with none, the behaviour is exactly as before. Co-Authored-By: Claude Opus 5 <[email protected]>
An argument default may now name one of the task's inputs or outputs. It
has to be staged, because the dependency runs in a circle: resolving the
default needs the pattern rendered, rendering the pattern may need
argument values, and those come from the defaults. So the arguments given
on the command line and the defaults that name no self reference are
settled first, the patterns are rendered against those, and the remaining
defaults are settled last - which is why the executor is now built before
the arguments are parsed, rather than after.
The staging is what makes the overridden case right: a pattern naming
{{ arg.config }} renders with the value the caller passed, not the default
it replaced, so a default naming that pattern sees the override too.
A pattern that names an argument still waiting on its own default is a
real loop and says so. A pattern naming an argument that simply has no
value yet is not a loop, so the specs are returned untouched and the
familiar "missing required argument" is reached instead.
One case is gated rather than fixed: a dependency named as a bare string
gets no argument defaults at all, because parse_dependency_spec returns
args=None for that form and nothing fills them in later. Even a plain
literal default is missing, and the task fails with a leaked internal
name. That is a separate defect, identical on c5ad5c7, and fixing it
changes the arguments a simple dependency is invoked with and so its cache
key.
Co-Authored-By: Claude Opus 5 <[email protected]>
Co-Authored-By: Claude Opus 5 <[email protected]>
deps: [generate] is still an invocation of generate, so it gets generate's argument defaults. The positional and named forms always filled in what the caller left out; naming a task on its own leaves out all of them, and that branch filled in nothing - so the task ran with no arguments at all and a command referencing one failed with a leaked _ArgNamespace name. It also means the same task no longer runs once per route. Reached directly it had its defaults and so an args hash; reached as a bare dependency it had neither, so the two invocations took different cache keys and left two state entries for one task. They now agree, at the cost of one re-run for each affected task as its bare-dependency key gains the arguments it should always have had. None, not an empty dict, when there is nothing to fill: a dependency that takes no arguments keeps the invocation identity, and so the cache key, it always had. A task that does not exist is left alone too, so the existing "not found" report is still what the user sees. An argument with no default is deliberately left out rather than reported here. A task whose required argument is never supplied is a recipe error, but reading the dependency list is not where it should surface, and making it raise here would fail recipes that get away with it today because nothing references the value. This also makes a dependency's default able to name the task's own inputs, which was the one case slice 10e left gated - and shows that the textual self-reference pass over argument defaults is live for dependencies after all, not dead as recorded: it rewrites the default before execute_task resolves the execution order a second time. Co-Authored-By: Claude Opus 5 <[email protected]>
Co-Authored-By: Claude Opus 5 <[email protected]>
Review of PR #212: Use JSON schema to validate incoming tasksI reviewed the new modules ( What's good
Concerns and suggestions
SecurityNothing concerning. The schema is read from package or repo paths only, YAML is loaded via the existing loader (confirm it still uses Action items
|
The review asked for a regression test for the silent corruption even though the fix belongs to slice 12, so here are three: the self. and dep. rewrites in rendering.py, and the import-side var. rewrite in template_refs.py. All expected failures, so they report themselves the moment the rewrites become AST-aware. Writing them turned up a correction to what was recorded. Only the self. case is silent. A literal naming a dependency output has the subscript rewrite applied inside the quotes, so the result will not parse and a correctly written template is reported as malformed - which accuses the user's own template, and is arguably the worse of the two symptoms. Co-Authored-By: Claude Opus 5 <[email protected]>
Review of PR #212: runtime schema validation pipelineScope of this review. The PR is very large (about 25 source and test files, +~7k lines, including a 1,244-line plan doc). I read the PR description and file list, the plan document, What looks good
Concerns
Actions
🤖 Generated with Claude Code |
One rule for what a deps list mentions. dep_task_names is now the single
place that decides, which is what the review asked for - but the behaviour
it documents is the one that was already there, not the one suggested. A
multi-key dep dict still contributes nothing, because the schema reports it
precisely on the pruned tree ("has too many properties"), so following the
extra keys would add nothing and dropping them loses nothing. _rewrite_deps
rewrites every key, and that is not a disagreement: it has to preserve
whatever shape it was handed for validation to report on.
A test that every copy of the schema present agrees with the others. Two
copies exist on purpose - the authored one at the repository root keeps the
raw-GitHub URL in the READMEs resolving, and the wheel force-includes one
under the package - and the installed copy wins at runtime, so a stale one
in a source tree would quietly validate against yesterday's schema.
load_file_schema's docstring now says the cache hands out a shared dict.
Nothing mutates it; merged_tree_schema deep-copies first.
CLAUDE.md's component list was stale, listing nine modules of the
twenty-four and none of raw_merge, recipe_schema, rendering, template_refs,
task_config or field_order - the last of which this branch added. All are
listed now with current sizes, and Jinja2 joins the dependency list.
Co-Authored-By: Claude Opus 5 <[email protected]>
Review of PR #212: schema validation pipelineI read the non-test source in the diff ( OverallThe design is coherent. The merged-tree schema is derived from the file schema, so the two can't drift. Rendering is sandboxed and happens at the point of use. Pruning is name-aware. Process and conventions
Potential bugs and risks
Code quality
Tests
Action list
|
The Windows jobs failed on an assertion for "only has 2" in a message that read "...but only \nhas 2 inputs". Not a width difference: Windows cannot print the unicode markers, so the failure prefix is "[ FAIL ]" rather than "✗", seven characters that move where Rich folds the line. Slice 10b is what exposed it. A bad self-reference is now reported when the task renders rather than when the execution order is resolved, so the message carries a "task 'x' failed:" prefix and is long enough to wrap. error_text collapses whitespace, so these tests are about what the message says rather than where it was folded. Reproduced locally by forcing the ASCII markers: before this change the same assertion fails here too, which is the evidence that a test-only fix is the right one and the message itself is fine. Co-Authored-By: Claude Opus 5 <[email protected]>
Review of PR #212: JSON-schema validation pipelineScope caveat: this is a partial review. I could read the PR metadata, the file list, all of Code quality and conventions
Potential bugs and risks
Performance
Security
Test coverage
Suggested actions
🤖 Generated with Claude Code |
The review asked for a test that a structurally invalid recipe never runs an eval: variable. That ordering is a security property rather than a detail - an eval: variable is a shell command - so it is now pinned, with a positive control so the guard cannot pass by eval: never running at all. It went in its own file: test_schema_validation.py is deliberately importable with nothing but parse_recipe so it can be copied into the reference worktree, and this needs the CLI. The behaviour changes are collected in one place in the plan doc, since the repository has no CHANGELOG to put them in and inventing that convention is not mine to do. Whoever cuts the release can lift the list; if a CHANGELOG is added it should move there rather than be duplicated. CLAUDE.md gains the pipeline order the review asked for - read, merge, prune, validate, evaluate, build, render at the point of use - with the two consequences that explain why rendering sits where it does. Also dropped the two `cp` invocations my own tests had picked up. They work on the Windows runners because Git for Windows is on PATH, and the neighbouring tests rely on that too, but the copy was never what these tests asserted on. Co-Authored-By: Claude Opus 5 <[email protected]>
Review of PR #212 (schema validation pipeline)I read the new modules ( What looks good
Issues and risks
SecurityNo new concerns. The sandbox is a net improvement. PerformanceSchema load is cached, and Actions
|
A variable named only inside {% if var.flag %} was invisible to the walk
that decides which variables to evaluate and which values a task's hash
counts, because only {{ ... }} was scanned. Two consequences, both real: a
valid recipe failed at render claiming the variable was not defined, and
had it resolved, changing the value would not have re-run the task. An
imported file's statement blocks were not namespaced either, so such a
variable resolved against the importing file's scope.
Discovery over-matching is fine, as the docstring says, so widening the
block pattern is the whole fix for it. Precision is the rewriting side's
problem, which is slice 12's AST work - and that now has a fourth gate,
for {{ foo.var.bar }} being rewritten as though var were the namespace
prefix because \b matches after a dot.
render_pattern loses its underscore. The CLI hands it to the dependency
walk, which needs rendering without owning it, so another module depending
on a private method was the review's point and a fair one.
And the sandbox docstring now says what it does not do: it bounds reach,
not resources, so {{ 'a' * 10**10 }} is not something it protects against.
Co-Authored-By: Claude Opus 5 <[email protected]>
Review of PR #212 (schema validation pipeline)Scope of this review: I read SummaryThis replaces the object-level merge and validation with a raw-dict pipeline: merge imports, prune, schema-validate, evaluate variables lazily, build objects, then render with Jinja at the point of use. The design is sound, and the module docstrings explain why the pieces are shaped as they are. The stage ordering (validate before any Code quality
Potential bugs
Performance
Security
Test coverage
Housekeeping
Actions
🤖 Generated with Claude Code |
A runner's args: is a mapping of build arguments, so "Arguments are a list, one entry per line" was wrong advice there - and it was being given, right after "is not of type 'object'". The hint is for a task's own arguments, which sit at exactly three path segments under tasks; an inline runner under a task puts its args deeper, which is why the depth is pinned rather than just the first key. The offending value is also no longer quoted back at full length. It may be an entire task, and replacing jsonschema's wall of output is what this module is for. And a test that deriving the merged-tree schema leaves the cached file schema untouched. merged_tree_schema deep-copies today; this is what fails if that stops being true, since every caller shares that dict. All three raised in the PR #212 review. Co-Authored-By: Claude Opus 5 <[email protected]>
Windows wrote an empty file and the assertion read '' != 'on'. The command
was `{% if var.flag %}echo on{% else %}echo off{% endif %}`, and `echo on`
and `echo off` are cmd.exe's own echo-control commands: they set the echo
state and print nothing. The tests echo "yes"/"no" now, and say why, so the
obvious words do not come back.
The feature under test was fine; only the words chosen to demonstrate it
were not portable.
Co-Authored-By: Claude Opus 5 <[email protected]>
Review of PR #212 (schema-validation pipeline)I read the new modules ( SummaryThe design is sound. Merging imports as raw dicts, pruning to reachable tasks, validating against a schema derived from the file schema, and only then building objects is a clear improvement over the old object-level import path. The new modules are small, well documented and mostly single-purpose, and the test volume is high. The points below are mainly robustness gaps on unvalidated input and a few things that cut against the repo's conventions. Potential bugs / robustness
Performance
Security
Style / conventions (CLAUDE.md)
Test coverageCoverage is strong: unit tests for merge, template refs, schema errors and field order, plus integration tests for ordering, pruning and hashing. Gaps I noticed:
Actions
🤖 Generated with Claude Code |
Three smaller points from the review. field_order's ValueError now says
`from e`, so the chaining is deliberate rather than incidental, and a
comment explains what CycleError's second argument holds instead of leaving
an undocumented index.
The README gains the other side of hash tracking: referencing a value that
changes every run makes the task run every time. {{ env.CI_JOB_ID }} in a
command, or in the runner it uses, is enough - the value behind the
reference is part of what the task is. Reading it inside the command
instead keeps it out of the task's identity.
And state written before task names were recorded now has tests: it loads
with an empty name and prunes by the hash-only rule, which is all that can
be applied when there is no name to tell a renamed task from a stale hash.
The round-trip worked; nothing covered it.
Co-Authored-By: Claude Opus 5 <[email protected]>
Two files importing each other as './sub/b.yaml' and '../tt.yaml' are two
files importing each other, but the paths as written compare equal to
nothing, so the merge recursed until Python gave up - an OSError or a
RecursionError traceback where CircularImportError belonged. The membership
check compares resolved paths now; the chain is still reported as written,
which is how the author will recognise it.
A single './' never had the problem: pathlib drops it. The test for that
case is here anyway, since nothing said so.
And a prefix now counts only at the start of a path. \b matched after a
dot, so {{ foo.var.bar }} read as a reference to var.bar and, on import,
had its text rewritten as though var were the namespace - a (?<![\w.])
lookbehind says what the rule actually is. That gate becomes a passing
test, which leaves slice 12's AST work scoped to string literals alone: a
quote is not a word character, so no lookbehind can tell {{ "see var.x" }}
from code.
Both raised in the PR #212 review.
Co-Authored-By: Claude Opus 5 <[email protected]>
Review of PR #212 (runtime schema validation pipeline)Scope note: I could only partly review this. The PR is very large (parser.py +712/−1024, executor.py +368/−159, plus new What looks good
Concerns
Actions
🤖 Generated with Claude Code |
--list, --show and --tree take a different route: with no task to invoke they skip pruning, and they evaluate every variable rather than only the reachable ones. That makes them the paths most likely to reach an eval: early, and the ordering test only covered running a task. The guarantee holds on all three - verified before writing the tests, so these pin behaviour rather than fix it. Raised in the PR #212 review, which asked for exactly this. Co-Authored-By: Claude Opus 5 <[email protected]>
Review of PR #212 (schema validation pipeline)This is a large PR: about 40 files, roughly +3k/-1.3k in source, plus very large test additions. I read the new modules ( Strengths
Issues / risks
Actions
|
A file whose root is a list or a bare scalar reached the merge's dict operations and failed with "pop expected at most 1 argument, got 2" or "'str' object has no attribute 'pop'" - Python's words for a mistake the author could have been told about plainly. _load_yaml now says a recipe must be a mapping of sections, and names the file, which matters because with imports in play it may not be the one that was invoked. An empty file is still an empty mapping, and now has a test saying so. Also from the review, and both confirmations rather than fixes. The sdist does carry the authored schema, so a wheel built from it has something to force-include; nothing configures the sdist, so that rested on hatch's defaults and now has a test. And the reachability walk's tolerance of a multi-key dependency dict really does leave the schema to report it: there is an end-to-end test for the "too many properties" error now, rather than the claim alone. The process references are out of raw_merge's docstrings. "Slice 4", "slice 6" and "until slice 6/8 replaces this" describe a plan that will finish; what the reader needs is why the check runs per file, which is that naming the file is the whole value of the message. Co-Authored-By: Claude Opus 5 <[email protected]>
Review of PR #212 (schema validation pipeline)Scope of this review: I read the new modules What's good
Potential bugs / issues
Performance
Security
Process / conventions
Actions
|
A string `imports:` was iterated one character at a time, an entry that was
not a mapping gave "string indices must be integers", and a missing key
raised a bare KeyError. The merge runs before schema validation by design,
so the shapes it reads have to be checked where it reads them; the rules
are the schema's own - a list of mappings, each with 'file' and 'as' - and
every message names the file and the entry's index, since with imports in
play neither is obvious.
Also cached the merged-tree validator. Both halves of building it are pure
functions of a cached input, yet every parse deep-copied the schema and
recompiled the validator, which the LSP pays on each parse. Three separate
reviews asked for this.
And pinned a pre-existing defect the review turned up: an imported file's
interpreters are namespaced, but an `interpreter: {use: name}` reference to
one is not, so it resolves to nothing. Verified identical on main before
writing the gates, so it is not this branch's - and whether an imported
file should be able to use the interpreters it defines is a design question
rather than a typo to fix in passing.
Co-Authored-By: Claude Opus 5 <[email protected]>
This PR is being opened prematurely to allow assessment of the state of CI tests against this work.
Eventually thë whole feature will be present and we can merge it.