Skip to content

Commit 9fd9125

Browse files
committed
refactor(lint): remove find dependency and drop mainWithDeps
1 parent 9d35ebd commit 9fd9125

File tree

4 files changed

+149
-103
lines changed

4 files changed

+149
-103
lines changed

plans/refactor/STATE.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,20 @@
2424
- removed permissive unknown-option behavior so unknown flags are rejected by the CLI parser
2525
- Added tests for this slice in [`tests/bin/lint.test.ts`](../../tests/bin/lint.test.ts).
2626

27+
## Implemented in follow-up cleanup slice
28+
29+
- Removed shell-domain dependence on external `find`:
30+
- shell discovery now walks filesystem roots in Node and collects `*.sh` files before invoking `shellcheck`
31+
- non-existent shell roots are still ignored
32+
- explicit shell requests (`--shell ...` or `--only shell`) still fail if `shellcheck` is missing
33+
- default shell auto-run still warns/skips when `shellcheck` is missing
34+
- Removed runtime dependency-injection indirection from CLI entry:
35+
- removed `MainDeps`, `defaultMainDeps`, and `mainWithDeps` from `src/bin/lint.ts`
36+
- retained `main` as the runtime/default export entrypoint
37+
- Refactored CLI tests to use Jest spies/mocks instead of `mainWithDeps`:
38+
- tests now mock dependency behavior through module spies (for example `utils.commandExists`)
39+
- shell assertions updated to verify direct `shellcheck` invocation behavior
40+
2741
## Current pain points (observed in current implementation)
2842

2943
- Domains run in a fixed sequence and cannot be cleanly targeted.
@@ -43,7 +57,6 @@
4357

4458
- Final naming for the new flags (for example `--domain` vs `--only`, `--skip` vs `--exclude-domain`).
4559
- Config schema evolution strategy: extend `matrixai-lint-config.json` in place vs introduce a v2 file name.
46-
- Whether to keep using external `find` for shell file enumeration or replace with Node-based discovery for portability.
4760

4861
## Next actions
4962

plans/refactor/questions.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
I noticed that shellcheck run also depend on `find`. I wonder if that's a good idea, since that means `find` also must exist in the environment to run it... what do you think we should do about that?
2+
3+
I also think the lint.ts code @/src/bin/lint.ts is werid with `mainWithDeps`, I don't quite like it. Can you rewrite that script so that only `main` exists, and then explain if in the tests you actually need it? Like can you not make use of jest mocking to do any necessary injection?

src/bin/lint.ts

Lines changed: 81 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -83,39 +83,58 @@ function resolveDomainSelection(options: CLIOptions): {
8383
return { selectedDomains, shellDomainExplicitlyRequested };
8484
}
8585

86-
type MainDeps = {
87-
runESLint: typeof utils.runESLint;
88-
findUserESLintConfig: typeof utils.findUserESLintConfig;
89-
collectMarkdown: typeof utils.collectMarkdown;
90-
commandExists: typeof utils.commandExists;
91-
execFileSync: typeof childProcess.execFileSync;
92-
fileExists: typeof fs.existsSync;
93-
parseAsync: (argv: string[]) => Promise<void>;
94-
opts: () => CLIOptions;
95-
};
96-
97-
const defaultMainDeps: MainDeps = {
98-
runESLint: utils.runESLint,
99-
findUserESLintConfig: utils.findUserESLintConfig,
100-
collectMarkdown: utils.collectMarkdown,
101-
commandExists: utils.commandExists,
102-
execFileSync: ((...args: unknown[]) =>
103-
childProcess.execFileSync(
104-
args[0] as string,
105-
args[1] as ReadonlyArray<string> | undefined,
106-
args[2] as childProcess.ExecFileSyncOptions,
107-
)) as typeof childProcess.execFileSync,
108-
fileExists: fs.existsSync,
109-
parseAsync: async (argv) => {
110-
await program.parseAsync(argv);
111-
},
112-
opts: () => program.opts<CLIOptions>(),
113-
};
86+
const SHELL_FILE_PATTERN = /\.sh$/i;
87+
88+
function collectShellFiles(searchRoots: string[]): string[] {
89+
const shellFiles = new Set<string>();
90+
91+
const visitPath = (entryPath: string): void => {
92+
let entryStats: fs.Stats;
93+
try {
94+
entryStats = fs.statSync(entryPath);
95+
} catch {
96+
return;
97+
}
98+
99+
if (entryStats.isFile()) {
100+
if (SHELL_FILE_PATTERN.test(entryPath)) {
101+
shellFiles.add(entryPath);
102+
}
103+
return;
104+
}
105+
106+
if (!entryStats.isDirectory()) {
107+
return;
108+
}
109+
110+
let dirEntries: fs.Dirent[];
111+
try {
112+
dirEntries = fs.readdirSync(entryPath, { withFileTypes: true });
113+
} catch {
114+
return;
115+
}
116+
117+
for (const dirEntry of dirEntries) {
118+
const childPath = path.join(entryPath, dirEntry.name);
119+
if (dirEntry.isDirectory()) {
120+
visitPath(childPath);
121+
} else if (dirEntry.isFile() && SHELL_FILE_PATTERN.test(dirEntry.name)) {
122+
shellFiles.add(childPath);
123+
}
124+
}
125+
};
126+
127+
for (const searchRoot of searchRoots) {
128+
visitPath(searchRoot);
129+
}
130+
131+
return [...shellFiles].sort();
132+
}
114133

