Install eslint@9 + typescript-eslint@8 + globals@16 + eslint-plugin-n@17 + eslint-plugin-no-only-tests@3 + typescript as devDependencies. eslint.config.mjs (flat config): - Global ignores: node_modules, dist, .worktrees, .claude, coverage, the 12 generated get-shit-done/bin/lib/*.cjs files - Block for get-shit-done/bin/**/*.cjs + scripts/**/*.cjs: js.recommended + eslint-plugin-n + local plugin; generic quality rules (no-var, prefer-const, no-unused-vars, no-empty, n/no-process-exit) - Block for tests/**/*.test.cjs: no-only-tests (error), local timing rules, no-restricted-syntax timing bans eslint-rules/ local plugin (three AST rules, all at warn pending cleanup): - no-source-grep: flag readFileSync on source .cjs/.js/.ts + text methods - no-magic-sleep-in-tests: flag Atomics.wait and await-new-Promise(setTimeout) - no-elapsed-assertion: flag assert*() on timing props (elapsed/duration/took/ms) tsconfig.lint.json: allowJs + checkJs + noEmit for future type-aware passes. tests/eslint-rules.test.cjs: 15 RuleTester unit tests (all pass, 0 fail). package.json: add lint/lint:fix scripts; remove lint:tests (subsumed by ESLint local/no-source-grep). Rules that produced pre-existing errors downgraded to warn: no-useless-escape, no-unsafe-finally, no-regex-spaces, no-control-regex, no-irregular-whitespace. ESLint exits 0 (warnings ok). .github/workflows/test.yml lint-tests job: add npm ci + ESLint step; remove "Lint — no source-grep tests" step (now covered by ESLint); bump timeout 3→5 min. .gitignore: add node_modules/.cache/eslint/ entry. eslint --fix auto-cleaned: no-regex-spaces in tests, prefer-const in state.cjs, redundant eslint-disable-next-line comments. Co-authored-by: CI Rebase Check <ci@gsd-redux> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
117 lines
4.0 KiB
JavaScript
117 lines
4.0 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* no-source-grep
|
|
*
|
|
* Flags variables bound to readFileSync() of a .cjs/.js/.ts source path that
|
|
* later have .includes/.match/.startsWith/.indexOf called on them.
|
|
*
|
|
* Honor file-level escape comment: // allow-test-rule: <reason>
|
|
*/
|
|
|
|
/** @type {import('eslint').Rule.RuleModule} */
|
|
const rule = {
|
|
meta: {
|
|
type: 'problem',
|
|
docs: {
|
|
description:
|
|
'Disallow reading source .cjs/.js/.ts files with readFileSync and then doing text search on the result',
|
|
category: 'Best Practices',
|
|
},
|
|
schema: [],
|
|
messages: {
|
|
noSourceGrep:
|
|
'Source-grep test: do not read source .cjs/.js/.ts files with readFileSync and call .includes/.match/.startsWith/.indexOf on the result. Use require() to run the module instead. Add // allow-test-rule: <reason> at the top of the file to suppress.',
|
|
},
|
|
},
|
|
create(context) {
|
|
const sourceCode = context.getSourceCode
|
|
? context.getSourceCode()
|
|
: context.sourceCode;
|
|
|
|
// Check for file-level escape comment
|
|
const comments = sourceCode.getAllComments();
|
|
const hasAllowAnnotation = comments.some(
|
|
(c) => /allow-test-rule:\s*\S/.test(c.value)
|
|
);
|
|
if (hasAllowAnnotation) return {};
|
|
|
|
// Track variable names bound to readFileSync of a source path
|
|
const sourceGrepVars = new Set();
|
|
|
|
// Detect if a node represents a readFileSync call on a source file (.cjs/.js/.ts)
|
|
// that lives in a source directory (bin, lib, get-shit-done, src).
|
|
function isSourceReadFileSync(node) {
|
|
if (node.type !== 'CallExpression') return false;
|
|
|
|
// Match: readFileSync(...) or fs.readFileSync(...) or require('fs').readFileSync(...)
|
|
const callee = node.callee;
|
|
const isFsRead =
|
|
(callee.type === 'Identifier' && callee.name === 'readFileSync') ||
|
|
(callee.type === 'MemberExpression' &&
|
|
callee.property.type === 'Identifier' &&
|
|
callee.property.name === 'readFileSync');
|
|
|
|
if (!isFsRead) return false;
|
|
|
|
const args = node.arguments;
|
|
if (!args || args.length === 0) return false;
|
|
|
|
const firstArg = args[0];
|
|
const fullSrc = sourceCode.getText(firstArg);
|
|
|
|
return looksLikeSourcePath(fullSrc);
|
|
}
|
|
|
|
// Given the source text of a path expression, determine if it references
|
|
// a .cjs/.js/.ts source file in a source directory.
|
|
function looksLikeSourcePath(src) {
|
|
// Must end with a .cjs, .js, or .ts extension (in a string)
|
|
const hasCjsExt = /['"`.][^'"`.]*\.(?:cjs|js|ts)['"`)]/i.test(src);
|
|
if (!hasCjsExt) return false;
|
|
|
|
// Must reference a source directory indicator somewhere in the expression
|
|
const hasSourceDir = /['"](?:bin|lib|get-shit-done|src)['"]/i.test(src);
|
|
return hasSourceDir;
|
|
}
|
|
|
|
const TEXT_METHODS = new Set(['includes', 'match', 'startsWith', 'endsWith', 'indexOf', 'search']);
|
|
|
|
return {
|
|
VariableDeclarator(node) {
|
|
// const varName = readFileSync(...) OR const varName = fs.readFileSync(...)
|
|
if (node.init && isSourceReadFileSync(node.init)) {
|
|
if (node.id.type === 'Identifier') {
|
|
sourceGrepVars.add(node.id.name);
|
|
}
|
|
}
|
|
},
|
|
AssignmentExpression(node) {
|
|
if (node.right && isSourceReadFileSync(node.right)) {
|
|
if (node.left.type === 'Identifier') {
|
|
sourceGrepVars.add(node.left.name);
|
|
}
|
|
}
|
|
},
|
|
CallExpression(node) {
|
|
// varName.includes(...), varName.match(...), etc.
|
|
if (
|
|
node.callee.type === 'MemberExpression' &&
|
|
TEXT_METHODS.has(node.callee.property.name)
|
|
) {
|
|
const obj = node.callee.object;
|
|
if (obj.type === 'Identifier' && sourceGrepVars.has(obj.name)) {
|
|
context.report({ node, messageId: 'noSourceGrep' });
|
|
}
|
|
// Inline: readFileSync(...).includes(...)
|
|
if (isSourceReadFileSync(obj)) {
|
|
context.report({ node, messageId: 'noSourceGrep' });
|
|
}
|
|
}
|
|
},
|
|
};
|
|
},
|
|
};
|
|
|
|
module.exports = rule;
|