@intentius/chant 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/package.json +3 -3
- package/src/cli/commands/init.ts +18 -9
- package/src/cli/commands/migrate.test.ts +165 -0
- package/src/cli/commands/migrate.ts +469 -0
- package/src/cli/handlers/init.ts +1 -0
- package/src/cli/handlers/migrate.ts +54 -0
- package/src/cli/main.ts +43 -1
- package/src/cli/registry.ts +16 -0
- package/src/cli/reporters/stylish.ts +2 -2
- package/src/codegen/docs-sections.ts +1 -1
- package/src/codegen/docs.ts +14 -2
- package/src/codegen/rehype-base-url.d.mts +17 -0
- package/src/codegen/rehype-base-url.mjs +68 -0
- package/src/codegen/rehype-base-url.test.ts +161 -0
- package/src/lexicon.ts +51 -0
- package/src/lint/rule-registry.ts +1 -1
package/package.json
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.14",
|
|
4
4
|
"description": "Declarative infrastructure-as-code toolkit — TypeScript on Node.js",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://intentius.io/chant",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
9
|
-
"url": "https://github.com/
|
|
9
|
+
"url": "https://github.com/INTENTIUS/chant.git",
|
|
10
10
|
"directory": "packages/core"
|
|
11
11
|
},
|
|
12
12
|
"bugs": {
|
|
13
|
-
"url": "https://github.com/
|
|
13
|
+
"url": "https://github.com/INTENTIUS/chant/issues"
|
|
14
14
|
},
|
|
15
15
|
"keywords": [
|
|
16
16
|
"infrastructure-as-code",
|
package/src/cli/commands/init.ts
CHANGED
|
@@ -33,6 +33,12 @@ export interface InitOptions {
|
|
|
33
33
|
skipMcp?: boolean;
|
|
34
34
|
/** Skip interactive install prompt */
|
|
35
35
|
skipInstall?: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* If set, install only the named skill (e.g. "chant-gitlab-migrate")
|
|
38
|
+
* rather than every skill the plugin exports. Useful for incremental
|
|
39
|
+
* skill installation without re-scaffolding the project.
|
|
40
|
+
*/
|
|
41
|
+
skill?: string;
|
|
36
42
|
}
|
|
37
43
|
|
|
38
44
|
/**
|
|
@@ -442,18 +448,21 @@ export async function initCommand(options: InitOptions): Promise<InitResult> {
|
|
|
442
448
|
);
|
|
443
449
|
}
|
|
444
450
|
|
|
445
|
-
// Install skills from the lexicon's plugin
|
|
451
|
+
// Install skills from the lexicon's plugin. With --skill, install only
|
|
452
|
+
// the matching skill; without, install all.
|
|
446
453
|
try {
|
|
447
454
|
const plugin = await loadPlugin(options.lexicon);
|
|
448
455
|
if (plugin.skills) {
|
|
449
|
-
const
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
}
|
|
456
|
+
const all = plugin.skills();
|
|
457
|
+
const skills = options.skill ? all.filter((s) => s.name === options.skill) : all;
|
|
458
|
+
if (options.skill && skills.length === 0) {
|
|
459
|
+
warnings.push(`No skill named "${options.skill}" in lexicon ${options.lexicon}; nothing installed`);
|
|
460
|
+
}
|
|
461
|
+
for (const skill of skills) {
|
|
462
|
+
const skillDir = join(targetDir, "skills", skill.name);
|
|
463
|
+
mkdirSync(skillDir, { recursive: true });
|
|
464
|
+
writeFileSync(join(skillDir, "SKILL.md"), skill.content);
|
|
465
|
+
createdFiles.push(`skills/${skill.name}/SKILL.md`);
|
|
457
466
|
}
|
|
458
467
|
}
|
|
459
468
|
} catch {
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { mkdtempSync, writeFileSync, rmSync, existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { migrateCommand } from "./migrate";
|
|
6
|
+
import type { LexiconPlugin, MigrationResult } from "../../lexicon";
|
|
7
|
+
|
|
8
|
+
function stubPlugin(behavior: Partial<{ supports: string[]; detect: boolean; result: MigrationResult }>): LexiconPlugin {
|
|
9
|
+
return {
|
|
10
|
+
name: "stub",
|
|
11
|
+
serializer: { name: "stub", rulePrefix: "STB", serialize: () => "" },
|
|
12
|
+
async generate() {},
|
|
13
|
+
async validate() {},
|
|
14
|
+
async coverage() {},
|
|
15
|
+
async package() {},
|
|
16
|
+
migrationSource(from: string) {
|
|
17
|
+
if (!(behavior.supports ?? ["github"]).includes(from)) return undefined;
|
|
18
|
+
return {
|
|
19
|
+
detect: () => behavior.detect ?? true,
|
|
20
|
+
async transform() {
|
|
21
|
+
return behavior.result ?? { output: "stub-output", provenance: [], diagnostics: [] };
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("migrateCommand", () => {
|
|
29
|
+
let testDir: string;
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
testDir = mkdtempSync(join(tmpdir(), "chant-migrate-test-"));
|
|
33
|
+
});
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("fails when target lexicon is not installed", async () => {
|
|
39
|
+
const file = join(testDir, "ci.yml");
|
|
40
|
+
writeFileSync(file, "jobs:\n x:\n runs-on: ubuntu-latest\n");
|
|
41
|
+
const r = await migrateCommand({
|
|
42
|
+
sourceFile: file, from: "github", to: "missing",
|
|
43
|
+
emit: "yaml", strict: false, validate: false, useComposites: false,
|
|
44
|
+
plugins: [],
|
|
45
|
+
});
|
|
46
|
+
expect(r.exitCode).toBe(1);
|
|
47
|
+
expect(r.error).toContain("not installed");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("fails when target lexicon does not support migration", async () => {
|
|
51
|
+
const file = join(testDir, "ci.yml");
|
|
52
|
+
writeFileSync(file, "jobs:\n x:\n runs-on: ubuntu-latest\n");
|
|
53
|
+
const plugin: LexiconPlugin = {
|
|
54
|
+
name: "no-migrate",
|
|
55
|
+
serializer: { name: "no-migrate", rulePrefix: "NM", serialize: () => "" },
|
|
56
|
+
async generate() {},
|
|
57
|
+
async validate() {},
|
|
58
|
+
async coverage() {},
|
|
59
|
+
async package() {},
|
|
60
|
+
};
|
|
61
|
+
const r = await migrateCommand({
|
|
62
|
+
sourceFile: file, from: "github", to: "no-migrate",
|
|
63
|
+
emit: "yaml", strict: false, validate: false, useComposites: false,
|
|
64
|
+
plugins: [plugin],
|
|
65
|
+
});
|
|
66
|
+
expect(r.exitCode).toBe(1);
|
|
67
|
+
expect(r.error).toContain("does not support migration");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("fails when source content is not recognised", async () => {
|
|
71
|
+
const file = join(testDir, "ci.yml");
|
|
72
|
+
writeFileSync(file, "not a workflow");
|
|
73
|
+
const r = await migrateCommand({
|
|
74
|
+
sourceFile: file, from: "github", to: "stub",
|
|
75
|
+
emit: "yaml", strict: false, validate: false, useComposites: false,
|
|
76
|
+
plugins: [stubPlugin({ detect: false })],
|
|
77
|
+
});
|
|
78
|
+
expect(r.exitCode).toBe(1);
|
|
79
|
+
expect(r.error).toContain("does not look like github");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("writes output to --output file", async () => {
|
|
83
|
+
const file = join(testDir, "ci.yml");
|
|
84
|
+
const out = join(testDir, "out.yml");
|
|
85
|
+
writeFileSync(file, "jobs:\n x:\n runs-on: ubuntu-latest\n");
|
|
86
|
+
const r = await migrateCommand({
|
|
87
|
+
sourceFile: file, from: "github", to: "stub", output: out,
|
|
88
|
+
emit: "yaml", strict: false, validate: false, useComposites: false,
|
|
89
|
+
plugins: [stubPlugin({ result: { output: "hello-yaml\n", provenance: [], diagnostics: [] } })],
|
|
90
|
+
});
|
|
91
|
+
expect(r.exitCode).toBe(0);
|
|
92
|
+
expect(existsSync(out)).toBe(true);
|
|
93
|
+
expect(readFileSync(out, "utf-8")).toBe("hello-yaml\n");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("--strict escalates error diagnostics to non-zero exit", async () => {
|
|
97
|
+
const file = join(testDir, "ci.yml");
|
|
98
|
+
writeFileSync(file, "jobs:\n x:\n runs-on: ubuntu-latest\n");
|
|
99
|
+
const r = await migrateCommand({
|
|
100
|
+
sourceFile: file, from: "github", to: "stub", output: join(testDir, "out.yml"),
|
|
101
|
+
emit: "yaml", strict: true, validate: false, useComposites: false,
|
|
102
|
+
plugins: [stubPlugin({
|
|
103
|
+
result: {
|
|
104
|
+
output: "out",
|
|
105
|
+
provenance: [],
|
|
106
|
+
diagnostics: [{ severity: "error", ruleId: "TEST-001", message: "bad", file: "<input>", line: 1, column: 1 }],
|
|
107
|
+
},
|
|
108
|
+
})],
|
|
109
|
+
});
|
|
110
|
+
expect(r.exitCode).toBe(1);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("--report writes valid SARIF v2.1.0 JSON", async () => {
|
|
114
|
+
const file = join(testDir, "ci.yml");
|
|
115
|
+
const out = join(testDir, "out.yml");
|
|
116
|
+
const report = join(testDir, "r.sarif");
|
|
117
|
+
writeFileSync(file, "jobs:\n x:\n runs-on: ubuntu-latest\n");
|
|
118
|
+
await migrateCommand({
|
|
119
|
+
sourceFile: file, from: "github", to: "stub", output: out, reportFile: report,
|
|
120
|
+
emit: "yaml", strict: false, validate: false, useComposites: false,
|
|
121
|
+
plugins: [stubPlugin({
|
|
122
|
+
result: {
|
|
123
|
+
output: "out",
|
|
124
|
+
provenance: [],
|
|
125
|
+
diagnostics: [{ severity: "warning", ruleId: "TEST-W", message: "warn", file: "<in>", line: 2, column: 1 }],
|
|
126
|
+
},
|
|
127
|
+
})],
|
|
128
|
+
});
|
|
129
|
+
expect(existsSync(report)).toBe(true);
|
|
130
|
+
const sarif = JSON.parse(readFileSync(report, "utf-8"));
|
|
131
|
+
expect(sarif.version).toBe("2.1.0");
|
|
132
|
+
expect(sarif.runs?.[0]?.results?.length).toBe(1);
|
|
133
|
+
expect(sarif.runs[0].results[0].ruleId).toBe("TEST-W");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("--strict off keeps exit 0 even with error diagnostics", async () => {
|
|
137
|
+
const file = join(testDir, "ci.yml");
|
|
138
|
+
writeFileSync(file, "jobs:\n x:\n runs-on: ubuntu-latest\n");
|
|
139
|
+
const r = await migrateCommand({
|
|
140
|
+
sourceFile: file, from: "github", to: "stub", output: join(testDir, "out.yml"),
|
|
141
|
+
emit: "yaml", strict: false, validate: false, useComposites: false,
|
|
142
|
+
plugins: [stubPlugin({
|
|
143
|
+
result: {
|
|
144
|
+
output: "out",
|
|
145
|
+
provenance: [],
|
|
146
|
+
diagnostics: [{ severity: "error", ruleId: "TEST-001", message: "bad", file: "<input>", line: 1, column: 1 }],
|
|
147
|
+
},
|
|
148
|
+
})],
|
|
149
|
+
});
|
|
150
|
+
expect(r.exitCode).toBe(0);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("tryValidateExternal", () => {
|
|
155
|
+
test("returns ran=false when neither glci nor glab is on PATH", async () => {
|
|
156
|
+
const { tryValidateExternal } = await import("./migrate");
|
|
157
|
+
const r = tryValidateExternal("stages:\n - build\n");
|
|
158
|
+
if (!r.ran) {
|
|
159
|
+
expect(r.ok).toBe(false);
|
|
160
|
+
expect(r.backend).toBeUndefined();
|
|
161
|
+
} else {
|
|
162
|
+
expect(["glci", "glab"]).toContain(r.backend);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chant migrate` command implementation.
|
|
3
|
+
*
|
|
4
|
+
* Dispatches to the target lexicon's `migrationSource(from)` extension hook.
|
|
5
|
+
* The lexicon owns the actual translation logic; core orchestrates I/O,
|
|
6
|
+
* stdout/stderr surfaces, and exit codes.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { spawnSync } from "node:child_process";
|
|
11
|
+
import { formatError, formatInfo } from "../format";
|
|
12
|
+
import { formatSarif } from "../reporters/stylish";
|
|
13
|
+
import type { LexiconPlugin } from "../../lexicon";
|
|
14
|
+
import type { LintRule, LintDiagnostic } from "../../lint/rule";
|
|
15
|
+
|
|
16
|
+
export interface MigrateCliOpts {
|
|
17
|
+
sourceFile: string;
|
|
18
|
+
from: string;
|
|
19
|
+
to: string;
|
|
20
|
+
emit: "yaml" | "ts";
|
|
21
|
+
strict: boolean;
|
|
22
|
+
validate: boolean;
|
|
23
|
+
useComposites: boolean;
|
|
24
|
+
output?: string;
|
|
25
|
+
reportFile?: string;
|
|
26
|
+
plugins: LexiconPlugin[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface MigrateCliResult {
|
|
30
|
+
exitCode: number;
|
|
31
|
+
/** Bytes written (output) if any */
|
|
32
|
+
output?: string;
|
|
33
|
+
/** All diagnostic records */
|
|
34
|
+
diagnostics: Array<Record<string, unknown>>;
|
|
35
|
+
/** Provenance records (used for SARIF + Markdown report) */
|
|
36
|
+
provenance: Array<Record<string, unknown>>;
|
|
37
|
+
/** Error message if dispatch failed */
|
|
38
|
+
error?: string;
|
|
39
|
+
/** Markdown summary lines printed to stderr */
|
|
40
|
+
markdownSummary?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function migrateCommand(opts: MigrateCliOpts): Promise<MigrateCliResult> {
|
|
44
|
+
const targetPlugin = opts.plugins.find((p) => p.name === opts.to);
|
|
45
|
+
if (!targetPlugin) {
|
|
46
|
+
return {
|
|
47
|
+
exitCode: 1,
|
|
48
|
+
diagnostics: [],
|
|
49
|
+
provenance: [],
|
|
50
|
+
error: `Target lexicon "${opts.to}" is not installed`,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
if (!targetPlugin.migrationSource) {
|
|
54
|
+
return {
|
|
55
|
+
exitCode: 1,
|
|
56
|
+
diagnostics: [],
|
|
57
|
+
provenance: [],
|
|
58
|
+
error: `Lexicon "${opts.to}" does not support migration`,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
const source = targetPlugin.migrationSource(opts.from);
|
|
62
|
+
if (!source) {
|
|
63
|
+
return {
|
|
64
|
+
exitCode: 1,
|
|
65
|
+
diagnostics: [],
|
|
66
|
+
provenance: [],
|
|
67
|
+
error: `Lexicon "${opts.to}" does not support migration from "${opts.from}"`,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let content: string;
|
|
72
|
+
try {
|
|
73
|
+
content = readFileSync(opts.sourceFile, "utf-8");
|
|
74
|
+
} catch (err) {
|
|
75
|
+
return {
|
|
76
|
+
exitCode: 1,
|
|
77
|
+
diagnostics: [],
|
|
78
|
+
provenance: [],
|
|
79
|
+
error: `Cannot read ${opts.sourceFile}: ${err instanceof Error ? err.message : String(err)}`,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!source.detect(content)) {
|
|
84
|
+
return {
|
|
85
|
+
exitCode: 1,
|
|
86
|
+
diagnostics: [],
|
|
87
|
+
provenance: [],
|
|
88
|
+
error: `Input file ${opts.sourceFile} does not look like ${opts.from} source`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let result;
|
|
93
|
+
try {
|
|
94
|
+
result = await source.transform(content, {
|
|
95
|
+
emit: opts.emit,
|
|
96
|
+
useComposites: opts.useComposites,
|
|
97
|
+
sourceFile: opts.sourceFile,
|
|
98
|
+
strict: opts.strict,
|
|
99
|
+
});
|
|
100
|
+
} catch (err) {
|
|
101
|
+
return {
|
|
102
|
+
exitCode: 1,
|
|
103
|
+
diagnostics: [],
|
|
104
|
+
provenance: [],
|
|
105
|
+
error: `Transformation failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Write output (--output file or stdout)
|
|
110
|
+
if (opts.output && opts.output !== "-") {
|
|
111
|
+
try {
|
|
112
|
+
writeFileSync(opts.output, result.output);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
return {
|
|
115
|
+
exitCode: 1,
|
|
116
|
+
diagnostics: result.diagnostics,
|
|
117
|
+
provenance: result.provenance,
|
|
118
|
+
error: `Cannot write ${opts.output}: ${err instanceof Error ? err.message : String(err)}`,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
} else {
|
|
122
|
+
process.stdout.write(result.output);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// External validator (--validate) — glci preferred, glab fallback.
|
|
126
|
+
let validatorWarning: string | undefined;
|
|
127
|
+
if (opts.validate && opts.emit === "yaml") {
|
|
128
|
+
const v = tryValidateExternal(result.output);
|
|
129
|
+
if (!v.ran) {
|
|
130
|
+
validatorWarning = "neither glci nor glab is on PATH; skipping --validate";
|
|
131
|
+
if (opts.strict) {
|
|
132
|
+
return {
|
|
133
|
+
exitCode: 1,
|
|
134
|
+
output: result.output,
|
|
135
|
+
diagnostics: result.diagnostics,
|
|
136
|
+
provenance: result.provenance,
|
|
137
|
+
error: "--strict --validate: neither glci nor glab is on PATH",
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
} else if (!v.ok) {
|
|
141
|
+
console.error(`Validator (${v.backend}) reported errors:\n${v.output}`);
|
|
142
|
+
if (opts.strict) {
|
|
143
|
+
return {
|
|
144
|
+
exitCode: 1,
|
|
145
|
+
output: result.output,
|
|
146
|
+
diagnostics: result.diagnostics,
|
|
147
|
+
provenance: result.provenance,
|
|
148
|
+
error: `--strict: ${v.backend} validation failed`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
} else {
|
|
152
|
+
console.error(`Validator (${v.backend}) OK`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// SARIF report (--report <path>) — reuse the lint-side formatSarif so any
|
|
157
|
+
// CI SARIF ingest path treats migration findings uniformly.
|
|
158
|
+
if (opts.reportFile) {
|
|
159
|
+
try {
|
|
160
|
+
const rules = await loadMigrationRules(opts.to);
|
|
161
|
+
const lintShape = result.diagnostics as unknown as LintDiagnostic[];
|
|
162
|
+
const sarif = formatSarif(lintShape, rules);
|
|
163
|
+
writeFileSync(opts.reportFile, sarif);
|
|
164
|
+
} catch (err) {
|
|
165
|
+
// Non-fatal: surface the failure but don't abort the migration
|
|
166
|
+
console.error(`Warning: could not write SARIF report to ${opts.reportFile}: ${err instanceof Error ? err.message : String(err)}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Markdown summary (always to stderr — leaves stdout clean for piping)
|
|
171
|
+
const markdownSummary = formatMarkdownSummary(result.provenance, result.diagnostics, content);
|
|
172
|
+
|
|
173
|
+
// Determine exit code: any error-severity diagnostic fails when --strict.
|
|
174
|
+
// The transformer already escalates needs-review → error when opts.strict
|
|
175
|
+
// is passed via MigrationSource.transform(); we double-check here.
|
|
176
|
+
const errorDiagnostics = result.diagnostics.filter((d) => d.severity === "error");
|
|
177
|
+
const exitCode = opts.strict && errorDiagnostics.length > 0 ? 1 : 0;
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
exitCode,
|
|
181
|
+
output: result.output,
|
|
182
|
+
diagnostics: result.diagnostics,
|
|
183
|
+
provenance: result.provenance,
|
|
184
|
+
markdownSummary,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
interface ValidatorResult {
|
|
189
|
+
ran: boolean;
|
|
190
|
+
ok: boolean;
|
|
191
|
+
backend?: "glci" | "glab";
|
|
192
|
+
output: string;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function isOnPath(cmd: string): boolean {
|
|
196
|
+
// Use the OS-native lookup. `which` exists on macOS/Linux; `where` on Windows.
|
|
197
|
+
const lookup = process.platform === "win32" ? "where" : "which";
|
|
198
|
+
const r = spawnSync(lookup, [cmd], { encoding: "utf-8" });
|
|
199
|
+
return r.status === 0;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Run glci or glab against the generated .gitlab-ci.yml. Prefers glci
|
|
204
|
+
* (offline, no auth). Falls back to glab ci lint. Returns a structured
|
|
205
|
+
* result so the caller can decide how to surface success/failure.
|
|
206
|
+
*
|
|
207
|
+
* Exported for testability.
|
|
208
|
+
*/
|
|
209
|
+
export function tryValidateExternal(yamlText: string): ValidatorResult {
|
|
210
|
+
if (isOnPath("glci")) {
|
|
211
|
+
const r = spawnSync("glci", ["lint", "-f", "-"], { input: yamlText, encoding: "utf-8" });
|
|
212
|
+
return { ran: true, ok: r.status === 0, backend: "glci", output: (r.stdout ?? "") + (r.stderr ?? "") };
|
|
213
|
+
}
|
|
214
|
+
if (isOnPath("glab")) {
|
|
215
|
+
const r = spawnSync("glab", ["ci", "lint", "-f", "-"], { input: yamlText, encoding: "utf-8" });
|
|
216
|
+
return { ran: true, ok: r.status === 0, backend: "glab", output: (r.stdout ?? "") + (r.stderr ?? "") };
|
|
217
|
+
}
|
|
218
|
+
return { ran: false, ok: false, output: "" };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Lazily load the target lexicon's MIGRATION_RULES (used for SARIF enrichment).
|
|
223
|
+
* Returns an empty array if the lexicon doesn't expose them.
|
|
224
|
+
*/
|
|
225
|
+
async function loadMigrationRules(targetLexicon: string): Promise<LintRule[]> {
|
|
226
|
+
// For now only gitlab exposes migration rules. Hard-coded import keeps
|
|
227
|
+
// the dependency direction explicit; widen the switch when more
|
|
228
|
+
// lexicons ship their own migrate paths.
|
|
229
|
+
if (targetLexicon === "gitlab") {
|
|
230
|
+
try {
|
|
231
|
+
const mod = await import("@intentius/chant-lexicon-gitlab/migrate/from-github/rules");
|
|
232
|
+
return (mod as { MIGRATION_RULES: LintRule[] }).MIGRATION_RULES;
|
|
233
|
+
} catch {
|
|
234
|
+
return [];
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return [];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Format the migration report as Markdown, mirroring the output format
|
|
242
|
+
* prescribed by the upstream gitlab-org/ci-cd/github-actions-to-gitlab-ci
|
|
243
|
+
* skill: overview, classification, diagnostic table, aggregated manual
|
|
244
|
+
* setup steps, suggested GitLab improvements, honest caveats.
|
|
245
|
+
*
|
|
246
|
+
* The same data backs the SARIF report (via formatSarif); this is the
|
|
247
|
+
* human-readable surface.
|
|
248
|
+
*/
|
|
249
|
+
function formatMarkdownSummary(
|
|
250
|
+
provenance: Array<Record<string, unknown>>,
|
|
251
|
+
diagnostics: Array<Record<string, unknown>>,
|
|
252
|
+
sourceContent?: string,
|
|
253
|
+
): string {
|
|
254
|
+
const totals = { error: 0, warning: 0, info: 0 };
|
|
255
|
+
for (const d of diagnostics) {
|
|
256
|
+
const sev = d.severity as string;
|
|
257
|
+
if (sev === "error" || sev === "warning" || sev === "info") {
|
|
258
|
+
totals[sev]++;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Derive workflow shape from source (best effort) for the overview line
|
|
263
|
+
const overview = deriveOverview(sourceContent);
|
|
264
|
+
const classification = classifyWorkflow(sourceContent);
|
|
265
|
+
const manualSteps = collectManualSetupSteps(diagnostics);
|
|
266
|
+
const suggestions = collectSuggestions(provenance, sourceContent);
|
|
267
|
+
|
|
268
|
+
const lines: string[] = [];
|
|
269
|
+
lines.push("");
|
|
270
|
+
lines.push("## Migration report");
|
|
271
|
+
lines.push("");
|
|
272
|
+
if (overview) lines.push(`**Overview** — ${overview}`);
|
|
273
|
+
if (classification) {
|
|
274
|
+
lines.push("");
|
|
275
|
+
lines.push(classification);
|
|
276
|
+
}
|
|
277
|
+
lines.push("");
|
|
278
|
+
lines.push(`- Provenance records: ${provenance.length}`);
|
|
279
|
+
lines.push(`- Diagnostics: ${totals.error} error, ${totals.warning} warning, ${totals.info} info`);
|
|
280
|
+
|
|
281
|
+
if (diagnostics.length > 0) {
|
|
282
|
+
lines.push("");
|
|
283
|
+
lines.push("### Diagnostics");
|
|
284
|
+
lines.push("");
|
|
285
|
+
lines.push("| Severity | Rule | Message |");
|
|
286
|
+
lines.push("|---|---|---|");
|
|
287
|
+
for (const d of diagnostics.slice(0, 50)) {
|
|
288
|
+
lines.push(`| ${d.severity} | ${d.ruleId} | ${String(d.message).slice(0, 120)} |`);
|
|
289
|
+
}
|
|
290
|
+
if (diagnostics.length > 50) {
|
|
291
|
+
lines.push(`| … | … | ${diagnostics.length - 50} more diagnostics omitted |`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (manualSteps.length > 0) {
|
|
296
|
+
lines.push("");
|
|
297
|
+
lines.push("### Manual setup steps");
|
|
298
|
+
lines.push("");
|
|
299
|
+
for (let i = 0; i < manualSteps.length; i++) {
|
|
300
|
+
lines.push(`${i + 1}. ${manualSteps[i]}`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (suggestions.length > 0) {
|
|
305
|
+
lines.push("");
|
|
306
|
+
lines.push("### Suggested GitLab improvements");
|
|
307
|
+
lines.push("");
|
|
308
|
+
for (const s of suggestions) lines.push(`- ${s}`);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (totals.error > 0 || totals.warning > 0) {
|
|
312
|
+
lines.push("");
|
|
313
|
+
lines.push("### Honest caveats");
|
|
314
|
+
lines.push("");
|
|
315
|
+
lines.push(`The translation has ${totals.error} item${totals.error === 1 ? "" : "s"} needing review and ${totals.warning} approximation${totals.warning === 1 ? "" : "s"}. Review the diagnostics above before pushing the generated YAML.`);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return lines.join("\n");
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Derive a one-sentence overview from the source workflow shape. */
|
|
322
|
+
function deriveOverview(content?: string): string | undefined {
|
|
323
|
+
if (!content) return undefined;
|
|
324
|
+
const nameMatch = /^\s*name\s*:\s*(.+?)\s*$/m.exec(content);
|
|
325
|
+
const name = nameMatch ? nameMatch[1].replace(/['"]/g, "") : undefined;
|
|
326
|
+
const jobCount = countJobs(content);
|
|
327
|
+
const triggers = (content.match(/^\s*on\s*:/gm) ?? []).length > 0;
|
|
328
|
+
const parts: string[] = [];
|
|
329
|
+
if (name) parts.push(`workflow "${name}"`);
|
|
330
|
+
if (jobCount > 0) parts.push(`${jobCount} job${jobCount === 1 ? "" : "s"}`);
|
|
331
|
+
if (triggers) parts.push("triggered via on:");
|
|
332
|
+
if (parts.length === 0) return undefined;
|
|
333
|
+
return parts.join(", ") + ".";
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Count top-level entries directly under `jobs:`. */
|
|
337
|
+
function countJobs(content: string): number {
|
|
338
|
+
// Find the jobs: line, then walk forward collecting indented-2 keys until
|
|
339
|
+
// we hit a non-indented non-empty line (next top-level key) or EOF.
|
|
340
|
+
const lines = content.split(/\r?\n/);
|
|
341
|
+
let inJobs = false;
|
|
342
|
+
let count = 0;
|
|
343
|
+
for (const line of lines) {
|
|
344
|
+
if (/^\s*jobs\s*:\s*$/.test(line)) {
|
|
345
|
+
inJobs = true;
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
if (!inJobs) continue;
|
|
349
|
+
if (/^\S/.test(line)) break; // next top-level key
|
|
350
|
+
if (/^\s{2}[A-Za-z_][A-Za-z0-9_-]*\s*:\s*$/.test(line)) {
|
|
351
|
+
count++;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return count;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Detect workflows whose triggers are predominantly repo-automation
|
|
359
|
+
* events (issues, labels, comments, discussions) — GitLab CI can't replace
|
|
360
|
+
* these because pipelines only run on git events.
|
|
361
|
+
*/
|
|
362
|
+
function classifyWorkflow(content?: string): string | undefined {
|
|
363
|
+
if (!content) return undefined;
|
|
364
|
+
const onBlockMatch = /^\s*on\s*:([\s\S]*?)(?=^\S|\Z)/m.exec(content);
|
|
365
|
+
if (!onBlockMatch) return undefined;
|
|
366
|
+
const onText = onBlockMatch[1];
|
|
367
|
+
const automationEvents = [
|
|
368
|
+
"issues", "issue_comment", "pull_request_review", "pull_request_review_comment",
|
|
369
|
+
"discussion", "discussion_comment", "label", "milestone", "project_card",
|
|
370
|
+
"release", "star", "watch", "fork", "create", "delete",
|
|
371
|
+
];
|
|
372
|
+
const found = automationEvents.filter((e) =>
|
|
373
|
+
new RegExp(`^\\s*${e}\\s*:`, "m").test(onText),
|
|
374
|
+
);
|
|
375
|
+
const gitEvents = ["push", "pull_request", "schedule", "workflow_dispatch", "workflow_call", "tag"];
|
|
376
|
+
const foundGit = gitEvents.filter((e) =>
|
|
377
|
+
new RegExp(`^\\s*${e}\\s*:|${e}\\b`, "m").test(onText),
|
|
378
|
+
);
|
|
379
|
+
if (found.length > 0 && foundGit.length === 0) {
|
|
380
|
+
return `> ⚠️ **Repo-automation workflow detected** (triggers: ${found.join(", ")}). GitLab CI/CD only runs on git events; consider [gitlab-triage](https://gitlab.com/gitlab-org/ruby/gems/gitlab-triage) on a schedule, or webhooks + an external service. The translated YAML below is best-effort.`;
|
|
381
|
+
}
|
|
382
|
+
if (found.length > 0 && foundGit.length > 0) {
|
|
383
|
+
return `> ℹ️ Mixed triggers: git (${foundGit.join(", ")}) + automation (${found.join(", ")}). The automation events have no GitLab equivalent; the translated pipeline only fires on the git events.`;
|
|
384
|
+
}
|
|
385
|
+
return undefined;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Aggregate the human-actionable manual setup steps from needs-review diagnostics. */
|
|
389
|
+
function collectManualSetupSteps(diagnostics: Array<Record<string, unknown>>): string[] {
|
|
390
|
+
const seen = new Set<string>();
|
|
391
|
+
const steps: string[] = [];
|
|
392
|
+
for (const d of diagnostics) {
|
|
393
|
+
if (d.severity !== "warning" && d.severity !== "error") continue;
|
|
394
|
+
const ruleId = d.ruleId as string;
|
|
395
|
+
const action = manualStepFor(ruleId);
|
|
396
|
+
if (action && !seen.has(action)) {
|
|
397
|
+
seen.add(action);
|
|
398
|
+
steps.push(action);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return steps;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const MANUAL_STEPS_BY_RULE: Record<string, string> = {
|
|
405
|
+
"MIG-PERMISSIONS-001": "Configure CI/CD token access at Project Settings > CI/CD > Token Access (no per-job YAML equivalent in GitLab).",
|
|
406
|
+
"MIG-ON-SCHEDULE": "Create a pipeline schedule at Project Settings > CI/CD > Schedules (cron lives in the GitLab UI, not in YAML).",
|
|
407
|
+
"MIG-ON-DISPATCH": "Convert `workflow_dispatch.inputs` to `spec:inputs` at the top of the generated YAML (GitLab 17+). Every input must have a default so auto-triggered pipelines don't fail.",
|
|
408
|
+
"MIG-ON-NON-GIT": "Replace issue/MR/discussion triggers with gitlab-triage on a schedule, or webhooks + an external service. GitLab CI/CD only runs on git events.",
|
|
409
|
+
"MIG-NEEDS-OUTPUTS-001": "Convert step/job outputs to the `artifacts:reports:dotenv` pattern in the producing job, and add `needs: [{ job: X, artifacts: true }]` in the consuming job.",
|
|
410
|
+
"MIG-JOB-OUTPUTS": "Replace GitHub job outputs with `artifacts:reports:dotenv` files written by the producing job.",
|
|
411
|
+
"MIG-MATRIX-INCLUDE-001": "Manually unroll `matrix.include`/`matrix.exclude` entries; GitLab `parallel:matrix:` doesn't support these directly.",
|
|
412
|
+
"MIG-FAIL-FAST": "GitLab's `parallel:matrix:` doesn't fail-fast by default. If fail-fast is critical, wrap the matrix in a job that exits on first child failure.",
|
|
413
|
+
"MIG-RUNS-ON-NON-LINUX": "Register a self-hosted GitLab runner with the appropriate `tags:` for macOS or Windows jobs.",
|
|
414
|
+
"MIG-REUSABLE-WORKFLOW": "Rewrite `uses: org/repo/.github/workflows/*.yml` calls as GitLab `include:project:` + `variables:` parameterisation. Typed inputs aren't supported; document expected variable names.",
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
function manualStepFor(ruleId: string): string | undefined {
|
|
418
|
+
return MANUAL_STEPS_BY_RULE[ruleId];
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Suggest GitLab-native improvements based on workflow shape + provenance. */
|
|
422
|
+
function collectSuggestions(
|
|
423
|
+
provenance: Array<Record<string, unknown>>,
|
|
424
|
+
content?: string,
|
|
425
|
+
): string[] {
|
|
426
|
+
const out: string[] = [];
|
|
427
|
+
// DAG (needs:) is already passed through; suggest it if multiple jobs exist without explicit needs
|
|
428
|
+
const hasNeeds = provenance.some((p) => p.rule === "MIG-NEEDS");
|
|
429
|
+
const jobCount = content ? countJobs(content) : 0;
|
|
430
|
+
if (jobCount >= 3 && !hasNeeds) {
|
|
431
|
+
out.push("**DAG with `needs:`** — your jobs run sequentially via stage barriers. Adding explicit `needs:` lets jobs run as soon as their dependencies finish, often cutting pipeline time significantly.");
|
|
432
|
+
}
|
|
433
|
+
// rules:changes: when the workflow looks like it might benefit from path filtering
|
|
434
|
+
if (content && /paths\s*:/m.test(content)) {
|
|
435
|
+
out.push("**`rules:changes:`** — GitLab supports path-based job filtering natively. Convert GitHub `on:push:paths:` to `rules:changes:` on each job for monorepo-friendly conditional execution.");
|
|
436
|
+
}
|
|
437
|
+
// include: for multi-file workflow repos
|
|
438
|
+
if (content && /\buses\s*:\s*\.\/.+\.ya?ml/m.test(content)) {
|
|
439
|
+
out.push("**`include:`** — your workflow references local reusable workflows. GitLab `include:local:` merges YAML at parse time; consider migrating those references too.");
|
|
440
|
+
}
|
|
441
|
+
// Composite-recogniser hint when --use-composites would simplify
|
|
442
|
+
if (content && /actions\/setup-node/.test(content) && jobCount >= 1) {
|
|
443
|
+
out.push("**`--use-composites`** — re-run with this flag to collapse Node-shaped pipelines into a single `NodePipeline({...})` call (5–10× shorter generated TypeScript).");
|
|
444
|
+
}
|
|
445
|
+
// resource_group / interruptible already mapped; suggest protected environments for deploy jobs
|
|
446
|
+
if (content && /\bdeploy\b/i.test(content)) {
|
|
447
|
+
out.push("**Protected environments** — gate deploy jobs by approval rules and environment-specific variables (Project Settings > CI/CD > Environments). No GitHub equivalent.");
|
|
448
|
+
}
|
|
449
|
+
// GitLab CI templates
|
|
450
|
+
if (content && /security|sast|dast|terraform/i.test(content)) {
|
|
451
|
+
out.push("**GitLab CI templates** — `include:template:` gives you Auto DevOps, SAST, DAST, Container Scanning, Terraform, etc. out of the box. No GitHub Actions equivalent.");
|
|
452
|
+
}
|
|
453
|
+
return out;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export function printMigrateResult(result: MigrateCliResult): void {
|
|
457
|
+
if (result.error) {
|
|
458
|
+
console.error(formatError({ message: result.error }));
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
if (result.markdownSummary) {
|
|
462
|
+
console.error(result.markdownSummary);
|
|
463
|
+
}
|
|
464
|
+
if (result.exitCode === 0) {
|
|
465
|
+
console.error(formatInfo("\nMigration complete."));
|
|
466
|
+
} else {
|
|
467
|
+
console.error(formatError({ message: "Migration completed with errors (--strict)" }));
|
|
468
|
+
}
|
|
469
|
+
}
|
package/src/cli/handlers/init.ts
CHANGED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { migrateCommand, printMigrateResult } from "../commands/migrate";
|
|
2
|
+
import { formatError } from "../format";
|
|
3
|
+
import { loadPlugins } from "../plugins";
|
|
4
|
+
import type { CommandContext } from "../registry";
|
|
5
|
+
|
|
6
|
+
export async function runMigrate(ctx: CommandContext): Promise<number> {
|
|
7
|
+
const { args } = ctx;
|
|
8
|
+
|
|
9
|
+
// The migrate path is the second positional (args.path); `chant migrate <file>`
|
|
10
|
+
// populates args.path with the file path. Default is current directory.
|
|
11
|
+
if (!args.path || args.path === ".") {
|
|
12
|
+
console.error(formatError({
|
|
13
|
+
message: "chant migrate requires a source file path",
|
|
14
|
+
hint: "chant migrate .github/workflows/ci.yml --output .gitlab-ci.yml",
|
|
15
|
+
}));
|
|
16
|
+
return 1;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// migrate does not require a chant project context. Load the target
|
|
20
|
+
// lexicon directly by name.
|
|
21
|
+
const toName = args.migrateTo ?? "gitlab";
|
|
22
|
+
let plugins;
|
|
23
|
+
try {
|
|
24
|
+
plugins = await loadPlugins([toName]);
|
|
25
|
+
} catch (err) {
|
|
26
|
+
console.error(formatError({
|
|
27
|
+
message: `Cannot load target lexicon "${toName}": ${err instanceof Error ? err.message : String(err)}`,
|
|
28
|
+
hint: `Install @intentius/chant-lexicon-${toName} or pass --to <other-lexicon>`,
|
|
29
|
+
}));
|
|
30
|
+
return 1;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const emit = (args.emit as "yaml" | "ts" | undefined) ?? "yaml";
|
|
34
|
+
if (emit !== "yaml" && emit !== "ts") {
|
|
35
|
+
console.error(formatError({ message: `Invalid --emit value: ${emit}. Expected 'yaml' or 'ts'.` }));
|
|
36
|
+
return 1;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const result = await migrateCommand({
|
|
40
|
+
sourceFile: args.path,
|
|
41
|
+
from: args.migrateFrom ?? "github",
|
|
42
|
+
to: args.migrateTo ?? "gitlab",
|
|
43
|
+
emit,
|
|
44
|
+
strict: args.strict ?? false,
|
|
45
|
+
validate: args.validate ?? false,
|
|
46
|
+
useComposites: args.useComposites ?? false,
|
|
47
|
+
output: args.output,
|
|
48
|
+
reportFile: args.reportFile,
|
|
49
|
+
plugins,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
printMigrateResult(result);
|
|
53
|
+
return result.exitCode;
|
|
54
|
+
}
|
package/src/cli/main.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { runDevGenerate, runDevPublish, runDevOnboard, runDevCheckLexicon, runDe
|
|
|
12
12
|
import { runServeLsp, runServeMcp, runServeUnknown } from "./handlers/serve";
|
|
13
13
|
import { runInit, runInitLexicon } from "./handlers/init";
|
|
14
14
|
import { runList, runImport, runUpdate, runDoctor } from "./handlers/misc";
|
|
15
|
+
import { runMigrate } from "./handlers/migrate";
|
|
15
16
|
import { runStateSnapshot, runStateShow, runStateDiff, runStateLog, runStateUnknown } from "./handlers/state";
|
|
16
17
|
import { runGraph } from "./handlers/graph";
|
|
17
18
|
import { runOp, runOpList, runOpStatus, runOpSignal, runOpCancel, runOpLog } from "./handlers/run";
|
|
@@ -37,6 +38,14 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
37
38
|
profile: undefined,
|
|
38
39
|
report: undefined,
|
|
39
40
|
live: false,
|
|
41
|
+
migrateFrom: undefined,
|
|
42
|
+
migrateTo: undefined,
|
|
43
|
+
emit: undefined,
|
|
44
|
+
strict: false,
|
|
45
|
+
validate: false,
|
|
46
|
+
useComposites: false,
|
|
47
|
+
reportFile: undefined,
|
|
48
|
+
skill: undefined,
|
|
40
49
|
};
|
|
41
50
|
|
|
42
51
|
let i = 0;
|
|
@@ -64,9 +73,31 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
64
73
|
} else if (arg === "--profile" || arg === "-p") {
|
|
65
74
|
result.profile = args[++i];
|
|
66
75
|
} else if (arg === "--report") {
|
|
67
|
-
|
|
76
|
+
// --report alone is the boolean (used by `run`); --report <path> is
|
|
77
|
+
// the migrate-command file path. Look ahead for a non-flag.
|
|
78
|
+
const next = args[i + 1];
|
|
79
|
+
if (next && !next.startsWith("-")) {
|
|
80
|
+
result.reportFile = next;
|
|
81
|
+
i++;
|
|
82
|
+
} else {
|
|
83
|
+
result.report = true;
|
|
84
|
+
}
|
|
68
85
|
} else if (arg === "--live") {
|
|
69
86
|
result.live = true;
|
|
87
|
+
} else if (arg === "--from") {
|
|
88
|
+
result.migrateFrom = args[++i];
|
|
89
|
+
} else if (arg === "--to") {
|
|
90
|
+
result.migrateTo = args[++i];
|
|
91
|
+
} else if (arg === "--emit") {
|
|
92
|
+
result.emit = args[++i];
|
|
93
|
+
} else if (arg === "--strict") {
|
|
94
|
+
result.strict = true;
|
|
95
|
+
} else if (arg === "--validate") {
|
|
96
|
+
result.validate = true;
|
|
97
|
+
} else if (arg === "--use-composites") {
|
|
98
|
+
result.useComposites = true;
|
|
99
|
+
} else if (arg === "--skill") {
|
|
100
|
+
result.skill = args[++i];
|
|
70
101
|
} else if (!arg.startsWith("-")) {
|
|
71
102
|
if (!result.command) {
|
|
72
103
|
result.command = arg;
|
|
@@ -102,6 +133,8 @@ Commands:
|
|
|
102
133
|
lint Check specifications for issues
|
|
103
134
|
list List discovered entities
|
|
104
135
|
import Import external template into TypeScript
|
|
136
|
+
migrate <file> Translate a workflow between lexicons
|
|
137
|
+
(default: --from github --to gitlab)
|
|
105
138
|
|
|
106
139
|
Ops:
|
|
107
140
|
run <name> Start an Op workflow (spawns worker + submits to Temporal)
|
|
@@ -142,6 +175,7 @@ Options:
|
|
|
142
175
|
- lint: stylish (default), json, or sarif
|
|
143
176
|
-d, --lexicon <name> Build only the specified lexicon (e.g. aws, gitlab)
|
|
144
177
|
-t, --template <name> Init template (e.g. node-pipeline, docker-build)
|
|
178
|
+
--skill <name> Init: install only this skill from the lexicon
|
|
145
179
|
--fix Auto-fix fixable issues (lint command)
|
|
146
180
|
--force Force overwrite existing files (import command)
|
|
147
181
|
-w, --watch Watch for changes and rebuild/re-lint (build, lint)
|
|
@@ -149,6 +183,13 @@ Options:
|
|
|
149
183
|
-h, --help Show this help message
|
|
150
184
|
-p, --profile <name> Temporal worker profile to use (run command)
|
|
151
185
|
--report Print deployment report instead of running (run command)
|
|
186
|
+
OR with a path arg: SARIF report destination (migrate)
|
|
187
|
+
--from <name> Source lexicon for migrate (default: github)
|
|
188
|
+
--to <name> Target lexicon for migrate (default: gitlab)
|
|
189
|
+
--emit <fmt> Migration output format: yaml (default) or ts
|
|
190
|
+
--strict Escalate needs-review/validation to errors (migrate)
|
|
191
|
+
--validate Run external validator (glci/glab) after migrate
|
|
192
|
+
--use-composites Rewrite to composite calls when patterns match (migrate)
|
|
152
193
|
|
|
153
194
|
Examples:
|
|
154
195
|
chant build ./infra/
|
|
@@ -197,6 +238,7 @@ const registry: CommandDef[] = [
|
|
|
197
238
|
{ name: "lint", handler: runLint },
|
|
198
239
|
{ name: "list", handler: runList },
|
|
199
240
|
{ name: "import", handler: runImport },
|
|
241
|
+
{ name: "migrate", handler: runMigrate },
|
|
200
242
|
{ name: "init", handler: runInit },
|
|
201
243
|
{ name: "init lexicon", handler: runInitLexicon },
|
|
202
244
|
{ name: "update", handler: runUpdate },
|
package/src/cli/registry.ts
CHANGED
|
@@ -21,6 +21,22 @@ export interface ParsedArgs {
|
|
|
21
21
|
profile?: string;
|
|
22
22
|
report?: boolean;
|
|
23
23
|
live: boolean;
|
|
24
|
+
/** `chant migrate --from <name>` (default "github") */
|
|
25
|
+
migrateFrom?: string;
|
|
26
|
+
/** `chant migrate --to <name>` (default "gitlab") */
|
|
27
|
+
migrateTo?: string;
|
|
28
|
+
/** `chant migrate --emit yaml|ts` */
|
|
29
|
+
emit?: string;
|
|
30
|
+
/** Escalate needs-review diagnostics to errors (migrate command) */
|
|
31
|
+
strict?: boolean;
|
|
32
|
+
/** Run glci/glab after emit (migrate command) */
|
|
33
|
+
validate?: boolean;
|
|
34
|
+
/** Recognise composite patterns in output (migrate command) */
|
|
35
|
+
useComposites?: boolean;
|
|
36
|
+
/** Write SARIF report to this path (migrate command); distinct from boolean --report */
|
|
37
|
+
reportFile?: string;
|
|
38
|
+
/** `chant init --skill <name>` filter (added in #95 commit) */
|
|
39
|
+
skill?: string;
|
|
24
40
|
}
|
|
25
41
|
|
|
26
42
|
/**
|
|
@@ -215,7 +215,7 @@ export function formatSarif(
|
|
|
215
215
|
driver: {
|
|
216
216
|
name: "chant",
|
|
217
217
|
version: version ?? "0.1.0",
|
|
218
|
-
informationUri: "https://chant
|
|
218
|
+
informationUri: "https://intentius.io/chant",
|
|
219
219
|
rules: sarifRules,
|
|
220
220
|
},
|
|
221
221
|
},
|
|
@@ -267,7 +267,7 @@ function buildRuleMetadata(
|
|
|
267
267
|
id,
|
|
268
268
|
shortDescription: { text: descText },
|
|
269
269
|
fullDescription: { text: descText },
|
|
270
|
-
helpUri: rule?.helpUri || `https://
|
|
270
|
+
helpUri: rule?.helpUri || `https://intentius.io/chant/lint-rules/${id.toLowerCase()}`,
|
|
271
271
|
defaultConfiguration: {
|
|
272
272
|
level: mapSeverity(rule?.severity ?? "warning"),
|
|
273
273
|
},
|
|
@@ -150,7 +150,7 @@ export function generateSerialization(config: DocsConfig): string {
|
|
|
150
150
|
lines.push(
|
|
151
151
|
`The ${config.displayName} lexicon serializes resources into its native output format during the build step.`,
|
|
152
152
|
"",
|
|
153
|
-
"See the [Serialization](/serialization/output-formats) guide for general information about output formats in chant.",
|
|
153
|
+
"See the [Serialization](/chant/serialization/output-formats) guide for general information about output formats in chant.",
|
|
154
154
|
);
|
|
155
155
|
}
|
|
156
156
|
|
package/src/codegen/docs.ts
CHANGED
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
* (service grouping, resource type URLs, custom overview content).
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { readFileSync, writeFileSync, mkdirSync, rmSync } from "fs";
|
|
10
|
+
import { copyFileSync, readFileSync, writeFileSync, mkdirSync, rmSync } from "fs";
|
|
11
11
|
import { join } from "path";
|
|
12
|
+
import { fileURLToPath } from "url";
|
|
12
13
|
|
|
13
14
|
import { expandFileMarkers } from "./docs-file-markers";
|
|
14
15
|
import { scanRules, generateRules } from "./docs-rule-scanning";
|
|
@@ -222,14 +223,25 @@ export const collections = {
|
|
|
222
223
|
`,
|
|
223
224
|
);
|
|
224
225
|
|
|
226
|
+
// src/rehype-base-url.mjs — copied from chant core so Astro can import it
|
|
227
|
+
// without the generated docs site needing a workspace dep on @intentius/chant.
|
|
228
|
+
const pluginSrcPath = fileURLToPath(
|
|
229
|
+
new URL("./rehype-base-url.mjs", import.meta.url),
|
|
230
|
+
);
|
|
231
|
+
copyFileSync(pluginSrcPath, join(outDir, "src", "rehype-base-url.mjs"));
|
|
232
|
+
|
|
225
233
|
// astro.config.mjs
|
|
234
|
+
const rehypeLine = config.basePath
|
|
235
|
+
? `\n markdown: {\n rehypePlugins: [[rehypeBaseUrl, { base: '${config.basePath}', projectBase: '/chant' }]],\n },`
|
|
236
|
+
: "";
|
|
226
237
|
writeFileSync(
|
|
227
238
|
join(outDir, "astro.config.mjs"),
|
|
228
239
|
`// @ts-check
|
|
229
240
|
import { defineConfig } from 'astro/config';
|
|
230
241
|
import starlight from '@astrojs/starlight';
|
|
242
|
+
import rehypeBaseUrl from './src/rehype-base-url.mjs';
|
|
231
243
|
|
|
232
|
-
export default defineConfig({${config.basePath ? `\n base: '${config.basePath}',` : ""}
|
|
244
|
+
export default defineConfig({${config.basePath ? `\n base: '${config.basePath}',` : ""}${rehypeLine}
|
|
233
245
|
integrations: [
|
|
234
246
|
starlight({
|
|
235
247
|
title: '${config.displayName}',
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface RehypeBaseUrlOptions {
|
|
2
|
+
/** Site base, e.g. "/chant" or "/chant/lexicons/aws". Trailing/leading slashes optional. */
|
|
3
|
+
base: string;
|
|
4
|
+
/** Project-wide base used to detect already-correctly-prefixed cross-site links. */
|
|
5
|
+
projectBase?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
type HastNode = {
|
|
9
|
+
type: string;
|
|
10
|
+
tagName?: string;
|
|
11
|
+
properties?: Record<string, unknown>;
|
|
12
|
+
children?: HastNode[];
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export default function rehypeBaseUrl(
|
|
16
|
+
opts: RehypeBaseUrlOptions,
|
|
17
|
+
): (tree: HastNode) => void;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rehype plugin: prepend a configured `base` to root-relative `<a href>` attributes.
|
|
3
|
+
*
|
|
4
|
+
* Astro/Starlight only base-prefixes its own internal navigation (sidebar `link:`
|
|
5
|
+
* entries, `slug:` entries). Root-relative links written in MD/MDX content body
|
|
6
|
+
* — e.g. `[AWS](/lexicons/aws/)` — are emitted verbatim and 404 in production
|
|
7
|
+
* when the site is served from a non-root `base`.
|
|
8
|
+
*
|
|
9
|
+
* This plugin walks the HAST tree, finds `<a>` elements whose href starts with
|
|
10
|
+
* `/` (single leading slash, not `//`), and prepends the site's `base`. It
|
|
11
|
+
* idempotently skips hrefs that already start with the site's own base or the
|
|
12
|
+
* project-wide base.
|
|
13
|
+
*
|
|
14
|
+
* @typedef {Object} RehypeBaseUrlOptions
|
|
15
|
+
* @property {string} base - Site base, e.g. "/chant" or "/chant/lexicons/aws". Trailing/leading slashes optional.
|
|
16
|
+
* @property {string} [projectBase] - Project-wide base used to detect already-correctly-prefixed cross-site links.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const PROTOCOL_RE = /^[a-z][a-z0-9+.-]*:/i;
|
|
20
|
+
|
|
21
|
+
function normalizeBase(value) {
|
|
22
|
+
return "/" + value.replace(/^\/+|\/+$/g, "");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {RehypeBaseUrlOptions} opts
|
|
27
|
+
*/
|
|
28
|
+
export default function rehypeBaseUrl(opts) {
|
|
29
|
+
const base = normalizeBase(opts.base);
|
|
30
|
+
if (base === "/") {
|
|
31
|
+
return () => {};
|
|
32
|
+
}
|
|
33
|
+
const ownPrefix = base + "/";
|
|
34
|
+
const projectPrefix = opts.projectBase
|
|
35
|
+
? normalizeBase(opts.projectBase) + "/"
|
|
36
|
+
: null;
|
|
37
|
+
|
|
38
|
+
function rewrite(node) {
|
|
39
|
+
if (
|
|
40
|
+
node &&
|
|
41
|
+
node.type === "element" &&
|
|
42
|
+
node.tagName === "a" &&
|
|
43
|
+
node.properties &&
|
|
44
|
+
typeof node.properties.href === "string"
|
|
45
|
+
) {
|
|
46
|
+
const href = node.properties.href;
|
|
47
|
+
if (
|
|
48
|
+
href.length > 0 &&
|
|
49
|
+
!href.startsWith("//") &&
|
|
50
|
+
!PROTOCOL_RE.test(href) &&
|
|
51
|
+
!href.startsWith("#") &&
|
|
52
|
+
href.startsWith("/") &&
|
|
53
|
+
href !== base &&
|
|
54
|
+
!href.startsWith(ownPrefix) &&
|
|
55
|
+
!(projectPrefix && href.startsWith(projectPrefix))
|
|
56
|
+
) {
|
|
57
|
+
node.properties.href = base + href;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (node && node.children) {
|
|
61
|
+
for (const child of node.children) rewrite(child);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return (tree) => {
|
|
66
|
+
rewrite(tree);
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import rehypeBaseUrl from "./rehype-base-url.mjs";
|
|
3
|
+
|
|
4
|
+
type Element = {
|
|
5
|
+
type: "element";
|
|
6
|
+
tagName: string;
|
|
7
|
+
properties: Record<string, unknown>;
|
|
8
|
+
children: Element[];
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
function a(href: string): Element {
|
|
12
|
+
return { type: "element", tagName: "a", properties: { href }, children: [] };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function tree(...links: Element[]): Element {
|
|
16
|
+
return { type: "element", tagName: "root", properties: {}, children: links };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function run(opts: { base: string; projectBase?: string }, href: string): string {
|
|
20
|
+
const plugin = rehypeBaseUrl(opts);
|
|
21
|
+
const root = tree(a(href));
|
|
22
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
23
|
+
(plugin as any)(root);
|
|
24
|
+
return root.children[0].properties.href as string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("rehypeBaseUrl — main docs (base=/chant)", () => {
|
|
28
|
+
const opts = { base: "/chant", projectBase: "/chant" };
|
|
29
|
+
|
|
30
|
+
it("prepends base to plain root-relative link", () => {
|
|
31
|
+
expect(run(opts, "/lexicons/aws/")).toBe("/chant/lexicons/aws/");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("prepends base to /api/* TypeDoc link", () => {
|
|
35
|
+
expect(run(opts, "/api/classes/attrref/")).toBe("/chant/api/classes/attrref/");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("leaves already-prefixed /chant/ link unchanged", () => {
|
|
39
|
+
expect(run(opts, "/chant/concepts/philosophy/")).toBe(
|
|
40
|
+
"/chant/concepts/philosophy/",
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("leaves the bare base unchanged", () => {
|
|
45
|
+
expect(run(opts, "/chant")).toBe("/chant");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("leaves https://… unchanged", () => {
|
|
49
|
+
expect(run(opts, "https://example.com/foo")).toBe("https://example.com/foo");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("leaves protocol-relative // unchanged", () => {
|
|
53
|
+
expect(run(opts, "//cdn.example.com/x")).toBe("//cdn.example.com/x");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("leaves mailto: unchanged", () => {
|
|
57
|
+
expect(run(opts, "mailto:a@b")).toBe("mailto:a@b");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("leaves anchor #foo unchanged", () => {
|
|
61
|
+
expect(run(opts, "#section")).toBe("#section");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("leaves relative path unchanged", () => {
|
|
65
|
+
expect(run(opts, "foo/bar")).toBe("foo/bar");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("leaves dot-relative path unchanged", () => {
|
|
69
|
+
expect(run(opts, "../sibling/")).toBe("../sibling/");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("leaves empty href unchanged", () => {
|
|
73
|
+
expect(run(opts, "")).toBe("");
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("rehypeBaseUrl — lexicon (base=/chant/lexicons/aws, projectBase=/chant)", () => {
|
|
78
|
+
const opts = { base: "/chant/lexicons/aws", projectBase: "/chant" };
|
|
79
|
+
|
|
80
|
+
it("leaves cross-site /chant/… unchanged (projectBase guard)", () => {
|
|
81
|
+
expect(run(opts, "/chant/concepts/philosophy/")).toBe(
|
|
82
|
+
"/chant/concepts/philosophy/",
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("leaves cross-lexicon /chant/lexicons/k8s/… unchanged", () => {
|
|
87
|
+
expect(run(opts, "/chant/lexicons/k8s/")).toBe("/chant/lexicons/k8s/");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("leaves the lexicon's own base prefix unchanged", () => {
|
|
91
|
+
expect(run(opts, "/chant/lexicons/aws/composites/")).toBe(
|
|
92
|
+
"/chant/lexicons/aws/composites/",
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("prepends lexicon base to bare /foo/ (interpreted as site-local)", () => {
|
|
97
|
+
expect(run(opts, "/foo/bar/")).toBe("/chant/lexicons/aws/foo/bar/");
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("rehypeBaseUrl — base normalization", () => {
|
|
102
|
+
it("is a no-op when base is '/'", () => {
|
|
103
|
+
const plugin = rehypeBaseUrl({ base: "/" });
|
|
104
|
+
const root = tree(a("/foo"));
|
|
105
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
106
|
+
(plugin as any)(root);
|
|
107
|
+
expect(root.children[0].properties.href).toBe("/foo");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("handles trailing slashes in base option", () => {
|
|
111
|
+
expect(run({ base: "/chant/", projectBase: "/chant/" }, "/foo")).toBe(
|
|
112
|
+
"/chant/foo",
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("handles missing leading slash in base option", () => {
|
|
117
|
+
expect(run({ base: "chant", projectBase: "chant" }, "/foo")).toBe("/chant/foo");
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("rehypeBaseUrl — tree traversal", () => {
|
|
122
|
+
it("rewrites nested <a> hrefs", () => {
|
|
123
|
+
const plugin = rehypeBaseUrl({ base: "/chant", projectBase: "/chant" });
|
|
124
|
+
const root = tree();
|
|
125
|
+
root.children = [
|
|
126
|
+
{
|
|
127
|
+
type: "element",
|
|
128
|
+
tagName: "div",
|
|
129
|
+
properties: {},
|
|
130
|
+
children: [a("/foo"), a("/bar")],
|
|
131
|
+
},
|
|
132
|
+
a("/baz"),
|
|
133
|
+
];
|
|
134
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
135
|
+
(plugin as any)(root);
|
|
136
|
+
const div = root.children[0] as Element;
|
|
137
|
+
expect(div.children[0].properties.href).toBe("/chant/foo");
|
|
138
|
+
expect(div.children[1].properties.href).toBe("/chant/bar");
|
|
139
|
+
expect(root.children[1].properties.href).toBe("/chant/baz");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("leaves non-<a> elements alone", () => {
|
|
143
|
+
const plugin = rehypeBaseUrl({ base: "/chant" });
|
|
144
|
+
const root: Element = {
|
|
145
|
+
type: "element",
|
|
146
|
+
tagName: "root",
|
|
147
|
+
properties: {},
|
|
148
|
+
children: [
|
|
149
|
+
{
|
|
150
|
+
type: "element",
|
|
151
|
+
tagName: "img",
|
|
152
|
+
properties: { src: "/foo.png" },
|
|
153
|
+
children: [],
|
|
154
|
+
},
|
|
155
|
+
],
|
|
156
|
+
};
|
|
157
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
158
|
+
(plugin as any)(root);
|
|
159
|
+
expect(root.children[0].properties.src).toBe("/foo.png");
|
|
160
|
+
});
|
|
161
|
+
});
|
package/src/lexicon.ts
CHANGED
|
@@ -97,6 +97,47 @@ export interface IntrinsicDef {
|
|
|
97
97
|
readonly isTag?: boolean;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Options passed to a MigrationSource by `chant migrate`.
|
|
102
|
+
*/
|
|
103
|
+
export interface MigrateOptions {
|
|
104
|
+
/** Output format. */
|
|
105
|
+
emit?: "yaml" | "ts";
|
|
106
|
+
/** Recognise composite patterns when emitting. */
|
|
107
|
+
useComposites?: boolean;
|
|
108
|
+
/** Source file path (for provenance display only). */
|
|
109
|
+
sourceFile?: string;
|
|
110
|
+
/** Escalate needs-review diagnostics to errors. */
|
|
111
|
+
strict?: boolean;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Result of `MigrationSource.transform()`.
|
|
116
|
+
*
|
|
117
|
+
* Provenance is a generic side channel: each record is `{ sourceKey, rule,
|
|
118
|
+
* category, note?, ... }`. Diagnostics are SARIF-compatible records derived
|
|
119
|
+
* from provenance (concrete shape lives in `packages/core/src/lint/rule.ts`).
|
|
120
|
+
*/
|
|
121
|
+
export interface MigrationResult {
|
|
122
|
+
/** Rendered output (YAML by default, TS when emit: "ts"). */
|
|
123
|
+
output: string;
|
|
124
|
+
/** Per-key provenance records (typed loosely at the core level). */
|
|
125
|
+
provenance: Array<Record<string, unknown>>;
|
|
126
|
+
/** SARIF-shaped diagnostics. */
|
|
127
|
+
diagnostics: Array<Record<string, unknown>>;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Edge that translates one lexicon's source format into this lexicon's IR
|
|
132
|
+
* and output. Exposed via `LexiconPlugin.migrationSource(from)`.
|
|
133
|
+
*/
|
|
134
|
+
export interface MigrationSource {
|
|
135
|
+
/** Lightweight detector: does this content look like the expected source? */
|
|
136
|
+
detect(content: string): boolean;
|
|
137
|
+
/** Run the translation. */
|
|
138
|
+
transform(content: string, opts: MigrateOptions): Promise<MigrationResult>;
|
|
139
|
+
}
|
|
140
|
+
|
|
100
141
|
/**
|
|
101
142
|
* Structured init template output from a lexicon plugin.
|
|
102
143
|
*/
|
|
@@ -194,6 +235,16 @@ export interface LexiconPlugin {
|
|
|
194
235
|
/** Return MCP resource contributions */
|
|
195
236
|
mcpResources?(): McpResourceContribution[];
|
|
196
237
|
|
|
238
|
+
// Migration
|
|
239
|
+
/**
|
|
240
|
+
* Return a migration source for translating from another lexicon's
|
|
241
|
+
* format into this lexicon. Returns undefined if `from` is not supported.
|
|
242
|
+
*
|
|
243
|
+
* Example: the gitlab lexicon implements `migrationSource("github")` to
|
|
244
|
+
* translate `.github/workflows/*.yml` into `.gitlab-ci.yml`.
|
|
245
|
+
*/
|
|
246
|
+
migrationSource?(from: string): MigrationSource | undefined;
|
|
247
|
+
|
|
197
248
|
// State
|
|
198
249
|
/**
|
|
199
250
|
* Query deployed resources and return API metadata. Opt-in.
|
|
@@ -83,7 +83,7 @@ export function buildRuleRegistry(
|
|
|
83
83
|
source: plugin.name,
|
|
84
84
|
phase: "post-synth",
|
|
85
85
|
hasAutoFix: false,
|
|
86
|
-
helpUri: `https://
|
|
86
|
+
helpUri: `https://intentius.io/chant/lint-rules/${check.id.toLowerCase()}`,
|
|
87
87
|
});
|
|
88
88
|
}
|
|
89
89
|
}
|