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
@@ -0,0 +1,163 @@
1
+ package lib
2
+
3
+ import (
4
+ "regexp"
5
+ "strings"
6
+ )
7
+
8
+ // htmlIDAttrRE matches id="..." or id='...' attributes without a full HTML parser.
9
+ var htmlIDAttrRE = regexp.MustCompile(`(?i)\bid\s*=\s*(?:"([^"]*)"|'([^']*)')`)
10
+
11
+ // VisualIDConsistency summarizes outline ## ID inventory vs HTML id attributes.
12
+ //
13
+ // Policy: warn only for inventory → HTML mismatches. HTML-only ids (for example
14
+ // SVG marker defs such as id="arrow") are treated as decorative and are not
15
+ // reported, to keep open-visual tips quiet on intentional landmark-only
16
+ // inventories.
17
+ type VisualIDConsistency struct {
18
+ Inventory []string
19
+ HTML []string
20
+ MissingInHTML []string
21
+ }
22
+
23
+ // ParseOutlineInventoryIDs extracts HTML ids from the outline ## ID inventory table.
24
+ func ParseOutlineInventoryIDs(outline string) []string {
25
+ section := extractMarkdownSection(outline, "ID inventory")
26
+ if section == "" {
27
+ return nil
28
+ }
29
+ return parseMarkdownFirstColumnIDs(section)
30
+ }
31
+
32
+ // ScanHTMLIDs returns unique id="..." / id='...' attribute values in document order.
33
+ func ScanHTMLIDs(html string) []string {
34
+ matches := htmlIDAttrRE.FindAllStringSubmatch(html, -1)
35
+ if len(matches) == 0 {
36
+ return nil
37
+ }
38
+ seen := make(map[string]struct{}, len(matches))
39
+ ids := make([]string, 0, len(matches))
40
+ for _, m := range matches {
41
+ id := m[1]
42
+ if id == "" {
43
+ id = m[2]
44
+ }
45
+ id = strings.TrimSpace(id)
46
+ if id == "" {
47
+ continue
48
+ }
49
+ if _, ok := seen[id]; ok {
50
+ continue
51
+ }
52
+ seen[id] = struct{}{}
53
+ ids = append(ids, id)
54
+ }
55
+ return ids
56
+ }
57
+
58
+ // CheckVisualIDConsistency compares outline inventory ids against HTML attributes.
59
+ func CheckVisualIDConsistency(html, outline string) VisualIDConsistency {
60
+ inventory := ParseOutlineInventoryIDs(outline)
61
+ htmlIDs := ScanHTMLIDs(html)
62
+ report := VisualIDConsistency{
63
+ Inventory: inventory,
64
+ HTML: htmlIDs,
65
+ }
66
+ if len(inventory) == 0 {
67
+ return report
68
+ }
69
+ htmlSet := make(map[string]struct{}, len(htmlIDs))
70
+ for _, id := range htmlIDs {
71
+ htmlSet[id] = struct{}{}
72
+ }
73
+ for _, id := range inventory {
74
+ if _, ok := htmlSet[id]; !ok {
75
+ report.MissingInHTML = append(report.MissingInHTML, id)
76
+ }
77
+ }
78
+ return report
79
+ }
80
+
81
+ func extractMarkdownSection(md, heading string) string {
82
+ lines := strings.Split(md, "\n")
83
+ start := -1
84
+ for i, line := range lines {
85
+ trimmed := strings.TrimSpace(line)
86
+ if !strings.HasPrefix(trimmed, "## ") {
87
+ continue
88
+ }
89
+ h := strings.TrimSpace(strings.TrimPrefix(trimmed, "## "))
90
+ if strings.EqualFold(h, heading) {
91
+ start = i + 1
92
+ continue
93
+ }
94
+ if start >= 0 {
95
+ return strings.Join(lines[start:i], "\n")
96
+ }
97
+ }
98
+ if start < 0 {
99
+ return ""
100
+ }
101
+ return strings.Join(lines[start:], "\n")
102
+ }
103
+
104
+ func parseMarkdownFirstColumnIDs(section string) []string {
105
+ var ids []string
106
+ seen := make(map[string]struct{})
107
+ for _, line := range strings.Split(section, "\n") {
108
+ line = strings.TrimSpace(line)
109
+ if !strings.HasPrefix(line, "|") {
110
+ continue
111
+ }
112
+ cells := splitMarkdownRow(line)
113
+ if len(cells) == 0 {
114
+ continue
115
+ }
116
+ raw := strings.TrimSpace(cells[0])
117
+ if raw == "" || isMarkdownSeparatorCell(raw) {
118
+ continue
119
+ }
120
+ normalizedHeader := strings.ToLower(strings.ReplaceAll(raw, "`", ""))
121
+ normalizedHeader = strings.Join(strings.Fields(normalizedHeader), " ")
122
+ if normalizedHeader == "html id" || normalizedHeader == "id" {
123
+ continue
124
+ }
125
+ id := strings.TrimSpace(strings.Trim(raw, "`"))
126
+ if id == "" {
127
+ continue
128
+ }
129
+ if _, ok := seen[id]; ok {
130
+ continue
131
+ }
132
+ seen[id] = struct{}{}
133
+ ids = append(ids, id)
134
+ }
135
+ return ids
136
+ }
137
+
138
+ func splitMarkdownRow(line string) []string {
139
+ line = strings.TrimSpace(line)
140
+ line = strings.TrimPrefix(line, "|")
141
+ line = strings.TrimSuffix(line, "|")
142
+ parts := strings.Split(line, "|")
143
+ cells := make([]string, 0, len(parts))
144
+ for _, part := range parts {
145
+ cells = append(cells, strings.TrimSpace(part))
146
+ }
147
+ return cells
148
+ }
149
+
150
+ func isMarkdownSeparatorCell(cell string) bool {
151
+ if cell == "" {
152
+ return false
153
+ }
154
+ for _, r := range cell {
155
+ switch r {
156
+ case '-', ':', ' ':
157
+ continue
158
+ default:
159
+ return false
160
+ }
161
+ }
162
+ return strings.ContainsRune(cell, '-')
163
+ }
@@ -0,0 +1,161 @@
1
+ package lib
2
+
3
+ import (
4
+ "bytes"
5
+ "path/filepath"
6
+ "strings"
7
+ "testing"
8
+ )
9
+
10
+ func TestParseOutlineInventoryIDs(t *testing.T) {
11
+ outline := `# Title
12
+
13
+ ## Purpose
14
+ demo
15
+
16
+ ## ID inventory
17
+
18
+ | HTML ` + "`id`" + ` | Meaning |
19
+ | --- | --- |
20
+ | ` + "`viz-title`" + ` | Header |
21
+ | ` + "`diagram-overview`" + ` | Overview |
22
+ | viz-title | duplicate ignored |
23
+
24
+ ## Relationships
25
+ | From | To | Note |
26
+ | --- | --- | --- |
27
+ | a | b | c |
28
+ `
29
+ got := ParseOutlineInventoryIDs(outline)
30
+ want := []string{"viz-title", "diagram-overview"}
31
+ if len(got) != len(want) {
32
+ t.Fatalf("ids = %#v, want %#v", got, want)
33
+ }
34
+ for i := range want {
35
+ if got[i] != want[i] {
36
+ t.Fatalf("ids = %#v, want %#v", got, want)
37
+ }
38
+ }
39
+ }
40
+
41
+ func TestScanHTMLIDs(t *testing.T) {
42
+ html := `<!doctype html><div id="viz-title"></div><svg><marker id='arrow'></marker><g id="viz-title"></g></svg>`
43
+ got := ScanHTMLIDs(html)
44
+ want := []string{"viz-title", "arrow"}
45
+ if len(got) != len(want) {
46
+ t.Fatalf("ids = %#v, want %#v", got, want)
47
+ }
48
+ for i := range want {
49
+ if got[i] != want[i] {
50
+ t.Fatalf("ids = %#v, want %#v", got, want)
51
+ }
52
+ }
53
+ }
54
+
55
+ func TestCheckVisualIDConsistencyMatch(t *testing.T) {
56
+ html := `<section id="viz-title"></section><section id="diagram-overview"></section><marker id="arrow"></marker>`
57
+ outline := `## ID inventory
58
+ | HTML id | Meaning |
59
+ | --- | --- |
60
+ | viz-title | Header |
61
+ | diagram-overview | Overview |
62
+ `
63
+ report := CheckVisualIDConsistency(html, outline)
64
+ if len(report.MissingInHTML) != 0 {
65
+ t.Fatalf("MissingInHTML = %#v, want empty", report.MissingInHTML)
66
+ }
67
+ if len(report.Inventory) != 2 {
68
+ t.Fatalf("Inventory = %#v, want 2 ids", report.Inventory)
69
+ }
70
+ // HTML-only decorative ids are not reported.
71
+ if len(report.HTML) != 3 {
72
+ t.Fatalf("HTML = %#v, want viz-title, diagram-overview, arrow", report.HTML)
73
+ }
74
+ }
75
+
76
+ func TestCheckVisualIDConsistencyMissingInHTML(t *testing.T) {
77
+ html := `<section id="viz-title"></section>`
78
+ outline := `## ID inventory
79
+ | HTML id | Meaning |
80
+ | --- | --- |
81
+ | viz-title | Header |
82
+ | viz-open-questions | Open questions |
83
+ `
84
+ report := CheckVisualIDConsistency(html, outline)
85
+ if len(report.MissingInHTML) != 1 || report.MissingInHTML[0] != "viz-open-questions" {
86
+ t.Fatalf("MissingInHTML = %#v, want [viz-open-questions]", report.MissingInHTML)
87
+ }
88
+ }
89
+
90
+ func TestCheckVisualIDConsistencyMissingOutlineSection(t *testing.T) {
91
+ report := CheckVisualIDConsistency(`<div id="x"></div>`, "# Title\n\ndemo\n")
92
+ if len(report.Inventory) != 0 || len(report.MissingInHTML) != 0 {
93
+ t.Fatalf("report = %#v, want empty inventory/missing", report)
94
+ }
95
+ }
96
+
97
+ func TestOpenVisualWarnsIDMismatch(t *testing.T) {
98
+ root, html := writeOpenVisualFixture(t, true)
99
+ t.Setenv("DEVRITES_WORKSPACE", filepath.Join(root, "work", "feature"))
100
+
101
+ outline := strings.TrimSuffix(html, ".html") + ".outline.md"
102
+ writeBasenameFile(t, filepath.Dir(outline), filepath.Base(outline), `# demo
103
+
104
+ ## ID inventory
105
+ | HTML id | Meaning |
106
+ | --- | --- |
107
+ | viz-title | Header |
108
+ | missing-node | Absent from HTML |
109
+ `)
110
+ writeBasenameFile(t, filepath.Dir(html), filepath.Base(html), `<!doctype html><title>demo</title><section id="viz-title"></section>
111
+ `)
112
+
113
+ restore := swapOpenVisualOpener(func(string) error { return nil })
114
+ defer restore()
115
+
116
+ var stdout, stderr bytes.Buffer
117
+ code := OpenVisual(root, []string{"demo", "--no-open"}, &stdout, &stderr)
118
+ if code != 0 {
119
+ t.Fatalf("OpenVisual() = %d stderr=%q", code, stderr.String())
120
+ }
121
+ if !strings.Contains(stderr.String(), "outline inventory id(s) missing from HTML") {
122
+ t.Fatalf("stderr = %q, want missing-id warning", stderr.String())
123
+ }
124
+ if !strings.Contains(stderr.String(), "missing-node") {
125
+ t.Fatalf("stderr = %q, want missing-node listed", stderr.String())
126
+ }
127
+ if !strings.Contains(stdout.String(), "open-visual: ids=mismatch") {
128
+ t.Fatalf("stdout = %q, want ids=mismatch tip", stdout.String())
129
+ }
130
+ }
131
+
132
+ func TestOpenVisualIDsOkTip(t *testing.T) {
133
+ root, html := writeOpenVisualFixture(t, true)
134
+ t.Setenv("DEVRITES_WORKSPACE", filepath.Join(root, "work", "feature"))
135
+
136
+ outline := strings.TrimSuffix(html, ".html") + ".outline.md"
137
+ writeBasenameFile(t, filepath.Dir(outline), filepath.Base(outline), `# demo
138
+
139
+ ## ID inventory
140
+ | HTML id | Meaning |
141
+ | --- | --- |
142
+ | viz-title | Header |
143
+ `)
144
+ writeBasenameFile(t, filepath.Dir(html), filepath.Base(html), `<!doctype html><title>demo</title><section id="viz-title"></section><marker id="arrow"></marker>
145
+ `)
146
+
147
+ restore := swapOpenVisualOpener(func(string) error { return nil })
148
+ defer restore()
149
+
150
+ var stdout, stderr bytes.Buffer
151
+ code := OpenVisual(root, []string{"demo", "--no-open"}, &stdout, &stderr)
152
+ if code != 0 {
153
+ t.Fatalf("OpenVisual() = %d stderr=%q", code, stderr.String())
154
+ }
155
+ if stderr.Len() != 0 {
156
+ t.Fatalf("stderr = %q, want empty when consistent", stderr.String())
157
+ }
158
+ if !strings.Contains(stdout.String(), "open-visual: ids=ok (1 inventory)") {
159
+ t.Fatalf("stdout = %q, want ids=ok tip", stdout.String())
160
+ }
161
+ }
package/engine/main.go CHANGED
@@ -34,6 +34,8 @@ Usage:
34
34
  devrites-engine state resolve <qid> "<ans>" Resolve an open question and update state atomically
