feat(arxjit): add Python-subset validation for decorated functions - #99
Conversation
Add arxjit.validation, the stage after source extraction: walk the ast node from arxjit.source and accept only the v1 supported subset of pure Python (typed scalar arguments, int/float/bool literals, single-target local assignments, arithmetic/comparison/boolean expressions, if/else, while, for over range(...), return). Every construct outside the subset is collected into its own Diagnostic and reported together through UnsupportedSyntaxError, so a function with several unsupported constructs surfaces all of them in one pass instead of one at a time. The validator is an ast.NodeVisitor with an explicit visit_* handler per accepted construct; generic_visit fails closed, so any node without a handler (a known-unsupported construct with a specific message, or a future Python construct this pass was not updated for) is rejected rather than silently accepted. Diagnostic columns are converted from ast's zero-based UTF-8 byte offsets to the one-based character contract, and node line numbers are resolved against the real file (accounting for extract_source keeping file-accurate line numbers on nested functions). This PR is the validator plus a core test set covering every code branch (100% coverage). The exhaustive per-construct rejection matrix and the @jit wiring with the fallback/strict behavior are follow-up PRs.
xmnlab
left a comment
There was a problem hiding this comment.
thanks for working on that @Jaskirat-s7
for function names, please use everything in lowercase. for visit methods use multiple dispatch from plumb instead of creating visit_*
other than that, I found three cases where unsupported semantics can pass the new validator.
- High — closures and global-variable reads are accepted
visit_Name() accepts every variable reference without checking whether the name is an argument or a local variable. Validation then walks only the body after checking argument shape; it performs no scope or binding analysis.
This closure therefore validates successfully:
def outer(scale):
def kernel(x: int) -> int:
return x * scale
return kernel
validate(extract_source(outer(2)))
The extracted node contains only kernel, so there is no nested FunctionDef or nonlocal node to reject. scale is simply an accepted ast.Name.
Module globals have the same problem:
SCALE = 2
def kernel(x: int) -> int:
return x * SCALE
This contradicts the stated rejection of closures and the pure-function model. It could also leave lowering with an undefined value or cause compiled behavior to differ from the Python fallback.
The validator needs name-binding analysis, or ExtractedSource/validate() must retain enough function metadata to detect co_freevars and globals.
Location: packages/arxjit/src/arxjit/validation.py, visit_Name() and validate().
- High — a shadowed or invalid range call is treated as the builtin
_is_range_call() checks only that the callee is a bare name spelled range and that there are no keyword arguments. It does not detect shadowing or validate the number of positional arguments. visit_For() then accepts the call and validates only its arguments.
These all pass:
def kernel(range: int) -> int:
total = 0
for i in range(3):
total = total + i
return total
def kernel(n: int) -> int:
range = n
for i in range(3):
pass
return 0
def kernel() -> int:
for i in range():
pass
return 0
def kernel() -> int:
for i in range(1, 2, 3, 4):
pass
return 0
The first two call an integer in Python; the latter two raise TypeError. A compiler that treats the syntax as an intrinsic could silently change behavior.
At minimum, reject local or argument bindings named range and require one to three positional arguments. Module-level shadowing requires either access to the original function’s globals or an explicit language rule that reserves range.
Location: packages/arxjit/src/arxjit/validation.py, _is_range_call() and visit_For().
- Medium — Python 3.12+ generic functions bypass the fail-closed policy
The PR says new Python syntax fails closed, but the top-level FunctionDef itself is never visited. validate() checks async status, argument shapes, and body statements only. Argument and return annotations are also deliberately not inspected.
On Python 3.12+, this can therefore validate:
def identity[T](x: T) -> T:
return x
Its type_params field is never checked, and all body nodes are accepted. This is a concrete hole in the claimed fail-closed handling of future/version-specific syntax.
A guarded check such as if getattr(node, "type_params", ()): should reject generic function parameters. Annotation reconciliation can remain a later stage if that is intentional.
Location: packages/arxjit/src/arxjit/validation.py, validate().
Summary
Changes requested. The AST whitelist is well structured and the diagnostic-location handling is thoughtful, but the missing binding analysis means closures, globals, and shadowed range calls currently cross the validation boundary despite being outside the declared subset.
|
@Jaskirat-s7 , It looks like diagnostic.column is still None where the test expects a non-None value on 3.13+. Could you take a look and confirm whether this is related to the changes in this PR or a pre-existing issue? |
|
@yuvimittal Thanks for flagging it , I dug into the CI logs and it's a pre-existing issue, not from this PR. The failing test is test_malformed_fstring_column_is_validated in test_source.py (merged in #98), not a validation test — all the validation tests pass on 3.13/3.14. what i noticed was that the newer 3.13 CI runner produces a different error for a malformed f-string ("invalid syntax. Perhaps you forgot a comma?"), and its offset points into a synthetic replacement-field string rather than the real source line. Our _column_of guard correctly detects that the offset isn't trustworthy and returns column=None — that's the intended behavior (better no column than a misleading one). So the production code is right. The problem is the test assumption: it asserts column is not None for 3.13+, which was a bet on CPython patch behavior. When #98 merged, the 3.13 runner was an older patch that gave a validated column; it's since updated. I'll relax that assertion to accept either a validated column or None on 3.13+ (the same tolerant check I already use for 3.12), so it stops depending on the exact CPython patch. I'll fold that fix into this PR since it's what's reddening CI here. |
Address the review on arxlang#99. Convention: the visitor now dispatches by node type via plum (@dispatch on a single lowercase visit method), matching the visitor pattern used across the Arx packages, instead of NodeVisitor's CapWords visit_* methods. plum- dispatch is declared as a direct dependency. Three semantics that could pass the old validator are now rejected: - Closures and module globals: the validator performs binding analysis (arguments plus every assigned or for-loop-target name); any name read that is not bound is a free variable or global and is rejected, so an extracted nested function that captures an outer name no longer passes. - Shadowed or invalid range: a for loop's range() is rejected when range is shadowed by a local or argument, when keyword arguments are used, or when it does not have one to three positional arguments. - PEP 695 generic functions: a def with type parameters is rejected via a guarded type_params check, closing the fail-closed gap on Python 3.12+. Also keep the earlier audit fixes (loop-else and break/continue), and drop the unreachable BoolOp operator check (Python has only and/or). Verified on CPython 3.10, 3.11, 3.13 and 3.14: holes closed, valid subset accepted. Adds tests for each rejected case; arxjit coverage stays 100%. Separately, fix a pre-existing flaky test unrelated to validation: the malformed-f-string column test in test_source.py asserted a non-None column on 3.13+, but the column depends on the CPython patch level (a newer 3.13 reports the offset into a synthetic string, so _column_of correctly returns None). The assertion now checks the real invariant -- the column is either absent or a valid position in the source line, never a bogus offset -- independent of version.
|
Thanks for the thorough review @xmnlab all four addressed in the latest commit:
i have verified the closed holes and the accepted subset on CPython 3.10, 3.11, 3.13, and 3.14. Also dropped an unreachable BoolOp operator check (Python only has and/or). Coverage stays 100%. |
|
thanks @Jaskirat-s7 for addressing the points I made before. I found two remaining issues.
in the comments it is mentioned tge shadowed range is rejected when it is a local variable or argument. That is accurate, but the earlier review also separately noted module-level shadowing. The implementation only checks: if "range" in self.bound: self.bound contains parameters and local assignment or loop targets. It contains no information about the original function’s globals. Also, _visit_for_iter() does not visit the range callee as an ordinary Name, so the general free-variable check never sees it. This therefore still validates: range = 42 def kernel(n: int) -> int: Python raises TypeError because the module global shadows the builtin. A compiled implementation treating it as the range intrinsic would silently change the function’s behavior. The current tests cover argument shadowing, arity, and keyword arguments, but not module-global shadowing. Possible resolutions: Preserve the original callable or its global namespace through extraction and verify that fn.globals.get("range", builtins.range) is builtins.range. Move this check to the @jit wiring stage, where the callable is still available. Explicitly define range as a reserved intrinsic in ArxJIT, though that creates a deliberate difference from Python fallback semantics and must be documented and enforced consistently.
_bound_names() uses ast.walk(node), which traverses nested functions, lambdas, and classes. Assignment and loop targets found inside those nested scopes are therefore incorrectly treated as locals of the outer function. For example: def kernel(n: int) -> int: The outer function’s range is not locally shadowed, but _bound_names() collects range from helper() and produces the misleading diagnostic: range is shadowed by a local variable or argument A similar case can hide an independent free-variable diagnostic: def kernel() -> int: The nested function is correctly rejected, but the outer scale read is incorrectly considered bound, undermining the PR’s collect-all-at-once guarantee. The binding collector should traverse only the current function’s scope and skip the bodies of nested FunctionDef, AsyncFunctionDef, Lambda, and ClassDef nodes. Non-blocking documentation The PR description still describes the original ast.NodeVisitor implementation and says there are 25 tests. It should be updated to describe Plum dispatch, binding analysis, the revised range rules, the generic-function check, and the current test count. summary: Changes requested. The original review findings are substantially and thoughtfully addressed, and CI is green. Module-global range shadowing remains a behavior-changing validation bypass, while nested-scope traversal makes the new binding analysis produce inaccurate or incomplete diagnostics. |
Address the second review round on arxlang#99. Module-global range shadow (high): the range check consulted only local and argument bindings, so a module-level 'range = 42' passed validation even though Python raises TypeError there, meaning a compiled range intrinsic would silently disagree with the Python fallback. Extraction now preserves the defining module's namespace on ExtractedSource (excluded from repr and equality, since it is incidental runtime context), and validation rejects a for loop whose range does not resolve to builtins.range. The namespace is read from the unwrapped function, so it describes the same function as the retrieved source; a shared _unwrapped helper now backs both it and the filename. Nested-scope binding leak (medium): the binding collector walked the whole tree, so locals of a nested function, lambda, or class were treated as locals of the outer scope. That produced a misleading 'range is shadowed by a local variable or argument' when only a nested helper bound the name, and it masked outer free-variable reads, breaking the collect-all-at-once guarantee. The collector now traverses only the current scope, recording a nested definition's own name while skipping its body. It also records the tuple, list, starred, annotated, augmented, and walrus binding forms, so a statement the subset rejects no longer also reports a spurious free variable for the name it binds. Verified on CPython 3.10, 3.11, 3.13 and 3.14. Adds tests for the module shadow, both nested-scope regressions, every target shape, and namespace preservation; arxjit coverage stays 100%.
Found while auditing the module-global range check from the previous commit: a closure capture is a third way the builtin can be shadowed, and it passed both existing checks. An enclosing function's local shadows range for the whole nested function, so Python resolves the call through the closure cell and raises TypeError when it is not callable, while the extracted AST shows only a plain Name load indistinguishable from the builtin. The name is also exempt from the free-variable check precisely because it is expected to be the builtin, which left it uniquely exposed. Extraction now preserves the function's captured names (co_freevars of the unwrapped function) alongside the module namespace, and the three shadowing paths are consolidated into one check that reports which one applies: a local variable or argument, an enclosing function's variable, or a module-level name. Verified on CPython 3.10, 3.11, 3.13 and 3.14: the captured-range case is rejected on all of them and an ordinary range loop still validates. Adds a test for the capture and for the preserved names; coverage stays 100%.
|
Thanks @xmnlab , both addressed, plus a third case your findings led me to.
One more I found while auditing the above: a closure-captured range slipped through both checks , it's neither a local nor a module global, but Python resolves it through the closure cell and raises TypeError: def outer(): One thing I left deliberately and flagged as design point 5 ,when the module namespace is unavailable (only for a hand-built ExtractedSource; extract_source always provides it) module-level shadowing can't be observed and the name is assumed to be the builtin. Happy to make that fail closed if you'd prefer. |
|
thanks @Jaskirat-s7 The latest changes address the previous blockers: module-level and closure-captured "range" shadowing are detected, binding collection respects nested scopes, the PR description is current, regression tests cover the reported cases, and CI is green. I found one remaining edge case involving functions created with a customised "builtins" mapping, plus a diagnostic-quality issue for some unsupported binding forms. Neither needs to block this PR in my view. Please track a follow-up to either preserve and validate the function’s actual builtins namespace or explicitly reject non-standard builtins before treating "range" as an intrinsic. A separate follow-up can complete binding collection for unsupported constructs such as imports and "with ... as". Approved from my side. |
Summary
Sprint 2, Python-subset validation (wiki "Proposed Issue 4: Define and validate the supported pure-Python subset"). Builds on source extraction (#96/#98): it adds
arxjit.validation, the stage that runs afterextract_source, walking the parsed function and accepting only the v1 supported subset while rejecting everything else with clear, located diagnostics.Issue 4 checklist coverage:
What it accepts (the v1 subset)
Function definitions, typed scalar arguments,
int/float/boolliterals, single-target local assignments, arithmetic (+ - * / // % **), comparisons (including chained), booleanand/or, unary+ - not,if/else,while,for ... in range(...),return,pass, and docstrings.Everything else is rejected: classes,
async, closures and globals, lambdas, containers and comprehensions, calls (exceptrange(...)in afor), imports, exceptions,with, attribute access, subscripting, f-strings, walrus,break/continue, loopelse, generic (PEP 695) functions, and*args/**kwargs/keyword-only/default parameters.How it works
validate(extracted: ExtractedSource) -> NoneraisesUnsupportedSyntaxErrorcarrying oneDiagnosticper rejected construct, so a function with several problems reports all of them at once instead of one-per-run.Type-based dispatch. The validator dispatches on node type with plum
@dispatch(a singlevisitmethod overloaded per node type), matching the visitor convention used elsewhere in the Arx packages. Theast.ASToverload is the fail-closed default: any node without an accepting overload is rejected, either with a specific message from a lookup table or a generic fallback, so unhandled or future syntax fails safe rather than passing silently. A rejected node's children are not descended into (one diagnostic per bad subtree), while independent sibling statements are each validated in full.Binding analysis.
validatecollects the names bound in the function's own scope — parameters plus every assignment and loop target — and rejects any name read that is not bound, which is how closure captures and module globals are caught (an extracted nested function keeps its free variables as plainNameloads, so this cannot be done structurally). The collector deliberately does not descend into nested functions, lambdas, or classes: those have their own locals, and collecting them would both mask free-variable reads in this scope and misreport a nested binding as shadowing an outer name. A nested definition's own name does bind in the enclosing scope, so it is recorded while its body is skipped. The tuple, list, starred, annotated, augmented, and walrus binding forms are all recorded, so a statement the subset rejects does not additionally report a spurious free variable for the name it binds.rangemust really berange. Aforloop's iterable must be a call to the builtinrangewith 1–3 positional arguments and no keywords. Because only one of the three ways the name can be shadowed is visible in the AST, extraction preserves the defining module's namespace and the function's captured names, and validation rejects the loop whenrangeresolves to:co_freevars)builtins.rangeEach of those is a
TypeErrorin Python when the shadowing value is not callable, so compiling them as the range intrinsic would silently disagree with the Python fallback.Locations. Diagnostic columns are converted from ast's zero-based UTF-8 byte offsets to the one-based character contract from #95, and node line numbers are resolved against the real file — correctly for nested functions, where
node.linenois a real file line but the extracted source is indexed from its own first line.Scope of this PR
To keep review small, this PR is the validator plus a test set that exercises every code branch (100% coverage): the accept path, collect-all-at-once, real-file line/column correctness including a nested-function case, the binding and
rangerules, argument-shape rules, and the helpers. The exhaustive one-test-per-unsupported-construct rejection matrix lands in a small stacked follow-up — it is completeness coverage over the whitelist, and all of it routes through code paths already covered here.Design points to review
codestaysNone. I'd like to introduce a stable code scheme (e.g.AJ001) with this validation work, since it is the first stage emitting many distinct diagnostics — flagging for a decision on format/prefix before baking numbers in.passexplicitly allowed; a bareyieldis routed to the specific "generators are not supported" message.elseis rejected, consistent with rejectingbreak: a loopelseonly runs when the loop exits withoutbreak, so accepting it would be dead code and would complicate lowering.validatetakesExtractedSource, returnsNone, raises on failure — the fallback-vs-strictbehavior belongs to the@jitwiring PR, not here.ExtractedSource, sinceextract_sourcealways provides it for a real function — module-level shadowing cannot be observed and the name is taken to be the builtin. Happy to make that path stricter if you would rather it fail closed.Verification
FunctionDef.lineno, version-gated node types) were probed on CPython 3.10, 3.11, 3.13 and 3.14, and the binding,range-shadowing and generic-function rules were verified to behave identically on all four.TryStar(except*, 3.11+) andtype_params(PEP 695, 3.12+) are both accessed through runtime guards, so the module imports on 3.10.douki syncidempotent.Note on the diff size
GitHub reports a large addition count because both files are new. Roughly half of it is mandatory Douki docstrings plus blank lines; the validator logic itself is about 250 lines, alongside a flat construct-to-message lookup table that reads as data.