@intentius/chant 0.1.14 → 0.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/package.json +1 -1
  2. package/src/build.ts +18 -2
  3. package/src/cli/commands/build.ts +9 -1
  4. package/src/cli/commands/import-live.test.ts +126 -0
  5. package/src/cli/commands/import.ts +152 -2
  6. package/src/cli/commands/migrate.ts +2 -2
  7. package/src/cli/handlers/{state.test.ts → lifecycle.test.ts} +37 -37
  8. package/src/cli/handlers/{state.ts → lifecycle.ts} +148 -36
  9. package/src/cli/handlers/misc.ts +31 -2
  10. package/src/cli/handlers/run.test.ts +98 -0
  11. package/src/cli/handlers/run.ts +123 -0
  12. package/src/cli/main.test.ts +14 -0
  13. package/src/cli/main.ts +38 -12
  14. package/src/cli/mcp/{state-tools.ts → lifecycle-tools.ts} +9 -9
  15. package/src/cli/mcp/op-tools.ts +2 -2
  16. package/src/cli/mcp/resource-handlers.ts +1 -1
  17. package/src/cli/mcp/server.test.ts +2 -2
  18. package/src/cli/mcp/server.ts +1 -1
  19. package/src/cli/registry.ts +21 -2
  20. package/src/codegen/fetch.test.ts +103 -2
  21. package/src/codegen/fetch.ts +62 -10
  22. package/src/config.ts +31 -0
  23. package/src/detectLexicon.test.ts +2 -2
  24. package/src/index.ts +2 -2
  25. package/src/lexicon-export.test.ts +92 -0
  26. package/src/lexicon.ts +88 -9
  27. package/src/lifecycle/change-set.test.ts +151 -0
  28. package/src/lifecycle/change-set.ts +172 -0
  29. package/src/{state → lifecycle}/git.test.ts +15 -15
  30. package/src/{state → lifecycle}/git.ts +14 -14
  31. package/src/{state → lifecycle}/index.ts +2 -0
  32. package/src/{state → lifecycle}/snapshot.test.ts +5 -5
  33. package/src/{state → lifecycle}/snapshot.ts +9 -9
  34. package/src/{state → lifecycle}/types.ts +1 -1
  35. package/src/op/activity-registry.test.ts +59 -0
  36. package/src/op/activity-registry.ts +91 -0
  37. package/src/op/builders.ts +3 -3
  38. package/src/op/index.ts +6 -1
  39. package/src/op/local-executor.test.ts +247 -0
  40. package/src/op/local-executor.ts +300 -0
  41. package/src/op/local-output.test.ts +54 -0
  42. package/src/op/local-output.ts +63 -0
  43. package/src/op/op.test.ts +4 -4
  44. package/src/op/types.ts +1 -1
  45. package/src/ownership.test.ts +109 -0
  46. package/src/ownership.ts +142 -0
  47. package/src/serializer.ts +19 -1
  48. package/src/toml-parse.ts +3 -3
  49. /package/src/{state → lifecycle}/digest.test.ts +0 -0
  50. /package/src/{state → lifecycle}/digest.ts +0 -0
  51. /package/src/{state → lifecycle}/live-diff.test.ts +0 -0
  52. /package/src/{state → lifecycle}/live-diff.ts +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
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",
package/src/build.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { Declarable } from "./declarable";
2
2
  import type { Serializer, SerializerResult } from "./serializer";
3
+ import type { OwnershipMarker } from "./ownership";
3
4
  import type { DiscoveryError, BuildError } from "./errors";
4
5
  import { BuildError as BuildErrorClass } from "./errors";
5
6
  import { LexiconOutput, isLexiconOutput } from "./lexicon-output";
