Skip to content

Suppress element-permitted-content FPs from unresolvable wrappers (heuristic + classic-by-name)#21

Open
johanrd wants to merge 16 commits intomainfrom
fix/fp-classic-resolver-by-name-hbs
Open

Suppress element-permitted-content FPs from unresolvable wrappers (heuristic + classic-by-name)#21
johanrd wants to merge 16 commits intomainfrom
fix/fp-classic-resolver-by-name-hbs

Conversation

@johanrd
Copy link
Copy Markdown
Owner

@johanrd johanrd commented May 8, 2026

Summary

Two cohesive fixes targeting the largest single FP class surfaced by ecosystem CI: element-permitted-content firing on PascalCase components that transparent-blank, where the wrapper is presumed to render the structurally-correct parent at runtime via a yield chain we can't statically resolve.

Builds on #17 (provides the disableForRules infrastructure used by the heuristic) and #19 (provides extractSplattedRootFromTemplate and the existing classic-addon-resolver shape) — both merged.

What's in this PR

1. Heuristic suppression in detectStructuralYieldRules

When a non-native wrapper contains content-restricted structural children (<option>, <th>, <li>, <optgroup>, <tr>, <thead>, <tbody>, <tfoot>, <caption>, <colgroup>, <col>, <legend>, <summary>), add element-permitted-content to disableForRules for the Source.

Catches HDS-style multi-level yield chains (<HdsFormSelectField as |F|><F.Options>...</F.Options>) where Glint genuinely can't pin the type — both the wrapper and its yielded sub-component declare Element: HTMLElement (bare generic → 'transparent'). Same per-Source-suppression trade-off as #17's wcag/h32 fix for input-driven forms.

Gated by Glint precision: containsContentRestrictedStructuralChild consults glintComponentTagMap first. When Glint resolves the wrapper to a precise native tag (e.g. <C.Options>'select' via #18's aliasTypeArguments reading), the heuristic bails out and lets element-permitted-content fire on real violations. Only 'transparent' (Glint succeeded but element-type is bare HTMLElement) or missing entries fall through to suppression.

2. Classic-Ember by-name resolver (lib/classic-resolver.ts)

For .hbs consumers (no JS imports, no Glint), walk PascalCase tags, kebab-case them, look up in node_modules against the canonical addon component-template paths (addon/templates/components/<name>.hbs, addon/components/<name>.hbs, app/components/<name>.hbs).

Builds a componentTagMap for the .hbs path that previously had none. Catches the ember-website <EsCard> pattern: EsCard → kebab es-cardember-styleguide/addon/components/es-card.hbs (root <li>).

Handles pnpm-style symlinks (treats them as directories — entry.isDirectory() returns false on symlinks).

Verified impact

  • ember-website/browser-support.hbs: 18 element-permitted-content findings → 0 with this PR.
  • HDS's 172 entries (mostly <option>-under-<div> from yield chains) are eliminated by the heuristic suppression.
  • ember-website's 99 entries (mostly <EsCard>-class) are eliminated by the by-name resolver.
  • discourse's 99 entries: similar mix, expected to clear most.

Test plan

  • 161/161 unit tests pass + 2 documented .fails() tests
  • Typecheck clean
  • Three regression fixtures + tests:
    • multi-level-yield-chain-options.gts (heuristic case): unresolvable wrapper containing <option> children; asserts no element-permitted-content fires.
    • classic-resolver-no-import.hbs (by-name case): .hbs consumer using a fake <ClassicCard> from a fixture node_modules; asserts the wrapper resolves to its <li> root.
    • glint-resolved-no-suppression.gts (gating regression): <C.Options> resolves to <select> via Glint, illegal <th> child placed inside; asserts element-permitted-content DOES fire (heuristic must defer to Glint when it has precision).
  • Ecosystem CI (will run on PR open): expect significant reductions on hds-design-system, ember-website, discourse baselines.

Trade-offs (documented as .fails() tests)

  • Heuristic suppression is per-Source: real bugs at OTHER locations in the same template get suppressed alongside the FPs. heuristic-masks-real-bug.gts documents a <p><div></div></p> violation masked by the same-Source suppression. Would require per-line <!-- html-validate-disable-next --> injection (complicated by multipass branching) to narrow. Same trade-off shape as Suppress wcag/h32 / wcag/h71 on yield-bearing <form>/<fieldset> #17. Acceptable given the volume (~370+ FPs across baselines vs few real-bug overlaps).
  • Namespaced classic components unsupported: <Forms::TextInput> (the :: syntax) doesn't resolve. namespaced-classic-resolver.hbs documents the limitation. Single-segment names (<EsCard>) work. Adding ::-splitting + nested-path probing is a small follow-up.
  • By-name resolver walks node_modules: per-PascalCase-tag in .hbs files. Cached per-(node_modules-root, kebab-name) pair. Negatives not cached (filesystem might gain the addon mid-session).

What's NOT in this PR

  • Cross-file yield-chain analysis: investigated and concluded unnecessary for the cases Glint can pin precisely. The suspected gap turned out to be a misdiagnosed bug in a spike's post-pass; PR Resolve Element for block-param-yielded curried sub-components #18's aliasTypeArguments reading + Glint's TS type emit already preserve Element correctly through curried block-params. Multi-level cases where Parent and yielded sub-component both declare Element: HTMLElement are genuinely outside Glint's precision today and stay on heuristic suppression.
  • Any change to existing rule configs.
  • Built-in fallback for the curried-sub-component submit-detection helper from Suppress wcag/h32 / wcag/h71 on yield-bearing <form>/<fieldset> #17 (separate concern).

…uristic + classic-by-name)

