Skip to content

fix(template-no-template-lint-directives): preserve directive scope on conversion - #2833

Draft
johanrd wants to merge 1 commit into
ember-cli:masterfrom
johanrd:fix/template-lint-directive-next-line-scope
Draft

fix(template-no-template-lint-directives): preserve directive scope on conversion#2833
johanrd wants to merge 1 commit into
ember-cli:masterfrom
johanrd:fix/template-lint-directive-next-line-scope

Conversation

@johanrd

@johanrd johanrd commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

The problem

{{! template-lint-disable }} does not have one scope. Per ember-template-lint's docs/configuration.md:

An in-element instruction will apply to only that element

An in-element instruction with the -tree suffix will apply to that element and all its descendants

Standing alone it runs from the comment to the end of the template; inside an element's opening tag it covers that element; with -tree it covers the subtree.

This rule converted all of them to eslint-disable, which is file-wide. That silently widened the element-scoped forms:

<div
  {{! template-lint-disable no-positive-tabindex }}
  tabindex="1"
></div>
<span tabindex="2"></span>

ember-template-lint reports the <span>. After eslint --fix, nothing reported at all — one migrated directive swallowed a real violation, and every later violation of that rule in the file.

That is the worst failure mode for a migration tool: it rewrites a codebase and quietly turns off the linting you were migrating to, with no diagnostic. It surfaced as three unrelated a11y rules reported as "no longer reporting failures that ember-template-lint reported"; differential testing cleared those rules, and the losses traced back here.

-tree was a second hole: it never matched the rule at all, so those directives migrated to nothing, also without a report.

The fix

ESLint's own scopes are the next line and the rest of the file — neither matches an element. But an eslint-disable / eslint-enable pair delimits an arbitrary region, and bracketing the element reproduces both in-element scopes exactly. Closing the region as the element's first child covers the opening tag alone; closing it after the element covers the subtree:

placement template-lint scope conversion
standing alone comment → end of template eslint-disable (rest of file)
paired with template-lint-enable between the two eslint-disableeslint-enable
inside an opening tag that element's opening tag only eslint-disable before element, eslint-enable as first child
-tree suffix element and descendants eslint-disable before element, eslint-enable after it

The closing comment is inserted without a surrounding newline: {{! }} compiles away and leaves no trace in the DOM, but a newline would add a whitespace text node and can change inline layout.

Verification

Each scope checked against ember-template-lint 7.9.3 on the same template, using a fixture where every element carries a distinct tabindex so violations stay identifiable after the fixer shifts lines:

directive ember-template-lint reports after --fix
in-tag disable tabindex="2", tabindex="3" tabindex="2", tabindex="3"
in-tag disable-tree tabindex="3" tabindex="3"
standalone disable nothing nothing

Also covered: nested in-tag directives (converted across several fix passes — asserted to converge and to keep each scope), two directives on one element, and self-closing elements. Tests assert suppression scope end-to-end — migrate, then lint — rather than only the rewritten comment text, which was never the part that broke.

Relation to earlier attempts

Two closed PRs went at this gap from the processor direction — teaching ESLint to honour template-lint-* comments natively, so codebases keep their existing directives:

  • Add template-lint-disable pragma for template regions #2627 added an opt-in template-lint-disable processor. Next-line only; it explicitly did not implement template-lint-enable, -next-line or -tree. Closed after review, with a perf benchmark requested since every file pays a preprocess cost.
  • NullVoxPopuli-ai-agent#3 built on that branch and added template-lint-enable range semantics, on the reasoning that "projects migrating from ember-template-lint have existing block comments written for ETL's semantics. With next-line-only behaviour those comments silently do nothing beyond the first line, making the migration painful."

That second PR had already identified the right diagnosis — ranges are the missing piece — and this PR reaches the same semantics from the other side. Rather than emulating template-lint's range behaviour in a processor, it emits ESLint's own eslint-disable / eslint-enable, which already provides ranges natively. No processor, no per-file preprocess cost, and no second directive system to keep in step with ESLint.

It also extends the idea past where #3 stopped. #3 covered the standalone disable … enable range; the in-element and -tree scopes are the ones that were actually losing violations here, and a region pair expresses those too — the element's first child and the element's end are just two more range boundaries.

And it answers the question left open on the #2627 thread — "maybe it is enough with next line + whole file disable?" — with a measured no. Next-line is too narrow for a multi-line element, whole-file is too wide, and the pair is what closes the gap.

Behaviour change

User-visible for anyone on the template-lint-migration config. Newly converted templates preserve scope exactly, so --fix should not change which violations report. Templates converted by an earlier version keep their over-broad file-wide directives and want a manual pass — re-running --fix will not find them, since they no longer match.

Docs

Three claims were wrong and are corrected: the rule doc presented the file-wide output as correct (contradicting the README, which is what made the behaviour look deliberate); the rule doc said -tree has no ESLint equivalent; and the README said only two scopes are expressible.

@johanrd
johanrd marked this pull request as draft July 31, 2026 13:56
@johanrd
johanrd force-pushed the fix/template-lint-directive-next-line-scope branch from b0dd449 to a796013 Compare July 31, 2026 14:43
@johanrd johanrd changed the title fix(template-no-template-lint-directives): convert disables to next-line scope fix(template-no-template-lint-directives): preserve directive scope on conversion Jul 31, 2026
…n conversion

The fixer emitted `eslint-disable` for every `template-lint-disable`, but that
directive carries three different scopes depending on placement, and only one
of them is file-wide. ember-template-lint's docs/configuration.md:

  An in-element instruction will apply to only that element
  An in-element instruction with the `-tree` suffix will apply to that element
  and all its descendants

Converting either in-element form to a file-wide `eslint-disable` widened a
one-element suppression into the rest of the file, so a single migrated
directive silently swallowed every later violation of that rule:

    <div
      {{! template-lint-disable no-positive-tabindex }}
      tabindex="1"
    ></div>
    <span tabindex="2"></span>

ember-template-lint reports the <span>; after `--fix` nothing reported at all.

ESLint has no element scope, but an `eslint-disable` / `eslint-enable` pair
delimits an arbitrary region, which reproduces every template-lint scope
exactly. Closing the region as the element's first child covers the opening tag
alone; closing it after the element covers the subtree. So:

  standing alone   -> eslint-disable (rest of file)
  in opening tag   -> eslint-disable before element, eslint-enable first child
  `-tree` suffix   -> eslint-disable before element, eslint-enable after it

`-tree` directives were previously not matched at all and migrated to nothing
without a report; they now convert.

Removing the converted directive is by line only when the comment has that line
to itself. A directive written inline — `<div {{! template-lint-disable … }}
tabindex="1">` — shares its line with the tag, and reaching back to the line
start would delete `<div ` along with the comment, so the inline form strips
just the comment and the single space it sat in.

The closing comment is inserted without a surrounding newline: `{{! }}`
compiles away, but a newline would add a whitespace text node and can change
inline layout.

Tests assert suppression scope end-to-end — migrate, then lint — against the
scopes ember-template-lint 7.9.3 produces on the same templates, rather than
only checking the rewritten comment text.

The rule doc claimed the file-wide output was correct and that `-tree` had no
ESLint equivalent; the README claimed only two scopes were expressible. Both
are corrected.
@johanrd
johanrd force-pushed the fix/template-lint-directive-next-line-scope branch from a796013 to a4aadc6 Compare August 2, 2026 18:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant