Semantic-diff renderer framework¶
Status: Implemented (registry + recipe renderer)
Issue: #287
Umbrella: #283
Scope: Plugin registry in the gateway UI that maps a CRD kind to a
function producing structured, human-readable diff entries for the
Promote / ChangeRequest preview.
Why¶
The GitOps for Automation Engineers explanation doc commits to one load-bearing UX promise:
The Promote preview does not show you raw YAML. It shows you the change in the language you already use — phase hold times, alarm setpoints, equipment requirements, recipe parameter ranges.
A raw YAML diff (holdSeconds: 1800 → 2700) violates that promise.
The semantic-diff renderer is the bridge: at preview time it walks
the before and after objects and emits structured entries
(Mash Hold: hold time 30 min → 45 min) the engineer can review in
their domain.
The framework is deliberately a small registry (one function per CRD kind), so adding new coverage is an isolated change and the long-tail of CRDs (#292) can be parcelled out per kind without churning load-bearing code.
Where it lives¶
| File | Purpose |
|---|---|
internal/gateway/static/js/diff-renderer.js |
Registry + shared formatters (formatDuration, parseISA88Duration). DOM-free. |
internal/gateway/static/js/diff-renderers/<kind>.js |
One file per kind. Auto-registers when loaded. |
internal/gateway/static/js/diff-renderers/_procedural-helpers.js |
Shared helpers for the procedural-template family (PhaseTemplate, OperationTemplate, UnitProcedureTemplate). Underscore-prefixed to mark as internal-to-the-family. |
test/js/diff-renderer.test.js |
Framework unit tests. |
test/js/diff-renderer-recipe.test.js |
Recipe renderer fixtures. |
test/js/diff-renderer-{phase,operation,unitprocedure}template.test.js |
Procedural-template renderer fixtures. |
The renderer modules are loaded by <script src="…"> from the three
sub-app entry HTMLs (system/, hmi/, data/index.html). Browser
loading auto-registers the renderer via the if (typeof window !==
'undefined') guard at the bottom of each file. The same files run
under node:test via a CommonJS shim so the units can be exercised
without a browser harness.
Registry API¶
// diff-renderer.js exposes these on `window` and as CommonJS exports.
registerDiffRenderer(kind, fn);
// fn(beforeObj, afterObj, ctx) -> Array<DiffEntry>
const result = computeSemanticDiff(beforeObj, afterObj, ctx);
// { kind: "MasterRecipe", entries: [...], fallback: false }
computeSemanticDiff looks up the renderer by afterObj.kind (or
beforeObj.kind for deletes) and invokes it. When no renderer is
registered the result carries fallback: true and an empty
entries array. The UI is expected to fall back to the raw-YAML
diff toggle in that case.
Entry shape¶
Every renderer returns an array of DiffEntry:
interface DiffEntry {
// Structural path for traceability and Apply-time correlation.
// Bracket notation uses the natural key (name) of array items
// rather than the array index, so it remains stable across reorders.
path: string;
// Engineer-facing label. "Mash Hold: hold time", "Equipment
// requirement added: Cooling", "Formula Small Batch: Binder Mass".
label: string;
// Pre-formatted before/after values. Empty string for adds (no
// before) and removes (no after).
before: string;
after: string;
// Severity hint for the UI to colour / group entries. Renderers
// may invent additional values, but these are the conventions:
// "structural" — added / removed step, requirement, branch
// "parameter" — value inside an existing structure
// "annotation" — metadata-only (description, author, etc.)
impact?: "structural" | "parameter" | "annotation" | string;
}
Renderer contract¶
Renderers must be:
- Deterministic — same inputs, same outputs.
- Side-effect-free — no network, no DOM, no globals beyond
window.__diffRendererUtils. - Tolerant — accept missing/null sub-objects and treat them as empty. The framework guarantees a kind match but not field shape.
- Stable in ordering — emit entries in a deterministic order so the UI's diff output is reproducible.
- Default-aware — a sparse declared manifest and a re-defaulted
cluster object are the same state, and must render no entry (#825).
Three equivalences apply:
- Absent vs server-applied default: a field carrying a
+kubebuilder:defaultmarker (e.g.SFCStep.waitForAction,Unit.spec.mode) is normalised through__diffRendererUtils.withDefault(value, default)on both sides before comparing, with the Go marker named in a comment at the call site. This also makes a real change render the effective value ("Manual -> Automatic", where "Manual -> (empty)" would mislead). - Zero vs absent: non-pointer
omitemptyscalars (timeoutSeconds, transitionpriority,required) cannot round-trip their zero value through the cluster, so an explicit0/falsein a manifest equals an absent field. The duration helpers fold0into the unset state. - Unset states:
undefined,null, and''are one state, and the sharedpushScalarChangehelpers skip when both sides are unset (#749).
- Absent vs server-applied default: a field carrying a
Worked example: the recipe renderer¶
internal/gateway/static/js/diff-renderers/recipe.js is registered
for MasterRecipe (and for the cluster-scoped recipe-template kinds
when their UI flows are re-enabled, since the renderer is
shape-driven). It walks:
| Spec section | Diff treatment |
|---|---|
Top-level metadata (description, productName, version, author, …) |
Per-field scalar entries with impact: "annotation" (or "structural" for targetProcessCell). |
header |
Per-field scalar entries with impact: "parameter". |
parameters (RecipeParameter list) |
Add / remove / per-field modify (type, defaultValue, minValue, maxValue, engineeringUnit, description). TIME-typed defaults are normalised through parseISA88Duration and rendered via formatDuration. |
equipmentRequirements |
Add / remove with summary; modify emits per-field entries (minCount and a sub-diff of parameters). |
procedureSteps (deprecated flat) |
Step add / remove with structural summary. Modify emits per-field entries (template refs, capability, description, timeoutSeconds rendered via formatDuration, plus a sub-diff of per-step parameters and parameterBindings). Reorder of an unchanged set surfaces as a single Step order entry. |
procedure.chart (SFC) |
Add / remove of the chart, plus sub-diffs of steps, transitions (keyed by from->to) and divergences. |
formulas (named parameter sets) |
Add / remove with summary; per-formula sub-diff of parameters, materialInputs, materialOutputs. FormulaParameter.value formats with engineeringUnit when present. |
Step names are humanised before being placed in label: mash-hold
becomes Mash Hold, binderMass becomes Binder Mass, so an
engineer never sees the raw identifier in the preview unless they
look at path.
Unit conversions¶
The renderer applies these conversions automatically:
- Numeric
*Secondsfields (e.g.timeoutSeconds,holdSeconds) and the per-stepparameters[…]entries whose name matches that pattern are rendered throughformatDuration(1800 → "30 min"). RecipeParameterentries whosetypeis"TIME"accept any of IEC 61131 (T#1h30m,30m), ISO 8601 (PT45M), or bare seconds (1800), and render throughformatDuration.FormulaParameter.valueis rendered with the parameter'sengineeringUnitappended ("7.5 kg").
Writing a new renderer¶
To add coverage for a new CRD kind (e.g. PhaseTemplate,
AlarmDefinition), drop a file at
internal/gateway/static/js/diff-renderers/<kind>.js following the
recipe pattern:
- Define the function:
renderXDiff(before, after, ctx), returning an array of entries. Walk the spec, and for each user-meaningful field push aDiffEntry. - Register on load. Call
registerDiffRenderer('<Kind>', renderXDiff)inside anif (typeof window !== 'undefined')guard so node:test fixtures can register manually. - Add to the entry HTMLs. Append a
<script src= "/js/diff-renderers/<kind>.js">line to the relevantinternal/gateway/static/{system,hmi,data}/index.html(afterdiff-renderer.js). - Test it. Add fixtures to
test/js/diff-renderer-<kind>.test.jscovering at minimum: no-op, scalar field change, add inside a list, remove inside a list, structural reorder. The recipe tests are the reference shape. Copy aggressively. - Run
make test-jsandmake lint-js.
The shared helpers __diffRendererUtils.indexByKey(arr, key) and
__diffRendererUtils.deepEqual(a, b) are exposed on window for
renderer use. Prefer them over rolling your own.
When to extract a sub-family helper file¶
The procedural-template renderers (PhaseTemplate,
OperationTemplate, UnitProcedureTemplate) share enough surface area
(same ParameterSpec, same OutputSpec, same SFCChart) that a
sibling helper file (diff-renderers/_procedural-helpers.js) earns its
keep. The convention is:
- Underscore-prefixed filename so the helper is visibly not a registered renderer.
- Helpers are exposed on
window.__procDiffHelpers(and a CommonJS export for node:test) so each renderer can pull only the pieces it needs. - One renderer file per kind still, even when the bulk of the work flows through helpers. The registry contract is one function per CRD kind, and a thin renderer file is the right place to make domain-specific labeling decisions ("Phase added" vs "Operation added" vs "Step (Action) added").
Don't over-share: the recipe renderer's RecipeParameter shape uses
engineeringUnit where the procedural family uses unit, and its SFC
step shape contains
phaseTemplateRef where the generic shape carries templateRef. A single
catch-all helper would have to disambiguate, so the recipe renderer
keeps its own copies of the parameter-summary / step-summary
formatters.
Why this and not a YAML-text diff¶
Two concrete reasons:
- Domain language.
holdSeconds: 1800 -> 2700reads as Greek to an engineer who thinks in minutes.Mash Hold: hold time 30 min -> 45 minreads as the change they intended. The framework exists precisely to do that translation. - Stable identity under reorder. A YAML diff treats
move array element from index 2 to index 0as a sea of+/-lines. The semantic renderer keys arrays by their natural identifier (name,capability,from->to). A reorder therefore collapses to a singleStep order: A -> B -> C => B -> A -> Centry that reviewers can actually read.
A "View YAML diff" toggle is still always one click away, per the always allowed to peek under the hood principle in the explanation doc. The semantic diff is a UX layer on top of the truth. The truth stays reachable.
Open questions¶
These do not block #287 but inform the long-tail work in #292:
- Conflict surface. When
desiredObjectis stale (the live resource has moved since the preview was computed), should the diff renderer flag conflicting fields or leave that to the apply step? Current answer: leave it to apply, since the renderer is a pure function of (before, after). - i18n. Labels are English-only today. If a customer needs localisation, a label-table indirection would be the right shape. It is not yet justified.
- Per-field severity overrides. Some fields (e.g.
targetProcessCell) deserve a louder marker than the genericstructuralhint. We may grow aseverity: "warning" | "info"annotation, but want to see real customer review traffic before deciding the shape.
Acceptance for #287¶
- Registry API in place at
internal/gateway/static/js/diff-renderer.js. - Recipe renderer covers operations / parameters / equipment
requirements / SFC procedure / formulas / unit conversions, with
tests in
test/js/diff-renderer-recipe.test.js. make test-jsgreen, andmake lint-js,make lint-css,make lint-iconspass.- This doc merged so #292 contributors can pick up new kinds without rediscovering the contract.