@intentius/chant 0.98.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 (49) hide show
  1. package/dist/cli/commands/import.d.ts +20 -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/deep-observation.d.ts.map +1 -1
  5. package/dist/import/embedded.d.ts +184 -0
  6. package/dist/import/embedded.d.ts.map +1 -0
  7. package/dist/import/generator.d.ts +13 -0
  8. package/dist/import/generator.d.ts.map +1 -1
  9. package/dist/import/parser.d.ts +15 -1
  10. package/dist/import/parser.d.ts.map +1 -1
  11. package/dist/lexicon.d.ts +19 -0
  12. package/dist/lexicon.d.ts.map +1 -1
  13. package/dist/lint/engine.d.ts +6 -1
  14. package/dist/lint/engine.d.ts.map +1 -1
  15. package/dist/lint/rule.d.ts +10 -0
  16. package/dist/lint/rule.d.ts.map +1 -1
  17. package/dist/lint/rules/file-declarable-limit.d.ts.map +1 -1
  18. package/dist/lint/rules/flat-declarations.d.ts.map +1 -1
  19. package/dist/lint/rules/no-unused-declarable.d.ts.map +1 -1
  20. package/dist/lint/rules/property-kind.d.ts +7 -0
  21. package/dist/lint/rules/property-kind.d.ts.map +1 -0
  22. package/dist/workspace/conformance/index.d.ts +9 -0
  23. package/dist/workspace/conformance/index.d.ts.map +1 -1
  24. package/dist/yaml.d.ts +28 -6
  25. package/dist/yaml.d.ts.map +1 -1
  26. package/package.json +1 -1
  27. package/src/cli/commands/import-layout.test.ts +142 -0
  28. package/src/cli/commands/import-no-parser.test.ts +1 -1
  29. package/src/cli/commands/import.test.ts +25 -0
  30. package/src/cli/commands/import.ts +158 -36
  31. package/src/cli/commands/lint.ts +21 -8
  32. package/src/deep-observation.test.ts +21 -0
  33. package/src/deep-observation.ts +9 -0
  34. package/src/import/embedded.test.ts +153 -0
  35. package/src/import/embedded.ts +376 -0
  36. package/src/import/generator.ts +14 -0
  37. package/src/import/parser.ts +17 -1
  38. package/src/lexicon.ts +21 -0
  39. package/src/lint/engine.ts +7 -0
  40. package/src/lint/rule.ts +10 -0
  41. package/src/lint/rules/file-declarable-limit.ts +11 -4
  42. package/src/lint/rules/flat-declarations.ts +6 -1
  43. package/src/lint/rules/no-unused-declarable.ts +59 -2
  44. package/src/lint/rules/property-kind.test.ts +99 -0
  45. package/src/lint/rules/property-kind.ts +56 -0
  46. package/src/workspace/conformance/index.mjs +1 -0
  47. package/src/workspace/conformance/index.ts +23 -1
  48. package/src/yaml.test.ts +192 -1
  49. package/src/yaml.ts +404 -250
@@ -141,7 +141,13 @@ function lexiconResolutionDiagnostic(projectRoot: string, error: Error): LintDia
141
141
  */
