Repository navigation
Conversation
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
The engine accepts invalid project data and either falls back silently or fails later, when the affected line runs:
screenrenders at 1920x1080 (createBackgroundColorElementand the background centering insrc/stores/constructRenderState.js). Zero, negative, fractional,NaNand string sizes are accepted, as are unknown top-level andscreenkeys.initialLineIdputs the read pointer on a line that does not exist (createInitialStateinsrc/stores/system.store.js). An unknowninitialSceneIdorinitialSectionIdthrows an uncodedTypeError, and a line without anidis accepted.dispatchStoreActionlooks up the action with a truthy member lookup (if (!_systemStore[actionType]) return;), so authored keys reach store members that are not actions.toStringandselectRenderStateare invoked,appendPendingEffectqueues an effect, and__proto__throws "is not a function", but only when the line is entered.nulland string action payloads are accepted.__proto__keyboard binding (valid JSON) is dropped:toRenderStateKeyboardassignsrenderStateKeyboard[key] = ..., which sets the object's prototype instead of adding a binding, and when another binding is present, the render-state keyboard object ends up with a non-standard prototype. In the hotkeys-js version route-graphics ships, aconstructorbinding resolves to an inherited member of the key-code tables, so it can never fire. The engine already treats__proto__,constructorandprototypeas reserved elsewhere:getValueByPath(src/util.js) andassertSafeMapId(src/l10n.js) refuse all three, andvalidateComputedVariableConfigs(src/util.js) refuses a__proto__variable ID.Tools that need one answer to "is this project valid?" cannot get it today: authoring-tool exports, CI checks of project files, and other runtimes that must agree with this engine.
Design
createRouteEngine({ handlePendingEffects, validation: "strict" }). Leaving it out, or passing"default", keeps today's behavior. Any other value throws aTypeErrorat creation, so a typo cannot silently turn strict mode off. The option sits besiderandomSourcebecause it applies to everyinitandupdateProjectDataof the engine.initvalidates the canonicalinitialState.projectData(before L10n resolution) and scansinitialState.l10nData.updateProjectDatavalidatespayload.projectDatain the same way. Both checks run on thestructuredClonethe engine already makes, and before any state changes, so a failed strictinitor update leaves the running generation untouched, as other failed initializations already do.Errorwith two fields that hosts can rely on:codeandpath.pathis an RFC 6901 JSON Pointer intoinitialStateor into theupdateProjectDatapayload, for example/projectData/story/scenes/s/sections/a/lines/1/actions/teleport. This follows the existingrouting_cycleconvention: identify the failure bycode, not by message text.lone_surrogate: an unpaired surrogate in a string value or an object key.reserved_key: an object key__proto__,constructororprototype, anywhere in the document.unknown_field: a top-level key other thanconfig,screen,resourcesandstory, or ascreenkey other thanwidth,heightandbackgroundColor.missing_field: a required envelope or story field is absent, such asscreen, its size, the story structure, a lineid, or the initial scene'sinitialSectionId.invalid_value: a value has the wrong type or range, such as a screen size that is not a positive integer, a non-objectresources, scene, section, line,actionsmap or action payload, an emptylinesarray, or a cyclic reference.unknown_reference:initialSceneId,initialSectionIdorinitialLineIdnames nothing.unknown_action: an action name outside the authored vocabulary.presentationActions.yamlandsystemActions.yaml, which are exactly the line action properties ofstory.yaml. It also includesaddViewedLine,addViewedResourceandupdateProjectData, whichdocs/RouteEngine.mddocuments as public but the schemas do not list. Internal store actions and selectors are rejected. A test keeps the list in sync with the schemas.actionsmap, including nestedconditionalbranches and weightedrandomoutcomes. Templated action maps in resources are out of scope for this PR (see Follow-ups).Compatibility
normalizeValidationMode(undefined)and two branches that are never taken.validationomitted. It recorded the effects, system state and render state afterinitand two to four follow-up actions. The results were identical for all 231 VT spec projects and for 9 invalid projects built from the strict-mode cases.validation: "strict"matched v1.48.0 on 230 of the 231 VT projects. The remaining project is one that strict mode rejects (see Tests). The project built from the RouteVN Creator default template initializes in both modes.validationvalue other than"default"or"strict"now throws, where it used to be ignored. Older engine versions ignorevalidation: "strict"entirely, so hosts that depend on strict checks should require the release that adds it.initandupdateProjectData. On a synthetic 7.5 MB project with 50,000 lines, that is about 150 ms on Node 24, roughly the cost of thestructuredClonethe engine already performs. The minified bundle grows by 6.3 KB (1.9 KB gzip).Tests
New file
spec/RouteEngine.strictValidation.test.js(80 tests, synthetic projects only):Option handling: omitted,
"default"and"strict"are accepted;"Strict","lenient",true,nulland1throw aTypeError.30 invalid inputs. Each one is rejected in strict mode with the exact
codeandpath, and each one is still accepted byinitin the default mode. The inputs:~0/~1escaping, and a surrogate in L10n data;__proto__andconstructorkeyboard bindings, aprototypekey in layout data, and__proto__as an action name;NaNsizes, and an unknown screen field;resources, an unknowninitialLineId, a line without anid, and a section without lines;toString), selector and internal-store action names, and unknown names inside a conditional branch and a weighted random outcome;nulland string payloads, and anullactions map.The default-mode fallbacks that strict mode replaces:
An unknown initial scene and a cyclic reference already throw without a code in the default mode; strict mode reports
unknown_referenceandinvalid_valuewith a path.Strict mode accepts well-formed surrogate pairs, look-alike keys (
Constructor,toString) and shared, non-cyclic references.A failed strict re-initialization keeps the previous generation. A failed strict
updateProjectDataleaves the state unchanged, and the default mode accepts the same updates.Fixtures:
story.yamllists exactly the schema actions.init, asvt/static/main.jsloads them. Every one passes in the default mode, and all but one pass in strict mode. The exception isvt/specs/complete/one.yaml, which still authorsnextConfig, an action removed in the v0.1.0 rewrite (8ab56fd) that the default mode skips without an error. The test asserts this rejection exactly.actions) is accepted.Commands, run one after another:
bun run lint,bun run check:test-markers, Prettier on the changed files, andbun run test(101 files, 2,446 passed).node esbuild.jsbuilds. VT screenshots were not re-run, because no VT spec and no default-mode behavior changed.As a one-off check that was not committed, making strict the default left 2,358 of the 2,366 existing tests passing. All 8 failures feed deliberately invalid or internal-only input:
{}ornull.updateProjectDatawith a non-object payload to force a failing state action.unknown_reference.appendPendingEffectin story lines.Follow-ups (documented, not implemented here)
Unknown fields and payload types everywhere. The schemas already say
additionalProperties: false, but at runtime the generated standalone validators (src/generated/l10nPayloadValidators.js, fromscripts/generate-l10n-payload-validators.js) only check L10n patches. Generating theprojectDataand system-action validators the same way and running them in strict mode would also cover numeric resource IDs, string volumes and unknown payload fields. This first needs the schema'srequiredlists reconciled with the runtime defaults:projectData.yaml. The largest groups are scene and sectionname(307 errors), text-stylefontWeightandfontStyle(68 each), and variablescope(8).dialogue.layoutId,sectionTransition.sceneId, ascenedIdtypo,endReplay,pushOverlay.resourceType,characters.*.sprites, and numeric dialogue text.Action names outside story lines. These maps are not yet checked:
keyboardandkeyupbindings (resources.yamlaroundkeyboard:,actions:,payload:);events(presentationActions.yaml, choiceitems.events);submitActionsandcancelActions;showConfirmDialog.confirmActionsandcancelActions;submitForm.actionsandcancelForm.actions(systemActions.yaml).Some of these maps are templated, so load-time checks must allow template keys. The dispatch-time hole stays open:
dispatchStoreAction's truthy lookup (src/RouteEngine.js,if (!_systemStore[actionType]) return;) could reject members that are not own action functions, withunknown_action, in strict mode.JSON-safe save records.
saveSlotbuilds{ formatVersion, slotId, savedAt, image: thumbnailImage, state }(src/stores/system.store.js,saveSlot), andcreateIndexedDbPersistencestores it by structured clone (store.put(nextRecord)insrc/indexedDbPersistence.js). Own-undefinedmembers therefore survive in IndexedDB but disappear in any JSON backend. Examples areimagewhen there is no thumbnail, an unset BGM resource ID, andundefinedmembers of authored objects in contexts. Proposal: specify the slot record as JSON data and, in strict mode, rejectundefinedand non-finite values when a slot is written, with a path into the slot.Persisted global state.
initialState.global(variables, runtime,saveSlotsand the account registries) is not scanned. Lone surrogates and reserved keys can still enter through it.Reference integrity at load. Section, line, resource and variable targets named by actions (for example
jumpToLine,sectionTransition, andupdateVariablevariableId) are still only checked when the action runs.Schema gap.
addViewedLine,addViewedResourceandupdateProjectDataare documented public actions, anddocs/MusicBox.mdauthorsaddViewedResource, butsystemActions.yamlandstory.yamldo not list them. Adding their schemas would let the strict action list come from the schemas alone.Dead fixture action.
vt/specs/complete/one.yamlline 1663 still authorsnextConfig. Migrating it tosetNextLineConfigwould change that VT reference, so this PR leaves it alone and only asserts its strict rejection.Possible later work.
validateProjectData(projectData, { validation: "strict" })export for authoring tools.