@intentius/chant-lexicon-gitlab 0.0.8 → 0.0.10

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 (45) hide show
  1. package/dist/integrity.json +10 -6
  2. package/dist/manifest.json +1 -1
  3. package/dist/meta.json +186 -8
  4. package/dist/rules/wgl012.ts +86 -0
  5. package/dist/rules/wgl013.ts +62 -0
  6. package/dist/rules/wgl014.ts +51 -0
  7. package/dist/rules/wgl015.ts +85 -0
  8. package/dist/rules/yaml-helpers.ts +65 -3
  9. package/dist/skills/chant-gitlab.md +467 -24
  10. package/dist/types/index.d.ts +55 -16
  11. package/package.json +2 -2
  12. package/src/codegen/__snapshots__/snapshot.test.ts.snap +58 -0
  13. package/src/codegen/docs.ts +32 -9
  14. package/src/codegen/generate-lexicon.ts +6 -1
  15. package/src/codegen/generate.ts +45 -50
  16. package/src/codegen/naming.ts +3 -0
  17. package/src/codegen/parse.test.ts +154 -4
  18. package/src/codegen/parse.ts +161 -49
  19. package/src/codegen/snapshot.test.ts +7 -5
  20. package/src/composites/composites.test.ts +452 -0
  21. package/src/composites/docker-build.ts +81 -0
  22. package/src/composites/index.ts +8 -0
  23. package/src/composites/node-pipeline.ts +104 -0
  24. package/src/composites/python-pipeline.ts +75 -0
  25. package/src/composites/review-app.ts +63 -0
  26. package/src/generated/index.d.ts +55 -16
  27. package/src/generated/index.ts +3 -0
  28. package/src/generated/lexicon-gitlab.json +186 -8
  29. package/src/import/generator.ts +3 -2
  30. package/src/index.ts +4 -0
  31. package/src/lint/post-synth/wgl012.test.ts +131 -0
  32. package/src/lint/post-synth/wgl012.ts +86 -0
  33. package/src/lint/post-synth/wgl013.test.ts +164 -0
  34. package/src/lint/post-synth/wgl013.ts +62 -0
  35. package/src/lint/post-synth/wgl014.test.ts +97 -0
  36. package/src/lint/post-synth/wgl014.ts +51 -0
  37. package/src/lint/post-synth/wgl015.test.ts +139 -0
  38. package/src/lint/post-synth/wgl015.ts +85 -0
  39. package/src/lint/post-synth/yaml-helpers.ts +65 -3
  40. package/src/plugin.test.ts +39 -13
  41. package/src/plugin.ts +636 -40
  42. package/src/serializer.test.ts +140 -0
  43. package/src/serializer.ts +63 -5
  44. package/src/validate.ts +1 -0
  45. package/src/variables.ts +4 -0
