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>
This commit is contained in:
319
tests/eslint-rules.test.cjs
Normal file
319
tests/eslint-rules.test.cjs
Normal file
@@ -0,0 +1,319 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* eslint-rules.test.cjs
|
||||
*
|
||||
* RuleTester unit tests for the three local ESLint rules:
|
||||
* - local/no-source-grep
|
||||
* - local/no-magic-sleep-in-tests
|
||||
* - local/no-elapsed-assertion
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { RuleTester } = require('eslint');
|
||||
|
||||
const noSourceGrep = require('../eslint-rules/no-source-grep.cjs');
|
||||
const noMagicSleepInTests = require('../eslint-rules/no-magic-sleep-in-tests.cjs');
|
||||
const noElapsedAssertion = require('../eslint-rules/no-elapsed-assertion.cjs');
|
||||
|
||||
const ruleTester = new RuleTester({
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: 'commonjs',
|
||||
},
|
||||
});
|
||||
|
||||
// ─── no-source-grep ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('no-source-grep rule', () => {
|
||||
test('valid: readFileSync on .md file is allowed', () => {
|
||||
ruleTester.run('no-source-grep', noSourceGrep, {
|
||||
valid: [
|
||||
{
|
||||
code: `
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const content = fs.readFileSync(path.join(__dirname, '..', 'docs', 'readme.md'), 'utf-8');
|
||||
content.includes('hello');
|
||||
`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
{
|
||||
code: `
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const content = fs.readFileSync(path.join(__dirname, '..', 'get-shit-done', 'workflows', 'config.json'), 'utf-8');
|
||||
content.includes('key');
|
||||
`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
assert.ok(true, 'no-source-grep valid cases passed');
|
||||
});
|
||||
|
||||
test('invalid: readFileSync on .cjs source file followed by .includes()', () => {
|
||||
ruleTester.run('no-source-grep', noSourceGrep, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib', 'core.cjs'), 'utf-8');
|
||||
src.includes('someFunction');
|
||||
`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'noSourceGrep' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.ok(true, 'no-source-grep invalid case detected');
|
||||
});
|
||||
|
||||
test('invalid: readFileSync on .cjs source file followed by .match()', () => {
|
||||
ruleTester.run('no-source-grep', noSourceGrep, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', 'lib', 'foo.cjs'), 'utf-8');
|
||||
src.match(/pattern/);
|
||||
`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'noSourceGrep' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.ok(true, 'no-source-grep match case detected');
|
||||
});
|
||||
|
||||
test('valid: file with allow-test-rule annotation is exempt', () => {
|
||||
ruleTester.run('no-source-grep', noSourceGrep, {
|
||||
valid: [
|
||||
{
|
||||
// The allow annotation exempts the whole file
|
||||
code: `
|
||||
// allow-test-rule: pending migration
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib', 'core.cjs'), 'utf-8');
|
||||
src.includes('someFunction');
|
||||
`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
assert.ok(true, 'no-source-grep allow-test-rule annotation works');
|
||||
});
|
||||
|
||||
test('valid: require() of a .cjs file is allowed (not readFileSync)', () => {
|
||||
ruleTester.run('no-source-grep', noSourceGrep, {
|
||||
valid: [
|
||||
{
|
||||
code: `
|
||||
const mod = require('../get-shit-done/bin/lib/core.cjs');
|
||||
mod.someMethod();
|
||||
`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
assert.ok(true, 'no-source-grep require() is allowed');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── no-magic-sleep-in-tests ─────────────────────────────────────────────────
|
||||
|
||||
describe('no-magic-sleep-in-tests rule', () => {
|
||||
test('valid: setTimeout used outside tests (no-op since rule only applies to *.test.cjs)', () => {
|
||||
// Rule only applies to *.test.cjs files; a non-test filename is always valid
|
||||
ruleTester.run('no-magic-sleep-in-tests', noMagicSleepInTests, {
|
||||
valid: [
|
||||
{
|
||||
code: `
|
||||
const delay = new Promise(resolve => setTimeout(resolve, 100));
|
||||
`,
|
||||
filename: 'scripts/some-script.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
assert.ok(true, 'no-magic-sleep-in-tests does not apply outside test files');
|
||||
});
|
||||
|
||||
test('invalid: Atomics.wait() in test file', () => {
|
||||
ruleTester.run('no-magic-sleep-in-tests', noMagicSleepInTests, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `
|
||||
const shared = new SharedArrayBuffer(4);
|
||||
const arr = new Int32Array(shared);
|
||||
Atomics.wait(arr, 0, 0, 100);
|
||||
`,
|
||||
filename: 'tests/some.test.cjs',
|
||||
errors: [{ messageId: 'atomicsWaitSleep' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.ok(true, 'no-magic-sleep-in-tests flags Atomics.wait()');
|
||||
});
|
||||
|
||||
test('invalid: setTimeout used for synchronization in Promise in test file', () => {
|
||||
ruleTester.run('no-magic-sleep-in-tests', noMagicSleepInTests, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `
|
||||
async function waitABit() {
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
}
|
||||
`,
|
||||
filename: 'tests/some.test.cjs',
|
||||
errors: [{ messageId: 'setTimeoutSync' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.ok(true, 'no-magic-sleep-in-tests flags setTimeout in Promise');
|
||||
});
|
||||
|
||||
test('valid: setTimeout with callback (not synchronization pattern) in test file', () => {
|
||||
// A setTimeout with no second arg or with a callback that does real work
|
||||
// is allowed. The rule only flags the await-new-Promise(setTimeout) pattern.
|
||||
ruleTester.run('no-magic-sleep-in-tests', noMagicSleepInTests, {
|
||||
valid: [
|
||||
{
|
||||
code: `
|
||||
function doSomethingLater(cb) {
|
||||
setTimeout(cb, 100);
|
||||
}
|
||||
`,
|
||||
filename: 'tests/some.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
assert.ok(true, 'no-magic-sleep-in-tests allows simple callback setTimeout');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── no-elapsed-assertion ─────────────────────────────────────────────────────
|
||||
|
||||
describe('no-elapsed-assertion rule', () => {
|
||||
test('valid: assert on non-timing property', () => {
|
||||
ruleTester.run('no-elapsed-assertion', noElapsedAssertion, {
|
||||
valid: [
|
||||
{
|
||||
code: `
|
||||
const assert = require('node:assert/strict');
|
||||
const result = { count: 5 };
|
||||
assert.equal(result.count, 5);
|
||||
`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
{
|
||||
code: `
|
||||
const assert = require('node:assert/strict');
|
||||
assert.ok(result.success);
|
||||
`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
assert.ok(true, 'no-elapsed-assertion valid cases passed');
|
||||
});
|
||||
|
||||
test('invalid: assert on .elapsed property', () => {
|
||||
ruleTester.run('no-elapsed-assertion', noElapsedAssertion, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `
|
||||
const assert = require('node:assert/strict');
|
||||
const result = { elapsed: 150 };
|
||||
assert.ok(result.elapsed < 200);
|
||||
`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'noElapsedAssertion' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.ok(true, 'no-elapsed-assertion flags assert on .elapsed');
|
||||
});
|
||||
|
||||
test('invalid: assert on .duration property', () => {
|
||||
ruleTester.run('no-elapsed-assertion', noElapsedAssertion, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `
|
||||
const assert = require('node:assert/strict');
|
||||
assert.equal(stats.duration, 100);
|
||||
`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'noElapsedAssertion' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.ok(true, 'no-elapsed-assertion flags assert on .duration');
|
||||
});
|
||||
|
||||
test('invalid: assert on .took property', () => {
|
||||
ruleTester.run('no-elapsed-assertion', noElapsedAssertion, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `
|
||||
const assert = require('node:assert/strict');
|
||||
assert.ok(result.took < 500);
|
||||
`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'noElapsedAssertion' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.ok(true, 'no-elapsed-assertion flags assert on .took');
|
||||
});
|
||||
|
||||
test('invalid: assert on .ms property', () => {
|
||||
ruleTester.run('no-elapsed-assertion', noElapsedAssertion, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `
|
||||
const assert = require('node:assert/strict');
|
||||
assert.ok(result.ms > 0);
|
||||
`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'noElapsedAssertion' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.ok(true, 'no-elapsed-assertion flags assert on .ms');
|
||||
});
|
||||
|
||||
test('invalid: assert.equal with timing comparison', () => {
|
||||
ruleTester.run('no-elapsed-assertion', noElapsedAssertion, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `
|
||||
const assert = require('node:assert/strict');
|
||||
assert.equal(result.elapsed > 0, true);
|
||||
`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'noElapsedAssertion' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.ok(true, 'no-elapsed-assertion flags assert.equal with timing comparison');
|
||||
});
|
||||
});
|
||||
@@ -145,8 +145,8 @@ describe('Gemini Markdown Processor', () => {
|
||||
|
||||
const result = convertClaudeToGeminiAgent(input);
|
||||
|
||||
assert.match(result, /^ - read_file$/m, 'Read should still map to Gemini read_file');
|
||||
assert.doesNotMatch(result, /^ - ask_user$/m, 'ask_user must not be emitted as a Gemini tool');
|
||||
assert.match(result, /^ {2}- read_file$/m, 'Read should still map to Gemini read_file');
|
||||
assert.doesNotMatch(result, /^ {2}- ask_user$/m, 'ask_user must not be emitted as a Gemini tool');
|
||||
assert.doesNotMatch(result, /\bAskUserQuestion\b/, 'Claude tool references must be neutralized');
|
||||
assert.doesNotMatch(result, /\bask_user\b/, 'lowercase ask_user references must be neutralized');
|
||||
assert.match(result, /conversational prompting/, 'body should use runtime-neutral wording');
|
||||
|
||||
@@ -64,7 +64,7 @@ const SKILL_RUNTIMES = [
|
||||
// ─── Helper functions ─────────────────────────────────────────────────────────
|
||||
|
||||
function stripAnsi(str) {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
|
||||
return str.replace(/\x1b\[[0-9;]*m/g, '');
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ function parseWorkflowStructure(src) {
|
||||
// A new top-level key ends the `on:` block
|
||||
if (/^[a-zA-Z]/.test(line) && !line.startsWith(' ')) { inOn = false; continue; }
|
||||
// Direct children of `on:` are trigger names at 2-space indent
|
||||
const m = line.match(/^ ([a-zA-Z_][a-zA-Z0-9_]*):/);
|
||||
const m = line.match(/^ {2}([a-zA-Z_][a-zA-Z0-9_]*):/);
|
||||
if (m) onTriggers.push(m[1]);
|
||||
}
|
||||
}
|
||||
@@ -45,17 +45,17 @@ function parseWorkflowStructure(src) {
|
||||
let inPrPaths = false;
|
||||
let inPullRequest = false;
|
||||
for (const line of lines) {
|
||||
if (/^ pull_request:/.test(line)) { inPullRequest = true; continue; }
|
||||
if (/^ {2}pull_request:/.test(line)) { inPullRequest = true; continue; }
|
||||
if (inPullRequest) {
|
||||
if (/^ paths:/.test(line)) { inPrPaths = true; continue; }
|
||||
if (/^ {4}paths:/.test(line)) { inPrPaths = true; continue; }
|
||||
if (inPrPaths) {
|
||||
const m = line.match(/^ - '(.+)'/);
|
||||
const m = line.match(/^ {6}- '(.+)'/);
|
||||
if (m) { prPaths.push(m[1]); continue; }
|
||||
// End of paths list
|
||||
if (/^ [a-zA-Z]/.test(line)) inPrPaths = false;
|
||||
if (/^ {4}[a-zA-Z]/.test(line)) inPrPaths = false;
|
||||
}
|
||||
// End of pull_request block
|
||||
if (/^ [a-zA-Z]/.test(line) && !line.startsWith(' ')) inPullRequest = false;
|
||||
if (/^ {2}[a-zA-Z]/.test(line) && !line.startsWith(' ')) inPullRequest = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,46 +67,46 @@ function parseWorkflowStructure(src) {
|
||||
|
||||
for (const line of lines) {
|
||||
// Step boundary: 6-space "- name:" or "- uses:"
|
||||
if (/^ - name:/.test(line)) {
|
||||
if (/^ {6}- name:/.test(line)) {
|
||||
if (currentStep && runLines.length) {
|
||||
currentStep.run = runLines.join('\n');
|
||||
}
|
||||
if (currentStep) steps.push(currentStep);
|
||||
currentStep = { name: line.replace(/^ - name:\s*/, '').trim() };
|
||||
currentStep = { name: line.replace(/^ {6}- name:\s*/, '').trim() };
|
||||
inRun = false;
|
||||
runLines = [];
|
||||
continue;
|
||||
}
|
||||
if (/^ - uses:/.test(line)) {
|
||||
if (/^ {6}- uses:/.test(line)) {
|
||||
if (currentStep && runLines.length) {
|
||||
currentStep.run = runLines.join('\n');
|
||||
}
|
||||
if (currentStep) steps.push(currentStep);
|
||||
currentStep = { uses: line.replace(/^ - uses:\s*/, '').trim() };
|
||||
currentStep = { uses: line.replace(/^ {6}- uses:\s*/, '').trim() };
|
||||
inRun = false;
|
||||
runLines = [];
|
||||
continue;
|
||||
}
|
||||
// uses: field inside a named step (e.g. "- name: Foo\n uses: actions/...")
|
||||
if (currentStep && /^ uses:\s/.test(line)) {
|
||||
currentStep.uses = line.replace(/^ uses:\s*/, '').trim();
|
||||
if (currentStep && /^ {8}uses:\s/.test(line)) {
|
||||
currentStep.uses = line.replace(/^ {8}uses:\s*/, '').trim();
|
||||
continue;
|
||||
}
|
||||
// run: block inside a step
|
||||
if (currentStep && /^ run:\s*\|/.test(line)) {
|
||||
if (currentStep && /^ {8}run:\s*\|/.test(line)) {
|
||||
inRun = true;
|
||||
runLines = [];
|
||||
continue;
|
||||
}
|
||||
if (currentStep && /^ run:\s*(?!\|)/.test(line)) {
|
||||
if (currentStep && /^ {8}run:\s*(?!\|)/.test(line)) {
|
||||
// Inline run (no |)
|
||||
currentStep.run = line.replace(/^ run:\s*/, '').trim();
|
||||
currentStep.run = line.replace(/^ {8}run:\s*/, '').trim();
|
||||
inRun = false;
|
||||
continue;
|
||||
}
|
||||
if (inRun) {
|
||||
// Lines deeper than 8 spaces belong to the run block
|
||||
if (/^ /.test(line) || line.trim() === '') {
|
||||
if (/^ {10}/.test(line) || line.trim() === '') {
|
||||
runLines.push(line);
|
||||
} else {
|
||||
inRun = false;
|
||||
|
||||
Reference in New Issue
Block a user