@intentius/chant-lexicon-github 0.49.0 → 0.50.0

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 (92) hide show
  1. package/dist/components/generate-op-pipeline.d.ts +81 -0
  2. package/dist/components/generate-op-pipeline.d.ts.map +1 -0
  3. package/dist/integrity.json +15 -5
  4. package/dist/lint/audit-catalog.d.ts.map +1 -1
  5. package/dist/lint/post-synth/gha059.d.ts +40 -0
  6. package/dist/lint/post-synth/gha059.d.ts.map +1 -0
  7. package/dist/lint/post-synth/gha060.d.ts +34 -0
  8. package/dist/lint/post-synth/gha060.d.ts.map +1 -0
  9. package/dist/lint/post-synth/gha061.d.ts +27 -0
  10. package/dist/lint/post-synth/gha061.d.ts.map +1 -0
  11. package/dist/lint/post-synth/gha062.d.ts +24 -0
  12. package/dist/lint/post-synth/gha062.d.ts.map +1 -0
  13. package/dist/lint/post-synth/gha063.d.ts +12 -0
  14. package/dist/lint/post-synth/gha063.d.ts.map +1 -0
  15. package/dist/lint/post-synth/gha064.d.ts +12 -0
  16. package/dist/lint/post-synth/gha064.d.ts.map +1 -0
  17. package/dist/lint/post-synth/gha065.d.ts +19 -0
  18. package/dist/lint/post-synth/gha065.d.ts.map +1 -0
  19. package/dist/lint/post-synth/gha066.d.ts +12 -0
  20. package/dist/lint/post-synth/gha066.d.ts.map +1 -0
  21. package/dist/lint/post-synth/gha067.d.ts +12 -0
  22. package/dist/lint/post-synth/gha067.d.ts.map +1 -0
  23. package/dist/lint/post-synth/gha068.d.ts +13 -0
  24. package/dist/lint/post-synth/gha068.d.ts.map +1 -0
  25. package/dist/lint/post-synth/index.d.ts.map +1 -1
  26. package/dist/lint/post-synth/yaml-helpers.d.ts +20 -0
  27. package/dist/lint/post-synth/yaml-helpers.d.ts.map +1 -1
  28. package/dist/lint/rules/data/action-usage-policy.d.ts +30 -0
  29. package/dist/lint/rules/data/action-usage-policy.d.ts.map +1 -0
  30. package/dist/lint/rules/data/advisory-feed.d.ts +36 -0
  31. package/dist/lint/rules/data/advisory-feed.d.ts.map +1 -0
  32. package/dist/manifest.json +109 -1
  33. package/dist/meta.json +5 -0
  34. package/dist/okf/index.md +10 -0
  35. package/dist/okf/rules/GHA059.md +15 -0
  36. package/dist/okf/rules/GHA060.md +15 -0
  37. package/dist/okf/rules/GHA061.md +15 -0
  38. package/dist/okf/rules/GHA062.md +15 -0
  39. package/dist/okf/rules/GHA063.md +15 -0
  40. package/dist/okf/rules/GHA064.md +15 -0
  41. package/dist/okf/rules/GHA065.md +15 -0
  42. package/dist/okf/rules/GHA066.md +15 -0
  43. package/dist/okf/rules/GHA067.md +15 -0
  44. package/dist/okf/rules/GHA068.md +15 -0
  45. package/dist/okf/types/Job.md +9 -0
  46. package/dist/okf/types/Workflow.md +1 -0
  47. package/dist/plugin.d.ts.map +1 -1
  48. package/dist/rules/gha059.ts +132 -0
  49. package/dist/rules/gha060.ts +146 -0
  50. package/dist/rules/gha061.ts +85 -0
  51. package/dist/rules/gha062.ts +68 -0
  52. package/dist/rules/gha063.ts +60 -0
  53. package/dist/rules/gha064.ts +55 -0
  54. package/dist/rules/gha065.ts +129 -0
  55. package/dist/rules/gha066.ts +47 -0
  56. package/dist/rules/gha067.ts +76 -0
  57. package/dist/rules/gha068.ts +51 -0
  58. package/dist/rules/yaml-helpers.ts +37 -2
  59. package/dist/types/index.d.ts +1 -0
  60. package/package.json +2 -2
  61. package/src/codegen/docs.ts +1 -1
  62. package/src/components/generate-op-pipeline.test.ts +146 -0
  63. package/src/components/generate-op-pipeline.ts +183 -0
  64. package/src/generated/index.d.ts +1 -0
  65. package/src/generated/lexicon-github.json +5 -0
  66. package/src/lint/audit-catalog.ts +16 -1
  67. package/src/lint/post-synth/gha059.test.ts +147 -0
  68. package/src/lint/post-synth/gha059.ts +132 -0
  69. package/src/lint/post-synth/gha060.test.ts +144 -0
  70. package/src/lint/post-synth/gha060.ts +146 -0
  71. package/src/lint/post-synth/gha061.test.ts +85 -0
  72. package/src/lint/post-synth/gha061.ts +85 -0
  73. package/src/lint/post-synth/gha062.test.ts +88 -0
  74. package/src/lint/post-synth/gha062.ts +68 -0
  75. package/src/lint/post-synth/gha063.test.ts +86 -0
  76. package/src/lint/post-synth/gha063.ts +60 -0
  77. package/src/lint/post-synth/gha064.test.ts +73 -0
  78. package/src/lint/post-synth/gha064.ts +55 -0
  79. package/src/lint/post-synth/gha065.test.ts +85 -0
  80. package/src/lint/post-synth/gha065.ts +129 -0
  81. package/src/lint/post-synth/gha066.test.ts +66 -0
  82. package/src/lint/post-synth/gha066.ts +47 -0
  83. package/src/lint/post-synth/gha067.test.ts +85 -0
  84. package/src/lint/post-synth/gha067.ts +76 -0
  85. package/src/lint/post-synth/gha068.test.ts +71 -0
  86. package/src/lint/post-synth/gha068.ts +51 -0
  87. package/src/lint/post-synth/index.ts +20 -0
  88. package/src/lint/post-synth/yaml-helpers.ts +37 -2
  89. package/src/lint/rules/data/action-usage-policy.ts +31 -0
  90. package/src/lint/rules/data/advisory-feed.ts +38 -0
  91. package/src/plugin.test.ts +24 -3
  92. package/src/plugin.ts +28 -0
