@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.
Files changed (106) hide show
  1. package/dist/integrity.json +31 -0
  2. package/dist/manifest.json +15 -0
  3. package/dist/meta.json +135 -0
  4. package/dist/rules/deprecated-action-version.ts +49 -0
  5. package/dist/rules/detect-secrets.ts +53 -0
  6. package/dist/rules/extract-inline-structs.ts +62 -0
  7. package/dist/rules/file-job-limit.ts +49 -0
  8. package/dist/rules/gha006.ts +58 -0
  9. package/dist/rules/gha009.ts +42 -0
  10. package/dist/rules/gha011.ts +40 -0
  11. package/dist/rules/gha017.ts +32 -0
  12. package/dist/rules/gha018.ts +40 -0
  13. package/dist/rules/gha019.ts +72 -0
  14. package/dist/rules/job-timeout.ts +59 -0
  15. package/dist/rules/missing-recommended-inputs.ts +61 -0
  16. package/dist/rules/no-hardcoded-secrets.ts +46 -0
  17. package/dist/rules/no-raw-expressions.ts +51 -0
  18. package/dist/rules/suggest-cache.ts +71 -0
  19. package/dist/rules/use-condition-builders.ts +45 -0
  20. package/dist/rules/use-matrix-builder.ts +44 -0
  21. package/dist/rules/use-typed-actions.ts +47 -0
  22. package/dist/rules/validate-concurrency.ts +66 -0
  23. package/dist/rules/yaml-helpers.ts +129 -0
  24. package/dist/skills/chant-github.md +29 -0
  25. package/dist/skills/github-actions-patterns.md +93 -0
  26. package/dist/types/index.d.ts +358 -0
  27. package/package.json +33 -0
  28. package/src/codegen/docs-cli.ts +3 -0
  29. package/src/codegen/docs.ts +1138 -0
  30. package/src/codegen/generate-cli.ts +36 -0
  31. package/src/codegen/generate-lexicon.ts +58 -0
  32. package/src/codegen/generate-typescript.ts +149 -0
  33. package/src/codegen/generate.ts +141 -0
  34. package/src/codegen/naming.ts +57 -0
  35. package/src/codegen/package.ts +65 -0
  36. package/src/codegen/parse.ts +700 -0
  37. package/src/codegen/patches.ts +46 -0
  38. package/src/composites/cache.ts +25 -0
  39. package/src/composites/checkout.ts +31 -0
  40. package/src/composites/composites.test.ts +675 -0
  41. package/src/composites/deploy-environment.ts +77 -0
  42. package/src/composites/docker-build.ts +120 -0
  43. package/src/composites/download-artifact.ts +24 -0
  44. package/src/composites/go-ci.ts +91 -0
  45. package/src/composites/index.ts +26 -0
  46. package/src/composites/node-ci.ts +71 -0
  47. package/src/composites/node-pipeline.ts +151 -0
  48. package/src/composites/python-ci.ts +92 -0
  49. package/src/composites/setup-go.ts +24 -0
  50. package/src/composites/setup-node.ts +26 -0
  51. package/src/composites/setup-python.ts +24 -0
  52. package/src/composites/upload-artifact.ts +27 -0
  53. package/src/coverage.ts +49 -0
  54. package/src/expression.test.ts +147 -0
  55. package/src/expression.ts +214 -0
  56. package/src/generated/index.d.ts +358 -0
  57. package/src/generated/index.ts +29 -0
  58. package/src/generated/lexicon-github.json +135 -0
  59. package/src/generated/runtime.ts +4 -0
  60. package/src/import/generator.test.ts +110 -0
  61. package/src/import/generator.ts +119 -0
  62. package/src/import/parser.test.ts +98 -0
  63. package/src/import/parser.ts +73 -0
  64. package/src/index.ts +53 -0
  65. package/src/lint/post-synth/gha006.ts +58 -0
  66. package/src/lint/post-synth/gha009.ts +42 -0
  67. package/src/lint/post-synth/gha011.ts +40 -0
  68. package/src/lint/post-synth/gha017.ts +32 -0
  69. package/src/lint/post-synth/gha018.ts +40 -0
  70. package/src/lint/post-synth/gha019.ts +72 -0
  71. package/src/lint/post-synth/post-synth.test.ts +318 -0
  72. package/src/lint/post-synth/yaml-helpers.ts +129 -0
  73. package/src/lint/rules/data/deprecated-versions.ts +13 -0
  74. package/src/lint/rules/data/known-actions.ts +13 -0
  75. package/src/lint/rules/data/recommended-inputs.ts +10 -0
  76. package/src/lint/rules/data/secret-patterns.ts +31 -0
  77. package/src/lint/rules/deprecated-action-version.ts +49 -0
  78. package/src/lint/rules/detect-secrets.ts +53 -0
  79. package/src/lint/rules/extract-inline-structs.ts +62 -0
  80. package/src/lint/rules/file-job-limit.ts +49 -0
  81. package/src/lint/rules/index.ts +17 -0
  82. package/src/lint/rules/job-timeout.ts +59 -0
  83. package/src/lint/rules/missing-recommended-inputs.ts +61 -0
  84. package/src/lint/rules/no-hardcoded-secrets.ts +46 -0
  85. package/src/lint/rules/no-raw-expressions.ts +51 -0
  86. package/src/lint/rules/rules.test.ts +365 -0
  87. package/src/lint/rules/suggest-cache.ts +71 -0
  88. package/src/lint/rules/use-condition-builders.ts +45 -0
  89. package/src/lint/rules/use-matrix-builder.ts +44 -0
  90. package/src/lint/rules/use-typed-actions.ts +47 -0
  91. package/src/lint/rules/validate-concurrency.ts +66 -0
  92. package/src/lsp/completions.test.ts +9 -0
  93. package/src/lsp/completions.ts +20 -0
  94. package/src/lsp/hover.test.ts +9 -0
  95. package/src/lsp/hover.ts +38 -0
  96. package/src/package-cli.ts +42 -0
  97. package/src/plugin.test.ts +128 -0
  98. package/src/plugin.ts +408 -0
  99. package/src/serializer.test.ts +270 -0
  100. package/src/serializer.ts +383 -0
  101. package/src/skills/github-actions-patterns.md +93 -0
  102. package/src/spec/fetch.ts +55 -0
  103. package/src/validate-cli.ts +19 -0
  104. package/src/validate.test.ts +12 -0
  105. package/src/validate.ts +32 -0
  106. package/src/variables.ts +44 -0
