Skip to content

[heft-lint-plugin] Lint files selected by ESLint flat config - #6006

Draft
Ian Clanton-Thuon (iclanton) wants to merge 6 commits into
microsoft:mainfrom
iclanton:fix/heft-lint-flat-config-files
Draft

[heft-lint-plugin] Lint files selected by ESLint flat config#6006
Ian Clanton-Thuon (iclanton) wants to merge 6 commits into
microsoft:mainfrom
iclanton:fix/heft-lint-flat-config-files

Conversation

@iclanton

@iclanton Ian Clanton-Thuon (iclanton) commented Sep 2, 2026

Copy link
Copy Markdown
Member

Summary

The Heft lint plugin currently sends ESLint only the files from the TypeScript program. As a result, files selected exclusively by the ESLint flat config — for example configuration files, tests excluded from tsconfig.json, or custom extensions such as Markdown or JSON — are never linted.

This change uses ESLint's native flat-config enumeration to discover those files and lints them through the plugin's existing cache, fix, diagnostic, and SARIF pipeline.

Details

For ESLint 9, the plugin creates a discovery-only ESLint instance and calls lintFiles() with rules disabled to enumerate the files the flat config selects. TypeScript program roots and primary TypeScript emit directories are excluded from discovery. ESLint's built-in JavaScript extensions (.js, .mjs, .cjs) are excluded from the additional-file pass because ESLint enumerates them even when the flat config did not introduce them; this also prevents build outputs produced by other Heft tasks from becoming new lint inputs. Extensions introduced by the flat config (Markdown, JSON, JSX, custom languages, .ts files outside the program, etc.) remain discoverable.

A single ESLint instance lints both the program files and the discovered additional files. The injected TypeScript Program is scoped (via a files-limited override) to the program's own files, so additional files fall through to the flat config's own parser instead of failing to resolve against the program. Additional files are run through the existing per-file content/config cache and result reporting, and SARIF metadata is collected from that instance. In builds with multiple TypeScript programs, additional files are included only once.

When a type-aware rule would apply to a file that is not part of the TypeScript program (typescript-eslint cannot produce type information for it), the plugin now reports an actionable error telling the user to either exclude the file from ESLint or lint it with a configuration that does not enable type-aware rules.

ESLint 8 and TSLint behavior is unchanged.

@rushstack/eslint-config: separating type-aware rules

To make the above easy to adopt, @rushstack/eslint-config now groups its type-aware rules (naming-convention, no-floating-promises, no-for-in-array) into an explicit typeAwareRules set alongside the derived nonTypeAwareRules (the combined rule set is unchanged), and adds a flat/without-type-information helper. The helper disables type-aware parsing and the type-aware rules for a given set of files, so files outside the TypeScript program can be linted with only the non-type-aware rules. The same split is applied to the repo's internal localCommonConfig and wired through the node rigs.

@rushstack/playwright-browser-tunnel demonstrates the pattern: its playwright.config.ts and tests/** files are excluded from tsconfig.json and are now linted with only the non-type-aware rules via withoutTypeInformation(...). This surfaced — and this PR fixes — several previously-unlinted issues in those files (including a broken import in the test fixture).

How it was tested

  • heft build in heft-plugins/heft-lint-plugin (TypeScript + self-lint + API Extractor): passed
  • heft test --clean in build-tests/eslint-9-test: 1 passed, including a non-TypeScript custom-extension SARIF result
  • heft build --clean in apps/playwright-browser-tunnel: passes, linting the Playwright config and test files with only the non-type-aware rules
  • rush rebuild for @rushstack/heft-lint-plugin (local rig) and @rushstack/eslint-plugin (decoupled rig): no lint regressions from the config split
  • rush change --verify / git diff --check

Earlier manual verification (on the pre-rebase feature commit) also confirmed that generated dist/**/*.js outputs in libraries/rush-lib and build-tests/localization-plugin-test-02 are not picked up as lint inputs.