@@ -24,6 +25,17 @@ export interface BuildManifest {
24
25
  /**
25
26
  * Result of the build process
26
27
  */
28
+ /**
29
+ * Optional inputs to the build pipeline.
30
+ */
31
+ export interface BuildOptions {
32
+ /**
33
+ * When set, serializers stamp this ownership marker into each resource's
34
+ * native metadata channel. Resolved from project config by the caller.
35
+ */
36
+ ownership?: OwnershipMarker;
37
+ }
38
+
27
39
  export interface BuildResult {
28
40
  /** Map of lexicon name to serialized output (string or multi-file result) */
29
41
  outputs: Map<string, string | SerializerResult>;
@@ -310,6 +322,7 @@ export async function build(
310
322
  path: string,
311
323
  serializers: Serializer[],
312
324
  parentBuildStack?: Set<string>,
325
+ options?: BuildOptions,
313
326
  ): Promise<BuildResult> {
314
327
  const warnings: string[] = [];
315
328
  const errors: Array<DiscoveryError | BuildError> = [];
@@ -363,7 +376,7 @@ export async function build(
363
376
  );
364
377
  continue;
365
378
  }
366
- const childResult = await build(childPath, serializers, buildStack);
379
+ const childResult = await build(childPath, serializers, buildStack, options);
367
380
  entity.buildResult = childResult;
368
381
  if (childResult.errors.length > 0) {
369
382
  for (const err of childResult.errors) {
@@ -429,7 +442,10 @@ export async function build(
429
442
  ...(outputsByLexicon.get(lexiconName) ?? []),
430
443
  ...unassignedOutputs,
431
444
  ];
432
- outputs.set(lexiconName, serializer.serialize(lexiconEntities, lexiconLexiconOutputs));
445
+ outputs.set(
446
+ lexiconName,
447
+ serializer.serialize(lexiconEntities, lexiconLexiconOutputs, { ownership: options?.ownership }),
448
+ );
433
449
  } else {
434
450
  warnings.push(`No serializer found for lexicon "${lexiconName}"`);
435
451
  }
@@ -1,4 +1,5 @@
1
1
  import { build } from "../../build";
2
+ import { loadChantConfig, resolveOwnershipMarker } from "../../config";
2
3
  import type { Serializer, SerializerResult } from "../../serializer";
3
4
  import type { LexiconPlugin } from "../../lexicon";
4
5
  import { runPostSynthChecks } from "../../lint/post-synth";
@@ -53,8 +54,15 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
53
54
  // Resolve the path
54
55
  const infraPath = resolve(options.path);
55
56
 
57
+ // Resolve opt-in ownership marking from project config (search the infra dir
58
+ // and its parent, where chant.config.* usually lives).
59
+ const { config } = await loadChantConfig(infraPath).then((r) =>
60
+ r.configPath ? r : loadChantConfig(dirname(infraPath)),
61
+ );
62
+ const ownership = resolveOwnershipMarker(config);
63
+
56
64
  // Run the build
57
- const result = await build(infraPath, options.serializers);
65
+ const result = await build(infraPath, options.serializers, undefined, { ownership });
58
66
 
59
67
  // Format errors
60
68
  for (const error of result.errors) {
@@ -0,0 +1,126 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "vitest";
2
+ import { liveImportFromPlugins } from "./import";
3
+ import { mkdir, rm, readFile } from "node:fs/promises";
4
+ import { existsSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { tmpdir } from "node:os";
7
+ import type { LexiconPlugin, ExportedTemplate, ResourceSelector } from "../../lexicon";
8
+ import type { TypeScriptGenerator } from "../../import/generator";
9
+ import type { TemplateIR } from "../../import/parser";
10
+
11
+ const generator: TypeScriptGenerator = {
12
+ generate(ir: TemplateIR) {
13
+ return [
14
+ {
15
+ path: "main.ts",
16
+ content: ir.resources
17
+ .map((r) => `export const ${r.logicalId} = ${JSON.stringify(r.properties)};`)
18
+ .join("\n"),
19
+ },
20
+ ];
21
+ },
22
+ };
23
+
24
+ function fakeExporter(name: string, ir: ExportedTemplate): LexiconPlugin {
25
+ return {
26
+ name,
27
+ serializer: {} as never,
28
+ generate: async () => {},
29
+ validate: async () => {},
30
+ coverage: async () => {},
31
+ package: async () => {},
32
+ templateGenerator: () => generator,
33
+ async exportResources(opts: { selector?: ResourceSelector }): Promise<ExportedTemplate> {
34
+ if (!opts.selector) return ir;
35
+ return {
36
+ ...ir,
37
+ resources: ir.resources.filter(
38
+ (r) =>
39
+ (opts.selector!.type === undefined || r.type === opts.selector!.type) &&
40
+ (opts.selector!.name === undefined || r.logicalId === opts.selector!.name),
41
+ ),
42
+ };
43
+ },
44
+ };
45
+ }
46
+
47
+ const sampleIR: ExportedTemplate = {
48
+ resources: [
49
+ { logicalId: "bucket", type: "Fake::Bucket", properties: { versioning: true } },
50
+ { logicalId: "queue", type: "Fake::Queue", properties: { fifo: false } },
51
+ ],
52
+ parameters: [],
53
+ };
54
+
55
+ describe("liveImportFromPlugins (#114)", () => {
56
+ let outputDir: string;
57
+
58
+ beforeEach(async () => {
59
+ outputDir = join(tmpdir(), `chant-live-import-${Date.now()}-${Math.random()}`);
60
+ await mkdir(outputDir, { recursive: true });
61
+ });
62
+ afterEach(async () => {
63
+ await rm(outputDir, { recursive: true, force: true });
64
+ });
65
+
66
+ test("regenerates resources from a live exporter", async () => {
67
+ const result = await liveImportFromPlugins([fakeExporter("fake", sampleIR)], {
68
+ environment: "prod",
69
+ output: outputDir,
70
+ force: true,
71
+ });
72
+ expect(result.success).toBe(true);
73
+ expect(result.lexicon).toBe("fake");
74
+ const content = await readFile(join(outputDir, result.generatedFiles[0]), "utf-8");
75
+ expect(content).toContain("bucket");
76
+ expect(content).toContain("queue");
77
+ });
78
+
79
+ test("--name selector narrows the regenerated source", async () => {
80
+ const result = await liveImportFromPlugins([fakeExporter("fake", sampleIR)], {
81
+ environment: "prod",
82
+ output: outputDir,
83
+ force: true,
84
+ selector: { name: "queue" },
85
+ });
86
+ const content = await readFile(join(outputDir, result.generatedFiles[0]), "utf-8");
87
+ expect(content).toContain("queue");
88
+ expect(content).not.toContain("bucket");
89
+ });
90
+
91
+ test("errors when no lexicon supports live export", async () => {
92
+ const nonExporter: LexiconPlugin = {
93
+ name: "noexport",
94
+ serializer: {} as never,
95
+ generate: async () => {},
96
+ validate: async () => {},
97
+ coverage: async () => {},
98
+ package: async () => {},
99
+ };
100
+ const result = await liveImportFromPlugins([nonExporter], {
101
+ environment: "prod",
102
+ output: outputDir,
103
+ });
104
+ expect(result.success).toBe(false);
105
+ expect(result.error).toContain("live export");
106
+ });
107
+
108
+ test("--lexicon narrows to the named exporter", async () => {
109
+ const result = await liveImportFromPlugins(
110
+ [fakeExporter("a", sampleIR), fakeExporter("b", sampleIR)],
111
+ { environment: "prod", output: outputDir, force: true, lexicon: "b" },
112
+ );
113
+ expect(result.success).toBe(true);
114
+ expect(result.lexicon).toBe("b");
115
+ });
116
+
117
+ test("errors when the environment exports nothing", async () => {
118
+ const empty = fakeExporter("fake", { resources: [], parameters: [] });
119
+ const result = await liveImportFromPlugins([empty], {
120
+ environment: "prod",
121
+ output: outputDir,
122
+ });
123
+ expect(result.success).toBe(false);
124
+ expect(result.error).toContain("No resources exported");
125
+ });
126
+ });
@@ -1,10 +1,10 @@
1
1
  import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync } from "fs";
2
2
  import { join, resolve, basename, dirname } from "path";
3
3
  import { formatSuccess, formatWarning, formatError } from "../format";
4
- import type { TemplateIR, ResourceIR, TemplateParser } from "../../import/parser";
4
+ import type { TemplateIR, ResourceIR, ParameterIR, TemplateParser } from "../../import/parser";
5
5
  import type { GeneratedFile, TypeScriptGenerator } from "../../import/generator";
6
6
  import { loadPlugins, resolveProjectLexicons } from "../plugins";
7
- import type { LexiconPlugin } from "../../lexicon";
7
+ import type { LexiconPlugin, ResourceSelector } from "../../lexicon";
8
8
 
9
9
  /**
10
10
  * Import command options
@@ -310,6 +310,156 @@ export async function importCommand(options: ImportOptions): Promise<ImportResul
310
310
  };
311
311
  }
312
312
 
313
+ /**
314
+ * Live import options — read from a running cloud/cluster instead of a file.
315
+ */
316
+ export interface LiveImportOptions {
317
+ /** Environment to resolve (passed to each lexicon's exportResources). */
318
+ environment: string;
319
+ /** Restrict to one lexicon by name (e.g. "aws", "k8s"). */
320
+ lexicon?: string;
321
+ /** Output directory (defaults to ./infra/). */
322
+ output?: string;
323
+ /** Force overwrite existing files. */
324
+ force?: boolean;
325
+ /** Selector forwarded to the lexicon. */
326
+ selector?: ResourceSelector;
327
+ /** Restrict to chant-owned resources (inert until ownership marking lands). */
328
+ owned?: boolean;
329
+ /** Keep server-defaulted fields instead of stripping to declared shape. */
330
+ verbatim?: boolean;
331
+ }
332
+
333
+ /**
334
+ * Merge several template IRs into one. Resources and parameters concatenate;
335
+ * later metadata wins on key collisions.
336
+ */
337
+ function mergeIR(parts: TemplateIR[]): TemplateIR {
338
+ const resources: ResourceIR[] = [];
339
+ const parameters: ParameterIR[] = [];
340
+ let metadata: Record<string, unknown> | undefined;
341
+ for (const part of parts) {
342
+ resources.push(...part.resources);
343
+ parameters.push(...part.parameters);
344
+ if (part.metadata) metadata = { ...(metadata ?? {}), ...part.metadata };
345
+ }
346
+ return { resources, parameters, metadata };
347
+ }
348
+
349
+ /**
350
+ * Import directly from a live environment: ask each lexicon's exportResources
351
+ * for full-fidelity IR, then generate chant TypeScript from it.
352
+ *
353
+ * Unlike file import, the live config may contain secrets — the caller prints a
354
+ * warning. Reuses the same by-category output organization as file import.
355
+ */
356
+ export async function importFromLive(options: LiveImportOptions): Promise<ImportResult> {
357
+ const projectDir = resolve(options.output ? dirname(options.output) : ".");
358
+
359
+ // Resolve project lexicons, then hand off to the testable core.
360
+ let plugins: LexiconPlugin[];
361
+ try {
362
+ const lexiconNames = await resolveProjectLexicons(projectDir);
363
+ plugins = await loadPlugins(lexiconNames);
364
+ } catch {
365
+ plugins = [];
366
+ }
367
+
368
+ return liveImportFromPlugins(plugins, options);
369
+ }
370
+
371
+ /**
372
+ * Live-import core: given resolved plugins, export and generate. Split from
373
+ * plugin resolution so it can be tested with fake exporters (no cloud calls).
374
+ */
375
+ export async function liveImportFromPlugins(
376
+ plugins: LexiconPlugin[],
377
+ options: LiveImportOptions,
378
+ ): Promise<ImportResult> {
379
+ const outputDir = resolve(options.output ?? "./infra/");
380
+ const warnings: string[] = [];
381
+
382
+ let exporters = plugins.filter((p) => p.exportResources && p.templateGenerator);
383
+ if (options.lexicon) {
384
+ exporters = exporters.filter((p) => p.name === options.lexicon);
385
+ }
386
+
387
+ if (exporters.length === 0) {
388
+ return {
389
+ success: false,
390
+ generatedFiles: [],
391
+ warnings: [],
392
+ error: options.lexicon
393
+ ? `Lexicon "${options.lexicon}" does not support live export, or is not in this project.`
394
+ : "No project lexicon supports live export (exportResources).",
395
+ };
396
+ }
397
+
398
+ // Collect IR from every exporter, tagging which lexicon produced output.
399
+ const irParts: TemplateIR[] = [];
400
+ let generatorLexicon: LexiconPlugin | undefined;
401
+ for (const plugin of exporters) {
402
+ let ir: TemplateIR;
403
+ try {
404
+ ir = await plugin.exportResources!({
405
+ environment: options.environment,
406
+ selector: options.selector,
407
+ owned: options.owned,
408
+ verbatim: options.verbatim,
409
+ });
410
+ } catch (err) {
411
+ warnings.push(`${plugin.name}: live export failed — ${err instanceof Error ? err.message : String(err)}`);
412
+ continue;
413
+ }
414
+ if (ir.resources.length === 0) continue;
415
+ irParts.push(ir);
416
+ generatorLexicon ??= plugin;
417
+ }
418
+
419
+ if (irParts.length === 0 || !generatorLexicon) {
420
+ return {
421
+ success: false,
422
+ generatedFiles: [],
423
+ warnings,
424
+ error: `No resources exported from environment "${options.environment}".`,
425
+ };
426
+ }
427
+
428
+ if (exporters.length > 1 && irParts.length > 1) {
429
+ warnings.push("Multiple lexicons exported resources; generated with the first. Use --lexicon to target one.");
430
+ }
431
+
432
+ const ir = mergeIR(irParts);
433
+ const generator = generatorLexicon.templateGenerator!();
434
+
435
+ if (!existsSync(outputDir)) {
436
+ mkdirSync(outputDir, { recursive: true });
437
+ }
438
+
439
+ const files = generateOrganizedFiles(ir, generator);
440
+ const generatedFiles: string[] = [];
441
+ for (const file of files) {
442
+ const filePath = join(outputDir, file.path);
443
+ const dirPath = join(outputDir, file.path.split("/").slice(0, -1).join("/"));
444
+ if (dirPath && !existsSync(dirPath)) {
445
+ mkdirSync(dirPath, { recursive: true });
446
+ }
447
+ if (existsSync(filePath) && !options.force) {
448
+ warnings.push(`File ${file.path} already exists, skipping`);
449
+ continue;
450
+ }
451
+ writeFileSync(filePath, file.content);
452
+ generatedFiles.push(file.path);
453
+ }
454
+
455
+ return {
456
+ success: true,
457
+ generatedFiles,
458
+ warnings,
459
+ lexicon: generatorLexicon.name,
460
+ };
461
+ }
462
+
313
463
  /**
314
464
  * Print import result
315
465
  */
@@ -241,7 +241,7 @@ async function loadMigrationRules(targetLexicon: string): Promise<LintRule[]> {
241
241
  * Format the migration report as Markdown, mirroring the output format
242
242
  * prescribed by the upstream gitlab-org/ci-cd/github-actions-to-gitlab-ci
243
243
  * skill: overview, classification, diagnostic table, aggregated manual
244
- * setup steps, suggested GitLab improvements, honest caveats.
244
+ * setup steps, suggested GitLab improvements, caveats.
245
245
  *
246
246
  * The same data backs the SARIF report (via formatSarif); this is the
247
247
  * human-readable surface.
@@ -310,7 +310,7 @@ function formatMarkdownSummary(
310
310
 
311
311
  if (totals.error > 0 || totals.warning > 0) {
312
312
  lines.push("");
313
- lines.push("### Honest caveats");
313
+ lines.push("### Caveats");
314
314
  lines.push("");
315
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
316
  }