Skip to content

Commit 9b164b4

Browse files
committed
feat(lint): repo-local no-inline-lazy-node-getter rule via fleet config factory
socket-lib's .config/repo/oxlint.config.mts imports the fleet oxlint factory (config()) and augments it with a repo-local plugin + the socket-repo/no-inline-lazy-node-getter rule. Flags inline-chained lazy node-module getters (getFs().existsSync(x)) and autofixes to a bound const, hoisted before any leading oxlint-disable comment, deduped per statement. The factory call (not oxlint extends) makes both the fleet + repo plugins load with fleet rules/overrides/ignores intact.
1 parent 4aebe67 commit 9b164b4

4 files changed

Lines changed: 411 additions & 0 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/**
2+
* @file Socket-lib repo-local oxlint plugin. Rules that encode conventions
3+
* specific to socket-lib's own module shape (not fleet-canonical, so not in
4+
* `.config/fleet/oxlint-plugin/`). Wiring: `.config/repo/oxlintrc.json`
5+
* extends the fleet config, adds this plugin to `jsPlugins`, and enables its
6+
* rules under the `socket-repo/` namespace.
7+
*/
8+
9+
import noInlineLazyNodeGetter from './rules/no-inline-lazy-node-getter.mts'
10+
11+
const plugin = {
12+
meta: {
13+
name: 'socket-repo',
14+
},
15+
rules: {
16+
'no-inline-lazy-node-getter': noInlineLazyNodeGetter,
17+
},
18+
}
19+
20+
export default plugin
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
/**
2+
* @file Repo-local rule: forbid inline-chained member access on a lazy
3+
* node-module getter — `getFs().existsSync(x)`, `getPath().join(a, b)`,
4+
* `getNodeChildProcess().spawn(...)`. socket-lib loads Node builtins through
5+
* lazy `getNode*()` getters (`src/node/*`) and their short aliases (`getFs`,
6+
* `getPath`, …) so a builtin is resolved once and memoized. Calling the
7+
* getter inline at every use defeats the memoization read-site ergonomics and
8+
* reads noisily — `getFs().existsSync(a) … getFs().statSync(b)` re-invokes
9+
* the getter on every line. The fleet convention is to bind it once: const fs
10+
* = getFs() if (fs.existsSync(a)) { … } const s = fs.statSync(b) This rule
11+
* flags `getX().member` where `getX` is one of the lazy node-module getters
12+
* and rewrites it: it hoists `const <name> = getX()` immediately before the
13+
* enclosing statement and replaces the inline call with `<name>`. `<name>` is
14+
* derived from the getter (`getFs`/`getNodeFs` → `fs`, `getNodePath` →
15+
* `path`, `getNodeChildProcess` → `childProcess`). Repo-local (not
16+
* fleet-canonical): the `getNode*` lazy-getter convention is socket-lib's own
17+
* module shape, so the rule lives under `.config/repo/oxlint-plugin/` and is
18+
* wired via `.config/repo/oxlintrc.json`, not cascaded.
19+
*/
20+
21+
import type {
22+
AstNode,
23+
RuleContext,
24+
RuleFixer,
25+
} from '../../../fleet/oxlint-plugin/lib/rule-types.mts'
26+
27+
/**
28+
* Lazy node-module getter names → the conventional const name to bind them to.
29+
* Both the canonical `getNode*` form and the short `get*` alias map to the same
30+
* variable name so the rewrite reads like hand-written code.
31+
*/
32+
const GETTER_TO_BINDING: Record<string, string> = {
33+
__proto__: null,
34+
getCrypto: 'crypto',
35+
getFs: 'fs',
36+
getFsPromises: 'fsPromises',
37+
getHttp: 'http',
38+
getHttps: 'https',
39+
getNodeAsyncHooks: 'asyncHooks',
40+
getNodeChildProcess: 'childProcess',
41+
getNodeCrypto: 'crypto',
42+
getNodeEvents: 'events',
43+
getNodeFs: 'fs',
44+
getNodeFsPromises: 'fsPromises',
45+
getNodeHttp: 'http',
46+
getNodeHttps: 'https',
47+
getNodeModule: 'nodeModule',
48+
getNodeOs: 'os',
49+
getNodePath: 'path',
50+
getNodeTimersPromises: 'timersPromises',
51+
getNodeUrl: 'url',
52+
getNodeUtil: 'util',
53+
getPath: 'path',
54+
getSemver: 'semver',
55+
getUtil: 'util',
56+
} as Record<string, string>
57+
58+
/**
59+
* Walk up from a node to the nearest enclosing statement — the node whose
60+
* parent is a block / program / switch-case body. The hoisted `const` is
61+
* inserted before it.
62+
*/
63+
function findEnclosingStatement(node: AstNode): AstNode | undefined {
64+
let current = node
65+
let parent = current.parent
66+
while (parent) {
67+
const parentType = parent.type
68+
if (
69+
parentType === 'BlockStatement' ||
70+
parentType === 'Program' ||
71+
parentType === 'StaticBlock' ||
72+
parentType === 'SwitchCase'
73+
) {
74+
return current
75+
}
76+
current = parent
77+
parent = current.parent
78+
}
79+
return undefined
80+
}
81+
82+
const rule = {
83+
create(context: RuleContext) {
84+
const sourceCode = context.getSourceCode
85+
? context.getSourceCode()
86+
: context.sourceCode
87+
88+
// Dedup hoists within a single lint pass. When one statement holds two
89+
// inline calls of the SAME getter — `path.basename(x, getNodePath()
90+
// .extname(x))` next to another `getNodePath()` — emitting the
91+
// `const path = getNodePath()` hoist for each would write the
92+
// declaration twice. Key by enclosing-statement range + binding: the
93+
// first occurrence hoists + rewrites, the rest only rewrite to the
94+
// already-declared binding.
95+
const hoisted = new Set<string>()
96+
97+
return {
98+
// Match `<getter>().<member>` — a MemberExpression whose object is a
99+
// CallExpression of a bare getter identifier.
100+
MemberExpression(node: AstNode) {
101+
const object = node.object
102+
if (
103+
!object ||
104+
object.type !== 'CallExpression' ||
105+
object.callee?.type !== 'Identifier' ||
106+
// The getter takes no args; a call with args isn't one of ours.
107+
(object.arguments && object.arguments.length > 0)
108+
) {
109+
return
110+
}
111+
const getter = object.callee.name
112+
const binding = GETTER_TO_BINDING[getter]
113+
if (!binding) {
114+
return
115+
}
116+
const member =
117+
node.property?.type === 'Identifier' ? node.property.name : '…'
118+
119+
const enclosing = findEnclosingStatement(node)
120+
const hoistKey = enclosing
121+
? `${enclosing.range?.[0] ?? enclosing.start ?? enclosing.loc?.start?.line}:${binding}`
122+
: ''
123+
124+
context.report({
125+
node: object,
126+
messageId: 'inlineGetter',
127+
data: { getter, member, binding },
128+
fix(fixer: RuleFixer) {
129+
// Can't safely hoist without an enclosing statement to anchor to.
130+
if (!enclosing) {
131+
return undefined
132+
}
133+
// Second+ inline call of the same getter in one statement: the
134+
// hoist already exists, just point this call at the binding.
135+
if (hoisted.has(hoistKey)) {
136+
return fixer.replaceText(object, binding)
137+
}
138+
hoisted.add(hoistKey)
139+
const indentMatch = /^[ \t]*/.exec(
140+
sourceCode.lines?.[enclosing.loc.start.line - 1] ?? '',
141+
)
142+
const indent = indentMatch ? indentMatch[0] : ''
143+
// Anchor the hoist BEFORE any line comment that directly precedes
144+
// the statement. A leading `// oxlint-disable-next-line …` (or any
145+
// explanatory comment) targets the statement on the next line;
146+
// inserting the `const` between the comment and the statement would
147+
// orphan the directive onto the injected line and re-expose the
148+
// statement to the disabled rule.
149+
const commentsBefore =
150+
sourceCode.getCommentsBefore?.(enclosing) ?? []
151+
const lastComment = commentsBefore[commentsBefore.length - 1]
152+
const anchor =
153+
lastComment &&
154+
lastComment.loc?.end?.line === enclosing.loc.start.line - 1
155+
? lastComment
156+
: enclosing
157+
return [
158+
fixer.insertTextBefore(
159+
anchor,
160+
`const ${binding} = ${getter}()\n${indent}`,
161+
),
162+
fixer.replaceText(object, binding),
163+
]
164+
},
165+
})
166+
},
167+
}
168+
},
169+
170+
meta: {
171+
type: 'suggestion',
172+
docs: {
173+
description:
174+
'Bind a lazy node-module getter to a const once (`const fs = getFs()`) instead of calling it inline at each use (`getFs().existsSync(x)`).',
175+
category: 'Best Practices',
176+
recommended: true,
177+
},
178+
fixable: 'code',
179+
messages: {
180+
inlineGetter:
181+
'`{{getter}}().{{member}}` calls the lazy node-module getter inline. Bind it once — `const {{binding}} = {{getter}}()` — then use `{{binding}}.{{member}}(…)`. Re-invoking the getter at every call site is noisy and defeats the single-read convention.',
182+
},
183+
schema: [],
184+
},
185+
}
186+
187+
export default rule
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
/**
2+
* @file Integration test for socket-repo/no-inline-lazy-node-getter. The fleet
3+
* RuleTester is hardcoded to the fleet plugin + `socket/` namespace, so a
4+
* repo rule under `socket-repo/` can't use it. Instead this test exercises
5+
* the real repo overlay (`.config/repo/oxlintrc.json` → extends fleet + loads
6+
* the repo plugin) end-to-end: write a fixture, run oxlint with the repo
7+
* config, assert the finding fires (and `--fix` rewrites to the bound-const
8+
* form).
9+
*/
10+
11+
import assert from 'node:assert/strict'
12+
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
13+
import os from 'node:os'
14+
import path from 'node:path'
15+
import { fileURLToPath } from 'node:url'
16+
import { describe, test } from 'node:test'
17+
18+
import { whichSync } from '@socketsecurity/lib-stable/bin/which'
19+
import { safeDeleteSync } from '@socketsecurity/lib-stable/fs/safe'
20+
import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child'
21+
22+
const HERE = path.dirname(fileURLToPath(import.meta.url))
23+
// Plugin entry, absolute so the spawned oxlint resolves it from a tmpdir.
24+
const PLUGIN_INDEX = path.resolve(HERE, '..', 'index.mts')
25+
const NODE_MODULES_BIN = path.resolve(
26+
HERE,
27+
'..',
28+
'..',
29+
'..',
30+
'..',
31+
'node_modules',
32+
'.bin',
33+
)
34+
35+
const RULE_NAME = 'no-inline-lazy-node-getter'
36+
37+
/**
38+
* Oxlint renders the rule id as either `socket-repo/<name>` (diagnostic ruleId)
39+
* or `socket-repo(<name>)` (compact code form) depending on reporter. Match
40+
* either.
41+
*/
42+
function hasRuleFinding(stdout: string): boolean {
43+
return (
44+
stdout.includes(`socket-repo/${RULE_NAME}`) ||
45+
stdout.includes(`socket-repo(${RULE_NAME})`)
46+
)
47+
}
48+
49+
/**
50+
* Resolve the locally-installed oxlint, never a global PATH copy (per
51+
* socket/no-which-for-local-bin). Returns undefined when it isn't installed so
52+
* the cases skip gracefully instead of failing.
53+
*/
54+
function resolveOxlintBinary(): string | undefined {
55+
const found = whichSync('oxlint', { path: NODE_MODULES_BIN, nothrow: true })
56+
return typeof found === 'string' ? found : undefined
57+
}
58+
59+
function runOxlint(
60+
code: string,
61+
fix: boolean,
62+
): { stdout: string; code: string } {
63+
const tmpdir = mkdtempSync(path.join(os.tmpdir(), 'oxlint-repo-rule-'))
64+
const fixture = path.join(tmpdir, 'fixture.ts')
65+
// Minimal isolated config: just this plugin + rule, no `extends`/
66+
// ignorePatterns (the full overlay's fleet inheritance is verified by
67+
// `pnpm run lint`, not here). Keeps the unit test independent of the
68+
// fleet config and resolvable from a tmpdir.
69+
const config = path.join(tmpdir, '.oxlintrc.json')
70+
writeFileSync(
71+
config,
72+
JSON.stringify({
73+
plugins: [],
74+
jsPlugins: [PLUGIN_INDEX],
75+
rules: { 'socket-repo/no-inline-lazy-node-getter': 'error' },
76+
}),
77+
)
78+
writeFileSync(fixture, code)
79+
try {
80+
const args = ['-c', config]
81+
if (fix) {
82+
args.push('--fix')
83+
}
84+
args.push(fixture)
85+
const result = spawnSync(resolveOxlintBinary() ?? 'oxlint', args, {
86+
encoding: 'utf8',
87+
})
88+
return {
89+
stdout: String(result.stdout ?? ''),
90+
code: fix ? readFileSync(fixture, 'utf8') : '',
91+
}
92+
} finally {
93+
safeDeleteSync(tmpdir)
94+
}
95+
}
96+
97+
describe('socket-repo/no-inline-lazy-node-getter', () => {
98+
test('flags getFs().member inline access', () => {
99+
if (!resolveOxlintBinary()) {
100+
return
101+
}
102+
const { stdout } = runOxlint(
103+
'function f() {\n return getFs().existsSync("/x")\n}\n',
104+
false,
105+
)
106+
assert.ok(
107+
hasRuleFinding(stdout),
108+
`expected ${RULE_NAME} finding, got:\n${stdout}`,
109+
)
110+
})
111+
112+
test('does not flag a bound-const getter', () => {
113+
if (!resolveOxlintBinary()) {
114+
return
115+
}
116+
const { stdout } = runOxlint(
117+
'function f() {\n const fs = getFs()\n return fs.existsSync("/x")\n}\n',
118+
false,
119+
)
120+
assert.ok(
121+
!hasRuleFinding(stdout),
122+
`expected no ${RULE_NAME} finding, got:\n${stdout}`,
123+
)
124+
})
125+
126+
test('autofix hoists the const and rewrites the call', () => {
127+
if (!resolveOxlintBinary()) {
128+
return
129+
}
130+
const { code } = runOxlint(
131+
'function f() {\n return getNodePath().join("a", "b")\n}\n',
132+
true,
133+
)
134+
assert.ok(
135+
code.includes('const path = getNodePath()'),
136+
`expected hoisted const, got:\n${code}`,
137+
)
138+
assert.ok(
139+
code.includes('path.join("a", "b")'),
140+
`expected rewritten call, got:\n${code}`,
141+
)
142+
assert.ok(
143+
!/getNodePath\(\)\.join/.test(code),
144+
`expected no remaining inline call, got:\n${code}`,
145+
)
146+
})
147+
148+
test('autofix dedupes two inline calls of the same getter in one statement', () => {
149+
if (!resolveOxlintBinary()) {
150+
return
151+
}
152+
const { code } = runOxlint(
153+
'function f(x) {\n return getNodePath().basename(x, getNodePath().extname(x))\n}\n',
154+
true,
155+
)
156+
const hoists = (code.match(/const path = getNodePath\(\)/g) ?? []).length
157+
assert.equal(
158+
hoists,
159+
1,
160+
`expected exactly one hoisted const, got ${hoists}:\n${code}`,
161+
)
162+
assert.ok(
163+
code.includes('path.basename(x, path.extname(x))'),
164+
`expected both calls rewritten, got:\n${code}`,
165+
)
166+
})
167+
168+
test('autofix keeps a leading oxlint-disable-next-line attached to its statement', () => {
169+
if (!resolveOxlintBinary()) {
170+
return
171+
}
172+
const { code } = runOxlint(
173+
'function f(p) {\n // oxlint-disable-next-line some/rule -- intentional\n return getFs().statSync(p)\n}\n',
174+
true,
175+
)
176+
// The hoisted const must land BEFORE the disable comment, so the directive
177+
// still sits on the line directly above the statSync statement.
178+
assert.ok(
179+
/const fs = getFs\(\)\n\s*\/\/ oxlint-disable-next-line[^\n]*\n\s*return fs\.statSync/.test(
180+
code,
181+
),
182+
`expected hoist before the disable comment, got:\n${code}`,
183+
)
184+
})
185+
})

0 commit comments

Comments
 (0)