Skip to content

Commit ce3b401

Browse files
committed
feat: add codemod to migrate template-lint-disable comments
Adds a one-shot codemod that rewrites ember-template-lint comment directives to ESLint-native equivalents, so projects migrating to eslint-plugin-ember can keep their in-template suppression comments working without reimplementing ETL's directive semantics as an ESLint processor. Scope is grounded in what ETL itself parses today (lib/rules/_base.js:247-337): - Mustache comments only ({{! ... }} and {{!-- ... --}}); HTML comments are skipped because ETL does not honour directives in them either. - template-lint-disable / template-lint-enable rewrite to eslint-disable / eslint-enable with rules remapped from the bare ETL name (no-foo) to the plugin rule ID (ember/template-no-foo). - template-lint-disable-tree / -enable-tree and template-lint-configure[-tree] are left unchanged with a warning; they have no ESLint equivalent (tree scope / in-template rule config respectively). - Unknown rule names are still rewritten so they surface in lint output, with a warning. Includes a CLI entry (dry-run by default, --write to apply) and 26 tests covering simple and block forms, multi-line block comments, quoted and whitespace-separated rule lists, skip+warn paths, line numbers in warnings, idempotency, and context preservation. README gains a "Rewriting {{! template-lint-disable }} comments" subsection under the ETL migration guide.
1 parent 76a147f commit ce3b401

3 files changed

Lines changed: 443 additions & 0 deletions

File tree

