Skip to content

recommended: disable noisy stylistic rules; demote prefer-native-element - #47

Merged
johanrd merged 1 commit into
mainfrom
presets-signal-first
Aug 28, 2026
Merged

recommended: disable noisy stylistic rules; demote prefer-native-element#47
johanrd merged 1 commit into
mainfrom
presets-signal-first

Conversation

@johanrd

@johanrd johanrd commented May 22, 2026

Copy link
Copy Markdown
Owner

Makes :recommended signal-first: surface content-model bugs, a11y issues, and missing required attributes — not stylistic preferences that fire on legitimate Ember/Glimmer code.

Changes

  • no-inline-style: off — bans the style= attribute, which breaks legitimate runtime style-binding (<div style={{this.computedStyle}}>). Use a separate stylelint pipeline if you want inline-style policy.
  • void-style: off — html-validate defaults to omit, Ember/Glimmer convention is selfclosing, and mixing is harmless. Previously :gts-recommended forced selfclosing, which fought projects using omit. Now disabled entirely; enforce via ember-template-lint if you want a house style.
  • prefer-native-element: warn (was error) — real a11y signal (<div role="button"> should usually be <button>), but design systems intentionally wrap generic elements with role + keyboard handling. Demoted so it surfaces without failing builds; promote back to error per-project if desired.

:recommended and :gts-recommended now resolve to identical rule sets. The :gts-recommended alias stays for backwards compatibility, so existing consumers' extends keep working.

Context

Same spirit as the discussion in #38 (don't make :recommended pedantic) — though #38 is specifically about technique-based WCAG rules (e.g. wcag/h63), while this PR addresses stylistic rules.

Evidence from the ecosystem CI