115134
/* eslint-disable no-console */
116135
async function main(argv = process.argv) {
117-
await defaultMainDeps.parseAsync(argv);
118-
const options = defaultMainDeps.opts();
136+
await program.parseAsync(argv);
137+
const options = program.opts<CLIOptions>();
119138

120139
const fix = Boolean(options.fix);
121140
const useUserConfig = Boolean(options.userConfig);
@@ -135,7 +154,7 @@ async function main(argv = process.argv) {
135154
if (explicitConfigPath !== undefined) {
136155
const absolutePath = path.resolve(explicitConfigPath);
137156

138-
if (!defaultMainDeps.fileExists(absolutePath)) {
157+
if (!fs.existsSync(absolutePath)) {
139158
console.error(
140159
`--config points to "${explicitConfigPath}", but that file does not exist.`,
141160
);
@@ -145,7 +164,7 @@ async function main(argv = process.argv) {
145164
chosenConfig = absolutePath;
146165
}
147166
} else if (useUserConfig) {
148-
chosenConfig = defaultMainDeps.findUserESLintConfig();
167+
chosenConfig = utils.findUserESLintConfig();
149168
if (chosenConfig === undefined) {
150169
console.error(
151170
'--user-config given but no local ESLint config was found. Falling back to built-in config.',
@@ -159,7 +178,7 @@ async function main(argv = process.argv) {
159178
hadFailure = true;
160179
} else {
161180
try {
162-
const hadLintingErrors = await defaultMainDeps.runESLint({
181+
const hadLintingErrors = await utils.runESLint({
163182
fix,
164183
configPath: chosenConfig,
165184
explicitGlobs: eslintPatterns,
@@ -176,60 +195,51 @@ async function main(argv = process.argv) {
176195
}
177196

178197
if (selectedDomains.has('shell')) {
179-
const hasFind = defaultMainDeps.commandExists('find');
180-
const hasShellcheck = defaultMainDeps.commandExists('shellcheck');
198+
const hasShellcheck = utils.commandExists('shellcheck');
181199

182-
if (!(hasFind && hasShellcheck)) {
200+
if (!hasShellcheck) {
183201
if (shellDomainExplicitlyRequested) {
184202
console.error(
185-
'Shell domain requested explicitly, but find or shellcheck was not found in environment.',
203+
'Shell domain requested explicitly, but shellcheck was not found in environment.',
186204
);
187205
hadFailure = true;
188206
} else {
189207
console.warn(
190-
'Skipping shellcheck: find or shellcheck not found in environment.',
208+
'Skipping shellcheck: shellcheck not found in environment.',
191209
);
192210
}
193211
} else {
194212
const searchRoots = (
195213
shellPatterns?.length ? shellPatterns : DEFAULT_SHELLCHECK_SEARCH_ROOTS
196214
)
197215
.map((p) => path.resolve(process.cwd(), p))
198-
.filter((p) => defaultMainDeps.fileExists(p));
199-
200-
// Linting shell scripts (this does not have auto-fixing)
201-
const shellCheckArgs = [
202-
...searchRoots,
203-
'-type',
204-
'f',
205-
'-regextype',
206-
'posix-extended',
207-
'-regex',
208-
'.*\\.(sh)',
209-
'-exec',
210-
'shellcheck',
211-
'{}',
212-
'+',
213-
];
216+
.filter((p) => fs.existsSync(p));
214217

215218
console.error('Running shellcheck:');
216219
if (searchRoots.length === 0) {
217220
console.warn(
218221
'No search roots found for shellcheck. Skipping shellcheck.',
219222
);
220223
} else {
221-
console.error(' ' + ['find', ...shellCheckArgs].join(' '));
222-
try {
223-
defaultMainDeps.execFileSync('find', shellCheckArgs, {
224-
stdio: ['inherit', 'inherit', 'inherit'],
225-
windowsHide: true,
226-
encoding: 'utf-8',
227-
shell: platform === 'win32' ? true : false,
228-
cwd: process.cwd(),
229-
});
230-
} catch (err) {
231-
console.error('Shellcheck failed. ' + err);
232-
hadFailure = true;
224+
const shellFiles = collectShellFiles(searchRoots);
225+
if (shellFiles.length === 0) {
226+
console.warn(
227+
'No shell script files found for shellcheck. Skipping shellcheck.',
228+
);
229+
} else {
230+
console.error(' ' + ['shellcheck', ...shellFiles].join(' '));
231+
try {
232+
childProcess.execFileSync('shellcheck', shellFiles, {
233+
stdio: ['inherit', 'inherit', 'inherit'],
234+
windowsHide: true,
235+
encoding: 'utf-8',
236+
shell: platform === 'win32',
237+
cwd: process.cwd(),
238+
});
239+
} catch (err) {
240+
console.error('Shellcheck failed. ' + err);
241+
hadFailure = true;
242+
}
233243
}
234244
}
235245
}
@@ -239,12 +249,12 @@ async function main(argv = process.argv) {
239249
// Linting markdown files
240250
// Always include README if it exists
241251
const markdownFiles: string[] = [];
242-
if (defaultMainDeps.fileExists('README.md')) {
252+
if (fs.existsSync('README.md')) {
243253
markdownFiles.push('README.md');
244254
}
245255
for (const dir of ['pages', 'blog', 'docs']) {
246-
if (defaultMainDeps.fileExists(dir)) {
247-
markdownFiles.push(...defaultMainDeps.collectMarkdown(dir));
256+
if (fs.existsSync(dir)) {
257+
markdownFiles.push(...utils.collectMarkdown(dir));
248258
}
249259
}
250260
if (markdownFiles.length === 0) {
@@ -274,7 +284,7 @@ async function main(argv = process.argv) {
274284
try {
275285
if (prettierBin) {
276286
console.error(` ${prettierBin} \n ${prettierArgs.join('\n' + ' ')}`);
277-
defaultMainDeps.execFileSync(
287+
childProcess.execFileSync(
278288
process.execPath,
279289
[prettierBin, ...prettierArgs],
280290
{
@@ -286,7 +296,7 @@ async function main(argv = process.argv) {
286296
);
287297
} else {
288298
console.error('prettier' + prettierArgs.join('\n' + ' '));
289-
defaultMainDeps.execFileSync('prettier', prettierArgs, {
299+
childProcess.execFileSync('prettier', prettierArgs, {
290300
stdio: 'inherit',
291301
windowsHide: true,
292302
encoding: 'utf-8',
@@ -314,21 +324,6 @@ async function main(argv = process.argv) {
314324
}
315325
/* eslint-enable no-console */
316326

317-
async function mainWithDeps(
318-
argv: string[],
319-
deps: Partial<MainDeps>,
320-
): Promise<void> {
321-
const previousDeps = {
322-
...defaultMainDeps,
323-
};
324-
Object.assign(defaultMainDeps, deps);
325-
try {
326-
await main(argv);
327-
} finally {
328-
Object.assign(defaultMainDeps, previousDeps);
329-
}
330-
}
331-
332327
if (import.meta.url.startsWith('file:')) {
333328
const modulePath = url.fileURLToPath(import.meta.url);
334329
if (process.argv[1] === modulePath) {
@@ -337,4 +332,3 @@ if (import.meta.url.startsWith('file:')) {
337332
}
338333

339334
export default main;
340-
export { mainWithDeps };

tests/bin/lint.test.ts

Lines changed: 51 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import path from 'node:path';
22
import fs from 'node:fs';
33
import childProcess from 'node:child_process';
44
import { jest } from '@jest/globals';
5-
import main, { mainWithDeps } from '#bin/lint.js';
5+
import main from '#bin/lint.js';
66

77
describe('matrixai-lint CLI domain semantics', () => {
88
let capturedExecCalls: Array<{
@@ -85,7 +85,7 @@ describe('matrixai-lint CLI domain semantics', () => {
8585
]),
8686
).rejects.toBeDefined();
8787

88-
const shellCalls = capturedExecCalls.filter((c) => c.file === 'find');
88+
const shellCalls = capturedExecCalls.filter((c) => c.file === 'shellcheck');
8989
expect(shellCalls).toHaveLength(0);
9090

9191
const prettierCalls = capturedExecCalls.filter(
@@ -97,16 +97,34 @@ describe('matrixai-lint CLI domain semantics', () => {
9797
});
9898

9999
test('explicit shell request + missing shellcheck fails', async () => {
100+
jest
101+
.spyOn(childProcess, 'spawnSync')
102+
.mockImplementation((file: string, args?: readonly string[]) => {
103+
const commandName = args?.[0];
104+
const status =
105+
(file === 'which' || file === 'where') && commandName === 'shellcheck'
106+
? 1
107+
: 0;
108+
109+
return {
110+
pid: 0,
111+
output: [null, null, null],
112+
stdout: null,
113+
stderr: null,
114+
status,
115+
signal: null,
116+
error: undefined,
117+
} as unknown as ReturnType<typeof childProcess.spawnSync>;
118+
});
119+
100120
await expect(
101-
mainWithDeps(['node', 'matrixai-lint', '--shell', 'scripts'], {
102-
commandExists: (cmd: string) => cmd === 'find',
103-
}),
121+
main(['node', 'matrixai-lint', '--shell', 'scripts']),
104122
).rejects.toBeDefined();
105123

106-
const shellcheckFindCall = capturedExecCalls.find(
107-
(c) => c.file === 'find' && c.args.includes('shellcheck'),
124+
const shellcheckCall = capturedExecCalls.find(
125+
(c) => c.file === 'shellcheck',
108126
);
109-
expect(shellcheckFindCall).toBeUndefined();
127+
expect(shellcheckCall).toBeUndefined();
110128
});
111129

112130
test('default shell missing shellcheck warns/skips (non-fatal unless other failures)', async () => {
@@ -122,14 +140,32 @@ describe('matrixai-lint CLI domain semantics', () => {
122140
'utf8',
123141
);
124142

125-
await mainWithDeps(['node', 'matrixai-lint'], {
126-
commandExists: (cmd: string) => cmd === 'find',
127-
});
128-
129-
const shellcheckFindCall = capturedExecCalls.find(
130-
(c) => c.file === 'find' && c.args.includes('shellcheck'),
143+
jest
144+
.spyOn(childProcess, 'spawnSync')
145+
.mockImplementation((file: string, args?: readonly string[]) => {
146+
const commandName = args?.[0];
147+
const status =
148+
(file === 'which' || file === 'where') && commandName === 'shellcheck'
149+
? 1
150+
: 0;
151+
152+
return {
153+
pid: 0,
154+
output: [null, null, null],
155+
stdout: null,
156+
stderr: null,
157+
status,
158+
signal: null,
159+
error: undefined,
160+
} as unknown as ReturnType<typeof childProcess.spawnSync>;
161+
});
162+
163+
await main(['node', 'matrixai-lint']);
164+
165+
const shellcheckCall = capturedExecCalls.find(
166+
(c) => c.file === 'shellcheck',
131167
);
132-
expect(shellcheckFindCall).toBeUndefined();
168+
expect(shellcheckCall).toBeUndefined();
133169

134170
const prettierCalls = capturedExecCalls.filter(
135171
(c) =>

0 commit comments

Comments
 (0)