Skip to content

Commit a11596d

Browse files
committed
chore(sync): cascade fleet oxlint plugin from socket-repo-template
Adds .config/oxlint-plugin/ — six custom rules under the socket/ namespace (no-status-emoji, no-console-prefer-logger, no-inline-logger, no-dynamic-import-outside-bundle, prefer-undefined-over-null, no-fetch-prefer-http-request). Plugin is in place but NOT activated in this repo's .oxlintrc.json. Per-repo activation + cleanup pass lands separately so rule introduction and mass autofix don't bundle into the same review. Source: socket-repo-template@f864ca0 — 'feat(lint): fleet oxlint plugin with 6 fixable rules'.
1 parent fc8208a commit a11596d

7 files changed

Lines changed: 620 additions & 0 deletions

.config/oxlint-plugin/index.js

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* @fileoverview Fleet oxlint plugin. Custom rules that encode the
3+
* fleet's CLAUDE.md style guide as lint errors with autofix where
4+
* the rewrite is unambiguous.
5+
*
6+
* Why a plugin instead of a separate scanner: oxlint's native plugin
7+
* surface integrates with the existing `pnpm run lint` pipeline,
8+
* inherits oxlint's AST + sourcemap + fix-application machinery, and
9+
* keeps the rule set discoverable via `oxlint --rules`.
10+
*
11+
* Wiring: `.oxlintrc.json` adds this plugin via `jsPlugins:
12+
* ["./.config/oxlint-plugin/index.js"]` and enables rules under the
13+
* `socket/` namespace.
14+
*/
15+
16+
import noStatusEmoji from './rules/no-status-emoji.js'
17+
import noConsolePreferLogger from './rules/no-console-prefer-logger.js'
18+
import noInlineLogger from './rules/no-inline-logger.js'
19+
import noDynamicImportOutsideBundle from './rules/no-dynamic-import-outside-bundle.js'
20+
import preferUndefinedOverNull from './rules/prefer-undefined-over-null.js'
21+
import noFetchPreferHttpRequest from './rules/no-fetch-prefer-http-request.js'
22+
23+
/** @type {import('eslint').ESLint.Plugin} */
24+
const plugin = {
25+
meta: {
26+
name: 'socket',
27+
version: '0.1.0',
28+
},
29+
rules: {
30+
'no-status-emoji': noStatusEmoji,
31+
'no-console-prefer-logger': noConsolePreferLogger,
32+
'no-inline-logger': noInlineLogger,
33+
'no-dynamic-import-outside-bundle': noDynamicImportOutsideBundle,
34+
'prefer-undefined-over-null': preferUndefinedOverNull,
35+
'no-fetch-prefer-http-request': noFetchPreferHttpRequest,
36+
},
37+
}
38+
39+
export default plugin
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/**
2+
* @fileoverview Ban `console.log` / `console.error` / `console.warn`
3+
* / `console.info` / `console.debug` / `console.trace`. The fleet uses
4+
* `getDefaultLogger()` from `@socketsecurity/lib/logger` — those
5+
* methods emit theme-aware coloring + canonical symbols.
6+
*
7+
* Autofix: rewrites `console.log(...)` → `logger.log(...)` and
8+
* similar. Assumes a hoisted `logger` const at the top of the file
9+
* (the no-inline-logger rule enforces that). If no `logger` import
10+
* exists, the human can run `pnpm fix` then add the import.
11+
*/
12+
13+
const CONSOLE_TO_LOGGER = {
14+
log: 'log',
15+
error: 'fail',
16+
warn: 'warn',
17+
info: 'info',
18+
debug: 'log',
19+
trace: 'log',
20+
}
21+
22+
/** @type {import('eslint').Rule.RuleModule} */
23+
const rule = {
24+
meta: {
25+
type: 'problem',
26+
docs: {
27+
description:
28+
'Ban console.* calls; use logger from @socketsecurity/lib/logger.',
29+
category: 'Best Practices',
30+
recommended: true,
31+
},
32+
fixable: 'code',
33+
messages: {
34+
banned:
35+
'console.{{method}}() — use logger.{{loggerMethod}}() from @socketsecurity/lib/logger.',
36+
},
37+
schema: [],
38+
},
39+
40+
create(context) {
41+
return {
42+
MemberExpression(node) {
43+
if (
44+
node.object.type !== 'Identifier' ||
45+
node.object.name !== 'console' ||
46+
node.property.type !== 'Identifier'
47+
) {
48+
return
49+
}
50+
const method = node.property.name
51+
const loggerMethod = CONSOLE_TO_LOGGER[method]
52+
if (!loggerMethod) {
53+
return
54+
}
55+
56+
// Only flag when console.<method> is the callee of a call
57+
// (skip e.g. `typeof console.log` or destructuring).
58+
const parent = node.parent
59+
if (!parent || parent.type !== 'CallExpression' || parent.callee !== node) {
60+
return
61+
}
62+
63+
context.report({
64+
node,
65+
messageId: 'banned',
66+
data: { method, loggerMethod },
67+
fix(fixer) {
68+
return fixer.replaceText(node, `logger.${loggerMethod}`)
69+
},
70+
})
71+
},
72+
}
73+
},
74+
}
75+
76+
export default rule
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/**
2+
* @fileoverview Ban dynamic `import()` (ImportExpression) in code that
3+
* isn't bundled. The fleet favors static ES6 imports — dynamic import
4+
* is only meaningful when a bundler resolves it statically at build
5+
* time. Scripts under `scripts/` run directly via `node`; nothing
6+
* bundles them, so a dynamic import only adds a runtime async hop for
7+
* no resolution win.
8+
*
9+
* Allowed paths: `src/**`, `.config/**` (bundler configs themselves
10+
* may load tools dynamically via the bundler's API).
11+
*
12+
* No autofix: converting `await import('foo')` to `import 'foo'`
13+
* requires moving the statement to the top of the file and removing
14+
* `await`/destructuring — the bundler-aware AST rewrite is non-trivial
15+
* to do safely. Reporting only.
16+
*/
17+
18+
import path from 'node:path'
19+
20+
const DEFAULT_BUNDLED_ROOTS = ['src/', '.config/', 'packages/']
21+
22+
/** @type {import('eslint').Rule.RuleModule} */
23+
const rule = {
24+
meta: {
25+
type: 'problem',
26+
docs: {
27+
description:
28+
'Ban dynamic import() outside bundled trees (src/, .config/, packages/).',
29+
category: 'Best Practices',
30+
recommended: true,
31+
},
32+
messages: {
33+
dynamic:
34+
"Dynamic import() in {{file}} — favor a static `import` statement at the top of the file. Dynamic import is only valid in bundled code (src/, .config/, packages/). If lazy resolution is required, justify it explicitly.",
35+
},
36+
schema: [
37+
{
38+
type: 'object',
39+
properties: {
40+
bundledRoots: {
41+
type: 'array',
42+
items: { type: 'string' },
43+
description:
44+
'Path prefixes (relative to repo root) where dynamic import() is allowed.',
45+
},
46+
},
47+
additionalProperties: false,
48+
},
49+
],
50+
},
51+
52+
create(context) {
53+
const options = context.options[0] || {}
54+
const bundledRoots = options.bundledRoots || DEFAULT_BUNDLED_ROOTS
55+
const filename = context.physicalFilename || context.filename
56+
const cwd = context.cwd || process.cwd()
57+
const relative = path
58+
.relative(cwd, filename)
59+
.split(path.sep)
60+
.join('/')
61+
62+
const inBundled = bundledRoots.some(root => relative.startsWith(root))
63+
64+
if (inBundled) {
65+
return {}
66+
}
67+
68+
return {
69+
ImportExpression(node) {
70+
context.report({
71+
node,
72+
messageId: 'dynamic',
73+
data: { file: relative },
74+
})
75+
},
76+
}
77+
},
78+
}
79+
80+
export default rule
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/**
2+
* @fileoverview Per CLAUDE.md "HTTP — never `fetch()`. Use httpJson /
3+
* httpText / httpRequest from @socketsecurity/lib/http-request."
4+
*
5+
* Reports any `fetch(...)` call (global fetch). Does NOT auto-fix
6+
* because the right replacement (`httpJson` vs `httpText` vs
7+
* `httpRequest`) depends on what the caller does with the response —
8+
* a wrong autofix would silently change behavior. Reporting only.
9+
*
10+
* Allowed exceptions (skipped):
11+
* - `globalThis.fetch` — explicit reference (often for monkey-patching
12+
* in tests).
13+
* - Method calls (`obj.fetch(...)`) — those aren't the global.
14+
*/
15+
16+
/** @type {import('eslint').Rule.RuleModule} */
17+
const rule = {
18+
meta: {
19+
type: 'problem',
20+
docs: {
21+
description:
22+
'Use httpJson / httpText / httpRequest from @socketsecurity/lib/http-request instead of global fetch().',
23+
category: 'Best Practices',
24+
recommended: true,
25+
},
26+
messages: {
27+
banned:
28+
'global fetch() — use httpJson / httpText / httpRequest from @socketsecurity/lib/http-request. The right replacement depends on what you do with the response; the lib helpers ship consistent error shapes (HttpError) and JSON/text decoding.',
29+
},
30+
schema: [],
31+
},
32+
33+
create(context) {
34+
return {
35+
CallExpression(node) {
36+
const callee = node.callee
37+
// Only flag direct `fetch(...)` calls (Identifier callee).
38+
if (callee.type !== 'Identifier' || callee.name !== 'fetch') {
39+
return
40+
}
41+
42+
// Skip if `fetch` is locally shadowed by a parameter / declaration.
43+
// Best-effort: check the scope chain.
44+
const scope = context.getScope ? context.getScope() : null
45+
if (scope) {
46+
const variable = scope.references.find(
47+
ref => ref.identifier === callee,
48+
)?.resolved
49+
if (variable && variable.scope.type !== 'global') {
50+
return
51+
}
52+
}
53+
54+
context.report({
55+
node,
56+
messageId: 'banned',
57+
})
58+
},
59+
}
60+
},
61+
}
62+
63+
export default rule
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/**
2+
* @fileoverview Ban inline `getDefaultLogger().<method>(...)`. The
3+
* logger must be hoisted at the top of the file:
4+
* const logger = getDefaultLogger()
5+
* ...
6+
* logger.success('...')
7+
*
8+
* Inline `getDefaultLogger().success(...)` re-resolves the logger on
9+
* every call and reads inconsistently. The hoisted form is the
10+
* fleet-canonical pattern.
11+
*
12+
* No autofix: the right hoist position depends on the file's import
13+
* block layout, which can't be safely inferred at the call site.
14+
* Reporting only.
15+
*/
16+
17+
/** @type {import('eslint').Rule.RuleModule} */
18+
const rule = {
19+
meta: {
20+
type: 'problem',
21+
docs: {
22+
description:
23+
'Hoist getDefaultLogger() to a const at the top of the file; do not call it inline.',
24+
category: 'Best Practices',
25+
recommended: true,
26+
},
27+
messages: {
28+
inline:
29+
'getDefaultLogger() must be hoisted: add `const logger = getDefaultLogger()` near the top of the file and use `logger.{{method}}(...)`.',
30+
},
31+
schema: [],
32+
},
33+
34+
create(context) {
35+
return {
36+
MemberExpression(node) {
37+
// Match: getDefaultLogger().<method>
38+
if (node.property.type !== 'Identifier') {
39+
return
40+
}
41+
const obj = node.object
42+
if (
43+
obj.type !== 'CallExpression' ||
44+
obj.callee.type !== 'Identifier' ||
45+
obj.callee.name !== 'getDefaultLogger' ||
46+
obj.arguments.length !== 0
47+
) {
48+
return
49+
}
50+
51+
context.report({
52+
node,
53+
messageId: 'inline',
54+
data: { method: node.property.name },
55+
})
56+
},
57+
}
58+
},
59+
}
60+
61+
export default rule

0 commit comments

Comments
 (0)