devrites 3.2.17 → 3.2.19
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 +13 -0
- package/README.md +1 -1
- package/engine/Makefile +3 -3
- package/engine/internal/install/install.go +36 -16
- package/engine/internal/install/install_test.go +50 -1
- package/engine/internal/iohooks/iohooks.go +5 -3
- package/engine/internal/iohooks/iohooks_test.go +11 -0
- package/engine/internal/lib/adoption.go +14 -4
- package/engine/internal/lib/analyze.go +10 -2
- package/engine/internal/lib/archivesearch.go +41 -9
- package/engine/internal/lib/archivesearch_test.go +28 -0
- package/engine/internal/lib/buildreadiness.go +5 -1
- package/engine/internal/lib/context.go +6 -1
- package/engine/internal/lib/extensions.go +5 -1
- package/engine/internal/lib/learnings.go +8 -2
- package/engine/internal/lib/ledger.go +17 -4
- package/engine/internal/lib/ledger_test.go +24 -0
- package/engine/internal/lib/resolve.go +8 -3
- package/engine/internal/lib/resolve_remediation_test.go +14 -0
- package/engine/internal/lib/runbook.go +4 -1
- package/engine/internal/lib/runbook_context_test.go +43 -0
- package/engine/internal/lib/tickafk.go +6 -2
- package/engine/internal/profile/profile.go +27 -13
- package/engine/internal/profile/profile_test.go +13 -0
- package/engine/subagent_orient_context.txt +2 -1
- package/engine/testdata/golden/TestParitySubagentOrient/devrites-agent-subagent-type-key.golden +1 -1
- package/engine/testdata/golden/TestParitySubagentOrient/devrites-agent.golden +1 -1
- package/engine/tests/hook_test.go +1 -1
- package/pack/generated/codex/AGENTS.md +1 -0
- package/pack/generated/codex/agents/devrites-code-reviewer.toml +2 -0
- package/pack/generated/codex/agents/devrites-devex-reviewer.toml +2 -0
- package/pack/generated/codex/agents/devrites-doubt-reviewer.toml +2 -0
- package/pack/generated/codex/agents/devrites-evidence-scout.toml +2 -0
- package/pack/generated/codex/agents/devrites-forge-judge.toml +2 -0
- package/pack/generated/codex/agents/devrites-frontend-reviewer.toml +2 -0
- package/pack/generated/codex/agents/devrites-performance-reviewer.toml +2 -0
- package/pack/generated/codex/agents/devrites-plan-drafter.toml +2 -0
- package/pack/generated/codex/agents/devrites-plan-reviewer.toml +2 -0
- package/pack/generated/codex/agents/devrites-proof-runner.toml +2 -0
- package/pack/generated/codex/agents/devrites-retrospector.toml +2 -0
- package/pack/generated/codex/agents/devrites-security-auditor.toml +2 -0
- package/pack/generated/codex/agents/devrites-simplifier-reviewer.toml +2 -0
- package/pack/generated/codex/agents/devrites-slice-wright.toml +2 -0
- package/pack/generated/codex/agents/devrites-spec-reviewer.toml +2 -0
- package/pack/generated/codex/agents/devrites-strategy-reviewer.toml +2 -0
- package/pack/generated/codex/agents/devrites-test-analyst.toml +2 -0
- package/pack/generated/codex/agents/devrites-upgrade-planner.toml +2 -0
- package/pack/generated/codex/skills/devrites-api-interface/SKILL.md +1 -0
- package/pack/generated/codex/skills/devrites-audit/SKILL.md +1 -0
- package/pack/generated/codex/skills/devrites-browser-proof/SKILL.md +1 -0
- package/pack/generated/codex/skills/devrites-debug-recovery/SKILL.md +1 -0
- package/pack/generated/codex/skills/devrites-doubt/SKILL.md +1 -0
- package/pack/generated/codex/skills/devrites-frontend-craft/SKILL.md +1 -0
- package/pack/generated/codex/skills/devrites-interview/SKILL.md +1 -0
- package/pack/generated/codex/skills/devrites-lib/SKILL.md +1 -0
- package/pack/generated/codex/skills/devrites-prose-craft/SKILL.md +1 -0
- package/pack/generated/codex/skills/devrites-refresh-indexes/SKILL.md +1 -0
- package/pack/generated/codex/skills/devrites-source-driven/SKILL.md +1 -0
- package/pack/generated/codex/skills/devrites-ux-shape/SKILL.md +1 -0
- package/pack/generated/codex/skills/rite/SKILL.md +1 -0
- package/pack/generated/codex/skills/rite-adopt/SKILL.md +1 -0
- package/pack/generated/codex/skills/rite-autocomplete/SKILL.md +1 -0
- package/pack/generated/codex/skills/rite-build/SKILL.md +1 -0
- package/pack/generated/codex/skills/rite-clarify/SKILL.md +1 -0
- package/pack/generated/codex/skills/rite-converge/SKILL.md +1 -0
- package/pack/generated/codex/skills/rite-customize/SKILL.md +1 -0
- package/pack/generated/codex/skills/rite-define/SKILL.md +1 -0
- package/pack/generated/codex/skills/rite-doctor/SKILL.md +1 -0
- package/pack/generated/codex/skills/rite-dogfood/SKILL.md +1 -0
- package/pack/generated/codex/skills/rite-explain/SKILL.md +1 -0
- package/pack/generated/codex/skills/rite-frame/SKILL.md +1 -0
- package/pack/generated/codex/skills/rite-handoff/SKILL.md +1 -0
- package/pack/generated/codex/skills/rite-learn/SKILL.md +1 -0
- package/pack/generated/codex/skills/rite-plan/SKILL.md +1 -0
- package/pack/generated/codex/skills/rite-polish/SKILL.md +1 -0
- package/pack/generated/codex/skills/rite-pov/SKILL.md +1 -0
- package/pack/generated/codex/skills/rite-pr-feedback/SKILL.md +1 -0
- package/pack/generated/codex/skills/rite-pressure-test/SKILL.md +1 -0
- package/pack/generated/codex/skills/rite-prototype/SKILL.md +1 -0
- package/pack/generated/codex/skills/rite-prove/SKILL.md +1 -0
- package/pack/generated/codex/skills/rite-quick/SKILL.md +1 -0
- package/pack/generated/codex/skills/rite-resolve/SKILL.md +1 -0
- package/pack/generated/codex/skills/rite-review/SKILL.md +1 -0
- package/pack/generated/codex/skills/rite-seal/SKILL.md +1 -0
- package/pack/generated/codex/skills/rite-ship/SKILL.md +1 -0
- package/pack/generated/codex/skills/rite-spec/SKILL.md +1 -0
- package/pack/generated/codex/skills/rite-status/SKILL.md +1 -0
- package/pack/generated/codex/skills/rite-temper/SKILL.md +1 -0
- package/pack/generated/codex/skills/rite-upgrade/SKILL.md +1 -0
- package/pack/generated/codex/skills/rite-vet/SKILL.md +1 -0
- package/pack/generated/codex/skills/rite-zoom-out/SKILL.md +1 -0
- package/package.json +1 -1
- package/scripts/codex-generate.sh +3 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
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.19](https://github.com/ViktorsBaikers/DevRites/compare/v3.2.18...v3.2.19) (2026-07-28)
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
* **devrites:** recover ignored Codex reads ([9b0db60](https://github.com/ViktorsBaikers/DevRites/commit/9b0db603b298a8c8d7e36aafa61b909d11c00361))
|
|
10
|
+
|
|
11
|
+
## [3.2.18](https://github.com/ViktorsBaikers/DevRites/compare/v3.2.17...v3.2.18) (2026-07-28)
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
* **devrites:** fail closed on filesystem errors ([e0e6c6c](https://github.com/ViktorsBaikers/DevRites/commit/e0e6c6c5c9114fc423cc704b8031d4bcb3728831))
|
|
16
|
+
* **devrites:** validate archive entry types ([02235fb](https://github.com/ViktorsBaikers/DevRites/commit/02235fb078ff4c15bdd36e58c57cc32e6bba8b75))
|
|
17
|
+
|
|
5
18
|
## [3.2.17](https://github.com/ViktorsBaikers/DevRites/compare/v3.2.16...v3.2.17) (2026-07-27)
|
|
6
19
|
|
|
7
20
|
### 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.
|
|
28
|
+
**Status:** [`v3.2.19`](https://github.com/ViktorsBaikers/DevRites/releases/tag/v3.2.19): 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 :=
|
|
10
|
-
GOVULNCHECK_VERSION := v1.
|
|
11
|
-
GOSEC_VERSION := v2.
|
|
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,
|
|
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,
|
|
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
|
-
|
|
741
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
693
|
+
if err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
|
694
694
|
if err != nil {
|
|
695
|
-
return
|
|
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
|
-
|
|
588
|
-
if err != nil
|
|
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
|
|
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,
|
|
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,
|
|
57
|
-
|
|
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:
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
// quoted phrase is split on whitespace. Ranking is by the count of
|
|
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
|
-
|
|
39
|
+
archive := filepath.Join(root, "archive")
|
|
40
|
+
info, err := os.Stat(archive)
|
|
38
41
|
if err != nil {
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
371
|
-
if err != nil
|
|
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
|
-
|
|
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
|
-
|
|
186
|
-
if
|
|
187
|
-
|
|
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
|
-
|
|
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
|
}
|