Two fixes that target the same FP class — `element-permitted-content`
firing on PascalCase components that transparent-blank, where the
wrapper is presumed to render the structurally-correct parent at
runtime via a yield chain we can't statically resolve.

1. Heuristic suppression in detectStructuralYieldRules:
   When a non-native wrapper contains content-restricted structural
   children (`<option>`, `<th>`, `<li>`, `<optgroup>`, etc.), add
   `element-permitted-content` to disableForRules for the Source.
   Catches HDS-style multi-level yield chains
   (`<HdsFormSelectField as |F|><F.Options>...</F.Options>`) where
   precise resolution would require ~250+ lines of cross-file yield-
   chain analysis. Same per-Source-suppression trade-off as Thread B's
   wcag/h32 fix.

2. Classic-Ember by-name resolver (`lib/classic-resolver.ts`):
   For `.hbs` consumers (no JS imports, no Glint), walk PascalCase
   tags, kebab-case them, look up in node_modules under the canonical
   classic-Ember component template paths. Builds a componentTagMap
   for the .hbs path that previously had none. Catches the
   ember-website `<EsCard>` pattern: `EsCard` → kebab `es-card` →
   ember-styleguide's `addon/components/es-card.hbs` (root `<li>`).
   Also handles pnpm-style symlinks (treats them as directories).

Together: clears HDS's ~107 `<option>`-under-`<div>` family, ember-
website's `<EsCard>`-class entries, plus analogous patterns across
discourse and others.

Two regression fixtures + tests:

- `multi-level-yield-chain-options.gts` (heuristic case): unresolvable
  wrapper containing `<option>` children; asserts no
  element-permitted-content fires.
- `classic-resolver-no-import.hbs` (by-name case): `.hbs` consumer
  using a fake `<ClassicCard>` from a fixture node_modules; asserts
  the wrapper resolves to its `<li>` root.

161/161 pass. Verified on real ember-website browser-support.hbs:
18 element-permitted-content findings → 0.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR reduces false positives from element-permitted-content by (1) adding a heuristic suppression when structural-only children appear under unresolvable component wrappers, and (2) adding a classic Ember “by-name” resolver for .hbs templates that maps PascalCase component invocations to their addon template roots found under node_modules.

Changes:

  • Add heuristic per-Source suppression of element-permitted-content for unresolvable wrappers containing content-restricted structural children.
  • Add lib/classic-resolver.ts and wire it into the .hbs transform path to resolve PascalCase component tags via node_modules lookup.
  • Add integration fixtures/tests covering the classic by-name resolution and multi-level yield-chain suppression behavior.

Reviewed changes

