Skip to content
Merged
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: 3 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ export default [
'local/no-fs-realpathSync': 'error',
'local/no-os-tmpdir': 'error',
'local/no-unix-shell-commands': 'error',
'local/no-manual-path-normalize': 'error',
'local/no-path-sep-in-strings': 'error',
'local/no-path-operations-in-comparisons': 'error',

// TypeScript
'no-unused-vars': 'off', // Use @typescript-eslint/no-unused-vars instead
Expand Down
6 changes: 6 additions & 0 deletions packages/dev-tools/eslint-local-rules/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
* - no-fs-mkdirSync: Enforce mkdirSyncReal() instead of fs.mkdirSync() (Windows path normalization)
* - no-fs-realpathSync: Enforce normalizePath() instead of fs.realpathSync() (consistent Windows resolution)
* - no-unix-shell-commands: Prevent Unix-specific commands that break Windows compatibility
* - no-manual-path-normalize: Enforce toForwardSlash() instead of manual .split(path.sep).join('/') patterns
* - no-path-sep-in-strings: Prevent path.sep in string operations (split, includes, etc.)
* - no-path-operations-in-comparisons: Require normalizing path operations before string comparisons
*
* ## Why Custom Rules?
*
Expand Down Expand Up @@ -50,5 +53,8 @@ export default {
'no-fs-realpathSync': require('./no-fs-realpathSync.cjs'),
'no-os-tmpdir': require('./no-os-tmpdir.cjs'),
'no-unix-shell-commands': require('./no-unix-shell-commands.cjs'),
'no-manual-path-normalize': require('./no-manual-path-normalize.cjs'),
'no-path-sep-in-strings': require('./no-path-sep-in-strings.cjs'),
'no-path-operations-in-comparisons': require('./no-path-operations-in-comparisons.cjs'),
},
};
112 changes: 112 additions & 0 deletions packages/dev-tools/eslint-local-rules/no-manual-path-normalize.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/**
* ESLint rule to enforce using toForwardSlash() instead of manual normalization
*
* Detects manual path normalization patterns and suggests using the utility function.
*
* @example
* // ❌ BAD - manual normalization
* const normalized = relativePath.split(path.sep).join('/');
* const normalized = somePath.split('\\').join('/');
*
* // ✅ GOOD - use utility function
* import { toForwardSlash } from '@ts-monorepo-template/example-utils';
* const normalized = toForwardSlash(relativePath);
*/

