@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
@@ -3,8 +3,11 @@ import { join, resolve, basename, dirname } from "path";
3
3
  import { formatSuccess, formatWarning, formatError } from "../format";
4
4
  import type { TemplateIR, ResourceIR, ParameterIR, TemplateParser } from "../../import/parser";
5
5
  import type { GeneratedFile, TypeScriptGenerator } from "../../import/generator";
6
- import { loadPlugins, resolveProjectLexicons } from "../plugins";
6
+ import { listInstalledLexicons, loadPlugin, loadPlugins, resolveProjectLexicons } from "../plugins";
7
7
  import type { LexiconPlugin, ResourceSelector } from "../../lexicon";
8
+ import { parseYAMLDocument, splitYAMLDocuments } from "../../yaml";
9
+ import { importLexiconPackage } from "../../lexicon-module";
10
+ import { EmbeddedImports, type EmbeddedContent, type RegisteredEmbeddedImporter } from "../../import/embedded";
8
11
 
9
12
  /**
10
13
  * Import command options
@@ -16,6 +19,11 @@ export interface ImportOptions {
16
19
  output?: string;
17
20
  /** Force overwrite existing files */
18
21
  force?: boolean;
22
+ /**
23
+ * Lexicon whose parser handles the file (#2935). Skips detection and the
24
+ * JSON/YAML check: the raw content goes straight to that plugin's parser.
25
+ */
26
+ lexicon?: string;
19
27
  }
20
28
 
21
29
  /**
@@ -30,8 +38,13 @@ export interface ImportResult {
30
38
  warnings: string[];
31
39
  /** Error message if failed */
32
40
  error?: string;
33
- /** Detected lexicon */
41
+ /** The lexicon that handled the template */
34
42
  lexicon?: string;
43
+ /**
44
+ * True when `lexicon` was found by template detection, false or absent when
45
+ * it was named (`--lexicon`, `--kustomize`) (#2965).
46
+ */
47
+ detected?: boolean;
35
48
  }
36
49
 
37
50
  /**
@@ -40,18 +53,118 @@ export interface ImportResult {
40
53
  type ResourceCategory = "storage" | "compute" | "network" | "other";
41
54
 
42
55
  /**
43
- * Detect which plugin handles a template by asking each plugin.
44
- * @param data - Parsed JSON object
45
- * @param plugins - Loaded lexicon plugins
46
- * @returns The matching plugin, or undefined if none match
56
+ * Parse template content for detection (#2935): JSON first, then YAML. A YAML
57
+ * file is split into documents with core's `splitYAMLDocuments` (the k8s
58
+ * parser splits the same way), and each document is parsed on its own, so
59
+ * detection sees the same per-document objects the plugin's parser will. A
60
+ * document whose top level is a list parses as that list (#2965). JSON yields
61
+ * one document, the parsed value, exactly as before. Returns undefined when
62
+ * the content is neither. A document core's YAML reader cannot parse is
63
+ * skipped here, since detection only needs one it can read; the plugin's
64
+ * parser meets the same document and reports it (#2991). A file with no
65
+ * non-empty document is rejected.
47
66
  */
