@intentius/chant 0.97.0 → 0.99.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/dist/cli/commands/import.d.ts +67 -4
  2. package/dist/cli/commands/import.d.ts.map +1 -1
  3. package/dist/cli/commands/lint.d.ts.map +1 -1
  4. package/dist/cli/handlers/misc.d.ts.map +1 -1
  5. package/dist/cli/handlers/operator.d.ts.map +1 -1
  6. package/dist/cli/main.d.ts.map +1 -1
  7. package/dist/cli/mcp/tools/import.d.ts +4 -0
  8. package/dist/cli/mcp/tools/import.d.ts.map +1 -1
  9. package/dist/cli/plugins.d.ts +10 -0
  10. package/dist/cli/plugins.d.ts.map +1 -1
  11. package/dist/deep-observation.d.ts.map +1 -1
  12. package/dist/import/embedded.d.ts +184 -0
  13. package/dist/import/embedded.d.ts.map +1 -0
  14. package/dist/import/generator.d.ts +13 -0
  15. package/dist/import/generator.d.ts.map +1 -1
  16. package/dist/import/parser.d.ts +15 -1
  17. package/dist/import/parser.d.ts.map +1 -1
  18. package/dist/lexicon.d.ts +19 -0
  19. package/dist/lexicon.d.ts.map +1 -1
  20. package/dist/lint/engine.d.ts +6 -1
  21. package/dist/lint/engine.d.ts.map +1 -1
  22. package/dist/lint/rule.d.ts +10 -0
  23. package/dist/lint/rule.d.ts.map +1 -1
  24. package/dist/lint/rules/file-declarable-limit.d.ts.map +1 -1
  25. package/dist/lint/rules/flat-declarations.d.ts.map +1 -1
  26. package/dist/lint/rules/no-unused-declarable.d.ts.map +1 -1
  27. package/dist/lint/rules/property-kind.d.ts +7 -0
  28. package/dist/lint/rules/property-kind.d.ts.map +1 -0
  29. package/dist/workspace/conformance/index.d.ts +9 -0
  30. package/dist/workspace/conformance/index.d.ts.map +1 -1
  31. package/dist/yaml.d.ts +44 -6
  32. package/dist/yaml.d.ts.map +1 -1
  33. package/package.json +1 -1
  34. package/src/cli/commands/import-layout.test.ts +142 -0
  35. package/src/cli/commands/import-no-parser.test.ts +70 -0
  36. package/src/cli/commands/import.test.ts +284 -2
  37. package/src/cli/commands/import.ts +325 -86
  38. package/src/cli/commands/lint.ts +21 -8
  39. package/src/cli/handlers/misc.ts +3 -0
  40. package/src/cli/handlers/operator-steward-signal.e2e.test.ts +19 -11
  41. package/src/cli/handlers/operator.ts +17 -3
  42. package/src/cli/main.ts +3 -1
  43. package/src/cli/mcp/tools/import.ts +6 -0
  44. package/src/cli/plugins.ts +41 -2
  45. package/src/deep-observation.test.ts +21 -0
  46. package/src/deep-observation.ts +9 -0
  47. package/src/import/embedded.test.ts +153 -0
  48. package/src/import/embedded.ts +376 -0
  49. package/src/import/generator.ts +14 -0
  50. package/src/import/parser.ts +17 -1
  51. package/src/lexicon.ts +21 -0
  52. package/src/lint/engine.ts +7 -0
  53. package/src/lint/rule.ts +10 -0
  54. package/src/lint/rules/file-declarable-limit.ts +11 -4
  55. package/src/lint/rules/flat-declarations.ts +6 -1
  56. package/src/lint/rules/no-unused-declarable.ts +59 -2
  57. package/src/lint/rules/property-kind.test.ts +99 -0
  58. package/src/lint/rules/property-kind.ts +56 -0
  59. package/src/workspace/conformance/index.mjs +1 -0
  60. package/src/workspace/conformance/index.ts +23 -1
  61. package/src/yaml.test.ts +244 -1
  62. package/src/yaml.ts +443 -241
@@ -1,5 +1,12 @@
1
- import { describe, test, expect, beforeEach, afterEach } from "vitest";
2
- import { importCommand, type ImportOptions } from "./import";
1
+ import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import {
3
+ detectTemplateLexicon,
4
+ importCommand,
5
+ parseTemplateDocuments,
6
+ printImportResult,
7
+ type ImportOptions,
8
+ } from "./import";
9
+ import { listInstalledLexicons } from "../plugins";
3
10
  import { mkdir, rm, writeFile } from "node:fs/promises";
4
11
  import { existsSync, readFileSync } from "node:fs";
5
12
  import { join } from "node:path";
@@ -377,3 +384,278 @@ describe("importCommand", () => {
377
384
  }
378
385
  });
379
386
  });
387
+
388
+ // #2935 — YAML templates reach detection and the plugin's parser instead of
389
+ // failing JSON.parse, and `--lexicon` skips detection.
390
+ describe("importCommand with YAML templates", () => {
391
+ let testDir: string;
392
+ let outputDir: string;
393
+
394
+ const configMap = `apiVersion: v1
395
+ kind: ConfigMap
396
+ metadata:
397
+ name: app-config
398
+ data:
399
+ LOG_LEVEL: info
400
+ `;
401
+
402
+ const deployment = `apiVersion: apps/v1
403
+ kind: Deployment
404
+ metadata:
405
+ name: web
406
+ spec:
407
+ replicas: 2
408
+ selector:
409
+ matchLabels:
410
+ app: web
411
+ template:
412
+ metadata:
413
+ labels:
414
+ app: web
415
+ spec:
416
+ containers:
417
+ - name: web
418
+ image: nginx:1.27
419
+ `;
420
+
421
+ beforeEach(async () => {
422
+ testDir = join(tmpdir(), `chant-import-yaml-test-${Date.now()}-${Math.random()}`);
423
+ outputDir = join(testDir, "output");
424
+ await mkdir(testDir, { recursive: true });
425
+ });
426
+
427
+ afterEach(async () => {
428
+ await rm(testDir, { recursive: true, force: true });
429
+ });
430
+
431
+ /** A project that declares the k8s lexicon, so detection can find it. */
432
+ async function k8sProject(): Promise<void> {
433
+ await writeFile(join(testDir, "chant.config.json"), JSON.stringify({ lexicons: ["k8s"] }));
434
+ }
435
+
436
+ function generated(files: string[]): string {
437
+ return files.map((f) => readFileSync(join(outputDir, f), "utf-8")).join("\n");
438
+ }
439
+
440
+ test("detects and imports a single-document k8s manifest", async () => {
441
+ await k8sProject();
442
+ const templatePath = join(testDir, "manifest.yaml");
443
+ await writeFile(templatePath, configMap);
444
+
445
+ const result = await importCommand({ templatePath, output: outputDir });
446
+
447
+ expect(result.error).toBeUndefined();
448
+ expect(result.success).toBe(true);
449
+ expect(result.lexicon).toBe("k8s");
450
+ expect(generated(result.generatedFiles)).toContain("ConfigMap");
451
+ }, 30000);
452
+
453
+ test("detects and imports a multi-document k8s manifest", async () => {
454
+ await k8sProject();
455
+ const templatePath = join(testDir, "manifests.yaml");
456
+ await writeFile(templatePath, `# app manifests\n---\n${configMap}---\n${deployment}`);
457
+
458
+ const result = await importCommand({ templatePath, output: outputDir });
459
+
460
+ expect(result.error).toBeUndefined();
461
+ expect(result.success).toBe(true);
462
+ expect(result.lexicon).toBe("k8s");
463
+ const code = generated(result.generatedFiles);
464
+ expect(code).toContain("ConfigMap");
465
+ expect(code).toContain("Deployment");
466
+ }, 30000);
467
+
468
+ test("--lexicon imports without detection", async () => {
469
+ const templatePath = join(testDir, "manifest.yaml");
470
+ await writeFile(templatePath, configMap);
471
+
472
+ const result = await importCommand({ templatePath, output: outputDir, lexicon: "k8s" });
473
+
474
+ expect(result.error).toBeUndefined();
475
+ expect(result.success).toBe(true);
476
+ expect(result.lexicon).toBe("k8s");
477
+ expect(result.detected).toBeFalsy();
478
+ expect(generated(result.generatedFiles)).toContain("ConfigMap");
479
+ }, 30000);
480
+
481
+ // #2965 — with no chant.config and no source imports, every installed
482
+ // lexicon is tried, not only aws.
483
+ test("detects a k8s manifest outside a project from the installed lexicons", async () => {
484
+ const templatePath = join(testDir, "manifest.yaml");
485
+ await writeFile(templatePath, `--- # config\n${configMap}...\n`);
486
+
487
+ const result = await importCommand({ templatePath, output: outputDir });
488
+
489
+ expect(result.error).toBeUndefined();
490
+ expect(result.success).toBe(true);
491
+ expect(result.lexicon).toBe("k8s");
492
+ expect(result.detected).toBe(true);
493
+ expect(generated(result.generatedFiles)).toContain("ConfigMap");
494
+ }, 60000);
495
+
496
+ test("--lexicon with an unknown lexicon fails", async () => {
497
+ const templatePath = join(testDir, "manifest.yaml");
498
+ await writeFile(templatePath, configMap);
499
+
500
+ const result = await importCommand({ templatePath, output: outputDir, lexicon: "no-such-lexicon" });
501
+
502
+ expect(result.success).toBe(false);
503
+ expect(result.error).toContain("no-such-lexicon");
504
+ });
505
+
506
+ // #2991 — a blank or whitespace-only line after `spec:` (Helm renders one
507
+ // for an empty conditional) no longer ends the pod template's spec.
508
+ test("a blank line after a key keeps its block nested", async () => {
509
+ const templatePath = join(testDir, "manifest.yaml");
510
+ await writeFile(templatePath, deployment.replace(" spec:\n", " spec:\n \n"));
511
+
512
+ const result = await importCommand({ templatePath, output: outputDir, lexicon: "k8s" });
513
+
514
+ expect(result.error).toBeUndefined();
515
+ // The containers stay under template.spec, not hoisted beside `replicas`.
516
+ expect(generated(result.generatedFiles)).toMatch(/template: \{[\s\S]*spec: \{\s*containers: \[/);
517
+ }, 30000);
518
+
519
+ // #2991 — a document the YAML reader cannot place fails the import with
520
+ // its line, where it used to import a different manifest.
521
+ test("a mis-indented document fails the import and names the line", async () => {
522
+ const templatePath = join(testDir, "manifests.yaml");
523
+ await writeFile(templatePath, `${configMap}---\n${deployment.replace(" replicas: 2\n", " replicas: 2\n paused: true\n")}`);
524
+
525
+ const result = await importCommand({ templatePath, output: outputDir });
526
+
527
+ expect(result.success).toBe(false);
528
+ expect(result.error).toContain("YAML line 7");
529
+ }, 30000);
530
+
531
+ test("content that is neither JSON nor YAML names both formats", async () => {
532
+ const templatePath = join(testDir, "notes.txt");
533
+ await writeFile(templatePath, "this is not a template\n{ nor is this");
534
+
535
+ const result = await importCommand({ templatePath, output: outputDir });
536
+
537
+ expect(result.success).toBe(false);
538
+ expect(result.error).toContain("JSON");
539
+ expect(result.error).toContain("YAML");
540
+ });
541
+ });
542
+
543
+ // #2965 — detection outside a project, and inside one when the project's
544
+ // lexicons don't match.
545
+ describe("detectTemplateLexicon", () => {
546
+ let testDir: string;
547
+
548
+ const collector = {
549
+ receivers: { otlp: { protocols: { grpc: {} } } },
550
+ exporters: { debug: {} },
551
+ service: { pipelines: { traces: { receivers: ["otlp"], exporters: ["debug"] } } },
552
+ };
553
+
554
+ beforeEach(async () => {
555
+ testDir = join(tmpdir(), `chant-import-detect-test-${Date.now()}-${Math.random()}`);
556
+ await mkdir(testDir, { recursive: true });
557
+ });
558
+
559
+ afterEach(async () => {
560
+ await rm(testDir, { recursive: true, force: true });
561
+ });
562
+
563
+ test("an empty directory detects an otel collector config from the installed lexicons", async () => {
564
+ const detection = await detectTemplateLexicon([collector], testDir);
565
+ expect(detection?.plugin.name).toBe("otel");
566
+ expect(detection?.source).toBe("installed");
567
+ }, 60000);
568
+
569
+ test("a project lexicon that matches decides", async () => {
570
+ await writeFile(join(testDir, "chant.config.json"), JSON.stringify({ lexicons: ["aws"] }));
571
+ const detection = await detectTemplateLexicon([{ AWSTemplateFormatVersion: "2010-09-09", Resources: {} }], testDir);
572
+ expect(detection?.plugin.name).toBe("aws");
573
+ expect(detection?.source).toBe("project");
574
+ }, 60000);
575
+
576
+ test("inside a project, installed lexicons are tried when the project's don't match", async () => {
577
+ await writeFile(join(testDir, "chant.config.json"), JSON.stringify({ lexicons: ["aws"] }));
578
+ const detection = await detectTemplateLexicon([collector], testDir);
579
+ expect(detection?.plugin.name).toBe("otel");
580
+ expect(detection?.source).toBe("installed");
581
+ }, 60000);
582
+
583
+ test("names the other installed lexicons that also recognized the template", async () => {
584
+ // github and forgejo both read a GitHub-Actions-shaped workflow.
585
+ const workflow = { on: { push: {} }, jobs: { build: { "runs-on": "ubuntu-latest", steps: [] } } };
586
+ const detection = await detectTemplateLexicon([workflow], testDir);
587
+ expect(detection).toBeDefined();
588
+ // github has a template parser and forgejo does not, so github is chosen.
589
+ expect(detection!.plugin.name).toBe("github");
590
+ expect(detection!.alsoMatched).toContain("forgejo");
591
+ }, 60000);
592
+
593
+ test("returns undefined when no installed lexicon recognizes the template", async () => {
594
+ expect(await detectTemplateLexicon([{ version: "1.0", unknownField: {} }], testDir)).toBeUndefined();
595
+ }, 60000);
596
+ });
597
+
598
+ describe("listInstalledLexicons", () => {
599
+ test("lists the @intentius/chant-lexicon-* packages installed alongside chant", () => {
600
+ const names = listInstalledLexicons(tmpdir());
601
+ expect(names).toContain("aws");
602
+ expect(names).toContain("k8s");
603
+ expect(names).toContain("otel");
604
+ expect(names).toEqual([...names].sort());
605
+ });
606
+ });
607
+
608
+ describe("printImportResult", () => {
609
+ function printed(result: Parameters<typeof printImportResult>[0]): string {
610
+ const log = vi.spyOn(console, "log").mockImplementation(() => {});
611
+ try {
612
+ printImportResult(result);
613
+ return log.mock.calls.map((c) => c.join(" ")).join("\n");
614
+ } finally {
615
+ log.mockRestore();
616
+ }
617
+ }
618
+
619
+ test("says Detected lexicon when the lexicon was detected", () => {
620
+ expect(printed({ success: true, generatedFiles: [], warnings: [], lexicon: "k8s", detected: true })).toContain(
621
+ "Detected lexicon: k8s",
622
+ );
623
+ });
624
+
625
+ test("says Lexicon when the lexicon was named", () => {
626
+ const out = printed({ success: true, generatedFiles: [], warnings: [], lexicon: "k8s" });
627
+ expect(out).toContain("Lexicon: k8s");
628
+ expect(out).not.toContain("Detected");
629
+ });
630
+ });
631
+
632
+ describe("parseTemplateDocuments", () => {
633
+ test("JSON yields the parsed value as one document", () => {
634
+ expect(parseTemplateDocuments('{"a": 1}')).toEqual([{ a: 1 }]);
635
+ expect(parseTemplateDocuments("[1, 2]")).toEqual([[1, 2]]);
636
+ });
637
+
638
+ test("YAML splits on document separators and skips empty or comment-only documents", () => {
639
+ const docs = parseTemplateDocuments("---\n# only a comment\n---\na: 1\n---\nb: two\n---\n");
640
+ expect(docs).toEqual([{ a: 1 }, { b: "two" }]);
641
+ });
642
+
643
+ test("a top-level YAML list parses as a list (#2965)", () => {
644
+ expect(parseTemplateDocuments("- name: one\n expr: up\n- name: two\n")).toEqual([
645
+ [{ name: "one", expr: "up" }, { name: "two" }],
646
+ ]);
647
+ });
648
+
649
+ test("commented separators and document ends split documents (#2965)", () => {
650
+ expect(parseTemplateDocuments("--- # first\na: 1\n...\n--- # second\nb: two\n...\n")).toEqual([
651
+ { a: 1 },
652
+ { b: "two" },
653
+ ]);
654
+ });
655
+
656
+ test("returns undefined for content that is neither", () => {
657
+ expect(parseTemplateDocuments("")).toBeUndefined();
658
+ expect(parseTemplateDocuments("just some words")).toBeUndefined();
659
+ expect(parseTemplateDocuments("{ broken json")).toBeUndefined();
660
+ });
661
+ });