@intentius/chant-lexicon-github 0.0.18 → 0.0.22

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 (61) hide show
  1. package/dist/integrity.json +14 -4
  2. package/dist/manifest.json +1 -1
  3. package/dist/rules/gha020.ts +40 -0
  4. package/dist/rules/gha021.ts +48 -0
  5. package/dist/rules/gha022.ts +50 -0
  6. package/dist/rules/gha023.ts +44 -0
  7. package/dist/rules/gha024.ts +42 -0
  8. package/dist/rules/gha025.ts +42 -0
  9. package/dist/rules/gha026.ts +40 -0
  10. package/dist/rules/gha027.ts +57 -0
  11. package/dist/rules/gha028.ts +37 -0
  12. package/dist/skills/chant-github.md +1 -1
  13. package/dist/skills/github-actions-security.md +87 -0
  14. package/package.json +20 -2
  15. package/src/codegen/docs.test.ts +19 -0
  16. package/src/codegen/generate.test.ts +12 -0
  17. package/src/codegen/package.test.ts +8 -0
  18. package/src/coverage.test.ts +23 -0
  19. package/src/import/roundtrip.test.ts +206 -0
  20. package/src/lint/post-synth/gha006.test.ts +56 -0
  21. package/src/lint/post-synth/gha009.test.ts +56 -0
  22. package/src/lint/post-synth/gha011.test.ts +61 -0
  23. package/src/lint/post-synth/gha017.test.ts +51 -0
  24. package/src/lint/post-synth/gha018.test.ts +66 -0
  25. package/src/lint/post-synth/gha019.test.ts +66 -0
  26. package/src/lint/post-synth/gha020.test.ts +66 -0
  27. package/src/lint/post-synth/gha020.ts +40 -0
  28. package/src/lint/post-synth/gha021.test.ts +67 -0
  29. package/src/lint/post-synth/gha021.ts +48 -0
  30. package/src/lint/post-synth/gha022.test.ts +45 -0
  31. package/src/lint/post-synth/gha022.ts +50 -0
  32. package/src/lint/post-synth/gha023.test.ts +52 -0
  33. package/src/lint/post-synth/gha023.ts +44 -0
  34. package/src/lint/post-synth/gha024.test.ts +67 -0
  35. package/src/lint/post-synth/gha024.ts +42 -0
  36. package/src/lint/post-synth/gha025.test.ts +65 -0
  37. package/src/lint/post-synth/gha025.ts +42 -0
  38. package/src/lint/post-synth/gha026.test.ts +65 -0
  39. package/src/lint/post-synth/gha026.ts +40 -0
  40. package/src/lint/post-synth/gha027.test.ts +48 -0
  41. package/src/lint/post-synth/gha027.ts +57 -0
  42. package/src/lint/post-synth/gha028.test.ts +48 -0
  43. package/src/lint/post-synth/gha028.ts +37 -0
  44. package/src/lint/rules/deprecated-action-version.test.ts +26 -0
  45. package/src/lint/rules/detect-secrets.test.ts +25 -0
  46. package/src/lint/rules/extract-inline-structs.test.ts +25 -0
  47. package/src/lint/rules/file-job-limit.test.ts +28 -0
  48. package/src/lint/rules/job-timeout.test.ts +31 -0
  49. package/src/lint/rules/missing-recommended-inputs.test.ts +26 -0
  50. package/src/lint/rules/no-hardcoded-secrets.test.ts +31 -0
  51. package/src/lint/rules/no-raw-expressions.test.ts +31 -0
  52. package/src/lint/rules/suggest-cache.test.ts +25 -0
  53. package/src/lint/rules/use-condition-builders.test.ts +31 -0
  54. package/src/lint/rules/use-matrix-builder.test.ts +25 -0
  55. package/src/lint/rules/use-typed-actions.test.ts +39 -0
  56. package/src/lint/rules/validate-concurrency.test.ts +31 -0
  57. package/src/plugin.test.ts +1 -1
  58. package/src/plugin.ts +30 -2
  59. package/src/skills/github-actions-security.md +87 -0
  60. package/src/validate.ts +14 -1
  61. package/src/variables.test.ts +48 -0
