@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,119 @@
1
+ /**
2
+ * TypeScript code generator for GitHub Actions import.
3
+ *
4
+ * Converts a TemplateIR into TypeScript source code using
5
+ * the @intentius/chant-lexicon-github constructors.
6
+ */
7
+
8
+ import type { TypeScriptGenerator, GeneratedFile } from "@intentius/chant/import/generator";
9
+ import type { TemplateIR } from "@intentius/chant/import/parser";
10
+
11
+ const TYPE_TO_CLASS: Record<string, string> = {
12
+ "GitHub::Actions::Workflow": "Workflow",
13
+ "GitHub::Actions::Job": "Job",
14
+ "GitHub::Actions::ReusableWorkflowCallJob": "ReusableWorkflowCallJob",
15
+ };
16
+
17
+ const PROPERTY_CONSTRUCTORS: Record<string, string> = {
18
+ strategy: "Strategy",
19
+ concurrency: "Concurrency",
20
+ container: "Container",
21
+ environment: "Environment",
22
+ permissions: "Permissions",
23
+ defaults: "Defaults",
24
+ };
25
+
26
+ /**
27
+ * Generate TypeScript source code from a GitHub Actions IR.
28
+ */
29
+ export class GitHubActionsGenerator implements TypeScriptGenerator {
30
+ generate(ir: TemplateIR): GeneratedFile[] {
31
+ const lines: string[] = [];
32
+
33
+ const usedConstructors = new Set<string>();
34
+ for (const resource of ir.resources) {
35
+ const cls = TYPE_TO_CLASS[resource.type];
36
+ if (cls) usedConstructors.add(cls);
37
+ this.collectNestedConstructors(resource.properties, usedConstructors);
38
+ }
39
+
40
+ const imports = [...usedConstructors].sort().join(", ");
41
+ lines.push(`import { ${imports} } from "@intentius/chant-lexicon-github";`);
42
+ lines.push("");
43
+
44
+ for (const resource of ir.resources) {
45
+ const cls = TYPE_TO_CLASS[resource.type];
46
+ if (!cls) continue;
47
+
48
+ const varName = resource.logicalId;
49
+ const propsStr = this.emitProps(resource.properties, 1);
50
+ lines.push(`export const ${varName} = new ${cls}(${propsStr});`);
51
+ lines.push("");
52
+ }
53
+
54
+ return [{ path: "main.ts", content: lines.join("\n") }];
55
+ }
56
+
57
+ private collectNestedConstructors(props: Record<string, unknown>, used: Set<string>): void {
58
+ for (const [key, value] of Object.entries(props)) {
59
+ const constructor = PROPERTY_CONSTRUCTORS[key];
60
+ if (constructor && typeof value === "object" && value !== null && !Array.isArray(value)) {
61
+ used.add(constructor);
62
+ }
63
+ }
64
+ }
65
+
66
+ private emitProps(props: Record<string, unknown>, depth: number): string {
67
+ const indent = " ".repeat(depth);
68
+ const innerIndent = " ".repeat(depth + 1);
69
+ const entries: string[] = [];
70
+
71
+ for (const [key, value] of Object.entries(props)) {
72
+ if (value === undefined || value === null) continue;
73
+ const emitted = this.emitValue(key, value, depth + 1);
74
+ entries.push(`${innerIndent}${JSON.stringify(key)}: ${emitted},`);
75
+ }
76
+
77
+ if (entries.length === 0) return "{}";
78
+ return `{\n${entries.join("\n")}\n${indent}}`;
79
+ }
80
+
81
+ private emitValue(key: string, value: unknown, depth: number): string {
82
+ if (value === null || value === undefined) return "undefined";
83
+
84
+ const constructor = PROPERTY_CONSTRUCTORS[key];
85
+ if (constructor && typeof value === "object" && !Array.isArray(value)) {
86
+ const propsStr = this.emitProps(value as Record<string, unknown>, depth);
87
+ return `new ${constructor}(${propsStr})`;
88
+ }
89
+
90
+ return this.emitLiteral(value, depth);
91
+ }
92
+
93
+ private emitLiteral(value: unknown, depth: number): string {
94
+ if (value === null || value === undefined) return "undefined";
95
+ if (typeof value === "string") return JSON.stringify(value);
96
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
97
+
98
+ if (Array.isArray(value)) {
99
+ if (value.length === 0) return "[]";
100
+ const items = value.map((item) => this.emitLiteral(item, depth + 1));
101
+ const oneLine = `[${items.join(", ")}]`;
102
+ if (oneLine.length < 80) return oneLine;
103
+ const indent = " ".repeat(depth);
104
+ const innerIndent = " ".repeat(depth + 1);
105
+ return `[\n${items.map((i) => `${innerIndent}${i},`).join("\n")}\n${indent}]`;
106
+ }
107
+
108
+ if (typeof value === "object") {
109
+ const entries = Object.entries(value as Record<string, unknown>);
110
+ if (entries.length === 0) return "{}";
111
+ const indent = " ".repeat(depth);
112
+ const innerIndent = " ".repeat(depth + 1);
113
+ const items = entries.map(([k, v]) => `${innerIndent}${JSON.stringify(k)}: ${this.emitLiteral(v, depth + 1)},`);
114
+ return `{\n${items.join("\n")}\n${indent}}`;
115
+ }
116
+
117
+ return String(value);
118
+ }
119
+ }
@@ -0,0 +1,98 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { GitHubActionsParser } from "./parser";
3
+
4
+ const parser = new GitHubActionsParser();
5
+
6
+ describe("GitHubActionsParser", () => {
7
+ test("parses workflow-level properties", () => {
8
+ const yaml = `name: CI
9
+ on:
10
+ push:
11
+ branches: [main]
12
+ permissions:
13
+ contents: read
14
+ `;
15
+ const ir = parser.parse(yaml);
16
+ const wf = ir.resources.find((r) => r.type === "GitHub::Actions::Workflow");
17
+ expect(wf).toBeDefined();
18
+ expect(wf!.properties.name).toBe("CI");
19
+ expect(wf!.properties.permissions).toBeDefined();
20
+ });
21
+
22
+ test("parses jobs as Job resources", () => {
23
+ const yaml = `name: CI
24
+ on:
25
+ push:
26
+ jobs:
27
+ build:
28
+ runs-on: ubuntu-latest
29
+ steps:
30
+ - run: echo build
31
+ test:
32
+ runs-on: ubuntu-latest
33
+ steps:
34
+ - run: echo test
35
+ `;
36
+ const ir = parser.parse(yaml);
37
+ const jobs = ir.resources.filter((r) => r.type === "GitHub::Actions::Job");
38
+ expect(jobs).toHaveLength(2);
39
+ expect(jobs[0].logicalId).toBe("build");
40
+ expect(jobs[1].logicalId).toBe("test");
41
+ });
42
+
43
+ test("converts kebab-case job IDs to camelCase logical IDs", () => {
44
+ const yaml = `name: CI
45
+ on:
46
+ push:
47
+ jobs:
48
+ build-and-test:
49
+ runs-on: ubuntu-latest
50
+ steps:
51
+ - run: echo test
52
+ `;
53
+ const ir = parser.parse(yaml);
54
+ const job = ir.resources.find((r) => r.type === "GitHub::Actions::Job");
55
+ expect(job!.logicalId).toBe("buildAndTest");
56
+ expect(job!.metadata?.originalName).toBe("build-and-test");
57
+ });
58
+
59
+ test("detects reusable workflow call jobs", () => {
60
+ const yaml = `name: CI
61
+ on:
62
+ push:
63
+ jobs:
64
+ call-other:
65
+ uses: owner/repo/.github/workflows/reusable.yml@main
66
+ `;
67
+ const ir = parser.parse(yaml);
68
+ const job = ir.resources.find((r) => r.type === "GitHub::Actions::ReusableWorkflowCallJob");
69
+ expect(job).toBeDefined();
70
+ expect(job!.logicalId).toBe("callOther");
71
+ });
72
+
73
+ test("returns empty resources for non-workflow YAML", () => {
74
+ const yaml = `key: value\nother: stuff`;
75
+ const ir = parser.parse(yaml);
76
+ // May have a workflow resource with key/value, but no jobs
77
+ const jobs = ir.resources.filter((r) => r.type === "GitHub::Actions::Job");
78
+ expect(jobs).toHaveLength(0);
79
+ });
80
+
81
+ test("preserves job properties", () => {
82
+ const yaml = `name: CI
83
+ on:
84
+ push:
85
+ jobs:
86
+ build:
87
+ runs-on: ubuntu-latest
88
+ timeout-minutes: 30
89
+ steps:
90
+ - uses: actions/checkout@v4
91
+ - run: npm test
92
+ `;
93
+ const ir = parser.parse(yaml);
94
+ const job = ir.resources.find((r) => r.type === "GitHub::Actions::Job");
95
+ expect(job!.properties["runs-on"]).toBe("ubuntu-latest");
96
+ expect(job!.properties["timeout-minutes"]).toBe(30);
97
+ });
98
+ });
@@ -0,0 +1,73 @@
1
+ /**
2
+ * GitHub Actions YAML parser for `chant import`.
3
+ *
4
+ * Parses existing .github/workflows/*.yml files into TemplateIR.
5
+ */
6
+
7
+ import type { TemplateParser, TemplateIR, ResourceIR } from "@intentius/chant/import/parser";
8
+ import { parseYAML } from "@intentius/chant/yaml";
9
+
10
+ function kebabToCamelCase(name: string): string {
11
+ return name.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
12
+ }
13
+
14
+ function snakeToCamelCase(name: string): string {
15
+ return name.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase());
16
+ }
17
+
18
+ /**
19
+ * GitHub Actions YAML parser implementation.
20
+ */
21
+ export class GitHubActionsParser implements TemplateParser {
22
+ parse(content: string): TemplateIR {
23
+ const doc = parseYAML(content);
24
+ const resources: ResourceIR[] = [];
25
+
26
+ // Extract workflow-level properties
27
+ const workflowProps: Record<string, unknown> = {};
28
+ if (doc.name) workflowProps.name = doc.name;
29
+ if (doc["run-name"]) workflowProps["run-name"] = doc["run-name"];
30
+ if (doc.on) workflowProps.on = doc.on;
31
+ if (doc.permissions) workflowProps.permissions = doc.permissions;
32
+ if (doc.env) workflowProps.env = doc.env;
33
+ if (doc.concurrency) workflowProps.concurrency = doc.concurrency;
34
+ if (doc.defaults) workflowProps.defaults = doc.defaults;
35
+
36
+ if (Object.keys(workflowProps).length > 0) {
37
+ resources.push({
38
+ logicalId: "workflow",
39
+ type: "GitHub::Actions::Workflow",
40
+ properties: workflowProps,
41
+ });
42
+ }
43
+
44
+ // Extract jobs
45
+ if (doc.jobs && typeof doc.jobs === "object") {
46
+ for (const [jobId, jobDef] of Object.entries(doc.jobs as Record<string, unknown>)) {
47
+ if (typeof jobDef !== "object" || jobDef === null) continue;
48
+
49
+ const jobObj = jobDef as Record<string, unknown>;
50
+ const logicalId = kebabToCamelCase(jobId);
51
+
52
+ // Determine if it's a reusable workflow call job
53
+ const type = jobObj.uses
54
+ ? "GitHub::Actions::ReusableWorkflowCallJob"
55
+ : "GitHub::Actions::Job";
56
+
57
+ resources.push({
58
+ logicalId,
59
+ type,
60
+ properties: jobObj,
61
+ metadata: {
62
+ originalName: jobId,
63
+ },
64
+ });
65
+ }
66
+ }
67
+
68
+ return {
69
+ resources,
70
+ parameters: [],
71
+ };
72
+ }
73
+ }
package/src/index.ts ADDED
@@ -0,0 +1,53 @@
1
+ // Serializer
2
+ export { githubSerializer } from "./serializer";
3
+
4
+ // Plugin
5
+ export { githubPlugin } from "./plugin";
6
+
7
+ // Expression system
8
+ export {
9
+ Expression,
10
+ github, runner, secrets, matrix, steps, needs, inputs, vars, env,
11
+ always, failure, success, cancelled,
12
+ contains, startsWith, toJSON, fromJSON, format,
13
+ branch, tag,
14
+ } from "./expression";
15
+
16
+ // Context Variables
17
+ export { GitHub, Runner } from "./variables";
18
+
19
+ // Generated entities — export everything from generated index
20
+ // After running `chant generate`, this re-exports all entity classes
21
+ export * from "./generated/index";
22
+
23
+ // Composites
24
+ export {
25
+ Checkout, SetupNode, SetupGo, SetupPython,
26
+ CacheAction, UploadArtifact, DownloadArtifact,
27
+ NodeCI,
28
+ NodePipeline, BunPipeline, PnpmPipeline, YarnPipeline,
29
+ PythonCI,
30
+ DockerBuild,
31
+ DeployEnvironment,
32
+ GoCI,
33
+ } from "./composites/index";
34
+ export type {
35
+ CheckoutProps, SetupNodeProps, SetupGoProps, SetupPythonProps,
36
+ CacheActionProps, UploadArtifactProps, DownloadArtifactProps,
37
+ NodeCIProps,
38
+ NodePipelineProps,
39
+ PythonCIProps,
40
+ DockerBuildProps,
41
+ DeployEnvironmentProps,
42
+ GoCIProps,
43
+ } from "./composites/index";
44
+
45
+ // Spec utilities (for tooling)
46
+ export { fetchWorkflowSchema, fetchSchemas } from "./spec/fetch";
47
+ export { parseWorkflowSchema, githubShortName, githubServiceName } from "./codegen/parse";
48
+ export type { GitHubParseResult, ParsedResource, ParsedProperty, ParsedPropertyType, ParsedEnum } from "./codegen/parse";
49
+
50
+ // Code generation pipeline
51
+ export { generate, writeGeneratedFiles } from "./codegen/generate";
52
+ export { packageLexicon } from "./codegen/package";
53
+ export type { PackageOptions, PackageResult } from "./codegen/package";
@@ -0,0 +1,58 @@
1
+ /**
2
+ * GHA006: Duplicate Workflow Name
3
+ *
4
+ * Detects multiple workflows sharing the same `name:` value.
5
+ */
6
+
7
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
8
+ import { extractWorkflowName } from "./yaml-helpers";
9
+ import type { SerializerResult } from "@intentius/chant/serializer";
10
+
11
+ export const gha006: PostSynthCheck = {
12
+ id: "GHA006",
13
+ description: "Multiple workflows share the same name",
14
+
15
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
16
+ const diagnostics: PostSynthDiagnostic[] = [];
17
+ const nameMap = new Map<string, string[]>();
18
+
19
+ for (const [outputName, output] of ctx.outputs) {
20
+ const yaml = typeof output === "string" ? output : (output as SerializerResult).primary;
21
+
22
+ // Check if this is a multi-file output
23
+ if (typeof output === "object" && "files" in output) {
24
+ const result = output as SerializerResult;
25
+ if (result.files) {
26
+ for (const [fileName, fileContent] of Object.entries(result.files)) {
27
+ const name = extractWorkflowName(fileContent);
28
+ if (name) {
29
+ const existing = nameMap.get(name) ?? [];
30
+ existing.push(fileName);
31
+ nameMap.set(name, existing);
32
+ }
33
+ }
34
+ }
35
+ } else {
36
+ const name = extractWorkflowName(yaml);
37
+ if (name) {
38
+ const existing = nameMap.get(name) ?? [];
39
+ existing.push(outputName);
40
+ nameMap.set(name, existing);
41
+ }
42
+ }
43
+ }
44
+
45
+ for (const [name, files] of nameMap) {
46
+ if (files.length > 1) {
47
+ diagnostics.push({
48
+ checkId: "GHA006",
49
+ severity: "error",
50
+ message: `Duplicate workflow name "${name}" found in: ${files.join(", ")}`,
51
+ lexicon: "github",
52
+ });
53
+ }
54
+ }
55
+
56
+ return diagnostics;
57
+ },
58
+ };
@@ -0,0 +1,42 @@
1
+ /**
2
+ * GHA009: Empty Matrix Dimension
3
+ *
4
+ * Detects matrix dimensions with empty values arrays.
5
+ */
6
+
7
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
8
+ import { getPrimaryOutput } from "./yaml-helpers";
9
+
10
+ export const gha009: PostSynthCheck = {
11
+ id: "GHA009",
12
+ description: "Matrix dimension has empty values array",
13
+
14
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
15
+ const diagnostics: PostSynthDiagnostic[] = [];
16
+
17
+ for (const [, output] of ctx.outputs) {
18
+ const yaml = getPrimaryOutput(output);
19
+
20
+ // Find matrix sections and check for empty arrays
21
+ const matrixMatch = yaml.match(/matrix:\n([\s\S]*?)(?=\n\s{4}[a-z]|\n\s{2}[a-z]|\n[a-z]|$)/gm);
22
+ if (!matrixMatch) continue;
23
+
24
+ for (const section of matrixMatch) {
25
+ const lines = section.split("\n");
26
+ for (let i = 0; i < lines.length; i++) {
27
+ const keyMatch = lines[i].match(/^\s+([a-z][a-z0-9_-]*):\s*\[\s*\]\s*$/);
28
+ if (keyMatch) {
29
+ diagnostics.push({
30
+ checkId: "GHA009",
31
+ severity: "error",
32
+ message: `Matrix dimension "${keyMatch[1]}" has an empty values array.`,
33
+ lexicon: "github",
34
+ });
35
+ }
36
+ }
37
+ }
38
+ }
39
+
40
+ return diagnostics;
41
+ },
42
+ };
@@ -0,0 +1,40 @@
1
+ /**
2
+ * GHA011: Invalid Needs Reference
3
+ *
4
+ * Detects `needs:` references to non-existent job IDs.
5
+ */
6
+
7
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
8
+ import { getPrimaryOutput, extractJobs } from "./yaml-helpers";
9
+
10
+ export const gha011: PostSynthCheck = {
11
+ id: "GHA011",
12
+ description: "Job needs: references non-existent job",
13
+
14
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
15
+ const diagnostics: PostSynthDiagnostic[] = [];
16
+
17
+ for (const [, output] of ctx.outputs) {
18
+ const yaml = getPrimaryOutput(output);
19
+ const jobs = extractJobs(yaml);
20
+ const jobNames = new Set(jobs.keys());
21
+
22
+ for (const [jobName, job] of jobs) {
23
+ if (!job.needs) continue;
24
+ for (const need of job.needs) {
25
+ if (!jobNames.has(need)) {
26
+ diagnostics.push({
27
+ checkId: "GHA011",
28
+ severity: "error",
29
+ message: `Job "${jobName}" needs "${need}", but no such job exists.`,
30
+ entity: jobName,
31
+ lexicon: "github",
32
+ });
33
+ }
34
+ }
35
+ }
36
+ }
37
+
38
+ return diagnostics;
39
+ },
40
+ };
@@ -0,0 +1,32 @@
1
+ /**
2
+ * GHA017: Missing Permissions
3
+ *
4
+ * Flags workflows without an explicit `permissions:` block.
5
+ */
6
+
7
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
8
+ import { getPrimaryOutput, hasPermissions } from "./yaml-helpers";
9
+
10
+ export const gha017: PostSynthCheck = {
11
+ id: "GHA017",
12
+ description: "Workflow without explicit permissions block",
13
+
14
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
15
+ const diagnostics: PostSynthDiagnostic[] = [];
16
+
17
+ for (const [, output] of ctx.outputs) {
18
+ const yaml = getPrimaryOutput(output);
19
+
20
+ if (!hasPermissions(yaml)) {
21
+ diagnostics.push({
22
+ checkId: "GHA017",
23
+ severity: "info",
24
+ message: "Workflow does not specify permissions. Consider adding explicit permissions for least-privilege security.",
25
+ lexicon: "github",
26
+ });
27
+ }
28
+ }
29
+
30
+ return diagnostics;
31
+ },
32
+ };
@@ -0,0 +1,40 @@
1
+ /**
2
+ * GHA018: Pull Request Target + Checkout Security Risk
3
+ *
4
+ * Flags workflows using `pull_request_target` trigger with a checkout action
5
+ * in steps — this is a known security anti-pattern.
6
+ */
7
+
8
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
9
+ import { getPrimaryOutput, extractJobs, extractTriggers, hasCheckoutAction } from "./yaml-helpers";
10
+
11
+ export const gha018: PostSynthCheck = {
12
+ id: "GHA018",
13
+ description: "pull_request_target with checkout action is a security risk",
14
+
15
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
16
+ const diagnostics: PostSynthDiagnostic[] = [];
17
+
18
+ for (const [, output] of ctx.outputs) {
19
+ const yaml = getPrimaryOutput(output);
20
+ const triggers = extractTriggers(yaml);
21
+
22
+ if (!triggers["pull_request_target"]) continue;
23
+
24
+ const jobs = extractJobs(yaml);
25
+ for (const [jobName, job] of jobs) {
26
+ if (job.steps && hasCheckoutAction(job.steps)) {
27
+ diagnostics.push({
28
+ checkId: "GHA018",
29
+ severity: "warning",
30
+ message: `Job "${jobName}" uses checkout with pull_request_target trigger. This runs untrusted PR code with write permissions — a security risk.`,
31
+ entity: jobName,
32
+ lexicon: "github",
33
+ });
34
+ }
35
+ }
36
+ }
37
+
38
+ return diagnostics;
39
+ },
40
+ };
@@ -0,0 +1,72 @@
1
+ /**
2
+ * GHA019: Circular Needs Chain
3
+ *
4
+ * DFS-based cycle detection on the job `needs:` dependency graph.
5
+ */
6
+
7
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
8
+ import { getPrimaryOutput, buildNeedsGraph } from "./yaml-helpers";
9
+
10
+ export function checkCircularNeeds(ctx: PostSynthContext): PostSynthDiagnostic[] {
11
+ const diagnostics: PostSynthDiagnostic[] = [];
12
+
13
+ for (const [, output] of ctx.outputs) {
14
+ const yaml = getPrimaryOutput(output);
15
+ const graph = buildNeedsGraph(yaml);
16
+
17
+ const visited = new Set<string>();
18
+ const inStack = new Set<string>();
19
+ const reportedInCycle = new Set<string>();
20
+
21
+ function dfs(node: string, path: string[]): void {
22
+ if (inStack.has(node)) {
23
+ const cycleStart = path.indexOf(node);
24
+ const cycle = path.slice(cycleStart);
25
+ cycle.push(node);
26
+
27
+ const cycleKey = [...cycle].sort().join(",");
28
+ if (!reportedInCycle.has(cycleKey)) {
29
+ reportedInCycle.add(cycleKey);
30
+ diagnostics.push({
31
+ checkId: "GHA019",
32
+ severity: "error",
33
+ message: `Circular needs: chain detected: ${cycle.join(" → ")}`,
34
+ entity: node,
35
+ lexicon: "github",
36
+ });
37
+ }
38
+ return;
39
+ }
40
+
41
+ if (visited.has(node)) return;
42
+
43
+ visited.add(node);
44
+ inStack.add(node);
45
+
46
+ for (const neighbor of graph.get(node) ?? []) {
47
+ if (graph.has(neighbor)) {
48
+ dfs(neighbor, [...path, node]);
49
+ }
50
+ }
51
+
52
+ inStack.delete(node);
53
+ }
54
+
55
+ for (const jobName of graph.keys()) {
56
+ if (!visited.has(jobName)) {
57
+ dfs(jobName, []);
58
+ }
59
+ }
60
+ }
61
+
62
+ return diagnostics;
63
+ }
64
+
65
+ export const gha019: PostSynthCheck = {
66
+ id: "GHA019",
67
+ description: "Circular needs: chain — cycle in job dependency graph",
68
+
69
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
70
+ return checkCircularNeeds(ctx);
71
+ },
72
+ };