Skip to content

v0.14.0

Choose a tag to compare

@lyriks-dev lyriks-dev released this 22 Jul 16:19

Reference-over-copy for project-canonical definitions, plus a round of write/read symmetry fixes, driven by what the Lyriks platform needs from the engine. A project can now own entities, resources, and personas once and have member features REFERENCE them, so the copy-per-feature pattern (and the twin-collapsing heuristics it forces on readers) can go away. The maturity heuristic ships as a callable function so it stops being hand-copied across repos, verify reports per-scenario results machine-readably, and three classes of silently-wrong write are now rejected or rolled back.

Two things in the driving report did NOT reproduce on this engine and needed no fix: a condition-less invariant is already rejected at write time (fixed in 0.10.1, apply_batch covers surface, action, AND feature invariants), and apply_batch dry-run has never used weaker validation than commit — both paths run the same diff-aware gate. What was real is that the gate did not deep-validate SHAPES; that is fixed below.

No breaking changes. The new validation is diff-aware on every write path, so existing malformed data stays editable (pinned by tests); the package keeps main/types rather than an exports map, so every existing subpath import — including the unspaghettit/cli/scenarios path baked into generated scenario adapters — resolves exactly as before. The blocked-action fix was checked against 2155 simulated runs over 133 real features: no verdict changed anywhere, and the only two state differences were the leak being fixed. See "Verified against real data" below.

Added

  • A project can own canonical entities, resources, and personas, referenced (not copied) by its features. A feature has to be verifiable in isolation, so every consumer — validator, maturity scorer, simulator, model checker, digest, dashboard — reads feature.entities / .resources / .personas directly. Modeling one domain object across N features therefore meant N copies that drift, double-count in graphs, and force readers to guess which twins are "the same thing". A project now carries entities[] / resources[] / personas[] with stable ids, and a feature references them via entityRefs / resourceRefs / personaRefs. References are resolved at the REPOSITORY boundary, so nothing downstream changes: verify, model_check, run_all_scenarios, score_feature, digests, and the dashboard all see a self-contained feature, while the definition is stored exactly once. Saving strips the resolved copies back to refs, and an edit made through a member feature (update_entity, add_entity_field) writes back to the canonical definition — which is what canonical means. New tools: list_project_library, promote_to_project_library, link_project_definition, unlink_project_definition, remove_project_definition, plus link_project_definition / unlink_project_definition as apply_batch ops.
  • promote_to_project_library is the inline → reference migration. It moves one definition into the project library, replaces it with a ref, and collapses identically-named copies in the project's OTHER features into references to the same definition. Copies are compared on content with ids ignored (independently authored twins mint their own ids for the entity AND every nested field), and a copy that has genuinely DIVERGED is left alone and reported in skipped rather than silently discarded. Pass dedupe:false to promote only the one feature's copy.
  • computeFeatureMaturity(feature) is exported from the npm package. Consumers that need the maturity number offline were hand-copying the check weights out of MaturityScorer, so every engine release could silently drift the two formulas apart. import { computeFeatureMaturity } from 'unspaghettit' now returns { featureId, featureName, score, maxScore, percentage, criticalIssues, recommendedIssues, breakdown } from the same code the dashboard and verify use — pure, synchronous, no I/O, safe air-gapped. Delivered via main + types pointing at a self-contained bundle (npm run build:lib), deliberately NOT an exports map: an exports map disables directory-index resolution, which would have broken unspaghettit/cli/scenarios — the path every generated scenario adapter imports. All existing subpath imports and the unspa / unspa-mcp bins resolve exactly as before (verified against a packed tarball in a clean consumer). Deliberately NOT a TRL number: the engine has no TRL concept, and inventing a 1-9 ladder here would just be a second formula to keep in sync — map percentage and breakdown to whatever scale you publish. get_digest also carries a maturity[] block now, so a digest consumer needs no second call.
  • verify reports per-scenario results, not just a count. Each feature verdict carries scenarios[]: one row per authored scenario with scenarioId, name, surfaceId / actionId / actionName, passed, expected vs actual status, assertion counts (evaluated / failed / skipped), firstFailingStep and the action it invoked, and a one-line reason. Passing scenarios are included, because a report that lists only failures cannot answer "was this criterion actually exercised?". This is what lets a consumer trace acceptance criteria → scenarios → results without parsing prose.
  • The dashboard shows which release it is running. A muted Unspaghettit v0.14.0 stamp sits at the bottom-left of the projects container, so a screenshot or a bug report carries its own build identity instead of needing "which version are you on?" as a first reply. The string is inlined from package.json at build time (Vite define), so it costs no fetch and keeps package.json out of the browser bundle. The update banner still owns the "a newer one exists" message; this is just the "which one am I on" one.

