@intentius/chant-lexicon-github 0.1.23 → 0.3.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 (114) hide show
  1. package/dist/integrity.json +34 -4
  2. package/dist/manifest.json +1 -1
  3. package/dist/rules/{gha020.ts → gha013.ts} +4 -4
  4. package/dist/rules/gha029.ts +68 -0
  5. package/dist/rules/gha030.ts +41 -0
  6. package/dist/rules/gha031.ts +77 -0
  7. package/dist/rules/gha032.ts +39 -0
  8. package/dist/rules/gha033.ts +47 -0
  9. package/dist/rules/gha034.ts +41 -0
  10. package/dist/rules/gha035.ts +66 -0
  11. package/dist/rules/gha036.ts +42 -0
  12. package/dist/rules/gha037.ts +46 -0
  13. package/dist/rules/gha038.ts +41 -0
  14. package/dist/rules/gha039.ts +41 -0
  15. package/dist/rules/gha040.ts +43 -0
  16. package/dist/rules/gha041.ts +38 -0
  17. package/dist/rules/gha042.ts +40 -0
  18. package/dist/rules/gha043.ts +41 -0
  19. package/dist/rules/gha044.ts +41 -0
  20. package/dist/rules/gha045.ts +38 -0
  21. package/dist/rules/gha046.ts +56 -0
  22. package/dist/rules/gha047.ts +51 -0
  23. package/dist/rules/gha048.ts +41 -0
  24. package/dist/rules/gha049.ts +42 -0
  25. package/dist/rules/gha050.ts +44 -0
  26. package/dist/rules/gha051.ts +45 -0
  27. package/dist/rules/gha052.ts +42 -0
  28. package/dist/rules/gha053.ts +41 -0
  29. package/dist/rules/gha054.ts +41 -0
  30. package/dist/rules/gha055.ts +50 -0
  31. package/dist/rules/gha056.ts +37 -0
  32. package/dist/rules/gha057.ts +40 -0
  33. package/dist/rules/gha058.ts +48 -0
  34. package/dist/rules/yaml-helpers.ts +335 -0
  35. package/package.json +1 -1
  36. package/src/codegen/docs.ts +273 -4
  37. package/src/composites/dependabot.test.ts +48 -0
  38. package/src/composites/dependabot.ts +91 -0
  39. package/src/composites/index.ts +2 -0
  40. package/src/index.ts +2 -0
  41. package/src/lint/post-synth/{gha020.test.ts → gha013.test.ts} +7 -7
  42. package/src/lint/post-synth/{gha020.ts → gha013.ts} +4 -4
  43. package/src/lint/post-synth/gha029.test.ts +108 -0
  44. package/src/lint/post-synth/gha029.ts +68 -0
  45. package/src/lint/post-synth/gha030.test.ts +87 -0
  46. package/src/lint/post-synth/gha030.ts +41 -0
  47. package/src/lint/post-synth/gha031.test.ts +75 -0
  48. package/src/lint/post-synth/gha031.ts +77 -0
  49. package/src/lint/post-synth/gha032.test.ts +65 -0
  50. package/src/lint/post-synth/gha032.ts +39 -0
  51. package/src/lint/post-synth/gha033.test.ts +68 -0
  52. package/src/lint/post-synth/gha033.ts +47 -0
  53. package/src/lint/post-synth/gha034.test.ts +86 -0
  54. package/src/lint/post-synth/gha034.ts +41 -0
  55. package/src/lint/post-synth/gha035.test.ts +85 -0
  56. package/src/lint/post-synth/gha035.ts +66 -0
  57. package/src/lint/post-synth/gha036.test.ts +68 -0
  58. package/src/lint/post-synth/gha036.ts +42 -0
  59. package/src/lint/post-synth/gha037.test.ts +66 -0
  60. package/src/lint/post-synth/gha037.ts +46 -0
  61. package/src/lint/post-synth/gha038.test.ts +64 -0
  62. package/src/lint/post-synth/gha038.ts +41 -0
  63. package/src/lint/post-synth/gha039.test.ts +66 -0
  64. package/src/lint/post-synth/gha039.ts +41 -0
  65. package/src/lint/post-synth/gha040.test.ts +77 -0
  66. package/src/lint/post-synth/gha040.ts +43 -0
  67. package/src/lint/post-synth/gha041.test.ts +48 -0
  68. package/src/lint/post-synth/gha041.ts +38 -0
  69. package/src/lint/post-synth/gha042.test.ts +53 -0
  70. package/src/lint/post-synth/gha042.ts +40 -0
  71. package/src/lint/post-synth/gha043.test.ts +69 -0
  72. package/src/lint/post-synth/gha043.ts +41 -0
  73. package/src/lint/post-synth/gha044.test.ts +56 -0
  74. package/src/lint/post-synth/gha044.ts +41 -0
  75. package/src/lint/post-synth/gha045.test.ts +51 -0
  76. package/src/lint/post-synth/gha045.ts +38 -0
  77. package/src/lint/post-synth/gha046.test.ts +59 -0
  78. package/src/lint/post-synth/gha046.ts +56 -0
  79. package/src/lint/post-synth/gha047.test.ts +66 -0
  80. package/src/lint/post-synth/gha047.ts +51 -0
  81. package/src/lint/post-synth/gha048.test.ts +51 -0
  82. package/src/lint/post-synth/gha048.ts +41 -0
  83. package/src/lint/post-synth/gha049.test.ts +72 -0
  84. package/src/lint/post-synth/gha049.ts +42 -0
  85. package/src/lint/post-synth/gha050.test.ts +55 -0
  86. package/src/lint/post-synth/gha050.ts +44 -0
  87. package/src/lint/post-synth/gha051.test.ts +72 -0
  88. package/src/lint/post-synth/gha051.ts +45 -0
  89. package/src/lint/post-synth/gha052.test.ts +52 -0
  90. package/src/lint/post-synth/gha052.ts +42 -0
  91. package/src/lint/post-synth/gha053.test.ts +65 -0
  92. package/src/lint/post-synth/gha053.ts +41 -0
  93. package/src/lint/post-synth/gha054.test.ts +49 -0
  94. package/src/lint/post-synth/gha054.ts +41 -0
  95. package/src/lint/post-synth/gha055.test.ts +49 -0
  96. package/src/lint/post-synth/gha055.ts +50 -0
  97. package/src/lint/post-synth/gha056.test.ts +47 -0
  98. package/src/lint/post-synth/gha056.ts +37 -0
  99. package/src/lint/post-synth/gha057.test.ts +61 -0
  100. package/src/lint/post-synth/gha057.ts +40 -0
  101. package/src/lint/post-synth/gha058.test.ts +67 -0
  102. package/src/lint/post-synth/gha058.ts +48 -0
  103. package/src/lint/post-synth/yaml-helpers.ts +335 -0
  104. package/src/lint/rules/data/flagged-actions.ts +36 -0
  105. package/src/lint/rules/data/known-action-slugs.ts +38 -0
  106. package/src/lint/rules/data/preinstalled-tools.ts +36 -0
  107. package/src/lint/rules/data/risky-features.ts +39 -0
  108. package/src/lint/rules/data/trusted-action-owners.ts +12 -0
  109. package/src/lint/rules/data/untrusted-contexts.ts +44 -0
  110. package/src/mcp/context-tools.test.ts +108 -0
  111. package/src/mcp/context-tools.ts +172 -0
  112. package/src/plugin.test.ts +58 -3
  113. package/src/plugin.ts +5 -1
  114. package/src/serializer.ts +38 -5
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Read-only MCP tools that expose what `chant build` already computes about a
3
+ * GitHub Actions workflow — its jobs and run order, what it pulls in from
4
+ * outside (actions, images), and its security findings — so an agent can ask
5
+ * about a change *before it runs or merges* (#327).
6
+ *
7
+ * The GitHub counterpart to the GitLab context tools. Every tool builds from
8
+ * source and returns data. None of them touch the live GitHub instance, read
9
+ * run history, or write anything — that boundary is what keeps chant a context
10
+ * producer and not a copy of the live tooling.
11
+ */
12
+
13
+ import { build, type BuildResult } from "@intentius/chant/build";
14
+ import { runPostSynthChecks, getPrimaryOutput } from "@intentius/chant/lint/post-synth";
15
+ import { discoverPostSynthChecks } from "@intentius/chant/lint/discover";
16
+ import type { McpToolContribution } from "@intentius/chant/mcp/types";
17
+ import { dirname, join } from "path";
18
+ import { fileURLToPath } from "url";
19
+ import { githubSerializer } from "../serializer";
20
+ import {
21
+ extractJobs,
22
+ extractActionRefs,
23
+ extractImageRefs,
24
+ extractTriggers,
25
+ extractWorkflowName,
26
+ } from "../lint/post-synth/yaml-helpers";
27
+
28
+ /** Build the project and return the emitted GitHub workflow YAML plus the raw result. */
29
+ async function buildGithub(path: string): Promise<{ yaml: string; result: BuildResult }> {
30
+ const result = await build(path, [githubSerializer]);
31
+ const out = result.outputs.get("github");
32
+ return { yaml: out ? getPrimaryOutput(out) : "", result };
33
+ }
34
+
35
+ /** Discover the lexicon's post-synth checks without depending on the plugin. */
36
+ function githubPostSynthChecks() {
37
+ const dir = join(dirname(fileURLToPath(import.meta.url)), "..", "lint", "post-synth");
38
+ return discoverPostSynthChecks(dir, import.meta.url);
39
+ }
40
+
41
+ /** Is a `uses:` ref pinned to a full commit SHA (the only immutable form)? */
42
+ export function actionPinned(ref: string): boolean {
43
+ const at = ref.lastIndexOf("@");
44
+ return at !== -1 && /^[0-9a-f]{40}$/.test(ref.slice(at + 1));
45
+ }
46
+
47
+ /** Jobs that would re-run because they depend (transitively) on `job`. */
48
+ export function downstreamJobs(yaml: string, job: string): string[] {
49
+ const jobs = extractJobs(yaml);
50
+ // edge: upstream -> downstream (J depends on each of its needs)
51
+ const downstreamOf = new Map<string, string[]>();
52
+ for (const [name, j] of jobs) {
53
+ for (const dep of j.needs ?? []) {
54
+ const arr = downstreamOf.get(dep) ?? [];
55
+ arr.push(name);
56
+ downstreamOf.set(dep, arr);
57
+ }
58
+ }
59
+ const out = new Set<string>();
60
+ const queue = [job];
61
+ while (queue.length) {
62
+ const cur = queue.shift()!;
63
+ for (const next of downstreamOf.get(cur) ?? []) {
64
+ if (!out.has(next)) {
65
+ out.add(next);
66
+ queue.push(next);
67
+ }
68
+ }
69
+ }
70
+ return [...out];
71
+ }
72
+
73
+ const PATH_INPUT = {
74
+ type: "object" as const,
75
+ properties: {
76
+ path: { type: "string", description: "Path to the chant project directory (default: current directory)" },
77
+ },
78
+ };
79
+
80
+ /** The read-only context tools for the GitHub lexicon. */
81
+ export function githubContextTools(): McpToolContribution[] {
82
+ return [
83
+ {
84
+ name: "github:checks",
85
+ description:
86
+ "Build the workflow and return its security and correctness findings (the GHA checks) as JSON. Read-only — does not touch the live GitHub instance.",
87
+ inputSchema: PATH_INPUT,
88
+ async handler(params: Record<string, unknown>): Promise<unknown> {
89
+ const { yaml, result } = await buildGithub((params.path as string) ?? ".");
90
+ if (!yaml) return { findings: [], note: "no GitHub workflow produced from this project" };
91
+ const scoped = { ...result, outputs: new Map([["github", result.outputs.get("github")!]]) };
92
+ const diags = runPostSynthChecks(githubPostSynthChecks(), scoped);
93
+ return {
94
+ findings: diags.map((d) => ({
95
+ id: d.checkId,
96
+ severity: d.severity,
97
+ job: d.entity ?? null,
98
+ message: d.message,
99
+ })),
100
+ };
101
+ },
102
+ },
103
+ {
104
+ name: "github:workflow",
105
+ description:
106
+ "Build the workflow and return its triggers and jobs as written (name, what each job runs after, step count) — before anything runs. Read-only.",
107
+ inputSchema: PATH_INPUT,
108
+ async handler(params: Record<string, unknown>): Promise<unknown> {
109
+ const { yaml } = await buildGithub((params.path as string) ?? ".");
110
+ const jobs = [...extractJobs(yaml).values()].map((j) => ({
111
+ name: j.name,
112
+ runsAfter: j.needs ?? [],
113
+ steps: j.steps?.length ?? 0,
114
+ }));
115
+ return {
116
+ workflow: extractWorkflowName(yaml) ?? null,
117
+ triggers: Object.keys(extractTriggers(yaml)),
118
+ jobs,
119
+ };
120
+ },
121
+ },
122
+ {
123
+ name: "github:references",
124
+ description:
125
+ "Build the workflow and list everything it pulls in from outside (actions via uses:, container/service images) and whether each is pinned to an immutable commit SHA. Read-only.",
126
+ inputSchema: PATH_INPUT,
127
+ async handler(params: Record<string, unknown>): Promise<unknown> {
128
+ const { yaml } = await buildGithub((params.path as string) ?? ".");
129
+ const actions = extractActionRefs(yaml).map((a) => ({
130
+ kind: a.level === "job" ? ("reusable-workflow" as const) : ("action" as const),
131
+ job: a.job,
132
+ source: a.ref,
133
+ pinned: actionPinned(a.ref),
134
+ }));
135
+ const images = extractImageRefs(yaml).map((i) => ({
136
+ kind: "image" as const,
137
+ job: i.job,
138
+ source: i.image,
139
+ pinned: i.image.includes("@sha256:"),
140
+ }));
141
+ return [...actions, ...images];
142
+ },
143
+ },
144
+ {
145
+ name: "github:affected",
146
+ description:
147
+ "Build the workflow and, given a job name, list the jobs that would re-run because they depend on it (the needs chain). Read-only.",
148
+ inputSchema: {
149
+ type: "object" as const,
150
+ properties: {
151
+ path: { type: "string", description: "Path to the chant project directory (default: current directory)" },
152
+ job: { type: "string", description: "Job name to trace downstream from" },
153
+ },
154
+ required: ["job"],
155
+ },
156
+ async handler(params: Record<string, unknown>): Promise<unknown> {
157
+ const { yaml } = await buildGithub((params.path as string) ?? ".");
158
+ const job = params.job as string;
159
+ return { job, wouldRerun: downstreamJobs(yaml, job) };
160
+ },
161
+ },
162
+ {
163
+ name: "github:workflow-yaml",
164
+ description: "Build the project and return the generated GitHub Actions workflow YAML as a string. Read-only.",
165
+ inputSchema: PATH_INPUT,
166
+ async handler(params: Record<string, unknown>): Promise<unknown> {
167
+ const { yaml } = await buildGithub((params.path as string) ?? ".");
168
+ return { yaml };
169
+ },
170
+ },
171
+ ];
172
+ }
@@ -24,15 +24,61 @@ describe("githubPlugin", () => {
24
24
 
25
25
  test("provides post-synth checks", () => {
26
26
  const checks = githubPlugin.postSynthChecks!();
27
- expect(checks.length).toBe(15);
27
+ expect(checks.length).toBe(45);
28
28
 
29
29
  const checkIds = checks.map((c) => c.id);
30
30
  expect(checkIds).toContain("GHA006");
31
31
  expect(checkIds).toContain("GHA009");
32
32
  expect(checkIds).toContain("GHA011");
33
+ expect(checkIds).toContain("GHA013");
33
34
  expect(checkIds).toContain("GHA017");
34
35
  expect(checkIds).toContain("GHA018");
35
36
  expect(checkIds).toContain("GHA019");
37
+ expect(checkIds).toContain("GHA029");
38
+ expect(checkIds).toContain("GHA030");
39
+ expect(checkIds).toContain("GHA031");
40
+ expect(checkIds).toContain("GHA032");
41
+ expect(checkIds).toContain("GHA033");
42
+ expect(checkIds).toContain("GHA034");
43
+ expect(checkIds).toContain("GHA035");
44
+ expect(checkIds).toContain("GHA036");
45
+ expect(checkIds).toContain("GHA037");
46
+ expect(checkIds).toContain("GHA038");
47
+ expect(checkIds).toContain("GHA039");
48
+ expect(checkIds).toContain("GHA040");
49
+ expect(checkIds).toContain("GHA041");
50
+ expect(checkIds).toContain("GHA042");
51
+ expect(checkIds).toContain("GHA043");
52
+ expect(checkIds).toContain("GHA044");
53
+ expect(checkIds).toContain("GHA045");
54
+ expect(checkIds).toContain("GHA046");
55
+ expect(checkIds).toContain("GHA047");
56
+ expect(checkIds).toContain("GHA048");
57
+ expect(checkIds).toContain("GHA049");
58
+ expect(checkIds).toContain("GHA050");
59
+ expect(checkIds).toContain("GHA051");
60
+ expect(checkIds).toContain("GHA052");
61
+ expect(checkIds).toContain("GHA053");
62
+ expect(checkIds).toContain("GHA054");
63
+ expect(checkIds).toContain("GHA055");
64
+ expect(checkIds).toContain("GHA056");
65
+ expect(checkIds).toContain("GHA057");
66
+ expect(checkIds).toContain("GHA058");
67
+ });
68
+
69
+ test("lint IDs are unique across rules and post-synth checks", () => {
70
+ const ruleIds = githubPlugin.lintRules!().map((r) => r.id);
71
+ const checkIds = githubPlugin.postSynthChecks!().map((c) => c.id);
72
+ const allIds = [...ruleIds, ...checkIds];
73
+
74
+ const seen = new Set<string>();
75
+ const duplicates = new Set<string>();
76
+ for (const id of allIds) {
77
+ if (seen.has(id)) duplicates.add(id);
78
+ seen.add(id);
79
+ }
80
+
81
+ expect(Array.from(duplicates)).toEqual([]);
36
82
  });
37
83
 
38
84
  test("provides intrinsics", () => {
@@ -79,8 +125,17 @@ describe("githubPlugin", () => {
79
125
 
80
126
  test("provides MCP tools", () => {
81
127
  const tools = githubPlugin.mcpTools!();
82
- expect(tools.length).toBe(1);
83
- expect(tools[0].name).toBe("github:diff");
128
+ const names = tools.map((t) => t.name);
129
+ expect(names).toContain("github:diff");
130
+ // Read-only context tools (#327)
131
+ expect(names).toContain("github:checks");
132
+ expect(names).toContain("github:workflow");
133
+ expect(names).toContain("github:references");
134
+ expect(names).toContain("github:affected");
135
+ expect(names).toContain("github:workflow-yaml");
136
+ for (const t of tools) {
137
+ expect(typeof t.handler).toBe("function");
138
+ }
84
139
  });
85
140
 
86
141
  test("provides MCP resources", () => {
package/src/plugin.ts CHANGED
@@ -29,6 +29,7 @@ import { githubCompletions } from "./lsp/completions";
29
29
  import { githubHover } from "./lsp/hover";
30
30
  import { GitHubActionsParser } from "./import/parser";
31
31
  import { GitHubActionsGenerator } from "./import/generator";
32
+ import { githubContextTools } from "./mcp/context-tools";
32
33
 
33
34
  export const githubPlugin: LexiconPlugin = {
34
35
  name: "github",
@@ -224,7 +225,10 @@ export const build = new Job({
224
225
  },
225
226
 
226
227
  mcpTools() {
227
- return [createDiffTool(githubSerializer, "Compare current build output against previous output for GitHub Actions", "github")];
228
+ return [
229
+ createDiffTool(githubSerializer, "Compare current build output against previous output for GitHub Actions", "github"),
230
+ ...githubContextTools(),
231
+ ];
228
232
  },
229
233
 
230
234
  mcpResources() {
package/src/serializer.ts CHANGED
@@ -233,6 +233,7 @@ export const githubSerializer: Serializer = {
233
233
  const workflows: Array<[string, Declarable]> = [];
234
234
  const jobs: Array<[string, Declarable]> = [];
235
235
  const triggers: Array<[string, Declarable]> = [];
236
+ const dependabot: Array<[string, Declarable]> = [];
236
237
  const others: Array<[string, Declarable]> = [];
237
238
 
238
239
  for (const [name, entity] of entities) {
@@ -245,21 +246,53 @@ export const githubSerializer: Serializer = {
245
246
  jobs.push([name, entity]);
246
247
  } else if (isTriggerType(entityType)) {
247
248
  triggers.push([name, entity]);
249
+ } else if (entityType === "GitHub::Dependabot::Config") {
250
+ dependabot.push([name, entity]);
248
251
  } else {
249
252
  others.push([name, entity]);
250
253
  }
251
254
  }
252
255
 
253
- // If multiple workflows, produce multiple files
254
- if (workflows.length > 1) {
255
- return serializeMultiWorkflow(workflows, jobs, triggers, entities, entityNames);
256
+ const workflowOut =
257
+ workflows.length > 1
258
+ ? serializeMultiWorkflow(workflows, jobs, triggers, entities, entityNames)
259
+ : serializeSingleWorkflow(workflows, jobs, triggers, entities, entityNames);
260
+
261
+ // A DependabotConfig emits an additional `.github/dependabot.yml` file
262
+ // alongside (or instead of) any workflow output.
263
+ if (dependabot.length > 0) {
264
+ const [, dep] = dependabot[0];
265
+ const depProps = (dep as unknown as Record<string, unknown>).props as Record<string, unknown>;
266
+ const dependabotYaml = emitDependabotYaml(depProps);
267
+ if (typeof workflowOut === "string") {
268
+ return { primary: workflowOut, files: { "dependabot.yml": dependabotYaml } };
269
+ }
270
+ return { ...workflowOut, files: { ...(workflowOut.files ?? {}), "dependabot.yml": dependabotYaml } };
256
271
  }
257
272
 
258
- // Single workflow (or implicit workflow from jobs)
259
- return serializeSingleWorkflow(workflows, jobs, triggers, entities, entityNames);
273
+ return workflowOut;
260
274
  },
261
275
  };
262
276
 
277
+ /** Deep camelCase → kebab-case key conversion for dependabot props. */
278
+ function deepKebabKeys(value: unknown): unknown {
279
+ if (Array.isArray(value)) return value.map(deepKebabKeys);
280
+ if (value !== null && typeof value === "object") {
281
+ const out: Record<string, unknown> = {};
282
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
283
+ out[toKebabCase(k)] = deepKebabKeys(v);
284
+ }
285
+ return out;
286
+ }
287
+ return value;
288
+ }
289
+
290
+ /** Serialize a DependabotConfig's props into `.github/dependabot.yml`. */
291
+ function emitDependabotYaml(props: Record<string, unknown>): string {
292
+ const doc = deepKebabKeys(props) as Record<string, unknown>;
293
+ return emitYAMLDocument(doc);
294
+ }
295
+
263
296
  function serializeSingleWorkflow(
264
297
  workflows: Array<[string, Declarable]>,
265
298
  jobs: Array<[string, Declarable]>,