@intentius/chant-lexicon-github 0.124.18 → 0.124.19

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.
@@ -0,0 +1,27 @@
1
+ /**
2
+ * GitHub Actions dependencies as graph edges (#3910).
3
+ *
4
+ * `chant graph` draws an edge for every typed reference it can find, and a
5
+ * workflow has almost none: a job names the jobs it waits for, the reusable
6
+ * workflow it calls and the workflows that trigger it as strings. This module
7
+ * resolves those strings against the declared entities, the way the
8
+ * serializer would place them, and reports each one through the plugin's
9
+ * `graphEdges` hook:
10
+ *
11
+ * - `needs` job -> job in the same workflow
12
+ * - `calls` job -> the workflow a `uses: ./.github/workflows/<file>` names,
13
+ * and job -> a composite action a step uses by local path
14
+ * - `triggers` workflow -> each workflow its `on.workflow_run.workflows` names
15
+ *
16
+ * A string that names nothing declared (another repository's workflow, a
17
+ * typo'd job, a composite action, which chant does not declare) is reported
18
+ * as dangling, never matched to a near name.
19
+ *
20
+ * Forgejo reuses this module: its entities are github entities, and Forgejo
21
+ * reads reusable workflows from `.forgejo/workflows/` as well.
22
+ */
23
+ import type { Declarable } from "@intentius/chant/declarable";
24
+ import type { LexiconGraphEdges } from "@intentius/chant/graph-ir";
25
+ /** The graph edges and pipeline membership a GitHub (or Forgejo) project declares. */
26
+ export declare function githubGraphEdges(entities: ReadonlyMap<string, Declarable>): LexiconGraphEdges | undefined;
27
+ //# sourceMappingURL=graph-edges.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"graph-edges.d.ts","sourceRoot":"","sources":["../src/graph-edges.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAC;AAE9D,OAAO,KAAK,EAAE,iBAAiB,EAAyB,MAAM,2BAA2B,CAAC;AAqG1F,sFAAsF;AACtF,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,WAAW,CAAC,MAAM,EAAE,UAAU,CAAC,GAAG,iBAAiB,GAAG,SAAS,CA8DzG"}
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { githubSerializer } from "./serializer.js";
2
+ export { githubGraphEdges } from "./graph-edges.js";
2
3
  export { githubPlugin } from "./plugin.js";
3
4
  export { Expression, github, runner, secrets, matrix, steps, needs, inputs, vars, env, always, failure, success, cancelled, contains, startsWith, toJSON, fromJSON, format, branch, tag, } from "./expression.js";
