devrites 4.3.0 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CHANGELOG.md +2 -3
  2. package/README.md +1 -1
  3. package/docs/cli.md +15 -0
  4. package/docs/engine/commands.md +17 -3
  5. package/engine/commands.go +1 -1
  6. package/engine/internal/lib/open_visual.go +254 -0
  7. package/engine/internal/lib/open_visual_test.go +280 -0
  8. package/engine/internal/lib/visual_outline.go +163 -0
  9. package/engine/internal/lib/visual_outline_test.go +161 -0
  10. package/engine/main.go +4 -0
  11. package/engine/root_routing_test.go +49 -14
  12. package/engine/testdata/visual/open-visual-smoke.html +675 -0
  13. package/engine/testdata/visual/open-visual-smoke.outline.md +54 -0
  14. package/pack/.claude/skills/devrites-lib/SKILL.md +2 -0
  15. package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/code.md +46 -0
  16. package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/comparison.md +44 -0
  17. package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/diagram.md +53 -0
  18. package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/index.md +57 -0
  19. package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/input.md +47 -0
  20. package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/outline-template.md +106 -0
  21. package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/plan.md +49 -0
  22. package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/slides.md +42 -0
  23. package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/table.md +44 -0
  24. package/pack/.claude/skills/devrites-lib/reference/workspace-artifact-schema.md +24 -9
  25. package/pack/.claude/skills/rite-define/SKILL.md +10 -1
  26. package/pack/.claude/skills/rite-explain/SKILL.md +23 -7
  27. package/pack/.claude/skills/rite-explain/reference/intake.md +11 -2
  28. package/pack/.claude/skills/rite-spec/reference/spec-template.md +5 -2
  29. package/pack/.claude/skills/rite-spec/reference/state-workspace.md +26 -3
  30. package/pack/generated/claude/skills/devrites-lib/SKILL.md +2 -0
  31. package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/code.md +46 -0
  32. package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/comparison.md +44 -0
  33. package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/diagram.md +53 -0
  34. package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/index.md +57 -0
  35. package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/input.md +47 -0
  36. package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/outline-template.md +106 -0
  37. package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/plan.md +49 -0
  38. package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/slides.md +42 -0
  39. package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/table.md +44 -0
  40. package/pack/generated/claude/skills/devrites-lib/reference/workspace-artifact-schema.md +24 -9
  41. package/pack/generated/claude/skills/rite-define/SKILL.md +10 -1
  42. package/pack/generated/claude/skills/rite-explain/SKILL.md +23 -7
  43. package/pack/generated/claude/skills/rite-explain/reference/intake.md +11 -2
  44. package/pack/generated/claude/skills/rite-spec/reference/spec-template.md +5 -2
  45. package/pack/generated/claude/skills/rite-spec/reference/state-workspace.md +26 -3
  46. package/pack/generated/codex/skills/devrites-lib/SKILL.md +2 -0
  47. package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/code.md +46 -0
  48. package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/comparison.md +44 -0
  49. package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/diagram.md +53 -0
  50. package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/index.md +57 -0
  51. package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/input.md +47 -0
  52. package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/outline-template.md +106 -0
  53. package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/plan.md +49 -0
  54. package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/slides.md +42 -0
  55. package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/table.md +44 -0
  56. package/pack/generated/codex/skills/devrites-lib/reference/workspace-artifact-schema.md +24 -9
  57. package/pack/generated/codex/skills/rite-define/SKILL.md +10 -1
  58. package/pack/generated/codex/skills/rite-explain/SKILL.md +23 -7
  59. package/pack/generated/codex/skills/rite-explain/reference/intake.md +11 -2
  60. package/pack/generated/codex/skills/rite-spec/reference/spec-template.md +5 -2
  61. package/pack/generated/codex/skills/rite-spec/reference/state-workspace.md +26 -3
  62. package/package.json +1 -1
  63. package/scripts/install-lib.sh +1 -1
package/CHANGELOG.md CHANGED
@@ -2,12 +2,11 @@
2
2
 