48
- function detectPlugin(data: unknown, plugins: LexiconPlugin[]): LexiconPlugin | undefined {
49
- for (const plugin of plugins) {
50
- if (plugin.detectTemplate?.(data)) {
51
- return plugin;
67
+ export function parseTemplateDocuments(content: string): unknown[] | undefined {
68
+ try {
69
+ return [JSON.parse(content)];
70
+ } catch {
71
+ // Not JSON: try YAML.
72
+ }
73
+ const documents: unknown[] = [];
74
+ for (const chunk of splitYAMLDocuments(content)) {
75
+ let doc: unknown;
76
+ try {
77
+ doc = parseYAMLDocument(chunk);
78
+ } catch {
79
+ continue;
80
+ }
81
+ if (typeof doc === "object" && doc !== null && Object.keys(doc).length > 0) {
82
+ documents.push(doc);
52
83
  }
53
84
  }
54
- return undefined;
85
+ return documents.length > 0 ? documents : undefined;
86
+ }
87
+
88
+ /** The outcome of template detection (#2965). */
89
+ export interface TemplateDetection {
90
+ /** The plugin that handles the template. */
91
+ plugin: LexiconPlugin;
92
+ /**
93
+ * Where it came from: one of the project's lexicons, or an installed
94
+ * lexicon package tried because none of the project's matched.
95
+ */
96
+ source: "project" | "installed";
97
+ /** Other installed lexicons that also recognized the deciding document. */
98
+ alsoMatched: string[];
99
+ }
100
+
101
+ /**
102
+ * Find the plugins that recognize a template. Documents are tried in file
103
+ * order and the first one some plugin recognizes decides; every plugin that
104
+ * recognizes that document is returned, in the order given. For a JSON
105
+ * template there is exactly one document, so this is the old behaviour.
106
+ */
107
+ function matchingPlugins(documents: unknown[], plugins: LexiconPlugin[]): LexiconPlugin[] {
108
+ for (const data of documents) {
109
+ const matches = plugins.filter((plugin) => {
110
+ try {
111
+ return plugin.detectTemplate?.(data) === true;
112
+ } catch {
113
+ return false;
114
+ }
115
+ });
116
+ if (matches.length > 0) return matches;
117
+ }
118
+ return [];
119
+ }
120
+
121
+ /**
122
+ * Detect which lexicon handles a template (#2965). The project's lexicons
123
+ * (from chant.config, or the lexicons its source imports) are asked first, so
124
+ * inside a project nothing changes when one of them matches. When none does,
125
+ * or there is no project, every installed `@intentius/chant-lexicon-*`
126
+ * package that is not already a project lexicon is asked, in name order,
127
+ * and one with a `templateParser` is preferred over one without.
128
+ * Installed lexicons are loaded one at a time and without `init()`; one that
129
+ * fails to load is skipped. The chosen plugin is initialized before it is
130
+ * returned.
131
+ */
132
+ export async function detectTemplateLexicon(
133
+ documents: unknown[],
134
+ projectDir: string,
135
+ ): Promise<TemplateDetection | undefined> {
136
+ let projectNames: string[] = [];
137
+ let projectPlugins: LexiconPlugin[] = [];
138
+ try {
139
+ projectNames = await resolveProjectLexicons(projectDir);
140
+ projectPlugins = await loadPlugins(projectNames);
141
+ } catch {
142
+ projectPlugins = [];
143
+ }
144
+
145
+ const [fromProject] = matchingPlugins(documents, projectPlugins);
146
+ if (fromProject) return { plugin: fromProject, source: "project", alsoMatched: [] };
147
+
148
+ const installed: LexiconPlugin[] = [];
149
+ for (const name of listInstalledLexicons(projectDir)) {
150
+ if (projectNames.includes(name)) continue;
151
+ try {
152
+ installed.push(await loadPlugin(name));
153
+ } catch {
154
+ // Not loadable from here: it cannot handle the template either.
155
+ }
156
+ }
157
+
158
+ // A lexicon that can import the template goes ahead of one that only
159
+ // recognizes it (github and forgejo both read an Actions workflow).
160
+ const matches = matchingPlugins(documents, installed);
161
+ const [plugin, ...others] = [
162
+ ...matches.filter((p) => p.templateParser),
163
+ ...matches.filter((p) => !p.templateParser),
164
+ ];
165
+ if (!plugin) return undefined;
166
+ await plugin.init?.();
167
+ return { plugin, source: "installed", alsoMatched: others.map((p) => p.name) };
55
168
  }
56
169
 
57
170
  /**
@@ -94,22 +207,56 @@ function organizeByCategory(ir: TemplateIR): Map<ResourceCategory, ResourceIR[]>
94
207
  return categories;
95
208
  }
96
209
 
210
+ /** The files an import writes, and anything core could not keep. */
211
+ export interface OrganizedFiles {
212
+ files: GeneratedFile[];
213
+ warnings: string[];
214
+ }
215
+
216
+ /**
217
+ * The first file of one per-category `generate()` call, which core writes as
218
+ * `<name>.ts`. Any further files that call returned are named in a warning
219
+ * rather than dropped silently; a call that returned nothing is skipped with
220
+ * a warning rather than failing on `generated[0]`.
221
+ */
222
+ function firstFileOf(generated: GeneratedFile[], fileName: string, warnings: string[]): string | undefined {
223
+ if (generated.length === 0) {
224
+ warnings.push(`The generator returned no file for ${fileName}; it was not written.`);
225
+ return undefined;
226
+ }
227
+ if (generated.length > 1) {
228
+ const dropped = generated.slice(1).map((f) => f.path).join(", ");
229
+ warnings.push(
230
+ `The generator returned ${generated.length} files for ${fileName}; only the first was kept, ` +
231
+ `and ${dropped} ${generated.length === 2 ? "was" : "were"} not written. ` +
232
+ "A generator that places its own files sets ownsLayout (#2964).",
233
+ );
234
+ }
235
+ return generated[0].content;
236
+ }
237
+
97
238
  /**
98
- * Generate organized files with separate modules
239
+ * Decide the files an import writes. A generator with `ownsLayout` is called
240
+ * once with the whole IR and its files are written exactly as returned
241
+ * (#2964). Otherwise an IR of up to three resources is generated in one call,
242
+ * and a larger one is split into one file per resource category plus an
243
+ * `index.ts` barrel.
99
244
  */
100
- function generateOrganizedFiles(
245
+ export function generateOrganizedFiles(
101
246
  ir: TemplateIR,
102
247
  generator: TypeScriptGenerator,
103
- ): GeneratedFile[] {
248
+ ): OrganizedFiles {
249
+ const warnings: string[] = [];
250
+
251
+ // The generator places its own files, or everything fits in one call.
252
+ if (generator.ownsLayout === true || ir.resources.length <= 3) {
253
+ return { files: generator.generate(ir), warnings };
254
+ }
255
+
104
256
  const files: GeneratedFile[] = [];
105
257
  const categories = organizeByCategory(ir);
106
258
  const exports: string[] = [];
107
259
 
108
- // If all resources fit in one file, just generate main.ts
109
- if (ir.resources.length <= 3) {
110
- return generator.generate(ir);
111
- }
112
-
113
260
  // Generate files for each category
114
261
  for (const [category, resources] of categories) {
115
262
  if (resources.length === 0) continue;
@@ -119,13 +266,10 @@ function generateOrganizedFiles(
119
266
  resources,
120
267
  };
121
268
 
122
- const generated = generator.generate(categoryIr);
123
269
  const fileName = `${category}.ts`;
124
-
125
- files.push({
126
- path: fileName,
127
- content: generated[0].content,
128
- });
270
+ const content = firstFileOf(generator.generate(categoryIr), fileName, warnings);
271
+ if (content === undefined) continue;
272
+ files.push({ path: fileName, content });
129
273
 
130
274
  // Track exports
131
275
  for (const resource of resources) {
@@ -140,15 +284,13 @@ function generateOrganizedFiles(
140
284
  parameters: ir.parameters,
141
285
  resources: [],
142
286
  };
143
- const generated = generator.generate(paramsIr);
144
- files.push({
145
- path: "parameters.ts",
146
- content: generated[0].content,
147
- });
148
-
149
- for (const param of ir.parameters) {
150
- const varName = param.name.charAt(0).toLowerCase() + param.name.slice(1);
151
- exports.push(`export { ${varName} } from "./parameters";`);
287
+ const content = firstFileOf(generator.generate(paramsIr), "parameters.ts", warnings);
288
+ if (content !== undefined) {
289
+ files.push({ path: "parameters.ts", content });
290
+ for (const param of ir.parameters) {
291
+ const varName = param.name.charAt(0).toLowerCase() + param.name.slice(1);
292
+ exports.push(`export { ${varName} } from "./parameters";`);
293
+ }
152
294
  }
153
295
  }
154
296
 
@@ -160,7 +302,7 @@ function generateOrganizedFiles(
160
302
  });
161
303
  }
162
304
 
163
- return files;
305
+ return { files, warnings };
164
306
  }
165
307
 
166
308
  /**
@@ -195,61 +337,63 @@ export async function importCommand(options: ImportOptions): Promise<ImportResul
195
337
  };
196
338
  }
197
339
 
198
- // Load plugins and detect lexicon
199
- let data: unknown;
200
- try {
201
- data = JSON.parse(content);
202
- } catch {
340
+ // `--lexicon <name>` names the plugin, so there is nothing to detect and no
341
+ // format to check: the plugin's parser decides what it accepts (#2935).
342
+ if (options.lexicon) {
343
+ return importFromContent({
344
+ content,
345
+ lexicon: options.lexicon,
346
+ output: options.output,
347
+ force: options.force,
348
+ });
349
+ }
350
+
351
+ // Parse for detection: JSON, falling back to YAML.
352
+ const documents = parseTemplateDocuments(content);
353
+ if (!documents) {
203
354
  return {
204
355
  success: false,
205
356
  generatedFiles: [],
206
357
  warnings: [],
207
- error: "Template is not valid JSON.",
358
+ error: "Template is neither valid JSON nor YAML.",
208
359
  };
209
360
  }
210
361
 
211
- // Load plugins — resolve from the output directory (or CWD) so that
212
- // project config is found relative to where the user is working, not
213
- // an arbitrary monorepo root.
362
+ // Detect from the output directory (or CWD) so that project config is
363
+ // found relative to where the user is working, not an arbitrary monorepo
364
+ // root.
214
365
  const projectDir = resolve(options.output ? dirname(options.output) : ".");
215
- let plugins: LexiconPlugin[];
216
- try {
217
- const lexiconNames = await resolveProjectLexicons(projectDir);
218
- plugins = await loadPlugins(lexiconNames);
219
- } catch {
220
- plugins = [];
221
- }
222
-
223
- // If no plugins resolved (no config, no source files), try common lexicons
224
- if (plugins.length === 0) {
225
- try {
226
- plugins = await loadPlugins(["aws"]);
227
- } catch {
228
- // No lexicons available at all
229
- }
230
- }
231
-
232
- const plugin = detectPlugin(data, plugins);
233
- if (!plugin) {
366
+ const detection = await detectTemplateLexicon(documents, projectDir);
367
+ if (!detection) {
234
368
  return {
235
369
  success: false,
236
370
  generatedFiles: [],
237
371
  warnings: [],
238
- error: "Could not detect template lexicon. No installed lexicon recognizes this template.",
372
+ error:
373
+ "Could not detect template lexicon. No installed lexicon recognizes this template. " +
374
+ "Pass --lexicon <name> to import it with a specific lexicon.",
239
375
  };
240
376
  }
241
377
 
242
- const lexicon = plugin.name;
378
+ const { plugin } = detection;
379
+ if (detection.alsoMatched.length > 0) {
380
+ warnings.push(
381
+ `The template is also recognized by ${detection.alsoMatched.join(", ")}. ` +
382
+ `Importing with ${plugin.name}; pass --lexicon <name> to choose another.`,
383
+ );
384
+ }
243
385
 
244
- return parseAndWrite(plugin, content, outputDir, options.force, warnings, generatedFiles, lexicon);
386
+ const result = await parseAndWrite(plugin, content, outputDir, options.force, warnings, generatedFiles, plugin.name, projectDir);
387
+ return { ...result, detected: true };
245
388
  }
246
389
 
247
390
  /**
248
391
  * Import from an in-memory template string through a KNOWN plugin — no
249
392
  * detection, no JSON assumption (#1548). This is the seam
250
393
  * `chant import --kustomize <dir>` drives with `kustomize build` output
251
- * through the k8s plugin's YAML parser; `importCommand` above is the same
252
- * pipeline behind file reading + JSON detection.
394
+ * through the k8s plugin's YAML parser, and `chant import <file> --lexicon
395
+ * <name>` drives with the file's content (#2935); `importCommand` above is
396
+ * the same pipeline behind file reading + JSON/YAML detection.
253
397
  */
254
398
  export interface ContentImportOptions {
255
399
  /** The raw template content (YAML or JSON — the plugin's parser decides). */
@@ -277,20 +421,93 @@ export async function importFromContent(options: ContentImportOptions): Promise<
277
421
  if (!plugin) {
278
422
  return { success: false, generatedFiles: [], warnings: [], error: `Lexicon "${options.lexicon}" not available.` };
279
423
  }
280
- if (!plugin.templateParser || !plugin.templateGenerator) {
281
- return {
282
- success: false,
283
- generatedFiles: [],
284
- warnings: [],
285
- error: `Lexicon "${plugin.name}" does not support template import.`,
286
- lexicon: plugin.name,
287
- };
424
+ const projectDir = resolve(options.output ? dirname(options.output) : ".");
425
+ return parseAndWrite(plugin, options.content, outputDir, options.force, [], [], plugin.name, projectDir);
426
+ }
427
+
428
+ /**
429
+ * The importers for the content a host's parser offered (#2962): those of the
430
+ * project's lexicons, and of each installed lexicon whose `detectTemplate`
431
+ * recognizes one of the offered documents. The recognizing is done with the
432
+ * lexicon's light `/detect` module, so a lexicon is loaded in full only when
433
+ * it may own something; one that fails to load is skipped.
434
+ */
435
+ async function embeddedImportersFor(
436
+ offered: readonly EmbeddedContent[],
437
+ host: LexiconPlugin,
438
+ projectDir: string,
439
+ ): Promise<RegisteredEmbeddedImporter[]> {
440
+ const documents = offered.map((c) => c.document).filter((d) => typeof d === "object" && d !== null);
441
+ if (documents.length === 0) return [];
442
+
443
+ let projectNames: string[] = [];
444
+ try {
445
+ projectNames = await resolveProjectLexicons(projectDir);
446
+ } catch {
447
+ projectNames = [];
288
448
  }
289
- return parseAndWrite(plugin, options.content, outputDir, options.force, [], [], plugin.name);
449
+ const candidates: string[] = [...projectNames];
450
+ for (const name of listInstalledLexicons(projectDir)) {
451
+ if (candidates.includes(name)) continue;
452
+ try {
453
+ const detect = await importLexiconPackage(`@intentius/chant-lexicon-${name}/detect`, projectDir);
454
+ const detectTemplate = detect.detectTemplate;
455
+ if (typeof detectTemplate !== "function") continue;
456
+ if (documents.some((d) => {
457
+ try {
458
+ return detectTemplate(d) === true;
459
+ } catch {
460
+ return false;
461
+ }
462
+ })) {
463
+ candidates.push(name);
464
+ }
465
+ } catch {
466
+ // No /detect module, or it failed to load: not a candidate.
467
+ }
468
+ }
469
+
470
+ const registered: RegisteredEmbeddedImporter[] = [];
471
+ for (const name of candidates) {
472
+ let plugin: LexiconPlugin;
473
+ try {
474
+ plugin = name === host.name ? host : await loadPlugin(name);
475
+ } catch {
476
+ continue;
477
+ }
478
+ let importers;
479
+ try {
480
+ importers = plugin.embeddedImporters?.() ?? [];
481
+ } catch {
482
+ continue;
483
+ }
484
+ for (const importer of importers) registered.push({ lexicon: plugin.name, importer });
485
+ }
486
+ return registered;
487
+ }
488
+
489
+ /**
490
+ * Parse, resolving embedded content (#2962). A first parse with a probe
491
+ * collects what the parser offers; when it offers anything, the owners'
492
+ * importers are loaded and the content is parsed again with them. A parser
493
+ * that offers nothing is parsed once, and nothing is loaded.
494
+ */
495
+ async function parseWithEmbedded(
496
+ plugin: LexiconPlugin,
497
+ parser: TemplateParser,
498
+ content: string,
499
+ projectDir: string,
500
+ ): Promise<{ ir: TemplateIR; embedded?: EmbeddedImports }> {
501
+ const probe = new EmbeddedImports([], { quiet: true });
502
+ const ir = parser.parse(content, { embedded: probe });
503
+ if (probe.offered.length === 0) return { ir };
504
+ const importers = await embeddedImportersFor(probe.offered, plugin, projectDir);
505
+ const embedded = new EmbeddedImports(importers);
506
+ return { ir: parser.parse(content, { embedded }), embedded };
290
507
  }
291
508
 
292
509
  /** The shared tail of every template import: parse → generate → write. */
293
- function parseAndWrite(
510
+ async function parseAndWrite(
294
511
  plugin: LexiconPlugin,
295
512
  content: string,
296
513
  outputDir: string,
@@ -298,12 +515,27 @@ function parseAndWrite(
298
515
  warnings: string[],
299
516
  generatedFiles: string[],
300
517
  lexicon: string,
301
- ): ImportResult {
518
+ projectDir: string,
519
+ ): Promise<ImportResult> {
520
+ // A lexicon can recognize a template (detectTemplate) without being able to
521
+ // import it (grafana had no parser until #2945). Every path funnels through here, so
522
+ // this one check covers detection, --lexicon and content import (#2940).
523
+ if (!plugin.templateParser || !plugin.templateGenerator) {
524
+ return {
525
+ success: false,
526
+ generatedFiles: [],
527
+ warnings: [],
528
+ error: `lexicon "${plugin.name}" does not support template import`,
529
+ lexicon: plugin.name,
530
+ };
531
+ }
532
+
302
533
  // Parse template
303
534
  let ir: TemplateIR;
535
+ let embedded: EmbeddedImports | undefined;
304
536
  try {
305
- const parser = plugin.templateParser!();
306
- ir = parser.parse(content);
537
+ const parser = plugin.templateParser();
538
+ ({ ir, embedded } = await parseWithEmbedded(plugin, parser, content, projectDir));
307
539
  } catch (err) {
308
540
  return {
309
541
  success: false,
@@ -318,8 +550,10 @@ function parseAndWrite(
318
550
  if (ir.warnings) {
319
551
  warnings.push(...ir.warnings);
320
552
  }
553
+ // Embedded content the owner could not carry, or that no installed lexicon imports.
554
+ if (embedded) warnings.push(...embedded.warnings);
321
555
 
322
- const generator = plugin.templateGenerator!();
556
+ const generator = plugin.templateGenerator();
323
557
 
324
558
  // Check output directory
325
559
  if (existsSync(outputDir) && !force) {
@@ -335,7 +569,10 @@ function parseAndWrite(
335
569
  }
336
570
 
337
571
  // Generate files
338
- const files = generateOrganizedFiles(ir, generator);
572
+ const { files: hostFiles, warnings: layoutWarnings } = generateOrganizedFiles(ir, generator);
573
+ warnings.push(...layoutWarnings);
574
+ // The owners' modules for embedded content, each in a directory of its own.
575
+ const files = [...hostFiles, ...(embedded?.files ?? [])];
339
576
 
340
577
  // Write files
341
578
  for (const file of files) {
@@ -409,7 +646,7 @@ function mergeIR(parts: TemplateIR[]): TemplateIR {
409
646
  * for full-fidelity IR, then generate chant TypeScript from it.
410
647
  *
411
648
  * Unlike file import, the live config may contain secrets — the caller prints a
412
- * warning. Reuses the same by-category output organization as file import.
649
+ * warning. Uses the same file layout as file import (`generateOrganizedFiles`).
413
650
  */
414
651
  export async function importFromLive(options: LiveImportOptions): Promise<ImportResult> {
415
652
  const projectDir = resolve(options.output ? dirname(options.output) : ".");
@@ -524,7 +761,8 @@ export async function liveImportFromPlugins(
524
761
  mkdirSync(outputDir, { recursive: true });
525
762
  }
526
763
 
527
- const files = generateOrganizedFiles(ir, generator);
764
+ const { files, warnings: layoutWarnings } = generateOrganizedFiles(ir, generator);
765
+ warnings.push(...layoutWarnings);
528
766
  const generatedFiles: string[] = [];
529
767
  for (const file of files) {
530
768
  const filePath = join(outputDir, file.path);
@@ -545,6 +783,7 @@ export async function liveImportFromPlugins(
545
783
  generatedFiles,
546
784
  warnings,
547
785
  lexicon: generatorLexicon.name,
786
+ detected: !options.lexicon,
548
787
  };
549
788
  }
550
789
 
@@ -562,7 +801,7 @@ export function printImportResult(result: ImportResult): void {
562
801
  }
563
802
 
564
803
  if (result.lexicon) {
565
- console.log(`Detected lexicon: ${result.lexicon}`);
804
+ console.log(`${result.detected ? "Detected lexicon" : "Lexicon"}: ${result.lexicon}`);
566
805
  }
567
806
 
568
807
  if (result.generatedFiles.length > 0) {
@@ -141,7 +141,13 @@ function lexiconResolutionDiagnostic(projectRoot: string, error: Error): LintDia
141
141
  */
142
142
  async function loadAllPluginRules(
143
143
  projectPath: string,
144
- ): Promise<{ rules: Map<string, LintRule>; intrinsics: IntrinsicDef[]; plugins: LexiconPlugin[]; lexiconError?: Error }> {
144
+ ): Promise<{
145
+ rules: Map<string, LintRule>;
146
+ intrinsics: IntrinsicDef[];
147
+ propertyClasses: Set<string>;
148
+ plugins: LexiconPlugin[];
149
+ lexiconError?: Error;
150
+ }> {
145
151
  const rules = new Map<string, LintRule>();
146
152
 
147
153
  // Load core COR/EVL rules directly
@@ -198,6 +204,12 @@ async function loadAllPluginRules(
198
204
  // hence the guard.
199
205
  const intrinsics = plugins.flatMap((plugin) => plugin.intrinsics?.() ?? []);
200
206
 
207
+ // chant #2957 — the class names these plugins declare property-kind, so
208
+ // COR001, COR004 and COR009 leave out declarables that live inside a
209
+ // resource (a Grafana panel inside its dashboard) instead of treating
210
+ // them as resources.
211
+ const propertyClasses = new Set(plugins.flatMap((plugin) => plugin.propertyClassNames?.() ?? []));
212
+
201
213
  for (const plugin of plugins) {
202
214
  if (plugin.lintRules) {
203
215
  for (const r of plugin.lintRules()) {
@@ -219,7 +231,7 @@ async function loadAllPluginRules(
219
231
  rules.set(r.id, r);
220
232
  }
221
233
 
222
- return { rules, intrinsics, plugins, ...(lexiconError ? { lexiconError } : {}) };
234
+ return { rules, intrinsics, propertyClasses, plugins, ...(lexiconError ? { lexiconError } : {}) };
223
235
  }
224
236
 
225
237
  /**
@@ -667,6 +679,7 @@ export async function lintCommand(options: LintOptions): Promise<LintResult> {
667
679
  // flagging it. Computed once here regardless of which branch below runs,
668
680
  // same as `allRules`.
669
681
  const intrinsics = loaded.intrinsics;
682
+ const propertyClasses = loaded.propertyClasses;
670
683
 
671
684
  // Merge in any config-level plugin rules (custom .ts rule files)
672
685
  if (config.plugins && config.plugins.length > 0) {
@@ -681,7 +694,7 @@ export async function lintCommand(options: LintOptions): Promise<LintResult> {
681
694
  let diagnostics: LintDiagnostic[];
682
695
  let suppressed: Array<LintDiagnostic & { reason?: string }> = [];
683
696
  if (options.rules) {
684
- const result = await runLint(files, options.rules, undefined, intrinsics, projectConfig);
697
+ const result = await runLint(files, options.rules, undefined, intrinsics, projectConfig, propertyClasses);
685
698
  diagnostics = result.diagnostics;
686
699
  suppressed = result.suppressed;
687
700
  } else if (hasOverrides) {
@@ -689,13 +702,13 @@ export async function lintCommand(options: LintOptions): Promise<LintResult> {
689
702
  for (const file of files) {
690
703
  const relativePath = relative(projectRoot, file);
691
704
  const { rules: fileRules, ruleOptions } = getDefaultRules(projectRoot, relativePath, allRules);
692
- const result = await runLint([file], fileRules, ruleOptions, intrinsics, projectConfig);
705
+ const result = await runLint([file], fileRules, ruleOptions, intrinsics, projectConfig, propertyClasses);
693
706
  diagnostics.push(...result.diagnostics);
694
707
  suppressed.push(...result.suppressed);
695
708
  }
696
709
  } else {
697
710
  const { rules, ruleOptions } = getDefaultRules(projectRoot, undefined, allRules);
698
- const result = await runLint(files, rules, ruleOptions, intrinsics, projectConfig);
711
+ const result = await runLint(files, rules, ruleOptions, intrinsics, projectConfig, propertyClasses);
699
712
  diagnostics = result.diagnostics;
700
713
  suppressed = result.suppressed;
701
714
  }
@@ -736,7 +749,7 @@ export async function lintCommand(options: LintOptions): Promise<LintResult> {
736
749
 
737
750
  // Re-lint after fixes to get updated diagnostics
738
751
  if (options.rules) {
739
- const postResult = await runLint(files, options.rules, undefined, intrinsics, projectConfig);
752
+ const postResult = await runLint(files, options.rules, undefined, intrinsics, projectConfig, propertyClasses);
740
753
  diagnostics = postResult.diagnostics;
741
754
  suppressed = postResult.suppressed;
742
755
  } else if (hasOverrides) {
@@ -745,13 +758,13 @@ export async function lintCommand(options: LintOptions): Promise<LintResult> {
745
758
  for (const file of files) {
746
759
  const relativePath = relative(projectRoot, file);
747
760
  const { rules: fileRules, ruleOptions } = getDefaultRules(projectRoot, relativePath, allRules);
748
- const postResult = await runLint([file], fileRules, ruleOptions, intrinsics, projectConfig);
761
+ const postResult = await runLint([file], fileRules, ruleOptions, intrinsics, projectConfig, propertyClasses);
749
762
  diagnostics.push(...postResult.diagnostics);
750
763
  suppressed.push(...postResult.suppressed);
751
764
  }
752
765
  } else {
753
766
  const { rules, ruleOptions } = getDefaultRules(projectRoot, undefined, allRules);
754
- const postResult = await runLint(files, rules, ruleOptions, intrinsics, projectConfig);
767
+ const postResult = await runLint(files, rules, ruleOptions, intrinsics, projectConfig, propertyClasses);
755
768
  diagnostics = postResult.diagnostics;
756
769
  suppressed = postResult.suppressed;
757
770
  }
@@ -374,10 +374,13 @@ export async function runImport(ctx: CommandContext): Promise<number> {
374
374
  return result.success ? 0 : 1;
375
375
  }
376
376
 
377
+ // A template file: JSON or YAML, detected, unless `--lexicon` names the
378
+ // plugin (#2935).
377
379
  const result = await importCommand({
378
380
  templatePath: ctx.args.path,
379
381
  output: ctx.args.output,
380
382
  force: ctx.args.force,
383
+ lexicon: ctx.args.lexicon,
381
384
  });
382
385
 
383
386
  printImportResult(result);