@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.
Files changed (49) hide show
  1. package/.dockerignore +12 -0
  2. package/.env.example +9 -0
  3. package/.eslintrc.js +8 -0
  4. package/CHANGELOG.md +47 -0
  5. package/Dockerfile +8 -0
  6. package/LICENSE +15 -0
  7. package/README.md +33 -0
  8. package/app.yml +137 -0
  9. package/jest.config.js +9 -0
  10. package/lib/netlify/functions/snowflake.d.ts +2 -0
  11. package/lib/netlify/functions/snowflake.js +10 -0
  12. package/lib/src/__mocks__/sourceSample.d.ts +2 -0
  13. package/lib/src/__mocks__/sourceSample.js +27 -0
  14. package/lib/src/__tests__/parseSource.spec.d.ts +1 -0
  15. package/lib/src/__tests__/parseSource.spec.js +41 -0
  16. package/lib/src/index.d.ts +3 -0
  17. package/lib/src/index.js +142 -0
  18. package/lib/src/parseSource.d.ts +7 -0
  19. package/lib/src/parseSource.js +102 -0
  20. package/lib/src/parsers/typescript.d.ts +3 -0
  21. package/lib/src/parsers/typescript.js +37 -0
  22. package/lib/src/reports/constants.d.ts +215 -0
  23. package/lib/src/reports/constants.js +848 -0
  24. package/lib/src/reports/reportClassName.d.ts +3 -0
  25. package/lib/src/reports/reportClassName.js +15 -0
  26. package/lib/src/reports/reportCustomStyleProperties.d.ts +10 -0
  27. package/lib/src/reports/reportCustomStyleProperties.js +109 -0
  28. package/lib/src/reports/reportInlineStyle.d.ts +7 -0
  29. package/lib/src/reports/reportInlineStyle.js +179 -0
  30. package/lib/src/reports/reportStyledComponents.d.ts +6 -0
  31. package/lib/src/reports/reportStyledComponents.js +95 -0
  32. package/lib/src/reports/types.d.ts +3 -0
  33. package/lib/src/reports/types.js +2 -0
  34. package/lib/src/test.tsx +123 -0
  35. package/netlify/functions/snowflake.ts +9 -0
  36. package/netlify.toml +21 -0
  37. package/package.json +44 -0
  38. package/src/__mocks__/sourceSample.tsx +67 -0
  39. package/src/__tests__/parseSource.spec.ts +15 -0
  40. package/src/index.ts +201 -0
  41. package/src/parseSource.ts +97 -0
  42. package/src/parsers/typescript.ts +8 -0
  43. package/src/reports/constants.ts +965 -0
  44. package/src/reports/reportClassName.ts +20 -0
  45. package/src/reports/reportCustomStyleProperties.ts +125 -0
  46. package/src/reports/reportInlineStyle.ts +221 -0
  47. package/src/reports/reportStyledComponents.ts +109 -0
  48. package/src/reports/types.ts +5 -0
  49. 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
+ };