@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.
Files changed (56) hide show
  1. package/dist/codegen/naming.d.ts.map +1 -1
  2. package/dist/components/generate-pipeline.d.ts.map +1 -1
  3. package/dist/composites/node-pipeline.d.ts +16 -16
  4. package/dist/composites/node-pipeline.d.ts.map +1 -1
  5. package/dist/generated/index.d.ts +19 -19
  6. package/dist/generated/index.d.ts.map +1 -1
  7. package/dist/import/generator.d.ts.map +1 -1
  8. package/dist/import/parser.d.ts.map +1 -1
  9. package/dist/integrity.json +10 -10
  10. package/dist/intrinsics.d.ts.map +1 -1
  11. package/dist/lint/post-synth/wgl013.d.ts +3 -1
  12. package/dist/lint/post-synth/wgl013.d.ts.map +1 -1
  13. package/dist/lint/post-synth/wgl017.d.ts.map +1 -1
  14. package/dist/lint/post-synth/wgl020.d.ts.map +1 -1
  15. package/dist/lint/post-synth/wgl021.d.ts.map +1 -1
  16. package/dist/lint/post-synth/wgl050.d.ts.map +1 -1
  17. package/dist/lint/post-synth/yaml-helpers.d.ts +57 -8
  18. package/dist/lint/post-synth/yaml-helpers.d.ts.map +1 -1
  19. package/dist/manifest.json +1 -1
  20. package/dist/migrate/from-github/provenance.d.ts.map +1 -1
  21. package/dist/op/builders.d.ts.map +1 -1
  22. package/dist/rules/wgl013.ts +4 -2
  23. package/dist/rules/wgl017.ts +3 -11
  24. package/dist/rules/wgl020.ts +4 -5
  25. package/dist/rules/wgl021.ts +4 -5
  26. package/dist/rules/wgl048.ts +2 -2
  27. package/dist/rules/wgl049.ts +4 -4
  28. package/dist/rules/wgl050.ts +5 -8
  29. package/dist/rules/yaml-helpers.ts +176 -113
  30. package/dist/variables.d.ts.map +1 -1
  31. package/package.json +4 -4
  32. package/src/components/generate-op-pipeline.ts +4 -4
  33. package/src/components/generate-pipeline.ts +6 -11
  34. package/src/lint/post-synth/post-synth.test.ts +29 -0
  35. package/src/lint/post-synth/wgl013.test.ts +53 -0
  36. package/src/lint/post-synth/wgl013.ts +4 -2
  37. package/src/lint/post-synth/wgl014.test.ts +23 -0
  38. package/src/lint/post-synth/wgl015.test.ts +35 -0
  39. package/src/lint/post-synth/wgl017.test.ts +14 -0
  40. package/src/lint/post-synth/wgl017.ts +3 -11
  41. package/src/lint/post-synth/wgl020.test.ts +26 -0
  42. package/src/lint/post-synth/wgl020.ts +4 -5
  43. package/src/lint/post-synth/wgl021.test.ts +29 -0
  44. package/src/lint/post-synth/wgl021.ts +4 -5
  45. package/src/lint/post-synth/wgl028.test.ts +27 -0
  46. package/src/lint/post-synth/wgl042.test.ts +14 -0
  47. package/src/lint/post-synth/wgl048.test.ts +14 -0
  48. package/src/lint/post-synth/wgl048.ts +2 -2
  49. package/src/lint/post-synth/wgl049.test.ts +25 -0
  50. package/src/lint/post-synth/wgl049.ts +4 -4
  51. package/src/lint/post-synth/wgl050.test.ts +16 -0
  52. package/src/lint/post-synth/wgl050.ts +5 -8
  53. package/src/lint/post-synth/yaml-helpers.test.ts +249 -0
  54. package/src/lint/post-synth/yaml-helpers.ts +176 -113
  55. package/src/migrate/from-github/fixtures/syntax-mapping/03-matrix/expected.gitlab-ci.yml +3 -1
  56. 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
- * Since GitLab CI output is YAML (not JSON like CloudFormation), we parse
5
- * the YAML sections structurally using simple regex/string parsing. The
6
- * serializer emits a predictable format so we can extract what we need
7
- * without a full YAML parser dependency.
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
- * Extract stages list from serialized YAML.
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 function extractStages(yaml: string): string[] {
38
- const stages: string[] = [];
39
- const stagesMatch = yaml.match(/^stages:\n((?:\s+- .+\n?)+)/m);
40
- if (stagesMatch) {
41
- for (const line of stagesMatch[1].split("\n")) {
42
- const item = line.match(/^\s+- (.+)$/);
43
- if (item) stages.push(item[1].trim().replace(/^'|'$/g, ""));
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
- * Extract job names and their stage values from serialized YAML.
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
- // Split into sections by double newlines
56
- const sections = yaml.split("\n\n");
57
- for (const section of sections) {
58
- const lines = section.split("\n");
59
- if (lines.length === 0) continue;
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
- // Top-level key (including dot-prefixed hidden jobs like .deploy-template)
62
- const topMatch = lines[0].match(/^(\.?[a-z][a-z0-9_.-]*):/);
63
- if (!topMatch) continue;
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
- const name = topMatch[1];
66
- // Skip reserved keys
67
- if (["stages", "default", "workflow", "variables", "include"].includes(name)) continue;
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
- // Find stage, needs, extends within the section
72
- let inNeeds = false;
73
- for (const line of lines) {
74
- const stageMatch = line.match(/^\s+stage:\s+(.+)$/);
75
- if (stageMatch) {
76
- job.stage = stageMatch[1].trim().replace(/^'|'$/g, "");
77
- }
78
-
79
- // extends: .template or extends: [.a, .b]
80
- const extendsMatch = line.match(/^\s+extends:\s+(.+)$/);
81
- if (extendsMatch) {
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
- // End of needs block when we hit a non-indented-list line
118
- if (!line.match(/^\s+\s/) || line.match(/^\s+[a-z_]+:/)) {
119
- inNeeds = false;
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
- // needs: [a, b] (inline array form)
124
- const inlineNeeds = line.match(/^\s+needs:\s+\[(.+)\]$/);
125
- if (inlineNeeds) {
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 match = yaml.match(/^variables:\n((?:\s+.+\n?)+)/m);
151
- if (!match) return vars;
152
-
153
- for (const line of match[1].split("\n")) {
154
- const kv = line.match(/^\s+(\w+):\s+(.+)$/);
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
- * Extract the full section text for a given job name.
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 extractJobSection(yaml: string, jobName: string): string | null {
166
- const sections = yaml.split("\n\n");
167
- for (const section of sections) {
168
- const lines = section.split("\n");
169
- if (lines.length > 0 && lines[0].startsWith(`${jobName}:`)) {
170
- return section;
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
- return null;
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.split("\n\n")) {
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.split("\n\n").find((s) => /^include:/.test(s));
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.split("\n\n")) {
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.split("\n\n")) {
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;
@@ -1 +1 @@
1
- {"version":3,"file":"variables.d.ts","sourceRoot":"","sources":["../src/variables.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,eAAO,MAAM,EAAE;;;;;;;;;;;;;;;;;;;;;;;CAuBL,CAAC"}
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.101.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": "tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json && find dist -type f \\( -name \"*.js\" -o -name \"*.js.map\" \\) -delete"
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.101.0",
71
- "@intentius/chant-lexicon-github": "^0.101.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 { emitYAML } from "@intentius/chant/yaml";
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:" + emitYAML(doc.stages, 1));
525
- if (doc.variables) sections.push("variables:" + emitYAML(doc.variables, 1));
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(`${jobName}:` + emitYAML(doc[jobName], 1));
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 { emitYAML } from "@intentius/chant/yaml";
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
- // `emitYAML` returns a `\n`-led block for a non-empty sequence and an inline
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(yamlStages.length > 0 ? "stages:" + emitYAML(yamlStages, 1) : "stages: []");
200
- if (doc.variables) sections.push("variables:" + emitYAML(doc.variables, 1));
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(`${job.jobName}:` + emitYAML(props, 1));
198
+ sections.push(emitYAMLEntry(job.jobName, props));
204
199
  }
205
- if (promoteJob) sections.push(`${promoteJob}:` + emitYAML(doc[promoteJob], 1));
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
+ });