@intentius/chant 0.96.0 → 0.98.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.
@@ -9,6 +9,11 @@ export interface ImportOptions {
9
9
  output?: string;
10
10
  /** Force overwrite existing files */
11
11
  force?: boolean;
12
+ /**
13
+ * Lexicon whose parser handles the file (#2935). Skips detection and the
14
+ * JSON/YAML check: the raw content goes straight to that plugin's parser.
15
+ */
16
+ lexicon?: string;
12
17
  }
13
18
  /**
14
19
  * Import command result
@@ -22,9 +27,50 @@ export interface ImportResult {
22
27
  warnings: string[];
23
28
  /** Error message if failed */
24
29
  error?: string;
25
- /** Detected lexicon */
30
+ /** The lexicon that handled the template */
26
31
  lexicon?: string;
32
+ /**
33
+ * True when `lexicon` was found by template detection, false or absent when
34
+ * it was named (`--lexicon`, `--kustomize`) (#2965).
35
+ */
36
+ detected?: boolean;
37
+ }
38
+ /**
39
+ * Parse template content for detection (#2935): JSON first, then YAML. A YAML
40
+ * file is split into documents with core's `splitYAMLDocuments` (the k8s
41
+ * parser splits the same way), and each document is parsed on its own, so
42
+ * detection sees the same per-document objects the plugin's parser will. A
43
+ * document whose top level is a list parses as that list (#2965). JSON yields
44
+ * one document, the parsed value, exactly as before. Returns undefined when
45
+ * the content is neither: core's YAML reader is lenient and turns unparseable
46
+ * text into an empty mapping, so a file with no non-empty document is
47
+ * rejected.
48
+ */
49
+ export declare function parseTemplateDocuments(content: string): unknown[] | undefined;
50
+ /** The outcome of template detection (#2965). */
51
+ export interface TemplateDetection {
52
+ /** The plugin that handles the template. */
53
+ plugin: LexiconPlugin;
54
+ /**
55
+ * Where it came from: one of the project's lexicons, or an installed
56
+ * lexicon package tried because none of the project's matched.
57
+ */
58
+ source: "project" | "installed";
59
+ /** Other installed lexicons that also recognized the deciding document. */
60
+ alsoMatched: string[];
27
61
  }
62
+ /**
63
+ * Detect which lexicon handles a template (#2965). The project's lexicons
64
+ * (from chant.config, or the lexicons its source imports) are asked first, so
65
+ * inside a project nothing changes when one of them matches. When none does,
66
+ * or there is no project, every installed `@intentius/chant-lexicon-*`
67
+ * package that is not already a project lexicon is asked, in name order,
68
+ * and one with a `templateParser` is preferred over one without.
69
+ * Installed lexicons are loaded one at a time and without `init()`; one that
70
+ * fails to load is skipped. The chosen plugin is initialized before it is
71
+ * returned.
72
+ */
73
+ export declare function detectTemplateLexicon(documents: unknown[], projectDir: string): Promise<TemplateDetection | undefined>;
28
74
  /**
29
75
  * Execute the import command
30
76
  */
@@ -33,8 +79,9 @@ export declare function importCommand(options: ImportOptions): Promise<ImportRes
33
79
  * Import from an in-memory template string through a KNOWN plugin — no
34
80
  * detection, no JSON assumption (#1548). This is the seam
35
81
  * `chant import --kustomize <dir>` drives with `kustomize build` output
36
- * through the k8s plugin's YAML parser; `importCommand` above is the same
37
- * pipeline behind file reading + JSON detection.
82
+ * through the k8s plugin's YAML parser, and `chant import <file> --lexicon
83
+ * <name>` drives with the file's content (#2935); `importCommand` above is
84
+ * the same pipeline behind file reading + JSON/YAML detection.
38
85
  */
39
86
  export interface ContentImportOptions {
40
87
  /** The raw template content (YAML or JSON — the plugin's parser decides). */
@@ -1 +1 @@
1
- {"version":3,"file":"import.d.ts","sourceRoot":"","sources":["../../../src/cli/commands/import.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,aAAa,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAErE;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,4BAA4B;IAC5B,YAAY,EAAE,MAAM,CAAC;IACrB,8CAA8C;IAC9C,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,qCAAqC;IACrC,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B,+BAA+B;IAC/B,OAAO,EAAE,OAAO,CAAC;IACjB,sBAAsB;IACtB,cAAc,EAAE,MAAM,EAAE,CAAC;IACzB,uBAAuB;IACvB,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,8BAA8B;IAC9B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,uBAAuB;IACvB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAmID;;GAEG;AACH,wBAAsB,aAAa,CAAC,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,YAAY,CAAC,CA4EjF;AAED;;;;;;GAMG;AACH,MAAM,WAAW,oBAAoB;IACnC,6EAA6E;IAC7E,OAAO,EAAE,MAAM,CAAC;IAChB,gEAAgE;IAChE,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED,wBAAsB,iBAAiB,CAAC,OAAO,EAAE,oBAAoB,GAAG,OAAO,CAAC,YAAY,CAAC,CA2B5F;AA6ED;;GAEG;AACH,MAAM,WAAW,iBAAiB;IAChC,yEAAyE;IACzE,WAAW,EAAE,MAAM,CAAC;IACpB;;uBAEmB;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,2DAA2D;IAC3D,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,+CAA+C;IAC/C,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,sCAAsC;IACtC,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,yCAAyC;IACzC,QAAQ,CAAC,EAAE,gBAAgB,CAAC;IAC5B,+EAA+E;IAC/E,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,2EAA2E;IAC3E,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAkBD;;;;;;GAMG;AACH,wBAAsB,cAAc,CAAC,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,YAAY,CAAC,CAatF;AAED;;;;;;;;GAQG;AACH,wBAAsB,oBAAoB,CACxC,OAAO,EAAE,IAAI,CAAC,iBAAiB,EAAE,OAAO,GAAG,QAAQ,CAAC,EACpD,MAAM,EAAE,KAAK,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAAC,GAC3C,OAAO,CAAC,KAAK,CAAC;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,YAAY,CAAA;CAAE,CAAC,CAAC,CAezD;AAED;;;GAGG;AACH,wBAAsB,qBAAqB,CACzC,OAAO,EAAE,aAAa,EAAE,EACxB,OAAO,EAAE,iBAAiB,GACzB,OAAO,CAAC,YAAY,CAAC,CAoFvB;AAED;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,YAAY,GAAG,IAAI,CAsB5D"}
1
+ {"version":3,"file":"import.d.ts","sourceRoot":"","sources":["../../../src/cli/commands/import.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"misc.d.ts","sourceRoot":"","sources":["../../../src/cli/handlers/misc.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AA8ClD,wBAAsB,QAAQ,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAqGnE;AAED,wBAAsB,OAAO,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAuClE;AAED,wBAAsB,WAAW,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CA8CtE;AAED,wBAAsB,SAAS,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAsIpE;AAED,wBAAsB,SAAS,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAKpE;AAED,wBAAsB,SAAS,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAgBpE"}
1
+ {"version":3,"file":"misc.d.ts","sourceRoot":"","sources":["../../../src/cli/handlers/misc.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AA8ClD,wBAAsB,QAAQ,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAqGnE;AAED,wBAAsB,OAAO,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAuClE;AAED,wBAAsB,WAAW,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CA8CtE;AAED,wBAAsB,SAAS,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAyIpE;AAED,wBAAsB,SAAS,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAKpE;AAED,wBAAsB,SAAS,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAgBpE"}
@@ -1 +1 @@
1
- {"version":3,"file":"operator.d.ts","sourceRoot":"","sources":["../../../src/cli/handlers/operator.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"operator.d.ts","sourceRoot":"","sources":["../../../src/cli/handlers/operator.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"main.d.ts","sourceRoot":"","sources":["../../src/cli/main.ts"],"names":[],"mappings":";AAMA,OAAO,EAAkB,KAAK,UAAU,EAAE,KAAK,UAAU,EAAE,MAAM,YAAY,CAAC;AAuG9E;;GAEG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,UAAU,CAyhBpD;AA6rBD;;;;;;;;;;GAUG;AACH,wBAAsB,mBAAmB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAuBzE;AAID;;;;;;;;;GASG;AACH,eAAO,MAAM,eAAe,EAAE,UAAU,EAoIvC,CAAC;AA4KF;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,CAAC,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAuB5E"}
1
+ {"version":3,"file":"main.d.ts","sourceRoot":"","sources":["../../src/cli/main.ts"],"names":[],"mappings":";AAMA,OAAO,EAAkB,KAAK,UAAU,EAAE,KAAK,UAAU,EAAE,MAAM,YAAY,CAAC;AAuG9E;;GAEG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,UAAU,CAyhBpD;AA+rBD;;;;;;;;;;GAUG;AACH,wBAAsB,mBAAmB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAuBzE;AAID;;;;;;;;;GASG;AACH,eAAO,MAAM,eAAe,EAAE,UAAU,EAoIvC,CAAC;AA4KF;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,CAAC,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAuB5E"}
@@ -15,6 +15,10 @@ export declare const importTool: {
15
15
  type: string;
16
16
  description: string;
17
17
  };
18
+ lexicon: {
19
+ type: string;
20
+ description: string;
21
+ };
18
22
  };
19
23
  required: string[];
20
24
  };
@@ -1 +1 @@
1
- {"version":3,"file":"import.d.ts","sourceRoot":"","sources":["../../../../src/cli/mcp/tools/import.ts"],"names":[],"mappings":"AAEA;;GAEG;AACH,eAAO,MAAM,UAAU;;;;;;;;;;;;;;;;;CAiBtB,CAAC;AAEF;;GAEG;AACH,wBAAsB,YAAY,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAAC,OAAO,CAAC,CAoBpF"}
1
+ {"version":3,"file":"import.d.ts","sourceRoot":"","sources":["../../../../src/cli/mcp/tools/import.ts"],"names":[],"mappings":"AAEA;;GAEG;AACH,eAAO,MAAM,UAAU;;;;;;;;;;;;;;;;;;;;;CAqBtB,CAAC;AAEF;;GAEG;AACH,wBAAsB,YAAY,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAAC,OAAO,CAAC,CAsBpF"}
@@ -20,6 +20,16 @@ export declare function recordProjectLexicons(dir: string): StaticLexiconRead;
20
20
  * LexiconPlugin export. Falls back to wrapping a raw Serializer export.
21
21
  */
22
22
  export declare function loadPlugin(lexiconName: string): Promise<LexiconPlugin>;
23
+ /**
24
+ * The names of the lexicon packages installed where {@link loadPlugin} can
25
+ * load them (#2965), sorted. `loadPlugin` imports
26
+ * `@intentius/chant-lexicon-<name>` from chant's own install and then from the
27
+ * project (`importLexiconPackage`), so this lists the `@intentius` scope of
28
+ * every `node_modules` directory Node would search from either place: each
29
+ * ancestor of chant's own module and each ancestor of `projectDir`. A name
30
+ * being listed does not promise it loads; the caller still handles a failure.
31
+ */
32
+ export declare function listInstalledLexicons(projectDir?: string): string[];
23
33
  /**
24
34
  * The installed version of each named lexicon package (chant #1442).
25
35
  *
@@ -1 +1 @@
1
- {"version":3,"file":"plugins.d.ts","sourceRoot":"","sources":["../../src/cli/plugins.ts"],"names":[],"mappings":"AACA,OAAO,EAAqC,KAAK,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAC7F,OAAO,EAAE,yBAAyB,EAAE,MAAM,kBAAkB,CAAC;AAI7D,OAAO,EAAmB,KAAK,aAAa,EAAE,MAAM,YAAY,CAAC;AAKjE;;;;;;;;;;GAUG;AACH,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,MAAM,GAAG,iBAAiB,CAIpE;AAED;;;;;GAKG;AACH,wBAAsB,UAAU,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC,CA8C5E;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,sBAAsB,CAAC,YAAY,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CA0C9F;AAED;;;;;;GAMG;AACH,wBAAgB,4BAA4B,CAC1C,OAAO,EAAE,SAAS,aAAa,EAAE,GAAG,SAAS,EAC7C,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,WAAW,EAAE,MAAM,GAClB,KAAK,CAAC,OAAO,YAAY,EAAE,oBAAoB,CAAC,CAMlD;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,wBAAwB,CACtC,OAAO,EAAE,SAAS,aAAa,EAAE,GAAG,SAAS,EAC7C,OAAO,EAAE;IACP,WAAW,EAAE,MAAM,CAAC;IACpB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,wDAAwD;IACxD,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAC,CAAC,CAAC;CAC5F,GACA,KAAK,CAAC,OAAO,gBAAgB,EAAE,gBAAgB,CAAC,CAgBlD;AAED;;;GAGG;AACH,wBAAsB,WAAW,CAAC,YAAY,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,aAAa,EAAE,CAAC,CA2BlF;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,wBAAsB,sBAAsB,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAUnF"}
1
+ {"version":3,"file":"plugins.d.ts","sourceRoot":"","sources":["../../src/cli/plugins.ts"],"names":[],"mappings":"AACA,OAAO,EAAqC,KAAK,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAC7F,OAAO,EAAE,yBAAyB,EAAE,MAAM,kBAAkB,CAAC;AAK7D,OAAO,EAAmB,KAAK,aAAa,EAAE,MAAM,YAAY,CAAC;AAKjE;;;;;;;;;;GAUG;AACH,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,MAAM,GAAG,iBAAiB,CAIpE;AAED;;;;;GAKG;AACH,wBAAsB,UAAU,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC,CA8C5E;AAID;;;;;;;;GAQG;AACH,wBAAgB,qBAAqB,CAAC,UAAU,GAAE,MAAsB,GAAG,MAAM,EAAE,CAyBlF;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,sBAAsB,CAAC,YAAY,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CA0C9F;AAED;;;;;;GAMG;AACH,wBAAgB,4BAA4B,CAC1C,OAAO,EAAE,SAAS,aAAa,EAAE,GAAG,SAAS,EAC7C,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,WAAW,EAAE,MAAM,GAClB,KAAK,CAAC,OAAO,YAAY,EAAE,oBAAoB,CAAC,CAMlD;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,wBAAwB,CACtC,OAAO,EAAE,SAAS,aAAa,EAAE,GAAG,SAAS,EAC7C,OAAO,EAAE;IACP,WAAW,EAAE,MAAM,CAAC;IACpB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,wDAAwD;IACxD,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAC,CAAC,CAAC;CAC5F,GACA,KAAK,CAAC,OAAO,gBAAgB,EAAE,gBAAgB,CAAC,CAgBlD;AAED;;;GAGG;AACH,wBAAsB,WAAW,CAAC,YAAY,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,aAAa,EAAE,CAAC,CA2BlF;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,wBAAsB,sBAAsB,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAUnF"}
package/dist/yaml.d.ts CHANGED
@@ -19,6 +19,22 @@ export interface ParseResult {
19
19
  * handles the subset of YAML commonly found in CI configuration files.
20
20
  */
21
21
  export declare function parseYAML(content: string): Record<string, unknown>;
22
+ /**
23
+ * Parse one YAML (or JSON) document whose top level may be a sequence
24
+ * (#2965). {@link parseYAML} reads only a top-level mapping and returns `{}`
25
+ * for a file like `- a\n- b`; its callers rely on getting a mapping back, so
26
+ * it keeps doing that. This returns the list instead, and otherwise defers to
27
+ * `parseYAML`.
28
+ */
29
+ export declare function parseYAMLDocument(content: string): unknown;
30
+ /**
31
+ * Split a YAML stream into its documents (#2965). A line holding only `---`
32
+ * starts a document and a line holding only `...` ends one; either may carry
33
+ * a trailing `# comment`. Documents that are empty or hold only comments are
34
+ * dropped. The text of each document is returned unparsed, with `\r\n`
35
+ * normalized to `\n`.
36
+ */
37
+ export declare function splitYAMLDocuments(content: string): string[];
22
38
  /**
23
39
  * Parse indentation-based YAML lines into a key-value object.
24
40
  */
@@ -1 +1 @@
1
- {"version":3,"file":"yaml.d.ts","sourceRoot":"","sources":["../src/yaml.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AA0BH,wBAAgB,QAAQ,CAAC,KAAK,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAgH/D;AAMD,sCAAsC;AACtC,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,OAAO,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;CAClB;AAuBD;;;;;GAKG;AACH,wBAAgB,SAAS,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CASlE;AAiFD;;GAEG;AACH,wBAAgB,cAAc,CAC5B,KAAK,EAAE,MAAM,EAAE,EACf,UAAU,EAAE,MAAM,EAClB,UAAU,EAAE,MAAM,GACjB,WAAW,CA8Eb;AA0GD;;GAEG;AACH,wBAAgB,cAAc,CAC5B,KAAK,EAAE,MAAM,EAAE,EACf,UAAU,EAAE,MAAM,EAClB,UAAU,EAAE,MAAM,GACjB,WAAW,CAqGb;AAED;;GAEG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAwBlD"}
1
+ {"version":3,"file":"yaml.d.ts","sourceRoot":"","sources":["../src/yaml.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AA0BH,wBAAgB,QAAQ,CAAC,KAAK,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAgH/D;AAMD,sCAAsC;AACtC,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,OAAO,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;CAClB;AAuBD;;;;;GAKG;AACH,wBAAgB,SAAS,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CASlE;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAa1D;AAKD;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,CAc5D;AAiFD;;GAEG;AACH,wBAAgB,cAAc,CAC5B,KAAK,EAAE,MAAM,EAAE,EACf,UAAU,EAAE,MAAM,EAClB,UAAU,EAAE,MAAM,GACjB,WAAW,CA8Eb;AA0GD;;GAEG;AACH,wBAAgB,cAAc,CAC5B,KAAK,EAAE,MAAM,EAAE,EACf,UAAU,EAAE,MAAM,EAClB,UAAU,EAAE,MAAM,GACjB,WAAW,CAqGb;AAED;;GAEG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAwBlD"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant",
3
- "version": "0.96.0",
3
+ "version": "0.98.0",
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",
@@ -0,0 +1,70 @@
1
+ import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import { mkdir, rm, writeFile } from "node:fs/promises";
3
+ import { existsSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import type { LexiconPlugin } from "../../lexicon";
7
+ import { importCommand, importFromContent } from "./import";
8
+
9
+ // A lexicon that recognizes a template but has no templateParser, as grafana
10
+ // does today (#2940). Import must say it cannot import, not throw
11
+ // "plugin.templateParser is not a function".
12
+ const { detectOnly } = vi.hoisted(() => ({
13
+ detectOnly: {
14
+ name: "detect-only",
15
+ detectTemplate: (data: unknown) =>
16
+ typeof data === "object" && data !== null && "detectOnlyMarker" in data,
17
+ } as unknown as LexiconPlugin,
18
+ }));
19
+
20
+ vi.mock("../plugins", () => ({
21
+ resolveProjectLexicons: async () => ["detect-only"],
22
+ loadPlugins: async (names: string[]) => names.map(() => detectOnly),
23
+ }));
24
+
25
+ const EXPECTED = 'lexicon "detect-only" does not support template import';
26
+
27
+ describe("import through a lexicon with detectTemplate and no templateParser (#2940)", () => {
28
+ let testDir: string;
29
+ let templatePath: string;
30
+ let outputDir: string;
31
+
32
+ beforeEach(async () => {
33
+ testDir = join(tmpdir(), `chant-import-no-parser-${Date.now()}-${Math.random()}`);
34
+ await mkdir(testDir, { recursive: true });
35
+ templatePath = join(testDir, "dashboard.json");
36
+ outputDir = join(testDir, "out");
37
+ await writeFile(templatePath, JSON.stringify({ detectOnlyMarker: true, panels: [] }));
38
+ });
39
+
40
+ afterEach(async () => {
41
+ await rm(testDir, { recursive: true, force: true });
42
+ });
43
+
44
+ test("file import with detection fails cleanly", async () => {
45
+ const result = await importCommand({ templatePath, output: outputDir });
46
+ expect(result.success).toBe(false);
47
+ expect(result.error).toBe(EXPECTED);
48
+ expect(result.error).not.toContain("is not a function");
49
+ expect(result.lexicon).toBe("detect-only");
50
+ expect(existsSync(outputDir)).toBe(false);
51
+ });
52
+
53
+ test("file import with --lexicon fails cleanly", async () => {
54
+ const result = await importCommand({ templatePath, output: outputDir, lexicon: "detect-only" });
55
+ expect(result.success).toBe(false);
56
+ expect(result.error).toBe(EXPECTED);
57
+ expect(existsSync(outputDir)).toBe(false);
58
+ });
59
+
60
+ test("content import fails cleanly", async () => {
61
+ const result = await importFromContent({
62
+ content: JSON.stringify({ detectOnlyMarker: true }),
63
+ lexicon: "detect-only",
64
+ output: outputDir,
65
+ });
66
+ expect(result.success).toBe(false);
67
+ expect(result.error).toBe(EXPECTED);
68
+ expect(existsSync(outputDir)).toBe(false);
69
+ });
70
+ });
@@ -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,253 @@ 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
+ test("content that is neither JSON nor YAML names both formats", async () => {
507
+ const templatePath = join(testDir, "notes.txt");
508
+ await writeFile(templatePath, "this is not a template\n{ nor is this");
509
+
510
+ const result = await importCommand({ templatePath, output: outputDir });
511
+
512
+ expect(result.success).toBe(false);
513
+ expect(result.error).toContain("JSON");
514
+ expect(result.error).toContain("YAML");
515
+ });
516
+ });
517
+
518
+ // #2965 — detection outside a project, and inside one when the project's
519
+ // lexicons don't match.
520
+ describe("detectTemplateLexicon", () => {
521
+ let testDir: string;
522
+
523
+ const collector = {
524
+ receivers: { otlp: { protocols: { grpc: {} } } },
525
+ exporters: { debug: {} },
526
+ service: { pipelines: { traces: { receivers: ["otlp"], exporters: ["debug"] } } },
527
+ };
528
+
529
+ beforeEach(async () => {
530
+ testDir = join(tmpdir(), `chant-import-detect-test-${Date.now()}-${Math.random()}`);
531
+ await mkdir(testDir, { recursive: true });
532
+ });
533
+
534
+ afterEach(async () => {
535
+ await rm(testDir, { recursive: true, force: true });
536
+ });
537
+
538
+ test("an empty directory detects an otel collector config from the installed lexicons", async () => {
539
+ const detection = await detectTemplateLexicon([collector], testDir);
540
+ expect(detection?.plugin.name).toBe("otel");
541
+ expect(detection?.source).toBe("installed");
542
+ }, 60000);
543
+
544
+ test("a project lexicon that matches decides", async () => {
545
+ await writeFile(join(testDir, "chant.config.json"), JSON.stringify({ lexicons: ["aws"] }));
546
+ const detection = await detectTemplateLexicon([{ AWSTemplateFormatVersion: "2010-09-09", Resources: {} }], testDir);
547
+ expect(detection?.plugin.name).toBe("aws");
548
+ expect(detection?.source).toBe("project");
549
+ }, 60000);
550
+
551
+ test("inside a project, installed lexicons are tried when the project's don't match", async () => {
552
+ await writeFile(join(testDir, "chant.config.json"), JSON.stringify({ lexicons: ["aws"] }));
553
+ const detection = await detectTemplateLexicon([collector], testDir);
554
+ expect(detection?.plugin.name).toBe("otel");
555
+ expect(detection?.source).toBe("installed");
556
+ }, 60000);
557
+
558
+ test("names the other installed lexicons that also recognized the template", async () => {
559
+ // github and forgejo both read a GitHub-Actions-shaped workflow.
560
+ const workflow = { on: { push: {} }, jobs: { build: { "runs-on": "ubuntu-latest", steps: [] } } };
561
+ const detection = await detectTemplateLexicon([workflow], testDir);
562
+ expect(detection).toBeDefined();
563
+ // github has a template parser and forgejo does not, so github is chosen.
564
+ expect(detection!.plugin.name).toBe("github");
565
+ expect(detection!.alsoMatched).toContain("forgejo");
566
+ }, 60000);
567
+
568
+ test("returns undefined when no installed lexicon recognizes the template", async () => {
569
+ expect(await detectTemplateLexicon([{ version: "1.0", unknownField: {} }], testDir)).toBeUndefined();
570
+ }, 60000);
571
+ });
572
+
573
+ describe("listInstalledLexicons", () => {
574
+ test("lists the @intentius/chant-lexicon-* packages installed alongside chant", () => {
575
+ const names = listInstalledLexicons(tmpdir());
576
+ expect(names).toContain("aws");
577
+ expect(names).toContain("k8s");
578
+ expect(names).toContain("otel");
579
+ expect(names).toEqual([...names].sort());
580
+ });
581
+ });
582
+
583
+ describe("printImportResult", () => {
584
+ function printed(result: Parameters<typeof printImportResult>[0]): string {
585
+ const log = vi.spyOn(console, "log").mockImplementation(() => {});
586
+ try {
587
+ printImportResult(result);
588
+ return log.mock.calls.map((c) => c.join(" ")).join("\n");
589
+ } finally {
590
+ log.mockRestore();
591
+ }
592
+ }
593
+
594
+ test("says Detected lexicon when the lexicon was detected", () => {
595
+ expect(printed({ success: true, generatedFiles: [], warnings: [], lexicon: "k8s", detected: true })).toContain(
596
+ "Detected lexicon: k8s",
597
+ );
598
+ });
599
+
600
+ test("says Lexicon when the lexicon was named", () => {
601
+ const out = printed({ success: true, generatedFiles: [], warnings: [], lexicon: "k8s" });
602
+ expect(out).toContain("Lexicon: k8s");
603
+ expect(out).not.toContain("Detected");
604
+ });
605
+ });
606
+
607
+ describe("parseTemplateDocuments", () => {
608
+ test("JSON yields the parsed value as one document", () => {
609
+ expect(parseTemplateDocuments('{"a": 1}')).toEqual([{ a: 1 }]);
610
+ expect(parseTemplateDocuments("[1, 2]")).toEqual([[1, 2]]);
611
+ });
612
+
613
+ test("YAML splits on document separators and skips empty or comment-only documents", () => {
614
+ const docs = parseTemplateDocuments("---\n# only a comment\n---\na: 1\n---\nb: two\n---\n");
615
+ expect(docs).toEqual([{ a: 1 }, { b: "two" }]);
616
+ });
617
+
618
+ test("a top-level YAML list parses as a list (#2965)", () => {
619
+ expect(parseTemplateDocuments("- name: one\n expr: up\n- name: two\n")).toEqual([
620
+ [{ name: "one", expr: "up" }, { name: "two" }],
621
+ ]);
622
+ });
623
+
624
+ test("commented separators and document ends split documents (#2965)", () => {
625
+ expect(parseTemplateDocuments("--- # first\na: 1\n...\n--- # second\nb: two\n...\n")).toEqual([
626
+ { a: 1 },
627
+ { b: "two" },
628
+ ]);
629
+ });
630
+
631
+ test("returns undefined for content that is neither", () => {
632
+ expect(parseTemplateDocuments("")).toBeUndefined();
633
+ expect(parseTemplateDocuments("just some words")).toBeUndefined();
634
+ expect(parseTemplateDocuments("{ broken json")).toBeUndefined();
635
+ });
636
+ });
@@ -3,8 +3,9 @@ import { join, resolve, basename, dirname } from "path";
3
3
  import { formatSuccess, formatWarning, formatError } from "../format";
4
4
  import type { TemplateIR, ResourceIR, ParameterIR, TemplateParser } from "../../import/parser";
5
5
  import type { GeneratedFile, TypeScriptGenerator } from "../../import/generator";
6
- import { loadPlugins, resolveProjectLexicons } from "../plugins";
6
+ import { listInstalledLexicons, loadPlugin, loadPlugins, resolveProjectLexicons } from "../plugins";
7
7
  import type { LexiconPlugin, ResourceSelector } from "../../lexicon";
8
+ import { parseYAMLDocument, splitYAMLDocuments } from "../../yaml";
8
9
 
9
10
  /**
10
11
  * Import command options
@@ -16,6 +17,11 @@ export interface ImportOptions {
16
17
  output?: string;
17
18
  /** Force overwrite existing files */
18
19
  force?: boolean;
20
+ /**
21
+ * Lexicon whose parser handles the file (#2935). Skips detection and the
22
+ * JSON/YAML check: the raw content goes straight to that plugin's parser.
23
+ */
24
+ lexicon?: string;
19
25
  }
20
26
 
21
27
  /**
@@ -30,8 +36,13 @@ export interface ImportResult {
30
36
  warnings: string[];
31
37
  /** Error message if failed */
32
38
  error?: string;
33
- /** Detected lexicon */
39
+ /** The lexicon that handled the template */
34
40
  lexicon?: string;
41
+ /**
42
+ * True when `lexicon` was found by template detection, false or absent when
43
+ * it was named (`--lexicon`, `--kustomize`) (#2965).
44
+ */
45
+ detected?: boolean;
35
46
  }
36
47
 
37
48
  /**
@@ -40,18 +51,117 @@ export interface ImportResult {
40
51
  type ResourceCategory = "storage" | "compute" | "network" | "other";
41
52
 
42
53
  /**
43
- * Detect which plugin handles a template by asking each plugin.
44
- * @param data - Parsed JSON object
45
- * @param plugins - Loaded lexicon plugins
46
- * @returns The matching plugin, or undefined if none match
54
+ * Parse template content for detection (#2935): JSON first, then YAML. A YAML
55
+ * file is split into documents with core's `splitYAMLDocuments` (the k8s
56
+ * parser splits the same way), and each document is parsed on its own, so
57
+ * detection sees the same per-document objects the plugin's parser will. A
58
+ * document whose top level is a list parses as that list (#2965). JSON yields
59
+ * one document, the parsed value, exactly as before. Returns undefined when
60
+ * the content is neither: core's YAML reader is lenient and turns unparseable
61
+ * text into an empty mapping, so a file with no non-empty document is
62
+ * rejected.
63
+ */
64
+ export function parseTemplateDocuments(content: string): unknown[] | undefined {
65
+ try {
66
+ return [JSON.parse(content)];
67
+ } catch {
68
+ // Not JSON: try YAML.
69
+ }
70
+ const documents: unknown[] = [];
71
+ for (const chunk of splitYAMLDocuments(content)) {
72
+ let doc: unknown;
73
+ try {
74
+ doc = parseYAMLDocument(chunk);
75
+ } catch {
76
+ continue;
77
+ }
78
+ if (typeof doc === "object" && doc !== null && Object.keys(doc).length > 0) {
79
+ documents.push(doc);
80
+ }
81
+ }
82
+ return documents.length > 0 ? documents : undefined;
83
+ }
84
+
85
+ /** The outcome of template detection (#2965). */
86
+ export interface TemplateDetection {
87
+ /** The plugin that handles the template. */
88
+ plugin: LexiconPlugin;
89
+ /**
90
+ * Where it came from: one of the project's lexicons, or an installed
91
+ * lexicon package tried because none of the project's matched.
92
+ */
93
+ source: "project" | "installed";
94
+ /** Other installed lexicons that also recognized the deciding document. */
95
+ alsoMatched: string[];
96
+ }
97
+
98
+ /**
99
+ * Find the plugins that recognize a template. Documents are tried in file
100
+ * order and the first one some plugin recognizes decides; every plugin that
101
+ * recognizes that document is returned, in the order given. For a JSON
102
+ * template there is exactly one document, so this is the old behaviour.
103
+ */
104
+ function matchingPlugins(documents: unknown[], plugins: LexiconPlugin[]): LexiconPlugin[] {
105
+ for (const data of documents) {
106
+ const matches = plugins.filter((plugin) => {
107
+ try {
108
+ return plugin.detectTemplate?.(data) === true;
109
+ } catch {
110
+ return false;
111
+ }
112
+ });
113
+ if (matches.length > 0) return matches;
114
+ }
115
+ return [];
116
+ }
117
+
118
+ /**
119
+ * Detect which lexicon handles a template (#2965). The project's lexicons
120
+ * (from chant.config, or the lexicons its source imports) are asked first, so
121
+ * inside a project nothing changes when one of them matches. When none does,
122
+ * or there is no project, every installed `@intentius/chant-lexicon-*`
123
+ * package that is not already a project lexicon is asked, in name order,
124
+ * and one with a `templateParser` is preferred over one without.
125
+ * Installed lexicons are loaded one at a time and without `init()`; one that
126
+ * fails to load is skipped. The chosen plugin is initialized before it is
127
+ * returned.
47
128
  */
48
- function detectPlugin(data: unknown, plugins: LexiconPlugin[]): LexiconPlugin | undefined {
49
- for (const plugin of plugins) {
50
- if (plugin.detectTemplate?.(data)) {
51
- return plugin;
129
+ export async function detectTemplateLexicon(
130
+ documents: unknown[],
131
+ projectDir: string,
132
+ ): Promise<TemplateDetection | undefined> {
133
+ let projectNames: string[] = [];
134
+ let projectPlugins: LexiconPlugin[] = [];
135
+ try {
136
+ projectNames = await resolveProjectLexicons(projectDir);
137
+ projectPlugins = await loadPlugins(projectNames);
138
+ } catch {
139
+ projectPlugins = [];
140
+ }
141
+
142
+ const [fromProject] = matchingPlugins(documents, projectPlugins);
143
+ if (fromProject) return { plugin: fromProject, source: "project", alsoMatched: [] };
144
+
145
+ const installed: LexiconPlugin[] = [];
146
+ for (const name of listInstalledLexicons(projectDir)) {
147
+ if (projectNames.includes(name)) continue;
148
+ try {
149
+ installed.push(await loadPlugin(name));
150
+ } catch {
151
+ // Not loadable from here: it cannot handle the template either.
52
152
  }
53
153
  }
54
- return undefined;
154
+
155
+ // A lexicon that can import the template goes ahead of one that only
156
+ // recognizes it (github and forgejo both read an Actions workflow).
157
+ const matches = matchingPlugins(documents, installed);
158
+ const [plugin, ...others] = [
159
+ ...matches.filter((p) => p.templateParser),
160
+ ...matches.filter((p) => !p.templateParser),
161
+ ];
162
+ if (!plugin) return undefined;
163
+ await plugin.init?.();
164
+ return { plugin, source: "installed", alsoMatched: others.map((p) => p.name) };
55
165
  }
56
166
 
57
167
  /**
@@ -195,61 +305,63 @@ export async function importCommand(options: ImportOptions): Promise<ImportResul
195
305
  };
196
306
  }
197
307
 
198
- // Load plugins and detect lexicon
199
- let data: unknown;
200
- try {
201
- data = JSON.parse(content);
202
- } catch {
308
+ // `--lexicon <name>` names the plugin, so there is nothing to detect and no
309
+ // format to check: the plugin's parser decides what it accepts (#2935).
310
+ if (options.lexicon) {
311
+ return importFromContent({
312
+ content,
313
+ lexicon: options.lexicon,
314
+ output: options.output,
315
+ force: options.force,
316
+ });
317
+ }
318
+
319
+ // Parse for detection: JSON, falling back to YAML.
320
+ const documents = parseTemplateDocuments(content);
321
+ if (!documents) {
203
322
  return {
204
323
  success: false,
205
324
  generatedFiles: [],
206
325
  warnings: [],
207
- error: "Template is not valid JSON.",
326
+ error: "Template is neither valid JSON nor YAML.",
208
327
  };
209
328
  }
210
329
 
211
- // Load plugins — resolve from the output directory (or CWD) so that
212
- // project config is found relative to where the user is working, not
213
- // an arbitrary monorepo root.
330
+ // Detect from the output directory (or CWD) so that project config is
331
+ // found relative to where the user is working, not an arbitrary monorepo
332
+ // root.
214
333
  const projectDir = resolve(options.output ? dirname(options.output) : ".");
215
- let plugins: LexiconPlugin[];
216
- try {
217
- const lexiconNames = await resolveProjectLexicons(projectDir);
218
- plugins = await loadPlugins(lexiconNames);
219
- } catch {
220
- plugins = [];
221
- }
222
-
223
- // If no plugins resolved (no config, no source files), try common lexicons
224
- if (plugins.length === 0) {
225
- try {
226
- plugins = await loadPlugins(["aws"]);
227
- } catch {
228
- // No lexicons available at all
229
- }
230
- }
231
-
232
- const plugin = detectPlugin(data, plugins);
233
- if (!plugin) {
334
+ const detection = await detectTemplateLexicon(documents, projectDir);
335
+ if (!detection) {
234
336
  return {
235
337
  success: false,
236
338
  generatedFiles: [],
237
339
  warnings: [],
238
- error: "Could not detect template lexicon. No installed lexicon recognizes this template.",
340
+ error:
341
+ "Could not detect template lexicon. No installed lexicon recognizes this template. " +
342
+ "Pass --lexicon <name> to import it with a specific lexicon.",
239
343
  };
240
344
  }
241
345
 
242
- const lexicon = plugin.name;
346
+ const { plugin } = detection;
347
+ if (detection.alsoMatched.length > 0) {
348
+ warnings.push(
349
+ `The template is also recognized by ${detection.alsoMatched.join(", ")}. ` +
350
+ `Importing with ${plugin.name}; pass --lexicon <name> to choose another.`,
351
+ );
352
+ }
243
353
 
244
- return parseAndWrite(plugin, content, outputDir, options.force, warnings, generatedFiles, lexicon);
354
+ const result = parseAndWrite(plugin, content, outputDir, options.force, warnings, generatedFiles, plugin.name);
355
+ return { ...result, detected: true };
245
356
  }
246
357
 
247
358
  /**
248
359
  * Import from an in-memory template string through a KNOWN plugin — no
249
360
  * detection, no JSON assumption (#1548). This is the seam
250
361
  * `chant import --kustomize <dir>` drives with `kustomize build` output
251
- * through the k8s plugin's YAML parser; `importCommand` above is the same
252
- * pipeline behind file reading + JSON detection.
362
+ * through the k8s plugin's YAML parser, and `chant import <file> --lexicon
363
+ * <name>` drives with the file's content (#2935); `importCommand` above is
364
+ * the same pipeline behind file reading + JSON/YAML detection.
253
365
  */
254
366
  export interface ContentImportOptions {
255
367
  /** The raw template content (YAML or JSON — the plugin's parser decides). */
@@ -277,15 +389,6 @@ export async function importFromContent(options: ContentImportOptions): Promise<
277
389
  if (!plugin) {
278
390
  return { success: false, generatedFiles: [], warnings: [], error: `Lexicon "${options.lexicon}" not available.` };
279
391
  }
280
- if (!plugin.templateParser || !plugin.templateGenerator) {
281
- return {
282
- success: false,
283
- generatedFiles: [],
284
- warnings: [],
285
- error: `Lexicon "${plugin.name}" does not support template import.`,
286
- lexicon: plugin.name,
287
- };
288
- }
289
392
  return parseAndWrite(plugin, options.content, outputDir, options.force, [], [], plugin.name);
290
393
  }
291
394
 
@@ -299,10 +402,23 @@ function parseAndWrite(
299
402
  generatedFiles: string[],
300
403
  lexicon: string,
301
404
  ): ImportResult {
405
+ // A lexicon can recognize a template (detectTemplate) without being able to
406
+ // import it (grafana has no parser). Every path funnels through here, so
407
+ // this one check covers detection, --lexicon and content import (#2940).
408
+ if (!plugin.templateParser || !plugin.templateGenerator) {
409
+ return {
410
+ success: false,
411
+ generatedFiles: [],
412
+ warnings: [],
413
+ error: `lexicon "${plugin.name}" does not support template import`,
414
+ lexicon: plugin.name,
415
+ };
416
+ }
417
+
302
418
  // Parse template
303
419
  let ir: TemplateIR;
304
420
  try {
305
- const parser = plugin.templateParser!();
421
+ const parser = plugin.templateParser();
306
422
  ir = parser.parse(content);
307
423
  } catch (err) {
308
424
  return {
@@ -319,7 +435,7 @@ function parseAndWrite(
319
435
  warnings.push(...ir.warnings);
320
436
  }
321
437
 
322
- const generator = plugin.templateGenerator!();
438
+ const generator = plugin.templateGenerator();
323
439
 
324
440
  // Check output directory
325
441
  if (existsSync(outputDir) && !force) {
@@ -545,6 +661,7 @@ export async function liveImportFromPlugins(
545
661
  generatedFiles,
546
662
  warnings,
547
663
  lexicon: generatorLexicon.name,
664
+ detected: !options.lexicon,
548
665
  };
549
666
  }
550
667
 
@@ -562,7 +679,7 @@ export function printImportResult(result: ImportResult): void {
562
679
  }
563
680
 
564
681
  if (result.lexicon) {
565
- console.log(`Detected lexicon: ${result.lexicon}`);
682
+ console.log(`${result.detected ? "Detected lexicon" : "Lexicon"}: ${result.lexicon}`);
566
683
  }
567
684
 
568
685
  if (result.generatedFiles.length > 0) {
@@ -374,10 +374,13 @@ export async function runImport(ctx: CommandContext): Promise<number> {
374
374
  return result.success ? 0 : 1;
375
375
  }
376
376
 
377
+ // A template file: JSON or YAML, detected, unless `--lexicon` names the
378
+ // plugin (#2935).
377
379
  const result = await importCommand({
378
380
  templatePath: ctx.args.path,
379
381
  output: ctx.args.output,
380
382
  force: ctx.args.force,
383
+ lexicon: ctx.args.lexicon,
381
384
  });
382
385
 
383
386
  printImportResult(result);
@@ -45,7 +45,7 @@ function sleep(ms: number): Promise<void> {
45
45
  }
46
46
 
47
47
  describe("chant operator --steward — a repeated SIGTERM (#2872)", () => {
48
- test("a second and third SIGTERM sent in quick succession still release the steward's lease", async () => {
48
+ test("repeated SIGTERMs sent in quick succession still release the steward's lease and exit 0", async () => {
49
49
  const dir = repo(
50
50
  {
51
51
  "chant.config.ts": "export default { lexicons: [] };\n",
@@ -77,16 +77,24 @@ describe("chant operator --steward — a repeated SIGTERM (#2872)", () => {
77
77
  await sleep(50);
78
78
  }
79
79
 
80
- // Three SIGTERMs, each given a moment to actually reach the child's event
81
- // loop before the next: with `process.once`, the first one's auto-removal
82
- // happens the instant it fires, so the second (or third) finds no
83
- // listener left and the process exits right there, skipping the
84
- // `finally` that releases the lease.
85
- child.kill("SIGTERM");
86
- await sleep(30);
87
- child.kill("SIGTERM");
88
- await sleep(30);
89
- child.kill("SIGTERM");
80
+ // A SIGTERM, then more every few ms until the child is gone, each given
81
+ // a moment to reach the child's event loop before the next. With
82
+ // `process.once`, the first one's auto-removal happens the instant it
83
+ // fires, so the second finds no listener left and the process exits
84
+ // right there, skipping the `finally` that releases the lease. Firing
85
+ // until the child closes (not a fixed three) also covers the stretch
86
+ // after that `finally`, up to `process.exit`: when the handler removed
87
+ // its listeners there, a SIGTERM arriving in that stretch ended the
88
+ // process by signal (exit code null) though the lease was released. With
89
+ // three signals 30ms apart that failed CI now and then (runs
90
+ // 36357297831, 36358285603, 36368551353); signalling to the end hit it
91
+ // every time.
92
+ let closed = false;
93
+ child.on("close", () => (closed = true));
94
+ for (let sent = 0; !closed && sent < 1000; sent++) {
95
+ child.kill("SIGTERM");
96
+ await sleep(5);
97
+ }
90
98
 
91
99
  const code = await exited;
92
100
  expect(code, `chant operator --steward did not exit cleanly; stderr:\n${stderr}`).toBe(0);
@@ -216,7 +216,12 @@ export async function runOperator(ctx: CommandContext): Promise<number> {
216
216
  });
217
217
  return 0;
218
218
  } finally {
219
- process.removeListener("SIGINT", onSigint);
219
+ // Only when no signal started this shutdown. After one did, the process
220
+ // exits right after this returns, and removing the listener would put
221
+ // SIGINT back to its default disposition for that last stretch: a repeat
222
+ // signal arriving there ends the process by signal instead of the exit
223
+ // code (see the steward's `finally` below).
224
+ if (!stopping) process.removeListener("SIGINT", onSigint);
220
225
  }
221
226
  }
222
227
 
@@ -347,8 +352,17 @@ export async function runStewardOperator(ctx: CommandContext): Promise<number> {
347
352
  // A courtesy: the lease expires on its own if this never runs.
348
353
  const { record } = await readLease(stewardLeaseName(steward.name)).catch(() => ({ record: undefined }));
349
354
  if (record?.holder === holder) await releaseLease(stewardLeaseName(steward.name), holder, record.token).catch(() => false);
350
- process.removeListener("SIGINT", onSigint);
351
- process.removeListener("SIGTERM", onSigint);
355
+ // Removed only when no signal started this shutdown. After one did, the
356
+ // CLI goes on to `flushAndExit` (../main.ts) and `process.exit`, and
357
+ // removing the last SIGTERM listener hands the signal back to its
358
+ // default disposition for that stretch: a repeat SIGTERM arriving there
359
+ // ended the process by signal (exit code null) with the lease already
360
+ // released. That was the operator-steward-signal e2e test's intermittent
361
+ // failure; a SIGTERM every 2ms after the first hit it 8 runs of 8.
362
+ if (!stopping) {
363
+ process.removeListener("SIGINT", onSigint);
364
+ process.removeListener("SIGTERM", onSigint);
365
+ }
352
366
  }
353
367
  }
354
368
 
package/src/cli/main.ts CHANGED
@@ -1091,7 +1091,8 @@ Options:
1091
1091
  - build: json (default) or yaml
1092
1092
  - list: text (default) or json
1093
1093
  - lint: stylish (default), json, or sarif
1094
- -d, --lexicon <name> Build only the specified lexicon (e.g. aws, gitlab)
1094
+ -d, --lexicon <name> Build only the specified lexicon (e.g. aws, gitlab);
1095
+ import: use this lexicon instead of detecting one
1095
1096
  --env <name> Active environment: sets CHANT_ENV so env-aware source
1096
1097
  re-evaluates for that environment (build + graph), and
1097
1098
  drives organizational policy. Must be in chant.config
@@ -1201,6 +1202,7 @@ Examples:
1201
1202
  chant run --components all --env production
1202
1203
  chant components export prod --component search-service -o ./dist/search-service
1203
1204
  chant import template.json --output ./infra/
1205
+ chant import manifest.yaml --lexicon k8s --output ./infra/
1204
1206
  chant import --from prod --name my-bucket --output src/
1205
1207
  chant lint ./infra/
1206
1208
  chant lint ./infra/ --format sarif
@@ -17,6 +17,10 @@ export const importTool = {
17
17
  type: "string",
18
18
  description: "Output directory (default: ./infra/)",
19
19
  },
20
+ lexicon: {
21
+ type: "string",
22
+ description: "Lexicon to import with (e.g. k8s); skips template detection",
23
+ },
20
24
  },
21
25
  required: ["source"],
22
26
  },
@@ -28,11 +32,13 @@ export const importTool = {
28
32
  export async function handleImport(params: Record<string, unknown>): Promise<unknown> {
29
33
  const source = params.source as string;
30
34
  const output = params.output as string | undefined;
35
+ const lexicon = params.lexicon as string | undefined;
31
36
 
32
37
  const result = await importCommand({
33
38
  templatePath: source,
34
39
  output,
35
40
  force: true,
41
+ lexicon,
36
42
  });
37
43
 
38
44
  if (!result.success) {
@@ -2,8 +2,9 @@ import { importLexiconModule, importLexiconPackage, lexiconModulePath, lexiconNa
2
2
  import { readLexiconDeclarationsStatically, type StaticLexiconRead } from "../config-static";
3
3
  export { unknownPathLexiconsNotice } from "../config-static";
4
4
  import { createRequire } from "node:module";
5
- import { dirname, join } from "node:path";
6
- import { existsSync, readFileSync } from "node:fs";
5
+ import { dirname, join, resolve } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { existsSync, readdirSync, readFileSync, type Dirent } from "node:fs";
7
8
  import { isLexiconPlugin, type LexiconPlugin } from "../lexicon";
8
9
  import { loadChantConfigUpward } from "../config";
9
10
  import { findInfraFiles, detectLexicons } from "../index";
@@ -80,6 +81,44 @@ export async function loadPlugin(lexiconName: string): Promise<LexiconPlugin> {
80
81
  throw new Error(`Package ${packageName} does not export a LexiconPlugin or Serializer`);
81
82
  }
82
83
 
84
+ const LEXICON_PACKAGE_PREFIX = "chant-lexicon-";
85
+
86
+ /**
87
+ * The names of the lexicon packages installed where {@link loadPlugin} can
88
+ * load them (#2965), sorted. `loadPlugin` imports
89
+ * `@intentius/chant-lexicon-<name>` from chant's own install and then from the
90
+ * project (`importLexiconPackage`), so this lists the `@intentius` scope of
91
+ * every `node_modules` directory Node would search from either place: each
92
+ * ancestor of chant's own module and each ancestor of `projectDir`. A name
93
+ * being listed does not promise it loads; the caller still handles a failure.
94
+ */
95
+ export function listInstalledLexicons(projectDir: string = process.cwd()): string[] {
96
+ const names = new Set<string>();
97
+ const starts = [dirname(fileURLToPath(import.meta.url)), resolve(projectDir)];
98
+ for (const start of starts) {
99
+ let dir = start;
100
+ for (;;) {
101
+ const scope = join(dir, "node_modules", "@intentius");
102
+ let entries: Dirent[] = [];
103
+ try {
104
+ entries = readdirSync(scope, { withFileTypes: true });
105
+ } catch {
106
+ // No @intentius scope here.
107
+ }
108
+ for (const entry of entries) {
109
+ if (!entry.name.startsWith(LEXICON_PACKAGE_PREFIX)) continue;
110
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
111
+ const name = entry.name.slice(LEXICON_PACKAGE_PREFIX.length);
112
+ if (name !== "") names.add(name);
113
+ }
114
+ const parent = dirname(dir);
115
+ if (parent === dir) break;
116
+ dir = parent;
117
+ }
118
+ }
119
+ return [...names].sort();
120
+ }
121
+
83
122
  /**
84
123
  * The installed version of each named lexicon package (chant #1442).
85
124
  *
package/src/yaml.test.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "vitest";
2
- import { emitYAML, parseYAML, parseScalar } from "./yaml";
2
+ import { emitYAML, parseYAML, parseYAMLDocument, parseScalar, splitYAMLDocuments } from "./yaml";
3
3
 
4
4
  // ---------------------------------------------------------------------------
5
5
  // emitYAML
@@ -538,3 +538,55 @@ describe("parseYAML — a colon without following space is not a mapping (#2013)
538
538
  });
539
539
  });
540
540
  });
541
+
542
+ // ---------------------------------------------------------------------------
543
+ // parseYAMLDocument / splitYAMLDocuments (#2965)
544
+ // ---------------------------------------------------------------------------
545
+
546
+ describe("parseYAMLDocument", () => {
547
+ test("a top-level list parses as a list", () => {
548
+ expect(parseYAMLDocument("- a\n- b\n")).toEqual(["a", "b"]);
549
+ expect(parseYAMLDocument("# rules\n\n- name: one\n expr: up\n- name: two\n")).toEqual([
550
+ { name: "one", expr: "up" },
551
+ { name: "two" },
552
+ ]);
553
+ });
554
+
555
+ test("parseYAML still returns a mapping for the same input", () => {
556
+ expect(parseYAML("- a\n- b\n")).toEqual({});
557
+ });
558
+
559
+ test("a mapping and JSON parse as parseYAML parses them", () => {
560
+ expect(parseYAMLDocument("a: 1\nb:\n - x\n")).toEqual({ a: 1, b: ["x"] });
561
+ expect(parseYAMLDocument("[1, 2]")).toEqual([1, 2]);
562
+ expect(parseYAMLDocument("")).toEqual({});
563
+ });
564
+ });
565
+
566
+ describe("splitYAMLDocuments", () => {
567
+ test("splits on bare --- separators", () => {
568
+ expect(splitYAMLDocuments("a: 1\n---\nb: 2\n")).toEqual(["a: 1", "b: 2\n"]);
569
+ });
570
+
571
+ test("a separator may carry a trailing comment", () => {
572
+ expect(splitYAMLDocuments("--- # first\na: 1\n--- # second\nb: 2")).toEqual(["a: 1", "b: 2"]);
573
+ });
574
+
575
+ test("... ends a document", () => {
576
+ expect(splitYAMLDocuments("a: 1\n...\nb: 2\n... # done\n")).toEqual(["a: 1", "b: 2"]);
577
+ expect(splitYAMLDocuments("---\na: 1\n...\n---\nb: 2\n...\n")).toEqual(["a: 1", "b: 2"]);
578
+ });
579
+
580
+ test("drops empty and comment-only documents", () => {
581
+ expect(splitYAMLDocuments("# header\n---\n\n---\na: 1\n---\n# trailing\n")).toEqual(["a: 1"]);
582
+ expect(splitYAMLDocuments("")).toEqual([]);
583
+ });
584
+
585
+ test("only a whole-line marker splits", () => {
586
+ expect(splitYAMLDocuments("a: ---\nb: |\n ---\n---x: 1\n")).toEqual(["a: ---\nb: |\n ---\n---x: 1\n"]);
587
+ });
588
+
589
+ test("normalizes CRLF line endings", () => {
590
+ expect(splitYAMLDocuments("a: 1\r\n--- # c\r\nb: 2\r\n")).toEqual(["a: 1", "b: 2\n"]);
591
+ });
592
+ });
package/src/yaml.ts CHANGED
@@ -193,6 +193,54 @@ export function parseYAML(content: string): Record<string, unknown> {
193
193
  return parseYAMLLines(lines, 0, 0).value as Record<string, unknown>;
194
194
  }
195
195
 
196
+ /**
197
+ * Parse one YAML (or JSON) document whose top level may be a sequence
198
+ * (#2965). {@link parseYAML} reads only a top-level mapping and returns `{}`
199
+ * for a file like `- a\n- b`; its callers rely on getting a mapping back, so
200
+ * it keeps doing that. This returns the list instead, and otherwise defers to
201
+ * `parseYAML`.
202
+ */
203
+ export function parseYAMLDocument(content: string): unknown {
204
+ try {
205
+ return JSON.parse(content);
206
+ } catch {
207
+ // Fall through to YAML parsing
208
+ }
209
+
210
+ const lines = content.replace(/\r\n?/g, "\n").split("\n");
211
+ const first = lines.findIndex((line) => line.trim() !== "" && !line.trim().startsWith("#"));
212
+ if (first !== -1 && /^\s*- /.test(lines[first])) {
213
+ return parseYAMLArray(lines, first, lines[first].search(/\S/)).value;
214
+ }
215
+ return parseYAMLLines(lines, 0, 0).value;
216
+ }
217
+
218
+ /** A document start (`---`) or end (`...`) marker, optionally followed by a comment. */
219
+ const DOCUMENT_MARKER = /^(?:---|\.\.\.)(?:[ \t]+#.*)?[ \t]*$/;
220
+
221
+ /**
222
+ * Split a YAML stream into its documents (#2965). A line holding only `---`
223
+ * starts a document and a line holding only `...` ends one; either may carry
224
+ * a trailing `# comment`. Documents that are empty or hold only comments are
225
+ * dropped. The text of each document is returned unparsed, with `\r\n`
226
+ * normalized to `\n`.
227
+ */
228
+ export function splitYAMLDocuments(content: string): string[] {
229
+ const documents: string[] = [];
230
+ let current: string[] = [];
231
+ const flush = (): void => {
232
+ const text = current.join("\n");
233
+ if (text.replace(/#[^\n]*/g, "").trim() !== "") documents.push(text);
234
+ current = [];
235
+ };
236
+ for (const line of content.replace(/\r\n?/g, "\n").split("\n")) {
237
+ if (DOCUMENT_MARKER.test(line)) flush();
238
+ else current.push(line);
239
+ }
240
+ flush();
241
+ return documents;
242
+ }
243
+
196
244
  /**
197
245
  * A block-scalar header: `|` (literal, keep newlines) or `>` (folded, newlines →
198
246
  * spaces), with a chomping indicator (`-` strip trailing newlines, `+` keep them,