Skip to content

Commit 4f31897

Browse files
authored
fix(uncheck): reject paths outside cwd, take existing exclusions literally, skip linked folders (#20)
- A file, folder or glob above the working directory now fails with a clear error instead of reaching oxlint and oxfmt as a "../" path they reject. - An exclusion naming an existing path leaves out that path alone, so `![id].ts` no longer also drops i.ts and d.ts; backslash escapes now reach the glob matcher on POSIX. - Linked folders, which git lists as one file, are no longer handed to the tools, so their files are not reported twice or fixed outside the repo.
1 parent 0fcd222 commit 4f31897

7 files changed

Lines changed: 97 additions & 73 deletions

File tree

‎packages/uncheck/src/files.ts‎

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import { existsSync } from 'node:fs'
1+
import { existsSync, statSync } from 'node:fs'
22

33
import { Effect, FileSystem, Path, Predicate } from 'effect'
44
import type { ChildProcessSpawner } from 'effect/unstable/process'
55
import { Minimatch } from 'minimatch'
66

7+
import { userError } from './errors'
78
import { gitPaths } from './git'
89

910
export type ProjectFiles = Effect.Effect<
@@ -59,8 +60,9 @@ export const resolvePaths = Effect.fn(function* (
5960
) {
6061
const fs = yield* FileSystem.FileSystem
6162
const path = yield* Path.Path
63+
// A backslash is a glob escape on POSIX, never a separator.
6264
const relative = (pattern: string) =>
63-
path.relative(cwd, path.resolve(cwd, pattern)).replaceAll('\\', '/')
65+
path.relative(cwd, path.resolve(cwd, pattern)).split(path.sep).join('/')
6466

6567
const includes = patterns.filter((pattern) => !pattern.startsWith('!'))
6668
const matched = new Set<string>()
@@ -70,6 +72,13 @@ export const resolvePaths = Effect.fn(function* (
7072
for (const pattern of includes.length > 0 ? includes : ['.']) {
7173
const target = relative(pattern)
7274

75+
// oxlint and oxfmt reject a path containing "..".
76+
if (target === '..' || target.startsWith('../') || path.isAbsolute(target)) {
77+
return yield* userError(
78+
`${pattern} is outside ${cwd}, run from a folder that contains it or pass one with --cwd`,
79+
)
80+
}
81+
7382
// An existing path is taken as it is, so `app/[id].ts` names that file rather than a glob.
7483
const kind = yield* fs.stat(path.resolve(cwd, pattern)).pipe(
7584
Effect.map((info) => info.type),
@@ -111,15 +120,18 @@ export const resolvePaths = Effect.fn(function* (
111120
return () => true
112121
}
113122

114-
const matches = glob(target)
123+
// As for an inclusion, so `![id].ts` leaves out that file and not `i.ts` too.
124+
if (existsSync(path.resolve(cwd, pattern.slice(1)))) {
125+
return (file: string) => file === target || file.startsWith(`${target}/`)
126+
}
115127

116-
return (file: string) => file === target || file.startsWith(`${target}/`) || matches(file)
128+
return glob(target)
117129
})
118130

119131
// One fiber per file costs far more than the check itself on a large project.
120132
const files = yield* Effect.sync(() =>
121133
[...matched].filter(
122-
(file) => !excludes.some((excluded) => excluded(file)) && existsSync(path.resolve(cwd, file)),
134+
(file) => !excludes.some((excluded) => excluded(file)) && isFile(path.resolve(cwd, file)),
123135
),
124136
)
125137

@@ -128,6 +140,15 @@ export const resolvePaths = Effect.fn(function* (
128140

129141
const GLOB_CHARACTERS = /[*?[\]{}()]/
130142

143+
// git lists a linked folder as one file, which the tools would check through the link.
144+
function isFile(file: string): boolean {
145+
try {
146+
return statSync(file).isFile()
147+
} catch {
148+
return false
149+
}
150+
}
151+
131152
/** Dot files match too, as they do for oxfmt and for a directory given as it is. */
132153
function glob(pattern: string): (file: string) => boolean {
133154
// Level 2 drops the `.` of `src/{.,deep}/*.ts` as path.matchesGlob does.

‎packages/uncheck/tests/uncheck/paths-exclusions.test.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,20 @@ describe.each(LAYOUTS)('uncheck with exclusions in a $name', ({ create, app }) =
4545
)
4646
})
4747

48-
it('leaves out a file whose name looks like a glob along with every file that glob matches', async () => {
48+
it('leaves out an existing file or folder whose name looks like a glob as that path alone', async () => {
4949
const project = routesProject()
5050

51+
expect(await check(project, '.', '![id].ts')).toEqual(without('[id].ts'))
52+
expect(await check(project, '.', '!(group)')).toEqual(without('(group)/page.ts'))
53+
})
54+
55+
it('reads an exclusion naming no existing path as a glob, with \\ escaping', async () => {
56+
const project = routesProject()
57+
58+
expect(await check(project, '.', '!\\[id\\].ts')).toEqual(without('[id].ts'))
59+
60+
project.write({ [`${cwd}/[id].ts`]: null })
61+
5162
expect(await check(project, '.', '![id].ts')).toEqual(without('[id].ts', 'd.ts', 'i.ts'))
5263
})
5364

‎packages/uncheck/tests/uncheck/paths-git.test.ts‎

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { writeFileSync } from 'node:fs'
1+
import { readFileSync, writeFileSync } from 'node:fs'
22
import { join } from 'node:path'
33

44
import { cliError, LAYOUTS, temporaryDirectory } from '../utils/project'
@@ -94,24 +94,38 @@ describe.each(LAYOUTS)('uncheck with paths in the git repository of a $name', ({
9494
}
9595
})
9696

97-
it('hands the tools a linked folder as the file git lists, so oxlint checks its files twice', async () => {
98-
const project = create({ [`${routes}/shared/util.ts`]: CODE_WITH_VAR })
97+
it('checks a linked file but never a linked folder or a broken link, as the walk outside git', async () => {
98+
const store = temporaryDirectory()
99+
writeFileSync(join(store, 'vendor.ts'), CODE_WITH_VAR)
100+
const project = create({
101+
[`${routes}/home.ts`]: CLEAN_CODE,
102+
[`${routes}/shared/util.ts`]: CODE_WITH_VAR,
103+
})
104+
.link(`${routes}/alias.ts`, 'home.ts')
105+
.link(`${routes}/broken.ts`, 'missing.ts')
99106
.link(`${routes}/linked`, 'shared')
107+
.link(`${routes}/vendor`, store)
100108
.commit()
101109

102-
const { exitCode, stdout } = await project.uncheck(['--only=oxlint', routes])
110+
const check = await project.uncheck(['--only=oxlint', routes])
103111

104-
expect(exitCode).toBe(1)
105-
expect(stdout).toContain(`${routes}/linked/util.ts:1:1`)
106-
expect(stdout).toContain(`${routes}/shared/util.ts:1:1`)
107-
expect(stdout).toContain('eslint(no-var)')
108-
expect(selectedReport(stdout)).toEqual([
112+
expect(check.exitCode).toBe(1)
113+
expect(check.stdout).toContain(`${routes}/shared/util.ts:1:1`)
114+
expect(check.stdout).not.toContain(`${routes}/linked/`)
115+
expect(check.stdout).not.toContain(`${routes}/vendor/`)
116+
expect(selectedReport(check.stdout)).toEqual([
109117
`uncheck in ${project.dir}`,
110-
`▶ oxlint --no-error-on-unmatched-pattern ${routes}/linked ${routes}/shared/util.ts`,
118+
`▶ oxlint --no-error-on-unmatched-pattern ${routes}/alias.ts ${routes}/home.ts ${routes}/shared/util.ts`,
111119
'✘ oxlint failed',
112120
'✘ 1 of 1 checks failed: oxlint',
113121
' rerun with `--fix` to apply oxlint fixes',
114122
])
123+
124+
const fix = await project.uncheck(['--only=oxlint', '--fix', routes])
125+
126+
expect(fix.exitCode).toBe(0)
127+
expect(project.read(`${routes}/shared/util.ts`)).toBe('const count = 1;\nexport { count };\n')
128+
expect(readFileSync(join(store, 'vendor.ts'), 'utf8')).toBe(CODE_WITH_VAR)
115129
})
116130

117131
it('leaves out a linked node_modules that a folder-only ignore rule misses', async () => {

‎packages/uncheck/tests/uncheck/paths-globs.test.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,14 @@ describe.each(LAYOUTS)('uncheck with globs in a $name', ({ create, app }) => {
102102
expect(await check(project, routes('@(home|about).ts'))).toEqual(routes('about.ts', 'home.ts'))
103103
})
104104

105+
it('matches a character escaped with \\ literally', async () => {
106+
const project = routesProject()
107+
108+
expect(await check(project, routes('\\[id\\].ts', '\\[slug\\]/*'))).toEqual(
109+
routes('[id].ts', '[slug]/page.ts'),
110+
)
111+
})
112+
105113
it('reads a glob starting with "#" as a glob rather than a comment', async () => {
106114
const project = routesProject()
107115

‎packages/uncheck/tests/uncheck/paths-tools.test.ts‎

Lines changed: 1 addition & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,4 @@
1-
import { writeFileSync } from 'node:fs'
2-
import { join, relative } from 'node:path'
3-
4-
import { LAYOUTS, report, temporaryDirectory } from '../utils/project'
1+
import { LAYOUTS, report } from '../utils/project'
52
import { CLEAN_CODE, CODE_WITH_VAR, NOT_COVERED, selectedReport } from './utils'
63

74
const ONLY_FILE_CHECKS = ['--only=oxlint', '--only=oxfmt']
@@ -200,33 +197,4 @@ describe.each(LAYOUTS)('uncheck handing files to the tools in a $name', ({ creat
200197
expect(project.read(`${app}-draft.ts`)).toBe('const count = 1;\nexport { count };\n')
201198
expect(project.read(`${app}!notes.ts`)).toBe('export const notes = 1;\n')
202199
})
203-
204-
it('hands oxlint and oxfmt a file above the directory it runs in as a "../" path they reject', async () => {
205-
const project = create()
206-
const outside = temporaryDirectory()
207-
writeFileSync(join(outside, 'shared.ts'), CLEAN_CODE)
208-
const handed = relative(project.dir, join(outside, 'shared.ts'))
209-
210-
const { exitCode, stdout, stderr } = await project.uncheck([
211-
...ONLY_FILE_CHECKS,
212-
join(outside, 'shared.ts'),
213-
])
214-
215-
expect(stderr).toBe('')
216-
expect(exitCode).toBe(1)
217-
expect(selectedReport(stdout)).toEqual([
218-
`uncheck in ${project.dir}`,
219-
`▶ oxlint --no-error-on-unmatched-pattern ${handed}`,
220-
'✘ oxlint failed',
221-
`▶ oxfmt --check --no-error-on-unmatched-pattern ${handed}`,
222-
'✘ oxfmt failed',
223-
'✘ 2 of 2 checks failed: oxlint, oxfmt',
224-
' rerun with `--fix` to apply oxlint and oxfmt fixes',
225-
])
226-
expect(
227-
stdout
228-
.split('\n')
229-
.filter((line) => line === `Error: \`${handed}\`: PATH must not contain ".."`),
230-
).toHaveLength(2)
231-
})
232200
})

‎packages/uncheck/tests/uncheck/paths.test.ts‎

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -101,26 +101,36 @@ describe.each(LAYOUTS)('uncheck selecting files by path in a $name', ({ create,
101101
])
102102
})
103103

104-
it('matches nothing with a directory or glob above the directory it runs in', async () => {
104+
it('fails naming a file, directory or glob above the directory it runs in', async () => {
105105
const project = create({
106106
[`${app}src/routes/home.ts`]: CLEAN_CODE,
107107
[`${app}src/legacy.ts`]: CODE_WITH_VAR,
108108
})
109-
const outside = temporaryDirectory()
110-
writeFileSync(join(outside, 'shared.ts'), CLEAN_CODE)
111-
112-
const { exitCode, stdout, stderr } = await project.uncheck(
113-
['--only=oxlint', '..', '../*.ts', outside],
114-
{ cwd: `${app}src/routes` },
115-
)
116-
117-
expect(exitCode).toBe(1)
118-
expect(report(stdout)).toEqual([`uncheck in ${project.path(app, 'src/routes')}`])
119-
expect(stderr).toBe(
120-
cliError(
121-
`No files match .., ../*.ts, ${outside}. Pass --no-error-on-unmatched-pattern to run with whatever matched.`,
122-
),
123-
)
109+
const outside = join(temporaryDirectory(), 'shared.ts')
110+
writeFileSync(outside, CLEAN_CODE)
111+
const routes = `${app}src/routes`
112+
113+
for (const pattern of ['../legacy.ts', '..', '../*.ts', outside]) {
114+
const fromInside = await project.uncheck(['--only=oxlint', 'home.ts', pattern], {
115+
cwd: routes,
116+
})
117+
const withCwd = await project.uncheck([
118+
'--only=oxlint',
119+
'--no-error-on-unmatched-pattern',
120+
`--cwd=${routes}`,
121+
pattern,
122+
])
123+
124+
for (const { exitCode, stdout, stderr } of [fromInside, withCwd]) {
125+
expect(exitCode).toBe(1)
126+
expect(report(stdout)).toEqual([`uncheck in ${project.path(routes)}`])
127+
expect(stderr).toBe(
128+
cliError(
129+
`${pattern} is outside ${project.path(routes)}, run from a folder that contains it or pass one with --cwd`,
130+
),
131+
)
132+
}
133+
}
124134
})
125135
})
126136

‎packages/uncheck/tests/uncheck/tsc-references.test.ts‎

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -173,14 +173,6 @@ describe('tsc project references across the packages of a monorepo', () => {
173173
)
174174
})
175175

176-
it('builds a package from its folder for a change in a package it references', async () => {
177-
const project = withFakeTsc(monorepo)
178-
179-
expect(await tscPlan(project, 'packages/app', ['../core/src/index.ts'])).toEqual([
180-
'▶ tsc -b tsconfig.json',
181-
])
182-
})
183-
184176
it('checks a package on its own from its folder', async () => {
185177
const project = withFakeTsc(monorepo)
186178

0 commit comments

Comments
 (0)