huskies: merge 1158 story Reject boilerplate user stories at save time

This commit is contained in:
Huskies Agent
2026-07-16 00:03:44 +00:00
parent 6cceec9c26
commit e67eff17ad
9 changed files with 158 additions and 16 deletions
+4 -6
View File
@@ -111,10 +111,9 @@ fn parse_pub_item(line: &str) -> Option<(String, String)> {
let rest = if let Some(r) = trimmed.strip_prefix("pub(") {
let end = r.find(')')?;
r[end + 1..].trim_start()
} else if let Some(r) = trimmed.strip_prefix("pub ") {
r.trim_start()
} else {
return None;
let r = trimmed.strip_prefix("pub ")?;
r.trim_start()
};
// Handle "async fn"
@@ -139,10 +138,9 @@ fn parse_pub_item(line: &str) -> Option<(String, String)> {
("const", r.trim_start())
} else if let Some(r) = rest.strip_prefix("static ") {
("static", r.trim_start())
} else if let Some(r) = rest.strip_prefix("mod ") {
("mod", r.trim_start())
} else {
return None;
let r = rest.strip_prefix("mod ")?;
("mod", r.trim_start())
};
let name: String = name_part
+4 -6
View File
@@ -123,10 +123,9 @@ fn parse_exported_item(line: &str) -> Option<(String, String)> {
// Strip "export default" or "export"
let rest = if let Some(r) = trimmed.strip_prefix("export default ") {
r.trim_start()
} else if let Some(r) = trimmed.strip_prefix("export ") {
r.trim_start()
} else {
return None;
let r = trimmed.strip_prefix("export ")?;
r.trim_start()
};
// Strip optional "async"
@@ -148,10 +147,9 @@ fn parse_exported_item(line: &str) -> Option<(String, String)> {
("const", r.trim_start())
} else if let Some(r) = rest.strip_prefix("let ") {
("let", r.trim_start())
} else if let Some(r) = rest.strip_prefix("enum ") {
("enum", r.trim_start())
} else {
return None;
let r = rest.strip_prefix("enum ")?;
("enum", r.trim_start())
};
let name: String = name_part
@@ -51,10 +51,9 @@ pub fn extract_project_rebuild_command(
.trim()
.trim_start_matches(|c: char| !c.is_alphanumeric());
let rest = if let Some(r) = trimmed.strip_prefix("project-rebuild") {
let rest = {
let r = trimmed.strip_prefix("project-rebuild")?;
r.trim()
} else {
return None;
};
let mut parts = rest.split_whitespace();
+14
View File
@@ -156,6 +156,20 @@ mod tests {
assert_eq!(state.steps[1].status, StepStatus::Generating);
}
#[test]
fn wizard_generate_rejects_boilerplate_user_story_content() {
let dir = TempDir::new().unwrap();
let ctx = setup(&dir);
let result = tool_wizard_generate(
&serde_json::json!({"content": "As a user, I want X, so that Y."}),
&ctx,
);
let err = result.unwrap_err();
assert!(err.contains("Invalid content"));
let state = WizardState::load(dir.path()).unwrap();
assert_eq!(state.steps[1].status, StepStatus::Pending);
}
#[test]
fn wizard_generate_with_content_stages_content() {
let dir = TempDir::new().unwrap();
+25
View File
@@ -11,6 +11,7 @@ pub(crate) mod io;
pub mod state_machine;
use crate::io::wizard::{StepStatus, WizardState, WizardStep, format_wizard_state};
use crate::validation::check_boilerplate_user_story;
use std::path::Path;
// ── Error type ────────────────────────────────────────────────────────────────
@@ -21,6 +22,7 @@ use std::path::Path;
/// - [`Error::NotActive`] → 404 Not Found
/// - [`Error::InvalidStateTransition`] → 400 Bad Request
/// - [`Error::MissingInput`] → 400 Bad Request
/// - [`Error::InvalidContent`] → 400 Bad Request
/// - [`Error::GenerationFailure`] → 500 Internal Server Error
/// - [`Error::PersistenceFailure`] → 500 Internal Server Error
#[derive(Debug)]
@@ -32,6 +34,8 @@ pub enum Error {
InvalidStateTransition(String),
/// Required input was absent (e.g. content not staged before confirming).
MissingInput(String),
/// Staged content failed validation (e.g. boilerplate user-story template).
InvalidContent(String),
/// The LLM or agent failed to generate content for the step.
GenerationFailure(String),
/// A filesystem read or write operation failed.
@@ -46,6 +50,7 @@ impl std::fmt::Display for Error {
}
Self::InvalidStateTransition(msg) => write!(f, "Invalid state transition: {msg}"),
Self::MissingInput(msg) => write!(f, "Missing input: {msg}"),
Self::InvalidContent(msg) => write!(f, "Invalid content: {msg}"),
Self::GenerationFailure(msg) => write!(f, "Generation failed: {msg}"),
Self::PersistenceFailure(msg) => write!(f, "Persistence error: {msg}"),
}
@@ -104,6 +109,8 @@ pub fn status(root: &Path) -> Result<String, Error> {
///
/// # Errors
/// - [`Error::NotActive`] if no wizard is active.
/// - [`Error::InvalidContent`] if `content` matches the boilerplate
/// "As a …, I want …, so that …" user-story template.
/// - [`Error::PersistenceFailure`] if saving state fails.
pub fn generate(root: &Path, content: Option<&str>) -> Result<String, Error> {
let mut state = io::load(root).ok_or(Error::NotActive)?;
@@ -116,6 +123,9 @@ pub fn generate(root: &Path, content: Option<&str>) -> Result<String, Error> {
let step = state.steps[current_idx].step;
if let Some(c) = content {
if let Some(err) = check_boilerplate_user_story("content", c) {
return Err(Error::InvalidContent(err.to_string()));
}
state.set_step_status(step, StepStatus::AwaitingConfirmation, Some(c.to_string()));
io::save(&state, root)?;
return Ok(format!(
@@ -381,6 +391,21 @@ mod tests {
assert_eq!(state.steps[1].status, StepStatus::AwaitingConfirmation);
}
#[test]
fn generate_rejects_boilerplate_user_story_content() {
let dir = TempDir::new().unwrap();
init_wizard(&dir);
let err = generate(
dir.path(),
Some("As a user, I want this, so that I get value."),
)
.unwrap_err();
assert!(matches!(err, Error::InvalidContent(_)));
// Content must not be staged when rejected.
let state = get_state(dir.path()).unwrap();
assert_eq!(state.steps[1].status, StepStatus::Pending);
}
#[test]
fn generate_no_content_marks_generating() {
let dir = TempDir::new().unwrap();
+11
View File
@@ -29,6 +29,8 @@ pub enum ValidationError {
},
/// A field value contains a tool-call grammar fragment that must be rejected.
AntiGrammarToken { field: String, token: String },
/// A field value matches the unfilled "As a …, I want …, so that …" boilerplate template.
BoilerplateUserStory { field: String },
/// A numeric field value is outside its allowed range.
OutOfRange {
field: String,
@@ -91,6 +93,15 @@ impl fmt::Display for ValidationError {
"field '{field}' contains a tool-call grammar fragment: {token:?}"
)
}
Self::BoilerplateUserStory { field } => {
write!(
f,
"field '{field}' looks like the unfilled 'As a …, I want …, so that …' \
template. Rephrase it as a concrete user story in plain language, \
describing the actual actor, action, and benefit. If you cannot \
determine the right phrasing, ask the commissioning user."
)
}
Self::OutOfRange {
field,
min,
+1
View File
@@ -20,6 +20,7 @@ mod sanitize;
pub use error::{ValidationError, format_errors_as_json};
pub use newtypes::{
AcceptanceCriterion, DependsOnId, Description, StoryId, StoryName, TargetStage,
check_boilerplate_user_story,
};
pub use requests::{
AddCriterionRequest, ConvertItemTypeRequest, CreateBugRequest, CreateEpicRequest,
+75
View File
@@ -6,7 +6,9 @@
//! in preference to nutype's lower-level `new()`.
use nutype::nutype;
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::sync::LazyLock;
use super::error::ValidationError;
use super::sanitize;
@@ -51,6 +53,25 @@ fn check_grammar_tokens(field: &str, value: &str) -> Vec<ValidationError> {
.collect()
}
/// Matches the classic "As a …, I want …, so that …" user-story template,
/// case-insensitively and tolerant of punctuation/whitespace variation between
/// the three clauses (commas, dashes, or nothing at all).
static BOILERPLATE_USER_STORY_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?is)\bas\s+an?\b.*?\bi\s+want\b.*?\bso\s+that\b").unwrap());
/// Return a [`ValidationError::BoilerplateUserStory`] if `value` matches the
/// unfilled "As a …, I want …, so that …" template rather than a concrete,
/// plain-language user story.
pub fn check_boilerplate_user_story(field: &str, value: &str) -> Option<ValidationError> {
if BOILERPLATE_USER_STORY_RE.is_match(value) {
Some(ValidationError::BoilerplateUserStory {
field: field.to_string(),
})
} else {
None
}
}
// ---------------------------------------------------------------------------
// StoryName newtype
// ---------------------------------------------------------------------------
@@ -481,6 +502,60 @@ mod tests {
assert!(matches!(err[0], ValidationError::AntiGrammarToken { .. }));
}
// --- check_boilerplate_user_story ---
#[test]
fn boilerplate_user_story_rejects_classic_template() {
let err = check_boilerplate_user_story(
"user_story",
"As a user, I want to log in, so that I can access my account.",
);
assert!(matches!(
err,
Some(ValidationError::BoilerplateUserStory { .. })
));
}
#[test]
fn boilerplate_user_story_rejects_without_commas() {
let err = check_boilerplate_user_story(
"user_story",
"As a developer I want CI so that builds are faster",
);
assert!(matches!(
err,
Some(ValidationError::BoilerplateUserStory { .. })
));
}
#[test]
fn boilerplate_user_story_rejects_case_insensitively() {
let err = check_boilerplate_user_story(
"user_story",
"AS AN admin I WANT reports SO THAT I can audit usage",
);
assert!(matches!(
err,
Some(ValidationError::BoilerplateUserStory { .. })
));
}
#[test]
fn boilerplate_user_story_accepts_concrete_plain_language() {
let err = check_boilerplate_user_story(
"user_story",
"The pipeline dashboard should show test coverage next to each story so \
QA doesn't have to check CI separately.",
);
assert!(err.is_none());
}
#[test]
fn boilerplate_user_story_accepts_partial_phrase_without_so_that() {
let err = check_boilerplate_user_story("user_story", "As a user I want this");
assert!(err.is_none());
}
// --- DependsOnId ---
#[test]
+22 -1
View File
@@ -10,6 +10,7 @@ use serde_json::Value;
use super::error::{ValidationError, format_errors_as_json};
use super::newtypes::{
AcceptanceCriterion, DependsOnId, Description, StoryId, StoryName, TargetStage,
check_boilerplate_user_story,
};
// ---------------------------------------------------------------------------
@@ -91,7 +92,16 @@ impl CreateStoryRequest {
let user_story = match args.get("user_story").and_then(|v| v.as_str()) {
None => None,
Some(raw) => match Description::parse("user_story", raw) {
Ok(d) => Some(d),
Ok(d) => {
if let Some(boilerplate_err) =
check_boilerplate_user_story("user_story", d.as_str())
{
errors.push(boilerplate_err);
None
} else {
Some(d)
}
}
Err(mut errs) => {
errors.append(&mut errs);
None
@@ -1365,6 +1375,17 @@ mod tests {
assert!(err.contains("AntiGrammarToken"));
}
#[test]
fn create_story_request_rejects_boilerplate_user_story() {
let args = json!({
"name": "Valid Name",
"user_story": "As a user, I want to do a thing, so that I get value.",
"acceptance_criteria": ["AC1"]
});
let err = CreateStoryRequest::from_json(&args).unwrap_err();
assert!(err.contains("BoilerplateUserStory"));
}
#[test]
fn create_story_request_with_all_optional_fields() {
let args = json!({