Copilot reviewed 6 out of 8 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
transform.ts Parses .hbs templates to build classic component tag/attr maps and passes them into blankTemplateContent.
blank.ts Extends detectStructuralYieldRules to suppress element-permitted-content for certain unresolvable-wrapper patterns; adds helper + tag list.
lib/classic-resolver.ts Implements a .hbs-only by-name classic resolver scanning node_modules for addon component templates and extracting their splatted-root tags/attrs.
test/integration.test.ts Adds end-to-end regression tests for classic by-name resolution and heuristic suppression in yield-chain cases.
examples/multi-level-yield-chain-options.gts Adds a fixture demonstrating the multi-level yield-chain <option> FP pattern.
test/glint-fixtures/classic-resolver-no-import.hbs Adds a .hbs fixture consuming a PascalCase component without JS imports to validate by-name resolution.
test/glint-fixtures/node_modules/classic-card-addon/package.json Adds a fake addon package used by the classic resolver fixture.
test/glint-fixtures/node_modules/classic-card-addon/addon/components/classic-card.hbs Adds the fake addon's component template root (<li ...attributes>{{yield}}</li>) used for resolution.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread blank.ts
Comment thread lib/classic-resolver.ts Outdated
Comment thread lib/classic-resolver.ts
Comment thread lib/classic-resolver.ts Outdated
Comment thread lib/classic-resolver.ts Outdated
johanrd added 2 commits May 8, 2026 09:34
Two `.fails()`-marked tests + fixtures pinning down the limitations
this PR explicitly accepts:

1. heuristic-masks-real-bug.gts — Per-Source
   `element-permitted-content` suppression. The same template has
   an unresolvable wrapper with structural children (correct
   suppression target) AND a real `<p><div></div></p>` violation
   that html-validate would normally catch. Whole-Source suppression
   masks the real bug. Asserted as `.fails()` so future yield-chain-
   precise resolution surfaces the win.

2. namespaced-classic-resolver.hbs — `<Forms::TextInput>`-style
   namespaced classic-Ember invocations. The by-name resolver
   currently handles single-segment kebab names only
   (`<EsCard>` → `es-card.hbs`); doesn't parse `::` separators or
   probe nested addon paths like `addon/components/forms/
   text-input.hbs`. `.fails()`-tagged: when namespaced resolution
   lands, vitest signals "remove .fails — your fix worked".

