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
1,839 changes: 1,800 additions & 39 deletions package-lock.json

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions packages/code-analyzer-lwc-engine/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# @salesforce/code-analyzer-lwc-engine

POC implementation of the LWC compiler engine for Salesforce Code Analyzer.

This engine runs the LWC compiler (`@lwc/compiler`) against `.js` / `.html` / `.css`
files inside LWC component bundles and translates each `CompilerDiagnostic` into
a Code Analyzer `Violation`.

See [`handoff-notes/lwc-engine-spike-doc.md`](../../../handoff-notes/lwc-engine-spike-doc.md)
for the full design.

## Status

Draft / proof-of-concept. Not yet wired into `code-analyzer-core`'s plugin loader.
16 changes: 16 additions & 0 deletions packages/code-analyzer-lwc-engine/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';

export default tseslint.config(
eslint.configs.recommended,
...tseslint.configs.recommended,
{
rules: {
"@typescript-eslint/no-unused-vars": ["error", {
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_",
"caughtErrorsIgnorePattern": "^_"
}]
}
}
);
69 changes: 69 additions & 0 deletions packages/code-analyzer-lwc-engine/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
{
"name": "@salesforce/code-analyzer-lwc-engine",
"description": "LWC compiler engine for Salesforce Code Analyzer (POC)",
"version": "0.1.0-SNAPSHOT",
"author": "The Salesforce Code Analyzer Team",
"license": "BSD-3-Clause",
"homepage": "https://developer.salesforce.com/docs/platform/salesforce-code-analyzer/overview",
"repository": {
"type": "git",
"url": "git+https://github.com/forcedotcom/code-analyzer-core.git",
"directory": "packages/code-analyzer-lwc-engine"
},
"main": "dist/index.js",
"types": "dist/index.d.ts",
"dependencies": {
"@types/node": "^20.0.0",
"@salesforce/code-analyzer-engine-api": "0.36.0",
"@lwc/compiler": "9.2.1",
"@lwc/errors": "9.2.1",
"@lwc/sfdc-lwc-compiler": "15.0.5",
"@lwc/metadata": "15.0.5"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@types/jest": "^30.0.0",
"eslint": "^9.39.2",
"jest": "^30.2.0",
"rimraf": "^6.1.2",
"ts-jest": "^29.4.6",
"typescript": "^5.9.3",
"typescript-eslint": "^8.50.0"
},
"engines": {
"node": ">=20.0.0"
},
"files": [
"dist",
"LICENSE",
"package.json"
],
"scripts": {
"build": "tsc --build tsconfig.build.json --verbose",
"test": "tsc --build tsconfig.json && cross-env NODE_OPTIONS=--experimental-vm-modules jest --coverage",
"lint": "eslint src/**/*.ts",
"package": "npm pack",
"all": "npm run build && npm run lint && npm run test && npm run package",
"clean": "tsc --build tsconfig.build.json --clean",
"postclean": "rimraf dist && rimraf coverage && rimraf ./*.tgz",
"scrub": "npm run clean && rimraf node_modules"
},
"jest": {
"preset": "ts-jest",
"testEnvironment": "node",
"testMatch": [
"**/*.test.ts"
],
"testPathIgnorePatterns": [
"/node_modules/",
"/dist/"
],
"collectCoverageFrom": [
"src/**/*.ts",
"!src/index.ts"
],
"transformIgnorePatterns": [
"node_modules/(?!(@lwc)/)"
]
}
}
35 changes: 35 additions & 0 deletions packages/code-analyzer-lwc-engine/src/bundle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import path from "node:path";

const BUNDLE_EXTENSIONS = new Set([".js", ".ts", ".mjs", ".html", ".css"]);
// TODO: Parse namespace from sfdx-project.json instead of hard-coding "c"
const DEFAULT_NAMESPACE = "c";

export interface BundleIdentity {
name: string;
namespace: string;
}

// Spike doc §10.1. A file qualifies as an LWC bundle file when its extension
// is supported AND its parent directory's basename matches the file's stem
// AND the file is not under a __tests__/ directory.
export function isLwcBundleFile(absPath: string): boolean {
const ext = path.extname(absPath).toLowerCase();
if (!BUNDLE_EXTENSIONS.has(ext)) return false;

const stem = path.basename(absPath, ext);
const parentDir = path.basename(path.dirname(absPath));
if (parentDir !== stem) return false;

const segments = absPath.split(path.sep);
if (segments.includes("__tests__")) return false;

return true;
}

// Spike doc §10.2. v1: namespace is hard-coded to "c". sfdx-project.json
// parsing is a known limitation (§16).
export function bundleIdentity(absPath: string): BundleIdentity {
const ext = path.extname(absPath);
const stem = path.basename(absPath, ext);
return { name: stem, namespace: DEFAULT_NAMESPACE };
}
190 changes: 190 additions & 0 deletions packages/code-analyzer-lwc-engine/src/compile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import * as fsp from "node:fs/promises";
import path from "node:path";
import { BundleIdentity } from "./bundle";

export interface CollectedDiagnostic {
code: number;
message: string;
filename?: string;
location?: { line: number; column: number; start?: number; length?: number };
level: number;
url?: string;
}