4
5
  export { GitHub, Runner } from "./variables.js";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAGhD,OAAO,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AAGxC,OAAO,EACL,UAAU,EACV,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,GAAG,EAChE,MAAM,EAAE,OAAO,EAAE,OAAO,EAAE,SAAS,EACnC,QAAQ,EAAE,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAC9C,MAAM,EAAE,GAAG,GACZ,MAAM,cAAc,CAAC;AAGtB,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAI7C,cAAc,mBAAmB,CAAC;AAGlC,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,eAAe,CAAC;AAClE,YAAY,EAAE,SAAS,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAG5E,OAAO,EACL,QAAQ,EAAE,SAAS,EAAE,OAAO,EAAE,WAAW,EACzC,WAAW,EAAE,cAAc,EAAE,gBAAgB,EAC7C,MAAM,EACN,YAAY,EAAE,WAAW,EAAE,YAAY,EAAE,YAAY,EACrD,QAAQ,EACR,WAAW,EACX,iBAAiB,EACjB,IAAI,EACJ,UAAU,EAAE,gBAAgB,EAC5B,YAAY,GACb,MAAM,oBAAoB,CAAC;AAC5B,YAAY,EACV,aAAa,EAAE,cAAc,EAAE,YAAY,EAAE,gBAAgB,EAC7D,gBAAgB,EAAE,mBAAmB,EAAE,qBAAqB,EAC5D,WAAW,EACX,iBAAiB,EACjB,aAAa,EACb,gBAAgB,EAChB,sBAAsB,EACtB,SAAS,EACT,eAAe,EAAE,qBAAqB,EAAE,gBAAgB,EAAE,mBAAmB,EAC7E,iBAAiB,GAClB,MAAM,oBAAoB,CAAC;AAG5B,OAAO,EAAE,mBAAmB,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AACjE,OAAO,EAAE,mBAAmB,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAC1F,YAAY,EAAE,iBAAiB,EAAE,cAAc,EAAE,cAAc,EAAE,kBAAkB,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAGzH,OAAO,EAAE,QAAQ,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AACnE,OAAO,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AACnD,YAAY,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAChD,OAAO,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAGjD,OAAO,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AAGxC,OAAO,EACL,UAAU,EACV,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,GAAG,EAChE,MAAM,EAAE,OAAO,EAAE,OAAO,EAAE,SAAS,EACnC,QAAQ,EAAE,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAC9C,MAAM,EAAE,GAAG,GACZ,MAAM,cAAc,CAAC;AAGtB,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAI7C,cAAc,mBAAmB,CAAC;AAGlC,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,eAAe,CAAC;AAClE,YAAY,EAAE,SAAS,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAG5E,OAAO,EACL,QAAQ,EAAE,SAAS,EAAE,OAAO,EAAE,WAAW,EACzC,WAAW,EAAE,cAAc,EAAE,gBAAgB,EAC7C,MAAM,EACN,YAAY,EAAE,WAAW,EAAE,YAAY,EAAE,YAAY,EACrD,QAAQ,EACR,WAAW,EACX,iBAAiB,EACjB,IAAI,EACJ,UAAU,EAAE,gBAAgB,EAC5B,YAAY,GACb,MAAM,oBAAoB,CAAC;AAC5B,YAAY,EACV,aAAa,EAAE,cAAc,EAAE,YAAY,EAAE,gBAAgB,EAC7D,gBAAgB,EAAE,mBAAmB,EAAE,qBAAqB,EAC5D,WAAW,EACX,iBAAiB,EACjB,aAAa,EACb,gBAAgB,EAChB,sBAAsB,EACtB,SAAS,EACT,eAAe,EAAE,qBAAqB,EAAE,gBAAgB,EAAE,mBAAmB,EAC7E,iBAAiB,GAClB,MAAM,oBAAoB,CAAC;AAG5B,OAAO,EAAE,mBAAmB,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AACjE,OAAO,EAAE,mBAAmB,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAC1F,YAAY,EAAE,iBAAiB,EAAE,cAAc,EAAE,cAAc,EAAE,kBAAkB,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAGzH,OAAO,EAAE,QAAQ,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AACnE,OAAO,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AACnD,YAAY,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC"}
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "62765f1a817547b9377e08ba5f12be9af77e4d8a62d92f7a456c896c8a0358be",
4
+ "manifest.json": "70f80920964615564f5d1eaee17b6f74bdac4ff7c52bd4f2016400505dab9af2",
5
5
  "meta.json": "c50e0ef3845c370c3ad2210b8bba455980417c0f818aa6ed46e06d4fb8b1b9f9",
6
6
  "types/index.d.ts": "0ead95536e947ddfb98a5acf0ddb88547913b5a53a342df67711c59266f84b3e",
7
7
  "rules/deprecated-action-version.ts": "a9ba7cc924ff1c7055110416d8989273690415bfb5e8dcc32dc9431b9351f5ca",
@@ -78,5 +78,5 @@
78
78
  "skills/chant-github-patterns.md": "bb3abef289a8fdfcf07d6bb2d7289dcb2f38bc0cb0321ea320b78b45a6f548c0",
79
79
  "skills/chant-github-security.md": "aab111cb0871cad30281ce48d7da23663689619351029219e2be019a1a61e394"
80
80
  },
