Skip to content

feat: resolve form-submitter boundness in webjs check and make the residual loud - #1314

Draft
vivek7405 wants to merge 20 commits into
mainfrom
feat/submitter-needs-bound-form
Draft

feat: resolve form-submitter boundness in webjs check and make the residual loud#1314
vivek7405 wants to merge 20 commits into
mainfrom
feat/submitter-needs-bound-form

Conversation

@vivek7405

@vivek7405 vivek7405 commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Closes #1307

A <button formaction=${fn}> submitter whose enclosing <form> cannot carry its identity posts nowhere, and today that reaches production silently whenever the two live in different modules.

The residual, measured

The renderer's boundness inference is four-state and the 'unknown' branch binds anyway. That fallback is correct and stays: refusing on cannot-tell would reject a per-row button in a list and a button inside a component, and an SSR refusal is isolated per component, so production would return 200 with the button silently gone.

What the residual actually does is narrower and quieter than "an unbound form is broken". Boundness and deliverability are two different questions and only both together are a defect. An unbound <form method="post"> WORKS: a submitter's identity rides its own name/value pair, which the browser submits for the pressed button alone, so it reaches the body and the dispatcher runs the action. What breaks is a form that sends nothing readable, and there the failure is a silent 200: with no method (or method="get") the browser submits a GET, the reserved identity rides the QUERY STRING, the action never runs, and the page simply re-renders. No throw, no log, no 405. Only enctype="text/plain" reaches the 405, because enctype falls back to urlencoded for a missing AND an invalid value.

That measurement is what the design is built on: telemetry hung on the 405 alone would report almost nothing, so the primary production signal is the query-string GET.

Three layers

  1. webjs check: a new submitter-needs-bound-form rule. It has neither renderer's limit, reading every template in the app at once, so it resolves the enclosing form across module boundaries and transitively through intermediate components. It fires only when EVERY call site puts the tag in a form that cannot deliver.
  2. A dev-only client guard at submit time. The client cannot answer boundness at reconcile time, but by submit time both the form and the body are in hand. It logs once per shape and never throws, because the listener is delegated at the document and a throw there would abort before preventDefault and make dev behave differently from production.
  3. Production telemetry on both server-visible fingerprints. WEBJS_FORM_SUBMITTED_AS_GET and WEBJS_FORM_ACTION_MISSING reach the existing onError hook. Detect-only, so no status changes; field NAMES ride and values never do; deduplicated per process on the code, the method, and the matched ROUTE, so crafted urls on a dynamic route cannot exhaust the cap and silence the diagnostics.

No file implementing the render-time refusal matrix changes. The cannot-tell fallback keeps binding, assertSubmitterFormIsBound keeps its signature, and neither SSR state machine is edited.

The rule is conservative by construction and is silent on: a tag with mixed call sites, a tag whose unbound host form still delivers, a form whose method/enctype comes from a hole, a tag with no call site, a submitter in a bare html helper, a multi-component file, a file that opens a form of its own, a template passed as a component property, and a reference cycle. Silence is therefore not proof a form is bound, and the docs say so rather than overselling it.

One fix outside the plan

