@intentius/chant-lexicon-gitlab 0.1.12 → 0.1.14

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.
Files changed (82) hide show
  1. package/README.md +4 -0
  2. package/dist/integrity.json +3 -2
  3. package/dist/manifest.json +1 -1
  4. package/dist/skills/chant-gitlab-migrate.md +117 -0
  5. package/package.json +11 -4
  6. package/src/import/generator.ts +20 -2
  7. package/src/migrate/from-github/actions/index.ts +27 -0
  8. package/src/migrate/from-github/actions/registry.ts +112 -0
  9. package/src/migrate/from-github/actions/tier-1.test.ts +128 -0
  10. package/src/migrate/from-github/actions/tier-1.ts +325 -0
  11. package/src/migrate/from-github/actions/tier-2-3.test.ts +144 -0
  12. package/src/migrate/from-github/actions/tier-2.ts +296 -0
  13. package/src/migrate/from-github/actions/tier-3.ts +124 -0
  14. package/src/migrate/from-github/composites/patterns.ts +167 -0
  15. package/src/migrate/from-github/composites/rewriter.test.ts +98 -0
  16. package/src/migrate/from-github/composites/rewriter.ts +29 -0
  17. package/src/migrate/from-github/diagnostics.ts +45 -0
  18. package/src/migrate/from-github/emit-ts.test.ts +49 -0
  19. package/src/migrate/from-github/emit-yaml.ts +128 -0
  20. package/src/migrate/from-github/expressions.test.ts +124 -0
  21. package/src/migrate/from-github/expressions.ts +302 -0
  22. package/src/migrate/from-github/fixtures/README.md +27 -0
  23. package/src/migrate/from-github/fixtures/marketplace-actions/tier-1/actions-checkout/expected-report.json +15 -0
  24. package/src/migrate/from-github/fixtures/marketplace-actions/tier-1/actions-checkout/expected.gitlab-ci.yml +13 -0
  25. package/src/migrate/from-github/fixtures/marketplace-actions/tier-1/actions-checkout/input.yml +7 -0
  26. package/src/migrate/from-github/fixtures/marketplace-actions/tier-1/actions-setup-node/expected-report.json +20 -0
  27. package/src/migrate/from-github/fixtures/marketplace-actions/tier-1/actions-setup-node/expected.gitlab-ci.yml +20 -0
  28. package/src/migrate/from-github/fixtures/marketplace-actions/tier-1/actions-setup-node/input.yml +12 -0
  29. package/src/migrate/from-github/fixtures/marketplace-actions/tier-1/actions-setup-python/expected-report.json +20 -0
  30. package/src/migrate/from-github/fixtures/marketplace-actions/tier-1/actions-setup-python/expected.gitlab-ci.yml +17 -0
  31. package/src/migrate/from-github/fixtures/marketplace-actions/tier-1/actions-setup-python/input.yml +12 -0
  32. package/src/migrate/from-github/fixtures/marketplace-actions/tier-1/docker-build-push/expected-report.json +24 -0
  33. package/src/migrate/from-github/fixtures/marketplace-actions/tier-1/docker-build-push/expected.gitlab-ci.yml +20 -0
  34. package/src/migrate/from-github/fixtures/marketplace-actions/tier-1/docker-build-push/input.yml +16 -0
  35. package/src/migrate/from-github/fixtures/marketplace-actions/tier-1/upload-download-artifact/expected-report.json +24 -0
  36. package/src/migrate/from-github/fixtures/marketplace-actions/tier-1/upload-download-artifact/expected.gitlab-ci.yml +27 -0
  37. package/src/migrate/from-github/fixtures/marketplace-actions/tier-1/upload-download-artifact/input.yml +20 -0
  38. package/src/migrate/from-github/fixtures/marketplace-actions/tier-2/codecov-action/expected-report.json +24 -0
  39. package/src/migrate/from-github/fixtures/marketplace-actions/tier-2/codecov-action/expected.gitlab-ci.yml +15 -0
  40. package/src/migrate/from-github/fixtures/marketplace-actions/tier-2/codecov-action/input.yml +13 -0
  41. package/src/migrate/from-github/fixtures/marketplace-actions/tier-2/setup-bun/expected-report.json +20 -0
  42. package/src/migrate/from-github/fixtures/marketplace-actions/tier-2/setup-bun/expected.gitlab-ci.yml +17 -0
  43. package/src/migrate/from-github/fixtures/marketplace-actions/tier-2/setup-bun/input.yml +11 -0
  44. package/src/migrate/from-github/fixtures/marketplace-actions/tier-3/paths-filter/expected-report.json +21 -0
  45. package/src/migrate/from-github/fixtures/marketplace-actions/tier-3/paths-filter/expected.gitlab-ci.yml +15 -0
  46. package/src/migrate/from-github/fixtures/marketplace-actions/tier-3/paths-filter/input.yml +11 -0
  47. package/src/migrate/from-github/fixtures/syntax-mapping/01-triggers/expected-report.json +20 -0
  48. package/src/migrate/from-github/fixtures/syntax-mapping/01-triggers/expected.gitlab-ci.yml +16 -0
  49. package/src/migrate/from-github/fixtures/syntax-mapping/01-triggers/input.yml +12 -0
  50. package/src/migrate/from-github/fixtures/syntax-mapping/02-stages-needs/expected-report.json +13 -0
  51. package/src/migrate/from-github/fixtures/syntax-mapping/02-stages-needs/expected.gitlab-ci.yml +31 -0
  52. package/src/migrate/from-github/fixtures/syntax-mapping/02-stages-needs/input.yml +16 -0
  53. package/src/migrate/from-github/fixtures/syntax-mapping/03-matrix/expected-report.json +13 -0
  54. package/src/migrate/from-github/fixtures/syntax-mapping/03-matrix/expected.gitlab-ci.yml +20 -0
  55. package/src/migrate/from-github/fixtures/syntax-mapping/03-matrix/input.yml +10 -0
  56. package/src/migrate/from-github/fixtures/syntax-mapping/04-env-secrets/expected-report.json +13 -0
  57. package/src/migrate/from-github/fixtures/syntax-mapping/04-env-secrets/expected.gitlab-ci.yml +18 -0
  58. package/src/migrate/from-github/fixtures/syntax-mapping/04-env-secrets/input.yml +11 -0
  59. package/src/migrate/from-github/fixtures/syntax-mapping/05-conditional/expected-report.json +13 -0
  60. package/src/migrate/from-github/fixtures/syntax-mapping/05-conditional/expected.gitlab-ci.yml +24 -0
  61. package/src/migrate/from-github/fixtures/syntax-mapping/05-conditional/input.yml +12 -0
  62. package/src/migrate/from-github/fixtures/syntax-mapping/06-services/expected-report.json +13 -0
  63. package/src/migrate/from-github/fixtures/syntax-mapping/06-services/expected.gitlab-ci.yml +18 -0
  64. package/src/migrate/from-github/fixtures/syntax-mapping/06-services/input.yml +13 -0
  65. package/src/migrate/from-github/fixtures/syntax-mapping/07-job-control/expected-report.json +20 -0
  66. package/src/migrate/from-github/fixtures/syntax-mapping/07-job-control/expected.gitlab-ci.yml +17 -0
  67. package/src/migrate/from-github/fixtures/syntax-mapping/07-job-control/input.yml +13 -0
  68. package/src/migrate/from-github/fixtures/syntax-mapping/08-workflow-name/expected-report.json +13 -0
  69. package/src/migrate/from-github/fixtures/syntax-mapping/08-workflow-name/expected.gitlab-ci.yml +14 -0
  70. package/src/migrate/from-github/fixtures/syntax-mapping/08-workflow-name/input.yml +7 -0
  71. package/src/migrate/from-github/fixtures.test.ts +92 -0
  72. package/src/migrate/from-github/index.ts +128 -0
  73. package/src/migrate/from-github/provenance.ts +68 -0
  74. package/src/migrate/from-github/rules.ts +82 -0
  75. package/src/migrate/from-github/stages.test.ts +99 -0
  76. package/src/migrate/from-github/stages.ts +177 -0
  77. package/src/migrate/from-github/transformer.test.ts +278 -0
  78. package/src/migrate/from-github/transformer.ts +719 -0
  79. package/src/migrate.mcp.test.ts +69 -0
  80. package/src/plugin.test.ts +7 -3
  81. package/src/plugin.ts +105 -1
  82. package/src/skills/chant-gitlab-migrate.md +117 -0
