Diagnostics-driven repair
Read the Code, Not the Sentence
Every diagnostic SJON emits carries a code, a snake_case identifier
like unknown_key or vector_too_short, alongside its prose message, a
source span, and a semantic path. The code is the part to build a habit
around, for two reasons. It is wire-stable, so it means the same thing
in this release and the next one, which the wording of a message does
not promise. And it names a layer, which is what tells you where to
look.
That last point is the whole method. Write this against the shapes
plugin and you get two diagnostics, both of them
positional_not_allowed, both saying that circle does not accept
positional children:
(circle :center [0 0] :radius 1 :fill :evenodd)Read that literally and you go looking for a stray child form. There is
no child form. What happened is the pairing rule from
Forms and keyword pairing: :fill
could not pair with :evenodd, so both became positional flags, and
circle accepts no positionals. The count is the tell, incidentally,
since two complaints from one mistake means two children appeared where
you wrote one thing. The diagnostic is telling the truth about the tree,
and the tree is not what you thought you wrote. Fixing the layer it
names would mean deleting children that do not exist; fixing the layer
underneath it takes one character:
(circle :center [0 0] :radius 1 :fill evenodd)So the first move on any diagnostic is to place the code in the stack:
vocabulary unknown head or key, or a head outside a slot's local form setform contract duplicate key, missing key, positional placementvalue shape wrong underlying kind, wrong vector lengthkind refinement member, head, unit, bound, or representation errorunion no alternative accepted the valueexpression arity, typed argument, or kvpair in a callcross-reference name undeclared, duplicated, cyclic, or out of scopeexclusive group too many or too few alternatives presentThen work the code first and the prose second.
Stable codes you are likely to see while authoring:
| Code | Usual repair direction |
|---|---|
unknown_form / ambiguous_form / ambiguous_expr | Check the head spelling, loaded plugin set, or qualify with plugin/head. |
unknown_key / duplicate_key / missing_required_key | Check the form’s key table. |
positional_not_allowed | Move the child under a containing form or into a form-valued key. |
wrong_underlying / vector_length_mismatch | Match the declared value shape. |
vector_too_short / vector_too_long | Add or drop elements to land in the kind’s :min-len/:max-len window (distinct from a fixed :len). |
repr_out_of_range | Bring the number into the :repr type’s range, and make it whole for an integer type (u16/u32/i32). |
unit_required / unit_not_allowed | Add an allowed unit suffix. |
unit_forbidden | Drop the unit suffix; the kind rejects every unit, which is distinct from a suffix outside an allowed list. |
not_member / not_head_member | Use one of the plugin’s closed-set values or heads. |
unknown_local_form | Use a head the slot accepts: a local form the message lists, or a known global head. |
union_no_branch_matched | Read the listed alternatives (the message names every shape the slot accepts) and rewrite the value to fit one of them. |
expr_kvpair_not_allowed / arity_mismatch / expr_type_mismatch | Rewrite the expression call shape. |
not_cross_ref | Spell the referenced name correctly, or add the missing declaration. |
duplicate_cross_ref_target | Rename one of the two declarations or remove the duplicate. |
cyclic_cross_ref | Break the cycle in the chain (typically a :parent-style key). |
cross_ref_outside_scope | Move the reference inside the enclosing scope form, or declare the name in the right scope. |
cross_ref_extraction_failed | Fix the source string the diagnostic points at; a provider read it and rejected it, so the names inside are unknown. |
cross_ref_provider_unavailable | Nothing in the document to repair: this host cannot run the provider, so those names go unchecked here. |
missing_discriminant_key | Add the discriminant key (e.g. :kind) to the form. |
unknown_key (with discriminant-first hint) | Reorder so the discriminant key precedes any variant-only key. |
mutually_exclusive_keys_present | Remove all but one alternative from the group the message names. |
required_one_of_missing | Add exactly one of the alternatives the message names. |
Worked Example
The pairing case from the opening is the first half of this drill, so here is the second. Broken:
(badge :label "x" :shape (group :name "oops"))Likely diagnostic: not_head_member. The :shape slot accepts only
specific form heads. Repair with an allowed form:
(badge :label "x" :shape (circle :center [0 0] :radius 1))Exercises
Predict the diagnostic category and repair each example.
Unknown form:
(circl :center [0 0] :radius 1)Repair:
(circle :center [0 0] :radius 1)Unknown key:
(canvas :width 320 :height 240)Repair:
(canvas :w 320 :h 240)Duplicate key:
(scene :title "a" :title "b")Repair by choosing one value:
(scene :title "b")Missing required key. This one is a schema-reading drill: assume the
plugin docs say (circle ...) requires both :center and :radius.
(circle :center [0 0])Repair:
(circle :center [0 0] :radius 1)Wrong underlying kind:
(circle :center "middle" :radius 1)Repair:
(circle :center [0 0] :radius 1)Arity mismatch:
(shape :sdf :radius (lerp 0 10))Repair:
(shape :sdf :radius (lerp 0 10 0.5))Typed expression mismatch:
(vec3 1 "two" 3)vec3 takes three numbers:
(vec3 1 2 3)If you meant a scalar radius, use a scalar expression instead:
(shape :sdf :radius (* 2 16))Member set mismatch:
(circle :center [0 0] :radius 1 :fill diagonal)Repair:
(circle :center [0 0] :radius 1 :fill nonzero)Unit mismatch. Assume duration allows s | ms | b:
(delay :wait 4px)Repair with an allowed suffix:
(delay :wait 4b)Head set mismatch:
(badge :label "x" :shape (group :name "oops"))Repair:
(badge :label "x" :shape (rect :origin [0 0] :size [10 10]))Variable-length vector. Assume :position is documented as
vector, length 2-4, a range rather than a fixed :len:
(vertex :position [0.0])Likely diagnostic: vector_too_short (the floor is 2; a fixed-length
kind fires vector_length_mismatch instead). Repair into range:
(vertex :position [0.0 1.0])Representation out of range. Assume :tint is documented as
number, repr u16:
(vertex :tint 70000)Likely diagnostic: repr_out_of_range. u16 tops out at 65535, and
an integer type also rejects a fractional value. Repair into range:
(vertex :tint 65535)Unit rejected. Assume :lod-bias is documented as
number, unit rejected:
(draw :lod-bias 0.5f)Likely diagnostic: unit_forbidden. The slot takes bare numbers; the
trailing f lexes as a unit suffix. Repair by dropping it:
(draw :lod-bias 0.5)Slot-local form. Assume canvas’s :shape slot defines local forms
circle | rect | group:
(canvas :shape (triangle))Likely diagnostic: unknown_local_form, listing the slot’s local
heads, which is more specific than a top-level unknown_form. Repair with a
head the slot accepts:
(canvas :shape (circle :r 12))Union no-branch matched. Assume :notes is documented as
vector<note-or-event> where note-or-event = note-or-rest | event
and event is a form with head n or rest:
(phrase :notes [E4 42])Likely diagnostic: union_no_branch_matched listing the alternatives
(note-or-rest, event). The number 42 is neither a pitch symbol
nor an event form. Repair with a value matching either alternative:
(phrase :notes [E4 (n G4 0.5b)])Union with a form alternative, which is the wildcard pitfall. Assume the
plugin documents :value as number | vec4 | form:
(set :value (foo 1 2))Likely diagnostic: unknown_form (not union_no_branch_matched).
The form alternative still resolves the form’s head against the
schema; “any form” is not the same as “any parens.” Repair by using
a form whose head the schema knows:
(set :value (+ 1 2))Ambiguous form. Assume two loaded plugins both declare circle:
(circle :center [0 0] :radius 1)Repair by qualifying the domain head:
(shapes/circle :center [0 0] :radius 1)Discriminant absent. Assume (track ...) is documented with
discriminant: kind and variants kick | groove | animation:
(track :name k1 :from 0)Likely diagnostic: missing_discriminant_key. Repair:
(track :kind kick :name k1 :step 4 :from 0)Discriminant out of order. The variant key :step is written before
:kind:
(track :name k1 :step 4 :kind kick)Likely diagnostic: unknown_key on :step with a hint that the
discriminant must be set first. Repair by placing :kind before any
variant-only key:
(track :kind kick :name k1 :step 4)Cross-variant key. :mesh is an animation-variant key, but
:kind = kick:
(track :kind kick :name k1 :mesh logo)Likely diagnostic: unknown_key (no hint, because the discriminant is set and the
key just isn’t valid under this variant). Repair by changing the
discriminant or removing the wrong-variant key:
(track :kind animation :name k1 :mesh logo)Exclusive-group violation. Assume (phrase ...) is documented with
an exactly-one group over :notes | :events:
(phrase :name p0 :notes [E4 G4] :events [(n A4 0.5b)])Likely diagnostic: mutually_exclusive_keys_present listing
:notes | :events. Repair by keeping one alternative:
(phrase :name p0 :notes [E4 G4])Required-one-of missing. Same (phrase ...) summary:
(phrase :name p1)Likely diagnostic: required_one_of_missing. Repair by adding one
of the alternatives:
(phrase :name p1 :events [(n A4 0.5b)])Cross-reference miss. Assume the plugin’s :sequence slot expects
vector<phrase-name> and only p0 is declared:
(phrase :name p0 :notes [E4 G4 A4 G4])
(track :sequence [p0 p1])Likely diagnostic: not_cross_ref at [track sequence]. Repair by
adding the missing phrase or fixing the spelling:
(phrase :name p0 :notes [E4 G4 A4 G4])(phrase :name p1 :notes [B4 A4 G4 E4])
(track :sequence [p0 p1])Cross-references covers these shapes in detail.
LSP Quickfixes
SJON’s language server offers single-step quickfixes for the diagnostics it can repair without guessing. The action title tells you what it will do; review before applying.
| Diagnostic | Quickfix title | What it does |
|---|---|---|
unknown_form | Replace with “ | Substitute the typo with the closest known head (Levenshtein-bounded). |
unknown_key | Replace with : | Substitute the typo with the closest declared key on this form. |
ambiguous_form | Qualify with | One action per claimant plugin. |
missing_required_key | Insert : with stub | Insert each missing required key with a typed placeholder value. |
expr_kvpair_not_allowed | Drop : (keep value) | Strip the keyword tag, leave the value as a positional argument. |
duplicate_key | Remove duplicate : | Delete the later occurrence and its preceding whitespace. |
not_cross_ref | Replace with | Replace the symbol with the closest in-scope registered name. |
cross_ref_outside_scope | Replace with | Surfaces only when an in-scope alternative exists; otherwise no fix (move the reference instead). |
not_member | Replace with | Replace the value with the closest non-deprecated member of the slot’s enum. |
For typo-style fixes, only the single closest candidate within Levenshtein distance 3 is offered; farther typos return no action so the suggestion can’t mislead.
Mastery Check
-
Which diagnostic category usually means you misspelled a key?
-
Which category points to a closed enum-like value?
-
Why should tooling match diagnostic codes rather than message prose?
-
Why can
positional_not_allowedbe caused by keyword pairing? -
For an
exactly-oneexclusive group, which two codes cover "too many" vs. "too few"? -
A vector value is rejected. Which diagnostic tells you the slot has a fixed length rather than a variable-length range?
-
What separates
unit_not_allowedfromunit_forbidden? -
Why does a bad form head inside a slot with local forms produce
unknown_local_formrather thanunknown_form?