69e77d6823
The audit could not start because the Nix toolchain shipped no wasm32 std and there is no rustup here. Adding targets = ["wasm32-unknown-unknown"] to the flake's rustToolchain was the whole blocker: all four bundle crates — improvise-core, improvise-formula, improvise-io, improvise-command — then check clean on the first try with no feature flags and no source changes. The root improvise crate does not build for wasm because crossterm does not. That is correct: it is the terminal binary, not part of the browser bundle, so the CI job names the four bundle crates rather than checking the workspace. Adds .github/workflows/ci.yml with that wasm job plus a host job running fmt, clippy -D warnings and the test suite — there was no test or lint CI before, only cargo-dist's release workflow. A check gate cannot catch what merely panics at runtime, and the audit's real finding was that two of the ticket's six named hazards are real while four are not. Instant::now() panics on wasm yet compiles, so improvise-command now uses web-time, which re-exports std::time off-wasm. std::fs likewise compiles and then fails in a browser; StartImportWizard and LoadModel still bypass the Storage trait, filed as improvise-fuj because the fix needs a decision on how a wasm build injects its backend. Verified by compilation on both targets and by the native suite. Nothing here has been run on a wasm host. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V69PGQfMJuGRmtSWpfVAqn
209 lines
19 KiB
Prolog
209 lines
19 KiB
Prolog
sleep(A) :-
|
|
wasm_generic:host_rpc(sleep(A)).
|
|
delay(A) :-
|
|
wasm_generic:host_rpc(delay(A)).
|
|
console_log(A) :-
|
|
wasm_generic:host_rpc(console_log(A)).
|
|
js_eval(A,B) :-
|
|
wasm_generic:host_rpc(js_eval(A,B)).
|
|
js_eval_json(A,B) :-
|
|
wasm_generic:host_rpc(js_eval_json(A,B)).
|
|
future(A,B) :-
|
|
wasm_generic:host_rpc(future(A,B)).
|
|
await_any(A,B,C) :-
|
|
wasm_generic:host_rpc(await_any(A,B,C)).
|
|
future_cancel(A) :-
|
|
wasm_generic:host_rpc(future_cancel(A)).
|
|
crate(improvise_core,'crates/improvise-core','Model, View, Workbook, formatting; no UI/IO').
|
|
crate(improvise_formula,'crates/improvise-formula','formula parser, AST, parse_formula').
|
|
crate(improvise_io,'crates/improvise-io','improv save/load, CSV/JSON import; no UI/commands').
|
|
crate(improvise_command,'crates/improvise-command','command/effect layer + semantic App state; no ratatui').
|
|
crate(improvise_main,src,'ratatui TUI rendering, draw loop, clap CLI').
|
|
layer(model,improvise_core).
|
|
layer(view,improvise_core).
|
|
layer(formula,improvise_formula).
|
|
layer(io,improvise_io).
|
|
layer(command,improvise_command).
|
|
layer(rendering,improvise_main).
|
|
forbidden(improvise_core,ui).
|
|
forbidden(improvise_core,io).
|
|
forbidden(improvise_io,ui).
|
|
forbidden(improvise_io,commands).
|
|
forbidden(improvise_command,ratatui).
|
|
forbidden(improvise_command,crossterm).
|
|
location(add_keybinding,'crates/improvise-command/src/command/keymap.rs','default_keymaps()').
|
|
location(add_command,'crates/improvise-command/src/command/cmd/registry.rs','default_registry()').
|
|
location(add_effect,'crates/improvise-command/src/ui/effect.rs','impl Effect').
|
|
location(formula_eval,'crates/improvise-core/src/model/types.rs','eval_formula / eval_expr').
|
|
location(cell_storage,'crates/improvise-core/src/model/cell.rs','DataStore').
|
|
location(category_behavior,'crates/improvise-core/src/model/category.rs','Category').
|
|
location(view_axis_logic,'crates/improvise-core/src/view/types.rs','View').
|
|
location(grid_layout,'crates/improvise-core/src/view/layout.rs','GridLayout').
|
|
location(improv_format,'crates/improvise-io/src/persistence/improv.pest','PEG grammar, source of truth').
|
|
location(number_display,'crates/improvise-core/src/format.rs',format_f64).
|
|
location(cli_args,'src/main.rs',clap).
|
|
location(import_logic,'crates/improvise-io/src/import/wizard.rs','ImportPipeline').
|
|
location(frame_layout,'src/draw.rs','draw()').
|
|
location(app_state_modes,'crates/improvise-command/src/ui/app.rs','App, AppMode').
|
|
location(wizard_steps,'crates/improvise-io/src/import/wizard.rs','ImportWizard advance/previous/build/validation_error').
|
|
location(wizard_keys,'crates/improvise-command/src/command/cmd/wizard.rs','step_effects per-step key tables').
|
|
location(wizard_render,'src/ui/import_wizard_ui.rs','ImportWizardWidget').
|
|
location(save_effect,'crates/improvise-command/src/ui/effect.rs','Save, save_to_current_path').
|
|
location(abort_chain,'crates/improvise-command/src/ui/effect.rs','AbortChain').
|
|
location(apply_effects,'crates/improvise-command/src/ui/app.rs','App::apply_effects').
|
|
location(sequence_dispatch,'crates/improvise-command/src/command/keymap.rs','Keymap::dispatch Binding::Sequence').
|
|
pipeline_step(1,keypress,keymap_lookup).
|
|
pipeline_step(2,keymap_lookup,cmd_execute).
|
|
pipeline_step(3,cmd_execute,effect_list).
|
|
pipeline_step(4,effect_list,effect_apply).
|
|
pure(cmd_execute).
|
|
mutating(effect_apply).
|
|
only_mutation_site('Effect::apply(&mut App)').
|
|
invariant(cellkey_sorted,'CellKey coords always sorted by category; build via CellKey::new/with/without').
|
|
invariant(display_rounding,'format_f64 is render-only; never feed rounded values back into eval').
|
|
invariant(fixedpoint_eval,'recompute_formulas iterates to stability; circular -> CellValue::Error(\"circular\")').
|
|
invariant(indexmap_order,'IndexMap insertion order is load-bearing for persistence').
|
|
invariant(no_model_add_item,'No Model::add_item; use Workbook facade or category_mut().add_item()').
|
|
invariant(stale_layout,'app.layout rebuilds only after a whole effect batch; prepend RebuildLayout when needed').
|
|
invariant(wizard_back_is_history_stack,'advance pushes the step it leaves onto ImportWizard::history; previous pops it, so back retraces the path actually taken through the conditional transitions rather than re-deriving a reverse table').
|
|
invariant(wizard_validate_where_fixable,'preconditions live in validation_error() and are checked on the step where the user can still act -- never deferred to build_model after the final confirm').
|
|
invariant(wizard_done_is_confirm,'NameModel is terminal for advance; Enter there calls build(), which parks the workbook in ImportWizard::built on Done. AcceptWizardModel takes it. previous() from Done discards built').
|
|
invariant(failed_save_aborts_batch,'Save sets app.abort_effects on failure (no file path, or write error), so effects chained after it -- the Quit in wq/ZZ -- are skipped and the error stays on screen').
|
|
invariant(sequence_one_batch,'Binding::Sequence flattens all step effects into one Vec applied by a single apply_effects call, so abort_effects carries across sequence steps').
|
|
invariant(minibuffer_spine,'Seven text-entry modes built by TextEntry builder in keymap.rs; SearchMode hand-written').
|
|
doctrine(bug_first,'write a failing test demonstrating the bug before fixing it').
|
|
doctrine(unit_over_e2e,'prefer small unit tests over integration/e2e').
|
|
doctrine(suite_speed,'workspace suite must stay under 2 seconds').
|
|
doctrine(coverage,'aim ~80% line/branch on logic; skip ratatui render paths').
|
|
doctrine(test_contract,'test the observable contract, not the internal storage').
|
|
doctrine(proptest_cases,'keep proptest at default 256 cases; constrain strategies instead of brute force').
|
|
tracker(beads,bd,'bd ready / bd show / bd update --claim / bd close; no TodoWrite or markdown TODOs').
|
|
session_close(1,'file issues for remaining work').
|
|
session_close(2,'run quality gates if code changed').
|
|
session_close(3,'update issue status').
|
|
session_close(4,'git merge origin/main, bd dolt push, git push').
|
|
session_close(5,'clean up stashes and pruned branches').
|
|
session_close(6,'verify committed AND pushed').
|
|
session_close(7,'hand off context').
|
|
crate_of_layer(A,B) :-
|
|
layer(A,B).
|
|
may_depend_on(A,B) :-
|
|
crate(A,C,D), \+forbidden(A,B).
|
|
where(A,B,C) :-
|
|
location(A,B,C).
|
|
before_fixing(A,B) :-
|
|
A=C,doctrine(bug_first,B).
|
|
|
|
|
|
work_done('improvise-8tk',bug_fix,'ZZ/:wq quit even when the save failed, silently discarding the model. Save now aborts the effect batch on failure; save_to_current_path/1 returns Result<String,String>; three tests in ui/effect.rs.').
|
|
work_followup('improvise-8tk','improvise-lae','no-file-path UX: prompt for a path rather than only erroring. ExportPrompt is CSV-only, so a new SavePrompt TextEntry mode committing to SaveAs is the likely shape.').
|
|
work_unblocked('improvise-8tk','improvise-git','its stated blocker -- a Sequence has no way to stop between steps -- does not hold: abort_effects carries across a flattened Sequence.').
|
|
work_done('improvise-907',bug_fix,'Import wizard step machine redesign: validation_error() gate at ReviewProposals (no-category and over-limit), history-stack previous() bound to Left on every step, and Done made a real confirmation step holding the built workbook. Superseded improvise-gfq, improvise-o1c, improvise-c6j.').
|
|
decision('improvise-907',done_step,'chose to make Done a real confirm step rather than dropping the variant -- user decision. Warnings from build_model_with_warnings now render in the wizard panel instead of being crammed into a one-line status bar.').
|
|
decision('improvise-907',back_key,'Left is the back key on every step. Backspace is taken by the name buffer and the formula buffer; h is a printable character NameModel needs. Left is the only key free everywhere.').
|
|
work_done('improvise-6sq',wont_do,'Merging EditOrDrill and EnterEditAtCursorCmd into one EnterEdit{mode} is not safe: they differ in WHEN the cell value is read, not in a mode parameter. Added ui/app.rs::tests::i_and_o_diverge_on_an_empty_records_grid as executable evidence.').
|
|
decision('improvise-6sq',no_merge,'EditOrDrill reads ctx.display_value at Cmd time; EnterEditAtCursorCmd emits RebuildLayout then EnterEditAtCursor, which re-reads display_value_at_cursor after the rebuild. A merged command needs a rebuild_first/skip_drill flag -- the same fork renamed.').
|
|
invariant(records_mode_all_none,'ToggleRecordsMode puts every regular category on Axis::None, so EditOrDrill regular_none is always true in records mode and the edit branch depends entirely on is_synthetic').
|
|
work_followup('improvise-6sq','improvise-ct5','i on an empty records grid reports \'cannot drill\', leaking a pivot-mode concept into records mode').
|
|
work_done('improvise-k8h',refactor,'TextEntry::new/with_parent now take the AppMode and read the buffer name off its MinibufferConfig, so the keymap no longer spells a buffer name. Added two guard tests in keymap.rs pinning mode<->keymap agreement and the completeness of the text-entry mode list.').
|
|
invariant(buffer_name_single_source,'the buffer a text-entry mode types into is named once, in that mode AppMode constructor MinibufferConfig; TextEntry reads it off the mode so the prompt and the keystrokes cannot address different buffers').
|
|
location(text_entry_builder,'crates/improvise-command/src/command/keymap.rs','TextEntry, buffer_of').
|
|
work_done('improvise-4pg',refactor,'Extracted PanelKeymap builder for the three panel modes, following the TextEntry precedent. Panel::ALL/name()/key() drive the toggle keys for Normal and all three panels. Verified by diffing all 4065 (mode,key,modifier) lookups before and after: identical.').
|
|
invariant(panel_keymap_spine,'the three panel modes share a spine built by PanelKeymap::new(panel): Esc/Tab to Normal, arrows and j/k to move-panel-cursor for that panel, every panel toggle key, and : for command mode. Content keys come through chained .bind/.bind_args/.delete because they vary in shape not just command name').
|
|
invariant(panel_name_matches_parser,'Panel::name() must spell what parse_panel in cmd/core.rs accepts; pinned by panel_toggle_keys_dispatch_to_real_effects, since dispatch swallows an unresolvable command with .ok()?').
|
|
doctrine(binding_dump_diff,'when refactoring keymap construction, dump every (mode,key,modifier) lookup for all 16 modes before and after and diff: most bindings have no direct test, so a green suite is not evidence').
|
|
location(panel_keymap_builder,'crates/improvise-command/src/command/keymap.rs','PanelKeymap, bind_panel_toggles').
|
|
|
|
% ── Command-registry name-drift model (improvise-9cn / improvise-61f) ───────
|
|
% How a registration gets the name it files a command under.
|
|
name_source_kind(prototype_instance, safe, 'Cmd::name() is read off a real instance; the compiler fixes the type').
|
|
name_source_kind(literal_string, unsafe, 'a hand-written string with nothing tying it to the type the closure builds').
|
|
|
|
% A command whose name() branches on a field: prototype and constructed value
|
|
% share a TYPE but may differ in VARIANT, which no type check reaches.
|
|
variant_named_cmd('ViewNavigate', forward).
|
|
variant_named_cmd('TileAxisOp', axis).
|
|
variant_named_cmd('ToggleGroupAtCursor', is_row).
|
|
variant_named_cmd('CommitAndAdvance', advance).
|
|
|
|
% What each defence actually reaches.
|
|
catches(prototype_instance, typo_in_name).
|
|
catches(uniqueness_test, wrong_variant_prototype).
|
|
catches(name_roundtrip_test, constructor_builds_other_name).
|
|
catches(bound_names_test, keymap_references_missing_command).
|
|
|
|
% A wrong-variant prototype does NOT surface as a name mismatch: it collides
|
|
% with its sibling, so every surviving entry still agrees with its own name
|
|
% and the sibling's name disappears. Uniqueness is the check that reaches it.
|
|
insufficient_for(name_roundtrip_test, wrong_variant_prototype).
|
|
|
|
needs_runtime_check(T) :- variant_named_cmd(T, _).
|
|
|
|
% ── Records mode: wide format (improvise-c5v) ───────────────────────────────
|
|
record_row_identity(wide, coord_group_excluding_measure).
|
|
record_column(wide, category_col, rename_coordinate, all_cells_in_row).
|
|
record_column(wide, measure_col, write_cell_value, one_cell).
|
|
record_column(wide, new_measure_col, create_measure, no_cells).
|
|
% Both edit paths must implement every column kind.
|
|
record_edit_path(plain_records).
|
|
record_edit_path(drill_pending).
|
|
record_obligation(P,C,A,S) :- record_edit_path(P), record_column(wide,C,A,S).
|
|
% The case types cannot reach: a category edit spans the whole row.
|
|
invariant(records_rename_spans_row,'editing a category column in wide records mode moves every measure cell in the row; moving only the cell under the cursor would tear the row apart').
|
|
invariant(records_dim_is_column_index,'the synthetic records CellKey stores the column INDEX in _Dim, not a label, because a category and a measure item may share a name; resolve via GridLayout::record_col').
|
|
location(records_layout,'crates/improvise-core/src/view/layout.rs','RecordRow, RecordCol, build_records_mode, records_display').
|
|
|
|
% ── Persistence storage abstraction (improvise-6mq) ─────────────────────────
|
|
storage_op(read, 'whole document in').
|
|
storage_op(write, 'whole document out').
|
|
storage_backend(physical_fs, native, std_fs).
|
|
storage_backend(memory_fs, tests, hashmap).
|
|
invariant(storage_is_two_ops,'persistence only ever reads or writes one whole document, so the Storage trait is read/write bytes and nothing else; a backend that is not a filesystem implements it without directories, metadata or seeking').
|
|
invariant(gzip_is_a_name_property,'the .gz suffix selects gzip in save_to/load_from, so it is a property of the document name and not of the storage backend').
|
|
decision('improvise-6mq',narrow_trait_over_vfs_crate,'chose Option B (own two-method trait) over the ticket-recommended Option A (vfs crate): the real surface is read/write one document, and the ticket own con for A -- sync-only while OPFS is async -- means adopting A would mean doing B later anyway').
|
|
location(storage_trait,'crates/improvise-io/src/storage.rs','Storage, PhysicalFs, MemoryFs').
|
|
work_done('improvise-6mq',refactor,'persistence save/load routed through a Storage trait; save/load kept as PhysicalFs wrappers so all 83 persistence tests pass unchanged; MemoryFs gives a filesystem-free test fixture.').
|
|
|
|
% ── Effect classification for projection (improvise-mae) ────────────────────
|
|
effect_kind_class(view_only,'never projected to other clients').
|
|
effect_kind_class(cell_data,'cell values, categories, items').
|
|
effect_kind_class(layout_geometry,'axis, page, hidden, grouping, format, active view').
|
|
effect_kind_class(whole_workbook,'workbook replaced wholesale').
|
|
invariant(effect_kind_no_default,'Effect::kind has no default impl, so a new effect cannot forget to classify itself; the failure mode would be silent (a mis-declared ViewOnly edit never reaches connected clients)').
|
|
invariant(view_lives_in_model_state,'the View is stored inside model_state.workbook, so touching model_state does NOT imply a model change: axis/page/format are layout_geometry, cursor and scroll are per-client view_only, Save/SaveAs/MarkDirty are bookkeeping').
|
|
decision('improvise-mae',cursor_is_per_client,'SetSelected/SetRowOffset/SetColOffset classed ViewOnly even though they write the shared persisted View, because one client scrolling must not move another viewport; the residual inconsistency is filed as improvise-8uz').
|
|
doctrine(derive_then_verify,'when classifying many items from source, extract facts mechanically and reason in Prolog, but VERIFY the extraction: multi-line method chains and free-function helpers defeated naive regexes and mis-classified SetCell, SortData and ApplyAndClearDrill as view-only').
|
|
location(effect_kind,'crates/improvise-command/src/ui/effect.rs','EffectKind, ModelChange, Effect::kind').
|
|
work_done('improvise-mae',refactor,'all 58 effects classified as ViewOnly or Model(CellData|LayoutGeometry|WholeWorkbook); kind() has no default so the compiler enforces completeness; three tests verify declarations empirically by applying effects and fingerprinting shared state.').
|
|
|
|
% ── Unified client/server architecture: what actually crosses the wire ──────
|
|
wire_carries(command, both_directions, 'user intent up, projection commands down').
|
|
derived_by_fold(view_state, command_stream, reduce_view).
|
|
derived_by_fold(render_cache, projection_commands, reduce_view).
|
|
% The ONE exception: a client that never saw the stream needs a seed.
|
|
wire_carries(snapshot, on_connect_only, 'improvise-q08: full ViewState + render cache filled from current viewport').
|
|
invariant(view_state_is_a_projection,'the server does NOT serialize ViewState to maintain a client mirror -- it applies the same commands through reduce_view (improvise-cqq). Steady-state traffic is Commands only; ViewState is serialized solely for the bootstrap Snapshot').
|
|
invariant(bootstrap_excludes_host_state,'because the only serialization point is the on-connect Snapshot, host-only state must not live in ViewState: a fresh client should not inherit a half-open import wizard, an armed prefix key, or another session staged drill edits').
|
|
invariant(prefix_is_an_address,'ViewState::transient_prefix stores the (KeyCode,KeyModifiers) that opened a prefix; KeymapSet::prefix_keymap resolves it, walking parents. Assumes prefixes are FLAT -- guarded by prefix_keymaps_are_flat').
|
|
decision('improvise-jc8',flip_the_arc,'Arc<Keymap> moved out of ViewState and back into KeymapSet, which already owns every keymap. Motivated by a hard dependency cycle -- Keymap is in improvise-command, which will depend on improvise-protocol once reduce_full lives there -- not by serde.').
|
|
work_done('improvise-jc8',refactor,'ViewState no longer holds Arc<Keymap>; four characterization tests written before the change pinned gg/ZZ/prefix-consumption/records-inheritance, plus two new guards for flatness and parent resolution.').
|
|
location(prefix_resolution,'crates/improvise-command/src/command/keymap.rs','KeymapSet::prefix_keymap, SetTransientPrefix').
|
|
|
|
% ── Wasm compatibility (improvise-ywd, audited 2026-08-30) ──────────────────
|
|
wasm_bundle_crate('improvise-core').
|
|
wasm_bundle_crate('improvise-formula').
|
|
wasm_bundle_crate('improvise-io').
|
|
wasm_bundle_crate('improvise-command').
|
|
not_in_wasm_bundle('improvise', 'terminal binary; depends on crossterm').
|
|
wasm_checks_clean(all_bundle_crates, no_feature_flags_needed).
|
|
% Hazards a compile check cannot see.
|
|
wasm_runtime_hazard(instant_now, 'panics on wasm32-unknown-unknown', fixed_with(web_time)).
|
|
wasm_runtime_hazard(std_fs, 'compiles on wasm but fails in a browser', tracked_as('improvise-fuj')).
|
|
wasm_non_hazard(dirs, 'declared in root Cargo.toml but used nowhere').
|
|
wasm_non_hazard(chrono_local_now, 'never called').
|
|
wasm_non_hazard(std_env, 'only in a test').
|
|
wasm_non_hazard(flate2, 'compiles for wasm as-is').
|
|
invariant(compile_check_is_not_runtime_proof,'the wasm CI gate is cargo check, so it catches nothing that merely panics at runtime: Instant and std::fs both COMPILE for wasm32-unknown-unknown').
|
|
work_done('improvise-ywd',audit,'added wasm32-unknown-unknown to the flake toolchain, which was the real blocker; all four bundle crates then checked clean with zero source changes. Swapped Instant for web_time and added the CI wasm job.').
|
|
location(wasm_ci,'.github/workflows/ci.yml','wasm job').
|