142
142
  async function loadAllPluginRules(
143
143
  projectPath: string,
144
- ): Promise<{ rules: Map<string, LintRule>; intrinsics: IntrinsicDef[]; plugins: LexiconPlugin[]; lexiconError?: Error }> {
144
+ ): Promise<{
145
+ rules: Map<string, LintRule>;
146
+ intrinsics: IntrinsicDef[];
147
+ propertyClasses: Set<string>;
148
+ plugins: LexiconPlugin[];
149
+ lexiconError?: Error;
150
+ }> {
145
151
  const rules = new Map<string, LintRule>();
146
152
 
147
153
  // Load core COR/EVL rules directly
@@ -198,6 +204,12 @@ async function loadAllPluginRules(
198
204
  // hence the guard.
199
205
  const intrinsics = plugins.flatMap((plugin) => plugin.intrinsics?.() ?? []);
200
206
 
207
+ // chant #2957 — the class names these plugins declare property-kind, so
208
+ // COR001, COR004 and COR009 leave out declarables that live inside a
209
+ // resource (a Grafana panel inside its dashboard) instead of treating
210
+ // them as resources.
211
+ const propertyClasses = new Set(plugins.flatMap((plugin) => plugin.propertyClassNames?.() ?? []));
212
+
201
213
  for (const plugin of plugins) {
202
214
  if (plugin.lintRules) {
203
215
  for (const r of plugin.lintRules()) {
@@ -219,7 +231,7 @@ async function loadAllPluginRules(
219
231
  rules.set(r.id, r);
220
232
  }
221
233
 
222
- return { rules, intrinsics, plugins, ...(lexiconError ? { lexiconError } : {}) };
234
+ return { rules, intrinsics, propertyClasses, plugins, ...(lexiconError ? { lexiconError } : {}) };
223
235
  }
224
236
 
225
237
  /**
@@ -667,6 +679,7 @@ export async function lintCommand(options: LintOptions): Promise<LintResult> {
667
679
  // flagging it. Computed once here regardless of which branch below runs,
668
680
  // same as `allRules`.
669
681
  const intrinsics = loaded.intrinsics;
682
+ const propertyClasses = loaded.propertyClasses;
670
683
 
671
684
  // Merge in any config-level plugin rules (custom .ts rule files)
672
685
  if (config.plugins && config.plugins.length > 0) {
@@ -681,7 +694,7 @@ export async function lintCommand(options: LintOptions): Promise<LintResult> {
681
694
  let diagnostics: LintDiagnostic[];
682
695
  let suppressed: Array<LintDiagnostic & { reason?: string }> = [];
683
696
  if (options.rules) {
684
- const result = await runLint(files, options.rules, undefined, intrinsics, projectConfig);
697
+ const result = await runLint(files, options.rules, undefined, intrinsics, projectConfig, propertyClasses);
685
698
  diagnostics = result.diagnostics;
686
699
  suppressed = result.suppressed;
687
700
  } else if (hasOverrides) {
@@ -689,13 +702,13 @@ export async function lintCommand(options: LintOptions): Promise<LintResult> {
689
702
  for (const file of files) {
690
703
  const relativePath = relative(projectRoot, file);
691
704
  const { rules: fileRules, ruleOptions } = getDefaultRules(projectRoot, relativePath, allRules);
692
- const result = await runLint([file], fileRules, ruleOptions, intrinsics, projectConfig);
705
+ const result = await runLint([file], fileRules, ruleOptions, intrinsics, projectConfig, propertyClasses);
693
706
  diagnostics.push(...result.diagnostics);
694
707
  suppressed.push(...result.suppressed);
695
708
  }
696
709
  } else {
697
710
  const { rules, ruleOptions } = getDefaultRules(projectRoot, undefined, allRules);
698
- const result = await runLint(files, rules, ruleOptions, intrinsics, projectConfig);
711
+ const result = await runLint(files, rules, ruleOptions, intrinsics, projectConfig, propertyClasses);
699
712
  diagnostics = result.diagnostics;
700
713
  suppressed = result.suppressed;
701
714
  }
@@ -736,7 +749,7 @@ export async function lintCommand(options: LintOptions): Promise<LintResult> {
736
749
 
737
750
  // Re-lint after fixes to get updated diagnostics
738
751
  if (options.rules) {
739
- const postResult = await runLint(files, options.rules, undefined, intrinsics, projectConfig);
752
+ const postResult = await runLint(files, options.rules, undefined, intrinsics, projectConfig, propertyClasses);
740
753
  diagnostics = postResult.diagnostics;
741
754
  suppressed = postResult.suppressed;
742
755
  } else if (hasOverrides) {
@@ -745,13 +758,13 @@ export async function lintCommand(options: LintOptions): Promise<LintResult> {
745
758
  for (const file of files) {
746
759
  const relativePath = relative(projectRoot, file);
747
760
  const { rules: fileRules, ruleOptions } = getDefaultRules(projectRoot, relativePath, allRules);
748
- const postResult = await runLint([file], fileRules, ruleOptions, intrinsics, projectConfig);
761
+ const postResult = await runLint([file], fileRules, ruleOptions, intrinsics, projectConfig, propertyClasses);
749
762
  diagnostics.push(...postResult.diagnostics);
750
763
  suppressed.push(...postResult.suppressed);
751
764
  }
752
765
  } else {
753
766
  const { rules, ruleOptions } = getDefaultRules(projectRoot, undefined, allRules);
754
- const postResult = await runLint(files, rules, ruleOptions, intrinsics, projectConfig);
767
+ const postResult = await runLint(files, rules, ruleOptions, intrinsics, projectConfig, propertyClasses);
755
768
  diagnostics = postResult.diagnostics;
756
769
  suppressed = postResult.suppressed;
757
770
  }
@@ -309,6 +309,27 @@ describe("deepPathSet", () => {
309
309
  const set = deepPathSet({ Tags: [{ Key: "a" }] });
310
310
  expect([...set].sort()).toEqual(["Tags", "Tags[0]", "Tags[0].Key", "Tags[]", "Tags[].Key"]);
311
311
  });
312
+
313
+ // #2946: the path set has to agree with the normalized tree, which inlines
314
+ // a property-kind declarable's props (#1314). Otherwise a live node under
315
+ // one reads `counterpart: "absent"` and is pruned as an undeclared default.
316
+ test("walks a property-kind declarable as its props, as normalization inlines it", () => {
317
+ class Panel {
318
+ readonly entityType = "Grafana::Panel::stat";
319
+ readonly kind = "property";
320
+ constructor(readonly props: Record<string, unknown>) {}
321
+ }
322
+ const set = deepPathSet({ panels: [new Panel({ gridPos: { w: 6 } })] });
323
+ expect(set.has("panels[0].gridPos.w")).toBe(true);
324
+ expect(set.has("panels[].gridPos.w")).toBe(true);
325
+
326
+ const hooks = { prune: (n: { side: string; counterpart: string }) => n.side === "live" && n.counterpart === "absent" };
327
+ const live = normalizeDeepProperties(
328
+ { panels: [{ gridPos: { w: 6, x: 0 } }] },
329
+ { entityType: "T", side: "live", hooks, counterpartPaths: set },
330
+ );
331
+ expect(live).toEqual({ panels: [{ gridPos: { w: 6 } }] });
332
+ });
312
333
  });
313
334
 
314
335
  describe("deepValueEqual", () => {
@@ -363,6 +363,15 @@ export function deepPathSet(tree: Record<string, unknown>): Set<string> {
363
363
  // gives it, so this path set agrees with what the normalized tree
364
364
  // actually looks like a path down.
365
365
  if (isHeldElsewhere(value)) return;
366
+ // A property-kind declarable is walked as its props, exactly as
367
+ // `normalizeDeepProperties` inlines it (#1314). Stopping at it left every
368
+ // path under a typed nested property out of the set, so the other tree's
369
+ // nodes there read `counterpart: "absent"` and default subtraction fired
370
+ // on properties the author did declare.
371
+ if (isPropertyDeclarableValue(value)) {
372
+ walk((value as { props?: unknown }).props ?? {}, path, pattern);
373
+ return;
374
+ }
366
375
  if (Array.isArray(value)) {
367
376
  value.forEach((el, i) => walk(el, joinIndex(path, i), joinPattern(pattern)));
368
377
  return;
@@ -0,0 +1,153 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import {
3
+ EmbeddedImports,
4
+ EmbeddedImportScope,
5
+ embeddedDocument,
6
+ exportedNames,
7
+ isEmbeddedReference,
8
+ renderEmbeddedReference,
9
+ type EmbeddedContent,
10
+ type EmbeddedContentImporter,
11
+ } from "./embedded";
12
+
13
+ const site = (over: Partial<EmbeddedContent> = {}): EmbeddedContent => ({
14
+ host: "k8s",
15
+ hostType: "K8s::Core::ConfigMap",
16
+ location: 'ConfigMap agent data["config.yaml"]',
17
+ directory: "agent",
18
+ text: "kind: widget\nsize: 3\n",
19
+ ...over,
20
+ });
21
+
22
+ /** Imports any `kind: widget` document as `widgets.ts`, passed through `render`. */
23
+ const widgets: EmbeddedContentImporter = {
24
+ what: "a widget",
25
+ matches: (c) => (c.document as { kind?: string } | undefined)?.kind === "widget",
26
+ import: () => ({
27
+ files: [{ path: "widgets.ts", content: "const w = 1;\n\nexport { w };\n" }],
28
+ value: { bindings: [{ from: "widgets.ts", name: "w" }], shape: "list", through: { from: "@acme/widgets", name: "render" } },
29
+ warnings: ["dropped the colour"],
30
+ }),
31
+ };
32
+
33
+ describe("EmbeddedImports", () => {
34
+ test("an importer that matches gets the content, with its document parsed from the text", () => {
35
+ const seen: EmbeddedContent[] = [];
36
+ const resolver = new EmbeddedImports([{ lexicon: "acme", importer: { ...widgets, import: (c) => (seen.push(c), widgets.import(c)) } }]);
37
+ const ref = resolver.resolve(site());
38
+ expect(seen[0].document).toEqual({ kind: "widget", size: 3 });
39
+ expect(isEmbeddedReference(ref)).toBe(true);
40
+ // Local modules are rebased under the content's directory; packages stay as they are.
41
+ expect(ref!.$embedded).toEqual({
42
+ lexicon: "acme",
43
+ what: "a widget",
44
+ location: 'ConfigMap agent data["config.yaml"]',
45
+ value: { bindings: [{ from: "agent/widgets.ts", name: "w" }], shape: "list", through: { from: "@acme/widgets", name: "render" } },
46
+ });
47
+ expect(resolver.files).toEqual([{ path: "agent/widgets.ts", content: "const w = 1;\n\nexport { w };\n" }]);
48
+ expect(resolver.warnings).toEqual(['ConfigMap agent data["config.yaml"]: dropped the colour']);
49
+ expect(resolver.offered).toHaveLength(1);
50
+ });
51
+
52
+ test("two pieces of content wanting one directory get two", () => {
53
+ const resolver = new EmbeddedImports([{ lexicon: "acme", importer: widgets }]);
54
+ resolver.resolve(site({ directory: "Agent Config" }));
55
+ resolver.resolve(site({ directory: "agent-config" }));
56
+ expect(resolver.files.map((f) => f.path)).toEqual(["agent-config/widgets.ts", "agent-config-2/widgets.ts"]);
57
+ });
58
+
59
+ test("content nobody claims is kept, with a warning only when the host names its expected owner", () => {
60
+ const resolver = new EmbeddedImports([{ lexicon: "acme", importer: widgets }]);
61
+ expect(resolver.resolve(site({ text: "kind: gadget\n" }))).toBeUndefined();
62
+ expect(resolver.warnings).toEqual([]);
63
+ expect(resolver.resolve(site({ text: "kind: gadget\n", expectedOwner: { lexicon: "gadgets", what: "a gadget" } }))).toBeUndefined();
64
+ expect(resolver.warnings).toHaveLength(1);
65
+ expect(resolver.warnings[0]).toContain("looks like a gadget");
66
+ expect(resolver.warnings[0]).toContain("@intentius/chant-lexicon-gadgets");
67
+ });
68
+
69
+ test("a quiet probe collects what is offered and warns about nothing", () => {
70
+ const probe = new EmbeddedImports([], { quiet: true });
71
+ expect(probe.resolve(site({ expectedOwner: { lexicon: "gadgets", what: "a gadget" } }))).toBeUndefined();
72
+ expect(probe.offered).toHaveLength(1);
73
+ expect(probe.warnings).toEqual([]);
74
+ });
75
+
76
+ test("a failing import keeps the content and says why; a throwing matcher is a non-match", () => {
77
+ const resolver = new EmbeddedImports([
78
+ { lexicon: "broken", importer: { what: "a widget", matches: () => { throw new Error("no"); }, import: widgets.import } },
79
+ { lexicon: "acme", importer: { ...widgets, import: () => { throw new Error("bad size"); } } },
80
+ ]);
81
+ expect(resolver.resolve(site())).toBeUndefined();
82
+ expect(resolver.files).toEqual([]);
83
+ expect(resolver.warnings).toEqual([
84
+ 'ConfigMap agent data["config.yaml"] looks like a widget, but the acme import failed, so it is kept as written: bad size',
85
+ ]);
86
+ });
87
+
88
+ test("when two lexicons match, the first imports it and the other is named", () => {
89
+ const resolver = new EmbeddedImports([
90
+ { lexicon: "acme", importer: widgets },
91
+ { lexicon: "other", importer: widgets },
92
+ ]);
93
+ expect(resolver.resolve(site())!.$embedded.lexicon).toBe("acme");
94
+ expect(resolver.warnings[0]).toContain("also importable by other");
95
+ });
96
+ });
97
+
98
+ describe("renderEmbeddedReference", () => {
99
+ const ref = (bindings: Array<{ from: string; name: string; member?: string }>, shape: "list" | "single", through?: { from: string; name: string }) => ({
100
+ $embedded: { lexicon: "acme", what: "a widget", location: "here", value: { bindings, shape, ...(through ? { through } : {}) } },
101
+ });
102
+
103
+ test("imports each name once, relative to the module, and aliases a name already taken", () => {
104
+ const scope = new EmbeddedImportScope("", ["otlp"]);
105
+ const a = renderEmbeddedReference(
106
+ ref([{ from: "agent/receivers.ts", name: "otlp" }, { from: "agent/pipelines.ts", name: "traces" }], "list", { from: "@acme/otel", name: "collectorYaml" }),
107
+ scope,
108
+ );
109
+ const b = renderEmbeddedReference(ref([{ from: "gateway/receivers.ts", name: "otlp" }], "list"), scope);
110
+ expect(a).toBe("collectorYaml([otlp2, traces])");
111
+ expect(b).toBe("[otlp3]");
112
+ expect(scope.lines()).toEqual([
113
+ 'import { collectorYaml } from "@acme/otel";',
114
+ 'import { traces } from "./agent/pipelines";',
115
+ 'import { otlp as otlp2 } from "./agent/receivers";',
116
+ 'import { otlp as otlp3 } from "./gateway/receivers";',
117
+ ]);
118
+ });
119
+
120
+ test("a single binding, a member, and a module in a subdirectory", () => {
121
+ const scope = new EmbeddedImportScope("infra");
122
+ expect(renderEmbeddedReference(ref([{ from: "rules/slos.ts", name: "api", member: "rules" }], "single"), scope)).toBe("api.rules");
123
+ expect(scope.lines()).toEqual(['import { api } from "../rules/slos";']);
124
+ });
125
+
126
+ test("a long list is broken one binding per line", () => {
127
+ const names = Array.from({ length: 12 }, (_, i) => ({ from: "c/x.ts", name: `component${i}` }));
128
+ const out = renderEmbeddedReference(ref(names, "list"), new EmbeddedImportScope(""), 4);
129
+ expect(out.split("\n")).toHaveLength(14);
130
+ expect(out.split("\n")[1]).toBe(" component0,");
131
+ expect(out.endsWith("\n ]")).toBe(true);
132
+ });
133
+ });
134
+
135
+ describe("helpers", () => {
136
+ test("embeddedDocument reads JSON or one YAML document", () => {
137
+ expect(embeddedDocument('{"a": 1}')).toEqual({ a: 1 });
138
+ expect(embeddedDocument("a: 1\n")).toEqual({ a: 1 });
139
+ expect(embeddedDocument("a: 1\n---\nb: 2\n")).toBeUndefined();
140
+ });
141
+
142
+ test("exportedNames reads one-line and multi-line export lists", () => {
143
+ expect(exportedNames("const a = 1;\n\nexport { a, b };\n")).toEqual(["a", "b"]);
144
+ expect(exportedNames("export {\n one,\n two as three,\n};\n")).toEqual(["one", "three"]);
145
+ expect(exportedNames("export const x = 1;\n")).toEqual([]);
146
+ });
147
+
148
+ test("isEmbeddedReference", () => {
149
+ expect(isEmbeddedReference({ $embedded: { value: { bindings: [] } } })).toBe(true);
150
+ expect(isEmbeddedReference({ $embedded: true })).toBe(false);
151
+ expect(isEmbeddedReference("x")).toBe(false);
152
+ });
153
+ });
@@ -0,0 +1,376 @@
1
+ /**
2
+ * Content embedded in another lexicon's resources, imported by the lexicon
3
+ * that owns it (#2962).
4
+ *
5
+ * A Kubernetes ConfigMap holding a collector's `config.yaml`, a
6
+ * `PrometheusRule` whose `spec.groups` are Prometheus rule groups, and a
7
+ * ConfigMap holding Grafana dashboard JSON all carry another lexicon's
8
+ * source inside a k8s resource. The host lexicon's parser (k8s) finds such
9
+ * content and offers it here; the lexicon that can import it (otel,
10
+ * prometheus, grafana) declares so with `LexiconPlugin.embeddedImporters()`.
11
+ * Core matches the two at run time, so the host needs no dependency on the
12
+ * owner: when the owner is not installed the content stays as written, with
13
+ * a warning.
14
+ *
15
+ * The flow, driven by `chant import`:
16
+ *
17
+ * 1. The host's `TemplateParser.parse(content, context)` calls
18
+ * `context.embedded.resolve(site)` for each place content can be
19
+ * embedded, and puts the `EmbeddedReference` it gets back into its IR in
20
+ * place of the raw value. `undefined` means keep the raw value.
21
+ * 2. The owner's `EmbeddedContentImporter.import(site)` returns the modules
22
+ * declaring the content and how the host's value is built from them
23
+ * (`collectorYaml([...])`, a list of rule groups, `dashboardJson(...)`).
24
+ * 3. Core writes those modules in a directory of their own beside the
25
+ * host's files, and the host's generator renders the reference with
26
+ * `renderEmbeddedReference`, which also writes the imports it needs.
27
+ */
28
+
29
+ import { posix } from "path";
30
+ import { parseYAMLDocument, splitYAMLDocuments } from "../yaml";
31
+ import type { GeneratedFile } from "./generator";
32
+
33
+ // ── what the host offers ─────────────────────────────────────────────
34
+
35
+ /** One place in a host resource that may hold another lexicon's content. */
36
+ export interface EmbeddedContent {
37
+ /** The host lexicon, e.g. `"k8s"`. */
38
+ readonly host: string;
39
+ /** The host resource's type, e.g. `"K8s::Core::ConfigMap"`. */
40
+ readonly hostType: string;
41
+ /** Where it is, for messages: `ConfigMap otel-agent data["config.yaml"]`. */
42
+ readonly location: string;
43
+ /** A name for the directory its modules are written to; core makes it unique. */
44
+ readonly directory: string;
45
+ /** The content as text, when the host holds it as text (a ConfigMap value). */
46
+ readonly text?: string;
47
+ /**
48
+ * The content as the document it would be in a file of its own. Core
49
+ * parses `text` into it (JSON, then YAML) when the host leaves it out.
50
+ * `PrometheusRule` `spec.groups` is offered as `{ groups: [...] }`, the rule
51
+ * file those groups would make.
52
+ */
53
+ readonly document?: unknown;
54
+ /**
55
+ * When the host's value is one member of `document` rather than the whole
56
+ * of it: `"groups"` for `spec.groups`. The reference must then evaluate to
57
+ * `document[select]`. Absent, it evaluates to `text`.
58
+ */
59
+ readonly select?: string;
60
+ /** The host resource's labels, where the host has them. */
61
+ readonly labels?: Readonly<Record<string, string>>;
62
+ /**
63
+ * The lexicon the host expects to own this content, from conventions it
64
+ * knows (a PrometheusRule's groups are Prometheus rules). Only used for the
65
+ * warning when no installed lexicon imports the content.
66
+ */
67
+ readonly expectedOwner?: { readonly lexicon: string; readonly what: string };
68
+ }
69
+
70
+ /** A declaration the host's value is built from. */
71
+ export interface EmbeddedBinding {
72
+ /** A package (`@intentius/chant-lexicon-otel`) or the path of one of the import's `files`. */
73
+ readonly from: string;
74
+ /** The exported name. */
75
+ readonly name: string;
76
+ /** A member of it the value uses instead, e.g. an `Slo`'s `rules`. */
77
+ readonly member?: string;
78
+ }
79
+
80
+ /** How the host's value is built from the imported declarations. */
81
+ export interface EmbeddedValue {
82
+ /** The declarations, in order. */
83
+ readonly bindings: readonly EmbeddedBinding[];
84
+ /** `"list"`: an array of them. `"single"`: the one binding. */
85
+ readonly shape: "list" | "single";
86
+ /** A function the value is passed through, e.g. otel's `collectorYaml`. */
87
+ readonly through?: { readonly from: string; readonly name: string };
88
+ }
89
+
90
+ /** What an owner's importer returns for one piece of content. */
91
+ export interface EmbeddedImport {
92
+ /** The modules declaring the content, at paths relative to their own directory. */
93
+ readonly files: readonly GeneratedFile[];
94
+ readonly value: EmbeddedValue;
95
+ /** What the import read but could not carry. */
96
+ readonly warnings?: readonly string[];
97
+ }
98
+
99
+ /** An owner lexicon's declaration that it can import content embedded in another's resources. */
100
+ export interface EmbeddedContentImporter {
101
+ /** What it imports, for messages: `"an OpenTelemetry Collector config"`. */
102
+ readonly what: string;
103
+ /** Whether this content is one it imports. Must not throw on content that is not. */
104
+ matches(content: EmbeddedContent): boolean;
105
+ /** Import it. A throw keeps the content as written, with a warning. */
106
+ import(content: EmbeddedContent): EmbeddedImport;
107
+ }
108
+
109
+ /** An importer with the lexicon that registered it. */
110
+ export interface RegisteredEmbeddedImporter {
111
+ readonly lexicon: string;
112
+ readonly importer: EmbeddedContentImporter;
113
+ }
114
+
115
+ // ── what the host gets back ──────────────────────────────────────────
116
+
117
+ /**
118
+ * What the host puts in its IR in place of the raw value. Plain data: the
119
+ * bindings' local paths are relative to the import's output directory.
120
+ */
121
+ export interface EmbeddedReference {
122
+ readonly $embedded: {
123
+ readonly lexicon: string;
124
+ readonly what: string;
125
+ readonly location: string;
126
+ readonly value: EmbeddedValue;
127
+ };
128
+ }
129
+
130
+ export function isEmbeddedReference(value: unknown): value is EmbeddedReference {
131
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
132
+ const e = (value as { $embedded?: unknown }).$embedded;
133
+ return (
134
+ typeof e === "object" &&
135
+ e !== null &&
136
+ Array.isArray((e as { value?: { bindings?: unknown } }).value?.bindings)
137
+ );
138
+ }
139
+
140
+ /** What a host's parser is handed to resolve embedded content. */
141
+ export interface EmbeddedContentResolver {
142
+ /** The reference to put in place of the content, or undefined to keep it as written. */
143
+ resolve(content: EmbeddedContent): EmbeddedReference | undefined;
144
+ }
145
+
146
+ // ── the registry ─────────────────────────────────────────────────────
147
+
148
+ function isObject(v: unknown): v is Record<string, unknown> {
149
+ return typeof v === "object" && v !== null && !Array.isArray(v);
150
+ }
151
+
152
+ /** The document a text holds: JSON, or a single YAML document. Undefined for anything else. */
153
+ export function embeddedDocument(text: string): unknown {
154
+ try {
155
+ return JSON.parse(text);
156
+ } catch {
157
+ // Not JSON: try YAML.
158
+ }
159
+ const docs = splitYAMLDocuments(text);
160
+ if (docs.length !== 1) return undefined;
161
+ let doc: unknown;
162
+ try {
163
+ doc = parseYAMLDocument(docs[0]);
164
+ } catch {
165
+ return undefined;
166
+ }
167
+ // Text that is not YAML (`just text`, `KEY=value` lines) makes core's YAML
168
+ // reader throw, caught above (#2991); an empty or comment-only text reads
169
+ // as an empty mapping. Neither is a document.
170
+ if (typeof doc === "object" && doc !== null && Object.keys(doc).length === 0) return undefined;
171
+ return doc;
172
+ }
173
+
174
+ /** A directory name: lower-case letters, digits and `-`. */
175
+ function slug(text: string): string {
176
+ const s = text
177
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
178
+ .toLowerCase()
179
+ .replace(/[^a-z0-9]+/g, "-")
180
+ .replace(/^-+|-+$/g, "");
181
+ return s === "" ? "embedded" : s;
182
+ }
183
+
184
+ /**
185
+ * Resolves embedded content against the importers registered for one
186
+ * import, and collects what the imports produce: the files to write beside
187
+ * the host's and the warnings to print. `offered` lists every piece of
188
+ * content the host offered, claimed or not.
189
+ */
190
+ export class EmbeddedImports implements EmbeddedContentResolver {
191
+ readonly offered: EmbeddedContent[] = [];
192
+ readonly files: GeneratedFile[] = [];
193
+ readonly warnings: string[] = [];
194
+ private readonly directories = new Set<string>();
195
+
196
+ /**
197
+ * @param importers what the installed lexicons registered, in the order they are asked
198
+ * @param options.quiet collect `offered` only, with no warnings (a probe before the importers are known)
199
+ */
200
+ constructor(
201
+ private readonly importers: readonly RegisteredEmbeddedImporter[] = [],
202
+ private readonly options: { quiet?: boolean } = {},
203
+ ) {}
204
+
205
+ resolve(offered: EmbeddedContent): EmbeddedReference | undefined {
206
+ const content: EmbeddedContent =
207
+ offered.document === undefined && typeof offered.text === "string"
208
+ ? { ...offered, document: embeddedDocument(offered.text) }
209
+ : offered;
210
+ this.offered.push(content);
211
+
212
+ const matching = this.importers.filter(({ importer }) => {
213
+ try {
214
+ return importer.matches(content);
215
+ } catch {
216
+ return false;
217
+ }
218
+ });
219
+ const [chosen, ...others] = matching;
220
+ if (!chosen) {
221
+ const owner = content.expectedOwner;
222
+ if (owner && !this.options.quiet) {
223
+ this.warnings.push(
224
+ `${content.location} looks like ${owner.what}, and no installed lexicon imports it, so it is kept as written. ` +
225
+ `Install @intentius/chant-lexicon-${owner.lexicon} (or a version that imports embedded content) to import it as typed declarations.`,
226
+ );
227
+ }
228
+ return undefined;
229
+ }
230
+ if (others.length > 0) {
231
+ this.warnings.push(
232
+ `${content.location} is also importable by ${others.map((o) => o.lexicon).join(", ")}; imported with ${chosen.lexicon}.`,
233
+ );
234
+ }
235
+
236
+ let result: EmbeddedImport;
237
+ try {
238
+ result = chosen.importer.import(content);
239
+ } catch (err) {
240
+ this.warnings.push(
241
+ `${content.location} looks like ${chosen.importer.what}, but the ${chosen.lexicon} import failed, so it is kept as written: ` +
242
+ (err instanceof Error ? err.message : String(err)),
243
+ );
244
+ return undefined;
245
+ }
246
+
247
+ const dir = this.claimDirectory(content.directory);
248
+ const local = new Set(result.files.map((f) => f.path));
249
+ const rebase = (from: string) => (local.has(from) ? `${dir}/${from}` : from);
250
+ for (const f of result.files) this.files.push({ path: `${dir}/${f.path}`, content: f.content });
251
+ for (const w of result.warnings ?? []) this.warnings.push(`${content.location}: ${w}`);
252
+
253
+ const v = result.value;
254
+ const value: EmbeddedValue = {
255
+ bindings: v.bindings.map((b) => ({ ...b, from: rebase(b.from) })),
256
+ shape: v.shape,
257
+ ...(v.through ? { through: { ...v.through, from: rebase(v.through.from) } } : {}),
258
+ };
259
+ return {
260
+ $embedded: { lexicon: chosen.lexicon, what: chosen.importer.what, location: content.location, value },
261
+ };
262
+ }
263
+
264
+ private claimDirectory(name: string): string {
265
+ const base = slug(name);
266
+ let dir = base;
267
+ for (let n = 2; this.directories.has(dir); n++) dir = `${base}-${n}`;
268
+ this.directories.add(dir);
269
+ return dir;
270
+ }
271
+ }
272
+
273
+ // ── rendering ────────────────────────────────────────────────────────
274
+
275
+ const RESERVED = new Set(
276
+ (
277
+ "break case catch class const continue debugger default delete do else enum export extends false finally for " +
278
+ "function if import in instanceof new null return super switch this throw true try typeof var void while with " +
279
+ "yield let static implements interface package private protected public await arguments eval undefined"
280
+ ).split(" "),
281
+ );
282
+
283
+ /**
284
+ * The imports one generated module needs for the references it renders.
285
+ * Names are made unique against the module's own declarations: a second
286
+ * `otlp` from another collector's directory is imported as `otlp2`.
287
+ */
288
+ export class EmbeddedImportScope {
289
+ private readonly taken: Set<string>;
290
+ /** specifier -> exported name -> local name */
291
+ private readonly bySpecifier = new Map<string, Map<string, string>>();
292
+
293
+ /**
294
+ * @param fromDir the directory of the module being generated, relative to the output directory (`""` for its top)
295
+ * @param taken names the module already declares or imports
296
+ */
297
+ constructor(
298
+ private readonly fromDir: string,
299
+ taken: Iterable<string> = [],
300
+ ) {
301
+ this.taken = new Set(taken);
302
+ }
303
+
304
+ /** The local name `name`, exported by `from` (a package or an output-relative path), is used under. */
305
+ bind(from: string, name: string): string {
306
+ const spec = this.specifier(from);
307
+ let names = this.bySpecifier.get(spec);
308
+ if (!names) {
309
+ names = new Map();
310
+ this.bySpecifier.set(spec, names);
311
+ }
312
+ const existing = names.get(name);
313
+ if (existing) return existing;
314
+ let local = name;
315
+ for (let n = 2; this.taken.has(local) || RESERVED.has(local); n++) local = `${name}${n}`;
316
+ this.taken.add(local);
317
+ names.set(name, local);
318
+ return local;
319
+ }
320
+
321
+ /** The import statements, packages first, then local modules, each sorted. */
322
+ lines(): string[] {
323
+ const specs = [...this.bySpecifier.keys()].sort((a, b) => {
324
+ const la = a.startsWith("."), lb = b.startsWith(".");
325
+ return la === lb ? a.localeCompare(b) : la ? 1 : -1;
326
+ });
327
+ return specs.map((spec) => {
328
+ const entries = [...this.bySpecifier.get(spec)!].sort(([a], [b]) => a.localeCompare(b));
329
+ const list = entries.map(([name, local]) => (name === local ? name : `${name} as ${local}`));
330
+ const one = `import { ${list.join(", ")} } from "${spec}";`;
331
+ return one.length <= 100 ? one : `import {\n${list.map((n) => ` ${n},`).join("\n")}\n} from "${spec}";`;
332
+ });
333
+ }
334
+
335
+ private specifier(from: string): string {
336
+ if (!from.endsWith(".ts")) return from;
337
+ let rel = posix.relative(this.fromDir || ".", from.replace(/\.ts$/, ""));
338
+ if (!rel.startsWith(".")) rel = `./${rel}`;
339
+ return rel;
340
+ }
341
+ }
342
+
343
+ /**
344
+ * The TypeScript expression for a reference, binding its names in `scope`.
345
+ * A list longer than one line is broken one binding per line, indented from
346
+ * `indent` spaces.
347
+ */
348
+ export function renderEmbeddedReference(ref: EmbeddedReference, scope: EmbeddedImportScope, indent = 0): string {
349
+ const { value } = ref.$embedded;
350
+ const items = value.bindings.map((b) => `${scope.bind(b.from, b.name)}${b.member ? `.${b.member}` : ""}`);
351
+ let inner: string;
352
+ if (value.shape === "single") {
353
+ if (items.length !== 1) throw new Error(`${ref.$embedded.location}: a single embedded value needs exactly one binding`);
354
+ inner = items[0];
355
+ } else {
356
+ const one = `[${items.join(", ")}]`;
357
+ const pad = " ".repeat(indent);
358
+ inner = one.length <= 80 ? one : `[\n${items.map((i) => `${pad} ${i},`).join("\n")}\n${pad}]`;
359
+ }
360
+ return value.through ? `${scope.bind(value.through.from, value.through.name)}(${inner})` : inner;
361
+ }
362
+
363
+ /**
364
+ * The names a generated module exports in its `export { … }` list, for an
365
+ * importer whose generator writes one per module (COR004).
366
+ */
367
+ export function exportedNames(content: string): string[] {
368
+ const names: string[] = [];
369
+ for (const m of content.matchAll(/^export\s*\{([^}]*)\};?\s*$/gm)) {
370
+ for (const part of m[1].split(",")) {
371
+ const name = part.trim().split(/\s+as\s+/).pop()!.trim();
372
+ if (name !== "" && !name.startsWith("type ")) names.push(name);
373
+ }
374
+ }
375
+ return names;
376
+ }