// Compile a single LWC file using both open-source and platform compilers.
// Returns a merged, deduplicated list of diagnostics from both paths.
//
// Path 1: @lwc/compiler transformSync — throws CompilerError/CompilerAggregateError (codes 1001-1213)
// Path 2: @lwc/sfdc-lwc-compiler compile() — returns diagnostics on output (codes 1500-1538)
export async function compileAndCollect(
file: string,
bundle: BundleIdentity
): Promise<CollectedDiagnostic[]> {
const source = await fsp.readFile(file, "utf-8");
const bundleFiles = await readBundleFiles(file, bundle.name);

// Sequential — not parallel. Both paths import @lwc/compiler internally;
// concurrent dynamic import() of the same ESM module causes a Node race condition.
const openSourceDiags = await collectFromTransformSync(source, file, bundle);
const platformDiags = await collectFromPlatformCompile(bundleFiles, bundle);

return deduplicateDiagnostics([...openSourceDiags, ...platformDiags]);
}

// Read all sibling files in the bundle directory that belong to this component.
async function readBundleFiles(file: string, name: string): Promise<Record<string, string>> {
const dir = path.dirname(file);
const files: Record<string, string> = {};
try {
const entries = await fsp.readdir(dir);
for (const entry of entries) {
const stem = path.basename(entry, path.extname(entry));
if (stem === name && !entry.includes("__tests__")) {
const content = await fsp.readFile(path.join(dir, entry), "utf-8");
files[entry] = content;
}
}
} catch {
// If we can't read the directory, just use the single file
files[path.basename(file)] = await fsp.readFile(file, "utf-8");
}
return files;
}

// Path 1: open-source @lwc/compiler (throws on error)
async function collectFromTransformSync(
source: string,
file: string,
bundle: BundleIdentity
): Promise<CollectedDiagnostic[]> {
const lwcCompiler = await import("@lwc/compiler");
const lwcErrors = await import("@lwc/errors");
const { transformSync } = lwcCompiler as { transformSync: (src: string, filename: string, opts: { name: string; namespace: string }) => unknown };
const { CompilerError, CompilerAggregateError } = lwcErrors as {
CompilerError: new (...args: unknown[]) => Error & CollectedDiagnostic;
CompilerAggregateError: new (...args: unknown[]) => Error & { errors: (Error & CollectedDiagnostic)[] };
};

try {
transformSync(source, file, { name: bundle.name, namespace: bundle.namespace });
return [];
} catch (e) {
if (e instanceof CompilerError) {
return [unwrap(e)];
}
if (e instanceof CompilerAggregateError) {
return e.errors.map(unwrap);
}
const err = e as Error;
return [{
code: 1001,
message: `Unexpected compilation error: ${err.message}`,
filename: file,
level: 1,
}];
}
}

// Path 2: @lwc/sfdc-lwc-compiler platform compile (returns diagnostics, doesn't throw)
async function collectFromPlatformCompile(
bundleFiles: Record<string, string>,
bundle: BundleIdentity
): Promise<CollectedDiagnostic[]> {
try {
const sfdcCompiler = await import("@lwc/sfdc-lwc-compiler");
const compile = (sfdcCompiler as { compile: (config: unknown) => Promise<PlatformOutput> }).compile;

const output = await compile({
bundle: {
type: "platform" as const,
name: bundle.name,
namespace: bundle.namespace,
files: bundleFiles,
},
});

const diagnostics: CollectedDiagnostic[] = [];

// Top-level diagnostics
if (output.diagnostics) {
for (const d of output.diagnostics) {
if (isPlatformDiagnostic(d)) diagnostics.push(toDiag(d));
}
}

// Per-bundle result diagnostics
if (output.results) {
for (const result of output.results) {
if (result.diagnostics) {
for (const d of result.diagnostics) {
if (isPlatformDiagnostic(d)) diagnostics.push(toDiag(d));
}
}
}
}

return diagnostics;
} catch (_err) {
// If platform compile fails entirely (missing deps, version mismatch), fall back silently.
// Open-source path still provides coverage for codes 1001-1213.
// Platform compile unavailable — open-source path still covers codes 1001-1213.
return [];
}
}

// Only keep diagnostics in the platform range (1500+) to avoid double-counting
// open-source errors that both compilers might emit.
function isPlatformDiagnostic(d: unknown): d is RawDiagnostic {
if (!d || typeof d !== "object") return false;
const obj = d as Record<string, unknown>;
return typeof obj.code === "number" && obj.code >= 1500;
}

function toDiag(d: RawDiagnostic): CollectedDiagnostic {
return {
code: d.code,
message: d.message ?? "",
filename: d.filename,
location: d.location,
level: d.level ?? 1,
url: d.url,
};
}

function unwrap(e: Error & CollectedDiagnostic): CollectedDiagnostic {
return {
code: e.code,
message: e.message,
filename: e.filename,
location: e.location,
level: e.level,
url: e.url,
};
}

function deduplicateDiagnostics(diags: CollectedDiagnostic[]): CollectedDiagnostic[] {
const seen = new Set<string>();
const result: CollectedDiagnostic[] = [];
for (const d of diags) {
const key = `${d.code}|${d.message}|${d.filename ?? ""}|${d.location?.line ?? ""}|${d.location?.column ?? ""}`;
if (!seen.has(key)) {
seen.add(key);
result.push(d);
}
}
return result;
}

interface RawDiagnostic {
code: number;
message?: string;
filename?: string;
location?: { line: number; column: number; start?: number; length?: number };
level?: number;
url?: string;
}

interface PlatformOutput {
diagnostics?: unknown[];
results?: Array<{ diagnostics?: unknown[] }>;
}
Loading