@intentius/chant-lexicon-forgejo 0.4.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/README.md +116 -0
- package/package.json +46 -0
- package/src/actions.test.ts +82 -0
- package/src/actions.ts +112 -0
- package/src/dialect.test.ts +104 -0
- package/src/dialect.ts +189 -0
- package/src/index.ts +50 -0
- package/src/lint/post-synth/wfj.test.ts +80 -0
- package/src/lint/post-synth/wfj010.ts +38 -0
- package/src/lint/post-synth/wfj011.ts +41 -0
- package/src/mcp/context-tools.test.ts +99 -0
- package/src/mcp/context-tools.ts +263 -0
- package/src/migrate/from-github/index.ts +103 -0
- package/src/migrate/from-github/security.test.ts +120 -0
- package/src/migrate/from-github/security.ts +195 -0
- package/src/migrate.mcp.test.ts +75 -0
- package/src/migrate.test.ts +71 -0
- package/src/plugin.ts +173 -0
- package/src/serializer.test.ts +124 -0
- package/src/serializer.ts +62 -0
- package/src/skills/chant-forgejo.md +68 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { parseYAML } from "@intentius/chant/yaml";
|
|
3
|
+
import { analyzeForgejoSecurity, renderSecurityPosture, provenanceToDiagnostics } from "./security";
|
|
4
|
+
|
|
5
|
+
function analyze(yaml: string) {
|
|
6
|
+
return analyzeForgejoSecurity(parseYAML(yaml), { sourceFile: "ci.yml" });
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe("analyzeForgejoSecurity — fate classes", () => {
|
|
10
|
+
test("workflow-level permissions is lost", () => {
|
|
11
|
+
const records = analyze(`on: push
|
|
12
|
+
permissions:
|
|
13
|
+
contents: read
|
|
14
|
+
jobs:
|
|
15
|
+
build:
|
|
16
|
+
runs-on: ubuntu-latest
|
|
17
|
+
steps:
|
|
18
|
+
- run: echo hi
|
|
19
|
+
`);
|
|
20
|
+
const perm = records.filter((r) => r.rule === "MIG-FJ-PERMISSIONS");
|
|
21
|
+
expect(perm).toHaveLength(1);
|
|
22
|
+
expect(perm[0].security.fate).toBe("lost");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("continue-on-error is lost (job and step)", () => {
|
|
26
|
+
const records = analyze(`on: push
|
|
27
|
+
jobs:
|
|
28
|
+
build:
|
|
29
|
+
runs-on: ubuntu-latest
|
|
30
|
+
continue-on-error: true
|
|
31
|
+
steps:
|
|
32
|
+
- run: echo a
|
|
33
|
+
continue-on-error: true
|
|
34
|
+
`);
|
|
35
|
+
const coe = records.filter((r) => r.rule === "MIG-FJ-CONTINUE-ON-ERROR");
|
|
36
|
+
expect(coe).toHaveLength(2);
|
|
37
|
+
expect(coe.every((r) => r.security.fate === "lost")).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("an unmapped action ref needs review", () => {
|
|
41
|
+
const records = analyze(`on: push
|
|
42
|
+
jobs:
|
|
43
|
+
build:
|
|
44
|
+
runs-on: ubuntu-latest
|
|
45
|
+
steps:
|
|
46
|
+
- uses: some-org/custom-action@v1
|
|
47
|
+
`);
|
|
48
|
+
const act = records.filter((r) => r.rule === "MIG-FJ-ACTION-UNRESOLVED");
|
|
49
|
+
expect(act).toHaveLength(1);
|
|
50
|
+
expect(act[0].security.fate).toBe("needs-review");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("a mapped action ref produces no finding", () => {
|
|
54
|
+
const records = analyze(`on: push
|
|
55
|
+
jobs:
|
|
56
|
+
build:
|
|
57
|
+
runs-on: ubuntu-latest
|
|
58
|
+
steps:
|
|
59
|
+
- uses: actions/checkout@v4
|
|
60
|
+
`);
|
|
61
|
+
expect(records.filter((r) => r.rule === "MIG-FJ-ACTION-UNRESOLVED")).toHaveLength(0);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("an unmapped runner label needs review; a mapped one does not", () => {
|
|
65
|
+
const records = analyze(`on: push
|
|
66
|
+
jobs:
|
|
67
|
+
a:
|
|
68
|
+
runs-on: macos-latest
|
|
69
|
+
steps:
|
|
70
|
+
- run: echo a
|
|
71
|
+
b:
|
|
72
|
+
runs-on: ubuntu-latest
|
|
73
|
+
steps:
|
|
74
|
+
- run: echo b
|
|
75
|
+
`);
|
|
76
|
+
const labels = records.filter((r) => r.rule === "MIG-FJ-RUNNER-LABEL");
|
|
77
|
+
expect(labels).toHaveLength(1);
|
|
78
|
+
expect(labels[0].note).toContain("macos-latest");
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("renderSecurityPosture", () => {
|
|
83
|
+
test("renders a table with fate rows", () => {
|
|
84
|
+
const records = analyze(`on: push
|
|
85
|
+
permissions:
|
|
86
|
+
contents: read
|
|
87
|
+
jobs:
|
|
88
|
+
build:
|
|
89
|
+
runs-on: ubuntu-latest
|
|
90
|
+
steps:
|
|
91
|
+
- uses: actions/checkout@v4
|
|
92
|
+
`);
|
|
93
|
+
const md = renderSecurityPosture(records);
|
|
94
|
+
expect(md).toContain("## Security posture");
|
|
95
|
+
expect(md).toContain("MIG-FJ-PERMISSIONS");
|
|
96
|
+
expect(md).toContain("lost");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("clean workflow reports no weakening", () => {
|
|
100
|
+
const md = renderSecurityPosture([]);
|
|
101
|
+
expect(md).toContain("No security-relevant properties weaken or drop");
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("provenanceToDiagnostics", () => {
|
|
106
|
+
test("emits one diagnostic per record; --strict escalates to error", () => {
|
|
107
|
+
const records = analyze(`on: push
|
|
108
|
+
permissions:
|
|
109
|
+
contents: read
|
|
110
|
+
jobs:
|
|
111
|
+
build:
|
|
112
|
+
runs-on: ubuntu-latest
|
|
113
|
+
steps:
|
|
114
|
+
- run: echo hi
|
|
115
|
+
`);
|
|
116
|
+
expect(provenanceToDiagnostics(records)).toHaveLength(1);
|
|
117
|
+
expect(provenanceToDiagnostics(records)[0].severity).toBe("warning");
|
|
118
|
+
expect(provenanceToDiagnostics(records, { strict: true })[0].severity).toBe("error");
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security-fate model for the github → forgejo migration edge.
|
|
3
|
+
*
|
|
4
|
+
* github → forgejo YAML is near-identical, so the migration itself is thin. The
|
|
5
|
+
* differentiated value is the **compare**: classifying what survives the move
|
|
6
|
+
* and what Forgejo silently drops. Most properties translate verbatim; the
|
|
7
|
+
* useful findings are the keys the Forgejo runner ignores (`permissions`,
|
|
8
|
+
* `continue-on-error` → `lost`) plus refs/labels that need attention
|
|
9
|
+
* (unresolved `uses:`, unmapped runner labels → `needs-review`).
|
|
10
|
+
*
|
|
11
|
+
* Mirrors the gitlab lexicon's fate model (translated / approximated /
|
|
12
|
+
* needs-review / lost) but for the Forgejo edge — kept local so the forgejo
|
|
13
|
+
* lexicon does not depend on the gitlab one.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { LintDiagnostic } from "@intentius/chant/lint/rule";
|
|
17
|
+
import { DEFAULT_RUNNER_LABELS } from "../../dialect";
|
|
18
|
+
import { resolveActionRef } from "../../actions";
|
|
19
|
+
|
|
20
|
+
export type SecurityFate = "translated" | "approximated" | "needs-review" | "lost";
|
|
21
|
+
|
|
22
|
+
export interface SecurityProvenance {
|
|
23
|
+
/** Human label for the property (e.g. "Least-privilege permissions"). */
|
|
24
|
+
property: string;
|
|
25
|
+
/** What happened to the property as it crossed the github → forgejo edge. */
|
|
26
|
+
fate: SecurityFate;
|
|
27
|
+
/** Diagnostic severity for this finding. */
|
|
28
|
+
severity: "error" | "warning" | "info";
|
|
29
|
+
/** How to re-establish the property on Forgejo, when it doesn't carry. */
|
|
30
|
+
reestablish?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A migration provenance record carrying a security classification. */
|
|
34
|
+
export interface SecurityRecord {
|
|
35
|
+
/** YAML-ish path in the source (e.g. "jobs.build.steps[1].uses"). */
|
|
36
|
+
sourceKey: string;
|
|
37
|
+
/** Source file name (for diagnostics). */
|
|
38
|
+
sourceFile?: string;
|
|
39
|
+
/** Functional category (forgejo migration only emits security records). */
|
|
40
|
+
category: "needs-review" | "skipped" | "synthesis";
|
|
41
|
+
/** Rule ID (MIG-FJ-*). */
|
|
42
|
+
rule: string;
|
|
43
|
+
/** Human-readable explanation. */
|
|
44
|
+
note?: string;
|
|
45
|
+
/** Security classification. */
|
|
46
|
+
security: SecurityProvenance;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function toKebabCase(name: string): string {
|
|
50
|
+
return name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Classify the fate of security-relevant properties in a parsed GitHub Actions
|
|
55
|
+
* workflow as it migrates to Forgejo. Walks the source object so occurrence
|
|
56
|
+
* counts (and paths) are precise.
|
|
57
|
+
*/
|
|
58
|
+
export function analyzeForgejoSecurity(
|
|
59
|
+
workflow: unknown,
|
|
60
|
+
opts: { sourceFile?: string } = {},
|
|
61
|
+
): SecurityRecord[] {
|
|
62
|
+
const records: SecurityRecord[] = [];
|
|
63
|
+
const file = opts.sourceFile;
|
|
64
|
+
|
|
65
|
+
function visit(value: unknown, path: string): void {
|
|
66
|
+
if (value === null || typeof value !== "object") return;
|
|
67
|
+
|
|
68
|
+
if (Array.isArray(value)) {
|
|
69
|
+
value.forEach((v, i) => visit(v, `${path}[${i}]`));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
for (const [key, v] of Object.entries(value as Record<string, unknown>)) {
|
|
74
|
+
const kebab = toKebabCase(key);
|
|
75
|
+
const childPath = path ? `${path}.${key}` : key;
|
|
76
|
+
|
|
77
|
+
if (kebab === "permissions") {
|
|
78
|
+
records.push({
|
|
79
|
+
sourceKey: childPath,
|
|
80
|
+
sourceFile: file,
|
|
81
|
+
category: "skipped",
|
|
82
|
+
rule: "MIG-FJ-PERMISSIONS",
|
|
83
|
+
note: `permissions: is ignored by the Forgejo runner — the least-privilege control is lost. Re-establish it through your Forgejo/runner and repository token settings.`,
|
|
84
|
+
security: { property: "Least-privilege permissions", fate: "lost", severity: "warning", reestablish: "Forgejo runner/token settings" },
|
|
85
|
+
});
|
|
86
|
+
continue; // don't descend into a dropped subtree
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (kebab === "continue-on-error") {
|
|
90
|
+
records.push({
|
|
91
|
+
sourceKey: childPath,
|
|
92
|
+
sourceFile: file,
|
|
93
|
+
category: "skipped",
|
|
94
|
+
rule: "MIG-FJ-CONTINUE-ON-ERROR",
|
|
95
|
+
note: `continue-on-error is ignored by the Forgejo runner — the failure-tolerance control is lost. A failing step/job will fail the run.`,
|
|
96
|
+
security: { property: "continue-on-error tolerance", fate: "lost", severity: "warning" },
|
|
97
|
+
});
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (kebab === "uses" && typeof v === "string") {
|
|
102
|
+
const { warning } = resolveActionRef(v);
|
|
103
|
+
if (warning) {
|
|
104
|
+
records.push({
|
|
105
|
+
sourceKey: childPath,
|
|
106
|
+
sourceFile: file,
|
|
107
|
+
category: "needs-review",
|
|
108
|
+
rule: "MIG-FJ-ACTION-UNRESOLVED",
|
|
109
|
+
note: `Action ref '${v}' has no built-in Forgejo mapping and won't resolve from a GitHub Marketplace. Use a full repository URL or mirror it under forgejo.actionsRoot.`,
|
|
110
|
+
security: { property: "Action reference", fate: "needs-review", severity: "warning" },
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (kebab === "runs-on") {
|
|
117
|
+
for (const label of runnerLabels(v)) {
|
|
118
|
+
if (DEFAULT_RUNNER_LABELS[label] === undefined) {
|
|
119
|
+
records.push({
|
|
120
|
+
sourceKey: childPath,
|
|
121
|
+
sourceFile: file,
|
|
122
|
+
category: "needs-review",
|
|
123
|
+
rule: "MIG-FJ-RUNNER-LABEL",
|
|
124
|
+
note: `Runner label '${label}' has no built-in Forgejo mapping. Confirm a Forgejo runner advertises it, or map it via forgejo.runnerLabels.`,
|
|
125
|
+
security: { property: "Runner label", fate: "needs-review", severity: "warning" },
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
visit(v, childPath);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
visit(workflow, "");
|
|
137
|
+
return records;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function runnerLabels(value: unknown): string[] {
|
|
141
|
+
if (typeof value === "string") return [value];
|
|
142
|
+
if (Array.isArray(value)) return value.filter((v): v is string => typeof v === "string");
|
|
143
|
+
return [];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Convert security records into SARIF-shaped diagnostics. Security findings
|
|
148
|
+
* always emit a diagnostic at their severity, escalated to `error` under
|
|
149
|
+
* `--strict` when the property was lost or needs review.
|
|
150
|
+
*/
|
|
151
|
+
export function provenanceToDiagnostics(
|
|
152
|
+
records: SecurityRecord[],
|
|
153
|
+
opts: { strict?: boolean } = {},
|
|
154
|
+
): LintDiagnostic[] {
|
|
155
|
+
return records.map((r) => {
|
|
156
|
+
const escalate = opts.strict && (r.security.fate === "lost" || r.security.fate === "needs-review");
|
|
157
|
+
return {
|
|
158
|
+
file: r.sourceFile ?? "<input>",
|
|
159
|
+
line: 1,
|
|
160
|
+
column: 1,
|
|
161
|
+
ruleId: r.rule,
|
|
162
|
+
severity: escalate ? "error" : r.security.severity,
|
|
163
|
+
message: r.note ?? r.rule,
|
|
164
|
+
};
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
interface PostureLine {
|
|
169
|
+
property: string;
|
|
170
|
+
fate: SecurityFate;
|
|
171
|
+
rule: string;
|
|
172
|
+
count: number;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Render a "Security posture" Markdown section from the security records. */
|
|
176
|
+
export function renderSecurityPosture(records: SecurityRecord[]): string {
|
|
177
|
+
if (records.length === 0) {
|
|
178
|
+
return "## Security posture\n\nNo security-relevant properties weaken or drop at the Forgejo migration edge.\n";
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const byRule = new Map<string, PostureLine>();
|
|
182
|
+
for (const r of records) {
|
|
183
|
+
const existing = byRule.get(r.rule);
|
|
184
|
+
if (existing) existing.count += 1;
|
|
185
|
+
else byRule.set(r.rule, { property: r.security.property, fate: r.security.fate, rule: r.rule, count: 1 });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
let out = "## Security posture\n\n";
|
|
189
|
+
out += "| Property | Fate | Rule | Count |\n|---|---|---|---|\n";
|
|
190
|
+
for (const line of byRule.values()) {
|
|
191
|
+
out += `| ${line.property} | ${line.fate} | ${line.rule} | ${line.count} |\n`;
|
|
192
|
+
}
|
|
193
|
+
out += "\nFates: **translated** (carried as-is) · **approximated** · **needs-review** (confirm/adjust on Forgejo) · **lost** (the Forgejo runner ignores the property).\n";
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smoke test for the forgejo MCP `forgejo:compare` tool. Exercises the
|
|
3
|
+
* handler directly (the tool contract is a plain async function from
|
|
4
|
+
* inputSchema params to a result object).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, test, expect, beforeAll, afterAll } from "vitest";
|
|
8
|
+
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { forgejoPlugin } from "./plugin";
|
|
12
|
+
|
|
13
|
+
const GHA_WORKFLOW = `name: CI
|
|
14
|
+
on:
|
|
15
|
+
push:
|
|
16
|
+
branches:
|
|
17
|
+
- main
|
|
18
|
+
permissions:
|
|
19
|
+
contents: read
|
|
20
|
+
jobs:
|
|
21
|
+
build:
|
|
22
|
+
runs-on: ubuntu-latest
|
|
23
|
+
steps:
|
|
24
|
+
- uses: actions/checkout@v4
|
|
25
|
+
- uses: some-org/custom-action@v1
|
|
26
|
+
- run: npm test
|
|
27
|
+
`;
|
|
28
|
+
|
|
29
|
+
let dir: string;
|
|
30
|
+
let file: string;
|
|
31
|
+
|
|
32
|
+
beforeAll(() => {
|
|
33
|
+
dir = mkdtempSync(join(tmpdir(), "fj-compare-"));
|
|
34
|
+
file = join(dir, "ci.yml");
|
|
35
|
+
writeFileSync(file, GHA_WORKFLOW);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterAll(() => {
|
|
39
|
+
rmSync(dir, { recursive: true, force: true });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("forgejo MCP compare tool", () => {
|
|
43
|
+
test("registered in mcpTools", () => {
|
|
44
|
+
const tools = forgejoPlugin.mcpTools?.() ?? [];
|
|
45
|
+
expect(tools.map((t) => t.name)).toContain("forgejo:compare");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("reports per-property fates and summary counts", async () => {
|
|
49
|
+
const tools = forgejoPlugin.mcpTools?.() ?? [];
|
|
50
|
+
const compare = tools.find((t) => t.name === "forgejo:compare");
|
|
51
|
+
expect(compare).toBeDefined();
|
|
52
|
+
|
|
53
|
+
const result = (await compare!.handler({ file })) as {
|
|
54
|
+
found: boolean;
|
|
55
|
+
properties: Array<{ property: string; fate: string }>;
|
|
56
|
+
summary: Record<string, number>;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
expect(result.found).toBe(true);
|
|
60
|
+
const fates = result.properties.map((p) => p.fate);
|
|
61
|
+
expect(fates).toContain("lost"); // permissions
|
|
62
|
+
expect(fates).toContain("needs-review"); // unmapped action ref
|
|
63
|
+
expect(result.summary.lost).toBeGreaterThanOrEqual(1);
|
|
64
|
+
expect(result.summary["needs-review"]).toBeGreaterThanOrEqual(1);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("returns found:false for a non-workflow file", async () => {
|
|
68
|
+
const tools = forgejoPlugin.mcpTools?.() ?? [];
|
|
69
|
+
const compare = tools.find((t) => t.name === "forgejo:compare");
|
|
70
|
+
const other = join(dir, "not-a-workflow.yml");
|
|
71
|
+
writeFileSync(other, "foo: bar\n");
|
|
72
|
+
const result = (await compare!.handler({ file: other })) as { found: boolean };
|
|
73
|
+
expect(result.found).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { transform, detectGitHubWorkflow } from "./migrate/from-github";
|
|
3
|
+
import { forgejoPlugin } from "./plugin";
|
|
4
|
+
|
|
5
|
+
const GHA_WORKFLOW = `name: CI
|
|
6
|
+
on:
|
|
7
|
+
push:
|
|
8
|
+
branches:
|
|
9
|
+
- main
|
|
10
|
+
permissions:
|
|
11
|
+
contents: read
|
|
12
|
+
jobs:
|
|
13
|
+
build:
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
continue-on-error: true
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
- uses: some-org/custom-action@v1
|
|
19
|
+
- run: npm test
|
|
20
|
+
`;
|
|
21
|
+
|
|
22
|
+
describe("github → forgejo transform", () => {
|
|
23
|
+
test("emits forgejo YAML with the dialect applied", async () => {
|
|
24
|
+
const { output } = await transform(GHA_WORKFLOW, { sourceFile: "ci.yml" });
|
|
25
|
+
expect(output).toContain("runs-on: docker");
|
|
26
|
+
expect(output).not.toContain("permissions");
|
|
27
|
+
expect(output).not.toContain("continue-on-error");
|
|
28
|
+
expect(output).toContain("https://code.forgejo.org/actions/checkout@v4");
|
|
29
|
+
// unmapped ref still present (passed through), surfaced in the compare
|
|
30
|
+
expect(output).toContain("some-org/custom-action@v1");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("classifies property fates (provenance + posture)", async () => {
|
|
34
|
+
const { provenance, securityPosture, diagnostics } = await transform(GHA_WORKFLOW, { sourceFile: "ci.yml" });
|
|
35
|
+
const rules = provenance.map((r) => r.rule);
|
|
36
|
+
expect(rules).toContain("MIG-FJ-PERMISSIONS");
|
|
37
|
+
expect(rules).toContain("MIG-FJ-CONTINUE-ON-ERROR");
|
|
38
|
+
expect(rules).toContain("MIG-FJ-ACTION-UNRESOLVED");
|
|
39
|
+
expect(provenance.find((r) => r.rule === "MIG-FJ-PERMISSIONS")?.security.fate).toBe("lost");
|
|
40
|
+
expect(securityPosture).toContain("## Security posture");
|
|
41
|
+
expect(diagnostics.length).toBe(provenance.length);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("emit: ts produces a chant pipeline importing from forgejo", async () => {
|
|
45
|
+
const { output } = await transform(GHA_WORKFLOW, { emit: "ts", sourceFile: "ci.yml" });
|
|
46
|
+
expect(output).toContain("@intentius/chant-lexicon-forgejo");
|
|
47
|
+
expect(output).not.toContain("@intentius/chant-lexicon-github");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("detectGitHubWorkflow recognizes a workflow", () => {
|
|
51
|
+
expect(detectGitHubWorkflow(GHA_WORKFLOW)).toBe(true);
|
|
52
|
+
expect(detectGitHubWorkflow("foo: bar\n")).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("forgejoPlugin.migrationSource", () => {
|
|
57
|
+
test("supports github only", () => {
|
|
58
|
+
expect(forgejoPlugin.migrationSource?.("github")).toBeDefined();
|
|
59
|
+
expect(forgejoPlugin.migrationSource?.("gitlab")).toBeUndefined();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("transform mirrors the gitlab MigrationResult shape", async () => {
|
|
63
|
+
const source = forgejoPlugin.migrationSource?.("github");
|
|
64
|
+
expect(source?.detect(GHA_WORKFLOW)).toBe(true);
|
|
65
|
+
const result = await source!.transform(GHA_WORKFLOW, { emit: "yaml", sourceFile: "ci.yml" });
|
|
66
|
+
expect(result.output).toContain("runs-on: docker");
|
|
67
|
+
expect(Array.isArray(result.provenance)).toBe(true);
|
|
68
|
+
expect(Array.isArray(result.diagnostics)).toBe(true);
|
|
69
|
+
expect(result.securityPosture).toContain("Security posture");
|
|
70
|
+
});
|
|
71
|
+
});
|
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forgejo Actions lexicon plugin.
|
|
3
|
+
*
|
|
4
|
+
* Forgejo runs GitHub-Actions-compatible workflows, so this lexicon is a thin
|
|
5
|
+
* dialect of the github lexicon: it reuses github's generated entities and
|
|
6
|
+
* composites wholesale (re-exported from ./index) and overrides only the
|
|
7
|
+
* serializer. There is no own spec, so the codegen lifecycle methods are
|
|
8
|
+
* intentionally no-ops — they delegate the real work to the github lexicon.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { LexiconPlugin, InitTemplateSet, MigrationSource } from "@intentius/chant/lexicon";
|
|
12
|
+
import { discoverPostSynthChecks } from "@intentius/chant/lint/discover";
|
|
13
|
+
import { createSkillsLoader, createDiffTool } from "@intentius/chant/lexicon-plugin-helpers";
|
|
14
|
+
import { join, dirname } from "path";
|
|
15
|
+
import { fileURLToPath } from "url";
|
|
16
|
+
import { forgejoSerializer } from "./serializer";
|
|
17
|
+
import { forgejoContextTools } from "./mcp/context-tools";
|
|
18
|
+
|
|
19
|
+
const reuseNote =
|
|
20
|
+
"forgejo reuses the github lexicon's entities — run `chant generate` in the github lexicon instead.";
|
|
21
|
+
|
|
22
|
+
export const forgejoPlugin: LexiconPlugin = {
|
|
23
|
+
name: "forgejo",
|
|
24
|
+
serializer: forgejoSerializer,
|
|
25
|
+
|
|
26
|
+
initTemplates(template?: string): InitTemplateSet {
|
|
27
|
+
if (template === "docker-build") {
|
|
28
|
+
return {
|
|
29
|
+
src: {
|
|
30
|
+
"pipeline.ts": `import { Workflow, Job, Step, Checkout } from "@intentius/chant-lexicon-forgejo";
|
|
31
|
+
|
|
32
|
+
export const workflow = new Workflow({
|
|
33
|
+
name: "Docker Build",
|
|
34
|
+
on: { push: { branches: ["main"] } },
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
export const build = new Job({
|
|
38
|
+
"runs-on": "ubuntu-latest",
|
|
39
|
+
steps: [
|
|
40
|
+
Checkout({}).step,
|
|
41
|
+
new Step({ name: "Build", run: "docker build -t myapp ." }),
|
|
42
|
+
],
|
|
43
|
+
});
|
|
44
|
+
`,
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Default template: a Node CI workflow. Reuses github entities; the forgejo
|
|
50
|
+
// serializer maps "ubuntu-latest" to a Forgejo runner label on build.
|
|
51
|
+
return {
|
|
52
|
+
src: {
|
|
53
|
+
"pipeline.ts": `import { Workflow, Job, Step, Checkout, SetupNode } from "@intentius/chant-lexicon-forgejo";
|
|
54
|
+
|
|
55
|
+
export const workflow = new Workflow({
|
|
56
|
+
name: "CI",
|
|
57
|
+
on: {
|
|
58
|
+
push: { branches: ["main"] },
|
|
59
|
+
pull_request: { branches: ["main"] },
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
export const build = new Job({
|
|
64
|
+
"runs-on": "ubuntu-latest",
|
|
65
|
+
steps: [
|
|
66
|
+
Checkout({}).step,
|
|
67
|
+
SetupNode({ nodeVersion: "22", cache: "npm" }).step,
|
|
68
|
+
new Step({ name: "Install", run: "npm ci" }),
|
|
69
|
+
new Step({ name: "Build", run: "npm run build" }),
|
|
70
|
+
new Step({ name: "Test", run: "npm test" }),
|
|
71
|
+
],
|
|
72
|
+
});
|
|
73
|
+
`,
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
detectTemplate(data: unknown): boolean {
|
|
79
|
+
if (typeof data !== "object" || data === null) return false;
|
|
80
|
+
const obj = data as Record<string, unknown>;
|
|
81
|
+
|
|
82
|
+
// Forgejo Actions workflows are GitHub-Actions-shaped: `on:` + `jobs:`.
|
|
83
|
+
if (obj.on !== undefined && obj.jobs !== undefined) return true;
|
|
84
|
+
|
|
85
|
+
for (const value of Object.values(obj)) {
|
|
86
|
+
if (typeof value === "object" && value !== null) {
|
|
87
|
+
const entry = value as Record<string, unknown>;
|
|
88
|
+
if (entry["runs-on"] !== undefined || entry.steps !== undefined) {
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return false;
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
migrationSource(from: string): MigrationSource | undefined {
|
|
98
|
+
if (from !== "github") return undefined;
|
|
99
|
+
return {
|
|
100
|
+
detect(content: string): boolean {
|
|
101
|
+
// Inline heuristic — keeps the migrate code out of the import graph
|
|
102
|
+
// until a transform actually runs.
|
|
103
|
+
if (!/^\s*jobs\s*:/m.test(content)) return false;
|
|
104
|
+
return /^\s*on\s*:/m.test(content) || /^\s*runs-on\s*:/m.test(content);
|
|
105
|
+
},
|
|
106
|
+
async transform(content: string, opts) {
|
|
107
|
+
const { transform } = await import("./migrate/from-github");
|
|
108
|
+
const result = await transform(content, {
|
|
109
|
+
emit: opts.emit,
|
|
110
|
+
sourceFile: opts.sourceFile,
|
|
111
|
+
strict: opts.strict,
|
|
112
|
+
security: opts.security,
|
|
113
|
+
});
|
|
114
|
+
return {
|
|
115
|
+
output: result.output,
|
|
116
|
+
provenance: result.provenance as unknown as Array<Record<string, unknown>>,
|
|
117
|
+
diagnostics: result.diagnostics as unknown as Array<Record<string, unknown>>,
|
|
118
|
+
securityPosture: result.securityPosture,
|
|
119
|
+
};
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
postSynthChecks() {
|
|
125
|
+
const dir = join(dirname(fileURLToPath(import.meta.url)), "lint", "post-synth");
|
|
126
|
+
return discoverPostSynthChecks(dir, import.meta.url);
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
mcpTools() {
|
|
130
|
+
return [
|
|
131
|
+
createDiffTool(forgejoSerializer, "Compare current build output against previous output for Forgejo Actions", "forgejo"),
|
|
132
|
+
...forgejoContextTools(),
|
|
133
|
+
];
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
skills: createSkillsLoader(import.meta.url, [
|
|
137
|
+
{
|
|
138
|
+
file: "chant-forgejo.md",
|
|
139
|
+
name: "chant-forgejo",
|
|
140
|
+
description: "Forgejo / Codeberg / Gitea Actions with chant — build, the dialect (dropped keys, runner labels, uses: resolution), and github→forgejo migration",
|
|
141
|
+
triggers: [
|
|
142
|
+
{ type: "file-pattern", value: "**/.forgejo/workflows/*.yml" },
|
|
143
|
+
{ type: "file-pattern", value: "**/.gitea/workflows/*.yml" },
|
|
144
|
+
{ type: "context", value: "forgejo" },
|
|
145
|
+
{ type: "context", value: "codeberg" },
|
|
146
|
+
{ type: "context", value: "gitea" },
|
|
147
|
+
],
|
|
148
|
+
parameters: [],
|
|
149
|
+
examples: [
|
|
150
|
+
{
|
|
151
|
+
title: "Build a Forgejo workflow",
|
|
152
|
+
description: "Author github-style; the forgejo dialect applies on build",
|
|
153
|
+
input: "Create a CI workflow for Codeberg",
|
|
154
|
+
output: `chant build src -o .forgejo/workflows/ci.yml`,
|
|
155
|
+
},
|
|
156
|
+
],
|
|
157
|
+
},
|
|
158
|
+
]),
|
|
159
|
+
|
|
160
|
+
// ── Codegen lifecycle — delegated to github (no own spec) ──────────
|
|
161
|
+
async generate(): Promise<void> {
|
|
162
|
+
console.error(reuseNote);
|
|
163
|
+
},
|
|
164
|
+
async validate(): Promise<void> {
|
|
165
|
+
console.error("All checks passed.");
|
|
166
|
+
},
|
|
167
|
+
async coverage(): Promise<void> {
|
|
168
|
+
console.error(reuseNote);
|
|
169
|
+
},
|
|
170
|
+
async package(): Promise<void> {
|
|
171
|
+
console.error(reuseNote);
|
|
172
|
+
},
|
|
173
|
+
};
|