Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
3cc1d2a
Suppress element-permitted-content FPs from unresolvable wrappers (he…
johanrd May 8, 2026
452b958
Document PR #21 trade-offs with `.fails()` regression tests
johanrd May 8, 2026
1221959
Regression test: heuristic must NOT suppress when Glint resolves prec…
johanrd May 8, 2026
24e998a
Project mustache-bound src/alt through classic-resolved <img>
johanrd May 8, 2026
09ab145
Address Copilot review feedback on PR #21
johanrd May 8, 2026
57dfde7
Address two more Copilot review comments on PR #21
johanrd May 8, 2026
289e75a
Fall back to template-root tag when Glint resolves to 'transparent'
johanrd May 8, 2026
9d2848f
Recursive outer-wrapper resolver overrides leaf-interactive substitut…
johanrd May 8, 2026
3dbff07
Import-based outer-wrapper fallback for cross-package barrels
johanrd May 8, 2026
5dbe339
Address Copilot review (round 2): C8/C9/C10
johanrd May 8, 2026
a358d40
Chain-attr collection in outer-wrapper resolver
johanrd May 8, 2026
521f80c
Document position on no-implicit-button-type
johanrd May 8, 2026
7e3d498
Suppress wcag/h32 when form contains an unresolved component child
johanrd May 8, 2026
f8d4b57
Address Copilot review (round 3): C11/C12/C13/C14/C15
johanrd May 8, 2026
6542a1b
Fix three regressions surfaced during ecosystem re-baseline
johanrd May 8, 2026
f6682d7
Address Copilot review (round 4): C16/C17 cache perf
johanrd May 8, 2026
a1c28bb
Suppress element-permitted-content/parent on resolved curried-child w…
johanrd May 8, 2026
8f5939d
Address Copilot review (round 5): C18/C19/C20
johanrd May 8, 2026
fd7fb2a
Extend wcag/h32, wcag/h71, and form-submit suppression
johanrd May 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,11 @@ The bundled `validate-gts` CLI dedupes identical messages by `(line, column, rul
## Known limitations

- **Static-string scope.** `{{NAME}}` resolves against same-file `const NAME = '...'` declarations and one-level-deep `import { NAME } from './sibling'` (relative paths only — package and path-aliased imports are skipped). `{{this.field}}` resolves against same-file class-field initializers (`field = '...'` or `field: T = '...'`). What's not resolved: transitive re-exports (`export { X } from './...'` chains), default imports, namespace imports, and getters returning literals — Glint narrows some of these to string-literal types when `--glint` is on, which the blanker picks up through a separate code path.
- **`no-implicit-button-type` fires on every untyped `<button>`** regardless of `<form>` ancestry — that's html-validate's strict design (default `type=submit` is non-obvious). The plugin doesn't try to soften it; if you'd rather only flag buttons that actually live inside a `<form>` at runtime (where the default-submit matters), disable the rule project-wide and rely on review / a custom lint:
```json
{ "rules": { "no-implicit-button-type": "off" } }
```
Static "is this `<button>` inside a `<form>`?" detection is feasible in principle (walk ancestors at the AST + chain into PascalCase wrappers) but adds the same per-Source-suppression caveat as `wcag/h32`: a button inside a wrapper component that someone else's template wraps in `<form>` would be silenced in the wrong direction. We left it untouched.
- **TS-flavored block-param types are stripped, not parsed.** `@glimmer/syntax`'s parser doesn't understand `{{#each items as |item: T|}}`-style annotations (or the comma separators that come with multi-param lists). The transformer pre-strips them to whitespace before Glimmer parses, with balanced-bracket scanning so unions (`A | B`), object types (`{ a: number }`), parenthesized types (`(A | B)[]`), generics (`Map<string, number>`), arrays (`T[]`), and qualified names (`NS.Type`) all work. Length-preserving — AST offsets after the strip match original source. The strip only operates inside `as |…|` ranges of mustache openers; type literals appearing elsewhere in the template aren't touched (and Glimmer wouldn't accept them there anyway).

## Future work
Expand Down
330 changes: 310 additions & 20 deletions blank.ts

Large diffs are not rendered by default.

41 changes: 41 additions & 0 deletions examples/form-with-unresolved-component-submit.gts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// `<form>` containing an UNRESOLVED PascalCase component that
// (presumably) provides the submit button at runtime. The component's
// source isn't Glint-typed (no @Element annotation), isn't in
// node_modules where the classic-by-name resolver looks, and isn't a
// builtin — so `componentTagMap` has no concrete native tag for it.
//
// At runtime, button-style components (e.g. addon-shipped `<XButton
// @type="submit">`) render real `<button>`s, but our static blanker
// can't see that — especially when the component's template uses a
// dynamic-element pattern (`<this.wrapperElement type={{...}}>`).
// Without this heuristic wcag/h32 would FP-fire because the form
// looks empty of submit candidates from the validator's view.
//
// Heuristic: when a `<form>` contains an unresolved PascalCase
// component AND lacks a statically-detectable submit, the rule
// suppresses for the Source — the component MIGHT render submit at
// runtime. Same per-Source-suppression trade-off as the
// yield-bearing-form case (PR #17): real bugs at OTHER locations in
// the same template get suppressed too. Acceptable given the volume
// of these FPs in real-world Ember code.
import Component from '@glimmer/component';

interface Sig {
Element: HTMLFormElement;
Args: { onSubmit: () => void };
Blocks: { default: [] };
}

export default class FormWithUnresolvedComponentSubmit extends Component<Sig> {
handleSubmit = (e: Event): void => {
e.preventDefault();
this.args.onSubmit();
};

<template>
<form {{on 'submit' this.handleSubmit}} ...attributes>
<input type="text" name="email" />
<SubmitWidget @label="Save" />
</form>
</template>
}
64 changes: 64 additions & 0 deletions examples/heuristic-masks-real-bug.gts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// Documents a trade-off: the per-Source `element-permitted-content`
// heuristic suppression (PR #21) masks REAL `element-permitted-content`
// violations in OTHER parts of the same template. Same trade-off shape
// as PR #17's wcag/h32 suppression for input-driven forms.
//
// This template has:
// 1. An unresolvable wrapper with structural children (triggers
// suppression — fine, FP avoidance).
// 2. A genuine spec violation elsewhere (`<p><div></div></p>` —
// `<p>` is phrasing content, can't contain `<div>`). With the
// whole-Source suppression, html-validate's
// `element-permitted-content` doesn't fire on this either.
//
// The accompanying test in `test/integration.test.ts` is marked
// `it.fails(...)` — it asserts the real bug fires, and when (if) we
// ever implement multi-level yield-chain analysis, the heuristic
// suppression will narrow / disappear, the real bug will surface,
// and vitest will signal "remove .fails — your fix worked".
import Component from '@glimmer/component';

interface PassThroughSig {
Element: HTMLElement;
Args: Record<string, never>;
Blocks: { default: [] };
}
class PassThrough extends Component<PassThroughSig> {
<template>
{{yield}}
</template>
}

interface FormSelectFieldSig {
Args: Record<string, never>;
Blocks: { default: [{ Options?: typeof PassThrough }] };
Element: HTMLElement;
}
class FormSelectField extends Component<FormSelectFieldSig> {
<template>
{{yield (hash Options=PassThrough)}}
</template>
}

<template>
<div>
{{! 1. Heuristic-suppression target (curried sub-component
containing <option> children — wrapper presumed to render
<select> via yield chain). }}
<FormSelectField as |F|>
<F.Options>
<option value='one'>One</option>
</F.Options>
</FormSelectField>

{{! 2. Genuine spec violation: <p> is phrasing content, can't
contain <div>. html-validate would normally flag this — but
our heuristic disables `element-permitted-content` for the
whole Source, masking it. }}
<p>
<div>this should fire element-permitted-content but doesn't</div>
</p>
</div>
</template>

const hash = <T,>(o: T): T => o;
55 changes: 55 additions & 0 deletions examples/multi-level-yield-chain-options.gts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Mirrors the unresolvable-yield-chain pattern in HDS's
// `<HdsFormSelectField as |F|><F.Options>...` — a curried sub-
// component (`F.Options`) yielded through multiple levels where the
// final native parent (`<select>`) lives in an inner component's
// template.
//
// To make the FP actually fire here, the outer wrapper has bare
// `Element: HTMLElement` (not a specific tag) so PR #12 resolves it
// as 'transparent'. Then everything floats out: <option> ends up
// directly under <div>, and `element-permitted-content` fires.
//
// Resolving this precisely would require recursive cross-file yield-
// chain analysis (~250+ lines, deferred). The pragmatic fix is
// heuristic suppression: when an unresolvable component invocation
// contains structural children (`<option>`/`<th>`/`<li>`) that would
// be invalid under the actual outer parent, the plugin presumes the
// wrapper is structurally-rendering and suppresses
// `element-permitted-content` for the Source. Same per-Source
// suppression trade-off as Thread B's wcag/h32 fix.
import Component from '@glimmer/component';

interface PassThroughSig {
Element: HTMLElement;
Args: Record<string, never>;
Blocks: { default: [] };
}
class PassThrough extends Component<PassThroughSig> {
<template>
{{yield}}
</template>
}

interface FormSelectFieldSig {
Args: Record<string, never>;
Blocks: { default: [{ Options?: typeof PassThrough }] };
Element: HTMLElement;
}
class FormSelectField extends Component<FormSelectFieldSig> {
<template>
{{yield (hash Options=PassThrough)}}
</template>
}

<template>
<div>
<FormSelectField as |F|>
<F.Options>
<option value='one'>One</option>
<option value='two'>Two</option>
</F.Options>
</FormSelectField>
</div>
</template>

const hash = <T,>(o: T): T => o;
Loading
Loading