@intentius/chant-lexicon-gitlab 0.0.1
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/package.json +27 -0
- package/src/codegen/__snapshots__/snapshot.test.ts.snap +33 -0
- package/src/codegen/docs-cli.ts +3 -0
- package/src/codegen/docs.ts +962 -0
- package/src/codegen/fetch.ts +73 -0
- package/src/codegen/generate-cli.ts +41 -0
- package/src/codegen/generate-lexicon.ts +53 -0
- package/src/codegen/generate-typescript.ts +144 -0
- package/src/codegen/generate.ts +166 -0
- package/src/codegen/naming.ts +52 -0
- package/src/codegen/package.ts +64 -0
- package/src/codegen/parse.test.ts +195 -0
- package/src/codegen/parse.ts +531 -0
- package/src/codegen/patches.test.ts +99 -0
- package/src/codegen/patches.ts +100 -0
- package/src/codegen/rollback.ts +26 -0
- package/src/codegen/snapshot.test.ts +109 -0
- package/src/coverage.test.ts +39 -0
- package/src/coverage.ts +52 -0
- package/src/generated/index.d.ts +248 -0
- package/src/generated/index.ts +23 -0
- package/src/generated/lexicon-gitlab.json +77 -0
- package/src/generated/runtime.ts +4 -0
- package/src/import/generator.test.ts +151 -0
- package/src/import/generator.ts +173 -0
- package/src/import/parser.test.ts +160 -0
- package/src/import/parser.ts +282 -0
- package/src/import/roundtrip.test.ts +89 -0
- package/src/index.ts +25 -0
- package/src/intrinsics.test.ts +42 -0
- package/src/intrinsics.ts +40 -0
- package/src/lint/post-synth/post-synth.test.ts +155 -0
- package/src/lint/post-synth/wgl010.ts +41 -0
- package/src/lint/post-synth/wgl011.ts +54 -0
- package/src/lint/post-synth/yaml-helpers.ts +88 -0
- package/src/lint/rules/artifact-no-expiry.ts +62 -0
- package/src/lint/rules/deprecated-only-except.ts +53 -0
- package/src/lint/rules/index.ts +8 -0
- package/src/lint/rules/missing-script.ts +65 -0
- package/src/lint/rules/missing-stage.ts +62 -0
- package/src/lint/rules/rules.test.ts +146 -0
- package/src/lsp/completions.test.ts +85 -0
- package/src/lsp/completions.ts +18 -0
- package/src/lsp/hover.test.ts +60 -0
- package/src/lsp/hover.ts +36 -0
- package/src/plugin.test.ts +228 -0
- package/src/plugin.ts +380 -0
- package/src/serializer.test.ts +309 -0
- package/src/serializer.ts +226 -0
- package/src/testdata/ci-schema-fixture.json +2184 -0
- package/src/testdata/create-fixture.ts +46 -0
- package/src/testdata/load-fixtures.ts +23 -0
- package/src/validate-cli.ts +19 -0
- package/src/validate.test.ts +43 -0
- package/src/validate.ts +125 -0
- package/src/variables.ts +27 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WGL011: Unreachable job
|
|
3
|
+
*
|
|
4
|
+
* Detects jobs where all `rules:` entries evaluate to `when: never`,
|
|
5
|
+
* making the job unreachable. This usually indicates a configuration error.
|
|
6
|
+
*
|
|
7
|
+
* Note: This is a simple static check — it only catches the obvious case
|
|
8
|
+
* where every rule has `when: "never"` literally set. Complex conditions
|
|
9
|
+
* with `if:` expressions are not evaluated.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
13
|
+
import { isPropertyDeclarable } from "@intentius/chant/declarable";
|
|
14
|
+
|
|
15
|
+
export const wgl011: PostSynthCheck = {
|
|
16
|
+
id: "WGL011",
|
|
17
|
+
description: "Job has rules that always evaluate to never (unreachable)",
|
|
18
|
+
|
|
19
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
20
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
21
|
+
|
|
22
|
+
for (const [entityName, entity] of ctx.entities) {
|
|
23
|
+
if (isPropertyDeclarable(entity)) continue;
|
|
24
|
+
const entityType = (entity as Record<string, unknown>).entityType as string;
|
|
25
|
+
if (entityType !== "GitLab::CI::Job") continue;
|
|
26
|
+
|
|
27
|
+
const props = (entity as Record<string, unknown>).props as Record<string, unknown> | undefined;
|
|
28
|
+
if (!props?.rules || !Array.isArray(props.rules)) continue;
|
|
29
|
+
|
|
30
|
+
const rules = props.rules as Array<Record<string, unknown>>;
|
|
31
|
+
if (rules.length === 0) continue;
|
|
32
|
+
|
|
33
|
+
// Check if ALL rules have when: "never"
|
|
34
|
+
const allNever = rules.every((rule) => {
|
|
35
|
+
// If the rule is a declarable (e.g. new Rule({...})), check its props
|
|
36
|
+
const ruleProps = (rule as Record<string, unknown>).props as Record<string, unknown> | undefined;
|
|
37
|
+
const when = ruleProps?.when ?? (rule as Record<string, unknown>).when;
|
|
38
|
+
return when === "never";
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
if (allNever) {
|
|
42
|
+
diagnostics.push({
|
|
43
|
+
checkId: "WGL011",
|
|
44
|
+
severity: "warning",
|
|
45
|
+
message: `Job "${entityName}" has rules that all evaluate to "never" — this job will never run.`,
|
|
46
|
+
entity: entityName,
|
|
47
|
+
lexicon: "gitlab",
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return diagnostics;
|
|
53
|
+
},
|
|
54
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for parsing serialized GitLab CI YAML in post-synth checks.
|
|
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.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { SerializerResult } from "@intentius/chant/serializer";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Extract the primary output string from a serializer result.
|
|
14
|
+
*/
|
|
15
|
+
export function getPrimaryOutput(output: string | SerializerResult): string {
|
|
16
|
+
return typeof output === "string" ? output : output.primary;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Parse a serialized GitLab CI YAML into a structured object.
|
|
21
|
+
* Returns null if the output can't be parsed.
|
|
22
|
+
*/
|
|
23
|
+
export interface ParsedGitLabCI {
|
|
24
|
+
stages: string[];
|
|
25
|
+
jobs: Map<string, ParsedJob>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ParsedJob {
|
|
29
|
+
name: string;
|
|
30
|
+
stage?: string;
|
|
31
|
+
rules?: ParsedRule[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ParsedRule {
|
|
35
|
+
when?: string;
|
|
36
|
+
if?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Extract stages list from serialized YAML.
|
|
41
|
+
*/
|
|
42
|
+
export function extractStages(yaml: string): string[] {
|
|
43
|
+
const stages: string[] = [];
|
|
44
|
+
const stagesMatch = yaml.match(/^stages:\n((?:\s+- .+\n?)+)/m);
|
|
45
|
+
if (stagesMatch) {
|
|
46
|
+
for (const line of stagesMatch[1].split("\n")) {
|
|
47
|
+
const item = line.match(/^\s+- (.+)$/);
|
|
48
|
+
if (item) stages.push(item[1].trim().replace(/^'|'$/g, ""));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return stages;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Extract job names and their stage values from serialized YAML.
|
|
56
|
+
*/
|
|
57
|
+
export function extractJobs(yaml: string): Map<string, ParsedJob> {
|
|
58
|
+
const jobs = new Map<string, ParsedJob>();
|
|
59
|
+
|
|
60
|
+
// Split into sections by double newlines
|
|
61
|
+
const sections = yaml.split("\n\n");
|
|
62
|
+
for (const section of sections) {
|
|
63
|
+
const lines = section.split("\n");
|
|
64
|
+
if (lines.length === 0) continue;
|
|
65
|
+
|
|
66
|
+
// Top-level key
|
|
67
|
+
const topMatch = lines[0].match(/^([a-z][a-z0-9_-]*):/);
|
|
68
|
+
if (!topMatch) continue;
|
|
69
|
+
|
|
70
|
+
const name = topMatch[1];
|
|
71
|
+
// Skip reserved keys
|
|
72
|
+
if (["stages", "default", "workflow", "variables", "include"].includes(name)) continue;
|
|
73
|
+
|
|
74
|
+
const job: ParsedJob = { name };
|
|
75
|
+
|
|
76
|
+
// Find stage within the section
|
|
77
|
+
for (const line of lines) {
|
|
78
|
+
const stageMatch = line.match(/^\s+stage:\s+(.+)$/);
|
|
79
|
+
if (stageMatch) {
|
|
80
|
+
job.stage = stageMatch[1].trim().replace(/^'|'$/g, "");
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
jobs.set(name, job);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return jobs;
|
|
88
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WGL004: Artifacts without expiry
|
|
3
|
+
*
|
|
4
|
+
* Artifacts without `expireIn` (or `expire_in`) will be kept indefinitely,
|
|
5
|
+
* wasting storage. Always set an expiry for job artifacts.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { LintRule, LintDiagnostic, LintContext } from "@intentius/chant/lint/rule";
|
|
9
|
+
import * as ts from "typescript";
|
|
10
|
+
|
|
11
|
+
export const artifactNoExpiryRule: LintRule = {
|
|
12
|
+
id: "WGL004",
|
|
13
|
+
severity: "warning",
|
|
14
|
+
category: "performance",
|
|
15
|
+
|
|
16
|
+
check(context: LintContext): LintDiagnostic[] {
|
|
17
|
+
const { sourceFile } = context;
|
|
18
|
+
const diagnostics: LintDiagnostic[] = [];
|
|
19
|
+
|
|
20
|
+
function visit(node: ts.Node): void {
|
|
21
|
+
if (ts.isNewExpression(node)) {
|
|
22
|
+
let isArtifacts = false;
|
|
23
|
+
const expression = node.expression;
|
|
24
|
+
|
|
25
|
+
if (ts.isIdentifier(expression) && expression.text === "Artifacts") {
|
|
26
|
+
isArtifacts = true;
|
|
27
|
+
} else if (ts.isPropertyAccessExpression(expression) && expression.name.text === "Artifacts") {
|
|
28
|
+
isArtifacts = true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (isArtifacts && node.arguments && node.arguments.length > 0) {
|
|
32
|
+
const props = node.arguments[0];
|
|
33
|
+
if (ts.isObjectLiteralExpression(props)) {
|
|
34
|
+
const hasExpiry = props.properties.some((prop) => {
|
|
35
|
+
if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name)) {
|
|
36
|
+
return prop.name.text === "expireIn" || prop.name.text === "expire_in";
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
if (!hasExpiry) {
|
|
42
|
+
const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart());
|
|
43
|
+
diagnostics.push({
|
|
44
|
+
file: sourceFile.fileName,
|
|
45
|
+
line: line + 1,
|
|
46
|
+
column: character + 1,
|
|
47
|
+
ruleId: "WGL004",
|
|
48
|
+
severity: "warning",
|
|
49
|
+
message: 'Artifacts without "expireIn" will be kept indefinitely. Set an expiry to save storage.',
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
ts.forEachChild(node, visit);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
visit(sourceFile);
|
|
60
|
+
return diagnostics;
|
|
61
|
+
},
|
|
62
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WGL001: Deprecated only/except keywords
|
|
3
|
+
*
|
|
4
|
+
* Flags usage of `only:` and `except:` in GitLab CI jobs.
|
|
5
|
+
* These keywords are deprecated in favor of `rules:` which provides
|
|
6
|
+
* more flexible conditional execution.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { LintRule, LintDiagnostic, LintContext } from "@intentius/chant/lint/rule";
|
|
10
|
+
import * as ts from "typescript";
|
|
11
|
+
|
|
12
|
+
export const deprecatedOnlyExceptRule: LintRule = {
|
|
13
|
+
id: "WGL001",
|
|
14
|
+
severity: "warning",
|
|
15
|
+
category: "style",
|
|
16
|
+
|
|
17
|
+
check(context: LintContext): LintDiagnostic[] {
|
|
18
|
+
const { sourceFile } = context;
|
|
19
|
+
const diagnostics: LintDiagnostic[] = [];
|
|
20
|
+
|
|
21
|
+
function visit(node: ts.Node): void {
|
|
22
|
+
// Look for property assignments named "only" or "except"
|
|
23
|
+
// inside new Job(...) or similar constructor calls
|
|
24
|
+
if (ts.isPropertyAssignment(node) && ts.isIdentifier(node.name)) {
|
|
25
|
+
const propName = node.name.text;
|
|
26
|
+
if (propName === "only" || propName === "except") {
|
|
27
|
+
// Check if this is inside a new expression (Job constructor)
|
|
28
|
+
let parent: ts.Node | undefined = node.parent;
|
|
29
|
+
while (parent) {
|
|
30
|
+
if (ts.isNewExpression(parent)) {
|
|
31
|
+
const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart());
|
|
32
|
+
diagnostics.push({
|
|
33
|
+
file: sourceFile.fileName,
|
|
34
|
+
line: line + 1,
|
|
35
|
+
column: character + 1,
|
|
36
|
+
ruleId: "WGL001",
|
|
37
|
+
severity: "warning",
|
|
38
|
+
message: `"${propName}" is deprecated. Use "rules" for conditional job execution instead.`,
|
|
39
|
+
});
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
parent = parent.parent;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
ts.forEachChild(node, visit);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
visit(sourceFile);
|
|
51
|
+
return diagnostics;
|
|
52
|
+
},
|
|
53
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitLab CI/CD lint rules
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export { deprecatedOnlyExceptRule } from "./deprecated-only-except";
|
|
6
|
+
export { missingScriptRule } from "./missing-script";
|
|
7
|
+
export { missingStageRule } from "./missing-stage";
|
|
8
|
+
export { artifactNoExpiryRule } from "./artifact-no-expiry";
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WGL002: Missing script
|
|
3
|
+
*
|
|
4
|
+
* A GitLab CI job must have `script`, `trigger`, or `run` defined.
|
|
5
|
+
* Jobs without any of these will fail pipeline validation.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { LintRule, LintDiagnostic, LintContext } from "@intentius/chant/lint/rule";
|
|
9
|
+
import * as ts from "typescript";
|
|
10
|
+
|
|
11
|
+
const VALID_EXECUTION_PROPS = new Set(["script", "trigger", "run"]);
|
|
12
|
+
|
|
13
|
+
export const missingScriptRule: LintRule = {
|
|
14
|
+
id: "WGL002",
|
|
15
|
+
severity: "error",
|
|
16
|
+
category: "correctness",
|
|
17
|
+
|
|
18
|
+
check(context: LintContext): LintDiagnostic[] {
|
|
19
|
+
const { sourceFile } = context;
|
|
20
|
+
const diagnostics: LintDiagnostic[] = [];
|
|
21
|
+
|
|
22
|
+
function visit(node: ts.Node): void {
|
|
23
|
+
if (ts.isNewExpression(node)) {
|
|
24
|
+
// Check for `new Job(...)` or `new gl.Job(...)`
|
|
25
|
+
let isJob = false;
|
|
26
|
+
const expression = node.expression;
|
|
27
|
+
|
|
28
|
+
if (ts.isIdentifier(expression) && expression.text === "Job") {
|
|
29
|
+
isJob = true;
|
|
30
|
+
} else if (ts.isPropertyAccessExpression(expression) && expression.name.text === "Job") {
|
|
31
|
+
isJob = true;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (isJob && node.arguments && node.arguments.length > 0) {
|
|
35
|
+
const props = node.arguments[0];
|
|
36
|
+
if (ts.isObjectLiteralExpression(props)) {
|
|
37
|
+
const hasExecution = props.properties.some((prop) => {
|
|
38
|
+
if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name)) {
|
|
39
|
+
return VALID_EXECUTION_PROPS.has(prop.name.text);
|
|
40
|
+
}
|
|
41
|
+
return false;
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
if (!hasExecution) {
|
|
45
|
+
const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart());
|
|
46
|
+
diagnostics.push({
|
|
47
|
+
file: sourceFile.fileName,
|
|
48
|
+
line: line + 1,
|
|
49
|
+
column: character + 1,
|
|
50
|
+
ruleId: "WGL002",
|
|
51
|
+
severity: "error",
|
|
52
|
+
message: 'Job must have "script", "trigger", or "run" defined.',
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
ts.forEachChild(node, visit);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
visit(sourceFile);
|
|
63
|
+
return diagnostics;
|
|
64
|
+
},
|
|
65
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WGL003: Missing stage
|
|
3
|
+
*
|
|
4
|
+
* Jobs should declare a `stage` property. Without it, the job defaults
|
|
5
|
+
* to the "test" stage which may not be the intended behavior.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { LintRule, LintDiagnostic, LintContext } from "@intentius/chant/lint/rule";
|
|
9
|
+
import * as ts from "typescript";
|
|
10
|
+
|
|
11
|
+
export const missingStageRule: LintRule = {
|
|
12
|
+
id: "WGL003",
|
|
13
|
+
severity: "info",
|
|
14
|
+
category: "style",
|
|
15
|
+
|
|
16
|
+
check(context: LintContext): LintDiagnostic[] {
|
|
17
|
+
const { sourceFile } = context;
|
|
18
|
+
const diagnostics: LintDiagnostic[] = [];
|
|
19
|
+
|
|
20
|
+
function visit(node: ts.Node): void {
|
|
21
|
+
if (ts.isNewExpression(node)) {
|
|
22
|
+
let isJob = false;
|
|
23
|
+
const expression = node.expression;
|
|
24
|
+
|
|
25
|
+
if (ts.isIdentifier(expression) && expression.text === "Job") {
|
|
26
|
+
isJob = true;
|
|
27
|
+
} else if (ts.isPropertyAccessExpression(expression) && expression.name.text === "Job") {
|
|
28
|
+
isJob = true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (isJob && node.arguments && node.arguments.length > 0) {
|
|
32
|
+
const props = node.arguments[0];
|
|
33
|
+
if (ts.isObjectLiteralExpression(props)) {
|
|
34
|
+
const hasStage = props.properties.some((prop) => {
|
|
35
|
+
if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name)) {
|
|
36
|
+
return prop.name.text === "stage";
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
if (!hasStage) {
|
|
42
|
+
const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart());
|
|
43
|
+
diagnostics.push({
|
|
44
|
+
file: sourceFile.fileName,
|
|
45
|
+
line: line + 1,
|
|
46
|
+
column: character + 1,
|
|
47
|
+
ruleId: "WGL003",
|
|
48
|
+
severity: "info",
|
|
49
|
+
message: 'Job does not declare a "stage". It will default to "test".',
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
ts.forEachChild(node, visit);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
visit(sourceFile);
|
|
60
|
+
return diagnostics;
|
|
61
|
+
},
|
|
62
|
+
};
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import * as ts from "typescript";
|
|
3
|
+
import type { LintContext } from "@intentius/chant/lint/rule";
|
|
4
|
+
import { deprecatedOnlyExceptRule } from "./deprecated-only-except";
|
|
5
|
+
import { missingScriptRule } from "./missing-script";
|
|
6
|
+
import { missingStageRule } from "./missing-stage";
|
|
7
|
+
import { artifactNoExpiryRule } from "./artifact-no-expiry";
|
|
8
|
+
|
|
9
|
+
function createContext(code: string, fileName = "test.ts"): LintContext {
|
|
10
|
+
const sourceFile = ts.createSourceFile(fileName, code, ts.ScriptTarget.Latest, true);
|
|
11
|
+
return { sourceFile, entities: [], filePath: fileName };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// ── WGL001: deprecated only/except ──────────────────────────────────
|
|
15
|
+
|
|
16
|
+
describe("WGL001: deprecated-only-except", () => {
|
|
17
|
+
test("flags 'only' in new Job()", () => {
|
|
18
|
+
const ctx = createContext(`const j = new Job({ only: ["main"], script: ["test"] });`);
|
|
19
|
+
const diags = deprecatedOnlyExceptRule.check(ctx);
|
|
20
|
+
expect(diags).toHaveLength(1);
|
|
21
|
+
expect(diags[0].ruleId).toBe("WGL001");
|
|
22
|
+
expect(diags[0].message).toContain("only");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("flags 'except' in new Job()", () => {
|
|
26
|
+
const ctx = createContext(`const j = new Job({ except: ["tags"], script: ["test"] });`);
|
|
27
|
+
const diags = deprecatedOnlyExceptRule.check(ctx);
|
|
28
|
+
expect(diags).toHaveLength(1);
|
|
29
|
+
expect(diags[0].message).toContain("except");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("flags both 'only' and 'except'", () => {
|
|
33
|
+
const ctx = createContext(`const j = new Job({ only: ["main"], except: ["tags"], script: ["test"] });`);
|
|
34
|
+
const diags = deprecatedOnlyExceptRule.check(ctx);
|
|
35
|
+
expect(diags).toHaveLength(2);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("does not flag 'rules'", () => {
|
|
39
|
+
const ctx = createContext(`const j = new Job({ rules: [{ if: "$CI" }], script: ["test"] });`);
|
|
40
|
+
const diags = deprecatedOnlyExceptRule.check(ctx);
|
|
41
|
+
expect(diags).toHaveLength(0);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("does not flag 'only' outside new expression", () => {
|
|
45
|
+
const ctx = createContext(`const only = "main";`);
|
|
46
|
+
const diags = deprecatedOnlyExceptRule.check(ctx);
|
|
47
|
+
expect(diags).toHaveLength(0);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// ── WGL002: missing script ──────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
describe("WGL002: missing-script", () => {
|
|
54
|
+
test("flags Job without script, trigger, or run", () => {
|
|
55
|
+
const ctx = createContext(`const j = new Job({ stage: "test" });`);
|
|
56
|
+
const diags = missingScriptRule.check(ctx);
|
|
57
|
+
expect(diags).toHaveLength(1);
|
|
58
|
+
expect(diags[0].ruleId).toBe("WGL002");
|
|
59
|
+
expect(diags[0].severity).toBe("error");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("does not flag Job with script", () => {
|
|
63
|
+
const ctx = createContext(`const j = new Job({ script: ["test"] });`);
|
|
64
|
+
const diags = missingScriptRule.check(ctx);
|
|
65
|
+
expect(diags).toHaveLength(0);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("does not flag Job with trigger", () => {
|
|
69
|
+
const ctx = createContext(`const j = new Job({ trigger: "other/project" });`);
|
|
70
|
+
const diags = missingScriptRule.check(ctx);
|
|
71
|
+
expect(diags).toHaveLength(0);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("does not flag Job with run", () => {
|
|
75
|
+
const ctx = createContext(`const j = new Job({ run: ["echo test"] });`);
|
|
76
|
+
const diags = missingScriptRule.check(ctx);
|
|
77
|
+
expect(diags).toHaveLength(0);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("flags gl.Job() without script", () => {
|
|
81
|
+
const ctx = createContext(`const j = new gl.Job({ stage: "build" });`);
|
|
82
|
+
const diags = missingScriptRule.check(ctx);
|
|
83
|
+
expect(diags).toHaveLength(1);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("does not flag non-Job constructors", () => {
|
|
87
|
+
const ctx = createContext(`const c = new Cache({ paths: ["node_modules/"] });`);
|
|
88
|
+
const diags = missingScriptRule.check(ctx);
|
|
89
|
+
expect(diags).toHaveLength(0);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// ── WGL003: missing stage ───────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
describe("WGL003: missing-stage", () => {
|
|
96
|
+
test("flags Job without stage", () => {
|
|
97
|
+
const ctx = createContext(`const j = new Job({ script: ["test"] });`);
|
|
98
|
+
const diags = missingStageRule.check(ctx);
|
|
99
|
+
expect(diags).toHaveLength(1);
|
|
100
|
+
expect(diags[0].ruleId).toBe("WGL003");
|
|
101
|
+
expect(diags[0].severity).toBe("info");
|
|
102
|
+
expect(diags[0].message).toContain("test");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("does not flag Job with stage", () => {
|
|
106
|
+
const ctx = createContext(`const j = new Job({ stage: "build", script: ["make"] });`);
|
|
107
|
+
const diags = missingStageRule.check(ctx);
|
|
108
|
+
expect(diags).toHaveLength(0);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// ── WGL004: artifact no expiry ──────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
describe("WGL004: artifact-no-expiry", () => {
|
|
115
|
+
test("flags Artifacts without expireIn", () => {
|
|
116
|
+
const ctx = createContext(`const a = new Artifacts({ paths: ["dist/"] });`);
|
|
117
|
+
const diags = artifactNoExpiryRule.check(ctx);
|
|
118
|
+
expect(diags).toHaveLength(1);
|
|
119
|
+
expect(diags[0].ruleId).toBe("WGL004");
|
|
120
|
+
expect(diags[0].severity).toBe("warning");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("does not flag Artifacts with expireIn", () => {
|
|
124
|
+
const ctx = createContext(`const a = new Artifacts({ paths: ["dist/"], expireIn: "1 week" });`);
|
|
125
|
+
const diags = artifactNoExpiryRule.check(ctx);
|
|
126
|
+
expect(diags).toHaveLength(0);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("does not flag Artifacts with expire_in", () => {
|
|
130
|
+
const ctx = createContext(`const a = new Artifacts({ paths: ["dist/"], expire_in: "30 days" });`);
|
|
131
|
+
const diags = artifactNoExpiryRule.check(ctx);
|
|
132
|
+
expect(diags).toHaveLength(0);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("flags gl.Artifacts without expiry", () => {
|
|
136
|
+
const ctx = createContext(`const a = new gl.Artifacts({ paths: ["dist/"] });`);
|
|
137
|
+
const diags = artifactNoExpiryRule.check(ctx);
|
|
138
|
+
expect(diags).toHaveLength(1);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("does not flag non-Artifacts constructors", () => {
|
|
142
|
+
const ctx = createContext(`const j = new Job({ script: ["test"] });`);
|
|
143
|
+
const diags = artifactNoExpiryRule.check(ctx);
|
|
144
|
+
expect(diags).toHaveLength(0);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { gitlabCompletions } from "./completions";
|
|
3
|
+
import type { CompletionContext } from "@intentius/chant/lsp/types";
|
|
4
|
+
|
|
5
|
+
function makeCtx(overrides: Partial<CompletionContext>): CompletionContext {
|
|
6
|
+
return {
|
|
7
|
+
uri: "file:///test.ts",
|
|
8
|
+
content: "",
|
|
9
|
+
position: { line: 0, character: 0 },
|
|
10
|
+
wordAtCursor: "",
|
|
11
|
+
linePrefix: "",
|
|
12
|
+
...overrides,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("gitlabCompletions", () => {
|
|
17
|
+
test("returns resource completions for 'new ' prefix", () => {
|
|
18
|
+
const ctx = makeCtx({
|
|
19
|
+
linePrefix: "const j = new Job",
|
|
20
|
+
wordAtCursor: "Job",
|
|
21
|
+
content: "const j = new Job",
|
|
22
|
+
position: { line: 0, character: 17 },
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const items = gitlabCompletions(ctx);
|
|
26
|
+
expect(items.length).toBeGreaterThan(0);
|
|
27
|
+
const job = items.find((i) => i.label === "Job");
|
|
28
|
+
expect(job).toBeDefined();
|
|
29
|
+
expect(job!.kind).toBe("resource");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("returns all resource completions when no filter", () => {
|
|
33
|
+
const ctx = makeCtx({
|
|
34
|
+
linePrefix: "const x = new ",
|
|
35
|
+
wordAtCursor: "",
|
|
36
|
+
content: "const x = new ",
|
|
37
|
+
position: { line: 0, character: 14 },
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const items = gitlabCompletions(ctx);
|
|
41
|
+
const labels = items.map((i) => i.label);
|
|
42
|
+
expect(labels).toContain("Job");
|
|
43
|
+
expect(labels).toContain("Default");
|
|
44
|
+
expect(labels).toContain("Workflow");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("filters completions by prefix", () => {
|
|
48
|
+
const ctx = makeCtx({
|
|
49
|
+
linePrefix: "const x = new D",
|
|
50
|
+
wordAtCursor: "D",
|
|
51
|
+
content: "const x = new D",
|
|
52
|
+
position: { line: 0, character: 15 },
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const items = gitlabCompletions(ctx);
|
|
56
|
+
const labels = items.map((i) => i.label);
|
|
57
|
+
expect(labels).toContain("Default");
|
|
58
|
+
expect(labels).not.toContain("Job");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("returns empty for non-constructor context", () => {
|
|
62
|
+
const ctx = makeCtx({
|
|
63
|
+
linePrefix: "const x = foo(",
|
|
64
|
+
wordAtCursor: "",
|
|
65
|
+
content: "const x = foo(",
|
|
66
|
+
position: { line: 0, character: 14 },
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const items = gitlabCompletions(ctx);
|
|
70
|
+
expect(items).toHaveLength(0);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("completion items have detail with resource type", () => {
|
|
74
|
+
const ctx = makeCtx({
|
|
75
|
+
linePrefix: "const j = new Job",
|
|
76
|
+
wordAtCursor: "Job",
|
|
77
|
+
content: "const j = new Job",
|
|
78
|
+
position: { line: 0, character: 17 },
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const items = gitlabCompletions(ctx);
|
|
82
|
+
const job = items.find((i) => i.label === "Job");
|
|
83
|
+
expect(job!.detail).toContain("GitLab::CI::Job");
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { CompletionContext, CompletionItem } from "@intentius/chant/lsp/types";
|
|
2
|
+
import { LexiconIndex, lexiconCompletions, type LexiconEntry } from "@intentius/chant/lsp/lexicon-providers";
|
|
3
|
+
|
|
4
|
+
let cachedIndex: LexiconIndex | null = null;
|
|
5
|
+
|
|
6
|
+
function getIndex(): LexiconIndex {
|
|
7
|
+
if (cachedIndex) return cachedIndex;
|
|
8
|
+
const data = require("../generated/lexicon-gitlab.json") as Record<string, LexiconEntry>;
|
|
9
|
+
cachedIndex = new LexiconIndex(data);
|
|
10
|
+
return cachedIndex;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Provide GitLab CI completions based on context.
|
|
15
|
+
*/
|
|
16
|
+
export function gitlabCompletions(ctx: CompletionContext): CompletionItem[] {
|
|
17
|
+
return lexiconCompletions(ctx, getIndex(), "GitLab CI entity");
|
|
18
|
+
}
|