@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.
- package/README.md +4 -0
- package/dist/integrity.json +3 -2
- package/dist/manifest.json +1 -1
- package/dist/skills/chant-gitlab-migrate.md +117 -0
- package/package.json +11 -4
- package/src/import/generator.ts +20 -2
- package/src/migrate/from-github/actions/index.ts +27 -0
- package/src/migrate/from-github/actions/registry.ts +112 -0
- package/src/migrate/from-github/actions/tier-1.test.ts +128 -0
- package/src/migrate/from-github/actions/tier-1.ts +325 -0
- package/src/migrate/from-github/actions/tier-2-3.test.ts +144 -0
- package/src/migrate/from-github/actions/tier-2.ts +296 -0
- package/src/migrate/from-github/actions/tier-3.ts +124 -0
- package/src/migrate/from-github/composites/patterns.ts +167 -0
- package/src/migrate/from-github/composites/rewriter.test.ts +98 -0
- package/src/migrate/from-github/composites/rewriter.ts +29 -0
- package/src/migrate/from-github/diagnostics.ts +45 -0
- package/src/migrate/from-github/emit-ts.test.ts +49 -0
- package/src/migrate/from-github/emit-yaml.ts +128 -0
- package/src/migrate/from-github/expressions.test.ts +124 -0
- package/src/migrate/from-github/expressions.ts +302 -0
- package/src/migrate/from-github/fixtures/README.md +27 -0
- package/src/migrate/from-github/fixtures/marketplace-actions/tier-1/actions-checkout/expected-report.json +15 -0
- package/src/migrate/from-github/fixtures/marketplace-actions/tier-1/actions-checkout/expected.gitlab-ci.yml +13 -0
- package/src/migrate/from-github/fixtures/marketplace-actions/tier-1/actions-checkout/input.yml +7 -0
- package/src/migrate/from-github/fixtures/marketplace-actions/tier-1/actions-setup-node/expected-report.json +20 -0
- package/src/migrate/from-github/fixtures/marketplace-actions/tier-1/actions-setup-node/expected.gitlab-ci.yml +20 -0
- package/src/migrate/from-github/fixtures/marketplace-actions/tier-1/actions-setup-node/input.yml +12 -0
- package/src/migrate/from-github/fixtures/marketplace-actions/tier-1/actions-setup-python/expected-report.json +20 -0
- package/src/migrate/from-github/fixtures/marketplace-actions/tier-1/actions-setup-python/expected.gitlab-ci.yml +17 -0
- package/src/migrate/from-github/fixtures/marketplace-actions/tier-1/actions-setup-python/input.yml +12 -0
- package/src/migrate/from-github/fixtures/marketplace-actions/tier-1/docker-build-push/expected-report.json +24 -0
- package/src/migrate/from-github/fixtures/marketplace-actions/tier-1/docker-build-push/expected.gitlab-ci.yml +20 -0
- package/src/migrate/from-github/fixtures/marketplace-actions/tier-1/docker-build-push/input.yml +16 -0
- package/src/migrate/from-github/fixtures/marketplace-actions/tier-1/upload-download-artifact/expected-report.json +24 -0
- package/src/migrate/from-github/fixtures/marketplace-actions/tier-1/upload-download-artifact/expected.gitlab-ci.yml +27 -0
- package/src/migrate/from-github/fixtures/marketplace-actions/tier-1/upload-download-artifact/input.yml +20 -0
- package/src/migrate/from-github/fixtures/marketplace-actions/tier-2/codecov-action/expected-report.json +24 -0
- package/src/migrate/from-github/fixtures/marketplace-actions/tier-2/codecov-action/expected.gitlab-ci.yml +15 -0
- package/src/migrate/from-github/fixtures/marketplace-actions/tier-2/codecov-action/input.yml +13 -0
- package/src/migrate/from-github/fixtures/marketplace-actions/tier-2/setup-bun/expected-report.json +20 -0
- package/src/migrate/from-github/fixtures/marketplace-actions/tier-2/setup-bun/expected.gitlab-ci.yml +17 -0
- package/src/migrate/from-github/fixtures/marketplace-actions/tier-2/setup-bun/input.yml +11 -0
- package/src/migrate/from-github/fixtures/marketplace-actions/tier-3/paths-filter/expected-report.json +21 -0
- package/src/migrate/from-github/fixtures/marketplace-actions/tier-3/paths-filter/expected.gitlab-ci.yml +15 -0
- package/src/migrate/from-github/fixtures/marketplace-actions/tier-3/paths-filter/input.yml +11 -0
- package/src/migrate/from-github/fixtures/syntax-mapping/01-triggers/expected-report.json +20 -0
- package/src/migrate/from-github/fixtures/syntax-mapping/01-triggers/expected.gitlab-ci.yml +16 -0
- package/src/migrate/from-github/fixtures/syntax-mapping/01-triggers/input.yml +12 -0
- package/src/migrate/from-github/fixtures/syntax-mapping/02-stages-needs/expected-report.json +13 -0
- package/src/migrate/from-github/fixtures/syntax-mapping/02-stages-needs/expected.gitlab-ci.yml +31 -0
- package/src/migrate/from-github/fixtures/syntax-mapping/02-stages-needs/input.yml +16 -0
- package/src/migrate/from-github/fixtures/syntax-mapping/03-matrix/expected-report.json +13 -0
- package/src/migrate/from-github/fixtures/syntax-mapping/03-matrix/expected.gitlab-ci.yml +20 -0
- package/src/migrate/from-github/fixtures/syntax-mapping/03-matrix/input.yml +10 -0
- package/src/migrate/from-github/fixtures/syntax-mapping/04-env-secrets/expected-report.json +13 -0
- package/src/migrate/from-github/fixtures/syntax-mapping/04-env-secrets/expected.gitlab-ci.yml +18 -0
- package/src/migrate/from-github/fixtures/syntax-mapping/04-env-secrets/input.yml +11 -0
- package/src/migrate/from-github/fixtures/syntax-mapping/05-conditional/expected-report.json +13 -0
- package/src/migrate/from-github/fixtures/syntax-mapping/05-conditional/expected.gitlab-ci.yml +24 -0
- package/src/migrate/from-github/fixtures/syntax-mapping/05-conditional/input.yml +12 -0
- package/src/migrate/from-github/fixtures/syntax-mapping/06-services/expected-report.json +13 -0
- package/src/migrate/from-github/fixtures/syntax-mapping/06-services/expected.gitlab-ci.yml +18 -0
- package/src/migrate/from-github/fixtures/syntax-mapping/06-services/input.yml +13 -0
- package/src/migrate/from-github/fixtures/syntax-mapping/07-job-control/expected-report.json +20 -0
- package/src/migrate/from-github/fixtures/syntax-mapping/07-job-control/expected.gitlab-ci.yml +17 -0
- package/src/migrate/from-github/fixtures/syntax-mapping/07-job-control/input.yml +13 -0
- package/src/migrate/from-github/fixtures/syntax-mapping/08-workflow-name/expected-report.json +13 -0
- package/src/migrate/from-github/fixtures/syntax-mapping/08-workflow-name/expected.gitlab-ci.yml +14 -0
- package/src/migrate/from-github/fixtures/syntax-mapping/08-workflow-name/input.yml +7 -0
- package/src/migrate/from-github/fixtures.test.ts +92 -0
- package/src/migrate/from-github/index.ts +128 -0
- package/src/migrate/from-github/provenance.ts +68 -0
- package/src/migrate/from-github/rules.ts +82 -0
- package/src/migrate/from-github/stages.test.ts +99 -0
- package/src/migrate/from-github/stages.ts +177 -0
- package/src/migrate/from-github/transformer.test.ts +278 -0
- package/src/migrate/from-github/transformer.ts +719 -0
- package/src/migrate.mcp.test.ts +69 -0
- package/src/plugin.test.ts +7 -3
- package/src/plugin.ts +105 -1
- package/src/skills/chant-gitlab-migrate.md +117 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `--use-composites` orchestrator: walks the registered patterns and
|
|
3
|
+
* applies the first match. Patterns are tried in declaration order
|
|
4
|
+
* (NodePipeline before NodeCI so the 2-job shape wins over the 1-job
|
|
5
|
+
* shape when both could match).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { TemplateIR } from "@intentius/chant/import/parser";
|
|
9
|
+
import type { ProvenanceRecord } from "../provenance";
|
|
10
|
+
import { PATTERNS, type CompositePattern } from "./patterns";
|
|
11
|
+
|
|
12
|
+
export function applyComposites(
|
|
13
|
+
ir: TemplateIR,
|
|
14
|
+
registry: CompositePattern[] = PATTERNS,
|
|
15
|
+
): { ir: TemplateIR; provenance: ProvenanceRecord[] } {
|
|
16
|
+
const provenance: ProvenanceRecord[] = [];
|
|
17
|
+
let current = ir;
|
|
18
|
+
for (const pattern of registry) {
|
|
19
|
+
const match = pattern.match(current);
|
|
20
|
+
if (match) {
|
|
21
|
+
const result = pattern.rewrite(current, match);
|
|
22
|
+
current = result.ir;
|
|
23
|
+
provenance.push(...result.provenance);
|
|
24
|
+
// Only one composite rewrite per migrate run (keeps semantics simple)
|
|
25
|
+
break;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return { ir: current, provenance };
|
|
29
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert ProvenanceRecord[] into LintDiagnostic[] for SARIF output.
|
|
3
|
+
*
|
|
4
|
+
* Mapping:
|
|
5
|
+
* - category "needs-review" → severity "warning" (or "error" if --strict)
|
|
6
|
+
* - category "skipped" → severity "info"
|
|
7
|
+
* - category "action-map" tier 3 → severity "warning"
|
|
8
|
+
* - all others → no diagnostic (clean translation)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { LintDiagnostic } from "@intentius/chant/lint/rule";
|
|
12
|
+
import type { ProvenanceRecord } from "./provenance";
|
|
13
|
+
|
|
14
|
+
export interface DiagnosticOptions {
|
|
15
|
+
/** When true, needs-review records become errors instead of warnings */
|
|
16
|
+
strict?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function provenanceToDiagnostics(
|
|
20
|
+
records: ProvenanceRecord[],
|
|
21
|
+
opts: DiagnosticOptions = {},
|
|
22
|
+
): LintDiagnostic[] {
|
|
23
|
+
const out: LintDiagnostic[] = [];
|
|
24
|
+
for (const r of records) {
|
|
25
|
+
let severity: "error" | "warning" | "info" | undefined;
|
|
26
|
+
if (r.category === "needs-review") {
|
|
27
|
+
severity = opts.strict ? "error" : "warning";
|
|
28
|
+
} else if (r.category === "skipped") {
|
|
29
|
+
severity = "info";
|
|
30
|
+
} else if (r.category === "action-map" && r.mappingTier === 3) {
|
|
31
|
+
severity = "warning";
|
|
32
|
+
} else {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
out.push({
|
|
36
|
+
file: r.sourceFile ?? "<input>",
|
|
37
|
+
line: r.sourceLine ?? 1,
|
|
38
|
+
column: r.sourceColumn ?? 1,
|
|
39
|
+
ruleId: r.rule,
|
|
40
|
+
severity,
|
|
41
|
+
message: r.note ?? r.rule,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { transform } from "./index";
|
|
3
|
+
|
|
4
|
+
describe("transform --emit ts", () => {
|
|
5
|
+
test("produces TS source importing from @intentius/chant-lexicon-gitlab", async () => {
|
|
6
|
+
const yml = `on: push
|
|
7
|
+
jobs:
|
|
8
|
+
build:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
steps:
|
|
11
|
+
- run: npm ci
|
|
12
|
+
- run: npm run build
|
|
13
|
+
`;
|
|
14
|
+
const result = await transform(yml, { emit: "ts", sourceFile: "ci.yml" });
|
|
15
|
+
expect(result.output).toContain('from "@intentius/chant-lexicon-gitlab"');
|
|
16
|
+
expect(result.output).toContain("new Job(");
|
|
17
|
+
expect(result.output).toContain("Migrated from ci.yml");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("emits TODO footer for NeedsReview items", async () => {
|
|
21
|
+
const yml = `on: schedule
|
|
22
|
+
jobs:
|
|
23
|
+
cron:
|
|
24
|
+
runs-on: ubuntu-latest
|
|
25
|
+
steps:
|
|
26
|
+
- run: ./run.sh
|
|
27
|
+
`;
|
|
28
|
+
const result = await transform(yml, { emit: "ts", sourceFile: "ci.yml" });
|
|
29
|
+
expect(result.output).toContain("TODO(migration)");
|
|
30
|
+
expect(result.output).toContain("MIG-ON-SCHEDULE");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("yaml and ts emit modes produce equivalent IR (same jobs)", async () => {
|
|
34
|
+
const yml = `on: push
|
|
35
|
+
jobs:
|
|
36
|
+
build:
|
|
37
|
+
runs-on: ubuntu-latest
|
|
38
|
+
needs: []
|
|
39
|
+
steps:
|
|
40
|
+
- run: make
|
|
41
|
+
`;
|
|
42
|
+
const yamlResult = await transform(yml, { emit: "yaml" });
|
|
43
|
+
const tsResult = await transform(yml, { emit: "ts" });
|
|
44
|
+
// Same IR underneath; output strings differ.
|
|
45
|
+
expect(yamlResult.ir.resources.map((r) => r.logicalId)).toEqual(
|
|
46
|
+
tsResult.ir.resources.map((r) => r.logicalId),
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Direct GitLab TemplateIR → `.gitlab-ci.yml` text emission.
|
|
3
|
+
*
|
|
4
|
+
* We deliberately bypass the existing `gitlabSerializer.serialize()` because
|
|
5
|
+
* that path consumes `Map<string, Declarable>` (entity instances built by
|
|
6
|
+
* user TS code), not a raw IR. Going through entity instances would require
|
|
7
|
+
* runtime construction of typed objects we don't have. Direct emission is
|
|
8
|
+
* ~60 LOC.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { TemplateIR } from "@intentius/chant/import/parser";
|
|
12
|
+
import { emitYAML } from "@intentius/chant/yaml";
|
|
13
|
+
|
|
14
|
+
const RESERVED_TOP_LEVEL_ORDER = [
|
|
15
|
+
"spec",
|
|
16
|
+
"include",
|
|
17
|
+
"default",
|
|
18
|
+
"stages",
|
|
19
|
+
"variables",
|
|
20
|
+
"workflow",
|
|
21
|
+
"image",
|
|
22
|
+
"services",
|
|
23
|
+
"before_script",
|
|
24
|
+
"after_script",
|
|
25
|
+
"cache",
|
|
26
|
+
"pages",
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
function isReservedTopLevelKey(key: string): boolean {
|
|
30
|
+
return RESERVED_TOP_LEVEL_ORDER.includes(key);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function emitKeyValue(key: string, value: unknown, indent: number): string {
|
|
34
|
+
const prefix = " ".repeat(indent);
|
|
35
|
+
if (value === null || value === undefined) return `${prefix}${key}: null`;
|
|
36
|
+
if (typeof value === "boolean" || typeof value === "number") return `${prefix}${key}: ${value}`;
|
|
37
|
+
if (typeof value === "string") return `${prefix}${key}: ${emitYAML(value, indent + 1)}`;
|
|
38
|
+
if (Array.isArray(value)) {
|
|
39
|
+
if (value.length === 0) return `${prefix}${key}: []`;
|
|
40
|
+
return `${prefix}${key}:${emitYAML(value, indent + 1)}`;
|
|
41
|
+
}
|
|
42
|
+
if (typeof value === "object") {
|
|
43
|
+
if (Object.keys(value).length === 0) return `${prefix}${key}: {}`;
|
|
44
|
+
return `${prefix}${key}:${emitYAML(value, indent + 1)}`;
|
|
45
|
+
}
|
|
46
|
+
return `${prefix}${key}: ${String(value)}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function emitBlock(obj: Record<string, unknown>, indent: number): string {
|
|
50
|
+
return Object.entries(obj)
|
|
51
|
+
.filter(([, v]) => v !== undefined)
|
|
52
|
+
.map(([k, v]) => emitKeyValue(k, v, indent))
|
|
53
|
+
.join("\n");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Convert a GitLab `TemplateIR` (produced by the GH→GL transformer) into
|
|
58
|
+
* `.gitlab-ci.yml` text.
|
|
59
|
+
*/
|
|
60
|
+
export function emitGitlabYaml(ir: TemplateIR): string {
|
|
61
|
+
const top: Record<string, unknown> = {};
|
|
62
|
+
|
|
63
|
+
// Stages from metadata
|
|
64
|
+
const meta = (ir.metadata ?? {}) as Record<string, unknown>;
|
|
65
|
+
if (Array.isArray(meta.stages) && meta.stages.length > 0) {
|
|
66
|
+
top.stages = meta.stages;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Top-level variables from metadata.variables
|
|
70
|
+
if (meta.variables && typeof meta.variables === "object" && Object.keys(meta.variables).length > 0) {
|
|
71
|
+
top.variables = meta.variables;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Workflow / Default / Include resources go into reserved top-level keys
|
|
75
|
+
for (const r of ir.resources) {
|
|
76
|
+
if (r.type === "GitLab::CI::Workflow") {
|
|
77
|
+
top.workflow = r.properties;
|
|
78
|
+
} else if (r.type === "GitLab::CI::Default") {
|
|
79
|
+
top.default = r.properties;
|
|
80
|
+
} else if (r.type === "GitLab::CI::Include") {
|
|
81
|
+
top.include = r.properties;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Job resources go at the top level keyed by logicalId (kebab-cased)
|
|
86
|
+
const jobs: Record<string, unknown> = {};
|
|
87
|
+
for (const r of ir.resources) {
|
|
88
|
+
if (r.type === "GitLab::CI::Job") {
|
|
89
|
+
const key = (r.metadata?.originalName as string) ?? r.logicalId;
|
|
90
|
+
if (isReservedTopLevelKey(key)) {
|
|
91
|
+
// Avoid colliding with reserved keywords; prefix
|
|
92
|
+
jobs[`job-${key}`] = r.properties;
|
|
93
|
+
} else {
|
|
94
|
+
jobs[key] = r.properties;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Emit ordered top-level keys (reserved first, then jobs)
|
|
100
|
+
const lines: string[] = [];
|
|
101
|
+
|
|
102
|
+
for (const key of RESERVED_TOP_LEVEL_ORDER) {
|
|
103
|
+
if (top[key] !== undefined) {
|
|
104
|
+
lines.push(emitKeyValue(key, top[key], 0));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Migration banner comment
|
|
109
|
+
if (meta.migration && typeof meta.migration === "object") {
|
|
110
|
+
const m = meta.migration as Record<string, unknown>;
|
|
111
|
+
if (lines.length === 0) {
|
|
112
|
+
lines.push(`# Generated by chant migrate from ${m.sourceFile ?? "(unknown)"}`);
|
|
113
|
+
lines.push("");
|
|
114
|
+
} else {
|
|
115
|
+
lines.unshift("");
|
|
116
|
+
lines.unshift(`# Generated by chant migrate from ${m.sourceFile ?? "(unknown)"}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Jobs (sorted by logicalId for determinism)
|
|
121
|
+
const jobKeys = Object.keys(jobs).sort();
|
|
122
|
+
for (const key of jobKeys) {
|
|
123
|
+
lines.push("");
|
|
124
|
+
lines.push(emitKeyValue(key, jobs[key], 0));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return lines.join("\n") + "\n";
|
|
128
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { substituteExpressions, translateIfCondition, translateIfFunction } from "./expressions";
|
|
3
|
+
|
|
4
|
+
describe("substituteExpressions — context vars", () => {
|
|
5
|
+
const ctx = { gitlabPath: "x", sourceKey: "y" };
|
|
6
|
+
|
|
7
|
+
test.each([
|
|
8
|
+
["${{ github.sha }}", "$CI_COMMIT_SHA"],
|
|
9
|
+
["${{ github.ref }}", "$CI_COMMIT_REF_NAME"],
|
|
10
|
+
["${{ github.ref_name }}", "$CI_COMMIT_REF_NAME"],
|
|
11
|
+
["${{ github.repository }}", "$CI_PROJECT_PATH"],
|
|
12
|
+
["${{ github.repository_owner }}", "$CI_PROJECT_NAMESPACE"],
|
|
13
|
+
["${{ github.actor }}", "$GITLAB_USER_LOGIN"],
|
|
14
|
+
["${{ github.event_name }}", "$CI_PIPELINE_SOURCE"],
|
|
15
|
+
["${{ github.run_id }}", "$CI_PIPELINE_ID"],
|
|
16
|
+
["${{ github.run_number }}", "$CI_PIPELINE_IID"],
|
|
17
|
+
["${{ github.job }}", "$CI_JOB_NAME"],
|
|
18
|
+
["${{ github.head_ref }}", "$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME"],
|
|
19
|
+
["${{ github.base_ref }}", "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME"],
|
|
20
|
+
["${{ github.workspace }}", "$CI_PROJECT_DIR"],
|
|
21
|
+
["${{ runner.workspace }}", "$CI_PROJECT_DIR"],
|
|
22
|
+
["${{ job.status }}", "$CI_JOB_STATUS"],
|
|
23
|
+
["${{ strategy.job-index }}", "$CI_NODE_INDEX"],
|
|
24
|
+
["${{ strategy.job-total }}", "$CI_NODE_TOTAL"],
|
|
25
|
+
])("%s → %s", (input, expected) => {
|
|
26
|
+
expect(substituteExpressions(input, ctx).output).toBe(expected);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("substituteExpressions — user-defined vars", () => {
|
|
31
|
+
const ctx = { gitlabPath: "x", sourceKey: "y" };
|
|
32
|
+
|
|
33
|
+
test("env.NAME → $NAME", () => {
|
|
34
|
+
expect(substituteExpressions("${{ env.NODE_VERSION }}", ctx).output).toBe("$NODE_VERSION");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("vars.NAME → $NAME", () => {
|
|
38
|
+
expect(substituteExpressions("${{ vars.REGISTRY }}", ctx).output).toBe("$REGISTRY");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("secrets.NAME → $NAME", () => {
|
|
42
|
+
expect(substituteExpressions("${{ secrets.API_TOKEN }}", ctx).output).toBe("$API_TOKEN");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("inputs.NAME → $NAME", () => {
|
|
46
|
+
expect(substituteExpressions("${{ inputs.environment }}", ctx).output).toBe("$environment");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("matrix.NAME → $NAME", () => {
|
|
50
|
+
expect(substituteExpressions("${{ matrix.os }}", ctx).output).toBe("$os");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("multiple substitutions in one string", () => {
|
|
54
|
+
expect(
|
|
55
|
+
substituteExpressions("Deploying ${{ env.APP }} to ${{ vars.ENV }}", ctx).output,
|
|
56
|
+
).toBe("Deploying $APP to $ENV");
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("substituteExpressions — needs-review", () => {
|
|
61
|
+
const ctx = { gitlabPath: "x", sourceKey: "y" };
|
|
62
|
+
|
|
63
|
+
test("steps.<id>.outputs.<name> → $name + needs-review", () => {
|
|
64
|
+
const { output, provenance } = substituteExpressions("${{ steps.get-version.outputs.tag }}", ctx);
|
|
65
|
+
expect(output).toBe("$tag");
|
|
66
|
+
expect(provenance.some((p) => p.category === "needs-review")).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("needs.<job>.outputs.<name> → $name + needs-review", () => {
|
|
70
|
+
const { output, provenance } = substituteExpressions("${{ needs.build.outputs.version }}", ctx);
|
|
71
|
+
expect(output).toBe("$version");
|
|
72
|
+
expect(provenance.some((p) => p.rule === "MIG-NEEDS-OUTPUTS-001")).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("runner.os has no equivalent", () => {
|
|
76
|
+
const { provenance } = substituteExpressions("${{ runner.os }}", ctx);
|
|
77
|
+
expect(provenance.some((p) => p.rule === "MIG-EXPR-NO-EQUIV")).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("github.run_attempt has no equivalent", () => {
|
|
81
|
+
const { provenance } = substituteExpressions("${{ github.run_attempt }}", ctx);
|
|
82
|
+
expect(provenance.some((p) => p.rule === "MIG-EXPR-NO-EQUIV")).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("translateIfFunction", () => {
|
|
87
|
+
test("always()", () => {
|
|
88
|
+
expect(translateIfFunction("always()").whenClause).toBe("always");
|
|
89
|
+
});
|
|
90
|
+
test("success()", () => {
|
|
91
|
+
expect(translateIfFunction("success()").whenClause).toBe("on_success");
|
|
92
|
+
});
|
|
93
|
+
test("failure()", () => {
|
|
94
|
+
expect(translateIfFunction("failure()").whenClause).toBe("on_failure");
|
|
95
|
+
});
|
|
96
|
+
test("cancelled() flagged for review", () => {
|
|
97
|
+
expect(translateIfFunction("cancelled()").needsReview).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("translateIfCondition", () => {
|
|
102
|
+
const ctx = { gitlabPath: "rules", sourceKey: "if" };
|
|
103
|
+
|
|
104
|
+
test("simple branch comparison", () => {
|
|
105
|
+
const { ifExpression } = translateIfCondition("github.ref == 'refs/heads/main'", ctx);
|
|
106
|
+
expect(ifExpression).toContain("$CI_COMMIT_REF_NAME");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("boolean function alone → when:", () => {
|
|
110
|
+
const r = translateIfCondition("failure()", ctx);
|
|
111
|
+
expect(r.whenClause).toBe("on_failure");
|
|
112
|
+
expect(r.ifExpression).toBe("");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("retains operators", () => {
|
|
116
|
+
const { ifExpression } = translateIfCondition(
|
|
117
|
+
"github.event_name == 'push' && github.ref == 'refs/heads/main'",
|
|
118
|
+
ctx,
|
|
119
|
+
);
|
|
120
|
+
expect(ifExpression).toContain("$CI_PIPELINE_SOURCE");
|
|
121
|
+
expect(ifExpression).toContain("$CI_COMMIT_REF_NAME");
|
|
122
|
+
expect(ifExpression).toContain("&&");
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub Actions expression substitution.
|
|
3
|
+
*
|
|
4
|
+
* Translates `${{ github.X }}`, `${{ runner.X }}`, `${{ secrets.X }}`,
|
|
5
|
+
* `${{ env.X }}`, `${{ vars.X }}`, `${{ inputs.X }}`, `${{ matrix.X }}`,
|
|
6
|
+
* and a small set of expression functions (`always()`, `failure()`, etc.)
|
|
7
|
+
* to their GitLab CI predefined-variable equivalents.
|
|
8
|
+
*
|
|
9
|
+
* Source: the upstream `github-actions-to-gitlab-ci` skill's
|
|
10
|
+
* `references/syntax-mapping.md` "github.* context expressions" tables.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ProvenanceRecord } from "./provenance";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Direct 1:1 mappings from GitHub context identifiers to GitLab predefined
|
|
17
|
+
* variables. Keys are the dotted identifier *without* the `${{ }}` wrapper.
|
|
18
|
+
*/
|
|
19
|
+
const GITHUB_CONTEXT_MAP: Record<string, string> = {
|
|
20
|
+
"github.sha": "$CI_COMMIT_SHA",
|
|
21
|
+
"github.ref": "$CI_COMMIT_REF_NAME",
|
|
22
|
+
"github.ref_name": "$CI_COMMIT_REF_NAME",
|
|
23
|
+
"github.repository": "$CI_PROJECT_PATH",
|
|
24
|
+
"github.repository_owner": "$CI_PROJECT_NAMESPACE",
|
|
25
|
+
"github.actor": "$GITLAB_USER_LOGIN",
|
|
26
|
+
"github.triggering_actor": "$GITLAB_USER_LOGIN",
|
|
27
|
+
"github.event_name": "$CI_PIPELINE_SOURCE",
|
|
28
|
+
"github.workflow": "$CI_PIPELINE_NAME",
|
|
29
|
+
"github.run_id": "$CI_PIPELINE_ID",
|
|
30
|
+
"github.run_number": "$CI_PIPELINE_IID",
|
|
31
|
+
"github.job": "$CI_JOB_NAME",
|
|
32
|
+
"github.head_ref": "$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME",
|
|
33
|
+
"github.base_ref": "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME",
|
|
34
|
+
"github.workspace": "$CI_PROJECT_DIR",
|
|
35
|
+
"github.server_url": "$CI_SERVER_URL",
|
|
36
|
+
"runner.workspace": "$CI_PROJECT_DIR",
|
|
37
|
+
"job.status": "$CI_JOB_STATUS",
|
|
38
|
+
"strategy.job-index": "$CI_NODE_INDEX",
|
|
39
|
+
"strategy.job-total": "$CI_NODE_TOTAL",
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* GitHub expressions with no GitLab equivalent. When detected, the
|
|
44
|
+
* substitution leaves a placeholder and registers a needs-review record.
|
|
45
|
+
*/
|
|
46
|
+
const NO_EQUIVALENT = new Set<string>([
|
|
47
|
+
"github.run_attempt",
|
|
48
|
+
"runner.os",
|
|
49
|
+
"runner.arch",
|
|
50
|
+
"runner.temp",
|
|
51
|
+
"job.container.id",
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Map a GitHub `if:` boolean function to a GitLab `when:` value or rule.
|
|
56
|
+
*
|
|
57
|
+
* Returned tuple is `[gitlabExpression, isWhenClause]` — when `isWhenClause`
|
|
58
|
+
* is true, the caller should emit `when: <expr>` rather than `if: <expr>`.
|
|
59
|
+
*/
|
|
60
|
+
export function translateIfFunction(
|
|
61
|
+
expr: string,
|
|
62
|
+
): { expression: string; whenClause?: string; needsReview?: boolean } {
|
|
63
|
+
const trimmed = expr.trim();
|
|
64
|
+
if (trimmed === "always()") return { expression: "true", whenClause: "always" };
|
|
65
|
+
if (trimmed === "success()") return { expression: "true", whenClause: "on_success" };
|
|
66
|
+
if (trimmed === "failure()") return { expression: "true", whenClause: "on_failure" };
|
|
67
|
+
if (trimmed === "cancelled()" || trimmed === "canceled()") {
|
|
68
|
+
return { expression: "true", needsReview: true };
|
|
69
|
+
}
|
|
70
|
+
return { expression: trimmed };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Substitute all `${{ ... }}` template expressions in a string with their
|
|
75
|
+
* GitLab equivalents. Returns the substituted string plus any provenance
|
|
76
|
+
* records describing which substitutions happened (or which need review).
|
|
77
|
+
*/
|
|
78
|
+
export function substituteExpressions(
|
|
79
|
+
input: string,
|
|
80
|
+
ctx: { gitlabPath: string; sourceKey?: string; sourceFile?: string },
|
|
81
|
+
): { output: string; provenance: ProvenanceRecord[] } {
|
|
82
|
+
const records: ProvenanceRecord[] = [];
|
|
83
|
+
// GitHub expressions are wrapped in ${{ ... }}.
|
|
84
|
+
const output = input.replace(/\$\{\{\s*([^}]+?)\s*\}\}/g, (match, expr: string) => {
|
|
85
|
+
const trimmed = expr.trim();
|
|
86
|
+
|
|
87
|
+
// Direct context lookup
|
|
88
|
+
if (Object.prototype.hasOwnProperty.call(GITHUB_CONTEXT_MAP, trimmed)) {
|
|
89
|
+
records.push({
|
|
90
|
+
gitlabPath: ctx.gitlabPath,
|
|
91
|
+
sourceKey: ctx.sourceKey,
|
|
92
|
+
sourceFile: ctx.sourceFile,
|
|
93
|
+
category: "rewrite",
|
|
94
|
+
rule: "MIG-EXPR-CONTEXT",
|
|
95
|
+
note: `${trimmed} → ${GITHUB_CONTEXT_MAP[trimmed]}`,
|
|
96
|
+
});
|
|
97
|
+
return GITHUB_CONTEXT_MAP[trimmed];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// env.NAME / vars.NAME / secrets.NAME → $NAME
|
|
101
|
+
const userVarMatch = /^(env|vars|secrets|inputs|matrix)\.([A-Za-z_][A-Za-z0-9_]*)$/.exec(trimmed);
|
|
102
|
+
if (userVarMatch) {
|
|
103
|
+
const [, , name] = userVarMatch;
|
|
104
|
+
records.push({
|
|
105
|
+
gitlabPath: ctx.gitlabPath,
|
|
106
|
+
sourceKey: ctx.sourceKey,
|
|
107
|
+
sourceFile: ctx.sourceFile,
|
|
108
|
+
category: "rewrite",
|
|
109
|
+
rule: "MIG-EXPR-USERVAR",
|
|
110
|
+
note: `${trimmed} → $${name}`,
|
|
111
|
+
});
|
|
112
|
+
return `$${name}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// steps.<id>.outputs.<name> → $name with needs-review note (requires dotenv)
|
|
116
|
+
const stepsOutputMatch = /^steps\.([A-Za-z_][A-Za-z0-9_-]*)\.outputs\.([A-Za-z_][A-Za-z0-9_]*)$/.exec(trimmed);
|
|
117
|
+
if (stepsOutputMatch) {
|
|
118
|
+
const [, , name] = stepsOutputMatch;
|
|
119
|
+
records.push({
|
|
120
|
+
gitlabPath: ctx.gitlabPath,
|
|
121
|
+
sourceKey: ctx.sourceKey,
|
|
122
|
+
sourceFile: ctx.sourceFile,
|
|
123
|
+
category: "needs-review",
|
|
124
|
+
rule: "MIG-NEEDS-OUTPUTS-001",
|
|
125
|
+
note: `${trimmed} requires artifacts:reports:dotenv pattern in GitLab; emitting $${name} placeholder`,
|
|
126
|
+
});
|
|
127
|
+
return `$${name}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// needs.<job>.outputs.<name> → $name with needs-review note
|
|
131
|
+
const needsOutputMatch = /^needs\.([A-Za-z_][A-Za-z0-9_-]*)\.outputs\.([A-Za-z_][A-Za-z0-9_]*)$/.exec(trimmed);
|
|
132
|
+
if (needsOutputMatch) {
|
|
133
|
+
const [, , name] = needsOutputMatch;
|
|
134
|
+
records.push({
|
|
135
|
+
gitlabPath: ctx.gitlabPath,
|
|
136
|
+
sourceKey: ctx.sourceKey,
|
|
137
|
+
sourceFile: ctx.sourceFile,
|
|
138
|
+
category: "needs-review",
|
|
139
|
+
rule: "MIG-NEEDS-OUTPUTS-001",
|
|
140
|
+
note: `${trimmed} requires artifacts:reports:dotenv pattern in GitLab; emitting $${name} placeholder`,
|
|
141
|
+
});
|
|
142
|
+
return `$${name}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Boolean expression functions: always(), success(), failure(), cancelled()
|
|
146
|
+
const fnResult = translateIfFunction(trimmed);
|
|
147
|
+
if (fnResult.whenClause) {
|
|
148
|
+
records.push({
|
|
149
|
+
gitlabPath: ctx.gitlabPath,
|
|
150
|
+
sourceKey: ctx.sourceKey,
|
|
151
|
+
sourceFile: ctx.sourceFile,
|
|
152
|
+
category: "rewrite",
|
|
153
|
+
rule: "MIG-EXPR-FUNCTION",
|
|
154
|
+
note: `${trimmed} → when: ${fnResult.whenClause}`,
|
|
155
|
+
});
|
|
156
|
+
return fnResult.expression;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// No-equivalent context expressions
|
|
160
|
+
if (NO_EQUIVALENT.has(trimmed)) {
|
|
161
|
+
records.push({
|
|
162
|
+
gitlabPath: ctx.gitlabPath,
|
|
163
|
+
sourceKey: ctx.sourceKey,
|
|
164
|
+
sourceFile: ctx.sourceFile,
|
|
165
|
+
category: "needs-review",
|
|
166
|
+
rule: "MIG-EXPR-NO-EQUIV",
|
|
167
|
+
note: `${trimmed} has no GitLab predefined variable equivalent`,
|
|
168
|
+
});
|
|
169
|
+
return `# TODO: ${trimmed} has no GitLab equivalent`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Unknown expression — leave the original and flag
|
|
173
|
+
records.push({
|
|
174
|
+
gitlabPath: ctx.gitlabPath,
|
|
175
|
+
sourceKey: ctx.sourceKey,
|
|
176
|
+
sourceFile: ctx.sourceFile,
|
|
177
|
+
category: "needs-review",
|
|
178
|
+
rule: "MIG-EXPR-UNKNOWN",
|
|
179
|
+
note: `Could not translate expression: ${match}`,
|
|
180
|
+
});
|
|
181
|
+
return match;
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
return { output, provenance: records };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Substitute GitHub identifiers (e.g. `github.ref`) appearing anywhere
|
|
189
|
+
* in a string with their GitLab predefined-variable equivalents.
|
|
190
|
+
* Used for if-conditions, concurrency.group values, and other places
|
|
191
|
+
* where identifiers appear without `${{ }}` wrapping.
|
|
192
|
+
*/
|
|
193
|
+
export function substituteIdentifiers(
|
|
194
|
+
input: string,
|
|
195
|
+
ctx: { gitlabPath: string; sourceKey?: string; sourceFile?: string },
|
|
196
|
+
): { output: string; provenance: ProvenanceRecord[] } {
|
|
197
|
+
const records: ProvenanceRecord[] = [];
|
|
198
|
+
// Match dotted identifiers like `github.ref_name`, `runner.os`,
|
|
199
|
+
// `steps.foo.outputs.bar`, `env.NAME`, etc. — but not when they are
|
|
200
|
+
// already prefixed by `$` (already substituted).
|
|
201
|
+
const output = input.replace(/(?<![\w$])([a-z]+(?:\.[A-Za-z_][A-Za-z0-9_-]*)+)/g, (match: string) => {
|
|
202
|
+
if (Object.prototype.hasOwnProperty.call(GITHUB_CONTEXT_MAP, match)) {
|
|
203
|
+
records.push({
|
|
204
|
+
gitlabPath: ctx.gitlabPath,
|
|
205
|
+
sourceKey: ctx.sourceKey,
|
|
206
|
+
sourceFile: ctx.sourceFile,
|
|
207
|
+
category: "rewrite",
|
|
208
|
+
rule: "MIG-EXPR-CONTEXT",
|
|
209
|
+
note: `${match} → ${GITHUB_CONTEXT_MAP[match]}`,
|
|
210
|
+
});
|
|
211
|
+
return GITHUB_CONTEXT_MAP[match];
|
|
212
|
+
}
|
|
213
|
+
const userVar = /^(env|vars|secrets|inputs|matrix)\.([A-Za-z_][A-Za-z0-9_]*)$/.exec(match);
|
|
214
|
+
if (userVar) {
|
|
215
|
+
records.push({
|
|
216
|
+
gitlabPath: ctx.gitlabPath,
|
|
217
|
+
sourceKey: ctx.sourceKey,
|
|
218
|
+
sourceFile: ctx.sourceFile,
|
|
219
|
+
category: "rewrite",
|
|
220
|
+
rule: "MIG-EXPR-USERVAR",
|
|
221
|
+
note: `${match} → $${userVar[2]}`,
|
|
222
|
+
});
|
|
223
|
+
return `$${userVar[2]}`;
|
|
224
|
+
}
|
|
225
|
+
const stepsOutput = /^steps\.[A-Za-z_][A-Za-z0-9_-]*\.outputs\.([A-Za-z_][A-Za-z0-9_]*)$/.exec(match);
|
|
226
|
+
if (stepsOutput) {
|
|
227
|
+
records.push({
|
|
228
|
+
gitlabPath: ctx.gitlabPath,
|
|
229
|
+
sourceKey: ctx.sourceKey,
|
|
230
|
+
sourceFile: ctx.sourceFile,
|
|
231
|
+
category: "needs-review",
|
|
232
|
+
rule: "MIG-NEEDS-OUTPUTS-001",
|
|
233
|
+
note: `${match} requires artifacts:reports:dotenv pattern; emitting $${stepsOutput[1]} placeholder`,
|
|
234
|
+
});
|
|
235
|
+
return `$${stepsOutput[1]}`;
|
|
236
|
+
}
|
|
237
|
+
const needsOutput = /^needs\.[A-Za-z_][A-Za-z0-9_-]*\.outputs\.([A-Za-z_][A-Za-z0-9_]*)$/.exec(match);
|
|
238
|
+
if (needsOutput) {
|
|
239
|
+
records.push({
|
|
240
|
+
gitlabPath: ctx.gitlabPath,
|
|
241
|
+
sourceKey: ctx.sourceKey,
|
|
242
|
+
sourceFile: ctx.sourceFile,
|
|
243
|
+
category: "needs-review",
|
|
244
|
+
rule: "MIG-NEEDS-OUTPUTS-001",
|
|
245
|
+
note: `${match} requires artifacts:reports:dotenv pattern; emitting $${needsOutput[1]} placeholder`,
|
|
246
|
+
});
|
|
247
|
+
return `$${needsOutput[1]}`;
|
|
248
|
+
}
|
|
249
|
+
if (NO_EQUIVALENT.has(match)) {
|
|
250
|
+
records.push({
|
|
251
|
+
gitlabPath: ctx.gitlabPath,
|
|
252
|
+
sourceKey: ctx.sourceKey,
|
|
253
|
+
sourceFile: ctx.sourceFile,
|
|
254
|
+
category: "needs-review",
|
|
255
|
+
rule: "MIG-EXPR-NO-EQUIV",
|
|
256
|
+
note: `${match} has no GitLab predefined variable equivalent`,
|
|
257
|
+
});
|
|
258
|
+
return match;
|
|
259
|
+
}
|
|
260
|
+
return match;
|
|
261
|
+
});
|
|
262
|
+
return { output, provenance: records };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Translate a GitHub `if:` condition expression (without `${{ }}` wrapping)
|
|
267
|
+
* into a GitLab `rules:if:` expression. Returns substituted expression and
|
|
268
|
+
* any whenClause hint extracted from boolean function calls.
|
|
269
|
+
*/
|
|
270
|
+
export function translateIfCondition(
|
|
271
|
+
rawIf: string,
|
|
272
|
+
ctx: { gitlabPath: string; sourceKey?: string; sourceFile?: string },
|
|
273
|
+
): { ifExpression: string; whenClause?: string; provenance: ProvenanceRecord[] } {
|
|
274
|
+
const records: ProvenanceRecord[] = [];
|
|
275
|
+
let whenClause: string | undefined;
|
|
276
|
+
|
|
277
|
+
// If the whole expression is a single boolean function, translate to when:
|
|
278
|
+
const fnAlone = /^\s*([a-z]+\(\))\s*$/i.exec(rawIf);
|
|
279
|
+
if (fnAlone) {
|
|
280
|
+
const fnResult = translateIfFunction(fnAlone[1]);
|
|
281
|
+
if (fnResult.whenClause) {
|
|
282
|
+
records.push({
|
|
283
|
+
gitlabPath: ctx.gitlabPath,
|
|
284
|
+
sourceKey: ctx.sourceKey,
|
|
285
|
+
sourceFile: ctx.sourceFile,
|
|
286
|
+
category: "rewrite",
|
|
287
|
+
rule: "MIG-IF-WHEN",
|
|
288
|
+
note: `if: ${fnAlone[1]} → when: ${fnResult.whenClause}`,
|
|
289
|
+
});
|
|
290
|
+
whenClause = fnResult.whenClause;
|
|
291
|
+
return { ifExpression: "", whenClause, provenance: records };
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Strip any `${{ }}` wrappers first
|
|
296
|
+
const unwrapped = rawIf.replace(/\$\{\{\s*/g, "").replace(/\s*\}\}/g, "");
|
|
297
|
+
// Then substitute identifiers inline
|
|
298
|
+
const subbed = substituteIdentifiers(unwrapped, ctx);
|
|
299
|
+
records.push(...subbed.provenance);
|
|
300
|
+
|
|
301
|
+
return { ifExpression: subbed.output, whenClause, provenance: records };
|
|
302
|
+
}
|