1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-13 11:07:31 +00:00

analyzer: add four new warning diagnostics

Extends the analyzer with the warnings listed under "Error message
polish" in docs/future-work.md:

- W0102 (existing): now also fires for `while true { ... }` and
  `loop { if cond { continue } }`. `continue` is explicitly not
  counted as an exit — the loop still spins forever.
- W0105 (new): palette declarations whose sub-palette first-bytes
  disagree. The NES mirrors $3F10/$3F14/$3F18/$3F1C onto $3F00/
  $3F04/$3F08/$3F0C, so a 32-byte sequential write silently
  overwrites the background universal colour; the grouped
  `universal:` form auto-fixes this so only flat `colors: [...]`
  declarations can trip it.
- W0106 (new): a call at statement position whose callee has a
  declared return type — the value is silently dropped.
- W0107 (new): `fast` variables with fewer than three observed
  reads+writes, which don't justify holding a scarce zero-page
  slot. Leading-underscore names are exempt.

All four are warnings only — no IR, codegen, or runtime changes,
so every committed example ROM rebuilds byte-identical and no
emulator goldens flip. Tests added in src/analyzer/tests.rs.

One legitimate W0106 surfaces on examples/platformer.ne (the
`resolve_enemy_hit` helper uses early-return for control flow
but its return value is discarded by the caller); fixing it
would shift the ROM and flip goldens, so it is left in place
as an informational hint rather than a hard fix.

https://claude.ai/code/session_01MaNVcDmK9gsspRkdxowQAM
This commit is contained in:
Claude 2026-04-14 01:35:08 +00:00
parent 68184b7296
commit d2cfce595b
No known key found for this signature in database
3 changed files with 556 additions and 2 deletions

View file

