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
3 changes: 2 additions & 1 deletion .node-scripts/validate-changed-package-versions.js
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,8 @@ function getLatestReleasedVersion(changedPackage) {

function isPackageThatHasNotPublished(changedPackage) {
return [
"packages/ENGINE-TEMPLATE"
"packages/ENGINE-TEMPLATE",
"packages/code-analyzer-lwc-engine" // remove this exception once the PR merges and the package is published
].includes(changedPackage.replace("\\","/"));
}

Expand Down
3,695 changes: 2,980 additions & 715 deletions package-lock.json

Large diffs are not rendered by default.

23 changes: 23 additions & 0 deletions packages/code-analyzer-lwc-engine/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# @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.

## Requirements

Full rule coverage needs **Node >= 20.19** (or >= 22.12). The platform rule ranges
(`@lwc/sfdc-lwc-compiler` 1500s, `@lwc/metadata` 1700s) are loaded via `import()`, which
relies on flagless `require(ESM)` — added in those Node versions. On Node 20.0–20.18 those
registries fail to load; this is non-fatal and the engine falls back to the open-source
range (codes 1001–1213 only), logging the reason at debug level. The `engines` floor stays
`>=20.0.0` to match the rest of the monorepo.

## 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.42.0-SNAPSHOT",
"@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)/)"
]
}
}
102 changes: 102 additions & 0 deletions packages/code-analyzer-lwc-engine/src/bundle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import path from "node:path";
import * as fsp from "node:fs/promises";

const BUNDLE_EXTENSIONS = new Set([".js", ".ts", ".mjs", ".html", ".css"]);
// The default LWC namespace, used when no sfdx-project.json declares one.
const DEFAULT_NAMESPACE = "c";
const SFDX_PROJECT_FILE = "sfdx-project.json";

// Cache resolved namespaces per directory so we don't re-walk/re-read for every
// file in the same project during a single run.
const namespaceCache = new Map<string, string>();

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. The namespace is read from the nearest ancestor
// sfdx-project.json's top-level "namespace" field, falling back to "c" when the
// manifest is absent, unreadable, or declares no (non-empty) namespace.
export async function bundleIdentity(absPath: string): Promise<BundleIdentity> {
const ext = path.extname(absPath);
const stem = path.basename(absPath, ext);
return { name: stem, namespace: await resolveNamespace(path.dirname(absPath)) };
}

// Walk up from startDir looking for an sfdx-project.json and return its declared
// namespace. Results are cached per directory. A missing/blank namespace, a
// missing manifest, or malformed JSON all resolve to DEFAULT_NAMESPACE.
async function resolveNamespace(startDir: string): Promise<string> {
const cached = namespaceCache.get(startDir);
if (cached !== undefined) return cached;

const visited: string[] = [];
let dir = startDir;
// Stop when path.dirname stops changing (filesystem root).
while (true) {
visited.push(dir);
const cachedForDir = namespaceCache.get(dir);
if (cachedForDir !== undefined) {
return cacheFor(visited, cachedForDir);
}
const ns = await readNamespace(path.join(dir, SFDX_PROJECT_FILE));
if (ns !== undefined) {
// A manifest exists here — its namespace (or the default, if blank/malformed)
// wins and we stop walking.
return cacheFor(visited, ns);
}
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
return cacheFor(visited, DEFAULT_NAMESPACE);
}

// Read the namespace from a manifest. Returns undefined when the manifest does not
// exist (so the caller keeps walking up), the trimmed namespace when one is declared,
// or DEFAULT_NAMESPACE when the manifest exists but is blank/malformed.
async function readNamespace(manifestPath: string): Promise<string | undefined> {
let raw: string;
try {
raw = await fsp.readFile(manifestPath, "utf-8");
} catch {
return undefined; // no manifest at this level
}
try {
const parsed = JSON.parse(raw) as { namespace?: unknown };
const ns = parsed.namespace;
if (typeof ns === "string" && ns.trim().length > 0) {
return ns.trim();
}
} catch {
// Manifest exists but is malformed — fall back to the default namespace.
}
return DEFAULT_NAMESPACE;
}

// Cache the resolved namespace against every directory visited on the walk so
// sibling files short-circuit on the next lookup.
function cacheFor(dirs: string[], namespace: string): string {
for (const d of dirs) {
namespaceCache.set(d, namespace);
}
return namespace;
}
Loading