Skip to content

feat: use LinterHost for linting#1605

Merged
lishaduck merged 14 commits intoflint-fyi:mainfrom
auvred:use-vfs-for-linting
Jan 19, 2026
Merged

feat: use LinterHost for linting#1605
lishaduck merged 14 commits intoflint-fyi:mainfrom
auvred:use-vfs-for-linting

Conversation

@auvred
Copy link
Member

@auvred auvred commented Jan 13, 2026

PR Checklist

Overview

I could probably split this up into two PRs: one to pass LinterHost everywhere around and another to remove @typescript/vfs. But the diff is a moderate size, so I think this is fine.

Nice bonus: on my machine, running pnpm vitest --run packages/ts takes:

@changeset-bot
Copy link

changeset-bot bot commented Jan 13, 2026

🦋 Changeset detected

Latest commit: e28e4ae

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 25 packages
Name Type
@flint.fyi/rule-tester Minor
@flint.fyi/core Minor
@flint.fyi/cli Minor
@flint.fyi/typescript-language Minor
@flint.fyi/browser Patch
@flint.fyi/json Patch
@flint.fyi/jsx Patch
@flint.fyi/md Patch
@flint.fyi/node Patch
@flint.fyi/performance Patch
@flint.fyi/plugin-flint Patch
@flint.fyi/spelling Patch
@flint.fyi/ts Patch
@flint.fyi/yaml Patch
@flint.fyi/astro Patch
@flint.fyi/json-language Patch
@flint.fyi/markdown-language Patch
@flint.fyi/next Patch
@flint.fyi/nuxt Patch
@flint.fyi/package-json Patch
@flint.fyi/react Patch
@flint.fyi/solid Patch
@flint.fyi/text-language Patch
@flint.fyi/yaml-language Patch
@flint.fyi/site Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel
Copy link

vercel bot commented Jan 13, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Review Updated (UTC)
flint Ready Ready Preview, Comment Jan 19, 2026 7:19am

Request Review

@@ -3,7 +3,7 @@
"tsBuildInfoFile": "node_modules/.cache/tsbuild/info.test.json",
"rootDir": "src/",
"outDir": "node_modules/.cache/tsbuild/test",
"types": []
"types": ["node"]
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need this so that import.meta.dirname is available

Comment on lines +10 to +11
// TODO: remove this; there is a bug in blobReadingMethods - it doesn't respect type from @types/node
lib: ["DOM"],
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO: file followup issue once this PR merged

Comment on lines +57 to +85
const patchedReaddirSync: typeof fs.readdirSync = (readPath, options) => {
assert(
typeof options === "object" &&
options != null &&
Object.keys(options).length === 1 &&
options.withFileTypes === true,
`ts.sys.readDirectory passed unexpected options to fs.readdirSync: ${JSON.stringify(options)}`,
);
assert(
typeof readPath === "string",
"ts.sys.readDirectory passed unexpected path to fs.readdirSync",
);
try {
fs.readdirSync = originalReadDirSync;
return host
.readDirectory(path.resolve(host.getCurrentDirectory(), readPath))
.map(
(dirent) =>
new DirentCtor(
dirent.name,
dirent.type === "file"
? UV_DIRENT_TYPE.UV_DIRENT_FILE
: UV_DIRENT_TYPE.UV_DIRENT_DIR,
readPath,
),
);
} finally {
fs.readdirSync = patchedReaddirSync;
}
Copy link
Member Author

@auvred auvred Jan 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reasoning from #1221:

TypeScript allows users of its Compiler API to provide their own implementation of ts.System, a collection of host operation abstractions. One of these operations is readDirectory. At first glance, it may seem roughly equivalent to fs.readdirSync, but in reality it is a full-blown, filterable function with recursion support. Its signature is: readDirectory(directoryPath, extensions, exclude, include, depth).

If someone (me) wants to implement their own VFS overlay on top of the disk FS without loosing semantic correctness (the original readDirectory has many quircks), they would have to copy the original readDirectory implementation from the TypeScript source code. This is because TypeScript doesn't export the internal primitives used by readDirectory.

The total amount of code used involved in the original readDirectory implementation is ~1500 LOC!!! For reference, Volar also ended up copying TypeScript source code.

Fortunatelly, I was able to narrow this down to ~50 LOC by patching the fs.readdirSync call used inside the original readDirectory. This approach is far from perfect, but it's definitely better than copying 1500 LOC of TypeScript source code to Flint.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO: bump typescript-eslint to 8.53.0 so that deps are deduped. I didn't to it because it introduces a new fixer, and the diff would be big.

"typescript-eslint": "8.50.0",

Copy link
Collaborator

@JoshuaKGoldberg JoshuaKGoldberg left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks great to me! I just have a few questions and suggestions about the TS-specific host (putting it in rule-tester vs. ts; having it at all).


const host = createEphemeralLinterHost(
createDiskBackedLinterHost(process.cwd()),
);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Non-Actionable] Hmm, runCliOnce is called by runCliWatch. runCliWatch might want to set up a longer-lived linter host... but I don't think we should block this PR on that. That's a nice-to-have optimization for now IMO.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, we could also reuse disk-backed LinterHost's watchDirectory there. I'll file a followup.

