@intentius/chant-lexicon-gitlab 0.124.18 → 0.124.20

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,28 @@
1
+ /**
2
+ * GitLab CI dependencies as graph edges (#3910).
3
+ *
4
+ * A GitLab job names everything it depends on as a string: the jobs it
5
+ * `needs`, the job it `extends`, the pipeline it `trigger`s, and the pipeline
6
+ * names the files it `include`s. `chant graph` cannot read those, so this
7
+ * module resolves them against the declared entities and reports each one
8
+ * through the plugin's `graphEdges` hook:
9
+ *
10
+ * - `needs` job -> job, from `needs:` (a name, `{ job }`, or the Job
11
+ * itself); a job without `needs:` waits for the stages before
12
+ * its own, reported as `needs` via `stage`
13
+ * - `extends` job -> the job it inherits from
14
+ * - `triggers` job -> the child or multi-project pipeline it starts
15
+ * - `includes` pipeline -> each file its `include:` pulls in
16
+ *
17
+ * Jobs are named in the YAML by their kebab-cased export name, as
18
+ * ./serializer.ts writes them. Cross-pipeline references (`trigger:`,
19
+ * `include:`, `needs: { project }` and `needs: { pipeline }`) name a pipeline
20
+ * this project does not generate, so they are reported as dangling and
21
+ * marked `crossPipeline`. So is any job name nothing declares: a hidden
22
+ * template from an included file, a typo.
23
+ */
24
+ import type { Declarable } from "@intentius/chant/declarable";
25
+ import type { LexiconGraphEdges } from "@intentius/chant/graph-ir";
26
+ /** The graph edges and pipeline membership a GitLab CI project declares. */
27
+ export declare function gitlabGraphEdges(entities: ReadonlyMap<string, Declarable>): LexiconGraphEdges | undefined;
28
+ //# 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;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAC;AAE9D,OAAO,KAAK,EAAE,iBAAiB,EAAyB,MAAM,2BAA2B,CAAC;AA8D1F,4EAA4E;AAC5E,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,WAAW,CAAC,MAAM,EAAE,UAAU,CAAC,GAAG,iBAAiB,GAAG,SAAS,CA6GzG"}
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { gitlabSerializer } from "./serializer.js";
2
+ export { gitlabGraphEdges } from "./graph-edges.js";
2
3
  export { gitlabPlugin } from "./plugin.js";
3
4
  export { gitlabPipeline } from "./op/builders.js";
4
5
  export { reference, ReferenceIntrinsic } from "./intrinsics.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;AAOxC,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAG/C,OAAO,EAAE,SAAS,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAG7D,OAAO,EAAE,EAAE,EAAE,MAAM,aAAa,CAAC;AAIjC,cAAc,mBAAmB,CAAC;AAGlC,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,WAAW,EAAE,YAAY,EAAE,cAAc,EAAE,SAAS,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AACnI,YAAY,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,mBAAmB,EAAE,cAAc,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAGtI,OAAO,EAAE,aAAa,EAAE,YAAY,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAC;AACrF,OAAO,EAAE,aAAa,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AACpF,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;AAOxC,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAG/C,OAAO,EAAE,SAAS,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAG7D,OAAO,EAAE,EAAE,EAAE,MAAM,aAAa,CAAC;AAIjC,cAAc,mBAAmB,CAAC;AAGlC,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,WAAW,EAAE,YAAY,EAAE,cAAc,EAAE,SAAS,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AACnI,YAAY,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,mBAAmB,EAAE,cAAc,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAGtI,OAAO,EAAE,aAAa,EAAE,YAAY,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAC;AACrF,OAAO,EAAE,aAAa,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AACpF,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": "9fc693da7737f59ded275b02bec5d0728953c080cbf890af2a7addff921bad7a",
4
+ "manifest.json": "76651fdf8d5ef3e97c06ffa43878ca1557abeed81fcd6c831be67ba25435810b",
5
5
  "meta.json": "db082fbf90c5941c498b2bee0f106103449c1a4c257716ca2220f0dd490450b2",
6
6
  "types/index.d.ts": "a048a66ebe20da0cb5fc889054cac69044d9bf882e2c56b5c5bf113d1f3ba57c",
7
7
  "rules/artifact-no-expiry.ts": "0ca5bb972d4a542126e8a0229d43b55658394cb7dd9b9923b2c590bec1c2ad7d",
