diff --git a/src/parser/transforms.js b/src/parser/transforms.js
index cf52e51..ba1efa5 100644
--- a/src/parser/transforms.js
+++ b/src/parser/transforms.js
@@ -279,8 +279,11 @@ export const replaceRange = function replaceRange(s, start, end, substitute) {
};
function maskTemplateContentForLint(content) {
- // Preserve line endings and UTF-16 length, but remove template syntax from the JS placeholder.
- return content.replace(/[^\r\n]/g, ' ');
+ // Must produce byte-identical output to ember-estree's toPlaceholderJS
+ // masking, or typescript-eslint sees two different contents for the same
+ // .gts file (disk read vs lint parse) and invalidates + rebuilds the
+ // program on every file.
+ return content.replace(/[`$]/g, ' ');
}
const processor = new Preprocessor();
diff --git a/tests/placeholder-parity.test.js b/tests/placeholder-parity.test.js
new file mode 100644
index 0000000..ea03919
--- /dev/null
+++ b/tests/placeholder-parity.test.js
@@ -0,0 +1,59 @@
+import { describe, expect, it } from 'vitest';
+import { toTree } from 'ember-estree';
+import { transformForLint } from '../src/parser/transforms.js';
+
+/**
+ * transformForLint (used by the patched ts.sys.readFile for type-aware
+ * linting) must produce byte-identical output to the placeholder JS that
+ * ember-estree's toTree hands to the JS/TS parser at lint time.
+ *
+ * typescript-eslint hashes the code passed to parseForESLint and compares it
+ * against what the watch program last read off disk; any difference marks the
+ * file as changed and silently rebuilds the whole program inside
+ * getProgram() — once per .gts/.gjs file linted (#229).
+ */
+
+function toTreePlaceholder(code) {
+ let placeholder;
+ try {
+ toTree(code, {
+ filePath: 'x.gts',
+ parser: (js) => {
+ placeholder = js;
+ // Only the placeholder is needed; abort before JS parsing.
+ throw new Error('stop');
+ },
+ });
+ } catch {
+ // expected
+ }
+ return placeholder;
+}
+
+const cases = {
+ 'backtick-heavy template comments (#226)': `import Component from '@glimmer/component';
+
+export default class MyComponent extends Component {
+
+ {{! \`asd\` \`qwe\` \`zxc\` \`undefined\` \`asd\` }}
+ {{! \`@foo\` }}
+
+}
+`,
+ 'expression template with dollar signs': `export const x = costs \${{amount}} \`really\` $$$;
+`,
+ 'multibyte content (emoji, CJK)': `export const y = 🎉 日本語 \` $ 🚀;
+`,
+ 'CRLF line endings': `export const z = \r\n hi \`there\`\r\n;\r\n`,
+ 'multiple templates in one module': `export const a = one \`x\`;
+export const b = two $y;
+`,
+};
+
+describe('transformForLint matches toTree placeholder byte-for-byte', () => {
+ for (const [name, code] of Object.entries(cases)) {
+ it(name, () => {
+ expect(transformForLint(code).output).toBe(toTreePlaceholder(code));
+ });
+ }
+});