@hero-design/snowflake-guard 1.0.7-alpha0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dockerignore +12 -0
- package/.env.example +9 -0
- package/.eslintrc.js +8 -0
- package/CHANGELOG.md +47 -0
- package/Dockerfile +8 -0
- package/LICENSE +15 -0
- package/README.md +33 -0
- package/app.yml +137 -0
- package/jest.config.js +9 -0
- package/lib/netlify/functions/snowflake.d.ts +2 -0
- package/lib/netlify/functions/snowflake.js +10 -0
- package/lib/src/__mocks__/sourceSample.d.ts +2 -0
- package/lib/src/__mocks__/sourceSample.js +27 -0
- package/lib/src/__tests__/parseSource.spec.d.ts +1 -0
- package/lib/src/__tests__/parseSource.spec.js +41 -0
- package/lib/src/index.d.ts +3 -0
- package/lib/src/index.js +142 -0
- package/lib/src/parseSource.d.ts +7 -0
- package/lib/src/parseSource.js +102 -0
- package/lib/src/parsers/typescript.d.ts +3 -0
- package/lib/src/parsers/typescript.js +37 -0
- package/lib/src/reports/constants.d.ts +215 -0
- package/lib/src/reports/constants.js +848 -0
- package/lib/src/reports/reportClassName.d.ts +3 -0
- package/lib/src/reports/reportClassName.js +15 -0
- package/lib/src/reports/reportCustomStyleProperties.d.ts +10 -0
- package/lib/src/reports/reportCustomStyleProperties.js +109 -0
- package/lib/src/reports/reportInlineStyle.d.ts +7 -0
- package/lib/src/reports/reportInlineStyle.js +179 -0
- package/lib/src/reports/reportStyledComponents.d.ts +6 -0
- package/lib/src/reports/reportStyledComponents.js +95 -0
- package/lib/src/reports/types.d.ts +3 -0
- package/lib/src/reports/types.js +2 -0
- package/lib/src/test.tsx +123 -0
- package/netlify/functions/snowflake.ts +9 -0
- package/netlify.toml +21 -0
- package/package.json +44 -0
- package/src/__mocks__/sourceSample.tsx +67 -0
- package/src/__tests__/parseSource.spec.ts +15 -0
- package/src/index.ts +201 -0
- package/src/parseSource.ts +97 -0
- package/src/parsers/typescript.ts +8 -0
- package/src/reports/constants.ts +965 -0
- package/src/reports/reportClassName.ts +20 -0
- package/src/reports/reportCustomStyleProperties.ts +125 -0
- package/src/reports/reportInlineStyle.ts +221 -0
- package/src/reports/reportStyledComponents.ts +109 -0
- package/src/reports/types.ts +5 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Button, Empty, Card, Badge } from '@hero-design/react';
|
|
3
|
+
import styled from 'styled-components';
|
|
4
|
+
|
|
5
|
+
// Snowflakes using styled-components
|
|
6
|
+
const StyledButton = styled(Button)`
|
|
7
|
+
padding: 10px;
|
|
8
|
+
`;
|
|
9
|
+
|
|
10
|
+
const StyledLinkButton = styled(Button.Link)`
|
|
11
|
+
color: red;
|
|
12
|
+
`;
|
|
13
|
+
|
|
14
|
+
const { Link } = Button;
|
|
15
|
+
const StyledLink = styled(Link)`
|
|
16
|
+
color: red;
|
|
17
|
+
`;
|
|
18
|
+
|
|
19
|
+
const customBtnStyles = {
|
|
20
|
+
padding: 30,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const badgeStyles = {
|
|
24
|
+
pt: 10,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const styles = {
|
|
28
|
+
btn: {
|
|
29
|
+
padding: 30,
|
|
30
|
+
},
|
|
31
|
+
badge: {
|
|
32
|
+
pt: 10,
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const Sample = () => {
|
|
37
|
+
<>
|
|
38
|
+
<StyledButton />
|
|
39
|
+
<StyledLinkButton />
|
|
40
|
+
<StyledLink />
|
|
41
|
+
{/* Snowflakes using classname */}
|
|
42
|
+
<Button className="custom-class" />
|
|
43
|
+
<Button.Link className="custom-class" />
|
|
44
|
+
<Link className="custom-class" />
|
|
45
|
+
{/* Snowflakes using style prop */}
|
|
46
|
+
<Button style={{ width: 200 }} /> {/* acceptable */}
|
|
47
|
+
<Empty style={{ width: 200 }} />
|
|
48
|
+
<Card style={{ width: 200 }} /> {/* acceptable */}
|
|
49
|
+
<Card.Header style={{ width: 200 }} />
|
|
50
|
+
<Button style={customBtnStyles} />
|
|
51
|
+
<Button style={{ ...customBtnStyles }} />
|
|
52
|
+
<Button style={styles.btn} />
|
|
53
|
+
<Button style={{ ...styles.btn }} />
|
|
54
|
+
<Link style={{ padding: 20 }} />
|
|
55
|
+
<Link style={{ padding: 20 }} /> {/* @snowflake-guard/snowflake-approved-by-andromeda */}
|
|
56
|
+
{/* Snowflakes using sx prop */}
|
|
57
|
+
<Badge sx={{ mt: 10 }} /> {/* acceptable */}
|
|
58
|
+
<Badge.Count sx={{ mt: 10 }} />
|
|
59
|
+
<Badge sx={badgeStyles} />
|
|
60
|
+
<Badge sx={{ ...badgeStyles }} />
|
|
61
|
+
<Badge sx={styles.badge} />
|
|
62
|
+
<Badge sx={{ ...styles.badge }} />
|
|
63
|
+
<Link sx={{ padding: 20 }} />
|
|
64
|
+
</>;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export default Sample;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import parseSource from '../parseSource';
|
|
3
|
+
|
|
4
|
+
describe('parseSource', () => {
|
|
5
|
+
it('reports correct snowflakes', () => {
|
|
6
|
+
const source = fs.readFileSync('./src/__mocks__/sourceSample.tsx', 'utf-8');
|
|
7
|
+
|
|
8
|
+
expect(parseSource(source)).toEqual({
|
|
9
|
+
classNameLocs: [44, 42, 43],
|
|
10
|
+
styleLocs: [54, 47, 49, 50, 51, 52, 53],
|
|
11
|
+
sxLocs: [63, 58, 59, 60, 61, 62],
|
|
12
|
+
styledComponentLocs: [6, 10, 15],
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { Probot, ProbotOctokit } from 'probot';
|
|
2
|
+
import parseSource from './parseSource';
|
|
3
|
+
|
|
4
|
+
type DiffLocs = [number, number][];
|
|
5
|
+
|
|
6
|
+
type Comment = {
|
|
7
|
+
path: string;
|
|
8
|
+
body: string;
|
|
9
|
+
line: number;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const TSX_REGEX = /\.tsx$/;
|
|
13
|
+
const TEST_REGEX = /__tests__/;
|
|
14
|
+
const DIFF_LOCS_REGEX = /@@(.*)@@/g;
|
|
15
|
+
|
|
16
|
+
const SNOWFLAKE_COMMENTS = {
|
|
17
|
+
style:
|
|
18
|
+
'Snowflake detected! A component is customized using inline styles. Make sure to not use [prohibited CSS properties](https://docs.google.com/spreadsheets/d/1Dj8vqLdFaf-CSaSVoYqyYZIkGqF6OoyP7K4G1_9L62U/edit?usp=sharing).',
|
|
19
|
+
sx: 'Snowflake detected! A component is customized via sx prop. Make sure to not use [prohibited CSS properties](https://docs.google.com/spreadsheets/d/1Dj8vqLdFaf-CSaSVoYqyYZIkGqF6OoyP7K4G1_9L62U/edit?usp=sharing).',
|
|
20
|
+
'styled-component':
|
|
21
|
+
'Please do not use styled-component to customize this component, use sx prop or inline style instead.',
|
|
22
|
+
className:
|
|
23
|
+
'Please make sure that this className is not used as a CSS classname for component customization purposes, use sx prop or inline style instead.',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const getDiffLocs = (diffStrs: string[]) => {
|
|
27
|
+
const locs: DiffLocs = [];
|
|
28
|
+
diffStrs.forEach((diffStr) => {
|
|
29
|
+
const [startLocStr, numberOfLinesStr] = diffStr
|
|
30
|
+
.split('+')[1]
|
|
31
|
+
.split(' ')[0]
|
|
32
|
+
.split(',');
|
|
33
|
+
|
|
34
|
+
const startLoc = Number(startLocStr);
|
|
35
|
+
const numberOfLines = Number(numberOfLinesStr);
|
|
36
|
+
locs.push([startLoc, numberOfLines]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
return locs;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const checkIfDetectedSnowflakesInDiff = (
|
|
43
|
+
diffLocs: DiffLocs,
|
|
44
|
+
locToComment: number
|
|
45
|
+
) => {
|
|
46
|
+
const locIdx = diffLocs.findIndex(([start, numberOfLines]) => {
|
|
47
|
+
return locToComment >= start && locToComment < start + numberOfLines;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
return locIdx !== -1;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export = (app: Probot) => {
|
|
54
|
+
app.on(
|
|
55
|
+
['pull_request.opened', 'pull_request.synchronize'],
|
|
56
|
+
async (context) => {
|
|
57
|
+
// Get PR info
|
|
58
|
+
const prNumber = context.payload.number;
|
|
59
|
+
const repoInfo = {
|
|
60
|
+
repo: context.payload.repository.name,
|
|
61
|
+
owner: context.payload.repository.owner.login,
|
|
62
|
+
};
|
|
63
|
+
const prBranch = context.payload.pull_request.head.ref;
|
|
64
|
+
|
|
65
|
+
// List all changed files
|
|
66
|
+
const prFiles = await context.octokit.pulls.listFiles({
|
|
67
|
+
...repoInfo,
|
|
68
|
+
pull_number: prNumber,
|
|
69
|
+
});
|
|
70
|
+
const tsxFiles = prFiles.data.filter(
|
|
71
|
+
(file) =>
|
|
72
|
+
TSX_REGEX.test(file.filename) &&
|
|
73
|
+
!TEST_REGEX.test(file.filename) &&
|
|
74
|
+
file.status !== 'removed'
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
// Saving file patches to get diff locations
|
|
78
|
+
const prFilePatches = tsxFiles.reduce((acc, file) => {
|
|
79
|
+
acc[file.filename] = file.patch || '';
|
|
80
|
+
return acc;
|
|
81
|
+
}, {} as { [key: string]: string });
|
|
82
|
+
|
|
83
|
+
// Get file contents
|
|
84
|
+
const prFileContentPromises = tsxFiles.map((file) =>
|
|
85
|
+
context.octokit.repos.getContent({
|
|
86
|
+
...repoInfo,
|
|
87
|
+
path: file.filename,
|
|
88
|
+
ref: prBranch,
|
|
89
|
+
})
|
|
90
|
+
);
|
|
91
|
+
const prFileContents = await Promise.all(prFileContentPromises);
|
|
92
|
+
|
|
93
|
+
const styleComments: Comment[] = [];
|
|
94
|
+
const sxComments: Comment[] = [];
|
|
95
|
+
const styledComponentComments: Comment[] = [];
|
|
96
|
+
const classNameComments: Comment[] = [];
|
|
97
|
+
|
|
98
|
+
prFileContents.forEach(async (file) => {
|
|
99
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
100
|
+
// @ts-ignore
|
|
101
|
+
const filePath = file.data.path;
|
|
102
|
+
const diffLocs = getDiffLocs(
|
|
103
|
+
prFilePatches[filePath].match(DIFF_LOCS_REGEX) || []
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const stringContent = Buffer.from(
|
|
107
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
108
|
+
// @ts-ignore
|
|
109
|
+
file.data.content,
|
|
110
|
+
'base64'
|
|
111
|
+
).toString();
|
|
112
|
+
|
|
113
|
+
// Parse file content to check for snowflakes
|
|
114
|
+
const snowflakeReport = parseSource(stringContent);
|
|
115
|
+
|
|
116
|
+
snowflakeReport.styleLocs.forEach((loc) => {
|
|
117
|
+
if (checkIfDetectedSnowflakesInDiff(diffLocs, loc)) {
|
|
118
|
+
styleComments.push({
|
|
119
|
+
path: filePath,
|
|
120
|
+
body: SNOWFLAKE_COMMENTS['style'],
|
|
121
|
+
line: loc,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
snowflakeReport.sxLocs.forEach((loc) => {
|
|
127
|
+
if (checkIfDetectedSnowflakesInDiff(diffLocs, loc)) {
|
|
128
|
+
sxComments.push({
|
|
129
|
+
path: filePath,
|
|
130
|
+
body: SNOWFLAKE_COMMENTS['sx'],
|
|
131
|
+
line: loc,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
snowflakeReport.styledComponentLocs.forEach((loc) => {
|
|
137
|
+
if (checkIfDetectedSnowflakesInDiff(diffLocs, loc)) {
|
|
138
|
+
styledComponentComments.push({
|
|
139
|
+
path: filePath,
|
|
140
|
+
body: SNOWFLAKE_COMMENTS['styled-component'],
|
|
141
|
+
line: loc,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
snowflakeReport.classNameLocs.forEach((loc) => {
|
|
147
|
+
if (checkIfDetectedSnowflakesInDiff(diffLocs, loc)) {
|
|
148
|
+
classNameComments.push({
|
|
149
|
+
path: filePath,
|
|
150
|
+
body: SNOWFLAKE_COMMENTS['className'],
|
|
151
|
+
line: loc,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const personalOctokit = new ProbotOctokit({
|
|
158
|
+
auth: { token: process.env.EH_BOT_GITHUB_TOKEN },
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// No snowflakes detected or only potential snowflakes using classname
|
|
162
|
+
if (
|
|
163
|
+
styleComments.length === 0 &&
|
|
164
|
+
sxComments.length === 0 &&
|
|
165
|
+
styledComponentComments.length === 0
|
|
166
|
+
) {
|
|
167
|
+
const reviewBody =
|
|
168
|
+
classNameComments.length > 0
|
|
169
|
+
? {
|
|
170
|
+
body: '[WARNING] Potential snowflakes detected in this PR using classnames. Please review the following comments.',
|
|
171
|
+
comments: classNameComments,
|
|
172
|
+
}
|
|
173
|
+
: {
|
|
174
|
+
body: 'No snowflakes detected in this PR.',
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
return personalOctokit.pulls.createReview({
|
|
178
|
+
...repoInfo,
|
|
179
|
+
pull_number: prNumber,
|
|
180
|
+
commit_id: context.payload.pull_request.head.sha,
|
|
181
|
+
event: 'APPROVE',
|
|
182
|
+
...reviewBody,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return personalOctokit.pulls.createReview({
|
|
187
|
+
...repoInfo,
|
|
188
|
+
pull_number: prNumber,
|
|
189
|
+
commit_id: context.payload.pull_request.head.sha,
|
|
190
|
+
event: 'REQUEST_CHANGES',
|
|
191
|
+
body: 'Snowflake Guard Bot has detected some snowflakes in this PR. Please review the following comments.',
|
|
192
|
+
comments: [
|
|
193
|
+
...styleComments,
|
|
194
|
+
...sxComments,
|
|
195
|
+
...styledComponentComments,
|
|
196
|
+
...classNameComments,
|
|
197
|
+
],
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
);
|
|
201
|
+
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import * as recast from 'recast';
|
|
2
|
+
import * as tsParser from './parsers/typescript';
|
|
3
|
+
import reportCustomProperties from './reports/reportCustomStyleProperties';
|
|
4
|
+
import reportStyledComponents from './reports/reportStyledComponents';
|
|
5
|
+
import { HD_COMPONENTS, APPROVED_COMMENT } from './reports/constants';
|
|
6
|
+
import type { ComponentName } from './reports/types';
|
|
7
|
+
|
|
8
|
+
const parseSource = (source: string) => {
|
|
9
|
+
let hasHeroDesignImport = false;
|
|
10
|
+
let hasStyledComponentsImport = false;
|
|
11
|
+
|
|
12
|
+
const componentList: { [k: string]: ComponentName } = {};
|
|
13
|
+
let styledAliasName = 'styled';
|
|
14
|
+
|
|
15
|
+
let styledComponentLocs: number[] = [];
|
|
16
|
+
let classNameLocs: number[] = [];
|
|
17
|
+
let styleLocs: number[] = [];
|
|
18
|
+
let sxLocs: number[] = [];
|
|
19
|
+
const approvedCmtLocs: number[] = [];
|
|
20
|
+
|
|
21
|
+
const ast = recast.parse(source, { parser: tsParser });
|
|
22
|
+
|
|
23
|
+
recast.visit(ast, {
|
|
24
|
+
visitImportDeclaration(path) {
|
|
25
|
+
this.traverse(path);
|
|
26
|
+
|
|
27
|
+
const importedFrom = path.value.source.value as string;
|
|
28
|
+
|
|
29
|
+
// Check if file imports components from '@hero-design/react'
|
|
30
|
+
if (importedFrom === '@hero-design/react') {
|
|
31
|
+
recast.visit(path.node, {
|
|
32
|
+
visitImportSpecifier(importPath) {
|
|
33
|
+
this.traverse(importPath);
|
|
34
|
+
|
|
35
|
+
if (HD_COMPONENTS.includes(importPath.value.imported.name)) {
|
|
36
|
+
componentList[importPath.value.local.name] =
|
|
37
|
+
importPath.value.imported.name;
|
|
38
|
+
hasHeroDesignImport = true;
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Check if file imports from 'styled-components'
|
|
45
|
+
if (importedFrom === 'styled-components') {
|
|
46
|
+
recast.visit(path.node, {
|
|
47
|
+
visitImportDefaultSpecifier(importPath) {
|
|
48
|
+
this.traverse(importPath);
|
|
49
|
+
|
|
50
|
+
styledAliasName = importPath.value.local.name;
|
|
51
|
+
hasStyledComponentsImport = true;
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
visitComment(path) {
|
|
58
|
+
this.traverse(path);
|
|
59
|
+
|
|
60
|
+
const comment = path.value.value as string;
|
|
61
|
+
if (comment.toLowerCase().includes(APPROVED_COMMENT.toLowerCase())) {
|
|
62
|
+
approvedCmtLocs.push(path.value.loc.start.line);
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const isNotApprovedSnowflakes = (loc: number) =>
|
|
68
|
+
!approvedCmtLocs.includes(loc);
|
|
69
|
+
|
|
70
|
+
if (hasHeroDesignImport) {
|
|
71
|
+
// Case 1: Using className to customise components
|
|
72
|
+
// Case 2: Using style object to customise components
|
|
73
|
+
// Case 3: Using sx object to customise components
|
|
74
|
+
const customPropLocs = reportCustomProperties(ast, componentList);
|
|
75
|
+
classNameLocs = customPropLocs.className.filter(isNotApprovedSnowflakes);
|
|
76
|
+
styleLocs = customPropLocs.style.filter(isNotApprovedSnowflakes);
|
|
77
|
+
sxLocs = customPropLocs.sx.filter(isNotApprovedSnowflakes);
|
|
78
|
+
|
|
79
|
+
// Case 4: Using styled-components to customise components
|
|
80
|
+
if (hasStyledComponentsImport) {
|
|
81
|
+
styledComponentLocs = reportStyledComponents(
|
|
82
|
+
ast,
|
|
83
|
+
componentList,
|
|
84
|
+
styledAliasName
|
|
85
|
+
).filter(isNotApprovedSnowflakes);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
classNameLocs,
|
|
91
|
+
styleLocs,
|
|
92
|
+
sxLocs,
|
|
93
|
+
styledComponentLocs,
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export default parseSource;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import * as babelParser from '@babel/parser';
|
|
2
|
+
import getBabelOptions, { Overrides } from 'recast/parsers/_babel_options';
|
|
3
|
+
|
|
4
|
+
export const parse = (source: string, options?: Overrides) => {
|
|
5
|
+
const babelOptions = getBabelOptions(options);
|
|
6
|
+
babelOptions.plugins.push('jsx', 'typescript');
|
|
7
|
+
return babelParser.parse(source, babelOptions);
|
|
8
|
+
};
|