@github-project-automation github-project-automation Bot moved this to Needs triage in Bug Triage Sep 2, 2026
@iclanton
Ian Clanton-Thuon (iclanton) force-pushed the fix/heft-lint-flat-config-files branch 2 times, most recently from bca4d92 to 54eb97a Compare September 5, 2026 00:05
Comment thread heft-plugins/heft-lint-plugin/package.json Outdated
Use ESLint's native flat-config enumeration to find files outside the
TypeScript program, then lint them through the existing cache and reporting
pipeline. Exclude TypeScript emit folders and ESLint's built-in JavaScript
extensions from the additional-file pass, partition SARIF metadata by ESLint
instance, keep the TypeScript plugin as an accessor-only development
dependency, and cover the behavior with the ESLint 9 SARIF fixture.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Scope the injected TypeScript program to the program's files so that a single
ESLint instance lints both program files (using the in-memory program) and the
additional files selected by the ESLint configuration (using the configuration's
own parser), removing the separate additional-files linter. When a type-aware
rule is applied to a file that is not part of the TypeScript program, emit
actionable guidance to either exclude the file or lint it with a configuration
that does not enable type-aware rules.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Consolidate the TypeScript program pattern computation into a single loop using
Path.isUnder, replace localeCompare with comparison operators, read the
additional files with a bounded Async.forEachAsync, drop redundant path
resolution of ESLint's already-absolute paths, use Path.convertToSlashes, and
clarify the enumerator configuration comment.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ation helper

Group the profile's type-aware rules (naming-convention, no-floating-promises,
no-for-in-array) into an explicit typeAwareRules set alongside the derived
nonTypeAwareRules, and add a reusable flat/without-type-information helper that
disables type-aware parsing and rules for a given set of files. Apply the same
split to the repo's localCommonConfig and wire the helper through the node rigs,
then use it in @rushstack/playwright-browser-tunnel to lint the Playwright config
and test files (which are not part of the TypeScript program) with only the
non-type-aware rules.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…iles

Now that the Playwright config and test files are linted (with non-type-aware
rules), fix the issues they surface: add the license header to playwright.config.ts,
and in tests/testFixture.ts correct the import to the exported
createTunneledBrowserAsync API, add the missing type annotations, order the
imports, and use an allowed console method.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- heft-lint-plugin: resolve TypeScript program root file names against the
  project folder (not the process cwd), and derive the project-relative ESLint
  patterns by slicing the project-folder prefix instead of using path.relative.
- @rushstack/eslint-config: drop the derived nonTypeAwareRules export/loop and
  keep only the explicit typeAwareRules group; simplify the
  without-type-information helper to an explicit disabled-rules list.
- Defer the decoupled-local-node-rig / node-rig wiring to a follow-up PR (to be
  done after @rushstack/eslint-config is published and the dependency is bumped);
  fix @rushstack/playwright-browser-tunnel inline in its own eslint.config.js for
  now.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