README.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,49 @@ export default [
174174

175175
`recommended-template` mirrors the ember-template-lint `recommended` preset.
176176

177+
### Rewriting `{{! template-lint-disable }}` comments
178+
179+
ESLint does not recognise `template-lint-disable` / `template-lint-enable`
180+
comments. The plugin ships a codemod that rewrites them to the equivalent
181+
ESLint-native directives, which this plugin already honours in mustache
182+
comments inside `.hbs`, `.gjs`, and `.gts` files.
183+
184+
```sh
185+
# Dry run — reports planned changes and warnings, writes nothing.
186+
node node_modules/eslint-plugin-ember/scripts/migrate-template-lint-directives.js app/ tests/
187+
188+
# Apply.
189+
node node_modules/eslint-plugin-ember/scripts/migrate-template-lint-directives.js --write app/ tests/
190+
```
191+
192+
Example rewrites:
193+
194+
| Before | After |
195+
| --- | --- |
196+
| `{{! template-lint-disable no-bare-strings }}` | `{{! eslint-disable ember/template-no-bare-strings }}` |
197+
| `{{!-- template-lint-disable no-bare-strings --}}` | `{{!-- eslint-disable ember/template-no-bare-strings --}}` |
198+
| `{{! template-lint-enable no-bare-strings }}` | `{{! eslint-enable ember/template-no-bare-strings }}` |
199+
| `{{! template-lint-disable }}` (all rules) | `{{! eslint-disable }}` |
200+
201+
Scope carries over directly: a bare disable is line-based block scope — open
202+
until a matching `eslint-enable` or end of file — which is how ESLint itself
203+
treats `eslint-disable` / `eslint-enable` pairs.
204+
205+
Directives with no ESLint equivalent are left **unchanged** with a warning:
206+
207+
- `template-lint-disable-tree` / `template-lint-enable-tree` — ESLint has no
208+
tree scope. Rewrite manually as a block-scoped `eslint-disable` /
209+
`eslint-enable` pair spanning the relevant element.
210+
- `template-lint-configure` / `template-lint-configure-tree` — in-template
211+
rule configuration has no ESLint equivalent. Move the configuration to
212+
your flat config file.
213+
214+
Unknown rule names (no matching `ember/template-*` rule in this plugin) are
215+
still rewritten with a warning so you can review and remove them.
216+
217+
HTML comments (`<!-- template-lint-disable -->`) are not transformed —
218+
ember-template-lint itself does not parse directives from HTML comments.
219+
177220
## 🧰 Configurations
178221

179222
<!-- begin auto-generated configs list -->
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
'use strict';
2+
3+
const fs = require('fs');
4+
const path = require('path');
5+
6+
const BLOCK_REGEX = /\{\{!--([\s\S]*?)--\}\}/g;
7+
const SIMPLE_REGEX = /\{\{!(?!--)([^}]*?)\}\}/g;
8+
const DIRECTIVE_RE =
9+
/^template-lint-(disable-tree|enable-tree|configure-tree|disable|enable|configure)(?:\s+([\s\S]*))?$/;
10+
11+
function parseRules(rest) {
12+
return rest
13+
.split(/\s+/)
14+
.filter(Boolean)
15+
.map((r) => r.replace(/^['"]|['"]$/g, ''));
16+
}
17+
18+
function rewriteDirective(body, form, knownRules, warn) {
19+
const match = body.trim().match(DIRECTIVE_RE);
20+
if (!match) return null;
21+
const name = match[1];
22+
const rest = (match[2] || '').trim();
23+
24+
if (name.startsWith('configure')) {
25+
warn(
26+
`template-lint-${name} has no ESLint equivalent; move configuration to your flat config file`
27+
);
28+
return null;
29+
}
30+
if (name.endsWith('-tree')) {
31+
warn(
32+
`template-lint-${name} (tree scope) has no ESLint equivalent; rewrite manually as a block-scoped eslint-disable/enable pair`
33+
);
34+
return null;
35+
}
36+
37+
const rules = parseRules(rest);
38+
const unknown = [];
39+
const mapped = rules.map((r) => {
40+
const pluginRuleId = `template-${r}`;
41+
if (!knownRules.has(pluginRuleId)) unknown.push(r);
42+
return `ember/${pluginRuleId}`;
43+
});
44+
if (unknown.length > 0) {
45+
warn(
46+
`no matching rule in eslint-plugin-ember for: ${unknown.join(
47+
', '
48+
)} (comment still rewritten — verify or remove)`
49+
);
50+
}
51+
52+
const action = name === 'disable' ? 'eslint-disable' : 'eslint-enable';
53+
const ruleList = mapped.length > 0 ? ' ' + mapped.join(', ') : '';
54+
return form === 'block'
55+
? `{{!-- ${action}${ruleList} --}}`
56+
: `{{! ${action}${ruleList} }}`;
57+
}
58+
59+
function computeLine(text, offset) {
60+
let line = 1;
61+
for (let i = 0; i < offset && i < text.length; i++) {
62+
if (text.charCodeAt(i) === 10) line++;
63+
}
64+
return line;
65+
}
66+
67+
function transform(source, { knownRules, onWarn } = {}) {
68+
const warnings = [];
69+
const emit = (msg) => {
70+
warnings.push(msg);
71+
if (onWarn) onWarn(msg);
72+
};
73+
const changes = [];
74+
75+
const scan = (regex, form) => {
76+
for (const m of source.matchAll(regex)) {
77+
const line = computeLine(source, m.index);
78+
const rewritten = rewriteDirective(m[1], form, knownRules, (w) =>
79+
emit(`line ${line}: ${w}`)
80+
);
81+
if (rewritten != null) {
82+
changes.push({ start: m.index, end: m.index + m[0].length, replacement: rewritten });
83+
}
84+
}
85+
};
86+
scan(BLOCK_REGEX, 'block');
87+
scan(SIMPLE_REGEX, 'simple');
88+
89+
changes.sort((a, b) => b.start - a.start);
90+
let output = source;
91+
for (const c of changes) {
92+
output = output.slice(0, c.start) + c.replacement + output.slice(c.end);
93+
}
94+
95+
return { output, warnings, changed: changes.length > 0 };
96+
}
97+
98+
function loadKnownRules() {
99+
const rulesDir = path.join(__dirname, '..', 'lib', 'rules');
100+
return new Set(
101+
fs
102+
.readdirSync(rulesDir)
103+
.filter((f) => f.endsWith('.js'))
104+
.map((f) => f.slice(0, -3))
105+
);
106+
}
107+
108+
module.exports = { transform, parseRules, loadKnownRules };
109+
110+
if (require.main === module) {
111+
const args = process.argv.slice(2);
112+
const write = args.includes('--write');
113+
const inputs = args.filter((a) => !a.startsWith('--'));
114+
115+
if (inputs.length === 0) {
116+
console.error(
117+
'usage: migrate-template-lint-directives.js [--write] <files-or-dirs>...\n' +
118+
' Without --write, performs a dry run and reports planned changes.'
119+
);
120+
process.exit(1);
121+
}
122+
123+
const knownRules = loadKnownRules();
124+
const files = [];
125+
for (const p of inputs) {
126+
const stat = fs.statSync(p);
127+
if (stat.isDirectory()) {
128+
for (const e of fs.readdirSync(p, { recursive: true })) {
129+
if (/\.(hbs|gjs|gts)$/.test(e)) files.push(path.join(p, e));
130+
}
131+
} else {
132+
files.push(p);
133+
}
134+
}
135+
136+
let changedFiles = 0;
137+
let warningCount = 0;
138+
for (const file of files) {
139+
const src = fs.readFileSync(file, 'utf8');
140+
const { output, warnings, changed } = transform(src, { knownRules });
141+
if (changed) {
142+
changedFiles++;
143+
if (write) fs.writeFileSync(file, output, 'utf8');
144+
console.log(`${write ? 'wrote' : 'would write'}: ${file}`);
145+
}
146+
for (const w of warnings) {
147+
warningCount++;
148+
console.warn(`${file}: ${w}`);
149+
}
150+
}
151+
152+
console.log(
153+
`\n${changedFiles} file(s) ${write ? 'rewritten' : 'would be rewritten'}, ${warningCount} warning(s)`
154+
);
155+
if (!write && changedFiles > 0) {
156+
console.log('Re-run with --write to apply changes.');
157+
}
158+
}

0 commit comments

Comments
 (0)