@@ -0,0 +1,318 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
3
+ import { gha006 } from "./gha006";
4
+ import { gha009 } from "./gha009";
5
+ import { gha011 } from "./gha011";
6
+ import { gha017 } from "./gha017";
7
+ import { gha018 } from "./gha018";
8
+ import { gha019 } from "./gha019";
9
+
10
+ // ── Helpers ─────────────────────────────────────────────────────────
11
+
12
+ function makeCtx(yaml: string): PostSynthContext {
13
+ return {
14
+ outputs: new Map([["github", yaml]]),
15
+ entities: new Map(),
16
+ buildResult: {
17
+ outputs: new Map([["github", yaml]]),
18
+ entities: new Map(),
19
+ warnings: [],
20
+ errors: [],
21
+ sourceFileCount: 1,
22
+ },
23
+ };
24
+ }
25
+
26
+ function makeMultiCtx(files: Record<string, string>): PostSynthContext {
27
+ const result = {
28
+ primary: Object.values(files)[0] ?? "",
29
+ files,
30
+ };
31
+ return {
32
+ outputs: new Map([["github", result as unknown as string]]),
33
+ entities: new Map(),
34
+ buildResult: {
35
+ outputs: new Map([["github", result as unknown as string]]),
36
+ entities: new Map(),
37
+ warnings: [],
38
+ errors: [],
39
+ sourceFileCount: 1,
40
+ },
41
+ };
42
+ }
43
+
44
+ // ── GHA006: Duplicate Workflow Name ─────────────────────────────────
45
+
46
+ describe("GHA006: duplicate workflow name", () => {
47
+ test("flags duplicate workflow names across files", () => {
48
+ const ctx = makeMultiCtx({
49
+ "ci.yml": `name: CI\non:\n push:\njobs:\n build:\n runs-on: ubuntu-latest\n`,
50
+ "deploy.yml": `name: CI\non:\n push:\njobs:\n deploy:\n runs-on: ubuntu-latest\n`,
51
+ });
52
+ const diags = gha006.check(ctx);
53
+ expect(diags).toHaveLength(1);
54
+ expect(diags[0].checkId).toBe("GHA006");
55
+ expect(diags[0].severity).toBe("error");
56
+ expect(diags[0].message).toContain("CI");
57
+ });
58
+
59
+ test("does not flag unique workflow names", () => {
60
+ const ctx = makeMultiCtx({
61
+ "ci.yml": `name: CI\non:\n push:\njobs:\n build:\n runs-on: ubuntu-latest\n`,
62
+ "deploy.yml": `name: Deploy\non:\n push:\njobs:\n deploy:\n runs-on: ubuntu-latest\n`,
63
+ });
64
+ const diags = gha006.check(ctx);
65
+ expect(diags).toHaveLength(0);
66
+ });
67
+ });
68
+
69
+ // ── GHA009: Empty Matrix Dimension ──────────────────────────────────
70
+
71
+ describe("GHA009: empty matrix dimension", () => {
72
+ test("flags empty matrix array", () => {
73
+ const yaml = `name: CI
74
+ on:
75
+ push:
76
+ jobs:
77
+ build:
78
+ runs-on: ubuntu-latest
79
+ strategy:
80
+ matrix:
81
+ node-version: []
82
+ steps:
83
+ - run: echo test
84
+ `;
85
+ const diags = gha009.check(makeCtx(yaml));
86
+ expect(diags).toHaveLength(1);
87
+ expect(diags[0].checkId).toBe("GHA009");
88
+ expect(diags[0].severity).toBe("error");
89
+ expect(diags[0].message).toContain("node-version");
90
+ });
91
+
92
+ test("does not flag non-empty matrix", () => {
93
+ const yaml = `name: CI
94
+ on:
95
+ push:
96
+ jobs:
97
+ build:
98
+ runs-on: ubuntu-latest
99
+ strategy:
100
+ matrix:
101
+ node-version: [18, 20, 22]
102
+ steps:
103
+ - run: echo test
104
+ `;
105
+ const diags = gha009.check(makeCtx(yaml));
106
+ expect(diags).toHaveLength(0);
107
+ });
108
+ });
109
+
110
+ // ── GHA011: Invalid Needs Reference ─────────────────────────────────
111
+
112
+ describe("GHA011: invalid needs reference", () => {
113
+ test("flags needs referencing non-existent job", () => {
114
+ const yaml = `name: CI
115
+ on:
116
+ push:
117
+ jobs:
118
+ build:
119
+ runs-on: ubuntu-latest
120
+ steps:
121
+ - run: echo build
122
+ deploy:
123
+ runs-on: ubuntu-latest
124
+ needs: [build, test]
125
+ steps:
126
+ - run: echo deploy
127
+ `;
128
+ const diags = gha011.check(makeCtx(yaml));
129
+ expect(diags).toHaveLength(1);
130
+ expect(diags[0].checkId).toBe("GHA011");
131
+ expect(diags[0].severity).toBe("error");
132
+ expect(diags[0].message).toContain("test");
133
+ expect(diags[0].message).toContain("deploy");
134
+ });
135
+
136
+ test("does not flag valid needs", () => {
137
+ const yaml = `name: CI
138
+ on:
139
+ push:
140
+ jobs:
141
+ build:
142
+ runs-on: ubuntu-latest
143
+ steps:
144
+ - run: echo build
145
+ deploy:
146
+ runs-on: ubuntu-latest
147
+ needs: [build]
148
+ steps:
149
+ - run: echo deploy
150
+ `;
151
+ const diags = gha011.check(makeCtx(yaml));
152
+ expect(diags).toHaveLength(0);
153
+ });
154
+ });
155
+
156
+ // ── GHA017: Missing Permissions ─────────────────────────────────────
157
+
158
+ describe("GHA017: missing permissions", () => {
159
+ test("flags workflow without permissions", () => {
160
+ const yaml = `name: CI
161
+ on:
162
+ push:
163
+ jobs:
164
+ build:
165
+ runs-on: ubuntu-latest
166
+ steps:
167
+ - run: echo test
168
+ `;
169
+ const diags = gha017.check(makeCtx(yaml));
170
+ expect(diags).toHaveLength(1);
171
+ expect(diags[0].checkId).toBe("GHA017");
172
+ expect(diags[0].severity).toBe("info");
173
+ });
174
+
175
+ test("does not flag workflow with permissions", () => {
176
+ const yaml = `name: CI
177
+ on:
178
+ push:
179
+ permissions:
180
+ contents: read
181
+ jobs:
182
+ build:
183
+ runs-on: ubuntu-latest
184
+ steps:
185
+ - run: echo test
186
+ `;
187
+ const diags = gha017.check(makeCtx(yaml));
188
+ expect(diags).toHaveLength(0);
189
+ });
190
+ });
191
+
192
+ // ── GHA018: PR Target + Checkout ────────────────────────────────────
193
+
194
+ describe("GHA018: pull_request_target + checkout", () => {
195
+ test("flags pull_request_target with checkout", () => {
196
+ const yaml = `name: CI
197
+ on:
198
+ pull_request_target:
199
+ jobs:
200
+ build:
201
+ runs-on: ubuntu-latest
202
+ steps:
203
+ - uses: actions/checkout@v4
204
+ - run: npm test
205
+ `;
206
+ const diags = gha018.check(makeCtx(yaml));
207
+ expect(diags).toHaveLength(1);
208
+ expect(diags[0].checkId).toBe("GHA018");
209
+ expect(diags[0].severity).toBe("warning");
210
+ expect(diags[0].message).toContain("checkout");
211
+ });
212
+
213
+ test("does not flag pull_request_target without checkout", () => {
214
+ const yaml = `name: CI
215
+ on:
216
+ pull_request_target:
217
+ jobs:
218
+ label:
219
+ runs-on: ubuntu-latest
220
+ steps:
221
+ - run: echo "label"
222
+ `;
223
+ const diags = gha018.check(makeCtx(yaml));
224
+ expect(diags).toHaveLength(0);
225
+ });
226
+
227
+ test("does not flag push trigger with checkout", () => {
228
+ const yaml = `name: CI
229
+ on:
230
+ push:
231
+ jobs:
232
+ build:
233
+ runs-on: ubuntu-latest
234
+ steps:
235
+ - uses: actions/checkout@v4
236
+ - run: npm test
237
+ `;
238
+ const diags = gha018.check(makeCtx(yaml));
239
+ expect(diags).toHaveLength(0);
240
+ });
241
+ });
242
+
243
+ // ── GHA019: Circular Needs ──────────────────────────────────────────
244
+
245
+ describe("GHA019: circular needs chain", () => {
246
+ test("detects simple cycle", () => {
247
+ const yaml = `name: CI
248
+ on:
249
+ push:
250
+ jobs:
251
+ build:
252
+ runs-on: ubuntu-latest
253
+ needs: [deploy]
254
+ steps:
255
+ - run: echo build
256
+ deploy:
257
+ runs-on: ubuntu-latest
258
+ needs: [build]
259
+ steps:
260
+ - run: echo deploy
261
+ `;
262
+ const diags = gha019.check(makeCtx(yaml));
263
+ expect(diags.length).toBeGreaterThanOrEqual(1);
264
+ expect(diags[0].checkId).toBe("GHA019");
265
+ expect(diags[0].severity).toBe("error");
266
+ expect(diags[0].message).toContain("→");
267
+ });
268
+
269
+ test("detects three-node cycle", () => {
270
+ const yaml = `name: CI
271
+ on:
272
+ push:
273
+ jobs:
274
+ a-job:
275
+ runs-on: ubuntu-latest
276
+ needs: [c-job]
277
+ steps:
278
+ - run: echo a
279
+ b-job:
280
+ runs-on: ubuntu-latest
281
+ needs: [a-job]
282
+ steps:
283
+ - run: echo b
284
+ c-job:
285
+ runs-on: ubuntu-latest
286
+ needs: [b-job]
287
+ steps:
288
+ - run: echo c
289
+ `;
290
+ const diags = gha019.check(makeCtx(yaml));
291
+ expect(diags.length).toBeGreaterThanOrEqual(1);
292
+ expect(diags[0].checkId).toBe("GHA019");
293
+ });
294
+
295
+ test("does not flag acyclic graph", () => {
296
+ const yaml = `name: CI
297
+ on:
298
+ push:
299
+ jobs:
300
+ build:
301
+ runs-on: ubuntu-latest
302
+ steps:
303
+ - run: echo build
304
+ test:
305
+ runs-on: ubuntu-latest
306
+ needs: [build]
307
+ steps:
308
+ - run: echo test
309
+ deploy:
310
+ runs-on: ubuntu-latest
311
+ needs: [test]
312
+ steps:
313
+ - run: echo deploy
314
+ `;
315
+ const diags = gha019.check(makeCtx(yaml));
316
+ expect(diags).toHaveLength(0);
317
+ });
318
+ });
@@ -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,13 @@
1
+ /**
2
+ * Deprecated action versions and recommended replacements.
3
+ */
4
+
5
+ export const deprecatedVersions: Record<string, { deprecated: string[]; recommended: string }> = {
6
+ "actions/checkout": { deprecated: ["v1", "v2", "v3"], recommended: "v4" },
7
+ "actions/setup-node": { deprecated: ["v1", "v2", "v3"], recommended: "v4" },
8
+ "actions/setup-go": { deprecated: ["v1", "v2", "v3", "v4"], recommended: "v5" },
9
+ "actions/setup-python": { deprecated: ["v1", "v2", "v3", "v4"], recommended: "v5" },
10
+ "actions/cache": { deprecated: ["v1", "v2", "v3"], recommended: "v4" },
11
+ "actions/upload-artifact": { deprecated: ["v1", "v2", "v3"], recommended: "v4" },
12
+ "actions/download-artifact": { deprecated: ["v1", "v2", "v3"], recommended: "v4" },
13
+ };
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Registry of well-known GitHub Actions and their typed composite wrappers.
3
+ */
4
+
5
+ export const knownActions: Record<string, { composite: string; importPath: string }> = {
6
+ "actions/checkout": { composite: "Checkout", importPath: "@intentius/chant-lexicon-github" },
7
+ "actions/setup-node": { composite: "SetupNode", importPath: "@intentius/chant-lexicon-github" },
8
+ "actions/setup-go": { composite: "SetupGo", importPath: "@intentius/chant-lexicon-github" },
9
+ "actions/setup-python": { composite: "SetupPython", importPath: "@intentius/chant-lexicon-github" },
10
+ "actions/cache": { composite: "Cache", importPath: "@intentius/chant-lexicon-github" },
11
+ "actions/upload-artifact": { composite: "UploadArtifact", importPath: "@intentius/chant-lexicon-github" },
12
+ "actions/download-artifact": { composite: "DownloadArtifact", importPath: "@intentius/chant-lexicon-github" },
13
+ };
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Recommended inputs for setup action composites.
3
+ * If a setup action is used without any of the listed inputs, a warning is raised.
4
+ */
5
+
6
+ export const recommendedInputs: Record<string, string[]> = {
7
+ SetupNode: ["nodeVersion", "node-version"],
8
+ SetupGo: ["goVersion", "go-version"],
9
+ SetupPython: ["pythonVersion", "python-version"],
10
+ };
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Regex patterns for detecting hardcoded secrets in source code.
3
+ */
4
+
5
+ export const secretPatterns: Array<{ pattern: RegExp; description: string }> = [
6
+ { pattern: /AKIA[0-9A-Z]{16}/, description: "AWS Access Key ID" },
7
+ { pattern: /-----BEGIN (RSA |EC |DSA )?PRIVATE KEY-----/, description: "Private Key" },
8
+ { pattern: /sk_live_[a-zA-Z0-9]{20,}/, description: "Stripe Live Secret Key" },
9
+ { pattern: /xox[bpors]-[a-zA-Z0-9-]{10,}/, description: "Slack Token" },
10
+ { pattern: /AIza[0-9A-Za-z_-]{35}/, description: "Google API Key" },
11
+ { pattern: /AC[a-z0-9]{32}/, description: "Twilio Account SID" },
12
+ { pattern: /SG\.[a-zA-Z0-9_-]{22}\.[a-zA-Z0-9_-]{43}/, description: "SendGrid API Key" },
13
+ { pattern: /key-[a-zA-Z0-9]{32}/, description: "Mailgun API Key" },
14
+ { pattern: /npm_[a-zA-Z0-9]{36}/, description: "NPM Token" },
15
+ { pattern: /pypi-[a-zA-Z0-9_-]{50,}/, description: "PyPI Token" },
16
+ { pattern: /eyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}/, description: "JWT Token" },
17
+ { pattern: /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/, description: "Heroku API Key (UUID)" },
18
+ { pattern: /dop_v1_[a-f0-9]{64}/, description: "DigitalOcean Personal Access Token" },
19
+ { pattern: /ghp_[a-zA-Z0-9]{36}/, description: "GitHub Personal Access Token" },
20
+ { pattern: /ghs_[a-zA-Z0-9]{36}/, description: "GitHub Server-to-Server Token" },
21
+ { pattern: /ghu_[a-zA-Z0-9]{36}/, description: "GitHub User-to-Server Token" },
22
+ { pattern: /ghr_[a-zA-Z0-9]{36}/, description: "GitHub Refresh Token" },
23
+ { pattern: /gho_[a-zA-Z0-9]{36}/, description: "GitHub OAuth Token" },
24
+ { pattern: /github_pat_[a-zA-Z0-9_]{22,}/, description: "GitHub Fine-grained PAT" },
25
+ { pattern: /glpat-[a-zA-Z0-9_-]{20,}/, description: "GitLab Personal Access Token" },
26
+ { pattern: /sk-[a-zA-Z0-9]{48}/, description: "OpenAI API Key" },
27
+ { pattern: /r8_[a-zA-Z0-9]{37}/, description: "Replicate API Token" },
28
+ { pattern: /sq0atp-[a-zA-Z0-9_-]{22}/, description: "Square Access Token" },
29
+ { pattern: /shpat_[a-fA-F0-9]{32}/, description: "Shopify Admin API Token" },
30
+ { pattern: /shr[a-z]{2}_[a-f0-9]{32}/, description: "Shopify Shared Secret" },
31
+ ];
@@ -0,0 +1,49 @@
1
+ /**
2
+ * GHA012: Deprecated Action Version
3
+ *
4
+ * Flags `uses:` strings referencing deprecated action versions.
5
+ */
6
+
7
+ import type { LintRule, LintDiagnostic, LintContext } from "@intentius/chant/lint/rule";
8
+ import * as ts from "typescript";
9
+ import { deprecatedVersions } from "./data/deprecated-versions";
10
+
11
+ export const deprecatedActionVersionRule: LintRule = {
12
+ id: "GHA012",
13
+ severity: "warning",
14
+ category: "correctness",
15
+ description: "Action version is deprecated — upgrade to the recommended version",
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.isStringLiteral(node)) {
23
+ const text = node.text;
24
+ const atIndex = text.indexOf("@");
25
+ if (atIndex === -1) { ts.forEachChild(node, visit); return; }
26
+
27
+ const actionName = text.slice(0, atIndex);
28
+ const version = text.slice(atIndex + 1);
29
+ const info = deprecatedVersions[actionName];
30
+
31
+ if (info && info.deprecated.includes(version)) {
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: "GHA012",
38
+ severity: "warning",
39
+ message: `"${text}" uses deprecated version ${version}. Upgrade to ${actionName}@${info.recommended}.`,
40
+ });
41
+ }
42
+ }
43
+ ts.forEachChild(node, visit);
44
+ }
45
+
46
+ visit(sourceFile);
47
+ return diagnostics;
48
+ },
49
+ };
@@ -0,0 +1,53 @@
1
+ /**
2
+ * GHA020: Detect Secrets
3
+ *
4
+ * Scans string literals for patterns matching known secret formats.
5
+ * Skips strings containing "secrets." (proper usage).
6
+ */
7
+
8
+ import type { LintRule, LintDiagnostic, LintContext } from "@intentius/chant/lint/rule";
9
+ import * as ts from "typescript";
10
+ import { secretPatterns } from "./data/secret-patterns";
11
+
12
+ export const detectSecretsRule: LintRule = {
13
+ id: "GHA020",
14
+ severity: "error",
15
+ category: "security",
16
+ description: "Potential secret detected 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
+
26
+ // Skip strings that reference secrets properly
27
+ if (text.includes("secrets.")) {
28
+ ts.forEachChild(node, visit);
29
+ return;
30
+ }
31
+
32
+ for (const { pattern, description } of secretPatterns) {
33
+ if (pattern.test(text)) {
34
+ const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart());
35
+ diagnostics.push({
36
+ file: sourceFile.fileName,
37
+ line: line + 1,
38
+ column: character + 1,
39
+ ruleId: "GHA020",
40
+ severity: "error",
41
+ message: `Potential ${description} detected. Use secrets() to reference secrets securely.`,
42
+ });
43
+ break; // One diagnostic per string
44
+ }
45
+ }
46
+ }
47
+ ts.forEachChild(node, visit);
48
+ }
49
+
50
+ visit(sourceFile);
51
+ return diagnostics;
52
+ },
53
+ };
@@ -0,0 +1,62 @@
1
+ /**
2
+ * GHA005: Extract Inline Structs
3
+ *
4
+ * Flags object literal nesting depth > 2 inside resource constructors.
5
+ */
6
+
7
+ import type { LintRule, LintDiagnostic, LintContext } from "@intentius/chant/lint/rule";
8
+ import * as ts from "typescript";
9
+
10
+ const RESOURCE_NAMES = new Set(["Job", "Workflow", "ReusableWorkflowCallJob"]);
11
+ const MAX_DEPTH = 2;
12
+
13
+ export const extractInlineStructsRule: LintRule = {
14
+ id: "GHA005",
15
+ severity: "info",
16
+ category: "style",
17
+ description: "Extract deeply nested inline objects to named constants",
18
+
19
+ check(context: LintContext): LintDiagnostic[] {
20
+ const { sourceFile } = context;
21
+ const diagnostics: LintDiagnostic[] = [];
22
+
23
+ function isResourceConstructor(node: ts.NewExpression): boolean {
24
+ if (ts.isIdentifier(node.expression)) return RESOURCE_NAMES.has(node.expression.text);
25
+ if (ts.isPropertyAccessExpression(node.expression)) return RESOURCE_NAMES.has(node.expression.name.text);
26
+ return false;
27
+ }
28
+
29
+ function checkDepth(node: ts.ObjectLiteralExpression, depth: number): void {
30
+ if (depth > MAX_DEPTH) {
31
+ const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart());
32
+ diagnostics.push({
33
+ file: sourceFile.fileName,
34
+ line: line + 1,
35
+ column: character + 1,
36
+ ruleId: "GHA005",
37
+ severity: "info",
38
+ message: `Object nesting depth ${depth} exceeds ${MAX_DEPTH}. Consider extracting to a named constant.`,
39
+ });
40
+ return;
41
+ }
42
+ for (const prop of node.properties) {
43
+ if (ts.isPropertyAssignment(prop) && ts.isObjectLiteralExpression(prop.initializer)) {
44
+ checkDepth(prop.initializer, depth + 1);
45
+ }
46
+ }
47
+ }
48
+
49
+ function visit(node: ts.Node): void {
50
+ if (ts.isNewExpression(node) && isResourceConstructor(node) && node.arguments?.[0]) {
51
+ const arg = node.arguments[0];
52
+ if (ts.isObjectLiteralExpression(arg)) {
53
+ checkDepth(arg, 1);
54
+ }
55
+ }
56
+ ts.forEachChild(node, visit);
57
+ }
58
+
59
+ visit(sourceFile);
60
+ return diagnostics;
61
+ },
62
+ };