matchClosingBrace incremented the brace depth at a ${ hole and never decremented it, so depth could never return to zero and a class body holding a template hole was unmatchable. Every existing caller passes a masked source where holes are already blanked, which is why it was invisible. Fixed with a context stack, with a regression test.

Regression evidence (shared code)

matchClosingBrace is the one thing here that pre-existing code depends on (five check rules and the elision analyser reach it through extractWebComponentClassBodies), so it was measured rather than reasoned about:

  • Brace matching on masked input, across all 1171 tracked source files: 0 disagreements at the 35098 brace positions actually in code position.
  • extractWebComponentClassBodies on masked input, both mask modes: 4 diffs of 1185 bodies. Two are the bug-fix direction; two are phantom bodies read out of class X extends WebComponent written inside a quoted STRING in a test fixture, where neither answer is meaningful.
  • Rule outcomes repo-wide: both versions of check.js report the identical 61 violations, 0 added and 0 removed, so the phantom-body difference changes no verdict.
  • Elision verdicts are byte-identical on examples/blog, website, and a freshly scaffolded app.
  • New rule false positives: 0 hits repo-wide across all 1171 files, including every docs page and check fixture.

Test plan

  • packages/server/test/check/submitter-needs-bound-form.test.js, 21 cases: the counterfactual plus every non-firing case above
  • packages/server/test/scanner/html-form-scopes.test.js, 16 cases: the lexical half, the delivery matrix, the matchClosingBrace regression, and a drift guard against the renderer's own PARSEABLE_ENCTYPES
  • packages/server/test/routing/form-dispatch.test.js: both codes, the unchanged 200 and 405, the dedupe, a 400-request dynamic-route flood, and no report for a non-form POST
  • packages/core/test/rendering/form-action-binding.test.js: streamed twins of both knowable refusals; the existing cannot-tell tests pass untouched
  • Browser: 18 pass on Chromium, Firefox, and Webkit
  • Bun parity: test/bun/form-action-dispatch.mjs extended with both codes and the dedupe, green on node 26.1.0 and bun 1.3.14
  • e2e: a new no-JS case on /feedback/triage-split, the cannot-tell shape rendering correctly and submitting with JavaScript off (14/14 in the form-actions block)
  • npm test: 3978 tests, 6 failures, all reproducing identically with this branch's source reverted (3 elision differential, 2 Bun listener, 1 blog HTTP integration)
  • Dogfood: website boots 200 in prod mode on /, the touched docs pages, /ui, and /ui/button, with no broken modulepreload; scaffold generates, boots, and checks clean

Docs

  • AGENTS.md invariant 12, .agents/skills/webjs/SKILL.md, and the skill's muscle-memory-gotchas.md, data-and-actions.md, built-ins.md
  • packages/server/AGENTS.md and packages/core/AGENTS.md module maps
  • website/app/docs/: server-actions, progressive-enhancement, troubleshooting (a new symptom entry, since this one had none), deployment
  • packages/cli/templates/gallery/: the caveat beside the existing submitter note. The scaffold's copy of the skill is the repo-root one, copied at generate time, so there is no second file to edit.

A `<button formaction=${fn}>` needs its enclosing form bound, because
`method="post"` and the enctype are supplied on the form's start tag and
a per-button action cannot retrofit them. Neither renderer can always
tell: SSR reads one template at a time and a component renders its own
template in a separate pass with no view of the host page, so a
submitter in a component is a cannot-tell, and cannot-tell has to bind
(refusing there would drop an isolated component from a page that still
returned 200).

What ships then is silent. The unbound form has no `method`, so the
browser submits it as a GET, the reserved identity rides the query
string, the action never runs, and the page re-renders with a 200. No
throw, no log, no 405.

`webjs check` has neither renderer's limit: it reads every template in
the app at once. The new `submitter-needs-bound-form` rule resolves the
enclosing form across module boundaries and transitively through
intermediate components, and stays silent on everything indefinite (a
tag with mixed call sites, no call site, a submitter in a bare `html`
helper, a multi-component file, a reference cycle), so it cannot
false-positive on the shapes the fallback exists to protect.

`matchClosingBrace` needed a fix to get there. It incremented the brace
depth at a `${` hole and then never decremented it, so depth could never
return to zero and a class body holding a template hole was unmatchable.
Every existing caller passes a masked source where holes are already
blanked, which is why the bug was invisible until one passed raw source.
A submitter bound inside a component whose host form is unbound is the
one form-binding mistake that reaches production, and until now it was
invisible everywhere. Measured rather than reasoned: the dominant case
is not a 405. An unbound form has no `method`, so the browser submits it
as a GET, the reserved identity rides the query string, and the page
re-renders with a 200. An unbound `<form method="post">` actually WORKS,
because the submitter's own name/value pair carries the identity into
the body. Only an unparseable enctype reaches the 405.

Two layers, both detect-only.

In dev the client logs once at submit time. The client cannot answer
boundness at reconcile time, but by submit time both the form and the
body are in hand. It logs and never throws: the listener is delegated at
the document, so a throw would escape uncaught AND abort before
preventDefault, making dev behave differently from production.

In production both server-visible fingerprints reach the onError hook
with a code to group on. A page GET carrying `__webjs_action` in the
query string is `WEBJS_FORM_SUBMITTED_AS_GET`; nothing else in the
framework ever puts that reserved field in a url. A form body carrying
no identity is `WEBJS_FORM_ACTION_MISSING`. The response never changes,
because answering a GET differently on a query parameter would hand any
visitor a way to turn any page into an error. Reports carry the field
NAMES, which are template constants that say WHICH form posted nowhere,
and never the values, which are user data. Both are deduplicated per
process on method plus pathname with a 256-entry cap, since either is
reachable by an unauthenticated request and an uncapped report would be
a free amplifier into a paid APM sink.

The other two 405s are left alone: a non-form POST is answered before
the body is read and is the cheapest thing on the file to flood, and the
GET-declared-action refusal already has its own check rule.
@vivek7405 vivek7405 self-assigned this Aug 6, 2026
No in-repo app exercised the fallback. `/feedback/triage` keeps the form
and the submitter in ONE template, so the renderer resolves boundness in
a single scan and the cannot-tell path is never taken.

`/feedback/triage-split` is the other half: the form is bound in the page
and the submitter is bound one module over, in a component that renders
in its own pass. That is the shape SSR cannot judge and therefore binds
on faith, and the e2e submits it with JavaScript off.

It doubles as the counterfactual for the whole change. Make cannot-tell
refuse and the component renders empty (an SSR component error is
isolated), so the button leaves the DOM and the e2e goes red on a page
that still returns 200.

Also pins the streamed twin of the two KNOWABLE refusals. The streamed
state machine is a second implementation of the same scan, so a shape
both machines express has to be asserted through both entry points. A
cannot-tell cannot arise there at all: the component pass that seeds
'unknown' lives in injectDSD, which both entry points share and
`{ ssr: false }` never reaches.
@vivek7405

Copy link
Copy Markdown
Collaborator Author

Design rationale: why the check rule refuses to guess, and what it gives up

The rule could have been much louder and I deliberately made it quiet. The failure it catches is real but rare; a false positive would land on a per-row button in a list or a button inside a component, which are the two most ordinary shapes there are. So every ambiguity resolves to silence.

Concretely it goes quiet on: a tag rendered inside a bound form somewhere and an unbound one elsewhere, a tag with no call site anywhere in the app, a submitter in a bare html helper rather than a component class body, a file registering more than one tag, and a reference cycle. A tag with no call site is the least obvious of those, and it matters: markup the scan cannot see can still render that tag.

The bare-helper carve-out is the one worth explaining. A fragment returned by a helper is rendered inside the CALLER's scan and inherits the caller's form scope, so a helper that looks form-less at rest can be perfectly bound at runtime. Attribution therefore requires the submitter to sit inside the single WebComponent class body of a file that registers exactly one tag. Anything looser and the rule starts guessing about a scope it cannot see.

The recursion is deliberate rather than a one-level lookup. A page binding <form action=${fn}> around <todo-list>, which renders <todo-row>, which holds the button, is an ordinary shape, and a one-level rule would go silent on exactly the case the rule exists for. There is a test for both directions of it.

The cost of all this is that silence from the rule is not proof a form is bound, which is why the docs say so in as many words rather than overselling it.

@vivek7405

Copy link
Copy Markdown
Collaborator Author

Correction: the production symptom is a silent 200, not the 405 I expected

Worth recording because it changed the design. I went in expecting the unbound-host-form case to surface as the bind-nothing 405 in form-dispatch.js, and it mostly does not.

Running the shape through the real renderer, an unbound <form> with no method emits the submitter carrying name="__webjs_action" and its value, and nothing else. The browser then submits that form as a GET, so the identity lands in the QUERY STRING and a GET at a page path just renders the page. Status 200, no body, no log, no telemetry. With JS the outcome is the same, because the router promotes a safe-method body to the query string.

Two more outcomes fall out of that. An unbound <form method="post"> actually WORKS, because the submitter's own name/value pair carries the identity into the body and the dispatcher takes the last entry it finds. And only an unparseable enctype reaches the 405, because that is the one path where the body parses to nothing.

So telemetry hung on the 405 alone would have reported close to nothing on the dominant case. That is why the primary signal here is the query-string GET instead: nothing in the framework ever puts that reserved field in a url, so it is an unambiguous fingerprint of a bound submitter submitted through an unbound form.

It also settled the response question. The GET keeps returning 200 and rendering. Answering it differently because of a query parameter would hand any visitor a way to turn any page into an error, and this layer is diagnostics, not enforcement.

@vivek7405

Copy link
Copy Markdown
Collaborator Author

Found on the way: matchClosingBrace could never close a brace containing a template hole

This was not in the plan and I want it visible rather than buried in the diff.

matchClosingBrace in js-scan.js tracked string and template state, and on a ${ inside a template it incremented the brace depth with a comment saying it would count the hole's closing }. It never did: after the ${ the walker was still in template state, so the } fell through the string branch and was skipped. Depth went up by one per hole and never came back down, so a class body containing html`...${x}...` returned -1 and extractWebComponentClassBodies found nothing.

The reason nobody hit it is that every existing caller passes a MASKED source, where redactStringsAndTemplates has already blanked template bodies and their holes to spaces, so no ${ survives to trip it. This rule is the first caller that needs raw source, because it needs the templates intact to read their markup, and it walked straight into it.

Fixed with a small context stack: a ${ pushes a code frame, and the } that returns that frame to depth zero pops back into the template instead of counting toward the block being matched. That is a no-op for every masked caller, and there is a direct test plus one that asserts the class body is extractable from raw source.

@vivek7405

Copy link
Copy Markdown
Collaborator Author

Decision: the dev guard logs and never throws, and the browser test dispatches instead of clicking

Two small calls worth writing down.

The guard sits in onSubmit, which is a delegated document-level listener. A throw there escapes as an uncaught error AND aborts before e.preventDefault() and performSubmission, so the page would submit differently in dev than in production, which is the opposite of what a dev diagnostic should do. It logs at console.error rather than console.warn because the existing warnOnce warnings are for degradations that are correct but suboptimal, and a form that posts nowhere is a broken write path. The dev overlay hooks neither level, so raising it pops nothing.

The browser test dispatches a synthetic SubmitEvent with the submitter set, rather than clicking the button. Clicking works on Chromium and Webkit and reloads the page out from under the runner on Firefox: the guard fires, the submission proceeds as designed, the GET promotion rewrites the url, and the nav degrades to a full page load because the new url shares no boundary. That degradation is the correct production behaviour, so the fix belongs in the test rather than the code. A dispatched event takes the identical router path without the browser's own submission behind it.

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Went through the whole diff against the invariants. The shape of the change is right and the conservatism in the check rule is the correct instinct, but the central classification is wrong in a way that matters: the rule treats "unbound" as the defect when the real defect is "cannot deliver the identity". Those come apart exactly on <form method="post">, which is the case the PR body itself calls out as working. As written the rule fires on working code and tells you it submitted as a GET, which sends you hunting a symptom that is not happening. The docs inherit the same over-broad claim in four places while progressive-enhancement states it correctly, so the doc set currently contradicts itself.

The other one I would not ship is the telemetry dedupe. Keyed on the request pathname it is exhaustible: a dynamic route gives unbounded distinct keys, so a few hundred crafted requests fill the cap and silence both diagnostics for the process, and because the two signals share one key space a flood of either kills the other. The cap was argued only in the amplification direction; the silencing direction is the more likely one to bite.

Two smaller things: this is the first caller to hand raw source to matchClosingBrace, which does not lex regex literals, so a component with a brace in a regex loses its class body and the rule quietly stops covering that file. And the per-package AGENTS.md module maps for js-scan.js, form-dispatch.js, and router-client.js are all stale.

The matchClosingBrace fix itself is good and the never-decremented hole depth was a real latent bug.

Comment thread packages/server/src/check.js Outdated
Comment thread packages/server/src/check.js Outdated
Comment thread packages/server/src/check.js Outdated
Comment thread packages/server/src/form-dispatch.js
Comment thread packages/server/src/js-scan.js
The rule treated "unbound" as the defect, but the defect is "cannot
deliver the identity", and the two come apart on exactly the shape the
PR body already called out as working. An unbound `<form method="post">`
delivers: a submitter's identity rides its own name/value pair, so it
reaches the body and the dispatcher runs the action. Flagging it was a
false positive on working code, told through a message describing a GET
and a query string that never happen.

The scanner now reports, per unbound form, whether it would still carry
the identity (`method="post"` plus a parseable enctype), with null when a
hole makes the answer dynamic. The cross-module verdict fires only when
EVERY call site cannot deliver. The same-scan case still fires whatever
its method, because the renderer refuses that shape outright, and its
message now says so instead of describing the silent path.

Three more from the same read:

The dedupe key is the matched ROUTE, not the request pathname. Keyed on
the pathname a dynamic route yields unbounded distinct keys, so a few
hundred crafted urls filled the 256-entry cap and permanently silenced
both diagnostics, which is worse than the amplification the cap exists
to stop. The code is part of the key too, so one signal cannot silence
the other, and a slot is no longer spent when there is no sink and no dev
logger to receive the report. That last one needed `hasOnError` threaded
into the request context, because `reportError` is always a function and
no-ops internally, so it can never say nobody is listening.

The class body is located in the MASK and sliced out of the raw source at
the same offsets, so the brace matcher is never asked to lex raw source.
It does not handle regex literals, and a component carrying
`static re = /[{]/` produced zero class bodies, silently dropping the
cross-module half of the rule for that file.

A file that opens a `<form>` of its own can no longer attribute a
scope-none tag use to its component. A fragment built into a local and
spliced into that form inherits the splice point's scope, which is the
same reasoning the submitter half already applied to a bare helper.

Also brings the per-package module maps in packages/server/AGENTS.md and
packages/core/AGENTS.md up to date, and corrects the four doc surfaces
that had copied the over-broad "an unbound form submits as a GET" claim.
@vivek7405

Copy link
Copy Markdown
Collaborator Author

Regression evidence for the matchClosingBrace change, since it is shared code

matchClosingBrace is the one thing in this PR that pre-existing code depends on: five check rules and the elision analyser reach it through extractWebComponentClassBodies. Rewriting its walker is the highest-risk edit here, so I measured rather than reasoned about it.

Differential, old implementation against new, over all 1171 tracked source files:

  • Brace matching, masked input, at the 35098 brace positions that are actually in code position: zero disagreements. My first pass at this reported 141, and that harness was wrong: it probed every { in the text including ones sitting inside string bodies, which the default mask keeps verbatim and which no caller ever asks about. Restricting to code positions (using the fully-blanked mask as the oracle) collapses it to zero.
  • extractWebComponentClassBodies, masked input, over the same corpus, both mask modes: 1185 bodies across 270 files, 4 disagreements. Two are the bug-fix direction (old returned no bodies at all where new finds them). The other two are phantom bodies read out of class X extends WebComponent written inside a quoted STRING in a test fixture, where the braces are unbalanced across string boundaries and neither answer is meaningful.
  • Rule outcomes, repo-wide: both versions of check.js over the whole repo report the identical 61 violations, zero added and zero removed. So the phantom-body difference changes no rule's verdict anywhere.
  • Elision verdicts: analyzeAppElision is byte-identical on examples/blog, website, and a freshly scaffolded app. That is the one I most wanted to see, since elision decides what the browser downloads.
  • New rule, false positives: zero hits repo-wide across all 1171 files, which includes every docs page and every check test fixture.

Suite-level, npm test is 3973 tests with 6 failures, and all 6 reproduce identically with this branch's source reverted (3 elision differential, 2 Bun listener, 1 blog HTTP integration; the last three are an unseeded blog database in my worktree).

Both are false positives in a rule whose whole design property is that it
never produces one.

`enctype` is an enumerated attribute whose MISSING and INVALID value
defaults are both `application/x-www-form-urlencoded`, so
`enctype="nonsense"` submits a perfectly parseable body and the identity
arrives. Testing it against an allowlist of the two parseable types
inverted that and called a working form broken. Only `text/plain`, the
third valid keyword, is a real loss, so the check is now a denylist of
one. The renderer refuses the wider set, but there it is a loud throw on
an attribute the author wrote deliberately, not a silent verdict about
someone else's form.

A template in a START-TAG hole is an attribute or property VALUE, so it
is handed to the receiving element and rendered in THAT component's own
pass. Scoring it by lexical nesting meant a submitter template passed as
`.tpl=${html`...`}` inside a form was recorded conclusively unbound,
while the renderer sees a cannot-tell there and binds. A hole in CHILD
position is rendered inline by the same scan and still inherits, which is
the pair that keeps this from being a blanket opt-out.

Three more from the same read:

The `opensForm` guard now covers the SUBMITTER half too, which the rule's
own description already claimed it did, and it tests the whole-file scan
rather than the class body. A module-scope helper can open a form the
class body never sees, and splicing into that is the same hole.

The same-scan message no longer asserts the page throws at render, which
the scan cannot guarantee for every shape it scores as unbound.

The enctype constant now has the drift guard its comment promised: the
test imports the renderer's own `PARSEABLE_ENCTYPES` and pins the
relationship, instead of hardcoding the same strings and claiming they
are kept in sync.

Doc surfaces: the dedupe key changed to code plus method plus route in
the previous commit and three surfaces still documented the pathname key,
which is the exact exhaustion that commit fixed. Four more still carried
the over-broad "an unbound form submits as a GET" claim, and three
silence lists were missing the two silences that landed with the
deliverability split.

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Traced the previous fix commit and its blast radius. The deliverability split is the right model and the dedupe rekey holds up, but the commit introduced one new false positive and left another standing, which matters more than usual here because "never false-positive" is the property the rule is sold on.

The new one is the enctype allowlist. enctype defaults to urlencoded for a missing value AND an invalid one, so enctype="nonsense" submits a parseable body and works; only text/plain actually loses it. The standing one is a template passed as a component property inside a form: lexically nested, but rendered in the receiving component's own pass, so the renderer treats it as cannot-tell and binds while the scan called it conclusively unbound.

Two places where the code and its own documentation disagree. The rule description says a cannot-tell submitter is attributed only when its file opens no form of its own, and the submitter half never got that guard; the tag half's guard reads the class body while its comment says the file, so a module-scope helper opening a form slips through. Both are the same splice hole the guard was added to close.

The doc drift is the part I would not let ride: three surfaces still document the dedupe key the previous commit changed, and they describe the pathname keying whose exhaustibility was the reason for changing it. Four more still carry the unqualified GET claim that was corrected elsewhere in the same PR, so the doc set contradicts itself.

Comment thread packages/server/src/js-scan.js
Comment thread packages/server/src/js-scan.js Outdated
Comment thread packages/server/src/check.js
Comment thread packages/server/src/check.js
Comment thread packages/server/src/check.js
Comment thread AGENTS.md Outdated
The previous commit marked a template in a start-tag hole `'none'`, which
silenced the false positive on a page and recreated it in a component
file. `'none'` is not a neutral "no enclosing form": it is specifically
the cannot-tell that the cross-module half ATTRIBUTES to the file's own
registered tag and then resolves against that tag's call sites. So a
component handing a submitter to `<my-thing .tpl=${...}>` had its button
resolved through the component's own call sites, when `<my-thing>` is
what decides where that button lands.

The two states are now distinct. `'none'` stays the attributable
cannot-tell; `'handed'` means some other element received this template,
so nothing in this file can speak for it. Every consumer already tested
for `'none'` exactly, so `'handed'` falls through to unknowable with no
other change, and there is a comment on `resolveTagScope` recording that
those exact tests are what make it work.

The same root cause reached the tag half, where a hyphenated tag inside a
property hole was recorded as this component's call site.

Both cases now have tests, and both go red with the fix neutered. The
previous test covered only the page variant, which is exactly why it
passed while the component variant was unguarded.

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Scoped to the previous fix commit and traced what it can reach. The enctype half is right, the opensForm widening is strictly more conservative, and the doc surfaces are consistent now (no remaining pathname dedupe key, no remaining unqualified GET claim anywhere in the tree).

The start-tag-hole half is not right, and it fails in a way that is easy to miss because the test written for it passes. Marking a handed-off template 'none' reads as neutral but is not: 'none' is specifically the cannot-tell that the cross-module half attributes to the file's own registered tag and resolves against that tag's call sites. On a page there is no registered tag so the fix appears to work, and in a component file the same shape is resolved through the wrong component. The test only covers the page variant, so it never exercised the path the fix was for.

That makes it a live false positive on the branch, in a rule whose stated invariant in three places is that it can never produce one.

Comment thread packages/server/src/js-scan.js Outdated
Comment thread packages/server/test/check/submitter-needs-bound-form.test.js
"A hole in a start tag is handed to the receiving element" was stated as
a universal and is not one. `<webjs-suspense .fallback=${html`...`}>` is
rendered INLINE, in the enclosing scan and with the enclosing form scope:
a TemplateResult is not serializer-safe and a hydration-time property is
too late for a streaming placeholder, so render-server renders it right
there and carries the HTML as `data-webjs-fallback`.

So a submitter in a fallback IS judged against the enclosing form, and
the renderer really does throw for an unbound one. Marking it handed off
made the same-scan half blind to a real render-time throw, which is the
one thing that half exists to pre-warn about.

The carve-out is one tag and one property name, matched case-sensitively
because a property name is a JS identifier rather than an attribute. It
is pinned by a DIFFERENTIAL test rather than an assertion about the
scanner alone, since the whole `'handed'` state is a claim about the
renderer: the test asserts the scanner's verdict AND that `renderToString`
throws for the fallback and does not for an ordinary property. An
assertion about the scanner by itself could have stayed green through
exactly this bug.

Also corrects the two places still documenting the removed universal (the
`scanHtmlFormScopes` contract and the packages/server module map), and
adds the handed-off silence to the four lists that enumerate them,
including the one `webjs check --rules` prints.

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Scoped to the 'none' to 'handed' split. The split itself is right and no false positive is reachable from it: 'handed' is tested for nowhere, every consumer uses an exact comparison, and the state can only turn a verdict into unknowable. The two new tests do go red with the fix neutered.

What is wrong is the premise it was justified with. "A hole in a start tag is a value the receiving element places" is stated as a universal, and <webjs-suspense .fallback=${…}> is the exception: the renderer renders it inline with the enclosing form scope, because a TemplateResult is not serializer-safe and a hydration-time property is too late for a streaming placeholder. I checked this against the branch's own renderer rather than reading the code, and it throws for a submitter in a fallback inside an unbound form while the scanner now stays silent. The direction is silence rather than a false positive, so it is not dangerous, but it blinds the same-scan half to the exact case that half is for.

The rest is the documentation catching up: two places still state the inheritance rule the commit removed, and four silence lists (including the one the CLI prints) do not mention the silence it added.

Comment thread packages/server/src/js-scan.js
Comment thread packages/server/AGENTS.md Outdated
Comment thread packages/server/src/check.js Outdated
The negative half asserted that rendering an ordinary `.prop` template
succeeded, and that proves nothing: it succeeds for ANY scanner verdict.
It also succeeds for a reason the surrounding comment got wrong. SSR does
not hand that template to the receiving element at all. A template
carrying a function cannot serialize, so `render-server.js` DROPS the
whole property binding with a warning and emits nothing for it, and the
submitter is never rendered or judged there. The hand-off that justifies
`'handed'` happens in the browser, at hydration, which is still exactly
what this scan cannot see.

So the assertion is now the mechanism: the button is absent from the SSR
output AND the drop warning fired. The previous version silenced that
warning, which was the one observable saying what really happened.

The suspense carve-out also changed the TAG half, where it can create
violations rather than only silence them, and nothing covered it. A tag
in a fallback is a real call site (rendered inline, inside the enclosing
form) while one in an ordinary property hole is not, and both directions
are now asserted at the scanner and through `webjs check`.

Corrects the contract in `js-scan.js` and the packages/server module map
to state that mechanism rather than the tidier version, and retitles the
scanner test, which still carried the removed universal.

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Scoped to the suspense carve-out. The carve-out is behaviourally right: the fallback is the only start-tag hole the renderer renders inline, the tag-name casing matches on both sides, and a before/after differential of the scanner over every tracked source file shows zero verdict changes in-repo.

What is wrong is the evidence around it. The differential's negative half asserts that rendering an ordinary property template succeeded, which is true for any scanner verdict, so it cannot fail for the reason the commit claims it protects against. It succeeds because a function-carrying template cannot serialize and SSR drops the binding entirely, so the submitter is never rendered or judged. That also makes the surrounding prose wrong: the hand-off is not SSR passing the template to the receiving element, it is the browser doing so at hydration. Same claim in two doc surfaces, and the test's own title still carries the universal the previous commit removed.

The gap I would not ship is the tag half. The carve-out flips a hyphenated tag inside a fallback from unknowable to a real call site, which can CREATE violations rather than only silence them, and nothing asserts it in either direction. That is the same tag-half blind spot the immediately preceding commit fixed, reappearing on the other side of the same mechanism.

Comment thread packages/server/test/scanner/html-form-scopes.test.js
Comment thread packages/server/src/js-scan.js
Comment thread packages/server/test/check/submitter-needs-bound-form.test.js
The two use a denylist and an allowlist for the same attribute, which
reads like an inconsistency waiting to be unified. It is not: they ask
different questions.

This rule asks whether the identity ARRIVES, and an invalid enctype
falls back to application/x-www-form-urlencoded, so it does. The
renderer asks whether the form does what the author wrote, and there the
invalid value is the dangerous one: a typo'd `multipart/form-dat` falls
back to urlencoded and silently drops every FILE from the submission, so
a hard render error is the only signal the author gets.

Unifying them would either teach the renderer to accept a typo that
loses uploads, or make this rule report a working form as broken. The
test asserts both sides so that change breaks something carrying its own
reason.
"A TemplateResult is not serializer-safe" is false, and it had spread to
five places. Measured against the renderer: `.tpl=${html`<b>x</b>`}`
emits `data-webjs-prop-tpl="{…}"` with no warning, so a plain
TemplateResult rides the normal property path perfectly well. Only one
carrying a FUNCTION fails to serialize, which is a different case.

The real reason `<webjs-suspense .fallback>` is rendered inline is
timing: a custom-element property applies at hydration, far too late for
a placeholder that has to be in the first flushed bytes. That reason
stands on its own and is what the comments now say. Two of the five were
pre-existing (`render-server.js` where the claim originated, and the
packages/core module map); three arrived with this PR. Comment-only, no
behaviour change.

Also corrects the three places that outlived the mechanism they
described. The comment AT the hand-off site still said a start-tag
template is "rendered in THAT component's own pass", which is the
sentence this PR already corrected in the JSDoc 180 lines above and in
the packages/server module map, left in the one place a reader of
`handOff` actually looks. Its worked example described a cannot-tell that
never happens, since nothing renders that button at SSR at all.

And the three prose silence lists claimed the start-tag-hole silence with
no exception, while the rule demonstrably fires on a submitter inside a
`<webjs-suspense .fallback>`. Each now names that exception.

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fifth and final delta round, scoped to the test-strengthening commit. No behaviour defect in it, and the two new assertions do go red when the carve-out is neutered, which is what the previous round asked for.

Everything found is prose that outlived the mechanism it described. The commit corrected the hand-off explanation in the JSDoc and the module map and left the copy at the code site, which is the one a reader of handOff actually opens, still describing SSR passing the template to the receiving element. The same sentence's worked example describes a cannot-tell that does not occur.

The one worth calling out beyond this PR: "a TemplateResult is not serializer-safe" is simply false, and it had propagated to five places, two of them pre-existing including the renderer comment where it started. A plain TemplateResult serializes and rides data-webjs-prop-*; only one carrying a function fails. The inline fallback is justified by timing alone, which is the half of the sentence that was always true.

Last, three prose silence lists say a start-tag hole is never judged, and the rule fires on a submitter in a suspense fallback. Each now names the exception.

Comment thread packages/server/src/js-scan.js
Comment thread AGENTS.md Outdated
@vivek7405

Copy link
Copy Markdown
Collaborator Author

Status: review cycle stopped at its round cap, PR deliberately left in draft

Recording where this stands so the next reader does not have to reconstruct it.

Six review rounds ran: one over the whole diff, then five delta rounds each scoped to the previous round's fix commit. Every round found something, all of it fixed, nothing rejected and nothing deferred. The chain hit the five-delta cap, so it stops here rather than running a sixth, and the last round's fixes (b19347c5) are on the branch unreviewed. That is the reason it stays a draft.

What the rounds actually found is worth separating, because the raw count reads worse than the trajectory:

Round Defects in shipped behaviour
1 (whole diff) 1 false positive on working code
Delta 1 2 false positives
Delta 2 1 (the first fix relocated a false positive rather than removing it)
Delta 3 1 silence gap
Delta 4 none
Delta 5 none

The code has been behaviourally stable for two rounds. What the last two found was my own evidence and prose: a differential whose negative half could not fail, a comment justifying the right verdict with the wrong mechanism, and a serializer claim that was simply false and had propagated to five places (two of them pre-existing in core, including the renderer comment where it started).

The pattern behind most of it, since it is the useful thing to carry forward: on a rule this inference-heavy, a test that asserts my scanner's own output tends to agree with my mistake. Four separate findings were tests of mine that passed while the thing they claimed to protect was broken. The tests that survived scrutiny are the ones that assert against the REAL renderer and go red when the fix is neutered, which is now how the load-bearing ones are written.

Regression evidence for the shared code is in a separate comment above and was re-run after every fix: rule outcomes repo-wide identical to origin/main (61 violations, 0 added, 0 removed), elision verdicts byte-identical on all three apps, zero false positives from the new rule across all tracked files.

CI has not completed a run on this branch: each push superseded the previous run before it finished. That needs to go green before this is mergeable, independent of the review state.

Two leftovers from the same sweep.

The rule description `webjs check --rules` prints is the fourth silence
list, and the one a user reads straight out of the tool (it is also what
the MCP check surface serves). It was the only one of the four still
claiming a start-tag hole is never judged, while two tests assert the
rule fires on a submitter and on a tag inside a
`<webjs-suspense .fallback>`.

And the clause added to AGENTS.md was spliced INTO the parenthetical
rather than after it, so the trailing "a reference cycle" stopped reading
as a silence-list item and started reading as something the rule judges.
That is the opposite of the behaviour, which a test pins. Closing the
list before stating the exception is what the other two surfaces already
did.

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Sixth round, scoped to the serializer-claim correction. I re-checked its central claims against the renderer rather than taking them on trust: a plain TemplateResult really does emit data-webjs-prop-tpl with no warning, one carrying a function really is dropped, and the isInlineStartTagHole carve-out matches the renderer on the spacing and casing variants I threw at it. So the correction is right.

Two leftovers from the sweep it was doing. The rule description that webjs check --rules prints is the fourth silence list, and the previous commit updated the three prose copies and missed it, so the one a user reads out of the tool is now the only one that does not name the exception two tests assert.

The other is worse than a miss. The clause added to AGENTS.md landed inside the parenthetical instead of after it, so the trailing "a reference cycle" now reads as something the rule judges rather than a silence, which is the reverse of the behaviour and of what the cycle test asserts. The two sibling surfaces avoided it by closing the list first.

Comment thread packages/server/src/check.js Outdated
Comment thread AGENTS.md Outdated

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Seventh round, scoped to the silence-list fix. Nothing to report: the rule text now names the suspense exception, the AGENTS.md list closes before the exception so the reference-cycle item reads as a silence again, and the two sibling surfaces already had it right.

Clean.

The rule reported a false positive on ordinary code, which is the one
thing its own description promises it never does.

The scan is lexical and treated EVERY `formaction=${…}` hole on a
button as an action binding. The renderer's test is narrower:
`isBoundFormAction` binds only when the value is a FUNCTION, so a plain
progressive-enhancement form whose buttons post to different `route.ts`
endpoints (`formaction=${'/api/items/' + id + '/archive'}`) is an
ordinary url attribute that renders and ships. The rule flagged it, and
told the author the page throws at render, which it does not.

The scanner now carries the hole's expression and the rule requires a
bare identifier imported from a `.server.{js,ts}` module, which is the
discriminator `form-action-not-a-get-action` already applies one rule
over. The counterfactual is a test: narrowing must not silence the real
binding, and it does not.

Two more from the same pass.

The dev client guard tested the enctype against the renderer's
ALLOWLIST, so it false-alarmed on `enctype="nonsense"`. That is the exact
inversion the scanner documents as forbidden and avoids, so the two
halves of one feature disagreed on the same input. It is a one-keyword
denylist now, and its message no longer claims a 405 for the JS path,
where the router posts FormData and the action runs.

And the bind-nothing 405 cannot report the unparseable-enctype variant:
that request arrives as `content-type: text/plain`, so it is answered by
the non-form 405 before its body is read. That early return is
deliberate, since buffering an attacker-chosen body to classify it is
what it exists to avoid, so the fix is to stop claiming the coverage.
Corrected in the comment and on the four doc surfaces that repeated it.

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Final pass over the whole diff, and it earned its place: three real problems that six delta rounds all missed, because each of them was scoped to a fix and none of them re-read the rule against ordinary app code.

The one that matters is a false positive. The scan is lexical and treated every formaction=${…} hole as an action binding, while the renderer binds only a function. So a plain progressive-enhancement form whose buttons post to different route.ts endpoints was reported as broken, and told the author the page throws at render, which it does not. That is precisely what the rule's registered description promises never happens, and the "zero hits repo-wide" evidence did not catch it because this repo happens to contain no such form.

The other two are the same feature disagreeing with itself. The dev client guard tested the enctype against the renderer's allowlist rather than the one-keyword denylist the scanner uses and documents, so the check rule and the runtime guard gave opposite answers for enctype="nonsense". And the bind-nothing 405 cannot report the unparseable-enctype variant at all, because that request is answered by the non-form 405 before its body is read, so the comment and four doc surfaces claimed coverage that does not exist.

Comment thread packages/server/src/check.js Outdated
Comment thread packages/core/src/router-client.js
Comment thread packages/server/src/form-dispatch.js
The previous narrowing was not narrow enough. "A bare identifier
imported from a `.server` module" still admits a url CONSTANT, and a
server module exports plenty of those, so `formaction=${ARCHIVE_URL}`
kept firing with the same wrong diagnosis the fix was supposed to
remove.

The rule now resolves the import to its target file and requires the
name to be exported as something PROVABLY callable: a function
declaration, or a const bound to an arrow or function expression. The
bias is deliberately the opposite of the sibling `'use server'` rules.
They tolerate a false negative, so anything ambiguous counts as
possibly-callable; here an ambiguous export firing is a false positive on
working code, so a factory (`export const go = cache(fn)`) stays silent
too.

That buys a new silence class, and it is real: a namespace import, a
default import, a barrel re-export, and any non-identifier expression
(`acts.publishDraft`, `this.act`) are bindings the rule now MISSES rather
than misreports. Silence is the safe direction, and all four are now
named in the rule's own description and in the three prose silence lists.

The fixtures needed real action modules and a `package.json` imports map
to prove any of this, since an unresolvable import is correctly silent
and a fixture without them would have passed no matter what the rule did.

Three more from the same pass. The client guard's denylist change shipped
with no test, so there is one now covering all three enctypes, plus a
drift guard pinning the client copy against the renderer's allowlist,
because there are three hardcoded copies of that keyword and the halves
disagreeing is exactly what went wrong. The guard's JSDoc still said the
action never runs, which its own new message contradicts for the JS path.
And the two module maps documented the pre-fix behaviour.
The previous commit's message said it had done this and it had not. The
resolver narrowing added a real silence class (a url string or constant,
a factory-produced export, a namespace or default import, a barrel
re-export, a non-identifier expression), and only the internal JSDoc
described it.

The registered rule description matters most here, since that is what
`webjs check --rules` and the MCP check surface print, and it is the copy
a user reads when a shape they expected to be flagged is not.
The callable test accepted a bare `(` after the `=`, and a paren proves a
parenthesized EXPRESSION rather than a function. So the url constant came
straight back in its ordinary spellings: `= ('/api/x')`, and the
env-fallback `= (process.env.X || '/api/x')` that is how anyone actually
writes one in a `.server.ts`. That is the same false positive, on the
same shape, for the third time.

It is a small scanner now instead of a regex: skip an optional `async`,
then accept a `function` keyword, or a parenthesized parameter list that
REACHES a `=>`, or a single bare parameter that reaches one. The
committed test only covered the unparenthesized url, which is why the
gap survived.

The mirror error went with it. A single-parameter arrow (`export const
publishDraft = async fd => 1`) and a clause export of a local function
(`function f(){}; export { f }`) are provably callable and were being
dropped, so real bindings went silent. Both fire now, and a clause export
of a CONST still does not.

The whole table is one test, since two rounds of defects here were each a
single spelling: four url shapes and a factory stay silent, six function
shapes fire.

Also removes a stale JSDoc block the previous commit left orphaned onto
`const byAbs`, still describing the superseded discriminator, and
corrects the function's own doc, which claimed it needs no `appDir` while
its signature takes one and its body resolves imports with it.
The previous commit swapped a regex for a regex-plus-scanner hybrid and
the seam leaked. The lazy annotation group stopped at the FIRST `=`,
which for an arrow-TYPED const is the `=` inside the annotation's own
`=>`, so the scanner resumed on `>` and every branch missed. The old
single regex could backtrack past it; the hybrid cannot.

That silenced `export const publishDraft: (fd: FormData) =>
Promise<void> = async (fd) => …`, which is the spelling this repo's
derive-the-type rule pushes authors toward, so the shape most likely to
appear in a real app was the one the rule stopped seeing. The table
committed one commit earlier had thirteen rows and not one annotated
declaration, which is exactly why it did not catch this.

The annotation is walked with bracket depth now, stepping over `=>`, so
the assignment is found wherever it really is. Four annotated rows join
the table, including the annotated NON-callable, which must stay silent.

The `(?!from)` guard on the export clause never rejected anything: `\s*`
backtracks to zero width and the lookahead then reads the whitespace, so
`export { x } from './y'` matched. The common barrel answered correctly
by accident, having no local to find, but a module that re-exports a name
AND declares a same-named local read as exporting that local, which is
not the barrel silence the four doc lists promise. It tests the text
after the clause now, with a case covering exactly that collision.
Three shapes the annotation walker got wrong, all of them ordinary.

A `;` ended the declaration at ANY depth, but `;` is TypeScript's
canonical member separator, so `: ActionFn<{ id: string; title: string }>`
carries one inside its own brackets. The bail only makes sense at depth
zero.

The operator guard rejected an `=` preceded by `<` or `>`, to avoid
reading a comparison as the assignment. In annotation position those are
brackets the same loop has already consumed as brackets, so
`: Promise<void>= async () => 1` classified one character two
contradictory ways and went silent.

And the arrow branch demanded `=>` immediately after the parameter list,
which drops every arrow carrying a RETURN type. That is at least as
common as annotating the const, and the rows added last commit annotate
only the const, which is why the table did not catch it. The return type
is walked with bracket depth now, so a nested
`: Promise<ActionResult<Draft>>` is fine.

Nine rows join the table, including the three non-callables that must
stay silent (an annotated string, an indexed object type, and the
parenthesized env fallback), since every one of these fixes moves the
line between callable and not, and that line is where every defect in
this helper has been.
Five rounds each found one more spelling this resolver got wrong, one at
a time. This sweeps the space instead: generics, a destructured
parameter, a rest parameter, a multi-line parameter list, a named
function expression, `export let`, a clause export of a local const, and
an `as` alias all bind; a template-literal url, a number, and an
expression result do not.

All of them already behave correctly. The point is that the next
refactor of the annotation walker has to keep them that way, since the
line between callable and not is where every defect in this helper has
been.
The previous commit fixed the depth-guarded separator bail on the
declaration side and reproduced the un-guarded version on the return-type
side, in the same hunk that advertised walking with bracket depth. So an
arrow whose return type contains a comma or a `;` went silent, and
`Promise<Record<string, string>>` is the fieldErrors shape the
ActionResult envelope pushes authors toward. Any two-argument generic
return type hit it.

Same one-line guard as the declaration walk.

The sweep added alongside it did not catch this, and the reason is worth
recording: it swept SPELLINGS (generics, destructuring, rest params,
multi-line, aliases) but every row happened to avoid a separator inside a
return type, so the sweep sat entirely on the passing side of the
boundary it claimed to pin. The rows added here put both separators in
both annotation positions, and they go red when the guard is neutered,
which the previous rows did not.
… rest

Two more grammar gaps, both silent misses.

The return-type walk broke on `=>` only at depth zero and otherwise fell
through, so a nested arrow's `>` was eaten by the closer branch and the
depth skewed: `: { a: () => void, b: string } =>` and the tuple form both
resolved as non-callable. It steps over a nested arrow whole now, which
is what the declaration walk already did.

And a generic arrow never reached the parameter-list branch, because that
branch required `(` immediately, so `async <T>(fd: T) => …` was dropped.
The optional type-parameter list is walked ahead of it.

The more useful change is how this was verified. Seven rounds have found
one more spelling each, and the sweeps I wrote kept sitting on the
passing side of the boundary they claimed to pin, so I mutation-tested
the resolver instead of guessing: break each guard, require the suite to
go red. Six of seven mutations are killed. Rows were added until the
generic-arrow branch was killed too, and the three the last round showed
were insensitive are replaced (one of them was a verbatim duplicate of a
row already in the sibling table).

The seventh mutation survives and stays that way on purpose: the `==` /
`!=` rejection is unreachable in valid TypeScript, so no test can pin it.
It is documented as defensive, since the input is a mask of arbitrary
source that may not be valid at all.
@vivek7405

Copy link
Copy Markdown
Collaborator Author

CI status: one green run, eight commits behind head, and why there is no newer one

Recording this so nobody reads the green check on this PR as covering what is on the branch now.

The only CI run this branch has is on b739e086 and it passed. Every commit after it has NO run at all. That is not supersession: GitHub Actions has been in a major outage since 15:22 UTC, and the incident notes say webhook triggers are throttled such that push and pull-request events fail to start workflow runs. Four branches in this repo sat queued for hours; the repo ran no job at all between 14:37 and that one completion.

I am deliberately not forcing a run. The workflow triggers only on pull_request and push to main, with no workflow_dispatch, so the options are a no-op commit or closing and reopening the PR, and while webhooks are throttled either is likely to be dropped anyway. Churning the PR to chase a run during an outage is not worth the noise. The right move is to trigger once the incident clears.

What the green run does cover, since it is not nothing: the check rule, both telemetry signals, the dev client guard, the e2e route, and the first round of review fixes were all present at that commit. What it does NOT cover is 14 commits, and specifically the formaction binding resolver in check.js (the false-positive fix and its refinements), the js-scan.js scanner changes, the suspense carve-out in render-server.js, the enctype denylist in router-client.js, and the tests added for all of them.

Locally those are covered: 3991 node tests with 6 failures that all reproduce with this branch's source reverted, the full browser suite green on all three engines, Bun parity green on both runtimes, the form-action e2e block green, and the check output repo-wide byte-identical to origin/main. That is the same set of suites CI runs, but it is my machine, not the required checks, and the branch protection gate is the required checks.

So: not mergeable yet, and the blocker is external.

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.

feat: resolve form-submitter boundness in webjs check and make the residual loud

1 participant