@@ -1,17 +1,21 @@
1
1
  {
2
2
  "algorithm": "xxhash64",
3
3
  "artifacts": {
4
- "manifest.json": "3a48cdeca93a7a4d",
5
- "meta.json": "9ee0d2f2d1679f09",
6
- "types/index.d.ts": "4e56a7de40d655c0",
4
+ "manifest.json": "592bb8cd37d44fed",
5
+ "meta.json": "c663c6c63748a9d0",
6
+ "types/index.d.ts": "64e65524615be023",
7
7
  "rules/missing-stage.ts": "6d5379e74209a735",
8
8
  "rules/missing-script.ts": "923dde9acb46cc28",
9
9
  "rules/deprecated-only-except.ts": "1f5a8c785777fb03",
10
10
  "rules/artifact-no-expiry.ts": "26874cb6adfbca26",
11
11
  "rules/wgl011.ts": "b6b97e5104d91267",
12
- "rules/yaml-helpers.ts": "a66cc193b4ef4f0a",
12
+ "rules/wgl015.ts": "d7e9e080994f985",
13
+ "rules/wgl012.ts": "3d188d13fb2236c0",
14
+ "rules/yaml-helpers.ts": "1f3c4e98b89b8deb",
13
15
  "rules/wgl010.ts": "1548cad287cdf286",
14
- "skills/chant-gitlab.md": "92ce73e97ee82ac9"
16
+ "rules/wgl014.ts": "6248a852888e8028",
17
+ "rules/wgl013.ts": "3519c933e23fc605",
18
+ "skills/chant-gitlab.md": "4393eb63e0b84b7f"
15
19
  },
16
- "composite": "c6ad87f69da787de"
20
+ "composite": "6c4db775282a1a38"
17
21
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitlab",
3
- "version": "0.0.8",
3
+ "version": "0.0.10",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "GitLab",
6
6
  "intrinsics": [
package/dist/meta.json CHANGED
@@ -7,17 +7,77 @@
7
7
  "Artifacts": {
8
8
  "resourceType": "GitLab::CI::Artifacts",
9
9
  "kind": "property",
10
- "lexicon": "gitlab"
10
+ "lexicon": "gitlab",
11
+ "constraints": {
12
+ "untracked": {
13
+ "default": false
14
+ },
15
+ "when": {
16
+ "default": "on_success",
17
+ "enum": [
18
+ "on_success",
19
+ "on_failure",
20
+ "always"
21
+ ]
22
+ },
23
+ "access": {
24
+ "default": "all",
25
+ "enum": [
26
+ "none",
27
+ "developer",
28
+ "all"
29
+ ]
30
+ },
31
+ "expire_in": {
32
+ "default": "30 days"
33
+ }
34
+ }
11
35
  },
12
36
  "AutoCancel": {
13
37
  "resourceType": "GitLab::CI::AutoCancel",
14
38
  "kind": "property",
15
- "lexicon": "gitlab"
39
+ "lexicon": "gitlab",
40
+ "constraints": {
41
+ "on_job_failure": {
42
+ "default": "none",
43
+ "enum": [
44
+ "none",
45
+ "all"
46
+ ]
47
+ },
48
+ "on_new_commit": {
49
+ "enum": [
50
+ "conservative",
51
+ "interruptible",
52
+ "none"
53
+ ]
54
+ }
55
+ }
16
56
  },
17
57
  "Cache": {
18
58
  "resourceType": "GitLab::CI::Cache",
19
59
  "kind": "property",
20
- "lexicon": "gitlab"
60
+ "lexicon": "gitlab",
61
+ "constraints": {
62
+ "policy": {
63
+ "pattern": "pull-push|pull|push|\\$\\w{1,255}",
64
+ "default": "pull-push"
65
+ },
66
+ "unprotect": {
67
+ "default": false
68
+ },
69
+ "untracked": {
70
+ "default": false
71
+ },
72
+ "when": {
73
+ "default": "on_success",
74
+ "enum": [
75
+ "on_success",
76
+ "on_failure",
77
+ "always"
78
+ ]
79
+ }
80
+ }
21
81
  },
22
82
  "Default": {
23
83
  "resourceType": "GitLab::CI::Default",
@@ -27,21 +87,93 @@
27
87
  "Environment": {
28
88
  "resourceType": "GitLab::CI::Environment",
29
89
  "kind": "property",
30
- "lexicon": "gitlab"
90
+ "lexicon": "gitlab",
91
+ "constraints": {
92
+ "name": {
93
+ "minLength": 1
94
+ },
95
+ "url": {
96
+ "pattern": "^(https?://.+|\\$[A-Za-z]+)",
97
+ "format": "uri"
98
+ },
99
+ "action": {
100
+ "default": "start",
101
+ "enum": [
102
+ "start",
103
+ "prepare",
104
+ "stop",
105
+ "verify",
106
+ "access"
107
+ ]
108
+ },
109
+ "deployment_tier": {
110
+ "enum": [
111
+ "production",
112
+ "staging",
113
+ "testing",
114
+ "development",
115
+ "other"
116
+ ]
117
+ }
118
+ }
31
119
  },
32
120
  "Image": {
33
121
  "resourceType": "GitLab::CI::Image",
34
122
  "kind": "property",
35
- "lexicon": "gitlab"
123
+ "lexicon": "gitlab",
124
+ "constraints": {
125
+ "name": {
126
+ "minLength": 1
127
+ },
128
+ "pull_policy": {
129
+ "default": "always"
130
+ }
131
+ }
36
132
  },
37
133
  "Include": {
38
134
  "resourceType": "GitLab::CI::Include",
39
135
  "kind": "property",
136
+ "lexicon": "gitlab",
137
+ "constraints": {
138
+ "project": {
139
+ "pattern": "(?:\\S/\\S|\\$\\S+)"
140
+ },
141
+ "local": {
142
+ "pattern": "\\.ya?ml$",
143
+ "format": "uri-reference"
144
+ },
145
+ "template": {
146
+ "pattern": "\\.ya?ml$",
147
+ "format": "uri-reference"
148
+ },
149
+ "component": {
150
+ "format": "uri-reference"
151
+ },
152
+ "remote": {
153
+ "pattern": "^https?://.+\\.ya?ml$",
154
+ "format": "uri-reference"
155
+ }
156
+ }
157
+ },
158
+ "Inherit": {
159
+ "resourceType": "GitLab::CI::Inherit",
160
+ "kind": "property",
40
161
  "lexicon": "gitlab"
41
162
  },
42
163
  "Job": {
43
164
  "resourceType": "GitLab::CI::Job",
44
165
  "kind": "resource",
166
+ "lexicon": "gitlab",
167
+ "constraints": {
168
+ "coverage": {
169
+ "pattern": "^/.+/$",
170
+ "format": "regex"
171
+ }
172
+ }
173
+ },
174
+ "Need": {
175
+ "resourceType": "GitLab::CI::Need",
176
+ "kind": "property",
45
177
  "lexicon": "gitlab"
46
178
  },
47
179
  "Parallel": {
@@ -52,7 +184,19 @@
52
184
  "Release": {
53
185
  "resourceType": "GitLab::CI::Release",
54
186
  "kind": "property",
55
- "lexicon": "gitlab"
187
+ "lexicon": "gitlab",
188
+ "constraints": {
189
+ "tag_name": {
190
+ "minLength": 1
191
+ },
192
+ "description": {
193
+ "minLength": 1
194
+ },
195
+ "released_at": {
196
+ "pattern": "^(?:[1-9]\\d{3}-(?:(?:0[1-9]|1[0-2])-(?:0[1-9]|1\\d|2[0-8])|(?:0[13-9]|1[0-2])-(?:29|30)|(?:0[13578]|1[02])-31)|(?:[1-9]\\d(?:0[48]|[2468][048]|[13579][26])|(?:[2468][048]|[13579][26])00)-02-29)T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:Z|[+-][01]\\d:[0-5]\\d)$",
197
+ "format": "date-time"
198
+ }
199
+ }
56
200
  },
57
201
  "Retry": {
58
202
  "resourceType": "GitLab::CI::Retry",
@@ -67,16 +211,50 @@
67
211
  "Service": {
68
212
  "resourceType": "GitLab::CI::Service",
69
213
  "kind": "property",
70
- "lexicon": "gitlab"
214
+ "lexicon": "gitlab",
215
+ "constraints": {
216
+ "name": {
217
+ "minLength": 1
218
+ },
219
+ "pull_policy": {
220
+ "default": "always"
221
+ },
222
+ "alias": {
223
+ "minLength": 1
224
+ }
225
+ }
71
226
  },
72
227
  "Trigger": {
73
228
  "resourceType": "GitLab::CI::Trigger",
74
229
  "kind": "property",
75
- "lexicon": "gitlab"
230
+ "lexicon": "gitlab",
231
+ "constraints": {
232
+ "project": {
233
+ "pattern": "(?:\\S/\\S|\\$\\S+)"
234
+ },
235
+ "strategy": {
236
+ "enum": [
237
+ "depend"
238
+ ]
239
+ }
240
+ }
76
241
  },
77
242
  "Workflow": {
78
243
  "resourceType": "GitLab::CI::Workflow",
79
244
  "kind": "resource",
80
245
  "lexicon": "gitlab"
246
+ },
247
+ "WorkflowRule": {
248
+ "resourceType": "GitLab::CI::WorkflowRule",
249
+ "kind": "property",
250
+ "lexicon": "gitlab",
251
+ "constraints": {
252
+ "when": {
253
+ "enum": [
254
+ "always",
255
+ "never"
256
+ ]
257
+ }
258
+ }
81
259
  }
82
260
  }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * WGL012: Deprecated Property Usage
3
+ *
4
+ * Flags properties marked as deprecated in the GitLab CI schema.
5
+ * Sources: description text mining (keywords like "Deprecated", "legacy").
6
+ */
7
+
8
+ import { readFileSync } from "fs";
9
+ import { join } from "path";
10
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
11
+ import { isPropertyDeclarable } from "@intentius/chant/declarable";
12
+
13
+ interface LexiconEntry {
14
+ kind: string;
15
+ resourceType: string;
16
+ deprecatedProperties?: string[];
17
+ [key: string]: unknown;
18
+ }
19
+
20
+ /**
21
+ * Load deprecated properties per entity type from the lexicon JSON.
22
+ */
23
+ function loadDeprecatedProperties(): Map<string, Set<string>> {
24
+ const map = new Map<string, Set<string>>();
25
+ try {
26
+ const pkgDir = join(__dirname, "..", "..", "..");
27
+ const lexiconPath = join(pkgDir, "src", "generated", "lexicon-gitlab.json");
28
+ const content = readFileSync(lexiconPath, "utf-8");
29
+ const data = JSON.parse(content) as Record<string, LexiconEntry>;
30
+
31
+ for (const [_name, entry] of Object.entries(data)) {
32
+ if (entry.resourceType && entry.deprecatedProperties && entry.deprecatedProperties.length > 0) {
33
+ map.set(entry.resourceType, new Set(entry.deprecatedProperties));
34
+ }
35
+ }
36
+ } catch {
37
+ // Lexicon not available — skip
38
+ }
39
+ return map;
40
+ }
41
+
42
+ /**
43
+ * Core detection logic — exported for direct testing with synthetic data.
44
+ */
45
+ export function checkDeprecatedProperties(
46
+ ctx: PostSynthContext,
47
+ deprecated: Map<string, Set<string>>,
48
+ ): PostSynthDiagnostic[] {
49
+ if (deprecated.size === 0) return [];
50
+
51
+ const diagnostics: PostSynthDiagnostic[] = [];
52
+
53
+ for (const [entityName, entity] of ctx.entities) {
54
+ if (isPropertyDeclarable(entity)) continue;
55
+
56
+ const entityType = (entity as Record<string, unknown>).entityType as string;
57
+ const deprProps = deprecated.get(entityType);
58
+ if (!deprProps) continue;
59
+
60
+ const props = (entity as Record<string, unknown>).props as Record<string, unknown> | undefined;
61
+ if (!props) continue;
62
+
63
+ for (const propName of Object.keys(props)) {
64
+ if (deprProps.has(propName)) {
65
+ diagnostics.push({
66
+ checkId: "WGL012",
67
+ severity: "warning",
68
+ message: `Entity "${entityName}" (${entityType}) uses deprecated property "${propName}" — consider alternatives`,
69
+ entity: entityName,
70
+ lexicon: "gitlab",
71
+ });
72
+ }
73
+ }
74
+ }
75
+
76
+ return diagnostics;
77
+ }
78
+
79
+ export const wgl012: PostSynthCheck = {
80
+ id: "WGL012",
81
+ description: "Deprecated property usage — flags properties marked as deprecated in the GitLab CI schema",
82
+
83
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
84
+ return checkDeprecatedProperties(ctx, loadDeprecatedProperties());
85
+ },
86
+ };
@@ -0,0 +1,62 @@
1
+ /**
2
+ * WGL013: Invalid `needs:` Target
3
+ *
4
+ * Detects two cases in the serialized YAML:
5
+ * - Dangling reference: `needs:` names a job not defined in the pipeline
6
+ * - Self-reference: job lists itself in `needs:`
7
+ *
8
+ * Both cause GitLab pipeline validation failures.
9
+ *
10
+ * Caveat: when `include:` is present, referenced jobs may come from
11
+ * included files, so the check is skipped.
12
+ */
13
+
14
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
15
+ import { getPrimaryOutput, extractJobs, hasInclude } from "./yaml-helpers";
16
+
17
+ export function checkInvalidNeeds(ctx: PostSynthContext): PostSynthDiagnostic[] {
18
+ const diagnostics: PostSynthDiagnostic[] = [];
19
+
20
+ for (const [, output] of ctx.outputs) {
21
+ const yaml = getPrimaryOutput(output);
22
+ if (hasInclude(yaml)) continue;
23
+
24
+ const jobs = extractJobs(yaml);
25
+ const jobNames = new Set(jobs.keys());
26
+
27
+ for (const [jobName, job] of jobs) {
28
+ if (!job.needs) continue;
29
+
30
+ for (const need of job.needs) {
31
+ if (need === jobName) {
32
+ diagnostics.push({
33
+ checkId: "WGL013",
34
+ severity: "error",
35
+ message: `Job "${jobName}" lists itself in needs: — self-references are invalid`,
36
+ entity: jobName,
37
+ lexicon: "gitlab",
38
+ });
39
+ } else if (!jobNames.has(need)) {
40
+ diagnostics.push({
41
+ checkId: "WGL013",
42
+ severity: "error",
43
+ message: `Job "${jobName}" needs "${need}" which is not defined in the pipeline`,
44
+ entity: jobName,
45
+ lexicon: "gitlab",
46
+ });
47
+ }
48
+ }
49
+ }
50
+ }
51
+
52
+ return diagnostics;
53
+ }
54
+
55
+ export const wgl013: PostSynthCheck = {
56
+ id: "WGL013",
57
+ description: "Invalid needs: target — dangling reference or self-reference",
58
+
59
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
60
+ return checkInvalidNeeds(ctx);
61
+ },
62
+ };
@@ -0,0 +1,51 @@
1
+ /**
2
+ * WGL014: Invalid `extends:` Target
3
+ *
4
+ * Detects jobs that `extends:` a template not defined in the pipeline YAML.
5
+ * GitLab rejects pipelines with unresolved extends references.
6
+ *
7
+ * Caveat: when `include:` is present, templates may come from
8
+ * included files, so the check is skipped.
9
+ */
10
+
11
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
12
+ import { getPrimaryOutput, extractJobs, hasInclude } from "./yaml-helpers";
13
+
14
+ export function checkInvalidExtends(ctx: PostSynthContext): PostSynthDiagnostic[] {
15
+ const diagnostics: PostSynthDiagnostic[] = [];
16
+
17
+ for (const [, output] of ctx.outputs) {
18
+ const yaml = getPrimaryOutput(output);
19
+ if (hasInclude(yaml)) continue;
20
+
21
+ const jobs = extractJobs(yaml);
22
+ const jobNames = new Set(jobs.keys());
23
+
24
+ for (const [jobName, job] of jobs) {
25
+ if (!job.extends) continue;
26
+
27
+ for (const target of job.extends) {
28
+ if (!jobNames.has(target)) {
29
+ diagnostics.push({
30
+ checkId: "WGL014",
31
+ severity: "error",
32
+ message: `Job "${jobName}" extends "${target}" which is not defined in the pipeline`,
33
+ entity: jobName,
34
+ lexicon: "gitlab",
35
+ });
36
+ }
37
+ }
38
+ }
39
+ }
40
+
41
+ return diagnostics;
42
+ }
43
+
44
+ export const wgl014: PostSynthCheck = {
45
+ id: "WGL014",
46
+ description: "Invalid extends: target — references a template not in the pipeline",
47
+
48
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
49
+ return checkInvalidExtends(ctx);
50
+ },
51
+ };
@@ -0,0 +1,85 @@
1
+ /**
2
+ * WGL015: Circular `needs:` Chain
3
+ *
4
+ * DFS-based cycle detection on the `needs:` dependency graph.
5
+ * If A needs B and B needs A, GitLab rejects the pipeline.
6
+ *
7
+ * Reports one diagnostic per cycle found, listing the full chain.
8
+ */
9
+
10
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
11
+ import { getPrimaryOutput, extractJobs } from "./yaml-helpers";
12
+
13
+ export function checkCircularNeeds(ctx: PostSynthContext): PostSynthDiagnostic[] {
14
+ const diagnostics: PostSynthDiagnostic[] = [];
15
+
16
+ for (const [, output] of ctx.outputs) {
17
+ const yaml = getPrimaryOutput(output);
18
+ const jobs = extractJobs(yaml);
19
+
20
+ // Build adjacency list from needs
21
+ const graph = new Map<string, string[]>();
22
+ for (const [jobName, job] of jobs) {
23
+ graph.set(jobName, job.needs ?? []);
24
+ }
25
+
26
+ // DFS cycle detection
27
+ const visited = new Set<string>();
28
+ const inStack = new Set<string>();
29
+ const reportedInCycle = new Set<string>();
30
+
31
+ function dfs(node: string, path: string[]): void {
32
+ if (inStack.has(node)) {
33
+ // Found a cycle — extract the cycle portion
34
+ const cycleStart = path.indexOf(node);
35
+ const cycle = path.slice(cycleStart);
36
+ cycle.push(node);
37
+
38
+ // Only report if we haven't already reported a cycle containing these nodes
39
+ const cycleKey = [...cycle].sort().join(",");
40
+ if (!reportedInCycle.has(cycleKey)) {
41
+ reportedInCycle.add(cycleKey);
42
+ diagnostics.push({
43
+ checkId: "WGL015",
44
+ severity: "error",
45
+ message: `Circular needs: chain detected: ${cycle.join(" → ")}`,
46
+ entity: node,
47
+ lexicon: "gitlab",
48
+ });
49
+ }
50
+ return;
51
+ }
52
+
53
+ if (visited.has(node)) return;
54
+
55
+ visited.add(node);
56
+ inStack.add(node);
57
+
58
+ for (const neighbor of graph.get(node) ?? []) {
59
+ // Only follow edges to known jobs
60
+ if (graph.has(neighbor)) {
61
+ dfs(neighbor, [...path, node]);
62
+ }
63
+ }
64
+
65
+ inStack.delete(node);
66
+ }
67
+
68
+ for (const jobName of graph.keys()) {
69
+ if (!visited.has(jobName)) {
70
+ dfs(jobName, []);
71
+ }
72
+ }
73
+ }
74
+
75
+ return diagnostics;
76
+ }
77
+
78
+ export const wgl015: PostSynthCheck = {
79
+ id: "WGL015",
80
+ description: "Circular needs: chain — cycle in job dependency graph",
81
+
82
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
83
+ return checkCircularNeeds(ctx);
84
+ },
85
+ };
@@ -29,6 +29,8 @@ export interface ParsedJob {
29
29
  name: string;
30
30
  stage?: string;
31
31
  rules?: ParsedRule[];
32
+ needs?: string[];
33
+ extends?: string[];
32
34
  }
