Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/site/reference/command-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ The first officer runs these against workflow state as it moves entities; you op

| Command | What it does |
|---------|--------------|
| `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) |
| `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) |
| `spacedock new` | Create an entity (`new [--folder] SLUG`) from a body on stdin |
| `spacedock dispatch` | Build the worker dispatch artifacts (`dispatch build`, `dispatch show-stage-def`) |
| `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) |
Expand Down
11 changes: 6 additions & 5 deletions internal/status/cycle3_extract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -621,9 +621,12 @@ func TestACScanEnumeratesAnnotatedAC(t *testing.T) {
}

// TestGateModeLoudFailures (AC-5) asserts the gate modes fail loudly with a
// non-zero exit and a named diagnostic, never a partial/silent emit: missing
// --stage, a --stage matching no report (no silent positional-last fallback),
// and --ac-scan over a file with no ## Acceptance criteria section.
// non-zero exit and a named diagnostic, never a partial/silent emit: an explicit
// --stage matching no report (no silent positional-last fallback), and --ac-scan
// over a file with no ## Acceptance criteria section. The omitted-flag fail-loud
// cases (no matching report for the defaulted current status; no status field
// to default from) live in gate_default_stage_test.go alongside the defaulting
// behavior they belong to.
func TestGateModeLoudFailures(t *testing.T) {
root, err := filepath.Abs(filepath.Join("testdata", "seq-workflow"))
if err != nil {
Expand All @@ -638,8 +641,6 @@ func TestGateModeLoudFailures(t *testing.T) {
args []string
wantWord string
}{
{"checklist without --stage", []string{"--read", fixture, "--checklist", "--json"}, "--stage"},
{"ac-scan without --stage", []string{"--read", fixture, "--ac-scan", "--json"}, "--stage"},
{"stage matches no report", []string{"--read", fixture, "--stage", "done", "--checklist", "--json"}, "done"},
{"ac-scan no acceptance criteria", []string{"--read", plain, "--stage", "ideation", "--ac-scan", "--json"}, "acceptance criteria"},
}
Expand Down
161 changes: 161 additions & 0 deletions internal/status/gate_default_stage_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
// ABOUTME: --checklist/--ac-scan --stage defaulting to the entity's current
// ABOUTME: frontmatter status when --stage is omitted (AC-1 through AC-5).
package status

import (
"path/filepath"
"strings"
"testing"
)

// TestDefaultStageMatchesExplicitCurrent (AC-1, AC-2) asserts a bare --checklist /
// --ac-scan call (no --stage) against an entity whose current status is
// "validation" succeeds and produces byte-identical output to the same call with
// --stage validation given explicitly, across both text and --json modes. The
// interleaved fixture's frontmatter status is "validation" and it carries a
// matching "## Stage Report: validation" section.
func TestDefaultStageMatchesExplicitCurrent(t *testing.T) {
root, err := filepath.Abs(filepath.Join("testdata", "seq-workflow"))
if err != nil {
t.Fatal(err)
}
env := pinnedEnv(t)
fixture := interleavedFixturePath(t)

cases := []struct {
name string
flag string
}{
{"checklist", "--checklist"},
{"ac-scan", "--ac-scan"},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name+" text", func(t *testing.T) {
omitted, _, code := runNative(t, root, env, "--workflow-dir", root, "--read", fixture, tc.flag)
if code != 0 {
t.Fatalf("bare %s exit=%d, want 0", tc.flag, code)
}
if strings.Contains(omitted, "requires --stage") {
t.Fatalf("bare %s emitted the retired 'requires --stage' wording: %s", tc.flag, omitted)
}
explicit, _, code := runNative(t, root, env, "--workflow-dir", root, "--read", fixture, "--stage", "validation", tc.flag)
if code != 0 {
t.Fatalf("explicit %s exit=%d, want 0", tc.flag, code)
}
if omitted != explicit {
t.Fatalf("bare %s output != explicit --stage validation output\n--- omitted ---\n%s\n--- explicit ---\n%s", tc.flag, omitted, explicit)
}
})
t.Run(tc.name+" json", func(t *testing.T) {
omitted, _, code := runNative(t, root, env, "--workflow-dir", root, "--read", fixture, tc.flag, "--json")
if code != 0 {
t.Fatalf("bare %s --json exit=%d, want 0", tc.flag, code)
}
explicit, _, code := runNative(t, root, env, "--workflow-dir", root, "--read", fixture, "--stage", "validation", tc.flag, "--json")
if code != 0 {
t.Fatalf("explicit %s --json exit=%d, want 0", tc.flag, code)
}
if omitted != explicit {
t.Fatalf("bare %s --json output != explicit --stage validation output\n--- omitted ---\n%s\n--- explicit ---\n%s", tc.flag, omitted, explicit)
}
if !strings.Contains(omitted, `"stage":"validation"`) {
t.Fatalf("bare %s --json envelope stage != validation: %s", tc.flag, omitted)
}
})
}
}

// TestExplicitStageOverridesDefault (AC-3) asserts --stage <name> with a name
// other than the current status still selects that stage's report, unaffected
// by the current-status default. The interleaved fixture's current status is
// "validation"; --stage implementation must still select the LATEST
// implementation cycle, exactly as it does without any default in play.
func TestExplicitStageOverridesDefault(t *testing.T) {
root, err := filepath.Abs(filepath.Join("testdata", "seq-workflow"))
if err != nil {
t.Fatal(err)
}
env := pinnedEnv(t)
fixture := interleavedFixturePath(t)

out, stderr, code := runNative(t, root, env, "--workflow-dir", root, "--read", fixture, "--stage", "implementation", "--checklist", "--json")
if code != 0 {
t.Fatalf("exit=%d stderr=%q", code, stderr)
}
if !strings.Contains(out, "rework after the gate") {
t.Fatalf("explicit --stage implementation did not select the latest implementation cycle: %s", out)
}
if strings.Contains(out, "write the first cut") {
t.Fatalf("explicit --stage implementation leaked the earlier cycle-1 item: %s", out)
}
if !strings.Contains(out, `"stage":"implementation"`) {
t.Fatalf("json envelope stage != implementation despite current status being validation: %s", out)
}
}

// TestDefaultStageNoMatchingReport (AC-4) asserts that when --stage is omitted
// and the entity's current status names a stage with no matching Stage Report,
// the command exits 1 with a diagnostic naming the stage as the current/defaulted
// one — never a silent empty emit. The section-reader fixture's status is
// "implementation" but it only carries a "## Stage Report: ideation" section.
func TestDefaultStageNoMatchingReport(t *testing.T) {
root, err := filepath.Abs(filepath.Join("testdata", "seq-workflow"))
if err != nil {
t.Fatal(err)
}
env := pinnedEnv(t)
fixture := fixturePath(t)

for _, flag := range []string{"--checklist", "--ac-scan"} {
flag := flag
t.Run(flag, func(t *testing.T) {
out, stderr, code := runNative(t, root, env, "--workflow-dir", root, "--read", fixture, flag)
if code == 0 {
t.Fatalf("exit=0 (want non-zero) — silent/partial emit\nstdout=%q", out)
}
if strings.TrimSpace(out) != "" {
t.Fatalf("stdout = %q, want empty on a loud failure", out)
}
if !strings.Contains(stderr, `"implementation"`) {
t.Fatalf("stderr = %q, want it to name the defaulted stage \"implementation\"", stderr)
}
if !strings.Contains(stderr, "--stage omitted") {
t.Fatalf("stderr = %q, want it to note --stage was omitted (defaulting transparency)", stderr)
}
})
}
}

// TestDefaultStageNoStatusField (AC-5) asserts that when --stage is omitted and
// the resolved --read target has no status frontmatter field, the command exits
// 1 with a diagnostic naming that the stage could not be defaulted — never the
// retired bare "requires --stage" wording, and never a silent emit. The real
// docs/dev/README.md has no status: frontmatter field.
func TestDefaultStageNoStatusField(t *testing.T) {
root, err := filepath.Abs(filepath.Join("testdata", "seq-workflow"))
if err != nil {
t.Fatal(err)
}
env := pinnedEnv(t)
readme := devReadmePath(t)

for _, flag := range []string{"--checklist", "--ac-scan"} {
flag := flag
t.Run(flag, func(t *testing.T) {
out, stderr, code := runNative(t, root, env, "--workflow-dir", root, "--read", readme, flag)
if code == 0 {
t.Fatalf("exit=0 (want non-zero) — silent/partial emit\nstdout=%q", out)
}
if strings.TrimSpace(out) != "" {
t.Fatalf("stdout = %q, want empty on a loud failure", out)
}
if strings.Contains(stderr, "requires --stage") {
t.Fatalf("stderr = %q, reused the retired 'requires --stage' wording (misleading now that the flag defaults)", stderr)
}
if !strings.Contains(stderr, "--stage omitted") || !strings.Contains(strings.ToLower(stderr), "status") {
t.Fatalf("stderr = %q, want a diagnostic naming that --stage was omitted and there is no status to default from", stderr)
}
})
}
}
61 changes: 42 additions & 19 deletions internal/status/gate_extract.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ import (
// reads its lines, and runs --checklist or --ac-scan. The two modes are mutually
// exclusive (each emits its own envelope). The stage-report/AC parsing reads the
// raw file lines (1-based) so emitted ranges are Read(offset, lines)-sliceable.
//
// When stage is omitted, it defaults once here to the resolved file's
// frontmatter status field — the entity's current stage — before mode dispatch,
// so the default applies uniformly to --checklist, --ac-scan, text, and --json.
// Explicit --stage is passed through unchanged. A target with no status field to
// default from fails loudly rather than silently emitting.
func runReadGate(roots roots, ref, stage string, checklist, acScan, asJSON bool, stdout, stderr io.Writer) int {
if checklist && acScan {
return errExit(stderr, "--checklist and --ac-scan are mutually exclusive")
Expand All @@ -27,11 +33,18 @@ func runReadGate(roots roots, ref, stage string, checklist, acScan, asJSON bool,
if err != nil {
return errExit(stderr, "cannot read file: "+path)
}
stageOmitted := stage == ""
if stageOmitted {
stage = parseFrontmatterContent(data)["status"]
if stage == "" {
return errExit(stderr, fmt.Sprintf("--stage omitted and %s has no status frontmatter to default from; pass --stage <stage>", path))
}
}
lines := splitLines(string(data))
if checklist {
return runChecklist(lines, stage, asJSON, stdout, stderr)
return runChecklist(lines, stage, stageOmitted, asJSON, stdout, stderr)
}
return runACScan(lines, stage, asJSON, stdout, stderr)
return runACScan(lines, stage, stageOmitted, asJSON, stdout, stderr)
}

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

// noStageReportDiagnostic formats the "no ## Stage Report for stage" error. When
// stage was defaulted from the current status (--stage omitted), it names the
// stage as the current status so the defaulting is transparent to the caller;
// an explicit --stage keeps the plain wording.
func noStageReportDiagnostic(stage string, stageOmitted bool) string {
if stageOmitted {
return fmt.Sprintf("no ## Stage Report for stage %q (current status; --stage omitted) in this file", stage)
}
return fmt.Sprintf("no ## Stage Report for stage %q in this file", stage)
}

// extractChecklist parses the DONE/SKIPPED/FAILED items within the stage-report
// section [start,end] (1-based inclusive). Each item owns from its bullet line
// through the line before the next bullet or sub-heading (### …) within the
Expand Down Expand Up @@ -178,17 +202,17 @@ func checklistJSON(stage string, items []checklistItem) *jsonObj {
setValue("checklist", arr)
}

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

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