Skip to content

refactor(checker,getter): declare FuncOpener, Closure, Container and opens_space; migrate entry-point callers #1696

Closed

Description

@dekobon

Objective

Declare FuncOpener, Closure, Container and Member per language, with a separate opens_space mapping; route is_func, is_closure, is_func_space, get_space_kind and the npm/npa member walks through them; audit every byte-less entry-point caller and migrate the ones that are actually byte-less.

Umbrella: R00: #1690 · Planning ID: R06: #1696
Completion prerequisites: R04: #1694 Completion of R04: #1694 includes a recorded go decision.

Why this exists

The space tree is built from three predicates and one mapping that must agree, and the failure when they do not is silent: a node the walker promotes but the getter leaves Unknown is not a member scope, so its space serialises no npm/npa block, which looks exactly like a language with no containers (grammar-dispatch.md §7). Today the agreement is held by a book paragraph (metrics.md: "In Python, Java, Kotlin and C++ a lambda opens no space") and tested nowhere — tests/parity/ covers ops-vs-metrics, not checker-vs-getter. Measured: a C++ lambda's if is charged to the enclosing function with no anonymous space; Rust and Go open one. Python recognises Lambda as a closure (checker/python.rs) and maps it to Unknown (getter/python.rs) — recognition and space-opening are two facts, and the rows keep them apart (umbrella contract 2).

