Skip to content

Commit 8d479a5

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 8d479a5

3 files changed

Lines changed: 570 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: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
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+
// Rule names are conventionally lowercase kebab-case with at least one hyphen
11+
// (all ported template rules follow `no-X`, `require-X`, `attribute-X`, etc.).
12+
// Requiring a hyphen lets us reject plain words that a user may have written
13+
// after the rule list (e.g. `template-lint-disable no-bare-strings extra text`)
14+
// before we emit bogus `ember/template-extra` IDs.
15+
const VALID_RULE_NAME = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)+$/;
16+
17+
function parseRules(rest) {
18+
return rest
19+
.split(/\s+/)
20+
.filter(Boolean)
21+
.map((r) =>
22+
r
23+
// Strip paired surrounding quotes (matches ETL's unquote behavior).
24+
.replace(/^(['"])(.*)\1$/, '$2')
25+
// Strip trailing commas — common typo for users migrating from ESLint habits.
26+
.replace(/,+$/, '')
27+
);
28+
}
29+
30+
function rewriteDirective(body, form, knownRules, warn) {
31+
const match = body.trim().match(DIRECTIVE_RE);
32+
if (!match) return null;
33+
const name = match[1];
34+
const rest = (match[2] || '').trim();
35+
36+
if (name.startsWith('configure')) {
37+
warn(
38+
`template-lint-${name} has no ESLint equivalent; move configuration to your flat config file`
39+
);
40+
return null;
41+
}
42+
if (name.endsWith('-tree')) {
43+
warn(
44+
`template-lint-${name} (tree scope) has no ESLint equivalent; rewrite manually as a block-scoped eslint-disable/enable pair`
45+
);
46+
return null;
47+
}
48+
49+
const rules = parseRules(rest);
50+
const invalid = rules.filter((r) => !VALID_RULE_NAME.test(r));
51+
if (invalid.length > 0) {
52+
// Malformed rule list (trailing prose, special chars, etc.) — refuse to
53+
// rewrite rather than emit bogus ESLint directives like "ember/template-extra".
54+
warn(
55+
`rule list contains tokens that are not valid rule names: ${invalid
56+
.map((r) => JSON.stringify(r))
57+
.join(', ')}; comment left unchanged`
58+
);
59+
return null;
60+
}
61+
62+
const unknown = rules.filter((r) => !knownRules.has(`template-${r}`));
63+
if (unknown.length > 0) {
64+
warn(
65+
`no matching rule in eslint-plugin-ember for: ${unknown.join(
66+
', '
67+
)} (comment still rewritten — verify or remove)`
68+
);
69+
}
70+
71+
const action = name === 'disable' ? 'eslint-disable' : 'eslint-enable';
72+
const mapped = rules.map((r) => `ember/template-${r}`);
73+
const ruleList = mapped.length > 0 ? ' ' + mapped.join(', ') : '';
74+
return form === 'block'
75+
? `{{!-- ${action}${ruleList} --}}`
76+
: `{{! ${action}${ruleList} }}`;
77+
}
78+
79+
function computeLine(text, offset) {
80+
let line = 1;
81+
for (let i = 0; i < offset && i < text.length; i++) {
82+
if (text.charCodeAt(i) === 10) line++;
83+
}
84+
return line;
85+
}
86+
87+
function transform(source, { knownRules } = {}) {
88+
const warnings = [];
89+
const blockRanges = []; // [start, end) — used to suppress SIMPLE matches nested inside BLOCK bodies
90+
const changes = [];
91+
92+
for (const m of source.matchAll(BLOCK_REGEX)) {
93+
const start = m.index;
94+
const end = start + m[0].length;
95+
blockRanges.push([start, end]);
96+
const line = computeLine(source, start);
97+
const rewritten = rewriteDirective(m[1], 'block', knownRules, (w) =>
98+
warnings.push(`line ${line}: ${w}`)
99+
);
100+
if (rewritten != null) {
101+
changes.push({ start, end, replacement: rewritten });
102+
}
103+
}
104+
105+
for (const m of source.matchAll(SIMPLE_REGEX)) {
106+
const start = m.index;
107+
// Skip SIMPLE matches that fall inside a BLOCK comment body — e.g. text
108+
// like `{{! template-lint-... }}` accidentally appearing inside a
109+
// `{{!-- ... --}}` block would otherwise be rewritten.
110+
if (blockRanges.some(([bs, be]) => start >= bs && start < be)) continue;
111+
const end = start + m[0].length;
112+
const line = computeLine(source, start);
113+
const rewritten = rewriteDirective(m[1], 'simple', knownRules, (w) =>
114+
warnings.push(`line ${line}: ${w}`)
115+
);
116+
if (rewritten != null) {
117+
changes.push({ start, end, replacement: rewritten });
118+
}
119+
}
120+
121+
changes.sort((a, b) => b.start - a.start);
122+
let output = source;
123+
for (const c of changes) {
124+
output = output.slice(0, c.start) + c.replacement + output.slice(c.end);
125+
}
126+
127+
return { output, warnings, changed: changes.length > 0 };
128+
}
129+
130+
function loadKnownRules() {
131+
const rulesDir = path.join(__dirname, '..', 'lib', 'rules');
132+
return new Set(
133+
fs
134+
.readdirSync(rulesDir)
135+
.filter((f) => f.endsWith('.js'))
136+
.map((f) => f.slice(0, -3))
137+
);
138+
}
139+
140+
module.exports = { transform, parseRules, loadKnownRules };
141+
142+
// Directories we never want to walk into when a directory is passed as input —
143+
// running the codemod from a repo root should not descend into vendored
144+
// templates or build output.
145+
const EXCLUDED_DIRS = new Set(['node_modules', '.git', 'dist', 'tmp', '.cache']);
146+
const TEMPLATE_EXT = /\.(hbs|gjs|gts)$/;
147+
148+
function collectFiles(inputPath) {
149+
let stat;
150+
try {
151+
stat = fs.statSync(inputPath);
152+
} catch (err) {
153+
const msg = err.code === 'ENOENT' ? `no such file or directory: ${inputPath}` : err.message;
154+
throw new Error(msg);
155+
}
156+
157+
if (!stat.isDirectory()) return [inputPath];
158+
159+
const result = [];
160+
for (const entry of fs.readdirSync(inputPath, { recursive: true })) {
161+
const segments = entry.split(path.sep);
162+
if (segments.some((s) => EXCLUDED_DIRS.has(s))) continue;
163+
if (TEMPLATE_EXT.test(entry)) result.push(path.join(inputPath, entry));
164+
}
165+
return result;
166+
}
167+
168+
if (require.main === module) {
169+
const args = process.argv.slice(2);
170+
const write = args.includes('--write');
171+
const inputs = args.filter((a) => !a.startsWith('--'));
172+
173+
if (inputs.length === 0) {
174+
console.error(
175+
'usage: migrate-template-lint-directives.js [--write] <files-or-dirs>...\n' +
176+
' Without --write, performs a dry run and reports planned changes.\n' +
177+
` Directory walk excludes: ${[...EXCLUDED_DIRS].join(', ')}.`
178+
);
179+
process.exit(1);
180+
}
181+
182+
const knownRules = loadKnownRules();
183+
const files = [];
184+
let hadInputError = false;
185+
for (const p of inputs) {
186+
try {
187+
files.push(...collectFiles(p));
188+
} catch (err) {
189+
console.error(`error: ${err.message}`);
190+
hadInputError = true;
191+
}
192+
}
193+
if (hadInputError) process.exitCode = 1;
194+
195+
let changedFiles = 0;
196+
let warningCount = 0;
197+
for (const file of files) {
198+
let src;
199+
try {
200+
src = fs.readFileSync(file, 'utf8');
201+
} catch (err) {
202+
console.error(`error reading ${file}: ${err.message}`);
203+
process.exitCode = 1;
204+
continue;
205+
}
206+
const { output, warnings, changed } = transform(src, { knownRules });
207+
if (changed) {
208+
changedFiles++;
209+
if (write) {
210+
try {
211+
fs.writeFileSync(file, output, 'utf8');
212+
} catch (err) {
213+
console.error(`error writing ${file}: ${err.message}`);
214+
process.exitCode = 1;
215+
continue;
216+
}
217+
}
218+
console.log(`${write ? 'wrote' : 'would write'}: ${file}`);
219+
}
220+
for (const w of warnings) {
221+
warningCount++;
222+
console.warn(`${file}: ${w}`);
223+
}
224+
}
225+
226+
console.log(
227+
`\n${changedFiles} file(s) ${write ? 'rewritten' : 'would be rewritten'}, ${warningCount} warning(s)`
228+
);
229+
if (!write && changedFiles > 0) {
230+
console.log('Re-run with --write to apply changes.');
231+
}
232+
}

0 commit comments

Comments
 (0)