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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ breaking entries are marked **BREAKING**.
register an external program as a tool with a declared grammar. Verbs and flags
are deny-by-default, refused with exit 2 before any spawn; the kernel renders
argv. Runs with `allow_external_commands` off. See `docs/wrapped_command.md`.
- **`ValidationIssue::command`** — the command an issue concerns, when one is
genuinely known (`UndefinedCommand`'s name, a builtin's own regex/schema
failure), so an embedder can route on it instead of parsing `message`.
Absent, not a placeholder, for issues that aren't about a command at all
(a bad assignment target, `break` outside a loop). `#[non_exhaustive]`
already blocked struct-literal construction, so this is not breaking.

- **Plan JSON carries build identity** — `kaish_version`, `kaish_git_hash`,
and `kaish_build_date` ride along on every `--plan`/`--plan-file`/`plan`
Expand Down
3 changes: 2 additions & 1 deletion crates/kaish-kernel/src/tools/builtin/diff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,8 @@ impl Tool for Diff {
IssueCode::DiffNeedsTwoFiles,
format!("diff: needs exactly two file operands (got {operand_count})"),
)
.with_suggestion("usage: diff OLD NEW"),
.with_suggestion("usage: diff OLD NEW")
.with_command(self.name()),
);
}

Expand Down
4 changes: 3 additions & 1 deletion crates/kaish-kernel/src/tools/builtin/grep.rs
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,9 @@ impl Tool for Grep {
rewrote,
Some("-E"),
),
).with_suggestion("check regex syntax at https://docs.rs/regex"));
)
.with_suggestion("check regex syntax at https://docs.rs/regex")
.with_command(self.name()));
}
}

Expand Down
3 changes: 2 additions & 1 deletion crates/kaish-kernel/src/tools/builtin/jq_native.rs
Original file line number Diff line number Diff line change
Expand Up @@ -507,7 +507,8 @@ impl Tool for JqNative {
IssueCode::InvalidJqFilter,
format!("jq: {msg}"),
)
.with_suggestion("check jq filter syntax: https://jqlang.org/manual/"),
.with_suggestion("check jq filter syntax: https://jqlang.org/manual/")
.with_command(self.name()),
);
}

Expand Down
3 changes: 2 additions & 1 deletion crates/kaish-kernel/src/tools/builtin/sed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,8 @@ impl Tool for Sed {
"commands: s/pat/rep/[gipN], y/abc/xyz/, d, p, q, a/i/c TEXT; \
chain with ; or -e; addresses: N, $, /re/, N,M; regex is ERE \
(egrep-style; GNU BRE \\| \\(…\\) \\{N,M\\} also accepted)",
),
)
.with_command(self.name()),
);
}
}
Expand Down
6 changes: 3 additions & 3 deletions crates/kaish-kernel/src/tools/builtin/seq.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,21 +59,21 @@ impl Tool for Seq {
issues.push(ValidationIssue::error(
IssueCode::SeqZeroIncrement,
"seq: increment cannot be zero (would cause infinite loop)",
));
).with_command(self.name()));
} else if let Some(Value::Float(f)) = args.positional.get(1) {
if *f == 0.0 {
issues.push(ValidationIssue::error(
IssueCode::SeqZeroIncrement,
"seq: increment cannot be zero (would cause infinite loop)",
));
).with_command(self.name()));
}
} else if let Some(Value::String(s)) = args.positional.get(1)
&& let Ok(n) = s.parse::<f64>()
&& n == 0.0 {
issues.push(ValidationIssue::error(
IssueCode::SeqZeroIncrement,
"seq: increment cannot be zero (would cause infinite loop)",
));
).with_command(self.name()));
}
}

Expand Down
3 changes: 2 additions & 1 deletion crates/kaish-kernel/src/tools/builtin/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,8 @@ impl Tool for Test {
IssueCode::TestCompoundOperator,
format!("test: '{op}' is not supported — {COMPOUND_HINT}"),
)
.with_suggestion("test EXPR1 && test EXPR2, or [[ EXPR1 && EXPR2 ]]"),
.with_suggestion("test EXPR1 && test EXPR2, or [[ EXPR1 && EXPR2 ]]")
.with_command(self.name()),
],
None => Vec::new(),
}
Expand Down
42 changes: 42 additions & 0 deletions crates/kaish-kernel/src/tools/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,48 @@ mod tests {
assert!(!registry.contains("nonexistent"));
}

