@intentius/chant-lexicon-gitlab 0.0.8 → 0.0.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/integrity.json +10 -6
- package/dist/manifest.json +1 -1
- package/dist/meta.json +186 -8
- package/dist/rules/wgl012.ts +86 -0
- package/dist/rules/wgl013.ts +62 -0
- package/dist/rules/wgl014.ts +51 -0
- package/dist/rules/wgl015.ts +85 -0
- package/dist/rules/yaml-helpers.ts +65 -3
- package/dist/skills/chant-gitlab.md +467 -24
- package/dist/types/index.d.ts +55 -16
- package/package.json +2 -2
- package/src/codegen/__snapshots__/snapshot.test.ts.snap +58 -0
- package/src/codegen/docs.ts +32 -9
- package/src/codegen/generate-lexicon.ts +6 -1
- package/src/codegen/generate.ts +45 -50
- package/src/codegen/naming.ts +3 -0
- package/src/codegen/parse.test.ts +154 -4
- package/src/codegen/parse.ts +161 -49
- package/src/codegen/snapshot.test.ts +7 -5
- package/src/composites/composites.test.ts +452 -0
- package/src/composites/docker-build.ts +81 -0
- package/src/composites/index.ts +8 -0
- package/src/composites/node-pipeline.ts +104 -0
- package/src/composites/python-pipeline.ts +75 -0
- package/src/composites/review-app.ts +63 -0
- package/src/generated/index.d.ts +55 -16
- package/src/generated/index.ts +3 -0
- package/src/generated/lexicon-gitlab.json +186 -8
- package/src/import/generator.ts +3 -2
- package/src/index.ts +4 -0
- package/src/lint/post-synth/wgl012.test.ts +131 -0
- package/src/lint/post-synth/wgl012.ts +86 -0
- package/src/lint/post-synth/wgl013.test.ts +164 -0
- package/src/lint/post-synth/wgl013.ts +62 -0
- package/src/lint/post-synth/wgl014.test.ts +97 -0
- package/src/lint/post-synth/wgl014.ts +51 -0
- package/src/lint/post-synth/wgl015.test.ts +139 -0
- package/src/lint/post-synth/wgl015.ts +85 -0
- package/src/lint/post-synth/yaml-helpers.ts +65 -3
- package/src/plugin.test.ts +39 -13
- package/src/plugin.ts +636 -40
- package/src/serializer.test.ts +140 -0
- package/src/serializer.ts +63 -5
- package/src/validate.ts +1 -0
- package/src/variables.ts +4 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
3
|
+
import { wgl015, checkCircularNeeds } from "./wgl015";
|
|
4
|
+
|
|
5
|
+
function makeCtx(yaml: string): PostSynthContext {
|
|
6
|
+
return {
|
|
7
|
+
outputs: new Map([["gitlab", yaml]]),
|
|
8
|
+
entities: new Map(),
|
|
9
|
+
buildResult: {
|
|
10
|
+
outputs: new Map([["gitlab", yaml]]),
|
|
11
|
+
entities: new Map(),
|
|
12
|
+
warnings: [],
|
|
13
|
+
errors: [],
|
|
14
|
+
sourceFileCount: 1,
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("WGL015: Circular needs: Chain", () => {
|
|
20
|
+
test("check metadata", () => {
|
|
21
|
+
expect(wgl015.id).toBe("WGL015");
|
|
22
|
+
expect(wgl015.description).toContain("Circular");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("A→B→A cycle → error", () => {
|
|
26
|
+
const yaml = `stages:
|
|
27
|
+
- build
|
|
28
|
+
|
|
29
|
+
job-a:
|
|
30
|
+
stage: build
|
|
31
|
+
needs:
|
|
32
|
+
- job-b
|
|
33
|
+
script:
|
|
34
|
+
- echo a
|
|
35
|
+
|
|
36
|
+
job-b:
|
|
37
|
+
stage: build
|
|
38
|
+
needs:
|
|
39
|
+
- job-a
|
|
40
|
+
script:
|
|
41
|
+
- echo b`;
|
|
42
|
+
const diags = checkCircularNeeds(makeCtx(yaml));
|
|
43
|
+
expect(diags).toHaveLength(1);
|
|
44
|
+
expect(diags[0].checkId).toBe("WGL015");
|
|
45
|
+
expect(diags[0].severity).toBe("error");
|
|
46
|
+
expect(diags[0].message).toContain("job-a");
|
|
47
|
+
expect(diags[0].message).toContain("job-b");
|
|
48
|
+
expect(diags[0].message).toContain("→");
|
|
49
|
+
expect(diags[0].lexicon).toBe("gitlab");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("A→B→C→A cycle → error", () => {
|
|
53
|
+
const yaml = `stages:
|
|
54
|
+
- build
|
|
55
|
+
|
|
56
|
+
job-a:
|
|
57
|
+
stage: build
|
|
58
|
+
needs:
|
|
59
|
+
- job-b
|
|
60
|
+
script:
|
|
61
|
+
- echo a
|
|
62
|
+
|
|
63
|
+
job-b:
|
|
64
|
+
stage: build
|
|
65
|
+
needs:
|
|
66
|
+
- job-c
|
|
67
|
+
script:
|
|
68
|
+
- echo b
|
|
69
|
+
|
|
70
|
+
job-c:
|
|
71
|
+
stage: build
|
|
72
|
+
needs:
|
|
73
|
+
- job-a
|
|
74
|
+
script:
|
|
75
|
+
- echo c`;
|
|
76
|
+
const diags = checkCircularNeeds(makeCtx(yaml));
|
|
77
|
+
expect(diags).toHaveLength(1);
|
|
78
|
+
expect(diags[0].message).toContain("job-a");
|
|
79
|
+
expect(diags[0].message).toContain("job-b");
|
|
80
|
+
expect(diags[0].message).toContain("job-c");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("A→B, B→C (no cycle) → no diagnostic", () => {
|
|
84
|
+
const yaml = `stages:
|
|
85
|
+
- build
|
|
86
|
+
|
|
87
|
+
job-a:
|
|
88
|
+
stage: build
|
|
89
|
+
needs:
|
|
90
|
+
- job-b
|
|
91
|
+
script:
|
|
92
|
+
- echo a
|
|
93
|
+
|
|
94
|
+
job-b:
|
|
95
|
+
stage: build
|
|
96
|
+
needs:
|
|
97
|
+
- job-c
|
|
98
|
+
script:
|
|
99
|
+
- echo b
|
|
100
|
+
|
|
101
|
+
job-c:
|
|
102
|
+
stage: build
|
|
103
|
+
script:
|
|
104
|
+
- echo c`;
|
|
105
|
+
const diags = checkCircularNeeds(makeCtx(yaml));
|
|
106
|
+
expect(diags).toHaveLength(0);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("no needs at all → no diagnostic", () => {
|
|
110
|
+
const yaml = `stages:
|
|
111
|
+
- build
|
|
112
|
+
|
|
113
|
+
job-a:
|
|
114
|
+
stage: build
|
|
115
|
+
script:
|
|
116
|
+
- echo a
|
|
117
|
+
|
|
118
|
+
job-b:
|
|
119
|
+
stage: build
|
|
120
|
+
script:
|
|
121
|
+
- echo b`;
|
|
122
|
+
const diags = checkCircularNeeds(makeCtx(yaml));
|
|
123
|
+
expect(diags).toHaveLength(0);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("needs referencing unknown job (no cycle) → no diagnostic", () => {
|
|
127
|
+
const yaml = `stages:
|
|
128
|
+
- build
|
|
129
|
+
|
|
130
|
+
job-a:
|
|
131
|
+
stage: build
|
|
132
|
+
needs:
|
|
133
|
+
- unknown-job
|
|
134
|
+
script:
|
|
135
|
+
- echo a`;
|
|
136
|
+
const diags = checkCircularNeeds(makeCtx(yaml));
|
|
137
|
+
expect(diags).toHaveLength(0);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WGL015: Circular `needs:` Chain
|
|
3
|
+
*
|
|
4
|
+
* DFS-based cycle detection on the `needs:` dependency graph.
|
|
5
|
+
* If A needs B and B needs A, GitLab rejects the pipeline.
|
|
6
|
+
*
|
|
7
|
+
* Reports one diagnostic per cycle found, listing the full chain.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
11
|
+
import { getPrimaryOutput, extractJobs } from "./yaml-helpers";
|
|
12
|
+
|
|
13
|
+
export function checkCircularNeeds(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
14
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
15
|
+
|
|
16
|
+
for (const [, output] of ctx.outputs) {
|
|
17
|
+
const yaml = getPrimaryOutput(output);
|
|
18
|
+
const jobs = extractJobs(yaml);
|
|
19
|
+
|
|
20
|
+
// Build adjacency list from needs
|
|
21
|
+
const graph = new Map<string, string[]>();
|
|
22
|
+
for (const [jobName, job] of jobs) {
|
|
23
|
+
graph.set(jobName, job.needs ?? []);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// DFS cycle detection
|
|
27
|
+
const visited = new Set<string>();
|
|
28
|
+
const inStack = new Set<string>();
|
|
29
|
+
const reportedInCycle = new Set<string>();
|
|
30
|
+
|
|
31
|
+
function dfs(node: string, path: string[]): void {
|
|
32
|
+
if (inStack.has(node)) {
|
|
33
|
+
// Found a cycle — extract the cycle portion
|
|
34
|
+
const cycleStart = path.indexOf(node);
|
|
35
|
+
const cycle = path.slice(cycleStart);
|
|
36
|
+
cycle.push(node);
|
|
37
|
+
|
|
38
|
+
// Only report if we haven't already reported a cycle containing these nodes
|
|
39
|
+
const cycleKey = [...cycle].sort().join(",");
|
|
40
|
+
if (!reportedInCycle.has(cycleKey)) {
|
|
41
|
+
reportedInCycle.add(cycleKey);
|
|
42
|
+
diagnostics.push({
|
|
43
|
+
checkId: "WGL015",
|
|
44
|
+
severity: "error",
|
|
45
|
+
message: `Circular needs: chain detected: ${cycle.join(" → ")}`,
|
|
46
|
+
entity: node,
|
|
47
|
+
lexicon: "gitlab",
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (visited.has(node)) return;
|
|
54
|
+
|
|
55
|
+
visited.add(node);
|
|
56
|
+
inStack.add(node);
|
|
57
|
+
|
|
58
|
+
for (const neighbor of graph.get(node) ?? []) {
|
|
59
|
+
// Only follow edges to known jobs
|
|
60
|
+
if (graph.has(neighbor)) {
|
|
61
|
+
dfs(neighbor, [...path, node]);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
inStack.delete(node);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
for (const jobName of graph.keys()) {
|
|
69
|
+
if (!visited.has(jobName)) {
|
|
70
|
+
dfs(jobName, []);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return diagnostics;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const wgl015: PostSynthCheck = {
|
|
79
|
+
id: "WGL015",
|
|
80
|
+
description: "Circular needs: chain — cycle in job dependency graph",
|
|
81
|
+
|
|
82
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
83
|
+
return checkCircularNeeds(ctx);
|
|
84
|
+
},
|
|
85
|
+
};
|
|
@@ -29,6 +29,8 @@ export interface ParsedJob {
|
|
|
29
29
|
name: string;
|
|
30
30
|
stage?: string;
|
|
31
31
|
rules?: ParsedRule[];
|
|
32
|
+
needs?: string[];
|
|
33
|
+
extends?: string[];
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
export interface ParsedRule {
|
|
@@ -63,8 +65,8 @@ export function extractJobs(yaml: string): Map<string, ParsedJob> {
|
|
|
63
65
|
const lines = section.split("\n");
|
|
64
66
|
if (lines.length === 0) continue;
|
|
65
67
|
|
|
66
|
-
// Top-level key
|
|
67
|
-
const topMatch = lines[0].match(/^([a-z][a-z0-9_
|
|
68
|
+
// Top-level key (including dot-prefixed hidden jobs like .deploy-template)
|
|
69
|
+
const topMatch = lines[0].match(/^(\.?[a-z][a-z0-9_.-]*):/);
|
|
68
70
|
if (!topMatch) continue;
|
|
69
71
|
|
|
70
72
|
const name = topMatch[1];
|
|
@@ -73,12 +75,63 @@ export function extractJobs(yaml: string): Map<string, ParsedJob> {
|
|
|
73
75
|
|
|
74
76
|
const job: ParsedJob = { name };
|
|
75
77
|
|
|
76
|
-
// Find stage within the section
|
|
78
|
+
// Find stage, needs, extends within the section
|
|
79
|
+
let inNeeds = false;
|
|
77
80
|
for (const line of lines) {
|
|
78
81
|
const stageMatch = line.match(/^\s+stage:\s+(.+)$/);
|
|
79
82
|
if (stageMatch) {
|
|
80
83
|
job.stage = stageMatch[1].trim().replace(/^'|'$/g, "");
|
|
81
84
|
}
|
|
85
|
+
|
|
86
|
+
// extends: .template or extends: [.a, .b]
|
|
87
|
+
const extendsMatch = line.match(/^\s+extends:\s+(.+)$/);
|
|
88
|
+
if (extendsMatch) {
|
|
89
|
+
const val = extendsMatch[1].trim();
|
|
90
|
+
if (val.startsWith("[")) {
|
|
91
|
+
// Inline array: [.a, .b]
|
|
92
|
+
job.extends = val.slice(1, -1).split(",").map((s) => s.trim().replace(/^'|'$/g, ""));
|
|
93
|
+
} else {
|
|
94
|
+
job.extends = [val.replace(/^'|'$/g, "")];
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// needs: block (list form)
|
|
99
|
+
if (line.match(/^\s+needs:$/)) {
|
|
100
|
+
inNeeds = true;
|
|
101
|
+
job.needs = [];
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (inNeeds) {
|
|
106
|
+
// - job-name (simple string form)
|
|
107
|
+
const simpleNeed = line.match(/^\s+- ([a-z][a-z0-9_.-]*)$/);
|
|
108
|
+
if (simpleNeed) {
|
|
109
|
+
job.needs!.push(simpleNeed[1]);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
// - 'job-name' (quoted string form)
|
|
113
|
+
const quotedNeed = line.match(/^\s+- '([^']+)'$/);
|
|
114
|
+
if (quotedNeed) {
|
|
115
|
+
job.needs!.push(quotedNeed[1]);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
// - job: job-name (object form)
|
|
119
|
+
const objectNeed = line.match(/^\s+- job:\s+(.+)$/);
|
|
120
|
+
if (objectNeed) {
|
|
121
|
+
job.needs!.push(objectNeed[1].trim().replace(/^'|'$/g, ""));
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
// End of needs block when we hit a non-indented-list line
|
|
125
|
+
if (!line.match(/^\s+\s/) || line.match(/^\s+[a-z_]+:/)) {
|
|
126
|
+
inNeeds = false;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// needs: [a, b] (inline array form)
|
|
131
|
+
const inlineNeeds = line.match(/^\s+needs:\s+\[(.+)\]$/);
|
|
132
|
+
if (inlineNeeds) {
|
|
133
|
+
job.needs = inlineNeeds[1].split(",").map((s) => s.trim().replace(/^'|'$/g, ""));
|
|
134
|
+
}
|
|
82
135
|
}
|
|
83
136
|
|
|
84
137
|
jobs.set(name, job);
|
|
@@ -86,3 +139,12 @@ export function extractJobs(yaml: string): Map<string, ParsedJob> {
|
|
|
86
139
|
|
|
87
140
|
return jobs;
|
|
88
141
|
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Check whether the YAML contains an `include:` directive.
|
|
145
|
+
* When includes are present, `needs:` and `extends:` may reference
|
|
146
|
+
* jobs/templates from included files, so checks should be lenient.
|
|
147
|
+
*/
|
|
148
|
+
export function hasInclude(yaml: string): boolean {
|
|
149
|
+
return /^include:/m.test(yaml);
|
|
150
|
+
}
|
package/src/plugin.test.ts
CHANGED
|
@@ -83,27 +83,51 @@ 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(6);
|
|
87
87
|
const ids = checks.map((c) => c.id);
|
|
88
88
|
expect(ids).toContain("WGL010");
|
|
89
89
|
expect(ids).toContain("WGL011");
|
|
90
|
+
expect(ids).toContain("WGL012");
|
|
91
|
+
expect(ids).toContain("WGL013");
|
|
92
|
+
expect(ids).toContain("WGL014");
|
|
93
|
+
expect(ids).toContain("WGL015");
|
|
90
94
|
});
|
|
91
95
|
|
|
92
96
|
// -----------------------------------------------------------------------
|
|
93
97
|
// Init templates
|
|
94
98
|
// -----------------------------------------------------------------------
|
|
95
99
|
|
|
96
|
-
test("returns init templates", () => {
|
|
97
|
-
const
|
|
98
|
-
expect(
|
|
99
|
-
expect(
|
|
100
|
-
expect(
|
|
100
|
+
test("returns default init templates with src files", () => {
|
|
101
|
+
const result = gitlabPlugin.initTemplates!();
|
|
102
|
+
expect(result.src).toBeDefined();
|
|
103
|
+
expect(result.src["config.ts"]).toBeDefined();
|
|
104
|
+
expect(result.src["pipeline.ts"]).toBeDefined();
|
|
101
105
|
});
|
|
102
106
|
|
|
103
|
-
test("init templates import from gitlab lexicon", () => {
|
|
104
|
-
const
|
|
105
|
-
expect(
|
|
106
|
-
expect(
|
|
107
|
+
test("default init templates import from gitlab lexicon", () => {
|
|
108
|
+
const result = gitlabPlugin.initTemplates!();
|
|
109
|
+
expect(result.src["config.ts"]).toContain("@intentius/chant-lexicon-gitlab");
|
|
110
|
+
expect(result.src["pipeline.ts"]).toContain("@intentius/chant-lexicon-gitlab");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("node-pipeline template uses NodePipeline composite", () => {
|
|
114
|
+
const result = gitlabPlugin.initTemplates!("node-pipeline");
|
|
115
|
+
expect(result.src["pipeline.ts"]).toContain("NodePipeline");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("python-pipeline template uses PythonPipeline composite", () => {
|
|
119
|
+
const result = gitlabPlugin.initTemplates!("python-pipeline");
|
|
120
|
+
expect(result.src["pipeline.ts"]).toContain("PythonPipeline");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("docker-build template uses DockerBuild composite", () => {
|
|
124
|
+
const result = gitlabPlugin.initTemplates!("docker-build");
|
|
125
|
+
expect(result.src["pipeline.ts"]).toContain("DockerBuild");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("review-app template uses ReviewApp composite", () => {
|
|
129
|
+
const result = gitlabPlugin.initTemplates!("review-app");
|
|
130
|
+
expect(result.src["pipeline.ts"]).toContain("ReviewApp");
|
|
107
131
|
});
|
|
108
132
|
|
|
109
133
|
// -----------------------------------------------------------------------
|
|
@@ -165,8 +189,10 @@ describe("gitlabPlugin", () => {
|
|
|
165
189
|
expect(skills[0].content).toContain("user-invocable: true");
|
|
166
190
|
expect(skills[0].content).toContain("chant build");
|
|
167
191
|
expect(skills[0].content).toContain("chant lint");
|
|
168
|
-
expect(skills[0].triggers).toHaveLength(
|
|
169
|
-
expect(skills[0].examples).toHaveLength(
|
|
192
|
+
expect(skills[0].triggers).toHaveLength(5);
|
|
193
|
+
expect(skills[0].examples).toHaveLength(5);
|
|
194
|
+
expect(skills[0].preConditions).toHaveLength(3);
|
|
195
|
+
expect(skills[0].postConditions).toHaveLength(2);
|
|
170
196
|
});
|
|
171
197
|
|
|
172
198
|
// -----------------------------------------------------------------------
|
|
@@ -194,7 +220,7 @@ describe("gitlabPlugin", () => {
|
|
|
194
220
|
const result = await catalog.handler();
|
|
195
221
|
const parsed = JSON.parse(result);
|
|
196
222
|
expect(Array.isArray(parsed)).toBe(true);
|
|
197
|
-
expect(parsed.length).toBe(
|
|
223
|
+
expect(parsed.length).toBe(19);
|
|
198
224
|
const job = parsed.find((e: { className: string }) => e.className === "Job");
|
|
199
225
|
expect(job).toBeDefined();
|
|
200
226
|
expect(job.kind).toBe("resource");
|