@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.
- package/dist/cli/commands/import.d.ts +20 -4
- package/dist/cli/commands/import.d.ts.map +1 -1
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/deep-observation.d.ts.map +1 -1
- package/dist/import/embedded.d.ts +184 -0
- package/dist/import/embedded.d.ts.map +1 -0
- package/dist/import/generator.d.ts +13 -0
- package/dist/import/generator.d.ts.map +1 -1
- package/dist/import/parser.d.ts +15 -1
- package/dist/import/parser.d.ts.map +1 -1
- package/dist/lexicon.d.ts +19 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lint/engine.d.ts +6 -1
- package/dist/lint/engine.d.ts.map +1 -1
- package/dist/lint/rule.d.ts +10 -0
- package/dist/lint/rule.d.ts.map +1 -1
- package/dist/lint/rules/file-declarable-limit.d.ts.map +1 -1
- package/dist/lint/rules/flat-declarations.d.ts.map +1 -1
- package/dist/lint/rules/no-unused-declarable.d.ts.map +1 -1
- package/dist/lint/rules/property-kind.d.ts +7 -0
- package/dist/lint/rules/property-kind.d.ts.map +1 -0
- package/dist/workspace/conformance/index.d.ts +9 -0
- package/dist/workspace/conformance/index.d.ts.map +1 -1
- package/dist/yaml.d.ts +28 -6
- package/dist/yaml.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/commands/import-layout.test.ts +142 -0
- package/src/cli/commands/import-no-parser.test.ts +1 -1
- package/src/cli/commands/import.test.ts +25 -0
- package/src/cli/commands/import.ts +158 -36
- package/src/cli/commands/lint.ts +21 -8
- package/src/deep-observation.test.ts +21 -0
- package/src/deep-observation.ts +9 -0
- package/src/import/embedded.test.ts +153 -0
- package/src/import/embedded.ts +376 -0
- package/src/import/generator.ts +14 -0
- package/src/import/parser.ts +17 -1
- package/src/lexicon.ts +21 -0
- package/src/lint/engine.ts +7 -0
- package/src/lint/rule.ts +10 -0
- package/src/lint/rules/file-declarable-limit.ts +11 -4
- package/src/lint/rules/flat-declarations.ts +6 -1
- package/src/lint/rules/no-unused-declarable.ts +59 -2
- package/src/lint/rules/property-kind.test.ts +99 -0
- package/src/lint/rules/property-kind.ts +56 -0
- package/src/workspace/conformance/index.mjs +1 -0
- package/src/workspace/conformance/index.ts +23 -1
- package/src/yaml.test.ts +192 -1
- package/src/yaml.ts +404 -250
package/src/cli/commands/lint.ts
CHANGED
|
@@ -141,7 +141,13 @@ function lexiconResolutionDiagnostic(projectRoot: string, error: Error): LintDia
|
|
|
141
141
|
*/
|
|
142
142
|
async function loadAllPluginRules(
|
|
143
143
|
projectPath: string,
|
|
144
|
-
): Promise<{
|
|
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", () => {
|
package/src/deep-observation.ts
CHANGED
|
@@ -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
|
+
}
|