@@ -0,0 +1,86 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
3
+ import { gha063 } from "./gha063";
4
+
5
+ function makeCtx(yaml: string): PostSynthContext {
6
+ return {
7
+ outputs: new Map([["github", yaml]]),
8
+ entities: new Map(),
9
+ buildResult: { outputs: new Map([["github", yaml]]), entities: new Map(), warnings: [], errors: [], sourceFileCount: 1 },
10
+ };
11
+ }
12
+
13
+ describe("GHA063: dependency setup without caching", () => {
14
+ test("flags setup-node with no cache option and no cache step", () => {
15
+ const yaml = `name: CI
16
+ on:
17
+ push:
18
+ jobs:
19
+ build:
20
+ runs-on: ubuntu-latest
21
+ steps:
22
+ - uses: actions/checkout@v4
23
+ - uses: actions/setup-node@v4
24
+ with:
25
+ node-version: "20"
26
+ - run: npm ci
27
+ `;
28
+ const diags = gha063.check(makeCtx(yaml));
29
+ expect(diags).toHaveLength(1);
30
+ expect(diags[0].checkId).toBe("GHA063");
31
+ expect(diags[0].entity).toBe("build");
32
+ });
33
+
34
+ test("does not flag setup-node with cache enabled", () => {
35
+ const yaml = `name: CI
36
+ on:
37
+ push:
38
+ jobs:
39
+ build:
40
+ runs-on: ubuntu-latest
41
+ steps:
42
+ - uses: actions/checkout@v4
43
+ - uses: actions/setup-node@v4
44
+ with:
45
+ node-version: "20"
46
+ cache: npm
47
+ - run: npm ci
48
+ `;
49
+ expect(gha063.check(makeCtx(yaml))).toHaveLength(0);
50
+ });
51
+
52
+ test("does not flag setup-node when a separate actions/cache step covers it", () => {
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
+ - uses: actions/setup-node@v4
62
+ with:
63
+ node-version: "20"
64
+ - uses: actions/cache@v4
65
+ with:
66
+ path: ~/.npm
67
+ key: npm-\${{ hashFiles('package-lock.json') }}
68
+ - run: npm ci
69
+ `;
70
+ expect(gha063.check(makeCtx(yaml))).toHaveLength(0);
71
+ });
72
+
73
+ test("does not flag a job with no setup action at all", () => {
74
+ const yaml = `name: CI
75
+ on:
76
+ push:
77
+ jobs:
78
+ build:
79
+ runs-on: ubuntu-latest
80
+ steps:
81
+ - uses: actions/checkout@v4
82
+ - run: make build
83
+ `;
84
+ expect(gha063.check(makeCtx(yaml))).toHaveLength(0);
85
+ });
86
+ });
@@ -0,0 +1,60 @@
1
+ /**
2
+ * GHA063: Dependency Setup Without Caching
3
+ *
4
+ * Flags a `actions/setup-{node,python,java,ruby,dotnet}` step that leaves its
5
+ * built-in `cache:` option unset, with no adjacent `actions/cache` step to
6
+ * cover the gap. Every run re-downloads the same dependencies from the
7
+ * registry from a cold cache — pure wasted time and bandwidth, not a
8
+ * correctness or security issue (efficiency, #444).
9
+ */
10
+
11
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
12
+ import { getPrimaryOutput, jobEntries, parseActionUses } from "./yaml-helpers";
13
+
14
+ /** Setup actions whose `with.cache` option enables built-in dependency caching. */
15
+ const CACHEABLE_SETUP_ACTIONS = new Set(["actions/setup-node", "actions/setup-python", "actions/setup-java", "actions/setup-ruby", "actions/setup-dotnet"]);
16
+
17
+ function cacheEnabled(withBlock: unknown): boolean {
18
+ if (!withBlock || typeof withBlock !== "object") return false;
19
+ const cache = (withBlock as Record<string, unknown>).cache;
20
+ if (typeof cache === "boolean") return cache;
21
+ if (typeof cache === "string") return cache.trim().length > 0 && cache.trim() !== "false";
22
+ return false;
23
+ }
24
+
25
+ export const gha063: PostSynthCheck = {
26
+ id: "GHA063",
27
+ description: "Dependency setup action without caching enabled",
28
+
29
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
30
+ const diagnostics: PostSynthDiagnostic[] = [];
31
+
32
+ for (const [, output] of ctx.outputs) {
33
+ const yaml = getPrimaryOutput(output);
34
+
35
+ for (const [jobName, jobObj] of jobEntries(yaml)) {
36
+ const steps = Array.isArray(jobObj.steps) ? (jobObj.steps as Array<Record<string, unknown>>) : [];
37
+ const hasGenericCacheStep = steps.some((s) => parseActionUses(String(s.uses ?? ""))?.slug === "actions/cache");
38
+ if (hasGenericCacheStep) continue;
39
+
40
+ for (const step of steps) {
41
+ const uses = typeof step.uses === "string" ? step.uses : undefined;
42
+ if (!uses) continue;
43
+ const parsed = parseActionUses(uses);
44
+ if (!parsed || !CACHEABLE_SETUP_ACTIONS.has(parsed.slug)) continue;
45
+ if (cacheEnabled(step.with)) continue;
46
+
47
+ diagnostics.push({
48
+ checkId: "GHA063",
49
+ severity: "info",
50
+ message: `Job "${jobName}" uses ${parsed.slug} without enabling its \`cache:\` option, and no separate actions/cache step covers it — dependencies are re-fetched from a cold cache on every run. Set \`with.cache\` (e.g. \`cache: npm\`) or add an actions/cache step.`,
51
+ entity: jobName,
52
+ lexicon: "github",
53
+ });
54
+ }
55
+ }
56
+ }
57
+
58
+ return diagnostics;
59
+ },
60
+ };
@@ -0,0 +1,73 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
3
+ import { gha064 } from "./gha064";
4
+
5
+ function makeCtx(yaml: string): PostSynthContext {
6
+ return {
7
+ outputs: new Map([["github", yaml]]),
8
+ entities: new Map(),
9
+ buildResult: { outputs: new Map([["github", yaml]]), entities: new Map(), warnings: [], errors: [], sourceFileCount: 1 },
10
+ };
11
+ }
12
+
13
+ describe("GHA064: expensive runner without justification", () => {
14
+ test("flags a macOS runner with no macOS-specific step", () => {
15
+ const yaml = `name: CI
16
+ on:
17
+ push:
18
+ jobs:
19
+ build:
20
+ runs-on: macos-latest
21
+ steps:
22
+ - uses: actions/checkout@v4
23
+ - run: npm test
24
+ `;
25
+ const diags = gha064.check(makeCtx(yaml));
26
+ expect(diags).toHaveLength(1);
27
+ expect(diags[0].checkId).toBe("GHA064");
28
+ expect(diags[0].entity).toBe("build");
29
+ });
30
+
31
+ test("does not flag macOS when a step is Xcode-specific", () => {
32
+ const yaml = `name: CI
33
+ on:
34
+ push:
35
+ jobs:
36
+ build:
37
+ runs-on: macos-latest
38
+ steps:
39
+ - uses: actions/checkout@v4
40
+ - run: xcodebuild -scheme App build
41
+ `;
42
+ expect(gha064.check(makeCtx(yaml))).toHaveLength(0);
43
+ });
44
+
45
+ test("does not flag runs-on driven by a matrix expression", () => {
46
+ const yaml = `name: CI
47
+ on:
48
+ push:
49
+ jobs:
50
+ build:
51
+ strategy:
52
+ matrix:
53
+ os: [ubuntu-latest, macos-latest, windows-latest]
54
+ runs-on: \${{ matrix.os }}
55
+ steps:
56
+ - run: npm test
57
+ `;
58
+ expect(gha064.check(makeCtx(yaml))).toHaveLength(0);
59
+ });
60
+
61
+ test("does not flag a Linux runner", () => {
62
+ const yaml = `name: CI
63
+ on:
64
+ push:
65
+ jobs:
66
+ build:
67
+ runs-on: ubuntu-latest
68
+ steps:
69
+ - run: npm test
70
+ `;
71
+ expect(gha064.check(makeCtx(yaml))).toHaveLength(0);
72
+ });
73
+ });
@@ -0,0 +1,55 @@
1
+ /**
2
+ * GHA064: Expensive Runner Without Justification
3
+ *
4
+ * Flags a job hardcoded onto a pricier macOS/Windows runner (billed at a
5
+ * multiple of Linux minutes) when none of its `run:` steps show any sign the
6
+ * job actually needs that OS. A matrix-driven `runs-on: ${{ matrix.os }}`
7
+ * (intentional cross-platform testing) is never flagged — only a literal,
8
+ * hardcoded label. Efficiency (#444), not a correctness/security issue.
9
+ */
10
+
11
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
12
+ import { getPrimaryOutput, extractRunsOnByJob, extractRunBlocks } from "./yaml-helpers";
13
+
14
+ const OS_JUSTIFICATION: Array<{ prefix: string; label: string; keywords: RegExp }> = [
15
+ { prefix: "macos-", label: "macOS", keywords: /xcodebuild|xcode-select|\.xcodeproj|\.xcworkspace|carthage|fastlane|cocoapods|pod install|swiftpm|codesign|notarize/i },
16
+ { prefix: "windows-", label: "Windows", keywords: /msbuild|\.sln\b|\.ps1\b|vcvarsall|nuget\s|choco\s|Set-ItemProperty|Get-ChildItem/i },
17
+ ];
18
+
19
+ export const gha064: PostSynthCheck = {
20
+ id: "GHA064",
21
+ description: "Job hardcoded onto an expensive runner with no sign it needs that OS",
22
+
23
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
24
+ const diagnostics: PostSynthDiagnostic[] = [];
25
+
26
+ for (const [, output] of ctx.outputs) {
27
+ const yaml = getPrimaryOutput(output);
28
+ const runsOnByJob = extractRunsOnByJob(yaml);
29
+ const runBlocks = extractRunBlocks(yaml);
30
+
31
+ for (const [jobName, labels] of runsOnByJob) {
32
+ if (labels.some((l) => l.includes("${{"))) continue; // matrix/expression-driven — intentional
33
+
34
+ for (const { prefix, label, keywords } of OS_JUSTIFICATION) {
35
+ const matched = labels.find((l) => l.toLowerCase().startsWith(prefix));
36
+ if (!matched) continue;
37
+
38
+ const justified = runBlocks.some((r) => r.job === jobName && keywords.test(r.run));
39
+ if (justified) break;
40
+
41
+ diagnostics.push({
42
+ checkId: "GHA064",
43
+ severity: "info",
44
+ message: `Job "${jobName}" runs on "${matched}" (a pricier ${label} runner) but no step looks ${label}-specific. Confirm it needs ${label}, or move it to a Linux runner.`,
45
+ entity: jobName,
46
+ lexicon: "github",
47
+ });
48
+ break;
49
+ }
50
+ }
51
+ }
52
+
53
+ return diagnostics;
54
+ },
55
+ };
@@ -0,0 +1,85 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
3
+ import { gha065 } from "./gha065";
4
+
5
+ function makeCtx(yaml: string): PostSynthContext {
6
+ return {
7
+ outputs: new Map([["github", yaml]]),
8
+ entities: new Map(),
9
+ buildResult: { outputs: new Map([["github", yaml]]), entities: new Map(), warnings: [], errors: [], sourceFileCount: 1 },
10
+ };
11
+ }
12
+
13
+ describe("GHA065: unbounded matrix fan-out", () => {
14
+ test("flags a large multi-dimension matrix with no max-parallel", () => {
15
+ const yaml = `name: CI
16
+ on:
17
+ push:
18
+ jobs:
19
+ build:
20
+ strategy:
21
+ matrix:
22
+ os: [ubuntu-latest, macos-latest, windows-latest]
23
+ node: [14, 16, 18, 20, 22]
24
+ runs-on: \${{ matrix.os }}
25
+ steps:
26
+ - run: npm test
27
+ `;
28
+ const diags = gha065.check(makeCtx(yaml));
29
+ expect(diags).toHaveLength(1);
30
+ expect(diags[0].checkId).toBe("GHA065");
31
+ expect(diags[0].entity).toBe("build");
32
+ expect(diags[0].message).toContain("15 jobs");
33
+ });
34
+
35
+ test("does not flag when max-parallel caps the fan-out", () => {
36
+ const yaml = `name: CI
37
+ on:
38
+ push:
39
+ jobs:
40
+ build:
41
+ strategy:
42
+ max-parallel: 4
43
+ matrix:
44
+ os: [ubuntu-latest, macos-latest, windows-latest]
45
+ node: [14, 16, 18, 20, 22]
46
+ runs-on: \${{ matrix.os }}
47
+ steps:
48
+ - run: npm test
49
+ `;
50
+ expect(gha065.check(makeCtx(yaml))).toHaveLength(0);
51
+ });
52
+
53
+ test("does not flag a small matrix under the threshold", () => {
54
+ const yaml = `name: CI
55
+ on:
56
+ push:
57
+ jobs:
58
+ build:
59
+ strategy:
60
+ matrix:
61
+ os: [ubuntu-latest, macos-latest]
62
+ node: [18, 20]
63
+ runs-on: \${{ matrix.os }}
64
+ steps:
65
+ - run: npm test
66
+ `;
67
+ expect(gha065.check(makeCtx(yaml))).toHaveLength(0);
68
+ });
69
+
70
+ test("does not flag a single-dimension matrix", () => {
71
+ const yaml = `name: CI
72
+ on:
73
+ push:
74
+ jobs:
75
+ build:
76
+ strategy:
77
+ matrix:
78
+ node: [16, 18, 20, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22]
79
+ runs-on: ubuntu-latest
80
+ steps:
81
+ - run: npm test
82
+ `;
83
+ expect(gha065.check(makeCtx(yaml))).toHaveLength(0);
84
+ });
85
+ });
@@ -0,0 +1,129 @@
1
+ /**
2
+ * GHA065: Unbounded Matrix Fan-Out
3
+ *
4
+ * Flags a `strategy.matrix` that combines two or more dimensions into more
5
+ * jobs than a `max-parallel:` cap bounds. Two orthogonal axes each with a
6
+ * handful of values multiply, not add — `os` x `node` x `arch` can produce
7
+ * dozens of jobs from a config that reads like a short list. Unbounded, that
8
+ * is capacity spent on a combination nobody asked for, not a correctness or
9
+ * security issue (efficiency, #444).
10
+ *
11
+ * Parses the `matrix:` block by indentation, not the structural YAML parser
12
+ * — a flow list of bare words (`os: [ubuntu-latest, macos-latest]`, the
13
+ * overwhelmingly common hand-written form) is not one it round-trips, only
14
+ * a flow list of numbers. Mirrors GHA009's regex-based matrix handling for
15
+ * the same reason.
16
+ */
17
+
18
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
19
+ import { getPrimaryOutput, extractJobs } from "./yaml-helpers";
20
+
21
+ /** Above this many combinations, an uncapped matrix is flagged. */
22
+ const FANOUT_THRESHOLD = 12;
23
+
24
+ /** The raw text of one job's block within `jobs:`, or undefined if not found. */
25
+ function jobSection(yaml: string, jobName: string): string | undefined {
26
+ const jobsIdx = yaml.search(/^jobs:\s*$/m);
27
+ if (jobsIdx === -1) return undefined;
28
+ const afterJobs = yaml.slice(jobsIdx + yaml.slice(jobsIdx).indexOf("\n") + 1);
29
+ const header = ` ${jobName}:\n`;
30
+ const start = afterJobs.indexOf(header);
31
+ if (start === -1) return undefined;
32
+ const rest = afterJobs.slice(start + header.length);
33
+ const nextJobMatch = rest.search(/\n {2}\S/);
34
+ return nextJobMatch === -1 ? rest : rest.slice(0, nextJobMatch);
35
+ }
36
+
37
+ interface MatrixInfo {
38
+ dims: Array<{ key: string; size: number }>;
39
+ maxParallel: boolean;
40
+ }
41
+
42
+ /** Parse a job section's `strategy.matrix` dimensions and whether max-parallel caps it. */
43
+ function parseMatrix(section: string): MatrixInfo | undefined {
44
+ const lines = section.split("\n");
45
+ const matrixLineIdx = lines.findIndex((l) => /^\s*matrix:\s*$/.test(l));
46
+ if (matrixLineIdx === -1) return undefined;
47
+ const matrixIndent = lines[matrixLineIdx].search(/\S/);
48
+
49
+ const maxParallel = lines.some((l) => {
50
+ const m = l.match(/^(\s*)max-parallel:/);
51
+ return !!m && m[1].length === matrixIndent;
52
+ });
53
+
54
+ const dims: Array<{ key: string; size: number }> = [];
55
+ let i = matrixLineIdx + 1;
56
+ while (i < lines.length) {
57
+ const line = lines[i];
58
+ if (line.trim() === "") { i++; continue; }
59
+ const indent = line.search(/\S/);
60
+ if (indent <= matrixIndent) break; // dedented out of the matrix: block
61
+
62
+ const kv = line.match(/^\s*([a-zA-Z0-9_-]+):\s*(.*)$/);
63
+ if (!kv) { i++; continue; }
64
+ const [, key, rest] = kv;
65
+ const value = rest.trim();
66
+
67
+ if (value.startsWith("[")) {
68
+ const inner = value.replace(/^\[|\]\s*$/g, "").trim();
69
+ dims.push({ key, size: inner === "" ? 0 : inner.split(",").length });
70
+ i++;
71
+ continue;
72
+ }
73
+ if (value !== "") {
74
+ dims.push({ key, size: 1 }); // a bare scalar dimension
75
+ i++;
76
+ continue;
77
+ }
78
+ // Block-list value on subsequent, deeper-indented lines.
79
+ let size = 0;
80
+ i++;
81
+ while (i < lines.length) {
82
+ const l2 = lines[i];
83
+ if (l2.trim() === "") { i++; continue; }
84
+ const ind2 = l2.search(/\S/);
85
+ if (ind2 <= indent) break;
86
+ if (/^\s*-\s/.test(l2)) size++;
87
+ i++;
88
+ }
89
+ dims.push({ key, size });
90
+ }
91
+
92
+ return { dims, maxParallel };
93
+ }
94
+
95
+ export const gha065: PostSynthCheck = {
96
+ id: "GHA065",
97
+ description: "Matrix combines dimensions into an uncapped, large fan-out",
98
+
99
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
100
+ const diagnostics: PostSynthDiagnostic[] = [];
101
+
102
+ for (const [, output] of ctx.outputs) {
103
+ const yaml = getPrimaryOutput(output);
104
+
105
+ for (const [jobName] of extractJobs(yaml)) {
106
+ const section = jobSection(yaml, jobName);
107
+ if (!section) continue;
108
+ const matrix = parseMatrix(section);
109
+ if (!matrix) continue;
110
+
111
+ const dims = matrix.dims.filter((d) => d.key !== "include" && d.key !== "exclude" && d.size > 0);
112
+ if (dims.length < 2) continue; // a single axis isn't "combined" fan-out
113
+
114
+ const combos = dims.reduce((a, d) => a * d.size, 1);
115
+ if (combos <= FANOUT_THRESHOLD || matrix.maxParallel) continue;
116
+
117
+ diagnostics.push({
118
+ checkId: "GHA065",
119
+ severity: "info",
120
+ message: `Job "${jobName}"'s matrix combines ${dims.length} dimensions (${dims.map((d) => d.key).join(" x ")}) into ${combos} jobs with no \`max-parallel:\` cap. Confirm the full cross-product is intended, trim it with \`include\`/\`exclude\`, or add \`max-parallel:\`.`,
121
+ entity: jobName,
122
+ lexicon: "github",
123
+ });
124
+ }
125
+ }
126
+
127
+ return diagnostics;
128
+ },
129
+ };
@@ -0,0 +1,66 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
3
+ import { gha066 } from "./gha066";
4
+
5
+ function makeCtx(yaml: string): PostSynthContext {
6
+ return {
7
+ outputs: new Map([["github", yaml]]),
8
+ entities: new Map(),
9
+ buildResult: { outputs: new Map([["github", yaml]]), entities: new Map(), warnings: [], errors: [], sourceFileCount: 1 },
10
+ };
11
+ }
12
+
13
+ describe("GHA066: unbounded artifact retention", () => {
14
+ test("flags upload-artifact with no retention-days", () => {
15
+ const yaml = `name: CI
16
+ on:
17
+ push:
18
+ jobs:
19
+ build:
20
+ runs-on: ubuntu-latest
21
+ steps:
22
+ - uses: actions/checkout@v4
23
+ - uses: actions/upload-artifact@v4
24
+ with:
25
+ name: build-output
26
+ path: dist/
27
+ `;
28
+ const diags = gha066.check(makeCtx(yaml));
29
+ expect(diags).toHaveLength(1);
30
+ expect(diags[0].checkId).toBe("GHA066");
31
+ expect(diags[0].entity).toBe("build");
32
+ expect(diags[0].message).toContain("build-output");
33
+ });
34
+
35
+ test("does not flag upload-artifact with retention-days set", () => {
36
+ const yaml = `name: CI
37
+ on:
38
+ push:
39
+ jobs:
40
+ build:
41
+ runs-on: ubuntu-latest
42
+ steps:
43
+ - uses: actions/checkout@v4
44
+ - uses: actions/upload-artifact@v4
45
+ with:
46
+ name: build-output
47
+ path: dist/
48
+ retention-days: 7
49
+ `;
50
+ expect(gha066.check(makeCtx(yaml))).toHaveLength(0);
51
+ });
52
+
53
+ test("does not flag a job with no artifact upload", () => {
54
+ const yaml = `name: CI
55
+ on:
56
+ push:
57
+ jobs:
58
+ build:
59
+ runs-on: ubuntu-latest
60
+ steps:
61
+ - uses: actions/checkout@v4
62
+ - run: npm test
63
+ `;
64
+ expect(gha066.check(makeCtx(yaml))).toHaveLength(0);
65
+ });
66
+ });
@@ -0,0 +1,47 @@
1
+ /**
2
+ * GHA066: Unbounded Artifact Retention
3
+ *
4
+ * Flags an `actions/upload-artifact` step with no `retention-days`, which
5
+ * falls back to the repository's default (up to 90 days). Build logs, test
6
+ * output, and intermediate artifacts rarely need to outlive the PR they came
7
+ * from; an unset retention silently accumulates storage. Efficiency (#444),
8
+ * not a correctness or security issue.
9
+ */
10
+
11
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
12
+ import { getPrimaryOutput, jobEntries, parseActionUses } from "./yaml-helpers";
13
+
14
+ export const gha066: PostSynthCheck = {
15
+ id: "GHA066",
16
+ description: "Uploaded artifact has no explicit retention-days",
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
+ for (const [jobName, jobObj] of jobEntries(yaml)) {
25
+ const steps = Array.isArray(jobObj.steps) ? (jobObj.steps as Array<Record<string, unknown>>) : [];
26
+ for (const step of steps) {
27
+ const uses = typeof step.uses === "string" ? step.uses : undefined;
28
+ if (!uses || parseActionUses(uses)?.slug !== "actions/upload-artifact") continue;
29
+
30
+ const withBlock = step.with as Record<string, unknown> | undefined;
31
+ if (withBlock && withBlock["retention-days"] !== undefined) continue;
32
+
33
+ const name = typeof withBlock?.name === "string" ? withBlock.name : undefined;
34
+ diagnostics.push({
35
+ checkId: "GHA066",
36
+ severity: "info",
37
+ message: `Job "${jobName}" uploads${name ? ` artifact "${name}"` : " an artifact"} with no \`retention-days\` — it falls back to the repository's default (up to 90 days). Set a \`retention-days\` sized to how long the artifact is actually needed.`,
38
+ entity: jobName,
39
+ lexicon: "github",
40
+ });
41
+ }
42
+ }
43
+ }
44
+
45
+ return diagnostics;
46
+ },
47
+ };
@@ -0,0 +1,85 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
3
+ import { gha067 } from "./gha067";
4
+
5
+ function makeCtx(yaml: string): PostSynthContext {
6
+ return {
7
+ outputs: new Map([["github", yaml]]),
8
+ entities: new Map(),
9
+ buildResult: { outputs: new Map([["github", yaml]]), entities: new Map(), warnings: [], errors: [], sourceFileCount: 1 },
10
+ };
11
+ }
12
+
13
+ describe("GHA067: unconditional docker build with no path filter or guard", () => {
14
+ test("flags a docker build on push with no paths filter and no if guard", () => {
15
+ const yaml = `name: CI
16
+ on:
17
+ push: {}
18
+ jobs:
19
+ build:
20
+ runs-on: ubuntu-latest
21
+ steps:
22
+ - uses: actions/checkout@v4
23
+ - run: docker build -t app .
24
+ `;
25
+ const diags = gha067.check(makeCtx(yaml));
26
+ expect(diags).toHaveLength(1);
27
+ expect(diags[0].checkId).toBe("GHA067");
28
+ expect(diags[0].entity).toBe("build");
29
+ });
30
+
31
+ test("does not flag when the trigger has a paths filter", () => {
32
+ const yaml = `name: CI
33
+ on:
34
+ push:
35
+ paths:
36
+ - "src/**"
37
+ jobs:
38
+ build:
39
+ runs-on: ubuntu-latest
40
+ steps:
41
+ - run: docker build -t app .
42
+ `;
43
+ expect(gha067.check(makeCtx(yaml))).toHaveLength(0);
44
+ });
45
+
46
+ test("does not flag when the step has an if guard", () => {
47
+ const yaml = `name: CI
48
+ on:
49
+ push: {}
50
+ jobs:
51
+ build:
52
+ runs-on: ubuntu-latest
53
+ steps:
54
+ - run: docker build -t app .
55
+ if: github.event.head_commit.modified contains 'Dockerfile'
56
+ `;
57
+ expect(gha067.check(makeCtx(yaml))).toHaveLength(0);
58
+ });
59
+
60
+ test("does not flag a workflow with no push/pull_request trigger", () => {
61
+ const yaml = `name: CI
62
+ on:
63
+ workflow_dispatch: {}
64
+ jobs:
65
+ build:
66
+ runs-on: ubuntu-latest
67
+ steps:
68
+ - run: docker build -t app .
69
+ `;
70
+ expect(gha067.check(makeCtx(yaml))).toHaveLength(0);
71
+ });
72
+
73
+ test("does not flag a step that isn't a docker build", () => {
74
+ const yaml = `name: CI
75
+ on:
76
+ push: {}
77
+ jobs:
78
+ build:
79
+ runs-on: ubuntu-latest
80
+ steps:
81
+ - run: npm test
82
+ `;
83
+ expect(gha067.check(makeCtx(yaml))).toHaveLength(0);
84
+ });
85
+ });