@ -1148,3 +1148,359 @@ fn analyze_does_not_reserve_zero_page_without_palette_or_bg() {
.expect("x should be allocated");
assert_eq!(x.address, 0x10);
}
// ── W0102 extended: `while true` + continue-only loops ──────
#[test]
fn analyze_while_true_without_exit_warns() {
// `while true { x = x + 1 }` — no break/return/wait_frame,
// so the same W0102 that fires on bare `loop { ... }` must
// also fire here.
let errors = analyze_errors(
r#"
game "T" { mapper: NROM }
var x: u8 = 0
on frame {
while true { x = x + 1 }
}
start Main
"#,
);
assert!(
errors.contains(&ErrorCode::W0102),
"`while true` with no exit should produce W0102, got: {errors:?}"
);
}
#[test]
fn analyze_while_true_with_wait_frame_ok() {
// `while true { wait_frame }` yields control to the NMI each
// iteration, so the NES actually makes progress — no warning.
let result = analyze_ok(
r#"
game "T" { mapper: NROM }
on frame {
while true { wait_frame }
}
start Main
"#,
);
assert!(
!result
.diagnostics
.iter()
.any(|d| d.code == ErrorCode::W0102),
"`while true` + wait_frame should NOT warn, got: {:?}",
result.diagnostics
);
}
#[test]
fn analyze_while_true_with_break_ok() {
// A reachable `break` satisfies W0102 just like it does for
// bare `loop`.
let result = analyze_ok(
r#"
game "T" { mapper: NROM }
var x: u8 = 0
on frame {
while true {
x = x + 1
if x == 10 { break }
}
}
start Main
"#,
);
assert!(
!result
.diagnostics
.iter()
.any(|d| d.code == ErrorCode::W0102),
"`while true` + break should NOT warn, got: {:?}",
result.diagnostics
);
}
#[test]
fn analyze_loop_with_only_continue_still_warns() {
// `continue` is *not* an exit — the loop still spins forever.
// W0102 must still fire here.
let errors = analyze_errors(
r#"
game "T" { mapper: NROM }
var x: u8 = 0
on frame {
loop {
if x == 0 { continue }
}
}
start Main
"#,
);
assert!(
errors.contains(&ErrorCode::W0102),
"`loop` whose only exit is `continue` should still produce W0102, got: {errors:?}"
);
}
// ── W0105: palette universal-byte consistency ───────────────
#[test]
fn analyze_palette_consistent_universals_ok() {
// Flat-form palette where every sub-palette's first byte is
// the same universal colour ($0F = black). No W0105.
let result = analyze_ok(
r#"
game "T" { mapper: NROM }
palette Consistent {
colors: [
0x0F, 0x11, 0x12, 0x13,
0x0F, 0x21, 0x22, 0x23,
0x0F, 0x31, 0x32, 0x33,
0x0F, 0x01, 0x02, 0x03,
0x0F, 0x05, 0x06, 0x07,
0x0F, 0x15, 0x16, 0x17,
0x0F, 0x25, 0x26, 0x27,
0x0F, 0x35, 0x36, 0x37
]
}
on frame { wait_frame }
start Main
"#,
);
assert!(
!result
.diagnostics
.iter()
.any(|d| d.code == ErrorCode::W0105),
"consistent palette should NOT warn, got: {:?}",
result.diagnostics
);
}
#[test]
fn analyze_palette_inconsistent_universals_warns() {
// Flat-form palette whose sub-palette first bytes disagree
// (index 0 = $0F, index 16 = $30, etc.) — the $3F10 mirror
// will overwrite the background universal colour at runtime.
let errors = analyze_errors(
r#"
game "T" { mapper: NROM }
palette Broken {
colors: [
0x0F, 0x11, 0x12, 0x13,
0x0F, 0x21, 0x22, 0x23,
0x0F, 0x31, 0x32, 0x33,
0x0F, 0x01, 0x02, 0x03,
0x30, 0x05, 0x06, 0x07,
0x0F, 0x15, 0x16, 0x17,
0x0F, 0x25, 0x26, 0x27,
0x0F, 0x35, 0x36, 0x37
]
}
on frame { wait_frame }
start Main
"#,
);
assert!(
errors.contains(&ErrorCode::W0105),
"inconsistent palette universals should produce W0105, got: {errors:?}"
);
}
#[test]
fn analyze_grouped_palette_is_always_consistent() {
// The grouped form uses the `universal:` field to drive
// every sub-palette's first byte, so it can never trip
// W0105 — even when the sub-palette bodies differ wildly.
let result = analyze_ok(
r#"
game "T" { mapper: NROM }
palette Grouped {
universal: 0x0F
bg0: [0x11, 0x12, 0x13]
bg1: [0x21, 0x22, 0x23]
bg2: [0x31, 0x32, 0x33]
bg3: [0x01, 0x02, 0x03]
sp0: [0x05, 0x06, 0x07]
sp1: [0x15, 0x16, 0x17]
sp2: [0x25, 0x26, 0x27]
sp3: [0x35, 0x36, 0x37]
}
on frame { wait_frame }
start Main
"#,
);
assert!(
!result
.diagnostics
.iter()
.any(|d| d.code == ErrorCode::W0105),
"grouped palette should never trip W0105, got: {:?}",
result.diagnostics
);
}
// ── W0106: implicit drop of a function return value ─────────
#[test]
fn analyze_discarded_non_void_return_warns() {
// `double(x)` returns u8 but the caller drops the result.
let errors = analyze_errors(
r#"
game "T" { mapper: NROM }
var x: u8 = 0
fun double(n: u8) -> u8 { return n + n }
on frame { double(x) }
start Main
"#,
);
assert!(
errors.contains(&ErrorCode::W0106),
"discarded non-void return should produce W0106, got: {errors:?}"
);
}
#[test]
fn analyze_discarded_void_call_ok() {
// Void function at statement position is the happy path —
// no discarded value, no warning.
let result = analyze_ok(
r#"
game "T" { mapper: NROM }
var x: u8 = 0
fun bump() { x = x + 1 }
on frame { bump() }
start Main
"#,
);
assert!(
!result
.diagnostics
.iter()
.any(|d| d.code == ErrorCode::W0106),
"void call should NOT produce W0106, got: {:?}",
result.diagnostics
);
}
#[test]
fn analyze_non_void_return_used_as_rhs_ok() {
// Same signature as the discarded case, but the return value
// is consumed by an assignment — no warning.
let result = analyze_ok(
r#"
game "T" { mapper: NROM }
var x: u8 = 0
var y: u8 = 0
fun double(n: u8) -> u8 { return n + n }
on frame { y = double(x) }
start Main
"#,
);
assert!(
!result
.diagnostics
.iter()
.any(|d| d.code == ErrorCode::W0106),
"assigned return value should NOT produce W0106, got: {:?}",
result.diagnostics
);
}
// ── W0107: `fast` variable slot under-use ───────────────────
#[test]
fn analyze_fast_var_underused_warns() {
// `counter` is declared `fast` but only one read (in the
// `if` condition), so its access count is 1 — below the
// threshold of 3. W0107 should fire.
let errors = analyze_errors(
r#"
game "T" { mapper: NROM }
fast var counter: u8 = 0
on frame {
if counter == 0 { wait_frame }
}
start Main
"#,
);
assert!(
errors.contains(&ErrorCode::W0107),
"under-used `fast` var should produce W0107, got: {errors:?}"
);
}
#[test]
fn analyze_fast_var_heavy_use_ok() {
// Three-plus accesses (one init + one read + one write-back)
// is enough to justify the slot — no W0107.
let result = analyze_ok(
r#"
game "T" { mapper: NROM }
fast var counter: u8 = 0
on frame {
counter = counter + 1
if counter == 0 { wait_frame }
}
start Main
"#,
);
assert!(
!result
.diagnostics
.iter()
.any(|d| d.code == ErrorCode::W0107),
"hot `fast` var should NOT warn, got: {:?}",
result.diagnostics
);
}
#[test]
fn analyze_non_fast_var_never_warns() {
// Only `fast` declarations are checked — a plain `var` with
// the same (light) access pattern must not fire W0107.
let result = analyze_ok(
r#"
game "T" { mapper: NROM }
var counter: u8 = 0
on frame {
if counter == 0 { wait_frame }
}
start Main
"#,
);
assert!(
!result
.diagnostics
.iter()
.any(|d| d.code == ErrorCode::W0107),
"plain `var` should never trip W0107, got: {:?}",
result.diagnostics
);
}
#[test]
fn analyze_fast_var_underscore_exempt() {
// Leading-underscore names are exempt from W0107, mirroring
// the W0103 convention for deliberately-unused variables.
let result = analyze_ok(
r#"
game "T" { mapper: NROM }
fast var _reserved: u8 = 0
on frame {
if _reserved == 0 { wait_frame }
}
start Main
"#,
);
assert!(
!result
.diagnostics
.iter()
.any(|d| d.code == ErrorCode::W0107),
"underscore-prefixed `fast` var should be exempt, got: {:?}",
result.diagnostics
);
}