Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions src/parser/size-limit-warning.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* TypeScript's ProjectService sums the size of every root it does not
* recognise as TypeScript and, past `maxProgramSizeForNonTsFiles` (20MB),
* calls `disableLanguageService` on the project. A disabled project reports
* only the files the client currently has open as program roots.
*
* `parserOptions.projectService` opens one file at a time, so against a
* demoted project every linted file forces a fresh `ts.Program`. That is
* several times slower than sharing one program, and it also changes results:
* ambient declarations the linted file does not import (`declare global`,
* module augmentation, standalone `.d.ts`) are no longer in the program, so
* type-aware rules see `any` where they would otherwise see a real type.
*
* None of that surfaces anywhere. This turns it into one line on stderr.
*
* @param {typeof import('typescript')} ts
*/
export function warnOnProjectSizeLimitDemotion(ts) {
const proto = ts?.server?.Project?.prototype;
if (!proto || typeof proto.disableLanguageService !== 'function') return;
if (proto.disableLanguageService.__emberEslintParserWarns) return;

const original = proto.disableLanguageService;
let warned = false;

function disableLanguageService(lastFileExceededProgramSize) {
// The argument is only ever passed by the size heuristic, so its presence
// is what distinguishes a demotion from any other reason a project might
// have its language service turned off.
if (lastFileExceededProgramSize && !warned) {
warned = true;
const projectName =
typeof this.getProjectName === 'function' ? this.getProjectName() : '<unknown project>';
const limitMb = Math.round((ts.server.maxProgramSizeForNonTsFiles ?? 0) / (1024 * 1024));
// eslint-disable-next-line no-console
console.warn(
`ember-eslint-parser: TypeScript disabled the language service for ${projectName} ` +
`because its non-TypeScript files exceed the ${limitMb}MB maxProgramSizeForNonTsFiles ` +
`limit (tipped over by ${lastFileExceededProgramSize}).\n` +
` Type-aware linting keeps working, but TypeScript now rebuilds its program for every ` +
`file instead of sharing one, and ambient declarations the linted file does not import ` +
`drop out of that program, so type-aware rules can report differently.\n` +
` Set "disableSizeLimit": true in that tsconfig's compilerOptions to lift the limit. ` +
`Note that .gjs files count toward it because they are JavaScript; .gts files do not.`
);
}
return original.call(this, lastFileExceededProgramSize);
}

// Marked so repeated imports (or a second copy of this package) don't stack
// wrappers on the same prototype.
disableLanguageService.__emberEslintParserWarns = true;
proto.disableLanguageService = disableLanguageService;
}
34 changes: 34 additions & 0 deletions src/parser/ts-patch.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import fs from 'node:fs';
import { createRequire } from 'node:module';
import { transformForLint, replaceRange } from './transforms.js';
import { warnOnProjectSizeLimitDemotion } from './size-limit-warning.js';

const require = createRequire(import.meta.url);

