diff --git a/docs/site/reference/command-reference.md b/docs/site/reference/command-reference.md index 142067fb1..42f8d175a 100644 --- a/docs/site/reference/command-reference.md +++ b/docs/site/reference/command-reference.md @@ -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 ` (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 ` (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 --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) | diff --git a/internal/status/cycle3_extract_test.go b/internal/status/cycle3_extract_test.go index 8d8356e29..1e00bcf5c 100644 --- a/internal/status/cycle3_extract_test.go +++ b/internal/status/cycle3_extract_test.go @@ -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 { @@ -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"}, } diff --git a/internal/status/gate_default_stage_test.go b/internal/status/gate_default_stage_test.go new file mode 100644 index 000000000..0837bc6ed --- /dev/null +++ b/internal/status/gate_default_stage_test.go @@ -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 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) + } + }) + } +} diff --git a/internal/status/gate_extract.go b/internal/status/gate_extract.go index c65e93f0c..c452cc211 100644 --- a/internal/status/gate_extract.go +++ b/internal/status/gate_extract.go @@ -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") @@ -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 ", 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: [qualifier...]` line @@ -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 @@ -178,17 +202,17 @@ func checklistJSON(stage string, items []checklistItem) *jsonObj { setValue("checklist", arr) } -// runChecklist handles --read --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 ") - } +// runChecklist handles --read [--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 { @@ -304,17 +328,16 @@ func acScanJSON(stage string, acs []acEvidence) *jsonObj { setValue("acs", arr) } -// runACScan handles --read --stage X --ac-scan. It cites each AC's +// runACScan handles --read [--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 ") - } +// 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 {