@intentius/chant 0.1.11 → 0.1.13

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 CHANGED
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "name": "@intentius/chant",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
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/intentius/chant.git",
9
+ "url": "https://github.com/INTENTIUS/chant.git",
10
10
  "directory": "packages/core"
11
11
  },
12
12
  "bugs": {
13
- "url": "https://github.com/intentius/chant/issues"
13
+ "url": "https://github.com/INTENTIUS/chant/issues"
14
14
  },
15
15
  "keywords": [
16
16
  "infrastructure-as-code",
@@ -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 skills = plugin.skills();
450
- if (skills.length > 0) {
451
- for (const skill of skills) {
452
- const skillDir = join(targetDir, "skills", skill.name);
453
- mkdirSync(skillDir, { recursive: true });
454
- writeFileSync(join(skillDir, "SKILL.md"), skill.content);
455
- createdFiles.push(`skills/${skill.name}/SKILL.md`);
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
+ }
@@ -17,6 +17,7 @@ export async function runInit(ctx: CommandContext): Promise<number> {
17
17
  path: args.path === "." ? undefined : args.path,
18
18
  lexicon: args.lexicon,
19
19
  template: args.template,
20
+ skill: args.skill,
20
21
  force: args.force,
21
22
  skipInstall: true,
22
23
  });
@@ -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
- result.report = true;
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 },
@@ -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.dev",
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://chant.dev/lint-rules/${id.toLowerCase()}`,
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
 
@@ -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://chant.dev/lint-rules/${check.id.toLowerCase()}`,
86
+ helpUri: `https://intentius.io/chant/lint-rules/${check.id.toLowerCase()}`,
87
87
  });
88
88
  }
89
89
  }