/// `validate_command` (validator/walker.rs) falls back to `tool.schema()`
/// on a catalog miss, and `validate_against_schema` attributes every
/// `ValidationIssue` it raises to that schema's `.name` — not to the AST
/// `cmd.name` the walker actually resolved and dispatched by (`==
/// tool.name()`, since `register`/`register_arc` key this map on it).
/// Nothing at the `Tool` trait level keeps `name()` and `schema().name`
/// in sync; a tool whose two names disagree would silently misattribute
/// every issue it raises to the wrong command.
///
/// This test is the actual enforcement of that invariant. A
/// `debug_assert!` at the call site would compile to nothing in a
/// release build — this workspace has no `[profile.release]` override,
/// so `debug_assertions` is off wherever kaish actually ships — while
/// this test runs in CI on every `cargo test` and covers every in-tree
/// builtin at once, not only the ones a particular script happens to
/// exercise.
///
/// Only covers what `register_builtins` installs — a third-party tool
/// an embedder registers at runtime is not walked here and is not
/// covered by anything else either.
#[test]
fn every_builtin_tool_name_matches_its_own_schema_name() {
let mut registry = ToolRegistry::new();
crate::tools::register_builtins(&mut registry);

for name in registry.names() {
let tool = registry.get(name).expect("a name from names() must resolve via get()");
assert_eq!(
tool.name(),
tool.schema().name.as_str(),
"tool '{name}': Tool::name() == {:?} but Tool::schema().name == {:?} -- \
these must agree. validate_against_schema attributes every ValidationIssue \
it raises to schema.name, while the walker resolves and dispatches the \
command by cmd.name (== tool.name()); a mismatch here means an embedder \
reading ValidationIssue::command sees the wrong command. Fix the tool so \
Tool::name() and Tool::schema().name agree -- do not relax this test.",
tool.name(),
tool.schema().name,
);
}
}

#[test]
fn test_names_sorted() {
let mut registry = ToolRegistry::new();
Expand Down
10 changes: 9 additions & 1 deletion crates/kaish-kernel/src/tools/wrapped.rs
Original file line number Diff line number Diff line change
Expand Up @@ -484,10 +484,18 @@ fn issue(error: &WrappedError, uncertain: bool) -> ValidationIssue {
}
_ => IssueCode::WrappedCallRejected,
};
if uncertain {
let issue = if uncertain {
ValidationIssue::warning(code, error.to_string())
} else {
ValidationIssue::error(code, error.to_string())
};
// Every variant names its command, so absent here would mean "not about a
// command" for a message that opens with one. Empty only before
// `attributed_to` has run.
if error.command().is_empty() {
issue
} else {
issue.with_command(error.command().to_string())
}
}

Expand Down
25 changes: 25 additions & 0 deletions crates/kaish-kernel/src/tools/wrapped/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,31 @@ impl WrappedError {
2
}

/// The wrapped command this refusal is about. Empty only for a path
/// failure that `attributed_to` has not filled in yet.
pub fn command(&self) -> &str {
match self {
WrappedError::UnknownVerb { command, .. }
| WrappedError::MissingVerb { command, .. }
| WrappedError::UnknownFlag { command, .. }
| WrappedError::ClusteredShort { command, .. }
| WrappedError::GluedShortValue { command, .. }
| WrappedError::MissingFlagValue { command, .. }
| WrappedError::UnexpectedFlagValue { command, .. }
| WrappedError::RepeatedFlag { command, .. }
| WrappedError::UnexpectedArgument { command, .. }
| WrappedError::UndeclaredPositional { command, .. }
| WrappedError::MissingRequiredFlag { command, .. }
| WrappedError::MissingRequiredPositional { command, .. }
| WrappedError::NotAnInteger { command, .. }
| WrappedError::NotInChoices { command, .. }
| WrappedError::PathOutsideRoot { command, .. }
| WrappedError::PathRootUnresolvable { command, .. }
| WrappedError::NulByte { command, .. }
| WrappedError::BinaryArgument { command, .. } => command,
}
}