81
- "composite": "0a21f2771c147c899dcbc07d4546aaace1d85ff61769bccff37dd42b7ebfa796"
81
+ "composite": "312cc217df7ecf85abe0e3611828a78fbf581ed38edf0023b787a5b55e1ecca5"
82
82
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github",
3
- "version": "0.124.18",
3
+ "version": "0.124.19",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "GitHub",
6
6
  "intrinsics": [
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,KAAK,EAAE,aAAa,EAAiC,MAAM,0BAA0B,CAAC;AA8B7F,eAAO,MAAM,YAAY,EAAE,aAgV1B,CAAC"}
1
+ {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,KAAK,EAAE,aAAa,EAAiC,MAAM,0BAA0B,CAAC;AA+B7F,eAAO,MAAM,YAAY,EAAE,aAkV1B,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-github",
3
- "version": "0.124.18",
3
+ "version": "0.124.19",
4
4
  "description": "GitHub Actions lexicon for chant — declarative IaC in TypeScript",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -61,6 +61,6 @@
61
61
  "typescript": "^5.9.3"
62
62
  },
63
63
  "peerDependencies": {
64
- "@intentius/chant": "^0.124.18"
64
+ "@intentius/chant": "^0.124.19"
65
65
  }
66
66
  }
@@ -0,0 +1,126 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { DECLARABLE_MARKER, type Declarable } from "@intentius/chant/declarable";
3
+ import { applyLexiconGraphEdges, buildGraphIr } from "@intentius/chant/graph-ir";
4
+ import { githubGraphEdges } from "./graph-edges";
5
+ import { githubPlugin } from "./plugin";
6
+
7
+ function entity(entityType: string, props: Record<string, unknown>, kind: "resource" | "property" = "resource"): Declarable {
8
+ return { [DECLARABLE_MARKER]: true, lexicon: "github", entityType, kind, props } as unknown as Declarable;
9
+ }
10
+ const workflow = (props: Record<string, unknown> = {}) => entity("GitHub::Actions::Workflow", props);
11
+ const job = (props: Record<string, unknown> = {}) => entity("GitHub::Actions::Job", props);
12
+ const step = (props: Record<string, unknown>) => entity("GitHub::Actions::Step", props, "property");
13
+
14
+ /** Run the hook and merge it the way `chant graph` does. */
15
+ function graph(entities: Map<string, Declarable>) {
16
+ const report = githubGraphEdges(entities);
17
+ return applyLexiconGraphEdges(buildGraphIr(entities), report ? [report] : []);
18
+ }
19
+
20
+ /** The edges the hook added. A workflow holding an exported Job in `jobs:`
21
+ * already had a `ref` edge to it from the core walk; that one is not ours. */
22
+ const ciEdges = (ir: ReturnType<typeof graph>) => ir.edges.filter((e) => e.kind !== "ref");
23
+
24
+ describe("githubGraphEdges (#3910)", () => {
25
+ test("the plugin exposes the hook", () => {
26
+ expect(githubPlugin.graphEdges).toBeTypeOf("function");
27
+ });
28
+
29
+ test("needs: job to job within the workflow, by kebab-cased key", () => {
30
+ const ir = graph(new Map<string, Declarable>([
31
+ ["ci", workflow({ name: "CI" })],
32
+ ["build", job({})],
33
+ ["unitTest", job({ needs: ["build"] })],
34
+ ["deploy", job({ needs: "unit-test" })],
35
+ ]));
36
+ expect(ciEdges(ir)).toEqual([
37
+ { from: "deploy", to: "unitTest", kind: "needs", viaAttr: "needs" },
38
+ { from: "unitTest", to: "build", kind: "needs", viaAttr: "needs" },
39
+ ]);
40
+ expect(ir.groups.byPipeline).toEqual({ ci: ["build", "ci", "deploy", "unitTest"] });
41
+ expect(ir.dangling).toBeUndefined();
42
+ });
43
+
44
+ test("needs resolves within the workflow that holds the job, not across workflows", () => {
45
+ const build = job({});
46
+ const release = job({ needs: ["build"] });
47
+ const ir = graph(new Map<string, Declarable>([
48
+ ["ci", workflow({ name: "CI", jobs: { build } })],
49
+ ["publish", workflow({ name: "Publish", jobs: { release } })],
50
+ ["build", build],
51
+ ["release", release],
52
+ ]));
53
+ expect(ciEdges(ir)).toEqual([]);
54
+ expect(ir.dangling).toEqual([{ from: "release", kind: "needs", path: "needs", value: "build" }]);
55
+ });
56
+
57
+ test("uses: ./.github/workflows/<file> calls the declared workflow written to that file", () => {
58
+ const callReusable = job({ uses: "./.github/workflows/reusable-ci.yml" });
59
+ const ir = graph(new Map<string, Declarable>([
60
+ ["caller", workflow({ name: "Caller", jobs: { callReusable } })],
61
+ ["reusableCi", workflow({ name: "Reusable", on: { workflow_call: {} }, jobs: { test: job({}) } })],
62
+ ["callReusable", callReusable],
63
+ ]));
64
+ expect(ciEdges(ir)).toEqual([
65
+ { from: "callReusable", to: "reusableCi", kind: "calls", viaAttr: "uses", crossPipeline: true },
66
+ ]);
67
+ });
68
+
69
+ test("a .forgejo/workflows path resolves the same way", () => {
70
+ const call = job({ uses: "./.forgejo/workflows/shared.yaml" });
71
+ const ir = graph(new Map<string, Declarable>([
72
+ ["main", workflow({ jobs: { call } })],
73
+ ["shared", workflow({ jobs: { t: job({}) } })],
74
+ ["call", call],
75
+ ]));
76
+ expect(ciEdges(ir).map((e) => [e.from, e.to, e.kind])).toEqual([["call", "shared", "calls"]]);
77
+ });
78
+
79
+ test("on.workflow_run.workflows: the triggered workflow points at the workflow named", () => {
80
+ const ir = graph(new Map<string, Declarable>([
81
+ ["ci", workflow({ name: "CI", jobs: { t: job({}) } })],
82
+ ["deploy", workflow({ name: "Deploy", on: { workflowRun: { workflows: ["CI"], types: ["completed"] } }, jobs: { d: job({}) } })],
83
+ ]));
84
+ expect(ciEdges(ir)).toEqual([
85
+ { from: "deploy", to: "ci", kind: "triggers", viaAttr: "workflow_run", crossPipeline: true },
86
+ ]);
87
+ });
88
+
89
+ test("a step using a local composite action is reported, dangling: chant does not declare actions", () => {
90
+ const ir = graph(new Map<string, Declarable>([
91
+ ["ci", workflow({})],
92
+ ["build", job({ steps: [step({ uses: "actions/checkout@v4" }), step({ uses: "./.github/actions/setup" })] })],
93
+ ]));
94
+ expect(ciEdges(ir)).toEqual([]);
95
+ expect(ir.dangling).toEqual([{ from: "build", kind: "calls", path: "steps.uses", value: "./.github/actions/setup" }]);
96
+ });
97
+
98
+ test("dangling: unknown job, another repository's workflow, an undeclared workflow name", () => {
99
+ const ir = graph(new Map<string, Declarable>([
100
+ ["ci", workflow({ name: "CI", on: { workflow_run: { workflows: ["Nightly"] } } })],
101
+ ["build", job({ needs: ["compile"] })],
102
+ ["shared", job({ uses: "octo-org/infra/.github/workflows/deploy.yml@v1" })],
103
+ ["local", job({ uses: "./.github/workflows/missing.yml" })],
104
+ ]));
105
+ expect(ciEdges(ir)).toEqual([]);
106
+ expect(ir.dangling).toEqual([
107
+ { from: "build", kind: "needs", path: "needs", value: "compile" },
108
+ { from: "ci", kind: "triggers", path: "workflow_run", value: "Nightly", crossPipeline: true },
109
+ { from: "local", kind: "calls", path: "uses", value: "./.github/workflows/missing.yml", crossPipeline: true },
110
+ { from: "shared", kind: "calls", path: "uses", value: "octo-org/infra/.github/workflows/deploy.yml@v1", crossPipeline: true },
111
+ ]);
112
+ });
113
+
114
+ test("standalone jobs follow the serializer: they run in the first workflow only", () => {
115
+ const ir = graph(new Map<string, Declarable>([
116
+ ["first", workflow({})],
117
+ ["second", workflow({})],
118
+ ["build", job({})],
119
+ ]));
120
+ expect(ir.groups.byPipeline).toEqual({ first: ["build", "first"], second: ["second"] });
121
+ });
122
+
123
+ test("a project with no workflows or jobs reports nothing", () => {
124
+ expect(githubGraphEdges(new Map())).toBeUndefined();
125
+ });
126
+ });
@@ -0,0 +1,190 @@
1
+ /**
2
+ * GitHub Actions dependencies as graph edges (#3910).
3
+ *
4
+ * `chant graph` draws an edge for every typed reference it can find, and a
5
+ * workflow has almost none: a job names the jobs it waits for, the reusable
6
+ * workflow it calls and the workflows that trigger it as strings. This module
7
+ * resolves those strings against the declared entities, the way the
8
+ * serializer would place them, and reports each one through the plugin's
9
+ * `graphEdges` hook:
10
+ *
11
+ * - `needs` job -> job in the same workflow
12
+ * - `calls` job -> the workflow a `uses: ./.github/workflows/<file>` names,
13
+ * and job -> a composite action a step uses by local path
14
+ * - `triggers` workflow -> each workflow its `on.workflow_run.workflows` names
15
+ *
16
+ * A string that names nothing declared (another repository's workflow, a
17
+ * typo'd job, a composite action, which chant does not declare) is reported
18
+ * as dangling, never matched to a near name.
19
+ *
20
+ * Forgejo reuses this module: its entities are github entities, and Forgejo
21
+ * reads reusable workflows from `.forgejo/workflows/` as well.
22
+ */
23
+
24
+ import type { Declarable } from "@intentius/chant/declarable";
25
+ import { isPropertyDeclarable } from "@intentius/chant/declarable";
26
+ import type { LexiconGraphEdges, LexiconGraphReference } from "@intentius/chant/graph-ir";
27
+
28
+ const WORKFLOW = "GitHub::Actions::Workflow";
29
+ const JOB_TYPES = new Set(["GitHub::Actions::Job", "GitHub::Actions::ReusableWorkflowCallJob"]);
30
+
31
+ function toKebabCase(name: string): string {
32
+ return name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
33
+ }
34
+
35
+ function entityType(value: unknown): string | undefined {
36
+ if (!value || typeof value !== "object") return undefined;
37
+ const t = (value as { entityType?: unknown }).entityType;
38
+ return typeof t === "string" ? t : undefined;
39
+ }
40
+
41
+ /** A Declarable's props bag, or the plain object itself (JSON-style jobs and steps). */
42
+ function propsOf(value: unknown): Record<string, unknown> | undefined {
43
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
44
+ const props = (value as { props?: unknown }).props;
45
+ if (entityType(value) !== undefined) {
46
+ return props && typeof props === "object" ? (props as Record<string, unknown>) : undefined;
47
+ }
48
+ return value as Record<string, unknown>;
49
+ }
50
+
51
+ /** One job as the serializer places it: its YAML key and the node it is, if any. */
52
+ interface PlacedJob {
53
+ key: string;
54
+ node?: string;
55
+ props: Record<string, unknown>;
56
+ }
57
+
58
+ interface PlacedWorkflow {
59
+ name: string;
60
+ props: Record<string, unknown>;
61
+ jobs: PlacedJob[];
62
+ }
63
+
64
+ /**
65
+ * Which jobs land in which workflow, mirroring ./serializer.ts: a workflow's
66
+ * own `jobs:` map when it has one, and otherwise the standalone Job exports,
67
+ * which go to the first workflow only.
68
+ */
69
+ function placeJobs(entities: ReadonlyMap<string, Declarable>): { workflows: PlacedWorkflow[]; orphans: PlacedJob[] } {
70
+ const names = new Map<unknown, string>();
71
+ const workflows: Array<[string, Declarable]> = [];
72
+ const standalone: PlacedJob[] = [];
73
+ for (const [name, entity] of entities) {
74
+ names.set(entity, name);
75
+ if (isPropertyDeclarable(entity)) continue;
76
+ const type = entityType(entity);
77
+ if (type === WORKFLOW) workflows.push([name, entity]);
78
+ else if (type && JOB_TYPES.has(type)) standalone.push({ key: toKebabCase(name), node: name, props: propsOf(entity) ?? {} });
79
+ }
80
+
81
+ const placed: PlacedWorkflow[] = workflows.map(([name, wf], i) => {
82
+ const props = propsOf(wf) ?? {};
83
+ const inline: PlacedJob[] = [];
84
+ const jobs = props.jobs;
85
+ if (jobs && typeof jobs === "object" && !Array.isArray(jobs)) {
86
+ for (const [key, value] of Object.entries(jobs as Record<string, unknown>)) {
87
+ const jobProps = propsOf(value);
88
+ if (!jobProps) continue;
89
+ inline.push({ key: toKebabCase(key), node: names.get(value), props: jobProps });
90
+ }
91
+ }
92
+ return { name, props, jobs: inline.length > 0 ? inline : i === 0 ? standalone : [] };
93
+ });
94
+ return { workflows: placed, orphans: workflows.length === 0 ? standalone : [] };
95
+ }
96
+
97
+ /** The file a declared workflow is written to. Every workflow after the first
98
+ * is `<kebab-name>.yml` (./serializer.ts); the first is written to the
99
+ * `--output` path, which the examples and templates name the same way. */
100
+ function workflowFile(name: string): string {
101
+ return `${toKebabCase(name)}.yml`;
102
+ }
103
+
104
+ const stripYamlExt = (file: string): string => file.replace(/\.ya?ml$/, "");
105
+
106
+ /** `needs:` as a list of job keys. A Job object held directly names itself. */
107
+ function needsKeys(needs: unknown, names: Map<unknown, string>): string[] {
108
+ const list = Array.isArray(needs) ? needs : needs === undefined ? [] : [needs];
109
+ const out: string[] = [];
110
+ for (const item of list) {
111
+ if (typeof item === "string") out.push(item);
112
+ else if (names.has(item)) out.push(toKebabCase(names.get(item)!));
113
+ }
114
+ return out;
115
+ }
116
+
117
+ /** `on.workflow_run.workflows`, read from the camelCase or snake_case key. */
118
+ function workflowRunNames(on: unknown): string[] {
119
+ const triggers = propsOf(on);
120
+ if (!triggers) return [];
121
+ const run = propsOf(triggers.workflow_run ?? triggers.workflowRun);
122
+ const workflows = run?.workflows;
123
+ if (typeof workflows === "string") return [workflows];
124
+ return Array.isArray(workflows) ? workflows.filter((w): w is string => typeof w === "string") : [];
125
+ }
126
+
127
+ /** The graph edges and pipeline membership a GitHub (or Forgejo) project declares. */
128
+ export function githubGraphEdges(entities: ReadonlyMap<string, Declarable>): LexiconGraphEdges | undefined {
129
+ const { workflows, orphans } = placeJobs(entities);
130
+ if (workflows.length === 0 && orphans.length === 0) return undefined;
131
+
132
+ const names = new Map<unknown, string>();
133
+ for (const [name, entity] of entities) names.set(entity, name);
134
+
135
+ const byFile = new Map<string, string>();
136
+ const byDisplayName = new Map<string, string>();
137
+ for (const wf of workflows) {
138
+ byFile.set(stripYamlExt(workflowFile(wf.name)), wf.name);
139
+ if (typeof wf.props.name === "string") byDisplayName.set(wf.props.name, wf.name);
140
+ }
141
+
142
+ const references: LexiconGraphReference[] = [];
143
+ const pipelines: Record<string, string[]> = {};
144
+
145
+ const visitJobs = (jobs: PlacedJob[], workflow?: string): void => {
146
+ const byKey = new Map(jobs.map((j) => [j.key, j]));
147
+ for (const job of jobs) {
148
+ const from = job.node ?? workflow;
149
+ if (!from) continue;
150
+
151
+ for (const key of needsKeys(job.props.needs, names)) {
152
+ const target = byKey.get(key);
153
+ const to = target ? (target.node ?? workflow) : undefined;
154
+ if (to) references.push({ from, to, kind: "needs", viaAttr: "needs" });
155
+ else references.push({ from, to: key, kind: "needs", viaAttr: "needs", dangling: true });
156
+ }
157
+
158
+ const uses = job.props.uses;
159
+ if (typeof uses === "string") {
160
+ const local = /^\.\/(?:.*\/)?workflows\/([^/]+\.ya?ml)$/.exec(uses);
161
+ const called = local ? byFile.get(stripYamlExt(local[1])) : undefined;
162
+ if (called) references.push({ from, to: called, kind: "calls", viaAttr: "uses", crossPipeline: true });
163
+ else references.push({ from, to: uses, kind: "calls", viaAttr: "uses", crossPipeline: true, dangling: true });
164
+ }
165
+
166
+ const steps = Array.isArray(job.props.steps) ? job.props.steps : [];
167
+ for (const step of steps) {
168
+ const stepUses = propsOf(step)?.uses;
169
+ // A local composite action. Remote actions (`owner/repo@ref`,
170
+ // `docker://`) are packages, not part of this project's graph.
171
+ if (typeof stepUses === "string" && stepUses.startsWith("./")) {
172
+ references.push({ from, to: stepUses, kind: "calls", viaAttr: "steps.uses", dangling: true });
173
+ }
174
+ }
175
+ }
176
+ };
177
+
178
+ for (const wf of workflows) {
179
+ visitJobs(wf.jobs, wf.name);
180
+ pipelines[wf.name] = [wf.name, ...wf.jobs.map((j) => j.node).filter((n): n is string => n !== undefined)];
181
+ for (const trigger of workflowRunNames(wf.props.on)) {
182
+ const to = byDisplayName.get(trigger);
183
+ if (to) references.push({ from: wf.name, to, kind: "triggers", viaAttr: "workflow_run", crossPipeline: true });
184
+ else references.push({ from: wf.name, to: trigger, kind: "triggers", viaAttr: "workflow_run", crossPipeline: true, dangling: true });
185
+ }
186
+ }
187
+ visitJobs(orphans);
188
+
189
+ return { references, ...(Object.keys(pipelines).length > 0 ? { pipelines } : {}) };
190
+ }
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  // Serializer
2
2
  export { githubSerializer } from "./serializer";
