@intentius/chant-lexicon-gitlab 0.1.23 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/integrity.json +25 -5
- package/dist/manifest.json +1 -1
- package/dist/rules/wgl029.ts +59 -0
- package/dist/rules/wgl030.ts +48 -0
- package/dist/rules/wgl031.ts +37 -0
- package/dist/rules/wgl032.ts +68 -0
- package/dist/rules/wgl033.ts +38 -0
- package/dist/rules/wgl034.ts +42 -0
- package/dist/rules/wgl035.ts +44 -0
- package/dist/rules/wgl036.ts +44 -0
- package/dist/rules/wgl037.ts +47 -0
- package/dist/rules/wgl038.ts +48 -0
- package/dist/rules/wgl039.ts +41 -0
- package/dist/rules/wgl040.ts +42 -0
- package/dist/rules/wgl041.ts +57 -0
- package/dist/rules/wgl042.ts +46 -0
- package/dist/rules/wgl043.ts +45 -0
- package/dist/rules/wgl044.ts +41 -0
- package/dist/rules/wgl045.ts +57 -0
- package/dist/rules/wgl046.ts +43 -0
- package/dist/rules/wgl047.ts +41 -0
- package/dist/rules/wgl048.ts +37 -0
- package/dist/rules/yaml-helpers.ts +214 -0
- package/dist/skills/chant-gitlab-migrate.md +10 -0
- package/dist/skills/chant-gitlab-patterns.md +15 -0
- package/package.json +1 -1
- package/src/codegen/docs.ts +251 -2
- package/src/composites/composites.test.ts +58 -0
- package/src/composites/index.ts +2 -0
- package/src/composites/mr-plan-report.ts +82 -0
- package/src/index.ts +2 -2
- package/src/lint/post-synth/wgl029.test.ts +77 -0
- package/src/lint/post-synth/wgl029.ts +59 -0
- package/src/lint/post-synth/wgl030.test.ts +57 -0
- package/src/lint/post-synth/wgl030.ts +48 -0
- package/src/lint/post-synth/wgl031.test.ts +66 -0
- package/src/lint/post-synth/wgl031.ts +37 -0
- package/src/lint/post-synth/wgl032.test.ts +53 -0
- package/src/lint/post-synth/wgl032.ts +68 -0
- package/src/lint/post-synth/wgl033.test.ts +58 -0
- package/src/lint/post-synth/wgl033.ts +38 -0
- package/src/lint/post-synth/wgl034.test.ts +60 -0
- package/src/lint/post-synth/wgl034.ts +42 -0
- package/src/lint/post-synth/wgl035.test.ts +42 -0
- package/src/lint/post-synth/wgl035.ts +44 -0
- package/src/lint/post-synth/wgl036.test.ts +49 -0
- package/src/lint/post-synth/wgl036.ts +44 -0
- package/src/lint/post-synth/wgl037.test.ts +45 -0
- package/src/lint/post-synth/wgl037.ts +47 -0
- package/src/lint/post-synth/wgl038.test.ts +54 -0
- package/src/lint/post-synth/wgl038.ts +48 -0
- package/src/lint/post-synth/wgl039.test.ts +39 -0
- package/src/lint/post-synth/wgl039.ts +41 -0
- package/src/lint/post-synth/wgl040.test.ts +48 -0
- package/src/lint/post-synth/wgl040.ts +42 -0
- package/src/lint/post-synth/wgl041.test.ts +49 -0
- package/src/lint/post-synth/wgl041.ts +57 -0
- package/src/lint/post-synth/wgl042.test.ts +45 -0
- package/src/lint/post-synth/wgl042.ts +46 -0
- package/src/lint/post-synth/wgl043.test.ts +43 -0
- package/src/lint/post-synth/wgl043.ts +45 -0
- package/src/lint/post-synth/wgl044.test.ts +46 -0
- package/src/lint/post-synth/wgl044.ts +41 -0
- package/src/lint/post-synth/wgl045.test.ts +48 -0
- package/src/lint/post-synth/wgl045.ts +57 -0
- package/src/lint/post-synth/wgl046.test.ts +67 -0
- package/src/lint/post-synth/wgl046.ts +43 -0
- package/src/lint/post-synth/wgl047.test.ts +40 -0
- package/src/lint/post-synth/wgl047.ts +41 -0
- package/src/lint/post-synth/wgl048.test.ts +56 -0
- package/src/lint/post-synth/wgl048.ts +37 -0
- package/src/lint/post-synth/yaml-helpers.ts +214 -0
- package/src/lint/rules/data/known-include-sources.ts +41 -0
- package/src/lint/rules/data/untrusted-variables.ts +31 -0
- package/src/mcp/context-tools.test.ts +178 -0
- package/src/mcp/context-tools.ts +282 -0
- package/src/migrate/from-github/diagnostics.ts +18 -0
- package/src/migrate/from-github/index.ts +20 -1
- package/src/migrate/from-github/provenance.ts +28 -0
- package/src/migrate/from-github/rules.ts +7 -0
- package/src/migrate/from-github/security.test.ts +98 -0
- package/src/migrate/from-github/security.ts +208 -0
- package/src/plugin.test.ts +30 -1
- package/src/plugin.ts +4 -0
- package/src/skills/chant-gitlab-migrate.md +10 -0
- package/src/skills/chant-gitlab-patterns.md +15 -0
|
@@ -223,3 +223,217 @@ export function extractJobRules(section: string): ParsedRule[] {
|
|
|
223
223
|
|
|
224
224
|
return rules;
|
|
225
225
|
}
|
|
226
|
+
|
|
227
|
+
/** A container image reference (job/default `image:` or a `services:` entry). */
|
|
228
|
+
export interface ImageRef {
|
|
229
|
+
/** Owning job name, or "default" for the global image. */
|
|
230
|
+
job: string;
|
|
231
|
+
/** The image reference (e.g. `node:20`). */
|
|
232
|
+
image: string;
|
|
233
|
+
/** Where the image was declared. */
|
|
234
|
+
source: "image" | "service";
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Extract container image references from `image:` and `services:` across the
|
|
239
|
+
* pipeline. Handles both the object form (`image:\n name: ...`) and the inline
|
|
240
|
+
* string form (`image: ...`).
|
|
241
|
+
*/
|
|
242
|
+
export function extractImageRefs(yaml: string): ImageRef[] {
|
|
243
|
+
const refs: ImageRef[] = [];
|
|
244
|
+
for (const section of yaml.split("\n\n")) {
|
|
245
|
+
const lines = section.split("\n");
|
|
246
|
+
const top = lines[0]?.match(/^(\.?[a-z][a-z0-9_.-]*):/i);
|
|
247
|
+
const job = top ? top[1] : "default";
|
|
248
|
+
|
|
249
|
+
for (let i = 0; i < lines.length; i++) {
|
|
250
|
+
// image: as object (next indented `name:`) or inline string
|
|
251
|
+
const imgInline = lines[i].match(/^\s+image:\s+(\S.*)$/);
|
|
252
|
+
if (imgInline) {
|
|
253
|
+
refs.push({ job, image: imgInline[1].trim().replace(/^['"]|['"]$/g, ""), source: "image" });
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if (/^\s+image:\s*$/.test(lines[i])) {
|
|
257
|
+
const nameLine = lines.slice(i + 1, i + 4).find((l) => /^\s+name:\s+/.test(l));
|
|
258
|
+
if (nameLine) {
|
|
259
|
+
const v = nameLine.replace(/^\s+name:\s+/, "").trim().replace(/^['"]|['"]$/g, "");
|
|
260
|
+
refs.push({ job, image: v, source: "image" });
|
|
261
|
+
}
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
// services: list entries — `- name:` or `- 'image'`
|
|
265
|
+
const svcName = lines[i].match(/^\s+-\s+name:\s+(\S.*)$/);
|
|
266
|
+
if (svcName) {
|
|
267
|
+
refs.push({ job, image: svcName[1].trim().replace(/^['"]|['"]$/g, ""), source: "service" });
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return refs;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** An `include:` entry from the top-level include block. */
|
|
275
|
+
export interface IncludeEntry {
|
|
276
|
+
kind: "project" | "remote" | "component" | "local" | "template" | "string";
|
|
277
|
+
/** The primary value (project path, URL, component address, …). */
|
|
278
|
+
value: string;
|
|
279
|
+
/** `ref:` for project includes, if present. */
|
|
280
|
+
ref?: string;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Parse the top-level `include:` block into structured entries.
|
|
285
|
+
*/
|
|
286
|
+
export function extractIncludes(yaml: string): IncludeEntry[] {
|
|
287
|
+
const entries: IncludeEntry[] = [];
|
|
288
|
+
const section = yaml.split("\n\n").find((s) => /^include:/.test(s));
|
|
289
|
+
if (!section) return entries;
|
|
290
|
+
|
|
291
|
+
// Inline string form: `include: <value>` (value on the same line, not a list)
|
|
292
|
+
const inline = section.match(/^include:[ \t]+(\S.*)$/m);
|
|
293
|
+
if (inline) {
|
|
294
|
+
entries.push({ kind: "string", value: inline[1].trim().replace(/^['"]|['"]$/g, "") });
|
|
295
|
+
return entries;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const lines = section.split("\n");
|
|
299
|
+
let current: IncludeEntry | undefined;
|
|
300
|
+
const push = () => { if (current) entries.push(current); current = undefined; };
|
|
301
|
+
for (const line of lines) {
|
|
302
|
+
const start = line.match(/^\s+-\s+(project|remote|component|local|template):\s*(.*)$/);
|
|
303
|
+
if (start) {
|
|
304
|
+
push();
|
|
305
|
+
current = { kind: start[1] as IncludeEntry["kind"], value: start[2].trim().replace(/^['"]|['"]$/g, "") };
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
const refLine = line.match(/^\s+ref:\s+(.+)$/);
|
|
309
|
+
if (refLine && current) {
|
|
310
|
+
current.ref = refLine[1].trim().replace(/^['"]|['"]$/g, "");
|
|
311
|
+
}
|
|
312
|
+
// A bare `- 'https://...'` short remote form
|
|
313
|
+
const bare = line.match(/^\s+-\s+(['"]?https?:\/\/\S+['"]?)\s*$/);
|
|
314
|
+
if (bare) {
|
|
315
|
+
push();
|
|
316
|
+
entries.push({ kind: "remote", value: bare[1].replace(/^['"]|['"]$/g, "") });
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
push();
|
|
320
|
+
return entries;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** True if a git ref is an immutable pin (40-hex SHA or a vN.N.N-style tag). */
|
|
324
|
+
export function isPinnedRef(ref: string): boolean {
|
|
325
|
+
if (/^[0-9a-f]{40}$/.test(ref)) return true;
|
|
326
|
+
// Semver-ish tag with all components fixed (v1.2.3 / 1.2.3)
|
|
327
|
+
if (/^v?\d+\.\d+\.\d+$/.test(ref)) return true;
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** An `id_tokens:` OIDC token declaration within a job. */
|
|
332
|
+
export interface IdTokenDecl {
|
|
333
|
+
job: string;
|
|
334
|
+
/** Token variable name (e.g. `GCP_ID_TOKEN`). */
|
|
335
|
+
name: string;
|
|
336
|
+
/** Declared audiences (`aud:`), empty if none. */
|
|
337
|
+
aud: string[];
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Extract `id_tokens:` declarations (OIDC) per job, with their audiences.
|
|
342
|
+
*/
|
|
343
|
+
export function extractIdTokens(yaml: string): IdTokenDecl[] {
|
|
344
|
+
const out: IdTokenDecl[] = [];
|
|
345
|
+
for (const section of yaml.split("\n\n")) {
|
|
346
|
+
const lines = section.split("\n");
|
|
347
|
+
const top = lines[0]?.match(/^(\.?[a-z][a-z0-9_.-]*):/i);
|
|
348
|
+
if (!top) continue;
|
|
349
|
+
const job = top[1];
|
|
350
|
+
|
|
351
|
+
let inIdTokens = false;
|
|
352
|
+
let idTokensIndent = -1;
|
|
353
|
+
let current: IdTokenDecl | undefined;
|
|
354
|
+
const flush = () => { if (current) out.push(current); current = undefined; };
|
|
355
|
+
|
|
356
|
+
for (const line of lines) {
|
|
357
|
+
if (/^\s+id_tokens:\s*$/.test(line)) {
|
|
358
|
+
inIdTokens = true;
|
|
359
|
+
idTokensIndent = line.search(/\S/);
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
if (!inIdTokens) continue;
|
|
363
|
+
const indent = line.search(/\S/);
|
|
364
|
+
if (line.trim() !== "" && indent <= idTokensIndent) { flush(); inIdTokens = false; continue; }
|
|
365
|
+
|
|
366
|
+
const tokenName = line.match(/^\s+([A-Z_][A-Z0-9_]*):\s*$/);
|
|
367
|
+
if (tokenName && indent === idTokensIndent + 2) {
|
|
368
|
+
flush();
|
|
369
|
+
current = { job, name: tokenName[1], aud: [] };
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
const audInline = line.match(/^\s+aud:\s+(\S.*)$/);
|
|
373
|
+
if (audInline && current) {
|
|
374
|
+
const v = audInline[1].trim();
|
|
375
|
+
if (v.startsWith("[")) {
|
|
376
|
+
for (const p of v.replace(/^\[|\]$/g, "").split(",")) {
|
|
377
|
+
const t = p.trim().replace(/^['"]|['"]$/g, "");
|
|
378
|
+
if (t) current.aud.push(t);
|
|
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
|
+
|
|
395
|
+
/** True if a job section's rules make it reachable from merge-request pipelines. */
|
|
396
|
+
export function isMergeRequestReachable(section: string): boolean {
|
|
397
|
+
return /merge_request_event|CI_MERGE_REQUEST|CI_PIPELINE_SOURCE\s*==\s*['"]?merge_request/.test(section);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** A shell command line from a `script:` / `before_script:` / `after_script:`. */
|
|
401
|
+
export interface ScriptCommand {
|
|
402
|
+
job: string;
|
|
403
|
+
command: string;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Extract shell command lines from all `script:` family blocks, per job.
|
|
408
|
+
*/
|
|
409
|
+
export function extractScriptCommands(yaml: string): ScriptCommand[] {
|
|
410
|
+
const out: ScriptCommand[] = [];
|
|
411
|
+
for (const section of yaml.split("\n\n")) {
|
|
412
|
+
const lines = section.split("\n");
|
|
413
|
+
const top = lines[0]?.match(/^(\.?[a-z][a-z0-9_.-]*):/i);
|
|
414
|
+
if (!top) continue;
|
|
415
|
+
const job = top[1];
|
|
416
|
+
|
|
417
|
+
let inScript = false;
|
|
418
|
+
let scriptIndent = -1;
|
|
419
|
+
for (const line of lines) {
|
|
420
|
+
if (/^\s+(before_script|after_script|script):\s*$/.test(line)) {
|
|
421
|
+
inScript = true;
|
|
422
|
+
scriptIndent = line.search(/\S/);
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
// inline form `script: cmd`
|
|
426
|
+
const inlineScript = line.match(/^\s+(?:before_script|after_script|script):\s+(\S.*)$/);
|
|
427
|
+
if (inlineScript) {
|
|
428
|
+
out.push({ job, command: inlineScript[1].trim().replace(/^['"]|['"]$/g, "") });
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
if (!inScript) continue;
|
|
432
|
+
const indent = line.search(/\S/);
|
|
433
|
+
if (line.trim() !== "" && indent <= scriptIndent) { inScript = false; continue; }
|
|
434
|
+
const item = line.match(/^\s+-\s+(.*)$/);
|
|
435
|
+
if (item) out.push({ job, command: item[1].trim().replace(/^['"]|['"]$/g, "") });
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return out;
|
|
439
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vendored snapshot of well-known GitLab CI include/component source projects,
|
|
3
|
+
* used by the look-alike check (WGL032) as the reference set: an `include` or
|
|
4
|
+
* `component` source that is a near-miss (edit distance 1–2) of one of these —
|
|
5
|
+
* but not an exact match — is likely a typo or an impersonation.
|
|
6
|
+
*
|
|
7
|
+
* Advisory only. Refresh source: GitLab-maintained CI templates and the popular
|
|
8
|
+
* CI/CD Catalog components. Accept staleness — a stale entry only weakens
|
|
9
|
+
* look-alike detection, it never blocks a build.
|
|
10
|
+
*/
|
|
11
|
+
export const KNOWN_INCLUDE_SOURCES: readonly string[] = [
|
|
12
|
+
"gitlab-org/gitlab",
|
|
13
|
+
"gitlab-org/security-products/ci-templates",
|
|
14
|
+
"components/sast",
|
|
15
|
+
"components/secret-detection",
|
|
16
|
+
"components/dependency-scanning",
|
|
17
|
+
"components/container-scanning",
|
|
18
|
+
"components/code-quality",
|
|
19
|
+
"gitlab-org/components/sast",
|
|
20
|
+
"gitlab-org/components/secret-detection",
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
/** Levenshtein edit distance, capped: returns early once it exceeds `max`. */
|
|
24
|
+
export function editDistance(a: string, b: string, max: number): number {
|
|
25
|
+
if (a === b) return 0;
|
|
26
|
+
if (Math.abs(a.length - b.length) > max) return max + 1;
|
|
27
|
+
let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
28
|
+
for (let i = 1; i <= a.length; i++) {
|
|
29
|
+
const curr = [i];
|
|
30
|
+
let rowMin = i;
|
|
31
|
+
for (let j = 1; j <= b.length; j++) {
|
|
32
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
33
|
+
const val = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
|
|
34
|
+
curr.push(val);
|
|
35
|
+
if (val < rowMin) rowMin = val;
|
|
36
|
+
}
|
|
37
|
+
if (rowMin > max) return max + 1;
|
|
38
|
+
prev = curr;
|
|
39
|
+
}
|
|
40
|
+
return prev[b.length];
|
|
41
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vendored snapshot of GitLab CI predefined variables whose values are
|
|
3
|
+
* attacker-controllable — branch/tag names, commit and merge-request titles and
|
|
4
|
+
* descriptions, author identity. Interpolating these into a `script:` (or using
|
|
5
|
+
* them as a gate) lets a crafted branch name or commit title influence what runs.
|
|
6
|
+
*
|
|
7
|
+
* Source: GitLab "Predefined CI/CD variables" reference, filtered to the values
|
|
8
|
+
* an outside contributor can set on a fork or merge request. Advisory and
|
|
9
|
+
* necessarily incomplete; editing this list is the refresh mechanism.
|
|
10
|
+
*/
|
|
11
|
+
export const UNTRUSTED_CI_VARIABLES: readonly string[] = [
|
|
12
|
+
"CI_COMMIT_TITLE",
|
|
13
|
+
"CI_COMMIT_MESSAGE",
|
|
14
|
+
"CI_COMMIT_DESCRIPTION",
|
|
15
|
+
"CI_COMMIT_REF_NAME",
|
|
16
|
+
"CI_COMMIT_REF_SLUG",
|
|
17
|
+
"CI_COMMIT_BRANCH",
|
|
18
|
+
"CI_COMMIT_TAG",
|
|
19
|
+
"CI_COMMIT_AUTHOR",
|
|
20
|
+
"CI_MERGE_REQUEST_TITLE",
|
|
21
|
+
"CI_MERGE_REQUEST_DESCRIPTION",
|
|
22
|
+
"CI_MERGE_REQUEST_SOURCE_BRANCH_NAME",
|
|
23
|
+
"CI_MERGE_REQUEST_SOURCE_BRANCH_SHA",
|
|
24
|
+
"CI_EXTERNAL_PULL_REQUEST_SOURCE_BRANCH_NAME",
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
/** Reference forms a variable can take in a script: `$VAR` or `${VAR}`. */
|
|
28
|
+
export function variableReferenced(text: string, name: string): boolean {
|
|
29
|
+
const re = new RegExp(`\\$\\{?${name}\\}?(?![A-Z0-9_])`);
|
|
30
|
+
return re.test(text);
|
|
31
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { gitlabContextTools, downstreamJobs, componentPinned } from "./context-tools";
|
|
6
|
+
|
|
7
|
+
const tools = Object.fromEntries(gitlabContextTools().map((t) => [t.name, t]));
|
|
8
|
+
|
|
9
|
+
async function call(name: string, params: Record<string, unknown>): Promise<unknown> {
|
|
10
|
+
return tools[name].handler(params);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("downstreamJobs / componentPinned (#327)", () => {
|
|
14
|
+
test("downstreamJobs follows the needs chain transitively", () => {
|
|
15
|
+
const yaml = `build:
|
|
16
|
+
stage: build
|
|
17
|
+
script:
|
|
18
|
+
- echo build
|
|
19
|
+
|
|
20
|
+
test:
|
|
21
|
+
stage: test
|
|
22
|
+
needs:
|
|
23
|
+
- build
|
|
24
|
+
script:
|
|
25
|
+
- echo test
|
|
26
|
+
|
|
27
|
+
deploy:
|
|
28
|
+
stage: deploy
|
|
29
|
+
needs:
|
|
30
|
+
- test
|
|
31
|
+
script:
|
|
32
|
+
- echo deploy
|
|
33
|
+
`;
|
|
34
|
+
expect(downstreamJobs(yaml, "build").sort()).toEqual(["deploy", "test"]);
|
|
35
|
+
expect(downstreamJobs(yaml, "test")).toEqual(["deploy"]);
|
|
36
|
+
expect(downstreamJobs(yaml, "deploy")).toEqual([]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("componentPinned recognises a fixed version", () => {
|
|
40
|
+
expect(componentPinned("gitlab.com/g/comp@1.2.3")).toBe(true);
|
|
41
|
+
expect(componentPinned("gitlab.com/g/comp@main")).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("gitlab context tools — end to end (#327/#328)", () => {
|
|
46
|
+
let dir: string;
|
|
47
|
+
beforeEach(async () => {
|
|
48
|
+
dir = join(tmpdir(), `chant-mcp-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
49
|
+
await mkdir(dir, { recursive: true });
|
|
50
|
+
await writeFile(
|
|
51
|
+
join(dir, "pipeline.infra.ts"),
|
|
52
|
+
`const M = Symbol.for("chant.declarable");
|
|
53
|
+
export const buildApp = { [M]: true, lexicon: "gitlab", entityType: "GitLab::CI::Job", kind: "resource",
|
|
54
|
+
props: { stage: "build", image: { name: "node:20" }, script: ["echo build"] } };
|
|
55
|
+
export const testApp = { [M]: true, lexicon: "gitlab", entityType: "GitLab::CI::Job", kind: "resource",
|
|
56
|
+
props: { stage: "test", script: ["npm test"], needs: ["build-app"] } };
|
|
57
|
+
`,
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
afterEach(async () => {
|
|
61
|
+
await rm(dir, { recursive: true, force: true });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("gitlab:pipeline returns stages and jobs", async () => {
|
|
65
|
+
const out = (await call("gitlab:pipeline", { path: dir })) as {
|
|
66
|
+
stages: string[];
|
|
67
|
+
jobs: Array<{ name: string; stage: string | null; runsAfter: string[] }>;
|
|
68
|
+
};
|
|
69
|
+
expect(out.stages).toEqual(expect.arrayContaining(["build", "test"]));
|
|
70
|
+
const names = out.jobs.map((j) => j.name);
|
|
71
|
+
expect(names).toEqual(expect.arrayContaining(["build-app", "test-app"]));
|
|
72
|
+
expect(out.jobs.find((j) => j.name === "test-app")?.runsAfter).toContain("build-app");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("gitlab:references reports the unpinned image", async () => {
|
|
76
|
+
const out = (await call("gitlab:references", { path: dir })) as Array<{ kind: string; source: string; pinned: boolean | null }>;
|
|
77
|
+
const img = out.find((r) => r.kind === "image" && r.source === "node:20");
|
|
78
|
+
expect(img).toBeDefined();
|
|
79
|
+
expect(img?.pinned).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("gitlab:checks returns the WGL findings (unpinned image trips WGL031)", async () => {
|
|
83
|
+
const out = (await call("gitlab:checks", { path: dir })) as { findings: Array<{ id: string; severity: string; job: string | null; message: string }> };
|
|
84
|
+
expect(Array.isArray(out.findings)).toBe(true);
|
|
85
|
+
expect(out.findings.some((f) => f.id === "WGL031")).toBe(true);
|
|
86
|
+
for (const f of out.findings) {
|
|
87
|
+
expect(typeof f.id).toBe("string");
|
|
88
|
+
expect(typeof f.message).toBe("string");
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("gitlab:affected lists downstream jobs", async () => {
|
|
93
|
+
const out = (await call("gitlab:affected", { path: dir, job: "build-app" })) as { job: string; wouldRerun: string[] };
|
|
94
|
+
expect(out.wouldRerun).toContain("test-app");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("gitlab:pipeline-yaml returns the generated YAML for a path", async () => {
|
|
98
|
+
const out = (await call("gitlab:pipeline-yaml", { path: dir })) as { yaml: string };
|
|
99
|
+
expect(typeof out.yaml).toBe("string");
|
|
100
|
+
expect(out.yaml).toContain("stages:");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("gitlab:source traces a job back to its declaring file", async () => {
|
|
104
|
+
const out = (await call("gitlab:source", { path: dir, job: "build-app" })) as {
|
|
105
|
+
job: string; found: boolean; entity: string; from: string | null; via: string | null;
|
|
106
|
+
};
|
|
107
|
+
expect(out.found).toBe(true);
|
|
108
|
+
expect(out.entity).toBe("buildApp");
|
|
109
|
+
expect(out.from).toMatch(/pipeline\.infra\.ts$/);
|
|
110
|
+
expect(out.via).toBeNull(); // plain declarable, not from a composite
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("gitlab:source reports not-found for an unknown job", async () => {
|
|
114
|
+
const out = (await call("gitlab:source", { path: dir, job: "nope" })) as { job: string; found: boolean };
|
|
115
|
+
expect(out.found).toBe(false);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("gitlab:owns reports a declared job as owned, an unknown one as not", async () => {
|
|
119
|
+
const owned = (await call("gitlab:owns", { path: dir, job: "test-app" })) as { owned: boolean; basis: string };
|
|
120
|
+
expect(owned.owned).toBe(true);
|
|
121
|
+
expect(owned.basis).toBe("declared-in-source");
|
|
122
|
+
const foreign = (await call("gitlab:owns", { path: dir, job: "not-here" })) as { owned: boolean };
|
|
123
|
+
expect(foreign.owned).toBe(false);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("gitlab:compare — migration safety view (#327)", () => {
|
|
128
|
+
let dir: string;
|
|
129
|
+
let wf: string;
|
|
130
|
+
beforeEach(async () => {
|
|
131
|
+
dir = join(tmpdir(), `chant-mcp-cmp-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
132
|
+
await mkdir(dir, { recursive: true });
|
|
133
|
+
wf = join(dir, "ci.yml");
|
|
134
|
+
await writeFile(
|
|
135
|
+
wf,
|
|
136
|
+
`name: CI
|
|
137
|
+
on:
|
|
138
|
+
push:
|
|
139
|
+
branches: [main]
|
|
140
|
+
permissions:
|
|
141
|
+
contents: read
|
|
142
|
+
jobs:
|
|
143
|
+
build:
|
|
144
|
+
runs-on: ubuntu-latest
|
|
145
|
+
steps:
|
|
146
|
+
- uses: actions/checkout@${"a".repeat(40)}
|
|
147
|
+
- run: npm ci
|
|
148
|
+
`,
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
afterEach(async () => {
|
|
152
|
+
await rm(dir, { recursive: true, force: true });
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("reports the SHA pin as lost across the migration edge", async () => {
|
|
156
|
+
const out = (await call("gitlab:compare", { file: wf })) as {
|
|
157
|
+
found: boolean;
|
|
158
|
+
properties: Array<{ property: string; fate: string }>;
|
|
159
|
+
summary: Record<string, number>;
|
|
160
|
+
};
|
|
161
|
+
expect(out.found).toBe(true);
|
|
162
|
+
const pin = out.properties.find((p) => p.property === "Pinned action SHA");
|
|
163
|
+
expect(pin?.fate).toBe("lost");
|
|
164
|
+
expect(out.summary.lost).toBeGreaterThanOrEqual(1);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("not-found for a missing file", async () => {
|
|
168
|
+
const out = (await call("gitlab:compare", { file: join(dir, "nope.yml") })) as { found: boolean };
|
|
169
|
+
expect(out.found).toBe(false);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("not-found for a non-workflow file", async () => {
|
|
173
|
+
const notWf = join(dir, "plain.yml");
|
|
174
|
+
await writeFile(notWf, "hello: world\n");
|
|
175
|
+
const out = (await call("gitlab:compare", { file: notWf })) as { found: boolean };
|
|
176
|
+
expect(out.found).toBe(false);
|
|
177
|
+
});
|
|
178
|
+
});
|