lang: refuse an empty when: connective, and doubt the gate in its own advice
Two follow-ups to the when: evaluator, both found by checking a claim rather than shipping it.
An empty (and) or (or) linted clean and was not a gate. variadic means "any number of predicates", not "including none": (and) is vacuously true and opens permanently, (or) is vacuously false and SKIPS permanently and silently. The second is this defect again through a different mechanism -- a pipeline that quietly stops running with no error -- so both are now refused at lint. Measured before the fix: when: (and) linted clean and scheduled pending.
The scheduler's undecidable-gate log line blamed the pipeline file. The same arm catches every exception out of the evaluator, so it fires for a clause kiln genuinely cannot decide AND for a defect in kiln's own evaluator, and an operator cannot tell which from outside. It now names both, with the outward blame labelled as the likely rather than the certain cause.
test/run-when-checks.sgl: 91 -> 95 checks (empty and, empty or, a nested empty connective, and a positive control that a non-empty and still lints clean). docs/when-predicates.md documents the twelve-head vocabulary, the comparison rules, the trust table, what a clause can and cannot read, and every way one fails loudly.
docs/when-predicates.md | 137 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/kiln/lang/predicates.sgl | 19 +++++++++++++++++++
src/kiln/scheduler.sgl | 17 +++++++++++++++--
test/run-when-checks.sgl | 22 ++++++++++++++++++++++
4 files changed, 193 insertions(+), 2 deletions(-)docs/when-predicates.mdadded
# `when:` predicates — what a pipeline author can and cannot expressA `when:` clause gates a job or a step. It is a **closed pattern language**,not user code: twelve predicate heads, no arithmetic, no calls, no userdefinitions. Kiln validates a clause when it reads your file (`kiln lint`)and decides it when it schedules or runs the work.```scheme(job release when: (and (tag? $ref) (trusted?)) ...)(job build matrix: #{ target: #[ "linux-amd64" "windows-amd64" ] } steps: #[ (artifact "build/${target}/bin/sigil.exe" as: "sigil-${target}.exe" when: (equal? $target "windows-amd64")) (artifact "build/${target}/bin/sigil" as: "sigil-${target}" when: (not (equal? $target "windows-amd64"))) ])```## What a false gate does- **A job** whose `when:` is false is scheduled `skipped`. It never runs, and anything that `needs:` it is skipped too.- **A step** whose `when:` is false is not compiled into the node's script at all. It leaves no section marker, so a gated-off step and a step that ran and printed nothing are not confusable in the log.- **A group whose jobs are all skipped** reads `skipped`, not `passed`. Green means work ran and succeeded.## The vocabulary| form | arity | true when ||---|---|---|| `(and P ...)` | 1+ | every `P` is true || `(or P ...)` | 1+ | some `P` is true || `(not P)` | 1 | `P` is false || `(equal? A B)` | 2 | `A` and `B` are the same value || `(member? X (v ...))` | 2 | `X` equals one of the values || `(tag? R)` | 1 | `R` is a ref string beginning `refs/tags/` || `(branch? R)` | 1 | `R` is a ref string beginning `refs/heads/` || `(pull-request?)` | 0 | this run was triggered by a pull request || `(manual?)` | 0 | this run was triggered manually || `(trusted?)` | 0 | this run is trusted (see below) || `(fork?)` | 0 | this run came from a forked repository || `(changed? R)` | 1 | **not implemented — refused by `kiln lint`** |`and` and `or` **do not short-circuit**. Every predicate is total andeffect-free, so there is nothing to gain by stopping early, and stoppingearly would let an undecidable predicate hide behind a false conjunct andcome back as a quiet "no".They also need **at least one** operand. `kiln lint` refuses an empty `(and)`or `(or)`: an empty connective is a gate that is not a gate — `(and)` would bevacuously true and open permanently, `(or)` vacuously false and skippermanently, and neither is anything an author means to write.## What a clause can readOnly these:- `$ref` — the full ref, e.g. `"refs/tags/v1.0.0"`- `$sha`- `$trigger` — `push`, `tag`, `pull-request`, `manual` or `chained`- **your matrix dimensions** — `$target`, `$os`, whatever you named themEverything else in kiln's reference namespace (`$actor`, `$repo`, `$run-id`,`$workspace`, `$needs.build.out`, `$steps.compile.x`) passes `kiln lint`,because those are legal references elsewhere in a job file, and **has novalue where a gate is decided**. A gate is decided before the work it gates,so `$needs.*` and `$steps.*` are meaningless there by construction, and kilnrecords no actor or workspace for a run at all.Using one is an error, not a false. See "If kiln cannot decide" below.### Comparison rules- A symbol and a string spelling the same name are **equal**: `(equal? $target linux-amd64)` and `(equal? $target "linux-amd64")` both work, because matrix values are strings while a bare name in a `.kiln` file reads as a symbol.- A number and a string are **not** equal: `(equal? 1 "1")` is false.- `member?`'s second argument is a list of values, written `#[ ... ]`: `(member? $target #[ "linux-amd64" "linux-arm64" ])`.### `trusted?` and `fork?``trusted?` is kiln's secret-bearing classification, computed by`classify-trust` from the trigger:| trigger | trusted? ||---|---|| `push` | yes || `tag` | yes || `manual` | yes || `pull-request` | yes, unless the PR is from a fork || `chained` | inherits the parent run's trust || anything else | no |`fork?` is **derived** from that, because kiln stores no fork flag: apull-request run is a fork run exactly when it is untrusted, and apush/tag/manual run cannot be one (all three require write access to thisrepository). For a `chained` run kiln has no record of where the chainbegan, so **`(fork?)` is an error on a chained run** rather than aplausible-looking `no`.## If kiln cannot decide**A predicate that cannot be decided fails loudly. It never silentlyevaluates false.** This is the point of the whole design: a skip and anundecidable gate produce the same absence of work, and if they were the sameoutcome, a release pipeline could quietly stop publishing and look greendoing it.Concretely:- **At lint time** — `changed?` is refused when you write the file, because kiln does not collect the changed-file list for a run and never can decide it. An unknown head (`(supported? $ref)`) is refused as unknown.- **At schedule time** — a job whose clause cannot be decided is scheduled `errored`, not `skipped`, with the reason in its run log. Its dependents are skipped and the group fails.- **At run time** — a step whose clause cannot be decided aborts the node's script compilation, and the node errors with the offending clause named.The cases you can actually hit: an unresolvable `$reference`, a dotted`$needs.*` / `$steps.*` reference, `changed?`, and `(fork?)` on a chainedrun.## Where this lives`src/kiln/lang/predicates.sgl` validates a clause;`src/kiln/lang/when-eval.sgl` decides one. Gates are applied in`schedule-plan-group!` (`src/kiln/scheduler.sgl`) for jobs and in`emit-step` (`src/kiln/nodeexec.sgl`) for steps.`test/run-when-checks.sgl` is the check suite.src/kiln/lang/predicates.sglmodified
(define (check-arity sink head arity args line form) (let ((n (length args))) (cond ;; `variadic` means "any number of predicates", NOT "including ;; none". An empty connective is a gate that is not a gate: ;; `(and)` is vacuously true and opens permanently, `(or)` is ;; vacuously false and SKIPS permanently and silently. Both are ;; the shape of an author deleting the last operand and not ;; noticing, and the second one lands the pipeline right back ;; in the defect (kiln lang when-eval) exists to close. ;; ;; Measured before this arm existed: `when: (and)` linted clean ;; and scheduled `pending`. ((and (eq? arity 'variadic) (= n 0)) (diag-error! sink (string-append "when: '" (symbol->string head) "' needs at least one predicate; an empty '" (symbol->string head) "' is not a gate -- (and) is always true and " "(or) is always false, so the clause silently " "stops deciding anything") line form)) ((eq? arity 'variadic) #t) ((not (= n arity)) (diag-error! sinksrc/kiln/scheduler.sglmodified
;; An undecidable gate must not look like an intended ;; skip, so it gets a status of its own AND the reason ;; written where an operator reads it. ;; ;; The message names BOTH possible causes rather than ;; blaming the pipeline file. This arm catches every ;; exception from the evaluator, so it fires for a clause ;; kiln genuinely cannot decide AND for a defect in the ;; evaluator -- and an operator reading it cannot tell ;; which from the outside. A gate's remediation advice ;; doubts the gate first. (when (pair? gate) (db-append-log! db id 1 "stderr" (string-append "[kiln] node " node-id " could not be scheduled: " (cdr gate)))) (string-append "[kiln] node " node-id " was not scheduled: its when: " "clause could not be decided, so kiln refused to guess. " (cdr gate) " -- this is usually the clause, but the same path " "reports a fault in kiln's own evaluator; if the clause " "looks correct, treat it as a kiln bug."))) (loop (cdr ns) (cons id ids) (or gated? (not (eq? gate 'open)))))))))test/run-when-checks.sglmodified
(sink-of "(pipeline p (job a when: (supported? $ref) steps: #[ (shell \"x\") ]))") "unknown when: predicate"));; An empty connective is a gate that is not a gate. `(and)` opens;; permanently; `(or)` SKIPS permanently and silently, which is this;; defect again with a different mechanism. Both refused at lint.(check "an empty (and) is refused at lint time" (has-diag? (sink-of "(pipeline p (job a when: (and) steps: #[ (shell \"x\") ]))") "at least one predicate"))(check "an empty (or) is refused at lint time" (has-diag? (sink-of "(pipeline p (job a when: (or) steps: #[ (shell \"x\") ]))") "at least one predicate"))(check "a nested empty connective is refused too" (has-diag? (sink-of "(pipeline p (job a when: (not (or)) steps: #[ (shell \"x\") ]))") "at least one predicate"));; Positive control: a NON-empty variadic connective still lints clean,;; so the new arm is not refusing every and/or.(check "positive control: a non-empty (and) still lints clean" (not (has-diag? (sink-of "(pipeline p (job a when: (and (tag? $ref)) steps: #[ (shell \"x\") ]))") "at least one predicate")));; ---- summary ----(display (string-append "\n" (number->string passes) " passed, " (number->string fails) " failed\n"))