Initial Commit

This commit is contained in:
2026-09-15 15:24:03 -04:00
commit 4dc7086e7e
605 changed files with 101380 additions and 0 deletions
+548
View File
@@ -0,0 +1,548 @@
"""MATLAB-vs-Octave compatibility checking.
We execute in GNU Octave and the user runs the result in real MATLAB, so
there are three failure classes and only one of them is the model's fault:
1. Octave ACCEPTS it, MATLAB REJECTS it. The worst case - we would tell
the user "it works" and it would not.
2. MATLAB has it, this sandbox does not (toolbox functions, display,
network). The model must be told NOT to rewrite correct code.
3. Both run, results differ. Rarely detectable statically; we flag the
few constructs worth flagging and are honest about the rest.
Every rule runs against source with string BODIES and comment BODIES
blanked out, markers kept. An adversarial review of the first version
found three high-severity bugs in this file alone, all of them in the
tokeniser rather than in the rules, so read the notes on strip_literals
before touching it.
The one exception is a rule marked `raw`, which sees the source
unstripped. That exists because the worst trap we know of lives *inside*
a string body: `regexp(s, '\b40\b', 'match')` returns nothing at all, so
a stripped-source rule could never see it and the model gets a wrong
count instead of an error.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
# After one of these keywords, a quote opens a string - it cannot be a
# transpose, because there is no value to transpose. Without this,
# `case '--'` mis-tokenised and the `--` inside the string was reported
# as an Octave-only increment operator in perfectly good MATLAB.
_KEYWORDS_BEFORE_STRING = frozenset({
"case", "otherwise", "return", "if", "elseif", "else", "while", "switch",
"for", "parfor", "do", "until", "function", "global", "persistent", "try",
"catch", "end", "break", "continue", "and", "or", "not", "spmd",
"classdef", "properties", "methods", "events", "arguments", "disp", "error",
"warning", "fprintf", "sprintf", "printf", "assert", "validateattributes",
})
_TRANSPOSE_AFTER = frozenset(")]}._'")
def strip_literals(code: str) -> str:
"""Blank string and comment BODIES, keeping their markers and length.
Offsets and line breaks are preserved so a match position still maps
to the right line of the original source.
Three things here are load-bearing, each because getting them wrong
produced a real, verified defect:
* **The comment marker survives.** Blanking from the `%`/`#` itself
made the `hash-comment` and `block-comment-hash` rules unable to
match anything, ever - two declared high-severity rules were dead
code, and `#` comments are the most common Octave-ism there is.
* **State resets at a newline, but not across a `...` continuation.**
Carrying the previous line's last character over meant a quote at
the start of a line was read as a transpose, so the string body
leaked into the stripped text and every rule scanned English prose.
That produced false "NOT VALID MATLAB" verdicts on ordinary code
like `plot(x, y, ...` / `'--r', 'LineWidth', 2);`. A continuation
is the one case where state must carry, because the logical line
genuinely continues.
* **`%{ ... %}` blocks are handled as blocks.** Treating the opener
as a line comment left the prose body to be linted as code.
"""
out = list(code)
n = len(code)
i = 0
prev_char = "" # last significant char on this logical line
word = "" # identifier immediately before the cursor
def blank(a: int, b: int) -> None:
for k in range(a, min(b, n)):
if out[k] != "\n":
out[k] = " "
def line_end(pos: int) -> int:
j = code.find("\n", pos)
return n if j == -1 else j
while i < n:
c = code[i]
if c == "\n":
prev_char = ""
word = ""
i += 1
continue
# Line continuation. Everything after `...` is a comment, and the
# logical line continues - so prev_char/word deliberately persist.
if c == "." and code.startswith("...", i):
j = line_end(i)
blank(i, j)
i = j + 1 if j < n else n
continue
if c in "%#":
marker = code[i:i + 2]
stripped_line = code[code.rfind("\n", 0, i) + 1:line_end(i)].strip()
# A block comment opener is the only thing on its line.
if stripped_line in ("%{", "#{"):
closer = "%}" if marker[0] == "%" else "#}"
j = i + 2
while j < n:
ls = j
le = line_end(j)
if code[ls:le].strip() in ("%}", "#}"):
blank(i + 2, ls) # body only
i = le
break
j = le + 1
else:
blank(i + 2, n)
i = n
continue
keep = 2 if (i + 1 < n and code[i + 1] in "{}") else 1
j = line_end(i)
blank(i + keep, j)
i = j # let the newline branch reset state
continue
if c == '"':
j = i + 1
while j < n and code[j] != "\n":
if code[j] == '"':
if j + 1 < n and code[j + 1] == '"':
j += 2
continue
break
j += 1
blank(i + 1, j)
i = min(j + 1, n)
prev_char = '"'
word = ""
continue
if c == "'":
is_transpose = (
(prev_char.isalnum() or prev_char in _TRANSPOSE_AFTER)
and word not in _KEYWORDS_BEFORE_STRING
)
if is_transpose:
prev_char = "'"
word = ""
i += 1
continue
j = i + 1
while j < n and code[j] != "\n":
if code[j] == "'":
if j + 1 < n and code[j + 1] == "'":
j += 2
continue
break
j += 1
blank(i + 1, j)
i = min(j + 1, n)
prev_char = "'"
word = ""
continue
if c.isalnum() or c == "_":
word += c
prev_char = c
elif c.isspace():
pass # whitespace keeps `word` (for `case 'x'`)
else:
word = ""
prev_char = c
i += 1
return "".join(out)
@dataclass(frozen=True)
class Rule:
id: str
pattern: re.Pattern
severity: str # "high" | "medium" | "low"
kind: str # "octave_only" | "sandbox" | "semantic"
message: str
# When set, a finding is dropped if the captured name is assigned
# anywhere in the script - a user variable called `input` or `stderr`
# is not a call to the builtin, and firing on it teaches the model
# that this whole section cries wolf.
name_group: int = 0
# Match against the ORIGINAL source instead of the literal-stripped
# copy. Only for rules whose whole subject is the content of a string
# - a regexp pattern - which is blank by the time the other rules
# run. The cost is that comments are visible too, so a raw rule can
# fire on a line of prose; keep raw rules narrow enough that this is
# rare and harmless.
raw: bool = False
def _r(pat: str) -> re.Pattern:
return re.compile(pat, re.MULTILINE)
OCTAVE_ONLY: tuple[Rule, ...] = (
Rule("hash-comment", _r(r"(?:^|[ \t;,])#(?![{}])"), "high", "octave_only",
"`#` starts a comment in Octave and is a syntax error in MATLAB. Use `%`."),
Rule("block-comment-hash", _r(r"^[ \t]*#[{}][ \t]*$"), "high", "octave_only",
"`#{ ... #}` block comments are Octave-only. MATLAB uses `%{ ... %}`."),
Rule("bang-ne", _r(r"!="), "high", "octave_only",
"`!=` is Octave-only. MATLAB spells not-equal `~=`."),
# NOT anchored at statement start: `!cmd` there is MATLAB's own shell
# escape and is valid MATLAB, so flagging it as Octave-only would be
# wrong. shell-escape covers that case separately.
Rule("bang-not", _r(r"(?<![=~<>!\w])(?<!^)![ \t]*[A-Za-z_(\[\d~]"), "high", "octave_only",
"`!` as logical NOT is Octave-only. MATLAB uses `~`."),
Rule("end-keywords", _r(r"(?<![.\w])end(if|while|for|function|switch|_try_catch|parfor)\b"),
"high", "octave_only",
"`endif`/`endwhile`/`endfor`/`endfunction`/`endswitch` are Octave-only. MATLAB closes "
"every block with a bare `end`."),
Rule("unwind-protect", _r(r"(?<![.\w])unwind_protect\b"), "high", "octave_only",
"`unwind_protect` is Octave-only. MATLAB has no direct equivalent; use try/catch, or "
"`onCleanup` for guaranteed cleanup."),
Rule("do-until", _r(r"^[ \t]*do[ \t]*$|^[ \t]*until[ \t]*\(|^[ \t]*until[ \t]+(?![=\s])"),
"high", "octave_only",
"`do ... until` is Octave-only. MATLAB has no do-while; use "
"`while true ... if cond, break; end, end`."),
Rule("aug-assign", _r(r"[A-Za-z_0-9)\]}][ \t]*(\+=|-=|\*=|/=|\^=)"), "high", "octave_only",
"`+=`, `-=`, `*=`, `/=` are Octave-only. MATLAB needs the full form, e.g. `x = x + 1`."),
Rule("incr-decr", _r(r"(?<![+\-])(\+\+|--)(?![+\-])"), "high", "octave_only",
"`++` and `--` are Octave-only. MATLAB has no increment operator; write `x = x + 1`."),
Rule("power-star-star", _r(r"\*\*"), "high", "octave_only",
"`**` is Octave-only. MATLAB's power operators are `^` and `.^`."),
Rule("printf", _r(r"(?<![.\w])printf[ \t]*\("), "high", "octave_only",
"`printf` is Octave-only. MATLAB uses `fprintf`.", 0),
Rule("octave-io", _r(r"(?<![.\w])(puts|fputs|fdisp|fflush)[ \t]*\("), "high", "octave_only",
"`puts`/`fputs`/`fdisp`/`fflush` are Octave-only. MATLAB uses `fprintf` and `disp`.", 1),
Rule("octave-streams", _r(r"(?<![.\w'\"])(stdout|stderr)(?![\w'\"])"), "medium", "octave_only",
"`stdout`/`stderr` are Octave-only names. MATLAB uses the file identifiers 1 and 2, "
"e.g. `fprintf(2, ...)` to write to stderr.", 1),
# `)(` with no space between: `f(x)(2)`. Requiring no space keeps this
# off `[f(1) (2)]`, which is a two-element matrix, not chained indexing.
Rule("chained-index", _r(r"\)\("), "medium", "octave_only",
"Indexing a call's result directly, `f(x)(2)`, is Octave-only. MATLAB needs a "
"temporary: `t = f(x); t(2)`."),
)
SANDBOX: tuple[Rule, ...] = (
Rule("interactive-input",
_r(r"(?<![.\w])(input|keyboard|menu|inputdlg|questdlg|uiwait|uigetfile|ginput)[ \t]*\("),
"high", "sandbox",
"This waits for a human. The sandbox runs non-interactively, so it will hang until the "
"timeout. Hard-code the value or pass it as a variable.", 1),
Rule("bare-pause", _r(r"(?<![.\w])pause[ \t]*(\(\s*\))?[ \t]*(;|$)"), "medium", "sandbox",
"`pause` with no argument waits for a keypress and will hang the run. Give it a "
"duration or remove it."),
Rule("network",
_r(r"(?<![.\w])(webread|webwrite|websave|urlread|urlwrite|ftp|sendmail|weboptions)[ \t]*\("),
"high", "sandbox",
"The sandbox has no network at all, so this cannot run here. The code may still be "
"correct for the user's machine - say so rather than rewriting it.", 1),
Rule("shell-escape", _r(r"(?<![.\w])(system|unix|dos|winqueryreg)[ \t]*\(|^[ \t]*![A-Za-z_/.]"),
"medium", "sandbox",
"Shell access is not available in the sandbox. Remove it or note that it is untested "
"here."),
Rule("exit-quit", _r(r"^[ \t]*(exit|quit)[ \t]*(\(|;|$)"), "medium", "sandbox",
"`exit`/`quit` terminates the interpreter before results are captured. Remove it."),
Rule("arguments-block", _r(r"^[ \t]*arguments[ \t]*(\(|$)"), "high", "sandbox",
"An `arguments` validation block is valid MATLAB (R2019b+) but this sandbox's Octave "
"cannot parse it, so the run fails with a syntax error. That is a sandbox limit, not a "
"mistake: keep it for the user if they are on R2019b+, or use "
"`inputParser`/`validateattributes` to get something runnable here."),
)
SEMANTIC: tuple[Rule, ...] = (
Rule("double-quoted", _r(r'"'), "low", "semantic",
'In MATLAB R2017a+ "..." is a *string* object; in Octave it is a char array, and some '
"functions branch on that type. Use '...' for char unless you deliberately want a "
"string."),
Rule("unique-multi", _r(r"\[[^\]\n]*,[^\]\n]*\][ \t]*=[ \t]*unique[ \t]*\("), "medium", "semantic",
"The index outputs of `unique` have differed between MATLAB and Octave over first/last "
"occurrence. Verify the indices, or pass an explicit option."),
# Matches the surviving quote character, since bodies are blanked.
Rule("rand-seed", _r(r"(?<![.\w])(rand|randn)[ \t]*\([ \t]*['\"]"), "low", "semantic",
"Legacy generator seeding does not reproduce the same stream across MATLAB and Octave. "
"Use `rng(n)` in MATLAB if reproducibility matters."),
# Raw, because the pattern argument is a string body and every other
# rule sees it blanked. This one is here because it cost a whole task:
# a word-boundary count of "40" in a 100-word text returned 0, which
# is a plausible number, so nothing looked wrong until the model
# happened to re-check it with a plain pattern.
Rule("regexp-backspace",
_r(r"(?<![.\w])regexp(?:i|rep)?[ \t]*\([^\n]*?['\"][^'\"\n]*\\b"),
"high", "semantic",
"`\\b` in a regexp pattern is the BACKSPACE character, not a word boundary - in MATLAB "
"as well as in Octave, because it is the regexp engine that reads the escape. The "
"pattern then matches nothing and you get a wrong COUNT rather than an error. Use `\\<` "
"and `\\>` for word start and end, or a lookaround. Measured here: "
"`regexp('a b', '\\bb', 'match')` returns 0 matches.",
0, raw=True),
# The weight flag is documented backwards more often than it is used
# wrongly: a graded deliverable computed std(X,0,1), called it the
# population standard deviation in the docstring, the code comment and
# the usage notes, and told the user to swap to std(X,1,1) for N-1.
# Every clause inverted. Both dialects agree on the flag; say it once.
Rule("std-weight-flag", _r(r"(?<![.\w])(?:std|var)[ \t]*\([^()\n]*?,[ \t]*[01][ \t]*[,)]"),
"low", "semantic",
"In `std(x, w, dim)` / `var(x, w, dim)` the weight flag w=0 normalises by N-1 (the "
"sample statistic, the default) and w=1 by N (the population statistic) - in MATLAB "
"and Octave alike. If you document which one the code uses, check it against this "
"line: the flag is the thing that gets written backwards."),
Rule("idivide-negative", _r(r"(?<![.\w])idivide[ \t]*\("), "medium", "semantic",
"`idivide` with negative operands returned -4 for -7/2 in this sandbox, where MATLAB's "
"default 'fix' rounding gives -3. For scratch arithmetic prefer `floor`, `fix` or "
"`round` on doubles, and say which rounding you used."),
)
ALL_RULES: tuple[Rule, ...] = OCTAVE_ONLY + SANDBOX + SEMANTIC
@dataclass(frozen=True)
class Finding:
rule: Rule
line: int
text: str
_ASSIGNED = re.compile(r"^[ \t]*(?:\[[^\]]*\][ \t]*=[^=]|([A-Za-z_]\w*)[ \t]*(?:\([^)]*\))?[ \t]*=(?!=))",
re.MULTILINE)
def _assigned_names(stripped: str) -> set[str]:
"""Identifiers the script assigns to, so builtin-name rules can skip them."""
names = set()
for m in _ASSIGNED.finditer(stripped):
if m.group(1):
names.add(m.group(1))
for m in re.finditer(r"^[ \t]*function\b[^\n]*?(?:=\s*)?([A-Za-z_]\w*)\s*\(", stripped, re.MULTILINE):
names.add(m.group(1))
return names
def lint(code: str) -> list[Finding]:
"""Run every rule against literal-stripped source, report original lines.
A rule marked `raw` is matched against the original instead. Line
numbers come out the same either way: strip_literals blanks in place,
so both strings have identical length and identical newline offsets.
"""
stripped = strip_literals(code)
lines = code.split("\n")
assigned = _assigned_names(stripped)
seen: set[tuple[str, int]] = set()
out: list[Finding] = []
for rule in ALL_RULES:
target = code if rule.raw else stripped
for m in rule.pattern.finditer(target):
if rule.name_group:
try:
name = m.group(rule.name_group)
except IndexError: # pragma: no cover
name = None
if name and name in assigned:
continue
line_no = target.count("\n", 0, m.start()) + 1
key = (rule.id, line_no)
if key in seen:
continue
seen.add(key)
text = lines[line_no - 1].strip() if line_no <= len(lines) else ""
out.append(Finding(rule, line_no, text[:120]))
out.sort(key=lambda f: (f.line, f.rule.id))
return out
def is_function_file(code: str) -> str | None:
"""Return the function name if the source is a function-definition file.
A file whose first statement is `function` defines a function rather
than running anything, in both MATLAB and Octave. Executing it looks
like a bizarre "'a' undefined" error at the first use of a parameter,
which sends the model hunting for a bug that is not there.
"""
stripped = strip_literals(code)
for raw in stripped.split("\n"):
line = raw.strip()
if not line or line.startswith("%") or line.startswith("#"):
continue
m = re.match(r"^function\b(?:[^=\n]*=)?\s*([A-Za-z_]\w*)", line)
return m.group(1) if m else None
return None
# --------------------------------------------------------------------------
# Script-local functions
#
# MATLAB (R2016b+) lets a script define its functions at the END and call
# them from anywhere above; Octave defines a function when execution
# reaches it, so the same script dies on "'foo' undefined". It is the one
# discrepancy that turns correct, ordinary MATLAB into a failed call - it
# cost m07 a whole tool call in the v3 round - and the model cannot fix it
# without writing something that is no longer the code the user asked for.
#
# So the tail is lifted out and written as separate .m files beside the
# script, which is where Octave will find them and how MATLAB behaves. The
# lines they came from are replaced with blank lines rather than deleted:
# an error two thirds of the way down still reports the line number the
# model wrote, and a diff of the file still lines up.
#
# Everything here bails rather than guesses. A file it does not fully
# understand is passed through untouched and fails the way it did before,
# which is a bad outcome; mangling a file that was about to work is a
# worse one.
# --------------------------------------------------------------------------
_MAX_LOCAL_FUNCTIONS = 40
_MAX_NAME = 63
# Statement-level block openers and closers. Matched at a statement
# boundary only - start of line, or after a `,`/`;` - so `x(end)` is not a
# block end and `numel(c)` is not a `for`.
_OPENERS = ("if", "for", "parfor", "while", "switch", "try", "do",
"function", "classdef", "unwind_protect", "spmd")
_CLOSERS = ("end", "endif", "endfor", "endparfor", "endwhile", "endswitch",
"end_try_catch", "endfunction", "end_unwind_protect", "until",
"endclassdef", "endspmd")
_BLOCK_TOKEN = re.compile(
r"(?:^|[;,])[ \t]*(" + "|".join(_OPENERS + _CLOSERS) + r")\b")
_FUNCTION_LINE = re.compile(r"^[ \t]*function\b(.*)$")
@dataclass(frozen=True)
class LocalFunction:
"""One function lifted out of a script."""
name: str
line: int # 1-based line it started on, so its file can be padded
text: str # the block, verbatim
@dataclass(frozen=True)
class LocalFunctions:
"""The result of lifting a script's trailing function definitions out."""
script: str # same line count as the original
functions: tuple[LocalFunction, ...]
bailed: str | None = None # why nothing was lifted, for diagnosis
def _function_name(signature: str) -> str | None:
"""`[a, b] = foo(x)` -> foo. Returns None for anything unexpected."""
# The signature arrives with string and comment bodies already blanked,
# which also blanks a `...` continuation: a signature split across lines
# leaves its first line readable, and one that continues before naming
# the function (`function y = ...`) leaves nothing to read and is
# refused by the name pattern below.
sig = signature.split("%")[0].split("#")[0].strip()
head = sig.split("(", 1)[0]
if "=" in head:
head = head.split("=", 1)[1]
name = head.strip()
if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]{0,%d}" % (_MAX_NAME - 1), name):
return None
if name in _KEYWORDS_BEFORE_STRING or name in ("main", "varargin", "varargout"):
return None
return name
def split_local_functions(code: str) -> LocalFunctions:
"""Lift a script's trailing `function` blocks into separate files.
Returns the script with those lines blanked and the functions in the
order they were written. `bailed` is set, and nothing is lifted, when
the file is not a plain script with a clean run of function
definitions at the end of it.
"""
stripped = strip_literals(code)
raw_lines = code.split("\n")
lines = stripped.split("\n")
stack: list[str] = []
first_fn: int | None = None
starts: list[int] = []
for idx, line in enumerate(lines):
body = line.split("%")[0].split("#")[0]
depth_at_start = len(stack)
top_level_function = False
for match in _BLOCK_TOKEN.finditer(body):
token = match.group(1)
if token in _OPENERS:
if token == "function":
# Top level means: nothing open, or only the previous
# unterminated function - Octave and MATLAB both allow
# a function file whose functions have no `end`.
if not stack or stack == ["function"]:
top_level_function = True
if stack == ["function"]:
stack.pop()
elif first_fn is not None:
return LocalFunctions(code, (), "a nested function")
if token == "classdef":
return LocalFunctions(code, (), "a classdef file")
stack.append(token)
else:
if not stack:
return LocalFunctions(code, (), "an unbalanced end")
stack.pop()
if top_level_function:
if first_fn is None:
first_fn = idx
starts.append(idx)
elif first_fn is not None and depth_at_start == 0 and body.strip():
# A statement at the top level after the definitions started -
# not a line of a function body, which sits at depth 1. MATLAB
# does not allow this either, so leave the file alone and let
# the run report whatever it reports.
return LocalFunctions(code, (), "code after the function definitions")
if first_fn is None:
return LocalFunctions(code, (), None)
if first_fn == 0 or not any(l.split("%")[0].split("#")[0].strip()
for l in lines[:first_fn]):
# Nothing above the first function: this is a function file, which
# is handled - and explained to the model - elsewhere.
return LocalFunctions(code, (), "a function file")
if len(starts) > _MAX_LOCAL_FUNCTIONS:
return LocalFunctions(code, (), f"more than {_MAX_LOCAL_FUNCTIONS} local functions")
bounds = list(zip(starts, starts[1:] + [len(lines)]))
functions: list[LocalFunction] = []
seen: set[str] = set()
for start, stop in bounds:
signature = _FUNCTION_LINE.match(lines[start])
name = _function_name(signature.group(1)) if signature else None
if name is None:
return LocalFunctions(code, (), "a function signature it could not read")
if name in seen:
return LocalFunctions(code, (), f"two functions named {name}")
seen.add(name)
functions.append(LocalFunction(
name, start + 1, "\n".join(raw_lines[start:stop]).rstrip() + "\n"))
script = raw_lines[:first_fn] + [""] * (len(raw_lines) - first_fn)
return LocalFunctions("\n".join(script), tuple(functions), None)