@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.
- package/dist/integrity.json +10 -6
- package/dist/manifest.json +1 -1
- package/dist/meta.json +186 -8
- package/dist/rules/wgl012.ts +86 -0
- package/dist/rules/wgl013.ts +62 -0
- package/dist/rules/wgl014.ts +51 -0
- package/dist/rules/wgl015.ts +85 -0
- package/dist/rules/yaml-helpers.ts +65 -3
- package/dist/skills/chant-gitlab.md +467 -24
- package/dist/types/index.d.ts +55 -16
- package/package.json +2 -2
- package/src/codegen/__snapshots__/snapshot.test.ts.snap +58 -0
- package/src/codegen/docs.ts +32 -9
- package/src/codegen/generate-lexicon.ts +6 -1
- package/src/codegen/generate.ts +45 -50
- package/src/codegen/naming.ts +3 -0
- package/src/codegen/parse.test.ts +154 -4
- package/src/codegen/parse.ts +161 -49
- package/src/codegen/snapshot.test.ts +7 -5
- package/src/composites/composites.test.ts +452 -0
- package/src/composites/docker-build.ts +81 -0
- package/src/composites/index.ts +8 -0
- package/src/composites/node-pipeline.ts +104 -0
- package/src/composites/python-pipeline.ts +75 -0
- package/src/composites/review-app.ts +63 -0
- package/src/generated/index.d.ts +55 -16
- package/src/generated/index.ts +3 -0
- package/src/generated/lexicon-gitlab.json +186 -8
- package/src/import/generator.ts +3 -2
- package/src/index.ts +4 -0
- package/src/lint/post-synth/wgl012.test.ts +131 -0
- package/src/lint/post-synth/wgl012.ts +86 -0
- package/src/lint/post-synth/wgl013.test.ts +164 -0
- package/src/lint/post-synth/wgl013.ts +62 -0
- package/src/lint/post-synth/wgl014.test.ts +97 -0
- package/src/lint/post-synth/wgl014.ts +51 -0
- package/src/lint/post-synth/wgl015.test.ts +139 -0
- package/src/lint/post-synth/wgl015.ts +85 -0
- package/src/lint/post-synth/yaml-helpers.ts +65 -3
- package/src/plugin.test.ts +39 -13
- package/src/plugin.ts +636 -40
- package/src/serializer.test.ts +140 -0
- package/src/serializer.ts +63 -5
- package/src/validate.ts +1 -0
- package/src/variables.ts +4 -0
package/dist/integrity.json
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"algorithm": "xxhash64",
|
|
3
3
|
"artifacts": {
|
|
4
|
-
"manifest.json": "
|
|
5
|
-
"meta.json": "
|
|
6
|
-
"types/index.d.ts": "
|
|
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/
|
|
12
|
+
"rules/wgl015.ts": "d7e9e080994f985",
|
|
13
|
+
"rules/wgl012.ts": "3d188d13fb2236c0",
|
|
14
|
+
"rules/yaml-helpers.ts": "1f3c4e98b89b8deb",
|
|
13
15
|
"rules/wgl010.ts": "1548cad287cdf286",
|
|
14
|
-
"
|
|
16
|
+
"rules/wgl014.ts": "6248a852888e8028",
|
|
17
|
+
"rules/wgl013.ts": "3519c933e23fc605",
|
|
18
|
+
"skills/chant-gitlab.md": "4393eb63e0b84b7f"
|
|
15
19
|
},
|
|
16
|
-
"composite": "
|
|
20
|
+
"composite": "6c4db775282a1a38"
|
|
17
21
|
}
|
package/dist/manifest.json
CHANGED
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
|
+
}
|