Skip to content

Commit 81940cf

Browse files
clkaoSpike Test
andauthored
status --read: default --checklist/--ac-scan --stage to the entity's current status (#476)
Collapses the two-call round-trip (status --fields status, then re-issue with --stage) into one: --stage now defaults to the resolved file's frontmatter status when omitted, resolved once in runReadGate before mode dispatch, so it applies uniformly to --checklist/--ac-scan and text/--json. Explicit --stage is unchanged. Both fail-loud cases are named transparently: no Stage Report for the defaulted stage, and no status field to default from. Co-authored-by: Spike Test <spike@example.com>
1 parent 35707f2 commit 81940cf

4 files changed

Lines changed: 210 additions & 25 deletions

File tree

docs/site/reference/command-reference.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ The first officer runs these against workflow state as it moves entities; you op
4343

4444
| Command | What it does |
4545
|---------|--------------|
46-
| `spacedock status` | Read or mutate the state: the entity table (omits the SOURCE column by default; `--fields source` or `--all-fields` restores it), `--next`, `--where`, `--set`, `--validate`, `--boot`, `--read <ref-or-path>` (a file's structured frontmatter — including the nested `stages:` taxonomy, and projectable with `--fields` — plus a heading offset/lines map, for section-scoped reads; with `--stage X --checklist` / `--stage X --ac-scan` it extracts a stage report's checklist items with line ranges and per-AC evidence citations for the first officer's gate prep) |
46+
| `spacedock status` | Read or mutate the state: the entity table (omits the SOURCE column by default; `--fields source` or `--all-fields` restores it), `--next`, `--where`, `--set`, `--validate`, `--boot`, `--read <ref-or-path>` (a file's structured frontmatter — including the nested `stages:` taxonomy, and projectable with `--fields` — plus a heading offset/lines map, for section-scoped reads; with `--checklist` / `--ac-scan` it extracts a stage report's checklist items with line ranges and per-AC evidence citations for the first officer's gate prep; `--stage` defaults to the entity's current `status` when omitted (so a bare `--read <entity> --checklist` reads the current stage's report), and `--stage X` reads a non-current stage) |
4747
| `spacedock new` | Create an entity (`new [--folder] SLUG`) from a body on stdin |
4848
| `spacedock dispatch` | Build the worker dispatch artifacts (`dispatch build`, `dispatch show-stage-def`) |
4949
| `spacedock state` | Manage a [split-root workflow](../advanced/split-root-state.md)'s state checkout (`state init` resumes one on a fresh clone, `state new` births one) |

internal/status/cycle3_extract_test.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -621,9 +621,12 @@ func TestACScanEnumeratesAnnotatedAC(t *testing.T) {
621621
}
622622

623623
// TestGateModeLoudFailures (AC-5) asserts the gate modes fail loudly with a
624-
// non-zero exit and a named diagnostic, never a partial/silent emit: missing
625-
// --stage, a --stage matching no report (no silent positional-last fallback),
626-
// and --ac-scan over a file with no ## Acceptance criteria section.
624+
// non-zero exit and a named diagnostic, never a partial/silent emit: an explicit
625+
// --stage matching no report (no silent positional-last fallback), and --ac-scan
626+
// over a file with no ## Acceptance criteria section. The omitted-flag fail-loud
627+
// cases (no matching report for the defaulted current status; no status field
628+
// to default from) live in gate_default_stage_test.go alongside the defaulting
629+
// behavior they belong to.
627630
func TestGateModeLoudFailures(t *testing.T) {
628631
root, err := filepath.Abs(filepath.Join("testdata", "seq-workflow"))
629632
if err != nil {
@@ -638,8 +641,6 @@ func TestGateModeLoudFailures(t *testing.T) {
638641
args []string
639642
wantWord string
640643
}{
641-
{"checklist without --stage", []string{"--read", fixture, "--checklist", "--json"}, "--stage"},
642-
{"ac-scan without --stage", []string{"--read", fixture, "--ac-scan", "--json"}, "--stage"},
643644
{"stage matches no report", []string{"--read", fixture, "--stage", "done", "--checklist", "--json"}, "done"},
644645
{"ac-scan no acceptance criteria", []string{"--read", plain, "--stage", "ideation", "--ac-scan", "--json"}, "acceptance criteria"},
645646
}
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
// ABOUTME: --checklist/--ac-scan --stage defaulting to the entity's current
2+
// ABOUTME: frontmatter status when --stage is omitted (AC-1 through AC-5).
3+
package status
4+
5+
import (
6+
"path/filepath"
7+
"strings"
8+
"testing"
9+
)
10+
11+
// TestDefaultStageMatchesExplicitCurrent (AC-1, AC-2) asserts a bare --checklist /
12+
// --ac-scan call (no --stage) against an entity whose current status is
13+
// "validation" succeeds and produces byte-identical output to the same call with
14+
// --stage validation given explicitly, across both text and --json modes. The
15+
// interleaved fixture's frontmatter status is "validation" and it carries a
16+
// matching "## Stage Report: validation" section.
17+
func TestDefaultStageMatchesExplicitCurrent(t *testing.T) {
18+
root, err := filepath.Abs(filepath.Join("testdata", "seq-workflow"))
19+
if err != nil {
20+
t.Fatal(err)
21+
}
22+
env := pinnedEnv(t)
23+
fixture := interleavedFixturePath(t)
24+
25+
cases := []struct {
26+
name string
27+
flag string
28+
}{
29+
{"checklist", "--checklist"},
30+
{"ac-scan", "--ac-scan"},
31+
}
32+
for _, tc := range cases {
33+
tc := tc
34+
t.Run(tc.name+" text", func(t *testing.T) {
35+
omitted, _, code := runNative(t, root, env, "--workflow-dir", root, "--read", fixture, tc.flag)
36+
if code != 0 {
37+
t.Fatalf("bare %s exit=%d, want 0", tc.flag, code)
38+
}
39+
if strings.Contains(omitted, "requires --stage") {
40+
t.Fatalf("bare %s emitted the retired 'requires --stage' wording: %s", tc.flag, omitted)
41+
}
42+
explicit, _, code := runNative(t, root, env, "--workflow-dir", root, "--read", fixture, "--stage", "validation", tc.flag)
43+
if code != 0 {
44+
t.Fatalf("explicit %s exit=%d, want 0", tc.flag, code)
45+
}
46+
if omitted != explicit {
47+
t.Fatalf("bare %s output != explicit --stage validation output\n--- omitted ---\n%s\n--- explicit ---\n%s", tc.flag, omitted, explicit)
48+
}
49+
})
50+
t.Run(tc.name+" json", func(t *testing.T) {
51+
omitted, _, code := runNative(t, root, env, "--workflow-dir", root, "--read", fixture, tc.flag, "--json")
52+
if code != 0 {
53+
t.Fatalf("bare %s --json exit=%d, want 0", tc.flag, code)
54+
}
55+
explicit, _, code := runNative(t, root, env, "--workflow-dir", root, "--read", fixture, "--stage", "validation", tc.flag, "--json")
56+
if code != 0 {
57+
t.Fatalf("explicit %s --json exit=%d, want 0", tc.flag, code)
58+
}
59+
if omitted != explicit {
60+
t.Fatalf("bare %s --json output != explicit --stage validation output\n--- omitted ---\n%s\n--- explicit ---\n%s", tc.flag, omitted, explicit)
61+
}
62+
if !strings.Contains(omitted, `"stage":"validation"`) {
63+
t.Fatalf("bare %s --json envelope stage != validation: %s", tc.flag, omitted)
64+
}
65+
})
66+
}
67+
}
68+
69+
// TestExplicitStageOverridesDefault (AC-3) asserts --stage <name> with a name
70+
// other than the current status still selects that stage's report, unaffected
71+
// by the current-status default. The interleaved fixture's current status is
72+
// "validation"; --stage implementation must still select the LATEST
73+
// implementation cycle, exactly as it does without any default in play.
74+
func TestExplicitStageOverridesDefault(t *testing.T) {
75+
root, err := filepath.Abs(filepath.Join("testdata", "seq-workflow"))
76+
if err != nil {
77+
t.Fatal(err)
78+
}
79+
env := pinnedEnv(t)
80+
fixture := interleavedFixturePath(t)
81+
82+
out, stderr, code := runNative(t, root, env, "--workflow-dir", root, "--read", fixture, "--stage", "implementation", "--checklist", "--json")
83+
if code != 0 {
84+
t.Fatalf("exit=%d stderr=%q", code, stderr)
85+
}
86+
if !strings.Contains(out, "rework after the gate") {
87+
t.Fatalf("explicit --stage implementation did not select the latest implementation cycle: %s", out)
88+
}
89+
if strings.Contains(out, "write the first cut") {
90+
t.Fatalf("explicit --stage implementation leaked the earlier cycle-1 item: %s", out)
91+
}
92+
if !strings.Contains(out, `"stage":"implementation"`) {
93+
t.Fatalf("json envelope stage != implementation despite current status being validation: %s", out)
94+
}
95+
}
96+
97+
// TestDefaultStageNoMatchingReport (AC-4) asserts that when --stage is omitted
98+
// and the entity's current status names a stage with no matching Stage Report,
99+
// the command exits 1 with a diagnostic naming the stage as the current/defaulted
100+
// one — never a silent empty emit. The section-reader fixture's status is
101+
// "implementation" but it only carries a "## Stage Report: ideation" section.
102+
func TestDefaultStageNoMatchingReport(t *testing.T) {
103+
root, err := filepath.Abs(filepath.Join("testdata", "seq-workflow"))
104+
if err != nil {
105+
t.Fatal(err)
106+
}
107+
env := pinnedEnv(t)
108+
fixture := fixturePath(t)
109+
110+
for _, flag := range []string{"--checklist", "--ac-scan"} {
111+
flag := flag
112+
t.Run(flag, func(t *testing.T) {
113+
out, stderr, code := runNative(t, root, env, "--workflow-dir", root, "--read", fixture, flag)
114+
if code == 0 {
115+
t.Fatalf("exit=0 (want non-zero) — silent/partial emit\nstdout=%q", out)
116+
}
117+
if strings.TrimSpace(out) != "" {
118+
t.Fatalf("stdout = %q, want empty on a loud failure", out)
119+
}
120+
if !strings.Contains(stderr, `"implementation"`) {
121+
t.Fatalf("stderr = %q, want it to name the defaulted stage \"implementation\"", stderr)
122+
}
123+
if !strings.Contains(stderr, "--stage omitted") {
124+
t.Fatalf("stderr = %q, want it to note --stage was omitted (defaulting transparency)", stderr)
125+
}
126+
})
127+
}
128+
}
129+
130+
// TestDefaultStageNoStatusField (AC-5) asserts that when --stage is omitted and
131+
// the resolved --read target has no status frontmatter field, the command exits
132+
// 1 with a diagnostic naming that the stage could not be defaulted — never the
133+
// retired bare "requires --stage" wording, and never a silent emit. The real
134+
// docs/dev/README.md has no status: frontmatter field.
135+
func TestDefaultStageNoStatusField(t *testing.T) {
136+
root, err := filepath.Abs(filepath.Join("testdata", "seq-workflow"))
137+
if err != nil {
138+
t.Fatal(err)
139+
}
140+
env := pinnedEnv(t)
141+
readme := devReadmePath(t)
142+
143+
for _, flag := range []string{"--checklist", "--ac-scan"} {
144+
flag := flag
145+
t.Run(flag, func(t *testing.T) {
146+
out, stderr, code := runNative(t, root, env, "--workflow-dir", root, "--read", readme, flag)
147+
if code == 0 {
148+
t.Fatalf("exit=0 (want non-zero) — silent/partial emit\nstdout=%q", out)
149+
}
150+
if strings.TrimSpace(out) != "" {
151+
t.Fatalf("stdout = %q, want empty on a loud failure", out)
152+
}
153+
if strings.Contains(stderr, "requires --stage") {
154+
t.Fatalf("stderr = %q, reused the retired 'requires --stage' wording (misleading now that the flag defaults)", stderr)
155+
}
156+
if !strings.Contains(stderr, "--stage omitted") || !strings.Contains(strings.ToLower(stderr), "status") {
157+
t.Fatalf("stderr = %q, want a diagnostic naming that --stage was omitted and there is no status to default from", stderr)
158+
}
159+
})
160+
}
161+
}

internal/status/gate_extract.go

Lines changed: 42 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ import (
1515
// reads its lines, and runs --checklist or --ac-scan. The two modes are mutually
1616
// exclusive (each emits its own envelope). The stage-report/AC parsing reads the
1717
// raw file lines (1-based) so emitted ranges are Read(offset, lines)-sliceable.
18+
//
19+
// When stage is omitted, it defaults once here to the resolved file's
20+
// frontmatter status field — the entity's current stage — before mode dispatch,
21+
// so the default applies uniformly to --checklist, --ac-scan, text, and --json.
22+
// Explicit --stage is passed through unchanged. A target with no status field to
23+
// default from fails loudly rather than silently emitting.
1824
func runReadGate(roots roots, ref, stage string, checklist, acScan, asJSON bool, stdout, stderr io.Writer) int {
1925
if checklist && acScan {
2026
return errExit(stderr, "--checklist and --ac-scan are mutually exclusive")
@@ -27,11 +33,18 @@ func runReadGate(roots roots, ref, stage string, checklist, acScan, asJSON bool,
2733
if err != nil {
2834
return errExit(stderr, "cannot read file: "+path)
2935
}
36+
stageOmitted := stage == ""
37+
if stageOmitted {
38+
stage = parseFrontmatterContent(data)["status"]
39+
if stage == "" {
40+
return errExit(stderr, fmt.Sprintf("--stage omitted and %s has no status frontmatter to default from; pass --stage <stage>", path))
41+
}
42+
}
3043
lines := splitLines(string(data))
3144
if checklist {
32-
return runChecklist(lines, stage, asJSON, stdout, stderr)
45+
return runChecklist(lines, stage, stageOmitted, asJSON, stdout, stderr)
3346
}
34-
return runACScan(lines, stage, asJSON, stdout, stderr)
47+
return runACScan(lines, stage, stageOmitted, asJSON, stdout, stderr)
3548
}
3649

3750
// stageReportHeadingRe matches a `## Stage Report: <stage> [qualifier...]` line
@@ -116,6 +129,17 @@ func selectStageReport(lines []string, stage string) (start, end int, ok bool) {
116129
return chosen + 1, endIdx + 1, true
117130
}
118131

132+
// noStageReportDiagnostic formats the "no ## Stage Report for stage" error. When
133+
// stage was defaulted from the current status (--stage omitted), it names the
134+
// stage as the current status so the defaulting is transparent to the caller;
135+
// an explicit --stage keeps the plain wording.
136+
func noStageReportDiagnostic(stage string, stageOmitted bool) string {
137+
if stageOmitted {
138+
return fmt.Sprintf("no ## Stage Report for stage %q (current status; --stage omitted) in this file", stage)
139+
}
140+
return fmt.Sprintf("no ## Stage Report for stage %q in this file", stage)
141+
}
142+
119143
// extractChecklist parses the DONE/SKIPPED/FAILED items within the stage-report
120144
// section [start,end] (1-based inclusive). Each item owns from its bullet line
121145
// through the line before the next bullet or sub-heading (### …) within the
@@ -178,17 +202,17 @@ func checklistJSON(stage string, items []checklistItem) *jsonObj {
178202
setValue("checklist", arr)
179203
}
180204

181-
// runChecklist handles --read <entity> --stage X --checklist. It selects the
182-
// latest stage-report section for X across interleaved sections and emits its
183-
// checklist items with line ranges; a missing --stage or a stage matching no
184-
// report fails loudly (non-zero exit, named diagnostic), never a silent emit.
185-
func runChecklist(lines []string, stage string, asJSON bool, stdout, stderr io.Writer) int {
186-
if stage == "" {
187-
return errExit(stderr, "--checklist requires --stage <stage>")
188-
}
205+
// runChecklist handles --read <entity> [--stage X] --checklist. It selects the
206+
// latest stage-report section for X (or, when --stage was omitted, the caller's
207+
// defaulted current-status stage) across interleaved sections and emits its
208+
// checklist items with line ranges; a stage matching no report fails loudly
209+
// (non-zero exit, named diagnostic), never a silent emit. When stage was
210+
// defaulted, the diagnostic names it as the current status so the defaulting is
211+
// transparent.
212+
func runChecklist(lines []string, stage string, stageOmitted, asJSON bool, stdout, stderr io.Writer) int {
189213
start, end, ok := selectStageReport(lines, stage)
190214
if !ok {
191-
return errExit(stderr, fmt.Sprintf("no ## Stage Report for stage %q in this file", stage))
215+
return errExit(stderr, noStageReportDiagnostic(stage, stageOmitted))
192216
}
193217
items := extractChecklist(lines, start, end)
194218
if asJSON {
@@ -304,17 +328,16 @@ func acScanJSON(stage string, acs []acEvidence) *jsonObj {
304328
setValue("acs", arr)
305329
}
306330

307-
// runACScan handles --read <entity> --stage X --ac-scan. It cites each AC's
331+
// runACScan handles --read <entity> [--stage X] --ac-scan. It cites each AC's
308332
// evidence from the gated stage's checklist line ranges only and flags the
309-
// unevidenced ACs. A missing --stage, a stage matching no report, or an absent
310-
// ## Acceptance criteria section fails loudly, never a silent emit.
311-
func runACScan(lines []string, stage string, asJSON bool, stdout, stderr io.Writer) int {
312-
if stage == "" {
313-
return errExit(stderr, "--ac-scan requires --stage <stage>")
314-
}
333+
// unevidenced ACs. A stage matching no report, or an absent ## Acceptance
334+
// criteria section, fails loudly, never a silent emit. When stage was omitted
335+
// and defaulted by the caller, the no-report diagnostic names it as the current
336+
// status.
337+
func runACScan(lines []string, stage string, stageOmitted, asJSON bool, stdout, stderr io.Writer) int {
315338
start, end, ok := selectStageReport(lines, stage)
316339
if !ok {
317-
return errExit(stderr, fmt.Sprintf("no ## Stage Report for stage %q in this file", stage))
340+
return errExit(stderr, noStageReportDiagnostic(stage, stageOmitted))
318341
}
319342
acStart, acEnd, acOK := findAcceptanceCriteria(lines)
320343
if !acOK {

0 commit comments

Comments
 (0)