Fixed

  • A blocked action no longer commits state. A parameter with bindToStatePath is written into state BEFORE rules run (deliberately, so a rule can gate on the incoming value), but the write survived a block_action — a validation rule blocking "amount exceeds balance" still left transfer.amount set to the illegal value. The same held for a set_state fired by a rule (or an action effect) that ran before the one that blocked, leaving half-applied state. A blocked run now rewinds to the pre-action snapshot: no bind, no effect, nothing commits. Messages, emitted events, the recorded transition, block reasons, and the applied-effect audit trail are all kept — they are the signal that the attempt happened — and onBlockedEffects still fire against the rewound state. Invariant violations are not rewound, so the model checker still gets its counterexample state. This makes the platform's workaround (guarding invariant-backing parameters with validations purely to stop the leak) unnecessary.
  • Tag values keep their casing byte-for-byte. Every write path lowercased tag types and values, so a consumer matching core: / family / phase values exactly (the Lyriks platform keys its Features projection off them) saw its projection silently empty out on any write that touched a tagged feature. Tags are now stored as authored, trimmed only. Identity stays case-insensitive — "Growth" and "growth" are still one tag for dedupe, lookup, filtering, and rename — and on a differently-cased re-add the FIRST spelling wins rather than the last. Display no longer down-cases a fragment that already carries a capital, so "MCP" renders as "MCP" rather than "Mcp". Existing lowercase tags are untouched; nothing is rewritten on disk.
  • Summary lines read as sentences and the scope pickers say what they pick. Every digest line rendered its label glued to its text ("Global Search:Header search over the entire model"): the separator was a whitespace-only template node that the compiler trimmed, so it never reached the page. The separator is now emitted as an expression and every line reads "Label: text". The Summary toolbar's dropdowns also carry visible "Feature" / "Surface" / "Action" labels: with two or three unlabeled selects side by side (and features and surfaces that can share a name), nothing said which level of the model each one narrowed.
  • invoke_operation effects pass validation, and their references are finally checked. The effect type shipped in the write path and the simulator while reference-integrity validation checked against a hand-copied list that omitted it, so a legitimately authored boundary call was rejected on the way back in — the same write/read asymmetry this release is closing, in the engine's own validator. All three copies of the effect vocabulary (reference integrity, the shape pass, the simulator's error message) now read from ALL_EFFECT_TYPES so the list cannot drift again. Because the effect was unreachable, the references it names had never been validated either: dependencyId now has to resolve to a declared Dependency, operation has to be one that dependency declares, and resultPath answers to the same scope rules as a set_state target (declared on or shared into the surface, never a derived path). This can only widen what is accepted — an invoke_operation could not previously pass validation at all — so nothing that used to work can start failing.

Changed

  • Validation now rejects malformed rule / invariant / effect / scenario SHAPES. apply_batch ops are an untyped record, so nothing checked the vocabulary a condition actually used: an unknown operator (gte, >=) fell through the evaluator to false, a binary operator with no right compared against undefined, {kind:"all", conditions:[]} was vacuously true, and an unknown composite kind was silently treated as a leaf. Each turned a guard the author wrote into one that could never fire, while dryRun reported valid:true. The write gate now deep-validates: operators against the runtime vocabulary, condition-tree node shapes (composites, quantifiers, leaves), per-type required effect fields, outcome kinds, and scenario expectedStatus / assertion / step shapes. Dry-run and commit run exactly the same checks. This is not a breaking change for existing stores: every write path gates on introducedValidationErrors (diff-aware), never on validateFeature directly, so a pre-existing malformed element blocks neither unrelated edits nor edits to the offending element itself — which is what keeps a spec repairable. Only NEW breakage is refused, and a brand-new feature must be clean. Tests pin all four of those properties. What does change is visibility: verify / score_feature / get_spec_gaps now report these, so expect a one-time crop of findings on an older store. Run unspa check to see them; each error names the field and the valid values.
  • A dangling project-library reference is a per-feature error, never a lost feature. A entityRefs / resourceRefs / personaRefs entry the owning project cannot satisfy (the definition was removed, or the feature belongs to no project) is reported by validation with the fix spelled out — re-add it, unlink_project_definition, or add_feature_to_project. The feature still loads and still appears in list_features, and list_project_library reports every dangling ref with the feature that holds it. Degrading one feature is always preferable to failing a query batch.
  • Snapshot shape: project.entities / .resources / .personas and feature.entityRefs / .resourceRefs / .personaRefs are new, all optional and additive. A snapshot written by 0.14.0 that uses NO references is byte-identical in shape to one written by 0.13.0, so upgrading alone rewrites nothing. The one-way door is per-project and opt-in: once you run promote_to_project_library, that project is no longer readable by an older engine (0.13 ignores the refs, so the promoted definitions simply vanish from the feature). Malformed ref arrays are dropped on read rather than reaching the resolver.

Verified against real data

The blocked-action rollback is a semantic change, so it was measured rather than argued. Both engines (0.13 and 0.14) were run over 133 real feature snapshots — every action, from its default snapshot and from each authored scenario's starting state — and their results diffed: 2155 simulated runs, 1350 of which blocked, 0 status changes, 0 verdict changes, and exactly 2 differences in resulting state. Both were the leak being fixed: one action wrote a rejected fileName / byteLength into state despite a validation rule blocking with "a source file is already stored", and one applied gravity to a player who was blocked out of the world. No sample or seed feature relies on state landing before a block.