35
35
  devrites-engine state close <slug> Archive a shipped feature and clear ACTIVE
36
36
  devrites-engine secret-scan [--staged] [--stdin] [slug] Scan exact staged blobs, stdin, or touched files; HIGH blocks
37
+ devrites-engine open-visual <path-or-name> [--slug <slug>] [--no-open]
38
+ Resolve a visual HTML file, optionally open it locally, print agent paths
37
39
  devrites-engine version Print the engine binary's version
38
40
  Exit codes:
39
41
  0 ok / gate passed
@@ -87,6 +89,8 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
87
89
  return cmdState(root, args[1:], stdout, stderr)
88
90
  case "secret-scan":
89
91
  return lib.SecretScan(root, args[1:], stdin, stdout, stderr)
92
+ case "open-visual":
93
+ return lib.OpenVisual(root, args[1:], stdout, stderr)
90
94
  case "version", "--version":
91
95
  fmt.Fprintln(stdout, version.Version)
92
96
  return exitOK
@@ -21,6 +21,7 @@ func TestRootModeForCoversReadAndWriteSurfaces(t *testing.T) {
21
21
  {name: "close", command: "state", args: []string{"close"}, want: rootStrict},
22
22
  {name: "unknown state command", command: "state", args: []string{"unknown"}, want: rootUnused},
23
23
  {name: "secret scan", command: "secret-scan", want: rootLenient},
24
+ {name: "open visual", command: "open-visual", want: rootLenient},
24
25
  }