Both tests use the same `.fails()` mechanism the codebase has used
elsewhere (see PR #17's deferred-fix patterns) — the suite passes
today (asserted-fail = pass), the test passes for real when we
narrow / remove the limitation, vitest tells us so we update the
marker.

160/160 passing tests + 2 expected-fail = 162 total.
…isely

Verifies the gating in `containsContentRestrictedStructuralChild` defers
to Glint when it has a precise resolution. Without this gating the
heuristic over-suppresses: a real `<th>`-under-`<select>` violation
inside `<C.Options>` (resolved to `<select>` via PR #18) would be
silenced.

Lives under `test/glint-fixtures/` so the local tsconfig wires up
Glint type extraction; `examples/` has no tsconfig and Glint stays
disabled there, which would mask the gate.
johanrd added a commit that referenced this pull request May 8, 2026
Net -228 findings across three targets — exactly the
`element-permitted-content` FP class PR #21 targets:

  ember-website   125 → 31  (-94 ; e-p-c 99 → 1)
  hds-design-system 282 → 162 (-120; e-p-c 172 → 52)
  discourse       446 → 432 (-14 ; e-p-c 99 → 85)

ember-website surfaces 4 new findings (element-required-attributes,
no-implicit-close) that were previously masked by the per-Source
suppression — real signals becoming visible. Same gating-works
trade-off documented by `glint-resolved-no-suppression.gts`: when
Glint resolves precisely the heuristic stays out of the way.

Other 9 targets unchanged — they don't use the curried-yield or
classic-by-name patterns the PR fixes.
@johanrd johanrd requested a review from Copilot May 8, 2026 11:20
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 9 out of 13 changed files in this pull request and generated 5 comments.

Comment thread lib/classic-resolver.ts Outdated
Comment thread lib/classic-resolver.ts Outdated
Comment thread lib/classic-resolver.ts Outdated
Comment thread blank.ts Outdated
Comment thread transform.ts
johanrd added 3 commits May 8, 2026 13:30
When the classic-by-name resolver substitutes `<MyImg>` to `<img>`
because the addon's template has `<img src={{this.src}} ...attributes />`,
the consumer's narrow Glimmer-attr blank slots (`@src="…"`) can't fit
the projected `src='   '` placeholder via tryInjectComponentAttrs's
source-side rewrite, and `element-required-attributes` FP-fires.

Mirror PR #13's narrow-slot fix: when resolved=='img' and the addon's
attrCtx records `src`/`alt` (literal OR mustache-bound), push the
consumer's offset to `imgSplatOffsets`. The processElement hook then
calls setAttribute at parse time with a DynamicValue, sidestepping
source-width entirely.

Caught by ecosystem CI on PR #21 baseline diff: ember-website's
`<ResponsiveImage @src="…" alt="" />` started FP-firing because the
new by-name resolver mapped it to `<img>` but didn't carry the
mustache-bound `src` from the addon's template through to the
consumer-side substitution.
C1: drop unused `preprocess` import from lib/classic-resolver.ts.
    `verbatimModuleSyntax` would otherwise preserve it in emitted JS.

C2: rewrite the cache-comment block in lib/classic-resolver.ts to match
    the actual key shape — `(consumer-dir, kebab-name)`, not
    `(node_modules-root, kebab-name)`. Drop the misleading "(tests)"
    note about `_clearClassicResolverCache`; record it as exported for
    manual reset if needed.

C3: docstring for `buildClassicComponentTagMap` claimed it walks
    "PascalCase / dotted invocation". The matcher excludes dotted
    (`/^[A-Z][A-Za-z0-9]*$/`). Comment now says single-segment
    PascalCase, with examples of what's excluded.

C4: comment in `containsContentRestrictedStructuralChild` claimed only
    fully-unresolved wrappers trigger suppression. The code also falls
    through on `'transparent'`. Comment now states both cases match the
    children-check path.

C5: `.hbs` classic resolver in transform.ts called `preprocess(data)`
    directly; `blankTemplateContent` first runs
    `stripBlockParamTypeAnnotations` to handle `as |x: T|`. A typed-
    block-param `.hbs` would have silently lost classic resolution.
    Export the stripper from blank.ts and apply it before the classic-
    resolver parse so both paths agree. Practically rare (typed params
    are a `.gts/.gjs` convention) but keeps the two paths consistent.
C6 (blank.ts): `containsContentRestrictedStructuralChild` descended
    into BOTH arms of every `{{#if}}/{{else}}` regardless of which
    branch the current multipass pass emits. A structural child living
    in the inactive arm could trigger Source-wide
    `element-permitted-content` suppression and mask a real violation
    in the active arm. Pass `branchSelections` through and use
    `selectBranch` (mirrors the outer `detectStructuralYieldRules`
    walker) so only the chosen arm contributes to suppression
    decisions.

C7 (lib/classic-resolver.ts): probe order for addon component
    templates differed from `lib/glint.ts:resolveAddonHbsTemplate`
    (`addon/templates/components` → `addon/components` → `app/components`
    here vs `addon/templates/components` → `app/components` →
    `addon/components` there). Aligned the classic-by-name resolver to
    the existing import-based order so a `.gts` consumer and `.hbs`
    consumer of the same addon resolve to the same template.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 10 out of 16 changed files in this pull request and generated 3 comments.

Comment thread blank.ts Outdated
Comment thread lib/classic-resolver.ts Outdated
Comment thread lib/classic-resolver.ts Outdated
johanrd added 6 commits May 8, 2026 15:18
When `resolveComponentElement` returns 'transparent' (the component
declares `Element: HTMLElement` bare, or the type chain didn't
propagate cleanly across a barrel re-export so Glint can't pin a
specific tag), we previously left the component in 'transparent' and
let children float to whatever native ancestor exists in source.
That's the right call when we have no better information — but for a
TOC whose `<template>` literally writes `<li ...attributes>` /
`<button ...attributes>` / etc., we already extracted the splatted
root in `componentAttrMap`. Use that tag instead. The runtime DOM
matches the template-root tag, so the substitution is at least as
accurate as 'transparent' and lets `element-permitted-content`
validate the correct parent context.

Documented limitation (`leaf-element-under-list-wrapper-consumer.gts`,
`.fails()`): when a component declares `Element: HTMLAnchorElement`
(or any leaf-interactive type) but its template wraps the anchor
inside a non-leaf wrapper (`<ListItem><a ...attributes>{{yield}}</a></ListItem>`),
Glint resolves to the leaf tag (`<a>`); our substitution puts `<a>`
directly under the consumer's `<ul>` even though the runtime DOM is
`<ul><li><a></a></li></ul>`. Fixing that needs recursive cross-file
template walking — deferred.

Updated `does NOT resolve generic HTMLElement to <abbr>` test:
component template root is `<div>{{yield}}</div>`, so the resolution
now lands on 'div' (more accurate) instead of 'transparent' (the
PR #12-era conservative fallback).
…ions

When a component declares `Element: HTMLAnchorElement` (or any leaf-
interactive type), Glint's TS-only resolution gives us the LEAF tag
('a'). But if the component's `<template>` wraps that anchor inside
another wrapper:

  <template>
    <ListItem>
      <a ...attributes>{{yield}}</a>
    </ListItem>
  </template>

…then the OUTERMOST runtime element is whatever `<ListItem>` renders
(`<li>` in this case). A consumer placing this component under `<ul>`
gets `<ul><li><a></a></li></ul>` at runtime — legal — but our
substitution puts `<a>` directly under `<ul>` and
`element-permitted-content` FP-fires.

The new `lib/outer-wrapper-resolver.ts` walks the component's template
AST to find the OUTERMOST ElementNode. If native, returns its tag.
If PascalCase, resolves the local import (relative-path resolver in
the same module) and recurses. Cap depth + cycle detection.

`lib/glint.ts` calls it after Glint's resolveComponentElement and
overrides the resolved tag when the outer wrapper differs and is a
native tag. Single-substitution trade-off: the inner-content
semantics (`<button>`-under-`<a>`) are lost on the consumer's lint
pass for these wrappers; the addon's own template lint catches them
on its side. Outer-context FPs are the dominant pattern, so the
trade-off favors the wrapper.

Verified impact on real HDS files (within-package imports):
  app-footer/legal-links.gts:    5 → 0  element-permitted-content
  app-side-nav/list/index.gts:   clean

Limitation (still not handled): cross-package barrel imports — when
the consumer imports through `@hashicorp/.../components`, TypeScript's
symbol resolution doesn't always reach back to the source `.gts`
file (depends on the package's exports + declarations layout). My
local-import-only resolveComponentImport doesn't bridge package
boundaries either. That's a separate import-based fallback project.
When a consumer imports a component through a cross-package barrel
re-export (`import { X } from '@scope/pkg/barrel'`), Glint's TS symbol
resolution often doesn't reach back to the source `.gts` file through
the package's compiled declarations / pnpm-linked layout. The same-
package outer-wrapper override (commit 9d2848f) sits inside the
declFile-based code path and never gets a chance to run for those
imports.

The new import-based fallback bypasses TS:
  1. Look up the consumer's `import` statement for the component name
     (regex on the file's source — both default and named imports,
     handling `as`-aliasing in either direction).
  2. Resolve the import path:
     - Relative imports: walk filesystem from the consumer's dir.
     - Package imports: walk node_modules upward, prefer `src/<sub>`
       source files over compiled `dist/`, fall back to common
       package shapes if `src/` isn't shipped.
  3. If the resolved file is a `.ts` barrel, parse it for
     `export { default as X } from '...'` (or named re-exports) and
     follow the re-exported path.
  4. Walk the resulting `.gts/.gjs` template AST chain to find the
     outermost native ancestor (recursing through nested PascalCase
     wrappers via local imports).

The fallback runs only when the same-package override didn't get a
chance (tracked via `sameTransitivePackageOuterRan` flag set inside
the declFile branch). Override condition is the same: outer wrapper
differs from current resolution AND is a native tag.

Earlier draft gated the fallback on a hardcoded `LEAF_INTERACTIVE_TAGS`
set; dropped that — the override-only-when-different condition is
already principled, and the `sameTransitivePackageOuterRan` flag
keeps the fallback from running redundantly for cases the same-
package path already handled.

Verified impact on real HDS files (after building HDS so the
package's `src/` is reachable through the linked node_modules):
  showcase/.../base-elements.gts:        51 → 0 element-permitted-content
  app-side-nav/.../with-generic-content.gts: clean
  30-file sample across HDS components:  2 e-p-c remaining

New regression test (`cross-package-barrel-consumer.gts`) exercises
the full chain: barrel re-export → recursive template walk →
outer-wrapper override.
C8 (blank.ts + transform.ts):
  `tryInjectImgRequiredAttrsViaHook` recorded the element offset in a
  single `imgSplatOffsets` list, and the downstream `processElement`
  hook injected BOTH `src` AND `alt` for any registered offset. A
  component template that binds only `src={{this.src}}` (common) would
  silently get an `alt=DynamicValue` injected, masking `wcag/h37`
  (missing alt) when the consumer forgot to pass an alt.

  Split into per-attr offset lists: `imgSplatSrcOffsets` /
  `imgSplatAltOffsets`. Each path (`tryInjectImgRequiredAttrs` for
  native `<img ...attributes>` and `tryInjectImgRequiredAttrsViaHook`
  for component-substituted `<img>`) registers only the attrs it can
  guarantee at runtime. The hook injects per-attr.

  Native `<img ...attributes>` still registers BOTH (the splat is the
  contract — we can't statically know what the parent passes). The
  component-substituted path registers only what's in the addon's
  `attrCtx.attrs`. Updated 4 tests in `test/blank.test.ts`; new test
  asserts the per-attr precision (consumer wrote `src=` literally,
  alt is missing — `imgSplatAltOffsets` records the offset,
  `imgSplatSrcOffsets` does not).

C9 (lib/classic-resolver.ts):
  Custom `isLowercaseHtmlTag` filtered to lowercase-ASCII tag names.
  This rejected mixed-case SVG roots (`linearGradient`,
  `clipPath`, …) that `lib/glint.ts:resolveAddonHbsTemplate` accepts
  via the shared `isNativeTag`. Fix: import `isNativeTag` from
  `blank.js` and use it in classic-resolver. The earlier comment
  about avoiding circular imports was speculative — blank.ts doesn't
  import classic-resolver (the dep flows transform.ts →
  classic-resolver), so direct import is safe.

C10 (lib/classic-resolver.ts):
  `findClassicComponent` iterated `fs.readdirSync` results in
  filesystem order, which varies across OS / filesystem and would
  return different addon templates for the same kebab name on
  different platforms. Sort entries (and scoped-package entries) by
  name before probing so the resolver is deterministic regardless of
  filesystem.
Fix-busts a class of `aria-label-misuse` (and assorted other
attribute-dependent rules) FPs caused by chained component wrappers.
A component declares `Element: HTMLAnchorElement` (Glint reads → 'a');
its template wraps the anchor in another component whose template
renders `<a href={{@href}}>`; the consumer places it under a parent
that's fine with anchors-with-href but not with anchors-without-href.
Pre-fix: we extracted attrs only at the IMMEDIATE template level
(non-native PascalCase wrapper) and substituted the leaf tag without
the `href` from the inner native — html-validate then fired
`aria-label-misuse` on a "non-interactive `<a>`" that's actually
interactive at runtime.

Two changes:

1. `findOutermostElement` now descends through `BlockStatement`
   bodies (e.g. `{{#if @route}}<X>{{else if @href}}<a>{{/if}}`) and
   skips dotted/slot-named elements as wrapper candidates while
   continuing to search for a usable one. Without this, HdsInteractive-
   style templates whose top-level is conditional would fail to walk
   into either branch.

2. `OuterWrapperResolution` now carries `attrs` and `hasSplat` UNIONED
   from each level of the chain (inner wins on conflicts — closer to
   the rendered DOM). `lib/glint.ts` overrides `componentAttrMap`
   with the chain's accumulated attrs, not just the top-level
   splatted-root descriptor. The runtime `<a>` carries `href` (from
   the inner native) AND `aria-label` (from the outer wrapper's
   invocation), so html-validate's role-validity checks pass.

Verified impact on real HDS files (with HDS built so source `src/` is
reachable through pnpm-linked node_modules):
  aria-label-misuse:        38 → 5  (33 cleared; remaining are real
                                     "strictly allowed but not
                                     recommended" stylistic warnings)
  element-permitted-content: 172 → 11 (161 cleared; chain-attr
                                       collection helps here too —
                                       parent-context checks see the
                                       outer wrapper instead of the
                                       leaf)

Sampled 5 cleared findings to confirm they were FPs:
  - HdsButton invocations resolved to `<a>` without href; runtime
    DOM is `<button>` (no @href branch) or `<a href>` (with @href) —
    both interactive, both allow aria-label.
  - HdsAppFooterLink under `<ul>`: runtime is
    `<ul><li><a></a></li></ul>` (HdsAppFooterLink wraps `<a>` in
    HdsAppFooterItem which renders `<li>`) — fully legal.

New regression test (`conditional-leaf-href-consumer.gts`) asserts
the chain-attr collection at the AST level: a wrapper whose template
contains a top-level `{{#if @href}}<a href={{@href}}>{{else}}<button>`
bubbles `href` up so consumer-side substitutions register it.

Exported `literalAttrs` and `elementHasSplat` from
`lib/component-attrs.ts` so the resolver can read attrs off any
element node directly (rather than going through the splatted-root
helper which has its own selection logic).
Add a note in Known Limitations: the rule fires on every untyped
`<button>` regardless of `<form>` ancestry, per html-validate's
strict design. We don't try to soften it. Users who only want
inside-form buttons flagged can disable the rule project-wide.

Static `<form>`-ancestry detection is feasible but inherits 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). Investigated ecosystem
findings (HDS 12 cleared by chain-attr collection — pre-fix wrong-
tag substitutions; discourse 20 are real per the rule).
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 24 out of 34 changed files in this pull request and generated 5 comments.

Comment thread lib/outer-wrapper-resolver.ts
Comment thread lib/outer-wrapper-resolver.ts Outdated
Comment thread lib/outer-wrapper-resolver.ts Outdated
Comment thread lib/classic-resolver.ts
Comment thread test/integration.test.ts Outdated
johanrd added 2 commits May 8, 2026 21:51
A `<form>` whose submit button is provided by a component the static
analyzer can't pin to a native tag (no Glint Element annotation, not
in node_modules, not a builtin, dynamic-element template like
`<this.wrapperElement type={{...}}>`) used to FP-fire wcag/h32 because
the blanker couldn't see any submit candidate. Common in real Ember
codebases that use button-style component wrappers from non-addon
packages or dynamic-element addon components.

Extend `elementYieldsAndLacksSubmit` (PR #17) to also recognize
"form contains unresolved PascalCase / dotted component" as
"may contain submit." Per-Source suppression — same trade-off as the
yield-bearing-form case (real bugs at OTHER locations in the same
template get suppressed too), acceptable given the FP volume.

A previously-`.fails()` test (`namespaced-classic-resolver.hbs`)
now passes for real: the namespaced-component support is still missing
as a feature, but the unresolved-component heuristic catches the same
wcag/h32 FP. Updated the test's narrative.

Verified ecosystem impact:
  discourse wcag/h32:    41 → 10 (31 cleared)
  HDS wcag/h32:          5  → 2  (3 cleared)
  Other rules:           unchanged (no regressions on
                         element-permitted-content, aria-label-misuse,
                         no-implicit-button-type)

Heuristic detail: when iterating a form's children, an ElementNode
is treated as an unresolved component when it's PascalCase / dotted
AND not a builtin AND either has no entry in `glintComponentTagMap`
or has an entry of `'transparent'`. The `'transparent'` case matters
because Glint may resolve a component to a generic-HTMLElement-style
"transparent" tag that doesn't tell us what runtime tag actually
renders.
C11 (lib/outer-wrapper-resolver.ts):
  `preprocess(block.contents)` lacked both `stripBlockParamTypeAnnotations`
  and `mode: 'codemod'`. A `.gts` template using TS-flavored block-
  param annotations (`as |x: T|`) would throw, the resolver would
  silently skip the block, and the FPs this resolver is meant to fix
  would re-surface. Apply the same preprocess preamble blank.ts uses.

C12: drop unused `traverse` import from
  `lib/outer-wrapper-resolver.ts` (the file uses only `preprocess` —
  `traverse` was left over from an earlier draft).

C13: rewrite the docstring for `resolvePackageImport` in
  `lib/outer-wrapper-resolver.ts`. The old text claimed the function
  consults `package.json` `exports` / `main`, which it never has —
  the implementation only probes `src/<sub>` and a few bare paths.
  New docstring lists the four probe shapes explicitly.

C14: add an `isEmberAddonPackage` pre-filter to
  `lib/classic-resolver.ts`. `findClassicComponent` previously did 3
  `existsSync` probes per package per PascalCase tag, even for
  packages that obviously don't ship classic component templates
  (anything that's not an Ember addon). Skip non-addon packages by
  reading `package.json` `keywords: ['ember-addon']` or `ember-addon`
  field once, cached per-package. Reduces IO in large
  `node_modules` while preserving the resolver's behavior.

C15: update the comment on the
  `classic-resolver-mustache-bound-attrs.hbs` test to reflect the
  per-attr offset sets (`imgSplatSrcOffsets` / `imgSplatAltOffsets`)
  that replaced the original single `imgSplatOffsets` list.

Verified 168/168 tests pass; HDS / ember-website ecosystem counts
unchanged from previous commit (no regressions from the addon-pre-
filter).
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 25 out of 35 changed files in this pull request and generated 2 comments.

Comment thread lib/outer-wrapper-resolver.ts
Comment thread lib/classic-resolver.ts Outdated
johanrd added 2 commits May 8, 2026 23:02
The chain-attr collection + outer-wrapper override (a358d40, 3dbff07)
introduced new FPs in three patterns that weren't covered by the
existing tests. All surfaced when re-baselining HDS / ember-power-
select against the latest tip.

1. blank.ts: void-element block-form guard.
   When the chain resolves a block-form component invocation
   (`<G.CheckboxField>...</G.CheckboxField>`) to a VOID native (e.g.
   `<input>`, the leaf in CheckboxField's template), the rename pass
   produced `<input>...</input>` — invalid HTML (void elements can't
   have content). HDS's form-complex.gts fired hundreds of `void-
   content` + `no-implicit-input-type` for these. Guard:
   `handleGlintSubstitution` now bails to transparent-blanking when
   `resolved` is void-and-block-form. Children float to the actual
   parent's content model — same as an unresolved component.

2. lib/glint.ts: gate the same-package outer-wrapper override on the
   declaration being TOP-LEVEL. The override walks the declaration
   file's first `<template>` block. That's correct for top-level TOC
   declarations (`const X: TOC<S> = <template>`), but produces wrong
   results for INNER-SCOPE bindings — a `{{let @x as |Group|}}`
   becomes `const [Group] = ...` inside the template-to-typescript
   output, and `findComponentDeclSourceFile` resolves Group's
   declFile back to the consumer file. Walking the consumer's first
   `<template>` block returns whatever wrapper happens to be there
   (e.g. `<ul>` for a power-select-options-style recursive template),
   not what `Group` actually renders. New helper
   `isTopLevelDeclaration` walks the declaration's parents for a
   SourceFile-direct `VariableStatement` / `ClassDeclaration` /
   `FunctionDeclaration` / etc; the override only runs when the
   declaration is top-level. Surfaced by ember-power-select's
   `<Group><Options>` recursive case (let-block-param).

3. lib/glint.ts: "essentially-all-elements" union threshold. When a
   component declares `Element: HTMLElementTagNameMap[keyof
   HTMLElementTagNameMap]` (the union of EVERY HTML element type),
   `matchElementTypeToTag` was picking the first matching branch
   arbitrarily — typically `<a>` or `<h1>` — and substituting the
   component to it, cascading FPs everywhere downstream. Treat union
   sizes >= 30 as "transparent" (children float to actual parent).
   HDS's `<HdsLayoutGrid>` uses this exact pattern; the showcase
   `page-layouts/grid/sub-sections/base-elements.gts` had 98 e-p-c
   findings that all clear with this gate. Threshold of 30 is well
   above realistic per-component declarations (5-10 union members
   are typical for "this component renders one of N specific tags")
   and well below the ~110 entries in HTMLElementTagNameMap.

Verified on real HDS / power-select files post-fix:
  HDS form-complex.gts:           hundreds of void-content/missing-type → 0
  HDS grid base-elements.gts:     98 element-permitted-content → 0
  HDS legal-links.gts:            still 0 (preserved win)
  power-select options.gts:       3 element-permitted-content → 0

168/168 tests pass.
C16 (lib/outer-wrapper-resolver.ts):
  `resolveOuterWrapperTagInner` only consulted/populated the per-
  filename cache when `depth === 0`, so the same wrapper file would be
  re-read and re-parsed every time it appeared as a nested wrapper.
  In large component graphs (e.g. design-system packages where many
  components wrap shared interactive primitives) this is noticeable
  IO. The result for a given filename is deterministic — it walks
  that file's first `<template>` block and recurses through its
  wrappers — so caching at all depths is safe. The cycle guard via
  `visited` still prevents recursing back into a file currently
  being walked higher up the call stack; it's a separate concern
  from cross-call caching.

C17 (lib/classic-resolver.ts):
  `findClassicComponent` re-did a full `readdirSync` + sort + per-
  package `isEmberAddonPackage` scan of each `node_modules` directory
  on every kebab-name lookup. With many distinct PascalCase tags in
  a `.hbs` template the cost was O(packages × tags). New
  `listAddonRootsIn` cache stores the sorted, addon-filtered list of
  package roots per `node_modules` path; subsequent lookups iterate
  the cached list and only pay the per-package hbs-probe cost
  (3 `existsSync` per addon per kebab-name). Per-name result cache
  unchanged.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants