/**
* Validate Code Tool
* Validates code against Magento coding standards.
*/
import {
INSECURE_FUNCTIONS,
DISCOURAGED_FUNCTIONS,
RESTRICTED_CLASSES,
checkForDeprecatedPatterns,
checkForJQueryDeprecations,
} from '../knowledge/index.js';
import { getActiveTheme } from '../themes/index.js';
export interface CodeViolation {
line?: number;
column?: number;
severity: number;
type: 'error' | 'warning';
message: string;
rule: string;
suggestion?: string;
}
export interface ValidationResult {
valid: boolean;
summary: {
errors: number;
warnings: number;
fixable: number;
};
violations: CodeViolation[];
categories: Record<string, CodeViolation[]>;
}
/**
* Check if a line position is inside a comment or string literal.
* Returns true if the match should be skipped.
*/
function isInsideCommentOrString(line: string, matchIndex?: number): boolean {
const trimmed = line.trim();
// Single-line comments
if (trimmed.startsWith('//') || trimmed.startsWith('#') || trimmed.startsWith('*') || trimmed.startsWith('/*')) {
return true;
}
// If matchIndex provided, check if it's inside a string
if (matchIndex !== undefined) {
const before = line.substring(0, matchIndex);
// Simple check: count unescaped quotes before match
const singleQuotes = (before.match(/(?<!\\)'/g) || []).length;
const doubleQuotes = (before.match(/(?<!\\)"/g) || []).length;
if (singleQuotes % 2 !== 0 || doubleQuotes % 2 !== 0) {
return true;
}
}
return false;
}
/**
* Validate PHP code against Magento standards
*/
export function validatePhpCode(code: string): ValidationResult {
const violations: CodeViolation[] = [];
const lines = code.split('\n');
// Check for insecure functions
for (const [func, info] of Object.entries(INSECURE_FUNCTIONS)) {
const regex = new RegExp(`\\b${func}\\s*\\(`, 'g');
lines.forEach((line, index) => {
let match;
regex.lastIndex = 0;
while ((match = regex.exec(line)) !== null) {
if (!isInsideCommentOrString(line, match.index)) {
violations.push({
line: index + 1,
severity: 10,
type: 'error',
message: `Use of insecure function '${func}' is forbidden. ${info.reason}`,
rule: 'Magento2.Security.InsecureFunction',
suggestion: info.replacement || 'Remove this function call'
});
}
}
});
}
// Check for discouraged functions
for (const [func, info] of Object.entries(DISCOURAGED_FUNCTIONS)) {
const regex = new RegExp(`\\b${func}\\s*\\(`, 'g');
lines.forEach((line, index) => {
let match;
regex.lastIndex = 0;
while ((match = regex.exec(line)) !== null) {
if (!isInsideCommentOrString(line, match.index)) {
violations.push({
line: index + 1,
severity: 8,
type: 'warning',
message: `Use of '${func}' is discouraged. ${info.reason}`,
rule: 'Magento2.Functions.DiscouragedFunction',
suggestion: `Use ${info.replacement} instead`
});
}
}
});
}
// Check for restricted classes
for (const [className, info] of Object.entries(RESTRICTED_CLASSES)) {
const escapedName = className.replace(/\\/g, '\\\\');
const regex = new RegExp(`\\b${escapedName}\\b`, 'g');
lines.forEach((line, index) => {
let match;
regex.lastIndex = 0;
while ((match = regex.exec(line)) !== null) {
if (!isInsideCommentOrString(line, match.index)) {
violations.push({
line: index + 1,
severity: 10,
type: 'error',
message: `Use of '${className}' is restricted. ${info.reason}`,
rule: 'Magento2.Legacy.RestrictedCode',
suggestion: `Use ${info.replacement} instead`
});
}
}
});
}
// Check for ObjectManager::getInstance()
lines.forEach((line, index) => {
if (!isInsideCommentOrString(line) && /ObjectManager\s*::\s*getInstance\s*\(/i.test(line)) {
violations.push({
line: index + 1,
severity: 10,
type: 'error',
message: 'Direct ObjectManager usage is forbidden. Use dependency injection.',
rule: 'Magento2.Classes.DiscouragedDependencies'
});
}
});
// Check for 'final' keyword
lines.forEach((line, index) => {
if (!isInsideCommentOrString(line) && /\bfinal\s+(class|function)\b/i.test(line)) {
violations.push({
line: index + 1,
severity: 10,
type: 'error',
message: 'The "final" keyword is forbidden. It breaks extensibility and plugin system.',
rule: 'Magento2.PHP.FinalImplementation'
});
}
});
// Check for 'goto' statement
lines.forEach((line, index) => {
if (!isInsideCommentOrString(line) && /\bgoto\s+\w+/i.test(line)) {
violations.push({
line: index + 1,
severity: 10,
type: 'error',
message: 'The "goto" statement is forbidden.',
rule: 'Magento2.PHP.Goto'
});
}
});
// Check for Magento 1 patterns
lines.forEach((line, index) => {
if (!isInsideCommentOrString(line) && /\bMage\s*::/i.test(line)) {
violations.push({
line: index + 1,
severity: 10,
type: 'error',
message: 'Magento 1 pattern detected. "Mage::" is not valid in Magento 2.',
rule: 'Magento2.Legacy.MageEntity'
});
}
});
// Check for raw SQL - improved regex to reduce false positives
lines.forEach((line, index) => {
if (isInsideCommentOrString(line)) return;
// Only flag if it looks like actual SQL in a string or query context
const sqlPattern = /(?:->query\s*\(|['"`]\s*(?:SELECT|INSERT\s+INTO|UPDATE\s+\w+\s+SET|DELETE\s+FROM|DROP\s+TABLE|ALTER\s+TABLE|CREATE\s+TABLE|TRUNCATE))/i;
if (sqlPattern.test(line)) {
violations.push({
line: index + 1,
severity: 9,
type: 'warning',
message: 'Raw SQL detected. Use repositories, collections, or query builder.',
rule: 'Magento2.SQL.RawQuery'
});
}
});
// Check for array_merge in loops (performance)
let inLoop = false;
lines.forEach((line, index) => {
if (/\b(foreach|for|while)\s*\(/.test(line)) inLoop = true;
if (inLoop && /\barray_merge\s*\(/.test(line) && !isInsideCommentOrString(line)) {
violations.push({
line: index + 1,
severity: 7,
type: 'warning',
message: 'array_merge() inside loop is a performance issue. Use array spread or collect and merge once.',
rule: 'Magento2.Performance.ForeachArrayMerge'
});
}
// Simple heuristic for end of block
if (line.trim() === '}') inLoop = false;
});
return buildResult(violations);
}
/**
* Validate PHTML template code
*/
export function validateTemplateCode(code: string): ValidationResult {
const violations: CodeViolation[] = [];
const lines = code.split('\n');
// Check for $this usage
lines.forEach((line, index) => {
if (!isInsideCommentOrString(line) && /\$this\s*->/i.test(line) && !/\$this\s*->\s*helper/i.test(line)) {
violations.push({
line: index + 1,
severity: 8,
type: 'warning',
message: 'Use of $this in templates is deprecated. Use $block instead.',
rule: 'Magento2.Templates.ThisInTemplate',
suggestion: 'Replace $this-> with $block->'
});
}
});
// Check for helper usage
lines.forEach((line, index) => {
if (/\$this\s*->\s*helper\s*\(/i.test(line)) {
violations.push({
line: index + 1,
severity: 8,
type: 'warning',
message: 'Use of helpers in templates is discouraged. Use ViewModel instead.',
rule: 'Magento2.Templates.ThisInTemplate',
suggestion: 'Create a ViewModel and attach it via layout XML'
});
}
});
// Check for ObjectManager
lines.forEach((line, index) => {
if (!isInsideCommentOrString(line) && /ObjectManager\s*::\s*getInstance/i.test(line)) {
violations.push({
line: index + 1,
severity: 10,
type: 'error',
message: 'ObjectManager usage in templates is forbidden.',
rule: 'Magento2.Templates.ObjectManager'
});
}
});
// Check for unescaped output
lines.forEach((line, index) => {
const echoMatch = /<\?=\s*([^?]+)\?>|<\?php\s+echo\s+([^;]+);/gi;
let match;
while ((match = echoMatch.exec(line)) !== null) {
const output = match[1] || match[2];
const isEscaped = /\$escaper\s*->\s*escape|\$block\s*->\s*escape|escapeHtml|escapeUrl|escapeJs|@noEscape|getJsLayout/i.test(output);
const isNumeric = /^[\s\d+\-*\/()]+$/.test(output.trim());
const isCount = /\bcount\s*\(/.test(output);
if (!isEscaped && !isNumeric && !isCount) {
violations.push({
line: index + 1,
severity: 10,
type: 'error',
message: 'Unescaped output detected. Possible XSS vulnerability.',
rule: 'Magento2.Security.XssTemplate',
suggestion: 'Use $escaper->escapeHtml(), escapeUrl(), or escapeJs()'
});
}
}
});
// Check for long echo syntax
lines.forEach((line, index) => {
if (/<\?php\s+echo\b/i.test(line)) {
violations.push({
line: index + 1,
severity: 6,
type: 'warning',
message: 'Use short echo syntax <?= instead of <?php echo',
rule: 'Magento2.PHP.ShortEchoSyntax',
suggestion: 'Replace <?php echo with <?='
});
}
});
return buildResult(violations);
}
/**
* Validate JavaScript code
*/
export function validateJsCode(code: string): ValidationResult {
const violations: CodeViolation[] = [];
const lines = code.split('\n');
// Check for jQuery deprecations
const jqueryDeprecations = checkForJQueryDeprecations(code);
for (const deprecation of jqueryDeprecations) {
violations.push({
severity: 6,
type: 'warning',
message: `Deprecated jQuery method: ${deprecation.method}. ${deprecation.reason}`,
rule: 'Magento2.jQuery.Deprecated',
suggestion: `Use ${deprecation.replacement} instead`
});
}
// Check for global variables
lines.forEach((line, index) => {
if (/^var\s+\w+\s*=/i.test(line.trim()) && !line.includes('define(')) {
violations.push({
line: index + 1,
severity: 7,
type: 'warning',
message: 'Global variable detected. Use RequireJS module pattern.',
rule: 'Magento2.JS.GlobalVariable'
});
}
});
return buildResult(violations);
}
/**
* Validate LESS/CSS code against Magento standards
*/
export function validateLessCode(code: string): ValidationResult {
const violations: CodeViolation[] = [];
const lines = code.split('\n');
// Check for #id selectors
lines.forEach((line, index) => {
if (!isInsideCommentOrString(line) && /#[a-zA-Z][\w-]*\s*[{,]/.test(line)) {
violations.push({
line: index + 1,
severity: 8,
type: 'warning',
message: 'Avoid using ID selectors (#id). Use class selectors instead.',
rule: 'Magento2.Less.AvoidId',
suggestion: 'Replace #id with .class selector'
});
}
});
// Check for !important
lines.forEach((line, index) => {
if (!isInsideCommentOrString(line) && /!important/i.test(line)) {
violations.push({
line: index + 1,
severity: 7,
type: 'warning',
message: 'Avoid using !important. Increase specificity instead.',
rule: 'Magento2.Less.ImportantProperty',
suggestion: 'Increase selector specificity or restructure styles'
});
}
});
// Check for units on zero values
lines.forEach((line, index) => {
if (!isInsideCommentOrString(line) && /:\s*0(px|em|rem|%|pt|cm|mm|in|ex|ch|vw|vh)\b/.test(line)) {
violations.push({
line: index + 1,
severity: 6,
type: 'warning',
message: 'No units needed for zero values.',
rule: 'Magento2.Less.ZeroUnits',
suggestion: 'Use 0 instead of 0px, 0em, etc.'
});
}
});
// Check for tab indentation (should be spaces)
lines.forEach((line, index) => {
if (/^\t/.test(line)) {
violations.push({
line: index + 1,
severity: 6,
type: 'warning',
message: 'Use spaces for indentation, not tabs.',
rule: 'Magento2.Less.Indentation',
suggestion: 'Configure editor to use 4 spaces for indentation'
});
}
});
// Check for missing semicolons
lines.forEach((line, index) => {
const trimmed = line.trim();
if (trimmed && !trimmed.startsWith('//') && !trimmed.startsWith('/*') && !trimmed.startsWith('*') &&
/^\s*[\w-]+\s*:\s*[^;{]+$/.test(line) && !trimmed.endsWith('{') && !trimmed.endsWith(',')) {
violations.push({
line: index + 1,
severity: 6,
type: 'warning',
message: 'Missing semicolon at end of property declaration.',
rule: 'Magento2.Less.SemicolonSpacing'
});
}
});
// Check for color definitions (should use variables)
lines.forEach((line, index) => {
if (!isInsideCommentOrString(line) && /#[0-9a-fA-F]{3,8}\b/.test(line) && !/@[\w-]+\s*:/.test(line)) {
violations.push({
line: index + 1,
severity: 6,
type: 'warning',
message: 'Use LESS variables for color definitions instead of hardcoded hex values.',
rule: 'Magento2.Less.ColourDefinition',
suggestion: 'Define color as a variable: @my-color: #hex; and use @my-color'
});
}
});
return buildResult(violations);
}
/**
* Apply active theme validation rules to the code.
* Theme rules layer ON TOP of base Magento rules.
*/
function applyThemeRules(code: string, fileType: string, violations: CodeViolation[]): void {
const theme = getActiveTheme();
if (!theme) return;
const lines = code.split('\n');
const ft = fileType.toLowerCase();
for (const rule of theme.validationRules) {
if (!rule.fileTypes.includes(ft as any)) continue;
try {
const regex = new RegExp(rule.pattern, 'g');
lines.forEach((line, index) => {
regex.lastIndex = 0;
if (regex.test(line)) {
violations.push({
line: index + 1,
severity: rule.severity,
type: rule.type,
message: `[${theme.name}] ${rule.message}`,
rule: rule.rule,
suggestion: rule.suggestion,
});
}
});
} catch {
// Invalid regex in theme rule — skip
}
}
}
/**
* Build validation result from violations array
*/
function buildResult(violations: CodeViolation[]): ValidationResult {
const errors = violations.filter(v => v.type === 'error').length;
const warnings = violations.filter(v => v.type === 'warning').length;
const fixable = violations.filter(v => v.suggestion).length;
const categories: Record<string, CodeViolation[]> = {};
for (const violation of violations) {
const category = violation.rule.split('.')[1] || 'Other';
if (!categories[category]) {
categories[category] = [];
}
categories[category].push(violation);
}
return {
valid: errors === 0,
summary: { errors, warnings, fixable },
violations,
categories
};
}
/**
* Main validation function
*/
export function validateCode(code: string, fileType: string): ValidationResult {
let baseResult: ValidationResult;
switch (fileType.toLowerCase()) {
case 'php':
baseResult = validatePhpCode(code);
break;
case 'phtml':
baseResult = validateTemplateCode(code);
break;
case 'js':
case 'javascript':
baseResult = validateJsCode(code);
break;
case 'less':
case 'css':
baseResult = validateLessCode(code);
break;
default:
baseResult = validatePhpCode(code);
break;
}
// Apply active theme rules on top of base validation
const themeViolations: CodeViolation[] = [];
applyThemeRules(code, fileType, themeViolations);
if (themeViolations.length > 0) {
const allViolations = [...baseResult.violations, ...themeViolations];
return buildResult(allViolations);
}
return baseResult;
}