Skip to content

Commit 4a08eb0

Browse files
authored
[EngSys] Add "if" property to suppressions (Azure#35605)
- Fixes Azure#35153
1 parent 16e11a3 commit 4a08eb0

7 files changed

Lines changed: 324 additions & 122 deletions

File tree

eng/scripts/TypeSpec-Validation.ps1

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ if ($TotalShards -gt 0 -and $Shard -ge $TotalShards) {
1818
. $PSScriptRoot/Suppressions-Functions.ps1
1919
. $PSScriptRoot/Array-Functions.ps1
2020

21-
$typespecFolders, $checkedAll = &"$PSScriptRoot/Get-TypeSpec-Folders.ps1" `
21+
$typespecFolders, $checkingAllSpecs = &"$PSScriptRoot/Get-TypeSpec-Folders.ps1" `
2222
-BaseCommitish:$BaseCommitish `
2323
-HeadCommitish:$HeadCommitish `
2424
-CheckAll:$CheckAll `
@@ -35,11 +35,11 @@ foreach ($typespecFolder in $typespecFolders) {
3535

3636
$typespecFoldersWithFailures = @()
3737
if ($typespecFolders) {
38-
$typespecFolders = $typespecFolders.Split('',[System.StringSplitOptions]::RemoveEmptyEntries)
38+
$typespecFolders = $typespecFolders.Split('', [System.StringSplitOptions]::RemoveEmptyEntries)
3939
foreach ($typespecFolder in $typespecFolders) {
4040
LogGroupStart "Validating $typespecFolder"
4141

42-
if ($checkedAll) {
42+
if ($checkingAllSpecs) {
4343
$suppression = Get-Suppression "TypeSpecValidationAll" $typespecFolder
4444
if ($suppression) {
4545
$reason = $suppression["reason"] ?? "<no reason specified>"
@@ -49,14 +49,17 @@ if ($typespecFolders) {
4949
}
5050
}
5151

52-
LogInfo "npm exec --no -- tsv $typespecFolder"
52+
# Example: '{"checkingAllSpecs"=true}'
53+
$context = @{ checkingAllSpecs = $checkingAllSpecs } | ConvertTo-Json -Compress
54+
55+
LogInfo "npm exec --no -- tsv $typespecFolder ""$context"""
5356

5457
if ($DryRun) {
5558
LogGroupEnd
5659
continue
5760
}
5861

59-
npm exec --no -- tsv $typespecFolder 2>&1 | Write-Host
62+
npm exec --no -- tsv $typespecFolder "$context" 2>&1 | Write-Host
6063
if ($LASTEXITCODE) {
6164
$typespecFoldersWithFailures += $typespecFolder
6265
$errorString = "TypeSpec Validation failed for project $typespecFolder run the following command locally to validate."
@@ -71,7 +74,8 @@ if ($typespecFolders) {
7174
}
7275
LogGroupEnd
7376
}
74-
} else {
77+
}
78+
else {
7579
if ($CheckAll) {
7680
LogError "TypeSpec Validation - All did not validate any specs"
7781
LogJobFailure

eng/tools/suppressions/src/suppressions.ts

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
import { Stats } from "fs";
22
import { access, constants, lstat, readFile } from "fs/promises";
33
import { minimatch } from "minimatch";
4+
import { createRequire } from "module";
45
import { dirname, join, resolve, sep } from "path";
56
import { sep as posixSep } from "path/posix";
7+
import vm from "vm";
68
import { parse as yamlParse } from "yaml";
79
import { z } from "zod";
810
import { fromError } from "zod-validation-error";
911

1012
export interface Suppression {
1113
tool: string;
14+
// String of JavaScript CJS code, executed in a prepared context, that determines if a suppression should be included
15+
if?: string;
1216
// Output only exposes "paths". For input, if "path" is defined, it is inserted at the start of "paths".
1317
paths: string[];
1418
rules?: string[];
@@ -20,6 +24,7 @@ const suppressionSchema = z.array(
2024
z
2125
.object({
2226
tool: z.string(),
27+
if: z.string().optional(),
2328
// For now, input allows "path" alongside "paths". Lather, may deprecate "path".
2429
path: z.string().optional(),
2530
paths: z.array(z.string()).optional(),
@@ -39,6 +44,7 @@ const suppressionSchema = z.array(
3944
}
4045
return {
4146
tool: s.tool,
47+
if: s.if,
4248
paths: paths,
4349
rules: s.rules,
4450
subRules: s["sub-rules"],
@@ -70,7 +76,11 @@ const suppressionSchema = z.array(
7076
* );
7177
* ```
7278
*/
73-
export async function getSuppressions(tool: string, path: string): Promise<Suppression[]> {
79+
export async function getSuppressions(
80+
tool: string,
81+
path: string,
82+
context: Record<string, any> = {},
83+
): Promise<Suppression[]> {
7484
path = resolve(path);
7585

7686
// If path doesn't exist, throw instead of returning "[]" to prevent confusion
@@ -86,6 +96,7 @@ export async function getSuppressions(tool: string, path: string): Promise<Suppr
8696
path,
8797
suppressionsFile,
8898
await readFile(suppressionsFile, { encoding: "utf8" }),
99+
context,
89100
),
90101
);
91102
}
@@ -125,6 +136,7 @@ export function getSuppressionsFromYaml(
125136
path: string,
126137
suppressionsFile: string,
127138
suppressionsYaml: string,
139+
context: Record<string, any> = {},
128140
): Suppression[] {
129141
path = resolve(path);
130142
suppressionsFile = resolve(suppressionsFile);
@@ -140,19 +152,28 @@ export function getSuppressionsFromYaml(
140152
throw fromError(err);
141153
}
142154

143-
return suppressions
144-
.filter((s) => s.tool === tool)
145-
.filter((s) => {
146-
// Minimatch only allows forward-slashes in patterns and input
147-
const pathPosix: string = path.split(sep).join(posixSep);
148-
149-
return s.paths.some((suppressionPath) => {
150-
const pattern: string = join(dirname(suppressionsFile), suppressionPath)
151-
.split(sep)
152-
.join(posixSep);
153-
return minimatch(pathPosix, pattern);
154-
});
155-
});
155+
// Make "require" available inside sandbox for CJS imports
156+
const sandbox = { ...context, require: createRequire(import.meta.url) };
157+
158+
return (
159+
suppressions
160+
// Tool name
161+
.filter((s) => s.tool === tool)
162+
// Path
163+
.filter((s) => {
164+
// Minimatch only allows forward-slashes in patterns and input
165+
const pathPosix: string = path.split(sep).join(posixSep);
166+
167+
return s.paths.some((suppressionPath) => {
168+
const pattern: string = join(dirname(suppressionsFile), suppressionPath)
169+
.split(sep)
170+
.join(posixSep);
171+
return minimatch(pathPosix, pattern);
172+
});
173+
})
174+
// If
175+
.filter((s) => s.if === undefined || vm.runInNewContext(s.if, sandbox))
176+
);
156177
}
157178

158179
/**

eng/tools/suppressions/test/suppressions.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,10 +268,77 @@ test("suppression with rules", () => {
268268
expect(suppressions).toStrictEqual([
269269
{
270270
tool: "TestTool",
271+
if: undefined,
271272
paths: ["foo"],
272273
rules: ["my-rule"],
273274
subRules: ["my.option.a", "my.option.b"],
274275
reason: "test",
275276
},
276277
]);
277278
});
279+
280+
test.each([
281+
{ context: { foo: false, bar: false }, expected: ["no-if", "process-version"] },
282+
{
283+
context: { foo: true, bar: false },
284+
expected: ["no-if", "if-foo", "if-foo-or-bar", "process-version"],
285+
},
286+
{
287+
context: { foo: false, bar: true },
288+
expected: ["no-if", "if-bar", "if-foo-or-bar", "process-version"],
289+
},
290+
{
291+
context: { foo: true, bar: true },
292+
expected: ["no-if", "if-foo", "if-bar", "if-foo-or-bar", "if-foo-and-bar", "process-version"],
293+
},
294+
])("if($context)", ({ context, expected }) => {
295+
const suppressionYaml = `
296+
- tool: TestTool
297+
path: "**"
298+
reason: no-if
299+
- tool: TestTool
300+
path: "**"
301+
if: foo
302+
reason: if-foo
303+
- tool: TestTool
304+
path: "**"
305+
if: bar
306+
reason: if-bar
307+
- tool: TestTool
308+
path: "**"
309+
if: foo || bar
310+
reason: if-foo-or-bar
311+
- tool: TestTool
312+
path: "**"
313+
if: foo && bar
314+
reason: if-foo-and-bar
315+
- tool: TestTool
316+
path: "**"
317+
if: require("process").version.startsWith("v")
318+
reason: process-version
319+
`;
320+
321+
let suppressions: Suppression[] = getSuppressionsFromYaml(
322+
"TestTool",
323+
"test-path",
324+
"suppressions.yaml",
325+
suppressionYaml,
326+
context,
327+
);
328+
329+
expect(suppressions.map((s) => s.reason).sort()).toEqual(expected.sort());
330+
});
331+
332+
test.each([
333+
["invalid javascript", "Unexpected identifier 'javascript'"],
334+
["1(1)", "1 is not a function"],
335+
])("if: %s", (ifExpression, expectedException) => {
336+
expect(() =>
337+
getSuppressionsFromYaml(
338+
"TestTool",
339+
"test-path",
340+
"suppressions.yaml",
341+
`- tool: TestTool\n if: "${ifExpression}"\n path: "**"\n reason: test`,
342+
),
343+
).throws(expectedException);
344+
});
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { configDefaults, defineConfig } from "vitest/config";
2+
3+
export default defineConfig({
4+
test: {
5+
coverage: {
6+
exclude: [...configDefaults.coverage.exclude!, "cmd/**", "src/index.ts"],
7+
},
8+
},
9+
});

eng/tools/typespec-validation/src/index.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { ParseArgsConfig, parseArgs } from "node:util";
21
import { stat } from "node:fs/promises";
2+
import { ParseArgsConfig, parseArgs } from "node:util";
33
import { Suppression } from "suppressions";
44
import { CompileRule } from "./rules/compile.js";
55
import { EmitAutorestRule } from "./rules/emit-autorest.js";
@@ -11,16 +11,28 @@ import { NpmPrefixRule } from "./rules/npm-prefix.js";
1111
import { SdkTspConfigValidationRule } from "./rules/sdk-tspconfig-validation.js";
1212
import { fileExists, getSuppressions, normalizePath } from "./utils.js";
1313

14+
// Context argument may add new properties or override checkingAllSpecs
15+
export var context: Record<string, any> = { checkingAllSpecs: false };
16+
1417
export async function main() {
1518
const args = process.argv.slice(2);
1619
const options = {
1720
folder: {
1821
type: "string",
1922
short: "f",
2023
},
24+
context: {
25+
type: "string",
26+
short: "c",
27+
},
2128
};
2229
const parsedArgs = parseArgs({ args, options, allowPositionals: true } as ParseArgsConfig);
2330
const folder = parsedArgs.positionals[0];
31+
32+
if (parsedArgs.positionals[1]) {
33+
context = { ...context, ...JSON.parse(parsedArgs.positionals[1]) };
34+
}
35+
2436
const absolutePath = normalizePath(folder);
2537

2638
if (!(await fileExists(absolutePath))) {

eng/tools/typespec-validation/src/utils.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { access, readFile } from "fs/promises";
55
import defaultPath, { join, PlatformPath } from "path";
66
import { simpleGit } from "simple-git";
77
import { getSuppressions as getSuppressionsImpl, Suppression } from "suppressions";
8+
import { context } from "./index.js";
89

910
// Enable simple-git debug logging to improve console output
1011
debug.enable("simple-git");
@@ -41,7 +42,7 @@ export async function readTspConfig(folder: string) {
4142
}
4243

4344
export async function getSuppressions(path: string): Promise<Suppression[]> {
44-
return getSuppressionsImpl("TypeSpecValidation", path);
45+
return getSuppressionsImpl("TypeSpecValidation", path, context);
4546
}
4647

4748
export function normalizePath(folder: string, path: PlatformPath = defaultPath) {

0 commit comments

Comments
 (0)