@intentius/chant 0.97.0 → 0.99.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/commands/import.d.ts +67 -4
- package/dist/cli/commands/import.d.ts.map +1 -1
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/handlers/misc.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/tools/import.d.ts +4 -0
- package/dist/cli/mcp/tools/import.d.ts.map +1 -1
- package/dist/cli/plugins.d.ts +10 -0
- package/dist/cli/plugins.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 +44 -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 +70 -0
- package/src/cli/commands/import.test.ts +284 -2
- package/src/cli/commands/import.ts +325 -86
- package/src/cli/commands/lint.ts +21 -8
- package/src/cli/handlers/misc.ts +3 -0
- package/src/cli/handlers/operator-steward-signal.e2e.test.ts +19 -11
- package/src/cli/handlers/operator.ts +17 -3
- package/src/cli/main.ts +3 -1
- package/src/cli/mcp/tools/import.ts +6 -0
- package/src/cli/plugins.ts +41 -2
- 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 +244 -1
- package/src/yaml.ts +443 -241
|
@@ -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
|
+
}
|
package/src/import/generator.ts
CHANGED
|
@@ -18,4 +18,18 @@ export interface TypeScriptGenerator {
|
|
|
18
18
|
* @returns Array of generated TypeScript files
|
|
19
19
|
*/
|
|
20
20
|
generate(ir: TemplateIR): GeneratedFile[];
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* True when the generator places its own files (#2964). Core then calls
|
|
24
|
+
* `generate()` once with the whole IR and writes exactly the files it
|
|
25
|
+
* returns, at the paths it gives, however many resources the IR holds.
|
|
26
|
+
*
|
|
27
|
+
* Leave it unset for core's default layout: up to three resources are
|
|
28
|
+
* generated in one call, and above three core splits the IR into
|
|
29
|
+
* per-category files (`storage.ts`, `compute.ts`, `network.ts`,
|
|
30
|
+
* `other.ts`) plus an `index.ts` barrel, keeping only the first file of
|
|
31
|
+
* each call. Set it when your resources refer to each other across that
|
|
32
|
+
* split, or when one `generate()` call returns several modules.
|
|
33
|
+
*/
|
|
34
|
+
readonly ownsLayout?: boolean;
|
|
21
35
|
}
|
package/src/import/parser.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { EmbeddedContentResolver } from "./embedded";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Intermediate representation of a template parameter
|
|
3
5
|
*/
|
|
@@ -63,6 +65,19 @@ export interface TemplateIR {
|
|
|
63
65
|
readonly warnings?: string[];
|
|
64
66
|
}
|
|
65
67
|
|
|
68
|
+
/**
|
|
69
|
+
* What `chant import` hands a parser besides the content (#2962).
|
|
70
|
+
*/
|
|
71
|
+
export interface ParseContext {
|
|
72
|
+
/**
|
|
73
|
+
* Resolves content embedded in the template's resources (a collector
|
|
74
|
+
* config in a ConfigMap) to a reference to declarations the owning
|
|
75
|
+
* lexicon imports. Absent outside `chant import`; a parser then keeps
|
|
76
|
+
* embedded content as written.
|
|
77
|
+
*/
|
|
78
|
+
readonly embedded?: EmbeddedContentResolver;
|
|
79
|
+
}
|
|
80
|
+
|
|
66
81
|
/**
|
|
67
82
|
* Interface for template parsers that convert external formats to IR
|
|
68
83
|
*/
|
|
@@ -70,7 +85,8 @@ export interface TemplateParser {
|
|
|
70
85
|
/**
|
|
71
86
|
* Parse template content into intermediate representation
|
|
72
87
|
* @param content - Raw template content (JSON, YAML, etc.)
|
|
88
|
+
* @param context - What `chant import` provides beyond the content; parsers may ignore it
|
|
73
89
|
* @returns Intermediate representation of the template
|
|
74
90
|
*/
|
|
75
|
-
parse(content: string): TemplateIR;
|
|
91
|
+
parse(content: string, context?: ParseContext): TemplateIR;
|
|
76
92
|
}
|
package/src/lexicon.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { RuleSpec } from "./lint/declarative";
|
|
|
5
5
|
import type { PostSynthCheck } from "./lint/post-synth";
|
|
6
6
|
import type { TemplateParser, TemplateIR } from "./import/parser";
|
|
7
7
|
import type { TypeScriptGenerator } from "./import/generator";
|
|
8
|
+
import type { EmbeddedContentImporter } from "./import/embedded";
|
|
8
9
|
import type { AgentConfigImporter } from "./agents/importer";
|
|
9
10
|
import type { ArtifactIntegrity } from "./lexicon-integrity";
|
|
10
11
|
import type { OkfFile } from "./okf";
|
|
@@ -1047,6 +1048,15 @@ export interface LexiconPlugin {
|
|
|
1047
1048
|
/** Return declarative rule specs for compilation via rule() */
|
|
1048
1049
|
declarativeRules?(): RuleSpec[];
|
|
1049
1050
|
|
|
1051
|
+
/**
|
|
1052
|
+
* Class names this lexicon exports whose instances are property-kind
|
|
1053
|
+
* declarables (`createProperty`), such as Grafana's panels and queries.
|
|
1054
|
+
* The core COR001, COR004 and COR009 heuristics leave them out, since a
|
|
1055
|
+
* property-kind declarable lives inside the resource that holds it
|
|
1056
|
+
* (chant #2957). A lexicon that leaves this out gets the rules unchanged.
|
|
1057
|
+
*/
|
|
1058
|
+
propertyClassNames?(): string[];
|
|
1059
|
+
|
|
1050
1060
|
/** Return post-synthesis checks for build validation */
|
|
1051
1061
|
postSynthChecks?(): PostSynthCheck[];
|
|
1052
1062
|
|
|
@@ -1156,6 +1166,17 @@ export interface LexiconPlugin {
|
|
|
1156
1166
|
/** Return a generator for converting IR to TypeScript */
|
|
1157
1167
|
templateGenerator?(): TypeScriptGenerator;
|
|
1158
1168
|
|
|
1169
|
+
/**
|
|
1170
|
+
* Importers for this lexicon's content when it is embedded in another
|
|
1171
|
+
* lexicon's resources (#2962): a collector config in a k8s ConfigMap, rule
|
|
1172
|
+
* groups in a `PrometheusRule`, dashboard JSON in a ConfigMap. The host's
|
|
1173
|
+
* parser offers the content through `ParseContext.embedded`, and core finds
|
|
1174
|
+
* the owner at run time among the project's lexicons and the installed
|
|
1175
|
+
* ones whose `detectTemplate` recognizes the content, so the host does not
|
|
1176
|
+
* depend on the owner. See `packages/core/src/import/embedded.ts`.
|
|
1177
|
+
*/
|
|
1178
|
+
embeddedImporters?(): EmbeddedContentImporter[];
|
|
1179
|
+
|
|
1159
1180
|
/**
|
|
1160
1181
|
* Re-express local agent configuration (skills, MCP servers, instruction
|
|
1161
1182
|
* files) discovered by `chant audit --agents` as this lexicon's resources.
|
package/src/lint/engine.ts
CHANGED
|
@@ -207,6 +207,11 @@ function isDiagnosticDisabled(
|
|
|
207
207
|
* config-aware rules (COR021 reads `environments` + `ownership`), put on
|
|
208
208
|
* every file's `LintContext.projectConfig`. Optional; without it those
|
|
209
209
|
* rules stay silent.
|
|
210
|
+
* @param propertyClasses - chant #2957 — the class names the active
|
|
211
|
+
* lexicons declare property-kind (`LexiconPlugin.propertyClassNames()`),
|
|
212
|
+
* put on every file's `LintContext.propertyClasses` so COR001, COR004 and
|
|
213
|
+
* COR009 leave those declarables out. Optional; without it every
|
|
214
|
+
* declarable counts.
|
|
210
215
|
* @returns LintRunResult with diagnostics and suppressed items
|
|
211
216
|
*/
|
|
212
217
|
export async function runLint(
|
|
@@ -215,6 +220,7 @@ export async function runLint(
|
|
|
215
220
|
ruleOptions?: Map<string, Record<string, unknown>>,
|
|
216
221
|
intrinsics?: readonly IntrinsicDef[],
|
|
217
222
|
projectConfig?: LintProjectConfig,
|
|
223
|
+
propertyClasses?: ReadonlySet<string>,
|
|
218
224
|
): Promise<LintRunResult> {
|
|
219
225
|
const allDiagnostics: LintDiagnostic[] = [];
|
|
220
226
|
const allSuppressed: Array<LintDiagnostic & { reason?: string }> = [];
|
|
@@ -237,6 +243,7 @@ export async function runLint(
|
|
|
237
243
|
lexicon: undefined,
|
|
238
244
|
intrinsics,
|
|
239
245
|
projectConfig,
|
|
246
|
+
propertyClasses,
|
|
240
247
|
};
|
|
241
248
|
|
|
242
249
|
// Execute each rule
|
package/src/lint/rule.ts
CHANGED
|
@@ -97,6 +97,16 @@ export interface LintContext {
|
|
|
97
97
|
* case config-aware rules stay silent.
|
|
98
98
|
*/
|
|
99
99
|
projectConfig?: LintProjectConfig;
|
|
100
|
+
/**
|
|
101
|
+
* chant #2957 — class names the active lexicons declare property-kind
|
|
102
|
+
* (`LexiconPlugin.propertyClassNames()`), such as Grafana's panels,
|
|
103
|
+
* queries and variables. COR001, COR004 and COR009 leave these out: a
|
|
104
|
+
* property-kind declarable lives inside the resource that holds it, so it
|
|
105
|
+
* is neither a resource to count nor dead code on its own. Undefined when
|
|
106
|
+
* no active lexicon names any, and those rules then treat every
|
|
107
|
+
* declarable alike.
|
|
108
|
+
*/
|
|
109
|
+
propertyClasses?: ReadonlySet<string>;
|
|
100
110
|
}
|
|
101
111
|
|
|
102
112
|
/**
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as ts from "typescript";
|
|
2
2
|
import type { LintRule, LintContext, LintDiagnostic } from "../rule";
|
|
3
|
+
import { isPropertyKindNew } from "./property-kind";
|
|
3
4
|
|
|
4
5
|
const DECLARABLE_LIMIT = 8;
|
|
5
6
|
|
|
@@ -57,15 +58,21 @@ function isDeclarableConstructor(node: ts.NewExpression): boolean {
|
|
|
57
58
|
return false;
|
|
58
59
|
}
|
|
59
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Collect the declarable `new` expressions this rule counts. Property-kind
|
|
63
|
+
* declarables (chant #2957) are left out: a dashboard with twenty panels,
|
|
64
|
+
* queries and variables is one resource, not twenty-one.
|
|
65
|
+
*/
|
|
60
66
|
function collectDeclarableNewExpressions(
|
|
61
67
|
node: ts.Node,
|
|
68
|
+
context: LintContext,
|
|
62
69
|
results: ts.NewExpression[],
|
|
63
70
|
): void {
|
|
64
|
-
if (ts.isNewExpression(node) && isDeclarableConstructor(node)) {
|
|
71
|
+
if (ts.isNewExpression(node) && isDeclarableConstructor(node) && !isPropertyKindNew(node, context)) {
|
|
65
72
|
results.push(node);
|
|
66
73
|
}
|
|
67
74
|
ts.forEachChild(node, (child) =>
|
|
68
|
-
collectDeclarableNewExpressions(child, results),
|
|
75
|
+
collectDeclarableNewExpressions(child, context, results),
|
|
69
76
|
);
|
|
70
77
|
}
|
|
71
78
|
|
|
@@ -73,11 +80,11 @@ export const fileDeclarableLimitRule: LintRule = {
|
|
|
73
80
|
id: "COR009",
|
|
74
81
|
severity: "warning",
|
|
75
82
|
category: "style",
|
|
76
|
-
description: "Limits the number of Declarable instances per file to encourage splitting by concern",
|
|
83
|
+
description: "Limits the number of resource Declarable instances per file to encourage splitting by concern; property-kind declarables are not counted",
|
|
77
84
|
check(context: LintContext, options?: Record<string, unknown>): LintDiagnostic[] {
|
|
78
85
|
const limit = (typeof options?.max === "number" ? options.max : null) ?? DECLARABLE_LIMIT;
|
|
79
86
|
const instances: ts.NewExpression[] = [];
|
|
80
|
-
collectDeclarableNewExpressions(context.sourceFile, instances);
|
|
87
|
+
collectDeclarableNewExpressions(context.sourceFile, context, instances);
|
|
81
88
|
|
|
82
89
|
if (instances.length > limit) {
|
|
83
90
|
return [
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as ts from "typescript";
|
|
2
2
|
import type { LintRule, LintContext, LintDiagnostic } from "../rule";
|
|
3
|
+
import { isPropertyKindNew } from "./property-kind";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* COR001: No inline objects in Declarable constructors
|
|
@@ -12,11 +13,15 @@ import type { LintRule, LintContext, LintDiagnostic } from "../rule";
|
|
|
12
13
|
* Triggers on: new Bucket({ tags: [{ key: "env", value: "prod" }] })
|
|
13
14
|
* OK: new Bucket({ bucketName: "my-bucket", accessControl: "Private" })
|
|
14
15
|
* OK: new Bucket({ encryption: dataEncryption })
|
|
16
|
+
* OK: new TimeSeriesPanel({ fieldConfig: { defaults: { unit: "ms" } } }) when
|
|
17
|
+
* the lexicon declares TimeSeriesPanel property-kind (chant #2957). A
|
|
18
|
+
* property-kind declarable is itself a nested value inside a resource, so
|
|
19
|
+
* the object literals it holds are already at the depth this rule asks for.
|
|
15
20
|
*/
|
|
16
21
|
|
|
17
22
|
function checkNode(node: ts.Node, context: LintContext, diagnostics: LintDiagnostic[]): void {
|
|
18
23
|
// Check for NewExpression nodes (constructor calls)
|
|
19
|
-
if (ts.isNewExpression(node)) {
|
|
24
|
+
if (ts.isNewExpression(node) && !isPropertyKindNew(node, context)) {
|
|
20
25
|
// Check if the first argument is an object literal
|
|
21
26
|
if (node.arguments && node.arguments.length > 0) {
|
|
22
27
|
const firstArg = node.arguments[0];
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as ts from "typescript";
|
|
2
2
|
import type { LintRule, LintContext, LintDiagnostic } from "../rule";
|
|
3
|
+
import { isPropertyKindNew } from "./property-kind";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* COR004: no-unused-declarable
|
|
@@ -10,6 +11,13 @@ import type { LintRule, LintContext, LintDiagnostic } from "../rule";
|
|
|
10
11
|
*
|
|
11
12
|
* Triggers on: export const bucket = new Bucket({...}) when bucket is never referenced
|
|
12
13
|
* OK: export const bucket = new Bucket({...}); export const fn = new Function({ bucket: bucket.arn })
|
|
14
|
+
*
|
|
15
|
+
* Property-kind declarables (chant #2957), such as Grafana panels, are left
|
|
16
|
+
* out on both sides. One is never flagged itself: it only means something
|
|
17
|
+
* inside a resource, and a file of them is usually assembled into that
|
|
18
|
+
* resource from another file. A resource that holds one, inline or through
|
|
19
|
+
* a const declared in this file, is the root that emits it, so it is not
|
|
20
|
+
* flagged either: nothing ever references a dashboard, and that is fine.
|
|
13
21
|
*/
|
|
14
22
|
|
|
15
23
|
interface DeclarableInfo {
|
|
@@ -31,8 +39,52 @@ function getNewExpressionClassName(expr: ts.NewExpression): string | undefined {
|
|
|
31
39
|
return undefined;
|
|
32
40
|
}
|
|
33
41
|
|
|
34
|
-
|
|
42
|
+
/** Names of this file's top-level consts initialised with a property-kind `new`. */
|
|
43
|
+
function collectPropertyKindConsts(context: LintContext): Set<string> {
|
|
44
|
+
const names = new Set<string>();
|
|
45
|
+
for (const stmt of context.sourceFile.statements) {
|
|
46
|
+
if (!ts.isVariableStatement(stmt)) continue;
|
|
47
|
+
for (const decl of stmt.declarationList.declarations) {
|
|
48
|
+
if (
|
|
49
|
+
ts.isIdentifier(decl.name) &&
|
|
50
|
+
decl.initializer &&
|
|
51
|
+
ts.isNewExpression(decl.initializer) &&
|
|
52
|
+
isPropertyKindNew(decl.initializer, context)
|
|
53
|
+
) {
|
|
54
|
+
names.add(decl.name.text);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return names;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* True when the constructor arguments hold a property-kind declarable,
|
|
63
|
+
* either inline (`panels: [new Row(…)]`) or through a const from
|
|
64
|
+
* `propertyConsts` (`panels: [red]`).
|
|
65
|
+
*/
|
|
66
|
+
function holdsPropertyKind(expr: ts.NewExpression, context: LintContext, propertyConsts: Set<string>): boolean {
|
|
67
|
+
let found = false;
|
|
68
|
+
function visit(node: ts.Node): void {
|
|
69
|
+
if (found) return;
|
|
70
|
+
if (ts.isNewExpression(node) && isPropertyKindNew(node, context)) {
|
|
71
|
+
found = true;
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (ts.isIdentifier(node) && propertyConsts.has(node.text)) {
|
|
75
|
+
found = true;
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
ts.forEachChild(node, visit);
|
|
79
|
+
}
|
|
80
|
+
for (const arg of expr.arguments ?? []) visit(arg);
|
|
81
|
+
return found;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function collectExportedDeclarables(context: LintContext): DeclarableInfo[] {
|
|
35
85
|
const declarables: DeclarableInfo[] = [];
|
|
86
|
+
const sourceFile = context.sourceFile;
|
|
87
|
+
const propertyConsts = collectPropertyKindConsts(context);
|
|
36
88
|
|
|
37
89
|
ts.forEachChild(sourceFile, (node) => {
|
|
38
90
|
if (!ts.isVariableStatement(node)) return;
|
|
@@ -54,6 +106,11 @@ function collectExportedDeclarables(sourceFile: ts.SourceFile): DeclarableInfo[]
|
|
|
54
106
|
// Parameters are inherently cross-file (declared in params.ts, consumed via Ref() elsewhere)
|
|
55
107
|
if (className === "Parameter") continue;
|
|
56
108
|
|
|
109
|
+
// chant #2957: a property-kind declarable is part of a resource, and a
|
|
110
|
+
// resource holding one is the root that emits it.
|
|
111
|
+
if (isPropertyKindNew(decl.initializer, context)) continue;
|
|
112
|
+
if (holdsPropertyKind(decl.initializer, context, propertyConsts)) continue;
|
|
113
|
+
|
|
57
114
|
declarables.push({
|
|
58
115
|
name: decl.name.text,
|
|
59
116
|
node,
|
|
@@ -98,7 +155,7 @@ export const noUnusedDeclarableRule: LintRule = {
|
|
|
98
155
|
description: "Detects exported declarables that are never referenced in the same file",
|
|
99
156
|
check(context: LintContext): LintDiagnostic[] {
|
|
100
157
|
const diagnostics: LintDiagnostic[] = [];
|
|
101
|
-
const declarables = collectExportedDeclarables(context
|
|
158
|
+
const declarables = collectExportedDeclarables(context);
|
|
102
159
|
|
|
103
160
|
for (const decl of declarables) {
|
|
104
161
|
if (!collectReferences(decl.name, context.sourceFile, decl.node)) {
|