@intentius/chant 0.97.0 → 0.99.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/dist/cli/commands/import.d.ts +67 -4
  2. package/dist/cli/commands/import.d.ts.map +1 -1
  3. package/dist/cli/commands/lint.d.ts.map +1 -1
  4. package/dist/cli/handlers/misc.d.ts.map +1 -1
  5. package/dist/cli/handlers/operator.d.ts.map +1 -1
  6. package/dist/cli/main.d.ts.map +1 -1
  7. package/dist/cli/mcp/tools/import.d.ts +4 -0
  8. package/dist/cli/mcp/tools/import.d.ts.map +1 -1
  9. package/dist/cli/plugins.d.ts +10 -0
  10. package/dist/cli/plugins.d.ts.map +1 -1
  11. package/dist/deep-observation.d.ts.map +1 -1
  12. package/dist/import/embedded.d.ts +184 -0
  13. package/dist/import/embedded.d.ts.map +1 -0
  14. package/dist/import/generator.d.ts +13 -0
  15. package/dist/import/generator.d.ts.map +1 -1
  16. package/dist/import/parser.d.ts +15 -1
  17. package/dist/import/parser.d.ts.map +1 -1
  18. package/dist/lexicon.d.ts +19 -0
  19. package/dist/lexicon.d.ts.map +1 -1
  20. package/dist/lint/engine.d.ts +6 -1
  21. package/dist/lint/engine.d.ts.map +1 -1
  22. package/dist/lint/rule.d.ts +10 -0
  23. package/dist/lint/rule.d.ts.map +1 -1
  24. package/dist/lint/rules/file-declarable-limit.d.ts.map +1 -1
  25. package/dist/lint/rules/flat-declarations.d.ts.map +1 -1
  26. package/dist/lint/rules/no-unused-declarable.d.ts.map +1 -1
  27. package/dist/lint/rules/property-kind.d.ts +7 -0
  28. package/dist/lint/rules/property-kind.d.ts.map +1 -0
  29. package/dist/workspace/conformance/index.d.ts +9 -0
  30. package/dist/workspace/conformance/index.d.ts.map +1 -1
  31. package/dist/yaml.d.ts +44 -6
  32. package/dist/yaml.d.ts.map +1 -1
  33. package/package.json +1 -1
  34. package/src/cli/commands/import-layout.test.ts +142 -0
  35. package/src/cli/commands/import-no-parser.test.ts +70 -0
  36. package/src/cli/commands/import.test.ts +284 -2
  37. package/src/cli/commands/import.ts +325 -86
  38. package/src/cli/commands/lint.ts +21 -8
  39. package/src/cli/handlers/misc.ts +3 -0
  40. package/src/cli/handlers/operator-steward-signal.e2e.test.ts +19 -11
  41. package/src/cli/handlers/operator.ts +17 -3
  42. package/src/cli/main.ts +3 -1
  43. package/src/cli/mcp/tools/import.ts +6 -0
  44. package/src/cli/plugins.ts +41 -2
  45. package/src/deep-observation.test.ts +21 -0
  46. package/src/deep-observation.ts +9 -0
  47. package/src/import/embedded.test.ts +153 -0
  48. package/src/import/embedded.ts +376 -0
  49. package/src/import/generator.ts +14 -0
  50. package/src/import/parser.ts +17 -1
  51. package/src/lexicon.ts +21 -0
  52. package/src/lint/engine.ts +7 -0
  53. package/src/lint/rule.ts +10 -0
  54. package/src/lint/rules/file-declarable-limit.ts +11 -4
  55. package/src/lint/rules/flat-declarations.ts +6 -1
  56. package/src/lint/rules/no-unused-declarable.ts +59 -2
  57. package/src/lint/rules/property-kind.test.ts +99 -0
  58. package/src/lint/rules/property-kind.ts +56 -0
  59. package/src/workspace/conformance/index.mjs +1 -0
  60. package/src/workspace/conformance/index.ts +23 -1
  61. package/src/yaml.test.ts +244 -1
  62. package/src/yaml.ts +443 -241
@@ -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
+ }
@@ -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
  }
@@ -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.
@@ -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
- function collectExportedDeclarables(sourceFile: ts.SourceFile): DeclarableInfo[] {
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.sourceFile);
158
+ const declarables = collectExportedDeclarables(context);
102
159
 
103
160
  for (const decl of declarables) {
104
161
  if (!collectReferences(decl.name, context.sourceFile, decl.node)) {