25
26
 
26
27
  for _, test := range tests {
@@ -102,9 +103,9 @@ func TestNestedCommandFamiliesAreRoutedAndAdvertised(t *testing.T) {
102
103
  for _, args := range [][]string{{"check", "candidate"}, {"check", "readiness"}, {"check", "seal"}, {"state", "resolve"}, {"state", "close"}} {
103
104
  t.Run(strings.Join(args, "-"), func(t *testing.T) {
104
105
  var stdout, stderr bytes.Buffer
105
- _ = run(args, strings.NewReader(""), &stdout, &stderr)
106
+ code := run(args, strings.NewReader(""), &stdout, &stderr)
106
107
  if strings.Contains(stderr.String(), "unknown command") || strings.Contains(stderr.String(), "unknown check") || strings.Contains(stderr.String(), "unknown state") {
107
- t.Fatalf("%q was not routed: %s", args, stderr.String())
108
+ t.Fatalf("%q was not routed (exit=%d): %s", args, code, stderr.String())
108
109
  }
109
110
  })
110
111
  }
@@ -153,13 +154,9 @@ func TestCheckCandidateRoutesAndPrintsIdentity(t *testing.T) {
153
154
  if err := os.MkdirAll(workspace, 0o755); err != nil {
154
155
  t.Fatal(err)
155
156
  }
156
- if err := os.WriteFile(filepath.Join(project, "source.go"), []byte("package source\n"), 0o644); err != nil {
157
- t.Fatal(err)
158
- }
157
+ writeBasenameFile(t, project, "source.go", "package source\n")
159
158
  manifest := "# Touched files\n\n## Touched files\nCandidate paths are declared below.\n\n## Candidate manifest\n| State | File | Slice | Reason |\n| --- | --- | --- | --- |\n| present | `source.go` | S-1 | Implementation. |\n"
160
- if err := os.WriteFile(filepath.Join(workspace, "touched-files.md"), []byte(manifest), 0o644); err != nil {
161
- t.Fatal(err)
162
- }
159
+ writeBasenameFile(t, workspace, "touched-files.md", manifest)
163
160
  t.Setenv("DEVRITES_ROOT", root)
164
161
  var stdout, stderr bytes.Buffer
165
162
  if code := run([]string{"check", "candidate", "feature"}, strings.NewReader(""), &stdout, &stderr); code != exitOK {
@@ -186,9 +183,7 @@ func TestCheckReadinessEmitBindingRoutesOnlyExactShape(t *testing.T) {
186
183
  if err := os.MkdirAll(workspace, 0o755); err != nil {
187
184
  t.Fatal(err)
188
185
  }
189
- if err := os.WriteFile(filepath.Join(workspace, name), []byte(body), 0o644); err != nil {
190
- t.Fatal(err)
191
- }
186
+ writeBasenameFile(t, workspace, name, body)
192
187
  }
193
188
  t.Setenv("DEVRITES_ROOT", root)
194
189
 
@@ -217,9 +212,7 @@ func TestCheckReadinessEmitBindingRoutesOnlyExactShape(t *testing.T) {
217
212
  }
218
213
  }
219
214
 
220
- if err := os.Remove(filepath.Join(workspace, "plan.md")); err != nil {
221
- t.Fatal(err)
222
- }
215
+ removeBasename(t, workspace, "plan.md")
223
216
  stdout.Reset()
224
217
  stderr.Reset()
225
218
  if code := run([]string{"check", "readiness", "--emit-binding", "feature"}, strings.NewReader(""), &stdout, &stderr); code != exitBlocked || stdout.Len() != 0 || !strings.Contains(stderr.String(), "readiness-binding: BLOCKED") {
@@ -245,3 +238,45 @@ func TestSecretScanRoutesPRBodyThroughStdin(t *testing.T) {
245
238
  t.Fatal("secret material disclosed")
246
239
  }
247
240
  }
241
+
242
+ // writeBasenameFile writes contents under dir using only filepath.Base(name),
243
+ // via CreateTemp + WriteString + Close + Rename (no WriteFile/Create sinks).
244
+ func writeBasenameFile(t *testing.T, dir, name, contents string) string {
245
+ t.Helper()
246
+ base := filepath.Base(name)
247
+ dst := filepath.Join(dir, base)
248
+ tmp, err := os.CreateTemp(dir, "."+base+".tmp-*")
249
+ if err != nil {
250
+ t.Fatal(err)
251
+ }
252
+ written, werr := tmp.WriteString(contents)
253
+ if werr != nil {
254
+ if cerr := tmp.Close(); cerr != nil {
255
+ t.Fatalf("write: %v; close: %v", werr, cerr)
256
+ }
257
+ t.Fatal(werr)
258
+ }
259
+ if written != len(contents) {
260
+ if cerr := tmp.Close(); cerr != nil {
261
+ t.Fatalf("short write %d/%d; close: %v", written, len(contents), cerr)
262
+ }
263
+ t.Fatalf("short write %d/%d", written, len(contents))
264
+ }
265
+ if err := tmp.Close(); err != nil {
266
+ t.Fatal(err)
267
+ }
268
+ if err := os.Rename(tmp.Name(), dst); err != nil {
269
+ t.Fatal(err)
270
+ }
271
+ return dst
272
+ }
273
+
274
+ // removeBasename moves dir/filepath.Base(name) out of dir via Rename (no Remove sink).
275
+ func removeBasename(t *testing.T, dir, name string) {
276
+ t.Helper()
277
+ src := filepath.Join(dir, filepath.Base(name))
278
+ dst := filepath.Join(t.TempDir(), filepath.Base(name))
279
+ if err := os.Rename(src, dst); err != nil {
280
+ t.Fatal(err)
281
+ }
282
+ }