Skip to content

fix(scripts): enclosingDeclaration recognises a CLASS METHOD header, so a method code helper is no longer dropped in silence - #13428

Merged
os-project-manager merged 4 commits into
mainfrom
claude/issue-13226-codehelper-class-method
Aug 30, 2026
Merged

fix(scripts): enclosingDeclaration recognises a CLASS METHOD header, so a method code helper is no longer dropped in silence#13428
os-project-manager merged 4 commits into
mainfrom
claude/issue-13226-codehelper-class-method

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

Fixes #13226

enclosingDeclaration in scripts/check-dispatcher-error-vocabulary.mjs recognised three declaration header forms — function NAME(, constructor(, const|let|var NAME = ( — and no CLASS METHOD. A code-carrying helper that happens to be a method was therefore unreachable even in the assignment position codehelper already implements: the identifier was never recognised as a parameter, helperCodesFor returned null, and the site fell through to resolveConstant, which reduced nothing. No site, no unresolved, nothing reported — the gate's declared bound (a value it cannot reduce is REPORTED, never dropped) failing in the one way the bound cannot notice.

The control table, reproduced through the real deriveSites

Same body, same unregistered probe, one declaration form apart. K is the positive control that proves the instrument fires, and it is reproduced on both sides precisely because a fix that lights up I while K silently died reads identically in a summary.

# declaration form body before after
K (control) function fail(code, message) e.code = code 1 site, codehelper 1 site, unchanged
I private fail(code, message) in a class e.code = code 0 sites, 0 unresolved 1 site, codehelper
J private fail(code, message) in a class { severity, code, message } 0 sites, 0 unresolved 0 sites, 0 unresolved — unchanged, and deliberately

J is the boundary. The declaration form is now seen; the object-literal stamp POSITION still has no shape, and that half is the sibling card's. J is pinned as its own case so the split stays legible.

Two halves, because one alone delivers only noise

The recognizer. A method header is bare NAME(, so the discriminator is the header's own shape: line start, optional modifier run, name, balanced parens, optional return-type annotation, {. The trailing { does the work — it separates a method from a line-initial CALL (; follows) and from a body-less interface or abstract SIGNATURE (no body, so no stamp can be inside it). A keyword lookahead removes the statement forms that share the shape (if (x) {, return (a) => {).

The call scan. helperCodesFor built its call regex with [^\w.$] before the name, which excludes a preceding dot on purpose. A method is reached through a receiver, so without widening that, every method helper would reduce to no literals and land as an unresolved — the declared bound technically restored, but the instrument permanently at the noise level and never resolving a single code. this. and only this. is admitted: the scan is in-file, so a method helper's callers here are its own siblings, and any other receiver stays unmatched, which is the safe direction.

Why not a real parser, and why not brace-depth class tracking

The card frames the discriminator as being directly inside a class body, and says a textual scan has to earn that. It cannot, for measured reasons rather than squeamish ones, so the header shape above is what is implemented instead:

  • Brace depth is the only textual route to "inside a class body", and this gate scans maskCommentsed source, which by contract leaves STRINGS, TEMPLATES and REGEX LITERALS standing — a brace inside a string flips the depth of everything after it. Masking literals too (scanSource exposes the flags) fixes that half and leaves the other: finding where a class BODY opens means walking a class X extends Y whose TYPE ARGUMENT is an inline object type — a brace that opens inside the type argument, before the class body ever starts, and telling a type argument from a comparison textually is the problem TypeScript wrote a parser for.
  • The AST route is refused on evidence already in this file: over-matching headers hand the scan 1218 argument lists that a recovering parse turns into confident wrong parameter names, so a parser here MANUFACTURES the wrong-INDEX hazard across a thousand slices to close one blindness.

DECLARED widening: the header shape also admits an OBJECT-LITERAL method. That is the same helper genre, it costs nothing today (zero in packages/** non-test source stamp a code in the assignment position), and it cannot produce a wrong site — an obj.fail(...) caller matches nothing, so it degrades to unresolved. Stated in the code rather than left to be discovered. Still NOT recognised: a class PROPERTY holding an arrow — a fourth form, filed separately, not smuggled in here.

Blast radius on this tree: ZERO, measured

Derived sites and unresolved are byte-identical before and after, through the real deriveSites over packages/**, comparing this branch against a worktree at the parent commit c09451bf1 — same tree, only the script version differing.

Two independent reasons, and the second is the one worth carrying forward:

  • No class-method helper stamps in the ASSIGNMENT position today. Of the 17 .code = ident matches in packages/** non-test source, 5 are true helpers (the identifier is a parameter) and every one is a function or a constructor; 0 are methods.
  • The 4 helpers this blindness was blocking all stamp through an OBJECT LITERAL, so the position blindness still drops them.

⚠️ Therefore the 25 call sites and 3 undischargeable unresolved the card attributes to this blindness are NOT a cost this change pays — they are a cost the POSITION widening will pay when it lands. The card reads as though closing the declaration form surfaces them; it does not, and the ledger now says so explicitly.

What this does buy is that the position widening will REACH those helpers. enclosingDeclaration at the Parser#error stamp offset now answers error(code, message, start, tag); before it answered the Parser CONSTRUCTOR. Helpers visible to enclosingDeclaration go from 10 of 14 to 13 of 14.

Re-derived numbers — the card's were claims, and one had drifted

quantity card re-derived verdict
helpers matching the object-literal predicate 13 (12 named, 1 anon) 14 (13 named, 1 anon) drifted +1
blocked by the declaration form 4 4 holds
their in-file call sites 25 25 holds
their new verdict rows 0 0 holds
their undischargeable unresolved 3 3 holds
whole-population call sites 106 116 drifted +10
whole-population new verdict rows 29 29 holds
whole-population undischargeable unresolved 4 5 drifted +1
SCREAMING_SNAKE values reached / registered 33 / 33 31 / 31 drifted, same conclusion
unregistered D1-shaped codes hiding 0 0 holds — the p2 stands

The whole-population drift is explained exactly and entirely by one helper that landed between the two readings (block in packages/spec/scripts/check-yaml-examples.ts, added 2026-08-29 21:33 UTC, 10 call sites, none reducing): 106 + 10 = 116, and 4 + 1 = 5. The card's figures were right when taken. This is a census of a moving tree, and the ledger now says a reader re-runs it rather than quoting it.

⭐ The number that grades the work — unregistered SCREAMING_SNAKE codes hiding behind the blindness — is still ZERO, so the p2 holds and this remains prevention rather than a live defect. It is a property of the TREE, not of the gate, so the ledger marks it as expiring.

Adjacent and re-checked, not assumed: the card records enclosingDeclaration answering the constructor whose parameters parsed as ["readonly","readonly"]. That half was already repaired on main — it reads ["src","opts"] now. Both halves had to be true for the stamp to resolve; neither alone sufficed.

Pins, and the two that ablation proved wrong

--self-test goes from 194 to 202 assertions. The former zero on the class-method form is now an EQUIVALENCE against the free-function control, plus the receiver assertion, the constructor-precedence case, the two look-alike shapes, and the J boundary.

Every load-bearing pin was ablated from the committed state — mutation proven on disk by anchored text counts and a git hash-object change, restore proven by the blob matching HEAD with an empty git diff HEAD, each leg carrying a trap on absolute paths.

ablation predicted observed
drop the method header form equivalence + mechanism + boundary pins fail 3 failures, exactly those
drop the this. receiver equivalence + receiver fail, mechanism stays green 2 failures, mechanism green
drop constructor from the keyword lookahead constructor pin fails GREEN — prediction wrong
drop the body-follows check signature pin fails GREEN — prediction wrong
drop a statement keyword from the lookahead control-flow pin fails 1 failure, exactly that

Both wrong predictions are reported rather than quietly fixed, because each was a real defect:

  • The keyword entry is not load-bearing. isConstructor is read off the matched TEXT, not off which alternative fired, so public constructor( is still classified as a constructor even when the method alternative wins the position. The entry is kept, the comment now says it is defensive and that removing it leaves the self-test green, and isMethod is made mutually exclusive with isConstructor so the flags cannot both be true.
  • My signature pin was VACUOUS. It placed the stamp inside a later function outer(, which wins as nearest-preceding whether or not the signature is recognised — so it passed with the body-follows check deleted. The pin is repaired to put the stamp where the signature would be the nearest header, and it now fails correctly under that ablation. The trap is written into the test as a comment, because it is silent.

Local verification, at 9344aaec

node scripts/check-dispatcher-error-vocabulary.mjs --self-test
  -> 8 shapes + 202 assertions OK (vocabulary + #9098 door typing)
node scripts/check-dispatcher-error-vocabulary.mjs
  -> OK - 22 unregistered code-stamping site(s), all classified; 1 awaiting a ledger entry (#8846)
pnpm lint  (eslint . --no-inline-config, whole repo)   -> exit 0

The gate's site count and pending count are unchanged from the parent commit, which is the zero blast radius stated above.

Gate family derived on this branch's own final head with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (1 changed path). All green, exit codes captured before any pipe:

check:dispatcher-error-vocabulary · check:agent-test-spelling · check:bash32-floor · check:cli-command-ids · check:cross-package-test-inputs · check:entry-guard · check:parse-guard · check:pnpm-filter-targets · check:watch-hint-literal · check:nul-bytes · check:pm-dispatch-gates · check:error-code-casing · pm/bare-root-worklist.mjs --self-test · check-self-test-wired.mjs and its --self-test · check-ci-filter-parity.mjs · check-cross-package-test-inputs.mjs · check-shard-attestation.mjs.

The two convention-triggered obligations for editing a GATE SCRIPT (bare-root-worklist --self-test and check:pm-dispatch-gates) are in that list and both pass; neither needed a new ledger row, since this change adds no population.

Declarations

  • Contract accept/reject behaviour: UNCHANGED. No schema, no envelope, no validator. This is a CI gate's own recognizer; packages/spec and the runtime doors are untouched.
  • Published surface: UNCHANGED. Nothing under packages/ or skills/ is modified, so nothing is released. scripts/ is CI-internal, the skip-changeset case, so no changeset accompanies this PR — matching the sibling change to this same file.
  • Governed surface: NOT touched. No docs/adr/, no .claude/, no AGENTS.md, no CLAUDE.md, no content/docs/releases/.
  • Serial-queue fence honoured. The object-literal stamp POSITION is out of scope here and is byte-untouched: SHAPES is unchanged, and the boundary case J is pinned to stay at zero. The queued sibling cards on this same file are not addressed by this branch and remain open.
  • The anonymous-arrow form is left OUT, explicitly, not silently. It has no NAME, and every one of helperCodesFor's call scans is built from the declaration's name, so it can produce neither a site nor an unresolved — admitting it to the header regex would buy nothing and cost the regex its anchor. Reaching it needs call-graph resolution this textual scan does not have. It remains the sole entry in invisibleDeclarationForms, and that entry is pinned.

Generated by Claude Code

@claude claude Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 30, 2026
@os-project-manager
os-project-manager marked this pull request as ready for review August 30, 2026 11:45
@os-project-manager
os-project-manager added this pull request to the merge queue Aug 30, 2026
Merged via the queue into main with commit bac201e Aug 30, 2026
34 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-13226-codehelper-class-method branch August 30, 2026 12:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants