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.

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

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 function bundleIdentity(absPath: string): BundleIdentity {
const ext = path.extname(absPath);
const stem = path.basename(absPath, ext);
return { name: stem, namespace: 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.
function resolveNamespace(startDir: string): 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 manifest = path.join(dir, SFDX_PROJECT_FILE);
if (fs.existsSync(manifest)) {
return cacheFor(visited, readNamespace(manifest));
}
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
return cacheFor(visited, DEFAULT_NAMESPACE);
}

function readNamespace(manifestPath: string): string {
try {
const parsed = JSON.parse(fs.readFileSync(manifestPath, "utf-8")) as { namespace?: unknown };
const ns = parsed.namespace;
if (typeof ns === "string" && ns.trim().length > 0) {
return ns.trim();
}
} catch {
// Malformed or unreadable manifest — 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