/// Fill in the command and positional a bare path failure belongs to.
///
/// [`crate::tools::wrapped::path_is_under`]'s companion
Expand Down
8 changes: 8 additions & 0 deletions crates/kaish-kernel/src/tools/wrapped/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,14 @@ fn a_literal_unknown_flag_is_an_error_before_anything_runs() {
);
}

#[test]
fn a_refusal_records_the_command_it_is_about() {
// The message opens with the command, so an absent field here would read
// as "not about a command" — the one thing the field exists to mean.
let issues = tool(git()).validate(&args(&["log", "--output=/tmp/x"]));
assert_eq!(issues[0].command.as_deref(), Some("git"), "{issues:?}");
}

#[test]
fn an_opaque_word_cannot_be_an_unknown_flag() {
// `git log "$branch"` — the validator has no value, so it has no verdict.
Expand Down
115 changes: 99 additions & 16 deletions crates/kaish-kernel/src/validator/walker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,16 @@ pub struct Validator<'a> {
/// it hits, and a hit is what skips the fallback. The kernel cannot go
/// stale — the registry is frozen before `set_tool_schemas` snapshots it —
/// but an embedder hand-building a catalog can. A hit also assumes
/// `tool.name() == tool.schema().name`, which nothing enforces.
/// `tool.name() == tool.schema().name` — on the fallback path this is
/// what keeps `validate_against_schema`'s `schema.name` (which populates
/// `ValidationIssue::command`) matching the command the walker actually
/// resolved and dispatched by. Nothing at runtime enforces this; ship
/// builds have no `[profile.release]` override in this workspace, so
/// `debug_assert!` compiles to nothing there. What holds it is
/// `tools::registry::tests::every_builtin_tool_name_matches_its_own_schema_name`,
/// which walks every builtin `register_builtins` installs and is true
/// for all of them today. It does not cover a third-party tool an
/// embedder registers — nothing does.
catalog: &'a [ToolSchema],
/// Variable scope tracker.
scope: ScopeTracker,
Expand Down Expand Up @@ -230,7 +239,9 @@ impl<'a> Validator<'a> {
self.issues.push(ValidationIssue::warning(
IssueCode::UndefinedCommand,
format!("command '{}' not found in builtin registry", cmd.name),
).with_suggestion("this may be a script in PATH or external command"));
)
.with_suggestion("this may be a script in PATH or external command")
.with_command(cmd.name.clone()));
}

// Validate arguments expressions
Expand Down Expand Up @@ -259,6 +270,26 @@ impl<'a> Validator<'a> {
&owned
}
};
// `validate_against_schema` reads `schema.name` — not `cmd.name` —
// to populate `ValidationIssue::command` (E002/W001/E003). A
// catalog hit already guarantees `schema.name == cmd.name` (the
// binary search matched on it); the fallback trusts
// `tool.schema().name == tool.name()`, which the `catalog` field
// doc above calls out as unenforced by anything at this call
// site — this `debug_assert_eq!` documents that trust here, but
// it is not the enforcement: `debug_assertions` is off in every
// build this workspace ships (no `[profile.release]` override),
// so it compiles to nothing outside `cargo test`/`cargo build`
// without `--release`. The actual invariant is pinned by
// `tools::registry::tests::every_builtin_tool_name_matches_its_own_schema_name`,
// which walks every in-tree builtin regardless of which script
// happens to exercise it.
debug_assert_eq!(
schema.name, cmd.name,
"schema-driven validation issues for '{}' would carry the wrong command \
name — schema.name ('{}') must match the command actually invoked",
cmd.name, schema.name
);
let tool_args = build_tool_args_for_validation(&cmd.args, Some(schema));
let tool_issues = tool.validate(&tool_args);
self.issues.extend(tool_issues);
Expand All @@ -285,21 +316,24 @@ impl<'a> Validator<'a> {

/// Validate a pipeline.
fn validate_pipeline(&mut self, pipe: &Pipeline) {
// Check for scatter without gather
let named = |name: &str| {
pipe.stages
.iter()
.filter_map(|s| s.as_command())
.any(|c| c.name == name)
};
let has_scatter = named("scatter");
let has_gather = named("gather");
if has_scatter && !has_gather {
// Check for scatter without gather. `command` is cloned off the
// matched stage's own name rather than hardcoded, so it can never
// drift from the guard that found it (both currently read "scatter",
// but nothing ties them together otherwise).
let scatter_stage =
pipe.stages.iter().filter_map(|s| s.as_command()).find(|c| c.name == "scatter");
let has_gather =
pipe.stages.iter().filter_map(|s| s.as_command()).any(|c| c.name == "gather");
if let Some(scatter_cmd) = scatter_stage
&& !has_gather
{
self.issues.push(
ValidationIssue::error(
IssueCode::ScatterWithoutGather,
"scatter without gather — parallel results would be lost",
).with_suggestion("add gather: ... | scatter | cmd | gather")
)
.with_suggestion("add gather: ... | scatter | cmd | gather")
.with_command(scatter_cmd.name.clone()),
);
}

Expand Down Expand Up @@ -692,7 +726,8 @@ impl<'a> Validator<'a> {
"'{}' requires {} arguments, got {}",
tool_def.name, required_count, positional_count
),
));
)
.with_command(tool_def.name.clone()));
}
}
}
Expand Down Expand Up @@ -1244,6 +1279,17 @@ mod tests {
let issues = validator.validate(&program);
assert!(!issues.is_empty());
assert!(issues.iter().any(|i| i.code == IssueCode::UndefinedCommand));
// The unresolved name is the whole point of the issue, so `command`
// must carry it structurally, not just inside `message`. Warning
// severity — this site is unreachable through `KernelError::Validation`
// (Error-only), so this direct `Validator` call is the only way to
// pin it at the field level.
assert!(
issues.iter().any(|i| i.code == IssueCode::UndefinedCommand
&& i.command.as_deref() == Some("nonexistent_command")),
"UndefinedCommand must carry the unresolved name: {:?}",
issues
);
}