33
35
 
34
36
  export interface ParsedRule {
@@ -63,8 +65,8 @@ export function extractJobs(yaml: string): Map<string, ParsedJob> {
63
65
  const lines = section.split("\n");
64
66
  if (lines.length === 0) continue;
65
67
 
66
- // Top-level key
67
- const topMatch = lines[0].match(/^([a-z][a-z0-9_-]*):/);
68
+ // Top-level key (including dot-prefixed hidden jobs like .deploy-template)
69
+ const topMatch = lines[0].match(/^(\.?[a-z][a-z0-9_.-]*):/);
68
70
  if (!topMatch) continue;
69
71
 
70
72
  const name = topMatch[1];
@@ -73,12 +75,63 @@ export function extractJobs(yaml: string): Map<string, ParsedJob> {
73
75
 
74
76
  const job: ParsedJob = { name };
75
77
 
76
- // Find stage within the section
78
+ // Find stage, needs, extends within the section
79
+ let inNeeds = false;
77
80
  for (const line of lines) {
78
81
  const stageMatch = line.match(/^\s+stage:\s+(.+)$/);
79
82
  if (stageMatch) {
80
83
  job.stage = stageMatch[1].trim().replace(/^'|'$/g, "");
81
84
  }
85
+
86
+ // extends: .template or extends: [.a, .b]
87
+ const extendsMatch = line.match(/^\s+extends:\s+(.+)$/);
88
+ if (extendsMatch) {
89
+ const val = extendsMatch[1].trim();
90
+ if (val.startsWith("[")) {
91
+ // Inline array: [.a, .b]
92
+ job.extends = val.slice(1, -1).split(",").map((s) => s.trim().replace(/^'|'$/g, ""));
93
+ } else {
94
+ job.extends = [val.replace(/^'|'$/g, "")];
95
+ }
96
+ }
97
+
98
+ // needs: block (list form)
99
+ if (line.match(/^\s+needs:$/)) {
100
+ inNeeds = true;
101
+ job.needs = [];
102
+ continue;
103
+ }
104
+
105
+ if (inNeeds) {
106
+ // - job-name (simple string form)
107
+ const simpleNeed = line.match(/^\s+- ([a-z][a-z0-9_.-]*)$/);
108
+ if (simpleNeed) {
109
+ job.needs!.push(simpleNeed[1]);
110
+ continue;
111
+ }
112
+ // - 'job-name' (quoted string form)
113
+ const quotedNeed = line.match(/^\s+- '([^']+)'$/);
114
+ if (quotedNeed) {
115
+ job.needs!.push(quotedNeed[1]);
116
+ continue;
117
+ }
118
+ // - job: job-name (object form)
119
+ const objectNeed = line.match(/^\s+- job:\s+(.+)$/);
120
+ if (objectNeed) {
121
+ job.needs!.push(objectNeed[1].trim().replace(/^'|'$/g, ""));
122
+ continue;
123
+ }
124
+ // End of needs block when we hit a non-indented-list line
125
+ if (!line.match(/^\s+\s/) || line.match(/^\s+[a-z_]+:/)) {
126
+ inNeeds = false;
127
+ }
128
+ }
129
+
130
+ // needs: [a, b] (inline array form)
131
+ const inlineNeeds = line.match(/^\s+needs:\s+\[(.+)\]$/);
132
+ if (inlineNeeds) {
133
+ job.needs = inlineNeeds[1].split(",").map((s) => s.trim().replace(/^'|'$/g, ""));
134
+ }
82
135
  }
83
136
 
84
137
  jobs.set(name, job);
@@ -86,3 +139,12 @@ export function extractJobs(yaml: string): Map<string, ParsedJob> {
86
139
 
87
140
  return jobs;
88
141
  }
142
+
143
+ /**
144
+ * Check whether the YAML contains an `include:` directive.
145
+ * When includes are present, `needs:` and `extends:` may reference
146
+ * jobs/templates from included files, so checks should be lenient.
147
+ */
148
+ export function hasInclude(yaml: string): boolean {
149
+ return /^include:/m.test(yaml);
150
+ }