peekchain
Version:
Optional chaining safety checker CLI
464 lines (386 loc) • 19.3 kB
JavaScript
// checkOptionalChaining.js
const { existsSync, readFileSync } = require('fs');
const p = require('path');
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
function isFullyOptionalChain(path) {
// Traverse down the chain from the current member expression
let current = path;
while (current && (current.isMemberExpression() || current.isOptionalMemberExpression())) {
if (!current.isOptionalMemberExpression()) {
// This node is a normal MemberExpression (no optional chaining at this link)
return false;
}
if (current.isOptionalMemberExpression() && !current.node.optional) {
// This node is an OptionalMemberExpression, but `.optional` is false,
// meaning this particular access used a plain dot.
return false;
}
// Move to the next object in the chain (e.g., traverse from `obj?.foo.bar` to `obj?.foo`)
current = current.get("object");
}
// If we exited the loop without finding a non-optional link, the chain is fully optional
return true;
}
// Needed for __dirname in ESM
// import { fileURLToPath } from 'url';
// import { argv } from 'process';
// 🧠 Get root identifier from any nested chain
function getBaseIdentifierName(node) {
let current = node;
while (current && (current.type === 'MemberExpression' || current.type === 'OptionalMemberExpression')) {
current = current.object;
}
return current?.type === 'Identifier' ? current.name : null;
}
function checkOptionalChainSafety(path, file) {
const chainLinks = [];
let { node } = path;
// Walk down the callee/member chain and collect links
while (
node.type === 'CallExpression' || node.type === 'OptionalCallExpression' ||
node.type === 'MemberExpression' || node.type === 'OptionalMemberExpression'
) {
chainLinks.push(node);
// Move to the next link: callee of calls, object of member access
if (node.type === 'CallExpression' || node.type === 'OptionalCallExpression') {
node = node.callee;
} else {
node = node.object;
}
}
// Now analyze the chain from base to top
let chainActive = false;
// let unsafe = false;
// Traverse from base object up to the final call
for (let i = chainLinks.length - 1; i >= 0; i--) {
const link = chainLinks[i];
const isOptionalType = link.type === 'OptionalMemberExpression' || link.type === 'OptionalCallExpression';
const hasOptionalOperator = isOptionalType && link.optional === true;
const isLast = i === 0; // <- Add this
const isCall = link.type === 'CallExpression' || link.type === 'OptionalCallExpression';
if (chainActive && (!isOptionalType || link.optional === false)) {
if (!(isLast && isCall)) {
// Chain already started, but this link has no optional operator
// unsafe = true;
const line = path.node.loc?.start?.line || '?';
console.log(`\n\n[Unsafe Optional Call] ${file}:${line}`);
console.log(` ↪ ${path.toString()}`);
console.log(` Once optional chaining starts, all links and the final call must use '?.'`);
return true; // unsafe
// break;
}
}
if (hasOptionalOperator) {
// This link has a ?. operator, so the optional chain is (or remains) active
chainActive = true;
}
}
// if (unsafe) { // never executed due to this earlier block
// // Report or collect the error (here we just log for illustration)
// console.log(`Unsafe optional call at line ${path.node.loc.start.line}: ${path.toString()}`);
// }
return false; // ✅ safe
}
function runOptionalChainingCheck() {
try {
console.log(`Script started...`); // test
const STATIC_SAFE_CALLS = new Set();
const relativePath = process.argv[2];
if (!relativePath) {
console.log(`No file argument provided.`);
process.exit(0);
}
const file = p.resolve(process.cwd(), relativePath);
const fileExists = existsSync(file);
if (!file || !fileExists) {
console.log(`No file provided or file doesn't exist.`);
process.exit(0);
}
const code = readFileSync(file, 'utf8');
const errorFound = { value: false };
// const astCommentCleaner = parser.parse(code, {
// sourceType: 'module',
// plugins: ['jsx', 'optionalChaining', ['optionalChainingAssign', { version: '2023-07' }]],
// comments: false,
// });
// const codeWithoutComments = babelGenerator.default(astCommentCleaner, { comments: false }).code;
// Ensure 'log' folder exists
// if (!fs.existsSync('log')) {
// fs.mkdirSync('log');
// }
// fs.writeFileSync('log/astCommentCleaner.json', JSON.stringify(astCommentCleaner, null, 2), 'utf8');
// fs.writeFileSync('log/code.txt', JSON.stringify(code, null, 2), 'utf8');
// ❌ Regex pre-checks for invalid optional chaining use
const invalidChainingPatterns = [
/\+\+\s*[a-zA-Z_$][\w$]*\?\.\w+/, // ++user?.count
/[a-zA-Z_$][\w$]*\?\.\w+\s*\+\+/, // user?.count++
/[a-zA-Z_$][\w$]*\?\.\w+\s*=(?!=|>)/ // user?.name = "x" but NOT ===, ==, =>
];
// const lines = codeWithoutComments.split('\n');
const lines = code.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!/^\s*(import|export)\s/.test(line)) {
for (const pattern of invalidChainingPatterns) {
if (pattern.test(line)) {
console.log(`\n\n[Invalid Pattern] ${file}:${i + 1}`);
console.log(` ↪ ${line.trim()}`);
console.log(` Optional chaining cannot be used on the left-hand side of assignment, delete, or increment/decrement.`);
process.exit(1);
}
}
}
}
const ast = parser.parse(code, {
sourceType: 'module',
plugins: [['optionalChainingAssign', { version: '2023-07' }], 'optionalChaining', 'jsx'],
comments: true
});
// fs.writeFileSync('log/ast.json', JSON.stringify(ast, null, 2), 'utf8');
// fs.writeFileSync('log/codeWithoutComments.txt', JSON.stringify(codeWithoutComments, null, 2), 'utf8');
const localIdentifiers = new Set();
// function isFullyOptionalChain(path) {
// let node = path.node;
// // Check downward
// while (node && (node.type === 'MemberExpression' || node.type === 'OptionalMemberExpression')) {
// if (!node.optional) {
// return false; // 🛑 Found non-optional access
// }
// node = node.object;
// }
// // Check upward
// let parentPath = path.parentPath;
// while (parentPath && (parentPath.isMemberExpression() || parentPath.isOptionalMemberExpression())) {
// if (!parentPath.node.optional) {
// return false; // 🛑 Parent access is non-optional
// }
// parentPath = parentPath.parentPath;
// }
// return true; // ✅ All access are optional
// }
// Helper function to check if a member chain is fully optional
traverse(ast, {
ImportDeclaration(path) {
const importSource = path.node.source.value;
const isLocalImport = importSource.startsWith('./') || importSource.startsWith('../');
path.node.specifiers.forEach(spec => {
if (spec.local) {
const importedName = spec.local.name;
if (!isLocalImport) {
STATIC_SAFE_CALLS.add(importedName);
} else {
localIdentifiers.add(importedName);
}
}
});
}
});
const addToLocalIdentifiers = (name) => {
if (!STATIC_SAFE_CALLS.has(name)) {
localIdentifiers.add(name);
}
}
traverse(ast, {
VariableDeclarator(path) {
if (path.node.id.type === 'Identifier') {
// if (!STATIC_SAFE_CALLS.has(path.node.id.name)) {
// localIdentifiers.add(path.node.id.name);
// }
addToLocalIdentifiers(path.node.id.name);
} else if (path.node.id.type === 'ObjectPattern') {
// ✅ Handle destructuring: { user } = this.props // Class based components
for (const property of path.node.id.properties) {
if (property.type === 'ObjectProperty' && property.key.type === 'Identifier') {
addToLocalIdentifiers(property.key.name);
}
}
}
},
FunctionDeclaration(path) {
if (path.node.id) {
// if (!STATIC_SAFE_CALLS.has(path.node.id.name)) {
// localIdentifiers.add(path.node.id.name);
// }
addToLocalIdentifiers(path.node.id.name);
}
},
ClassDeclaration(path) {
if (path.node.id) {
// if (!STATIC_SAFE_CALLS.has(path.node.id.name)) {
// localIdentifiers.add(path.node.id.name);
// }
getBaseIdentifierName(path.node.id.name);
}
},
ImportDeclaration(path) {
path.node.specifiers.forEach(spec => {
if (spec.local) {
// if (!STATIC_SAFE_CALLS.has(spec.local.name)) {
// localIdentifiers.add(spec.local.name);
// }
getBaseIdentifierName(spec.local.name);
}
});
}
});
// console.log('localIdentifiers populated ', localIdentifiers);
// console.log(JSON.stringify(ast, null, 2)); // 🌟 Full readable AST
// ✅ Deep inspection for member, optional, and call expressions
traverse(ast, {
// MemberExpression(path) {
// const baseName = getBaseIdentifierName(path.node);
// const propertyName = path.node.property?.name;
// // ✅ Skip if it's assigning .propTypes to a local component
// if (
// propertyName === 'propTypes' &&
// localIdentifiers.has(baseName) &&
// path.parent?.type === 'AssignmentExpression' &&
// path.parent.left === path.node
// ) {
// return; // ✅ This is safe: MyComponent.propTypes = { ... }
// }
// if (localIdentifiers.has(baseName) && !isFullyOptionalChain(path)) {
// const line = path.node.loc?.start?.line || '?';
// console.log(`❌ [Unsafe Access] ${file}:${line}`);
// console.log(` ↪ ${path.toString()}`);
// console.log(` ⚠️ '${baseName}' is local, but some part of the chain is accessed unsafely after optional chaining.`);
// errorFound = true;
// }
// },
// OptionalMemberExpression(path) {
// const propertyName = path.node.property?.name;
// const baseName = getBaseIdentifierName(path.node);
// // ✅ Skip known safe pattern: MyComponent.propTypes = ...
// if (
// propertyName === 'propTypes' &&
// localIdentifiers.has(baseName) &&
// path.parent?.type === 'AssignmentExpression' &&
// path.parent.left === path.node
// ) {
// return;
// }
// if (localIdentifiers.has(baseName)) {
// const parent = path.parentPath;
// const line = path.node.loc?.start?.line || '?';
// if (
// parent.isAssignmentExpression() ||
// parent.isUpdateExpression()
// ) {
// console.log(`❌ [Chaining Misuse] ${file}:${line}`);
// console.log(` ↪ ${path.toString()}`);
// console.log(` 🚫 Optional chaining misused with assignment/delete/increment.`);
// errorFound = true;
// }
// }
// },
// Handle both OptionalMemberExpression and MemberExpression nodes
"MemberExpression|OptionalMemberExpression": function checkMemberExpression(path) {
// Only check the outermost member of a chain to avoid duplicate checks
if (path.parentPath.isMemberExpression() || path.parentPath.isOptionalMemberExpression()) {
return; // Skip if parent is also a property access (not the chain's end)
}
// Now `path` is the top of a member access chain
if (!isFullyOptionalChain(path)) {
const { line } = path.node.loc.start;
const baseName = getBaseIdentifierName(path.node);
// You could collect this location or otherwise record the violation as needed
if (localIdentifiers.has(baseName)) {
console.log(`\n\n[Unsafe Access] ${file}:${line}`);
console.log(` ↪ ${path.toString()}`);
console.log(` '${baseName}' is local, but some part of the chain is accessed unsafely after optional chaining.`);
errorFound.value = true;
}
}
},
CallExpression(path) {
// if (path.node.type === 'CallExpression') {
if (checkOptionalChainSafety(path, file)) {
errorFound.value = true;
}
// }
const { callee } = path.node;
if (callee.type === 'MemberExpression' || callee.type === 'OptionalMemberExpression') {
const baseName = getBaseIdentifierName(callee);
if (localIdentifiers.has(baseName) && !isFullyOptionalChain(path.get('callee'))) {
const line = path.node.loc?.start?.line || '?';
console.log(`\n\n[Unsafe Call Access] ${file}:${line}`);
console.log(` ↪ ${path.toString()}`);
console.log(` '${baseName}' is local, but function/property call chain is not safely guarded.`);
errorFound.value = true;
}
}
},
VariableDeclarator(path) { // first
const line = path.node.loc?.start?.line || '?';
if (path.node.id.type === 'ObjectPattern') {
const { init } = path.node;
const unsafe =
!init ||
init.type === 'Identifier' ||
init.type === 'NullLiteral' ||
(init.type === 'Literal' && init.value === null);
if (unsafe) {
console.log(`\n\n[Unguarded Destructuring] ${file}:${line}`);
console.log(` ↪ const { ... } = ${init?.name || 'null/undefined'}`);
console.log(` Add fallback: const { name } = ${init?.name || 'obj'} ?? {}`);
errorFound.value = true;
}
}
},
UnaryExpression(path) {
if (path.node.operator !== 'delete') return;
const arg = path.node.argument;
// ✅ If already optional (safe delete), ignore
if (arg.type === 'OptionalMemberExpression') {
return;
}
// ❌ If non-optional, check if dangerous
if (
arg.type === 'MemberExpression' &&
!arg.optional && // redundant, but safe
arg.object.type === 'Identifier'
) {
const base = arg.object.name;
if (localIdentifiers.has(base)) {
const line = path.node.loc?.start?.line || '?';
console.log(`\n\n[Unsafe Delete Access] ${file}:${line}`);
console.log(` ↪ ${path.toString()}`);
console.log(` '${base}' may be null/undefined. Use optional chaining: delete ${base}?.prop`);
errorFound.value = true;
}
}
},
OptionalCallExpression(path) {
// Handle optional calls separately
if (checkOptionalChainSafety(path, file)) {
errorFound.value = true;
}
}
});
if (errorFound.value) {
console.log('FAIL');
process.exit(1);
} else {
console.log('All checks passed.');
console.log('PASS');
}
} catch (e) {
if (e instanceof Error && e.message.startsWith('ProcessExit_')) {
throw e; // Let Jest test catch this
}
console.log('Unexpected error during optional chaining analysis:', e);
console.log('FAIL');
process.exit(1); // fallback exit
}
}
module.exports = { runOptionalChainingCheck }; // for mjs
// export { runOptionalChainingCheck };for esm
// ✅ Call runOptionalChainingCheck if run via `node checkOptionalChaining.js file.js`
if (require?.main === module) { // for mjs
runOptionalChainingCheck();
}
// const currentFile = fileURLToPath(import.meta.url); // for esm
// if (argv[1] === currentFile) {
// runOptionalChainingCheck();
// }