...nodeTrustedToolProfile,
...friendlyLocalsMixin,
{
files: ['**/*.ts', '**/*.tsx'],

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Shouldn't the files covered by withoutTypeInformation be excluded from this?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

(COPILOT) The rig helper moved to a follow-up PR; I now disable type-aware linting for those files inline instead. That block is applied after the **/*.ts block, so it overrides project to false for playwright.config.ts/tests/** — the earlier tsconfigRootDir is harmless for them (it only matters once a project is loaded). Happy to add them to the earlier block's ignores if you'd prefer an explicit exclusion.

Comment on lines +788 to +800
// Derive the non-type-aware rule group from the authored TypeScript source-file config so that it stays in sync
// with the rules above. Everything that is not part of typeAwareRules can be applied to files that lack type
// information.
const typeScriptSourceFileConfig = commonConfig.find(
(configObject) =>
Array.isArray(configObject.files) && configObject.files.includes('**/*.ts') && configObject.rules
);
const nonTypeAwareRules = {};
for (const [ruleName, ruleValue] of Object.entries(typeScriptSourceFileConfig.rules)) {
if (!(ruleName in typeAwareRules)) {
nonTypeAwareRules[ruleName] = ruleValue;
}
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Seems inappropriate to loop over a data structure that we just created and fully control. Can we refactor to get rid of this?

Also, it would be useful to convert the JS files in this project to TS and introduce a build in this project. We can maintain imports using the "exports" package.json property.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

(COPILOT) Done — removed the derived nonTypeAwareRules (and its loop/export). The profile now exposes only the explicit typeAwareRules group, spread into the config. (Noted the JS→TS conversion idea as a separate improvement.)

Comment on lines +30 to +32
for (const ruleName of [...Object.keys(typeAwareRules), ...additionalTypeAwareRuleNames]) {
disabledTypeAwareRules[ruleName] = 'off';
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Wouldn't it be cleaner to just separate these into independent lists?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

(COPILOT) Simplified — the helper now uses an explicit disabledTypeAwareRules list and no longer merges/loops over rule names. The additionalTypeAwareRuleNames parameter is gone now that the rig wiring is a follow-up PR.

this.#sarifLogPath = sarifLogPath;

this.#typeScriptFilenames = new Set(
tsProgram.getRootFileNames().map((filePath: string) => path.resolve(filePath))

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Are these files not already resolved? We also don't know if the cwd is the project root/tsconfig folder.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

(COPILOT) Fixed — root file names are now resolved against buildFolderPath (path.resolve(buildFolderPath, filePath)) rather than the process cwd.

const typeScriptFilePatterns: string[] = [];
for (const filePath of this.#typeScriptFilenames) {
if (Path.isUnder(filePath, buildFolderPath)) {
typeScriptFilePatterns.push(Path.convertToSlashes(path.relative(buildFolderPath, filePath)));

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Can we avoid path.relative here? Can't we just cut off the number of characters in buildFolderPath if filePath is under it?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

(COPILOT) Done — since Path.isUnder(filePath, buildFolderPath) is already checked, the pattern is now filePath.slice(buildFolderPath.length + 1) then Path.convertToSlashes(...), with no path.relative.

const additionalFilePaths: string[] = [];
for (const { filePath } of lintResults) {
if (
!this.#typeScriptFilenames.has(filePath) &&

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Seems like this should get passed in as a parameter instead of assuming that this class property will be already defined.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

(COPILOT) #typeScriptFilenames is assigned in the constructor before any method can run, so it is always defined here; it is also used by the constructor's program scoping and the error path, which is why it is a field. I can thread it through as a parameter if you'd rather not rely on the field.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yeah, pass it as a parameter.

Comment on lines +51 to +54
/**
* Files selected by the linter configuration that are not part of the TypeScript program.
*/
additionalFiles?: ReadonlySet<TAdditionalLintFile>;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Why do we care about this outside of ESLint?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

(COPILOT) It is generic on LinterBase<TLintResult, TAdditionalLintFile = never>, so TSLint (and any linter without additional files) neither sees nor handles it — only ESLint sets the second type parameter. performLintingAsync just folds them into the file set/cache alongside the program files. I can push it entirely into the ESLint subclass if you'd prefer the base stay unaware of it.


const { buildFolderPath } = heftConfiguration;
return Array.from(outputFolderPaths, (outputFolderPath: string) => {
const relativePath: string = Path.convertToSlashes(path.relative(buildFolderPath, outputFolderPath));

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Same questions about file paths already being normalized/relative.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

(COPILOT) Reworked the same way — output folders are resolved against buildFolderPath, and for those under it the pattern is built by slicing the prefix (guarded by Path.isUnder) instead of path.relative + filtering.


const nodeImportResolverPath = require.resolve('eslint-import-resolver-node');

// These localCommonConfig rules require type information (i.e. the TypeScript program). They are grouped

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

These changes can probably all go away when @rushstack/eslint-config is published and pulled back in, right? I'm thinking we just make that its own PR and plan on doing this after the dependency of this project gets bumped.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

(COPILOT) Agreed — reverted the localTypeAwareRules split and the rig without-type-information wrappers out of this PR. I'll do them in a follow-up once @rushstack/eslint-config is published and this project's dependency is bumped, so the rig can consume the published typeAwareRules/helper directly. For now @rushstack/playwright-browser-tunnel is fixed inline in its own eslint.config.js.

@@ -1 +1 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

We should consider similarly converting this project to TypeScript.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

(COPILOT) Noted — I'll leave the TS conversion of this rig project for a separate change.

Comment on lines +323 to +331
additionalFilePaths.sort((left: string, right: string) => {
if (left < right) {
return -1;
} else if (left > right) {
return 1;
} else {
return 0;
}
});

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Can't we just say additionalFilePaths.sort()?

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

Labels

None yet

Projects

Status: Needs triage

Development

Successfully merging this pull request may close these issues.

1 participant