3
+ export { githubGraphEdges } from "./graph-edges";
3
4
 
4
5
  // Plugin
5
6
  export { githubPlugin } from "./plugin";
package/src/plugin.ts CHANGED
@@ -14,6 +14,7 @@ import { createSkillsLoader, createDiffTool, createCatalogResource } from "@inte
14
14
  import { join, dirname } from "path";
15
15
  import { fileURLToPath } from "url";
16
16
  import { githubSerializer } from "./serializer";
17
+ import { githubGraphEdges } from "./graph-edges";
17
18
  import { useTypedActionsRule } from "./lint/rules/use-typed-actions";
18
19
  import { useConditionBuildersRule } from "./lint/rules/use-condition-builders";
19
20
  import { noHardcodedSecretsRule } from "./lint/rules/no-hardcoded-secrets";
@@ -46,6 +47,8 @@ export const githubPlugin: LexiconPlugin = {
46
47
  // Ordered waves of Op runs, each behind one gate (#3679).
47
48
  generateOpWavesPipeline: (spec, options) => generateGithubOpWavesPipeline(spec, options),
48
49
  serializer: githubSerializer,
50
+ // `needs`, reusable-workflow `uses:` and `workflow_run` as graph edges (#3910).
51
+ graphEdges: (entities) => githubGraphEdges(entities),
49
52
 
50
53
  lintRules(): LintRule[] {
51
54
  return [