devrites 3.2.16 → 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 +15 -0
- package/README.md +1 -1
- package/engine/Makefile +3 -3
- package/engine/hooks.go +20 -4
- package/engine/hooks_agent_dispatch.go +297 -48
- package/engine/hooks_events_test.go +40 -0
- package/engine/hooks_workspace.go +26 -6
- package/engine/internal/devritespaths/paths.go +80 -0
- 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 +36 -11
- package/engine/internal/lib/adoption_test.go +30 -0
- 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/closeout.go +34 -11
- package/engine/internal/lib/closeout_test.go +99 -0
- package/engine/internal/lib/context.go +6 -1
- package/engine/internal/lib/dispatchwaive.go +31 -0
- package/engine/internal/lib/dispatchwaive_test.go +23 -0
- 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/mutationgate.go +6 -2
- package/engine/internal/lib/reconcile.go +201 -64
- package/engine/internal/lib/reconcile_test.go +159 -12
- 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/testintegrity.go +4 -4
- package/engine/internal/lib/testintegrity_test.go +15 -0
- package/engine/internal/lib/tickafk.go +6 -2
- package/engine/internal/lib/util.go +128 -23
- package/engine/internal/profile/profile.go +27 -13
- package/engine/internal/profile/profile_test.go +13 -0
- package/engine/main.go +4 -1
- package/engine/testdata/golden/TestParityReconcile/close-window.golden +1 -0
- package/engine/testdata/golden/TestParityReconcile/not-git.golden +1 -1
- package/engine/testdata/golden/TestParityTestIntegrity/not-git.golden +1 -1
- package/engine/tests/hook_test.go +353 -17
- package/engine/tests/parity_reconcile_test.go +4 -1
- package/engine/tests/parity_testintegrity_test.go +1 -1
- package/pack/.claude/agents/devrites-code-reviewer.md +3 -0
- package/pack/.claude/agents/devrites-devex-reviewer.md +12 -8
- package/pack/.claude/agents/devrites-doubt-reviewer.md +3 -0
- package/pack/.claude/agents/devrites-forge-judge.md +4 -1
- package/pack/.claude/agents/devrites-frontend-reviewer.md +3 -0
- package/pack/.claude/agents/devrites-performance-reviewer.md +10 -9
- package/pack/.claude/agents/devrites-plan-reviewer.md +3 -0
- package/pack/.claude/agents/devrites-proof-runner.md +14 -14
- package/pack/.claude/agents/devrites-retrospector.md +3 -0
- package/pack/.claude/agents/devrites-security-auditor.md +3 -0
- package/pack/.claude/agents/devrites-simplifier-reviewer.md +3 -0
- package/pack/.claude/agents/devrites-slice-wright.md +4 -2
- package/pack/.claude/agents/devrites-spec-reviewer.md +3 -0
- package/pack/.claude/agents/devrites-strategy-reviewer.md +3 -0
- package/pack/.claude/agents/devrites-test-analyst.md +3 -0
- package/pack/.claude/skills/devrites-lib/reference/parallel-dispatch.md +8 -0
- package/pack/.claude/skills/devrites-lib/reference/standards/agents.md +10 -0
- package/pack/.claude/skills/devrites-source-driven/SKILL.md +1 -1
- package/pack/.claude/skills/rite-build/reference/phase-contract.md +17 -3
- package/pack/.claude/skills/rite-build/reference/wright-dispatch.md +3 -1
- package/pack/.claude/skills/rite-prove/SKILL.md +25 -14
- package/pack/.claude/skills/rite-temper/SKILL.md +1 -1
- package/pack/.claude/skills/rite-upgrade/SKILL.md +1 -1
- package/pack/.claude/skills/rite-vet/SKILL.md +18 -15
- package/pack/generated/claude/agents/devrites-code-reviewer.md +3 -0
- package/pack/generated/claude/agents/devrites-devex-reviewer.md +12 -8
- package/pack/generated/claude/agents/devrites-doubt-reviewer.md +3 -0
- package/pack/generated/claude/agents/devrites-forge-judge.md +4 -1
- package/pack/generated/claude/agents/devrites-frontend-reviewer.md +3 -0
- package/pack/generated/claude/agents/devrites-performance-reviewer.md +10 -9
- package/pack/generated/claude/agents/devrites-plan-reviewer.md +3 -0
- package/pack/generated/claude/agents/devrites-proof-runner.md +14 -14
- package/pack/generated/claude/agents/devrites-retrospector.md +3 -0
- package/pack/generated/claude/agents/devrites-security-auditor.md +3 -0
- package/pack/generated/claude/agents/devrites-simplifier-reviewer.md +3 -0
- package/pack/generated/claude/agents/devrites-slice-wright.md +4 -2
- package/pack/generated/claude/agents/devrites-spec-reviewer.md +3 -0
- package/pack/generated/claude/agents/devrites-strategy-reviewer.md +3 -0
- package/pack/generated/claude/agents/devrites-test-analyst.md +3 -0
- package/pack/generated/claude/skills/devrites-lib/reference/parallel-dispatch.md +8 -0
- package/pack/generated/claude/skills/devrites-lib/reference/standards/agents.md +10 -0
- package/pack/generated/claude/skills/devrites-source-driven/SKILL.md +1 -1
- package/pack/generated/claude/skills/rite-build/reference/phase-contract.md +17 -3
- package/pack/generated/claude/skills/rite-build/reference/wright-dispatch.md +3 -1
- package/pack/generated/claude/skills/rite-prove/SKILL.md +25 -14
- package/pack/generated/claude/skills/rite-temper/SKILL.md +1 -1
- package/pack/generated/claude/skills/rite-upgrade/SKILL.md +1 -1
- package/pack/generated/claude/skills/rite-vet/SKILL.md +18 -15
- package/pack/generated/codex/agents/devrites-code-reviewer.toml +3 -0
- package/pack/generated/codex/agents/devrites-devex-reviewer.toml +11 -7
- package/pack/generated/codex/agents/devrites-doubt-reviewer.toml +3 -0
- package/pack/generated/codex/agents/devrites-forge-judge.toml +4 -1
- package/pack/generated/codex/agents/devrites-frontend-reviewer.toml +3 -0
- package/pack/generated/codex/agents/devrites-performance-reviewer.toml +9 -8
- package/pack/generated/codex/agents/devrites-plan-reviewer.toml +3 -0
- package/pack/generated/codex/agents/devrites-proof-runner.toml +13 -13
- package/pack/generated/codex/agents/devrites-retrospector.toml +3 -0
- package/pack/generated/codex/agents/devrites-security-auditor.toml +3 -0
- package/pack/generated/codex/agents/devrites-simplifier-reviewer.toml +3 -0
- package/pack/generated/codex/agents/devrites-slice-wright.toml +4 -2
- package/pack/generated/codex/agents/devrites-spec-reviewer.toml +3 -0
- package/pack/generated/codex/agents/devrites-strategy-reviewer.toml +3 -0
- package/pack/generated/codex/agents/devrites-test-analyst.toml +3 -0
- package/pack/generated/codex/hooks.json +1 -1
- package/pack/generated/codex/skills/devrites-lib/reference/parallel-dispatch.md +8 -0
- package/pack/generated/codex/skills/devrites-lib/reference/standards/agents.md +10 -0
- package/pack/generated/codex/skills/devrites-source-driven/SKILL.md +1 -1
- package/pack/generated/codex/skills/rite-build/reference/phase-contract.md +17 -3
- package/pack/generated/codex/skills/rite-build/reference/wright-dispatch.md +3 -1
- package/pack/generated/codex/skills/rite-prove/SKILL.md +25 -14
- package/pack/generated/codex/skills/rite-temper/SKILL.md +1 -1
- package/pack/generated/codex/skills/rite-upgrade/SKILL.md +1 -1
- package/pack/generated/codex/skills/rite-vet/SKILL.md +18 -15
- package/package.json +1 -1
- package/scripts/codex-generate.sh +1 -1
|
@@ -5,6 +5,9 @@ import (
|
|
|
5
5
|
"io"
|
|
6
6
|
"os"
|
|
7
7
|
"path/filepath"
|
|
8
|
+
|
|
9
|
+
"github.com/devrites/devrites/internal/devritespaths"
|
|
10
|
+
"github.com/devrites/devrites/internal/state"
|
|
8
11
|
)
|
|
9
12
|
|
|
10
13
|
// CloseOut retires a shipped feature: it moves the feature directory into the
|
|
@@ -30,13 +33,27 @@ func CloseOut(root string, args []string, stdout, stderr io.Writer) int {
|
|
|
30
33
|
return 4
|
|
31
34
|
}
|
|
32
35
|
|
|
33
|
-
work :=
|
|
34
|
-
|
|
36
|
+
work, err := devritespaths.ExistingFeatureDirChecked(dv, slug)
|
|
37
|
+
if err != nil {
|
|
38
|
+
if os.IsNotExist(err) {
|
|
39
|
+
fmt.Fprintf(stderr, "close-out: no workspace for %s\n", slug)
|
|
40
|
+
return 4
|
|
41
|
+
}
|
|
42
|
+
fmt.Fprintf(stderr, "close-out: invalid workspace for %s: %v\n", slug, err)
|
|
43
|
+
return 1
|
|
44
|
+
}
|
|
45
|
+
archiveDir, err := devritespaths.ArchiveDirChecked(dv)
|
|
46
|
+
if err != nil {
|
|
47
|
+
fmt.Fprintf(stderr, "close-out: invalid archive directory: %v\n", err)
|
|
48
|
+
return 1
|
|
49
|
+
}
|
|
50
|
+
arch := filepath.Join(archiveDir, slug)
|
|
35
51
|
active := filepath.Join(dv, "ACTIVE")
|
|
36
52
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
53
|
+
activeSlug, err := devritespaths.ActiveSlug(dv)
|
|
54
|
+
if err != nil {
|
|
55
|
+
fmt.Fprintf(stderr, "close-out: invalid ACTIVE cursor: %v\n", err)
|
|
56
|
+
return 1
|
|
40
57
|
}
|
|
41
58
|
// A broken symlink at the destination still counts as "already there", so use
|
|
42
59
|
// Lstat rather than Stat to avoid silently overwriting it.
|
|
@@ -47,7 +64,7 @@ func CloseOut(root string, args []string, stdout, stderr io.Writer) int {
|
|
|
47
64
|
|
|
48
65
|
// Fail closed: if the move does not happen, leave the cursor alone and do not
|
|
49
66
|
// claim success: a lost audit trail must never look like a clean close-out.
|
|
50
|
-
if err := os.MkdirAll(
|
|
67
|
+
if err := os.MkdirAll(archiveDir, 0o755); err != nil {
|
|
51
68
|
fmt.Fprintf(stderr, "close-out: cannot create archive dir: %v\n", err)
|
|
52
69
|
return 1
|
|
53
70
|
}
|
|
@@ -57,12 +74,18 @@ func CloseOut(root string, args []string, stdout, stderr io.Writer) int {
|
|
|
57
74
|
}
|
|
58
75
|
|
|
59
76
|
// Clear ACTIVE only if it still points at the slug we just archived.
|
|
60
|
-
if
|
|
61
|
-
if
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
77
|
+
if activeSlug == slug {
|
|
78
|
+
if err := state.AtomicWrite(active, nil, 0o644); err != nil {
|
|
79
|
+
rollbackErr := os.Rename(arch, work)
|
|
80
|
+
if rollbackErr != nil {
|
|
81
|
+
fmt.Fprintf(stderr, "close-out: cannot clear ACTIVE after archiving: %v; rollback %s -> %s also failed: %v\n", err, arch, work, rollbackErr)
|
|
82
|
+
} else {
|
|
83
|
+
fmt.Fprintf(stderr, "close-out: cannot clear ACTIVE; archive move rolled back: %v\n", err)
|
|
84
|
+
}
|
|
85
|
+
return 1
|
|
65
86
|
}
|
|
87
|
+
fmt.Fprintf(stdout, "close-out: archived %s -> %s and cleared ACTIVE\n", slug, filepath.ToSlash(arch))
|
|
88
|
+
return 0
|
|
66
89
|
}
|
|
67
90
|
fmt.Fprintf(stdout, "close-out: archived %s -> %s (ACTIVE pointed elsewhere: left as-is)\n", slug, filepath.ToSlash(arch))
|
|
68
91
|
return 0
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
package lib
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"os"
|
|
6
|
+
"path/filepath"
|
|
7
|
+
"runtime"
|
|
8
|
+
"strings"
|
|
9
|
+
"testing"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
func runCloseOut(t *testing.T, root string, args ...string) (int, string) {
|
|
13
|
+
t.Helper()
|
|
14
|
+
stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{}
|
|
15
|
+
code := CloseOut(root, args, stdout, stderr)
|
|
16
|
+
return code, stdout.String() + stderr.String()
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
func TestCloseOutRejectsWorkspaceSymlinkEscape(t *testing.T) {
|
|
20
|
+
root := t.TempDir()
|
|
21
|
+
outside := t.TempDir()
|
|
22
|
+
if err := os.MkdirAll(filepath.Join(root, "work"), 0o755); err != nil {
|
|
23
|
+
t.Fatal(err)
|
|
24
|
+
}
|
|
25
|
+
if err := os.Symlink(outside, filepath.Join(root, "work", "feat")); err != nil {
|
|
26
|
+
t.Skipf("symlinks unavailable: %v", err)
|
|
27
|
+
}
|
|
28
|
+
writeFile(t, filepath.Join(outside, "state.md"), "outside\n")
|
|
29
|
+
|
|
30
|
+
code, out := runCloseOut(t, root, "feat")
|
|
31
|
+
if code != 1 {
|
|
32
|
+
t.Fatalf("close-out = %d, want 1\n%s", code, out)
|
|
33
|
+
}
|
|
34
|
+
if !strings.Contains(out, "invalid workspace") {
|
|
35
|
+
t.Fatalf("missing workspace diagnostic:\n%s", out)
|
|
36
|
+
}
|
|
37
|
+
if !isFile(filepath.Join(outside, "state.md")) {
|
|
38
|
+
t.Fatal("close-out moved a workspace through an external symlink")
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
func TestCloseOutRejectsArchiveSymlinkEscape(t *testing.T) {
|
|
43
|
+
root := t.TempDir()
|
|
44
|
+
outside := t.TempDir()
|
|
45
|
+
writeFile(t, filepath.Join(root, "work", "feat", "state.md"), "inside\n")
|
|
46
|
+
if err := os.Symlink(outside, filepath.Join(root, "archive")); err != nil {
|
|
47
|
+
t.Skipf("symlinks unavailable: %v", err)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
code, out := runCloseOut(t, root, "feat")
|
|
51
|
+
if code != 1 {
|
|
52
|
+
t.Fatalf("close-out = %d, want 1\n%s", code, out)
|
|
53
|
+
}
|
|
54
|
+
if !strings.Contains(out, "invalid archive") {
|
|
55
|
+
t.Fatalf("missing archive diagnostic:\n%s", out)
|
|
56
|
+
}
|
|
57
|
+
if !isFile(filepath.Join(root, "work", "feat", "state.md")) {
|
|
58
|
+
t.Fatal("close-out moved the workspace through an external archive symlink")
|
|
59
|
+
}
|
|
60
|
+
if entries, err := os.ReadDir(outside); err != nil {
|
|
61
|
+
t.Fatal(err)
|
|
62
|
+
} else if len(entries) != 0 {
|
|
63
|
+
t.Fatalf("outside archive changed: %v", entries)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
func TestCloseOutRollsBackArchiveWhenActiveCannotBeCleared(t *testing.T) {
|
|
68
|
+
if runtime.GOOS == "windows" {
|
|
69
|
+
t.Skip("permission model differs on Windows")
|
|
70
|
+
}
|
|
71
|
+
root := t.TempDir()
|
|
72
|
+
writeFile(t, filepath.Join(root, "work", "feat", "state.md"), "inside\n")
|
|
73
|
+
writeFile(t, filepath.Join(root, "ACTIVE"), "feat\n")
|
|
74
|
+
if err := os.MkdirAll(filepath.Join(root, "archive"), 0o755); err != nil {
|
|
75
|
+
t.Fatal(err)
|
|
76
|
+
}
|
|
77
|
+
if err := os.Chmod(root, 0o555); err != nil {
|
|
78
|
+
t.Fatal(err)
|
|
79
|
+
}
|
|
80
|
+
defer func() { _ = os.Chmod(root, 0o755) }()
|
|
81
|
+
|
|
82
|
+
code, out := runCloseOut(t, root, "feat")
|
|
83
|
+
if code != 1 {
|
|
84
|
+
t.Fatalf("close-out = %d, want 1\n%s", code, out)
|
|
85
|
+
}
|
|
86
|
+
if !strings.Contains(out, "archive move rolled back") {
|
|
87
|
+
t.Fatalf("missing rollback diagnostic:\n%s", out)
|
|
88
|
+
}
|
|
89
|
+
if !isFile(filepath.Join(root, "work", "feat", "state.md")) {
|
|
90
|
+
t.Fatal("workspace was not restored after ACTIVE clear failed")
|
|
91
|
+
}
|
|
92
|
+
if _, err := os.Lstat(filepath.Join(root, "archive", "feat")); !os.IsNotExist(err) {
|
|
93
|
+
t.Fatalf("archive remained after rollback: %v", err)
|
|
94
|
+
}
|
|
95
|
+
data, err := os.ReadFile(filepath.Join(root, "ACTIVE"))
|
|
96
|
+
if err != nil || string(data) != "feat\n" {
|
|
97
|
+
t.Fatalf("ACTIVE changed after rollback: %q, %v", data, err)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -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)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
package lib
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"io"
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
var dispatchWaiverReasons = map[string]struct{}{
|
|
9
|
+
"human-gate-before-dispatch": {},
|
|
10
|
+
"no-active-workspace": {},
|
|
11
|
+
"no-eligible-work": {},
|
|
12
|
+
"readiness-failed": {},
|
|
13
|
+
"wrong-phase": {},
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
func ValidDispatchWaiver(reason string) bool {
|
|
17
|
+
_, ok := dispatchWaiverReasons[reason]
|
|
18
|
+
return ok
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// DispatchWaive validates the reason token used by the agent-dispatch hook.
|
|
22
|
+
// The hook records the successful PostToolUse receipt; this command deliberately
|
|
23
|
+
// owns no separate state.
|
|
24
|
+
func DispatchWaive(args []string, stdout, stderr io.Writer) int {
|
|
25
|
+
if len(args) != 1 || !ValidDispatchWaiver(args[0]) {
|
|
26
|
+
fmt.Fprintln(stderr, "usage: devrites-engine dispatch-waive <human-gate-before-dispatch|no-active-workspace|no-eligible-work|readiness-failed|wrong-phase>")
|
|
27
|
+
return 2
|
|
28
|
+
}
|
|
29
|
+
fmt.Fprintf(stdout, "dispatch-waive: accepted %s\n", args[0])
|
|
30
|
+
return 0
|
|
31
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
package lib
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"strings"
|
|
6
|
+
"testing"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
func TestDispatchWaiveAcceptsOnlyKnownSingleReason(t *testing.T) {
|
|
10
|
+
var stdout, stderr bytes.Buffer
|
|
11
|
+
if code := DispatchWaive([]string{"wrong-phase"}, &stdout, &stderr); code != 0 {
|
|
12
|
+
t.Fatalf("valid reason code=%d stderr=%s", code, stderr.String())
|
|
13
|
+
}
|
|
14
|
+
if !strings.Contains(stdout.String(), "accepted wrong-phase") {
|
|
15
|
+
t.Fatalf("missing receipt: %s", stdout.String())
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
stdout.Reset()
|
|
19
|
+
stderr.Reset()
|
|
20
|
+
if code := DispatchWaive([]string{"anything"}, &stdout, &stderr); code != 2 {
|
|
21
|
+
t.Fatalf("invalid reason code=%d, want 2", code)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -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,6 +1,7 @@
|
|
|
1
1
|
package lib
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
|
+
"errors"
|
|
4
5
|
"fmt"
|
|
5
6
|
"io"
|
|
6
7
|
"os"
|
|
@@ -32,8 +33,11 @@ func MutationGate(root string, args []string, stdout, stderr io.Writer) int {
|
|
|
32
33
|
// Config files are looked up relative to the repo root, falling back to the
|
|
33
34
|
// working directory when this is not a git repo.
|
|
34
35
|
cwd, _ := os.Getwd()
|
|
35
|
-
base := gitToplevel(cwd)
|
|
36
|
-
if
|
|
36
|
+
base, err := gitToplevel(cwd)
|
|
37
|
+
if errors.Is(err, errNotGitRepository) {
|
|
38
|
+
base = "."
|
|
39
|
+
} else if err != nil {
|
|
40
|
+
fmt.Fprintf(stderr, "mutation-gate: cannot resolve git worktree; using current directory: %v\n", err)
|
|
37
41
|
base = "."
|
|
38
42
|
}
|
|
39
43
|
|