3
3
  All notable changes to DevRites are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and DevRites adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Releases are generated automatically by [semantic-release](https://semantic-release.gitbook.io/) from Conventional Commits on `main`.
4
4
 
5
- ## [4.3.0](https://github.com/ViktorsBaikers/DevRites/compare/v4.2.0...v4.3.0) (2026-08-25)
5
+ ## [4.4.0](https://github.com/ViktorsBaikers/DevRites/compare/v4.3.0...v4.4.0) (2026-08-26)
6
6
 
7
7
  ### Added
8
8
 
9
- * **devrites:** add skill-trust, task-graph, and observe commands ([#38](https://github.com/ViktorsBaikers/DevRites/issues/38)) ([2562355](https://github.com/ViktorsBaikers/DevRites/commit/2562355a970726fb95e57e2f328961ca136c9f81))
10
- * **rite-build:** opt-in parallel worktree slices (N≤3) ([#37](https://github.com/ViktorsBaikers/DevRites/issues/37)) ([fe71d67](https://github.com/ViktorsBaikers/DevRites/commit/fe71d672408559af030e95f700b18ead798f18fc))
9
+ * **devrites:** add local visual HTML artifacts and open-visual ([#42](https://github.com/ViktorsBaikers/DevRites/issues/42)) ([577389e](https://github.com/ViktorsBaikers/DevRites/commit/577389ec3016312e51bbdcad918179b161386aed))
11
10
 
12
11
  ## [4.2.0](https://github.com/ViktorsBaikers/DevRites/compare/v4.1.0...v4.2.0) (2026-08-23)
13
12
 
package/README.md CHANGED
@@ -28,7 +28,7 @@ project-conventional push, tag, or PR action, and archive the workspace.
28
28
  Unattended runs may create local WIP checkpoint commits along the way, but they
29
29
  remain local unless Ship's disclosed plan includes an approved remote action.
30
30
 
31
- **Status:** [`v4.3.0`](https://github.com/ViktorsBaikers/DevRites/releases/tag/v4.3.0): see [`CHANGELOG.md`](CHANGELOG.md) for release notes.
31
+ **Status:** [`v4.4.0`](https://github.com/ViktorsBaikers/DevRites/releases/tag/v4.4.0): see [`CHANGELOG.md`](CHANGELOG.md) for release notes.
32
32
 
33
33
  This is the latest published release; `main` may contain unreleased work.
34
34
 
package/docs/cli.md CHANGED
@@ -24,6 +24,7 @@ devrites-engine state resolve <qid> "<answer>"
24
24
  devrites-engine state close <slug>
25
25
 
26
26
  devrites-engine secret-scan [--staged] [--stdin] [slug]
27
+ devrites-engine open-visual <path-or-name> [--slug <slug>] [--no-open]
27
28
  devrites-engine version
28
29
  ```
29
30
 
@@ -69,10 +70,12 @@ with the self-contained updater.
69
70
 
70
71
  - `check candidate <slug>` validates the strict `touched-files.md` manifest and
71
72
  hashes its exact path/state/type/mode/content identity. A pass prints:
73
+
72
74
  ```text
73
75
  candidate-sha256: <64 lowercase hex>
74
76
  candidate-files: <manifest row count>
75
77
  ```
78
+
76
79
  - `check readiness <slug>` verifies the files required to leave the workspace's
77
80
  current phase and, once `eng-review.md` is required, its exact stable
78
81
  Build-input binding.
@@ -118,6 +121,18 @@ secret bytes. It accepts at most 4,096 entries, 64 MiB total captured input, and
118
121
  Findings include only severity, a redacted or escaped source label, category,
119
122
  and zero-based byte offset. HIGH findings exit `3`.
120
123
 
124
+ ## Open visual
125
+
126
+ `open-visual` resolves a portable HTML visualization under the active or
127
+ `--slug` workspace `visual/` directory, or an absolute/relative `.html` path.
128
+ Unless `--no-open` is set, it launches the OS default browser for that local
129
+ file only. A missing sibling `.outline.md` prints a stderr warning and still
130
+ succeeds. When the outline exists, inventory ids missing from HTML also warn
131
+ (non-fatal; HTML-only decorative ids are ignored). Stdout prints compact agent
132
+ tips: absolute HTML path, outline path (or missing), the playbook index hint,
133
+ and `ids=ok` / `ids=mismatch` when an inventory is present. The command never
134
+ fetches remote hosts.
135
+
121
136
  ## Output and exits
122
137
 
123
138
  Lifecycle checks print stable line-oriented fields. `reason: DRV-...` identifies
@@ -7,7 +7,7 @@ workflow strategy.
7
7
  ## Complete operational command inventory
8
8
 
9
9
  | Command | Deterministic responsibility |
10
- |---|---|
10
+ | --- | --- |
11
11
  | `install [flags]` | Install manifest-owned host artifacts and the optional shared binary. |
12
12
  | `update [flags]` | Refresh an existing managed installation. |
13
13
  | `uninstall [flags]` | Remove managed artifacts while preserving runtime workspace state. |
@@ -15,13 +15,14 @@ workflow strategy.
15
15
  | `check readiness <slug>` | Check target-Phase files, open human gates from Clarify onward, and the current stable Build-input binding when applicable. |
16
16
  | `check readiness --emit-binding <slug>` | Render the exact stable Build-input binding for Vet to record after review. |
17
17
  | `check seal <slug>` | Check files required by target Phase `seal`, open human gates, the stable Build-input binding, and exact candidate bindings. |
18
- | `check path-disjoint [--root <dir>] [<json-file>|-]` | Verify slice path sets are pairwise disjoint. |
18
+ | `check path-disjoint` with optional `--root DIR` and optional `JSON-FILE` or `-` | Verify slice path sets are pairwise disjoint. |
19
19
  | `check task-graph <slug>` | Validate `tasks.md` slice dependency graph for cycles and unknown dependencies. |
20
20
  | `check skill-trust <path>` | Scan one skill/agent Markdown file for structural trust violations. |
21
21
  | `observe summary <slug>` | Emit sanitized JSON workspace summary from one retained observation. |
22
22
  | `state resolve <qid> "<answer>"` | Resolve an open question and update `questions.md` plus `state.md` atomically. |
23
23
  | `state close <slug>` | Archive a shipped workspace and clear matching `ACTIVE`. |
24
24
  | `secret-scan [--staged] [--stdin] [slug]` | Scan exact staged blobs, stdin, or touched regular files for credential material. |
25
+ | `open-visual <path-or-name> [--slug <slug>] [--no-open]` | Resolve a local visual HTML file, optionally open it in the OS browser, warn if the sibling outline is missing or inventory ids are absent from HTML, and print agent path tips. No network. |
25
26
  | `version` | Print the engine version. |
26
27
 
27
28
  `help`, `-h`, and `--help` print this operational inventory. `version` and
@@ -86,6 +87,19 @@ Each invocation accepts at most 4,096 entries, 64 MiB total captured input, and
86
87
  4,096 findings. Findings never include matched bytes, excerpts, or value hashes.
87
88
  Input, limit, and output errors exit `2`; HIGH findings exit `3`.
88
89
 
90
+ ## Open visual
91
+
92
+ `open-visual` resolves `<path-or-name>` to a local `.html` file under the
93
+ active/`DEVRITES_WORKSPACE`/`--slug` workspace `visual/` directory, or via an
94
+ absolute/relative path. Missing sibling `.outline.md` warns on stderr but does
95
+ not hard-fail. When the outline exists, the engine compares `## ID inventory`
96
+ ids to HTML `id="..."` attributes and warns (non-fatal) for inventory ids
97
+ missing from HTML; HTML-only decorative ids are ignored. Unless `--no-open`,
98
+ the engine starts the OS opener (`open`, `xdg-open`, or Windows `start`) for
99
+ the local file only — never a network fetch. Stdout prints the absolute HTML
100
+ path, outline path tip, playbook index hint, and an `ids=ok` / `ids=mismatch`
101
+ summary when an inventory is present.
102
+
89
103
  ## Output and exit contracts
90
104
 
91
105
  `check candidate` passes with exactly:
@@ -121,7 +135,7 @@ artifact is classified as `absent`, `empty`, `malformed`, `unsafe`,
121
135
  The closed diagnostic codes and recoveries are:
122
136
 
123
137
  | Code | Exact Gate recovery | Exact standalone readiness-binding payload |
124
- |---|---|---|
138
+ | --- | --- | --- |
125
139
  | `malformed_markdown` | `next: repair <logical-path>: replace invalid Markdown with valid Markdown; required artifacts need substantive content` | `readiness input <logical-path> is malformed (malformed_markdown); replace invalid Markdown with valid Markdown` |
126
140
  | `parent_symlink` | `next: repair <logical-path>: replace the symlinked parent with a real directory` | `readiness input <logical-path> is unsafe (parent_symlink); replace the symlinked parent with a real directory` |
127
141
  | `final_symlink` | `next: repair <logical-path>: replace the symlink with a regular file` | `readiness input <logical-path> is unsafe (final_symlink); replace the symlink with a regular file` |
@@ -26,7 +26,7 @@ const (
26
26
  func rootModeFor(command string, args []string) rootMode {
27
27
  subcommand := firstRootOperand(args)
28
28
  switch command {
29
- case "secret-scan":
29
+ case "secret-scan", "open-visual":
30
30
  return rootLenient
31
31
  case "state":
32
32
  switch subcommand {
@@ -0,0 +1,254 @@
1
+ package lib
2
+
3
+ import (
4
+ "fmt"
5
+ "io"
6
+ "os"
7
+ "os/exec"
8
+ "path/filepath"
9
+ "runtime"
10
+ "strings"
11
+
12
+ "github.com/devrites/devrites/internal/devritespaths"
13
+ "github.com/devrites/devrites/internal/safepath"
14
+ )
15
+
16
+ const openVisualPlaybookHint = "pack/.claude/skills/devrites-lib/reference/visual-playbooks/index.md"
17
+
18
+ // openVisualOpener launches a local file in the OS default browser.
19
+ // Tests replace this to assert --no-open and avoid spawning a browser.
20
+ var openVisualOpener = openLocalFile
21
+
22
+ // OpenVisual resolves a workspace visual HTML file, optionally opens it in the
23
+ // OS browser, warns when the sibling outline is missing or inventory ids are
24
+ // absent from HTML, and prints an agent tip. It never performs network I/O.
25
+ //
26
+ // Usage: open-visual <path-or-name> [--slug <slug>] [--no-open]
27
+ func OpenVisual(root string, args []string, stdout, stderr io.Writer) int {
28
+ operand, slug, noOpen, err := parseOpenVisualArgs(args)
29
+ if err != nil {
30
+ fmt.Fprintf(stderr, "open-visual: %v\n", err)
31
+ fmt.Fprintln(stderr, "usage: devrites-engine open-visual <path-or-name> [--slug <slug>] [--no-open]")
32
+ return 2
33
+ }
34
+
35
+ htmlPath, nameMode, err := resolveOpenVisualHTML(root, slug, operand)
36
+ if err != nil {
37
+ fmt.Fprintf(stderr, "open-visual: %v\n", err)
38
+ return 2
39
+ }
40
+
41
+ info, err := os.Stat(htmlPath)
42
+ if err != nil {
43
+ fmt.Fprintf(stderr, "open-visual: cannot open %s: %v\n", htmlPath, err)
44
+ return 2
45
+ }
46
+ if info.IsDir() {
47
+ fmt.Fprintf(stderr, "open-visual: %s is a directory, not an HTML file\n", htmlPath)
48
+ return 2
49
+ }
50
+ if resolved, err := filepath.EvalSymlinks(htmlPath); err == nil {
51
+ htmlPath = resolved
52
+ }
53
+ if !strings.EqualFold(filepath.Ext(htmlPath), ".html") {
54
+ fmt.Fprintf(stderr, "open-visual: require a .html file, got %s\n", htmlPath)
55
+ return 2
56
+ }
57
+ if nameMode {
58
+ feature, err := resolveOpenVisualFeatureDir(root, slug)
59
+ if err != nil {
60
+ fmt.Fprintf(stderr, "open-visual: %v\n", err)
61
+ return 2
62
+ }
63
+ visualDir := filepath.Join(feature, "visual")
64
+ if !safepath.WithinResolved(htmlPath, visualDir) {
65
+ fmt.Fprintf(stderr, "open-visual: refused: resolved path escapes workspace visual/\n")
66
+ return 2
67
+ }
68
+ }
69
+
70
+ outlinePath := strings.TrimSuffix(htmlPath, filepath.Ext(htmlPath)) + ".outline.md"
71
+ outlineMissing := false
72
+ if st, err := os.Stat(outlinePath); err != nil || st.IsDir() {
73
+ outlineMissing = true
74
+ fmt.Fprintf(stderr, "open-visual: warning: missing outline companion %s\n", outlinePath)
75
+ }
76
+
77
+ var idReport VisualIDConsistency
78
+ idsChecked := false
79
+ if !outlineMissing {
80
+ htmlBody, herr := os.ReadFile(htmlPath)
81
+ outlineBody, oerr := os.ReadFile(outlinePath)
82
+ switch {
83
+ case herr != nil:
84
+ fmt.Fprintf(stderr, "open-visual: warning: cannot read HTML for id check: %v\n", herr)
85
+ case oerr != nil:
86
+ fmt.Fprintf(stderr, "open-visual: warning: cannot read outline for id check: %v\n", oerr)
87
+ default:
88
+ idReport = CheckVisualIDConsistency(string(htmlBody), string(outlineBody))
89
+ idsChecked = true
90
+ if len(idReport.MissingInHTML) > 0 {
91
+ fmt.Fprintf(stderr, "open-visual: warning: %d outline inventory id(s) missing from HTML: %s\n",
92
+ len(idReport.MissingInHTML), strings.Join(idReport.MissingInHTML, ", "))
93
+ }
94
+ }
95
+ }
96
+
97
+ // Print tips before OS open so agents still get paths if the opener fails.
98
+ fmt.Fprintf(stdout, "open-visual: html=%s\n", htmlPath)
99
+ if outlineMissing {
100
+ fmt.Fprintf(stdout, "open-visual: outline=(missing) %s\n", outlinePath)
101
+ } else {
102
+ fmt.Fprintf(stdout, "open-visual: outline=%s\n", outlinePath)
103
+ }
104
+ fmt.Fprintf(stdout, "open-visual: playbooks=%s\n", openVisualPlaybookHint)
105
+ if idsChecked {
106
+ switch {
107
+ case len(idReport.MissingInHTML) > 0:
108
+ fmt.Fprintf(stdout, "open-visual: ids=mismatch missing=%d inventory=%d\n",
109
+ len(idReport.MissingInHTML), len(idReport.Inventory))
110
+ case len(idReport.Inventory) > 0:
111
+ fmt.Fprintf(stdout, "open-visual: ids=ok (%d inventory)\n", len(idReport.Inventory))
112
+ }
113
+ }
114
+
115
+ if !noOpen {
116
+ if err := openVisualOpener(htmlPath); err != nil {
117
+ fmt.Fprintf(stderr, "open-visual: warning: failed to open browser: %v\n", err)
118
+ // Tips already printed; HTML is local and resolved — warn-and-continue.
119
+ }
120
+ }
121
+ return 0
122
+ }
123
+
124
+ func parseOpenVisualArgs(args []string) (operand, slug string, noOpen bool, err error) {
125
+ for i := 0; i < len(args); i++ {
126
+ arg := args[i]
127
+ switch {
128
+ case arg == "--no-open":
129
+ noOpen = true
130
+ case arg == "--slug":
131
+ if i+1 >= len(args) {
132
+ return "", "", false, fmt.Errorf("--slug requires a value")
133
+ }
134
+ i++
135
+ slug = strings.TrimSpace(args[i])
136
+ if slug == "" {
137
+ return "", "", false, fmt.Errorf("--slug requires a value")
138
+ }
139
+ case strings.HasPrefix(arg, "--slug="):
140
+ slug = strings.TrimSpace(strings.TrimPrefix(arg, "--slug="))
141
+ if slug == "" {
142
+ return "", "", false, fmt.Errorf("--slug requires a value")
143
+ }
144
+ case strings.HasPrefix(arg, "-"):
145
+ return "", "", false, fmt.Errorf("unknown flag %q", arg)
146
+ default:
147
+ if operand != "" {
148
+ return "", "", false, fmt.Errorf("unexpected argument %q", arg)
149
+ }
150
+ operand = arg
151
+ }
152
+ }
153
+ if strings.TrimSpace(operand) == "" {
154
+ return "", "", false, fmt.Errorf("path or visual name required")
155
+ }
156
+ return operand, slug, noOpen, nil
157
+ }
158
+
159
+ // resolveOpenVisualHTML returns the HTML path and whether the operand was a
160
+ // workspace visual name (nameMode). Absolute/relative path operands open any
161
+ // local .html; name operands resolve under the feature visual/ tree.
162
+ func resolveOpenVisualHTML(root, slug, operand string) (string, bool, error) {
163
+ if isOpenVisualPathOperand(operand) {
164
+ abs, err := filepath.Abs(operand)
165
+ if err != nil {
166
+ retErr := fmt.Errorf("resolve path: %w", err)
167
+ return "", false, retErr
168
+ }
169
+ cleaned := filepath.Clean(abs)
170
+ return cleaned, false, nil
171
+ }
172
+
173
+ name := operand
174
+ if !strings.EqualFold(filepath.Ext(name), ".html") {
175
+ name += ".html"
176
+ }
177
+ if filepath.Base(name) != name {
178
+ retErr := fmt.Errorf("visual name must not contain path separators")
179
+ return "", true, retErr
180
+ }
181
+
182
+ feature, err := resolveOpenVisualFeatureDir(root, slug)
183
+ if err != nil {
184
+ return "", true, err
185
+ }
186
+ htmlPath := filepath.Join(feature, "visual", name)
187
+ return htmlPath, true, nil
188
+ }
189
+
190
+ // isOpenVisualPathOperand reports whether operand is a filesystem path rather
191
+ // than a workspace visual name. Absolute paths and operands with separators are
192
+ // paths. Leading "./" or "../" (and bare "." / "..") are relative paths; a
193
+ // leading-dot basename alone (e.g. ".draft") is still a visual name.
194
+ func isOpenVisualPathOperand(operand string) bool {
195
+ if filepath.IsAbs(operand) {
196
+ return true
197
+ }
198
+ if operand == "." || operand == ".." {
199
+ return true
200
+ }
201
+ if strings.HasPrefix(operand, "./") || strings.HasPrefix(operand, "../") {
202
+ return true
203
+ }
204
+ if strings.HasPrefix(operand, `.\`) || strings.HasPrefix(operand, `..\`) {
205
+ return true
206
+ }
207
+ return strings.ContainsAny(operand, `/\`)
208
+ }
209
+
210
+ func resolveOpenVisualFeatureDir(root, slug string) (string, error) {
211
+ if root == "" {
212
+ retErr := fmt.Errorf("DevRites root required to resolve a visual name")
213
+ return "", retErr
214
+ }
215
+ if slug == "" {
216
+ active, err := devritespaths.ActiveSlug(root)
217
+ if err != nil {
218
+ return "", err
219
+ }
220
+ if active == "" {
221
+ retErr := fmt.Errorf("no slug: pass --slug or set ACTIVE / DEVRITES_WORKSPACE")
222
+ return "", retErr
223
+ }
224
+ slug = active
225
+ }
226
+ dir, err := devritespaths.ExistingFeatureDirChecked(root, slug)
227
+ if err != nil {
228
+ if os.IsNotExist(err) {
229
+ retErr := fmt.Errorf("no workspace for slug %q", slug)
230
+ return "", retErr
231
+ }
232
+ return "", err
233
+ }
234
+ return dir, nil
235
+ }
236
+
237
+ func openLocalFile(path string) error {
238
+ var cmd *exec.Cmd
239
+ switch runtime.GOOS {
240
+ case "darwin":
241
+ // argv only — no shell.
242
+ cmd = exec.Command("open", path)
243
+ case "windows":
244
+ // Avoid cmd.exe /c shell composition; FileProtocolHandler takes one path argv.
245
+ cmd = exec.Command("rundll32.exe", "url.dll,FileProtocolHandler", path)
246
+ default:
247
+ // argv only — no shell.
248
+ cmd = exec.Command("xdg-open", path)
249
+ }
250
+ if err := cmd.Start(); err != nil {
251
+ return fmt.Errorf("open local file: %w", err)
252
+ }
253
+ return nil
254
+ }
@@ -0,0 +1,280 @@
1
+ package lib
2
+
3
+ import (
4
+ "bytes"
5
+ "errors"
6
+ "os"
7
+ "path/filepath"
8
+ "strings"
9
+ "sync/atomic"
10
+ "testing"
11
+ )
12
+
13
+ func TestOpenVisualResolvesNameUnderWorkspace(t *testing.T) {
14
+ root, html := writeOpenVisualFixture(t, true)
15
+ t.Setenv("DEVRITES_WORKSPACE", filepath.Join(root, "work", "feature"))
16
+
17
+ var opens atomic.Int32
18
+ restore := swapOpenVisualOpener(func(path string) error {
19
+ opens.Add(1)
20
+ if path != html {
21
+ t.Fatalf("opened %q, want %q", path, html)
22
+ }
23
+ return nil
24
+ })
25
+ defer restore()
26
+
27
+ var stdout, stderr bytes.Buffer
28
+ code := OpenVisual(root, []string{"demo"}, &stdout, &stderr)
29
+ if code != 0 {
30
+ t.Fatalf("OpenVisual() = %d stderr=%q", code, stderr.String())
31
+ }
32
+ if opens.Load() != 1 {
33
+ t.Fatalf("opener calls = %d, want 1", opens.Load())
34
+ }
35
+ out := stdout.String()
36
+ if !strings.Contains(out, "open-visual: html="+html) {
37
+ t.Fatalf("stdout missing html tip:\n%s", out)
38
+ }
39
+ if !strings.Contains(out, "open-visual: outline="+strings.TrimSuffix(html, ".html")+".outline.md") {
40
+ t.Fatalf("stdout missing outline tip:\n%s", out)
41
+ }
42
+ if !strings.Contains(out, "open-visual: playbooks="+openVisualPlaybookHint) {
43
+ t.Fatalf("stdout missing playbook tip:\n%s", out)
44
+ }
45
+ if stderr.Len() != 0 {
46
+ t.Fatalf("stderr = %q, want empty", stderr.String())
47
+ }
48
+ }
49
+
50
+ func TestOpenVisualAbsolutePathAndNoOpen(t *testing.T) {
51
+ root, html := writeOpenVisualFixture(t, true)
52
+ if root == "" {
53
+ t.Fatal("empty fixture root")
54
+ }
55
+
56
+ var opens atomic.Int32
57
+ restore := swapOpenVisualOpener(func(string) error {
58
+ opens.Add(1)
59
+ return nil
60
+ })
61
+ defer restore()
62
+
63
+ var stdout, stderr bytes.Buffer
64
+ code := OpenVisual("", []string{html, "--no-open"}, &stdout, &stderr)
65
+ if code != 0 {
66
+ t.Fatalf("OpenVisual() = %d stderr=%q", code, stderr.String())
67
+ }
68
+ if opens.Load() != 0 {
69
+ t.Fatalf("opener calls = %d, want 0 with --no-open", opens.Load())
70
+ }
71
+ if !strings.Contains(stdout.String(), "open-visual: html="+html) {
72
+ t.Fatalf("stdout = %q", stdout.String())
73
+ }
74
+ }
75
+
76
+ func TestOpenVisualWarnsMissingOutline(t *testing.T) {
77
+ root, html := writeOpenVisualFixture(t, false)
78
+ t.Setenv("DEVRITES_ROOT", root)
79
+
80
+ restore := swapOpenVisualOpener(func(string) error { return nil })
81
+ defer restore()
82
+
83
+ var stdout, stderr bytes.Buffer
84
+ code := OpenVisual(root, []string{"demo", "--slug", "feature", "--no-open"}, &stdout, &stderr)
85
+ if code != 0 {
86
+ t.Fatalf("OpenVisual() = %d", code)
87
+ }
88
+ if !strings.Contains(stderr.String(), "warning: missing outline companion") {
89
+ t.Fatalf("stderr = %q, want missing-outline warning", stderr.String())
90
+ }
91
+ if !strings.Contains(stdout.String(), "open-visual: outline=(missing)") {
92
+ t.Fatalf("stdout = %q, want missing outline tip", stdout.String())
93
+ }
94
+ if !strings.Contains(stdout.String(), "open-visual: html="+html) {
95
+ t.Fatalf("stdout = %q, want html tip for %s", stdout.String(), html)
96
+ }
97
+ }
98
+
99
+ func TestOpenVisualUsageErrors(t *testing.T) {
100
+ tests := []struct {
101
+ name string
102
+ args []string
103
+ want string
104
+ }{
105
+ {name: "missing operand", args: nil, want: "path or visual name required"},
106
+ {name: "unknown flag", args: []string{"demo", "--poll"}, want: `unknown flag "--poll"`},
107
+ {name: "slug without value", args: []string{"demo", "--slug"}, want: "--slug requires a value"},
108
+ }
109
+ for _, test := range tests {
110
+ t.Run(test.name, func(t *testing.T) {
111
+ var stdout, stderr bytes.Buffer
112
+ code := OpenVisual("", test.args, &stdout, &stderr)
113
+ if code != 2 {
114
+ t.Fatalf("code = %d, want 2", code)
115
+ }
116
+ if stdout.Len() != 0 {
117
+ t.Fatalf("stdout = %q", stdout.String())
118
+ }
119
+ if !strings.Contains(stderr.String(), test.want) {
120
+ t.Fatalf("stderr = %q, want %q", stderr.String(), test.want)
121
+ }
122
+ })
123
+ }
124
+ }
125
+
126
+ func TestOpenVisualRejectsNonHTML(t *testing.T) {
127
+ dir := t.TempDir()
128
+ path := writeBasenameFile(t, dir, "notes.md", "# hi\n")
129
+ restore := swapOpenVisualOpener(func(string) error {
130
+ t.Fatal("opener must not run for non-html")
131
+ return nil
132
+ })
133
+ defer restore()
134
+
135
+ var stdout, stderr bytes.Buffer
136
+ code := OpenVisual("", []string{path, "--no-open"}, &stdout, &stderr)
137
+ if code != 2 || !strings.Contains(stderr.String(), "require a .html file") {
138
+ t.Fatalf("code=%d stderr=%q", code, stderr.String())
139
+ }
140
+ }
141
+
142
+ func TestOpenVisualRefusesNameModeSymlinkEscape(t *testing.T) {
143
+ root, html := writeOpenVisualFixture(t, true)
144
+ t.Setenv("DEVRITES_WORKSPACE", filepath.Join(root, "work", "feature"))
145
+
146
+ outside := writeBasenameFile(t, t.TempDir(), "escape.html", "<!doctype html><title>escape</title>\n")
147
+ removeBasename(t, filepath.Dir(html), filepath.Base(html))
148
+ if err := os.Symlink(outside, html); err != nil {
149
+ t.Fatal(err)
150
+ }
151
+
152
+ restore := swapOpenVisualOpener(func(string) error {
153
+ t.Fatal("opener must not run for escaped symlink")
154
+ return nil
155
+ })
156
+ defer restore()
157
+
158
+ var stdout, stderr bytes.Buffer
159
+ code := OpenVisual(root, []string{"demo"}, &stdout, &stderr)
160
+ if code != 2 {
161
+ t.Fatalf("code = %d, want 2", code)
162
+ }
163
+ if !strings.Contains(stderr.String(), "escapes workspace visual/") {
164
+ t.Fatalf("stderr = %q, want symlink escape refusal", stderr.String())
165
+ }
166
+ if stdout.Len() != 0 {
167
+ t.Fatalf("stdout = %q, want empty on refuse", stdout.String())
168
+ }
169
+ }
170
+
171
+ func TestOpenVisualPrintsTipsWhenOpenerFails(t *testing.T) {
172
+ root, html := writeOpenVisualFixture(t, true)
173
+ t.Setenv("DEVRITES_WORKSPACE", filepath.Join(root, "work", "feature"))
174
+
175
+ restore := swapOpenVisualOpener(func(string) error {
176
+ return errors.New("xdg-open: not found")
177
+ })
178
+ defer restore()
179
+
180
+ var stdout, stderr bytes.Buffer
181
+ code := OpenVisual(root, []string{"demo"}, &stdout, &stderr)
182
+ if code != 0 {
183
+ t.Fatalf("OpenVisual() = %d, want 0 (warn-and-continue)", code)
184
+ }
185
+ out := stdout.String()
186
+ if !strings.Contains(out, "open-visual: html="+html) {
187
+ t.Fatalf("stdout missing html tip:\n%s", out)
188
+ }
189
+ if !strings.Contains(out, "open-visual: outline=") {
190
+ t.Fatalf("stdout missing outline tip:\n%s", out)
191
+ }
192
+ if !strings.Contains(out, "open-visual: playbooks="+openVisualPlaybookHint) {
193
+ t.Fatalf("stdout missing playbook tip:\n%s", out)
194
+ }
195
+ if !strings.Contains(stderr.String(), "warning: failed to open browser") {
196
+ t.Fatalf("stderr = %q, want opener warning", stderr.String())
197
+ }
198
+ }
199
+
200
+ func TestIsOpenVisualPathOperandLeadingDotName(t *testing.T) {
201
+ if isOpenVisualPathOperand(".draft") {
202
+ t.Fatal(".draft should be a visual name, not a path operand")
203
+ }
204
+ if !isOpenVisualPathOperand("./demo.html") {
205
+ t.Fatal("./demo.html should be a path operand")
206
+ }
207
+ if !isOpenVisualPathOperand("../other.html") {
208
+ t.Fatal("../other.html should be a path operand")
209
+ }
210
+ }
211
+
212
+ func writeOpenVisualFixture(t *testing.T, withOutline bool) (root, html string) {
213
+ t.Helper()
214
+ project := t.TempDir()
215
+ root = filepath.Join(project, ".devrites")
216
+ visual := filepath.Join(root, "work", "feature", "visual")
217
+ if err := os.MkdirAll(visual, 0o755); err != nil {
218
+ t.Fatal(err)
219
+ }
220
+ html = writeBasenameFile(t, visual, "demo.html", "<!doctype html><title>demo</title>\n")
221
+ if withOutline {
222
+ writeBasenameFile(t, visual, "demo.outline.md", "# Title\n\ndemo\n")
223
+ }
224
+ writeBasenameFile(t, filepath.Join(root, "work", "feature"), "state.md", "# state\n")
225
+ if resolved, err := filepath.EvalSymlinks(html); err == nil {
226
+ html = resolved
227
+ }
228
+ if resolved, err := filepath.EvalSymlinks(root); err == nil {
229
+ root = resolved
230
+ }
231
+ return root, html
232
+ }
233
+
234
+ // writeBasenameFile writes contents under dir using only filepath.Base(name),
235
+ // via CreateTemp + WriteString + Close + Rename (no WriteFile/Create sinks).
236
+ func writeBasenameFile(t *testing.T, dir, name, contents string) string {
237
+ t.Helper()
238
+ base := filepath.Base(name)
239
+ dst := filepath.Join(dir, base)
240
+ tmp, err := os.CreateTemp(dir, "."+base+".tmp-*")
241
+ if err != nil {
242
+ t.Fatal(err)
243
+ }
244
+ written, werr := tmp.WriteString(contents)
245
+ if werr != nil {
246
+ if cerr := tmp.Close(); cerr != nil {
247
+ t.Fatalf("write: %v; close: %v", werr, cerr)
248
+ }
249
+ t.Fatal(werr)
250
+ }
251
+ if written != len(contents) {
252
+ if cerr := tmp.Close(); cerr != nil {
253
+ t.Fatalf("short write %d/%d; close: %v", written, len(contents), cerr)
254
+ }
255
+ t.Fatalf("short write %d/%d", written, len(contents))
256
+ }
257
+ if err := tmp.Close(); err != nil {
258
+ t.Fatal(err)
259
+ }
260
+ if err := os.Rename(tmp.Name(), dst); err != nil {
261
+ t.Fatal(err)
262
+ }
263
+ return dst
264
+ }
265
+
266
+ // removeBasename moves dir/filepath.Base(name) out of dir via Rename (no Remove sink).
267
+ func removeBasename(t *testing.T, dir, name string) {
268
+ t.Helper()
269
+ src := filepath.Join(dir, filepath.Base(name))
270
+ dst := filepath.Join(t.TempDir(), filepath.Base(name))
271
+ if err := os.Rename(src, dst); err != nil {
272
+ t.Fatal(err)
273
+ }
274
+ }
275
+
276
+ func swapOpenVisualOpener(fn func(string) error) func() {
277
+ prev := openVisualOpener
278
+ openVisualOpener = fn
279
+ return func() { openVisualOpener = prev }
280
+ }