Skip to content

feat(arxjit): reject methods and make the rejection matrix exhaustive - #102

Merged
xmnlab merged 2 commits into
arxlang:mainfrom
Jaskirat-s7:feat/arxjit-method-validation
Jul 30, 2026
Merged

feat(arxjit): reject methods and make the rejection matrix exhaustive#102
xmnlab merged 2 commits into
arxlang:mainfrom
Jaskirat-s7:feat/arxjit-method-validation

Conversation

@Jaskirat-s7

@Jaskirat-s7 Jaskirat-s7 commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Continues the validation work from #99.

Methods defined in a class body were accepted

Wiki Issue 4 lists methods among the initial unsupported constructs, but nothing caught them. A method is an ordinary function in the extracted ast, and its first parameter is only conventionally named self, so all forms validated cleanly. Extraction now carries __qualname__ on ExtractedSource (the same rationale as globalns and freevars: runtime context the ast cannot supply) and validation uses it. A function nested inside another function is dotted too, but Python inserts the <locals> marker there, which distinguishes the two.

Scope of the check — narrowed after review

__qualname__ records where a function was defined, not whether a class currently owns the descriptor. The check is therefore deliberately named _defined_in_class_body rather than _is_method, and the contract is documented on the predicate, on the qualname field, and on validate. Measured boundary:

case __qualname__ validates?
instance method Calc.scaled rejected
staticmethod Calc.stat rejected
classmethod (bound method object) Calc.klass rejected
functools.wraps decorator chain Calc.wrapped rejected
decorator dropping __qualname__/__wrapped__ replacement.<locals>.wrapped accepted
module-level function assigned in a class body sample_module_level accepted
function attached to a class after creation sample_module_level accepted

All four detected forms are pinned by a parametrized test — the classmethod matters separately because it resolves to a bound method object rather than a function. The three undetected cases are pinned too, as accepted, with the reason in the test docstring, so the contract is explicit and a future fix has a test to flip.

Follow-up, verified not assumed: I checked on 3.10/3.11/3.14 that __set_name__ is called for anything bound in a class body, including a decorator's return value — so the @jit layer can close the first two cases by carrying the owning class. It is not called for assignment after class creation, so that case stays undetectable at any layer. This lands in the wiring PR, which is where core.py gets touched; this PR does not modify the decorator. One wrinkle recorded for it: an exception raised from __set_name__ is wrapped in RuntimeError on 3.10/3.11 but propagates bare on 3.12+, which strict=True will have to account for.

Not covered here for the same reason: the @jit + @staticmethod/@classmethod decorator-order combinations, which need the decorator.

The rejection matrix is now table-driven and exhaustive

The per-construct rejection tests are replaced by a matrix over _UNSUPPORTED_MESSAGES, plus a test asserting the matrix covers every entry in the table, so it cannot quietly fall behind the validator (wiki Issue 12, "tests for unsupported syntax diagnostics"). Each case asserts the construct reports its own message rather than one belonging to an enclosing node.

That check immediately found three entries whose message was shadowed:

Entry In the obvious position Reachable?
ast.Await await x reports "standalone expression statements" yes, as v = await x
ast.Starred v = [*x] reports "list literals" yes, as range(*x)
ast.Slice x[0:1] reports "subscripting" no reachable position found

Subscript and the container types reject without descending, so their children's messages are unreachable. On severity: every one of these constructs is still rejected, just with a less specific message — message quality plus one dead table entry, not a correctness hole.

The first two are now exercised in the positions that reach them. Design point: ast.Slice is kept as a safety net and named in _SHADOWED_MESSAGES so the exhaustiveness test stays honest, rather than silently deleted. Happy to drop the entry instead if you'd prefer the table to contain only reachable messages.

The matrix builds its sources as text rather than as real nested functions, so a lint autofix cannot rewrite the construct under test — that happened previously with unused in-function imports being stripped by ruff --fix, leaving tests validating the wrong source.

Verification

153 tests, arxjit coverage 100%. All gates green locally: pytest+cov, ruff format/check, mypy strict (cd packages/arxjit && mypy src), bandit -iii -lll, vulture 80, mccabe 10, douki sync idempotent.

Verified on CPython 3.10.19, 3.11.11 and 3.14.6. The TryStar case is version-gated the same way the validator guards its table entry, and appears only on 3.11+.

