Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
76db2fe
Phase 2: Add AT-008 annotation and AT-009 ownership-contract test
Jun 26, 2026
6a5f2be
Fix Phase 2 code review finding: strengthen command_ownership_contrac…
Jun 26, 2026
b6d9e1e
fix: apply rustfmt to ownership-contract assert! lines
Jun 26, 2026
c8d30f8
docs: add missing plugins group to command-dispatch.md
Jun 26, 2026
6cbdb6d
docs: add /slop and /canzha to dispatch flow and Permanent Exceptions
Jun 26, 2026
29a01d9
fix(arch): correct plugins group scope in command-dispatch.md
Jun 26, 2026
f963bdc
fix: resolve Phase 6 code review REQUIRED findings (F1, F2, F3)
Jun 27, 2026
a2cd8ea
Phase 6 code-review fixes: rename command_surfaces to eval_smoke, use…
Jun 27, 2026
6cefb38
Fix F1: Replace stale command_surfaces_acceptance reference with eval…
Jun 27, 2026
f8b93df
docs(pr-issue-evidence-prep): Add missing PR title and Refs #2870. to…
Jun 27, 2026
0d19150
Phase 6 recoverable: fix formatting in eval_smoke_acceptance.rs, adva…
Jun 27, 2026
f5f5826
fix: store and verify exit status in eval_smoke_acceptance binary_exi…
Jun 27, 2026
4e12419
fix(F1): rename Gherkin step from 'binary exits successfully' to 'bin…
Jun 27, 2026
81308bb
Phase 6 code-review recovery: correct repo-visible docs to Phase 6 (n…
Jun 27, 2026
7b08cec
fix: replace cfg!(unix) with #[cfg(unix)] helper functions in eval_sm…
Jun 27, 2026
09f11d7
fix(docs): update phase status from Phase 6 to Phase 7 in repo-visibl…
Jun 27, 2026
0962f78
Phase 8 final checkpoint: update docs with final validation evidence
Jun 27, 2026
7101edf
docs: align FEAT-008 with Layer 4.2 tracker
Jun 27, 2026
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
104 changes: 104 additions & 0 deletions crates/tui/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,10 @@ mod tests {
assert!(message.contains("Requested relay focus: next hand"));
}

/// AT-008: No built-in command name or alias is registered twice,
/// and no built-in alias collides with another command's canonical name.
/// This test iterates every command from `command_infos()` (all 9 groups)
/// and asserts uniqueness across the full set of names and aliases.
#[test]
fn command_registry_has_unique_names_and_aliases() {
let mut names = std::collections::BTreeSet::new();
Expand All @@ -569,6 +573,106 @@ mod tests {
}
}

/// AT-009: Command ownership contract — top-level `commands/mod.rs` only
/// registers groups (`groups::all_command_groups()`), each group owns its
/// `commands()` list, and every command has valid metadata.
///
/// Config and debug groups are documented permanent exceptions: they keep
/// group-local `CommandInfo` statics and `dispatch()` in `mod.rs` rather
/// than extracting every command into a focused module. This is accepted
/// final structure per FEAT-008 §3.2.
///
/// Enforcement strategy:
/// - Exactly 9 source-verified groups (from `groups/mod.rs`)
/// - Each group owns its commands() list
/// - Config and debug exceptions verified within their specific groups by
/// identifying the group through its first command ("config" and "tokens")
/// - Not circular: the group-iterated command count is a consistency check;
/// the primary enforcement is exact group count + per-group non-empty + valid metadata
#[test]
fn command_ownership_contract_is_enforced() {
let groups = groups::all_command_groups();

// AT-009 primary: exactly 9 groups matching groups/mod.rs
assert_eq!(
groups.len(),
9,
"expected exactly 9 command groups (core, session, config, debug, \
project, skills, memory, plugins, utility), got {}",
groups.len()
);

let mut total_commands = 0;
let mut has_config = false;
let mut has_debug = false;
for group in &groups {
let commands = group.commands();
assert!(
!commands.is_empty(),
"each group must have at least one command"
);
for cmd in &commands {
let info = cmd.info();
assert!(!info.name.is_empty(), "command name must not be empty");
assert!(
info.name
.chars()
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit()),
"/{} command names must be lowercase ASCII",
info.name
);
let usage_prefix = format!("/{}", info.name);
assert!(
info.usage.starts_with(&usage_prefix),
"/{} usage must start with /{{name}}, got {:?}",
info.name,
info.usage
);
}
total_commands += commands.len();

// Identify config and debug groups by their command content to
// verify permanent-exception counts within the correct group.
if commands.iter().any(|c| c.info().name == "config") {
has_config = true;
assert_eq!(
commands.len(),
11,
"config group (group-local metadata exception) expected \
exactly 11 commands, got {}",
commands.len()
);
}
if commands.iter().any(|c| c.info().name == "tokens") {
has_debug = true;
assert_eq!(
commands.len(),
11,
"debug group (group-local metadata exception) expected \
exactly 11 commands, got {}",
commands.len()
);
}
}

// Config and debug groups must be found and verified by content identity
assert!(
has_config,
"config group not found (expected first command: /config)"
);
assert!(
has_debug,
"debug group not found (expected first command: /tokens)"
);

// Consistency: group-iterated command count must match registry
assert_eq!(
total_commands,
command_infos().len(),
"group-iterated command count must match registry infos count"
);
}

