Skip to content

Commit d4b2e47

Browse files
authored
Merge pull request #94829 from Expensify/claude-onyx-connect-bypass
[No QA] Block eslint-disable bypasses of the Onyx.connect() ban
2 parents 5df38e8 + 91a8c93 commit d4b2e47

4 files changed

Lines changed: 240 additions & 1 deletion

File tree

scripts/checkOnyxConnectBypass.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
#!/usr/bin/env ts-node
2+
3+
/**
4+
* Fails the lint run when a new inline `eslint-disable` bypasses the Onyx.connect() ban.
5+
*
6+
* The ban (`rulesdir/no-onyx-connect`, shipped by eslint-config-expensify) is a normal lint rule,
7+
* so an inline disable can silence it. The ESLint CLI neither surfaces nor fails on such suppressed
8+
* violations, so this runner re-elevates them: it lints with only the ban enabled, reads the
9+
* suppressed violations off the results, and exits non-zero on any that are not grandfathered.
10+
* Because it works from ESLint's suppressed-message data, no disable directive can reach it.
11+
*
12+
* A real bypass requires a file to contain both an `Onyx.connect` reference and an `eslint-disable`
13+
* directive, so we first narrow the targets to files matching both (via git grep) and only run
14+
* ESLint on those — keeping the check fast even on a whole-repo lint. The `Onyx.connect` match
15+
* deliberately omits the `(` so it stays a superset of the AST rule (e.g. whitespace or a comment
16+
* before the paren); extra matches like `Onyx.connectWithoutView` are harmless, as the rule ignores them.
17+
*/
18+
import tsParser from '@typescript-eslint/parser';
19+
import {ESLint} from 'eslint';
20+
import type {Rule} from 'eslint';
21+
import {execFileSync} from 'node:child_process';
22+
import {createRequire} from 'node:module';
23+
import path from 'node:path';
24+
import {BANNED_RULE_ID, collectSuppressedBans, findNewBypasses} from './onyxConnectBypass';
25+
26+
const projectRoot = path.resolve(__dirname, '..');
27+
28+
/** The ban's rule name as registered under the `rulesdir` plugin (i.e. `BANNED_RULE_ID` without the prefix). */
29+
const RULE_NAME = 'no-onyx-connect';
30+
31+
function isRuleModule(value: unknown): value is Rule.RuleModule {
32+
return typeof value === 'object' && value !== null && 'create' in value && typeof value.create === 'function';
33+
}
34+
35+
/** Dynamically import the shipped `no-onyx-connect` rule, which is ESM with relative imports. */
36+
async function loadNoOnyxConnectRule(): Promise<Rule.RuleModule> {
37+
const require = createRequire(__filename);
38+
const expensifyConfigDirectory = path.dirname(require.resolve('eslint-config-expensify/package.json'));
39+
const ruleFile = path.join(expensifyConfigDirectory, 'eslint-plugin-expensify', 'no-onyx-connect.js');
40+
const imported: unknown = await import(ruleFile);
41+
if (isRuleModule(imported)) {
42+
return imported;
43+
}
44+
if (typeof imported === 'object' && imported !== null && 'default' in imported && isRuleModule(imported.default)) {
45+
return imported.default;
46+
}
47+
throw new Error(`Could not load the no-onyx-connect rule from ${ruleFile}`);
48+
}
49+
50+
/** Files among the lint targets that contain both an Onyx.connect() call and an eslint-disable. */
51+
function findCandidateFiles(targets: string[]): string[] {
52+
const pathSpecs = targets.length > 0 ? targets : ['.'];
53+
try {
54+
const output = execFileSync('git', ['grep', '-lI', '-F', '--all-match', '--untracked', '--no-recurse-submodules', '-e', 'Onyx.connect', '-e', 'eslint-disable', '--', ...pathSpecs], {
55+
cwd: projectRoot,
56+
encoding: 'utf8',
57+
});
58+
return output.split('\n').filter(Boolean);
59+
} catch (error: unknown) {
60+
// git grep exits 1 when nothing matches; anything else is a real failure.
61+
if (typeof error === 'object' && error !== null && 'status' in error && error.status === 1) {
62+
return [];
63+
}
64+
throw error;
65+
}
66+
}
67+
68+
async function run(): Promise<void> {
69+
const candidates = findCandidateFiles(process.argv.slice(2));
70+
if (candidates.length === 0) {
71+
return;
72+
}
73+
74+
const rule = await loadNoOnyxConnectRule();
75+
const eslint = new ESLint({
76+
cwd: projectRoot,
77+
warnIgnored: false,
78+
errorOnUnmatchedPattern: false,
79+
overrideConfigFile: true,
80+
overrideConfig: [
81+
{
82+
files: ['**/*.{js,jsx,ts,tsx,mjs,cjs}'],
83+
languageOptions: {parser: tsParser},
84+
plugins: {rulesdir: {rules: {[RULE_NAME]: rule}}},
85+
rules: {[BANNED_RULE_ID]: 'error'},
86+
},
87+
],
88+
});
89+
90+
const results = await eslint.lintFiles(candidates);
91+
const newBypasses = findNewBypasses(collectSuppressedBans(results, projectRoot));
92+
if (newBypasses.length === 0) {
93+
return;
94+
}
95+
96+
console.error('Onyx.connect() is banned and the ban cannot be bypassed with eslint-disable. Use the useOnyx() hook to read Onyx data instead.');
97+
console.error('New bypasses found:');
98+
for (const bypass of newBypasses) {
99+
console.error(` ${bypass.file}:${bypass.line}`);
100+
}
101+
process.exitCode = 1;
102+
}
103+
104+
run().catch((error: unknown) => {
105+
console.error(error instanceof Error ? error.message : error);
106+
process.exitCode = 1;
107+
});