Wiki Issue 4 lists methods among the initial unsupported constructs, but
nothing caught them: a method is an ordinary function in the extracted
ast, and its first parameter is only conventionally named self, so an
instance method, a staticmethod and a classmethod all validated cleanly.
__qualname__ does record the owning class, so extraction now carries it
and validation uses it. A function nested inside another function is
dotted too, but Python inserts the "<locals>" marker there, which
distinguishes the two cases.

Replace the per-construct rejection tests with a table-driven matrix over
_UNSUPPORTED_MESSAGES, plus a test asserting the matrix covers every
entry in the table, so it cannot quietly fall behind the validator (wiki
Issue 12, "tests for unsupported syntax diagnostics"). Each case asserts
the construct reports its own message rather than one belonging to an
enclosing node.

That exhaustiveness check found three entries whose message was shadowed:
Await is reported as a standalone expression statement unless it is the
value of an assignment, Starred is reported as the enclosing container
unless it appears in a range() call, and Slice has no reachable position
at all, because a slice can only appear inside a Subscript and Subscript
is rejected without descending into it. The first two are now exercised
in the positions that reach them; Slice is named in _SHADOWED_MESSAGES
and kept as a safety net rather than silently dropped.

The matrix builds its sources as text rather than as real nested
functions, so a lint autofix cannot rewrite the construct under test, as
happened previously with unused in-function imports.

148 tests, arxjit coverage 100%. Verified on CPython 3.10, 3.11 and 3.14.
@Jaskirat-s7
Jaskirat-s7 force-pushed the feat/arxjit-method-validation branch from c4b581b to 84b1a5a Compare July 28, 2026 19:00
@xmnlab

xmnlab commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

thanks for working on that @Jaskirat-s7

some notes from my side:


1. Medium: __qualname__ detects lexical class definitions, not all Python methods

Extraction stores only the unwrapped callable’s __qualname__.  Validation considers the callable a method when that string is dotted and its penultimate component is not <locals>.  The method diagnostic is emitted only when this heuristic succeeds.

That misses valid methods whose function was not lexically created under the class name. For example:

def replacement(fn):
    def wrapped(self, x: int) -> int:
        return x * 2

    return wrapped


class Calc:
    @replacement
    def scaled(self, x: int) -> int:
        return x

Calc().scaled(3) is a bound method and returns 6, but its qualified name is:

replacement.<locals>.wrapped

The current _is_method() returns False, so validate(extract_source(Calc.scaled)) accepts it when the wrapper body otherwise belongs to the supported subset.

A simpler dynamic assignment has the same limitation:

def scaled(self, x: int) -> int:
    return x * 2


class Calc:
    scaled = scaled

__qualname__ describes where a function was defined, not whether the descriptor is currently owned by a class.

This matters because rejecting methods is the primary behaviour added by this PR. Possible resolutions are:

Narrow the documented contract to “functions lexically defined in class bodies” and document that decorator chains must preserve __wrapped__/__qualname__.

Carry explicit class-binding context from the @jit layer, potentially through JitFunction.__set_name__, rather than inferring ownership solely from source metadata.

Add a regression test for a class method replaced by a non-functools.wraps decorator and decide its supported behaviour explicitly.


Location: packages/arxjit/src/arxjit/source.py::_qualname_of and packages/arxjit/src/arxjit/validation.py::_is_method.

2. Low — the advertised staticmethod and classmethod cases are not pinned

The PR description explicitly discusses instance methods, static methods, and class methods, but the added validation test covers only a regular instance method. The nested-function negative test is useful, but no test exercises either descriptor form.

classmethod is particularly worth testing because accessing it returns a bound method object, unlike the function objects returned for an instance method accessed through the class and for a static method. Tests should cover:

Calc.scaled
Calc.stat
Calc.klass

and ideally both decorator orders where @jit and @staticmethod/@classmethod can be combined.

Other observations

The exhaustive message matrix is thoughtfully structured. The Await and Starred cases exercise reachable positions for their own messages, and the unreachable Slice case is explicitly accounted for rather than silently omitted.

There are no existing review comments or unresolved threads, and main, Release, and Documentation all pass.

Summary

Changes requested. The matrix work is good and the ordinary lexical-method case works, but the new method contract currently overstates what __qualname__ can determine. The ownership limitation should be fixed or explicitly narrowed before merging.

