@intentius/chant-lexicon-gitlab 0.0.15 → 0.0.18
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 +18 -4
- package/dist/manifest.json +1 -1
- package/dist/rules/wgl016.ts +82 -0
- package/dist/rules/wgl017.ts +54 -0
- package/dist/rules/wgl018.ts +39 -0
- package/dist/rules/wgl019.ts +44 -0
- package/dist/rules/wgl020.ts +56 -0
- package/dist/rules/wgl021.ts +62 -0
- package/dist/rules/wgl022.ts +44 -0
- package/dist/rules/wgl023.ts +51 -0
- package/dist/rules/wgl024.ts +46 -0
- package/dist/rules/wgl025.ts +49 -0
- package/dist/rules/wgl026.ts +67 -0
- package/dist/rules/wgl027.ts +54 -0
- package/dist/rules/wgl028.ts +67 -0
- package/dist/rules/yaml-helpers.ts +82 -0
- package/dist/skills/chant-gitlab.md +1 -1
- package/dist/skills/gitlab-ci-patterns.md +309 -0
- package/package.json +3 -3
- package/src/codegen/fetch.test.ts +30 -0
- package/src/codegen/generate.test.ts +65 -0
- package/src/codegen/idempotency.test.ts +28 -0
- package/src/codegen/naming.test.ts +93 -0
- package/src/codegen/snapshot.test.ts +28 -19
- package/src/composites/composites.test.ts +160 -0
- package/src/coverage.test.ts +15 -7
- package/src/import/roundtrip.test.ts +132 -0
- package/src/lint/post-synth/wgl016.test.ts +72 -0
- package/src/lint/post-synth/wgl016.ts +82 -0
- package/src/lint/post-synth/wgl017.test.ts +53 -0
- package/src/lint/post-synth/wgl017.ts +54 -0
- package/src/lint/post-synth/wgl018.test.ts +69 -0
- package/src/lint/post-synth/wgl018.ts +39 -0
- package/src/lint/post-synth/wgl019.test.ts +76 -0
- package/src/lint/post-synth/wgl019.ts +44 -0
- package/src/lint/post-synth/wgl020.test.ts +54 -0
- package/src/lint/post-synth/wgl020.ts +56 -0
- package/src/lint/post-synth/wgl021.test.ts +62 -0
- package/src/lint/post-synth/wgl021.ts +62 -0
- package/src/lint/post-synth/wgl022.test.ts +86 -0
- package/src/lint/post-synth/wgl022.ts +44 -0
- package/src/lint/post-synth/wgl023.test.ts +88 -0
- package/src/lint/post-synth/wgl023.ts +51 -0
- package/src/lint/post-synth/wgl024.test.ts +77 -0
- package/src/lint/post-synth/wgl024.ts +46 -0
- package/src/lint/post-synth/wgl025.test.ts +85 -0
- package/src/lint/post-synth/wgl025.ts +49 -0
- package/src/lint/post-synth/wgl026.test.ts +87 -0
- package/src/lint/post-synth/wgl026.ts +67 -0
- package/src/lint/post-synth/wgl027.test.ts +84 -0
- package/src/lint/post-synth/wgl027.ts +54 -0
- package/src/lint/post-synth/wgl028.test.ts +95 -0
- package/src/lint/post-synth/wgl028.ts +67 -0
- package/src/lint/post-synth/yaml-helpers.ts +82 -0
- package/src/lsp/completions.test.ts +16 -6
- package/src/lsp/hover.test.ts +18 -7
- package/src/plugin.test.ts +15 -2
- package/src/plugin.ts +66 -3
- package/src/skills/gitlab-ci-patterns.md +309 -0
- package/src/testdata/pipelines/deploy-envs.gitlab-ci.yml +60 -0
- package/src/testdata/pipelines/docker-build.gitlab-ci.yml +41 -0
- package/src/testdata/pipelines/includes-templates.gitlab-ci.yml +52 -0
- package/src/testdata/pipelines/monorepo.gitlab-ci.yml +51 -0
- package/src/testdata/pipelines/multi-stage.gitlab-ci.yml +56 -0
- package/src/testdata/pipelines/simple.gitlab-ci.yml +9 -0
- package/src/validate.test.ts +12 -6
- package/src/variables.test.ts +58 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { DECLARABLE_MARKER, type Declarable } from "@intentius/chant/declarable";
|
|
3
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
4
|
+
import { wgl027 } from "./wgl027";
|
|
5
|
+
|
|
6
|
+
class MockJob implements Declarable {
|
|
7
|
+
readonly [DECLARABLE_MARKER] = true as const;
|
|
8
|
+
readonly lexicon = "gitlab";
|
|
9
|
+
readonly entityType = "GitLab::CI::Job";
|
|
10
|
+
readonly kind = "resource" as const;
|
|
11
|
+
readonly props: Record<string, unknown>;
|
|
12
|
+
|
|
13
|
+
constructor(props: Record<string, unknown> = {}) {
|
|
14
|
+
this.props = props;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function makeCtx(entities: Map<string, Declarable>): PostSynthContext {
|
|
19
|
+
return {
|
|
20
|
+
outputs: new Map(),
|
|
21
|
+
entities,
|
|
22
|
+
buildResult: {
|
|
23
|
+
outputs: new Map(),
|
|
24
|
+
entities,
|
|
25
|
+
warnings: [],
|
|
26
|
+
errors: [],
|
|
27
|
+
sourceFileCount: 1,
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("WGL027: Empty Script", () => {
|
|
33
|
+
test("check metadata", () => {
|
|
34
|
+
expect(wgl027.id).toBe("WGL027");
|
|
35
|
+
expect(wgl027.description).toContain("Empty script");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("flags empty script array", () => {
|
|
39
|
+
const entities = new Map<string, Declarable>([
|
|
40
|
+
["emptyJob", new MockJob({ script: [] })],
|
|
41
|
+
]);
|
|
42
|
+
const diags = wgl027.check(makeCtx(entities));
|
|
43
|
+
expect(diags).toHaveLength(1);
|
|
44
|
+
expect(diags[0].severity).toBe("error");
|
|
45
|
+
expect(diags[0].message).toContain("emptyJob");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("flags script with only empty strings", () => {
|
|
49
|
+
const entities = new Map<string, Declarable>([
|
|
50
|
+
["blankJob", new MockJob({ script: ["", " "] })],
|
|
51
|
+
]);
|
|
52
|
+
const diags = wgl027.check(makeCtx(entities));
|
|
53
|
+
expect(diags).toHaveLength(1);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("flags empty string script", () => {
|
|
57
|
+
const entities = new Map<string, Declarable>([
|
|
58
|
+
["strJob", new MockJob({ script: "" })],
|
|
59
|
+
]);
|
|
60
|
+
const diags = wgl027.check(makeCtx(entities));
|
|
61
|
+
expect(diags).toHaveLength(1);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("does not flag valid script", () => {
|
|
65
|
+
const entities = new Map<string, Declarable>([
|
|
66
|
+
["validJob", new MockJob({ script: ["npm test"] })],
|
|
67
|
+
]);
|
|
68
|
+
const diags = wgl027.check(makeCtx(entities));
|
|
69
|
+
expect(diags).toHaveLength(0);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("does not flag job without script", () => {
|
|
73
|
+
const entities = new Map<string, Declarable>([
|
|
74
|
+
["triggerJob", new MockJob({ trigger: "other-project" })],
|
|
75
|
+
]);
|
|
76
|
+
const diags = wgl027.check(makeCtx(entities));
|
|
77
|
+
expect(diags).toHaveLength(0);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("no diagnostics on empty entities", () => {
|
|
81
|
+
const diags = wgl027.check(makeCtx(new Map()));
|
|
82
|
+
expect(diags).toHaveLength(0);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WGL027: Empty Script
|
|
3
|
+
*
|
|
4
|
+
* Detects jobs with `script: []` or scripts containing only empty strings.
|
|
5
|
+
* GitLab rejects jobs with empty scripts at pipeline validation time.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
9
|
+
import { isPropertyDeclarable } from "@intentius/chant/declarable";
|
|
10
|
+
|
|
11
|
+
export const wgl027: PostSynthCheck = {
|
|
12
|
+
id: "WGL027",
|
|
13
|
+
description: "Empty script — jobs with empty or blank script entries",
|
|
14
|
+
|
|
15
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
16
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
17
|
+
|
|
18
|
+
for (const [entityName, entity] of ctx.entities) {
|
|
19
|
+
if (isPropertyDeclarable(entity)) continue;
|
|
20
|
+
const entityType = (entity as Record<string, unknown>).entityType as string;
|
|
21
|
+
if (entityType !== "GitLab::CI::Job") continue;
|
|
22
|
+
|
|
23
|
+
const props = (entity as Record<string, unknown>).props as Record<string, unknown> | undefined;
|
|
24
|
+
if (!props) continue;
|
|
25
|
+
|
|
26
|
+
const script = props.script;
|
|
27
|
+
if (script === undefined || script === null) continue;
|
|
28
|
+
|
|
29
|
+
let isEmpty = false;
|
|
30
|
+
|
|
31
|
+
if (Array.isArray(script)) {
|
|
32
|
+
if (script.length === 0) {
|
|
33
|
+
isEmpty = true;
|
|
34
|
+
} else if (script.every((s) => typeof s === "string" && s.trim() === "")) {
|
|
35
|
+
isEmpty = true;
|
|
36
|
+
}
|
|
37
|
+
} else if (typeof script === "string" && script.trim() === "") {
|
|
38
|
+
isEmpty = true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (isEmpty) {
|
|
42
|
+
diagnostics.push({
|
|
43
|
+
checkId: "WGL027",
|
|
44
|
+
severity: "error",
|
|
45
|
+
message: `Job "${entityName}" has an empty script — GitLab will reject this pipeline`,
|
|
46
|
+
entity: entityName,
|
|
47
|
+
lexicon: "gitlab",
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return diagnostics;
|
|
53
|
+
},
|
|
54
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { wgl028, checkRedundantNeeds } from "./wgl028";
|
|
3
|
+
|
|
4
|
+
describe("WGL028: Redundant Needs", () => {
|
|
5
|
+
test("check metadata", () => {
|
|
6
|
+
expect(wgl028.id).toBe("WGL028");
|
|
7
|
+
expect(wgl028.description).toContain("Redundant");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("flags needs pointing to earlier stage job", () => {
|
|
11
|
+
const yaml = `stages:
|
|
12
|
+
- build
|
|
13
|
+
- test
|
|
14
|
+
- deploy
|
|
15
|
+
|
|
16
|
+
build-app:
|
|
17
|
+
stage: build
|
|
18
|
+
script:
|
|
19
|
+
- npm build
|
|
20
|
+
|
|
21
|
+
deploy-app:
|
|
22
|
+
stage: deploy
|
|
23
|
+
needs:
|
|
24
|
+
- build-app
|
|
25
|
+
script:
|
|
26
|
+
- deploy.sh
|
|
27
|
+
`;
|
|
28
|
+
const diags = checkRedundantNeeds(yaml);
|
|
29
|
+
expect(diags).toHaveLength(1);
|
|
30
|
+
expect(diags[0].severity).toBe("info");
|
|
31
|
+
expect(diags[0].message).toContain("deploy-app");
|
|
32
|
+
expect(diags[0].message).toContain("build-app");
|
|
33
|
+
expect(diags[0].message).toContain("earlier stage");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("does not flag needs within same stage", () => {
|
|
37
|
+
const yaml = `stages:
|
|
38
|
+
- build
|
|
39
|
+
- test
|
|
40
|
+
|
|
41
|
+
test-a:
|
|
42
|
+
stage: test
|
|
43
|
+
script:
|
|
44
|
+
- test-a
|
|
45
|
+
|
|
46
|
+
test-b:
|
|
47
|
+
stage: test
|
|
48
|
+
needs:
|
|
49
|
+
- test-a
|
|
50
|
+
script:
|
|
51
|
+
- test-b
|
|
52
|
+
`;
|
|
53
|
+
const diags = checkRedundantNeeds(yaml);
|
|
54
|
+
expect(diags).toHaveLength(0);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("does not flag when no stages defined", () => {
|
|
58
|
+
const yaml = `build-app:
|
|
59
|
+
script:
|
|
60
|
+
- npm build
|
|
61
|
+
|
|
62
|
+
deploy-app:
|
|
63
|
+
needs:
|
|
64
|
+
- build-app
|
|
65
|
+
script:
|
|
66
|
+
- deploy.sh
|
|
67
|
+
`;
|
|
68
|
+
const diags = checkRedundantNeeds(yaml);
|
|
69
|
+
expect(diags).toHaveLength(0);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("does not flag when no needs defined", () => {
|
|
73
|
+
const yaml = `stages:
|
|
74
|
+
- build
|
|
75
|
+
- test
|
|
76
|
+
|
|
77
|
+
build-app:
|
|
78
|
+
stage: build
|
|
79
|
+
script:
|
|
80
|
+
- npm build
|
|
81
|
+
|
|
82
|
+
test-app:
|
|
83
|
+
stage: test
|
|
84
|
+
script:
|
|
85
|
+
- npm test
|
|
86
|
+
`;
|
|
87
|
+
const diags = checkRedundantNeeds(yaml);
|
|
88
|
+
expect(diags).toHaveLength(0);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("no diagnostics on empty yaml", () => {
|
|
92
|
+
const diags = checkRedundantNeeds("");
|
|
93
|
+
expect(diags).toHaveLength(0);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WGL028: Redundant Needs
|
|
3
|
+
*
|
|
4
|
+
* Detects `needs:` entries that list jobs already implied by stage ordering.
|
|
5
|
+
* While not incorrect, redundant needs add noise and make the pipeline
|
|
6
|
+
* harder to maintain. This is informational only.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
10
|
+
import { getPrimaryOutput, extractStages, extractJobs } from "./yaml-helpers";
|
|
11
|
+
|
|
12
|
+
export function checkRedundantNeeds(yaml: string): PostSynthDiagnostic[] {
|
|
13
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
14
|
+
|
|
15
|
+
const stages = extractStages(yaml);
|
|
16
|
+
if (stages.length === 0) return diagnostics;
|
|
17
|
+
|
|
18
|
+
const stageIndex = new Map<string, number>();
|
|
19
|
+
for (let i = 0; i < stages.length; i++) {
|
|
20
|
+
stageIndex.set(stages[i], i);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const jobs = extractJobs(yaml);
|
|
24
|
+
|
|
25
|
+
for (const [jobName, job] of jobs) {
|
|
26
|
+
if (!job.needs || !job.stage) continue;
|
|
27
|
+
|
|
28
|
+
const jobStageIdx = stageIndex.get(job.stage);
|
|
29
|
+
if (jobStageIdx === undefined) continue;
|
|
30
|
+
|
|
31
|
+
for (const need of job.needs) {
|
|
32
|
+
const neededJob = jobs.get(need);
|
|
33
|
+
if (!neededJob?.stage) continue;
|
|
34
|
+
|
|
35
|
+
const needStageIdx = stageIndex.get(neededJob.stage);
|
|
36
|
+
if (needStageIdx === undefined) continue;
|
|
37
|
+
|
|
38
|
+
// If the needed job is in an earlier stage, it's already implied
|
|
39
|
+
// by GitLab's default stage-based ordering
|
|
40
|
+
if (needStageIdx < jobStageIdx) {
|
|
41
|
+
diagnostics.push({
|
|
42
|
+
checkId: "WGL028",
|
|
43
|
+
severity: "info",
|
|
44
|
+
message: `Job "${jobName}" lists "${need}" in needs: but it's already in an earlier stage (${neededJob.stage} → ${job.stage})`,
|
|
45
|
+
entity: jobName,
|
|
46
|
+
lexicon: "gitlab",
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return diagnostics;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const wgl028: PostSynthCheck = {
|
|
56
|
+
id: "WGL028",
|
|
57
|
+
description: "Redundant needs — needs listing jobs already implied by stage ordering",
|
|
58
|
+
|
|
59
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
60
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
61
|
+
for (const [, output] of ctx.outputs) {
|
|
62
|
+
const yaml = getPrimaryOutput(output);
|
|
63
|
+
diagnostics.push(...checkRedundantNeeds(yaml));
|
|
64
|
+
}
|
|
65
|
+
return diagnostics;
|
|
66
|
+
},
|
|
67
|
+
};
|
|
@@ -141,3 +141,85 @@ export function extractJobs(yaml: string): Map<string, ParsedJob> {
|
|
|
141
141
|
export function hasInclude(yaml: string): boolean {
|
|
142
142
|
return /^include:/m.test(yaml);
|
|
143
143
|
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Extract global variables from serialized YAML.
|
|
147
|
+
*/
|
|
148
|
+
export function extractGlobalVariables(yaml: string): Map<string, string> {
|
|
149
|
+
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
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return vars;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Extract the full section text for a given job name.
|
|
164
|
+
*/
|
|
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;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Extract rules from a job section.
|
|
178
|
+
*/
|
|
179
|
+
export function extractJobRules(section: string): ParsedRule[] {
|
|
180
|
+
const rules: ParsedRule[] = [];
|
|
181
|
+
const lines = section.split("\n");
|
|
182
|
+
|
|
183
|
+
let inRules = false;
|
|
184
|
+
let currentRule: ParsedRule = {};
|
|
185
|
+
|
|
186
|
+
for (const line of lines) {
|
|
187
|
+
if (line.match(/^\s+rules:$/)) {
|
|
188
|
+
inRules = true;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (inRules) {
|
|
193
|
+
const ruleStart = line.match(/^\s+- (if|when|changes):\s*(.*)$/);
|
|
194
|
+
if (ruleStart) {
|
|
195
|
+
if (Object.keys(currentRule).length > 0) {
|
|
196
|
+
rules.push(currentRule);
|
|
197
|
+
}
|
|
198
|
+
currentRule = {};
|
|
199
|
+
if (ruleStart[1] === "if") currentRule.if = ruleStart[2].trim();
|
|
200
|
+
if (ruleStart[1] === "when") currentRule.when = ruleStart[2].trim();
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const whenMatch = line.match(/^\s+when:\s+(.+)$/);
|
|
205
|
+
if (whenMatch) {
|
|
206
|
+
currentRule.when = whenMatch[1].trim();
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// End of rules block
|
|
211
|
+
if (!line.match(/^\s+\s/) || line.match(/^\s+[a-z_]+:/) && !line.match(/^\s+when:/)) {
|
|
212
|
+
if (Object.keys(currentRule).length > 0) {
|
|
213
|
+
rules.push(currentRule);
|
|
214
|
+
}
|
|
215
|
+
inRules = false;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (inRules && Object.keys(currentRule).length > 0) {
|
|
221
|
+
rules.push(currentRule);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return rules;
|
|
225
|
+
}
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { describe, test, expect } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import { existsSync } from "fs";
|
|
3
|
+
import { join, dirname } from "path";
|
|
4
|
+
import { fileURLToPath } from "url";
|
|
3
5
|
import type { CompletionContext } from "@intentius/chant/lsp/types";
|
|
4
6
|
|
|
7
|
+
const generatedDir = join(dirname(dirname(fileURLToPath(import.meta.url))), "generated");
|
|
8
|
+
const hasGenerated = existsSync(join(generatedDir, "lexicon-gitlab.json"));
|
|
9
|
+
|
|
5
10
|
function makeCtx(overrides: Partial<CompletionContext>): CompletionContext {
|
|
6
11
|
return {
|
|
7
12
|
uri: "file:///test.ts",
|
|
@@ -14,7 +19,8 @@ function makeCtx(overrides: Partial<CompletionContext>): CompletionContext {
|
|
|
14
19
|
}
|
|
15
20
|
|
|
16
21
|
describe("gitlabCompletions", () => {
|
|
17
|
-
test("returns resource completions for 'new ' prefix", () => {
|
|
22
|
+
test.skipIf(!hasGenerated)("returns resource completions for 'new ' prefix", async () => {
|
|
23
|
+
const { gitlabCompletions } = await import("./completions");
|
|
18
24
|
const ctx = makeCtx({
|
|
19
25
|
linePrefix: "const j = new Job",
|
|
20
26
|
wordAtCursor: "Job",
|
|
@@ -29,7 +35,8 @@ describe("gitlabCompletions", () => {
|
|
|
29
35
|
expect(job!.kind).toBe("resource");
|
|
30
36
|
});
|
|
31
37
|
|
|
32
|
-
test("returns all resource completions when no filter", () => {
|
|
38
|
+
test.skipIf(!hasGenerated)("returns all resource completions when no filter", async () => {
|
|
39
|
+
const { gitlabCompletions } = await import("./completions");
|
|
33
40
|
const ctx = makeCtx({
|
|
34
41
|
linePrefix: "const x = new ",
|
|
35
42
|
wordAtCursor: "",
|
|
@@ -44,7 +51,8 @@ describe("gitlabCompletions", () => {
|
|
|
44
51
|
expect(labels).toContain("Workflow");
|
|
45
52
|
});
|
|
46
53
|
|
|
47
|
-
test("filters completions by prefix", () => {
|
|
54
|
+
test.skipIf(!hasGenerated)("filters completions by prefix", async () => {
|
|
55
|
+
const { gitlabCompletions } = await import("./completions");
|
|
48
56
|
const ctx = makeCtx({
|
|
49
57
|
linePrefix: "const x = new D",
|
|
50
58
|
wordAtCursor: "D",
|
|
@@ -58,7 +66,8 @@ describe("gitlabCompletions", () => {
|
|
|
58
66
|
expect(labels).not.toContain("Job");
|
|
59
67
|
});
|
|
60
68
|
|
|
61
|
-
test("returns empty for non-constructor context", () => {
|
|
69
|
+
test.skipIf(!hasGenerated)("returns empty for non-constructor context", async () => {
|
|
70
|
+
const { gitlabCompletions } = await import("./completions");
|
|
62
71
|
const ctx = makeCtx({
|
|
63
72
|
linePrefix: "const x = foo(",
|
|
64
73
|
wordAtCursor: "",
|
|
@@ -70,7 +79,8 @@ describe("gitlabCompletions", () => {
|
|
|
70
79
|
expect(items).toHaveLength(0);
|
|
71
80
|
});
|
|
72
81
|
|
|
73
|
-
test("completion items have detail with resource type", () => {
|
|
82
|
+
test.skipIf(!hasGenerated)("completion items have detail with resource type", async () => {
|
|
83
|
+
const { gitlabCompletions } = await import("./completions");
|
|
74
84
|
const ctx = makeCtx({
|
|
75
85
|
linePrefix: "const j = new Job",
|
|
76
86
|
wordAtCursor: "Job",
|
package/src/lsp/hover.test.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { describe, test, expect } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import { existsSync } from "fs";
|
|
3
|
+
import { join, dirname } from "path";
|
|
4
|
+
import { fileURLToPath } from "url";
|
|
3
5
|
import type { HoverContext } from "@intentius/chant/lsp/types";
|
|
4
6
|
|
|
7
|
+
const generatedDir = join(dirname(dirname(fileURLToPath(import.meta.url))), "generated");
|
|
8
|
+
const hasGenerated = existsSync(join(generatedDir, "lexicon-gitlab.json"));
|
|
9
|
+
|
|
5
10
|
function makeCtx(overrides: Partial<HoverContext>): HoverContext {
|
|
6
11
|
return {
|
|
7
12
|
uri: "file:///test.ts",
|
|
@@ -14,7 +19,8 @@ function makeCtx(overrides: Partial<HoverContext>): HoverContext {
|
|
|
14
19
|
}
|
|
15
20
|
|
|
16
21
|
describe("gitlabHover", () => {
|
|
17
|
-
test("returns hover for Job class", () => {
|
|
22
|
+
test.skipIf(!hasGenerated)("returns hover for Job class", async () => {
|
|
23
|
+
const { gitlabHover } = await import("./hover");
|
|
18
24
|
const ctx = makeCtx({ word: "Job" });
|
|
19
25
|
const hover = gitlabHover(ctx);
|
|
20
26
|
expect(hover).toBeDefined();
|
|
@@ -23,7 +29,8 @@ describe("gitlabHover", () => {
|
|
|
23
29
|
expect(hover!.contents).toContain("Resource entity");
|
|
24
30
|
});
|
|
25
31
|
|
|
26
|
-
test("returns hover for property entity", () => {
|
|
32
|
+
test.skipIf(!hasGenerated)("returns hover for property entity", async () => {
|
|
33
|
+
const { gitlabHover } = await import("./hover");
|
|
27
34
|
const ctx = makeCtx({ word: "Cache" });
|
|
28
35
|
const hover = gitlabHover(ctx);
|
|
29
36
|
expect(hover).toBeDefined();
|
|
@@ -32,27 +39,31 @@ describe("gitlabHover", () => {
|
|
|
32
39
|
expect(hover!.contents).toContain("Property entity");
|
|
33
40
|
});
|
|
34
41
|
|
|
35
|
-
test("returns hover for Default", () => {
|
|
42
|
+
test.skipIf(!hasGenerated)("returns hover for Default", async () => {
|
|
43
|
+
const { gitlabHover } = await import("./hover");
|
|
36
44
|
const ctx = makeCtx({ word: "Default" });
|
|
37
45
|
const hover = gitlabHover(ctx);
|
|
38
46
|
expect(hover).toBeDefined();
|
|
39
47
|
expect(hover!.contents).toContain("GitLab::CI::Default");
|
|
40
48
|
});
|
|
41
49
|
|
|
42
|
-
test("returns hover for Artifacts", () => {
|
|
50
|
+
test.skipIf(!hasGenerated)("returns hover for Artifacts", async () => {
|
|
51
|
+
const { gitlabHover } = await import("./hover");
|
|
43
52
|
const ctx = makeCtx({ word: "Artifacts" });
|
|
44
53
|
const hover = gitlabHover(ctx);
|
|
45
54
|
expect(hover).toBeDefined();
|
|
46
55
|
expect(hover!.contents).toContain("GitLab::CI::Artifacts");
|
|
47
56
|
});
|
|
48
57
|
|
|
49
|
-
test("returns undefined for unknown word", () => {
|
|
58
|
+
test.skipIf(!hasGenerated)("returns undefined for unknown word", async () => {
|
|
59
|
+
const { gitlabHover } = await import("./hover");
|
|
50
60
|
const ctx = makeCtx({ word: "UnknownEntity" });
|
|
51
61
|
const hover = gitlabHover(ctx);
|
|
52
62
|
expect(hover).toBeUndefined();
|
|
53
63
|
});
|
|
54
64
|
|
|
55
|
-
test("returns undefined for empty word", () => {
|
|
65
|
+
test.skipIf(!hasGenerated)("returns undefined for empty word", async () => {
|
|
66
|
+
const { gitlabHover } = await import("./hover");
|
|
56
67
|
const ctx = makeCtx({ word: "" });
|
|
57
68
|
const hover = gitlabHover(ctx);
|
|
58
69
|
expect(hover).toBeUndefined();
|
package/src/plugin.test.ts
CHANGED
|
@@ -83,7 +83,7 @@ describe("gitlabPlugin", () => {
|
|
|
83
83
|
|
|
84
84
|
test("returns post-synth checks", () => {
|
|
85
85
|
const checks = gitlabPlugin.postSynthChecks!();
|
|
86
|
-
expect(checks).toHaveLength(
|
|
86
|
+
expect(checks).toHaveLength(19);
|
|
87
87
|
const ids = checks.map((c) => c.id);
|
|
88
88
|
expect(ids).toContain("WGL010");
|
|
89
89
|
expect(ids).toContain("WGL011");
|
|
@@ -91,6 +91,19 @@ describe("gitlabPlugin", () => {
|
|
|
91
91
|
expect(ids).toContain("WGL013");
|
|
92
92
|
expect(ids).toContain("WGL014");
|
|
93
93
|
expect(ids).toContain("WGL015");
|
|
94
|
+
expect(ids).toContain("WGL016");
|
|
95
|
+
expect(ids).toContain("WGL017");
|
|
96
|
+
expect(ids).toContain("WGL018");
|
|
97
|
+
expect(ids).toContain("WGL019");
|
|
98
|
+
expect(ids).toContain("WGL020");
|
|
99
|
+
expect(ids).toContain("WGL021");
|
|
100
|
+
expect(ids).toContain("WGL022");
|
|
101
|
+
expect(ids).toContain("WGL023");
|
|
102
|
+
expect(ids).toContain("WGL024");
|
|
103
|
+
expect(ids).toContain("WGL025");
|
|
104
|
+
expect(ids).toContain("WGL026");
|
|
105
|
+
expect(ids).toContain("WGL027");
|
|
106
|
+
expect(ids).toContain("WGL028");
|
|
94
107
|
});
|
|
95
108
|
|
|
96
109
|
// -----------------------------------------------------------------------
|
|
@@ -182,7 +195,7 @@ describe("gitlabPlugin", () => {
|
|
|
182
195
|
|
|
183
196
|
test("returns skills", () => {
|
|
184
197
|
const skills = gitlabPlugin.skills!();
|
|
185
|
-
expect(skills).
|
|
198
|
+
expect(skills.length).toBeGreaterThanOrEqual(2);
|
|
186
199
|
expect(skills[0].name).toBe("chant-gitlab");
|
|
187
200
|
expect(skills[0].description).toBeDefined();
|
|
188
201
|
expect(skills[0].content).toContain("skill: chant-gitlab");
|
package/src/plugin.ts
CHANGED
|
@@ -31,7 +31,24 @@ export const gitlabPlugin: LexiconPlugin = {
|
|
|
31
31
|
const { wgl013 } = require("./lint/post-synth/wgl013");
|
|
32
32
|
const { wgl014 } = require("./lint/post-synth/wgl014");
|
|
33
33
|
const { wgl015 } = require("./lint/post-synth/wgl015");
|
|
34
|
-
|
|
34
|
+
const { wgl016 } = require("./lint/post-synth/wgl016");
|
|
35
|
+
const { wgl017 } = require("./lint/post-synth/wgl017");
|
|
36
|
+
const { wgl018 } = require("./lint/post-synth/wgl018");
|
|
37
|
+
const { wgl019 } = require("./lint/post-synth/wgl019");
|
|
38
|
+
const { wgl020 } = require("./lint/post-synth/wgl020");
|
|
39
|
+
const { wgl021 } = require("./lint/post-synth/wgl021");
|
|
40
|
+
const { wgl022 } = require("./lint/post-synth/wgl022");
|
|
41
|
+
const { wgl023 } = require("./lint/post-synth/wgl023");
|
|
42
|
+
const { wgl024 } = require("./lint/post-synth/wgl024");
|
|
43
|
+
const { wgl025 } = require("./lint/post-synth/wgl025");
|
|
44
|
+
const { wgl026 } = require("./lint/post-synth/wgl026");
|
|
45
|
+
const { wgl027 } = require("./lint/post-synth/wgl027");
|
|
46
|
+
const { wgl028 } = require("./lint/post-synth/wgl028");
|
|
47
|
+
return [
|
|
48
|
+
wgl010, wgl011, wgl012, wgl013, wgl014, wgl015,
|
|
49
|
+
wgl016, wgl017, wgl018, wgl019, wgl020, wgl021,
|
|
50
|
+
wgl022, wgl023, wgl024, wgl025, wgl026, wgl027, wgl028,
|
|
51
|
+
];
|
|
35
52
|
},
|
|
36
53
|
|
|
37
54
|
intrinsics(): IntrinsicDef[] {
|
|
@@ -342,7 +359,7 @@ export const deploy = new Job({
|
|
|
342
359
|
},
|
|
343
360
|
|
|
344
361
|
skills(): SkillDefinition[] {
|
|
345
|
-
|
|
362
|
+
const skills: SkillDefinition[] = [
|
|
346
363
|
{
|
|
347
364
|
name: "chant-gitlab",
|
|
348
365
|
description: "GitLab CI/CD pipeline lifecycle — build, validate, deploy, monitor, rollback, and troubleshoot",
|
|
@@ -495,7 +512,7 @@ Add \`"dry_run": true, "include_merged_yaml": true\` for full expansion with inc
|
|
|
495
512
|
| Step | Catches | When to run |
|
|
496
513
|
|------|---------|-------------|
|
|
497
514
|
| \`chant lint\` | Deprecated only/except (WGL001), missing script (WGL002), missing stage (WGL003), artifacts without expiry (WGL004) | Every edit |
|
|
498
|
-
| \`chant build\` | Post-synth checks: undefined stages (WGL010), unreachable jobs (WGL011), deprecated properties (WGL012), invalid needs targets (WGL013),
|
|
515
|
+
| \`chant build\` | Post-synth checks: undefined stages (WGL010), unreachable jobs (WGL011), deprecated properties (WGL012), invalid needs/extends targets (WGL013-014), circular needs (WGL015), secrets in variables (WGL016), insecure registry (WGL017), missing timeout/retry (WGL018-019), duplicate jobs (WGL020), unused variables (WGL021), missing artifacts expiry (WGL022), overly broad rules (WGL023), manual without allow_failure (WGL024), missing cache key (WGL025), DinD without TLS (WGL026), empty script (WGL027), redundant needs (WGL028) | Before push |
|
|
499
516
|
| CI Lint API | GitLab-specific validation: include resolution, variable expansion, YAML schema errors | Before merge to default branch |
|
|
500
517
|
|
|
501
518
|
Always run all three before deploying. Lint catches things the API cannot (and vice versa).
|
|
@@ -944,5 +961,51 @@ curl --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \\
|
|
|
944
961
|
],
|
|
945
962
|
},
|
|
946
963
|
];
|
|
964
|
+
|
|
965
|
+
// Load file-based skills from src/skills/
|
|
966
|
+
const { readFileSync } = require("fs");
|
|
967
|
+
const { join, dirname } = require("path");
|
|
968
|
+
const { fileURLToPath } = require("url");
|
|
969
|
+
const dir = dirname(fileURLToPath(import.meta.url));
|
|
970
|
+
|
|
971
|
+
const skillFiles = [
|
|
972
|
+
{
|
|
973
|
+
file: "gitlab-ci-patterns.md",
|
|
974
|
+
name: "gitlab-ci-patterns",
|
|
975
|
+
description: "GitLab CI/CD pipeline stages, caching, artifacts, includes, and advanced patterns",
|
|
976
|
+
triggers: [
|
|
977
|
+
{ type: "context" as const, value: "gitlab pipeline" },
|
|
978
|
+
{ type: "context" as const, value: "gitlab cache" },
|
|
979
|
+
{ type: "context" as const, value: "gitlab artifacts" },
|
|
980
|
+
{ type: "context" as const, value: "gitlab include" },
|
|
981
|
+
{ type: "context" as const, value: "gitlab stages" },
|
|
982
|
+
{ type: "context" as const, value: "review app" },
|
|
983
|
+
],
|
|
984
|
+
parameters: [],
|
|
985
|
+
examples: [
|
|
986
|
+
{
|
|
987
|
+
title: "Pipeline with caching",
|
|
988
|
+
input: "Set up a Node.js pipeline with proper caching",
|
|
989
|
+
output: "import { Job, Cache } from \"@intentius/chant-lexicon-gitlab\";\n\nconst cache = new Cache({ key: { files: [\"package-lock.json\"] }, paths: [\"node_modules/\"] });",
|
|
990
|
+
},
|
|
991
|
+
],
|
|
992
|
+
},
|
|
993
|
+
];
|
|
994
|
+
|
|
995
|
+
for (const skill of skillFiles) {
|
|
996
|
+
try {
|
|
997
|
+
const content = readFileSync(join(dir, "skills", skill.file), "utf-8");
|
|
998
|
+
skills.push({
|
|
999
|
+
name: skill.name,
|
|
1000
|
+
description: skill.description,
|
|
1001
|
+
content,
|
|
1002
|
+
triggers: skill.triggers,
|
|
1003
|
+
parameters: skill.parameters,
|
|
1004
|
+
examples: skill.examples,
|
|
1005
|
+
});
|
|
1006
|
+
} catch { /* skip missing skills */ }
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
return skills;
|
|
947
1010
|
},
|
|
948
1011
|
};
|