Skip to content

Commit 4eb6308

Browse files
devversionandrewseguin
authored andcommitted
build: lint rule to flag forbidden cross-package sass imports
1 parent 88f64c7 commit 4eb6308

File tree

3 files changed

+143
-47
lines changed

3 files changed

+143
-47
lines changed

.stylelintrc.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,11 @@
1414
"./tools/stylelint/theme-mixin-api.ts",
1515
"./tools/stylelint/no-import.ts",
1616
"./tools/stylelint/single-line-comment-only.ts",
17-
"./tools/stylelint/no-unused-import.ts"
17+
"./tools/stylelint/no-unused-import.ts",
18+
"./tools/stylelint/cross-package-import-validation.ts"
1819
],
1920
"rules": {
21+
"material/cross-package-import-validation": [true],
2022
"material/no-prefixes": [
2123
true,
2224
{
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import {createPlugin, utils} from 'stylelint';
2+
import {dirname, relative, join} from 'path';
3+
4+
const ruleName = 'material/cross-package-import-validation';
5+
const messages = utils.ruleMessages(ruleName, {
6+
forbidden: (targetPackage: string, importPath: string) =>
7+
`Relative reference to separate NPM package "${targetPackage}" is not allowed. ` +
8+
`Use a module specifier instead of "${importPath}".`,
9+
noDeepCrossPackageModuleImport: (importSpecifier: string) =>
10+
`Deep cross-package module imports are not allowed ("${importSpecifier}").`,
11+
});
12+
13+
const specifierRegex = /^["']([^"']+)["']/;
14+
const packageNameRegex = /^src\/([^/]+)/;
15+
const projectDir = join(__dirname, '../../');
16+
17+
/** Stylelint plugin that flags forbidden cross-package relative imports. */
18+
const plugin = createPlugin(ruleName, (isEnabled: boolean) => {
19+
return (root, result) => {
20+
if (!isEnabled) {
21+
return;
22+
}
23+
24+
const filePath = root.source.input.file;
25+
26+
// We skip non-theme files and legacy theming Sass files (like `.import.scss`).
27+
if (
28+
filePath.endsWith('.import.scss') ||
29+
filePath.endsWith('-legacy-index.scss') ||
30+
filePath.endsWith('material/_theming.scss')
31+
) {
32+
return;
33+
}
34+
35+
root.walkAtRules(rule => {
36+
if (rule.name === 'use' || rule.name === 'import' || rule.name === 'forward') {
37+
const [_, specifier] = rule.params.match(specifierRegex);
38+
39+
// Ensure no deep imports for cross-package `@angular/` imports.
40+
if (/@angular\/[^/]+\//.test(specifier)) {
41+
utils.report({
42+
result,
43+
ruleName,
44+
message: messages.noDeepCrossPackageModuleImport(specifier),
45+
node: rule,
46+
});
47+
return;
48+
}
49+
50+
// Skip the cross-package relative import check for module imports.
51+
if (!specifier.startsWith('.')) {
52+
return;
53+
}
54+
55+
const currentPath = convertToProjectRelativePosixPath(root.source.input.file);
56+
const targetPath = convertToProjectRelativePosixPath(
57+
join(dirname(root.source.input.file), specifier),
58+
);
59+
60+
const owningFilePackage = currentPath.match(packageNameRegex)[1];
61+
const targetFilePackage = targetPath.match(packageNameRegex)[1];
62+
63+
if (owningFilePackage !== targetFilePackage) {
64+
utils.report({
65+
result,
66+
ruleName,
67+
message: messages.forbidden(targetFilePackage, specifier),
68+
node: rule,
69+
});
70+
}
71+
}
72+
});
73+
};
74+
});
75+
76+
/** Converts the specified absolute path to a project relative POSIX path. */
77+
function convertToProjectRelativePosixPath(fileName: string): string {
78+
return relative(projectDir, fileName).replace(/[/\\]/g, '/');
79+
}
80+
81+
export default plugin;

tools/stylelint/theme-mixin-api.ts

Lines changed: 59 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {createPlugin, utils} from 'stylelint';
22
import {basename} from 'path';
3-
import {AtRule, atRule, decl, Declaration, Node} from 'postcss';
3+
import {AtRule, Declaration, Node} from 'postcss';
44

55
/** Name of this stylelint rule. */
66
const ruleName = 'material/theme-mixin-api';
@@ -67,8 +67,12 @@ const plugin = createPlugin(ruleName, (isEnabled: boolean, _options, context) =>
6767
}
6868

6969
const themePropName = `$theme`;
70-
const legacyColorExtractExpr = `theming.private-legacy-get-theme($theme-or-color-config)`;
71-
const duplicateStylesCheckExpr = `theming.private-check-duplicate-theme-styles(${themePropName}, '${componentName}')`;
70+
const legacyColorExtractExpr = anyPattern(
71+
`<..>.private-legacy-get-theme($theme-or-color-config)`,
72+
);
73+
const duplicateStylesCheckExpr = anyPattern(
74+
`<..>.private-check-duplicate-theme-styles(${themePropName}, '${componentName}')`,
75+
);
7276

7377
let legacyConfigDecl: Declaration | null = null;
7478
let duplicateStylesCheck: AtRule | null = null;
@@ -78,13 +82,13 @@ const plugin = createPlugin(ruleName, (isEnabled: boolean, _options, context) =>
7882
if (node.nodes) {
7983
for (let i = 0; i < node.nodes.length; i++) {
8084
const childNode = node.nodes[i];
81-
if (childNode.type === 'decl' && childNode.value === legacyColorExtractExpr) {
85+
if (childNode.type === 'decl' && legacyColorExtractExpr.test(childNode.value)) {
8286
legacyConfigDecl = childNode;
8387
isLegacyConfigRetrievalFirstStatement = i === 0;
8488
} else if (
8589
childNode.type === 'atrule' &&
8690
childNode.name === 'include' &&
87-
childNode.params === duplicateStylesCheckExpr
91+
duplicateStylesCheckExpr.test(childNode.params)
8892
) {
8993
duplicateStylesCheck = childNode;
9094
} else if (childNode.type !== 'comment') {
@@ -94,18 +98,13 @@ const plugin = createPlugin(ruleName, (isEnabled: boolean, _options, context) =>
9498
}
9599

96100
if (!legacyConfigDecl) {
97-
if (context.fix) {
98-
legacyConfigDecl = decl({prop: themePropName, value: legacyColorExtractExpr});
99-
node.insertBefore(0, legacyConfigDecl);
100-
} else {
101-
reportError(
102-
node,
103-
`Legacy color API is not handled. Consumers could pass in a ` +
104-
`color configuration directly to the theme mixin. For backwards compatibility, ` +
105-
`use the following declaration to retrieve the theme object: ` +
106-
`${themePropName}: ${legacyColorExtractExpr}`,
107-
);
108-
}
101+
reportError(
102+
node,
103+
`Legacy color API is not handled. Consumers could pass in a ` +
104+
`color configuration directly to the theme mixin. For backwards compatibility, ` +
105+
`use the following declaration to retrieve the theme object: ` +
106+
`${themePropName}: ${legacyColorExtractExpr}`,
107+
);
109108
} else if (legacyConfigDecl.prop !== themePropName) {
110109
reportError(
111110
legacyConfigDecl,
@@ -114,16 +113,11 @@ const plugin = createPlugin(ruleName, (isEnabled: boolean, _options, context) =>
114113
}
115114

116115
if (!duplicateStylesCheck) {
117-
if (context.fix) {
118-
duplicateStylesCheck = atRule({name: 'include', params: duplicateStylesCheckExpr});
119-
node.insertBefore(1, duplicateStylesCheck);
120-
} else {
121-
reportError(
122-
node,
123-
`Missing check for duplicative theme styles. Please include the ` +
124-
`duplicate styles check mixin: ${duplicateStylesCheckExpr}`,
125-
);
126-
}
116+
reportError(
117+
node,
118+
`Missing check for duplicative theme styles. Please include the ` +
119+
`duplicate styles check mixin: ${duplicateStylesCheckExpr}`,
120+
);
127121
}
128122

129123
if (hasNodesOutsideDuplicationCheck) {
@@ -157,12 +151,16 @@ const plugin = createPlugin(ruleName, (isEnabled: boolean, _options, context) =>
157151
const expectedValues =
158152
type === 'typography'
159153
? [
160-
'typography.private-typography-to-2014-config(' +
161-
'theming.get-typography-config($config-or-theme))',
162-
'typography.private-typography-to-2018-config(' +
163-
'theming.get-typography-config($config-or-theme))',
154+
anyPattern(
155+
'<..>.private-typography-to-2014-config(' +
156+
'<..>.get-typography-config($config-or-theme))',
157+
),
158+
anyPattern(
159+
'<..>.private-typography-to-2018-config(' +
160+
'<..>.get-typography-config($config-or-theme))',
161+
),
164162
]
165-
: [`theming.get-${type}-config($config-or-theme)`];
163+
: [anyPattern(`<..>.get-${type}-config($config-or-theme)`)];
166164
let configExtractionNode: Declaration | null = null;
167165
let nonCommentNodeCount = 0;
168166

@@ -174,7 +172,7 @@ const plugin = createPlugin(ruleName, (isEnabled: boolean, _options, context) =>
174172

175173
if (
176174
currentNode.type === 'decl' &&
177-
expectedValues.includes(stripNewlinesAndIndentation(currentNode.value))
175+
expectedValues.some(v => v.test(stripNewlinesAndIndentation(currentNode.value)))
178176
) {
179177
configExtractionNode = currentNode;
180178
break;
@@ -183,18 +181,12 @@ const plugin = createPlugin(ruleName, (isEnabled: boolean, _options, context) =>
183181
}
184182

185183
if (!configExtractionNode && nonCommentNodeCount > 0) {
186-
if (context.fix) {
187-
node.insertBefore(0, {prop: expectedProperty, value: expectedValues[0]});
188-
} else {
189-
reportError(
190-
node,
191-
`Config is not extracted. Consumers could pass a theme object. ` +
192-
`Extract the configuration by using one of the following:` +
193-
expectedValues
194-
.map(expectedValue => `${expectedProperty}: ${expectedValue}`)
195-
.join('\n'),
196-
);
197-
}
184+
reportError(
185+
node,
186+
`Config is not extracted. Consumers could pass a theme object. ` +
187+
`Extract the configuration by using one of the following:` +
188+
expectedValues.map(expectedValue => `${expectedProperty}: ${expectedValue}`).join('\n'),
189+
);
198190
} else if (configExtractionNode && configExtractionNode.prop !== expectedProperty) {
199191
reportError(
200192
configExtractionNode,
@@ -206,7 +198,7 @@ const plugin = createPlugin(ruleName, (isEnabled: boolean, _options, context) =>
206198
function reportError(node: Node, message: string) {
207199
// We need these `as any` casts, because Stylelint uses an older version
208200
// of the postcss typings that don't match up with our anymore.
209-
utils.report({result: result as any, ruleName, node: node as any, message});
201+
utils.report({result: result as any, ruleName, node: node, message});
210202
}
211203
};
212204
});
@@ -235,4 +227,25 @@ function stripNewlinesAndIndentation(value: string): string {
235227
return value.replace(/(\r|\n)\s+/g, '');
236228
}
237229

230+
/**
231+
* Template string function that converts a pattern to a regular expression
232+
* that can be used for assertions.
233+
*
234+
* The `<..>` character sequency is a placeholder that will allow for arbitrary
235+
* content.
236+
*/
237+
function anyPattern(pattern): RegExp {
238+
const regex = new RegExp(
239+
`^${sanitizeForRegularExpression(pattern).replace(/<\\.\\.>/g, '.*?')}$`,
240+
);
241+
// Preserve the original expression/pattern for better failure messages.
242+
regex.toString = () => pattern;
243+
return regex;
244+
}
245+
246+
/** Sanitizes a given string so that it can be used as literal in a RegExp. */
247+
function sanitizeForRegularExpression(value: string): string {
248+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
249+
}
250+
238251
export default plugin;

0 commit comments

Comments
 (0)