defaultCompilerOptions?: Record<string, unknown>;
}

export function createRuleTesterTSHost(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Refactor] 🤔 I'm not sold on making a language-specific host that's so hardcoded. I'd think many languages will eventually want to have their own customizations. My intuition is it'd be cleanest to have the RuleTester constructor take in whatever settings are needed.

If I'm understanding it right, there are roughly four customizations done here:

  1. Setting the dirname to a directory that doesn't exist
  2. Creating an ephemeral + disk-backed host for that directory, then a VFS host on top of that
  3. Upserting some TypeScript-specific files
  4. Stopping fs stat calls from reaching any tsconfig.json

Is that about right?

If so: how about the RuleTester...

  1. Always creates a host set to completely virtual directory - so no "polluting" it with files on disk not explicitly declared
  2. Has a "default files" constructor option to pre-populate that virtual directory with files such as tsconfigs
  3. Allows tests to override those files as needed

(where 3 is the #621 followup)

?

Copy link
Member Author

@auvred auvred Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I'm understanding it right, there are roughly four customizations done here:

  1. Setting the dirname to a directory that doesn't exist
  2. Creating an ephemeral + disk-backed host for that directory, then a VFS host on top of that
  3. Upserting some TypeScript-specific files
  4. Stopping fs stat calls from reaching any tsconfig.json

Is that about right?

Yes! Although, on second thought, preventing stat calls is not really useful here since the overlay VFS always has a tsconfig.json.

If so: how about the RuleTester...

  1. Always creates a host set to completely virtual directory - so no "polluting" it with files on disk not explicitly declared

Do you mean createVFSLinterHost({ baseHost: createDiskBackedLinterHost(), cwd: 'some-virtual-directory' })?

If so, only TS-based languages need to read disk (for node_modules only). All other languages should be fine with createVFSLinterHost() alone. They don't need to be backed by disk. So if we move this to the RuleTester constructor, we probably should introduce a new option - allow/disallow disk access.

  1. Has a "default files" constructor option to pre-populate that virtual directory with files such as tsconfigs

So every plugin that uses TS language would have to provide tsconfig.json + tsconfig.base.json individually? Or maybe we can export these two from TS plugin to avoid duplication...

I was hoping to keep things as composabile as possible, so RuleTester users can do whatever they want with LinterHost. For example, someone might want to test a situation where a peer dependency is missing on disk. The only way to do that is to provide an overlay that blocks access to individual files. If we construct LinterHost entirely inside RuleTester, it won't be as composable.

That said, it may turn out that this use case never comes up. And we can always move to this composable format later if needed!

Copy link
Collaborator

@JoshuaKGoldberg JoshuaKGoldberg Jan 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you mean

Yes, that 👍

So every plugin that uses TS language would have to provide tsconfig.json + tsconfig.base.json individually?

That's what I was thinking, yes. I don't think they'll always be the same - e.g. the browser and JSX plugins will want DOM types, but I don't think the Node.js and Performance ones will.

That said, it may turn out that this use case never comes up. And we can always move to this composable format later if needed!

Same thought, yeah. It'd be nice in theory to be composable but for now I think it'll be easier for us to start with a smaller, more "there's one main way to do it" API.

Copy link
Member Author

@auvred auvred Jan 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same thought, yeah. It'd be nice in theory to be composable but for now I think it'll be easier for us to start with a smaller, more "there's one main way to do it" API.

👍

Then this PR is ready for the review, I think

UPD: oops, let me resolve merge conflicts first

Copy link
Collaborator

@JoshuaKGoldberg JoshuaKGoldberg Jan 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome!

If so, only TS-based languages need to read disk (for node_modules only).

Yeah, this "one particular language has one particular need" has been bugging me. I see why you set this up to allow them to do so (and why it was required) but I'd love to have a followup to smooth that little wrinkle out. Maybe node_modules/ should just always be available ask a disk-backed part of the tester hosts? 🤷 This is definitely fine -better, really- as a followup discussion/issue IMO.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixes #73

I think this gets most of the way there, but there are still both prepareFromDisk and prepareFromVirtual. I was thinking resolving the issue would mean actually merging those two into one API. Like a unified prepare() or prepareFile() kind of name. And then later on, once those are simplified, it'd be easier to take a next step towards #1291.

export const textLanguage = createLanguage<TextNodes, TextFileServices>({
	about: {
		name: "Text",
	},
	createFileFactory: (host) => {
		return {
			prepareFile: (data) => {
				const sourceText =
					data.sourceText ?? host.readFile(data.filePathAbsolute);

				// (or maybe return undefined, or return the Error, ...)
				if (!sourceText) {
					throw new Error(`Could not find file '${data.filePathAbsolute}'.`);
				}

				return {
					file: createTextFile({
						...data,
						sourceText,
					}),
				};
			},
		};
	},
});

Does that differ from where you see this all evolving?

Not a blocker IMO, just a note that if this isn't done in this PR I think we'll have more work to do to streamline the file prep APIs.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, we have an issue for this #1500. I decided to split it out because it would require touching all language file factories.

@JoshuaKGoldberg JoshuaKGoldberg added the status: waiting for author Needs an action taken by the original poster label Jan 13, 2026
@github-actions github-actions bot removed the status: waiting for author Needs an action taken by the original poster label Jan 14, 2026
@auvred auvred requested review from JoshuaKGoldberg and removed request for JoshuaKGoldberg January 18, 2026 16:13
@github-actions github-actions bot removed the status: waiting for author Needs an action taken by the original poster label Jan 18, 2026
Copy link
Collaborator

@JoshuaKGoldberg JoshuaKGoldberg left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔥🔥🔥 Fantastic! I'm pumped we're moving so solidly into a host + virtual files setup like this. Not just because of the great perf speedup mentioned in the OP, but because this is really important work for filling out Flint's core fs architecture. Really solid work @auvred, nicely done!

If anybody from @flint-fyi/committer or anybody else from @flint-fyi/maintainer has time to look this over, that'd be great. But I recognize this has been open for a few days and is pretty deep, entrenched work that also is upstream of some more stuff. I think it's fine to merge whenever you want to @auvred.

defaultCompilerOptions?: Record<string, unknown>;
}

