@intentius/chant-lexicon-gitlab 0.101.0 → 0.102.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/codegen/naming.d.ts.map +1 -1
- package/dist/components/generate-pipeline.d.ts.map +1 -1
- package/dist/composites/node-pipeline.d.ts +16 -16
- package/dist/composites/node-pipeline.d.ts.map +1 -1
- package/dist/generated/index.d.ts +19 -19
- package/dist/generated/index.d.ts.map +1 -1
- package/dist/import/generator.d.ts.map +1 -1
- package/dist/import/parser.d.ts.map +1 -1
- package/dist/integrity.json +10 -10
- package/dist/intrinsics.d.ts.map +1 -1
- package/dist/lint/post-synth/wgl013.d.ts +3 -1
- package/dist/lint/post-synth/wgl013.d.ts.map +1 -1
- package/dist/lint/post-synth/wgl017.d.ts.map +1 -1
- package/dist/lint/post-synth/wgl020.d.ts.map +1 -1
- package/dist/lint/post-synth/wgl021.d.ts.map +1 -1
- package/dist/lint/post-synth/wgl050.d.ts.map +1 -1
- package/dist/lint/post-synth/yaml-helpers.d.ts +57 -8
- package/dist/lint/post-synth/yaml-helpers.d.ts.map +1 -1
- package/dist/manifest.json +1 -1
- package/dist/migrate/from-github/provenance.d.ts.map +1 -1
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/rules/wgl013.ts +4 -2
- package/dist/rules/wgl017.ts +3 -11
- package/dist/rules/wgl020.ts +4 -5
- package/dist/rules/wgl021.ts +4 -5
- package/dist/rules/wgl048.ts +2 -2
- package/dist/rules/wgl049.ts +4 -4
- package/dist/rules/wgl050.ts +5 -8
- package/dist/rules/yaml-helpers.ts +176 -113
- package/dist/variables.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/components/generate-op-pipeline.ts +4 -4
- package/src/components/generate-pipeline.ts +6 -11
- package/src/lint/post-synth/post-synth.test.ts +29 -0
- package/src/lint/post-synth/wgl013.test.ts +53 -0
- package/src/lint/post-synth/wgl013.ts +4 -2
- package/src/lint/post-synth/wgl014.test.ts +23 -0
- package/src/lint/post-synth/wgl015.test.ts +35 -0
- package/src/lint/post-synth/wgl017.test.ts +14 -0
- package/src/lint/post-synth/wgl017.ts +3 -11
- package/src/lint/post-synth/wgl020.test.ts +26 -0
- package/src/lint/post-synth/wgl020.ts +4 -5
- package/src/lint/post-synth/wgl021.test.ts +29 -0
- package/src/lint/post-synth/wgl021.ts +4 -5
- package/src/lint/post-synth/wgl028.test.ts +27 -0
- package/src/lint/post-synth/wgl042.test.ts +14 -0
- package/src/lint/post-synth/wgl048.test.ts +14 -0
- package/src/lint/post-synth/wgl048.ts +2 -2
- package/src/lint/post-synth/wgl049.test.ts +25 -0
- package/src/lint/post-synth/wgl049.ts +4 -4
- package/src/lint/post-synth/wgl050.test.ts +16 -0
- package/src/lint/post-synth/wgl050.ts +5 -8
- package/src/lint/post-synth/yaml-helpers.test.ts +249 -0
- package/src/lint/post-synth/yaml-helpers.ts +176 -113
- package/src/migrate/from-github/fixtures/syntax-mapping/03-matrix/expected.gitlab-ci.yml +3 -1
- package/src/serializer.ts +5 -5
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Helpers for parsing serialized GitLab CI YAML in post-synth checks.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Jobs, stages and global variables are read with the structural parser
|
|
5
|
+
* (`parseYAML`, js-yaml with chant's schema). Checks that pattern-match a
|
|
6
|
+
* job's text get it from {@link extractJobSection}, which cuts the document
|
|
7
|
+
* at top-level keys rather than at blank lines, so it does not depend on the
|
|
8
|
+
* serializer's spacing.
|
|
8
9
|
*/
|
|
9
10
|
|
|
11
|
+
import { parseYAML } from "@intentius/chant/yaml";
|
|
12
|
+
|
|
10
13
|
export { getPrimaryOutput } from "@intentius/chant/lint/post-synth";
|
|
11
14
|
|
|
12
15
|
/**
|
|
@@ -22,7 +25,15 @@ export interface ParsedJob {
|
|
|
22
25
|
name: string;
|
|
23
26
|
stage?: string;
|
|
24
27
|
rules?: ParsedRule[];
|
|
28
|
+
/**
|
|
29
|
+
* Same-pipeline jobs this job needs, in every form GitLab accepts: a plain
|
|
30
|
+
* name, `- job: name` (with `artifacts:`, `optional:`, `parallel:`), and
|
|
31
|
+
* `needs: []`. Cross-pipeline needs (`pipeline:` or `project:`) name jobs
|
|
32
|
+
* in another pipeline and are left out.
|
|
33
|
+
*/
|
|
25
34
|
needs?: string[];
|
|
35
|
+
/** The subset of {@link needs} declared `optional: true`. */
|
|
36
|
+
optionalNeeds?: string[];
|
|
26
37
|
extends?: string[];
|
|
27
38
|
}
|
|
28
39
|
|
|
@@ -32,104 +43,115 @@ export interface ParsedRule {
|
|
|
32
43
|
}
|
|
33
44
|
|
|
34
45
|
/**
|
|
35
|
-
*
|
|
46
|
+
* Top-level keys that are global keywords, not jobs. `image`, `services`,
|
|
47
|
+
* `cache`, `before_script` and `after_script` are the deprecated global
|
|
48
|
+
* forms of the `default:` keywords; `spec` is a CI component's header.
|
|
36
49
|
*/
|
|
37
|
-
export
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
50
|
+
export const RESERVED_TOP_LEVEL_KEYS: ReadonlySet<string> = new Set([
|
|
51
|
+
"stages",
|
|
52
|
+
"variables",
|
|
53
|
+
"default",
|
|
54
|
+
"include",
|
|
55
|
+
"workflow",
|
|
56
|
+
"image",
|
|
57
|
+
"services",
|
|
58
|
+
"cache",
|
|
59
|
+
"before_script",
|
|
60
|
+
"after_script",
|
|
61
|
+
"spec",
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
/** Parse the pipeline document; `undefined` when the YAML cannot be read. */
|
|
65
|
+
export function parsePipeline(yaml: string): Record<string, unknown> | undefined {
|
|
66
|
+
try {
|
|
67
|
+
const doc = parseYAML(yaml);
|
|
68
|
+
return doc && typeof doc === "object" && !Array.isArray(doc) ? doc : undefined;
|
|
69
|
+
} catch {
|
|
70
|
+
return undefined;
|
|
45
71
|
}
|
|
46
|
-
return stages;
|
|
47
72
|
}
|
|
48
73
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
export function extractJobs(yaml: string): Map<string, ParsedJob> {
|
|
53
|
-
const jobs = new Map<string, ParsedJob>();
|
|
74
|
+
function isMapping(v: unknown): v is Record<string, unknown> {
|
|
75
|
+
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
76
|
+
}
|
|
54
77
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
78
|
+
function scalarString(v: unknown): string | undefined {
|
|
79
|
+
if (typeof v === "string") return v;
|
|
80
|
+
if (typeof v === "number" || typeof v === "boolean") return String(v);
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
60
83
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
84
|
+
/**
|
|
85
|
+
* Every job in the pipeline as `[id, rawJobObject]`: each top-level key whose
|
|
86
|
+
* value is a mapping, minus the global keywords. Hidden jobs (ids starting
|
|
87
|
+
* with `.`, used as `extends:` templates) are included.
|
|
88
|
+
*/
|
|
89
|
+
export function jobEntries(yaml: string): Array<[string, Record<string, unknown>]> {
|
|
90
|
+
const doc = parsePipeline(yaml);
|
|
91
|
+
if (!doc) return [];
|
|
92
|
+
const out: Array<[string, Record<string, unknown>]> = [];
|
|
93
|
+
for (const [name, val] of Object.entries(doc)) {
|
|
94
|
+
if (RESERVED_TOP_LEVEL_KEYS.has(name)) continue;
|
|
95
|
+
if (isMapping(val)) out.push([name, val]);
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
64
99
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
100
|
+
/**
|
|
101
|
+
* Extract stages list from serialized YAML (block or flow list).
|
|
102
|
+
*/
|
|
103
|
+
export function extractStages(yaml: string): string[] {
|
|
104
|
+
const stages = parsePipeline(yaml)?.stages;
|
|
105
|
+
if (!Array.isArray(stages)) return [];
|
|
106
|
+
return stages.map(scalarString).filter((s): s is string => s !== undefined);
|
|
107
|
+
}
|
|
68
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Extract jobs with their stage, `needs:` and `extends:` from serialized YAML.
|
|
111
|
+
*
|
|
112
|
+
* Built on the structural parser, so every job id is read (capitals, spaces,
|
|
113
|
+
* colons, quoted ids) and every `needs:` entry is read whatever keys follow
|
|
114
|
+
* `job:` in its map. The line-based version this replaced skipped ids that
|
|
115
|
+
* start with a capital and stopped reading a `needs:` list at an
|
|
116
|
+
* `artifacts: true` line (#3256). YAML the parser cannot read gives no jobs.
|
|
117
|
+
*/
|
|
118
|
+
export function extractJobs(yaml: string): Map<string, ParsedJob> {
|
|
119
|
+
const jobs = new Map<string, ParsedJob>();
|
|
120
|
+
for (const [name, obj] of jobEntries(yaml)) {
|
|
69
121
|
const job: ParsedJob = { name };
|
|
70
122
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const val = extendsMatch[1].trim();
|
|
83
|
-
if (val.startsWith("[")) {
|
|
84
|
-
// Inline array: [.a, .b]
|
|
85
|
-
job.extends = val.slice(1, -1).split(",").map((s) => s.trim().replace(/^'|'$/g, ""));
|
|
86
|
-
} else {
|
|
87
|
-
job.extends = [val.replace(/^'|'$/g, "")];
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// needs: block (list form)
|
|
92
|
-
if (line.match(/^\s+needs:$/)) {
|
|
93
|
-
inNeeds = true;
|
|
94
|
-
job.needs = [];
|
|
95
|
-
continue;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
if (inNeeds) {
|
|
99
|
-
// - job-name (simple string form)
|
|
100
|
-
const simpleNeed = line.match(/^\s+- ([a-z][a-z0-9_.-]*)$/);
|
|
101
|
-
if (simpleNeed) {
|
|
102
|
-
job.needs!.push(simpleNeed[1]);
|
|
103
|
-
continue;
|
|
104
|
-
}
|
|
105
|
-
// - 'job-name' (quoted string form)
|
|
106
|
-
const quotedNeed = line.match(/^\s+- '([^']+)'$/);
|
|
107
|
-
if (quotedNeed) {
|
|
108
|
-
job.needs!.push(quotedNeed[1]);
|
|
109
|
-
continue;
|
|
110
|
-
}
|
|
111
|
-
// - job: job-name (object form)
|
|
112
|
-
const objectNeed = line.match(/^\s+- job:\s+(.+)$/);
|
|
113
|
-
if (objectNeed) {
|
|
114
|
-
job.needs!.push(objectNeed[1].trim().replace(/^'|'$/g, ""));
|
|
123
|
+
const stage = scalarString(obj.stage);
|
|
124
|
+
if (stage !== undefined) job.stage = stage;
|
|
125
|
+
|
|
126
|
+
if (obj.needs !== undefined && obj.needs !== null) {
|
|
127
|
+
const entries = Array.isArray(obj.needs) ? obj.needs : [obj.needs];
|
|
128
|
+
const needs: string[] = [];
|
|
129
|
+
const optional: string[] = [];
|
|
130
|
+
for (const entry of entries) {
|
|
131
|
+
const plain = scalarString(entry);
|
|
132
|
+
if (plain !== undefined) {
|
|
133
|
+
needs.push(plain);
|
|
115
134
|
continue;
|
|
116
135
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
136
|
+
if (!isMapping(entry)) continue;
|
|
137
|
+
// Cross-pipeline needs name a job in another pipeline.
|
|
138
|
+
if ("pipeline" in entry || "project" in entry) continue;
|
|
139
|
+
const target = scalarString(entry.job);
|
|
140
|
+
if (target === undefined) continue;
|
|
141
|
+
needs.push(target);
|
|
142
|
+
if (entry.optional === true) optional.push(target);
|
|
121
143
|
}
|
|
144
|
+
job.needs = needs;
|
|
145
|
+
if (optional.length > 0) job.optionalNeeds = optional;
|
|
146
|
+
}
|
|
122
147
|
|
|
123
|
-
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
job.needs = inlineNeeds[1].split(",").map((s) => s.trim().replace(/^'|'$/g, ""));
|
|
127
|
-
}
|
|
148
|
+
if (obj.extends !== undefined && obj.extends !== null) {
|
|
149
|
+
const ext = Array.isArray(obj.extends) ? obj.extends : [obj.extends];
|
|
150
|
+
job.extends = ext.map(scalarString).filter((s): s is string => s !== undefined);
|
|
128
151
|
}
|
|
129
152
|
|
|
130
153
|
jobs.set(name, job);
|
|
131
154
|
}
|
|
132
|
-
|
|
133
155
|
return jobs;
|
|
134
156
|
}
|
|
135
157
|
|
|
@@ -143,34 +165,83 @@ export function hasInclude(yaml: string): boolean {
|
|
|
143
165
|
}
|
|
144
166
|
|
|
145
167
|
/**
|
|
146
|
-
* Extract global variables from serialized YAML.
|
|
168
|
+
* Extract global variables from serialized YAML. A variable in the expanded
|
|
169
|
+
* form (`value:` / `description:` / `options:`) maps to its `value`.
|
|
147
170
|
*/
|
|
148
171
|
export function extractGlobalVariables(yaml: string): Map<string, string> {
|
|
149
172
|
const vars = new Map<string, string>();
|
|
150
|
-
const
|
|
151
|
-
if (!
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
if (kv) {
|
|
156
|
-
vars.set(kv[1], kv[2].trim().replace(/^['"]|['"]$/g, ""));
|
|
157
|
-
}
|
|
173
|
+
const variables = parsePipeline(yaml)?.variables;
|
|
174
|
+
if (!isMapping(variables)) return vars;
|
|
175
|
+
for (const [key, val] of Object.entries(variables)) {
|
|
176
|
+
const v = isMapping(val) ? scalarString(val.value) : scalarString(val);
|
|
177
|
+
vars.set(key, v ?? "");
|
|
158
178
|
}
|
|
159
179
|
return vars;
|
|
160
180
|
}
|
|
161
181
|
|
|
162
182
|
/**
|
|
163
|
-
*
|
|
183
|
+
* The key of a top-level mapping line (`build:`, `Build_Docs:`,
|
|
184
|
+
* `build:linux:`, `"deploy prod":`), unquoted, or undefined when the line
|
|
185
|
+
* does not start a top-level key.
|
|
164
186
|
*/
|
|
165
|
-
export function
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
return
|
|
187
|
+
export function topLevelKey(line: string): string | undefined {
|
|
188
|
+
if (line === "" || /^[\s#]/.test(line) || /^(---|\.\.\.)(\s|$)/.test(line) || line.startsWith("- ")) return undefined;
|
|
189
|
+
const dq = line.match(/^"((?:[^"\\]|\\.)*)"\s*:(?:\s|$)/);
|
|
190
|
+
if (dq) {
|
|
191
|
+
try {
|
|
192
|
+
return JSON.parse(`"${dq[1]}"`) as string;
|
|
193
|
+
} catch {
|
|
194
|
+
return dq[1];
|
|
171
195
|
}
|
|
172
196
|
}
|
|
173
|
-
|
|
197
|
+
const sq = line.match(/^'((?:[^']|'')*)'\s*:(?:\s|$)/);
|
|
198
|
+
if (sq) return sq[1].replace(/''/g, "'");
|
|
199
|
+
const plain = line.match(/^(.+?)\s*:(?:\s|$)/);
|
|
200
|
+
return plain ? plain[1] : undefined;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** A top-level section of the document: its key and its raw text. */
|
|
204
|
+
export interface TopLevelSection {
|
|
205
|
+
key: string;
|
|
206
|
+
text: string;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Split the document into top-level sections. A section starts at a line
|
|
211
|
+
* holding a top-level key and runs to the next one; blank lines and
|
|
212
|
+
* column-0 comments at its end are dropped. Unlike splitting on blank
|
|
213
|
+
* lines, this does not depend on the serializer's spacing.
|
|
214
|
+
*/
|
|
215
|
+
export function topLevelSections(yaml: string): TopLevelSection[] {
|
|
216
|
+
const lines = yaml.replace(/\r\n?/g, "\n").split("\n");
|
|
217
|
+
const out: TopLevelSection[] = [];
|
|
218
|
+
let key: string | undefined;
|
|
219
|
+
let start = 0;
|
|
220
|
+
const flush = (end: number) => {
|
|
221
|
+
if (key === undefined) return;
|
|
222
|
+
let last = end;
|
|
223
|
+
while (last > start + 1 && (lines[last - 1].trim() === "" || lines[last - 1].startsWith("#"))) last--;
|
|
224
|
+
out.push({ key, text: lines.slice(start, last).join("\n") });
|
|
225
|
+
};
|
|
226
|
+
for (let i = 0; i < lines.length; i++) {
|
|
227
|
+
const line = lines[i];
|
|
228
|
+
const k = topLevelKey(line);
|
|
229
|
+
const boundary = k !== undefined || /^(---|\.\.\.)(\s|$)/.test(line);
|
|
230
|
+
if (!boundary) continue;
|
|
231
|
+
flush(i);
|
|
232
|
+
key = k;
|
|
233
|
+
start = i;
|
|
234
|
+
}
|
|
235
|
+
flush(lines.length);
|
|
236
|
+
return out;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Extract the full section text for a given job name (exact key match, so
|
|
241
|
+
* `build` never returns the `build:linux` section).
|
|
242
|
+
*/
|
|
243
|
+
export function extractJobSection(yaml: string, jobName: string): string | null {
|
|
244
|
+
return topLevelSections(yaml).find((s) => s.key === jobName)?.text ?? null;
|
|
174
245
|
}
|
|
175
246
|
|
|
176
247
|
/**
|
|
@@ -241,10 +312,8 @@ export interface ImageRef {
|
|
|
241
312
|
*/
|
|
242
313
|
export function extractImageRefs(yaml: string): ImageRef[] {
|
|
243
314
|
const refs: ImageRef[] = [];
|
|
244
|
-
for (const section of yaml
|
|
315
|
+
for (const { key: job, text: section } of topLevelSections(yaml)) {
|
|
245
316
|
const lines = section.split("\n");
|
|
246
|
-
const top = lines[0]?.match(/^(\.?[a-z][a-z0-9_.-]*):/i);
|
|
247
|
-
const job = top ? top[1] : "default";
|
|
248
317
|
|
|
249
318
|
for (let i = 0; i < lines.length; i++) {
|
|
250
319
|
// image: as object (next indented `name:`) or inline string
|
|
@@ -285,7 +354,7 @@ export interface IncludeEntry {
|
|
|
285
354
|
*/
|
|
286
355
|
export function extractIncludes(yaml: string): IncludeEntry[] {
|
|
287
356
|
const entries: IncludeEntry[] = [];
|
|
288
|
-
const section = yaml
|
|
357
|
+
const section = topLevelSections(yaml).find((s) => s.key === "include")?.text;
|
|
289
358
|
if (!section) return entries;
|
|
290
359
|
|
|
291
360
|
// Inline string form: `include: <value>` (value on the same line, not a list)
|
|
@@ -342,11 +411,8 @@ export interface IdTokenDecl {
|
|
|
342
411
|
*/
|
|
343
412
|
export function extractIdTokens(yaml: string): IdTokenDecl[] {
|
|
344
413
|
const out: IdTokenDecl[] = [];
|
|
345
|
-
for (const section of yaml
|
|
414
|
+
for (const { key: job, text: section } of topLevelSections(yaml)) {
|
|
346
415
|
const lines = section.split("\n");
|
|
347
|
-
const top = lines[0]?.match(/^(\.?[a-z][a-z0-9_.-]*):/i);
|
|
348
|
-
if (!top) continue;
|
|
349
|
-
const job = top[1];
|
|
350
416
|
|
|
351
417
|
let inIdTokens = false;
|
|
352
418
|
let idTokensIndent = -1;
|
|
@@ -408,11 +474,8 @@ export interface ScriptCommand {
|
|
|
408
474
|
*/
|
|
409
475
|
export function extractScriptCommands(yaml: string): ScriptCommand[] {
|
|
410
476
|
const out: ScriptCommand[] = [];
|
|
411
|
-
for (const section of yaml
|
|
477
|
+
for (const { key: job, text: section } of topLevelSections(yaml)) {
|
|
412
478
|
const lines = section.split("\n");
|
|
413
|
-
const top = lines[0]?.match(/^(\.?[a-z][a-z0-9_.-]*):/i);
|
|
414
|
-
if (!top) continue;
|
|
415
|
-
const job = top[1];
|
|
416
479
|
|
|
417
480
|
let inScript = false;
|
|
418
481
|
let scriptIndent = -1;
|
package/dist/variables.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"variables.d.ts","sourceRoot":"","sources":["../src/variables.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,eAAO,MAAM,EAAE
|
|
1
|
+
{"version":3,"file":"variables.d.ts","sourceRoot":"","sources":["../src/variables.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,eAAO,MAAM,EAAE;aACb,YAAY,EAAE,mBAAmB;aACjC,SAAS,EAAE,qBAAqB;aAChC,aAAa,EAAE,qBAAqB;aACpC,SAAS,EAAE,gBAAgB;aAC3B,SAAS,EAAE,gBAAgB;aAC3B,aAAa,EAAE,oBAAoB;aACnC,WAAW,EAAE,sBAAsB;aACnC,eAAe,EAAE,sBAAsB;aACvC,KAAK,EAAE,YAAY;aACnB,OAAO,EAAE,cAAc;aACvB,QAAQ,EAAE,eAAe;aACzB,eAAe,EAAE,uBAAuB;aACxC,UAAU,EAAE,iBAAiB;aAC7B,cAAc,EAAE,qBAAqB;aACrC,UAAU,EAAE,iBAAiB;aAC7B,SAAS,EAAE,gBAAgB;aAC3B,WAAW,EAAE,kBAAkB;aAC/B,WAAW,EAAE,kBAAkB;aAC/B,QAAQ,EAAE,cAAc;aACxB,aAAa,EAAE,oBAAoB;aACnC,YAAY,EAAE,mBAAmB;aACjC,gBAAgB,EAAE,uBAAuB;CACjC,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant-lexicon-gitlab",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.102.0",
|
|
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",
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
"validate": "tsx src/validate-cli.ts",
|
|
60
60
|
"docs": "tsx src/codegen/docs-cli.ts",
|
|
61
61
|
"prepack": "npm run generate && npm run bundle && npm run validate && npm run build",
|
|
62
|
-
"build": "
|
|
62
|
+
"build": "../../scripts/tsc7.sh -p tsconfig.build.json && tsc-alias -p tsconfig.build.json && find dist -type f \\( -name \"*.js\" -o -name \"*.js.map\" \\) -delete"
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
|
65
65
|
"@intentius/chant": "*",
|
|
@@ -67,8 +67,8 @@
|
|
|
67
67
|
"typescript": "^5.9.3"
|
|
68
68
|
},
|
|
69
69
|
"peerDependencies": {
|
|
70
|
-
"@intentius/chant": "^0.
|
|
71
|
-
"@intentius/chant-lexicon-github": "^0.
|
|
70
|
+
"@intentius/chant": "^0.102.0",
|
|
71
|
+
"@intentius/chant-lexicon-github": "^0.102.0",
|
|
72
72
|
"typescript": "^5.9.3"
|
|
73
73
|
},
|
|
74
74
|
"peerDependenciesMeta": {
|
|
@@ -105,7 +105,7 @@
|
|
|
105
105
|
* beside it does not also point.
|
|
106
106
|
*/
|
|
107
107
|
|
|
108
|
-
import {
|
|
108
|
+
import { emitYAMLEntry } from "@intentius/chant/yaml";
|
|
109
109
|
import { memberGitlabChanges, memberRepoPath, memberShellDir, resolveOpTrigger } from "@intentius/chant/lexicon";
|
|
110
110
|
import type {
|
|
111
111
|
ComponentPipelineOptions as GenerateGitlabOpOptions,
|
|
@@ -521,10 +521,10 @@ export function generateGitlabOpPipeline(
|
|
|
521
521
|
headerLines.push("#", ...opLines);
|
|
522
522
|
|
|
523
523
|
const sections: string[] = [];
|
|
524
|
-
sections.push("stages
|
|
525
|
-
if (doc.variables) sections.push("variables
|
|
524
|
+
sections.push(emitYAMLEntry("stages", doc.stages));
|
|
525
|
+
if (doc.variables) sections.push(emitYAMLEntry("variables", doc.variables));
|
|
526
526
|
for (const { jobName } of jobs) {
|
|
527
|
-
sections.push(
|
|
527
|
+
sections.push(emitYAMLEntry(jobName, doc[jobName]));
|
|
528
528
|
}
|
|
529
529
|
|
|
530
530
|
const yaml = headerLines.join("\n") + "\n\n" + sections.join("\n\n") + "\n";
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
* touching the component declarations.
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
|
-
import {
|
|
35
|
+
import { emitYAMLEntry } from "@intentius/chant/yaml";
|
|
36
36
|
import { resolveComponentGraph, type DriverComponent } from "@intentius/chant/components/driver";
|
|
37
37
|
import { hasPublishStep, promoteArchivePaths } from "@intentius/chant/components/promote";
|
|
38
38
|
import { memberGitlabChanges, memberRepoPath, memberShellDir } from "@intentius/chant/lexicon";
|
|
@@ -187,22 +187,17 @@ export function generateGitlabPipeline(
|
|
|
187
187
|
}
|
|
188
188
|
|
|
189
189
|
const sections: string[] = [];
|
|
190
|
-
|
|
191
|
-
// `[]` for an empty one, so the header needs a space in the second case.
|
|
192
|
-
// `stages:[]` is the plain scalar "stages:[]" — a colon opens a mapping only
|
|
193
|
-
// when whitespace or the line's end follows it — which is how GitLab's own
|
|
194
|
-
// reader takes it, and now how `parseYAML` does too (chant #2013).
|
|
195
|
-
sections.push("workflow:" + emitYAML(doc.workflow, 1));
|
|
190
|
+
sections.push(emitYAMLEntry("workflow", doc.workflow));
|
|
196
191
|
// The promote stage is YAML only: `stages` in the result stays one entry
|
|
197
192
|
// per graph wave.
|
|
198
193
|
const yamlStages = promoteJob ? [...stages, "promote"] : stages;
|
|
199
|
-
sections.push(
|
|
200
|
-
if (doc.variables) sections.push("variables
|
|
194
|
+
sections.push(emitYAMLEntry("stages", yamlStages));
|
|
195
|
+
if (doc.variables) sections.push(emitYAMLEntry("variables", doc.variables));
|
|
201
196
|
for (const job of jobs) {
|
|
202
197
|
const props = doc[job.jobName] as Record<string, unknown>;
|
|
203
|
-
sections.push(
|
|
198
|
+
sections.push(emitYAMLEntry(job.jobName, props));
|
|
204
199
|
}
|
|
205
|
-
if (promoteJob) sections.push(
|
|
200
|
+
if (promoteJob) sections.push(emitYAMLEntry(promoteJob, doc[promoteJob]));
|
|
206
201
|
|
|
207
202
|
return { yaml: sections.join("\n\n") + "\n", stages, jobs, env };
|
|
208
203
|
}
|
|
@@ -153,3 +153,32 @@ describe("WGL011: unreachable job", () => {
|
|
|
153
153
|
expect(diags).toHaveLength(0);
|
|
154
154
|
});
|
|
155
155
|
});
|
|
156
|
+
|
|
157
|
+
describe("WGL010: job ids the old line parser skipped (#3256)", () => {
|
|
158
|
+
test("flags a job whose id starts with a capital", () => {
|
|
159
|
+
const yaml = `stages:
|
|
160
|
+
- build
|
|
161
|
+
|
|
162
|
+
Build_Docs:
|
|
163
|
+
stage: docs
|
|
164
|
+
script:
|
|
165
|
+
- make docs
|
|
166
|
+
`;
|
|
167
|
+
const diags = wgl010.check(makeCtx(yaml));
|
|
168
|
+
expect(diags).toHaveLength(1);
|
|
169
|
+
expect(diags[0].entity).toBe("Build_Docs");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("reads a flow-list stages: and jobs with no blank line between them", () => {
|
|
173
|
+
const yaml = `stages: [build, test]
|
|
174
|
+
build:
|
|
175
|
+
stage: build
|
|
176
|
+
script: make
|
|
177
|
+
lint:
|
|
178
|
+
stage: lint
|
|
179
|
+
script: make lint
|
|
180
|
+
`;
|
|
181
|
+
const diags = wgl010.check(makeCtx(yaml));
|
|
182
|
+
expect(diags.map((d) => d.entity)).toEqual(["lint"]);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
@@ -162,3 +162,56 @@ build:
|
|
|
162
162
|
expect(diags[1].message).toContain("ghost2");
|
|
163
163
|
});
|
|
164
164
|
});
|
|
165
|
+
|
|
166
|
+
describe("WGL013: needs: forms the old line parser misread (#3256)", () => {
|
|
167
|
+
test("a dangling need after an artifacts: line is reported", () => {
|
|
168
|
+
const yaml = `build:
|
|
169
|
+
script: make
|
|
170
|
+
|
|
171
|
+
deploy:
|
|
172
|
+
needs:
|
|
173
|
+
- job: build
|
|
174
|
+
artifacts: true
|
|
175
|
+
- job: missing
|
|
176
|
+
script: ./deploy.sh
|
|
177
|
+
`;
|
|
178
|
+
const diags = checkInvalidNeeds(makeCtx(yaml));
|
|
179
|
+
expect(diags).toHaveLength(1);
|
|
180
|
+
expect(diags[0].message).toContain('"missing"');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("a need on a job whose id starts with a capital is not dangling", () => {
|
|
184
|
+
const yaml = `Build_Docs:
|
|
185
|
+
script: make docs
|
|
186
|
+
|
|
187
|
+
deploy:
|
|
188
|
+
needs: [Build_Docs]
|
|
189
|
+
script: ./deploy.sh
|
|
190
|
+
`;
|
|
191
|
+
expect(checkInvalidNeeds(makeCtx(yaml))).toHaveLength(0);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("an optional need on an absent job is not reported", () => {
|
|
195
|
+
const yaml = `deploy:
|
|
196
|
+
needs:
|
|
197
|
+
- job: maybe-build
|
|
198
|
+
optional: true
|
|
199
|
+
script: ./deploy.sh
|
|
200
|
+
`;
|
|
201
|
+
expect(checkInvalidNeeds(makeCtx(yaml))).toHaveLength(0);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("cross-pipeline needs are not checked against this pipeline", () => {
|
|
205
|
+
const yaml = `deploy:
|
|
206
|
+
needs:
|
|
207
|
+
- pipeline: $PARENT_PIPELINE_ID
|
|
208
|
+
job: generate
|
|
209
|
+
- project: group/other
|
|
210
|
+
job: build
|
|
211
|
+
ref: main
|
|
212
|
+
artifacts: true
|
|
213
|
+
script: ./deploy.sh
|
|
214
|
+
`;
|
|
215
|
+
expect(checkInvalidNeeds(makeCtx(yaml))).toHaveLength(0);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
* Both cause GitLab pipeline validation failures.
|
|
9
9
|
*
|
|
10
10
|
* Caveat: when `include:` is present, referenced jobs may come from
|
|
11
|
-
* included files, so the check is skipped.
|
|
11
|
+
* included files, so the check is skipped. A `- job: x` entry with
|
|
12
|
+
* `optional: true` may name a job that is not in the pipeline (GitLab
|
|
13
|
+
* ignores it), so it is not reported as dangling.
|
|
12
14
|
*/
|
|
13
15
|
|
|
14
16
|
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
@@ -36,7 +38,7 @@ export function checkInvalidNeeds(ctx: PostSynthContext): PostSynthDiagnostic[]
|
|
|
36
38
|
entity: jobName,
|
|
37
39
|
lexicon: "gitlab",
|
|
38
40
|
});
|
|
39
|
-
} else if (!jobNames.has(need)) {
|
|
41
|
+
} else if (!jobNames.has(need) && !job.optionalNeeds?.includes(need)) {
|
|
40
42
|
diagnostics.push({
|
|
41
43
|
checkId: "WGL013",
|
|
42
44
|
severity: "error",
|
|
@@ -95,3 +95,26 @@ deploy:
|
|
|
95
95
|
expect(diags[0].message).toContain(".missing");
|
|
96
96
|
});
|
|
97
97
|
});
|
|
98
|
+
|
|
99
|
+
describe("WGL014: templates the old line parser skipped (#3256)", () => {
|
|
100
|
+
test("extends of a hidden template whose name has a capital is resolved", () => {
|
|
101
|
+
const yaml = `.Base:
|
|
102
|
+
image: node:20
|
|
103
|
+
|
|
104
|
+
lint:
|
|
105
|
+
extends: .Base
|
|
106
|
+
script: npm run lint
|
|
107
|
+
`;
|
|
108
|
+
expect(checkInvalidExtends(makeCtx(yaml))).toHaveLength(0);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("a job whose id starts with a capital is checked", () => {
|
|
112
|
+
const yaml = `Lint:
|
|
113
|
+
extends: .missing
|
|
114
|
+
script: npm run lint
|
|
115
|
+
`;
|
|
116
|
+
const diags = checkInvalidExtends(makeCtx(yaml));
|
|
117
|
+
expect(diags).toHaveLength(1);
|
|
118
|
+
expect(diags[0].entity).toBe("Lint");
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -137,3 +137,38 @@ job-a:
|
|
|
137
137
|
expect(diags).toHaveLength(0);
|
|
138
138
|
});
|
|
139
139
|
});
|
|
140
|
+
|
|
141
|
+
describe("WGL015: needs the old line parser dropped (#3256)", () => {
|
|
142
|
+
test("finds a cycle through a need listed after an artifacts: line", () => {
|
|
143
|
+
const yaml = `build:
|
|
144
|
+
needs: [deploy]
|
|
145
|
+
script: make
|
|
146
|
+
|
|
147
|
+
deploy:
|
|
148
|
+
needs:
|
|
149
|
+
- job: lint
|
|
150
|
+
artifacts: true
|
|
151
|
+
- job: build
|
|
152
|
+
script: ./deploy.sh
|
|
153
|
+
|
|
154
|
+
lint:
|
|
155
|
+
script: make lint
|
|
156
|
+
`;
|
|
157
|
+
const diags = checkCircularNeeds(makeCtx(yaml));
|
|
158
|
+
expect(diags).toHaveLength(1);
|
|
159
|
+
expect(diags[0].message).toContain("build");
|
|
160
|
+
expect(diags[0].message).toContain("deploy");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("finds a cycle through a job whose id starts with a capital", () => {
|
|
164
|
+
const yaml = `Build:
|
|
165
|
+
needs: [deploy]
|
|
166
|
+
script: make
|
|
167
|
+
|
|
168
|
+
deploy:
|
|
169
|
+
needs: [Build]
|
|
170
|
+
script: ./deploy.sh
|
|
171
|
+
`;
|
|
172
|
+
expect(checkCircularNeeds(makeCtx(yaml))).toHaveLength(1);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
@@ -51,3 +51,17 @@ describe("WGL017: Insecure Registry", () => {
|
|
|
51
51
|
expect(diags).toHaveLength(0);
|
|
52
52
|
});
|
|
53
53
|
});
|
|
54
|
+
|
|
55
|
+
describe("WGL017: job ids the old line parser skipped (#3256)", () => {
|
|
56
|
+
test("flags a job whose id starts with a capital, with no blank line before it", () => {
|
|
57
|
+
const yaml = `build:
|
|
58
|
+
script: make
|
|
59
|
+
Push_Image:
|
|
60
|
+
script:
|
|
61
|
+
- docker push http://registry.local/app:latest
|
|
62
|
+
`;
|
|
63
|
+
const diags = checkInsecureRegistry(yaml);
|
|
64
|
+
expect(diags).toHaveLength(1);
|
|
65
|
+
expect(diags[0].entity).toBe("Push_Image");
|
|
66
|
+
});
|
|
67
|
+
});
|