/// `test` is a first-class builtin now, so it validates through the
Expand Down Expand Up @@ -1347,10 +1393,24 @@ mod tests {
got {fallback_issues:?}"
);

fn render(issues: &[ValidationIssue]) -> Vec<(Severity, IssueCode, &str, Option<&str>)> {
// `command` is included on purpose: it is schema-driven the same way
// `message` is (both read `schema.name`), so a catalog-hit/fallback
// divergence on the command name is exactly the class of bug this
// comparison exists to catch — dropping the field here would make
// that divergence invisible to the one test built to find it.
type Rendered<'a> = (Severity, IssueCode, &'a str, Option<&'a str>, Option<&'a str>);
fn render(issues: &[ValidationIssue]) -> Vec<Rendered<'_>> {
issues
.iter()
.map(|i| (i.severity, i.code, i.message.as_str(), i.suggestion.as_deref()))
.map(|i| {
(
i.severity,
i.code,
i.message.as_str(),
i.suggestion.as_deref(),
i.command.as_deref(),
)
})
.collect()
}
assert_eq!(
Expand Down Expand Up @@ -1545,6 +1605,14 @@ mod tests {
let issues = validator.validate(&program);
assert!(issues.iter().any(|i| i.code == IssueCode::ScatterWithoutGather),
"should flag scatter without gather: {:?}", issues);
// `command` is cloned off the matched stage, not hardcoded — pin that
// it names the stage that actually triggered the check.
assert!(
issues.iter().any(|i| i.code == IssueCode::ScatterWithoutGather
&& i.command.as_deref() == Some("scatter")),
"ScatterWithoutGather must carry the scatter stage's own name: {:?}",
issues
);
}

#[test]
Expand Down Expand Up @@ -1646,5 +1714,20 @@ mod tests {
"missing positional should still error; got {:?}",
issues
);
// A real script can never reach this site today — every `ToolDef`
// the parser produces has `params: vec![]` (`posix_function_parser`
// and `bash_function_parser` both hardcode it), so `required_count`
// is always 0 and the branch is dead outside a hand-built
// `HashMap<String, ToolDef>` like this fixture's. Pin the field
// anyway: it is still a genuinely populated construction site
// (`walker.rs`'s own `.with_command(tool_def.name.clone())`), and a
// future parser change that lets user tools declare required params
// must not silently regress it.
assert!(
issues.iter().any(|i| i.code == IssueCode::MissingRequiredArg
&& i.command.as_deref() == Some("mytool")),
"user-tool MissingRequiredArg must carry the tool's own name: {:?}",
issues
);
}
}
Loading