@@ -0,0 +1,99 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { inferStages, type GhJobSummary } from "./stages";
3
+
4
+ function job(name: string, needs: string[] = [], id?: string): GhJobSummary {
5
+ return { logicalId: id ?? name, originalName: name, needs };
6
+ }
7
+
8
+ describe("inferStages", () => {
9
+ test("single job → build", () => {
10
+ const r = inferStages([job("build")]);
11
+ expect(r.stageByJob.get("build")).toBe("build");
12
+ expect(r.stages).toEqual(["build"]);
13
+ });
14
+
15
+ test("name heuristic: lint goes to lint stage", () => {
16
+ const r = inferStages([job("lint-check")]);
17
+ expect(r.stageByJob.get("lint-check")).toBe("lint");
18
+ });
19
+
20
+ test("name heuristic: deploy at depth 0 still maps to deploy", () => {
21
+ const r = inferStages([job("deploy-prod")]);
22
+ expect(r.stageByJob.get("deploy-prod")).toBe("deploy");
23
+ });
24
+
25
+ test("linear chain build → test → deploy", () => {
26
+ const r = inferStages([
27
+ job("build"),
28
+ job("test", ["build"]),
29
+ job("deploy", ["test"]),
30
+ ]);
31
+ expect(r.stageByJob.get("build")).toBe("build");
32
+ expect(r.stageByJob.get("test")).toBe("test");
33
+ expect(r.stageByJob.get("deploy")).toBe("deploy");
34
+ expect(r.stages).toEqual(["build", "test", "deploy"]);
35
+ });
36
+
37
+ test("diamond DAG", () => {
38
+ const r = inferStages([
39
+ job("setup"),
40
+ job("test-unit", ["setup"]),
41
+ job("test-int", ["setup"]),
42
+ job("ship", ["test-unit", "test-int"]),
43
+ ]);
44
+ expect(r.stageByJob.get("setup")).toBe("build");
45
+ expect(r.stageByJob.get("test-unit")).toBe("test");
46
+ expect(r.stageByJob.get("test-int")).toBe("test");
47
+ expect(r.stageByJob.get("ship")).toBe("deploy");
48
+ });
49
+
50
+ test("multiple roots", () => {
51
+ const r = inferStages([
52
+ job("build-a"),
53
+ job("build-b"),
54
+ job("test-a", ["build-a"]),
55
+ job("test-b", ["build-b"]),
56
+ ]);
57
+ expect(r.stageByJob.get("build-a")).toBe("build");
58
+ expect(r.stageByJob.get("build-b")).toBe("build");
59
+ expect(r.stageByJob.get("test-a")).toBe("test");
60
+ expect(r.stageByJob.get("test-b")).toBe("test");
61
+ });
62
+
63
+ test("cycle: each job in its own stage + needs-review", () => {
64
+ const r = inferStages([
65
+ job("a", ["b"]),
66
+ job("b", ["a"]),
67
+ ]);
68
+ expect(r.stageByJob.get("a")).toBe("cycle-a");
69
+ expect(r.stageByJob.get("b")).toBe("cycle-b");
70
+ expect(r.provenance.some((p) => p.rule === "MIG-NEEDS-CYCLE-001")).toBe(true);
71
+ });
72
+
73
+ test("post-N for depths beyond deploy", () => {
74
+ const r = inferStages([
75
+ job("a"),
76
+ job("b", ["a"]),
77
+ job("c", ["b"]),
78
+ job("d", ["c"]),
79
+ job("e", ["d"]),
80
+ ]);
81
+ expect(r.stageByJob.get("d")).toBe("post-1");
82
+ expect(r.stageByJob.get("e")).toBe("post-2");
83
+ });
84
+
85
+ test("stages sorted lint < build < test < deploy", () => {
86
+ const r = inferStages([
87
+ job("deploy"),
88
+ job("test", ["build"]),
89
+ job("build"),
90
+ job("lint"),
91
+ ]);
92
+ // lint and build are both at depth 0, deploy is at depth 0 too (no needs)
93
+ // The sort guarantees lint, build, deploy ordering for those at the same depth.
94
+ const indexOf = (s: string) => r.stages.indexOf(s);
95
+ expect(indexOf("lint")).toBeLessThan(indexOf("build"));
96
+ expect(indexOf("build")).toBeLessThan(indexOf("test"));
97
+ expect(indexOf("test")).toBeLessThan(indexOf("deploy"));
98
+ });
99
+ });
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Stage inference for GitHub jobs → GitLab stages.
3
+ *
4
+ * GitHub Actions has no `stages:` concept; jobs only declare optional
5
+ * `needs:` edges. GitLab CI requires every job to belong to a stage.
6
+ *
7
+ * Approach (Kahn topological sort on the `needs:` DAG):
8
+ * 1. Build adjacency from `needs:` (jobs without `needs:` are depth 0).
9
+ * 2. Peel zero-indegree nodes; assign stage by depth + name heuristic.
10
+ * 3. Map depths to stage names:
11
+ * depth 0 → "lint" / "build" (by name heuristic, default "build")
12
+ * depth 1 → "test"
13
+ * depth 2 → "deploy"
14
+ * depth N>2 → `post-${N-2}`
15
+ * 4. If a cycle is detected, place each remaining job in its own stage
16
+ * and append a needs-review record so the user notices.
17
+ */
18
+
19
+ import type { ProvenanceRecord } from "./provenance";
20
+
21
+ export interface GhJobSummary {
22
+ logicalId: string;
23
+ needs: string[];
24
+ /** Original GH job key (kebab-case) for name heuristics */
25
+ originalName: string;
26
+ }
27
+
28
+ export interface StageInferenceResult {
29
+ /** Map from job logicalId to assigned stage name */
30
+ stageByJob: Map<string, string>;
31
+ /** Ordered list of distinct stages, declaration-order */
32
+ stages: string[];
33
+ /** Any provenance records (cycles, synthesis events) */
34
+ provenance: ProvenanceRecord[];
35
+ }
36
+
37
+ const NAME_HEURISTIC_PATTERNS: Array<{ pattern: RegExp; stage: string }> = [
38
+ { pattern: /^(lint|check|fmt|format|style|typecheck|tsc)/i, stage: "lint" },
39
+ { pattern: /^(build|compile|bundle|pack)/i, stage: "build" },
40
+ { pattern: /^(test|spec|e2e|integration|unit)/i, stage: "test" },
41
+ { pattern: /^(deploy|publish|release|push)/i, stage: "deploy" },
42
+ ];
43
+
44
+ function heuristicStage(name: string, fallback: string): string {
45
+ for (const { pattern, stage } of NAME_HEURISTIC_PATTERNS) {
46
+ if (pattern.test(name)) return stage;
47
+ }
48
+ return fallback;
49
+ }
50
+
51
+ function defaultStageForDepth(depth: number): string {
52
+ if (depth === 0) return "build";
53
+ if (depth === 1) return "test";
54
+ if (depth === 2) return "deploy";
55
+ return `post-${depth - 2}`;
56
+ }
57
+
58
+ export function inferStages(jobs: GhJobSummary[]): StageInferenceResult {
59
+ const provenance: ProvenanceRecord[] = [];
60
+ const stageByJob = new Map<string, string>();
61
+ const jobsById = new Map(jobs.map((j) => [j.logicalId, j]));
62
+
63
+ // Compute indegree based on needs that resolve to known jobs
64
+ const indegree = new Map<string, number>();
65
+ for (const job of jobs) {
66
+ indegree.set(job.logicalId, 0);
67
+ }
68
+ for (const job of jobs) {
69
+ for (const dep of job.needs) {
70
+ if (jobsById.has(dep)) {
71
+ indegree.set(job.logicalId, (indegree.get(job.logicalId) ?? 0) + 1);
72
+ }
73
+ }
74
+ }
75
+
76
+ // Kahn's algorithm, recording depth per node
77
+ const depth = new Map<string, number>();
78
+ const queue: string[] = [];
79
+ for (const job of jobs) {
80
+ if ((indegree.get(job.logicalId) ?? 0) === 0) {
81
+ queue.push(job.logicalId);
82
+ depth.set(job.logicalId, 0);
83
+ }
84
+ }
85
+
86
+ const processed = new Set<string>();
87
+ while (queue.length > 0) {
88
+ const current = queue.shift()!;
89
+ processed.add(current);
90
+ const currentDepth = depth.get(current) ?? 0;
91
+ for (const next of jobs) {
92
+ if (next.needs.includes(current)) {
93
+ const remaining = (indegree.get(next.logicalId) ?? 0) - 1;
94
+ indegree.set(next.logicalId, remaining);
95
+ const proposed = currentDepth + 1;
96
+ const existing = depth.get(next.logicalId);
97
+ if (existing === undefined || proposed > existing) {
98
+ depth.set(next.logicalId, proposed);
99
+ }
100
+ if (remaining === 0) {
101
+ queue.push(next.logicalId);
102
+ }
103
+ }
104
+ }
105
+ }
106
+
107
+ // Detect cycles — any job not processed is on a cycle
108
+ const cycleJobs = jobs.filter((j) => !processed.has(j.logicalId));
109
+ for (const job of cycleJobs) {
110
+ provenance.push({
111
+ gitlabPath: `jobs.${job.logicalId}.stage`,
112
+ gitlabLogicalId: job.logicalId,
113
+ sourceKey: `jobs.${job.originalName}.needs`,
114
+ category: "needs-review",
115
+ rule: "MIG-NEEDS-CYCLE-001",
116
+ note: `Job "${job.originalName}" is part of a needs: cycle; assigned its own stage`,
117
+ });
118
+ }
119
+
120
+ // Assign stages
121
+ for (const job of jobs) {
122
+ if (cycleJobs.includes(job)) {
123
+ stageByJob.set(job.logicalId, `cycle-${job.logicalId}`);
124
+ continue;
125
+ }
126
+ const d = depth.get(job.logicalId) ?? 0;
127
+ const defaultStage = defaultStageForDepth(d);
128
+ const stage = d === 0
129
+ ? heuristicStage(job.originalName, defaultStage)
130
+ : d === 1
131
+ ? heuristicStage(job.originalName, defaultStage)
132
+ : defaultStage;
133
+ stageByJob.set(job.logicalId, stage);
134
+ if (stage !== defaultStage) {
135
+ provenance.push({
136
+ gitlabPath: `jobs.${job.logicalId}.stage`,
137
+ gitlabLogicalId: job.logicalId,
138
+ sourceKey: `jobs.${job.originalName}`,
139
+ category: "synthesis",
140
+ rule: "MIG-STAGE-HEURISTIC",
141
+ note: `Stage "${stage}" inferred from job name "${job.originalName}"`,
142
+ });
143
+ } else {
144
+ provenance.push({
145
+ gitlabPath: `jobs.${job.logicalId}.stage`,
146
+ gitlabLogicalId: job.logicalId,
147
+ sourceKey: `jobs.${job.originalName}`,
148
+ category: "synthesis",
149
+ rule: "MIG-STAGE-TOPO",
150
+ note: `Stage "${stage}" assigned from needs: depth ${d}`,
151
+ });
152
+ }
153
+ }
154
+
155
+ // Collect distinct stages, ordered by appearance
156
+ const stagesSet = new Set<string>();
157
+ const stagesOrdered: string[] = [];
158
+ for (const job of jobs) {
159
+ const s = stageByJob.get(job.logicalId);
160
+ if (s && !stagesSet.has(s)) {
161
+ stagesSet.add(s);
162
+ stagesOrdered.push(s);
163
+ }
164
+ }
165
+ // Sort by canonical depth order: lint, build, test, deploy, post-N, cycle-*
166
+ const canonicalOrder = ["lint", "build", "test", "deploy"];
167
+ stagesOrdered.sort((a, b) => {
168
+ const ai = canonicalOrder.indexOf(a);
169
+ const bi = canonicalOrder.indexOf(b);
170
+ if (ai !== -1 && bi !== -1) return ai - bi;
171
+ if (ai !== -1) return -1;
172
+ if (bi !== -1) return 1;
173
+ return a.localeCompare(b);
174
+ });
175
+
176
+ return { stageByJob, stages: stagesOrdered, provenance };
177
+ }
@@ -0,0 +1,278 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { transform, detectGitHubWorkflow } from "./index";
3
+
4
+ describe("detectGitHubWorkflow", () => {
5
+ test("recognises a minimal GH workflow", () => {
6
+ const yml = `name: CI\non: push\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - run: echo hello\n`;
7
+ expect(detectGitHubWorkflow(yml)).toBe(true);
8
+ });
9
+
10
+ test("rejects unrelated YAML", () => {
11
+ const yml = `stages:\n - build\nbuild-job:\n script:\n - make\n`;
12
+ expect(detectGitHubWorkflow(yml)).toBe(false);
13
+ });
14
+ });
15
+
16
+ describe("transform — minimal workflow", () => {
17
+ test("emits jobs at top level + stages", async () => {
18
+ const yml = `name: CI
19
+ on: push
20
+ jobs:
21
+ build:
22
+ runs-on: ubuntu-latest
23
+ steps:
24
+ - run: npm ci
25
+ - run: npm run build
26
+ test:
27
+ runs-on: ubuntu-latest
28
+ needs: build
29
+ steps:
30
+ - run: npm test
31
+ `;
32
+ const result = await transform(yml, { sourceFile: "ci.yml" });
33
+ expect(result.output).toContain("stages:");
34
+ expect(result.output).toContain("build:");
35
+ expect(result.output).toContain("test:");
36
+ expect(result.output).toContain("- npm ci");
37
+ expect(result.output).toContain("- npm test");
38
+ expect(result.stages).toContain("build");
39
+ expect(result.stages).toContain("test");
40
+ });
41
+
42
+ test("workflow name → workflow.name", async () => {
43
+ const yml = `name: My CI
44
+ on: push
45
+ jobs:
46
+ build:
47
+ runs-on: ubuntu-latest
48
+ steps:
49
+ - run: make
50
+ `;
51
+ const result = await transform(yml);
52
+ expect(result.output).toMatch(/workflow:[\s\S]*name:/);
53
+ });
54
+
55
+ test("runs-on: ubuntu-latest → image: ubuntu:24.04", async () => {
56
+ const yml = `on: push
57
+ jobs:
58
+ job1:
59
+ runs-on: ubuntu-latest
60
+ steps:
61
+ - run: echo hi
62
+ `;
63
+ const result = await transform(yml);
64
+ expect(result.output).toContain("ubuntu:24.04");
65
+ });
66
+
67
+ test("env at workflow + job level lands in variables", async () => {
68
+ const yml = `on: push
69
+ env:
70
+ TOP: top-value
71
+ jobs:
72
+ build:
73
+ runs-on: ubuntu-latest
74
+ env:
75
+ JOBVAR: job-value
76
+ steps:
77
+ - run: echo $TOP $JOBVAR
78
+ `;
79
+ const result = await transform(yml);
80
+ expect(result.output).toContain("TOP");
81
+ expect(result.output).toContain("JOBVAR");
82
+ });
83
+
84
+ test("matrix → parallel.matrix", async () => {
85
+ const yml = `on: push
86
+ jobs:
87
+ test:
88
+ runs-on: ubuntu-latest
89
+ strategy:
90
+ matrix:
91
+ node: [18, 20, 22]
92
+ os: [ubuntu, windows]
93
+ steps:
94
+ - run: echo testing
95
+ `;
96
+ const result = await transform(yml);
97
+ expect(result.output).toContain("parallel:");
98
+ expect(result.output).toContain("matrix:");
99
+ expect(result.output).toContain("NODE");
100
+ expect(result.output).toContain("OS");
101
+ });
102
+
103
+ test("timeout-minutes → timeout", async () => {
104
+ const yml = `on: push
105
+ jobs:
106
+ build:
107
+ runs-on: ubuntu-latest
108
+ timeout-minutes: 30
109
+ steps:
110
+ - run: make
111
+ `;
112
+ const result = await transform(yml);
113
+ expect(result.output).toMatch(/timeout:\s*'?30 minutes'?/);
114
+ });
115
+
116
+ test("continue-on-error → allow_failure", async () => {
117
+ const yml = `on: push
118
+ jobs:
119
+ flaky:
120
+ runs-on: ubuntu-latest
121
+ continue-on-error: true
122
+ steps:
123
+ - run: maybe-fail
124
+ `;
125
+ const result = await transform(yml);
126
+ expect(result.output).toMatch(/allow_failure:\s*true/);
127
+ });
128
+
129
+ test("if: github.ref == 'refs/heads/main' translates", async () => {
130
+ const yml = `on: push
131
+ jobs:
132
+ deploy:
133
+ runs-on: ubuntu-latest
134
+ if: github.ref == 'refs/heads/main'
135
+ steps:
136
+ - run: ./deploy.sh
137
+ `;
138
+ const result = await transform(yml);
139
+ expect(result.output).toContain("$CI_COMMIT_REF_NAME");
140
+ expect(result.output).toContain("rules:");
141
+ });
142
+
143
+ test("permissions emits needs-review", async () => {
144
+ const yml = `on: push
145
+ jobs:
146
+ build:
147
+ runs-on: ubuntu-latest
148
+ permissions:
149
+ contents: read
150
+ steps:
151
+ - run: make
152
+ `;
153
+ const result = await transform(yml);
154
+ expect(result.provenance.some((p) => p.rule === "MIG-PERMISSIONS-001")).toBe(true);
155
+ });
156
+
157
+ test("actions/checkout becomes a no-op skip (Tier 1 registry)", async () => {
158
+ const yml = `on: push
159
+ jobs:
160
+ build:
161
+ runs-on: ubuntu-latest
162
+ steps:
163
+ - uses: actions/checkout@v4
164
+ - run: npm test
165
+ `;
166
+ const result = await transform(yml);
167
+ // With Tier 1 registry, actions/checkout is intentionally skipped
168
+ expect(result.provenance.some((p) => p.rule === "ACT-actions-checkout" && p.category === "skipped")).toBe(true);
169
+ // npm test still emitted
170
+ expect(result.output).toContain("- npm test");
171
+ });
172
+
173
+ test("unknown action emits MIG-ACTION-UNKNOWN", async () => {
174
+ const yml = `on: push
175
+ jobs:
176
+ build:
177
+ runs-on: ubuntu-latest
178
+ steps:
179
+ - uses: some-org/never-mapped-action@v1
180
+ - run: echo done
181
+ `;
182
+ const result = await transform(yml);
183
+ expect(result.provenance.some((p) => p.rule === "MIG-ACTION-UNKNOWN")).toBe(true);
184
+ });
185
+ });
186
+
187
+ describe("transform — provenance + diagnostics", () => {
188
+ test("clean translation emits no error diagnostics", async () => {
189
+ const yml = `on: push
190
+ jobs:
191
+ build:
192
+ runs-on: ubuntu-latest
193
+ steps:
194
+ - run: npm test
195
+ `;
196
+ const result = await transform(yml);
197
+ const errors = result.diagnostics.filter((d) => d.severity === "error");
198
+ expect(errors).toHaveLength(0);
199
+ });
200
+
201
+ test("strict escalates needs-review to error", async () => {
202
+ const yml = `on: schedule
203
+ jobs:
204
+ cron:
205
+ runs-on: ubuntu-latest
206
+ steps:
207
+ - run: ./run.sh
208
+ `;
209
+ const result = await transform(yml, { strict: true });
210
+ const errors = result.diagnostics.filter((d) => d.severity === "error");
211
+ expect(errors.length).toBeGreaterThan(0);
212
+ });
213
+
214
+ test("matrix-driven image substitutes to $NODE (not leaking ${{ }})", async () => {
215
+ const yml = `on: push
216
+ jobs:
217
+ test:
218
+ runs-on: ubuntu-latest
219
+ strategy:
220
+ matrix:
221
+ node: [18, 20, 22]
222
+ steps:
223
+ - uses: actions/setup-node@v4
224
+ with:
225
+ node-version: \${{ matrix.node }}
226
+ - run: npm test
227
+ `;
228
+ const result = await transform(yml);
229
+ expect(result.output).toContain("image: node:$node");
230
+ expect(result.output).not.toContain("\${{ matrix.node }}");
231
+ });
232
+
233
+ test("env value with embedded ${{ secrets.X }} substitutes to $X", async () => {
234
+ const yml = `on: push
235
+ jobs:
236
+ deploy:
237
+ runs-on: ubuntu-latest
238
+ env:
239
+ TOKEN: \${{ secrets.API_TOKEN }}
240
+ steps:
241
+ - run: ./deploy.sh
242
+ `;
243
+ const result = await transform(yml);
244
+ // YAML emitter quotes strings starting with `$` as `'$API_TOKEN'`
245
+ expect(result.output).toMatch(/TOKEN:\s*'?\$API_TOKEN'?/);
246
+ expect(result.output).not.toContain("\${{ secrets.API_TOKEN }}");
247
+ });
248
+
249
+ test("container.image with embedded expression substitutes", async () => {
250
+ const yml = `on: push
251
+ jobs:
252
+ test:
253
+ runs-on: ubuntu-latest
254
+ container:
255
+ image: my-registry/app:\${{ github.sha }}
256
+ steps:
257
+ - run: ./test.sh
258
+ `;
259
+ const result = await transform(yml);
260
+ expect(result.output).toContain("my-registry/app:$CI_COMMIT_SHA");
261
+ expect(result.output).not.toContain("\${{ github.sha }}");
262
+ });
263
+
264
+ test("ir.metadata.migration is set", async () => {
265
+ const yml = `on: push
266
+ jobs:
267
+ build:
268
+ runs-on: ubuntu-latest
269
+ steps:
270
+ - run: make
271
+ `;
272
+ const result = await transform(yml, { sourceFile: "ci.yml" });
273
+ expect(result.ir.metadata?.migration).toEqual({
274
+ sourceFile: "ci.yml",
275
+ sourceTool: "github-actions",
276
+ });
277
+ });
278
+ });