@@ -0,0 +1,57 @@
1
+ /**
2
+ * GHA027: Missing `if: always()` on Cleanup Steps
3
+ *
4
+ * Flags steps whose name contains "cleanup", "teardown", or "clean up"
5
+ * (case-insensitive) that lack an `if:` condition. Cleanup steps should
6
+ * typically run with `if: always()` so they execute even when prior steps fail.
7
+ */
8
+
9
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
10
+ import { getPrimaryOutput, extractJobs } from "./yaml-helpers";
11
+
12
+ const CLEANUP_PATTERN = /cleanup|teardown|clean\s+up/i;
13
+
14
+ export const gha027: PostSynthCheck = {
15
+ id: "GHA027",
16
+ description: "Missing `if: always()` on cleanup steps",
17
+
18
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
19
+ const diagnostics: PostSynthDiagnostic[] = [];
20
+
21
+ for (const [, output] of ctx.outputs) {
22
+ const yaml = getPrimaryOutput(output);
23
+
24
+ // Scan raw YAML for step blocks with cleanup-like names
25
+ const stepPattern = /-\s+name:\s+(.+)/g;
26
+ let match: RegExpExecArray | null;
27
+
28
+ while ((match = stepPattern.exec(yaml)) !== null) {
29
+ const stepName = match[1].trim().replace(/^['"]|['"]$/g, "");
30
+ if (!CLEANUP_PATTERN.test(stepName)) continue;
31
+
32
+ // Get the block after this step name line
33
+ const afterName = yaml.slice(match.index + match[0].length);
34
+ // Capture lines until the next step entry or job
35
+ const blockEnd = afterName.search(/\n\s{6}-\s|\n\s{2}[a-z]/);
36
+ const block = blockEnd === -1 ? afterName : afterName.slice(0, blockEnd);
37
+
38
+ if (!/^\s+if:/m.test(block)) {
39
+ // Find which job this step belongs to
40
+ const beforeStep = yaml.slice(0, match.index);
41
+ const jobMatch = [...beforeStep.matchAll(/^\s{2}([a-z][a-z0-9-]*):/gm)];
42
+ const jobName = jobMatch.length > 0 ? jobMatch[jobMatch.length - 1][1] : "unknown";
43
+
44
+ diagnostics.push({
45
+ checkId: "GHA027",
46
+ severity: "info",
47
+ message: `Step "${stepName}" in job "${jobName}" looks like a cleanup step but has no \`if:\` condition. Add \`if: always()\` so it runs even when prior steps fail.`,
48
+ entity: `${jobName}.${stepName}`,
49
+ lexicon: "github",
50
+ });
51
+ }
52
+ }
53
+ }
54
+
55
+ return diagnostics;
56
+ },
57
+ };
@@ -0,0 +1,48 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
3
+ import { gha028 } from "./gha028";
4
+
5
+ function makeCtx(yaml: string): PostSynthContext {
6
+ return {
7
+ outputs: new Map([["github", yaml]]),
8
+ entities: new Map(),
9
+ buildResult: {
10
+ outputs: new Map([["github", yaml]]),
11
+ entities: new Map(),
12
+ warnings: [],
13
+ errors: [],
14
+ sourceFileCount: 1,
15
+ },
16
+ };
17
+ }
18
+
19
+ describe("GHA028: workflow with no on triggers", () => {
20
+ test("flags workflow without on: block", () => {
21
+ const yaml = `name: CI
22
+ jobs:
23
+ build:
24
+ runs-on: ubuntu-latest
25
+ steps:
26
+ - run: echo test
27
+ `;
28
+ const diags = gha028.check(makeCtx(yaml));
29
+ expect(diags).toHaveLength(1);
30
+ expect(diags[0].checkId).toBe("GHA028");
31
+ expect(diags[0].severity).toBe("error");
32
+ expect(diags[0].message).toContain("on:");
33
+ });
34
+
35
+ test("does not flag workflow with on: block", () => {
36
+ const yaml = `name: CI
37
+ on:
38
+ push:
39
+ jobs:
40
+ build:
41
+ runs-on: ubuntu-latest
42
+ steps:
43
+ - run: echo test
44
+ `;
45
+ const diags = gha028.check(makeCtx(yaml));
46
+ expect(diags).toHaveLength(0);
47
+ });
48
+ });
@@ -0,0 +1,37 @@
1
+ /**
2
+ * GHA028: Workflow With No `on` Triggers
3
+ *
4
+ * Flags workflow files that lack a top-level `on:` key, which means
5
+ * the workflow will never be triggered.
6
+ */
7
+
8
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
9
+ import { getPrimaryOutput } from "./yaml-helpers";
10
+
11
+ export const gha028: PostSynthCheck = {
12
+ id: "GHA028",
13
+ description: "Workflow with no `on` triggers",
14
+
15
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
16
+ const diagnostics: PostSynthDiagnostic[] = [];
17
+
18
+ for (const [, output] of ctx.outputs) {
19
+ const yaml = getPrimaryOutput(output);
20
+
21
+ const hasOn = /^on:/m.test(yaml);
22
+
23
+ if (!hasOn) {
24
+ diagnostics.push({
25
+ checkId: "GHA028",
26
+ severity: "error",
27
+ message:
28
+ "Workflow has no `on:` trigger block. Without triggers the workflow will never run. Add an `on:` section with at least one event.",
29
+ entity: "on",
30
+ lexicon: "github",
31
+ });
32
+ }
33
+ }
34
+
35
+ return diagnostics;
36
+ },
37
+ };
@@ -0,0 +1,26 @@
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 { deprecatedActionVersionRule } from "./deprecated-action-version";
5
+
6
+ function createContext(code: string, fileName = "test.ts"): LintContext {
7
+ const sourceFile = ts.createSourceFile(fileName, code, ts.ScriptTarget.Latest, true);
8
+ return { sourceFile, entities: [], filePath: fileName };
9
+ }
10
+
11
+ describe("GHA012: deprecated-action-version", () => {
12
+ test("flags deprecated checkout version", () => {
13
+ const ctx = createContext(`const s = "actions/checkout@v2";`);
14
+ const diags = deprecatedActionVersionRule.check(ctx);
15
+ expect(diags).toHaveLength(1);
16
+ expect(diags[0].ruleId).toBe("GHA012");
17
+ expect(diags[0].severity).toBe("warning");
18
+ expect(diags[0].message).toContain("v2");
19
+ });
20
+
21
+ test("does not flag current version", () => {
22
+ const ctx = createContext(`const s = "actions/checkout@v4";`);
23
+ const diags = deprecatedActionVersionRule.check(ctx);
24
+ expect(diags).toHaveLength(0);
25
+ });
26
+ });
@@ -0,0 +1,25 @@
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 { detectSecretsRule } from "./detect-secrets";
5
+
6
+ function createContext(code: string, fileName = "test.ts"): LintContext {
7
+ const sourceFile = ts.createSourceFile(fileName, code, ts.ScriptTarget.Latest, true);
8
+ return { sourceFile, entities: [], filePath: fileName };
9
+ }
10
+
11
+ describe("GHA020: detect-secrets", () => {
12
+ test("flags AWS access key", () => {
13
+ const ctx = createContext(`const key = "AKIAIOSFODNN7EXAMPLE";`);
14
+ const diags = detectSecretsRule.check(ctx);
15
+ expect(diags).toHaveLength(1);
16
+ expect(diags[0].ruleId).toBe("GHA020");
17
+ expect(diags[0].severity).toBe("error");
18
+ });
19
+
20
+ test("does not flag normal strings", () => {
21
+ const ctx = createContext(`const name = "my-application";`);
22
+ const diags = detectSecretsRule.check(ctx);
23
+ expect(diags).toHaveLength(0);
24
+ });
25
+ });
@@ -0,0 +1,25 @@
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 { extractInlineStructsRule } from "./extract-inline-structs";
5
+
6
+ function createContext(code: string, fileName = "test.ts"): LintContext {
7
+ const sourceFile = ts.createSourceFile(fileName, code, ts.ScriptTarget.Latest, true);
8
+ return { sourceFile, entities: [], filePath: fileName };
9
+ }
10
+
11
+ describe("GHA005: extract-inline-structs", () => {
12
+ test("flags deeply nested objects in Job constructor", () => {
13
+ const ctx = createContext(`const j = new Job({ on: { push: { branches: { pattern: "main" } } } });`);
14
+ const diags = extractInlineStructsRule.check(ctx);
15
+ expect(diags).toHaveLength(1);
16
+ expect(diags[0].ruleId).toBe("GHA005");
17
+ expect(diags[0].severity).toBe("info");
18
+ });
19
+
20
+ test("does not flag shallow nesting", () => {
21
+ const ctx = createContext(`const j = new Job({ env: { NODE_ENV: "production" } });`);
22
+ const diags = extractInlineStructsRule.check(ctx);
23
+ expect(diags).toHaveLength(0);
24
+ });
25
+ });
@@ -0,0 +1,28 @@
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 { fileJobLimitRule } from "./file-job-limit";
5
+
6
+ function createContext(code: string, fileName = "test.ts"): LintContext {
7
+ const sourceFile = ts.createSourceFile(fileName, code, ts.ScriptTarget.Latest, true);
8
+ return { sourceFile, entities: [], filePath: fileName };
9
+ }
10
+
11
+ describe("GHA007: file-job-limit", () => {
12
+ test("flags file with more than 10 jobs", () => {
13
+ const jobs = Array.from({ length: 11 }, (_, i) => `const j${i} = new Job({ "runs-on": "ubuntu-latest" });`).join("\n");
14
+ const ctx = createContext(jobs);
15
+ const diags = fileJobLimitRule.check(ctx);
16
+ expect(diags).toHaveLength(1);
17
+ expect(diags[0].ruleId).toBe("GHA007");
18
+ expect(diags[0].severity).toBe("warning");
19
+ expect(diags[0].message).toContain("11");
20
+ });
21
+
22
+ test("does not flag 10 or fewer jobs", () => {
23
+ const jobs = Array.from({ length: 10 }, (_, i) => `const j${i} = new Job({ "runs-on": "ubuntu-latest" });`).join("\n");
24
+ const ctx = createContext(jobs);
25
+ const diags = fileJobLimitRule.check(ctx);
26
+ expect(diags).toHaveLength(0);
27
+ });
28
+ });
@@ -0,0 +1,31 @@
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 { jobTimeoutRule } from "./job-timeout";
5
+
6
+ function createContext(code: string, fileName = "test.ts"): LintContext {
7
+ const sourceFile = ts.createSourceFile(fileName, code, ts.ScriptTarget.Latest, true);
8
+ return { sourceFile, entities: [], filePath: fileName };
9
+ }
10
+
11
+ describe("GHA014: job-timeout", () => {
12
+ test("flags Job without timeoutMinutes", () => {
13
+ const ctx = createContext(`const j = new Job({ "runs-on": "ubuntu-latest" });`);
14
+ const diags = jobTimeoutRule.check(ctx);
15
+ expect(diags).toHaveLength(1);
16
+ expect(diags[0].ruleId).toBe("GHA014");
17
+ expect(diags[0].severity).toBe("warning");
18
+ });
19
+
20
+ test("does not flag Job with timeoutMinutes", () => {
21
+ const ctx = createContext(`const j = new Job({ "runs-on": "ubuntu-latest", timeoutMinutes: 30 });`);
22
+ const diags = jobTimeoutRule.check(ctx);
23
+ expect(diags).toHaveLength(0);
24
+ });
25
+
26
+ test("does not flag non-Job constructors", () => {
27
+ const ctx = createContext(`const s = new Step({ run: "test" });`);
28
+ const diags = jobTimeoutRule.check(ctx);
29
+ expect(diags).toHaveLength(0);
30
+ });
31
+ });
@@ -0,0 +1,26 @@
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 { missingRecommendedInputsRule } from "./missing-recommended-inputs";
5
+
6
+ function createContext(code: string, fileName = "test.ts"): LintContext {
7
+ const sourceFile = ts.createSourceFile(fileName, code, ts.ScriptTarget.Latest, true);
8
+ return { sourceFile, entities: [], filePath: fileName };
9
+ }
10
+
11
+ describe("GHA010: missing-recommended-inputs", () => {
12
+ test("flags SetupNode without version", () => {
13
+ const ctx = createContext(`const step = SetupNode({ cache: "npm" });`);
14
+ const diags = missingRecommendedInputsRule.check(ctx);
15
+ expect(diags).toHaveLength(1);
16
+ expect(diags[0].ruleId).toBe("GHA010");
17
+ expect(diags[0].severity).toBe("warning");
18
+ expect(diags[0].message).toContain("SetupNode");
19
+ });
20
+
21
+ test("does not flag SetupNode with nodeVersion", () => {
22
+ const ctx = createContext(`const step = SetupNode({ nodeVersion: "22" });`);
23
+ const diags = missingRecommendedInputsRule.check(ctx);
24
+ expect(diags).toHaveLength(0);
25
+ });
26
+ });
@@ -0,0 +1,31 @@
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 { noHardcodedSecretsRule } from "./no-hardcoded-secrets";
5
+
6
+ function createContext(code: string, fileName = "test.ts"): LintContext {
7
+ const sourceFile = ts.createSourceFile(fileName, code, ts.ScriptTarget.Latest, true);
8
+ return { sourceFile, entities: [], filePath: fileName };
9
+ }
10
+
11
+ describe("GHA003: no-hardcoded-secrets", () => {
12
+ test("flags ghp_ prefix", () => {
13
+ const ctx = createContext(`const token = "ghp_1234567890abcdef";`);
14
+ const diags = noHardcodedSecretsRule.check(ctx);
15
+ expect(diags).toHaveLength(1);
16
+ expect(diags[0].ruleId).toBe("GHA003");
17
+ expect(diags[0].severity).toBe("error");
18
+ });
19
+
20
+ test("flags ghs_ prefix", () => {
21
+ const ctx = createContext(`const token = "ghs_abcdef1234567890";`);
22
+ const diags = noHardcodedSecretsRule.check(ctx);
23
+ expect(diags).toHaveLength(1);
24
+ });
25
+
26
+ test("does not flag normal strings", () => {
27
+ const ctx = createContext(`const name = "my-github-repo";`);
28
+ const diags = noHardcodedSecretsRule.check(ctx);
29
+ expect(diags).toHaveLength(0);
30
+ });
31
+ });
@@ -0,0 +1,31 @@
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 { noRawExpressionsRule } from "./no-raw-expressions";
5
+
6
+ function createContext(code: string, fileName = "test.ts"): LintContext {
7
+ const sourceFile = ts.createSourceFile(fileName, code, ts.ScriptTarget.Latest, true);
8
+ return { sourceFile, entities: [], filePath: fileName };
9
+ }
10
+
11
+ describe("GHA008: no-raw-expressions", () => {
12
+ test("flags unknown context in ${{ }}", () => {
13
+ const ctx = createContext(`const x = "\${{ custom.unknown }}";`);
14
+ const diags = noRawExpressionsRule.check(ctx);
15
+ expect(diags).toHaveLength(1);
16
+ expect(diags[0].ruleId).toBe("GHA008");
17
+ expect(diags[0].severity).toBe("info");
18
+ });
19
+
20
+ test("does not flag known contexts", () => {
21
+ const ctx = createContext(`const x = "\${{ github.ref }}";`);
22
+ const diags = noRawExpressionsRule.check(ctx);
23
+ expect(diags).toHaveLength(0);
24
+ });
25
+
26
+ test("does not flag secrets context", () => {
27
+ const ctx = createContext(`const x = "\${{ secrets.MY_TOKEN }}";`);
28
+ const diags = noRawExpressionsRule.check(ctx);
29
+ expect(diags).toHaveLength(0);
30
+ });
31
+ });
@@ -0,0 +1,25 @@
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 { suggestCacheRule } from "./suggest-cache";
5
+
6
+ function createContext(code: string, fileName = "test.ts"): LintContext {
7
+ const sourceFile = ts.createSourceFile(fileName, code, ts.ScriptTarget.Latest, true);
8
+ return { sourceFile, entities: [], filePath: fileName };
9
+ }
10
+
11
+ describe("GHA015: suggest-cache", () => {
12
+ test("flags SetupNode in steps without Cache", () => {
13
+ const ctx = createContext(`const j = new Job({ steps: [SetupNode({ nodeVersion: "22" })] });`);
14
+ const diags = suggestCacheRule.check(ctx);
15
+ expect(diags).toHaveLength(1);
16
+ expect(diags[0].ruleId).toBe("GHA015");
17
+ expect(diags[0].severity).toBe("warning");
18
+ });
19
+
20
+ test("does not flag SetupNode with cache prop", () => {
21
+ const ctx = createContext(`const j = new Job({ steps: [SetupNode({ nodeVersion: "22", cache: "npm" })] });`);
22
+ const diags = suggestCacheRule.check(ctx);
23
+ expect(diags).toHaveLength(0);
24
+ });
25
+ });
@@ -0,0 +1,31 @@
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 { useConditionBuildersRule } from "./use-condition-builders";
5
+
6
+ function createContext(code: string, fileName = "test.ts"): LintContext {
7
+ const sourceFile = ts.createSourceFile(fileName, code, ts.ScriptTarget.Latest, true);
8
+ return { sourceFile, entities: [], filePath: fileName };
9
+ }
10
+
11
+ describe("GHA002: use-condition-builders", () => {
12
+ test("flags ${{ in if property", () => {
13
+ const ctx = createContext(`const j = new Job({ if: "\${{ github.ref == 'refs/heads/main' }}" });`);
14
+ const diags = useConditionBuildersRule.check(ctx);
15
+ expect(diags).toHaveLength(1);
16
+ expect(diags[0].ruleId).toBe("GHA002");
17
+ expect(diags[0].severity).toBe("warning");
18
+ });
19
+
20
+ test("does not flag Expression object in if", () => {
21
+ const ctx = createContext(`const j = new Job({ if: branch("main") });`);
22
+ const diags = useConditionBuildersRule.check(ctx);
23
+ expect(diags).toHaveLength(0);
24
+ });
25
+
26
+ test("does not flag string without ${{ in if", () => {
27
+ const ctx = createContext(`const j = new Job({ if: "always()" });`);
28
+ const diags = useConditionBuildersRule.check(ctx);
29
+ expect(diags).toHaveLength(0);
30
+ });
31
+ });
@@ -0,0 +1,25 @@
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 { useMatrixBuilderRule } from "./use-matrix-builder";
5
+
6
+ function createContext(code: string, fileName = "test.ts"): LintContext {
7
+ const sourceFile = ts.createSourceFile(fileName, code, ts.ScriptTarget.Latest, true);
8
+ return { sourceFile, entities: [], filePath: fileName };
9
+ }
10
+
11
+ describe("GHA004: use-matrix-builder", () => {
12
+ test("flags inline matrix object", () => {
13
+ const ctx = createContext(`const s = new Strategy({ matrix: { "node-version": ["18", "20"] } });`);
14
+ const diags = useMatrixBuilderRule.check(ctx);
15
+ expect(diags).toHaveLength(1);
16
+ expect(diags[0].ruleId).toBe("GHA004");
17
+ expect(diags[0].severity).toBe("info");
18
+ });
19
+
20
+ test("does not flag matrix reference", () => {
21
+ const ctx = createContext(`const s = new Strategy({ matrix: myMatrix });`);
22
+ const diags = useMatrixBuilderRule.check(ctx);
23
+ expect(diags).toHaveLength(0);
24
+ });
25
+ });
@@ -0,0 +1,39 @@
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
+
6
+ function createContext(code: string, fileName = "test.ts"): LintContext {
7
+ const sourceFile = ts.createSourceFile(fileName, code, ts.ScriptTarget.Latest, true);
8
+ return { sourceFile, entities: [], filePath: fileName };
9
+ }
10
+
11
+ describe("GHA001: use-typed-actions", () => {
12
+ test("flags raw uses: string for known action", () => {
13
+ const ctx = createContext(`const s = new Step({ uses: "actions/checkout@v4" });`);
14
+ const diags = useTypedActionsRule.check(ctx);
15
+ expect(diags).toHaveLength(1);
16
+ expect(diags[0].ruleId).toBe("GHA001");
17
+ expect(diags[0].severity).toBe("warning");
18
+ expect(diags[0].message).toContain("Checkout");
19
+ });
20
+
21
+ test("flags actions/setup-node", () => {
22
+ const ctx = createContext(`const s = new Step({ uses: "actions/setup-node@v4" });`);
23
+ const diags = useTypedActionsRule.check(ctx);
24
+ expect(diags).toHaveLength(1);
25
+ expect(diags[0].message).toContain("SetupNode");
26
+ });
27
+
28
+ test("does not flag unknown action", () => {
29
+ const ctx = createContext(`const s = new Step({ uses: "custom/action@v1" });`);
30
+ const diags = useTypedActionsRule.check(ctx);
31
+ expect(diags).toHaveLength(0);
32
+ });
33
+
34
+ test("does not flag non-uses property", () => {
35
+ const ctx = createContext(`const s = new Step({ run: "npm test" });`);
36
+ const diags = useTypedActionsRule.check(ctx);
37
+ expect(diags).toHaveLength(0);
38
+ });
39
+ });
@@ -0,0 +1,31 @@
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 { validateConcurrencyRule } from "./validate-concurrency";
5
+
6
+ function createContext(code: string, fileName = "test.ts"): LintContext {
7
+ const sourceFile = ts.createSourceFile(fileName, code, ts.ScriptTarget.Latest, true);
8
+ return { sourceFile, entities: [], filePath: fileName };
9
+ }
10
+
11
+ describe("GHA016: validate-concurrency", () => {
12
+ test("flags cancelInProgress without group", () => {
13
+ const ctx = createContext(`const c = new Concurrency({ cancelInProgress: true });`);
14
+ const diags = validateConcurrencyRule.check(ctx);
15
+ expect(diags).toHaveLength(1);
16
+ expect(diags[0].ruleId).toBe("GHA016");
17
+ expect(diags[0].severity).toBe("warning");
18
+ });
19
+
20
+ test("does not flag cancelInProgress with group", () => {
21
+ const ctx = createContext(`const c = new Concurrency({ cancelInProgress: true, group: "ci-\${{ github.ref }}" });`);
22
+ const diags = validateConcurrencyRule.check(ctx);
23
+ expect(diags).toHaveLength(0);
24
+ });
25
+
26
+ test("does not flag concurrency without cancelInProgress", () => {
27
+ const ctx = createContext(`const c = new Concurrency({ group: "ci" });`);
28
+ const diags = validateConcurrencyRule.check(ctx);
29
+ expect(diags).toHaveLength(0);
30
+ });
31
+ });
@@ -24,7 +24,7 @@ describe("githubPlugin", () => {
24
24
 
25
25
  test("provides post-synth checks", () => {
26
26
  const checks = githubPlugin.postSynthChecks!();
27
- expect(checks.length).toBe(6);
27
+ expect(checks.length).toBe(15);
28
28
 
29
29
  const checkIds = checks.map((c) => c.id);
30
30
  expect(checkIds).toContain("GHA006");
package/src/plugin.ts CHANGED
@@ -54,7 +54,16 @@ export const githubPlugin: LexiconPlugin = {
54
54
  const { gha017 } = require("./lint/post-synth/gha017");
55
55
  const { gha018 } = require("./lint/post-synth/gha018");
56
56
  const { gha019 } = require("./lint/post-synth/gha019");
57
- return [gha006, gha009, gha011, gha017, gha018, gha019];
57
+ const { gha020 } = require("./lint/post-synth/gha020");
58
+ const { gha021 } = require("./lint/post-synth/gha021");
59
+ const { gha022 } = require("./lint/post-synth/gha022");
60
+ const { gha023 } = require("./lint/post-synth/gha023");
61
+ const { gha024 } = require("./lint/post-synth/gha024");
62
+ const { gha025 } = require("./lint/post-synth/gha025");
63
+ const { gha026 } = require("./lint/post-synth/gha026");
64
+ const { gha027 } = require("./lint/post-synth/gha027");
65
+ const { gha028 } = require("./lint/post-synth/gha028");
66
+ return [gha006, gha009, gha011, gha017, gha018, gha019, gha020, gha021, gha022, gha023, gha024, gha025, gha026, gha027, gha028];
58
67
  },
59
68
 
60
69
  intrinsics(): IntrinsicDef[] {
@@ -323,7 +332,7 @@ user-invocable: true
323
332
 
324
333
  ## How chant and GitHub Actions relate
325
334
 
326
- chant is a **synthesis-only** tool — it compiles TypeScript source files into \`.github/workflows/*.yml\` (YAML). chant does NOT call GitHub APIs.
335
+ chant is a **synthesis compiler** — it compiles TypeScript source files into \`.github/workflows/*.yml\` (YAML). \`chant build\` does not call GitHub APIs; synthesis is pure and deterministic.
327
336
 
328
337
  - Use **chant** for: build, lint, diff (local YAML comparison)
329
338
  - Use **git + GitHub** for: push, pull requests, workflow monitoring
@@ -387,6 +396,25 @@ git push
387
396
  },
388
397
  ],
389
398
  },
399
+ {
400
+ file: "github-actions-security.md",
401
+ name: "github-actions-security",
402
+ description: "GitHub Actions security — secret scanning, OIDC, permissions hardening, supply chain",
403
+ triggers: [
404
+ { type: "context" as const, value: "github security" },
405
+ { type: "context" as const, value: "workflow security" },
406
+ { type: "context" as const, value: "oidc" },
407
+ { type: "context" as const, value: "permissions" },
408
+ ],
409
+ parameters: [],
410
+ examples: [
411
+ {
412
+ title: "Permissions hardening",
413
+ input: "Lock down workflow permissions",
414
+ output: `new Workflow({ permissions: { contents: "read" } })`,
415
+ },
416
+ ],
417
+ },
390
418
  ];
391
419
 
392
420
  for (const skill of skillFiles) {