@intentius/chant-lexicon-github 0.0.18 → 0.0.24

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 (78) 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/{github-actions-patterns.md → chant-github-patterns.md} +2 -1
  13. package/dist/skills/chant-github-security.md +88 -0
  14. package/dist/skills/chant-github.md +569 -4
  15. package/package.json +20 -2
  16. package/src/codegen/docs.test.ts +19 -0
  17. package/src/codegen/generate.test.ts +12 -0
  18. package/src/codegen/package.test.ts +8 -0
  19. package/src/composites/cache.ts +8 -3
  20. package/src/composites/checkout.ts +8 -3
  21. package/src/composites/composites.test.ts +106 -0
  22. package/src/composites/deploy-environment.ts +11 -5
  23. package/src/composites/docker-build.ts +11 -5
  24. package/src/composites/download-artifact.ts +8 -3
  25. package/src/composites/go-ci.ts +17 -9
  26. package/src/composites/node-ci.ts +11 -5
  27. package/src/composites/node-pipeline.ts +14 -7
  28. package/src/composites/python-ci.ts +14 -7
  29. package/src/composites/setup-go.ts +8 -3
  30. package/src/composites/setup-node.ts +8 -3
  31. package/src/composites/setup-python.ts +8 -3
  32. package/src/composites/upload-artifact.ts +8 -3
  33. package/src/coverage.test.ts +23 -0
  34. package/src/import/roundtrip.test.ts +206 -0
  35. package/src/lint/post-synth/gha006.test.ts +56 -0
  36. package/src/lint/post-synth/gha009.test.ts +56 -0
  37. package/src/lint/post-synth/gha011.test.ts +61 -0
  38. package/src/lint/post-synth/gha017.test.ts +51 -0
  39. package/src/lint/post-synth/gha018.test.ts +66 -0
  40. package/src/lint/post-synth/gha019.test.ts +66 -0
  41. package/src/lint/post-synth/gha020.test.ts +66 -0
  42. package/src/lint/post-synth/gha020.ts +40 -0
  43. package/src/lint/post-synth/gha021.test.ts +67 -0
  44. package/src/lint/post-synth/gha021.ts +48 -0
  45. package/src/lint/post-synth/gha022.test.ts +45 -0
  46. package/src/lint/post-synth/gha022.ts +50 -0
  47. package/src/lint/post-synth/gha023.test.ts +52 -0
  48. package/src/lint/post-synth/gha023.ts +44 -0
  49. package/src/lint/post-synth/gha024.test.ts +67 -0
  50. package/src/lint/post-synth/gha024.ts +42 -0
  51. package/src/lint/post-synth/gha025.test.ts +65 -0
  52. package/src/lint/post-synth/gha025.ts +42 -0
  53. package/src/lint/post-synth/gha026.test.ts +65 -0
  54. package/src/lint/post-synth/gha026.ts +40 -0
  55. package/src/lint/post-synth/gha027.test.ts +48 -0
  56. package/src/lint/post-synth/gha027.ts +57 -0
  57. package/src/lint/post-synth/gha028.test.ts +48 -0
  58. package/src/lint/post-synth/gha028.ts +37 -0
  59. package/src/lint/rules/deprecated-action-version.test.ts +26 -0
  60. package/src/lint/rules/detect-secrets.test.ts +25 -0
  61. package/src/lint/rules/extract-inline-structs.test.ts +25 -0
  62. package/src/lint/rules/file-job-limit.test.ts +28 -0
  63. package/src/lint/rules/job-timeout.test.ts +31 -0
  64. package/src/lint/rules/missing-recommended-inputs.test.ts +26 -0
  65. package/src/lint/rules/no-hardcoded-secrets.test.ts +31 -0
  66. package/src/lint/rules/no-raw-expressions.test.ts +31 -0
  67. package/src/lint/rules/suggest-cache.test.ts +25 -0
  68. package/src/lint/rules/use-condition-builders.test.ts +31 -0
  69. package/src/lint/rules/use-matrix-builder.test.ts +25 -0
  70. package/src/lint/rules/use-typed-actions.test.ts +39 -0
  71. package/src/lint/rules/validate-concurrency.test.ts +31 -0
  72. package/src/plugin.test.ts +1 -1
  73. package/src/plugin.ts +70 -145
  74. package/src/skills/{github-actions-patterns.md → chant-github-patterns.md} +2 -1
  75. package/src/skills/chant-github-security.md +88 -0
  76. package/src/skills/chant-github.md +594 -0
  77. package/src/validate.ts +14 -1
  78. package/src/variables.test.ts +48 -0
