recommended: disable noisy stylistic rules; demote prefer-native-element - #47
Merged
Conversation
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
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
marked this pull request as ready for review
August 28, 2026 08:41
Merged
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
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.
Makes
:recommendedsignal-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 thestyle=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 toomit, Ember/Glimmer convention isselfclosing, and mixing is harmless. Previously:gts-recommendedforcedselfclosing, which fought projects usingomit. Now disabled entirely; enforce viaember-template-lintif you want a house style.prefer-native-element: warn(waserror) — 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 toerrorper-project if desired.:recommendedand:gts-recommendednow resolve to identical rule sets. The:gts-recommendedalias stays for backwards compatibility, so existing consumers'extendskeep working.Context
Same spirit as the discussion in #38 (don't make
:recommendedpedantic) — 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:recommendedmakes 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
:recommended/:gts-recommended— needs an appropriate release-plan label.