@intentius/chant-lexicon-github 0.0.18
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/dist/integrity.json +31 -0
- package/dist/manifest.json +15 -0
- package/dist/meta.json +135 -0
- package/dist/rules/deprecated-action-version.ts +49 -0
- package/dist/rules/detect-secrets.ts +53 -0
- package/dist/rules/extract-inline-structs.ts +62 -0
- package/dist/rules/file-job-limit.ts +49 -0
- package/dist/rules/gha006.ts +58 -0
- package/dist/rules/gha009.ts +42 -0
- package/dist/rules/gha011.ts +40 -0
- package/dist/rules/gha017.ts +32 -0
- package/dist/rules/gha018.ts +40 -0
- package/dist/rules/gha019.ts +72 -0
- package/dist/rules/job-timeout.ts +59 -0
- package/dist/rules/missing-recommended-inputs.ts +61 -0
- package/dist/rules/no-hardcoded-secrets.ts +46 -0
- package/dist/rules/no-raw-expressions.ts +51 -0
- package/dist/rules/suggest-cache.ts +71 -0
- package/dist/rules/use-condition-builders.ts +45 -0
- package/dist/rules/use-matrix-builder.ts +44 -0
- package/dist/rules/use-typed-actions.ts +47 -0
- package/dist/rules/validate-concurrency.ts +66 -0
- package/dist/rules/yaml-helpers.ts +129 -0
- package/dist/skills/chant-github.md +29 -0
- package/dist/skills/github-actions-patterns.md +93 -0
- package/dist/types/index.d.ts +358 -0
- package/package.json +33 -0
- package/src/codegen/docs-cli.ts +3 -0
- package/src/codegen/docs.ts +1138 -0
- package/src/codegen/generate-cli.ts +36 -0
- package/src/codegen/generate-lexicon.ts +58 -0
- package/src/codegen/generate-typescript.ts +149 -0
- package/src/codegen/generate.ts +141 -0
- package/src/codegen/naming.ts +57 -0
- package/src/codegen/package.ts +65 -0
- package/src/codegen/parse.ts +700 -0
- package/src/codegen/patches.ts +46 -0
- package/src/composites/cache.ts +25 -0
- package/src/composites/checkout.ts +31 -0
- package/src/composites/composites.test.ts +675 -0
- package/src/composites/deploy-environment.ts +77 -0
- package/src/composites/docker-build.ts +120 -0
- package/src/composites/download-artifact.ts +24 -0
- package/src/composites/go-ci.ts +91 -0
- package/src/composites/index.ts +26 -0
- package/src/composites/node-ci.ts +71 -0
- package/src/composites/node-pipeline.ts +151 -0
- package/src/composites/python-ci.ts +92 -0
- package/src/composites/setup-go.ts +24 -0
- package/src/composites/setup-node.ts +26 -0
- package/src/composites/setup-python.ts +24 -0
- package/src/composites/upload-artifact.ts +27 -0
- package/src/coverage.ts +49 -0
- package/src/expression.test.ts +147 -0
- package/src/expression.ts +214 -0
- package/src/generated/index.d.ts +358 -0
- package/src/generated/index.ts +29 -0
- package/src/generated/lexicon-github.json +135 -0
- package/src/generated/runtime.ts +4 -0
- package/src/import/generator.test.ts +110 -0
- package/src/import/generator.ts +119 -0
- package/src/import/parser.test.ts +98 -0
- package/src/import/parser.ts +73 -0
- package/src/index.ts +53 -0
- package/src/lint/post-synth/gha006.ts +58 -0
- package/src/lint/post-synth/gha009.ts +42 -0
- package/src/lint/post-synth/gha011.ts +40 -0
- package/src/lint/post-synth/gha017.ts +32 -0
- package/src/lint/post-synth/gha018.ts +40 -0
- package/src/lint/post-synth/gha019.ts +72 -0
- package/src/lint/post-synth/post-synth.test.ts +318 -0
- package/src/lint/post-synth/yaml-helpers.ts +129 -0
- package/src/lint/rules/data/deprecated-versions.ts +13 -0
- package/src/lint/rules/data/known-actions.ts +13 -0
- package/src/lint/rules/data/recommended-inputs.ts +10 -0
- package/src/lint/rules/data/secret-patterns.ts +31 -0
- package/src/lint/rules/deprecated-action-version.ts +49 -0
- package/src/lint/rules/detect-secrets.ts +53 -0
- package/src/lint/rules/extract-inline-structs.ts +62 -0
- package/src/lint/rules/file-job-limit.ts +49 -0
- package/src/lint/rules/index.ts +17 -0
- package/src/lint/rules/job-timeout.ts +59 -0
- package/src/lint/rules/missing-recommended-inputs.ts +61 -0
- package/src/lint/rules/no-hardcoded-secrets.ts +46 -0
- package/src/lint/rules/no-raw-expressions.ts +51 -0
- package/src/lint/rules/rules.test.ts +365 -0
- package/src/lint/rules/suggest-cache.ts +71 -0
- package/src/lint/rules/use-condition-builders.ts +45 -0
- package/src/lint/rules/use-matrix-builder.ts +44 -0
- package/src/lint/rules/use-typed-actions.ts +47 -0
- package/src/lint/rules/validate-concurrency.ts +66 -0
- package/src/lsp/completions.test.ts +9 -0
- package/src/lsp/completions.ts +20 -0
- package/src/lsp/hover.test.ts +9 -0
- package/src/lsp/hover.ts +38 -0
- package/src/package-cli.ts +42 -0
- package/src/plugin.test.ts +128 -0
- package/src/plugin.ts +408 -0
- package/src/serializer.test.ts +270 -0
- package/src/serializer.ts +383 -0
- package/src/skills/github-actions-patterns.md +93 -0
- package/src/spec/fetch.ts +55 -0
- package/src/validate-cli.ts +19 -0
- package/src/validate.test.ts +12 -0
- package/src/validate.ts +32 -0
- package/src/variables.ts +44 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA007: File Job Limit
|
|
3
|
+
*
|
|
4
|
+
* Flags files with more than 10 Job/ReusableWorkflowCallJob constructors.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { LintRule, LintDiagnostic, LintContext } from "@intentius/chant/lint/rule";
|
|
8
|
+
import * as ts from "typescript";
|
|
9
|
+
|
|
10
|
+
const JOB_NAMES = new Set(["Job", "ReusableWorkflowCallJob"]);
|
|
11
|
+
const MAX_JOBS = 10;
|
|
12
|
+
|
|
13
|
+
export const fileJobLimitRule: LintRule = {
|
|
14
|
+
id: "GHA007",
|
|
15
|
+
severity: "warning",
|
|
16
|
+
category: "style",
|
|
17
|
+
description: "Too many jobs in a single file",
|
|
18
|
+
|
|
19
|
+
check(context: LintContext): LintDiagnostic[] {
|
|
20
|
+
const { sourceFile } = context;
|
|
21
|
+
const diagnostics: LintDiagnostic[] = [];
|
|
22
|
+
let jobCount = 0;
|
|
23
|
+
|
|
24
|
+
function visit(node: ts.Node): void {
|
|
25
|
+
if (ts.isNewExpression(node)) {
|
|
26
|
+
let isJob = false;
|
|
27
|
+
if (ts.isIdentifier(node.expression)) isJob = JOB_NAMES.has(node.expression.text);
|
|
28
|
+
if (ts.isPropertyAccessExpression(node.expression)) isJob = JOB_NAMES.has(node.expression.name.text);
|
|
29
|
+
if (isJob) jobCount++;
|
|
30
|
+
}
|
|
31
|
+
ts.forEachChild(node, visit);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
visit(sourceFile);
|
|
35
|
+
|
|
36
|
+
if (jobCount > MAX_JOBS) {
|
|
37
|
+
diagnostics.push({
|
|
38
|
+
file: sourceFile.fileName,
|
|
39
|
+
line: 1,
|
|
40
|
+
column: 1,
|
|
41
|
+
ruleId: "GHA007",
|
|
42
|
+
severity: "warning",
|
|
43
|
+
message: `File has ${jobCount} job constructors (limit: ${MAX_JOBS}). Consider splitting into multiple files.`,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return diagnostics;
|
|
48
|
+
},
|
|
49
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub Actions lint rules
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export { useTypedActionsRule } from "./use-typed-actions";
|
|
6
|
+
export { useConditionBuildersRule } from "./use-condition-builders";
|
|
7
|
+
export { noHardcodedSecretsRule } from "./no-hardcoded-secrets";
|
|
8
|
+
export { useMatrixBuilderRule } from "./use-matrix-builder";
|
|
9
|
+
export { extractInlineStructsRule } from "./extract-inline-structs";
|
|
10
|
+
export { fileJobLimitRule } from "./file-job-limit";
|
|
11
|
+
export { noRawExpressionsRule } from "./no-raw-expressions";
|
|
12
|
+
export { missingRecommendedInputsRule } from "./missing-recommended-inputs";
|
|
13
|
+
export { deprecatedActionVersionRule } from "./deprecated-action-version";
|
|
14
|
+
export { jobTimeoutRule } from "./job-timeout";
|
|
15
|
+
export { suggestCacheRule } from "./suggest-cache";
|
|
16
|
+
export { validateConcurrencyRule } from "./validate-concurrency";
|
|
17
|
+
export { detectSecretsRule } from "./detect-secrets";
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA014: Job Timeout
|
|
3
|
+
*
|
|
4
|
+
* Flags `new Job({...})` without `timeoutMinutes` property.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { LintRule, LintDiagnostic, LintContext } from "@intentius/chant/lint/rule";
|
|
8
|
+
import * as ts from "typescript";
|
|
9
|
+
|
|
10
|
+
export const jobTimeoutRule: LintRule = {
|
|
11
|
+
id: "GHA014",
|
|
12
|
+
severity: "warning",
|
|
13
|
+
category: "correctness",
|
|
14
|
+
description: "Job should specify a timeout-minutes value",
|
|
15
|
+
|
|
16
|
+
check(context: LintContext): LintDiagnostic[] {
|
|
17
|
+
const { sourceFile } = context;
|
|
18
|
+
const diagnostics: LintDiagnostic[] = [];
|
|
19
|
+
|
|
20
|
+
function visit(node: ts.Node): void {
|
|
21
|
+
if (ts.isNewExpression(node)) {
|
|
22
|
+
let isJob = false;
|
|
23
|
+
if (ts.isIdentifier(node.expression) && node.expression.text === "Job") isJob = true;
|
|
24
|
+
if (ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === "Job") isJob = true;
|
|
25
|
+
|
|
26
|
+
if (isJob && node.arguments?.length) {
|
|
27
|
+
const arg = node.arguments[0];
|
|
28
|
+
if (ts.isObjectLiteralExpression(arg)) {
|
|
29
|
+
const hasTimeout = arg.properties.some((prop) => {
|
|
30
|
+
if (ts.isPropertyAssignment(prop)) {
|
|
31
|
+
const name = ts.isIdentifier(prop.name) ? prop.name.text
|
|
32
|
+
: ts.isStringLiteral(prop.name) ? prop.name.text
|
|
33
|
+
: "";
|
|
34
|
+
return name === "timeoutMinutes" || name === "timeout-minutes";
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
if (!hasTimeout) {
|
|
40
|
+
const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart());
|
|
41
|
+
diagnostics.push({
|
|
42
|
+
file: sourceFile.fileName,
|
|
43
|
+
line: line + 1,
|
|
44
|
+
column: character + 1,
|
|
45
|
+
ruleId: "GHA014",
|
|
46
|
+
severity: "warning",
|
|
47
|
+
message: "Job should specify timeoutMinutes. Default is 360 (6 hours), which may be excessive.",
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
ts.forEachChild(node, visit);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
visit(sourceFile);
|
|
57
|
+
return diagnostics;
|
|
58
|
+
},
|
|
59
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA010: Missing Recommended Inputs
|
|
3
|
+
*
|
|
4
|
+
* Flags setup action composites without version-related inputs.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { LintRule, LintDiagnostic, LintContext } from "@intentius/chant/lint/rule";
|
|
8
|
+
import * as ts from "typescript";
|
|
9
|
+
import { recommendedInputs } from "./data/recommended-inputs";
|
|
10
|
+
|
|
11
|
+
const SETUP_ACTIONS = new Set(Object.keys(recommendedInputs));
|
|
12
|
+
|
|
13
|
+
export const missingRecommendedInputsRule: LintRule = {
|
|
14
|
+
id: "GHA010",
|
|
15
|
+
severity: "warning",
|
|
16
|
+
category: "correctness",
|
|
17
|
+
description: "Setup action composite should specify a version input",
|
|
18
|
+
|
|
19
|
+
check(context: LintContext): LintDiagnostic[] {
|
|
20
|
+
const { sourceFile } = context;
|
|
21
|
+
const diagnostics: LintDiagnostic[] = [];
|
|
22
|
+
|
|
23
|
+
function visit(node: ts.Node): void {
|
|
24
|
+
// Match: SetupNode({...}), SetupGo({...}), SetupPython({...})
|
|
25
|
+
if (ts.isCallExpression(node)) {
|
|
26
|
+
let actionName: string | null = null;
|
|
27
|
+
|
|
28
|
+
if (ts.isIdentifier(node.expression) && SETUP_ACTIONS.has(node.expression.text)) {
|
|
29
|
+
actionName = node.expression.text;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (actionName && node.arguments.length > 0) {
|
|
33
|
+
const arg = node.arguments[0];
|
|
34
|
+
if (ts.isObjectLiteralExpression(arg)) {
|
|
35
|
+
const required = recommendedInputs[actionName] ?? [];
|
|
36
|
+
const propNames = arg.properties
|
|
37
|
+
.filter(ts.isPropertyAssignment)
|
|
38
|
+
.map((p) => (ts.isIdentifier(p.name) ? p.name.text : ""));
|
|
39
|
+
|
|
40
|
+
const hasAny = required.some((r) => propNames.includes(r));
|
|
41
|
+
if (!hasAny) {
|
|
42
|
+
const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart());
|
|
43
|
+
diagnostics.push({
|
|
44
|
+
file: sourceFile.fileName,
|
|
45
|
+
line: line + 1,
|
|
46
|
+
column: character + 1,
|
|
47
|
+
ruleId: "GHA010",
|
|
48
|
+
severity: "warning",
|
|
49
|
+
message: `${actionName}() should specify a version input (${required.join(" or ")}).`,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
ts.forEachChild(node, visit);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
visit(sourceFile);
|
|
59
|
+
return diagnostics;
|
|
60
|
+
},
|
|
61
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA003: No Hardcoded Secrets
|
|
3
|
+
*
|
|
4
|
+
* Flags string literals matching GitHub token prefixes.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { LintRule, LintDiagnostic, LintContext } from "@intentius/chant/lint/rule";
|
|
8
|
+
import * as ts from "typescript";
|
|
9
|
+
|
|
10
|
+
const TOKEN_PREFIXES = ["ghp_", "ghs_", "ghu_", "ghr_", "gho_", "github_pat_"];
|
|
11
|
+
|
|
12
|
+
export const noHardcodedSecretsRule: LintRule = {
|
|
13
|
+
id: "GHA003",
|
|
14
|
+
severity: "error",
|
|
15
|
+
category: "security",
|
|
16
|
+
description: "No hardcoded GitHub tokens in source code",
|
|
17
|
+
|
|
18
|
+
check(context: LintContext): LintDiagnostic[] {
|
|
19
|
+
const { sourceFile } = context;
|
|
20
|
+
const diagnostics: LintDiagnostic[] = [];
|
|
21
|
+
|
|
22
|
+
function visit(node: ts.Node): void {
|
|
23
|
+
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
|
|
24
|
+
const text = node.text;
|
|
25
|
+
for (const prefix of TOKEN_PREFIXES) {
|
|
26
|
+
if (text.includes(prefix)) {
|
|
27
|
+
const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart());
|
|
28
|
+
diagnostics.push({
|
|
29
|
+
file: sourceFile.fileName,
|
|
30
|
+
line: line + 1,
|
|
31
|
+
column: character + 1,
|
|
32
|
+
ruleId: "GHA003",
|
|
33
|
+
severity: "error",
|
|
34
|
+
message: `Hardcoded GitHub token detected (prefix: ${prefix}). Use secrets() instead.`,
|
|
35
|
+
});
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
ts.forEachChild(node, visit);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
visit(sourceFile);
|
|
44
|
+
return diagnostics;
|
|
45
|
+
},
|
|
46
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA008: No Raw Expressions
|
|
3
|
+
*
|
|
4
|
+
* Flags `${{` strings outside valid context paths.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { LintRule, LintDiagnostic, LintContext } from "@intentius/chant/lint/rule";
|
|
8
|
+
import * as ts from "typescript";
|
|
9
|
+
|
|
10
|
+
const VALID_CONTEXTS = ["github.", "secrets.", "matrix.", "steps.", "needs.", "inputs.", "env.", "vars.", "runner.", "always()", "failure()", "success()", "cancelled()", "contains(", "startsWith(", "endsWith(", "format(", "join(", "toJSON(", "fromJSON(", "hashFiles("];
|
|
11
|
+
|
|
12
|
+
export const noRawExpressionsRule: LintRule = {
|
|
13
|
+
id: "GHA008",
|
|
14
|
+
severity: "info",
|
|
15
|
+
category: "style",
|
|
16
|
+
description: "Avoid raw ${{ }} expression strings — use typed Expression helpers",
|
|
17
|
+
|
|
18
|
+
check(context: LintContext): LintDiagnostic[] {
|
|
19
|
+
const { sourceFile } = context;
|
|
20
|
+
const diagnostics: LintDiagnostic[] = [];
|
|
21
|
+
|
|
22
|
+
function visit(node: ts.Node): void {
|
|
23
|
+
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
|
|
24
|
+
const text = node.text;
|
|
25
|
+
if (text.includes("${{")) {
|
|
26
|
+
// Extract the expression content
|
|
27
|
+
const match = text.match(/\$\{\{\s*(.+?)\s*\}\}/);
|
|
28
|
+
if (match) {
|
|
29
|
+
const expr = match[1];
|
|
30
|
+
const isValid = VALID_CONTEXTS.some((ctx) => expr.startsWith(ctx));
|
|
31
|
+
if (!isValid) {
|
|
32
|
+
const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart());
|
|
33
|
+
diagnostics.push({
|
|
34
|
+
file: sourceFile.fileName,
|
|
35
|
+
line: line + 1,
|
|
36
|
+
column: character + 1,
|
|
37
|
+
ruleId: "GHA008",
|
|
38
|
+
severity: "info",
|
|
39
|
+
message: `Raw expression "$\{{ ${expr} }}" doesn't match a known context. Use typed Expression helpers.`,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
ts.forEachChild(node, visit);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
visit(sourceFile);
|
|
49
|
+
return diagnostics;
|
|
50
|
+
},
|
|
51
|
+
};
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import * as ts from "typescript";
|
|
3
|
+
import type { LintContext } from "@intentius/chant/lint/rule";
|
|
4
|
+
import { useTypedActionsRule } from "./use-typed-actions";
|
|
5
|
+
import { useConditionBuildersRule } from "./use-condition-builders";
|
|
6
|
+
import { noHardcodedSecretsRule } from "./no-hardcoded-secrets";
|
|
7
|
+
import { useMatrixBuilderRule } from "./use-matrix-builder";
|
|
8
|
+
import { extractInlineStructsRule } from "./extract-inline-structs";
|
|
9
|
+
import { fileJobLimitRule } from "./file-job-limit";
|
|
10
|
+
import { noRawExpressionsRule } from "./no-raw-expressions";
|
|
11
|
+
import { missingRecommendedInputsRule } from "./missing-recommended-inputs";
|
|
12
|
+
import { deprecatedActionVersionRule } from "./deprecated-action-version";
|
|
13
|
+
import { jobTimeoutRule } from "./job-timeout";
|
|
14
|
+
import { suggestCacheRule } from "./suggest-cache";
|
|
15
|
+
import { validateConcurrencyRule } from "./validate-concurrency";
|
|
16
|
+
import { detectSecretsRule } from "./detect-secrets";
|
|
17
|
+
|
|
18
|
+
function createContext(code: string, fileName = "test.ts"): LintContext {
|
|
19
|
+
const sourceFile = ts.createSourceFile(fileName, code, ts.ScriptTarget.Latest, true);
|
|
20
|
+
return { sourceFile, entities: [], filePath: fileName };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ── GHA001: use-typed-actions ───────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
describe("GHA001: use-typed-actions", () => {
|
|
26
|
+
test("flags raw uses: string for known action", () => {
|
|
27
|
+
const ctx = createContext(`const s = new Step({ uses: "actions/checkout@v4" });`);
|
|
28
|
+
const diags = useTypedActionsRule.check(ctx);
|
|
29
|
+
expect(diags).toHaveLength(1);
|
|
30
|
+
expect(diags[0].ruleId).toBe("GHA001");
|
|
31
|
+
expect(diags[0].severity).toBe("warning");
|
|
32
|
+
expect(diags[0].message).toContain("Checkout");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("flags actions/setup-node", () => {
|
|
36
|
+
const ctx = createContext(`const s = new Step({ uses: "actions/setup-node@v4" });`);
|
|
37
|
+
const diags = useTypedActionsRule.check(ctx);
|
|
38
|
+
expect(diags).toHaveLength(1);
|
|
39
|
+
expect(diags[0].message).toContain("SetupNode");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("does not flag unknown action", () => {
|
|
43
|
+
const ctx = createContext(`const s = new Step({ uses: "custom/action@v1" });`);
|
|
44
|
+
const diags = useTypedActionsRule.check(ctx);
|
|
45
|
+
expect(diags).toHaveLength(0);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("does not flag non-uses property", () => {
|
|
49
|
+
const ctx = createContext(`const s = new Step({ run: "npm test" });`);
|
|
50
|
+
const diags = useTypedActionsRule.check(ctx);
|
|
51
|
+
expect(diags).toHaveLength(0);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// ── GHA002: use-condition-builders ──────────────────────────────────
|
|
56
|
+
|
|
57
|
+
describe("GHA002: use-condition-builders", () => {
|
|
58
|
+
test("flags ${{ in if property", () => {
|
|
59
|
+
const ctx = createContext(`const j = new Job({ if: "\${{ github.ref == 'refs/heads/main' }}" });`);
|
|
60
|
+
const diags = useConditionBuildersRule.check(ctx);
|
|
61
|
+
expect(diags).toHaveLength(1);
|
|
62
|
+
expect(diags[0].ruleId).toBe("GHA002");
|
|
63
|
+
expect(diags[0].severity).toBe("warning");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("does not flag Expression object in if", () => {
|
|
67
|
+
const ctx = createContext(`const j = new Job({ if: branch("main") });`);
|
|
68
|
+
const diags = useConditionBuildersRule.check(ctx);
|
|
69
|
+
expect(diags).toHaveLength(0);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("does not flag string without ${{ in if", () => {
|
|
73
|
+
const ctx = createContext(`const j = new Job({ if: "always()" });`);
|
|
74
|
+
const diags = useConditionBuildersRule.check(ctx);
|
|
75
|
+
expect(diags).toHaveLength(0);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// ── GHA003: no-hardcoded-secrets ────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
describe("GHA003: no-hardcoded-secrets", () => {
|
|
82
|
+
test("flags ghp_ prefix", () => {
|
|
83
|
+
const ctx = createContext(`const token = "ghp_1234567890abcdef";`);
|
|
84
|
+
const diags = noHardcodedSecretsRule.check(ctx);
|
|
85
|
+
expect(diags).toHaveLength(1);
|
|
86
|
+
expect(diags[0].ruleId).toBe("GHA003");
|
|
87
|
+
expect(diags[0].severity).toBe("error");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("flags ghs_ prefix", () => {
|
|
91
|
+
const ctx = createContext(`const token = "ghs_abcdef1234567890";`);
|
|
92
|
+
const diags = noHardcodedSecretsRule.check(ctx);
|
|
93
|
+
expect(diags).toHaveLength(1);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("flags github_pat_ prefix", () => {
|
|
97
|
+
const ctx = createContext(`const token = "github_pat_ABCDEF1234567890";`);
|
|
98
|
+
const diags = noHardcodedSecretsRule.check(ctx);
|
|
99
|
+
expect(diags).toHaveLength(1);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("does not flag normal strings", () => {
|
|
103
|
+
const ctx = createContext(`const name = "my-github-repo";`);
|
|
104
|
+
const diags = noHardcodedSecretsRule.check(ctx);
|
|
105
|
+
expect(diags).toHaveLength(0);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// ── GHA004: use-matrix-builder ──────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
describe("GHA004: use-matrix-builder", () => {
|
|
112
|
+
test("flags inline matrix object", () => {
|
|
113
|
+
const ctx = createContext(`const s = new Strategy({ matrix: { "node-version": ["18", "20"] } });`);
|
|
114
|
+
const diags = useMatrixBuilderRule.check(ctx);
|
|
115
|
+
expect(diags).toHaveLength(1);
|
|
116
|
+
expect(diags[0].ruleId).toBe("GHA004");
|
|
117
|
+
expect(diags[0].severity).toBe("info");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("does not flag matrix reference", () => {
|
|
121
|
+
const ctx = createContext(`const s = new Strategy({ matrix: myMatrix });`);
|
|
122
|
+
const diags = useMatrixBuilderRule.check(ctx);
|
|
123
|
+
expect(diags).toHaveLength(0);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// ── GHA005: extract-inline-structs ──────────────────────────────────
|
|
128
|
+
|
|
129
|
+
describe("GHA005: extract-inline-structs", () => {
|
|
130
|
+
test("flags deeply nested objects in Job constructor", () => {
|
|
131
|
+
const ctx = createContext(`const j = new Job({ on: { push: { branches: { pattern: "main" } } } });`);
|
|
132
|
+
const diags = extractInlineStructsRule.check(ctx);
|
|
133
|
+
expect(diags).toHaveLength(1);
|
|
134
|
+
expect(diags[0].ruleId).toBe("GHA005");
|
|
135
|
+
expect(diags[0].severity).toBe("info");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("does not flag shallow nesting", () => {
|
|
139
|
+
const ctx = createContext(`const j = new Job({ env: { NODE_ENV: "production" } });`);
|
|
140
|
+
const diags = extractInlineStructsRule.check(ctx);
|
|
141
|
+
expect(diags).toHaveLength(0);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("does not flag non-resource constructors", () => {
|
|
145
|
+
const ctx = createContext(`const s = new Step({ env: { a: { b: { c: "deep" } } } });`);
|
|
146
|
+
const diags = extractInlineStructsRule.check(ctx);
|
|
147
|
+
expect(diags).toHaveLength(0);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// ── GHA007: file-job-limit ──────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
describe("GHA007: file-job-limit", () => {
|
|
154
|
+
test("flags file with more than 10 jobs", () => {
|
|
155
|
+
const jobs = Array.from({ length: 11 }, (_, i) => `const j${i} = new Job({ "runs-on": "ubuntu-latest" });`).join("\n");
|
|
156
|
+
const ctx = createContext(jobs);
|
|
157
|
+
const diags = fileJobLimitRule.check(ctx);
|
|
158
|
+
expect(diags).toHaveLength(1);
|
|
159
|
+
expect(diags[0].ruleId).toBe("GHA007");
|
|
160
|
+
expect(diags[0].severity).toBe("warning");
|
|
161
|
+
expect(diags[0].message).toContain("11");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("does not flag 10 or fewer jobs", () => {
|
|
165
|
+
const jobs = Array.from({ length: 10 }, (_, i) => `const j${i} = new Job({ "runs-on": "ubuntu-latest" });`).join("\n");
|
|
166
|
+
const ctx = createContext(jobs);
|
|
167
|
+
const diags = fileJobLimitRule.check(ctx);
|
|
168
|
+
expect(diags).toHaveLength(0);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("counts ReusableWorkflowCallJob too", () => {
|
|
172
|
+
const jobs = Array.from({ length: 11 }, (_, i) => `const j${i} = new ReusableWorkflowCallJob({ uses: "owner/repo/.github/workflows/x.yml@main" });`).join("\n");
|
|
173
|
+
const ctx = createContext(jobs);
|
|
174
|
+
const diags = fileJobLimitRule.check(ctx);
|
|
175
|
+
expect(diags).toHaveLength(1);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// ── GHA008: no-raw-expressions ──────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
describe("GHA008: no-raw-expressions", () => {
|
|
182
|
+
test("flags unknown context in ${{ }}", () => {
|
|
183
|
+
const ctx = createContext(`const x = "\${{ custom.unknown }}";`);
|
|
184
|
+
const diags = noRawExpressionsRule.check(ctx);
|
|
185
|
+
expect(diags).toHaveLength(1);
|
|
186
|
+
expect(diags[0].ruleId).toBe("GHA008");
|
|
187
|
+
expect(diags[0].severity).toBe("info");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("does not flag known contexts", () => {
|
|
191
|
+
const ctx = createContext(`const x = "\${{ github.ref }}";`);
|
|
192
|
+
const diags = noRawExpressionsRule.check(ctx);
|
|
193
|
+
expect(diags).toHaveLength(0);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("does not flag secrets context", () => {
|
|
197
|
+
const ctx = createContext(`const x = "\${{ secrets.MY_TOKEN }}";`);
|
|
198
|
+
const diags = noRawExpressionsRule.check(ctx);
|
|
199
|
+
expect(diags).toHaveLength(0);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("does not flag function calls", () => {
|
|
203
|
+
const ctx = createContext(`const x = "\${{ contains(github.ref, 'main') }}";`);
|
|
204
|
+
const diags = noRawExpressionsRule.check(ctx);
|
|
205
|
+
expect(diags).toHaveLength(0);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// ── GHA010: missing-recommended-inputs ──────────────────────────────
|
|
210
|
+
|
|
211
|
+
describe("GHA010: missing-recommended-inputs", () => {
|
|
212
|
+
test("flags SetupNode without version", () => {
|
|
213
|
+
const ctx = createContext(`const step = SetupNode({ cache: "npm" });`);
|
|
214
|
+
const diags = missingRecommendedInputsRule.check(ctx);
|
|
215
|
+
expect(diags).toHaveLength(1);
|
|
216
|
+
expect(diags[0].ruleId).toBe("GHA010");
|
|
217
|
+
expect(diags[0].severity).toBe("warning");
|
|
218
|
+
expect(diags[0].message).toContain("SetupNode");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("does not flag SetupNode with nodeVersion", () => {
|
|
222
|
+
const ctx = createContext(`const step = SetupNode({ nodeVersion: "22" });`);
|
|
223
|
+
const diags = missingRecommendedInputsRule.check(ctx);
|
|
224
|
+
expect(diags).toHaveLength(0);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("does not flag non-setup function calls", () => {
|
|
228
|
+
const ctx = createContext(`const step = Checkout({});`);
|
|
229
|
+
const diags = missingRecommendedInputsRule.check(ctx);
|
|
230
|
+
expect(diags).toHaveLength(0);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// ── GHA012: deprecated-action-version ───────────────────────────────
|
|
235
|
+
|
|
236
|
+
describe("GHA012: deprecated-action-version", () => {
|
|
237
|
+
test("flags deprecated checkout version", () => {
|
|
238
|
+
const ctx = createContext(`const s = "actions/checkout@v2";`);
|
|
239
|
+
const diags = deprecatedActionVersionRule.check(ctx);
|
|
240
|
+
expect(diags).toHaveLength(1);
|
|
241
|
+
expect(diags[0].ruleId).toBe("GHA012");
|
|
242
|
+
expect(diags[0].severity).toBe("warning");
|
|
243
|
+
expect(diags[0].message).toContain("v2");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("does not flag current version", () => {
|
|
247
|
+
const ctx = createContext(`const s = "actions/checkout@v4";`);
|
|
248
|
+
const diags = deprecatedActionVersionRule.check(ctx);
|
|
249
|
+
expect(diags).toHaveLength(0);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("does not flag unknown action", () => {
|
|
253
|
+
const ctx = createContext(`const s = "custom/action@v1";`);
|
|
254
|
+
const diags = deprecatedActionVersionRule.check(ctx);
|
|
255
|
+
expect(diags).toHaveLength(0);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// ── GHA014: job-timeout ─────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
describe("GHA014: job-timeout", () => {
|
|
262
|
+
test("flags Job without timeoutMinutes", () => {
|
|
263
|
+
const ctx = createContext(`const j = new Job({ "runs-on": "ubuntu-latest" });`);
|
|
264
|
+
const diags = jobTimeoutRule.check(ctx);
|
|
265
|
+
expect(diags).toHaveLength(1);
|
|
266
|
+
expect(diags[0].ruleId).toBe("GHA014");
|
|
267
|
+
expect(diags[0].severity).toBe("warning");
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("does not flag Job with timeoutMinutes", () => {
|
|
271
|
+
const ctx = createContext(`const j = new Job({ "runs-on": "ubuntu-latest", timeoutMinutes: 30 });`);
|
|
272
|
+
const diags = jobTimeoutRule.check(ctx);
|
|
273
|
+
expect(diags).toHaveLength(0);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("does not flag Job with timeout-minutes", () => {
|
|
277
|
+
const ctx = createContext(`const j = new Job({ "runs-on": "ubuntu-latest", "timeout-minutes": 30 });`);
|
|
278
|
+
const diags = jobTimeoutRule.check(ctx);
|
|
279
|
+
expect(diags).toHaveLength(0);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("does not flag non-Job constructors", () => {
|
|
283
|
+
const ctx = createContext(`const s = new Step({ run: "test" });`);
|
|
284
|
+
const diags = jobTimeoutRule.check(ctx);
|
|
285
|
+
expect(diags).toHaveLength(0);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// ── GHA015: suggest-cache ───────────────────────────────────────────
|
|
290
|
+
|
|
291
|
+
describe("GHA015: suggest-cache", () => {
|
|
292
|
+
test("flags SetupNode in steps without Cache", () => {
|
|
293
|
+
const ctx = createContext(`const j = new Job({ steps: [SetupNode({ nodeVersion: "22" })] });`);
|
|
294
|
+
const diags = suggestCacheRule.check(ctx);
|
|
295
|
+
expect(diags).toHaveLength(1);
|
|
296
|
+
expect(diags[0].ruleId).toBe("GHA015");
|
|
297
|
+
expect(diags[0].severity).toBe("warning");
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test("does not flag SetupNode with cache prop", () => {
|
|
301
|
+
const ctx = createContext(`const j = new Job({ steps: [SetupNode({ nodeVersion: "22", cache: "npm" })] });`);
|
|
302
|
+
const diags = suggestCacheRule.check(ctx);
|
|
303
|
+
expect(diags).toHaveLength(0);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("does not flag steps with Cache composite", () => {
|
|
307
|
+
const ctx = createContext(`const j = new Job({ steps: [SetupNode({ nodeVersion: "22" }), Cache({ path: "node_modules" })] });`);
|
|
308
|
+
const diags = suggestCacheRule.check(ctx);
|
|
309
|
+
expect(diags).toHaveLength(0);
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// ── GHA016: validate-concurrency ────────────────────────────────────
|
|
314
|
+
|
|
315
|
+
describe("GHA016: validate-concurrency", () => {
|
|
316
|
+
test("flags cancelInProgress without group", () => {
|
|
317
|
+
const ctx = createContext(`const c = new Concurrency({ cancelInProgress: true });`);
|
|
318
|
+
const diags = validateConcurrencyRule.check(ctx);
|
|
319
|
+
expect(diags).toHaveLength(1);
|
|
320
|
+
expect(diags[0].ruleId).toBe("GHA016");
|
|
321
|
+
expect(diags[0].severity).toBe("warning");
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test("does not flag cancelInProgress with group", () => {
|
|
325
|
+
const ctx = createContext(`const c = new Concurrency({ cancelInProgress: true, group: "ci-\${{ github.ref }}" });`);
|
|
326
|
+
const diags = validateConcurrencyRule.check(ctx);
|
|
327
|
+
expect(diags).toHaveLength(0);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("does not flag concurrency without cancelInProgress", () => {
|
|
331
|
+
const ctx = createContext(`const c = new Concurrency({ group: "ci" });`);
|
|
332
|
+
const diags = validateConcurrencyRule.check(ctx);
|
|
333
|
+
expect(diags).toHaveLength(0);
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
// ── GHA020: detect-secrets ──────────────────────────────────────────
|
|
338
|
+
|
|
339
|
+
describe("GHA020: detect-secrets", () => {
|
|
340
|
+
test("flags AWS access key", () => {
|
|
341
|
+
const ctx = createContext(`const key = "AKIAIOSFODNN7EXAMPLE";`);
|
|
342
|
+
const diags = detectSecretsRule.check(ctx);
|
|
343
|
+
expect(diags).toHaveLength(1);
|
|
344
|
+
expect(diags[0].ruleId).toBe("GHA020");
|
|
345
|
+
expect(diags[0].severity).toBe("error");
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("flags RSA private key header", () => {
|
|
349
|
+
const ctx = createContext(`const key = "-----BEGIN RSA PRIVATE KEY-----";`);
|
|
350
|
+
const diags = detectSecretsRule.check(ctx);
|
|
351
|
+
expect(diags).toHaveLength(1);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test("skips strings referencing secrets.", () => {
|
|
355
|
+
const ctx = createContext(`const x = "Use secrets.DEPLOY_KEY for deployment";`);
|
|
356
|
+
const diags = detectSecretsRule.check(ctx);
|
|
357
|
+
expect(diags).toHaveLength(0);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test("does not flag normal strings", () => {
|
|
361
|
+
const ctx = createContext(`const name = "my-application";`);
|
|
362
|
+
const diags = detectSecretsRule.check(ctx);
|
|
363
|
+
expect(diags).toHaveLength(0);
|
|
364
|
+
});
|
|
365
|
+
});
|