@intentius/chant-lexicon-gitlab 0.124.31 → 0.124.32
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/codegen/docs-cli.js +2 -0
- package/dist/codegen/docs.js +157 -0
- package/dist/codegen/fetch.js +93 -0
- package/dist/codegen/generate-cli.js +30 -0
- package/dist/codegen/generate-lexicon.js +36 -0
- package/dist/codegen/generate-typescript.js +105 -0
- package/dist/codegen/generate.js +125 -0
- package/dist/codegen/naming.js +48 -0
- package/dist/codegen/package.js +41 -0
- package/dist/codegen/parse.js +539 -0
- package/dist/codegen/patches.js +84 -0
- package/dist/codegen/typecheck.js +4 -0
- package/dist/components/ci-jobs.js +16 -0
- package/dist/components/generate-op-pipeline.js +288 -0
- package/dist/components/generate-op-waves-pipeline.js +125 -0
- package/dist/components/generate-pipeline.d.ts.map +1 -1
- package/dist/components/generate-pipeline.js +369 -0
- package/dist/composites/catalog.js +418 -0
- package/dist/composites/docker-build.js +38 -0
- package/dist/composites/index.js +5 -0
- package/dist/composites/mr-plan-report.js +42 -0
- package/dist/composites/node-pipeline.js +67 -0
- package/dist/composites/python-pipeline.js +45 -0
- package/dist/composites/review-app.js +32 -0
- package/dist/coverage.js +35 -0
- package/dist/generated/index.js +25 -0
- package/dist/generated/runtime.js +4 -0
- package/dist/graph-edges.js +202 -0
- package/dist/import/generator.js +196 -0
- package/dist/import/parser.js +111 -0
- package/dist/index.js +26 -0
- package/dist/integrity.json +3 -3
- package/dist/intrinsics.js +33 -0
- package/dist/lint/audit-catalog.js +53 -0
- package/dist/lint/audit-lineage.js +64 -0
- package/dist/lint/post-synth/index.js +84 -0
- package/dist/lint/post-synth/wgl010.js +34 -0
- package/dist/lint/post-synth/wgl011.js +48 -0
- package/dist/lint/post-synth/wgl012.d.ts.map +1 -1
- package/dist/lint/post-synth/wgl012.js +68 -0
- package/dist/lint/post-synth/wgl013.js +57 -0
- package/dist/lint/post-synth/wgl014.js +43 -0
- package/dist/lint/post-synth/wgl015.js +70 -0
- package/dist/lint/post-synth/wgl016.js +70 -0
- package/dist/lint/post-synth/wgl017.js +38 -0
- package/dist/lint/post-synth/wgl018.js +34 -0
- package/dist/lint/post-synth/wgl019.js +43 -0
- package/dist/lint/post-synth/wgl020.js +47 -0
- package/dist/lint/post-synth/wgl021.js +52 -0
- package/dist/lint/post-synth/wgl022.js +38 -0
- package/dist/lint/post-synth/wgl023.js +44 -0
- package/dist/lint/post-synth/wgl024.js +41 -0
- package/dist/lint/post-synth/wgl025.js +42 -0
- package/dist/lint/post-synth/wgl026.js +58 -0
- package/dist/lint/post-synth/wgl027.js +49 -0
- package/dist/lint/post-synth/wgl028.js +58 -0
- package/dist/lint/post-synth/wgl029.js +55 -0
- package/dist/lint/post-synth/wgl030.js +43 -0
- package/dist/lint/post-synth/wgl031.js +33 -0
- package/dist/lint/post-synth/wgl032.js +64 -0
- package/dist/lint/post-synth/wgl033.js +32 -0
- package/dist/lint/post-synth/wgl034.js +37 -0
- package/dist/lint/post-synth/wgl035.js +40 -0
- package/dist/lint/post-synth/wgl036.js +38 -0
- package/dist/lint/post-synth/wgl037.js +41 -0
- package/dist/lint/post-synth/wgl038.js +43 -0
- package/dist/lint/post-synth/wgl039.js +34 -0
- package/dist/lint/post-synth/wgl040.js +35 -0
- package/dist/lint/post-synth/wgl041.js +53 -0
- package/dist/lint/post-synth/wgl042.js +41 -0
- package/dist/lint/post-synth/wgl043.js +39 -0
- package/dist/lint/post-synth/wgl044.js +35 -0
- package/dist/lint/post-synth/wgl045.js +54 -0
- package/dist/lint/post-synth/wgl046.js +40 -0
- package/dist/lint/post-synth/wgl047.js +34 -0
- package/dist/lint/post-synth/wgl048.js +32 -0
- package/dist/lint/post-synth/wgl049.js +53 -0
- package/dist/lint/post-synth/wgl050.js +42 -0
- package/dist/lint/post-synth/yaml-helpers.js +432 -0
- package/dist/lint/rules/artifact-no-expiry.js +53 -0
- package/dist/lint/rules/data/known-include-sources.js +44 -0
- package/dist/lint/rules/data/untrusted-variables.js +30 -0
- package/dist/lint/rules/deprecated-only-except.js +46 -0
- package/dist/lint/rules/import-source.js +61 -0
- package/dist/lint/rules/index.js +7 -0
- package/dist/lint/rules/missing-script.js +67 -0
- package/dist/lint/rules/missing-stage.js +60 -0
- package/dist/lsp/completions.d.ts.map +1 -1
- package/dist/lsp/completions.js +18 -0
- package/dist/lsp/hover.d.ts.map +1 -1
- package/dist/lsp/hover.js +33 -0
- package/dist/manifest.json +1 -1
- package/dist/mcp/context-tools.js +262 -0
- package/dist/migrate/from-github/actions/index.js +17 -0
- package/dist/migrate/from-github/actions/registry.js +61 -0
- package/dist/migrate/from-github/actions/tier-1.js +298 -0
- package/dist/migrate/from-github/actions/tier-2.js +241 -0
- package/dist/migrate/from-github/actions/tier-3.js +96 -0
- package/dist/migrate/from-github/composites/patterns.js +151 -0
- package/dist/migrate/from-github/composites/rewriter.js +22 -0
- package/dist/migrate/from-github/diagnostics.js +53 -0
- package/dist/migrate/from-github/emit-yaml.js +121 -0
- package/dist/migrate/from-github/expressions.js +275 -0
- package/dist/migrate/from-github/index.js +96 -0
- package/dist/migrate/from-github/provenance.js +35 -0
- package/dist/migrate/from-github/rules.js +74 -0
- package/dist/migrate/from-github/security.js +189 -0
- package/dist/migrate/from-github/stages.js +156 -0
- package/dist/migrate/from-github/transformer.js +633 -0
- package/dist/op/activities/gitlab.js +38 -0
- package/dist/op/activities/index.js +10 -0
- package/dist/op/builders.js +26 -0
- package/dist/package-cli.js +21 -0
- package/dist/plugin.js +536 -0
- package/dist/rules/wgl012.ts +2 -2
- package/dist/serializer.js +197 -0
- package/dist/testdata/create-fixture.js +40 -0
- package/dist/testdata/load-fixtures.js +20 -0
- package/dist/validate-cli.js +16 -0
- package/dist/validate.js +29 -0
- package/dist/variables.js +30 -0
- package/package.json +8 -8
- package/src/codegen/fetch.ts +2 -2
- package/src/codegen/package.ts +2 -2
- package/src/components/__fixtures__/pr-loop-build.gitlab.golden.yml +3 -3
- package/src/components/__fixtures__/pr-loop-waves.gitlab.golden.yml +101 -0
- package/src/components/__fixtures__/pr-loop.gitlab.golden.yml +3 -3
- package/src/components/generate-pipeline.ts +60 -27
- package/src/components/generate-pr-pipeline.test.ts +14 -9
- package/src/components/generate-pr-waves-pipeline.test.ts +88 -0
- package/src/lint/post-synth/wgl012.ts +2 -2
- package/src/lsp/completions.ts +2 -1
- package/src/lsp/hover.ts +2 -1
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WGL047: Software Fetched and Executed at Runtime Without Verification
|
|
3
|
+
*
|
|
4
|
+
* Flags a `script:` command that pipes a network download straight into a shell
|
|
5
|
+
* (`curl ... | bash`, `wget ... | sh`). The fetched script is unpinned and
|
|
6
|
+
* unverified, so whoever controls the URL controls what runs in the job.
|
|
7
|
+
* Download to a file, verify a checksum/signature, then execute it.
|
|
8
|
+
*/
|
|
9
|
+
import { getPrimaryOutput, extractScriptCommands } from "./yaml-helpers.js";
|
|
10
|
+
const PIPE_TO_SHELL = /\b(curl|wget)\b[^\n|]*\|\s*(sudo\s+)?(ba)?sh\b/i;
|
|
11
|
+
export const wgl047 = {
|
|
12
|
+
id: "WGL047",
|
|
13
|
+
description: "Software fetched and piped to a shell without verification",
|
|
14
|
+
check(ctx) {
|
|
15
|
+
const diagnostics = [];
|
|
16
|
+
for (const [, output] of ctx.outputs) {
|
|
17
|
+
const yaml = getPrimaryOutput(output);
|
|
18
|
+
const seen = new Set();
|
|
19
|
+
for (const { job, command } of extractScriptCommands(yaml)) {
|
|
20
|
+
if (PIPE_TO_SHELL.test(command) && !seen.has(job)) {
|
|
21
|
+
seen.add(job);
|
|
22
|
+
diagnostics.push({
|
|
23
|
+
checkId: "WGL047",
|
|
24
|
+
severity: "warning",
|
|
25
|
+
message: `Job "${job}" pipes a network download directly into a shell — the fetched code is unpinned and unverified. Download to a file, verify a checksum or signature, then execute it.`,
|
|
26
|
+
entity: job,
|
|
27
|
+
lexicon: "gitlab",
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return diagnostics;
|
|
33
|
+
},
|
|
34
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WGL048: Pipeline Without a Name
|
|
3
|
+
*
|
|
4
|
+
* Flags a pipeline that defines a `workflow:` block but no `workflow:name:`.
|
|
5
|
+
* A pipeline name shows up in the GitLab UI and audit output; naming pipelines
|
|
6
|
+
* makes runs easier to identify and review. Only flags pipelines that already
|
|
7
|
+
* customize `workflow:` (where a name is cheap to add and most useful).
|
|
8
|
+
*/
|
|
9
|
+
import { getPrimaryOutput, topLevelSections } from "./yaml-helpers.js";
|
|
10
|
+
export const wgl048 = {
|
|
11
|
+
id: "WGL048",
|
|
12
|
+
description: "Pipeline defines workflow: but no workflow:name",
|
|
13
|
+
check(ctx) {
|
|
14
|
+
const diagnostics = [];
|
|
15
|
+
for (const [, output] of ctx.outputs) {
|
|
16
|
+
const yaml = getPrimaryOutput(output);
|
|
17
|
+
const workflowSection = topLevelSections(yaml).find((s) => s.key === "workflow")?.text;
|
|
18
|
+
if (!workflowSection)
|
|
19
|
+
continue;
|
|
20
|
+
if (!/^\s+name:/m.test(workflowSection)) {
|
|
21
|
+
diagnostics.push({
|
|
22
|
+
checkId: "WGL048",
|
|
23
|
+
severity: "info",
|
|
24
|
+
message: `The pipeline defines a workflow: block but no workflow:name. Add a name so runs are identifiable in the GitLab UI and audit output.`,
|
|
25
|
+
entity: "workflow",
|
|
26
|
+
lexicon: "gitlab",
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return diagnostics;
|
|
31
|
+
},
|
|
32
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WGL049: Dependency Install Without a Cache
|
|
3
|
+
*
|
|
4
|
+
* Flags a job whose `script:` runs a package-manager install command (`npm
|
|
5
|
+
* ci`, `pip install`, `bundle install`, etc.) with no `cache:` in scope —
|
|
6
|
+
* neither on the job itself, nor at the pipeline's `default:`/top level.
|
|
7
|
+
* Every run re-fetches the same dependencies from a cold cache. A job that
|
|
8
|
+
* `extends:` another config is left alone — the cache may be inherited and
|
|
9
|
+
* this check has no way to confirm that. Efficiency (#444), not a
|
|
10
|
+
* correctness or security issue.
|
|
11
|
+
*/
|
|
12
|
+
import { getPrimaryOutput, extractJobs, extractJobSection, topLevelSections } from "./yaml-helpers.js";
|
|
13
|
+
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;
|
|
14
|
+
function hasPipelineWideCache(yaml) {
|
|
15
|
+
const sections = topLevelSections(yaml);
|
|
16
|
+
if (sections.some((s) => s.key === "cache"))
|
|
17
|
+
return true;
|
|
18
|
+
const defaultSection = sections.find((s) => s.key === "default")?.text;
|
|
19
|
+
return !!defaultSection && /\n\s+cache:/.test(defaultSection);
|
|
20
|
+
}
|
|
21
|
+
export const wgl049 = {
|
|
22
|
+
id: "WGL049",
|
|
23
|
+
description: "Job installs dependencies with no cache: in scope",
|
|
24
|
+
check(ctx) {
|
|
25
|
+
const diagnostics = [];
|
|
26
|
+
for (const [, output] of ctx.outputs) {
|
|
27
|
+
const yaml = getPrimaryOutput(output);
|
|
28
|
+
if (hasPipelineWideCache(yaml))
|
|
29
|
+
continue;
|
|
30
|
+
for (const [jobName, job] of extractJobs(yaml)) {
|
|
31
|
+
if (jobName.startsWith("."))
|
|
32
|
+
continue; // hidden/template job, not run directly
|
|
33
|
+
if (job.extends && job.extends.length > 0)
|
|
34
|
+
continue; // cache may be inherited; can't confirm
|
|
35
|
+
const section = extractJobSection(yaml, jobName);
|
|
36
|
+
if (!section)
|
|
37
|
+
continue;
|
|
38
|
+
if (/^\s+cache:/m.test(section))
|
|
39
|
+
continue; // job-level cache present
|
|
40
|
+
if (!DEP_INSTALL_RE.test(section))
|
|
41
|
+
continue;
|
|
42
|
+
diagnostics.push({
|
|
43
|
+
checkId: "WGL049",
|
|
44
|
+
severity: "info",
|
|
45
|
+
message: `Job "${jobName}" installs dependencies with no cache: in scope — every run re-fetches from the registry. Add a cache: covering the dependency directory.`,
|
|
46
|
+
entity: jobName,
|
|
47
|
+
lexicon: "gitlab",
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return diagnostics;
|
|
52
|
+
},
|
|
53
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WGL050: Merge-Request Job Missing interruptible
|
|
3
|
+
*
|
|
4
|
+
* Flags a job reachable from merge-request pipelines that doesn't set
|
|
5
|
+
* `interruptible: true`. Without it, GitLab's auto-cancel-redundant-pipelines
|
|
6
|
+
* setting can't cancel the job when a new push supersedes it, so the runner
|
|
7
|
+
* keeps spending capacity on a pipeline nobody wants anymore. Deploy jobs are
|
|
8
|
+
* left alone — cancelling mid-deploy is its own hazard, not an efficiency
|
|
9
|
+
* win. Efficiency (#444), not a correctness or security issue.
|
|
10
|
+
*/
|
|
11
|
+
import { getPrimaryOutput, isMergeRequestReachable, extractJobs, extractJobSection } from "./yaml-helpers.js";
|
|
12
|
+
export const wgl050 = {
|
|
13
|
+
id: "WGL050",
|
|
14
|
+
description: "Merge-request-reachable job is not interruptible",
|
|
15
|
+
check(ctx) {
|
|
16
|
+
const diagnostics = [];
|
|
17
|
+
for (const [, output] of ctx.outputs) {
|
|
18
|
+
const yaml = getPrimaryOutput(output);
|
|
19
|
+
for (const [jobName] of extractJobs(yaml)) {
|
|
20
|
+
if (jobName.startsWith("."))
|
|
21
|
+
continue;
|
|
22
|
+
const section = extractJobSection(yaml, jobName);
|
|
23
|
+
if (!section)
|
|
24
|
+
continue;
|
|
25
|
+
if (/deploy/i.test(jobName))
|
|
26
|
+
continue; // cancelling mid-deploy is a hazard, not a win
|
|
27
|
+
if (!isMergeRequestReachable(section))
|
|
28
|
+
continue;
|
|
29
|
+
if (/^\s+interruptible:\s*true\s*$/m.test(section))
|
|
30
|
+
continue;
|
|
31
|
+
diagnostics.push({
|
|
32
|
+
checkId: "WGL050",
|
|
33
|
+
severity: "info",
|
|
34
|
+
message: `Job "${jobName}" runs on merge-request pipelines but is not interruptible: true — when a new commit supersedes this pipeline, this job keeps running instead of being cancelled. Add interruptible: true.`,
|
|
35
|
+
entity: jobName,
|
|
36
|
+
lexicon: "gitlab",
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return diagnostics;
|
|
41
|
+
},
|
|
42
|
+
};
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for parsing serialized GitLab CI YAML in post-synth checks.
|
|
3
|
+
*
|
|
4
|
+
* Jobs, stages and global variables are read with the structural parser
|
|
5
|
+
* (`parseYAML`, js-yaml with chant's schema). Checks that pattern-match a
|
|
6
|
+
* job's text get it from {@link extractJobSection}, which cuts the document
|
|
7
|
+
* at top-level keys rather than at blank lines, so it does not depend on the
|
|
8
|
+
* serializer's spacing.
|
|
9
|
+
*/
|
|
10
|
+
import { parseYAML } from "@intentius/chant/yaml";
|
|
11
|
+
export { getPrimaryOutput } from "@intentius/chant/lint/post-synth";
|
|
12
|
+
/**
|
|
13
|
+
* Top-level keys that are global keywords, not jobs. `image`, `services`,
|
|
14
|
+
* `cache`, `before_script` and `after_script` are the deprecated global
|
|
15
|
+
* forms of the `default:` keywords; `spec` is a CI component's header.
|
|
16
|
+
*/
|
|
17
|
+
export const RESERVED_TOP_LEVEL_KEYS = new Set([
|
|
18
|
+
"stages",
|
|
19
|
+
"variables",
|
|
20
|
+
"default",
|
|
21
|
+
"include",
|
|
22
|
+
"workflow",
|
|
23
|
+
"image",
|
|
24
|
+
"services",
|
|
25
|
+
"cache",
|
|
26
|
+
"before_script",
|
|
27
|
+
"after_script",
|
|
28
|
+
"spec",
|
|
29
|
+
]);
|
|
30
|
+
/** Parse the pipeline document; `undefined` when the YAML cannot be read. */
|
|
31
|
+
export function parsePipeline(yaml) {
|
|
32
|
+
try {
|
|
33
|
+
const doc = parseYAML(yaml);
|
|
34
|
+
return doc && typeof doc === "object" && !Array.isArray(doc) ? doc : undefined;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function isMapping(v) {
|
|
41
|
+
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
42
|
+
}
|
|
43
|
+
function scalarString(v) {
|
|
44
|
+
if (typeof v === "string")
|
|
45
|
+
return v;
|
|
46
|
+
if (typeof v === "number" || typeof v === "boolean")
|
|
47
|
+
return String(v);
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Every job in the pipeline as `[id, rawJobObject]`: each top-level key whose
|
|
52
|
+
* value is a mapping, minus the global keywords. Hidden jobs (ids starting
|
|
53
|
+
* with `.`, used as `extends:` templates) are included.
|
|
54
|
+
*/
|
|
55
|
+
export function jobEntries(yaml) {
|
|
56
|
+
const doc = parsePipeline(yaml);
|
|
57
|
+
if (!doc)
|
|
58
|
+
return [];
|
|
59
|
+
const out = [];
|
|
60
|
+
for (const [name, val] of Object.entries(doc)) {
|
|
61
|
+
if (RESERVED_TOP_LEVEL_KEYS.has(name))
|
|
62
|
+
continue;
|
|
63
|
+
if (isMapping(val))
|
|
64
|
+
out.push([name, val]);
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Extract stages list from serialized YAML (block or flow list).
|
|
70
|
+
*/
|
|
71
|
+
export function extractStages(yaml) {
|
|
72
|
+
const stages = parsePipeline(yaml)?.stages;
|
|
73
|
+
if (!Array.isArray(stages))
|
|
74
|
+
return [];
|
|
75
|
+
return stages.map(scalarString).filter((s) => s !== undefined);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Extract jobs with their stage, `needs:` and `extends:` from serialized YAML.
|
|
79
|
+
*
|
|
80
|
+
* Built on the structural parser, so every job id is read (capitals, spaces,
|
|
81
|
+
* colons, quoted ids) and every `needs:` entry is read whatever keys follow
|
|
82
|
+
* `job:` in its map. The line-based version this replaced skipped ids that
|
|
83
|
+
* start with a capital and stopped reading a `needs:` list at an
|
|
84
|
+
* `artifacts: true` line (#3256). YAML the parser cannot read gives no jobs.
|
|
85
|
+
*/
|
|
86
|
+
export function extractJobs(yaml) {
|
|
87
|
+
const jobs = new Map();
|
|
88
|
+
for (const [name, obj] of jobEntries(yaml)) {
|
|
89
|
+
const job = { name };
|
|
90
|
+
const stage = scalarString(obj.stage);
|
|
91
|
+
if (stage !== undefined)
|
|
92
|
+
job.stage = stage;
|
|
93
|
+
if (obj.needs !== undefined && obj.needs !== null) {
|
|
94
|
+
const entries = Array.isArray(obj.needs) ? obj.needs : [obj.needs];
|
|
95
|
+
const needs = [];
|
|
96
|
+
const optional = [];
|
|
97
|
+
for (const entry of entries) {
|
|
98
|
+
const plain = scalarString(entry);
|
|
99
|
+
if (plain !== undefined) {
|
|
100
|
+
needs.push(plain);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (!isMapping(entry))
|
|
104
|
+
continue;
|
|
105
|
+
// Cross-pipeline needs name a job in another pipeline.
|
|
106
|
+
if ("pipeline" in entry || "project" in entry)
|
|
107
|
+
continue;
|
|
108
|
+
const target = scalarString(entry.job);
|
|
109
|
+
if (target === undefined)
|
|
110
|
+
continue;
|
|
111
|
+
needs.push(target);
|
|
112
|
+
if (entry.optional === true)
|
|
113
|
+
optional.push(target);
|
|
114
|
+
}
|
|
115
|
+
job.needs = needs;
|
|
116
|
+
if (optional.length > 0)
|
|
117
|
+
job.optionalNeeds = optional;
|
|
118
|
+
}
|
|
119
|
+
if (obj.extends !== undefined && obj.extends !== null) {
|
|
120
|
+
const ext = Array.isArray(obj.extends) ? obj.extends : [obj.extends];
|
|
121
|
+
job.extends = ext.map(scalarString).filter((s) => s !== undefined);
|
|
122
|
+
}
|
|
123
|
+
jobs.set(name, job);
|
|
124
|
+
}
|
|
125
|
+
return jobs;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Check whether the YAML contains an `include:` directive.
|
|
129
|
+
* When includes are present, `needs:` and `extends:` may reference
|
|
130
|
+
* jobs/templates from included files, so checks should be lenient.
|
|
131
|
+
*/
|
|
132
|
+
export function hasInclude(yaml) {
|
|
133
|
+
return /^include:/m.test(yaml);
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Extract global variables from serialized YAML. A variable in the expanded
|
|
137
|
+
* form (`value:` / `description:` / `options:`) maps to its `value`.
|
|
138
|
+
*/
|
|
139
|
+
export function extractGlobalVariables(yaml) {
|
|
140
|
+
const vars = new Map();
|
|
141
|
+
const variables = parsePipeline(yaml)?.variables;
|
|
142
|
+
if (!isMapping(variables))
|
|
143
|
+
return vars;
|
|
144
|
+
for (const [key, val] of Object.entries(variables)) {
|
|
145
|
+
const v = isMapping(val) ? scalarString(val.value) : scalarString(val);
|
|
146
|
+
vars.set(key, v ?? "");
|
|
147
|
+
}
|
|
148
|
+
return vars;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* The key of a top-level mapping line (`build:`, `Build_Docs:`,
|
|
152
|
+
* `build:linux:`, `"deploy prod":`), unquoted, or undefined when the line
|
|
153
|
+
* does not start a top-level key.
|
|
154
|
+
*/
|
|
155
|
+
export function topLevelKey(line) {
|
|
156
|
+
if (line === "" || /^[\s#]/.test(line) || /^(---|\.\.\.)(\s|$)/.test(line) || line.startsWith("- "))
|
|
157
|
+
return undefined;
|
|
158
|
+
const dq = line.match(/^"((?:[^"\\]|\\.)*)"\s*:(?:\s|$)/);
|
|
159
|
+
if (dq) {
|
|
160
|
+
try {
|
|
161
|
+
return JSON.parse(`"${dq[1]}"`);
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return dq[1];
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const sq = line.match(/^'((?:[^']|'')*)'\s*:(?:\s|$)/);
|
|
168
|
+
if (sq)
|
|
169
|
+
return sq[1].replace(/''/g, "'");
|
|
170
|
+
const plain = line.match(/^(.+?)\s*:(?:\s|$)/);
|
|
171
|
+
return plain ? plain[1] : undefined;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Split the document into top-level sections. A section starts at a line
|
|
175
|
+
* holding a top-level key and runs to the next one; blank lines and
|
|
176
|
+
* column-0 comments at its end are dropped. Unlike splitting on blank
|
|
177
|
+
* lines, this does not depend on the serializer's spacing.
|
|
178
|
+
*/
|
|
179
|
+
export function topLevelSections(yaml) {
|
|
180
|
+
const lines = yaml.replace(/\r\n?/g, "\n").split("\n");
|
|
181
|
+
const out = [];
|
|
182
|
+
let key;
|
|
183
|
+
let start = 0;
|
|
184
|
+
const flush = (end) => {
|
|
185
|
+
if (key === undefined)
|
|
186
|
+
return;
|
|
187
|
+
let last = end;
|
|
188
|
+
while (last > start + 1 && (lines[last - 1].trim() === "" || lines[last - 1].startsWith("#")))
|
|
189
|
+
last--;
|
|
190
|
+
out.push({ key, text: lines.slice(start, last).join("\n") });
|
|
191
|
+
};
|
|
192
|
+
for (let i = 0; i < lines.length; i++) {
|
|
193
|
+
const line = lines[i];
|
|
194
|
+
const k = topLevelKey(line);
|
|
195
|
+
const boundary = k !== undefined || /^(---|\.\.\.)(\s|$)/.test(line);
|
|
196
|
+
if (!boundary)
|
|
197
|
+
continue;
|
|
198
|
+
flush(i);
|
|
199
|
+
key = k;
|
|
200
|
+
start = i;
|
|
201
|
+
}
|
|
202
|
+
flush(lines.length);
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Extract the full section text for a given job name (exact key match, so
|
|
207
|
+
* `build` never returns the `build:linux` section).
|
|
208
|
+
*/
|
|
209
|
+
export function extractJobSection(yaml, jobName) {
|
|
210
|
+
return topLevelSections(yaml).find((s) => s.key === jobName)?.text ?? null;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Extract rules from a job section.
|
|
214
|
+
*/
|
|
215
|
+
export function extractJobRules(section) {
|
|
216
|
+
const rules = [];
|
|
217
|
+
const lines = section.split("\n");
|
|
218
|
+
let inRules = false;
|
|
219
|
+
let currentRule = {};
|
|
220
|
+
for (const line of lines) {
|
|
221
|
+
if (line.match(/^\s+rules:$/)) {
|
|
222
|
+
inRules = true;
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
if (inRules) {
|
|
226
|
+
const ruleStart = line.match(/^\s+- (if|when|changes):\s*(.*)$/);
|
|
227
|
+
if (ruleStart) {
|
|
228
|
+
if (Object.keys(currentRule).length > 0) {
|
|
229
|
+
rules.push(currentRule);
|
|
230
|
+
}
|
|
231
|
+
currentRule = {};
|
|
232
|
+
if (ruleStart[1] === "if")
|
|
233
|
+
currentRule.if = ruleStart[2].trim();
|
|
234
|
+
if (ruleStart[1] === "when")
|
|
235
|
+
currentRule.when = ruleStart[2].trim();
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
const whenMatch = line.match(/^\s+when:\s+(.+)$/);
|
|
239
|
+
if (whenMatch) {
|
|
240
|
+
currentRule.when = whenMatch[1].trim();
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
// End of rules block
|
|
244
|
+
if (!line.match(/^\s+\s/) || line.match(/^\s+[a-z_]+:/) && !line.match(/^\s+when:/)) {
|
|
245
|
+
if (Object.keys(currentRule).length > 0) {
|
|
246
|
+
rules.push(currentRule);
|
|
247
|
+
}
|
|
248
|
+
inRules = false;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (inRules && Object.keys(currentRule).length > 0) {
|
|
253
|
+
rules.push(currentRule);
|
|
254
|
+
}
|
|
255
|
+
return rules;
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Extract container image references from `image:` and `services:` across the
|
|
259
|
+
* pipeline. Handles both the object form (`image:\n name: ...`) and the inline
|
|
260
|
+
* string form (`image: ...`).
|
|
261
|
+
*/
|
|
262
|
+
export function extractImageRefs(yaml) {
|
|
263
|
+
const refs = [];
|
|
264
|
+
for (const { key: job, text: section } of topLevelSections(yaml)) {
|
|
265
|
+
const lines = section.split("\n");
|
|
266
|
+
for (let i = 0; i < lines.length; i++) {
|
|
267
|
+
// image: as object (next indented `name:`) or inline string
|
|
268
|
+
const imgInline = lines[i].match(/^\s+image:\s+(\S.*)$/);
|
|
269
|
+
if (imgInline) {
|
|
270
|
+
refs.push({ job, image: imgInline[1].trim().replace(/^['"]|['"]$/g, ""), source: "image" });
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
if (/^\s+image:\s*$/.test(lines[i])) {
|
|
274
|
+
const nameLine = lines.slice(i + 1, i + 4).find((l) => /^\s+name:\s+/.test(l));
|
|
275
|
+
if (nameLine) {
|
|
276
|
+
const v = nameLine.replace(/^\s+name:\s+/, "").trim().replace(/^['"]|['"]$/g, "");
|
|
277
|
+
refs.push({ job, image: v, source: "image" });
|
|
278
|
+
}
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
// services: list entries — `- name:` or `- 'image'`
|
|
282
|
+
const svcName = lines[i].match(/^\s+-\s+name:\s+(\S.*)$/);
|
|
283
|
+
if (svcName) {
|
|
284
|
+
refs.push({ job, image: svcName[1].trim().replace(/^['"]|['"]$/g, ""), source: "service" });
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return refs;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Parse the top-level `include:` block into structured entries.
|
|
292
|
+
*/
|
|
293
|
+
export function extractIncludes(yaml) {
|
|
294
|
+
const entries = [];
|
|
295
|
+
const section = topLevelSections(yaml).find((s) => s.key === "include")?.text;
|
|
296
|
+
if (!section)
|
|
297
|
+
return entries;
|
|
298
|
+
// Inline string form: `include: <value>` (value on the same line, not a list)
|
|
299
|
+
const inline = section.match(/^include:[ \t]+(\S.*)$/m);
|
|
300
|
+
if (inline) {
|
|
301
|
+
entries.push({ kind: "string", value: inline[1].trim().replace(/^['"]|['"]$/g, "") });
|
|
302
|
+
return entries;
|
|
303
|
+
}
|
|
304
|
+
const lines = section.split("\n");
|
|
305
|
+
let current;
|
|
306
|
+
const push = () => { if (current)
|
|
307
|
+
entries.push(current); current = undefined; };
|
|
308
|
+
for (const line of lines) {
|
|
309
|
+
const start = line.match(/^\s+-\s+(project|remote|component|local|template):\s*(.*)$/);
|
|
310
|
+
if (start) {
|
|
311
|
+
push();
|
|
312
|
+
current = { kind: start[1], value: start[2].trim().replace(/^['"]|['"]$/g, "") };
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
const refLine = line.match(/^\s+ref:\s+(.+)$/);
|
|
316
|
+
if (refLine && current) {
|
|
317
|
+
current.ref = refLine[1].trim().replace(/^['"]|['"]$/g, "");
|
|
318
|
+
}
|
|
319
|
+
// A bare `- 'https://...'` short remote form
|
|
320
|
+
const bare = line.match(/^\s+-\s+(['"]?https?:\/\/\S+['"]?)\s*$/);
|
|
321
|
+
if (bare) {
|
|
322
|
+
push();
|
|
323
|
+
entries.push({ kind: "remote", value: bare[1].replace(/^['"]|['"]$/g, "") });
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
push();
|
|
327
|
+
return entries;
|
|
328
|
+
}
|
|
329
|
+
/** True if a git ref is an immutable pin (40-hex SHA or a vN.N.N-style tag). */
|
|
330
|
+
export function isPinnedRef(ref) {
|
|
331
|
+
if (/^[0-9a-f]{40}$/.test(ref))
|
|
332
|
+
return true;
|
|
333
|
+
// Semver-ish tag with all components fixed (v1.2.3 / 1.2.3)
|
|
334
|
+
if (/^v?\d+\.\d+\.\d+$/.test(ref))
|
|
335
|
+
return true;
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Extract `id_tokens:` declarations (OIDC) per job, with their audiences.
|
|
340
|
+
*/
|
|
341
|
+
export function extractIdTokens(yaml) {
|
|
342
|
+
const out = [];
|
|
343
|
+
for (const { key: job, text: section } of topLevelSections(yaml)) {
|
|
344
|
+
const lines = section.split("\n");
|
|
345
|
+
let inIdTokens = false;
|
|
346
|
+
let idTokensIndent = -1;
|
|
347
|
+
let current;
|
|
348
|
+
const flush = () => { if (current)
|
|
349
|
+
out.push(current); current = undefined; };
|
|
350
|
+
for (const line of lines) {
|
|
351
|
+
if (/^\s+id_tokens:\s*$/.test(line)) {
|
|
352
|
+
inIdTokens = true;
|
|
353
|
+
idTokensIndent = line.search(/\S/);
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
if (!inIdTokens)
|
|
357
|
+
continue;
|
|
358
|
+
const indent = line.search(/\S/);
|
|
359
|
+
if (line.trim() !== "" && indent <= idTokensIndent) {
|
|
360
|
+
flush();
|
|
361
|
+
inIdTokens = false;
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
const tokenName = line.match(/^\s+([A-Z_][A-Z0-9_]*):\s*$/);
|
|
365
|
+
if (tokenName && indent === idTokensIndent + 2) {
|
|
366
|
+
flush();
|
|
367
|
+
current = { job, name: tokenName[1], aud: [] };
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
const audInline = line.match(/^\s+aud:\s+(\S.*)$/);
|
|
371
|
+
if (audInline && current) {
|
|
372
|
+
const v = audInline[1].trim();
|
|
373
|
+
if (v.startsWith("[")) {
|
|
374
|
+
for (const p of v.replace(/^\[|\]$/g, "").split(",")) {
|
|
375
|
+
const t = p.trim().replace(/^['"]|['"]$/g, "");
|
|
376
|
+
if (t)
|
|
377
|
+
current.aud.push(t);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
else {
|
|
381
|
+
current.aud.push(v.replace(/^['"]|['"]$/g, ""));
|
|
382
|
+
}
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
const audItem = line.match(/^\s+-\s+(\S.*)$/);
|
|
386
|
+
if (audItem && current) {
|
|
387
|
+
current.aud.push(audItem[1].trim().replace(/^['"]|['"]$/g, ""));
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
flush();
|
|
391
|
+
}
|
|
392
|
+
return out;
|
|
393
|
+
}
|
|
394
|
+
/** True if a job section's rules make it reachable from merge-request pipelines. */
|
|
395
|
+
export function isMergeRequestReachable(section) {
|
|
396
|
+
return /merge_request_event|CI_MERGE_REQUEST|CI_PIPELINE_SOURCE\s*==\s*['"]?merge_request/.test(section);
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Extract shell command lines from all `script:` family blocks, per job.
|
|
400
|
+
*/
|
|
401
|
+
export function extractScriptCommands(yaml) {
|
|
402
|
+
const out = [];
|
|
403
|
+
for (const { key: job, text: section } of topLevelSections(yaml)) {
|
|
404
|
+
const lines = section.split("\n");
|
|
405
|
+
let inScript = false;
|
|
406
|
+
let scriptIndent = -1;
|
|
407
|
+
for (const line of lines) {
|
|
408
|
+
if (/^\s+(before_script|after_script|script):\s*$/.test(line)) {
|
|
409
|
+
inScript = true;
|
|
410
|
+
scriptIndent = line.search(/\S/);
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
// inline form `script: cmd`
|
|
414
|
+
const inlineScript = line.match(/^\s+(?:before_script|after_script|script):\s+(\S.*)$/);
|
|
415
|
+
if (inlineScript) {
|
|
416
|
+
out.push({ job, command: inlineScript[1].trim().replace(/^['"]|['"]$/g, "") });
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
if (!inScript)
|
|
420
|
+
continue;
|
|
421
|
+
const indent = line.search(/\S/);
|
|
422
|
+
if (line.trim() !== "" && indent <= scriptIndent) {
|
|
423
|
+
inScript = false;
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
const item = line.match(/^\s+-\s+(.*)$/);
|
|
427
|
+
if (item)
|
|
428
|
+
out.push({ job, command: item[1].trim().replace(/^['"]|['"]$/g, "") });
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return out;
|
|
432
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
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
|
+
import { ts } from "@intentius/chant/lint/typescript";
|
|
8
|
+
export const artifactNoExpiryRule = {
|
|
9
|
+
id: "WGL004",
|
|
10
|
+
severity: "warning",
|
|
11
|
+
category: "performance",
|
|
12
|
+
check(context) {
|
|
13
|
+
const { sourceFile } = context;
|
|
14
|
+
const diagnostics = [];
|
|
15
|
+
function visit(node) {
|
|
16
|
+
if (ts.isNewExpression(node)) {
|
|
17
|
+
let isArtifacts = false;
|
|
18
|
+
const expression = node.expression;
|
|
19
|
+
if (ts.isIdentifier(expression) && expression.text === "Artifacts") {
|
|
20
|
+
isArtifacts = true;
|
|
21
|
+
}
|
|
22
|
+
else if (ts.isPropertyAccessExpression(expression) && expression.name.text === "Artifacts") {
|
|
23
|
+
isArtifacts = true;
|
|
24
|
+
}
|
|
25
|
+
if (isArtifacts && node.arguments && node.arguments.length > 0) {
|
|
26
|
+
const props = node.arguments[0];
|
|
27
|
+
if (ts.isObjectLiteralExpression(props)) {
|
|
28
|
+
const hasExpiry = props.properties.some((prop) => {
|
|
29
|
+
if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name)) {
|
|
30
|
+
return prop.name.text === "expireIn" || prop.name.text === "expire_in";
|
|
31
|
+
}
|
|
32
|
+
return false;
|
|
33
|
+
});
|
|
34
|
+
if (!hasExpiry) {
|
|
35
|
+
const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart());
|
|
36
|
+
diagnostics.push({
|
|
37
|
+
file: sourceFile.fileName,
|
|
38
|
+
line: line + 1,
|
|
39
|
+
column: character + 1,
|
|
40
|
+
ruleId: "WGL004",
|
|
41
|
+
severity: "warning",
|
|
42
|
+
message: 'Artifacts without "expireIn" will be kept indefinitely. Set an expiry to save storage.',
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
ts.forEachChild(node, visit);
|
|
49
|
+
}
|
|
50
|
+
visit(sourceFile);
|
|
51
|
+
return diagnostics;
|
|
52
|
+
},
|
|
53
|
+
};
|