…ions

Review found that __qualname__ records where a function was defined, not
whether a class currently owns the descriptor, so the method check claimed
more than it can determine. Probing the boundary, three cases are missed:
a decorator that returns a different function without preserving
__qualname__ or setting __wrapped__, a module-level function assigned in a
class body, and a function attached to a class after it was created.

Rename _is_method to _defined_in_class_body so the code states what it
actually tests, and document the three gaps on it, on the qualname field
and on validate. A functools.wraps chain stays covered, because wraps
copies __qualname__ and sets __wrapped__ for extraction to follow; that is
now pinned by a test rather than left to chance.

Pin the descriptor forms the description advertised but did not test: an
instance method and a staticmethod resolve to plain functions when
accessed through the class, while a classmethod resolves to a bound method
object, so all four forms are parametrized rather than assumed equivalent.

Pin the two out-of-contract cases as accepted, with the reason in the test
docstring, so the contract is explicit and a future fix has a test to
flip. Verified on 3.10/3.11/3.14 that __set_name__ is called for anything
bound in a class body, including a decorator's return value, so the
decorator layer can close both; it is not called for assignment after
class creation, so that case stays undetectable and is documented as such.

153 tests, arxjit coverage 100%. Verified on CPython 3.10, 3.11 and 3.14.
@Jaskirat-s7

Jaskirat-s7 commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

Good catch, thanks @xmnlab , You're right that I conflated "defined in a class body" with "is a method" — __qualname__ only tells you where a function was defined, so ownership was never something it could answer. Fix pushed in the latest commit.

I went the narrowing route, plus the regression test you suggested. The predicate is called _defined_in_class_body now, which is what it actually checks, and the limitation is documented on it, on the qualname field and on validate. Rather than guess where the line falls I went and measured it: instance methods, staticmethods, classmethods and functools.wraps chains all get rejected — wraps copies __qualname__ and sets __wrapped__, so extraction follows it back to the original. Your non-wraps example, scaled = scaled in a class body, and attaching to a class after it's created all still validate cleanly.

The four that work are a parametrized test now; I kept the classmethod in there separately since it comes back as a bound method rather than a plain function. The three that don't are pinned as accepted with the reasoning in the docstring, so the contract is explicit and there's something concrete to flip when we fix it.

I left the __set_name__ approach out of this one since it doesn't touch core.py, but I didn't want to just assert it would work, so I tested it first: on 3.10/3.11/3.14 it fires for anything bound in a class body, including a decorator's return value, which covers the first two cases. It doesn't fire when you attach to a class afterwards, so that one is out of reach wherever we put the check. I'll do it in the wiring PR. One thing to watch there — exceptions raised from __set_name__ come back wrapped in RuntimeError on 3.10/3.11 but bare on 3.12+, so strict=True will need to handle both.

The @jit + @staticmethod/@classmethod orderings are missing for the same reason, they need the decorator to exist first.

153 tests, still 100% coverage, checked on 3.10/3.11/3.14. Updated the description to match as well.

@xmnlab xmnlab left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for addressing the earlier feedback, @Jaskirat-s7.

I reviewed the latest changes and the contract is much clearer now. Renaming the predicate to "_defined_in_class_body", narrowing the documented behaviour, and adding coverage for instance methods, static methods, class methods, "functools.wraps", and the known undetected cases resolves my previous concerns.

I did not find any remaining blocking issues, and all CI workflows are green.

Two small non-blocking suggestions:

1. The diagnostic currently says:
   
   "methods are not supported (only module-level functions)"
   
   Since the implementation specifically detects functions lexically defined in class bodies, a more precise message could be:
   
   "functions defined in class bodies are not supported"

2. The PR description mentions that attaching a function to a class after class creation is also pinned as an accepted limitation. I found tests for class-body assignment and a decorator that drops "__qualname__", but I did not see an explicit post-creation assignment test. It would be useful to add one, though it does not need to block this PR.

The exhaustive rejection matrix also looks solid, including the explicit handling of the unreachable "ast.Slice" message.

Approved from my side. Thanks again for the thorough follow-up.

@xmnlab
xmnlab merged commit 2fcd661 into arxlang:main Jul 30, 2026
39 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants