Skip to content

Commit f39050c

Browse files
authored
Merge pull request #44 from UiPath/feature/check_public_exports
chore: implement initial public api export checker
2 parents 949ad0e + 14bf51f commit f39050c

File tree

11 files changed

+657
-1539
lines changed

11 files changed

+657
-1539
lines changed
Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
import * as chalk from 'chalk';
2+
import {
3+
existsSync,
4+
readFileSync,
5+
} from 'fs';
6+
import * as glob from 'glob';
7+
import {
8+
basename,
9+
dirname,
10+
resolve,
11+
} from 'path';
12+
import * as ts from 'typescript';
13+
14+
import {
15+
getPullRequestHead,
16+
statusReporterFactory,
17+
} from '../common/github';
18+
19+
const reportStatus = statusReporterFactory('public-api-check');
20+
21+
const {
22+
red,
23+
cyan,
24+
green,
25+
grey,
26+
} = chalk.default;
27+
28+
const ROOT = resolve(__dirname, '../..');
29+
const BARREL = 'index.ts';
30+
31+
const resolveParentDir = (path) => dirname(resolve(ROOT, path));
32+
interface IExportPair {
33+
exportLocation: string;
34+
exported: string;
35+
}
36+
37+
interface IDecoratedClass {
38+
decoratorName: string;
39+
className: string;
40+
}
41+
42+
interface IDecoratedError extends IDecoratedClass {
43+
exportLocation: string;
44+
rootPath: string;
45+
}
46+
47+
let isErrorFlagged = false;
48+
49+
const _handleUnexpectedError = (err?: string) => {
50+
if (err) { console.error(err); }
51+
52+
process.exit(1);
53+
};
54+
55+
const buildError = ({ className, decoratorName, exportLocation, rootPath }: IDecoratedError, message: string) => {
56+
return [
57+
red(`ERROR:`),
58+
` Invalid export source: ${cyan(rootPath)}`,
59+
` Class source: ${cyan(`${exportLocation}.ts`)}`,
60+
` Class name: ${cyan(className)}`,
61+
` Annotation: ${cyan(decoratorName)}`,
62+
` ${green('Recommendation:')} ${cyan(message)}`,
63+
``,
64+
];
65+
};
66+
67+
const reportAnnotationError = (error: IDecoratedError) =>
68+
console.error(
69+
buildError(
70+
error,
71+
`Use named exports! eg: 'export { A } from './a.module`,
72+
)
73+
.join('\n'),
74+
);
75+
76+
const reportBarrelError = (error: IDecoratedError) =>
77+
console.error(
78+
buildError(
79+
error,
80+
`Please drop any barreled export files from the export chain of annotated classes.`,
81+
)
82+
.join('\n'),
83+
);
84+
85+
const readFileNode = (path: string) =>
86+
ts.createSourceFile(path, readFileSync(path, 'utf8'), ts.ScriptTarget.Latest, true);
87+
88+
const getChildren = (node: ts.Node) => {
89+
const children: ts.Node[] = [];
90+
91+
ts.forEachChild(node, (subnode) => {
92+
children.push(subnode);
93+
});
94+
95+
return children;
96+
};
97+
98+
const getExportDeclarations = (sourceNode: ts.Node) =>
99+
getChildren(sourceNode)
100+
.filter(node => ts.isExportDeclaration(node))
101+
.map((exportNode) => {
102+
const children = getChildren(exportNode);
103+
104+
const exportedFrom = children
105+
.find(node => ts.isStringLiteral(node))!;
106+
107+
const namedExports = children
108+
.find(node => ts.isNamedExports(node));
109+
110+
const locationText = exportedFrom.getText();
111+
112+
return {
113+
exportLocation: locationText.substring(1, locationText.length - 1),
114+
exported: namedExports ? namedExports.getText() : null,
115+
} as IExportPair;
116+
});
117+
118+
const searchForDecoratedClasses = (source: ts.SourceFile) => {
119+
const decoratedClassNodes = getChildren(source)
120+
.filter(node => ts.isClassDeclaration(node))
121+
.filter(node => !!node.decorators);
122+
123+
const classes: IDecoratedClass[] = [];
124+
125+
decoratedClassNodes
126+
.forEach(decoratedClassNode => {
127+
decoratedClassNode.decorators
128+
.forEach(decoratorNode => {
129+
const className = getChildren(decoratedClassNode)
130+
.find(classNode => ts.isIdentifier(classNode))
131+
.getText();
132+
133+
const [decoratorName] = getChildren(decoratorNode.expression)
134+
.map(expressionNode => expressionNode.getText());
135+
136+
classes.push({
137+
className,
138+
decoratorName,
139+
});
140+
});
141+
});
142+
143+
return classes;
144+
};
145+
146+
const resolveFileLocation = (exportLocation: string, path: string) => {
147+
const parentDir = resolveParentDir(path);
148+
149+
let fileName = `${exportLocation}.ts`;
150+
151+
const fileLocation = resolve(parentDir, fileName);
152+
153+
if (existsSync(fileLocation)) { return fileLocation; }
154+
155+
fileName = `${exportLocation}/index.ts`;
156+
157+
return resolve(parentDir, fileName);
158+
};
159+
160+
const checkForUnnamedDecoratedExports = (rootPath: string, path: string, exportList: IExportPair[]) =>
161+
exportList
162+
.filter(e => !e.exported)
163+
.forEach(({ exportLocation }) => {
164+
const fileLocation = resolveFileLocation(exportLocation, path);
165+
const file = readFileNode(fileLocation);
166+
167+
const exportDeclarations = getExportDeclarations(file);
168+
169+
if (!exportDeclarations.length) {
170+
const decoratedClasses = searchForDecoratedClasses(file);
171+
172+
if (!decoratedClasses.length) { return; }
173+
174+
isErrorFlagged = true;
175+
176+
decoratedClasses
177+
.map(
178+
({ className, decoratorName }) =>
179+
reportAnnotationError({
180+
className,
181+
decoratorName,
182+
rootPath,
183+
exportLocation,
184+
}),
185+
);
186+
} else {
187+
checkForUnnamedDecoratedExports(rootPath, fileLocation, exportDeclarations);
188+
}
189+
});
190+
191+
const barrelMap = new Map<string, IDecoratedError[]>();
192+
const barrelChainMap = new Map<string, boolean>();
193+
194+
const checkForBarreledDecoratedExports = (rootPath: string, path: string, exportList: IExportPair[]) => {
195+
const isFirstStackFrame = rootPath === path;
196+
197+
// exit early if an error has already been found down this path
198+
if (barrelMap.get(rootPath)) { return; }
199+
200+
exportList
201+
.forEach(({ exportLocation }) => {
202+
const fileLocation = resolveFileLocation(exportLocation, path);
203+
const file = readFileNode(fileLocation);
204+
205+
if (basename(fileLocation) === BARREL) {
206+
barrelChainMap.set(rootPath, true);
207+
}
208+
209+
const decoratedClasses = searchForDecoratedClasses(file);
210+
const exportDeclarations = getExportDeclarations(file);
211+
212+
if (
213+
barrelChainMap.has(rootPath) &&
214+
!!decoratedClasses.length
215+
) {
216+
const errors = decoratedClasses.map(
217+
({ className, decoratorName }) => ({
218+
className,
219+
decoratorName,
220+
exportLocation,
221+
rootPath,
222+
} as IDecoratedError),
223+
);
224+
225+
barrelMap.set(rootPath, errors);
226+
}
227+
228+
checkForBarreledDecoratedExports(rootPath, fileLocation, exportDeclarations);
229+
});
230+
231+
if (
232+
isFirstStackFrame &&
233+
barrelMap.has(rootPath)
234+
) {
235+
isErrorFlagged = true;
236+
const errors = barrelMap.get(rootPath);
237+
errors.forEach(error => reportBarrelError(error));
238+
}
239+
};
240+
241+
const checkFile = (path: string) => {
242+
const sourceFile = readFileNode(path);
243+
const rootExports = getExportDeclarations(sourceFile);
244+
245+
console.log(`${grey('Checking')} ${cyan(sourceFile.fileName)} ${grey('for unnamed decorated exports...')}`);
246+
checkForUnnamedDecoratedExports(path, path, rootExports);
247+
248+
console.log(`${grey('Checking')} ${cyan(sourceFile.fileName)} ${grey('decorated exports hidden behind barrels...')}`);
249+
checkForBarreledDecoratedExports(path, path, rootExports);
250+
};
251+
252+
const forEachApiFile = (callback: (string) => void) => {
253+
return new Promise((res, rej) => {
254+
const walker = new glob.Glob('./projects/**/public_api.ts', {
255+
root: ROOT,
256+
}, (err, files) => {
257+
if (err) {
258+
console.error(err);
259+
rej(err);
260+
}
261+
262+
files.forEach(callback);
263+
});
264+
265+
walker.on('end', res);
266+
});
267+
};
268+
269+
const run = async () => {
270+
const head = await getPullRequestHead();
271+
272+
await reportStatus('pending', head.sha, 'Checking Public API...')
273+
.catch(_handleUnexpectedError);
274+
275+
await forEachApiFile(checkFile);
276+
277+
if (isErrorFlagged) {
278+
console.error(red(`⛔ Something doesn't check out`));
279+
280+
await reportStatus('error', head.sha, 'npm run check:exports for more info...')
281+
.catch(_handleUnexpectedError);
282+
} else {
283+
console.log(green(`💯 Good to go!`));
284+
285+
await reportStatus('success', head.sha, '✔ Good to go!')
286+
.catch(_handleUnexpectedError);
287+
}
288+
};
289+
290+
(async () => {
291+
await run()
292+
.catch(_handleUnexpectedError);
293+
})();
294+

.build/commitlint/index.js

Lines changed: 7 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,60 +1,23 @@
11
/**
22
* Adapted from: https://github.com/fathyb/commitlint-circle
33
*/
4-
const Octokit = require('@octokit/rest')
54
const { lint, load } = require('@commitlint/core')
65
const path = require('path')
6+
const { red, yellow, blue, green } = require('chalk');
77

8-
const { variables } = require('../common')
9-
const { owner, repo, pull, token } = variables;
10-
11-
const github = new Octokit({
12-
auth: token,
13-
})
8+
const { getPullRequestHead, getCommits, statusReporterFactory } = require('../common/github');
149

1510
const _handleUnexpectedError = (err) => {
1611
console.error(err)
1712
process.exit(1)
1813
}
1914

20-
const getCommits = async () => {
21-
console.log('📡 Looking up commits for PR #%s...', pull)
22-
const response = await github.pulls.listCommits({
23-
owner,
24-
repo,
25-
pull_number: pull,
26-
per_page: 100
27-
})
28-
29-
return response.data
30-
}
31-
32-
const reportStatus = async (state, sha, description, target_url) => {
33-
await github.repos.createStatus({
34-
owner,
35-
repo,
36-
sha,
37-
state,
38-
description,
39-
context: 'commitlint',
40-
target_url,
41-
})
42-
}
43-
44-
const getPullRequestHead = async () => {
45-
const pr = await github.pullRequests.get({
46-
owner,
47-
repo,
48-
pull_number: pull,
49-
})
50-
51-
return pr.data.head
52-
}
15+
const reportStatus = statusReporterFactory('commitlint')
5316

5417
const run = async () => {
5518
const head = await getPullRequestHead()
5619

57-
console.log('👮‍ Calling Git Police...')
20+
console.log(blue('👮‍ Calling Git Police...'))
5821

5922
await reportStatus('pending', head.sha, 'Checking commits...')
6023

@@ -69,13 +32,11 @@ const run = async () => {
6932
const isConventionalCommitGuidelineRespected = lintResultList.every(result => result.valid)
7033
const isAnyFixupCommit = commits.some(({ commit }) => commit.message.startsWith('fixup!'))
7134

72-
console.log(isConventionalCommitGuidelineRespected)
73-
7435
if (
7536
isConventionalCommitGuidelineRespected &&
7637
!isAnyFixupCommit
7738
) {
78-
console.log(`💯 Good to go!`)
39+
console.log(green(`💯 Good to go!`))
7940

8041
await reportStatus('success', head.sha, '✔ Good to go!')
8142
.catch(_handleUnexpectedError)
@@ -84,15 +45,15 @@ const run = async () => {
8445
}
8546

8647
if (isAnyFixupCommit) {
87-
console.log(`😬 There are still some fixup commits.`)
48+
console.warn(yellow(`😬 There are still some fixup commits.`))
8849

8950
await reportStatus('error', head.sha, 'Please rebase and apply the fixup commits!')
9051
.catch(_handleUnexpectedError)
9152

9253
return;
9354
}
9455

95-
console.log(`⛔ Something doesn't check out`)
56+
console.error(red(`⛔ Something doesn't check out`))
9657

9758
await reportStatus('error', head.sha, 'We use conventional commits (╯°□°)╯︵ ┻━┻', 'https://www.conventionalcommits.org/en/v1.0.0-beta.4/')
9859
.catch(_handleUnexpectedError)

0 commit comments

Comments
 (0)