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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@ All notable changes to EigenScript are documented here.
## [Unreleased]

### Added
- **Lint `W018` — `e.kind` compared against an out-of-set error kind (#469).**
A `catch` handler comparing a caught error's `.kind` against a string that is
a near-miss of a real kind — a case variant (`"IO"`), a single-character typo
(`"index_rage"`), or a kind renamed out from under the handler — is dead code
that silently never fires. The new rule warns and suggests the intended kind.
Zero-false-positive by construction: the closed kind set is derived from
`err_kind_name` at run time (no hand list to drift — it already covers the
post-#509 `deadlock`), and a warning requires all of (1) the `.kind` read off
a **catch-bound** variable, (2) a non-exact match, and (3) a near-miss
(case variant or edit distance 1) — so a valid kind and a genuinely custom
`throw {kind: "..."}` value both stay silent. Calibrated to zero hits over
`lib/`, the test corpus, and the consumer repos.
- **Per-file lint allow-list in `eigs.json` (#455).** A project can now silence
a lint code for a whole file without editing it — an `eigs.json` `lint.allow`
map from project-root-relative path to a list of codes (`"all"` silences
Expand Down
1 change: 1 addition & 0 deletions docs/DIAGNOSTICS.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ a code's meaning never changes, and retired codes are not reused.
| `W015` | warning | A function assigns (without `local`) over a module-level **function** name, clobbering it via mutate-outward so later `<fn> of ...` calls fail — add `local` or rename. (Scoped to function clobbering; benign module-variable reuse is not flagged — that is #404's dataflow-aware territory. `_`-prefixed names are skipped as intentional module state.) |
| `W016` | warning | Bare trajectory predicate **outside a loop condition** (`if stable:`, `ok is converged`, `return diverging`) reads the last-observed binding — an invisible alias (#247/#262) — write `<predicate> of <var>`. Loop conditions are exempt: the single-assign `loop while not converged` form is the documented idiom, and the ambiguous multi-assign case is `W014`. Any explicit subject counts as named, including `stable of (x + 0.0)`; deliberate bare reads carry `# lint: allow W016`. |
| `W017` | warning | Bare 1-element literal arg list: `f of [x]` passes **one argument** — the element, not the list (#405; the pre-#405 rule meant the opposite, so the form reads ambiguously). Write `f of x` for one argument, or `f of ([x])` (#355) to pass a 1-element list. Doubles as the #405 migration audit: `--lint` over a consumer repo surfaces every behavior-changed call site. |
| `W018` | warning | A `catch`-bound error's `.kind` is compared (`==`/`!=`) against a string that is a **near-miss** of a real kind — a case variant (`"IO"`) or a single-character typo (`"index_rage"`), or a kind renamed out from under the handler — so the branch is dead code that silently never fires (#469). Kinds are a closed set (below). Zero-false-positive by construction: only near-misses of a closed kind fire, and only off a catch-bound variable — an exactly-valid kind, and a genuinely custom `throw {kind: "..."}` value many edits from every builtin, both stay silent. |

The human linter output carries the code inline:

Expand Down
233 changes: 233 additions & 0 deletions src/lint.c
Original file line number Diff line number Diff line change
Expand Up @@ -1198,6 +1198,238 @@
w017_scan(ast, ctx);
}

/* ---- W018 (#469): e.kind compared against an out-of-set error kind ---- */

/* The error-kind vocabulary is CLOSED (err_kind_name / the EK_* enum). A catch
* handler comparing a caught error's `.kind` against a string that is a
* near-miss of a real kind — a case variant, a single-character typo, or a
* kind renamed out from under the handler — is dead code that silently never
* fires (the silent-tolerance class the lint train fences).
*
* Zero-false-positive contract, three gates: (1) the `.kind` must be read off a
* **catch-bound** variable, so `.kind` on an unrelated user dict never fires;
* (2) an exactly-valid kind is silent; (3) we warn ONLY on a near-miss (case
* variant or edit distance 1) — a genuinely custom `throw {kind: "..."}` kind,
* many edits from every builtin, stays silent. The closed set is derived from
* err_kind_name at run time (no hand list to drift when a kind is added). */

#define W018_MAX_CATCH_VARS 64
typedef struct { const char *names[W018_MAX_CATCH_VARS]; int count; } W018Scope;

static int w018_valid_kind(const char *s) {
for (int k = EK_INTERNAL; k <= EK_USER; k++)
if (strcmp(s, err_kind_name((ErrKind)k)) == 0) return 1;
return 0;
}

/* Levenshtein bounded by cap; returns min(distance, cap+1). Kind names and
* literals are short, so the O(la*lb) DP over two rows is trivial. */
static int w018_edit_distance(const char *a, const char *b, int cap) {
int la = (int)strlen(a), lb = (int)strlen(b);
int diff = la - lb; if (diff < 0) diff = -diff;
if (diff > cap) return cap + 1;
if (lb >= 64) return cap + 1;
int prev[64], cur[64];
for (int j = 0; j <= lb; j++) prev[j] = j;
for (int i = 1; i <= la; i++) {
cur[0] = i;
int rowmin = cur[0];
for (int j = 1; j <= lb; j++) {
int cost = (a[i-1] == b[j-1]) ? 0 : 1;
int del = prev[j] + 1, ins = cur[j-1] + 1, sub = prev[j-1] + cost;
int m = del < ins ? del : ins; if (sub < m) m = sub;
cur[j] = m; if (m < rowmin) rowmin = m;
}
if (rowmin > cap) return cap + 1; /* whole row already exceeds cap */
for (int j = 0; j <= lb; j++) prev[j] = cur[j];
}
return prev[lb];
}

/* If `s` (assumed NOT an exact valid kind) is a case variant or an
* edit-distance-1 typo of a closed kind, return that canonical kind; else
* NULL. Case fold is ASCII-only — every kind name is lowercase ASCII. */
static const char *w018_near_miss(const char *s) {
char low[128];
size_t n = strlen(s);
if (n && n < sizeof(low)) {
int folded = 0;
for (size_t i = 0; i < n; i++) {
char c = s[i];
if (c >= 'A' && c <= 'Z') { c = (char)(c - 'A' + 'a'); folded = 1; }
low[i] = c;
}
low[n] = '\0';
if (folded && w018_valid_kind(low)) {
for (int k = EK_INTERNAL; k <= EK_USER; k++)
if (strcmp(low, err_kind_name((ErrKind)k)) == 0)
return err_kind_name((ErrKind)k);
}
}
for (int k = EK_INTERNAL; k <= EK_USER; k++) {
const char *m = err_kind_name((ErrKind)k);
if (w018_edit_distance(s, m, 1) <= 1) return m;
}
return NULL;
}

static int w018_scope_has(W018Scope *sc, const char *name) {
for (int i = 0; i < sc->count; i++)
if (strcmp(sc->names[i], name) == 0) return 1;
return 0;
}

static void w018_check_binop(ASTNode *n, LintContext *ctx, W018Scope *sc) {
const char *op = n->data.binop.op;
if (strcmp(op, "=") != 0 && strcmp(op, "!=") != 0) return; /* == or != */
ASTNode *l = n->data.binop.left, *r = n->data.binop.right;
ASTNode *dot = NULL, *str = NULL;
if (l && l->type == AST_DOT && r && r->type == AST_STR) { dot = l; str = r; }
else if (r && r->type == AST_DOT && l && l->type == AST_STR) { dot = r; str = l; }
if (!dot || !str) return;
if (!dot->data.dot.key || strcmp(dot->data.dot.key, "kind") != 0) return;
ASTNode *obj = dot->data.dot.target;
if (!obj || obj->type != AST_IDENT || !obj->data.ident.name) return;
if (!w018_scope_has(sc, obj->data.ident.name)) return;
const char *lit = str->data.str;
if (!lit || w018_valid_kind(lit)) return; /* valid kind → silent */
const char *sugg = w018_near_miss(lit);
if (!sugg) return; /* far off → custom kind */
lint_warn(ctx, n->line, "W018",
"'%s.kind %s \"%s\"' compares against an unknown error kind — did you "
"mean \"%s\"? error kinds are a closed set (docs/DIAGNOSTICS.md)",
obj->data.ident.name, (op[0] == '=' ? "==" : op), lit, sugg);
}

static void w018_scan(ASTNode *n, LintContext *ctx, W018Scope *sc) {

Check warning

Code scanning / CodeQL

Poorly documented large function Warning

Poorly documented function: fewer than 2% comments for a function of 123 lines.
if (!n) return;
switch (n->type) {
case AST_BINOP:
w018_check_binop(n, ctx, sc);
w018_scan(n->data.binop.left, ctx, sc);
w018_scan(n->data.binop.right, ctx, sc);
break;
case AST_TRY: {
for (int i = 0; i < n->data.trycatch.try_count; i++)
w018_scan(n->data.trycatch.try_body[i], ctx, sc);
const char *en = n->data.trycatch.err_name;
int pushed = 0;
if (en && sc->count < W018_MAX_CATCH_VARS) {
sc->names[sc->count++] = en; pushed = 1;
}
for (int i = 0; i < n->data.trycatch.catch_count; i++)
w018_scan(n->data.trycatch.catch_body[i], ctx, sc);
if (pushed) sc->count--;
break;
}
case AST_RELATION:
w018_scan(n->data.relation.left, ctx, sc);
w018_scan(n->data.relation.right, ctx, sc);
break;
case AST_UNARY:
w018_scan(n->data.unary.operand, ctx, sc);
break;
case AST_ASSIGN:
w018_scan(n->data.assign.expr, ctx, sc);
break;
case AST_IF:
w018_scan(n->data.cond.cond, ctx, sc);
for (int i = 0; i < n->data.cond.if_count; i++)
w018_scan(n->data.cond.if_body[i], ctx, sc);
for (int i = 0; i < n->data.cond.else_count; i++)
w018_scan(n->data.cond.else_body[i], ctx, sc);
break;
case AST_LOOP:
w018_scan(n->data.loop.cond, ctx, sc);
for (int i = 0; i < n->data.loop.body_count; i++)
w018_scan(n->data.loop.body[i], ctx, sc);
break;
case AST_FUNC:
for (int i = 0; i < n->data.func.body_count; i++)
w018_scan(n->data.func.body[i], ctx, sc);
break;
case AST_RETURN:
w018_scan(n->data.ret.expr, ctx, sc);
break;
case AST_BLOCK:
case AST_UNOBSERVED:
for (int i = 0; i < n->data.block.count; i++)
w018_scan(n->data.block.stmts[i], ctx, sc);
break;
case AST_LIST_PATTERN_ASSIGN:
w018_scan(n->data.list_pattern_assign.expr, ctx, sc);
break;
case AST_SLICE:
w018_scan(n->data.slice.target, ctx, sc);
w018_scan(n->data.slice.start, ctx, sc);
w018_scan(n->data.slice.end, ctx, sc);
break;
case AST_LIST:
for (int i = 0; i < n->data.list.count; i++)
w018_scan(n->data.list.elems[i], ctx, sc);
break;
case AST_INDEX:
w018_scan(n->data.index.target, ctx, sc);
w018_scan(n->data.index.index, ctx, sc);
break;
case AST_LISTCOMP:
w018_scan(n->data.listcomp.expr, ctx, sc);
w018_scan(n->data.listcomp.iter, ctx, sc);
if (n->data.listcomp.filter)
w018_scan(n->data.listcomp.filter, ctx, sc);
break;
case AST_FOR:
w018_scan(n->data.forloop.iter, ctx, sc);
for (int i = 0; i < n->data.forloop.body_count; i++)
w018_scan(n->data.forloop.body[i], ctx, sc);
break;
case AST_PROGRAM:
for (int i = 0; i < n->data.program.count; i++)
w018_scan(n->data.program.stmts[i], ctx, sc);
break;
case AST_DICT:
for (int i = 0; i < n->data.dict.count; i++) {
w018_scan(n->data.dict.keys[i], ctx, sc);
w018_scan(n->data.dict.vals[i], ctx, sc);
}
break;
case AST_DOT:
w018_scan(n->data.dot.target, ctx, sc);
break;
case AST_DOT_ASSIGN:
w018_scan(n->data.dot_assign.target, ctx, sc);
w018_scan(n->data.dot_assign.expr, ctx, sc);
break;
case AST_INDEX_ASSIGN:
w018_scan(n->data.index_assign.target, ctx, sc);
w018_scan(n->data.index_assign.index, ctx, sc);
w018_scan(n->data.index_assign.expr, ctx, sc);
break;
case AST_MATCH:
w018_scan(n->data.match.expr, ctx, sc);
for (int i = 0; i < n->data.match.case_count; i++) {
w018_scan(n->data.match.patterns[i], ctx, sc);
for (int j = 0; j < n->data.match.body_counts[i]; j++)
w018_scan(n->data.match.bodies[i][j], ctx, sc);
}
break;
case AST_LAMBDA:
w018_scan(n->data.lambda.body, ctx, sc);
break;
case AST_INTERROGATE:
w018_scan(n->data.interrogate.expr, ctx, sc);
if (n->data.interrogate.at_expr)
w018_scan(n->data.interrogate.at_expr, ctx, sc);
break;
default: break;
}
}

static void check_error_kind_typo(ASTNode *ast, LintContext *ctx) {
W018Scope sc = {0};
w018_scan(ast, ctx, &sc);
}

/* ---- E003 (#404): undefined name — no binding on any path ---- */

/* Increment one of the scope-aware name-resolution pass: a name that is READ
Expand Down Expand Up @@ -1670,6 +1902,7 @@
check_outer_mutation(ast, ctx);
check_bare_predicate_alias(ast, ctx);
check_one_element_arg_list(ast, ctx);
check_error_kind_typo(ast, ctx);
#if !EIGENSCRIPT_FREESTANDING
check_undefined_names(ast, path, source, ctx);
#else
Expand Down
62 changes: 62 additions & 0 deletions tests/test_lint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -766,6 +766,68 @@ OUTPUT=$($EIGS --lint "$TMPFILE" 2>&1 || true)
check_not_contains "E003 silent on scope-precise legal reads" "$OUTPUT" "E003"
rm -f "$TMPFILE"

# --- #469 (W018): e.kind compared against an out-of-set error kind ---
TMPFILE=$(mktemp /tmp/lint_test_XXXXXX.eigs)
cat > "$TMPFILE" << 'EIGS'
try:
x is [1][9]
catch e:
if e.kind == "index_rage":
print of "oops"
EIGS
OUTPUT=$($EIGS --lint "$TMPFILE" 2>&1 || true)
check_contains "W018 fires on a typo'd error kind (index_rage)" "$OUTPUT" "W018"
check_contains "W018 suggests the near-miss kind" "$OUTPUT" "index_range"
rm -f "$TMPFILE"

TMPFILE=$(mktemp /tmp/lint_test_XXXXXX.eigs)
cat > "$TMPFILE" << 'EIGS'
try:
x is 1
catch e:
if e.kind == "IO":
print of "io"
EIGS
OUTPUT=$($EIGS --lint "$TMPFILE" 2>&1 || true)
check_contains "W018 fires on a case-variant kind (IO)" "$OUTPUT" "W018"
rm -f "$TMPFILE"

TMPFILE=$(mktemp /tmp/lint_test_XXXXXX.eigs)
cat > "$TMPFILE" << 'EIGS'
try:
x is [1][9]
catch e:
if e.kind == "index_range":
print of "ok"
if e.kind != "deadlock":
print of "not dl"
EIGS
OUTPUT=$($EIGS --lint "$TMPFILE" 2>&1 || true)
check_not_contains "W018 silent on valid kinds (incl. post-#509 deadlock)" "$OUTPUT" "W018"
rm -f "$TMPFILE"

TMPFILE=$(mktemp /tmp/lint_test_XXXXXX.eigs)
cat > "$TMPFILE" << 'EIGS'
try:
throw {kind: "payment_declined", message: "no"}
catch e:
if e.kind == "payment_declined":
print of "custom"
EIGS
OUTPUT=$($EIGS --lint "$TMPFILE" 2>&1 || true)
check_not_contains "W018 silent on a genuine custom (far-off) kind" "$OUTPUT" "W018"
rm -f "$TMPFILE"

TMPFILE=$(mktemp /tmp/lint_test_XXXXXX.eigs)
cat > "$TMPFILE" << 'EIGS'
d is {kind: "index_rage"}
if d.kind == "index_rage":
print of "not an error dict"
EIGS
OUTPUT=$($EIGS --lint "$TMPFILE" 2>&1 || true)
check_not_contains "W018 silent on .kind off a non-catch (user dict) var" "$OUTPUT" "W018"
rm -f "$TMPFILE"

# --- #455: per-file lint allow-list in eigs.json ---
# A project can suppress a code for a whole file via eigs.json, without inline
# comments (generated/vendored code). Resolution walks to the project root
Expand Down
Loading