The ecosystem-CI harness (#24) has to suppress these exact three rules in its own config (ECOSYSTEM_RULE_OVERRIDES) to keep the regression signal readable across 12 real-world Ember repos — they generate a large share of the noise on otherwise-legitimate code. That volume is the concrete argument for this change: if :recommended makes real-world Ember projects that noisy out of the box, the default is mis-calibrated. If this PR lands, the ecosystem override becomes redundant and can be dropped.

Notes

  • Behavior change for every consumer of :recommended / :gts-recommended — needs an appropriate release-plan label.
  • Plugin suite green (288 passed, 1 expected fail).

Goal: ":recommended" should give "pure value" — content-model bugs,
a11y issues, missing required attributes — not pedantic style
preferences. Trim the html-validate-recommended noise that fires on
legitimate Ember/Glimmer patterns:

- `no-inline-style: off` — bans `style=` attribute. Breaks legitimate
  runtime style-binding (`<div style={{this.computedStyle}}>`). Use a
  separate stylelint pipeline for inline-style policy if needed.

- `void-style: off` — html-validate defaults to omit, Ember/Glimmer
  convention is selfclosing, mixing is harmless. Previously the
  `:gts-recommended` preset overrode to `selfclosing`, which fought
  projects using the omit form. Now disabled entirely; teams enforce
  via `ember-template-lint` if they want that policy.

- `prefer-native-element: warn` — kept on but demoted from error.
  Real a11y signal (`<div role="button">` should usually be
  `<button>`), but design systems intentionally wrap generic elements
  with role+keyboard handling. Surfaced as a warning so it doesn't
  fail builds; teams can promote back to error per-project.

`:recommended` and `:gts-recommended` now resolve to identical rule
sets. The alias stays for backwards compatibility — existing
consumers' `extends` keep working.

151/151 pass.
@johanrd
johanrd marked this pull request as draft May 22, 2026 09:41
johanrd added a commit that referenced this pull request May 22, 2026
The void-style / no-inline-style / prefer-native-element suppressions live
in the ecosystem runner's config (ECOSYSTEM_RULE_OVERRIDES) instead of the
plugin's shipped :recommended / :gts-recommended presets, which return to
their original form. Net rule set applied to the targets is unchanged, so
baselines are untouched (verified: ember-modifier, super-rentals,
ember-primitives all clean vs baseline). Whether the plugin's recommended
set should adopt the same suppressions is tracked in PR #47, not here.
@johanrd
johanrd marked this pull request as ready for review August 28, 2026 08:41
@johanrd
johanrd merged commit f8f4454 into main Aug 28, 2026
2 checks passed
@github-actions github-actions Bot mentioned this pull request Aug 28, 2026
@johanrd johanrd added the enhancement New feature or request label Aug 28, 2026
johanrd added a commit that referenced this pull request Aug 28, 2026
… repos (#24)

* ecosystem CI scaffolding + super-rentals triage

Pinned-snapshot validation against 10 public Ember repos (super-rentals,
ember-website, ember-power-select, ember-modifier, ember-simple-auth,
aria-voyager, ember-a11y-testing, hashicorp/design-system,
ember-primitives, NullVoxPopuli/limber). Each target lives in
`ecosystem/targets.json` with a pinned SHA, glob, and per-target Glint
flag. The runner clones, installs deps (best-effort, lockfile-driven),
validates with the same `:gts-recommended` config that `validate-gts`
uses, and diffs against committed baselines. A non-empty diff fails the
workflow.

`ecosystem/run.ts` is single-file, runs via `tsx`, no heavy deps. Glint
extraction is gated by `glint` flag per target (default true) and falls
back to no-Glint when install fails so the run continues.

Per-target triage docs live under `ecosystem/triage/`:
  - `<name>/triage.md` — every finding classified
  - `<name>/issue.md` — draft to file upstream when warranted
  - `FP-FIX-REPORT.md` — aggregate FPs across targets
  - `STYLISTIC-FINDINGS.md` — input for default-policy review

super-rentals triaged. 11 TP, 2 FP-plugin (img splat → required-attr
missing), 0 FP-rule. The 2 FPs are the first entry in FP-FIX-REPORT.

`vitest.config.ts` scopes test discovery to `test/**` so cloned repos'
test files don't get picked up. `tsconfig.test.json` typechecks
`ecosystem/**`.

* triage: ember-modifier (TP=0) + ember-simple-auth (TP=6 FP=0)

ember-modifier has a single trivial template (`{{outlet}}`) — no
findings; kept as a smoke target.

ember-simple-auth surfaces 6 real findings in the demo test-app, all
a11y-related: 3× anchor-as-button (`<a role='button' href='#'>`
pattern), 1× missing autocomplete on password input, 1× form without
submit, 1× implicit button type. No FPs.

* triage: ember-a11y-testing (TP-intentional=4 FP-plugin=4)

The demo app contains deliberate a11y fixtures (`violations.gts`,
`ignored-image-alt.gts`); html-validate's reports on those are correct
but by-design — marked TP-intentional, no upstream issue.

The 4 element-permitted-content findings are all one root cause:
`ViolationsGridItem` is a TOC declared via
  `<template>...</template> satisfies TOC<{ Element: HTMLLIElement }>`
which our Glint resolver doesn't recognize (works for `class extends
Component<Sig>` but not the TOC `satisfies` form). Without resolution,
the children float to <ul> instead of being correctly seen as inside
<li>.

New entry in FP-FIX-REPORT for the TOC-Element-resolution gap.

* triage: ember-primitives (TP=3 stylistic=5 strictness=1 FP-plugin=4)

13 findings on 58 files.

Real bugs (3): non-semantic heading `<div role="heading">` should be
`<h*>`; non-semantic `<div role="progressbar">` should be `<progress>`;
`readonly` on `<input type="radio">` is invalid (use `disabled`).

Stylistic (5): inline styles in docs landing, region-as-div in
accordion content. Tracked in STYLISTIC-FINDINGS for default-policy
review.

Strictness (1): `<style>` inside `<fieldset>` for SR-only CSS;
debatable per spec.

FP-plugin (4): wcag/h32 on `<form ...attributes>{{yield}}</form>` and
wcag/h71 on `<fieldset ...attributes>{{yield ...}}</fieldset>`. The
plugin blanks the yield and html-validate sees empty body. At runtime
the consumer provides the required submit-button / legend. New entry
in FP-FIX-REPORT — same shape as multipass yield-only-branch but for
non-multipass templates.

* triage: aria-voyager (intentional=13 — all `<div role="listbox">`)

All 13 findings are prefer-native-element on `<div role="listbox">`
patterns in rendering tests. The addon's whole purpose is providing
listbox/menu/tablist keyboard+ARIA behavior on non-native elements;
replacing with <select> would defeat the tests. No issue to file, no
plugin fix.

Triage doc notes this is an applicability question (not FP, not
stylistic-default) — addons that enhance ARIA-pattern widgets should
disable prefer-native-element in their tests. Worth a "recommended
overrides per project type" section in the plugin README.

* triage: ember-power-select (TP=27 stylistic=6 FP-plugin-suspect=1)

34 findings on 114 files. Three real-bug clusters:

- 12× <div role="button"> tabs in code-example.gts (anchor/div-as-
  button anti-pattern, or arguably should be role="tab" since they're
  inside <nav class="code-example-tabs">).
- 14× misnested <p> containing <ul>/<dl> (7 implicit-close + 7
  stray-</p> pairs, same root cause).
- 1× <br> inside <ol> for visual spacing (should be CSS).

6× no-inline-style in demo placeholders — tracked stylistic.

1× FP-plugin-suspect: html-validate reports <div> not permitted under
<abbr> at power-select.gts:1443 but the file has no literal <abbr>.
A component's Signature['Element'] = HTMLElement (the generic) is
likely being resolved by our plugin to "abbr" via a fallthrough in
the element-class → tag mapping. Added to FP-FIX-REPORT for scoped
investigation.

* triage: limber (TP=28 stylistic=10 strictness=1 FP-plugin=0)

39 findings on 67 files. No new FP-plugin patterns — all real bugs or
stylistic.

Real bugs (28):
- 20× misnested <p> containing <ul>/<dl> (10 blocks, two findings
  each — same root cause as ember-power-select's docs).
- 2× aria-label on <span> with no role (silently ignored by ATs).
- 2× unlabeled <footer> landmarks in same component (unique-landmark).
- 2× <input> missing type, 1× <button> missing type, 1× wcag/h32 form
  without submit.

Stylistic (10): no-inline-style across repl/components and templates.
no-inline-style is now 20 across 3 targets — strong default-off
candidate per STYLISTIC-FINDINGS.

Strictness (1): <style> inside <label> in apps/tutorial selection.gts.
Same shape as ember-primitives <style>-in-<fieldset>; defensible in
practice (component-scoped CSS).

* triage: ember-website (TP=17 stylistic=163 FP-plugin=98)

279 findings on 73 files; the largest target after HDS.

Stylistic cluster (163) dominated by 153× void-style — the site's
.hbs templates use omitted void-element form (<br>) over self-closing
(<br />). Plugin's :gts-recommended forces selfclosing. After this
target void-style is the largest stylistic cluster across all
ecosystem targets — strong default-off candidate.

Real bugs (17): <image> typo for <img>, 6× form-dup-name on commission
form, 4× obsolete IE conditional comments, 2× deprecated attrs
(frameborder, align), 1× misnested <p> with <ul>, 1× aria-labelledby
on element that doesn't accept it, 1× <pre> in <code>, 1× ad-hoc
inline style.

FP-plugin (98): classic Ember addon component <EsCard> from
ember-styleguide renders <li> but our plugin can't resolve the root
tag without JS-side type info — its template is .hbs (no Signature,
no `satisfies TOC`). Transparent-blanking floats children to the
parent <ul> and element-permitted-content fires.

New entry in FP-FIX-REPORT for the classic-addon-template-resolution
gap. Substantial extension of existing splatted-root resolution to
walk .hbs files.

* triage: hds-design-system (TP=~52 stylistic=9 FP-plugin=~217)

303 findings on 859 files. Per-finding triage at this scale is
infeasible; classified by-rule + by-pattern.

By far the largest cluster is plugin-side limitations (~70%):

- 107× <option> under <div> — yielded curried sub-components like
  `<HdsFormSelectBase as |C|><C.Options>...</C.Options>` lose Glint
  resolution. New entry in FP-FIX-REPORT for yielded-curried-component
  resolution.
- 39× <iframe> missing title — `<ShwFrame @label={{...}}>` provides
  title via arg-binding; plugin's splatted-root extractor only handles
  literal values. New entry for arg-bound-attribute resolution.
- 25× <div> under <ul> — same EsCard-class addon-list-item pattern as
  ember-website (now bumped on existing entry).
- 8× <div> under <abbr> + cascading abbr-flagged children — bumps the
  ember-power-select abbr-mystery entry significantly.
- 10× wcag/h32 + wcag/h71 on yield-only HdsForm/HdsFormFieldset (bumps
  the ember-primitives form/fieldset entry).

Real bugs (~52): 14× <div> in <button>, 5× <input> in <a>, 2× nested
<a>, 28× prefer-native-element, 6× implicit button-type, 8× aria-label
misuse warnings, plus misc.

Notable defensive pattern: 15× role="list" on <ul> — html-validate
flags as redundant but it's a deliberate Safari/VoiceOver fallback for
when list-style:none strips the implicit list role. Marked TP-rule-
context (correct per spec, intentional in practice).

issue.md is selective — filing 303 on HDS is unrealistic; surfaces
the ~10 highest-value real findings.

* triage: add Phase 1 summary to FP-FIX-REPORT

* add overlap discussion in readme and add an ECOSYSTEM-OVERLAP.md

* ecosystem: add 7 new targets (ember-concurrency, warp-drive, ember-intl, vertical-collection, ember-resources, cardstack-ui-components, discourse)

* ecosystem: re-baseline against post-fix plugin + seed 7 new targets

Merged combined-fp-fixes into ecosystem-ci, reseeded all 17 baselines
against the latest plugin so the diff isolates the FP-fix impact.

Existing 10 targets:
  super-rentals          -1 element-required-attributes (img splat FP, PR #13)
  ember-primitives       -2 wcag/h32 (yield-only form FP, PR #17)
  hds-design-system      -2 wcag/h71 (yield-only fieldset FP, PR #17)
                         +1 prefer-native-element (newly visible after
                            other fixes resolve a previously-blanked
                            region)
  others (7)             unchanged

7 new targets seeded (file count / finding count):
  ember-concurrency       56 / 32   (element-permitted, no-inline-style)
  warp-drive               8 /  0
  ember-intl              79 /  0
  vertical-collection     14 / 10   (no-implicit-button-type)
  ember-resources          3 /  0
  cardstack-ui-components 31 / 21   (void-style, no-implicit-button-type)
  discourse             1309 / 495  (Glint disabled — too heavy to
                                     install; mostly TPs in legacy .hbs)

Baselines reflect current findings; CI guards against future regressions.

* ecosystem: hds form/index.gts wcag/h32 cleared after directive fix

Re-baseline after the comma-vs-space fix in transform.ts. The HDS
`form/index.gts:81` wcag/h32 was the canonical multipass case that
exposed the bug — both arms of `{{#if (eq this.tag "form")}}` carry
yield, so multipass injects a directive carrying both
`no-unused-disable` and `wcag/h32`. With space-separated rules only
the first was disabled and h32 leaked through; comma-separated now
suppresses both correctly.

No other baselines moved.

* recommended: disable noisy stylistic rules; demote prefer-native-element

Goal: ":recommended" should give "pure value" — content-model bugs,
a11y issues, missing required attributes — not pedantic style
preferences. Trim the html-validate-recommended noise that fires on
legitimate Ember/Glimmer patterns:

- `no-inline-style: off` — bans `style=` attribute. Breaks legitimate
  runtime style-binding (`<div style={{this.computedStyle}}>`). Use a
  separate stylelint pipeline for inline-style policy if needed.

- `void-style: off` — html-validate defaults to omit, Ember/Glimmer
  convention is selfclosing, mixing is harmless. Previously the
  `:gts-recommended` preset overrode to `selfclosing`, which fought
  projects using the omit form. Now disabled entirely; teams enforce
  via `ember-template-lint` if they want that policy.

- `prefer-native-element: warn` — kept on but demoted from error.
  Real a11y signal (`<div role="button">` should usually be
  `<button>`), but design systems intentionally wrap generic elements
  with role+keyboard handling. Surfaced as a warning so it doesn't
  fail builds; teams can promote back to error per-project.

`:recommended` and `:gts-recommended` now resolve to identical rule
sets. The alias stays for backwards compatibility — existing
consumers' `extends` keep working.

151/151 pass.

* ecosystem: re-baseline after :recommended rule-trim

Removed 329 baseline entries across 11 targets (0 new findings) by
disabling stylistic rules from html-validate's recommended preset that
fire on legitimate Ember/Glimmer code without catching real bugs:

  void-style              -170  (disabled — Ember mixes both styles)
  no-inline-style          -87  (disabled — runtime style-binding pattern)
  prefer-native-element    -72  (demoted to warn — surfaces in lint
                                 output, doesn't fail builds, design
                                 systems can intentionally wrap divs)

The 5 already-removed FPs from the plugin fix-branches (super-rentals
img splat, ember-primitives 2x wcag/h32, hds 2x wcag/h71) plus the
hds form/index.gts:81 wcag/h32 cleared by the directive comma fix
all stayed cleared.

* ecosystem: record severity (error|warning) on each baseline finding

The runner previously skipped any file where `report.valid === true`,
which silently dropped warning-only files from the baseline — every
`prefer-native-element` warning on an otherwise-clean file was
invisible to CI, and the next run that introduced a warning to such
a file wouldn't show in the diff.

Changes:
- `Finding` interface gains a `severity: 'error' | 'warning'` field
  derived from html-validate's numeric `m.severity` (1=warn, 2=error).
- Drop the `if (report.valid) continue;` early-out so warning-only
  files reach the recording loop.
- `findingKey` and the sort comparator include severity, so a finding
  whose severity flips between runs counts as a real diff.
- Per-target stderr summary now reads
  `findings: N (errors: X, warnings: Y)`.
- `summarizeFindings` prefixes each line with `[severity]`.

All 17 baselines re-seeded. Net delta vs the previous baselines:

  aria-voyager           +13 warnings (previously fully skipped:
                          0 errors → report.valid → file dropped)
  cardstack-ui-components +1
  discourse              +33 (49 warnings now; 16 were on
                          mixed-error files and already recorded)
  ember-concurrency       +1
  ember-power-select     +12 (warnings on previously-clean files)
  ember-primitives        +3
  ember-simple-auth       +3
  hds-design-system      +10 (29 warnings now; 19 previously on
                          mixed-error files)

No error-severity findings changed (the rule trim already settled
those). Warnings-only files now visible to baseline diffs.

* ecosystem: drop noise-only targets (aria-voyager, ember-resources, ember-intl, warp-drive)

These four were noise-only against the trimmed ruleset:

  aria-voyager   13 warnings, all `<div role="listbox">` patterns
                 — intentional ARIA wrappers, not bugs
  ember-resources    0 errors, 0 warnings
  ember-intl         0 errors, 0 warnings
  warp-drive         0 errors, 0 warnings

Three were always-clean (no signal to guard against), one is
intentionally-different (signal we'd never want to fix). Removing
them shrinks the per-PR CI runtime, focuses the baseline diff on
targets where regressions actually matter, and removes the
"baseline at zero forever" noise from PR review.

Down from 17 → 13 targets.

* ecosystem: drop ember-a11y-testing — intentional violations, not regressions

ember-a11y-testing's `demo-app/` exists to demonstrate what its
runtime a11y-audit catches. The 8 baseline findings all sit on
`templates/violations.gts` (empty button, labelless input, <img>
without alt, <blink>, <button>/<input> directly under <ul>) and
`templates/ignored-image-alt.gts` (`<img src="" />`) — both files
exist BECAUSE they're broken, fixing them defeats their purpose.

Findings are correct (rule fires, violation is real); the file
purpose makes them noise for our CI. Same category as the targets
removed earlier (aria-voyager, ember-resources, ember-intl,
warp-drive). Down from 13 → 12 targets.

* Invalidate disk cache when plugin source files change

The disk cache (lib/cache.ts) keyed entries by:
  - `package.json` `version` field
  - tsconfig content SHA
  - consumer file content SHA

In a development workflow that's not enough — modifying
`lib/glint.ts` or `blank.ts` between local ecosystem-check runs
doesn't bump `version`, doesn't change consumer files, doesn't
change tsconfigs, so the cache returns stale results. Surfaced
during ember-power-select triage: the abbr-FP from PR #12 (bare
`HTMLElement` resolves to `<abbr>`) was reported as still firing,
but the underlying resolution code had been fixed long ago — the
cache was just feeding back the old result.

Added a `pluginSourceSha` field to each cache entry: SHA of the
plugin's core extraction-affecting source files (transformer,
blanker, Glint integration, shared helpers). Computed once at
module load. Read-side: any mismatch between stored and current
`pluginSourceSha` invalidates the entry, same as a tsconfig or
file-content mismatch.

Both `.ts` (source / dev) and `.js` (built / shipped) candidates
are tried per file; missing entries skipped silently — so the
hash works in either layout.

Trade-off: a 16-char hash adds a few bytes per cache entry; module
load reads ~10 small files once. Negligible vs. the cost of
incorrect cache hits masking real plugin changes.

New regression test asserts a corrupted `pluginSourceSha` in the
stored entry causes a read miss. 128/128 pass.

* ecosystem: re-baseline with fresh caches + latest fix tips

Re-merged combined-fp-fixes into ecosystem-ci with all latest fix
tips (PR #13 narrow-slot, PR #15 stale-comment cleanup, PR #17
Thread B + fieldset-with-component, PR #19 native-tag guard,
cache-invalidation-on-source-change). Cleared all target caches
before re-baseline so cache-stale findings (e.g., the abbr-FP from
power-select pre-PR-#12) re-resolve correctly.

Per-target deltas:

  super-rentals    -2 errors (img wcag/h37, form wcag/h32 cleared
                   by Thread A + Thread B)
  ember-primitives -1 error  (2 wcag/h71 cleared by fieldset-with-
                   component-content; +1 no-implicit-button-type
                   newly visible on tabs.gts:247 — TabButton now
                   resolves to <button>)
  ember-power-select -1 error -1 warning (abbr-FP cleared by cache
                   fix re-resolving via PR #12; one trigger.gts
                   prefer-native warning gone for the same reason)
  hds-design-system -10 errors (much shuffling: -39 element-
                   required-attributes from img-splat fix, -19
                   element-permitted-content, +31 aria-label-misuse
                   newly visible, +6 no-implicit-button-type)
  limber           +1 error  (Editor iframe missing title — newly
                   visible after better resolution; needs
                   investigation as potential plugin gap)

Total errors across all targets: −13.

* ecosystem: re-baseline on top of PR #21 (heuristic + classic-by-name)

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.

* ecosystem: clear 2 ResponsiveImage FPs from ember-website baseline

After 24e998a (project mustache-bound src/alt through classic-resolved
<img>), the two `<ResponsiveImage @src="…" alt="" />` entries that
fired `element-required-attributes` are gone:

  app/components/teams/team/member.hbs:5:4
  app/components/mascots/mascot-list/item.hbs:3:6

ember-website now down to 29. Other 11 targets unchanged.

* Extend wcag/h32, wcag/h71, and form-submit suppression

Three connected refinements surfaced by ecosystem re-baseline on HDS:

1. detectStructuralYieldRules now matches `<form>` / `<fieldset>`
   via the resolved tag, not just the literal source tag. HDS-style
   `<HdsFormFieldset>` substitutes to `<fieldset>` in the blanked
   output but the heuristic was only checking literal `<fieldset>`,
   so the per-Source `wcag/h71` suppression directive never got
   emitted and the rule FP-fired on the substituted fieldset
   (which lacks a static `<legend>` because the addon renders one
   inside its own template). Same fix for `<form>`/wcag/h32.

2. elementYieldsAndLacksSubmit now treats DOTTED invocations
   (`<F.Body>`, `<G.Item>`, ...) as opaque content sources that may
   contain submit. These are curried sub-components yielded from
   the wrapper — their content is supplied at the consumer of the
   WRAPPER, not at the form's site. Without this, a form whose
   submit lives in a slotted-from-outside block would FP-fire
   wcag/h32 (HDS's flyout-with-form pattern: the form is inside a
   `CodeFragmentWithTrigger` whose template wraps the form in
   `<HdsFlyout as |F|>` and yields the consumer's `<:flyout>`
   block content into it).

Verified on HDS:
  checkbox/group.gts:    wcag/h71 1 → 0
  flyout/demo.gts:       wcag/h32 1 → 0

* Dual-tag substitution: prefer yield-nearest-ancestor over outer wrapper

The general fix that subsumes most of the per-rule whack-a-mole. We
were substituting components to a SINGLE tag (the outer wrapper, the
splatted-root, or Glint's leaf type) — but a component's template
has a STRUCTURE, not just a tag:

  HdsTabs's template:
    <div ...attributes>           ← outer (Element type → 'div')
      <div class="...">
        <ul role="tablist">       ← yield-nearest-ancestor
          {{yield (hash Tab=...)}}
        </ul>
      </div>
    </div>

The OUTER tag determines what the consumer's PARENT context sees.
The YIELD-NEAREST-ANCESTOR determines what CHILDREN are validated
against. They can differ; today we picked one and got FPs whenever
the choice mismatched.

Two changes:

1. `lib/outer-wrapper-resolver.ts` now ALSO computes the
   yield-nearest-ancestor by walking the AST tracking the element
   stack and locating each `{{yield}}` / `{{has-block}}` mustache.
   When the yield's ancestor is a PascalCase wrapper, recurses into
   that wrapper (consumer-yielded content passes through ITS yield,
   so the actual native ancestor is reached transitively). The
   `OuterWrapperResolution` interface gained `yieldAncestorTag` and
   `yieldAncestorAttrs`.

   Two recursions in one walk (outer-wrapper + yield-ancestor) need
   distinct cycle tracking; the secondary recursion clones the
   `visited` set so it doesn't pollute the first.

   Multi-template-file guard: when a file has multiple `<template>`
   blocks (e.g. several inline TOC consts + a default export), we'd
   have to match each to the requested component. We can't yet — bail
   to null. Cross-file paths (one-template-per-file is the dominant
   shape) still work.

2. `lib/glint.ts` adds `chooseSubstitutionFromResolution`. Heuristic:
     - If outer is in `STRUCTURAL_CHILD_TAGS` (`<li>`, `<tr>`,
       `<option>`, etc. — tags that REQUIRE a specific parent),
       keep outer. Dropping it would break the consumer's parent-
       context (`<HdsAppFooterLink>` substituted to `<a>` under
       `<ul>` would fire e-p-c, but to `<li>` it's fine).
     - Otherwise (outer is permissive — `<div>`, `<span>`, etc.) AND
       yield-ancestor is a different native, prefer the yield-
       ancestor. Children validation hinges on what wraps them at
       runtime; a `<div>` lets `<li>`-yielded children sit under
       `<div>` and FP-fire e-p-c, while `<ul>` correctly accepts
       them.

Verified on real ecosystem files:
  HDS variants.gts (was 23×e-p-c + 23×e-p-p):     0
  HDS legal-links (li outer kept):                preserved 0
  HDS filter-bar/clear-button (a yield-ancestor): preserved 0
  HDS form/checkbox/group:                        preserved 0
  ember-website classic-card:                     preserved 0
  Local glint-resolved-no-suppression test:       still fires 1 (literal th)

* Hook-time setAttribute fallbacks for narrow Glimmer-attr slot cases

Two new hook-time fallbacks parallel to the existing imgSplat src/alt
mechanism, for substitution sites where the consumer's invocation has
no Glimmer-attr slot wide enough to fit the chain-recorded attrs:

- aSplatHrefOffsets — `<a>` block-form substitutions where chain-attr
  collection records `href` but the consumer's slots can't fit it
  (e.g. `<HdsLinkInline @href="..." @color="...">`). Without it, the
  blanked open tag is `<a target='_blank' >` and `attribute-misuse`
  ("target requires href") FP-fires across HDS's external-link
  showcases.

- inputSplatTypeOffsets — self-closing void `<input>` substitutions
  where the chain has a literal `type` (e.g. `<HdsFormCheckboxBase>`
  → `<input type="checkbox">`) but the consumer wrote no `@arg=`/
  modifier (`<HdsFormCheckboxBase aria-label="…" />`). Source-side
  `tryInjectInputType` had no candidate range; the hook now calls
  setAttribute('type', literal) at parse time so
  `no-implicit-input-type` doesn't fire.

Both cases mirror PR #13's setAttribute pattern; per-attr offsets
keep `wcag/h37`-style "consumer forgot to pass" detection intact at
the upstream invocation. Synthetic fixtures reproduce both FP
classes; tests confirm pre-fix red / post-fix green.

* Address Copilot review (round 6)

- blank.ts case (B): gate `containsContentRestrictedStructuralChild`'s
  curried-child suppression to dotted invocations (`<T.Tab>`, etc.).
  Hash-yielded curried components are always dotted on the consumer
  side, so the narrower predicate preserves the HdsTabs-style FP fix
  without masking real `<div><HdsListItem>` violations where the
  pinned wrapper IS the runtime parent.

- lib/classic-resolver.ts: rewrite `addonRootsCache` docstring so it
  matches the actual behavior — pnpm symlinks are NOT realpath-
  resolved (the previous wording overstated what the code does).
  Note the bounded-cost trade-off vs. realpath() (one extra syscall
  per package, deduping inner package.json reads).

* ecosystem: re-baseline against PR #21 tip (round 6)

Net deltas vs prior baselines:
- hds-design-system: 162 → 276 (+114)
- discourse:         432 → 388 (-44)
- ember-power-select: 26 → 34  (+8)
- limber:             30 → 34  (+4)
- cardstack-ui-components: 10 → 9 (-1)
- ember-concurrency / ember-modifier / ember-primitives /
  ember-simple-auth / ember-website / super-rentals /
  vertical-collection: unchanged

The two hook-time setAttribute fallbacks landed in af88b84 cleared
~166 prior FPs on HDS (attribute-misuse + no-implicit-input-type
clusters that previously fired on substituted <a> / <input> with
narrow consumer-side Glimmer slots).

Remaining +114 on HDS is dominated by element-permitted-content
(+57), aria-label-misuse change (-4), and rule-config clusters
(unique-landmark +17, attribute-boolean-style +13,
no-implicit-close/close-order +20). Most of these are genuine spec
issues surfaced by the dual-tag substitution finally seeing the
correct yield-ancestor — same classification pattern as the +8 on
ember-power-select (`<p><CodeExample/></p>` → real
no-implicit-close).

* Suppress element-permitted-* on transparent-curried-child / structural-parent shape

New case (C) in `detectStructuralYieldRules`: when a wrapper resolves
to a structural-content-restrictive parent (`<ol>`, `<select>`,
`<table>`, `<tr>`, `<dl>`, `<details>`, etc.) AND has a dotted direct
child that resolves to 'transparent', the static blanker can't see
the runtime structural intermediate (`<li>`, `<option>`, …) the
curried child renders. Block-level content INSIDE the dotted child
ends up looking like a direct child of the wrapper and FP-fires
`element-permitted-content` ("<div> not permitted under <ol>").

Mirrors case (B) from the OPPOSITE direction: case (B) catches
"non-native wrapper has a structural-tagged direct child"; case (C)
catches "native structural-restrictive wrapper has a transparent
dotted direct child". Same per-Source suppression trade-off.

Surfaced by HDS's `<HdsStepperList as |S|><S.Step>...content...
</S.Step>` pattern: HdsStepperList → `<ol>`, `<S.Step>` is curried
via `WithBoundArgs<...>` and resolves to 'transparent' on the
consumer side because Glint's TS emit doesn't propagate Element
through hash-yielded components. Verified: count of
element-permitted-content findings on stepper/list/sub-sections/
status.gts goes from 6 → 0.

Test: blank.test.ts adds 4 unit-level cases hand-building
glintComponentTagMap (avoids fixture-Glint-resolution flakiness)
covering: structural parent + transparent dotted child suppresses;
permissive parent doesn't; case (B) overlap on concrete-structural
child still fires; non-dotted child doesn't (preserves the
"wrapper IS runtime parent" assumption from round-6 narrowing).

* ecosystem: re-baseline after case (C) suppression (HDS 276→258, -18)

* Bail yield-ancestor resolution when template has multi-distinct yield ancestors

`findYieldNearestElement` previously returned the FIRST yield's
nearest native ancestor. Templates with multiple yields landing in
DIFFERENT native ancestors (e.g. `<HdsTable>`'s
`<thead>{{yield to="head"}}</thead><tbody>{{yield to="body"}}</tbody>`)
surfaced an unreliable single-ancestor signal — a consumer using
only `<:body>` would have content land in `<tbody>`, but our
resolver picked `<thead>` (whichever yield came first), and
dual-tag substitution then preferred `<thead>` over the actual
outer wrapper `<table>`.

With this change: collect ALL yields' ancestors during the walk;
when more than one distinct tag appears, return null. The caller
falls back to the outer wrapper (`<table>`), which is correct
regardless of which named block the consumer picks. Loses the
named-block-specific child-validation power for multi-yield
templates — acceptable trade-off vs the wrong-ancestor FP class
this caused on HDS pagination/table showcase fragments.

Synthetic fixture mirrors HdsTable's two-yield shape; test
asserts `<MultiYieldTable />` resolves to `<table>` (no
`element-permitted-content` FP) instead of `<thead>` post-fix.

* ecosystem: re-baseline after multi-yield bail (HDS 258→299)

Multi-yield-bail fix landed in 0fb53f6. Net delta on HDS: -20
element-permitted-content FPs eliminated (the `<thead>`-instead-
of-`<table>` cluster on pagination/table showcase wrappers), +63
NEW true-positive findings unmasked because the substitution is
now correct:
- prefer-tbody +45 (showcase tables lacking explicit `<tbody>`)
- wcag/h63 +18 (`<th>` without `scope` attribute)

These were hidden when our static blanker substituted to `<thead>`
instead of `<table>` — html-validate didn't see a `<table>` to
apply table-structure / accessibility rules to. Now that
`<HdsTable>`-wrapping components correctly resolve to `<table>`,
the rules fire on the real DOM shape that's rendered at runtime.

Other targets unchanged.

* Skip leaf-fallback resolution for multi-template files

`getSplattedRootsForFile` returns one entry per `<template>` block
in the source file. The leaf-fallback path picked `roots[0]`
unconditionally — fine for the common single-template case, but
in multi-template files (helpers + default export, multi-export
TOC sets) the first template's root cross-pollinates onto every
component declared in the file.

Mirrors limber's `apps/repl/app/templates/docs/support/api.gts`:
TOC `<Live>` (first, renders `<span>`) and class `<Wrapper>`
(second, renders `<div>...<p>{{yield}}</p>`). Every consumer of
`<Wrapper>` was getting tagged with Live's `<span>` —
`<ul><Wrapper>...</Wrapper></ul>` looked like
`<ul><span>...</span></ul>` and FP-fired
`element-permitted-content`.

Fix: skip the leaf-fallback when `roots.length > 1`. The
component stays at its underlying Glint Element type (typically
'transparent' for declarations without a Signature), and
children float to the actual consumer-side parent.

Declaration→template matching is the principled fix and is
deferred — it requires correlating the TS declaration's source
range with each `<template>` block's range. The narrower bail
preserves correctness for the multi-template case without that
mapping.

Outer-wrapper-resolver already had the same multi-template bail
(see `templateBlocks.length !== 1` in
`lib/outer-wrapper-resolver.ts`); this aligns the leaf-fallback
path with that existing behaviour.

* ecosystem: re-baseline after multi-template-file fix (limber -5)

* Hook-time setAttribute for substituted <button> + multi-template root match

Two fixes for `no-implicit-button-type` FP-firing on substituted
`<button>` elements:

1. Block-form `<button>` substitutions register the consumer offset
   in `inputSplatTypeOffsets` (re-using the existing channel from
   PR-21 commit af88b84). The `processElement` hook now keys on
   `tagName === 'input' || tagName === 'button'` and calls
   setAttribute('type', DynamicValue) at parse time when the
   consumer didn't write `type=` themselves. Mirrors HDS's
   `<HdsRichTooltip><RT.Toggle>...</RT.Toggle></HdsRichTooltip>`
   pattern: `RT.Toggle` is a hash-yielded curried component
   resolving to `<button>`, but `attrCtx` is undefined (no chain
   attrs available for curried-via-hash), so the source-side
   `tryInjectComponentAttrs` path doesn't run. The hook-time
   fallback closes the gap.

2. Multi-template-file leaf-fallback: pick the `<template>` block
   that falls inside the resolving declaration's TS source range.
   content-tag's preprocessor preserves byte positions from the
   original .gts in the emitted .ts (so TS's `getStart()`/`getEnd()`
   and content-tag's block ranges share a coordinate system). For
   a class declaration the template lives inside the class body;
   for a `const Foo = <template>...</template>;` it lives inside
   the variable initializer — both cases have the template range
   inside the declaration range.

   Previously `roots[0]` was used blindly, cross-pollinating any
   consumer of a class/const declared after the first `<template>`
   in a file (`Live` const at the top tagged every other
   component in the same file as `<span>`). Falls back to
   `roots[0]` only for single-template files (the common case).
   Single-template files keep working identically — `templateStart`/
   `templateEnd` are informational, not required.

* ecosystem: re-baseline after button-type + multi-template fixes (HDS -17, others -2)

* Anchor-aware aria-* injection: drop aria-* when role can't fit

`tryInjectComponentAttrs` was injecting aria-* attrs (e.g.
`aria-labelledby`) onto substituted elements regardless of whether
the anchoring `role` could fit alongside. Plain `<div
aria-labelledby='...'>` (no role) trips html-validate's
`aria-label-misuse` rule even though the addon's actual splatted
root is `<div role='dialog' aria-labelledby='...'>`.

Two-phase placement:
- Phase 1: fit `role` first (anchor).
- Phase 2: fit everything else longest-first; drop aria-* attrs
  from the plan when role wasn't placed.

Drift-proof: gates on the `aria-*` PREFIX, not an enumerated list
of role-dependent aria attrs (the list lives inside individual
html-validate rules and may grow over versions). When the addon's
splatted root literally writes `<div role aria-...>`, both attrs
are recorded and injected together; otherwise neither.

Mirrors HDS's `<HdsAppSideNav>` shape: addon template is
`<div role={{...}} aria-labelledby={{...}}>`, consumer-side has
narrow `@arg=` slots that fit `aria-labelledby='   '` but not
`role='   '` alongside. The substituted output dropped role,
keeping aria-labelledby — FP-firing `aria-label-misuse`
("aria-labelledby cannot be used on this element") on what's a
valid `<div role='dialog' aria-labelledby>` at runtime.

Existing test for "empty-string literal on a non-boolean attr"
asserted the previous (buggy) bare-aria-label injection — updated
to assert the new behavior (no aria-* without role) and a new
companion test for the role-anchored case.

* ecosystem: re-baseline after anchor-aware aria-* injection (HDS -14)

* Reject DynamicValue placeholder from `isLiteralSafeForAttr`

The chain-attr extractor records arg-bound / dynamic attribute
values as the 3-space `DYNAMIC_VALUE_PLACEHOLDER`. When the
substitution path looked up a chain attr like `type` and asked
`isLiteralSafeForAttr(value)`, the placeholder slipped through
(whitespace passed the regex that rejected
HTML-altering chars), causing the literal `'   '` to be embedded
verbatim in the substituted output (`<input type='   '>`,
`<button type='   '>`, …) and reaching html-validate WITHOUT
going through the `processAttribute` placeholder→DynamicValue
conversion (because the literal was injected as a string value
to `setAttribute`, not as the parser's text-content path).

Result: html-validate's `attribute-allowed-values` rule
FP-fired with `Attribute "type" has invalid value "   "` on
HDS's `<HdsFormTextInputBase aria-label="…" />` and similar
self-closing void substitutions where source-side slot fitting
failed and the hook-time fallback was used.

Fix: reject the DynamicValue placeholder from
`isLiteralSafeForAttr` so callers fall through to the
DynamicValue path. The placeholder is meant as a marker, not a
real literal value.

* Backfill regression tests for prior PR #21 commits

Audit revealed three earlier commits in this PR that landed
behavioral changes without test coverage. This commit adds focused
regression tests for each:

1. 76901ba (Hook-time setAttribute for substituted <button>):
   `block-form <button> substitution registers inputSplatTypeOffsets`
   in test/blank.test.ts. Hand-feeds a `glintComponentTagMap` to
   exercise the block-form button path without depending on
   Glint's hash-yielded curried-typeof resolution (which doesn't
   propagate Element on synthetic fixtures even though it does on
   real HDS source). Verified pre-76901ba fails / post-76901ba
   passes.

2. 9ef0b73 (Copilot round 6 — narrow case (B) to dotted):
   `case (B) does NOT suppress when a non-dotted child resolves to
   a structural tag` in test/blank.test.ts. Pre-9ef0b73 case (B)
   suppressed for ANY child resolving to structural; the narrowing
   ensures non-dotted children (where wrapper IS the runtime
   parent) DO surface real `<div><li>` violations.

3. fd7fb2a (Extend wcag/h32 + h71 + form-submit suppression):
   `glint-resolved-form-consumer` integration test. Wraps
   `<MyForm>` (which substitutes to `<form>` via Glint Element
   resolution) around `{{yield}}`. Pre-fd7fb2a, the heuristic
   checked literal `stmt.tag === 'form'` and missed Glint-
   resolved-to-form components — wcag/h32 FP-fired. Post-fd7fb2a
   the check uses `stmtResolved` so substituted-`<form>` consumers
   get suppression too.

The 109fa19 dual-tag heuristic remains exercised indirectly via
`multi-yield-table-consumer` (the bail-out test) and the cleared
HDS variants.gts/legal-links/filter-bar/checkbox-group findings —
no synthetic fixture reliably reproduces it without depending on
Glint's TS emit propagation behavior on hash-yielded curried-
typeof, which behaves differently on synthetic vs. ecosystem
sources.

* Polymorphic-tag chain trace via Glimmer (element ...) helper

HDS-style polymorphic-tag wrappers (HdsText, HdsTextBody, …) use
the Glimmer `(element X)` helper to render whatever tag `X`
resolves to. Glint's TS-side resolution sees the union element
type (`HTMLSpanElement | HTMLHeadingElement | ...`) and arbitrarily
picks the first match (`<h1>`), even when the runtime tag is
something different (e.g. `<li>` for HDS dropdown list items that
pass `@tag="li"`). Static substitution lands `<h1>` inside `<ul>`
and FP-fires `element-permitted-content`.

The chain trace surfaces the literal propagated through the
wrapper chain:
  - Detect `{{#let (element X) as |Tag|}}<Tag>...</Tag>{{/let}}`
    and classify X (literal / @arg / this.prop).
  - For @arg: walk the immediate template root's `@arg=` literal
    or `@arg={{@otherArg}}` pass-through, recursing through
    PascalCase wrappers via local imports.
  - For this.prop: read the class getter for the HDS-convention
    pattern (`const { argName = 'default' } = this.args; return
    argName;`) and convert to an arg dependency.

Wired into `glint.ts` leaf-fallback. Cached per addon file
(polymorphicCache); cycle-guarded; depth-capped to 10.

Performance verified: full ecosystem-ci run (~1500 files, 12
targets) is 12:33.04 with vs. 12:33.57 without — within noise.

Cross-package source resolution:
`resolveGtsPath` now also maps `<pkg>/declarations/X.d.ts` →
`<pkg>/src/X.gts` (and similar `dist/types/`, `dist/`) for
v2-addon packages that publish `.gts` source alongside `.d.ts`
declarations. Without this, the leaf-fallback (and the new
polymorphic chain) couldn't reach across-package imports through
package-managed `node_modules` paths.

Tests:
- `test/glint.test.ts` adds two unit tests directly exercising
  `getPolymorphicResolvedTag` against synthetic fixtures
  (polymorphic-text-leaf.gts → `{ kind: 'arg', argName: 'tag' }`,
  polymorphic-list-item-leaf.gts → `{ kind: 'tag', tag: 'li' }`).
- HDS regression verified: dropdown/list-items/not-interactive.gts
  goes from 6 element-permitted-content findings to 0.

* Narrow .d.ts → .gts mapping to polymorphic chain only

The earlier `resolveGtsPath` extension that mapped
`<pkg>/declarations/X.d.ts` → `<pkg>/src/X.gts` was too broad: it
caused the leaf-fallback to fire for ALL cross-package addon
components. The leaf-fallback then re-tagged components via
splatted-root scans they weren't designed to support, surfacing
~397 new `element-permitted-content` FPs on HDS in a re-baseline:

  HDS: 268 → 723 (+455)
  - element-permitted-content: 71 → 468 (+397)
  - unique-landmark: 17 → 102 (+85)
  - prefer-tbody: 45 → 7 (-38, lost some valid substitutions)
  - wcag/h63: 16 → 0 (-16, lost <th> substitutions)
  - patterns: <form> in <form>, <option> under <div>, <header>
    in <header>, <table> containing <div>/<th>/<button>, etc.

Split the resolver:
- `resolveGtsPath` (used by leaf-fallback): unchanged, narrow.
  Maps `.gts`/`.gjs`/`.ts` (non-`.d.ts`) only. Cross-package
  components with only `.d.ts` go through Glint's TS resolution.
- `resolveGtsPathForPolymorphic` (used by polymorphic chain):
  extends with `<pkg>/declarations/X.d.ts` → `<pkg>/src/X.gts`
  mapping (and `dist/types/`, `dist/`). The polymorphic chain
  only acts on components whose template uses `(element ...)`,
  so opening cross-package `.gts` here doesn't trigger the same
  broad regression.

Restructured the polymorphic-chain call site so it's INDEPENDENT
of the leaf-fallback's `if (gtsPath)` block — the chain runs
even when the leaf-fallback can't resolve a `.gts` path (cross-
package case). The chain remains a no-op for addons that ship
only prebuilt `.js + .d.ts` (no `.gts` source) — same behavior
as before for those.

Regression test:
- New fixture `test/glint-fixtures/node_modules/polymorphic-addon/`
  mirroring HDS's published layout (declarations/X.d.ts +
  src/X.gts + package.json).
- `test/glint.test.ts` adds a test asserting the polymorphic
  chain trace resolves `<PolyListItem>` (cross-package, .d.ts
  declFile) to `<li>` via the .d.ts → .gts mapping.
- Without the split, this fixture's leaf-fallback would re-tag
  PolyListItem's parent `<ul>` chain via splatted-root and trip
  the same FP class as HDS.

* Polymorphic-tag chain trace: extract from compiled .js via TS parser

Most v2 addons publish `.js + .d.ts` only (no `.gts` source) per
the v2-addon spec and emberjs/rfcs#0931. Previously the chain
trace only worked when an addon shipped `.gts` source alongside
(HDS specifically does this — most addons don't).

The compiled `.js` output preserves the template content as a
string literal in `precompileTemplate("CONTENT", ...)` (current
Ember 5.x shape) or `template("CONTENT", ...)` (the new
`@ember/template-compiler` shape introduced by emberjs/rfcs#0931).
We can extract the template content directly from the `.js` —
both shapes' first argument is a static string literal in the
common case.

Two parts of the chain trace switched from regex to TypeScript's
parser (already loaded transitively by Glint integration; reused
via `createRequire`):

1. `extractTemplateContent` for `.js`/`.ts`: walks the AST for
   `CallExpression`s whose callee is `precompileTemplate` or
   `template`, returns the string literal of the first arg.
   Handles JS escape forms (`\"`, `\n`, …) cleanly via TS's
   own literal-text unescaping; handles `'…'`, `"…"`, and
   no-substitution template literals (`` `…` ``).

2. `resolveThisPropPolymorphic` for the class-getter walk:
   walks the `GetAccessor` body for the HDS-convention shape
   (`const { argName = 'default' } = this.args; return argName`).
   The previous regex broke on the multi-line destructuring
   shape compilers emit (`.gts` source compresses to one line;
   compiled `.js` spreads it across three).

`resolveGtsPathForPolymorphic` extended to also consider compiled
`.js` paths (`<pkg>/dist/X.js`) when no `.gts` source is
available — mirrors the v2-addon spec's published layout.

Regression test:
- New fixture `polymorphic-addon-js-only/` mirrors a v2-spec-
  standard addon: `package.json` + `declarations/X.d.ts` +
  `dist/X.js` (NO `src/X.gts`). The compiled `.js` files are
  hand-written to match the shape an Ember v2-addon build emits
  (precompileTemplate with strictMode + scope).
- `test/glint.test.ts` adds a test asserting the chain trace
  resolves `<PolyListItem>` to `<li>` through this compiled-only
  setup.

* ecosystem: re-baseline after polymorphic chain trace via TS (HDS -10)

* ecosystem: re-baseline HDS (-10) + limber (+6) after FP-resolver work

HDS (258 → 248):
  Cleared:
  - 8 element-permitted-content '<div> under <h1>' on flex/sub-sections/
    display.gts — old Glint-TS-side union pick of HTMLHeadingElement
    for <HdsTextBody @tag="p"> superseded by polymorphic-chain trace.
  - 4 element-permitted-content '<div> under <h1>' on form/layout/
    sub-sections/containers.gts (same root cause via <FORM.HeaderTitle>).
  - 5 element-permitted-content '<div> under <h1>' on rich-tooltip/
    sub-sections/options.gts + states.gts (same).
  - 4 element-permitted-content '<h1> under <span>' on radio-card/
    sub-sections/base-control.gts (same union-pick FP, different shape).
  - 6 element-permitted-content '<div> under <span>'/'<button>' on
    HDS source (tag/index.gts, advanced-table/th-selectable.gts) and
    advanced-table/sub-sections/base-elements.gts — cleared by yield-
    ancestor preference now correctly tracking the inner wrapper.
  - 2 aria-label-misuse 'aria-labelledby cannot be used' on flyout/
    modal — canonical resolver now reaches the <dialog> outer.
  - 8 aria-label-misuse 'aria-label not recommended on this element' on
    breadcrumb/page-header/stepper-list/focus-ring consumers — cleared
    by the yield-ancestor + aria-* strip (lands on the actual splat
    target at runtime).
  - 4 wcag/h32 '<form> must have a submit button' on form/layout/
    within-containers.gts — chain resolution surfaced submit buttons.
  - 8 element-permitted-content position-shifts (added/removed pairs
    where the substituted output's line numbers shifted by a column).
  - 1 attribute-misuse 'target requires href' — chain href injection.
  Newly surfaced (real HTML5 violations in HDS showcase code, not FPs):
  - 4 containers.gts no-implicit-close/close-order on <p> from
    HdsFormHeaderDescription (@tag='p') containing <ShwPlaceholder>
    (<div>) — <p> can't contain block content.
  - 6 radio-card element-permitted-content '<div> under <span>' —
    HdsFormRadioCard yields inside <span class="content"> at runtime
    and consumers put <R.Badge> (<div>) there.
  - 2 super-select no-implicit-close/close-order on <li> —
    HdsFormSuperSelectOptionGroup renders <li role="group"> with
    sibling <li> children inside (no <ul> between).
  - 8 page-header unique-landmark — showcase has 3+ <HdsPageHeader>
    (= <header>) on one page without distinct aria-labels.
  - several rich-tooltip/focus-ring no-implicit-close on <p>.

limber (28 → 34):
  Newly surfaced (real HTML5 violations in docs/embedding.gts):
  - 6 no-implicit-close/close-order on <p> containing <ul>/<div>.

Also: bring back HVE_FULL_DIFF=1 env var on summarizeFindings to
print the complete added/removed lists without truncation (used
during this triage to enumerate every cluster).

* ecosystem: re-baseline HDS for 8 line-position shifts from lowercase-dotted resolution

Same findings (8 prefer-tbody + 1 prefer-native-element), just at
slightly earlier line positions. The previous baseline pre-dated the
fix that lets buildConsumerInfo + walkMapping process dotted
invocations through lowercase-headed block-params (`<dd.Interactive>`
in HDS dropdown showcases — `<HdsDropdown as |dd|><dd.Interactive>`).
Resolving those now substitutes them in the blanked output, which
shifts downstream substitution offsets and reports the same TPs at
new line numbers.

No findings added or removed in substance; net change is 0 findings.
The 8 +/- diff is purely positional.

* run: stop counting no-template .gts files as "from cache"

On a first run, the Glint summary reported

  Glint: 311 analyzed, 3 from cache

even though the cache was empty for every file — those 3 were
`.gts` files with no `<template>` block (rewriteEmpty). We write a
tombstone for them AFTER the run so they show up as literal cache
hits on the NEXT run, but at summary-print time none of them are
actually cached.

Tighten the message to report only real disk-cache hits in the
"from cache" number. When some `.gts` files had no template, surface
the count in a parenthetical instead of folding it into "from cache".
Read / rewrite errors continue to surface via HVE_DEBUG=1 only.

  before: Glint: 311 analyzed, 3 from cache
  after:  Glint: 311 analyzed, 0 from cache (3 .gts files had no <template>)

* add severity: 'errror' to run.js

* Address PR #24 Copilot review

- ecosystem.yml: switch to pnpm to fix failing CI (setup-node was
  looking for package-lock.json that doesn't exist); also swap the
  paths filter from package-lock.json to pnpm-lock.yaml
- ecosystem/run.ts: fix three stale comments (the void-style note in
  makeValidator, the "Glint intentionally not enabled" paragraph, and
  the false "fall back to plain yarn install" claim)
- test/cache.test.ts: drop the duplicate pluginSourceSha-mismatch test
- ECOSYSTEM-OVERLAP.md: replace local /Users/... paths with upstream
  repo URLs; reflect that gts-recommended now leaves void-style off
- README.md: rewrite the preset descriptions to match index.ts
  (void-style off, prefer-native-element warn; gts-recommended is
  now a backwards-compat alias of recommended)
- ecosystem/triage/*.md: mark the void-style triage docs as historical

* Address second pass of PR #24 Copilot review

- tsconfig.json: exclude vitest.config.ts from the build (it was
  being compiled into dist/ and would ship via files=["dist/"])
- ecosystem.yml: drop the redundant explicit pnpm-store cache step;
  setup-node's cache: pnpm already handles it, and the hard-coded
  ~/.pnpm-store path didn't match the runner's actual store
- ecosystem/run.ts: refuse to baseline transformer crashes — a
  __transformer-crash__ finding now skips writeBaseline for that
  target and forces a non-zero exit at end of run. The existing
  comment claimed this behavior; this commit makes it true.

Surfaced as NOT SURE (see not-sure.md): hard-coded 5-min install
timeout. No evidence of flake yet; deferred.

* Fix: force HVE_GLINT='0' (not delete) when target opts out of Glint

The plugin's transform.ts gates Glint on `process.env['HVE_GLINT'] !== '0'`,
so an unset env var is treated as Glint-ON (the default). The previous
`delete process.env['HVE_GLINT']` in the `else if` branch was therefore
a no-op for the Glint-disable intent — it cleared the env var but the
plugin then ran with Glint anyway.

This silently re-enabled Glint for every target with `glint: false`
(discourse, cardstack-ui-components, super-rentals) and for any target
whose dependency install failed. Verified end-to-end on
admin-badges.gjs: HVE_GLINT unset emits 8 `element-permitted-*` FPs
on lines 38/39 (`<li>` from Glint-resolving service-driven ghost
components like <DBreadcrumbsItem>), HVE_GLINT='0' emits 0.

Baselines for the three `(no Glint)`-labelled targets were recorded
under the buggy behavior and will shift after this fix; rebaselining
to follow.

* Fix #38 (partial): suppress technique-rule FPs per-element, not file-wide

Mel's principle from #38: when the validator can't determine code
structure, technique-named WCAG rules shouldn't fire on the resulting
uncertainty. Applied to the Glimmer adapter's scope — suppress only
when our blanker has demonstrably obscured the structure the rule
depends on. html-validate's preset choices aren't second-guessed; the
suppression is scoped to Glimmer-specific opacity.

New FP detections:
- wcag/h63 on <table>s with cell-generating {{#each}} / {{#if}} /
  {{#unless}} blocks, or with PascalCase row components that Glint
  resolves to <tr>/<td>/<th>. The substituted/blanked output has
  row widths that don't match the runtime — `isSimpleTable` decides
  "not simple" and demands scope on every <th>.
- wcag/h67 on <img alt='' title='{{x}}'> (incl. ConcatStatement
  titles with whitespace-only literal parts). Runtime title may
  legitimately be empty; the placeholder we emit isn't.
- wcag/h32 on <form>s whose only submit candidate is <button|input
  type='{{x}}'> or <input ...attributes> (type may arrive via the
  splat). Same uncertainty principle.

Mechanism change: all the new suppressions, plus the existing h32/h71
yield-form / yield-fieldset cases and element-permitted-content /
-parent / element-required-content, move from file-level
<!--html-validate-disable rule--> directives to per-element
el.disableRules(...) via the processElement hook (same API as the
existing SVG element-name handling in index.ts:22). Surgical scope:
a cell-loop FP on one <table> no longer silences a real wcag/h63
violation on a sibling table in the same template.

h32 trade-off reversed: the prior ambiguous-submit handling set
hasStaticSubmit=true to BLOCK suppression (rationale: avoid no-
unused-disable cascade if runtime turns out to be a real submit).
With per-element disableRules there's no directive comment to be
"unused", so ambiguous submits now trigger suppression — aligning
with Mel's principle.

Tests: 8 new integration tests against real-world FP shapes, plus
2 scope-guard tests (multi-table-mixed, multi-img-h67) verifying
per-element scope keeps sibling violations live. Both originally-
reported real-world templates (energy-audit-questionnaire.gts,
expected-fuel-use-card.gts) now clean.

* Add regression tests for invalid-markup patterns unmasked by fix/38

When fix/38 migrated element-permitted-content / -parent from file-
level disable to per-element, three pre-existing real-bug patterns
that the file-level disable had masked started firing correctly. The
shapes themselves are valid html-validate behavior — these tests
guard against any future broadening of the per-element suppression
detection accidentally re-masking them.

- <th> directly under <thead> (no <tr> wrapper) — <thead>'s content
  model is "zero or more <tr>"
- <div> (flow content) inside <span> (phrasing-only)
- non-native <ul>-resolving wrapper containing non-native <div>-
  resolving child — runtime DOM is <ul><div></ul>; an earlier
  fix/38 extension attempt masked this case via "wrapper is
  STRUCTURAL_CONTENT_PARENT + child is non-native → suppress" and
  the test catches that regression

* Address PR #41 Copilot review

- blank.ts: refresh the "Conservative on dynamic types" paragraph
  above detectSuppressions. The pre-migration logic treated
  ambiguous-typed submits as DISqualifying the wcag/h32 suppression
  (to avoid no-unused-disable cascades on file-level directives);
  the per-element migration reversed this — ambiguous submits now
  trigger suppression via hasAmbiguousSubmit because the rule does
  fire on the blanked output (the directive is load-bearing).
- blank.ts: collectThOffsets now also collects offsets of component
  invocations Glint-resolves to <th>. tableHasGlimmerObscuredCells
  already treated those as cell tags that trigger the table
  suppression; the per-<th> disableRules now lands on them too.
- blank.ts: narrow the STRUCTURAL_CONTENT_PARENTS + transparent-
  dotted-child branch to scope its suppression to the dotted
  child's subtree only. The previous implementation walked the
  entire wrapper subtree via collectContentRestrictedChildOffsets,
  which would mask real structural-literal violations on siblings
  unrelated to the dotted child's yield chain. Added
  collectTransparentDottedChildOffsets for the narrower scope.
- examples/ + test/integration.test.ts: two new tests cover the
  fixes (table-component-th, regression-sibling-structural-literal-
  under-structural-wrapper).

* Address PR #41 Copilot review (round 2)

- blank.ts: collectTransparentDottedChildOffsets now collects the
  ELEMENT CHILDREN of a transparent dotted node, not the dotted
  node itself. The dotted node's open/close tags are blanked away,
  so disableRules on its offset never runs at parse time — but its
  element children "float up" to the wrapper, where the wrapper's
  content-model rule fires on them. That's where the per-element
  disable must land. Recurses through nested transparent dotted
  children for deeper yield-chain floats.
- test/blank.test.ts: case-C suppression test now asserts the
  disable is registered at the FLOATING <div>'s offset specifically
  — not just "any rule on any offset". This catches the prior bug
  where suppression was registered on a transparent-blanked node
  that never reached processElement, leaving the runtime rule
  un-suppressed.

* Address PR #41 Copilot review (round 3)

- blank.ts: refresh six stale comment paragraphs that still framed
  wcag/h32 suppression as a file-level <!--html-validate-disable-->
  directive with `no-unused-disable` cascade concerns. Post-PR the
  suppression is per-element `el.disableRules(...)`; the rationale
  is now "the per-element disable would land on a form that wouldn't
  have fired wcag/h32 anyway, so we skip" rather than "the directive
  would be unused". Touched: Component-aware paragraph,
  elementYieldsAndLacksSubmit doc, hasAmbiguousSubmit declaration,
  inline ambiguous-submit comment, final-return rationale, and
  classifyComponentSubmit's static-submit / ambiguous descriptions.
- test/integration.test.ts: refresh h32-dynamic-submit-type test
  comment to describe per-element disable instead of directive.
- test/blank.test.ts: case-C failure message was running
  `JSON.stringify` on a `Map<offset, Set<rule>>` whose `Set` values
  serialize to `{}`, hiding which rules were registered. Format
  entries as `[offset, [...rules]]` first so failures are
  actionable.

* Address PR #41 Copilot review (round 4): drop dead disableForRules

After the per-element migration in this PR, no detection branch
populates `fileLevel` / `BlankResult.disableForRules` — that field
was always empty. Remove it and the dead transform.ts plumbing:

- blank.ts: drop `disableForRules` from BlankResult / BlankErrorResult.
  `detectSuppressions` now returns `Map<offset, Set<rule>>` directly
  (no `{ fileLevel, perElement }` wrapper). Rewrite the docstring
  above `detectSuppressions` to enumerate the 7 FP classes actually
  covered today and frame them all as per-element.
- transform.ts: .hbs source path drops the buildDisableDirective
  call (no rules to add). Multipass .gts path inlines
  `buildDisableDirective(['no-unused-disable'])` instead of
  iterating an empty `disableForRules`. buildDisableDirective doc
  updated to reflect that it carries only no-unused-disable today.
- test/blank.test.ts: `suppressesRule` helper drops the
  disableForRules check (always empty post-migration). Rationale
  comment updated; pointer to per-offset assertion added.
- test/integration.test.ts: three test rationale comments that still
  referenced `disableForRules` updated to describe the per-element
  map.

* ecosystem: re-baseline after fix/38 (per-element technique-rule suppression)

The fix/38 merge changed which findings the ecosystem targets
produce. Re-baselined the 4 affected targets:

- discourse: -600/+80 lines — bulk wcag/h63 / element-permitted-content
  FPs removed; the few additions are real upstream bugs (e.g. <div>
  under <ul>, <button> under <a>, <th> directly under <thead>) that
  the per-element migration unmasked once file-level over-suppression
  was dropped.
- hds-design-system: same shape — many FPs removed, a handful of
  real <div>-under-<ul> bugs in showcase demo code surfaced.
- ember-website: -1 (element-name FP on <image> fixed).
- limber: -1 (wcag/h32 FP fixed).

Generated locally with node 24 (CI runs node 22); discourse is
deterministic no-Glint, and the Glint targets use frozen lockfiles
+ pinned TS, so cross-node drift should be nil. If a subsequent CI
run still diffs, re-baseline from the CI output.

* ecosystem CI: corepack enable so package-manager-pinned targets install

cardstack-ui-components pins yarn via package.json#packageManager;
the runner's global yarn (1.22.22) rejects it with a Corepack error,
so installDeps falls back to no-Glint validation. `corepack enable`
routes the yarn/pnpm shims to the pinned version, restoring Glint-
resolved findings for those targets.

* ecosystem: build source-only workspace deps + re-baseline HDS

run.ts: add a per-target `build` step (run before validation, after a
fresh install, with a re-inject) so source-only/unbuilt workspace deps
resolve — HDS's @hashicorp/design-system-components ships only
dist/declarations via its `files` allowlist, absent until its rollup
build runs, so without this PascalCase components blanked transparent
and structural rules couldn't see the rendered DOM. Also add a
`--no-glint` flag (keeps install+build, disables Glint extraction) to
measure Glint's contribution vs the canonical resolver.

targets.json: build @hashicorp/design-system-components for HDS.

Re-baseline HDS with built deps + the merged resolver fixes (exports
resolution, re-yield h71 fix, dotted-transparent): 95 -> 271. The FP
classes are resolved (wcag/h71 74 -> 17 via the re-yield fix; no
element-permitted-content flood). Remaining findings are largely real
(e.g. block-in-<p> tooltips, prefer-tbody).

* ecosystem: label-gate CI + commit refreshed baselines back to PR

* ecosystem: rename gate label to run-ecosystem-ci

* ecosystem: suppress stylistic-noise rules in CI config, not the plugin

The void-style / no-inline-style / prefer-native-element suppressions live
in the ecosystem runner's config (ECOSYSTEM_RULE_OVERRIDES) instead of the
plugin's shipped :recommended / :gts-recommended presets, which return to
their original form. Net rule set applied to the targets is unchanged, so
baselines are untouched (verified: ember-modifier, super-rentals,
ember-primitives all clean vs baseline). Whether the plugin's recommended
set should adopt the same suppressions is tracked in PR #47, not here.

* ecosystem: document label-gated trigger + baseline auto-commit in README

* ecosystem: gate baseline auto-commit to same-repo PRs; fix triage typo

Addresses Copilot review on #24:
- explicitly gate the commit-back step on
  github.event.pull_request.head.repo.full_name == github.repository
  (fork PRs get a read-only token; makes the trust model explicit)
- 'Glouped' -> 'Grouped' in ember-power-select triage note

* ecosystem: treat a file-count drift vs the baseline as a regression

Cowritten by Claude
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant