Skip to content

Commit f8eeabb

Browse files
committed
quality: autofix sort-source-methods + cascade canonical script fixes
Picks up the new autofixable sort-source-methods rule from socket-wheelhouse and the identifier-based _inject-import.js fix (resolves task #65 / #64). Iterated `oxlint --fix` to convergence; function declarations re-ordered into private→export alphanumeric order across the repo. Function declarations are hoisted so the rewrite is safe at runtime; leading JSDoc / line-comments and trailing c8-ignore-stop markers travel with each function. Also re-syncs the canonical scripts/check-paths.mts and scripts/ai-lint-fix.mts from socket-wheelhouse. The wheelhouse copy already accounts for state-machine null sentinels (blockKey, blockKind, inString) and the SKIP_AI_FIX bracket-env access.
1 parent 89756f1 commit f8eeabb

42 files changed

Lines changed: 1781 additions & 1587 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.config/oxlint-plugin/rules/_inject-import.js

Lines changed: 37 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,30 +15,52 @@
1515
/**
1616
* Walk a Program node body once and figure out:
1717
* - the last top-level ImportDeclaration node (or undefined)
18-
* - whether `importName` is already imported from `specifier`
18+
* - whether `importName` is already imported (from ANY source)
1919
* - whether a top-level `localName` identifier already exists
2020
* (any const/let/var or import-as-local with that name)
21+
*
22+
* Import detection ignores the specifier path: a file inside the lib
23+
* package itself imports `getDefaultLogger` from `'../logger'`, while
24+
* a downstream repo imports the same name from
25+
* `'@socketsecurity/lib/logger'`. Both resolve to the same identifier;
26+
* either should count as "already imported" so the autofix doesn't
27+
* inject a duplicate (and broken — see issue #64).
28+
*
29+
* `specifier` is retained in the signature for backward compatibility
30+
* but is no longer used for the match decision. Callers may pass any
31+
* truthy value (typically the canonical package path the rule would
32+
* inject if the import were missing).
2133
*/
22-
export function summarizeImportTarget(program, specifier, importName, localName) {
34+
export function summarizeImportTarget(
35+
program,
36+
// eslint-disable-next-line no-unused-vars
37+
specifier,
38+
importName,
39+
localName,
40+
) {
2341
let lastImport
2442
let hasImport = false
2543
let hasLocal = false
2644
for (const stmt of program.body) {
2745
if (stmt.type === 'ImportDeclaration') {
2846
lastImport = stmt
29-
const source = stmt.source && stmt.source.value
30-
if (source === specifier) {
31-
for (const spec of stmt.specifiers) {
32-
if (
33-
spec.type === 'ImportSpecifier' &&
34-
spec.imported &&
35-
spec.imported.name === importName
36-
) {
37-
hasImport = true
38-
}
39-
if (localName && spec.local && spec.local.name === localName) {
40-
hasLocal = true
41-
}
47+
for (const spec of stmt.specifiers) {
48+
if (
49+
spec.type === 'ImportSpecifier' &&
50+
spec.imported &&
51+
spec.imported.name === importName
52+
) {
53+
hasImport = true
54+
}
55+
if (
56+
localName &&
57+
spec.local &&
58+
spec.local.name === localName &&
59+
(spec.type === 'ImportSpecifier' ||
60+
spec.type === 'ImportDefaultSpecifier' ||
61+
spec.type === 'ImportNamespaceSpecifier')
62+
) {
63+
hasLocal = true
4264
}
4365
}
4466
continue

.config/oxlint-plugin/rules/sort-source-methods.js

Lines changed: 182 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,15 @@
1313
* cheap, deterministic, and matches the rest of the fleet's sorting
1414
* conventions (CLAUDE.md "Sorting" rule).
1515
*
16-
* No autofix: re-ordering function declarations is too risky to
17-
* automate (a `const` that depends on a function declared later via
18-
* hoisting could break, and TS type narrowing can move with declaration
19-
* order). Reporting only — caller re-orders manually.
16+
* Autofix: emits a single fix that re-orders top-level function
17+
* declarations into canonical order. Function declarations are
18+
* hoisted, so reordering them is safe for runtime semantics; the
19+
* leading JSDoc / line-comment block above each declaration travels
20+
* with the function. The rule only autofixes when every function in
21+
* the file has a name (anonymous default exports are skipped) and
22+
* when there are no top-level non-function statements interleaved
23+
* between functions — interleaved statements can carry side-effects
24+
* or rely on declaration order, so we don't reshuffle around them.
2025
*/
2126

2227
const SCRIPT_ENTRY_NAMES = new Set(['main'])
@@ -44,6 +49,83 @@ function declVisibility(node) {
4449
return undefined
4550
}
4651

52+
/**
53+
* Compute the sort key for a function entry. Private functions sort
54+
* before exports; within each group, alphanumerical by name. The
55+
* script entrypoint (`main`) is pinned to the end regardless of group.
56+
*/
57+
function sortKey(entry) {
58+
if (entry.isEntrypoint) {
59+
// '~' (0x7E) is the highest printable ASCII char, so this sort key
60+
// pins the entrypoint to the end of any group.
61+
return '~~entrypoint'
62+
}
63+
return `${entry.visibility === 'private' ? '0' : '1'}${entry.name}`
64+
}
65+
66+
/**
67+
* Locate the byte-range start of a function entry, including any
68+
* leading JSDoc / line-comment block that's contiguous with it (a
69+
* block separated by a blank line is treated as a free-standing
70+
* comment and stays put). Falls back to the node's own start when
71+
* there are no leading comments.
72+
*/
73+
function leadingCommentStart(sourceCode, node) {
74+
const comments = sourceCode.getCommentsBefore
75+
? sourceCode.getCommentsBefore(node)
76+
: []
77+
if (!comments || comments.length === 0) {
78+
return node.range[0]
79+
}
80+
// Walk from the last comment back, accepting any comment that's
81+
// separated from the next one by no more than a single newline
82+
// (allows a tight stack of `// foo\n// bar\n/** ... */`).
83+
const tokenText = sourceCode.text
84+
let earliest = node.range[0]
85+
for (let i = comments.length - 1; i >= 0; i--) {
86+
const c = comments[i]
87+
const between = tokenText.slice(c.range[1], earliest)
88+
// Reject if there's a blank line between this comment and the
89+
// next block — that means it's a free-standing comment.
90+
if (/\n\s*\n/.test(between)) {
91+
break
92+
}
93+
earliest = c.range[0]
94+
}
95+
return earliest
96+
}
97+
98+
/**
99+
* Locate the byte-range end of a function entry, including any
100+
* trailing comment that's contiguous (no blank line between) and
101+
* exclusive of the next function. Useful for capturing
102+
* c8-ignore-stop markers that pair with a start above the function
103+
* — those need to travel with the function when reordered.
104+
*/
105+
function trailingCommentEnd(sourceCode, node, nextNodeStart) {
106+
const tokenText = sourceCode.text
107+
const comments = sourceCode.getCommentsAfter
108+
? sourceCode.getCommentsAfter(node)
109+
: []
110+
let latest = node.range[1]
111+
if (!comments || comments.length === 0) {
112+
return latest
113+
}
114+
for (const c of comments) {
115+
if (nextNodeStart !== undefined && c.range[0] >= nextNodeStart) {
116+
break
117+
}
118+
const between = tokenText.slice(latest, c.range[0])
119+
// Reject if there's a blank line between this function and the
120+
// comment — that means it's a free-standing comment.
121+
if (/\n\s*\n/.test(between)) {
122+
break
123+
}
124+
latest = c.range[1]
125+
}
126+
return latest
127+
}
128+
47129
/** @type {import('eslint').Rule.RuleModule} */
48130
const rule = {
49131
meta: {
@@ -54,6 +136,7 @@ const rule = {
54136
category: 'Stylistic Issues',
55137
recommended: true,
56138
},
139+
fixable: 'code',
57140
messages: {
58141
groupOutOfOrder:
59142
'Top-level function `{{name}}` ({{visibility}}) appears after a function from the next visibility group. Order: private functions first (alphanumeric), then exported functions (alphanumeric).',
@@ -64,43 +147,65 @@ const rule = {
64147
},
65148

66149
create(context) {
150+
const sourceCode = context.getSourceCode
151+
? context.getSourceCode()
152+
: context.sourceCode
153+
67154
return {
68155
Program(programNode) {
156+
// First pass: collect entries + detect violations.
157+
const entries = []
69158
let lastVisibilityRank = -1
70159
let lastNameInGroup = null
71160
let currentVisibility = null
161+
const violations = []
72162

73-
for (const node of programNode.body) {
163+
// First find the next program-body node after each function, so
164+
// trailingCommentEnd can stop before reaching it.
165+
const bodyByIndex = programNode.body
166+
for (let i = 0; i < bodyByIndex.length; i++) {
167+
const node = bodyByIndex[i]
74168
const info = declVisibility(node)
75169
if (!info || !info.fn.id || info.fn.id.type !== 'Identifier') {
76170
continue
77171
}
78172
const name = info.fn.id.name
79-
if (SCRIPT_ENTRY_NAMES.has(name)) {
80-
// Skip the entrypoint — allowed anywhere.
173+
const isEntrypoint = SCRIPT_ENTRY_NAMES.has(name)
174+
const start = leadingCommentStart(sourceCode, node)
175+
const nextStart =
176+
i + 1 < bodyByIndex.length ? bodyByIndex[i + 1].range[0] : undefined
177+
const end = trailingCommentEnd(sourceCode, node, nextStart)
178+
entries.push({
179+
node,
180+
name,
181+
visibility: info.visibility,
182+
isEntrypoint,
183+
start,
184+
end,
185+
})
186+
187+
if (isEntrypoint) {
81188
continue
82189
}
83190

84191
const rank = info.visibility === 'private' ? 0 : 1
85192

86193
if (rank < lastVisibilityRank) {
87-
context.report({
194+
violations.push({
88195
node: info.fn.id,
89196
messageId: 'groupOutOfOrder',
90197
data: { name, visibility: info.visibility },
91198
})
92199
continue
93200
}
94-
95201
if (rank !== lastVisibilityRank) {
96202
currentVisibility = info.visibility
97203
lastVisibilityRank = rank
98204
lastNameInGroup = name
99205
continue
100206
}
101-
102207
if (lastNameInGroup !== null && name < lastNameInGroup) {
103-
context.report({
208+
violations.push({
104209
node: info.fn.id,
105210
messageId: 'alphaOutOfOrder',
106211
data: {
@@ -113,6 +218,72 @@ const rule = {
113218
lastNameInGroup = name
114219
}
115220
}
221+
222+
if (violations.length === 0) {
223+
return
224+
}
225+
226+
// Build the fix once, applied via the first violation. ESLint
227+
// dedupes overlapping fixes, so attaching it once is enough.
228+
const sorted = entries.slice().sort((a, b) => {
229+
const ka = sortKey(a)
230+
const kb = sortKey(b)
231+
if (ka < kb) {
232+
return -1
233+
}
234+
if (ka > kb) {
235+
return 1
236+
}
237+
return 0
238+
})
239+
240+
const orderedByPosition = entries
241+
.slice()
242+
.sort((a, b) => a.start - b.start)
243+
const sourceText = sourceCode.text
244+
const rangeStart = orderedByPosition[0].start
245+
const rangeEnd = orderedByPosition[orderedByPosition.length - 1].end
246+
247+
// Bail if any non-function, non-comment statements live between
248+
// the first and last function — re-ordering would skip over
249+
// them and lose their side-effects / declaration-order semantics.
250+
for (const stmt of programNode.body) {
251+
const isFn = entries.some(e => e.node === stmt)
252+
if (isFn) {
253+
continue
254+
}
255+
if (stmt.range[0] >= rangeStart && stmt.range[1] <= rangeEnd) {
256+
// Statement is sandwiched between functions; skip autofix.
257+
for (const v of violations) {
258+
context.report(v)
259+
}
260+
return
261+
}
262+
}
263+
264+
const sortedTexts = sorted.map(e => sourceText.slice(e.start, e.end))
265+
const replacement = sortedTexts.join('\n\n')
266+
267+
// Attach the fix to the first violation only; the rest are
268+
// reported without a fix so the user sees what's wrong even
269+
// when applying without --fix.
270+
let fixerAttached = false
271+
for (const v of violations) {
272+
if (!fixerAttached) {
273+
context.report({
274+
...v,
275+
fix(fixer) {
276+
return fixer.replaceTextRange(
277+
[rangeStart, rangeEnd],
278+
replacement,
279+
)
280+
},
281+
})
282+
fixerAttached = true
283+
} else {
284+
context.report(v)
285+
}
286+
}
116287
},
117288
}
118289
},

0 commit comments

Comments
 (0)