The entry-point audit lives here because these predicates are the ones whose byte-less spelling is wrong for Elixir (defmodule/def are Call nodes told apart by target text, #275) and whose forwarding default hid that from bca ops for a release (#1130). A Node carries no bytes or chain, so the byte-less form cannot be made correct by forwarding; it stays as a documented legacy wrapper and each remaining byte-less caller moves.

Implementation steps

Tests and edge cases

  • Derived test per language: for every node in a fixture, the walker promotes it ⇔ opens_space != None — promotion against the space mapping, never against Closure recognition.
  • tests/parity/ops_metrics_space_parity.rs unchanged and passing; the Elixir case specifically.
  • Lesson 65's childless variants per gated row (expression-bodied C# property, new Foo() without a body, bodiless C++ specifier).
  • make bench-scaling and make chain-audit — these predicates sit on the walk's hot path and take Ancestors.
  • Zero drift on the corpus; both snapshot forms; plain run.

Acceptance criteria

Verification and completion evidence

  • Call-site audit; corpus diff; bench-scaling exponents; chain-audit; BCA_GATE: line.

Activity

  1. added
    refactorCode maintainability / tech-debt cleanup (no behaviour change)
    on Oct 23, 2026
  2. dekobon commented on Oct 23, 2026

    @dekobon
    OwnerAuthor

    Tracking is ready

    The body now links the master issue and the work packages it references. Native parent and blocked-by relationships match the listed completion prerequisites. Implementation and acceptance checkboxes remain open; prerequisite closure and the R04: #1694 go decision govern execution order.

  3. dekobon commented on Oct 25, 2026

    @dekobon
    OwnerAuthor

    Done — FuncOpener, Closure, Container, Member and opens_space declared in every language; entry-point callers audited; #1599 closed

    Branch refactor/1696-1698-recognition-roles from 7425561f (main), nine commits, not pushed, no PR. Per slice, one commit with its measured evidence in the message; a tenth takes the completion review's findings.

    Slice Commit Census (pending) Bypass
    Java (vertical slice, review checkpoint 2) 803fceb2 390 → 389 84 → 60
    JS family (JavaScript, MozJS, TypeScript, TSX) 314a8c23 → 373
    Rust, Go, Lua, Bash, Tcl, iRules, Perl, PHP, C 9b21b591 → 344
    C++, Mozcpp, Objective-C 456e5ee6 → 332
    C# 5a367017 → 328
    Kotlin, Groovy, Ruby 809f2726 → 316
    Python, Elixir b173f300 → 314 → 40
    #1599 hook, book cross-check, CHANGELOG 8bd5a700
    Checkpoint-3 review fixes f38eeb54

    No pending R06 cell remains; the 40 remaining bypass sites are all Call / StringLit (R08). Coverage ratchet −182 ids over the package, every change remove-only.

    Representation (umbrella contract 2, §3 amendment). FuncOpener is "opens a Function space"; its named functions carry callable (what is_func answers — a Java static { … } or a Kotlin accessor opens a space and is not one) or callable_when f (a JS function expression is a function only where bound; the is_js_func! ancestor walk moved onto the rows as js_family_binding_gates!, six checker.rs macros deleted). opens_by f labels a kind that spells two containers (Kotlin's class_declaration). Each table emits is_callable, CALLABLE (the marked kinds, gates aside) and the opens_space fold; utils/role_tables.py reads all three attributes; the dsl_gates proof table and the census self-tests pin them. Member recognises the declaration — a field, a property, a Python self.x / cls.x where it is bound, an Elixir defstruct, a Ruby @ivar as an assignment target — and leaves which body, what visibility and how many names to the consumer.

    Specification corrections, recorded on the rows:

    • The proof rows' FuncOpener was wider than is_func (Java's StaticInitializer, Elixir's AnonymousFunction), which the plan review caught before the first slice; callable is the third fact the issue's two-declaration framing needed.
    • CALLABLE lists kinds gates aside, so a consumer whose ancestors can carry a gated-away spelling keeps its own list: C#'s cognitive stop list, where a bodied property is in the list and is not a boundary — the one drift the corpus found in flight (two DeepSpeech files), fixed before landing and pinned by csharp_bodied_property_is_not_a_function_boundary_above_its_accessor.
    • Ruby's lambda-body gate covers block and do_block (the issue text named one).
    • Kotlin's and Groovy's closures open no space, as Java's lambda does not — the byte-less getters labelled them Function for a node the walk never promoted, the "pair that fails quietly" of grammar-dispatch §7, now recorded on the rows. The book's list gains Groovy, Mozcpp and Objective-C, and is read from the rows by closures_that_open_a_space_are_what_the_book_says.
    • Two consumers read the keyword, not the row, and say why: Elixir's cognitive boundary resets on a quoted def too (the row declines one, fix(elixir): WMC and NPM disagree on quoted def inside defmacro #310), and its method count counts a quoted defmodule's defs.
    • The byte-less is_func_space / get_space_kind forward with a known-empty chain, not Ancestors::unknown(), which climbs through Node::parent and would answer a gated result the old kind table never did (Ruby's lambda-body block; found by the completion review, pinned by ruby_byte_less_spellings_stay_the_kind_table).

    Entry-point audit (every non-test caller of the thirteen predicates outside checker/ / getter/, enumerated by search; verdict per site):

    Site Spelling Verdict
    src/spaces/compute.rs:883, src/ops.rs:353 promotes_to_func_space_with_code, real chain contextual already
    src/spaces/compute.rs:508, src/ops.rs:170 get_space_kind_with_code, real chain contextual already
    src/spaces/compute.rs:476, src/ops.rs:129 get_space_kind_with_code, Ancestors::unknown() exempt: the real tree root, which has no parent (review-confirmed)
    src/function.rs:65, src/suppression.rs:762, src/metrics/nom.rs:241, nargs.rs:296,709 is_func_with_code, real chain contextual already
    big-code-analysis-ast/src/parser.rs:151,174,183 is_call_with_code / is_string_with_code / is_func_with_code, real chain contextual already
    src/metrics/nargs.rs ×12 (is_func / is_closure, Go, Kotlin, Lua, Tcl, iRules, Perl, Groovy, Java) and nom.rs:245 byte-less, real chain exempt: no language's FuncOpener / Closure row reads bytes except Elixir's, whose NArgs already calls is_func_with_code; the chain is passed, so the JS and Ruby gates read it
    src/metrics/npm.rs:260 direct_child_funcs is_func, Ancestors::unknown() exempt (documented at the site): Java, Groovy, Kotlin, PHP rows read no chain
    src/metrics/cognitive.rs (JS) was is_func on a hand-listed set migrated: is_callable + CALLABLE
    src/metrics/nexits.rs:567 is_call R08 (Call)
    src/metrics/halstead.rs:407 was is_primitive migrated: keys_operator_by_text(node, code, ancestors) (#1599)
    big-code-analysis-ast/src/getter.rs:321, checker.rs:718,726,746 trait defaults forwarding unchanged; every language that needs the contextual form overrides it

    #1599 disposition — closed. Checker::keys_operator_by_text(node, code, ancestors) (default is_primitive) is what Halstead reads; Groovy answers it from the Primitive row's text gate, Objective-C from that row plus nullable and the Modifier row's gated identifier (#1579). ObjC's is_primitive now answers from the row's kinds (an identifier is no primitive); Groovy's byte-less one stays the kind table, since its primitives are told by text alone, and the test pins that limitation rather than claiming it away. #1299 disposition — untouched, residue named: the TemplateDeclaration Npm/Npa disagreement it describes is not a membership question the Member row answers (a templated static data member is a template_declaration > declaration, read by declarator), so its fix stays the separate behaviour change it is.

    Evidence. Zero drift against the base binary on every slice and the final tree: 25 populations (Mozcpp over the C++ files, MozJS over the JS files, ccomment / preproc over fixtures), CLI ops / metrics / dump / find -t call|string|function / count plus a library-level verbatim record (raw bytes, Alterator export, find, count): 0 of 26,992 outputs differ. Recovery sweeps per slice (every fixture cut at each line, 16 offsets, after every operator punctuation and at every word boundary, CLI + verbatim): 6,480 + 21,930 + 27,138 + 47,808 + 5,382 + 12,402 + 7,902 = 129,042 outputs, 0 differing, 0 failed executions. roles/space_tree_tests.rs pins the five answers per kind in every language independently of the classifiers that now read the rows (never-emitted aliases as drift markers); src/spaces/opens_space_parity_tests.rs runs the real analyze walk in all 23 grammar languages and holds its promotions to the opens_space labels. Perturbations: ~100 across the slices — a row owner, gate or callable removed or added, a consumer severed from its row, a consumer reading CALLABLE where it must not — each failing at least one named test; five lambda-channel / stop-list arms had no guarding test and gained one (Go, PHP, C#, Python, C#'s property). make chain-audit passes; make bench-scaling 87 of 87 probes within bound, the new nom/wide-arrow-array (0.96) pinning the sibling scan the review found quadratic in the first row-based spelling of the JS walk (4,000 arrows: 0.02 s → 4 s, exponent 2.00 on the regressed form; fixed to lazy evaluation). Single-feature -p builds (rust, go, elixir, tcl, kotlin) green in both crates; check-test-lang-gates-compare COMPARE_REF=origin/main loses no build. make pre-commit: BCA_GATE: pass (gate=pre-commit) on every commit. Both snapshot forms clean; no .snap.new or .pending-snap; the output submodule untouched.

    Reviews (astra-review, fresh context each): the plan (nine findings, all taken: the callable fact, the ObjC text-key set, Python's self/cls and target-position recognition, the Elixir single lookup, Ruby's two block kinds, body discovery as traversal, consumer-connection perturbations, a verbatim-EOF oracle, 25 populations); checkpoint 2 on the Java slice (one P2: callable needed an independently gated spelling → callable_when); checkpoint 3 on the whole package (one P1: the JS sibling scan evaluated eagerly — fixed with a probe; two P2: the Ruby byte-less climb, the Ruby visibility-wrapped def bypassing is_callable — both fixed and perturbed; one P3: the Groovy is_primitive claim — corrected).

    Limitations. CALLABLE is gates-aside by construction and the module doc says so; a consumer with gated-away ancestors keeps its own list. The byte-less is_func_space / get_space_kind are the kind table and are documented as such; nothing on a supported output path reads them. Groovy's byte-less is_primitive remains every name. #1299's template-payload residue is unchanged.

  4. dekobon commented on Oct 26, 2026

    @dekobon
    OwnerAuthor

    Post-landing review pass (five reviewers; fixes in 18ee3c98)

    After R06/R08 closed, the branch went through simplify-rust, audit-tests and rust-optimize passes and then five parallel reviews (the review checklist, an algorithmic-performance / recursion audit, a least-reviewed-code audit, an acceptance-criteria audit against this issue, #1698, #1599 and #1690, and astra-review). Everything they found is fixed in 18ee3c98; what touches this issue's claims:

    Step 1 / step 5 wording corrected in the body. ObjC's Primitive row is kind-only — its text-keyed words are the Modifier row's gated identifier plus keys_operator_by_text — and the TemplateDeclaration disagreement named in #1299 is not a Member-row question and stays with #1299 (npa/cpp.rs now carries a FIXME(#1299)).

    AC 3 (byte-less callers) is now pinned, not exempted by rationale. Each row emits TEXT_GATED, true iff an owner is text_gated_by; nargs::tests::byte_less_spellings_read_only_rows_without_a_text_gate and nom::tests::byte_less_is_closure_reads_only_rows_without_a_text_gate fail the day a row the byte-less is_func / is_closure readers depend on grows a text gate, naming the impl that must move to _with_code. The byte-less spellings hand the walk's chain through, so a chain-reading gate (Ruby's) was never the hazard.

    Rows are asked under the member's own chain. npa / npm handed a body's chain to the row for each of its members ("the row reads neither"); a gate the row grew later would have read the wrong parent silently, and the JS/TS is_callable on class-body children already reached the chain-climbing bound_* gates that way. Ancestors::of_children(body) (one Vec per body, never per node) now gives each member its own chain at every such site — 20 of them across npa*.rs / npm*.rs, including the two Ancestors::unknown() sites in npm/ruby.rs and direct_child_funcs, which also moves to is_func_with_code. Pinned by of_children_names_the_body_as_each_member_s_parent.

    Two pre-existing performance defects the branch touched are fixed. python_walk_target_elements was recursive and overflowed the consumer thread's stack at ~8,000 nesting levels (binds had already gone iterative in #1597); it is iterative now, pinned by python_deeply_nested_unpacking_target_does_not_overflow_the_stack on a 512 KiB thread. The JS arrow binding gate's property_identifier sibling scan was O(width) per positional arrow, so run(() => 1, …) with 4,000 arrows took 3.4 s; memoised per parent under MemoQuestion::NamesAnArrow it takes 0.01 s, and nom/wide-arrow-array now renders that positional form (it rendered a bound const cb = [...], which never reached the scan) — exponent 1.02, 87/87 probes on HEAD.

    is_primitive reads the row everywhere. Kotlin's and C#'s is_primitive still answered the Halstead key-selection question (C#'s listed seven modifier kinds); that moves to keys_operator_by_text overrides as Groovy's and ObjC's are, and C / C++ / Mozcpp / TS / TSX read primitive::lists_kind with their one text-keyed kind in the hook.

    Evidence on HEAD. Corpus oracle 0 differing of 27,008 compared (the two R05 fixture files the restart lost were reconstructed from their recorded exports and verified byte-identical through the base binary's dump; Kotlin's and Perl's could not be and are the 16 records short of 27,024); recovery sweep 0 of 124,488; chain-audit pass; make bench-scaling 87/87, worst exponent 1.12. Per-language populations: {"bash":146,"c":3,"cpp":1050,"mozcpp":1050,"ccomment":6,"preproc":6,"csharp":24,"elixir":3,"go":3,"groovy":3,"irules":3,"java":14,"javascript":392,"mozjs":392,"kotlin":3,"lua":3,"objc":3,"perl":3,"php":3,"python":75,"ruby":3,"rust":175,"tcl":3,"typescript":10,"tsx":2} — the populations of 3 are the fixture set only. The earlier comments' "26,992" was a miscount of the same 27,024-record runs; the recovery count is 129,042 (CHANGELOG said 128,062 and is corrected).

  5. dekobon commented on Oct 26, 2026

    @dekobon
    OwnerAuthor

    Astra-review of 18ee3c98 returned two findings, both fixed in 3ea78b95 and re-reviewed (approve, no findings): the members' chain was built before the body test in the four npa.rs / npm.rs macro walks and so ran per node (a 64,000-deep expression took 3–6 s in each metric; 0.04 s now, pinned by a new npa/java-deep-expression probe), and TEXT_GATED read text_gated_by only in first position (the scan arms now precede the fold; the DSL test declares the word in three positions). Final gate on an idle host: make bench-scaling 88 / 88, worst exponent 1.32; make pre-commit pass; zero drift unchanged.

  6. dekobon commented on Oct 26, 2026

    @dekobon
    OwnerAuthor

    Lost-fixture gap closed; evidence moved off /tmp

    The two R05 fixtures the host restart lost (f1.kt, f1.pl) are recovered byte-exact. Every token's text and span is in the recorded verbatim export, so the first pass rebuilt 20 of 22 from those alone; the two that failed parsed as ERROR because the semicolons Kotlin's grammar emits as anonymous separators, and the $a scalar names Perl's grammar reports only on the scalar_variable wrapper, are not leaves of the export. Reading those from the recorded dump (which prints every node's text) filled the remaining bytes, and the base binary's dump of each rebuilt file is identical to the recorded one — the same check the other 20 passed.

    With all 22 in place, the oracle on HEAD (3ea78b95) reads 0 differing of 27,024 corpus records and the recovery sweep 0 of 129,042 — the full populations the earlier comments cite, so the "27,008 compared, 16 short" and "124,488" caveats above no longer apply.

    The evidence is no longer only in /tmp: ~/bca-evidence/1696/ on this host holds the fixtures (original and recovered), the base and HEAD outputs (out2-7425561f, out-final), the base and HEAD bca / verbatim-oracle binaries, the oracle, recovery and perturbation drivers, the verbatim-oracle source, the bench / chain-audit / pre-commit logs, and the acceptance-criteria report (6.9 G). Whether the per-language fixtures should become integration tests under tests/ is a question for the PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    refactorCode maintainability / tech-debt cleanup (no behaviour change)

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions