devrites 3.2.17 → 3.2.18

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
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
+ ## [3.2.18](https://github.com/ViktorsBaikers/DevRites/compare/v3.2.17...v3.2.18) (2026-07-28)
6
+
7
+ ### Fixed
8
+
9
+ * **devrites:** fail closed on filesystem errors ([e0e6c6c](https://github.com/ViktorsBaikers/DevRites/commit/e0e6c6c5c9114fc423cc704b8031d4bcb3728831))
10
+ * **devrites:** validate archive entry types ([02235fb](https://github.com/ViktorsBaikers/DevRites/commit/02235fb078ff4c15bdd36e58c57cc32e6bba8b75))
11
+
5
12
  ## [3.2.17](https://github.com/ViktorsBaikers/DevRites/compare/v3.2.16...v3.2.17) (2026-07-27)
6
13
 
7
14
  ### Fixed
package/README.md CHANGED
@@ -25,7 +25,7 @@ final commit, push, and tag, and it requires a typed `GO` confirmation.
25
25
  Unattended runs may create local WIP checkpoint commits along the way, but only
26
26
  Ship collapses and pushes them.
27
27
 
28
- **Status:** [`v3.2.17`](https://github.com/ViktorsBaikers/DevRites/releases/tag/v3.2.17): see [`CHANGELOG.md`](CHANGELOG.md) for release notes.
28
+ **Status:** [`v3.2.18`](https://github.com/ViktorsBaikers/DevRites/releases/tag/v3.2.18): see [`CHANGELOG.md`](CHANGELOG.md) for release notes.
29
29
 
30
30
  ## Quick start
31
31
 
package/engine/Makefile CHANGED
@@ -6,9 +6,9 @@ PLATFORMS := darwin/arm64 darwin/amd64 linux/amd64 linux/arm64 windows/amd64
6
6
 
7
7
  .PHONY: build fmt-check test test-race vet quality staticcheck govulncheck gosec cover crosscompile clean
8
8
 
9
- STATICCHECK_VERSION := 2025.1.1
10
- GOVULNCHECK_VERSION := v1.5.0
11
- GOSEC_VERSION := v2.27.1
9
+ STATICCHECK_VERSION := 2026.1
10
+ GOVULNCHECK_VERSION := v1.6.0
11
+ GOSEC_VERSION := v2.28.0
12
12
  STATICCHECK := go run honnef.co/go/tools/cmd/staticcheck@$(STATICCHECK_VERSION)
13
13
  GOVULNCHECK := go run golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION)
14
14
  GOSEC := go run github.com/securego/gosec/v2/cmd/gosec@$(GOSEC_VERSION)
@@ -280,14 +280,20 @@ func newRunner(opts Options) (*runner, error) {
280
280
  source = inferSourceDir()
281
281
  }
282
282
  if source != "" {
283
- source, _ = filepath.Abs(source)
283
+ source, err = filepath.Abs(source)
284
+ if err != nil {
285
+ return nil, fmt.Errorf("resolve source %s: %w", opts.SourceDir, err)
286
+ }
284
287
  }
285
288
  payload := opts.PayloadDir
286
289
  if payload == "" {
287
290
  payload = inferPayloadDir(source)
288
291
  }
289
292
  if payload != "" {
290
- payload, _ = filepath.Abs(payload)
293
+ payload, err = filepath.Abs(payload)
294
+ if err != nil {
295
+ return nil, fmt.Errorf("resolve payload %s: %w", opts.PayloadDir, err)
296
+ }
291
297
  }
292
298
  r := &runner{
293
299
  opts: opts,
@@ -304,7 +310,10 @@ func newRunner(opts Options) (*runner, error) {
304
310
  return nil, fmt.Errorf("validate payload: %w", err)
305
311
  }
306
312
  }
307
- r.prev = readManifest(filepath.Join(target, ManifestName))
313
+ r.prev, err = readManifest(filepath.Join(target, ManifestName))
314
+ if err != nil {
315
+ return nil, err
316
+ }
308
317
  return r, nil
309
318
  }
310
319
 
@@ -737,13 +746,15 @@ func (r *runner) mergeMarkerFile(merge hostpack.MarkerMerge) error {
737
746
  dest := filepath.Join(r.target, filepath.FromSlash(merge.TargetRel))
738
747
  if r.opts.DryRun {
739
748
  verb := "create DevRites block"
740
- if exists(dest) {
741
- current, _ := os.ReadFile(dest)
749
+ current, readErr := os.ReadFile(dest)
750
+ if readErr == nil {
742
751
  if bytes.Contains(current, []byte(merge.Begin)) {
743
752
  verb = "refresh DevRites block"
744
753
  } else {
745
754
  verb = "append DevRites block"
746
755
  }
756
+ } else if !errors.Is(readErr, fs.ErrNotExist) {
757
+ return fmt.Errorf("cannot read %s: %w", merge.TargetRel, readErr)
747
758
  }
748
759
  fmt.Fprintf(r.opts.Stdout, " [merge] %s (%s)\n", merge.TargetRel, verb)
749
760
  } else {
@@ -751,8 +762,11 @@ func (r *runner) mergeMarkerFile(merge hostpack.MarkerMerge) error {
751
762
  return err
752
763
  }
753
764
  next := block
754
- if current, err := os.ReadFile(dest); err == nil {
765
+ current, readErr := os.ReadFile(dest)
766
+ if readErr == nil {
755
767
  next = hostpack.MergeMarkerBlock(current, block, merge.Begin, merge.End)
768
+ } else if !errors.Is(readErr, fs.ErrNotExist) {
769
+ return fmt.Errorf("cannot read %s: %w", merge.TargetRel, readErr)
756
770
  }
757
771
  if err := fsutil.WriteFileAtomic(dest, next, 0o644); err != nil {
758
772
  return fmt.Errorf("cannot write %s: %w", merge.TargetRel, err)
@@ -974,7 +988,10 @@ func (r *runner) flagsString() string {
974
988
 
975
989
  func (r *runner) uninstall() error {
976
990
  mf := filepath.Join(r.target, ManifestName)
977
- entries := readManifestList(mf)
991
+ entries, err := readManifestList(mf)
992
+ if err != nil {
993
+ return err
994
+ }
978
995
  if len(entries) == 0 {
979
996
  return fmt.Errorf("no DevRites manifest at %s - nothing to uninstall", mf)
980
997
  }
@@ -1922,20 +1939,23 @@ func readEngineVersion(path string, timeout time.Duration) (string, error) {
1922
1939
  return line, nil
1923
1940
  }
1924
1941
 
1925
- func readManifest(path string) map[string]managedRecord {
1926
- records, _ := parseManifest(path)
1927
- return records
1942
+ func readManifest(path string) (map[string]managedRecord, error) {
1943
+ records, _, err := parseManifest(path)
1944
+ return records, err
1928
1945
  }
1929
1946
 
1930
- func readManifestList(path string) []string {
1931
- _, entries := parseManifest(path)
1932
- return entries
1947
+ func readManifestList(path string) ([]string, error) {
1948
+ _, entries, err := parseManifest(path)
1949
+ return entries, err
1933
1950
  }
1934
1951
 
1935
- func parseManifest(path string) (map[string]managedRecord, []string) {
1952
+ func parseManifest(path string) (map[string]managedRecord, []string, error) {
1936
1953
  data, err := os.ReadFile(path)
1937
1954
  if err != nil {
1938
- return map[string]managedRecord{}, nil
1955
+ if errors.Is(err, fs.ErrNotExist) {
1956
+ return map[string]managedRecord{}, nil, nil
1957
+ }
1958
+ return nil, nil, fmt.Errorf("read manifest %s: %w", path, err)
1939
1959
  }
1940
1960
  hashes := map[string]string{}
1941
1961
  var out []string
@@ -1967,7 +1987,7 @@ func parseManifest(path string) (map[string]managedRecord, []string) {
1967
1987
  for _, rel := range out {
1968
1988
  records[rel] = managedRecord{Hash: hashes[rel]}
1969
1989
  }
1970
- return records, out
1990
+ return records, out, nil
1971
1991
  }
1972
1992
 
1973
1993
  func validManagedHash(hash string) bool {
@@ -18,6 +18,7 @@ import (
18
18
  "testing"
19
19
  "time"
20
20
 
21
+ "github.com/devrites/devrites/internal/hostpack"
21
22
  "github.com/devrites/devrites/internal/testutil"
22
23
  )
23
24
 
@@ -188,6 +189,50 @@ func TestMarkerMergeAndUninstallPreserveUserContent(t *testing.T) {
188
189
  }
189
190
  }
190
191
 
192
+ func TestMarkerMergeDryRunReportsUnreadableTarget(t *testing.T) {
193
+ payload := t.TempDir()
194
+ if err := os.WriteFile(filepath.Join(payload, "block.md"), []byte("DevRites\n"), 0o644); err != nil {
195
+ t.Fatal(err)
196
+ }
197
+ target := t.TempDir()
198
+ if err := os.Mkdir(filepath.Join(target, "AGENTS.md"), 0o755); err != nil {
199
+ t.Fatal(err)
200
+ }
201
+ r := runner{
202
+ opts: Options{DryRun: true, Stdout: &bytes.Buffer{}},
203
+ target: target,
204
+ payloadFS: os.DirFS(payload),
205
+ }
206
+ err := r.mergeMarkerFile(hostpack.MarkerMerge{
207
+ TargetRel: "AGENTS.md",
208
+ PayloadRel: "block.md",
209
+ Begin: "<!-- BEGIN -->",
210
+ End: "<!-- END -->",
211
+ })
212
+ if err == nil || !strings.Contains(err.Error(), "cannot read AGENTS.md") {
213
+ t.Fatalf("mergeMarkerFile error = %v, want target read error", err)
214
+ }
215
+ }
216
+
217
+ func TestUninstallReportsUnreadableManifest(t *testing.T) {
218
+ target := t.TempDir()
219
+ if err := os.Mkdir(filepath.Dir(filepath.Join(target, ManifestName)), 0o755); err != nil {
220
+ t.Fatal(err)
221
+ }
222
+ if err := os.Mkdir(filepath.Join(target, ManifestName), 0o755); err != nil {
223
+ t.Fatal(err)
224
+ }
225
+ opts := DefaultOptions(ModeUninstall)
226
+ opts.Target = target
227
+ err := Apply(opts)
228
+ if err == nil || !strings.Contains(err.Error(), "read manifest") {
229
+ t.Fatalf("Apply error = %v, want manifest read error", err)
230
+ }
231
+ if info, statErr := os.Stat(filepath.Join(target, ManifestName)); statErr != nil || !info.IsDir() {
232
+ t.Fatalf("manifest was replaced: info=%v err=%v", info, statErr)
233
+ }
234
+ }
235
+
191
236
  func TestInstallMergesClaudeHooksIntoExistingSettings(t *testing.T) {
192
237
  t.Setenv("DEVRITES_NO_BINARY", "1")
193
238
  payload := testPayload(t)
@@ -259,7 +304,11 @@ func TestInstallKeepsClaudeMergeOwnershipAcrossReinstall(t *testing.T) {
259
304
  if !exists(filepath.Join(target, filepath.FromSlash(markerRel))) {
260
305
  t.Fatal("first install did not create the Claude hook merge marker")
261
306
  }
262
- if _, ok := readManifest(filepath.Join(target, ManifestName))[markerRel]; !ok {
307
+ manifestRecords, err := readManifest(filepath.Join(target, ManifestName))
308
+ if err != nil {
309
+ t.Fatal(err)
310
+ }
311
+ if _, ok := manifestRecords[markerRel]; !ok {
263
312
  t.Fatalf("first install did not record the Claude hook merge marker:\n%s", testutil.ReadFile(t, filepath.Join(target, ManifestName)))
264
313
  }
265
314
  runInstall(t, target, payload, func(o *Options) {})
@@ -690,9 +690,9 @@ func repoChangedSince(root, stamp string) bool {
690
690
  }
691
691
  stampTime := info.ModTime()
692
692
  changed := false
693
- _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
693
+ if err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
694
694
  if err != nil {
695
- return nil
695
+ return err
696
696
  }
697
697
  if d.IsDir() {
698
698
  if path != root && refreshExcludedDirs[d.Name()] {
@@ -705,7 +705,9 @@ func repoChangedSince(root, stamp string) bool {
705
705
  return filepath.SkipAll
706
706
  }
707
707
  return nil
708
- })
708
+ }); err != nil {
709
+ return true
710
+ }
709
711
  return changed
710
712
  }
711
713
 
@@ -548,3 +548,14 @@ func TestRefreshStateAndChangeScan(t *testing.T) {
548
548
  t.Fatalf("repoChangedSince=false after tracked file write, want true")
549
549
  }
550
550
  }
551
+
552
+ func TestRepoChangedSinceTreatsScanFailureAsChanged(t *testing.T) {
553
+ base := t.TempDir()
554
+ stamp := filepath.Join(base, "stamp")
555
+ if err := os.WriteFile(stamp, nil, 0o644); err != nil {
556
+ t.Fatal(err)
557
+ }
558
+ if !repoChangedSince(filepath.Join(base, "missing"), stamp) {
559
+ t.Fatal("repoChangedSince=false after repository scan failure, want conservative refresh")
560
+ }
561
+ }
@@ -584,8 +584,11 @@ func SpecDedupe(root string, args []string, stdout, stderr io.Writer) int {
584
584
  }
585
585
  var hits []hit
586
586
  for _, base := range targets {
587
- _ = filepath.WalkDir(base, func(p string, d os.DirEntry, err error) error {
588
- if err != nil || d.IsDir() || !strings.HasSuffix(strings.ToLower(p), ".md") {
587
+ err := filepath.WalkDir(base, func(p string, d os.DirEntry, err error) error {
588
+ if err != nil {
589
+ return err
590
+ }
591
+ if d.IsDir() || !strings.HasSuffix(strings.ToLower(p), ".md") {
589
592
  return nil
590
593
  }
591
594
  lowName := strings.ToLower(filepath.Base(p))
@@ -594,7 +597,7 @@ func SpecDedupe(root string, args []string, stdout, stderr io.Writer) int {
594
597
  }
595
598
  b, err := os.ReadFile(p) // #nosec G122 -- scoring walk over the project's own .scratch tree; a symlink race requires an attacker already writing to the checkout
596
599
  if err != nil {
597
- return nil
600
+ return err
598
601
  }
599
602
  text := strings.ToLower(string(b))
600
603
  score := 0
@@ -604,11 +607,18 @@ func SpecDedupe(root string, args []string, stdout, stderr io.Writer) int {
604
607
  }
605
608
  }
606
609
  if score >= 2 || score == len(terms) {
607
- rel, _ := filepath.Rel(project, p)
610
+ rel, err := filepath.Rel(project, p)
611
+ if err != nil {
612
+ return err
613
+ }
608
614
  hits = append(hits, hit{score: score, path: filepath.ToSlash(rel), line: firstLine(string(b))})
609
615
  }
610
616
  return nil
611
617
  })
618
+ if err != nil && !os.IsNotExist(err) {
619
+ fmt.Fprintf(stderr, "spec-dedupe: search %s: %v\n", base, err)
620
+ return 1
621
+ }
612
622
  }
613
623
  sort.Slice(hits, func(i, j int) bool {
614
624
  if hits[i].score == hits[j].score {
@@ -53,8 +53,16 @@ func Analyze(root string, args []string, stdout, stderr io.Writer) int {
53
53
  return 2
54
54
  }
55
55
 
56
- specData, _ := os.ReadFile(spec)
57
- tasksData, _ := os.ReadFile(tasks)
56
+ specData, err := os.ReadFile(spec)
57
+ if err != nil {
58
+ fmt.Fprintf(stderr, "analyze: read spec.md: %v\n", err)
59
+ return 2
60
+ }
61
+ tasksData, err := os.ReadFile(tasks)
62
+ if err != nil {
63
+ fmt.Fprintf(stderr, "analyze: read tasks.md: %v\n", err)
64
+ return 2
65
+ }
58
66
 
59
67
  specACs := sortedACIDs(acIDRe, specData, true) // legacy brackets stripped: "[AC1]" -> "AC1"
60
68
  taskACs := sortedACIDs(taskACRe, tasksData, false)
@@ -1,8 +1,10 @@
1
1
  package lib
2
2
 
3
3
  import (
4
+ "errors"
4
5
  "fmt"
5
6
  "io"
7
+ "io/fs"
6
8
  "os"
7
9
  "path/filepath"
8
10
  "sort"
@@ -11,11 +13,11 @@ import (
11
13
 
12
14
  // ArchiveSearch surfaces shipped features whose spec.md overlaps a query, so
13
15
  // /rite-spec can spot prior art before writing a new spec: an extension, a
14
- // conflict, or a re-spec of solved work. It is advisory, not a gate: it always
15
- // exits 0 when it runs (empty output means no overlap), and reserves exit 2 for
16
- // a missing query. The query is the feature's key nouns, one or many args; a
17
- // quoted phrase is split on whitespace. Ranking is by the count of distinct query
18
- // terms a spec contains, highest first, ties broken by slug.
16
+ // conflict, or a re-spec of solved work. It is advisory, not a gate: empty
17
+ // output means no overlap. Exit 2 means a missing query; exit 3 means the
18
+ // archive could not be read. The query is the feature's key nouns, one or many
19
+ // args; a quoted phrase is split on whitespace. Ranking is by the count of
20
+ // distinct query terms a spec contains, highest first, ties broken by slug.
19
21
  //
20
22
  // args is `<term>...`. Terms shorter than three characters are dropped as noise.
21
23
  func ArchiveSearch(root string, args []string, stdout, stderr io.Writer) int {
@@ -34,10 +36,24 @@ func ArchiveSearch(root string, args []string, stdout, stderr io.Writer) int {
34
36
  return 2 // usage
35
37
  }
36
38
 
37
- entries, err := os.ReadDir(filepath.Join(root, "archive"))
39
+ archive := filepath.Join(root, "archive")
40
+ info, err := os.Stat(archive)
38
41
  if err != nil {
39
- // No archive yet: no prior art to surface. Silent, successful no-op.
40
- return 0
42
+ if errors.Is(err, fs.ErrNotExist) {
43
+ // No archive yet: no prior art to surface. Silent, successful no-op.
44
+ return 0
45
+ }
46
+ fmt.Fprintf(stderr, "archive-search: read archive: %v\n", err)
47
+ return 3
48
+ }
49
+ if !info.IsDir() {
50
+ fmt.Fprintf(stderr, "archive-search: read archive: %s is not a directory\n", archive)
51
+ return 3
52
+ }
53
+ entries, err := os.ReadDir(archive)
54
+ if err != nil {
55
+ fmt.Fprintf(stderr, "archive-search: read archive: %v\n", err)
56
+ return 3
41
57
  }
42
58
 
43
59
  type hit struct {
@@ -51,9 +67,25 @@ func ArchiveSearch(root string, args []string, stdout, stderr io.Writer) int {
51
67
  continue
52
68
  }
53
69
  spec := filepath.Join(root, "archive", e.Name(), "spec.md")
70
+ info, err := os.Stat(spec)
71
+ if err != nil {
72
+ if errors.Is(err, fs.ErrNotExist) {
73
+ continue
74
+ }
75
+ fmt.Fprintf(stderr, "archive-search: read %s: %v\n", spec, err)
76
+ return 3
77
+ }
78
+ if !info.Mode().IsRegular() {
79
+ fmt.Fprintf(stderr, "archive-search: read %s: not a regular file\n", spec)
80
+ return 3
81
+ }
54
82
  b, err := os.ReadFile(spec)
55
83
  if err != nil {
56
- continue
84
+ if errors.Is(err, fs.ErrNotExist) {
85
+ continue
86
+ }
87
+ fmt.Fprintf(stderr, "archive-search: read %s: %v\n", spec, err)
88
+ return 3
57
89
  }
58
90
  body := strings.ToLower(string(b))
59
91
  n := 0
@@ -98,6 +98,34 @@ func TestArchiveSearchNoArchive(t *testing.T) {
98
98
  }
99
99
  }
100
100
 
101
+ func TestArchiveSearchReportsUnreadableArchive(t *testing.T) {
102
+ root := t.TempDir()
103
+ if err := os.WriteFile(filepath.Join(root, "archive"), []byte("not a directory"), 0o644); err != nil {
104
+ t.Fatal(err)
105
+ }
106
+ stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{}
107
+ if code := ArchiveSearch(root, []string{"anything"}, stdout, stderr); code != 3 {
108
+ t.Fatalf("code=%d, want 3; stderr=%q", code, stderr.String())
109
+ }
110
+ if !strings.Contains(stderr.String(), "read archive") {
111
+ t.Fatalf("stderr=%q, want archive read error", stderr.String())
112
+ }
113
+ }
114
+
115
+ func TestArchiveSearchReportsUnreadableSpec(t *testing.T) {
116
+ root := t.TempDir()
117
+ if err := os.MkdirAll(filepath.Join(root, "archive", "broken", "spec.md"), 0o755); err != nil {
118
+ t.Fatal(err)
119
+ }
120
+ stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{}
121
+ if code := ArchiveSearch(root, []string{"anything"}, stdout, stderr); code != 3 {
122
+ t.Fatalf("code=%d, want 3; stderr=%q", code, stderr.String())
123
+ }
124
+ if !strings.Contains(stderr.String(), "archive-search: read") {
125
+ t.Fatalf("stderr=%q, want spec read error", stderr.String())
126
+ }
127
+ }
128
+
101
129
  func writeArchiveSpec(t *testing.T, root, slug, content string) {
102
130
  t.Helper()
103
131
  dir := filepath.Join(root, "archive", slug)
@@ -32,7 +32,11 @@ func BuildReadiness(root string, args []string, stdout, stderr io.Writer) int {
32
32
  return readinessCode("workspace-missing")
33
33
  }
34
34
 
35
- data, _ := os.ReadFile(s)
35
+ data, err := os.ReadFile(s)
36
+ if err != nil {
37
+ fmt.Fprintf(stderr, "readiness: cannot read state.md: %v\n", err)
38
+ return readinessCode("workspace-missing")
39
+ }
36
40
  lines := splitLinesNoTrailing(data)
37
41
 
38
42
  status := readinessField(lines, "Status")
@@ -2,8 +2,10 @@ package lib
2
2
 
3
3
  import (
4
4
  "encoding/json"
5
+ "errors"
5
6
  "fmt"
6
7
  "io"
8
+ "io/fs"
7
9
  "os"
8
10
  "path/filepath"
9
11
  "strings"
@@ -258,7 +260,10 @@ func upsertContextBlock(path, block string) error {
258
260
  if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
259
261
  return fmt.Errorf("create context dir: %w", err)
260
262
  }
261
- existingBytes, _ := os.ReadFile(path)
263
+ existingBytes, err := os.ReadFile(path)
264
+ if err != nil && !errors.Is(err, fs.ErrNotExist) {
265
+ return fmt.Errorf("read existing context file: %w", err)
266
+ }
262
267
  existing := string(existingBytes)
263
268
  var next string
264
269
  start := strings.Index(existing, contextStart)
@@ -481,7 +481,11 @@ func extensionsSync(extDir, projectDir string, stdout, stderr io.Writer) int {
481
481
  fmt.Fprintln(stderr, "extensions: not syncing: validation failed (run `devrites-engine extensions validate`)")
482
482
  return code
483
483
  }
484
- exts, _ := discoverExtensions(extDir)
484
+ exts, err := discoverExtensions(extDir)
485
+ if err != nil {
486
+ fmt.Fprintf(stderr, "extensions: discovery failed: %v\n", err)
487
+ return 1
488
+ }
485
489
  if len(exts) == 0 {
486
490
  fmt.Fprintln(stdout, "extensions: nothing to sync")
487
491
  return 0
@@ -367,8 +367,11 @@ func countSubdirs(arch string) int {
367
367
  // anyFileNewerThan reports whether any regular file under arch was modified after t.
368
368
  func anyFileNewerThan(arch string, t time.Time) bool {
369
369
  newer := false
370
- _ = filepath.WalkDir(arch, func(_ string, d os.DirEntry, err error) error {
371
- if err != nil || d.IsDir() {
370
+ err := filepath.WalkDir(arch, func(_ string, d os.DirEntry, err error) error {
371
+ if err != nil {
372
+ return err
373
+ }
374
+ if d.IsDir() {
372
375
  return nil
373
376
  }
374
377
  if info, err := d.Info(); err == nil && info.Mode().IsRegular() && info.ModTime().After(t) {
@@ -377,6 +380,9 @@ func anyFileNewerThan(arch string, t time.Time) bool {
377
380
  }
378
381
  return nil
379
382
  })
383
+ if err != nil && !os.IsNotExist(err) {
384
+ return true
385
+ }
380
386
  return newer
381
387
  }
382
388
 
@@ -92,7 +92,11 @@ func planFold(root, workspaceDir string, stderr io.Writer) ([]capabilityFold, in
92
92
 
93
93
  var folds []capabilityFold
94
94
  for _, capability := range order {
95
- fold := foldCapability(root, capability, byCap[capability])
95
+ fold, err := foldCapability(root, capability, byCap[capability])
96
+ if err != nil {
97
+ fmt.Fprintf(stderr, "ledger: %v\n", err)
98
+ return nil, 2
99
+ }
96
100
  folds = append(folds, fold)
97
101
  }
98
102
  return folds, 0
@@ -102,8 +106,17 @@ func planFold(root, workspaceDir string, stderr io.Writer) ([]capabilityFold, in
102
106
  // A requirement with no delta kind (a flat feature spec) folds as ADDED. ADDED and
103
107
  // MODIFIED are both an upsert (replace in place if the header exists, else append);
104
108
  // REMOVED deletes. Ordering: existing blocks keep their position, new blocks append.
105
- func foldCapability(root, capability string, deltas []Requirement) capabilityFold {
106
- existing, _ := ParseSpec(ledgerCapabilityPath(root, capability))
109
+ func foldCapability(root, capability string, deltas []Requirement) (capabilityFold, error) {
110
+ path := ledgerCapabilityPath(root, capability)
111
+ var existing *SpecDoc
112
+ if _, err := os.Stat(path); err == nil {
113
+ existing, err = ParseSpec(path)
114
+ if err != nil {
115
+ return capabilityFold{}, fmt.Errorf("read capability %s: %w", capability, err)
116
+ }
117
+ } else if !os.IsNotExist(err) {
118
+ return capabilityFold{}, fmt.Errorf("inspect capability %s: %w", capability, err)
119
+ }
107
120
  var blocks []Requirement
108
121
  idx := map[string]int{}
109
122
  if existing != nil {
@@ -140,7 +153,7 @@ func foldCapability(root, capability string, deltas []Requirement) capabilityFol
140
153
  }
141
154
  }
142
155
  fold.blocks = blocks
143
- return fold
156
+ return fold, nil
144
157
  }
145
158
 
146
159
  // renderLedgerSpec assembles a capability's ledger spec.md from its resolved blocks.
@@ -160,3 +160,27 @@ The system SHALL group appearance controls together.
160
160
  }
161
161
  }
162
162
  }
163
+
164
+ func TestLedgerSyncDoesNotOverwriteUnreadableCapability(t *testing.T) {
165
+ root := t.TempDir()
166
+ capability := ledgerCapabilityPath(root, "billing")
167
+ if err := os.MkdirAll(capability, 0o755); err != nil {
168
+ t.Fatal(err)
169
+ }
170
+ wsParent := t.TempDir()
171
+ writeSpec(t, wsParent, "feat", `## ADDED Requirements — capability: billing
172
+ ### Requirement: Invoice export
173
+ The system SHALL export invoices.
174
+ `)
175
+ stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{}
176
+ code := Ledger(root, []string{"sync", filepath.Join(wsParent, "feat")}, stdout, stderr)
177
+ if code != 2 {
178
+ t.Fatalf("sync code=%d, want 2; stdout=%q stderr=%q", code, stdout.String(), stderr.String())
179
+ }
180
+ if !strings.Contains(stderr.String(), "read capability billing") {
181
+ t.Fatalf("stderr=%q, want capability read error", stderr.String())
182
+ }
183
+ if info, err := os.Stat(capability); err != nil || !info.IsDir() {
184
+ t.Fatalf("capability was overwritten: info=%v err=%v", info, err)
185
+ }
186
+ }
@@ -1,8 +1,10 @@
1
1
  package lib
2
2
 
3
3
  import (
4
+ "errors"
4
5
  "fmt"
5
6
  "io"
7
+ "io/fs"
6
8
  "os"
7
9
  "path/filepath"
8
10
  "regexp"
@@ -182,9 +184,12 @@ func resolveNextQID(qpath string, stdout, stderr io.Writer) int {
182
184
  if qpath == "" {
183
185
  return fail(stderr, "Usage: devrites-engine resolve next-qid <questions.md path>", 5)
184
186
  }
185
- var content []byte
186
- if isFile(qpath) {
187
- content, _ = os.ReadFile(qpath)
187
+ content, err := os.ReadFile(qpath)
188
+ if err != nil {
189
+ if !errors.Is(err, fs.ErrNotExist) {
190
+ return fail(stderr, "read questions.md: "+err.Error(), 5)
191
+ }
192
+ content = nil
188
193
  }
189
194
  qid, err := nextQuestionID(content, clockNow())
190
195
  if err != nil {
@@ -48,6 +48,20 @@ func TestResolveAcceptsCanonicalUppercaseQuestionID(t *testing.T) {
48
48
  }
49
49
  }
50
50
 
51
+ func TestResolveNextQIDReportsUnreadableQuestions(t *testing.T) {
52
+ qpath := filepath.Join(t.TempDir(), "questions.md")
53
+ if err := os.Mkdir(qpath, 0o755); err != nil {
54
+ t.Fatal(err)
55
+ }
56
+ var stdout, stderr bytes.Buffer
57
+ if code := resolveNextQID(qpath, &stdout, &stderr); code != 5 {
58
+ t.Fatalf("code = %d, want 5; stdout=%q stderr=%q", code, stdout.String(), stderr.String())
59
+ }
60
+ if !strings.Contains(stderr.String(), "read questions.md") {
61
+ t.Fatalf("stderr = %q, want read error", stderr.String())
62
+ }
63
+ }
64
+
51
65
  func resolveWorkspace(t *testing.T, questions string) string {
52
66
  t.Helper()
53
67
  root := t.TempDir()
@@ -151,7 +151,10 @@ func executeRunbook(root, path string, start int, runID string, dry bool, stdout
151
151
  return 3
152
152
  }
153
153
  }
154
- _ = writeRunbookState(root, runbookState{RunID: runID, Source: path, Next: len(steps), Status: "completed"})
154
+ if err := writeRunbookState(root, runbookState{RunID: runID, Source: path, Next: len(steps), Status: "completed"}); err != nil {
155
+ fmt.Fprintf(stderr, "runbook: %v\n", err)
156
+ return 1
157
+ }
155
158
  fmt.Fprintf(stdout, "runbook: completed %s\n", runID)
156
159
  return 0
157
160
  }
@@ -60,6 +60,28 @@ func TestContextSyncRejectsUnsafePath(t *testing.T) {
60
60
  }
61
61
  }
62
62
 
63
+ func TestContextSyncDoesNotOverwriteUnreadableTarget(t *testing.T) {
64
+ project := t.TempDir()
65
+ root := filepath.Join(project, ".devrites")
66
+ if err := os.MkdirAll(root, 0o755); err != nil {
67
+ t.Fatal(err)
68
+ }
69
+ target := filepath.Join(project, "AGENTS.md")
70
+ if err := os.Mkdir(target, 0o755); err != nil {
71
+ t.Fatal(err)
72
+ }
73
+ stderr := &bytes.Buffer{}
74
+ if code := Context(root, []string{"sync", "AGENTS.md"}, &bytes.Buffer{}, stderr); code != 1 {
75
+ t.Fatalf("unreadable target code = %d, want 1; stderr=%q", code, stderr.String())
76
+ }
77
+ if !strings.Contains(stderr.String(), "read existing context file") {
78
+ t.Fatalf("stderr=%q, want target read error", stderr.String())
79
+ }
80
+ if info, err := os.Stat(target); err != nil || !info.IsDir() {
81
+ t.Fatalf("target was replaced: info=%v err=%v", info, err)
82
+ }
83
+ }
84
+
63
85
  func TestContextSyncRefusesExternalWorkspaceOverride(t *testing.T) {
64
86
  project := t.TempDir()
65
87
  root := filepath.Join(project, ".devrites")
@@ -137,3 +159,24 @@ func TestRunbookDryRunDoesNotExecuteShell(t *testing.T) {
137
159
  t.Fatal("dry run executed shell step")
138
160
  }
139
161
  }
162
+
163
+ func TestRunbookReportsCompletionStateWriteFailure(t *testing.T) {
164
+ root := filepath.Join(t.TempDir(), ".devrites")
165
+ if err := os.MkdirAll(root, 0o755); err != nil {
166
+ t.Fatal(err)
167
+ }
168
+ path := filepath.Join(t.TempDir(), "demo.yaml")
169
+ if err := os.WriteFile(path, []byte("steps:\n - rite: build\n"), 0o644); err != nil {
170
+ t.Fatal(err)
171
+ }
172
+ if err := os.WriteFile(filepath.Join(root, "runs"), []byte("blocks state directory"), 0o644); err != nil {
173
+ t.Fatal(err)
174
+ }
175
+ stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{}
176
+ if code := executeRunbook(root, path, 0, "test-run", false, stdout, stderr); code != 1 {
177
+ t.Fatalf("executeRunbook code = %d, want 1; stdout=%q stderr=%q", code, stdout.String(), stderr.String())
178
+ }
179
+ if strings.Contains(stdout.String(), "completed") || !strings.Contains(stderr.String(), "runbook:") {
180
+ t.Fatalf("completion write failure was misreported; stdout=%q stderr=%q", stdout.String(), stderr.String())
181
+ }
182
+ }
@@ -23,7 +23,7 @@ import (
23
23
  // 5 the file is missing, or the field holds a non-numeric value
24
24
  func TickAfk(args []string, stdout, stderr io.Writer) int {
25
25
  path := argAt(args, 0)
26
- if path == "" || !isFile(path) {
26
+ if path == "" {
27
27
  where := path
28
28
  if where == "" {
29
29
  where = "<unset>"
@@ -32,7 +32,11 @@ func TickAfk(args []string, stdout, stderr io.Writer) int {
32
32
  return 5
33
33
  }
34
34
 
35
- data, _ := os.ReadFile(path)
35
+ data, err := os.ReadFile(path)
36
+ if err != nil {
37
+ fmt.Fprintf(stderr, "tick-afk: read state.md at %s: %v\n", path, err)
38
+ return 5
39
+ }
36
40
  lines := splitLinesNoTrailing(data)
37
41
 
38
42
  remaining, found := readBudget(lines)
@@ -68,7 +68,11 @@ func Run(args []string, stdout, stderr io.Writer) int {
68
68
  fmt.Fprintln(stdout, path)
69
69
  return 0
70
70
  case "refresh":
71
- p := derive(root, rootSHA, headSHA)
71
+ p, err := derive(root, rootSHA, headSHA)
72
+ if err != nil {
73
+ fmt.Fprintf(stderr, "devrites: profile cache refresh failed: %v\n", err)
74
+ return 1
75
+ }
72
76
  if err := writeCache(path, p); err != nil {
73
77
  fmt.Fprintf(stderr, "devrites: profile cache write failed: %v\n", err)
74
78
  return 1
@@ -151,9 +155,12 @@ func writeCache(path string, p Profile) error {
151
155
  return os.Rename(name, path)
152
156
  }
153
157
 
154
- func derive(root, rootSHA, headSHA string) Profile {
158
+ func derive(root, rootSHA, headSHA string) (Profile, error) {
155
159
  p := Profile{ProfileSchemaVersion: schemaVersion, RootSHA: rootSHA, HeadSHA: headSHA, Inputs: map[string]FileInfo{}}
156
- entries, _ := os.ReadDir(root)
160
+ entries, err := os.ReadDir(root)
161
+ if err != nil {
162
+ return Profile{}, fmt.Errorf("read repository root: %w", err)
163
+ }
157
164
  for _, e := range entries {
158
165
  name := e.Name()
159
166
  if strings.HasPrefix(name, ".git") || name == "node_modules" {
@@ -165,12 +172,15 @@ func derive(root, rootSHA, headSHA string) Profile {
165
172
  p.TopLevel = append(p.TopLevel, name)
166
173
  }
167
174
  sort.Strings(p.TopLevel)
168
- _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
175
+ if err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
169
176
  if err != nil {
170
- return nil
177
+ return err
171
178
  }
172
179
  rel, err := filepath.Rel(root, path)
173
- if err != nil || rel == "." {
180
+ if err != nil {
181
+ return err
182
+ }
183
+ if rel == "." {
174
184
  return nil
175
185
  }
176
186
  rel = filepath.ToSlash(rel)
@@ -185,23 +195,27 @@ func derive(root, rootSHA, headSHA string) Profile {
185
195
  p.Manifests = append(p.Manifests, rel)
186
196
  }
187
197
  if isProfileInput(rel) {
188
- if info, ok := fileInfo(path); ok {
189
- p.Inputs[rel] = info
198
+ info, err := fileInfo(path)
199
+ if err != nil {
200
+ return err
190
201
  }
202
+ p.Inputs[rel] = info
191
203
  }
192
204
  return nil
193
- })
205
+ }); err != nil {
206
+ return Profile{}, fmt.Errorf("walk repository: %w", err)
207
+ }
194
208
  sort.Strings(p.Manifests)
195
- return p
209
+ return p, nil
196
210
  }
197
211
 
198
- func fileInfo(path string) (FileInfo, bool) {
212
+ func fileInfo(path string) (FileInfo, error) {
199
213
  raw, err := os.ReadFile(path)
200
214
  if err != nil {
201
- return FileInfo{}, false
215
+ return FileInfo{}, fmt.Errorf("read profile input %s: %w", path, err)
202
216
  }
203
217
  sum := sha256.Sum256(raw)
204
- return FileInfo{SHA256: hex.EncodeToString(sum[:]), Bytes: len(raw)}, true
218
+ return FileInfo{SHA256: hex.EncodeToString(sum[:]), Bytes: len(raw)}, nil
205
219
  }
206
220
 
207
221
  func cleanProfileInputs(root string) bool {
@@ -0,0 +1,13 @@
1
+ package profile
2
+
3
+ import (
4
+ "path/filepath"
5
+ "testing"
6
+ )
7
+
8
+ func TestDeriveReportsUnreadableRoot(t *testing.T) {
9
+ _, err := derive(filepath.Join(t.TempDir(), "missing"), "root", "head")
10
+ if err == nil {
11
+ t.Fatal("derive succeeded for a missing repository root")
12
+ }
13
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devrites",
3
- "version": "3.2.17",
3
+ "version": "3.2.18",
4
4
  "description": "DevRites: a disciplined senior-engineer workflow pack for Claude Code and Codex",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "homepage": "https://github.com/ViktorsBaikers/DevRites#readme",