'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. .replace(/\\/g, '/') — regex /\\/g with replacement '/' * 2. .replace(/[\\/]/g, '/') — regex /[\\/]/g with replacement '/' * 3. .replaceAll('\\', '/') — literal backslash to slash * 4. .replaceAll(path.sep, '/') — path.sep to slash * 5. .split(path.sep).join('/') — split-join idiom * 6. toPosixPath() — 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() 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; // .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; } } // .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; } } // .split(path.sep).join('/') // The callee is .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()`, return ``; 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 `.replace(...)`, * `.replaceAll(...)`, or `.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; } // .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, };