@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,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,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA015: Suggest Cache
|
|
3
|
+
*
|
|
4
|
+
* Flags setup action composites in steps without a corresponding Cache composite.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { LintRule, LintDiagnostic, LintContext } from "@intentius/chant/lint/rule";
|
|
8
|
+
import * as ts from "typescript";
|
|
9
|
+
|
|
10
|
+
const setupActionsNeedingCache = new Set(["SetupNode", "SetupGo", "SetupPython"]);
|
|
11
|
+
|
|
12
|
+
export const suggestCacheRule: LintRule = {
|
|
13
|
+
id: "GHA015",
|
|
14
|
+
severity: "warning",
|
|
15
|
+
category: "performance",
|
|
16
|
+
description: "Setup action should be paired with a Cache composite for faster builds",
|
|
17
|
+
|
|
18
|
+
check(context: LintContext): LintDiagnostic[] {
|
|
19
|
+
const { sourceFile } = context;
|
|
20
|
+
const diagnostics: LintDiagnostic[] = [];
|
|
21
|
+
|
|
22
|
+
function visit(node: ts.Node): void {
|
|
23
|
+
// Look for steps array with setup actions but no Cache
|
|
24
|
+
if (
|
|
25
|
+
ts.isPropertyAssignment(node) &&
|
|
26
|
+
ts.isIdentifier(node.name) &&
|
|
27
|
+
node.name.text === "steps" &&
|
|
28
|
+
ts.isArrayLiteralExpression(node.initializer)
|
|
29
|
+
) {
|
|
30
|
+
const elements = node.initializer.elements;
|
|
31
|
+
let hasSetup = false;
|
|
32
|
+
let setupName = "";
|
|
33
|
+
let hasCache = false;
|
|
34
|
+
|
|
35
|
+
for (const el of elements) {
|
|
36
|
+
if (ts.isCallExpression(el)) {
|
|
37
|
+
const name = ts.isIdentifier(el.expression) ? el.expression.text : "";
|
|
38
|
+
if (setupActionsNeedingCache.has(name)) {
|
|
39
|
+
hasSetup = true;
|
|
40
|
+
setupName = name;
|
|
41
|
+
// Check if the setup action already has cache in its props
|
|
42
|
+
if (el.arguments.length > 0 && ts.isObjectLiteralExpression(el.arguments[0])) {
|
|
43
|
+
const hasCacheProp = el.arguments[0].properties.some(
|
|
44
|
+
(p) => ts.isPropertyAssignment(p) && ts.isIdentifier(p.name) && p.name.text === "cache",
|
|
45
|
+
);
|
|
46
|
+
if (hasCacheProp) hasCache = true;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (name === "Cache") hasCache = true;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (hasSetup && !hasCache) {
|
|
54
|
+
const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart());
|
|
55
|
+
diagnostics.push({
|
|
56
|
+
file: sourceFile.fileName,
|
|
57
|
+
line: line + 1,
|
|
58
|
+
column: character + 1,
|
|
59
|
+
ruleId: "GHA015",
|
|
60
|
+
severity: "warning",
|
|
61
|
+
message: `${setupName}() found without Cache. Add a Cache() step or use the built-in cache option for faster builds.`,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
ts.forEachChild(node, visit);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
visit(sourceFile);
|
|
69
|
+
return diagnostics;
|
|
70
|
+
},
|
|
71
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA002: Use Condition Builders
|
|
3
|
+
*
|
|
4
|
+
* Flags string literals containing `${{` in `if` property assignments
|
|
5
|
+
* inside Job/Step constructors. Suggests using Expression helpers.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { LintRule, LintDiagnostic, LintContext } from "@intentius/chant/lint/rule";
|
|
9
|
+
import * as ts from "typescript";
|
|
10
|
+
|
|
11
|
+
export const useConditionBuildersRule: LintRule = {
|
|
12
|
+
id: "GHA002",
|
|
13
|
+
severity: "warning",
|
|
14
|
+
category: "style",
|
|
15
|
+
description: "Use Expression helpers instead of raw ${{ }} strings in if conditions",
|
|
16
|
+
|
|
17
|
+
check(context: LintContext): LintDiagnostic[] {
|
|
18
|
+
const { sourceFile } = context;
|
|
19
|
+
const diagnostics: LintDiagnostic[] = [];
|
|
20
|
+
|
|
21
|
+
function visit(node: ts.Node): void {
|
|
22
|
+
if (
|
|
23
|
+
ts.isPropertyAssignment(node) &&
|
|
24
|
+
ts.isIdentifier(node.name) &&
|
|
25
|
+
node.name.text === "if" &&
|
|
26
|
+
ts.isStringLiteral(node.initializer) &&
|
|
27
|
+
node.initializer.text.includes("${{")
|
|
28
|
+
) {
|
|
29
|
+
const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart());
|
|
30
|
+
diagnostics.push({
|
|
31
|
+
file: sourceFile.fileName,
|
|
32
|
+
line: line + 1,
|
|
33
|
+
column: character + 1,
|
|
34
|
+
ruleId: "GHA002",
|
|
35
|
+
severity: "warning",
|
|
36
|
+
message: "Use typed Expression helpers (e.g., github.ref.eq('refs/heads/main')) instead of raw ${{ }} strings.",
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
ts.forEachChild(node, visit);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
visit(sourceFile);
|
|
43
|
+
return diagnostics;
|
|
44
|
+
},
|
|
45
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA004: Use Matrix Builder
|
|
3
|
+
*
|
|
4
|
+
* Flags inline object literals in `matrix` property. Suggests extracting
|
|
5
|
+
* to a named const for reusability and clarity.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { LintRule, LintDiagnostic, LintContext } from "@intentius/chant/lint/rule";
|
|
9
|
+
import * as ts from "typescript";
|
|
10
|
+
|
|
11
|
+
export const useMatrixBuilderRule: LintRule = {
|
|
12
|
+
id: "GHA004",
|
|
13
|
+
severity: "info",
|
|
14
|
+
category: "style",
|
|
15
|
+
description: "Extract inline matrix objects to named constants",
|
|
16
|
+
|
|
17
|
+
check(context: LintContext): LintDiagnostic[] {
|
|
18
|
+
const { sourceFile } = context;
|
|
19
|
+
const diagnostics: LintDiagnostic[] = [];
|
|
20
|
+
|
|
21
|
+
function visit(node: ts.Node): void {
|
|
22
|
+
if (
|
|
23
|
+
ts.isPropertyAssignment(node) &&
|
|
24
|
+
ts.isIdentifier(node.name) &&
|
|
25
|
+
node.name.text === "matrix" &&
|
|
26
|
+
ts.isObjectLiteralExpression(node.initializer)
|
|
27
|
+
) {
|
|
28
|
+
const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart());
|
|
29
|
+
diagnostics.push({
|
|
30
|
+
file: sourceFile.fileName,
|
|
31
|
+
line: line + 1,
|
|
32
|
+
column: character + 1,
|
|
33
|
+
ruleId: "GHA004",
|
|
34
|
+
severity: "info",
|
|
35
|
+
message: "Consider extracting inline matrix to a named constant for clarity.",
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
ts.forEachChild(node, visit);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
visit(sourceFile);
|
|
42
|
+
return diagnostics;
|
|
43
|
+
},
|
|
44
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA001: Use Typed Action Composites
|
|
3
|
+
*
|
|
4
|
+
* Flags raw `uses:` string literals when a matching typed composite exists.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { LintRule, LintDiagnostic, LintContext } from "@intentius/chant/lint/rule";
|
|
8
|
+
import * as ts from "typescript";
|
|
9
|
+
import { knownActions } from "./data/known-actions";
|
|
10
|
+
|
|
11
|
+
export const useTypedActionsRule: LintRule = {
|
|
12
|
+
id: "GHA001",
|
|
13
|
+
severity: "warning",
|
|
14
|
+
category: "style",
|
|
15
|
+
description: "Use typed action composite instead of raw uses: string",
|
|
16
|
+
|
|
17
|
+
check(context: LintContext): LintDiagnostic[] {
|
|
18
|
+
const { sourceFile } = context;
|
|
19
|
+
const diagnostics: LintDiagnostic[] = [];
|
|
20
|
+
|
|
21
|
+
function visit(node: ts.Node): void {
|
|
22
|
+
if (ts.isPropertyAssignment(node) && ts.isIdentifier(node.name) && node.name.text === "uses") {
|
|
23
|
+
if (ts.isStringLiteral(node.initializer)) {
|
|
24
|
+
const value = node.initializer.text;
|
|
25
|
+
// Extract action name without version: "actions/checkout@v4" → "actions/checkout"
|
|
26
|
+
const actionName = value.split("@")[0];
|
|
27
|
+
const match = knownActions[actionName];
|
|
28
|
+
if (match) {
|
|
29
|
+
const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart());
|
|
30
|
+
diagnostics.push({
|
|
31
|
+
file: sourceFile.fileName,
|
|
32
|
+
line: line + 1,
|
|
33
|
+
column: character + 1,
|
|
34
|
+
ruleId: "GHA001",
|
|
35
|
+
severity: "warning",
|
|
36
|
+
message: `Use the typed ${match.composite}() composite instead of raw "${value}" string.`,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
ts.forEachChild(node, visit);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
visit(sourceFile);
|
|
45
|
+
return diagnostics;
|
|
46
|
+
},
|
|
47
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHA016: Validate Concurrency
|
|
3
|
+
*
|
|
4
|
+
* Flags `new Concurrency({cancelInProgress: true})` without `group`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { LintRule, LintDiagnostic, LintContext } from "@intentius/chant/lint/rule";
|
|
8
|
+
import * as ts from "typescript";
|
|
9
|
+
|
|
10
|
+
export const validateConcurrencyRule: LintRule = {
|
|
11
|
+
id: "GHA016",
|
|
12
|
+
severity: "warning",
|
|
13
|
+
category: "correctness",
|
|
14
|
+
description: "Concurrency with cancel-in-progress should specify a group",
|
|
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 isConcurrency = false;
|
|
23
|
+
if (ts.isIdentifier(node.expression) && node.expression.text === "Concurrency") isConcurrency = true;
|
|
24
|
+
if (ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === "Concurrency") isConcurrency = true;
|
|
25
|
+
|
|
26
|
+
if (isConcurrency && node.arguments?.length) {
|
|
27
|
+
const arg = node.arguments[0];
|
|
28
|
+
if (ts.isObjectLiteralExpression(arg)) {
|
|
29
|
+
let hasCancelInProgress = false;
|
|
30
|
+
let hasGroup = false;
|
|
31
|
+
|
|
32
|
+
for (const prop of arg.properties) {
|
|
33
|
+
if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name)) {
|
|
34
|
+
if (prop.name.text === "cancelInProgress" || prop.name.text === "cancel-in-progress") {
|
|
35
|
+
// Check if it's set to true
|
|
36
|
+
if (prop.initializer.kind === ts.SyntaxKind.TrueKeyword) {
|
|
37
|
+
hasCancelInProgress = true;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (prop.name.text === "group") {
|
|
41
|
+
hasGroup = true;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (hasCancelInProgress && !hasGroup) {
|
|
47
|
+
const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart());
|
|
48
|
+
diagnostics.push({
|
|
49
|
+
file: sourceFile.fileName,
|
|
50
|
+
line: line + 1,
|
|
51
|
+
column: character + 1,
|
|
52
|
+
ruleId: "GHA016",
|
|
53
|
+
severity: "warning",
|
|
54
|
+
message: "Concurrency with cancel-in-progress should specify a group to avoid cancelling unrelated runs.",
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
ts.forEachChild(node, visit);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
visit(sourceFile);
|
|
64
|
+
return diagnostics;
|
|
65
|
+
},
|
|
66
|
+
};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for parsing serialized GitHub Actions YAML in post-synth checks.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export { getPrimaryOutput } from "@intentius/chant/lint/post-synth";
|
|
6
|
+
|
|
7
|
+
export interface ParsedJob {
|
|
8
|
+
name: string;
|
|
9
|
+
needs?: string[];
|
|
10
|
+
steps?: Array<{ uses?: string; run?: string; name?: string }>;
|
|
11
|
+
permissions?: Record<string, string>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Extract jobs from a serialized GitHub Actions workflow YAML.
|
|
16
|
+
*/
|
|
17
|
+
export function extractJobs(yaml: string): Map<string, ParsedJob> {
|
|
18
|
+
const jobs = new Map<string, ParsedJob>();
|
|
19
|
+
|
|
20
|
+
// Find the jobs: section — take everything after `jobs:\n`
|
|
21
|
+
const jobsIdx = yaml.search(/^jobs:\s*$/m);
|
|
22
|
+
if (jobsIdx === -1) return jobs;
|
|
23
|
+
|
|
24
|
+
// Content after the `jobs:` line. Stop at the next top-level key (non-indented) or EOF.
|
|
25
|
+
const afterJobs = yaml.slice(jobsIdx + yaml.slice(jobsIdx).indexOf("\n") + 1);
|
|
26
|
+
const endMatch = afterJobs.search(/^[a-z]/m);
|
|
27
|
+
const jobsContent = endMatch === -1 ? afterJobs : afterJobs.slice(0, endMatch);
|
|
28
|
+
|
|
29
|
+
// Split into individual jobs by finding lines with exactly 2 spaces of indent followed by a key
|
|
30
|
+
const jobSections = jobsContent.split(/\n(?= [a-z][a-z0-9-]*:)/);
|
|
31
|
+
|
|
32
|
+
for (const section of jobSections) {
|
|
33
|
+
const nameMatch = section.match(/^\s{2}([a-z][a-z0-9-]*):/);
|
|
34
|
+
if (!nameMatch) continue;
|
|
35
|
+
|
|
36
|
+
const name = nameMatch[1];
|
|
37
|
+
const job: ParsedJob = { name };
|
|
38
|
+
|
|
39
|
+
// Extract needs
|
|
40
|
+
const needsInline = section.match(/^\s{4}needs:\s+\[(.+)\]$/m);
|
|
41
|
+
if (needsInline) {
|
|
42
|
+
job.needs = needsInline[1].split(",").map((s) => s.trim().replace(/^'|'$/g, "").replace(/^"|"$/g, ""));
|
|
43
|
+
} else {
|
|
44
|
+
const needsList = section.match(/^\s{4}needs:\n((?:\s{6}- .+\n?)+)/m);
|
|
45
|
+
if (needsList) {
|
|
46
|
+
job.needs = [];
|
|
47
|
+
for (const line of needsList[1].split("\n")) {
|
|
48
|
+
const item = line.match(/^\s{6}- (.+)$/);
|
|
49
|
+
if (item) job.needs.push(item[1].trim().replace(/^'|'$/g, "").replace(/^"|"$/g, ""));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Extract steps with uses:
|
|
55
|
+
const stepsMatch = section.match(/^\s{4}steps:\n([\s\S]*?)(?=\n\s{4}[a-z]|\n\s{2}[a-z]|$)/m);
|
|
56
|
+
if (stepsMatch) {
|
|
57
|
+
job.steps = [];
|
|
58
|
+
const stepEntries = stepsMatch[1].split(/\n(?=\s{6}- )/);
|
|
59
|
+
for (const stepEntry of stepEntries) {
|
|
60
|
+
const usesMatch = stepEntry.match(/uses:\s+(.+)$/m);
|
|
61
|
+
const runMatch = stepEntry.match(/run:\s+(.+)$/m);
|
|
62
|
+
const stepNameMatch = stepEntry.match(/name:\s+(.+)$/m);
|
|
63
|
+
if (usesMatch || runMatch) {
|
|
64
|
+
job.steps.push({
|
|
65
|
+
uses: usesMatch?.[1]?.trim().replace(/^'|'$/g, ""),
|
|
66
|
+
run: runMatch?.[1]?.trim(),
|
|
67
|
+
name: stepNameMatch?.[1]?.trim().replace(/^'|'$/g, ""),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
jobs.set(name, job);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return jobs;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Extract trigger events from the YAML.
|
|
81
|
+
*/
|
|
82
|
+
export function extractTriggers(yaml: string): Record<string, unknown> {
|
|
83
|
+
const onMatch = yaml.match(/^on:\n([\s\S]*?)(?=\n[a-z]|$)/m);
|
|
84
|
+
if (!onMatch) return {};
|
|
85
|
+
|
|
86
|
+
const triggers: Record<string, unknown> = {};
|
|
87
|
+
const lines = onMatch[1].split("\n");
|
|
88
|
+
for (const line of lines) {
|
|
89
|
+
const triggerMatch = line.match(/^\s{2}([a-z_]+):/);
|
|
90
|
+
if (triggerMatch) {
|
|
91
|
+
triggers[triggerMatch[1]] = true;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return triggers;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Check if any step in the list uses a checkout action.
|
|
99
|
+
*/
|
|
100
|
+
export function hasCheckoutAction(steps: Array<{ uses?: string }>): boolean {
|
|
101
|
+
return steps.some((s) => s.uses?.startsWith("actions/checkout"));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Build a needs dependency graph from the YAML.
|
|
106
|
+
*/
|
|
107
|
+
export function buildNeedsGraph(yaml: string): Map<string, string[]> {
|
|
108
|
+
const jobs = extractJobs(yaml);
|
|
109
|
+
const graph = new Map<string, string[]>();
|
|
110
|
+
for (const [name, job] of jobs) {
|
|
111
|
+
graph.set(name, job.needs ?? []);
|
|
112
|
+
}
|
|
113
|
+
return graph;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Extract the workflow name from the YAML.
|
|
118
|
+
*/
|
|
119
|
+
export function extractWorkflowName(yaml: string): string | undefined {
|
|
120
|
+
const match = yaml.match(/^name:\s+(.+)$/m);
|
|
121
|
+
return match?.[1]?.trim().replace(/^'|'$/g, "");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Check if the YAML has an explicit permissions block.
|
|
126
|
+
*/
|
|
127
|
+
export function hasPermissions(yaml: string): boolean {
|
|
128
|
+
return /^permissions:/m.test(yaml);
|
|
129
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
---
|
|
2
|
+
skill: chant-github
|
|
3
|
+
description: Build, validate, and deploy GitHub Actions workflows from a chant project
|
|
4
|
+
user-invocable: true
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# GitHub Actions Operational Playbook
|
|
8
|
+
|
|
9
|
+
## How chant and GitHub Actions relate
|
|
10
|
+
|
|
11
|
+
chant is a **synthesis-only** tool — it compiles TypeScript source files into `.github/workflows/*.yml` (YAML). chant does NOT call GitHub APIs.
|
|
12
|
+
|
|
13
|
+
- Use **chant** for: build, lint, diff (local YAML comparison)
|
|
14
|
+
- Use **git + GitHub** for: push, pull requests, workflow monitoring
|
|
15
|
+
|
|
16
|
+
## Build and validate
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
chant build src/ --output .github/workflows/ci.yml
|
|
20
|
+
chant lint src/
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Deploy
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
git add .github/workflows/ci.yml
|
|
27
|
+
git commit -m "Update workflow"
|
|
28
|
+
git push
|
|
29
|
+
```
|