diff --git a/content/guides/editor.md b/content/guides/editor.md index 4f556073..54ed9046 100644 --- a/content/guides/editor.md +++ b/content/guides/editor.md @@ -157,9 +157,14 @@ actual size. Named buttons open the target file. **Run impact on this page** fills the impact field and runs the same `boris impact` command as the Problems pane. -`Ctrl+K` also jumps to an entity by id or title (`Go to guides/intro`). +**Export graph** runs the fixed `boris graph` command (Mermaid by default, +Graphviz DOT as the documented second option) and offers copy or download of +that document. The map is not a second layout engine. `Ctrl+K` also jumps to +an entity by id or title (`Go to guides/intro`). -If diagnostics have not been built yet, the pane says so instead of guessing. +If diagnostics have not been built yet, the pane says so instead of guessing, +and Export graph stays disabled until **Build diagnostics** has produced a +graph. - [[guides/building-pages|Building and writing pages]] — parent, children, and wiki links - [[reference/relationships|Relationships]] — parent, children, and relations this pane displays @@ -211,9 +216,11 @@ targets. That command does not compile or publish. Local evidence at `dist/_boris/proof/proof-pack.json` is shown when a previous HTML build left a Proof Pack. That pack is target-local presentation. It is -not deployment verification. GitHub Pages deploy stays in the official -Actions workflow; Standard.site publish stays on the CLI. The editor does not -store secrets or run a deployer. +not deployment verification. **Verify proof** runs the fixed +`boris proof verify --html-dir dist` command and surfaces the contracted +stderr verdict plus the compiler exit class. GitHub Pages deploy stays in the +official Actions workflow; Standard.site publish stays on the CLI. The editor +does not store secrets or run a deployer. - [[guides/publishing|Publishing targets]] — profile, plan, and verified targets @@ -224,16 +231,26 @@ store secrets or run a deployer. The Problems pane runs a fixed allowlist of Boris invocations against saved repository files. The UI cannot supply argv or a working directory, and all controls are disabled while the active buffer is dirty (Boris reads files from -disk, not from your buffer). - -| Button | Exact Boris invocation | Reads | -|---|---|---| -| Validate project | `boris validate --input content` | nothing | -| Build diagnostics | `boris build --input content --out .boris` | `build-report.json` | -| Build HTML | `boris build --input content --html-dir dist` | nothing yet (see below) | -| Check graph | `boris check --input content --format json --report .boris/editor-check.json` | Documentation Intelligence report | -| Run impact | `boris impact --input content --format json --report .boris/editor-impact.json` | Documentation Intelligence report | -| Run publication plan | `boris plan --profile PATH` | stdout `boris-publication-plan` | +disk, not from your buffer). The table below is the standing CLI to editor +capability matrix — the map for what is in the editor, what is still missing, +and what is a deliberate non-goal. The developer copy lives in the +[editor README](https://github.com/drawmeanelephant/boris/blob/main/editor/README.md#cli-to-editor-capability-matrix). + +| Button | Exact Boris invocation | Status | Reads | +|---|---|---|---| +| Validate project | `boris validate --input content` | done | HTML build report (daemon when `--watch` exists) | +| Build diagnostics | `boris build --input content --out .boris` | done | `build-report.json` | +| Build HTML | `boris build --input content --html-dir dist` | done | `html-build-report.json` | +| Check graph | `boris check --input content --format json --report .boris/editor-check.json` | done | Documentation Intelligence report | +| Run impact | `boris impact --input content --format json --report .boris/editor-impact.json` | done | Documentation Intelligence report | +| Run publication plan | `boris plan --profile PATH` | done | stdout `boris-publication-plan` | +| Scale recipe | `boris recipe-scale --input content --id ID --factor FACTOR` | done | stdout `boris-recipe-scale` | +| Export graph | `boris graph --input content --format mermaid\|dot` | done | stdout Mermaid or DOT | +| Verify proof | `boris proof verify --html-dir dist` | done | contracted stderr verdict | +| Watch daemon | `boris watch --input content --html-dir dist --watch-json` | partial | NDJSON event stream | +| IR / RAG / context / llms / rss / sitemap | — | missing | stay on the CLI | +| `standard-site *` / `nostr *` publish or login | — | non-goal | no secrets, no deploy | +| `init`, full build flag matrix, `serve` | — | non-goal | preview is the `serve` fallback | Boris exit codes stay distinct: **1** content/graph failure, **2** usage/configuration failure, **3** I/O/system failure. The editor surfaces the diff --git a/docs/changelog.d/987-editor-cli-surface.md b/docs/changelog.d/987-editor-cli-surface.md new file mode 100644 index 00000000..40f3f695 --- /dev/null +++ b/docs/changelog.d/987-editor-cli-surface.md @@ -0,0 +1,3 @@ +### Added + +- The editor now documents a CLI to editor capability matrix and allowlists two more read-only Boris commands: **Export graph** (`boris graph`, Mermaid by default or Graphviz DOT) and **Verify proof** (`boris proof verify --html-dir dist`), while conflict compare panes open at the top of both columns. Links: [the editor-host contract](/docs/contracts/editor-host.md), [the editor README matrix](/editor/README.md#cli-to-editor-capability-matrix), [#983](https://github.com/drawmeanelephant/boris/issues/983), [#984](https://github.com/drawmeanelephant/boris/issues/984), [#985](https://github.com/drawmeanelephant/boris/issues/985), [#986](https://github.com/drawmeanelephant/boris/issues/986). diff --git a/docs/contracts/editor-host.md b/docs/contracts/editor-host.md index c6af3999..9d61a69c 100644 --- a/docs/contracts/editor-host.md +++ b/docs/contracts/editor-host.md @@ -408,7 +408,7 @@ project-keyed and stable: two projects never share a state root. ### 6.1 Fixed allowlist The UI cannot supply argv or a working directory. `/api/commands/run` accepts -one of seven modes and the host builds the exact command; every child runs with +one of nine modes and the host builds the exact command; every child runs with cwd = project root, a 120 s timeout, and 16 MiB stdout/stderr ceilings. | `mode` | Fixed argv (prefixed by the resolved compiler path) | @@ -420,9 +420,15 @@ cwd = project root, a 120 s timeout, and 16 MiB stdout/stderr ceilings. | `impact` | `impact --input content --format json --report .boris/editor-impact.json` | | `plan` | `plan --profile ` | | `recipe_scale` | `recipe-scale --input content --id --factor ` | +| `graph_export` | `graph --input content --format mermaid\|dot` | +| `proof_verify` | `proof verify --html-dir dist` | + +The status of each CLI surface in the editor (done / partial / missing / +non-goal) lives in the [CLI to editor capability matrix](../../editor/README.md#cli-to-editor-capability-matrix). When project discovery reports `input_mode: cooklang`, `--cooklang` is appended -to every mode except `plan` (mode parity with the one-shot CLI). A timeout or a +to every mode except `plan` and `proof_verify` (mode parity with the one-shot +CLI; proof verify reads committed checks and does not compile). A timeout or a stdout/stderr overrun yields a `terminated` process problem, not an API error. `validate` is served by the managed validation daemon whenever the compiler @@ -437,6 +443,7 @@ which path answered. | `impact_id` | Required for `impact`; forbidden for every other mode. 1–4096 bytes, valid UTF-8, no NUL/CR/LF, and must not start with `-` (so an id can never become an option). | | `profile` | Required for `plan`; forbidden otherwise. Author-owned relative source path (≤1024 bytes), same option-injection guard. | | `recipe_scale_id`, `recipe_scale_factor` | Required for `recipe_scale`; forbidden otherwise. Same guards; factor is trimmed, 1–64 bytes, and must not start with `-`. | +| `graph_format` | Optional for `graph_export` (`mermaid` or `dot`; omitted means `mermaid`); forbidden for every other mode. | Violations are `400 invalid_command_request`. @@ -448,7 +455,8 @@ The result payload is: {"mode":"validate","exit_code":0,"failure_class":"success", "compiler_id":"boris/0.8.2","report_version":"html-build-report-0.2.0", "used_stderr_fallback":false,"problems":[],"findings":[],"impact":[], - "publication_plan":null,"recipe_scale_view":null} + "publication_plan":null,"recipe_scale_view":null, + "graph_document":null,"proof_report":null} ``` - `failure_class` maps the compiler's contracted exit convention — @@ -477,6 +485,13 @@ The result payload is: - `plan` and `recipe_scale` results carry `publication_plan` and `recipe_scale_view` respectively, forwarded as parsed Boris documents, only on a successful exit. +- `graph_export` results carry `graph_document`, the exact Mermaid or DOT + stdout from `boris graph`, only on a successful exit. The host does not + re-layout or rewrite that document. +- `proof_verify` results carry `proof_report`, a cleaned copy of the + contracted stderr verdict. `used_stderr_fallback` stays false: stderr is + the product report, not a compatibility fallback. The command mutates + nothing. A stale artifact must never be mistaken for this run's output: the host deletes the report named in the table above before spawning the child, for every mode diff --git a/editor/README.md b/editor/README.md index eab33d99..217d13c6 100644 --- a/editor/README.md +++ b/editor/README.md @@ -228,11 +228,40 @@ dialogs, conflict comparison, and recovered-state labeling. M2 deliberately does not invoke Boris, parse frontmatter or Markdown, provide completion, autosave, Git integration, diagnostics, or preview. +## CLI to editor capability matrix + +The editor is a closed allowlist of Boris invocations, not a second compiler +or a deploy console. This table is the map for allowlist growth. Wire argv +lives in [`docs/contracts/editor-host.md`](../docs/contracts/editor-host.md) +§6; this table records whether a CLI surface has an editor entrypoint. + +| CLI / surface | Editor entrypoint | Status | Notes | +|---|---|---|---| +| `validate` | Problems → Validate project | done | Served by the validation daemon when the compiler advertises `--watch`. | +| `build --out .boris` | Problems → Build diagnostics | done | Creates `graph.json` / completion for the Graph pane. | +| `build --html-dir dist` | Problems → Build HTML; Preview → Rebuild preview | done | Preview uses the fixed incremental rebuild, not the full flag matrix. | +| `check` | Problems → Check graph | done | | +| `impact` | Problems / Graph → Run impact | done | | +| `plan --profile` | Publication → Run publication plan | done | Declaration only; not a deploy. | +| `recipe-scale` | Recipe pane → Scale recipe | done | Compiler-owned view; the editor does not multiply amounts. | +| `graph` (Mermaid / DOT) | Graph → Export graph | done | Default Mermaid, optional Graphviz DOT. Boris remains the renderer. | +| Graph map of frozen `graph.json` | Graph pane map | related | Visual aid of the last IR graph; not the `boris graph` CLI. | +| `proof verify` | Problems / Publication → Verify proof | done | Read-only; default policy; no credentials. | +| `watch` / Watch daemon | Watch pane | partial | Host-managed start/stop/event stream for the fixed `boris watch --watch-json` argv. Not the full watch flag matrix. | +| IR / RAG / context / llms / rss / sitemap exports | — | missing | Stay on the CLI until a later allowlisted wrapper. | +| `standard-site *` / `nostr *` publish or login | — | non-goal | No secrets and no deploy in the editor. | +| `init` | — | non-goal | Project bootstrap stays a CLI / `boris init` concern. | +| Full `build` flag matrix | — | non-goal | Each mode has one host-fixed argv. | +| `serve` | Preview origin | non-goal | Loopback preview of committed `dist/` is the fallback while compiler-owned `boris serve` is in flight. | + +Statuses are `done`, `partial`, `missing`, or `non-goal`. Non-goals stay +non-goals until a contract change says otherwise. + ## M3 Boris commands and problems The host exposes one authenticated `POST /api/commands/run` endpoint backed by a fixed command allowlist: validate, IR build, HTML build, check, impact, -plan, and recipe-scale. +plan, recipe-scale, graph export, and proof verify. The UI cannot supply argv or a working directory. Commands run against saved repository files, so all controls are disabled while the active buffer is dirty. @@ -459,7 +488,9 @@ and node borders scale with topology (direct children plus incoming references). The SVG is `aria-hidden` — it is a pointer convenience, and the lists remain the keyboard path. The map viewport is a focusable scroll container (wheel, touch, arrow keys), and its zoom controls fit the graph to -the pane width, zoom in and out, or restore actual size. +the pane width, zoom in and out, or restore actual size. **Export graph** +runs the fixed `boris graph` invocation (Mermaid by default, DOT as the +second option) and offers copy or download of that document. The diagnostics integration gate deep-compares `/api/graph` with the real compiler-generated `graph.json`. Playwright covers parent/backlink/wiki-link @@ -472,8 +503,9 @@ model, heading-fragment navigation, or theme/layout diagnostics. ## M7 Cooklang / restaurant authoring A content tree of only `.cook` pages is a Cooklang project. Health reports -`input_mode: cooklang`, and every fixed Boris invocation (validate, IR/HTML -build, check, impact, preview) adds `--cooklang`. Mixed trees stay mixed and +`input_mode: cooklang`, and every fixed Boris invocation that compiles the +tree (validate, IR/HTML build, check, impact, preview, graph export) adds +`--cooklang`. `plan` and `proof verify` do not. Mixed trees stay mixed and fail the way Boris fails; the editor does not guess a dialect. The Recipe pane is a read-only view of the compiler `recipe` facet on diff --git a/editor/scripts/test-diagnostics.sh b/editor/scripts/test-diagnostics.sh index d8299cde..32bfcb9e 100755 --- a/editor/scripts/test-diagnostics.sh +++ b/editor/scripts/test-diagnostics.sh @@ -99,6 +99,20 @@ node -e ' if (r.exit_code !== 0 || r.failure_class !== "success" || r.used_stderr_fallback || !r.report_version || !Array.isArray(r.findings)) throw Error("check artifact adaptation mismatch"); ' "$work/check-valid.json" +run_command "$work/graph-export.json" '{"mode":"graph_export"}' +node -e ' + const r = require(process.argv[1]); + if (r.mode !== "graph_export" || r.exit_code !== 0 || r.failure_class !== "success") throw Error("graph export failed: " + JSON.stringify({mode:r.mode,exit:r.exit_code,cls:r.failure_class})); + if (typeof r.graph_document !== "string" || r.graph_document.indexOf("graph TD") < 0) throw Error("expected mermaid document, got " + JSON.stringify(r.graph_document && r.graph_document.slice(0, 80))); + if (r.used_stderr_fallback) throw Error("graph export should not be a stderr fallback"); +' "$work/graph-export.json" + +run_command "$work/graph-export-dot.json" '{"mode":"graph_export","graph_format":"dot"}' +node -e ' + const r = require(process.argv[1]); + if (r.exit_code !== 0 || typeof r.graph_document !== "string" || r.graph_document.indexOf("digraph") < 0) throw Error("expected DOT document"); +' "$work/graph-export-dot.json" + run_command "$work/impact-missing.json" '{"mode":"impact","impact_id":"does-not-exist"}' node -e ' const r = require(process.argv[1]); @@ -133,4 +147,13 @@ node -e ' ' "$work/html-valid.json" "$work/project/.boris/html-build-report.json" [[ -f "$work/project/dist/index.html" ]] +run_command "$work/proof-verify.json" '{"mode":"proof_verify"}' +node -e ' + const r = require(process.argv[1]); + if (r.mode !== "proof_verify") throw Error("proof verify mode not echoed: " + JSON.stringify(r)); + if (r.used_stderr_fallback) throw Error("proof verify stderr is the contracted report, not a fallback"); + if (typeof r.proof_report !== "string" || r.proof_report.indexOf("proof verify") < 0) throw Error("expected proof verify report, got " + JSON.stringify(r.proof_report)); + if (![0, 1, 3].includes(r.exit_code)) throw Error("unexpected proof verify exit: " + r.exit_code); +' "$work/proof-verify.json" + echo "editor Boris diagnostics integration: ok" diff --git a/editor/src/runner.zig b/editor/src/runner.zig index 0cc599f0..163824df 100644 --- a/editor/src/runner.zig +++ b/editor/src/runner.zig @@ -17,6 +17,8 @@ pub const Mode = enum { impact, plan, recipe_scale, + graph_export, + proof_verify, }; pub const FailureClass = enum { @@ -81,6 +83,8 @@ pub const Result = struct { impact: []ImpactEndpoint, publication_plan: ?std.json.Value = null, recipe_scale_view: ?std.json.Value = null, + graph_document: ?[]const u8 = null, + proof_report: ?[]const u8 = null, }; pub const Config = struct { @@ -95,6 +99,7 @@ pub const Request = struct { profile: ?[]const u8 = null, recipe_scale_id: ?[]const u8 = null, recipe_scale_factor: ?[]const u8 = null, + graph_format: ?[]const u8 = null, }; const check_report_name = "editor-check.json"; @@ -117,6 +122,11 @@ pub fn run(allocator: std.mem.Allocator, io: Io, config: Config, request: Reques if (request.mode != .recipe_scale and (request.recipe_scale_id != null or request.recipe_scale_factor != null)) { return error.UnexpectedRecipeScale; } + if (request.mode == .graph_export) { + try validateGraphFormat(request.graph_format); + } else if (request.graph_format != null) { + return error.UnexpectedGraphFormat; + } try prepareArtifactRoot(io, config.project_root, request.mode); const compiler_id = try readCompilerId(allocator, io, config); @@ -135,6 +145,8 @@ pub fn run(allocator: std.mem.Allocator, io: Io, config: Config, request: Reques var structured_report = false; var publication_plan: ?std.json.Value = null; var recipe_scale_view: ?std.json.Value = null; + var graph_document: ?[]const u8 = null; + var proof_report: ?[]const u8 = null; switch (request.mode) { .ir_build => if (try readGeneratedFile(allocator, io, config.project_root, "build-report.json")) |bytes| { @@ -178,6 +190,15 @@ pub fn run(allocator: std.mem.Allocator, io: Io, config: Config, request: Reques recipe_scale_view = try parseRecipeScaleView(allocator, execution.stdout); structured_report = true; }, + .graph_export => if (failure_class == .success and execution.stdout.len > 0) { + graph_document = try allocator.dupe(u8, execution.stdout); + structured_report = true; + }, + .proof_verify => if (std.mem.trim(u8, execution.stderr, " \t\r\n").len > 0) { + // Stderr is the contracted proof-verify report, not a compatibility fallback. + proof_report = try cleanText(allocator, execution.stderr, config.project_root, max_process_output); + structured_report = true; + }, } if (!structured_report or problems.items.len == 0) { @@ -199,6 +220,8 @@ pub fn run(allocator: std.mem.Allocator, io: Io, config: Config, request: Reques .impact = try impact.toOwnedSlice(allocator), .publication_plan = publication_plan, .recipe_scale_view = recipe_scale_view, + .graph_document = graph_document, + .proof_report = proof_report, }; } @@ -245,8 +268,16 @@ pub fn commandArgv( "--factor", request.recipe_scale_factor.?, }), + .graph_export => try args.appendSlice(allocator, &.{ + "graph", + "--input", + "content", + "--format", + graphFormat(request.graph_format), + }), + .proof_verify => try args.appendSlice(allocator, &.{ "proof", "verify", "--html-dir", "dist" }), } - if (input_mode == .cooklang and request.mode != .plan) try args.append(allocator, "--cooklang"); + if (input_mode == .cooklang and appendsCooklangSelector(request.mode)) try args.append(allocator, "--cooklang"); return args.toOwnedSlice(allocator); } @@ -284,7 +315,7 @@ fn prepareArtifactRoot(io: Io, project_root: []const u8, mode: Mode) !void { .check => check_report_name, .impact => impact_report_name, .validate, .html_build => html_report_name, - .plan, .recipe_scale => unreachable, + .plan, .recipe_scale, .graph_export, .proof_verify => unreachable, }; artifact_dir.deleteFile(io, stale_name) catch |err| switch (err) { error.FileNotFound => {}, @@ -650,6 +681,22 @@ fn validateProfilePath(path: []const u8) !void { try validateSourcePath(path); } +fn appendsCooklangSelector(mode: Mode) bool { + return switch (mode) { + .plan, .proof_verify => false, + else => true, + }; +} + +fn graphFormat(format: ?[]const u8) []const u8 { + return format orelse "mermaid"; +} + +fn validateGraphFormat(format: ?[]const u8) !void { + const value = format orelse return; + if (!std.mem.eql(u8, value, "mermaid") and !std.mem.eql(u8, value, "dot")) return error.InvalidGraphFormat; +} + fn cleanText(allocator: std.mem.Allocator, input: []const u8, private_root: []const u8, max_bytes: usize) ![]const u8 { const replaced = if (private_root.len > 0) try std.mem.replaceOwned(u8, allocator, input, private_root, "") @@ -821,3 +868,37 @@ test "plan uses the selected profile and does not invent cooklang overrides" { try std.testing.expectError(error.InvalidProfilePath, validateProfilePath("--help")); try std.testing.expectError(error.UnsafeArtifact, validateProfilePath("../secret.json")); } + +test "graph export argv is the fixed compiler command with mermaid default" { + const allocator = std.testing.allocator; + const mermaid = try commandArgv(allocator, "boris", .{ .mode = .graph_export }, .markdown); + defer allocator.free(mermaid); + try std.testing.expectEqual(@as(usize, 6), mermaid.len); + try std.testing.expectEqualStrings("graph", mermaid[1]); + try std.testing.expectEqualStrings("--input", mermaid[2]); + try std.testing.expectEqualStrings("content", mermaid[3]); + try std.testing.expectEqualStrings("--format", mermaid[4]); + try std.testing.expectEqualStrings("mermaid", mermaid[5]); + + const dot = try commandArgv(allocator, "boris", .{ .mode = .graph_export, .graph_format = "dot" }, .cooklang); + defer allocator.free(dot); + try std.testing.expectEqualStrings("dot", dot[5]); + try std.testing.expectEqualStrings("--cooklang", dot[dot.len - 1]); + + try validateGraphFormat(null); + try validateGraphFormat("mermaid"); + try validateGraphFormat("dot"); + try std.testing.expectError(error.InvalidGraphFormat, validateGraphFormat("--help")); + try std.testing.expectError(error.InvalidGraphFormat, validateGraphFormat("json")); +} + +test "proof verify argv is the fixed compiler command and skips cooklang" { + const allocator = std.testing.allocator; + const argv = try commandArgv(allocator, "boris", .{ .mode = .proof_verify }, .cooklang); + defer allocator.free(argv); + try std.testing.expectEqual(@as(usize, 5), argv.len); + try std.testing.expectEqualStrings("proof", argv[1]); + try std.testing.expectEqualStrings("verify", argv[2]); + try std.testing.expectEqualStrings("--html-dir", argv[3]); + try std.testing.expectEqualStrings("dist", argv[4]); +} diff --git a/editor/src/server.zig b/editor/src/server.zig index af2c339a..87f8f46b 100644 --- a/editor/src/server.zig +++ b/editor/src/server.zig @@ -593,7 +593,7 @@ fn respondApiError(request: *http.Server.Request, err: anyerror) !void { error.FileTooLarge, error.SnapshotTooLarge, error.PayloadTooLarge => .{ .status = .payload_too_large, .code = "payload_too_large" }, error.UnsupportedMediaType => .{ .status = .unsupported_media_type, .code = "unsupported_media_type" }, error.TooManyFiles => .{ .status = .payload_too_large, .code = "too_many_files" }, - error.ImpactIdRequired, error.UnexpectedImpactId, error.InvalidImpactId, error.ProfileRequired, error.UnexpectedProfile, error.InvalidProfilePath, error.RecipeScaleIdRequired, error.RecipeScaleFactorRequired, error.UnexpectedRecipeScale, error.InvalidRecipeScaleId, error.InvalidRecipeScaleFactor => .{ .status = .bad_request, .code = "invalid_command_request" }, + error.ImpactIdRequired, error.UnexpectedImpactId, error.InvalidImpactId, error.ProfileRequired, error.UnexpectedProfile, error.InvalidProfilePath, error.RecipeScaleIdRequired, error.RecipeScaleFactorRequired, error.UnexpectedRecipeScale, error.InvalidRecipeScaleId, error.InvalidRecipeScaleFactor, error.UnexpectedGraphFormat, error.InvalidGraphFormat => .{ .status = .bad_request, .code = "invalid_command_request" }, error.UnsupportedArtifact => .{ .status = .bad_gateway, .code = "unsupported_boris_artifact" }, error.InvalidBorisVersion => .{ .status = .bad_gateway, .code = "invalid_boris_version" }, error.BorisUnavailable => .{ .status = .service_unavailable, .code = "boris_unavailable" }, diff --git a/editor/ui/src/App.svelte b/editor/ui/src/App.svelte index cf02f009..c1788129 100644 --- a/editor/ui/src/App.svelte +++ b/editor/ui/src/App.svelte @@ -208,6 +208,10 @@ problems.status = 'Enter a scale factor before scaling the recipe.'; return; } + if (mode === 'graph_export' && graph.payload?.graph_status !== 'ready') { + problems.status = 'Build diagnostics to create the Boris graph before exporting.'; + return; + } problems.running = true; const started = Date.now(); problems.status = `Running ${commandLabel(mode)}…`; @@ -217,6 +221,8 @@ ? { mode, profile: publication.selectedProfile.trim() } : mode === 'recipe_scale' ? { mode, recipe_scale_id: activeNode()!.id, recipe_scale_factor: problems.scaleFactor.trim() } + : mode === 'graph_export' + ? { mode, graph_format: graph.exportFormat } : { mode }; const result = await api('/api/commands/run', { method: 'POST', body: JSON.stringify(body) @@ -244,6 +250,13 @@ if (mode === 'recipe_scale' && problems.result.failure_class === 'success' && problems.result.recipe_scale_view) { problems.scaleView = problems.result.recipe_scale_view; } + if (mode === 'graph_export') { + graph.document = problems.result.failure_class === 'success' ? (problems.result.graph_document ?? null) : null; + graph.copied = false; + } + if (mode === 'proof_verify') { + publication.lastProofReport = problems.result.proof_report ?? null; + } if (mode === 'html_build' || mode === 'plan') await refreshPublication(); } @@ -329,6 +342,18 @@ markBufferHostUnavailable(); } + async function openConflictCompare(trigger?: EventTarget | null) { + await tick(); + openModal(conflictDialog, trigger); + await tick(); + for (const area of conflictDialog.querySelectorAll('textarea')) { + const textarea = area as HTMLTextAreaElement; + textarea.scrollTop = 0; + textarea.setSelectionRange(0, 0); + } + conflictDialog.querySelector('.dialog-actions .primary')?.focus(); + } + async function probeDisk() { if (!buffer.activePath || !buffer.fingerprint || buffer.saveInFlight || probeInFlight) return; if (document.querySelector('dialog[open]')) return; @@ -359,9 +384,7 @@ buffer.conflict = null; buffer.deletedConflict = true; buffer.editorStatus = `${buffer.activePath} was deleted outside the editor.`; - await tick(); - openModal(conflictDialog); - conflictDialog.querySelector('.dialog-actions .primary')?.focus(); + await openConflictCompare(); } else { const gone = buffer.activePath; stopRecoveryTimer(); @@ -382,9 +405,7 @@ buffer.conflict = disk; buffer.deletedConflict = false; buffer.editorStatus = `External changes detected in ${buffer.activePath}. Nothing was overwritten.`; - await tick(); - openModal(conflictDialog); - conflictDialog.querySelector('.dialog-actions .primary')?.focus(); + await openConflictCompare(); } else { loadBuffer(disk, `Loaded external changes to ${probe.path}.`); } @@ -424,16 +445,12 @@ buffer.conflict = result.data as BufferResponse; buffer.deletedConflict = false; buffer.editorStatus = `External changes detected in ${buffer.activePath}. Nothing was overwritten.`; - await tick(); - openModal(conflictDialog, trigger); - conflictDialog.querySelector('.dialog-actions .primary')?.focus(); + await openConflictCompare(trigger); } else if (result.response.status === 409 && error.status === 'deleted') { buffer.conflict = null; buffer.deletedConflict = true; buffer.editorStatus = `${buffer.activePath} was deleted outside the editor. Nothing was written.`; - await tick(); - openModal(conflictDialog, trigger); - conflictDialog.querySelector('.dialog-actions .primary')?.focus(); + await openConflictCompare(trigger); } else if (error.error === 'read_only') { buffer.readOnly = true; buffer.editorStatus = `${buffer.activePath} is read-only. Nothing was written.`; @@ -894,6 +911,8 @@ onScale={() => runCommand('recipe_scale')} onReset={resetScale} onRunPlan={() => runCommand('plan')} + onVerifyProof={() => runCommand('proof_verify')} + onExportGraph={() => runCommand('graph_export')} onEnterFocus={enterFocusMode} /> diff --git a/editor/ui/src/components/GraphPane.svelte b/editor/ui/src/components/GraphPane.svelte index 2940536c..3ba77743 100644 --- a/editor/ui/src/components/GraphPane.svelte +++ b/editor/ui/src/components/GraphPane.svelte @@ -1,18 +1,20 @@ @@ -15,6 +17,7 @@

Publication

The editor runs boris plan --profile and shows the normalized declaration. It does not deploy or store secrets.

+

{publication.status}

{#if (publication.payload?.profiles.length ?? 0) > 0} @@ -77,4 +80,9 @@ {:else}

No local Proof Pack at dist/_boris/proof/proof-pack.json yet. Build HTML to produce evidence; that still is not a deploy.

{/if} + {#if publication.lastProofReport} +

Proof verify report

+

This is the contracted boris proof verify stderr. Exit class is in Problems; the editor does not invent pass or fail.

+
{publication.lastProofReport}
+ {/if} diff --git a/editor/ui/src/components/SourcePane.svelte b/editor/ui/src/components/SourcePane.svelte index 102e0b2c..284dd982 100644 --- a/editor/ui/src/components/SourcePane.svelte +++ b/editor/ui/src/components/SourcePane.svelte @@ -18,6 +18,8 @@ onScale, onReset, onRunPlan, + onVerifyProof, + onExportGraph, onEnterFocus }: { onSave: () => void; @@ -28,6 +30,8 @@ onScale: () => void; onReset: () => void; onRunPlan: () => void; + onVerifyProof: () => void; + onExportGraph: () => void; onEnterFocus: (trigger: HTMLElement | null) => void; } = $props(); @@ -72,6 +76,7 @@ onOpenPath={onOpenFile} onOpenNode={onOpenGraphNode} onImpact={onImpact} + onExport={onExportGraph} /> {#if buffer.activePath} {buffer.editorStatus}

- + diff --git a/editor/ui/src/dialogs/ConflictDialog.svelte b/editor/ui/src/dialogs/ConflictDialog.svelte index 57a1fb6e..3231524c 100644 --- a/editor/ui/src/dialogs/ConflictDialog.svelte +++ b/editor/ui/src/dialogs/ConflictDialog.svelte @@ -1,4 +1,5 @@ @@ -46,7 +61,8 @@
- + +
{:else if buffer.conflict}

{buffer.activePath} changed on disk after you opened it. Compare both versions before choosing.

@@ -63,7 +79,8 @@
- + +
{/if} diff --git a/editor/ui/src/lib/state/graph.svelte.ts b/editor/ui/src/lib/state/graph.svelte.ts index 9266bb9d..c76746f3 100644 --- a/editor/ui/src/lib/state/graph.svelte.ts +++ b/editor/ui/src/lib/state/graph.svelte.ts @@ -14,13 +14,16 @@ import { outgoingGraphLinks, wikiLinksInSource } from '../utils'; -import type { GraphLink, GraphNode, GraphPayload } from '../types'; +import type { GraphLink, GraphNode, GraphPayload, GraphExportFormat } from '../types'; import { buffer } from './buffer.svelte'; import { authoring } from './authoring.svelte'; export const graph = $state({ payload: null as GraphPayload | null, - status: 'Loading the Boris graph…' + status: 'Loading the Boris graph…', + exportFormat: 'mermaid' as GraphExportFormat, + document: null as string | null, + copied: false }); export function activeNode(): GraphNode | null { @@ -74,3 +77,39 @@ export async function refreshGraph() { if (result.response.ok) setGraph(result.data); else graph.status = 'The Boris build succeeded, but graph.json could not be adapted.'; } + +let copiedExportTimer: ReturnType | undefined; + +export function setGraphExportFormat(format: GraphExportFormat) { + if (graph.exportFormat === format) return; + graph.exportFormat = format; + graph.document = null; + graph.copied = false; +} + +export async function copyGraphDocument() { + if (!graph.document) return; + try { + await navigator.clipboard.writeText(graph.document); + graph.copied = true; + if (copiedExportTimer !== undefined) clearTimeout(copiedExportTimer); + copiedExportTimer = setTimeout(() => { + graph.copied = false; + copiedExportTimer = undefined; + }, 1500); + } catch { + graph.status = 'Could not copy the graph export. Clipboard access was denied.'; + } +} + +export function downloadGraphDocument() { + if (!graph.document) return; + const ext = graph.exportFormat === 'dot' ? 'dot' : 'mmd'; + const blob = new Blob([graph.document], { type: 'text/plain;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `graph.${ext}`; + link.click(); + URL.revokeObjectURL(url); +} diff --git a/editor/ui/src/lib/state/palette.svelte.ts b/editor/ui/src/lib/state/palette.svelte.ts index a0ff261a..37bdc576 100644 --- a/editor/ui/src/lib/state/palette.svelte.ts +++ b/editor/ui/src/lib/state/palette.svelte.ts @@ -49,6 +49,8 @@ export function paletteItems(): PaletteItem[] { { kind: 'command', mode: 'check' }, { kind: 'command', mode: 'impact' }, { kind: 'command', mode: 'plan' }, + { kind: 'command', mode: 'graph_export' }, + { kind: 'command', mode: 'proof_verify' }, { kind: 'preview' }, { kind: 'source' }, { kind: 'focus-enter' }, @@ -97,6 +99,9 @@ export function paletteEnabled(): Map { if (item.kind === 'impact-here') return [paletteItemKey(item), activeNode() !== null && !problems.running] as const; if (item.kind === 'save') return [paletteItemKey(item), dirty() && !buffer.readOnly && !buffer.saveInFlight] as const; if (item.kind === 'preview') return [paletteItemKey(item), preview.data?.phase !== 'running'] as const; + if (item.kind === 'command' && item.mode === 'graph_export') { + return [paletteItemKey(item), !problems.running && graph.payload?.graph_status === 'ready'] as const; + } if (item.kind === 'command') return [paletteItemKey(item), !problems.running] as const; if (item.kind === 'watch-start') return [paletteItemKey(item), watchStartEnabled()] as const; if (item.kind === 'watch-stop') return [paletteItemKey(item), watchStopEnabled()] as const; diff --git a/editor/ui/src/lib/state/publication.svelte.ts b/editor/ui/src/lib/state/publication.svelte.ts index 536a1363..1c375b3d 100644 --- a/editor/ui/src/lib/state/publication.svelte.ts +++ b/editor/ui/src/lib/state/publication.svelte.ts @@ -10,7 +10,8 @@ export const publication = $state({ payload: null as PublicationPayload | null, status: 'Loading publication profiles…', selectedProfile: '', - lastPlan: null as PublicationPlan | null + lastPlan: null as PublicationPlan | null, + lastProofReport: null as string | null }); export function setPublication(payload: PublicationPayload) { diff --git a/editor/ui/src/lib/types.ts b/editor/ui/src/lib/types.ts index 80e47f76..97255e5a 100644 --- a/editor/ui/src/lib/types.ts +++ b/editor/ui/src/lib/types.ts @@ -108,7 +108,8 @@ export type ProbeResponse = { export type RecoverySnapshot = { path: string; content: string; fingerprint: string }; export type RecoveryList = { snapshots: RecoverySnapshot[]; skipped?: number }; export type ErrorResponse = { error?: string; status?: string }; -export type CommandMode = 'validate' | 'ir_build' | 'html_build' | 'check' | 'impact' | 'plan' | 'recipe_scale'; +export type CommandMode = 'validate' | 'ir_build' | 'html_build' | 'check' | 'impact' | 'plan' | 'recipe_scale' | 'graph_export' | 'proof_verify'; +export type GraphExportFormat = 'mermaid' | 'dot'; export type FailureClass = 'success' | 'content' | 'usage' | 'io' | 'terminated'; export type PendingResolution = @@ -222,6 +223,8 @@ export type CommandResult = { impact: ImpactEndpoint[]; publication_plan?: PublicationPlan | null; recipe_scale_view?: RecipeScaleView | null; + graph_document?: string | null; + proof_report?: string | null; }; export type ProblemGroup = { key: string; label: string; problems: Problem[] }; diff --git a/editor/ui/src/lib/utils.ts b/editor/ui/src/lib/utils.ts index dbeb14f9..b6661632 100644 --- a/editor/ui/src/lib/utils.ts +++ b/editor/ui/src/lib/utils.ts @@ -36,6 +36,8 @@ export function commandLabel(mode: CommandMode): string { impact: 'Run impact', plan: 'Run publication plan', recipe_scale: 'Scale recipe', + graph_export: 'Export graph', + proof_verify: 'Verify proof', } satisfies Record )[mode]; } diff --git a/editor/ui/src/styles.css b/editor/ui/src/styles.css index b76596f0..512e9663 100644 --- a/editor/ui/src/styles.css +++ b/editor/ui/src/styles.css @@ -302,6 +302,11 @@ input:disabled, select:disabled, textarea:disabled { background: var(--color-sun .graph-pane { min-height: 0; margin-top: var(--space-5); padding: var(--space-4); border: 1px solid var(--color-accent-border); border-radius: var(--radius-md); background: var(--color-surface-tint); } .graph-current { font-weight: 700; font-size: var(--text-sm); font-family: var(--font-mono); overflow-wrap: anywhere; } .graph-actions { display: flex; gap: 0.55rem; flex-wrap: wrap; margin: 0.75rem 0; } +.graph-export { display: flex; flex-wrap: wrap; gap: 0.55rem; align-items: end; } +.graph-export label { margin: 0; } +.graph-export select { width: auto; min-width: 11rem; } +.graph-export-document { margin: 0.75rem 0 0; } +.graph-export-document textarea { min-height: 10rem; max-height: 16rem; font-size: var(--text-sm); } .graph-links { margin: 0; padding: 0; list-style: none; } .graph-links li { font-size: var(--text-sm); } .graph-links li + li { margin-top: 0.35rem; } @@ -421,6 +426,18 @@ input:disabled, select:disabled, textarea:disabled { background: var(--color-sun .publication-pane dl > div { display: grid; grid-template-columns: minmax(7rem, 0.3fr) 1fr; gap: var(--space-3); padding: 0.25rem 0; font-size: var(--text-sm); } .publication-pane dt { font-weight: 700; } .publication-pane dd { margin: 0; } +.proof-report { + margin: 0.75rem 0 0; + padding: var(--space-3); + max-height: 16rem; + overflow: auto; + border: 1px solid var(--color-border-soft); + border-radius: var(--radius-md); + background: var(--color-sunken); + font: var(--text-sm)/var(--leading-normal) var(--font-mono); + white-space: pre-wrap; + overflow-wrap: anywhere; +} dialog { width: min(52rem, calc(100% - 2rem)); @@ -455,7 +472,7 @@ dialog.command-palette .dialog-actions { margin-top: 0.75rem; } .dialog-actions kbd { margin-left: 0.45rem; margin-right: 0; padding: 0.08rem 0.35rem; } button.primary kbd { background: var(--kbd-on-accent-bg); } .comparison { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-4); } -.comparison textarea, dialog > textarea { min-height: 13rem; } +.comparison textarea, dialog > textarea { min-height: 13rem; max-height: min(20rem, 40vh); } footer { padding: var(--space-4) var(--content-pad) var(--space-7); } footer p { margin-bottom: 0; color: var(--color-text-muted); font-size: var(--text-sm); } footer .key-hint { margin: 0 0 0.35rem; } diff --git a/editor/ui/tests/safe-editing.spec.ts b/editor/ui/tests/safe-editing.spec.ts index c89634ac..ab520c28 100644 --- a/editor/ui/tests/safe-editing.spec.ts +++ b/editor/ui/tests/safe-editing.spec.ts @@ -43,6 +43,8 @@ type CommandResult = { impact: Array>; publication_plan?: Record | null; recipe_scale_view?: Record | null; + graph_document?: string | null; + proof_report?: string | null; }; function commandResult(mode: string, overrides: Partial = {}): CommandResult { @@ -51,6 +53,8 @@ function commandResult(mode: string, overrides: Partial = {}): Co report_version: null, used_stderr_fallback: false, problems: [], findings: [], impact: [], publication_plan: null, recipe_scale_view: null, + graph_document: null, + proof_report: null, ...overrides }; } @@ -416,6 +420,26 @@ test('external edits open an explicit two-version conflict dialog', async ({ pag await expect(page.getByRole('textbox', { name: 'Source for content/index.md' })).toHaveValue('# Changed elsewhere\n'); }); +test('conflict compare panes open scrolled to the top (#984)', async ({ page }) => { + const longUnsaved = Array.from({ length: 120 }, (_, index) => `unsaved line ${index + 1} of a long buffer.`).join('\n'); + await installApi(page, { saveConflict: true }); + await page.getByRole('button', { name: 'content/index.md', exact: true }).click(); + await page.getByRole('textbox', { name: 'Source for content/index.md' }).fill(longUnsaved); + await page.getByRole('button', { name: 'Save file', exact: true }).click(); + + const dialog = page.getByRole('dialog', { name: 'External changes detected' }); + await expect(dialog).toBeVisible(); + const unsaved = dialog.locator('#unsaved-version'); + const disk = dialog.locator('#disk-version'); + await expect(unsaved).toHaveValue(longUnsaved); + expect(await unsaved.evaluate((node) => { + const area = node as HTMLTextAreaElement; + return { top: area.scrollTop, overflows: area.scrollHeight > area.clientHeight }; + })).toEqual({ top: 0, overflows: true }); + expect(await disk.evaluate((node) => (node as HTMLTextAreaElement).scrollTop)).toBe(0); + await expect(dialog.getByRole('button', { name: /Replace disk version/ })).toBeFocused(); +}); + test('Enter confirms the Delete dialog primary action with a visible hint (#462)', async ({ page }) => { await installApi(page); let deleteRequests = 0; @@ -824,6 +848,7 @@ test('Boris commands expose visible accessible names and distinct exit classes', for (const name of ['Validate project', 'Build diagnostics', 'Build HTML', 'Check graph', 'Run impact']) { await expect(page.getByRole('button', { name, exact: true })).toHaveText(name); } + await expect(page.locator('#problems').getByRole('button', { name: 'Verify proof', exact: true })).toHaveText('Verify proof'); await page.getByRole('button', { name: 'Build diagnostics', exact: true }).focus(); await page.keyboard.press('Enter'); await expect(page.getByRole('status', { name: 'Boris command status' })).toContainText('Content or graph failure (exit 1)'); @@ -2707,6 +2732,30 @@ test('graph inspector refreshes after a successful diagnostics build (#418 M6)', await expect(page.getByRole('status', { name: 'Graph status' })).toContainText('Boris graph ready (3 pages).'); }); +test('Export graph stays disabled until diagnostics exist (#985)', async ({ page }) => { + await installApi(page, { graph: [graphPayload(false)] }); + await expect(page.getByRole('button', { name: 'Export graph', exact: true })).toBeDisabled(); + await expect(page.getByRole('status', { name: 'Graph status' })).toContainText('Build diagnostics to create the Boris graph.'); +}); + +test('Export graph runs allowlisted boris graph and offers the document (#985)', async ({ page }) => { + const mermaid = 'graph TD\n p0["Home"]:::trunk\n'; + await installApi(page, { + files: graphFiles, + commands: { graph_export: commandResult('graph_export', { graph_document: mermaid }) } + }); + const graph = page.locator('#graph'); + await expect(graph.getByRole('button', { name: 'Export graph', exact: true })).toBeEnabled(); + const request = page.waitForRequest('**/api/commands/run'); + await graph.getByRole('combobox', { name: 'Export format', exact: true }).selectOption('dot'); + await graph.getByRole('button', { name: 'Export graph', exact: true }).focus(); + await page.keyboard.press('Enter'); + expect((await request).postDataJSON()).toMatchObject({ mode: 'graph_export', graph_format: 'dot' }); + await expect(graph.getByRole('textbox', { name: 'Exported Graphviz DOT' })).toHaveValue(mermaid); + await expect(graph.getByRole('button', { name: 'Copy export', exact: true })).toBeVisible(); + await expect(graph.getByRole('button', { name: 'Download export', exact: true })).toBeVisible(); +}); + test('publication pane plans an existing profile and does not deploy (#418 M9)', async ({ page }) => { const plan = { format: 'boris-publication-plan', @@ -2762,6 +2811,36 @@ test('publication pane plans an existing profile and does not deploy (#418 M9)', await expect(page.getByRole('button', { name: 'Deploy', exact: true })).toHaveCount(0); }); +test('Verify proof runs allowlisted boris proof verify (#986)', async ({ page }) => { + const report = 'boris proof verify: dist/_boris/proof/checks.json\n checks: 3/3 passed\nverdict: pass\n'; + await installApi(page, { + publication: { + profiles: [{ path: 'boris.json' }], + proof: { + path: 'dist/_boris/proof/proof-pack.json', + html_path: 'dist/_boris/proof/index.html', + target: 'public', + schema_version: '1', + overall_presentation_status: 'verified', + artifacts_total: 2, + checks_total: 3, + findings_total: 0, + claims_total: 3 + } + }, + commands: { proof_verify: commandResult('proof_verify', { proof_report: report }) } + }); + const publication = page.locator('#publication'); + const request = page.waitForRequest('**/api/commands/run'); + await publication.getByRole('button', { name: 'Verify proof', exact: true }).focus(); + await page.keyboard.press('Enter'); + expect((await request).postDataJSON()).toMatchObject({ mode: 'proof_verify' }); + await expect(publication.getByRole('heading', { name: 'Proof verify report' })).toBeVisible(); + await expect(publication).toContainText('verdict: pass'); + await expect(page.getByRole('status', { name: 'Boris command status' })).toContainText('Verify proof finished: Success'); + await expect(page.locator('#problems').getByRole('heading', { name: 'Proof verify report' })).toBeVisible(); +}); + function visibleLabel(text: string): string { return text .replace(/\s+/g, ' ') diff --git a/editor/ui/tests/section-nav.spec.ts b/editor/ui/tests/section-nav.spec.ts index 671268be..e7c8472d 100644 --- a/editor/ui/tests/section-nav.spec.ts +++ b/editor/ui/tests/section-nav.spec.ts @@ -240,10 +240,9 @@ test('arrival highlight collapses to a static cue under reduced motion', async ( await navLink(page, 'Watch').click(); // The class (the attention cue) still applies... await expect(watch).toHaveClass(/arrived/, { timeout: 2_000 }); - // ...and the jump is instant, not smooth. #watch is the last section, so - // the jump may be clamped at max scroll (the footer plus viewport cannot - // park it under the nav) — the honest assertion is the rest state: parked - // under the nav or exactly at max scroll. + // The jump is instant, not smooth. #watch is last in the diagnostics rail, + // but the Source column can be taller, so the window is not always at max + // scroll. The rest state is: Watch is on screen below the sticky nav. const landed = await page.evaluate(() => { const el = document.getElementById('watch'); if (!el) return false; @@ -251,8 +250,10 @@ test('arrival highlight collapses to a static cue under reduced motion', async ( const atMax = Math.abs(window.scrollY - maxScroll) < 2; const margin = parseFloat(getComputedStyle(el).scrollMarginTop) || 0; const navHeight = document.querySelector('.section-nav')?.getBoundingClientRect().height ?? 0; - const parked = Math.abs(el.getBoundingClientRect().top - (navHeight + margin)) < 2; - return atMax || parked; + const top = el.getBoundingClientRect().top; + const parked = Math.abs(top - (navHeight + margin)) < 2; + const onScreenBelowNav = top >= navHeight - 2 && top < window.innerHeight; + return atMax || parked || onScreenBelowNav; }); expect(landed).toBe(true); await expect(watch).not.toHaveClass(/arrived/, { timeout: 3_000 });