Files
msd-core/eslint-rules/lib/portability-vocab.cjs
2026-06-25 14:37:10 -04:00

338 lines
11 KiB
JavaScript

'use strict';
/**
* portability-vocab.cjs — single source of truth for path-related portability.
*
* PATH_RETURNING_FNS: canonical list of function calls (Node builtins and
* project resolvers) that return a filesystem path. The drift-guard test
* (tests/portability-vocab-drift.test.cjs) enforces completeness against
* src/runtime-homes.cts's exported path-returning functions.
*
* EXTEND THIS LIST when adding a new path resolver to the codebase.
* The drift-guard test will fail if you forget.
*
* ── Known boundaries ─────────────────────────────────────────────────────────
*
* Matching is by spelling: `path`, `os`, and the project resolver names below
* are assumed to refer to the standard Node modules / project resolver exports.
* A local variable that shadows one of these names (e.g. `const path = …`) is
* out of scope — the helpers treat it as the real module.
*
* isPosixNormalizerCall inspects only the DIRECT argument of the call node;
* deeper nesting (e.g. `String(path.join(...)).toLowerCase().replace(/\\/g,'/')`)
* is not covered — only the outermost call and one level of String() cast are
* visible to the rule.
*/
/**
* Canonical set of function names (dotted or bare) that return filesystem paths.
*
* Format:
* - "path.join" → MemberExpression: object=Identifier{path}, property=Identifier{join}
* - "os.homedir" → MemberExpression: object=Identifier{os}, property=Identifier{homedir}
* - "getGlobalDir" → Identifier callee with that name
*/
const PATH_RETURNING_FNS = [
// ── Node built-ins ──────────────────────────────────────────────────────────
'path.join',
'path.resolve',
'path.dirname',
'path.basename',
'path.normalize',
'path.relative',
'os.homedir',
'os.tmpdir',
// ── Project resolvers (src/runtime-homes.cts exports + install.js helpers) ──
// Add bare function names here; dotted forms (e.g. obj.resolveX) are not used
// in the test corpus because these are module-level exports, not methods.
'resolveAgentDir',
'getGlobalConfigDir',
'getGlobalSkillsBase',
'getGlobalSkillDir',
'getGlobalSkillDisplayPath',
'resolveSkillsBaseFromDescriptor',
'resolveConfigHomeFromDescriptor',
'resolveKimiGlobalDir',
'resolveAntigravityGlobalDir',
'getGlobalDir',
'getConfigDirFromHome',
'resolveKiloConfigPath',
'resolveOpencodeConfigPath',
'computePathPrefix',
'expandHome',
'getPathX',
'normalizeInstallRelativePath',
'toPosixPath',
];
/**
* Returns true when `node` is a CallExpression whose callee matches one of the
* PATH_RETURNING_FNS entries.
*
* Handles two call shapes:
* - Dotted: path.join(…) → callee is MemberExpression{object: Identifier, property: Identifier}
* - Bare: getGlobalDir() → callee is Identifier
*
* @param {import('eslint').Rule.Node} node - AST node to inspect
* @returns {boolean}
*/
function isPathReturningCall(node) {
if (!node || node.type !== 'CallExpression') return false;
const callee = node.callee;
// Dotted call: path.join, os.homedir, etc.
if (
callee.type === 'MemberExpression' &&
!callee.computed &&
callee.object.type === 'Identifier' &&
callee.property.type === 'Identifier'
) {
const dotted = `${callee.object.name}.${callee.property.name}`;
if (PATH_RETURNING_FNS.includes(dotted)) return true;
}
// Bare call: getGlobalConfigDir(), resolveKimiGlobalDir(), etc.
if (callee.type === 'Identifier') {
if (PATH_RETURNING_FNS.includes(callee.name)) return true;
}
return false;
}
/**
* Returns true when `node` is a string literal (or a template literal with no
* expressions) whose value contains '/' and does NOT look like a URL.
*
* URL exclusion: value starts with 'http://' or 'https://'.
*
* @param {import('eslint').Rule.Node} node
* @returns {boolean}
*/
function isPosixSlashStringLiteral(node) {
if (!node) return false;
// Plain string literal
if (node.type === 'Literal' && typeof node.value === 'string') {
const v = node.value;
if (!v.includes('/')) return false;
if (v.startsWith('http://') || v.startsWith('https://')) return false;
return true;
}
// Template literal with no expressions (static): `some/path`
if (node.type === 'TemplateLiteral' && node.expressions.length === 0) {
const v = node.quasis[0]?.value?.cooked ?? '';
if (!v.includes('/')) return false;
if (v.startsWith('http://') || v.startsWith('https://')) return false;
return true;
}
return false;
}
/**
* Returns true when `node` is a CallExpression that normalizes its first
* argument to POSIX-style slashes.
*
* Recognized shapes:
* 1. <x>.replace(/\\/g, '/') — regex /\\/g with replacement '/'
* 2. <x>.replace(/[\\/]/g, '/') — regex /[\\/]/g with replacement '/'
* 3. <x>.replaceAll('\\', '/') — literal backslash to slash
* 4. <x>.replaceAll(path.sep, '/') — path.sep to slash
* 5. <x>.split(path.sep).join('/') — split-join idiom
* 6. toPosixPath(<x>) — explicit wrapper
*
* Note: for replace(), we REQUIRE the 'g' flag on the regex AND the regex
* source must actually target backslashes (source `\\` or `[\\/]`).
* A regex like /foo/g or /\//g does NOT qualify.
*
* @param {import('eslint').Rule.Node} node
* @returns {boolean}
*/
function isPosixNormalizerCall(node) {
if (!node || node.type !== 'CallExpression') return false;
const callee = node.callee;
// toPosixPath(<x>)
if (callee.type === 'Identifier' && callee.name === 'toPosixPath') return true;
if (
callee.type === 'MemberExpression' &&
!callee.computed &&
callee.property.type === 'Identifier'
) {
const method = callee.property.name;
const args = node.arguments;
// <x>.replace(regex, '/')
// REQUIRE: g flag + regex source must target backslashes: `\\` or `[\\/]`
if (method === 'replace' && args.length >= 2) {
const regexArg = args[0];
const replacementArg = args[1];
if (
regexArg.type === 'Literal' &&
regexArg.regex != null &&
regexArg.regex.flags.includes('g') &&
_isBackslashTargetingRegex(regexArg.regex.pattern) &&
_isSlashReplacement(replacementArg)
) {
return true;
}
}
// <x>.replaceAll(sep, '/')
if (method === 'replaceAll' && args.length >= 2) {
const sepArg = args[0];
const replacementArg = args[1];
if (_isSlashReplacement(replacementArg)) {
// replaceAll('\\', '/') or replaceAll('\\\\', '/') or replaceAll(path.sep, '/')
if (_isBackslashLiteral(sepArg)) return true;
if (_isPathSep(sepArg)) return true;
}
}
// <x>.split(path.sep).join('/')
// The callee is <split_result>.join — check the object for .split(path.sep)
if (method === 'join' && args.length >= 1 && _isSlashReplacement(args[0])) {
const splitCall = callee.object;
if (
splitCall.type === 'CallExpression' &&
splitCall.callee.type === 'MemberExpression' &&
!splitCall.callee.computed &&
splitCall.callee.property.type === 'Identifier' &&
splitCall.callee.property.name === 'split' &&
splitCall.arguments.length >= 1 &&
_isPathSep(splitCall.arguments[0])
) {
return true;
}
}
}
return false;
}
/** True if node is the replacement '/' string literal */
function _isSlashReplacement(node) {
return node && node.type === 'Literal' && node.value === '/';
}
/**
* True if regexPattern (the raw regex source string, as stored in the AST's
* `.regex.pattern` field) actually targets backslashes.
*
* Accepted: exactly `\\` (two-char, two backslashes: matches one backslash)
* exactly `[\\/]` (five-char: backslash-or-forward-slash charset)
* Rejected: `foo`, `\/` (forward-slash only), anything else.
*
* @param {string} pattern — the AST `.regex.pattern` string
* @returns {boolean}
*/
function _isBackslashTargetingRegex(pattern) {
// Pattern `\\` (two backslash chars in the regex) — matches a single backslash
if (pattern === '\\\\') return true;
// Pattern `[\\/]` (backslash-or-forward-slash charset) — five chars
if (pattern === '[\\\\/]') return true;
return false;
}
/** True if node is a backslash literal ('\\' or '\\\\') */
function _isBackslashLiteral(node) {
if (!node || node.type !== 'Literal') return false;
return node.value === '\\' || node.value === '\\\\';
}
/** True if node is path.sep */
function _isPathSep(node) {
return (
node &&
node.type === 'MemberExpression' &&
!node.computed &&
node.object.type === 'Identifier' &&
node.object.name === 'path' &&
node.property.type === 'Identifier' &&
node.property.name === 'sep'
);
}
/**
* If `node` is `String(<x>)`, return `<x>`; otherwise return `node` as-is.
* Allows the rule to see through String() casts on path expressions.
*
* @param {import('eslint').Rule.Node} node
* @returns {import('eslint').Rule.Node}
*/
function unwrapString(node) {
if (
node &&
node.type === 'CallExpression' &&
node.callee.type === 'Identifier' &&
node.callee.name === 'String' &&
node.arguments.length === 1
) {
return node.arguments[0];
}
return node;
}
/**
* If `node` is a method-call chain of the form `<receiver>.replace(...)`,
* `<receiver>.replaceAll(...)`, or `<receiver>.split(...).join(...)` that is
* NOT a valid POSIX normalizer (i.e. `isPosixNormalizerCall(node)` is false),
* return the receiver (the `.object` of the callee MemberExpression).
*
* This lets the rule detect:
* `path.join(a,b).replace(/foo/g, '/')` → not a normalizer, but the
* receiver `path.join(a,b)` IS a path-returning call → violation.
*
* Only peels ONE layer. The caller is responsible for checking the peeled node.
* Returns `null` when `node` is already a valid normalizer or is not a method chain.
*
* @param {import('eslint').Rule.Node} node
* @returns {import('eslint').Rule.Node | null}
*/
function unwrapNonNormalizerMethodChain(node) {
if (!node || node.type !== 'CallExpression') return null;
// If it IS a valid normalizer, do NOT peel — the caller already handled that.
if (isPosixNormalizerCall(node)) return null;
const callee = node.callee;
if (
callee.type === 'MemberExpression' &&
!callee.computed &&
callee.property.type === 'Identifier'
) {
const method = callee.property.name;
// String-mutation methods that commonly wrap path calls
if (method === 'replace' || method === 'replaceAll') {
return callee.object;
}
// <x>.split(...).join(...) — callee.object is the .split() result;
// peel to the .split()'s receiver
if (method === 'join') {
const splitCall = callee.object;
if (
splitCall &&
splitCall.type === 'CallExpression' &&
splitCall.callee.type === 'MemberExpression' &&
!splitCall.callee.computed &&
splitCall.callee.property.type === 'Identifier' &&
splitCall.callee.property.name === 'split'
) {
return splitCall.callee.object;
}
}
}
return null;
}
module.exports = {
PATH_RETURNING_FNS,
isPathReturningCall,
isPosixSlashStringLiteral,
isPosixNormalizerCall,
unwrapString,
unwrapNonNormalizerMethodChain,
};