Expand All @@ -12,12 +13,45 @@ try {
const tsPath = require.resolve('typescript', { paths: [parserPath] });
const ts = require(tsPath);
typescriptParser = require('@typescript-eslint/parser');

// Installed at import rather than from patchTs(): typescript-eslint builds
// its ProjectService on the first type-aware parse in the process, which is
// whichever file ESLint reaches first. If that is a plain .ts file, the
// project -- and the size decision that demotes it -- is already made before
// this parser is ever asked for anything. This hook only observes, so there
// is nothing to gate it on.
warnOnProjectSizeLimitDemotion(ts);

patchTs = function patchTs() {
if (isPatched) return;
isPatched = true;
const sys = { ...ts.sys };
const newSys = {
...ts.sys,
// `.gts` is TypeScript with a template in it, but TypeScript has no way
// to be told that. Its ProjectService charges every root it does not
// recognise as TypeScript against `maxProgramSizeForNonTsFiles` (20MB)
// and, past the limit, disables the project's language service — see
// ./size-limit-warning.js for what that costs. `extraFileExtensions`
// looks like the place to declare the truth, but an extension registered
// as ScriptKind.TS is dropped from the supported set outright, so `.gts`
// has to be registered Deferred and Deferred is counted.
//
// Until that is fixed upstream, the only lever left is the size itself:
// report `.gts` as weightless and the project gets measured on the
// JavaScript it actually contains. `.gjs` IS JavaScript, so it keeps its
// weight — an app over the limit on .js + .gjs is still demoted, and the
// warning above is what tells them so.
//
// The other reader of this value is ScriptInfo#getFileTextAndSize, which
// skips loading the contents of non-TS files over 4MB. A `.gts` that big
// will now be loaded rather than blanked, which is what a linter wants.
...(sys.getFileSize && {
getFileSize(fileName) {
if (fileName.endsWith('.gts')) return 0;
return sys.getFileSize.call(this, fileName);
},
}),
readDirectory(...args) {
const results = sys.readDirectory.call(this, ...args);
const gtsVirtuals = results
Expand Down
157 changes: 157 additions & 0 deletions tests/size-limit-project.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';

import { parseForESLint } from '../src/parser/gjs-gts-parser.js';

// End-to-end cover for the size heuristic, which only shows itself above 20MB:
// TypeScript's ProjectService weighs every root it does not recognise as
// TypeScript against `maxProgramSizeForNonTsFiles` and disables the project's
// language service past the limit. A disabled project reports only the files
// the client has open as program roots, so with `parserOptions.projectService`
// -- one open file at a time -- the program collapses to that single file plus
// whatever it imports, and every file linted after it rebuilds from scratch.
//
// Two projects, identical but for the extension carrying the bytes: `.gts` is
// TypeScript and must not count, `.gjs` is JavaScript and must.

const MB = 1024 * 1024;
const BULK_FILES = 21;
const AMBIENT = `declare global {
const AMBIENT_FLAG: string;
}
export {};
`;

const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ee-parser-sizelimit-'));

function makeProject(name, ext) {
const dir = path.join(root, name);
const app = path.join(dir, 'app');
fs.mkdirSync(app, { recursive: true });

// Padding so each file is ~1MB on disk; only the byte total matters here.
const filler = `/*${'x'.repeat(MB)}*/\n`;
const typed = ext === 'gts';
for (let i = 0; i < BULK_FILES; i++) {
fs.writeFileSync(
path.join(app, `bulk${i}.${ext}`),
`${filler}export default class Bulk${i} {
get label()${typed ? ': string' : ''} {
return 'bulk${i}';
}
<template>
<div>{{this.label}}</div>
</template>
}
`
);
}

fs.writeFileSync(path.join(app, 'ambient.d.ts'), AMBIENT);
fs.writeFileSync(path.join(app, 'entry.ts'), 'export const entry = 1;\n');
fs.writeFileSync(
path.join(dir, 'tsconfig.json'),
JSON.stringify({
compilerOptions: {
target: 'ESNext',
module: 'ESNext',
moduleResolution: 'bundler',
allowJs: true,
noEmit: true,
skipLibCheck: true,
},
include: ['app/**/*'],
})
);

return { dir, probe: path.join(app, `bulk0.${ext}`) };
}

// `projectService` is spelled differently across typescript-eslint majors and
// is absent before v7, so probe a throwaway project rather than assuming it.
// The CI matrix runs this suite against @typescript-eslint/parser ^6 upward.
function projectServiceWorks() {
const dir = path.join(root, 'probe');
const app = path.join(dir, 'app');
fs.mkdirSync(app, { recursive: true });
const probe = path.join(app, 'probe.gts');
fs.writeFileSync(probe, 'export const probe = 1;\n<template>hi</template>\n');
fs.writeFileSync(path.join(app, 'sibling.ts'), 'export const sibling = 1;\n');
fs.writeFileSync(
path.join(dir, 'tsconfig.json'),
JSON.stringify({ compilerOptions: { noEmit: true, skipLibCheck: true }, include: ['app/**/*'] })
);
try {
// The sibling is only a root if the tsconfig was actually loaded. A parser
// that ignored the unknown option would hand back a single-file program
// instead, which would fail the real assertions for the wrong reason.
return parseProbe({ dir, probe }).rootFileNames.some((f) => f.endsWith('sibling.ts'));
} catch {
return false;
}
}

function parseProbe({ dir, probe }) {
const result = parseForESLint(fs.readFileSync(probe, 'utf8'), {
filePath: probe,
projectService: true,
tsconfigRootDir: dir,
sourceType: 'module',
ecmaVersion: 'latest',
loc: true,
range: true,
comment: true,
tokens: true,
});
const program = result.services.program;
return {
rootFileNames: program.getRootFileNames(),
hasAmbient: program.getSourceFiles().some((f) => f.fileName.endsWith('app/ambient.d.ts')),
};
}

const supported = projectServiceWorks();

afterAll(() => {
fs.rmSync(root, { recursive: true, force: true });
});

describe.skipIf(!supported)('a project over the 20MB non-TypeScript budget', () => {
let warn;
let gtsHeavy;
let gjsHeavy;

beforeAll(() => {
warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
gtsHeavy = parseProbe(makeProject('gts-heavy', 'gts'));
gjsHeavy = parseProbe(makeProject('gjs-heavy', 'gjs'));
}, 120_000);

afterAll(() => {
warn?.mockRestore();
});

it('keeps a whole program when the bytes are .gts', () => {
// Every file the tsconfig matched is a root, not just the one open file.
// (Root count itself is not exact: patched readDirectory also offers a
// virtual .mts per .gts, which some `include` globs match and some don't.)
const gtsRoots = gtsHeavy.rootFileNames.filter((f) => f.endsWith('.gts'));
expect(gtsRoots.length).toBe(BULK_FILES);
expect(gtsHeavy.hasAmbient).toBe(true);
});

it('still demotes when the bytes are .gjs, which is JavaScript', () => {
expect(gjsHeavy.rootFileNames.length).toBe(1);
// The ambient declaration nothing imports is gone from the program, which
// is why demotion changes type-aware results and not only their speed.
expect(gjsHeavy.hasAmbient).toBe(false);
});

it('says so on stderr instead of silently getting slower', () => {
const message = warn.mock.calls.map((c) => c[0]).find((m) => /disableSizeLimit/.test(m));
expect(message).toBeDefined();
expect(message).toContain('gjs-heavy');
});
});
127 changes: 127 additions & 0 deletions tests/size-limit.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { describe, expect, it, vi } from 'vitest';
import { createRequire } from 'node:module';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';

import { patchTs } from '../src/parser/ts-patch.js';
import { warnOnProjectSizeLimitDemotion } from '../src/parser/size-limit-warning.js';

// Same typescript instance ts-patch patches, so we observe the patched ts.sys.
const require = createRequire(import.meta.url);
const parserPath = require.resolve('@typescript-eslint/parser');
const ts = require(require.resolve('typescript', { paths: [parserPath] }));

// TypeScript's ProjectService sums host.getFileSize() over every root that is
// not a TS extension and, past maxProgramSizeForNonTsFiles (20MB), disables the
// project's language service — which leaves it with only the files the client
// has open as program roots. `.gts` is TypeScript with a template in it, so it
// has no business being weighed against a JavaScript budget. `.gjs` is
// JavaScript and is deliberately still counted.
describe('patched ts.sys.getFileSize — .gts is not charged to the non-TS size budget', () => {
patchTs();

const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ee-parser-size-'));
const write = (name, bytes) => {
const file = path.join(dir, name);
fs.writeFileSync(file, 'x'.repeat(bytes));
return file;
};

const gts = write('component.gts', 4096);
const gjs = write('component.gjs', 4096);
const tsFile = write('component.ts', 4096);
const js = write('component.js', 4096);

it('reports .gts as weightless', () => {
expect(ts.sys.getFileSize(gts)).toBe(0);
});

it('still reports the real size for .gjs, which is JavaScript', () => {
expect(ts.sys.getFileSize(gjs)).toBe(4096);
});

it('leaves .ts and .js sizes alone', () => {
expect(ts.sys.getFileSize(tsFile)).toBe(4096);
expect(ts.sys.getFileSize(js)).toBe(4096);
});

it('reports a missing file as 0, like the unpatched host', () => {
expect(ts.sys.getFileSize(path.join(dir, 'nope.js'))).toBe(0);
});
});

describe('warnOnProjectSizeLimitDemotion', () => {
// A stand-in for the slice of ts.server the hook touches, so the assertions
// don't depend on driving a real ProjectService over a >20MB fixture.
function fakeTs() {
const calls = [];
class Project {
constructor(name) {
this.name = name;
}

getProjectName() {
return this.name;
}

disableLanguageService(lastFileExceededProgramSize) {
calls.push(lastFileExceededProgramSize);
}
}
return {
ts: { server: { Project, maxProgramSizeForNonTsFiles: 20 * 1024 * 1024 } },
calls,
};
}

it('warns once, naming the project and disableSizeLimit', () => {
const { ts: fake, calls } = fakeTs();
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
warnOnProjectSizeLimitDemotion(fake);

new fake.server.Project('/app/tsconfig.json').disableLanguageService('/app/a.gjs');
new fake.server.Project('/other/tsconfig.json').disableLanguageService('/other/b.gjs');

expect(warn).toHaveBeenCalledTimes(1);
const message = warn.mock.calls[0][0];
expect(message).toContain('/app/tsconfig.json');
expect(message).toContain('/app/a.gjs');
expect(message).toContain('disableSizeLimit');
expect(message).toContain('20MB');
// Observation only — the real demotion still has to happen.
expect(calls).toEqual(['/app/a.gjs', '/other/b.gjs']);
} finally {
warn.mockRestore();
}
});

it('stays quiet when the language service is disabled for some other reason', () => {
const { ts: fake, calls } = fakeTs();
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
warnOnProjectSizeLimitDemotion(fake);
new fake.server.Project('/app/tsconfig.json').disableLanguageService();

expect(warn).not.toHaveBeenCalled();
expect(calls).toEqual([undefined]);
} finally {
warn.mockRestore();
}
});

it('does not stack wrappers when applied twice', () => {
const { ts: fake } = fakeTs();
warnOnProjectSizeLimitDemotion(fake);
const first = fake.server.Project.prototype.disableLanguageService;
warnOnProjectSizeLimitDemotion(fake);

expect(fake.server.Project.prototype.disableLanguageService).toBe(first);
});

it('is a no-op against a typescript build with no server API', () => {
expect(() => warnOnProjectSizeLimitDemotion({})).not.toThrow();
expect(() => warnOnProjectSizeLimitDemotion(undefined)).not.toThrow();
});
});
Loading