Skip to content

Commit 78ff558

Browse files
authored
feat: allow parsing multiple input types (#1050)
* feat: allow parsing multiple input types Parse now has a new method that allows the user to get all files and dependencies by providing a list of directories, files or globs * fix: performance test * fix: improve validate file * fix: pass directory * feat: normalize test paths
1 parent 1418114 commit 78ff558

File tree

4 files changed

+356
-17
lines changed

4 files changed

+356
-17
lines changed
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
import { Parser } from '../parser'
2+
import * as path from 'path'
3+
import { pathToPosix } from '../../util'
4+
5+
describe('project parser - getFilesAndDependencies()', () => {
6+
it('should handle JS file with no dependencies', async () => {
7+
const parser = new Parser({})
8+
const res = await parser.getFilesAndDependencies([path.join(__dirname, 'check-parser-fixtures', 'no-dependencies.js')])
9+
expect(res.files).toHaveLength(1)
10+
expect(res.errors).toHaveLength(0)
11+
})
12+
13+
it('should handle JS file with dependencies', async () => {
14+
const toAbsolutePath = (...filepath: string[]) => path.join(__dirname, 'check-parser-fixtures', 'simple-example', ...filepath)
15+
const parser = new Parser({})
16+
const res = await parser.getFilesAndDependencies([toAbsolutePath('entrypoint.js')])
17+
const expectedFiles = [
18+
'dep1.js',
19+
'dep2.js',
20+
'dep3.js',
21+
'entrypoint.js',
22+
'module-package/main.js',
23+
'module-package/package.json',
24+
'module/index.js',
25+
].map(file => pathToPosix(toAbsolutePath(file)))
26+
27+
expect(res.files.map(file => pathToPosix(file)).sort()).toEqual(expectedFiles)
28+
expect(res.errors).toHaveLength(0)
29+
})
30+
31+
it('Should not repeat files if duplicated', async () => {
32+
const toAbsolutePath = (...filepath: string[]) => path.join(__dirname, 'check-parser-fixtures', 'simple-example', ...filepath)
33+
const parser = new Parser({})
34+
const res = await parser.getFilesAndDependencies([toAbsolutePath('entrypoint.js'), toAbsolutePath('*.js')])
35+
const expectedFiles = [
36+
'dep1.js',
37+
'dep2.js',
38+
'dep3.js',
39+
'entrypoint.js',
40+
'module-package/main.js',
41+
'module-package/package.json',
42+
'module/index.js',
43+
'unreachable.js',
44+
].map(file => pathToPosix(toAbsolutePath(file)))
45+
46+
expect(res.files.map(file => pathToPosix(file)).sort()).toEqual(expectedFiles)
47+
expect(res.errors).toHaveLength(0)
48+
})
49+
50+
it('should not fail on a non-existing directory', async () => {
51+
const toAbsolutePath = (...filepath: string[]) => path.join(__dirname, 'check-parser-fixtures', 'simple-example-that-does-not-exist', ...filepath)
52+
const parser = new Parser({})
53+
const res = await parser.getFilesAndDependencies([toAbsolutePath('/')])
54+
expect(res.files).toHaveLength(0)
55+
expect(res.errors).toHaveLength(0)
56+
})
57+
58+
it('should parse the cli in less than 400ms', async () => {
59+
const toAbsolutePath = (...filepath: string[]) => path.join(__dirname, '../../../', ...filepath)
60+
const startTimestamp = Date.now().valueOf()
61+
const res = await new Parser({}).getFilesAndDependencies([toAbsolutePath('/index.ts')])
62+
const endTimestamp = Date.now().valueOf()
63+
expect(res.files).not.toHaveLength(0)
64+
expect(res.errors).toHaveLength(0)
65+
const isCI = process.env.CI === 'true'
66+
expect(endTimestamp - startTimestamp).toBeLessThan(isCI ? 2000 : 400)
67+
})
68+
69+
it('should handle JS file with dependencies glob patterns', async () => {
70+
const toAbsolutePath = (...filepath: string[]) => path.join(__dirname, 'check-parser-fixtures', 'simple-example', ...filepath)
71+
const parser = new Parser({})
72+
const res = await parser.getFilesAndDependencies([toAbsolutePath('*.js'), toAbsolutePath('*.json')])
73+
const expectedFiles = [
74+
'dep1.js',
75+
'dep2.js',
76+
'dep3.js',
77+
'entrypoint.js',
78+
'module-package/main.js',
79+
'module-package/package.json',
80+
'module/index.js',
81+
'unreachable.js',
82+
].map(file => pathToPosix(toAbsolutePath(file)))
83+
84+
expect(res.files.map(file => pathToPosix(file)).sort()).toEqual(expectedFiles)
85+
expect(res.errors).toHaveLength(0)
86+
})
87+
88+
it('should parse typescript dependencies', async () => {
89+
const toAbsolutePath = (...filepath: string[]) => path.join(__dirname, 'check-parser-fixtures', 'typescript-example', ...filepath)
90+
const parser = new Parser({})
91+
const res = await parser.getFilesAndDependencies([toAbsolutePath('entrypoint.ts')])
92+
const expectedFiles = [
93+
'dep1.ts',
94+
'dep2.ts',
95+
'dep3.ts',
96+
'dep4.js',
97+
'dep5.ts',
98+
'dep6.ts',
99+
'entrypoint.ts',
100+
'module-package/main.js',
101+
'module-package/package.json',
102+
'module/index.ts',
103+
'pages/external.first.page.js',
104+
'pages/external.second.page.ts',
105+
'type.ts',
106+
].map(file => pathToPosix(toAbsolutePath(file)))
107+
108+
expect(res.files.map(file => pathToPosix(file)).sort()).toEqual(expectedFiles)
109+
expect(res.errors).toHaveLength(0)
110+
})
111+
112+
it('should parse typescript dependencies using tsconfig', async () => {
113+
const toAbsolutePath = (...filepath: string[]) => path.join(__dirname, 'check-parser-fixtures', 'tsconfig-paths-sample-project', ...filepath)
114+
const parser = new Parser({})
115+
const res = await parser.getFilesAndDependencies([toAbsolutePath('src', 'entrypoint.ts')])
116+
const expectedFiles = [
117+
'lib1/file1.ts',
118+
'lib1/file2.ts',
119+
'lib1/folder/file1.ts',
120+
'lib1/folder/file2.ts',
121+
'lib1/index.ts',
122+
'lib1/package.json',
123+
'lib1/tsconfig.json',
124+
'lib2/index.ts',
125+
'lib3/foo/bar.ts',
126+
'src/entrypoint.ts',
127+
'tsconfig.json',
128+
].map(file => pathToPosix(toAbsolutePath(file)))
129+
130+
expect(res.files.map(file => pathToPosix(file)).sort()).toEqual(expectedFiles)
131+
expect(res.errors).toHaveLength(0)
132+
})
133+
134+
it('should not include tsconfig if not needed', async () => {
135+
const toAbsolutePath = (...filepath: string[]) => path.join(__dirname, 'check-parser-fixtures', 'tsconfig-paths-unused', ...filepath)
136+
const parser = new Parser({})
137+
const res = await parser.getFilesAndDependencies([toAbsolutePath('src', 'entrypoint.ts')])
138+
expect(res.files.map(file => pathToPosix(file)).sort()).toEqual([pathToPosix(toAbsolutePath('src', 'entrypoint.ts'))])
139+
expect(res.errors).toHaveLength(0)
140+
})
141+
142+
it('should support importing ts extensions if allowed', async () => {
143+
const toAbsolutePath = (...filepath: string[]) => path.join(__dirname, 'check-parser-fixtures', 'tsconfig-allow-importing-ts-extensions', ...filepath)
144+
const parser = new Parser({})
145+
const res = await parser.getFilesAndDependencies([toAbsolutePath('src', 'entrypoint.ts')])
146+
const expectedFiles = [
147+
'src/dep1.ts',
148+
'src/dep2.ts',
149+
'src/dep3.ts',
150+
'src/entrypoint.ts',
151+
].map(file => pathToPosix(toAbsolutePath(file)))
152+
153+
expect(res.files.map(file => pathToPosix(file)).sort()).toEqual(expectedFiles)
154+
expect(res.errors).toHaveLength(0)
155+
})
156+
157+
it('should not import TS files from a JS file', async () => {
158+
const toAbsolutePath = (...filepath: string[]) => path.join(__dirname, 'check-parser-fixtures', 'no-import-ts-from-js', ...filepath)
159+
const parser = new Parser({})
160+
expect.assertions(1)
161+
try {
162+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
163+
await parser.getFilesAndDependencies([toAbsolutePath('entrypoint.js')])
164+
} catch (err) {
165+
expect(err).toMatchObject({
166+
missingFiles: [
167+
pathToPosix(toAbsolutePath('dep1')),
168+
pathToPosix(toAbsolutePath('dep1.ts')),
169+
pathToPosix(toAbsolutePath('dep1.js')),
170+
],
171+
})
172+
}
173+
})
174+
175+
it('should import JS files from a TS file', async () => {
176+
const toAbsolutePath = (...filepath: string[]) => path.join(__dirname, 'check-parser-fixtures', 'import-js-from-ts', ...filepath)
177+
const parser = new Parser({})
178+
const res = await parser.getFilesAndDependencies([toAbsolutePath('entrypoint.ts')])
179+
const expectedFiles = [
180+
'dep1.js',
181+
'dep2.js',
182+
'dep3.ts',
183+
'entrypoint.ts',
184+
].map(file => pathToPosix(toAbsolutePath(file)))
185+
186+
expect(res.files.map(file => pathToPosix(file)).sort()).toEqual(expectedFiles)
187+
})
188+
189+
it('should handle ES Modules', async () => {
190+
const toAbsolutePath = (...filepath: string[]) => path.join(__dirname, 'check-parser-fixtures', 'esmodules-example', ...filepath)
191+
const parser = new Parser({})
192+
const res = await parser.getFilesAndDependencies([toAbsolutePath('entrypoint.js')])
193+
const expectedFiles = [
194+
'dep1.js',
195+
'dep2.js',
196+
'dep3.js',
197+
'dep5.js',
198+
'dep6.js',
199+
'entrypoint.js',
200+
].map(file => pathToPosix(toAbsolutePath(file)))
201+
202+
expect(res.files.map(file => pathToPosix(file)).sort()).toEqual(expectedFiles)
203+
})
204+
205+
it('should handle Common JS and ES Modules', async () => {
206+
const toAbsolutePath = (...filepath: string[]) => path.join(__dirname, 'check-parser-fixtures', 'common-esm-example', ...filepath)
207+
const parser = new Parser({})
208+
const res = await parser.getFilesAndDependencies([toAbsolutePath('entrypoint.mjs')])
209+
const expectedFiles = [
210+
'dep1.js',
211+
'dep2.mjs',
212+
'dep3.mjs',
213+
'dep4.mjs',
214+
'dep5.mjs',
215+
'dep6.mjs',
216+
'entrypoint.mjs',
217+
].map(file => pathToPosix(toAbsolutePath(file)))
218+
219+
expect(res.files.map(file => pathToPosix(file)).sort()).toEqual(expectedFiles)
220+
})
221+
222+
it('should handle node: prefix for built-ins', async () => {
223+
const toAbsolutePath = (...filepath: string[]) => path.join(__dirname, 'check-parser-fixtures', 'builtin-with-node-prefix', ...filepath)
224+
const parser = new Parser({})
225+
await parser.getFilesAndDependencies([toAbsolutePath('entrypoint.ts')])
226+
})
227+
228+
/*
229+
* There is an unhandled edge-case when require() is reassigned.
230+
* Even though the check might execute fine, we throw an error for a missing dependency.
231+
* We could address this by keeping track of assignments as we walk the AST.
232+
*/
233+
it.skip('should ignore cases where require is reassigned', async () => {
234+
const entrypoint = path.join(__dirname, 'check-parser-fixtures', 'reassign-require.js')
235+
const parser = new Parser({})
236+
await parser.getFilesAndDependencies([entrypoint])
237+
})
238+
239+
// Checks run on Checkly are wrapped to support top level await.
240+
// For consistency with checks created via the UI, the CLI should support this as well.
241+
it('should allow top-level await', async () => {
242+
const entrypoint = path.join(__dirname, 'check-parser-fixtures', 'top-level-await.js')
243+
const parser = new Parser({})
244+
await parser.getFilesAndDependencies([entrypoint])
245+
})
246+
247+
it('should allow top-level await in TypeScript', async () => {
248+
const entrypoint = path.join(__dirname, 'check-parser-fixtures', 'top-level-await.ts')
249+
const parser = new Parser({})
250+
await parser.getFilesAndDependencies([entrypoint])
251+
})
252+
})

packages/cli/src/services/check-parser/parser.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import * as path from 'path'
22
import * as fs from 'fs'
3+
import * as fsAsync from 'fs/promises'
34
import * as acorn from 'acorn'
45
import * as walk from 'acorn-walk'
56
import { Collector } from './collector'
67
import { DependencyParseError } from './errors'
78
import { PackageFilesResolver, Dependencies } from './package-files/resolver'
89
// Only import types given this is an optional dependency
910
import type { TSESTree, AST_NODE_TYPES } from '@typescript-eslint/typescript-estree'
11+
import { findFilesWithPattern, pathToPosix } from '../util'
1012

1113
// Our custom configuration to handle walking errors
1214
// eslint-disable-next-line @typescript-eslint/no-empty-function
@@ -113,6 +115,91 @@ export class Parser {
113115
return false
114116
}
115117

118+
private async validateFileAsync (filePath: string): Promise<{ filePath: string, content: string }> {
119+
const extension = path.extname(filePath)
120+
if (extension !== '.js' && extension !== '.ts' && extension !== '.mjs') {
121+
throw new Error(`Unsupported file extension for ${filePath}`)
122+
}
123+
try {
124+
const content = await fsAsync.readFile(filePath, { encoding: 'utf-8' })
125+
return { filePath, content }
126+
} catch (err) {
127+
throw new DependencyParseError(filePath, [filePath], [], [])
128+
}
129+
}
130+
131+
async getFilesAndDependencies (paths: string[]): Promise<{ files: string[], errors: string[] }> {
132+
const files = new Set(await this.getFilesFromPaths(paths))
133+
const errors = new Set<string>()
134+
const missingFiles = new Set<string>()
135+
const resultFileSet = new Set<string>()
136+
for (const file of files) {
137+
if (resultFileSet.has(file)) {
138+
continue
139+
}
140+
if (file.endsWith('.json')) {
141+
// Holds info about the main file and doesn't need to be parsed
142+
resultFileSet.add(file)
143+
continue
144+
}
145+
const item = await this.validateFileAsync(file)
146+
147+
const cache = this.cache.get(item.filePath)
148+
const { module, error } = cache !== undefined
149+
? cache
150+
: Parser.parseDependencies(item.filePath, item.content)
151+
152+
if (error) {
153+
this.cache.set(item.filePath, { module, error })
154+
errors.add(item.filePath)
155+
continue
156+
}
157+
const resolvedDependencies = cache?.resolvedDependencies ??
158+
this.resolver.resolveDependenciesForFilePath(item.filePath, module.dependencies)
159+
160+
for (const dep of resolvedDependencies.missing) {
161+
missingFiles.add(pathToPosix(dep.filePath))
162+
}
163+
164+
this.cache.set(item.filePath, { module, resolvedDependencies })
165+
166+
for (const dep of resolvedDependencies.local) {
167+
if (resultFileSet.has(dep.sourceFile.meta.filePath)) {
168+
continue
169+
}
170+
const filePath = dep.sourceFile.meta.filePath
171+
files.add(filePath)
172+
}
173+
resultFileSet.add(pathToPosix(item.filePath))
174+
}
175+
if (missingFiles.size) {
176+
throw new DependencyParseError(paths.join(', '), Array.from(missingFiles), [], [])
177+
}
178+
return { files: Array.from(resultFileSet), errors: Array.from(errors) }
179+
}
180+
181+
private async getFilesFromPaths (paths: string[]): Promise<string[]> {
182+
const files = paths.map(async (currPath) => {
183+
const normalizedPath = pathToPosix(currPath)
184+
try {
185+
const stats = await fsAsync.lstat(normalizedPath)
186+
if (stats.isDirectory()) {
187+
return findFilesWithPattern(normalizedPath, '**/*.{js,ts,mjs}', [])
188+
}
189+
return [normalizedPath]
190+
} catch (err) {
191+
if (normalizedPath.includes('*') || normalizedPath.includes('?') || normalizedPath.includes('{')) {
192+
return findFilesWithPattern(process.cwd(), normalizedPath, [])
193+
} else {
194+
return []
195+
}
196+
}
197+
})
198+
199+
const filesArray = await Promise.all(files)
200+
return filesArray.flat()
201+
}
202+
116203
parse (entrypoint: string) {
117204
const { content } = validateEntrypoint(entrypoint)
118205

packages/cli/src/services/project-parser.ts

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import { glob } from 'glob'
21
import * as path from 'path'
3-
import { loadJsFile, loadTsFile, pathToPosix } from './util'
2+
import { findFilesWithPattern, loadJsFile, loadTsFile, pathToPosix } from './util'
43
import {
54
Check, BrowserCheck, CheckGroup, Project, Session,
65
PrivateLocation, PrivateLocationCheckAssignment, PrivateLocationGroupAssignment, MultiStepCheck,
@@ -223,18 +222,3 @@ async function loadAllPrivateLocationsSlugNames (
223222
})
224223
})
225224
}
226-
227-
async function findFilesWithPattern (
228-
directory: string,
229-
pattern: string | string[],
230-
ignorePattern: string[],
231-
): Promise<string[]> {
232-
// The files are sorted to make sure that the processing order is deterministic.
233-
const files = await glob(pattern, {
234-
nodir: true,
235-
cwd: directory,
236-
ignore: ignorePattern,
237-
absolute: true,
238-
})
239-
return files.sort()
240-
}

0 commit comments

Comments
 (0)