@@ -55,5 +55,5 @@
55
55
  "skills/chant-gitlab-migrate.md": "4853d04980560b379e5b0f7267dcb0a1e5d5879cb9e9931681ce76b2a5fe69d8",
56
56
  "skills/chant-gitlab-patterns.md": "6d9a44e9e8de4c3820be9d65381b0d2ede4cb3626aaaf0011e21838a9fbf556e"
57
57
  },
58
- "composite": "0b5e06ca63b3184e95270d0da0f4cc03e7b0453782f3ae418fd853ffce06f048"
58
+ "composite": "6ea34b300003999b75345a60087bddcd33cbf1cea8d007dfe71e1add9587b60e"
59
59
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitlab",
3
- "version": "0.124.18",
3
+ "version": "0.124.20",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "GitLab",
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;AAqB7F,eAAO,MAAM,YAAY,EAAE,aA0hB1B,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;AAsB7F,eAAO,MAAM,YAAY,EAAE,aA4hB1B,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-gitlab",
3
- "version": "0.124.18",
3
+ "version": "0.124.20",
4
4
  "description": "GitLab CI lexicon for chant — declarative IaC in TypeScript",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -67,8 +67,8 @@
67
67
  "typescript": "^5.9.3"
68
68
  },
69
69
  "peerDependencies": {
70
- "@intentius/chant": "^0.124.18",
71
- "@intentius/chant-lexicon-github": "^0.124.18"
70
+ "@intentius/chant": "^0.124.20",
71
+ "@intentius/chant-lexicon-github": "^0.124.20"
72
72
  },