module.exports = {
meta: {
type: 'problem',
docs: {
description: 'Disallow manual path normalization patterns',
category: 'Cross-platform compatibility',
recommended: true,
},
fixable: 'code',
messages: {
useToForwardSlash:
'Use toForwardSlash() from @ts-monorepo-template/example-utils instead of manual path normalization. ' +
'Manual normalization is error-prone and less maintainable.',
},
schema: [],
},

create(context) {
const sourceCode = context.getSourceCode();
let hasToForwardSlashImport = false;
let utilsImportNode = null;

return {
ImportDeclaration(node) {
if (node.source.value === '@ts-monorepo-template/example-utils') {
utilsImportNode = node;
node.specifiers.forEach((spec) => {
if (spec.type === 'ImportSpecifier' && spec.imported.name === 'toForwardSlash') {
hasToForwardSlashImport = true;
}
});
}
},

CallExpression(node) {
// Check for .split(...).join('/') pattern
if (
node.callee.type === 'MemberExpression' &&
node.callee.property.name === 'join' &&
node.arguments.length === 1 &&
node.arguments[0].type === 'Literal' &&
node.arguments[0].value === '/'
) {
// Check if the object is a .split() call
const splitCall = node.callee.object;
if (
splitCall.type === 'CallExpression' &&
splitCall.callee.type === 'MemberExpression' &&
splitCall.callee.property.name === 'split' &&
splitCall.arguments.length === 1
) {
const splitArg = splitCall.arguments[0];

// Check if splitting by path.sep, '\\', or '\\\\'
const isSplittingByPathSep =
(splitArg.type === 'MemberExpression' &&
splitArg.object.name === 'path' &&
splitArg.property.name === 'sep') ||
(splitArg.type === 'Literal' && (splitArg.value === '\\' || splitArg.value === '\\\\'));

if (isSplittingByPathSep) {
const variableBeingSplit = splitCall.callee.object;

context.report({
node,
messageId: 'useToForwardSlash',
fix(fixer) {
const fixes = [];

// Replace the entire .split(...).join('/') with toForwardSlash(...)
const originalVar = sourceCode.getText(variableBeingSplit);
fixes.push(fixer.replaceText(node, `toForwardSlash(${originalVar})`));

// Add import if needed
if (!hasToForwardSlashImport) {
if (utilsImportNode) {
// Add to existing utils import
const lastSpecifier = utilsImportNode.specifiers[utilsImportNode.specifiers.length - 1];
fixes.push(fixer.insertTextAfter(lastSpecifier, ', toForwardSlash'));
} else {
// Create new import at the top
const firstNode = sourceCode.ast.body[0];
const newImport = `import { toForwardSlash } from '@ts-monorepo-template/example-utils';\n`;
fixes.push(fixer.insertTextBefore(firstNode, newImport));
}
}

return fixes;
},
});
}
}
}
},
};
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
/**
* ESLint rule to enforce normalizing path operations before string comparisons
*
* path.relative(), path.dirname(), path.basename(), path.join() return OS-specific separators.
* When comparing with literal strings (especially in markdown), normalize to forward slashes.
*
* @example
* // ❌ BAD - path.relative() returns backslashes on Windows
* const relativePath = path.relative(baseDir, filePath);
* if (content.includes(relativePath)) { ... } // FAILS on Windows!
*
* // ✅ GOOD - normalize before comparison
* import { toForwardSlash } from '@ts-monorepo-template/example-utils';
* const relativePath = toForwardSlash(path.relative(baseDir, filePath));
* if (content.includes(relativePath)) { ... }
*/

module.exports = {
meta: {
type: 'problem',
docs: {
description: 'Disallow using path operations directly in string comparisons',
category: 'Cross-platform compatibility',
recommended: true,
},
messages: {
normalizePathOperation:
'Wrap path.{{method}}() with toForwardSlash() from @ts-monorepo-template/example-utils before using in string operations. ' +
'Path operations return OS-specific separators that fail in cross-platform comparisons.',
},
schema: [],
},

create(context) {
// Path methods that return paths with OS-specific separators
const pathMethodsReturningPaths = new Set([
'relative',
'dirname',
'basename',
'join',
'resolve',
'normalize',
]);

// String methods that compare/search strings
const stringComparisonMethods = new Set([
'includes',
'indexOf',
'lastIndexOf',
'startsWith',
'endsWith',
'split',
'replace',
'replaceAll',
'match',
'search',
]);

// Track variables that hold unwrapped path operation results
const unwrappedPathVariables = new Set();
const wrappedPathVariables = new Set();

return {
VariableDeclarator(node) {
if (!node.init) return;

// Check if initializer is a path operation
if (
node.init.type === 'CallExpression' &&
node.init.callee.type === 'MemberExpression' &&
node.init.callee.object.type === 'Identifier' &&
node.init.callee.object.name === 'path' &&
pathMethodsReturningPaths.has(node.init.callee.property.name)
) {
// Check if it's wrapped in toForwardSlash()
if (node.id.type === 'Identifier') {
unwrappedPathVariables.add(node.id.name);
}
}

// Check if initializer is toForwardSlash(path.operation())
if (
node.init.type === 'CallExpression' &&
node.init.callee.type === 'Identifier' &&
node.init.callee.name === 'toForwardSlash' &&
node.init.arguments.length === 1
) {
if (node.id.type === 'Identifier') {
wrappedPathVariables.add(node.id.name);
}
}
},

CallExpression(node) {
// Check for direct path operation in string method argument
// e.g., content.includes(path.relative(...))
if (
node.callee.type === 'MemberExpression' &&
stringComparisonMethods.has(node.callee.property.name)
) {
node.arguments.forEach((arg) => {
if (
arg.type === 'CallExpression' &&
arg.callee.type === 'MemberExpression' &&
arg.callee.object.type === 'Identifier' &&
arg.callee.object.name === 'path' &&
pathMethodsReturningPaths.has(arg.callee.property.name)
) {
context.report({
node: arg,
messageId: 'normalizePathOperation',
data: {
method: arg.callee.property.name,
},
});
}

// Check if using unwrapped path variable
if (
arg.type === 'Identifier' &&
unwrappedPathVariables.has(arg.name) &&
!wrappedPathVariables.has(arg.name)
) {
context.report({
node: arg,
messageId: 'normalizePathOperation',
data: {
method: 'operation',
},
});
}
});
}

// Check for path operations in template literals used in string methods
if (
node.callee.type === 'MemberExpression' &&
stringComparisonMethods.has(node.callee.property.name)
) {
node.arguments.forEach((arg) => {
if (arg.type === 'TemplateLiteral') {
arg.expressions.forEach((expr) => {
if (
expr.type === 'CallExpression' &&
expr.callee.type === 'MemberExpression' &&
expr.callee.object.type === 'Identifier' &&
expr.callee.object.name === 'path' &&
pathMethodsReturningPaths.has(expr.callee.property.name)
) {
context.report({
node: expr,
messageId: 'normalizePathOperation',
data: {
method: expr.callee.property.name,
},
});
}
});
}
});
}
},
};
},
};
Loading