@@ -1,13 +1,18 @@
1
- import { Composite } from "@intentius/chant";
1
+ import { Composite, mergeDefaults } from "@intentius/chant";
2
+ import type { Step } from "../generated/index";
2
3
 
3
4
  export interface UploadArtifactProps {
4
5
  name: string;
5
6
  path: string;
6
7
  retentionDays?: number;
7
8
  compressionLevel?: number;
9
+ defaults?: {
10
+ step?: Partial<ConstructorParameters<typeof Step>[0]>;
11
+ };
8
12
  }
9
13
 
10
14
  export const UploadArtifact = Composite<UploadArtifactProps>((props) => {
15
+ const { defaults } = props;
11
16
  const withObj: Record<string, string> = {
12
17
  name: props.name,
13
18
  path: props.path,
@@ -17,11 +22,11 @@ export const UploadArtifact = Composite<UploadArtifactProps>((props) => {
17
22
 
18
23
  const { createProperty } = require("@intentius/chant/runtime");
19
24
  const StepClass = createProperty("GitHub::Actions::Step", "github");
20
- const step = new StepClass({
25
+ const step = new StepClass(mergeDefaults({
21
26
  name: "Upload Artifact",
22
27
  uses: "actions/upload-artifact@v4",
23
28
  with: withObj,
24
- });
29
+ }, defaults?.step));
25
30
 
26
31
  return { step };
27
32
  }, "UploadArtifact");
@@ -0,0 +1,23 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import {
3
+ analyzeGitHubCoverage,
4
+ computeCoverage,
5
+ overallPct,
6
+ formatSummary,
7
+ formatVerbose,
8
+ checkThresholds,
9
+ } from "./coverage";
10
+
11
+ describe("coverage module", () => {
12
+ test("analyzeGitHubCoverage is exported and callable", () => {
13
+ expect(typeof analyzeGitHubCoverage).toBe("function");
14
+ });
15
+
16
+ test("re-exports from core are functions", () => {
17
+ expect(typeof computeCoverage).toBe("function");
18
+ expect(typeof overallPct).toBe("function");
19
+ expect(typeof formatSummary).toBe("function");
20
+ expect(typeof formatVerbose).toBe("function");
21
+ expect(typeof checkThresholds).toBe("function");
22
+ });
23
+ });
@@ -0,0 +1,206 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { GitHubActionsParser } from "./parser";
3
+ import { GitHubActionsGenerator } from "./generator";
4
+
5
+ const parser = new GitHubActionsParser();
6
+ const generator = new GitHubActionsGenerator();
7
+
8
+ describe("roundtrip: parse → generate", () => {
9
+ test("simple CI workflow roundtrip", () => {
10
+ const yaml = `
11
+ name: CI
12
+ on:
13
+ push:
14
+ branches: [main]
15
+ jobs:
16
+ build:
17
+ runs-on: ubuntu-latest
18
+ steps:
19
+ - uses: actions/checkout@v4
20
+ - run: npm ci
21
+ - run: npm test
22
+ `;
23
+ const ir = parser.parse(yaml);
24
+ const files = generator.generate(ir);
25
+
26
+ expect(files).toHaveLength(1);
27
+ const content = files[0].content;
28
+
29
+ expect(content).toContain("import");
30
+ expect(content).toContain("export const");
31
+ expect(content).toContain("new Workflow(");
32
+ expect(content).toContain("new Job(");
33
+ expect(content).toContain("CI");
34
+ expect(content).toContain("npm ci");
35
+ expect(content).toContain("npm test");
36
+ });
37
+
38
+ test("multi-job workflow roundtrip", () => {
39
+ const yaml = `
40
+ name: Build and Deploy
41
+ on:
42
+ push:
43
+ branches: [main]
44
+ pull_request:
45
+ branches: [main]
46
+ permissions:
47
+ contents: read
48
+ jobs:
49
+ build:
50
+ runs-on: ubuntu-latest
51
+ steps:
52
+ - uses: actions/checkout@v4
53
+ - uses: actions/setup-node@v4
54
+ with:
55
+ node-version: '22'
56
+ - run: npm ci
57
+ - run: npm run build
58
+ test:
59
+ runs-on: ubuntu-latest
60
+ needs: [build]
61
+ steps:
62
+ - uses: actions/checkout@v4
63
+ - run: npm test
64
+ deploy:
65
+ runs-on: ubuntu-latest
66
+ needs: [test]
67
+ steps:
68
+ - run: echo "deploy"
69
+ `;
70
+ const ir = parser.parse(yaml);
71
+ expect(ir.resources.length).toBeGreaterThanOrEqual(3);
72
+
73
+ const files = generator.generate(ir);
74
+ const content = files[0].content;
75
+
76
+ expect(content).toContain("new Workflow(");
77
+ expect(content).toContain("new Job(");
78
+ expect(content).toContain("build");
79
+ expect(content).toContain("test");
80
+ expect(content).toContain("deploy");
81
+ expect(content).toContain("needs");
82
+ });
83
+
84
+ test("matrix strategy roundtrip", () => {
85
+ const yaml = `
86
+ name: Matrix
87
+ on:
88
+ push:
89
+ jobs:
90
+ test:
91
+ runs-on: ubuntu-latest
92
+ strategy:
93
+ matrix:
94
+ node-version: [18, 20, 22]
95
+ fail-fast: false
96
+ steps:
97
+ - uses: actions/checkout@v4
98
+ - run: npm test
99
+ `;
100
+ const ir = parser.parse(yaml);
101
+ const files = generator.generate(ir);
102
+ const content = files[0].content;
103
+
104
+ expect(content).toContain("matrix");
105
+ expect(content).toContain("node-version");
106
+ });
107
+
108
+ test("concurrency and permissions roundtrip", () => {
109
+ const yaml = `
110
+ name: Deploy
111
+ on:
112
+ push:
113
+ branches: [main]
114
+ permissions:
115
+ contents: read
116
+ pages: write
117
+ concurrency:
118
+ group: deploy
119
+ cancel-in-progress: true
120
+ jobs:
121
+ deploy:
122
+ runs-on: ubuntu-latest
123
+ steps:
124
+ - run: echo deploy
125
+ `;
126
+ const ir = parser.parse(yaml);
127
+ const files = generator.generate(ir);
128
+ const content = files[0].content;
129
+
130
+ expect(content).toContain("concurrency");
131
+ expect(content).toContain("permissions");
132
+ expect(content).toContain("deploy");
133
+ });
134
+
135
+ test("reusable workflow call roundtrip", () => {
136
+ const yaml = `
137
+ name: Caller
138
+ on:
139
+ push:
140
+ jobs:
141
+ call-deploy:
142
+ uses: ./.github/workflows/deploy.yml
143
+ with:
144
+ environment: production
145
+ secrets: inherit
146
+ `;
147
+ const ir = parser.parse(yaml);
148
+ const files = generator.generate(ir);
149
+ const content = files[0].content;
150
+
151
+ expect(content).toContain("Workflow");
152
+ expect(content).toContain("export const");
153
+ });
154
+
155
+ test("workflow dispatch with inputs roundtrip", () => {
156
+ const yaml = `
157
+ name: Manual Deploy
158
+ on:
159
+ workflow_dispatch:
160
+ inputs:
161
+ environment:
162
+ description: "Target environment"
163
+ required: true
164
+ type: choice
165
+ options:
166
+ - staging
167
+ - production
168
+ jobs:
169
+ deploy:
170
+ runs-on: ubuntu-latest
171
+ steps:
172
+ - run: echo deploying
173
+ `;
174
+ const ir = parser.parse(yaml);
175
+ const files = generator.generate(ir);
176
+ const content = files[0].content;
177
+
178
+ expect(content).toContain("import");
179
+ expect(content).toContain("export const");
180
+ expect(content).toContain("Manual Deploy");
181
+ expect(content).toContain("workflow_dispatch");
182
+ });
183
+
184
+ test("environment protection roundtrip", () => {
185
+ const yaml = `
186
+ name: Production Deploy
187
+ on:
188
+ push:
189
+ branches: [main]
190
+ jobs:
191
+ deploy:
192
+ runs-on: ubuntu-latest
193
+ environment:
194
+ name: production
195
+ url: https://example.com
196
+ steps:
197
+ - run: deploy.sh
198
+ `;
199
+ const ir = parser.parse(yaml);
200
+ const files = generator.generate(ir);
201
+ const content = files[0].content;
202
+
203
+ expect(content).toContain("environment");
204
+ expect(content).toContain("production");
205
+ });
206
+ });
@@ -0,0 +1,56 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
3
+ import type { SerializerResult } from "@intentius/chant/serializer";
4
+ import { gha006 } from "./gha006";
5
+
6
+ function makeMultiCtx(files: Record<string, string>): PostSynthContext {
7
+ const result = {
8
+ primary: Object.values(files)[0] ?? "",
9
+ files,
10
+ };
11
+ return {
12
+ outputs: new Map([["github", result as unknown as string]]),
13
+ entities: new Map(),
14
+ buildResult: {
15
+ outputs: new Map([["github", result as unknown as string]]),
16
+ entities: new Map(),
17
+ warnings: [],
18
+ errors: [],
19
+ sourceFileCount: 1,
20
+ },
21
+ };
22
+ }
23
+
24
+ describe("GHA006: duplicate workflow name", () => {
25
+ test("flags duplicate workflow names across files", () => {
26
+ const ctx = makeMultiCtx({
27
+ "ci.yml": `name: CI\non:\n push:\njobs:\n build:\n runs-on: ubuntu-latest\n`,
28
+ "deploy.yml": `name: CI\non:\n push:\njobs:\n deploy:\n runs-on: ubuntu-latest\n`,
29
+ });
30
+ const diags = gha006.check(ctx);
31
+ expect(diags).toHaveLength(1);
32
+ expect(diags[0].checkId).toBe("GHA006");
33
+ expect(diags[0].severity).toBe("error");
34
+ expect(diags[0].message).toContain("CI");
35
+ });
36
+
37
+ test("does not flag unique workflow names", () => {
38
+ const ctx = makeMultiCtx({
39
+ "ci.yml": `name: CI\non:\n push:\njobs:\n build:\n runs-on: ubuntu-latest\n`,
40
+ "deploy.yml": `name: Deploy\non:\n push:\njobs:\n deploy:\n runs-on: ubuntu-latest\n`,
41
+ });
42
+ const diags = gha006.check(ctx);
43
+ expect(diags).toHaveLength(0);
44
+ });
45
+
46
+ test("flags three files with same name", () => {
47
+ const ctx = makeMultiCtx({
48
+ "a.yml": `name: Build\non:\n push:\njobs:\n a:\n runs-on: ubuntu-latest\n`,
49
+ "b.yml": `name: Build\non:\n push:\njobs:\n b:\n runs-on: ubuntu-latest\n`,
50
+ "c.yml": `name: Build\non:\n push:\njobs:\n c:\n runs-on: ubuntu-latest\n`,
51
+ });
52
+ const diags = gha006.check(ctx);
53
+ expect(diags).toHaveLength(1);
54
+ expect(diags[0].message).toContain("Build");
55
+ });
56
+ });
@@ -0,0 +1,56 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
3
+ import { gha009 } from "./gha009";
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("GHA009: empty matrix dimension", () => {
20
+ test("flags empty matrix array", () => {
21
+ const yaml = `name: CI
22
+ on:
23
+ push:
24
+ jobs:
25
+ build:
26
+ runs-on: ubuntu-latest
27
+ strategy:
28
+ matrix:
29
+ node-version: []
30
+ steps:
31
+ - run: echo test
32
+ `;
33
+ const diags = gha009.check(makeCtx(yaml));
34
+ expect(diags).toHaveLength(1);
35
+ expect(diags[0].checkId).toBe("GHA009");
36
+ expect(diags[0].severity).toBe("error");
37
+ expect(diags[0].message).toContain("node-version");
38
+ });
39
+
40
+ test("does not flag non-empty matrix", () => {
41
+ const yaml = `name: CI
42
+ on:
43
+ push:
44
+ jobs:
45
+ build:
46
+ runs-on: ubuntu-latest
47
+ strategy:
48
+ matrix:
49
+ node-version: [18, 20, 22]
50
+ steps:
51
+ - run: echo test
52
+ `;
53
+ const diags = gha009.check(makeCtx(yaml));
54
+ expect(diags).toHaveLength(0);
55
+ });
56
+ });
@@ -0,0 +1,61 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
3
+ import { gha011 } from "./gha011";
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("GHA011: invalid needs reference", () => {
20
+ test("flags needs referencing non-existent job", () => {
21
+ const yaml = `name: CI
22
+ on:
23
+ push:
24
+ jobs:
25
+ build:
26
+ runs-on: ubuntu-latest
27
+ steps:
28
+ - run: echo build
29
+ deploy:
30
+ runs-on: ubuntu-latest
31
+ needs: [build, test]
32
+ steps:
33
+ - run: echo deploy
34
+ `;
35
+ const diags = gha011.check(makeCtx(yaml));
36
+ expect(diags).toHaveLength(1);
37
+ expect(diags[0].checkId).toBe("GHA011");
38
+ expect(diags[0].severity).toBe("error");
39
+ expect(diags[0].message).toContain("test");
40
+ expect(diags[0].message).toContain("deploy");
41
+ });
42
+
43
+ test("does not flag valid needs", () => {
44
+ const yaml = `name: CI
45
+ on:
46
+ push:
47
+ jobs:
48
+ build:
49
+ runs-on: ubuntu-latest
50
+ steps:
51
+ - run: echo build
52
+ deploy:
53
+ runs-on: ubuntu-latest
54
+ needs: [build]
55
+ steps:
56
+ - run: echo deploy
57
+ `;
58
+ const diags = gha011.check(makeCtx(yaml));
59
+ expect(diags).toHaveLength(0);
60
+ });
61
+ });
@@ -0,0 +1,51 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
3
+ import { gha017 } from "./gha017";
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("GHA017: missing permissions", () => {
20
+ test("flags workflow without permissions", () => {
21
+ const yaml = `name: CI
22
+ on:
23
+ push:
24
+ jobs:
25
+ build:
26
+ runs-on: ubuntu-latest
27
+ steps:
28
+ - run: echo test
29
+ `;
30
+ const diags = gha017.check(makeCtx(yaml));
31
+ expect(diags).toHaveLength(1);
32
+ expect(diags[0].checkId).toBe("GHA017");
33
+ expect(diags[0].severity).toBe("info");
34
+ });
35
+
36
+ test("does not flag workflow with permissions", () => {
37
+ const yaml = `name: CI
38
+ on:
39
+ push:
40
+ permissions:
41
+ contents: read
42
+ jobs:
43
+ build:
44
+ runs-on: ubuntu-latest
45
+ steps:
46
+ - run: echo test
47
+ `;
48
+ const diags = gha017.check(makeCtx(yaml));
49
+ expect(diags).toHaveLength(0);
50
+ });
51
+ });
@@ -0,0 +1,66 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
3
+ import { gha018 } from "./gha018";
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("GHA018: pull_request_target + checkout", () => {
20
+ test("flags pull_request_target with checkout", () => {
21
+ const yaml = `name: CI
22
+ on:
23
+ pull_request_target:
24
+ jobs:
25
+ build:
26
+ runs-on: ubuntu-latest
27
+ steps:
28
+ - uses: actions/checkout@v4
29
+ - run: npm test
30
+ `;
31
+ const diags = gha018.check(makeCtx(yaml));
32
+ expect(diags).toHaveLength(1);
33
+ expect(diags[0].checkId).toBe("GHA018");
34
+ expect(diags[0].severity).toBe("warning");
35
+ expect(diags[0].message).toContain("checkout");
36
+ });
37
+
38
+ test("does not flag pull_request_target without checkout", () => {
39
+ const yaml = `name: CI
40
+ on:
41
+ pull_request_target:
42
+ jobs:
43
+ label:
44
+ runs-on: ubuntu-latest
45
+ steps:
46
+ - run: echo "label"
47
+ `;
48
+ const diags = gha018.check(makeCtx(yaml));
49
+ expect(diags).toHaveLength(0);
50
+ });
51
+
52
+ test("does not flag push trigger with checkout", () => {
53
+ const yaml = `name: CI
54
+ on:
55
+ push:
56
+ jobs:
57
+ build:
58
+ runs-on: ubuntu-latest
59
+ steps:
60
+ - uses: actions/checkout@v4
61
+ - run: npm test
62
+ `;
63
+ const diags = gha018.check(makeCtx(yaml));
64
+ expect(diags).toHaveLength(0);
65
+ });
66
+ });
@@ -0,0 +1,66 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
3
+ import { gha019 } from "./gha019";
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("GHA019: circular needs chain", () => {
20
+ test("detects simple cycle", () => {
21
+ const yaml = `name: CI
22
+ on:
23
+ push:
24
+ jobs:
25
+ build:
26
+ runs-on: ubuntu-latest
27
+ needs: [deploy]
28
+ steps:
29
+ - run: echo build
30
+ deploy:
31
+ runs-on: ubuntu-latest
32
+ needs: [build]
33
+ steps:
34
+ - run: echo deploy
35
+ `;
36
+ const diags = gha019.check(makeCtx(yaml));
37
+ expect(diags.length).toBeGreaterThanOrEqual(1);
38
+ expect(diags[0].checkId).toBe("GHA019");
39
+ expect(diags[0].severity).toBe("error");
40
+ expect(diags[0].message).toContain("\u2192");
41
+ });
42
+
43
+ test("does not flag acyclic graph", () => {
44
+ const yaml = `name: CI
45
+ on:
46
+ push:
47
+ jobs:
48
+ build:
49
+ runs-on: ubuntu-latest
50
+ steps:
51
+ - run: echo build
52
+ test:
53
+ runs-on: ubuntu-latest
54
+ needs: [build]
55
+ steps:
56
+ - run: echo test
57
+ deploy:
58
+ runs-on: ubuntu-latest
59
+ needs: [test]
60
+ steps:
61
+ - run: echo deploy
62
+ `;
63
+ const diags = gha019.check(makeCtx(yaml));
64
+ expect(diags).toHaveLength(0);
65
+ });
66
+ });
@@ -0,0 +1,66 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
3
+ import { gha020 } from "./gha020";
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("GHA020: missing job-level permissions for sensitive triggers", () => {
20
+ test("flags job without permissions when using pull_request_target", () => {
21
+ const yaml = `name: Review
22
+ on:
23
+ pull_request_target:
24
+ jobs:
25
+ review:
26
+ runs-on: ubuntu-latest
27
+ steps:
28
+ - run: echo review
29
+ `;
30
+ const diags = gha020.check(makeCtx(yaml));
31
+ expect(diags).toHaveLength(1);
32
+ expect(diags[0].checkId).toBe("GHA020");
33
+ expect(diags[0].severity).toBe("warning");
34
+ expect(diags[0].message).toContain("review");
35
+ });
36
+
37
+ test("flags job without permissions when using workflow_dispatch", () => {
38
+ const yaml = `name: Manual
39
+ on:
40
+ workflow_dispatch:
41
+ jobs:
42
+ deploy:
43
+ runs-on: ubuntu-latest
44
+ steps:
45
+ - run: echo deploy
46
+ `;
47
+ const diags = gha020.check(makeCtx(yaml));
48
+ expect(diags).toHaveLength(1);
49
+ expect(diags[0].checkId).toBe("GHA020");
50
+ expect(diags[0].message).toContain("deploy");
51
+ });
52
+
53
+ test("does not flag when trigger is not sensitive", () => {
54
+ const yaml = `name: CI
55
+ on:
56
+ push:
57
+ jobs:
58
+ build:
59
+ runs-on: ubuntu-latest
60
+ steps:
61
+ - run: echo build
62
+ `;
63
+ const diags = gha020.check(makeCtx(yaml));
64
+ expect(diags).toHaveLength(0);
65
+ });
66
+ });