Skip to content

Support programmatic linting, remove FS-centric bundle management #59

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
May 30, 2025

Conversation

khawkins
Copy link
Contributor

What does this PR do?

  • Adds support for specifying an LWC bundle programmatically, by introducing an lwcBundle property on the processor (BundleAnalyzer).
  • BundleAnalyzer.preprocess() will look for this property, and fall back to the original filesystem interrogation if it's not set.
  • The management of the bundle in memory is no longer keyed off of filesystem paths of the files. This is what was breaking the functionality in an ESBuild bundle.
  • Introduced TypeScript types for access from TS projects.
  • I removed the dependency on other ESLint LWC configs, which we just had to get access to the parser. I implemented @babel/eslint-parser in our base configuration instead.
    • I had introduced a "no-op" custom parser to forego parsing altogether, since Komaci already does its own parsing. But it turns out that ESLint depends on the parsing to honor disable comments, so that had to be axed.

I'll add more commentary inline, wherever it makes sense.

What issues does this PR fix or reference?

  • Inability to run the plugin when bundled with ESBuild, by making internal bundle state agnostic to filesystem locations.
  • Provide programmatic specification of LWC bundles, to directly lint LWC bundles from code.

@khawkins khawkins requested review from dbreese, sfdctaka, maliroteh-sf and a team as code owners May 27, 2025 22:53
@@ -10,6 +10,17 @@
module.exports = {
plugins: ['@salesforce/lwc-graph-analyzer'],
processor: '@salesforce/lwc-graph-analyzer/bundleAnalyzer',
parser: '@babel/eslint-parser',
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Without this, every project that included our plugin also had to include one of the LWC ESLint configs from eslint-config-lwc, to get the parser that would accept LWC code with decorators.

@@ -8,6 +8,8 @@
'use strict';

const { createRule } = require('../util/createRule');
const ruleDefinition = createRule(__filename);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

__filename is problematic in ESBuild bundles. Which stands to reason, as ESBuild bundles all code into one file, making filenames largely irrelevant.

* @param {string | undefined} jsContent - The content of the JS file
* @param {string[]} htmlTemplateContent - The contents of the HTML templates
*/
setLwcBundleFromContent = (componentBaseName, jsContent, ...htmlTemplateContent) => {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

setLwcBundleFromContent() is the initialization of the programmatic use case for calling our plugin. It basically looks like this, from a consuming project:

import { Linter } from "eslint";
import lwcGraphAnalyzerPlugin, {
    LwcBundle,
} from "@salesforce/eslint-plugin-lwc-graph-analyzer";

function main() {
    const jsToAnalyze = `
import { LightningElement } from 'lwc';
export default class Example extends LightningElement {
    prop;
    get input() {
        this.prop = 'hi';
        return this.prop;
    }
}
    `;

    const bundleAnalyzer = lwcGraphAnalyzerPlugin.processors.bundleAnalyzer;
    bundleAnalyzer.setLwcBundleFromContent(
        "myFaultyComp",
        jsToAnalyze,
        // You'd add HTML template files one by one here, if they're a part of your bundle.
    );
    const linter = new Linter();
    const recommendedConfig = lwcGraphAnalyzerPlugin.configs.recommended;
    const messages = linter.verifyAndFix(jsToAnalyze, {
        plugins: {
            "@salesforce/lwc-graph-analyzer": lwcGraphAnalyzerPlugin,
        },
        rules: recommendedConfig.rules,
        processor: bundleAnalyzer,
    });
    console.log("messages:");
    console.log(`${JSON.stringify(messages, null, 2)}\n`);
}

main();

* by the ESLint CLI, or a dummy value (__placeholder__.js) if called programatically.
* @returns {{ text: string, filename: string }[]} An array of files and their content, to be processed.
*/
preprocess = (text, filename) => {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

preprocess() is the logical heart of the custom processor. Everything you see in this class basically gets tapped out of this workflow, other than pre-setting this.#lwcBundle from outside.

Note: Because of the way ESLint strips preprocess and postprocess methods out of their defining context before it runs them, I either had to use arrow functions like these, or do some explicit binding stuff in the class, to allow the functions to make use of this directives. I opted for arrow functions.

* by the ESLint CLI, or a dummy value (__placeholder__.js) if called programatically.
* @returns {{ text: string, filename: string }[]} An array of files and their content, to be processed.
*/
preprocess = (text, filename) => {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Here's a basic flow through the method. Key points:

  • If this.#lwcBundle has been set by the consumer, that's the bundle that will get processed.
  • If not, it's assumed that the input content is from an LWC on the filesystem, and it creates an LwcBundle based interrogating those contents.
  • If it's not on the filesystem either, it creates a one-file bundle out of the incoming file.
  • The concept of "primary file" is key to the storage and retrieval of the bundle: the file coming into preprocess() is the "primary file": the file being operated on. Any other files are just supporting bundle files to analysis. The primary file keys the in-memory bundle, for later retrieval by the rules-based analysis.
graph TD
    A[Start preprocess] --> B{Is lwcBundle set?}
    B -->|No| C[Try to create bundle from filesystem]
    B -->|Yes| D[Set primary file by content]
    
    C --> E{Bundle created successfully?}
    E -->|Yes| F[Use filesystem bundle - sets primary file]
    E -->|No| G[Create single file bundle - sets primary file]
    
    D --> H{Found matching file?}
    H -->|Yes| I[Continue processing]
    H -->|No| J[Return empty array]
    
    F --> I
    G --> I
    
    I --> K[Cache bundle]
    K --> L{Is file .js?}
    L -->|Yes| M[Return original text]
    L -->|No| N[Return empty text]
    
    M --> O[End]
    N --> O
    J --> O
Loading

*
* @returns {string} A unique key in the format `<base file name>_<uuid>.<extension>`
*/
getBundleKey() {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

We couldn't just use the filename, because it may not be unique between bundles. I originally was using a hash of the content, only to realize that when you zero out the content of the HTML file to pass it through, that's what ends up on the other side in the analysis, so no way to track it. Appending a unique identifier to the filename seemed the best way, on the only field that tracks from processing to analysis.

* @param {string} eslintFilename - The filename as provided by ESLint's context
* @returns {string} The original bundle key used for lookup
*/
function extractBundleKey(eslintFilename) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

ESLint makes some wonky path out of the filename that you create in the processor. So you have to undo that here—there's no other direct link or method to give you back the filename you specified.

if (ext === '.html') {
return [{ text: '', filename }];

setLwcBundleCacheEntry(this.#lwcBundle);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This adds the LwcBundle to the in-memory cache to pick up on the rules analysis side.

Copy link
Contributor

Choose a reason for hiding this comment

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

I want to understand more about BundleStateManager/cache a bit. When the processor runs wouldn't it process a bundle at a time? The way it is cached with BundleStateManager the code reads to me as though processing bundles is not serial but a parallel processing.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I don't know enough about the internals of how ESLint processes multiple files as input (e.g. eslint components/*.js), so I didn't want to run the risk of one bundle clashing with another, if ESLint adds any level of parallel processing when it gets multiple files as input.

/**
* Interface for filesystem operations
*/
class FilesystemProvider {
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm guessing class is used here because Javascript doesn't have interface like Typescript. And that you wanted to create something like abstract class in C++, which is like interface? And you wanted to create a layer of abstraction to be able to create mockups for tests?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Exactly. In TypeScript I would have made this an interface, because its intention is to support dependency injection. This felt like the best approximation that allowed clear visibility to developers, into the intent of describing an API shape that otherwise must be implemented, not executed.

Copy link
Contributor

@sfdctaka sfdctaka left a comment

Choose a reason for hiding this comment

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

LGTM! This modification to the plugin was a huge undertaking!

@khawkins khawkins merged commit ae44e59 into salesforce:main May 30, 2025
7 checks passed
@khawkins khawkins deleted the programmatic_file_api branch May 30, 2025 23:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants