@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
@@ -6,23 +6,15 @@
6
6
  */
7
7
 
8
8
  import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
9
- import { getPrimaryOutput, extractJobs } from "./yaml-helpers";
9
+ import { getPrimaryOutput, topLevelSections } from "./yaml-helpers";
10
10
 
11
11
  const INSECURE_REGISTRY_PATTERN = /docker\s+(push|pull|tag|login)\s+.*http:\/\/[^\s]+/;
12
12
 
13
13
  export function checkInsecureRegistry(yaml: string): PostSynthDiagnostic[] {
14
14
  const diagnostics: PostSynthDiagnostic[] = [];
15
15
 
16
- const sections = yaml.split("\n\n");
17
- for (const section of sections) {
18
- const lines = section.split("\n");
19
- if (lines.length === 0) continue;
20
-
21
- const topMatch = lines[0].match(/^(\.?[a-z][a-z0-9_.-]*):/);
22
- if (!topMatch) continue;
23
- const jobName = topMatch[1];
24
-
25
- for (const line of lines) {
16
+ for (const { key: jobName, text } of topLevelSections(yaml)) {
17
+ for (const line of text.split("\n")) {
26
18
  if (INSECURE_REGISTRY_PATTERN.test(line)) {
27
19
  diagnostics.push({
28
20
  checkId: "WGL017",
@@ -52,3 +52,29 @@ variables:
52
52
  expect(diags).toHaveLength(0);
53
53
  });
54
54
  });
55
+
56
+ describe("WGL020: keys the old line parser skipped (#3256)", () => {
57
+ test("flags a duplicated job id that starts with a capital", () => {
58
+ const yaml = `Build_Docs:
59
+ script: a
60
+
61
+ Build_Docs:
62
+ script: b
63
+ `;
64
+ const diags = checkDuplicateJobNames(yaml);
65
+ expect(diags).toHaveLength(1);
66
+ expect(diags[0].entity).toBe("Build_Docs");
67
+ });
68
+
69
+ test("treats a quoted and a plain key as the same job, and skips global keywords", () => {
70
+ const yaml = `image: node:20
71
+ build:
72
+ script: a
73
+ "build":
74
+ script: b
75
+ image: node:22
76
+ `;
77
+ const diags = checkDuplicateJobNames(yaml);
78
+ expect(diags.map((d) => d.entity)).toEqual(["build"]);
79
+ });
80
+ });
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
10
- import { getPrimaryOutput, extractJobs } from "./yaml-helpers";
10
+ import { getPrimaryOutput, topLevelKey, RESERVED_TOP_LEVEL_KEYS } from "./yaml-helpers";
11
11
 
12
12
  export function checkDuplicateJobNames(yaml: string): PostSynthDiagnostic[] {
13
13
  const diagnostics: PostSynthDiagnostic[] = [];
@@ -18,10 +18,9 @@ export function checkDuplicateJobNames(yaml: string): PostSynthDiagnostic[] {
18
18
  const lines = yaml.split("\n");
19
19
 
20
20
  for (const line of lines) {
21
- const topMatch = line.match(/^(\.?[a-z][a-z0-9_.-]*):/);
22
- if (topMatch) {
23
- const name = topMatch[1];
24
- if (["stages", "default", "workflow", "variables", "include"].includes(name)) continue;
21
+ const name = topLevelKey(line);
22
+ if (name !== undefined) {
23
+ if (RESERVED_TOP_LEVEL_KEYS.has(name)) continue;
25
24
  keyCounts.set(name, (keyCounts.get(name) ?? 0) + 1);
26
25
  }
27
26
  }
@@ -60,3 +60,32 @@ deploy-job:
60
60
  expect(diags).toHaveLength(0);
61
61
  });
62
62
  });
63
+
64
+ describe("WGL021: expanded variables (#3256)", () => {
65
+ test("reads the value:/description: form by variable name", () => {
66
+ const yaml = `variables:
67
+ DEPLOY_ENV:
68
+ value: staging
69
+ description: Target environment
70
+
71
+ deploy:
72
+ script:
73
+ - ./deploy.sh $DEPLOY_ENV
74
+ `;
75
+ expect(checkUnusedVariables(yaml)).toHaveLength(0);
76
+ });
77
+
78
+ test("reports an unused expanded variable by its name", () => {
79
+ const yaml = `variables:
80
+ UNUSED_ENV:
81
+ value: staging
82
+ description: Never read
83
+
84
+ deploy:
85
+ script:
86
+ - ./deploy.sh
87
+ `;
88
+ const diags = checkUnusedVariables(yaml);
89
+ expect(diags.map((d) => d.entity)).toEqual(["UNUSED_ENV"]);
90
+ });
91
+ });
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
9
- import { getPrimaryOutput, extractGlobalVariables } from "./yaml-helpers";
9
+ import { getPrimaryOutput, extractGlobalVariables, topLevelSections } from "./yaml-helpers";
10
10
 
11
11
  export function checkUnusedVariables(yaml: string): PostSynthDiagnostic[] {
12
12
  const diagnostics: PostSynthDiagnostic[] = [];
@@ -20,14 +20,13 @@ export function checkUnusedVariables(yaml: string): PostSynthDiagnostic[] {
20
20
  // Check if $VARNAME or ${VARNAME} appears anywhere in the YAML (outside the variables block)
21
21
  const refPattern = new RegExp(`\\$\\{?${varName}\\}?`);
22
22
  // Also check for uses in extends, needs, etc. — search all sections
23
- const sections = yaml.split("\n\n");
24
23
  let found = false;
25
24
 
26
- for (const section of sections) {
25
+ for (const { key, text } of topLevelSections(yaml)) {
27
26
  // Skip the global variables section itself
28
- if (section.trimStart().startsWith("variables:")) continue;
27
+ if (key === "variables") continue;
29
28
 
30
- if (refPattern.test(section)) {
29
+ if (refPattern.test(text)) {
31
30
  found = true;
32
31
  break;
33
32
  }
@@ -93,3 +93,30 @@ test-app:
93
93
  expect(diags).toHaveLength(0);
94
94
  });
95
95
  });
96
+
97
+ describe("WGL028: needs the old line parser dropped (#3256)", () => {
98
+ test("reads a need listed after an artifacts: line", () => {
99
+ const yaml = `stages:
100
+ - build
101
+ - deploy
102
+
103
+ lint:
104
+ stage: build
105
+ script: make lint
106
+
107
+ build:
108
+ stage: build
109
+ script: make
110
+
111
+ deploy:
112
+ stage: deploy
113
+ needs:
114
+ - job: lint
115
+ artifacts: false
116
+ - job: build
117
+ script: ./deploy.sh
118
+ `;
119
+ const diags = checkRedundantNeeds(yaml);
120
+ expect(diags.map((d) => d.message.match(/lists "([^"]+)"/)?.[1])).toEqual(["lint", "build"]);
121
+ });
122
+ });
@@ -43,3 +43,17 @@ describe("WGL042: unreachable rules after unconditional match", () => {
43
43
  expect(diags).toHaveLength(0);
44
44
  });
45
45
  });
46
+
47
+ describe("WGL042: job ids the old line parser skipped (#3256)", () => {
48
+ test("checks a job whose id starts with a capital", () => {
49
+ const yaml = `Build_Docs:
50
+ rules:
51
+ - when: always
52
+ - if: $CI_COMMIT_BRANCH == "main"
53
+ script: make docs
54
+ `;
55
+ const diags = wgl042.check(makeCtx(yaml));
56
+ expect(diags).toHaveLength(1);
57
+ expect(diags[0].entity).toBe("Build_Docs");
58
+ });
59
+ });
@@ -54,3 +54,17 @@ build:
54
54
  expect(diags).toHaveLength(0);
55
55
  });
56
56
  });