#[test]
fn command_registry_metadata_is_complete_and_palette_safe() {
for command in command_infos() {
Expand Down
194 changes: 194 additions & 0 deletions crates/tui/tests/eval_smoke_acceptance.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
//! Gherkin acceptance test: eval smoke test.
//!
//! Verifies that the binary loads and the eval harness reports step-level
//! success for a shell command after Layer 4.2 registry cleanup. Follows the
//! proven `core_session_command_extraction.rs` pattern.
//!
//! NOTE: This is an eval smoke test, not a command-surface verification
//! (AT-004) test. It confirms the binary starts and runs eval correctly.
//! For AT-004 command-surface coverage (help, palette, completion), see the
//! focused unit tests in command_palette.rs, widgets/mod.rs, and
//! commands/mod.rs.

use std::path::PathBuf;
use std::process::{Command, ExitStatus};

use cucumber::{World as _, given, then, when, writer::Stats as _};
use serde_json::Value;
use tempfile::TempDir;

const FEATURE_NAME: &str = "Eval smoke test (binary load and eval step reporting)";
const FEATURE_PATH: &str = concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/features/eval_smoke.feature"
);
const SMOKE_SCENARIO: &str = "Binary loads and reports step-level success via eval";

#[derive(Debug, Default, cucumber::World)]
struct EvalSmokeWorld {
_record_dir: Option<TempDir>,
report: Option<Value>,
exit_status: Option<ExitStatus>,
}

#[given("a clean CodeWhale evaluation workspace")]
fn clean_codewhale_evaluation_workspace(world: &mut EvalSmokeWorld) {
world._record_dir = Some(TempDir::new().expect("evaluation TempDir"));
}

#[when("the evaluation harness runs a shell command")]
fn eval_harness_runs_shell_command(world: &mut EvalSmokeWorld) {
let record_dir = world
._record_dir
.as_ref()
.expect("evaluation workspace should exist");

let output = Command::new(codewhale_tui_binary())
.args([
"eval",
"--json",
"--shell-command",
"echo eval-smoke-test",
"--record",
])
.arg(record_dir.path())
.output()
.expect("codewhale-tui eval should start");

// Capture stdout/stderr for diagnostics
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);

let report: Value = serde_json::from_str(&stdout).unwrap_or_else(|err| {
panic!("eval --json should emit valid JSON: {err}\nstdout:\n{stdout}\nstderr:\n{stderr}")
});

world.exit_status = Some(output.status);
world.report = Some(report);
}