73
73
  "peerDependenciesMeta": {
74
74
  "@intentius/chant-lexicon-github": {
@@ -0,0 +1,122 @@
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 { gitlabGraphEdges } from "./graph-edges";
5
+ import { gitlabPlugin } from "./plugin";
6
+
7
+ function entity(entityType: string, props: Record<string, unknown>, kind: "resource" | "property" = "resource"): Declarable {
8
+ return { [DECLARABLE_MARKER]: true, lexicon: "gitlab", entityType, kind, props } as unknown as Declarable;
9
+ }
10
+ const job = (props: Record<string, unknown> = {}) => entity("GitLab::CI::Job", props);
11
+ const pipeline = (props: Record<string, unknown>) => entity("GitLab::CI::Pipeline", props);
12
+ const need = (props: Record<string, unknown>) => entity("GitLab::CI::Need", props, "property");
13
+ const trigger = (props: Record<string, unknown>) => entity("GitLab::CI::Trigger", props, "property");
14
+ const include = (props: Record<string, unknown>) => entity("GitLab::CI::Include", props, "property");
15
+
16
+ /** Run the hook and merge it the way `chant graph` does. */
17
+ function graph(entities: Map<string, Declarable>) {
18
+ const report = gitlabGraphEdges(entities);
19
+ return applyLexiconGraphEdges(buildGraphIr(entities), report ? [report] : []);
20
+ }
21
+
22
+ describe("gitlabGraphEdges (#3910)", () => {
23
+ test("the plugin exposes the hook", () => {
24
+ expect(gitlabPlugin.graphEdges).toBeTypeOf("function");
25
+ });
26
+
27
+ test("needs: a name, { job } and the Job object itself all resolve to the job", () => {
28
+ const build = job({ stage: "build" });
29
+ const ir = graph(new Map<string, Declarable>([
30
+ ["build", build],
31
+ ["lintCode", job({ stage: "build" })],
32
+ ["unitTest", job({ stage: "test", needs: ["build", need({ job: "lint-code", artifacts: false })] })],
33
+ ["deploy", job({ stage: "deploy", needs: [build] })],
34
+ ]));
35
+ expect(ir.edges).toEqual([
36
+ { from: "deploy", to: "build", kind: "needs", viaAttr: "needs" },
37
+ { from: "unitTest", to: "build", kind: "needs", viaAttr: "needs" },
38
+ { from: "unitTest", to: "lintCode", kind: "needs", viaAttr: "needs" },
39
+ ]);
40
+ expect(ir.groups.byPipeline).toEqual({ ".gitlab-ci.yml": ["build", "deploy", "lintCode", "unitTest"] });
41
+ });
42
+
43
+ test("a job without needs: waits for the stage before its own", () => {
44
+ const ir = graph(new Map<string, Declarable>([
45
+ ["build", job({ stage: "build" })],
46
+ ["test", job({ stage: "test" })],
47
+ ["deploy", job({ stage: "deploy" })],
48
+ ]));
49
+ expect(ir.edges).toEqual([
50
+ { from: "deploy", to: "test", kind: "needs", viaAttr: "stage" },
51
+ { from: "test", to: "build", kind: "needs", viaAttr: "stage" },
52
+ ]);
53
+ });
54
+
55
+ test("stage order looks further back past a job that starts early with needs:", () => {
56
+ const ir = graph(new Map<string, Declarable>([
57
+ ["compile", job({ stage: "build" })],
58
+ ["quick", job({ stage: "test", needs: [] })],
59
+ ["deploy", job({ stage: "deploy" })],
60
+ ]));
61
+ expect(ir.edges).toEqual([
62
+ { from: "deploy", to: "compile", kind: "needs", viaAttr: "stage" },
63
+ { from: "deploy", to: "quick", kind: "needs", viaAttr: "stage" },
64
+ ]);
65
+ });
66
+
67
+ test("a Pipeline's stages: list sets the order, and a job with no stage is in test", () => {
68
+ const ir = graph(new Map<string, Declarable>([
69
+ ["pipeline", pipeline({ stages: ["test", "package"] })],
70
+ ["pack", job({ stage: "package" })],
71
+ ["check", job({})],
72
+ ]));
73
+ expect(ir.edges).toEqual([{ from: "pack", to: "check", kind: "needs", viaAttr: "stage" }]);
74
+ });
75
+
76
+ test("extends: the job it inherits from", () => {
77
+ const ir = graph(new Map<string, Declarable>([
78
+ ["baseJob", job({ stage: "build" })],
79
+ ["build", job({ stage: "build", extends: "base-job" })],
80
+ ]));
81
+ expect(ir.edges).toEqual([{ from: "build", to: "baseJob", kind: "extends", viaAttr: "extends" }]);
82
+ });
83
+
84
+ test("trigger: and include: name pipelines this project does not generate, so they dangle", () => {
85
+ const ir = graph(new Map<string, Declarable>([
86
+ ["pipeline", pipeline({ include: [include({ local: "ci/templates.yml" }), "https://example.com/ci.yml", include({ project: "group/shared", ref: "main", file: "ci.yml" })] })],
87
+ ["child", job({ stage: "build", trigger: trigger({ include: "child/.gitlab-ci.yml", strategy: "depend" }) })],
88
+ ["downstream", job({ stage: "build", trigger: "group/downstream" })],
89
+ ["multi", job({ stage: "build", trigger: trigger({ project: "group/other", branch: "main" }) })],
90
+ ]));
91
+ expect(ir.edges).toEqual([]);
92
+ expect(ir.dangling).toEqual([
93
+ { from: "child", kind: "triggers", path: "trigger.include", value: "child/.gitlab-ci.yml", crossPipeline: true },
94
+ { from: "downstream", kind: "triggers", path: "trigger", value: "group/downstream", crossPipeline: true },
95
+ { from: "multi", kind: "triggers", path: "trigger.project", value: "group/other@main", crossPipeline: true },
96
+ { from: "pipeline", kind: "includes", path: "include", value: "ci/templates.yml", crossPipeline: true },
97
+ { from: "pipeline", kind: "includes", path: "include", value: "group/shared@main:ci.yml", crossPipeline: true },
98
+ { from: "pipeline", kind: "includes", path: "include", value: "https://example.com/ci.yml", crossPipeline: true },
99
+ ]);
100
+ });
101
+
102
+ test("dangling: cross-project and parent-pipeline needs, unknown job, hidden template", () => {
103
+ const ir = graph(new Map<string, Declarable>([
104
+ ["deploy", job({
105
+ stage: "deploy",
106
+ needs: [need({ project: "group/lib", job: "package", ref: "main", artifacts: true }), need({ pipeline: "$PARENT_PIPELINE_ID", job: "gen" }), "compile"],
107
+ extends: [".deploy-template"],
108
+ })],
109
+ ]));
110
+ expect(ir.edges).toEqual([]);
111
+ expect(ir.dangling).toEqual([
112
+ { from: "deploy", kind: "extends", path: "extends", value: ".deploy-template" },
113
+ { from: "deploy", kind: "needs", path: "needs.pipeline", value: "$PARENT_PIPELINE_ID:gen", crossPipeline: true },
114
+ { from: "deploy", kind: "needs", path: "needs.project", value: "group/lib@main:package", crossPipeline: true },
115
+ { from: "deploy", kind: "needs", path: "needs", value: "compile" },
116
+ ]);
117
+ });
118
+
119
+ test("a project with no jobs or pipeline reports nothing", () => {
120
+ expect(gitlabGraphEdges(new Map())).toBeUndefined();
121
+ });
122
+ });
@@ -0,0 +1,199 @@
1
+ /**
2
+ * GitLab CI dependencies as graph edges (#3910).
3
+ *
4
+ * A GitLab job names everything it depends on as a string: the jobs it
5
+ * `needs`, the job it `extends`, the pipeline it `trigger`s, and the pipeline
6
+ * names the files it `include`s. `chant graph` cannot read those, so this
7
+ * module resolves them against the declared entities and reports each one
8
+ * through the plugin's `graphEdges` hook:
9
+ *
10
+ * - `needs` job -> job, from `needs:` (a name, `{ job }`, or the Job
11
+ * itself); a job without `needs:` waits for the stages before
12
+ * its own, reported as `needs` via `stage`
13
+ * - `extends` job -> the job it inherits from
14
+ * - `triggers` job -> the child or multi-project pipeline it starts
15
+ * - `includes` pipeline -> each file its `include:` pulls in
16
+ *
17
+ * Jobs are named in the YAML by their kebab-cased export name, as
18
+ * ./serializer.ts writes them. Cross-pipeline references (`trigger:`,
19
+ * `include:`, `needs: { project }` and `needs: { pipeline }`) name a pipeline
20
+ * this project does not generate, so they are reported as dangling and
21
+ * marked `crossPipeline`. So is any job name nothing declares: a hidden
22
+ * template from an included file, a typo.
23
+ */
24
+
25
+ import type { Declarable } from "@intentius/chant/declarable";
26
+ import { isPropertyDeclarable } from "@intentius/chant/declarable";
27
+ import type { LexiconGraphEdges, LexiconGraphReference } from "@intentius/chant/graph-ir";
28
+
29
+ const JOB = "GitLab::CI::Job";
30
+ const PIPELINE = "GitLab::CI::Pipeline";
31
+ const PIPELINE_LEVEL = new Set([PIPELINE, "GitLab::CI::Default", "GitLab::CI::Workflow"]);
32
+
33
+ /** The YAML name ./serializer.ts gives a job. */
34
+ function yamlName(name: string): string {
35
+ return name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
36
+ }
37
+
38
+ function entityType(value: unknown): string | undefined {
39
+ if (!value || typeof value !== "object") return undefined;
40
+ const t = (value as { entityType?: unknown }).entityType;
41
+ return typeof t === "string" ? t : undefined;
42
+ }
43
+
44
+ /** A Declarable's props bag, or the plain object itself. */
45
+ function propsOf(value: unknown): Record<string, unknown> | undefined {
46
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
47
+ if (entityType(value) !== undefined) {
48
+ const props = (value as { props?: unknown }).props;
49
+ return props && typeof props === "object" ? (props as Record<string, unknown>) : undefined;
50
+ }
51
+ return value as Record<string, unknown>;
52
+ }
53
+
54
+ const asList = (value: unknown): unknown[] => (Array.isArray(value) ? value : value === undefined || value === null ? [] : [value]);
55
+
56
+ /** GitLab's default stages, used when nothing declares a `stages:` list. */
57
+ const DEFAULT_STAGES = ["build", "test", "deploy"];
58
+
59
+ /**
60
+ * The stage order GitLab runs, as ./serializer.ts emits it: a Pipeline's
61
+ * `stages:` first, then every stage a job names that the list leaves out, in
62
+ * first use. With neither, GitLab's defaults. `.pre` and `.post` always
63
+ * bracket the list.
64
+ */
65
+ function stageOrder(pipelines: Record<string, unknown>[], jobs: Array<{ props: Record<string, unknown> }>): string[] {
66
+ const stages = new Set<string>();
67
+ for (const p of pipelines) for (const s of asList(p.stages)) if (typeof s === "string") stages.add(s);
68
+ for (const j of jobs) if (typeof j.props.stage === "string") stages.add(j.props.stage);
69
+ const list = stages.size > 0 ? [...stages] : DEFAULT_STAGES;
70
+ return [".pre", ...list.filter((s) => s !== ".pre" && s !== ".post"), ".post"];
71
+ }
72
+
73
+ /** A file an `include:` or `trigger: { include }` entry names, as written. */
74
+ function includeTarget(entry: unknown): string | undefined {
75
+ if (typeof entry === "string") return entry;
76
+ const p = propsOf(entry);
77
+ if (!p) return undefined;
78
+ for (const key of ["local", "remote", "template", "component", "artifact"]) {
79
+ if (typeof p[key] === "string") return p[key] as string;
80
+ }
81
+ if (typeof p.project === "string") {
82
+ const files = asList(p.file).filter((f): f is string => typeof f === "string");
83
+ const ref = typeof p.ref === "string" ? `@${p.ref}` : "";
84
+ return `${p.project}${ref}:${files.join(",")}`;
85
+ }
86
+ return undefined;
87
+ }
88
+
89
+ /** The graph edges and pipeline membership a GitLab CI project declares. */
90
+ export function gitlabGraphEdges(entities: ReadonlyMap<string, Declarable>): LexiconGraphEdges | undefined {
91
+ const names = new Map<unknown, string>();
92
+ const jobs: Array<{ node: string; key: string; props: Record<string, unknown> }> = [];
93
+ const pipelineNodes: string[] = [];
94
+ const pipelineEntities: Array<{ node: string; props: Record<string, unknown> }> = [];
95
+ for (const [name, entity] of entities) {
96
+ names.set(entity, name);
97
+ if (isPropertyDeclarable(entity)) continue;
98
+ const type = entityType(entity);
99
+ if (type === JOB) jobs.push({ node: name, key: yamlName(name), props: propsOf(entity) ?? {} });
100
+ else if (type && PIPELINE_LEVEL.has(type)) {
101
+ pipelineNodes.push(name);
102
+ if (type === PIPELINE) pipelineEntities.push({ node: name, props: propsOf(entity) ?? {} });
103
+ }
104
+ }
105
+ if (jobs.length === 0 && pipelineEntities.length === 0) return undefined;
106
+
107
+ const byKey = new Map(jobs.map((j) => [j.key, j.node]));
108
+ const references: LexiconGraphReference[] = [];
109
+
110
+ // A job name as written, or a Job object held directly.
111
+ const jobKey = (value: unknown): string | undefined => {
112
+ if (typeof value === "string") return value;
113
+ const name = names.get(value);
114
+ return name !== undefined && entityType(value) === JOB ? yamlName(name) : undefined;
115
+ };
116
+
117
+ // Stage order, for the jobs that say nothing about `needs:`.
118
+ const stages = stageOrder(pipelineEntities.map((p) => p.props), jobs);
119
+ const stageOf = (j: { props: Record<string, unknown> }): number =>
120
+ stages.indexOf(typeof j.props.stage === "string" ? j.props.stage : "test");
121
+ const atStage = new Map<number, typeof jobs>();
122
+ for (const j of jobs) {
123
+ const s = stageOf(j);
124
+ if (s < 0) continue;
125
+ const arr = atStage.get(s) ?? [];
126
+ arr.push(j);
127
+ atStage.set(s, arr);
128
+ }
129
+
130
+ for (const job of jobs) {
131
+ const from = job.node;
132
+
133
+ if (job.props.needs === undefined) {
134
+ // Stage order: wait for every job in the nearest earlier stage that has
135
+ // any. Those jobs wait for the stage before them in turn, unless one of
136
+ // them uses `needs:` and starts early, in which case this job also
137
+ // waits for the stage before, and so on back.
138
+ for (let s = stageOf(job) - 1; s >= 0; s--) {
139
+ const earlier = atStage.get(s);
140
+ if (!earlier) continue;
141
+ for (const e of earlier) references.push({ from, to: e.node, kind: "needs", viaAttr: "stage" });
142
+ if (earlier.every((e) => e.props.needs === undefined)) break;
143
+ }
144
+ }
145
+
146
+ for (const need of asList(job.props.needs)) {
147
+ const key = jobKey(need);
148
+ if (key !== undefined) {
149
+ const to = byKey.get(key);
150
+ references.push(to ? { from, to, kind: "needs", viaAttr: "needs" } : { from, to: key, kind: "needs", viaAttr: "needs", dangling: true });
151
+ continue;
152
+ }
153
+ const p = propsOf(need);
154
+ if (!p || typeof p.job !== "string") continue;
155
+ if (typeof p.project === "string") {
156
+ const ref = typeof p.ref === "string" ? `@${p.ref}` : "";
157
+ references.push({ from, to: `${p.project}${ref}:${p.job}`, kind: "needs", viaAttr: "needs.project", crossPipeline: true, dangling: true });
158
+ } else if (typeof p.pipeline === "string") {
159
+ references.push({ from, to: `${p.pipeline}:${p.job}`, kind: "needs", viaAttr: "needs.pipeline", crossPipeline: true, dangling: true });
160
+ } else {
161
+ const to = byKey.get(p.job);
162
+ references.push(to ? { from, to, kind: "needs", viaAttr: "needs" } : { from, to: p.job, kind: "needs", viaAttr: "needs", dangling: true });
163
+ }
164
+ }
165
+
166
+ for (const parent of asList(job.props.extends)) {
167
+ const key = jobKey(parent);
168
+ if (key === undefined) continue;
169
+ const to = byKey.get(key);
170
+ references.push(to ? { from, to, kind: "extends", viaAttr: "extends" } : { from, to: key, kind: "extends", viaAttr: "extends", dangling: true });
171
+ }
172
+
173
+ const trigger = job.props.trigger;
174
+ if (typeof trigger === "string") {
175
+ references.push({ from, to: trigger, kind: "triggers", viaAttr: "trigger", crossPipeline: true, dangling: true });
176
+ } else {
177
+ const t = propsOf(trigger);
178
+ if (t && typeof t.project === "string") {
179
+ const branch = typeof t.branch === "string" ? `@${t.branch}` : "";
180
+ references.push({ from, to: `${t.project}${branch}`, kind: "triggers", viaAttr: "trigger.project", crossPipeline: true, dangling: true });
181
+ }
182
+ for (const entry of asList(t?.include)) {
183
+ const file = includeTarget(entry);
184
+ if (file) references.push({ from, to: file, kind: "triggers", viaAttr: "trigger.include", crossPipeline: true, dangling: true });
185
+ }
186
+ }
187
+ }
188
+
189
+ for (const p of pipelineEntities) {
190
+ for (const entry of asList(p.props.include)) {
191
+ const file = includeTarget(entry);
192
+ if (file) references.push({ from: p.node, to: file, kind: "includes", viaAttr: "include", crossPipeline: true, dangling: true });
193
+ }
194
+ }
195
+
196
+ // One project, one `.gitlab-ci.yml`: every job and pipeline-level entity is in it.
197
+ const members = [...pipelineNodes, ...jobs.map((j) => j.node)].sort();
198
+ return { references, pipelines: { ".gitlab-ci.yml": members } };
199
+ }
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  // Serializer
2
2
  export { gitlabSerializer } from "./serializer";
3
+ export { gitlabGraphEdges } from "./graph-edges";
3
4
 
4
5
  // Plugin
5
6
  export { gitlabPlugin } 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 { gitlabSerializer } from "./serializer";
17
+ import { gitlabGraphEdges } from "./graph-edges";
17
18
  import { gitlabContextTools } from "./mcp/context-tools";
18
19
  import { deprecatedOnlyExceptRule } from "./lint/rules/deprecated-only-except";
19
20
  import { missingScriptRule } from "./lint/rules/missing-script";
@@ -46,6 +47,8 @@ export const gitlabPlugin: LexiconPlugin = {
46
47
  upstream: { owner: "gitlab-org", repo: "gitlab", kind: "tags", tagSuffix: "-ee" },
47
48
  },
48
49
  serializer: gitlabSerializer,
50
+ // `needs`, stage order, `extends`, `trigger:` and `include:` as graph edges (#3910).
51
+ graphEdges: (entities) => gitlabGraphEdges(entities),
49
52
 
50
53
  lintRules(): LintRule[] {
51
54
  return [deprecatedOnlyExceptRule, missingScriptRule, missingStageRule, artifactNoExpiryRule];