1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
//! Validation issues and formatting.
use std::fmt;
/// Severity level for validation issues.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
/// Errors prevent execution.
Error,
/// Warnings are advisory but allow execution.
Warning,
}
impl fmt::Display for Severity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Severity::Error => write!(f, "error"),
Severity::Warning => write!(f, "warning"),
}
}
}
/// Categorizes validation issues for filtering and tooling.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IssueCode {
/// Command not found in registry or user tools.
UndefinedCommand,
/// Required parameter not provided.
MissingRequiredArg,
/// Flag not defined in tool schema.
UnknownFlag,
/// Argument type doesn't match schema.
InvalidArgType,
/// seq increment is zero (infinite loop).
SeqZeroIncrement,
/// Regex pattern is invalid.
InvalidRegex,
/// break/continue outside of a loop.
BreakOutsideLoop,
/// return outside of a function.
ReturnOutsideFunction,
/// Variable may be undefined.
PossiblyUndefinedVariable,
/// Bare scalar variable in for loop (no word splitting in kaish).
ForLoopScalarVar,
/// scatter without gather — parallel results would be lost.
ScatterWithoutGather,
/// Field access on `$?` (e.g. `${?.data}`, `${?.ok}`) was removed.
/// `$?` is the POSIX exit code; use `kaish-last` for structured data.
LastResultFieldAccess,
/// diff was given other than two file operands.
DiffNeedsTwoFiles,
/// sed expression is syntactically invalid.
InvalidSedExpr,
/// jq filter expression is syntactically invalid.
InvalidJqFilter,
/// A subscripted assignment lvalue (`x[k]=v`) targets an undefined root
/// variable. Unlike a plain read, a path-set never autovivifies the
/// root — it must already exist as a collection.
LvalueUndefinedRoot,
/// An assignment target contains a dot (`user.email=x`). kaish is
/// brackets-only for collection access — the `Ident` token admits `.`
/// for other uses (filenames, `source foo.kai`), so this is caught here
/// rather than by tightening the lexer regex.
DottedAssignmentTarget,
}
impl IssueCode {
/// Returns a short code string for the issue.
///
/// Code numbers are stable identifiers, not contiguous. E010 and
/// W003/W004/W005 remain retired, as does W006 (PosixTestCommand, retired
/// when `test` became a first-class builtin). E006 (InvalidSedExpr), E007
/// (InvalidJqFilter), and E011 (DiffNeedsTwoFiles) were wired up with
/// real emitters in 2026-06-14.
pub fn code(&self) -> &'static str {
match self {
IssueCode::UndefinedCommand => "E001",
IssueCode::MissingRequiredArg => "E002",
IssueCode::UnknownFlag => "W001",
IssueCode::InvalidArgType => "E003",
IssueCode::SeqZeroIncrement => "E004",
IssueCode::InvalidRegex => "E005",
IssueCode::InvalidSedExpr => "E006",
IssueCode::InvalidJqFilter => "E007",
IssueCode::BreakOutsideLoop => "E008",
IssueCode::ReturnOutsideFunction => "E009",
// E010 retired — never emitted
IssueCode::PossiblyUndefinedVariable => "W002",
IssueCode::DiffNeedsTwoFiles => "E011",
IssueCode::ForLoopScalarVar => "E012",
IssueCode::ScatterWithoutGather => "E014",
IssueCode::LastResultFieldAccess => "E015",
IssueCode::LvalueUndefinedRoot => "E016",
IssueCode::DottedAssignmentTarget => "E017",
}
}
/// Whether a warning carrying this code should be surfaced to the agent
/// (appended to the result's stderr) rather than only trace-logged.
///
/// Most warnings stay trace-only — `UndefinedCommand` fires on every
/// external command (`grep`, `cargo`), so surfacing them all would be
/// noise. Opt a code in here only when its guidance is worth interrupting
/// for. This is the surfacing seam for the "did-you-mean" guidance pass.
///
/// Currently dormant: the one opted-in code (`PosixTestCommand`) was retired
/// when `test` became a builtin. The seam stays wired for the next code that
/// earns surfacing — add a `matches!(self, IssueCode::Foo | …)` arm here.
pub fn surfaces_to_agent(&self) -> bool {
let _ = self;
false
}
/// Default severity for this issue code.
pub fn default_severity(&self) -> Severity {
match self {
// These are hard errors that will definitely fail at runtime
IssueCode::SeqZeroIncrement
| IssueCode::InvalidRegex
| IssueCode::InvalidSedExpr
| IssueCode::InvalidJqFilter
| IssueCode::DiffNeedsTwoFiles
| IssueCode::BreakOutsideLoop
| IssueCode::ReturnOutsideFunction
| IssueCode::ForLoopScalarVar
| IssueCode::ScatterWithoutGather
| IssueCode::LastResultFieldAccess
| IssueCode::LvalueUndefinedRoot
| IssueCode::DottedAssignmentTarget => Severity::Error,
// These are warnings because context matters:
// - MissingRequiredArg: might be provided by pipeline stdin or environment
// - InvalidArgType: shell coerces types at runtime
// - UndefinedCommand: might be script in PATH or external tool
IssueCode::MissingRequiredArg
| IssueCode::InvalidArgType
| IssueCode::UndefinedCommand
| IssueCode::UnknownFlag
| IssueCode::PossiblyUndefinedVariable => Severity::Warning,
}
}
}
impl fmt::Display for IssueCode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.code())
}
}
/// Source location span.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Span {
/// Start byte offset in source.
pub start: usize,
/// End byte offset in source.
pub end: usize,
}
impl Span {
/// Create a new span.
pub fn new(start: usize, end: usize) -> Self {
Self { start, end }
}
/// Convert byte offset to line:column.
///
/// Returns (line, column) where both are 1-indexed.
pub fn to_line_col(&self, source: &str) -> (usize, usize) {
let mut line = 1;
let mut col = 1;
for (i, ch) in source.char_indices() {
if i >= self.start {
break;
}
if ch == '\n' {
line += 1;
col = 1;
} else {
col += 1;
}
}
(line, col)
}
/// Format span as "line:col" string.
pub fn format_location(&self, source: &str) -> String {
let (line, col) = self.to_line_col(source);
format!("{}:{}", line, col)
}
}
/// A validation issue found in the script.
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ValidationIssue {
/// Severity level.
pub severity: Severity,
/// Issue category code.
pub code: IssueCode,
/// Human-readable message.
pub message: String,
/// Optional source location.
pub span: Option<Span>,
/// Optional suggestion for fixing the issue.
pub suggestion: Option<String>,
}
impl ValidationIssue {
/// Create a new validation error.
pub fn error(code: IssueCode, message: impl Into<String>) -> Self {
Self {
severity: Severity::Error,
code,
message: message.into(),
span: None,
suggestion: None,
}
}
/// Create a new validation warning.
pub fn warning(code: IssueCode, message: impl Into<String>) -> Self {
Self {
severity: Severity::Warning,
code,
message: message.into(),
span: None,
suggestion: None,
}
}
/// Add a span to this issue.
pub fn with_span(mut self, span: Span) -> Self {
self.span = Some(span);
self
}
/// Add a suggestion to this issue.
pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
self.suggestion = Some(suggestion.into());
self
}
/// Format the issue for display.
///
/// With source provided, includes line:column information and source context.
pub fn format(&self, source: &str) -> String {
let mut result = String::new();
// Location prefix if we have a span
if let Some(span) = &self.span {
let loc = span.format_location(source);
result.push_str(&format!("{}: ", loc));
}
// Severity and code
result.push_str(&format!("{} [{}]: {}", self.severity, self.code, self.message));
// Suggestion if available
if let Some(suggestion) = &self.suggestion {
result.push_str(&format!("\n → {}", suggestion));
}
// Source context if we have a span
if let Some(span) = &self.span
&& let Some(line_content) = get_line_at_offset(source, span.start) {
result.push_str(&format!("\n | {}", line_content));
}
result
}
}
impl fmt::Display for ValidationIssue {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} [{}]: {}", self.severity, self.code, self.message)
}
}
/// Get the line containing a byte offset.
fn get_line_at_offset(source: &str, offset: usize) -> Option<&str> {
if offset >= source.len() {
return None;
}
let start = source[..offset].rfind('\n').map_or(0, |i| i + 1);
let end = source[offset..]
.find('\n')
.map_or(source.len(), |i| offset + i);
Some(&source[start..end])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn span_to_line_col_single_line() {
let source = "echo hello world";
let span = Span::new(5, 10);
assert_eq!(span.to_line_col(source), (1, 6));
}
#[test]
fn span_to_line_col_multi_line() {
let source = "line one\nline two\nline three";
// "line" on line 3 starts at offset 18
let span = Span::new(18, 22);
assert_eq!(span.to_line_col(source), (3, 1));
}
#[test]
fn span_format_location() {
let source = "first\nsecond\nthird";
let span = Span::new(6, 12); // "second"
assert_eq!(span.format_location(source), "2:1");
}
#[test]
fn issue_formatting() {
let issue = ValidationIssue::error(IssueCode::UndefinedCommand, "command 'foo' not found")
.with_span(Span::new(0, 3))
.with_suggestion("did you mean 'for'?");
let source = "foo bar";
let formatted = issue.format(source);
assert!(formatted.contains("1:1"));
assert!(formatted.contains("error"));
assert!(formatted.contains("E001"));
assert!(formatted.contains("command 'foo' not found"));
assert!(formatted.contains("did you mean 'for'?"));
}
#[test]
fn get_line_at_offset_works() {
let source = "line one\nline two\nline three";
assert_eq!(get_line_at_offset(source, 0), Some("line one"));
assert_eq!(get_line_at_offset(source, 9), Some("line two"));
assert_eq!(get_line_at_offset(source, 18), Some("line three"));
}
}