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
|
@@ -262,14 +262,15 @@ func hookStatusline(stdin io.Reader, stdout, stderr io.Writer) int {
|
|
|
262
262
|
// insensitive, and FAIL/PASS scans are line oriented.
|
|
263
263
|
// pack-scan-ignore: these are the hook's own red/green heuristics, not a payload.
|
|
264
264
|
var (
|
|
265
|
-
redTestCmdRe
|
|
266
|
-
redFailRe
|
|
267
|
-
redPassRe
|
|
265
|
+
redTestCmdRe = regexp.MustCompile(`(?i)(npm|pnpm|yarn|bun)([[:space:]]+run)?[[:space:]]+(test|build|lint|typecheck|check)|jest|vitest|pytest|go[[:space:]]+test|cargo[[:space:]]+(test|build|clippy)|\bmvn\b|gradle|eslint|ruff|mypy|\btsc\b|\bmake[[:space:]]+(test|build|check)`)
|
|
266
|
+
redFailRe = regexp.MustCompile(`(?im)(^|[^A-Za-z])FAIL([^A-Za-z]|$)|[1-9][0-9]*[[:space:]]+(failed|failing|failures?|errors?)|not ok|panic:|error TS[0-9]|Traceback|AssertionError|✗|✘|BUILD FAILED|exit code [1-9]`)
|
|
267
|
+
redPassRe = regexp.MustCompile(`(?im)PASS(ED)?|0[[:space:]]+(failed|failing|errors?)|all tests passed|BUILD SUCC(ESS|EEDED)|succeeded|no (errors|problems|issues)|(^|[^a-z])ok([^a-z]|$)|✓`)
|
|
268
|
+
reconcileRestoreCheckRe = regexp.MustCompile(`^\s*(?:rtk\s+)?(?:[A-Za-z0-9_./-]+/)?devrites-engine\s+reconcile\s+restore-check(?:\s+[A-Za-z0-9._-]+)?\s*$`)
|
|
268
269
|
)
|
|
269
270
|
|
|
270
271
|
const redReasonFmt = "DevRites: tests/build are RED (%s). Fix to green or record the failure + next step in state.md before stopping: the Stop gate blocks an end-of-turn while .red is set."
|
|
271
272
|
|
|
272
|
-
// hookRedwatch handles PostToolUse
|
|
273
|
+
// hookRedwatch handles PostToolUse shell events for test, build, lint, and type
|
|
273
274
|
// checking commands. It sets or clears <featureDir>/.red so the Stop gate can
|
|
274
275
|
// catch a known failing suite. Its command and output checks match
|
|
275
276
|
// devrites-redwatch.sh, and uncertain input leaves state unchanged.
|
|
@@ -286,7 +287,7 @@ func hookRedwatch(h harness.Harness, stdin io.Reader, stdout, stderr io.Writer)
|
|
|
286
287
|
return exitOK
|
|
287
288
|
}
|
|
288
289
|
in := h.ParseGuardInput(bytes.NewReader(data))
|
|
289
|
-
if in.ToolName
|
|
290
|
+
if !isShellTool(in.ToolName) {
|
|
290
291
|
return exitOK
|
|
291
292
|
}
|
|
292
293
|
// Ignore commands unrelated to tests, builds, linting, or type checking.
|
|
@@ -562,7 +563,8 @@ func hookWrightScope(h harness.Harness, stdin io.Reader, stdout, stderr io.Write
|
|
|
562
563
|
return exitOK
|
|
563
564
|
}
|
|
564
565
|
in := h.ParseGuardInput(bytes.NewReader(data))
|
|
565
|
-
|
|
566
|
+
binding := devritesAgentBindingForGuard(h, in)
|
|
567
|
+
kind := binding.Kind
|
|
566
568
|
genericCompat := os.Getenv("DEVRITES_CODEX_GENERIC_AGENT_COMPAT") == "1"
|
|
567
569
|
if in.AgentType == "" && in.AgentID == "" {
|
|
568
570
|
if code, handled := guardRootBuildWindow(h, data, in, stdout, stderr); handled {
|
|
@@ -620,6 +622,21 @@ func hookWrightScope(h harness.Harness, stdin io.Reader, stdout, stderr io.Write
|
|
|
620
622
|
return exitOK
|
|
621
623
|
}
|
|
622
624
|
if genericWright && !forgeDeclared {
|
|
625
|
+
if binding.Role != "devrites-slice-wright" {
|
|
626
|
+
if wsIsFile(filepath.Join(dir, ".reconcile-base")) {
|
|
627
|
+
return denyOrObserveWright(
|
|
628
|
+
h,
|
|
629
|
+
stdout,
|
|
630
|
+
stderr,
|
|
631
|
+
dir,
|
|
632
|
+
true,
|
|
633
|
+
wrightForbiddenReason,
|
|
634
|
+
drvreason.HookWrightForbiddenDenied,
|
|
635
|
+
[]string{"unbound generic worker"},
|
|
636
|
+
)
|
|
637
|
+
}
|
|
638
|
+
return exitOK
|
|
639
|
+
}
|
|
623
640
|
if !wsIsFile(filepath.Join(dir, ".reconcile-base")) {
|
|
624
641
|
return exitOK
|
|
625
642
|
}
|
|
@@ -1044,6 +1061,9 @@ func classifyRootBuildWindowOperation(root string, data []byte, in harness.Guard
|
|
|
1044
1061
|
if isOpaqueExecutionTool(in.ToolName) {
|
|
1045
1062
|
return true, true, in.ToolName
|
|
1046
1063
|
}
|
|
1064
|
+
if isShellTool(in.ToolName) && strings.Contains(in.Command, "reconcile restore-check") {
|
|
1065
|
+
return true, !reconcileRestoreCheckRe.MatchString(in.Command), in.Command
|
|
1066
|
+
}
|
|
1047
1067
|
|
|
1048
1068
|
projectDir := filepath.Dir(root)
|
|
1049
1069
|
targets, suspicious := rootMutationTargets(data, in)
|
|
@@ -93,6 +93,86 @@ func FeatureDir(root, slug string) string {
|
|
|
93
93
|
return work
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
// ExistingFeatureDirChecked resolves an existing canonical feature workspace
|
|
97
|
+
// without following a work/<slug> or features/<slug> symlink. Mutating
|
|
98
|
+
// control-plane commands use this instead of FeatureDir so an attacker cannot
|
|
99
|
+
// redirect archive/removal operations outside the selected DevRites root.
|
|
100
|
+
func ExistingFeatureDirChecked(root, slug string) (string, error) {
|
|
101
|
+
if !validSlug(slug) {
|
|
102
|
+
return "", fmt.Errorf("DRV-WORKSPACE-INVALID: %q is not a feature slug", slug)
|
|
103
|
+
}
|
|
104
|
+
if ws, err := WorkspaceOverrideChecked(root, slug); err != nil {
|
|
105
|
+
return "", err
|
|
106
|
+
} else if ws != "" {
|
|
107
|
+
return ws, nil
|
|
108
|
+
}
|
|
109
|
+
for _, parent := range []string{"work", "features"} {
|
|
110
|
+
candidate := filepath.Join(root, parent, slug)
|
|
111
|
+
resolved, err := checkedCanonicalDir(root, candidate, parent, slug)
|
|
112
|
+
if err == nil {
|
|
113
|
+
return resolved, nil
|
|
114
|
+
}
|
|
115
|
+
if !errors.Is(err, os.ErrNotExist) {
|
|
116
|
+
return "", err
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return "", os.ErrNotExist
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ArchiveDirChecked resolves (or safely prepares) the canonical archive
|
|
123
|
+
// directory. Existing symlinked archive components are rejected before a
|
|
124
|
+
// close-out can move a workspace through them.
|
|
125
|
+
func ArchiveDirChecked(root string) (string, error) {
|
|
126
|
+
return checkedCanonicalDir(root, filepath.Join(root, "archive"), "archive", "")
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
func checkedCanonicalDir(root, candidate, parent, slug string) (string, error) {
|
|
130
|
+
resolvedRoot, err := safepath.ResolveExisting(root)
|
|
131
|
+
if err != nil {
|
|
132
|
+
return "", fmt.Errorf("DRV-WORKSPACE-INVALID: resolve DevRites root %q: %w", root, err)
|
|
133
|
+
}
|
|
134
|
+
info, err := os.Lstat(candidate)
|
|
135
|
+
if err != nil {
|
|
136
|
+
if !os.IsNotExist(err) || parent != "archive" {
|
|
137
|
+
return "", err
|
|
138
|
+
}
|
|
139
|
+
resolved, resolveErr := safepath.ResolveExisting(candidate)
|
|
140
|
+
if resolveErr != nil {
|
|
141
|
+
return "", fmt.Errorf("DRV-ARCHIVE-INVALID: resolve archive %q: %w", candidate, resolveErr)
|
|
142
|
+
}
|
|
143
|
+
if !safepath.WithinResolved(resolved, resolvedRoot) {
|
|
144
|
+
return "", fmt.Errorf("DRV-ARCHIVE-OUTSIDE-ROOT: archive %q escapes %q", candidate, root)
|
|
145
|
+
}
|
|
146
|
+
rel, relErr := filepath.Rel(resolvedRoot, resolved)
|
|
147
|
+
if relErr != nil || filepath.Clean(rel) != "archive" {
|
|
148
|
+
return "", fmt.Errorf("DRV-ARCHIVE-INVALID: archive %q is not the canonical archive directory", candidate)
|
|
149
|
+
}
|
|
150
|
+
return filepath.Clean(candidate), nil
|
|
151
|
+
}
|
|
152
|
+
if info.Mode()&os.ModeSymlink != 0 {
|
|
153
|
+
return "", fmt.Errorf("DRV-WORKSPACE-SYMLINK: canonical directory %q must not be a symlink", candidate)
|
|
154
|
+
}
|
|
155
|
+
if !info.IsDir() {
|
|
156
|
+
return "", fmt.Errorf("DRV-WORKSPACE-INVALID: canonical directory %q is not a directory", candidate)
|
|
157
|
+
}
|
|
158
|
+
resolved, err := safepath.ResolveExisting(candidate)
|
|
159
|
+
if err != nil {
|
|
160
|
+
return "", fmt.Errorf("DRV-WORKSPACE-INVALID: resolve canonical directory %q: %w", candidate, err)
|
|
161
|
+
}
|
|
162
|
+
if !safepath.WithinResolved(resolved, resolvedRoot) {
|
|
163
|
+
return "", fmt.Errorf("DRV-WORKSPACE-OUTSIDE-ROOT: canonical directory %q escapes %q", candidate, root)
|
|
164
|
+
}
|
|
165
|
+
want := parent
|
|
166
|
+
if slug != "" {
|
|
167
|
+
want = filepath.Join(parent, slug)
|
|
168
|
+
}
|
|
169
|
+
rel, err := filepath.Rel(resolvedRoot, resolved)
|
|
170
|
+
if err != nil || filepath.Clean(rel) != filepath.Clean(want) {
|
|
171
|
+
return "", fmt.Errorf("DRV-WORKSPACE-INVALID: canonical directory %q resolves to unexpected path %q", candidate, resolved)
|
|
172
|
+
}
|
|
173
|
+
return filepath.Clean(candidate), nil
|
|
174
|
+
}
|
|
175
|
+
|
|
96
176
|
// ActiveSlug reads and trims the active-feature pointer. A missing pointer file
|
|
97
177
|
// is not an error; it yields the empty slug.
|
|
98
178
|
func ActiveSlug(root string) (string, error) {
|
|
@@ -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
|
+
}
|
|
@@ -414,7 +414,12 @@ func SecretScan(root string, args []string, stdout, stderr io.Writer) int {
|
|
|
414
414
|
for i := 0; i < len(args); i++ {
|
|
415
415
|
switch args[i] {
|
|
416
416
|
case "--staged":
|
|
417
|
-
|
|
417
|
+
staged, err := gitStagedNames(project)
|
|
418
|
+
if err != nil {
|
|
419
|
+
fmt.Fprintf(stderr, "secret-scan: cannot inspect staged paths: %v\n", err)
|
|
420
|
+
return 2
|
|
421
|
+
}
|
|
422
|
+
paths = append(paths, staged...)
|
|
418
423
|
case "--text":
|
|
419
424
|
i++
|
|
420
425
|
if i < len(args) {
|
|
@@ -433,7 +438,12 @@ func SecretScan(root string, args []string, stdout, stderr io.Writer) int {
|
|
|
433
438
|
paths = append(paths, touchedFiles(root, slug)...)
|
|
434
439
|
}
|
|
435
440
|
if len(paths) == 0 {
|
|
436
|
-
|
|
441
|
+
changed, err := gitDiffNames(project)
|
|
442
|
+
if err != nil {
|
|
443
|
+
fmt.Fprintf(stderr, "secret-scan: cannot inspect changed paths: %v\n", err)
|
|
444
|
+
return 2
|
|
445
|
+
}
|
|
446
|
+
paths = append(paths, changed...)
|
|
437
447
|
}
|
|
438
448
|
findings := []secretFinding{}
|
|
439
449
|
seen := map[string]bool{}
|
|
@@ -484,12 +494,12 @@ func scanSecrets(path, text string) []secretFinding {
|
|
|
484
494
|
return out
|
|
485
495
|
}
|
|
486
496
|
|
|
487
|
-
func gitStagedNames(project string) []string {
|
|
488
|
-
out, err :=
|
|
497
|
+
func gitStagedNames(project string) ([]string, error) {
|
|
498
|
+
out, err := runGitCommand(project, nil, "diff", "--cached", "--name-only")
|
|
489
499
|
if err != nil {
|
|
490
|
-
return nil
|
|
500
|
+
return nil, err
|
|
491
501
|
}
|
|
492
|
-
return splitLinesNoTrailing(out)
|
|
502
|
+
return splitLinesNoTrailing(out), nil
|
|
493
503
|
}
|
|
494
504
|
|
|
495
505
|
func touchedFiles(root, slug string) []string {
|
|
@@ -515,7 +525,12 @@ func DocsStale(root string, args []string, stdout, stderr io.Writer) int {
|
|
|
515
525
|
slug := slugOrActive(root, args)
|
|
516
526
|
changed := touchedFiles(root, slug)
|
|
517
527
|
if len(changed) == 0 {
|
|
518
|
-
|
|
528
|
+
var err error
|
|
529
|
+
changed, err = gitDiffNames(project)
|
|
530
|
+
if err != nil {
|
|
531
|
+
fmt.Fprintf(stderr, "docs-stale: cannot inspect changed paths: %v\n", err)
|
|
532
|
+
return 2
|
|
533
|
+
}
|
|
519
534
|
}
|
|
520
535
|
docsTouched, surfaceTouched := false, []string{}
|
|
521
536
|
for _, p := range changed {
|
|
@@ -569,8 +584,11 @@ func SpecDedupe(root string, args []string, stdout, stderr io.Writer) int {
|
|
|
569
584
|
}
|
|
570
585
|
var hits []hit
|
|
571
586
|
for _, base := range targets {
|
|
572
|
-
|
|
573
|
-
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") {
|
|
574
592
|
return nil
|
|
575
593
|
}
|
|
576
594
|
lowName := strings.ToLower(filepath.Base(p))
|
|
@@ -579,7 +597,7 @@ func SpecDedupe(root string, args []string, stdout, stderr io.Writer) int {
|
|
|
579
597
|
}
|
|
580
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
|
|
581
599
|
if err != nil {
|
|
582
|
-
return
|
|
600
|
+
return err
|
|
583
601
|
}
|
|
584
602
|
text := strings.ToLower(string(b))
|
|
585
603
|
score := 0
|
|
@@ -589,11 +607,18 @@ func SpecDedupe(root string, args []string, stdout, stderr io.Writer) int {
|
|
|
589
607
|
}
|
|
590
608
|
}
|
|
591
609
|
if score >= 2 || score == len(terms) {
|
|
592
|
-
rel,
|
|
610
|
+
rel, err := filepath.Rel(project, p)
|
|
611
|
+
if err != nil {
|
|
612
|
+
return err
|
|
613
|
+
}
|
|
593
614
|
hits = append(hits, hit{score: score, path: filepath.ToSlash(rel), line: firstLine(string(b))})
|
|
594
615
|
}
|
|
595
616
|
return nil
|
|
596
617
|
})
|
|
618
|
+
if err != nil && !os.IsNotExist(err) {
|
|
619
|
+
fmt.Fprintf(stderr, "spec-dedupe: search %s: %v\n", base, err)
|
|
620
|
+
return 1
|
|
621
|
+
}
|
|
597
622
|
}
|
|
598
623
|
sort.Slice(hits, func(i, j int) bool {
|
|
599
624
|
if hits[i].score == hits[j].score {
|
|
@@ -53,6 +53,36 @@ func TestSecretScanBlocksHighSeverityTouchedFile(t *testing.T) {
|
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
func TestSecretScanFailsClosedWhenGitIsUnavailable(t *testing.T) {
|
|
57
|
+
project := t.TempDir()
|
|
58
|
+
root := filepath.Join(project, ".devrites")
|
|
59
|
+
t.Chdir(project)
|
|
60
|
+
t.Setenv("PATH", t.TempDir())
|
|
61
|
+
|
|
62
|
+
var out, err bytes.Buffer
|
|
63
|
+
if code := SecretScan(root, nil, &out, &err); code != 2 {
|
|
64
|
+
t.Fatalf("want rc=2 got %d stdout=%s stderr=%s", code, out.String(), err.String())
|
|
65
|
+
}
|
|
66
|
+
if !strings.Contains(err.String(), "cannot inspect changed paths") {
|
|
67
|
+
t.Fatalf("missing fail-closed diagnostic: %s", err.String())
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
func TestDocsStaleFailsClosedWhenGitIsUnavailable(t *testing.T) {
|
|
72
|
+
project := t.TempDir()
|
|
73
|
+
root := filepath.Join(project, ".devrites")
|
|
74
|
+
t.Chdir(project)
|
|
75
|
+
t.Setenv("PATH", t.TempDir())
|
|
76
|
+
|
|
77
|
+
var out, err bytes.Buffer
|
|
78
|
+
if code := DocsStale(root, nil, &out, &err); code != 2 {
|
|
79
|
+
t.Fatalf("want rc=2 got %d stdout=%s stderr=%s", code, out.String(), err.String())
|
|
80
|
+
}
|
|
81
|
+
if !strings.Contains(err.String(), "cannot inspect changed paths") {
|
|
82
|
+
t.Fatalf("missing fail-closed diagnostic: %s", err.String())
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
56
86
|
func TestSpecDedupeSearchesScratchPRDs(t *testing.T) {
|
|
57
87
|
project := t.TempDir()
|
|
58
88
|
root := filepath.Join(project, ".devrites")
|
|
@@ -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")
|