scripts/lint.sh

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,4 +58,8 @@ ESLINT_ARGS+=(
5858
# Run ESLint with the repo's default memory ceiling and seatbelt behavior.
5959
NODE_OPTIONS="${NODE_OPTIONS:---max_old_space_size=8192}" \
6060
SEATBELT_FROZEN="${SEATBELT_FROZEN:-0}" \
61-
exec npx eslint "${ESLINT_ARGS[@]}"
61+
npx eslint "${ESLINT_ARGS[@]}"
62+
63+
# Fail if a new inline eslint-disable bypasses the Onyx.connect() ban (rulesdir/no-onyx-connect),
64+
# checking the same targets as ESLint above. Reached only when ESLint itself passes (set -e).
65+
exec npx ts-node scripts/checkOnyxConnectBypass.ts "${PASSTHROUGH_ARGS[@]}"

scripts/onyxConnectBypass.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/**
2+
* Detection logic for new `eslint-disable` bypasses of the Onyx.connect() ban.
3+
*
4+
* `rulesdir/no-onyx-connect` (shipped by eslint-config-expensify) is a normal lint rule, so an
5+
* inline `eslint-disable` can silence it. ESLint records such silenced violations as "suppressed
6+
* messages". This module finds suppressed `rulesdir/no-onyx-connect` violations and flags any that
7+
* go beyond the disables already present on `main`, so a new bypass can be re-elevated to an error
8+
* at the runner level — where no disable directive can reach it.
9+
*/
10+
import type {ESLint} from 'eslint';
11+
import path from 'node:path';
12+
13+
/** Rule id of the Onyx.connect() ban, as exposed through eslint-plugin-rulesdir. */
14+
const BANNED_RULE_ID = 'rulesdir/no-onyx-connect';
15+
16+
/**
17+
* Disables of the ban that already exist on `main`, keyed by repo-relative path with the number of
18+
* occurrences in each file. Migrating these call sites to useOnyx() is already in progress; any
19+
* suppressed violation beyond these counts is treated as a new bypass.
20+
*/
21+
const GRANDFATHERED_BYPASSES = new Map<string, number>([
22+
['src/libs/NextStepUtils.ts', 1],
23+
['src/libs/ReportNameUtils.ts', 2],
24+
]);
25+
26+
/** A `no-onyx-connect` violation that an inline disable directive silenced. */
27+
type SuppressedBan = {
28+
file: string;
29+
line: number;
30+
};
31+
32+
/** The fields of an ESLint result this module reads; real `ESLint.LintResult`s satisfy it. */
33+
type ResultWithSuppressed = Pick<ESLint.LintResult, 'filePath' | 'suppressedMessages'>;
34+
35+
/** Pull suppressed `no-onyx-connect` violations out of ESLint results, keyed by repo-relative path. */
36+
function collectSuppressedBans(results: readonly ResultWithSuppressed[], projectRoot: string): SuppressedBan[] {
37+
const bans: SuppressedBan[] = [];
38+
for (const result of results) {
39+
for (const message of result.suppressedMessages ?? []) {
40+
if (message.ruleId !== BANNED_RULE_ID) {
41+
continue;
42+
}
43+
const file = path.relative(projectRoot, result.filePath).split(path.sep).join('/');
44+
bans.push({file, line: message.line});
45+
}
46+
}
47+
return bans;
48+
}
49+
50+
/** Return the suppressed bans that exceed the grandfathered allowance for their file. */
51+
function findNewBypasses(suppressedBans: readonly SuppressedBan[]): SuppressedBan[] {
52+
const byFile = new Map<string, SuppressedBan[]>();
53+
for (const ban of suppressedBans) {
54+
const list = byFile.get(ban.file) ?? [];
55+
list.push(ban);
56+
byFile.set(ban.file, list);
57+
}
58+
59+
const newBypasses: SuppressedBan[] = [];
60+
for (const [file, bans] of byFile) {
61+
const allowed = GRANDFATHERED_BYPASSES.get(file) ?? 0;
62+
if (bans.length <= allowed) {
63+
continue;
64+
}
65+
const sortedByLine = [...bans].sort((a, b) => a.line - b.line);
66+
newBypasses.push(...sortedByLine.slice(allowed));
67+
}
68+
return newBypasses;
69+
}
70+
71+
export {BANNED_RULE_ID, GRANDFATHERED_BYPASSES, collectSuppressedBans, findNewBypasses};
72+
export type {SuppressedBan, ResultWithSuppressed};
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import type {ESLint} from 'eslint';
2+
import {BANNED_RULE_ID, collectSuppressedBans, findNewBypasses} from '../../scripts/onyxConnectBypass';
3+
import type {ResultWithSuppressed} from '../../scripts/onyxConnectBypass';
4+
5+
const PROJECT_ROOT = '/repo';
6+
7+
function makeResult(relativePath: string, suppressedMessages: ESLint.LintResult['suppressedMessages']): ResultWithSuppressed {
8+
return {filePath: `${PROJECT_ROOT}/${relativePath}`, suppressedMessages};
9+
}
10+
11+
function suppressed(ruleId: string, line: number): ESLint.LintResult['suppressedMessages'][number] {
12+
return {ruleId, line, column: 1, message: 'x', severity: 2, suppressions: [{kind: 'directive', justification: ''}]};
13+
}
14+
15+
describe('collectSuppressedBans', () => {
16+
it('keeps only suppressed no-onyx-connect violations and relativizes their paths', () => {
17+
const results = [makeResult('src/libs/Foo.ts', [suppressed(BANNED_RULE_ID, 12), suppressed('no-console', 3)]), makeResult('src/libs/Bar.ts', [suppressed(BANNED_RULE_ID, 7)])];
18+
19+
expect(collectSuppressedBans(results, PROJECT_ROOT)).toEqual([
20+
{file: 'src/libs/Foo.ts', line: 12},
21+
{file: 'src/libs/Bar.ts', line: 7},
22+
]);
23+
});
24+
25+
it('returns nothing when there are no suppressed messages', () => {
26+
expect(collectSuppressedBans([makeResult('src/libs/Foo.ts', [])], PROJECT_ROOT)).toEqual([]);
27+
});
28+
});
29+
30+
describe('findNewBypasses', () => {
31+
it('flags a bypass in a file with no grandfathered allowance', () => {
32+
expect(findNewBypasses([{file: 'src/libs/CurrencyUtils.ts', line: 5}])).toEqual([{file: 'src/libs/CurrencyUtils.ts', line: 5}]);
33+
});
34+
35+
it('allows grandfathered disables up to their recorded count', () => {
36+
const bans = [
37+
{file: 'src/libs/ReportNameUtils.ts', line: 192},
38+
{file: 'src/libs/ReportNameUtils.ts', line: 201},
39+
{file: 'src/libs/NextStepUtils.ts', line: 33},
40+
];
41+
expect(findNewBypasses(bans)).toEqual([]);
42+
});
43+
44+
it('flags only the overflow when a grandfathered file gains an extra disable', () => {
45+
const bans = [
46+
{file: 'src/libs/ReportNameUtils.ts', line: 192},
47+
{file: 'src/libs/ReportNameUtils.ts', line: 201},
48+
{file: 'src/libs/ReportNameUtils.ts', line: 300},
49+
];
50+
expect(findNewBypasses(bans)).toEqual([{file: 'src/libs/ReportNameUtils.ts', line: 300}]);
51+
});
52+
53+
it('returns nothing for an empty input', () => {
54+
expect(findNewBypasses([])).toEqual([]);
55+
});
56+
});

0 commit comments

Comments
 (0)