Suppress element-permitted-content FPs from unresolvable wrappers (heuristic + classic-by-name)#21
Open
Suppress element-permitted-content FPs from unresolvable wrappers (heuristic + classic-by-name)#21
Conversation
…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.
There was a problem hiding this comment.
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-contentfor unresolvable wrappers containing content-restricted structural children. - Add
lib/classic-resolver.tsand wire it into the.hbstransform path to resolve PascalCase component tags vianode_moduleslookup. - 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.
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.
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.
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).
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).
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two cohesive fixes targeting the largest single FP class surfaced by ecosystem CI:
element-permitted-contentfiring 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
disableForRulesinfrastructure used by the heuristic) and #19 (providesextractSplattedRootFromTemplateand the existing classic-addon-resolver shape) — both merged.What's in this PR
1. Heuristic suppression in
detectStructuralYieldRulesWhen a non-native wrapper contains content-restricted structural children (
<option>,<th>,<li>,<optgroup>,<tr>,<thead>,<tbody>,<tfoot>,<caption>,<colgroup>,<col>,<legend>,<summary>), addelement-permitted-contenttodisableForRulesfor 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 declareElement: HTMLElement(bare generic →'transparent'). Same per-Source-suppression trade-off as #17's wcag/h32 fix for input-driven forms.Gated by Glint precision:
containsContentRestrictedStructuralChildconsultsglintComponentTagMapfirst. When Glint resolves the wrapper to a precise native tag (e.g.<C.Options>→'select'via #18'saliasTypeArgumentsreading), the heuristic bails out and letselement-permitted-contentfire 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
.hbsconsumers (no JS imports, no Glint), walk PascalCase tags, kebab-case them, look up innode_modulesagainst the canonical addon component-template paths (addon/templates/components/<name>.hbs,addon/components/<name>.hbs,app/components/<name>.hbs).Builds a
componentTagMapfor the.hbspath that previously had none. Catches the ember-website<EsCard>pattern:EsCard→ kebabes-card→ember-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.<option>-under-<div>from yield chains) are eliminated by the heuristic suppression.<EsCard>-class) are eliminated by the by-name resolver.Test plan
.fails()testsmulti-level-yield-chain-options.gts(heuristic case): unresolvable wrapper containing<option>children; asserts noelement-permitted-contentfires.classic-resolver-no-import.hbs(by-name case):.hbsconsumer using a fake<ClassicCard>from a fixturenode_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; assertselement-permitted-contentDOES fire (heuristic must defer to Glint when it has precision).Trade-offs (documented as
.fails()tests)heuristic-masks-real-bug.gtsdocuments 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 Suppresswcag/h32/wcag/h71on yield-bearing<form>/<fieldset>#17. Acceptable given the volume (~370+ FPs across baselines vs few real-bug overlaps).<Forms::TextInput>(the::syntax) doesn't resolve.namespaced-classic-resolver.hbsdocuments the limitation. Single-segment names (<EsCard>) work. Adding::-splitting + nested-path probing is a small follow-up.node_modules: per-PascalCase-tag in.hbsfiles. Cached per-(node_modules-root, kebab-name)pair. Negatives not cached (filesystem might gain the addon mid-session).What's NOT in this PR
Elementfor block-param-yielded curried sub-components #18'saliasTypeArgumentsreading + Glint's TS type emit already preserveElementcorrectly through curried block-params. Multi-level cases where Parent and yielded sub-component both declareElement: HTMLElementare genuinely outside Glint's precision today and stay on heuristic suppression.wcag/h32/wcag/h71on yield-bearing<form>/<fieldset>#17 (separate concern).