export function createRuleTesterTSHost(
Copy link
Collaborator

@JoshuaKGoldberg JoshuaKGoldberg Jan 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome!

If so, only TS-based languages need to read disk (for node_modules only).

Yeah, this "one particular language has one particular need" has been bugging me. I see why you set this up to allow them to do so (and why it was required) but I'd love to have a followup to smooth that little wrinkle out. Maybe node_modules/ should just always be available ask a disk-backed part of the tester hosts? 🤷 This is definitely fine -better, really- as a followup discussion/issue IMO.

Comment on lines +38 to +43
for (const oldFile of linterHost.vfsListFiles().keys()) {
if (oldFile !== filePathAbsolute) {
linterHost.vfsDeleteFile(oldFile);
}
}
linterHost.vfsUpsertFile(filePathAbsolute, code);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need the !== check if we upsert immediately after? Is upserting just cheaper than inserting?

Copy link
Member Author

@auvred auvred Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deleting and then inserting triggers the watcher twice. If we upsert instead, the watcher will be called only once.

host: createTypeScriptServerHost(host),
});

function prepareFile(data: FileAboutData) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a particular reason to put this as a closure rather than keeping this a separate file? IMO it makes it harder to see what's going on in the function. Then again this is going away soon, so probably not worth refactoring.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, there will be a lot of stuff happening for Volar integration, so it will probably get refactored anyway.

Copy link
Member

@lishaduck lishaduck left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

I left a few nits but I don't think they're necessary to address, especially given how much more churn this'll be going through in the near future.

Copy link
Collaborator

@JoshuaKGoldberg JoshuaKGoldberg left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1950s-style Batman and Robin doing a dance on stage kicking their legs up

@lishaduck
Copy link
Member

I don't think there's anything left to merge, so... 🟩

@lishaduck lishaduck merged commit e257ec4 into flint-fyi:main Jan 19, 2026
11 checks passed
@lishaduck
Copy link
Member

🥳

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

4 participants