#[then("the binary exits without crashing")]
fn binary_exits_without_crashing(world: &mut EvalSmokeWorld) {
let status = world
.exit_status
.expect("exit status should have been captured");

// The eval harness exits with code 1 when `metrics.success` is false
// (run_eval in main.rs uses `bail!("...")` for non-successful scenarios).
// This is expected behavior: the eval runs a multi-step scenario offline
// (List, Read, Search, Edit, ApplyPatch, ExecShell) and the overall
// metrics.success reflects all steps, not just ExecShell. The ExecShell
// step itself succeeds — see `json_report_contains_execution_steps`.
//
// What we verify here:
// 1. The process ran to completion (was killed by no signal)
// 2. A known exit code was produced (not a crash/hang)
// 3. Step-level success is validated by the next Gherkin step.
let exit_code = status.code().expect("process should have terminated");
assert_no_signal_crash(&status);
assert!(
exit_code == 0 || exit_code == 1,
"codewhale-tui eval exited with unexpected code {exit_code} (expected 0 or 1)"
);

let report = world.report.as_ref().expect("eval report should exist");
let steps = report
.get("steps")
.and_then(|value| value.as_array())
.expect("eval report should have a 'steps' array");
assert!(
!steps.is_empty(),
"eval report should have at least one step"
);
}

#[then("the JSON report contains execution steps")]
fn json_report_contains_execution_steps(world: &mut EvalSmokeWorld) {
let report = world.report.as_ref().expect("eval report should exist");
let steps = report
.get("steps")
.and_then(|value| value.as_array())
.expect("eval report should have a 'steps' array");

// Find the ExecShell step and verify it contains the expected output
let exec_step = steps
.iter()
.find(|step| step.get("kind").and_then(|v| v.as_str()) == Some("ExecShell"))
.expect("eval report should have an ExecShell step");

let step_success = exec_step
.get("success")
.and_then(|v| v.as_bool())
.unwrap_or(false);
assert!(
step_success,
"ExecShell step should succeed, got: {exec_step:?}"
);

let output = exec_step
.get("output")
.and_then(|v| v.as_str())
.unwrap_or("");
assert!(
output.contains("eval-smoke-test"),
"ExecShell output should contain the shell command echo, got: {output}"
);
}

#[tokio::test(flavor = "current_thread")]
async fn eval_smoke_binary_loads_and_reports_steps() {
let writer = EvalSmokeWorld::cucumber()
.fail_on_skipped()
.with_default_cli()
.filter_run(FEATURE_PATH, move |feature, _, scenario| {
feature.name == FEATURE_NAME && scenario.name == SMOKE_SCENARIO
})
.await;
assert_eq!(
writer.failed_steps(),
0,
"scenario failed: {SMOKE_SCENARIO}"
);
assert_eq!(
writer.skipped_steps(),
0,
"scenario skipped steps: {SMOKE_SCENARIO}"
);
assert_eq!(
writer.passed_steps(),
4,
"scenario did not run: {SMOKE_SCENARIO}"
);
}

/// Assert the process was not killed by a signal (Unix-only check).
#[cfg(unix)]
fn assert_no_signal_crash(status: &ExitStatus) {
use std::os::unix::process::ExitStatusExt;
assert!(
status.signal().is_none(),
"codewhale-tui eval was killed by signal {} (crash?)",
status.signal().unwrap()
);
}

/// No-op on non-Unix platforms where `ExitStatusExt` is unavailable.
#[cfg(not(unix))]
fn assert_no_signal_crash(_status: &ExitStatus) {}

fn codewhale_tui_binary() -> PathBuf {
if let Some(path) = option_env!("CARGO_BIN_EXE_codewhale-tui") {
return PathBuf::from(path);
}
if let Ok(path) = std::env::var("CARGO_BIN_EXE_codewhale-tui") {
return PathBuf::from(path);
}

let mut path = std::env::current_exe().expect("current test executable path");
path.pop();
if path.ends_with("deps") {
path.pop();
}
path.push(format!("codewhale-tui{}", std::env::consts::EXE_SUFFIX));
path
}
12 changes: 12 additions & 0 deletions crates/tui/tests/features/eval_smoke.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
Feature: Eval smoke test (binary load and eval step reporting)

This is an eval smoke test, not a command-surface verification test.
AT-004 command-surface evidence uses focused palette, slash-completion,
and help unit tests. This feature confirms the binary loads and the eval
harness reports step-level success for a shell command.

Scenario: Binary loads and reports step-level success via eval
Given a clean CodeWhale evaluation workspace
When the evaluation harness runs a shell command
Then the binary exits without crashing
And the JSON report contains execution steps
Loading
Loading