57
+
58
+ describe("WGL048: section boundaries (#3256)", () => {
59
+ test("a name: in the next job is not read as workflow:name", () => {
60
+ const yaml = `workflow:
61
+ rules:
62
+ - when: always
63
+ build:
64
+ image:
65
+ name: node:20
66
+ script: make
67
+ `;
68
+ expect(wgl048.check(makeCtx(yaml))).toHaveLength(1);
69
+ });
70
+ });
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
11
- import { getPrimaryOutput } from "./yaml-helpers";
11
+ import { getPrimaryOutput, topLevelSections } from "./yaml-helpers";
12
12
 
13
13
  export const wgl048: PostSynthCheck = {
14
14
  id: "WGL048",
@@ -19,7 +19,7 @@ export const wgl048: PostSynthCheck = {
19
19
 
20
20
  for (const [, output] of ctx.outputs) {
21
21
  const yaml = getPrimaryOutput(output);
22
- const workflowSection = yaml.split("\n\n").find((s) => /^workflow:/.test(s));
22
+ const workflowSection = topLevelSections(yaml).find((s) => s.key === "workflow")?.text;
23
23
  if (!workflowSection) continue;
24
24
  if (!/^\s+name:/m.test(workflowSection)) {
25
25
  diagnostics.push({
@@ -69,3 +69,28 @@ build:
69
69
  expect(wgl049.check(makeCtx(yaml))).toHaveLength(0);
70
70
  });
71
71
  });
72
+
73
+ describe("WGL049: job ids and section boundaries (#3256)", () => {
74
+ test("flags a job whose id starts with a capital", () => {
75
+ const yaml = `Build_App:
76
+ script:
77
+ - npm ci
78
+ `;
79
+ const diags = wgl049.check(makeCtx(yaml));
80
+ expect(diags).toHaveLength(1);
81
+ expect(diags[0].entity).toBe("Build_App");
82
+ });
83
+
84
+ test("a cache: in the next job is not read as this job's cache", () => {
85
+ const yaml = `build:
86
+ script:
87
+ - npm ci
88
+ other:
89
+ cache:
90
+ paths: [node_modules/]
91
+ script: echo
92
+ `;
93
+ const diags = wgl049.check(makeCtx(yaml));
94
+ expect(diags.map((d) => d.entity)).toEqual(["build"]);
95
+ });
96
+ });
@@ -11,14 +11,14 @@
11
11
  */
12
12
 
13
13
  import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
14
- import { getPrimaryOutput, extractJobs, extractJobSection } from "./yaml-helpers";
14
+ import { getPrimaryOutput, extractJobs, extractJobSection, topLevelSections } from "./yaml-helpers";
15
15
 
16
16
  const DEP_INSTALL_RE = /\b(npm (install|ci)|yarn install|pnpm install|pip install|pip3 install|bundle install|composer install|go mod download|mvn (install|dependency:resolve))\b/i;
17
17
 
18
18
  function hasPipelineWideCache(yaml: string): boolean {
19
- const sections = yaml.split("\n\n");
20
- if (sections.some((s) => /^cache:/.test(s))) return true;
21
- const defaultSection = sections.find((s) => /^default:/.test(s));
19
+ const sections = topLevelSections(yaml);
20
+ if (sections.some((s) => s.key === "cache")) return true;
21
+ const defaultSection = sections.find((s) => s.key === "default")?.text;
22
22
  return !!defaultSection && /\n\s+cache:/.test(defaultSection);
23
23
  }
24
24
 
@@ -55,3 +55,19 @@ describe("WGL050: merge-request job missing interruptible", () => {
55
55
  expect(wgl050.check(makeCtx(yaml))).toHaveLength(0);
56
56
  });
57
57
  });
58
+
59
+ describe("WGL050: job ids and section boundaries (#3256)", () => {
60
+ test("flags a job whose id has a colon, with no blank line before it", () => {
61
+ const yaml = `build:
62
+ interruptible: true
63
+ script: make
64
+ test:unit:
65
+ rules:
66
+ - if: $CI_PIPELINE_SOURCE == "merge_request_event"
67
+ script:
68
+ - npm test
69
+ `;
70
+ const diags = wgl050.check(makeCtx(yaml));
71
+ expect(diags.map((d) => d.entity)).toEqual(["test:unit"]);
72
+ });
73
+ });
@@ -10,9 +10,7 @@
10
10
  */
11
11
 
12
12
  import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
13
- import { getPrimaryOutput, isMergeRequestReachable } from "./yaml-helpers";
14
-
15
- const RESERVED_TOP_LEVEL_KEYS = new Set(["stages", "default", "workflow", "variables", "include", "cache"]);
13
+ import { getPrimaryOutput, isMergeRequestReachable, extractJobs, extractJobSection } from "./yaml-helpers";
16
14
 
17
15
  export const wgl050: PostSynthCheck = {
18
16
  id: "WGL050",
@@ -24,11 +22,10 @@ export const wgl050: PostSynthCheck = {
24
22
  for (const [, output] of ctx.outputs) {
25
23
  const yaml = getPrimaryOutput(output);
26
24
 
27
- for (const section of yaml.split("\n\n")) {
28
- const top = section.split("\n")[0]?.match(/^(\.?[a-z][a-z0-9_.-]*):/i);
29
- if (!top) continue;
30
- const jobName = top[1];
31
- if (jobName.startsWith(".") || RESERVED_TOP_LEVEL_KEYS.has(jobName)) continue;
25
+ for (const [jobName] of extractJobs(yaml)) {
26
+ if (jobName.startsWith(".")) continue;
27
+ const section = extractJobSection(yaml, jobName);
28
+ if (!section) continue;
32
29
  if (/deploy/i.test(jobName)) continue; // cancelling mid-deploy is a hazard, not a win
33
30
 
34
31
  if (!isMergeRequestReachable(section)) continue;
@@ -0,0 +1,249 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import {
3
+ extractJobs,
4
+ extractStages,
5
+ extractGlobalVariables,
6
+ extractJobSection,
7
+ extractScriptCommands,
8
+ topLevelSections,
9
+ topLevelKey,
10
+ } from "./yaml-helpers";
11
+
12
+ // The reproduction from #3256.
13
+ const ISSUE_3256 = `stages:
14
+ - build
15
+ - deploy
16
+
17
+ build:
18
+ stage: build
19
+ script:
20
+ - make
21
+
22
+ Build_Docs:
23
+ stage: build
24
+ script:
25
+ - make docs
26
+
27
+ deploy:
28
+ stage: deploy
29
+ needs:
30
+ - job: build
31
+ artifacts: true
32
+ - job: Build_Docs
33
+ script:
34
+ - ./deploy.sh
35
+ `;
36
+
37
+ describe("extractJobs", () => {
38
+ test("reads a job id that starts with a capital and needs after an artifacts: line (#3256)", () => {
39
+ const jobs = extractJobs(ISSUE_3256);
40
+ expect([...jobs.keys()]).toEqual(["build", "Build_Docs", "deploy"]);
41
+ expect(jobs.get("Build_Docs")?.stage).toBe("build");
42
+ expect(jobs.get("deploy")?.needs).toEqual(["build", "Build_Docs"]);
43
+ });
44
+
45
+ test("skips global keywords, including the deprecated global forms", () => {
46
+ const yaml = `stages: [build]
47
+ variables:
48
+ FOO: bar
49
+ default:
50
+ image: node:20
51
+ include:
52
+ - local: ci/other.yml
53
+ workflow:
54
+ name: main
55
+ image: node:20
56
+ services:
57
+ - docker:dind
58
+ cache:
59
+ paths: [node_modules]
60
+ before_script:
61
+ - echo hi
62
+ after_script:
63
+ - echo bye
64
+ build:
65
+ stage: build
66
+ script: make
67
+ `;
68
+ expect([...extractJobs(yaml).keys()]).toEqual(["build"]);
69
+ });
70
+
71
+ test("keeps hidden jobs and reads extends: as a string or a list", () => {
72
+ const yaml = `.base:
73
+ image: node:20
74
+
75
+ .lint-base:
76
+ stage: test
77
+
78
+ lint:
79
+ extends: .base
80
+ script: npm run lint
81
+
82
+ test:
83
+ extends:
84
+ - .base
85
+ - .lint-base
86
+ script: npm test
87
+ `;
88
+ const jobs = extractJobs(yaml);
89
+ expect(jobs.has(".base")).toBe(true);
90
+ expect(jobs.get("lint")?.extends).toEqual([".base"]);
91
+ expect(jobs.get("test")?.extends).toEqual([".base", ".lint-base"]);
92
+ });
93
+
94
+ test("reads every needs: form", () => {
95
+ const yaml = `a:
96
+ script: x
97
+ b:
98
+ script: x
99
+ c:
100
+ script: x
101
+ plain:
102
+ needs: [a, 'b']
103
+ script: x
104
+ maps:
105
+ needs:
106
+ - job: a
107
+ artifacts: true
108
+ - job: b
109
+ optional: true
110
+ - job: c
111
+ parallel:
112
+ matrix:
113
+ - PROVIDER: aws
114
+ script: x
115
+ cross:
116
+ needs:
117
+ - pipeline: $PARENT_PIPELINE_ID
118
+ job: generate
119
+ - project: group/other
120
+ job: build
121
+ ref: main
122
+ artifacts: true
123
+ - a
124
+ script: x
125
+ none:
126
+ needs: []
127
+ script: x
128
+ scalar:
129
+ needs: a
130
+ script: x
131
+ `;
132
+ const jobs = extractJobs(yaml);
133
+ expect(jobs.get("plain")?.needs).toEqual(["a", "b"]);
134
+ expect(jobs.get("maps")?.needs).toEqual(["a", "b", "c"]);
135
+ expect(jobs.get("maps")?.optionalNeeds).toEqual(["b"]);
136
+ expect(jobs.get("cross")?.needs).toEqual(["a"]);
137
+ expect(jobs.get("none")?.needs).toEqual([]);
138
+ expect(jobs.get("scalar")?.needs).toEqual(["a"]);
139
+ expect(jobs.get("a")?.needs).toBeUndefined();
140
+ });
141
+
142
+ test("reads job ids with colons, spaces and quotes", () => {
143
+ const yaml = `build:linux:
144
+ stage: build
145
+ script: make
146
+ "deploy prod":
147
+ needs: ["build:linux"]
148
+ script: ./deploy
149
+ 'it''s':
150
+ script: x
151
+ `;
152
+ const jobs = extractJobs(yaml);
153
+ expect([...jobs.keys()]).toEqual(["build:linux", "deploy prod", "it's"]);
154
+ expect(jobs.get("deploy prod")?.needs).toEqual(["build:linux"]);
155
+ });
156
+
157
+ test("does not depend on blank lines between jobs", () => {
158
+ const yaml = `build:
159
+ stage: build
160
+ script: make
161
+ test:
162
+ stage: test
163
+ needs: [build]
164
+ script: make test
165
+ `;
166
+ const jobs = extractJobs(yaml);
167
+ expect(jobs.get("test")?.needs).toEqual(["build"]);
168
+ expect(jobs.get("test")?.stage).toBe("test");
169
+ });
170
+
171
+ test("unparseable YAML gives no jobs", () => {
172
+ expect(extractJobs("build:\n script: [unclosed\n").size).toBe(0);
173
+ });
174
+ });
175
+
176
+ describe("extractStages", () => {
177
+ test("reads block and flow lists", () => {
178
+ expect(extractStages("stages:\n - build\n - 'test'\n")).toEqual(["build", "test"]);
179
+ expect(extractStages("stages: [build, deploy]\n")).toEqual(["build", "deploy"]);
180
+ expect(extractStages("build:\n script: x\n")).toEqual([]);
181
+ });
182
+ });
183
+
184
+ describe("extractGlobalVariables", () => {
185
+ test("reads the expanded value:/description: form without treating its keys as variables", () => {
186
+ const vars = extractGlobalVariables(`variables:
187
+ PLAIN: one
188
+ DEPLOY_ENV:
189
+ value: staging
190
+ description: Target environment
191
+ `);
192
+ expect([...vars.entries()]).toEqual([
193
+ ["PLAIN", "one"],
194
+ ["DEPLOY_ENV", "staging"],
195
+ ]);
196
+ });
197
+ });
198
+
199
+ describe("topLevelSections / extractJobSection", () => {
200
+ test("topLevelKey unquotes and keeps colons inside the key", () => {
201
+ expect(topLevelKey("build:linux:")).toBe("build:linux");
202
+ expect(topLevelKey('"deploy prod":')).toBe("deploy prod");
203
+ expect(topLevelKey("Build_Docs:")).toBe("Build_Docs");
204
+ expect(topLevelKey(" script:")).toBeUndefined();
205
+ expect(topLevelKey("# build:")).toBeUndefined();
206
+ expect(topLevelKey("---")).toBeUndefined();
207
+ });
208
+
209
+ test("cuts at top-level keys, not blank lines", () => {
210
+ const yaml = `build:
211
+ script:
212
+ - make
213
+
214
+ - make install
215
+ test:
216
+ script: make test
217
+ `;
218
+ expect(topLevelSections(yaml).map((s) => s.key)).toEqual(["build", "test"]);
219
+ expect(extractJobSection(yaml, "build")).toContain("make install");
220
+ expect(extractJobSection(yaml, "build")).not.toContain("make test");
221
+ });
222
+
223
+ test("matches the exact key", () => {
224
+ const yaml = `build:linux:
225
+ script: linux
226
+
227
+ build:
228
+ script: plain
229
+ `;
230
+ expect(extractJobSection(yaml, "build")).toBe("build:\n script: plain");
231
+ expect(extractJobSection(yaml, "Build_Docs")).toBeNull();
232
+ });
233
+
234
+ test("a job preceded by a comment is still found", () => {
235
+ const yaml = `build:
236
+ script: make
237
+
238
+ # the docs job
239
+ Build_Docs:
240
+ script:
241
+ - make docs
242
+ `;
243
+ expect(extractJobSection(yaml, "build")).toBe("build:\n script: make");
244
+ expect(extractScriptCommands(yaml)).toEqual([
245
+ { job: "build", command: "make" },
246
+ { job: "Build_Docs", command: "make docs" },
247
+ ]);
248
+ });
249
+ });