@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
|
@@ -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
|
-
/**
|
|
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
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
*
|
|
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
|
-
):
|
|
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
|
-
|
|
126
|
-
|
|
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
|
|
144
|
-
|
|
145
|
-
path: "parameters.ts",
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
//
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
|
358
|
+
error: "Template is neither valid JSON nor YAML.",
|
|
208
359
|
};
|
|
209
360
|
}
|
|
210
361
|
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
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
|
-
|
|
216
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
|
252
|
-
*
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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(
|
|
804
|
+
console.log(`${result.detected ? "Detected lexicon" : "Lexicon"}: ${result.lexicon}`);
|
|
566
805
|
}
|
|
567
806
|
|
|
568
807
|
if (result.generatedFiles.length > 0) {
|
package/src/cli/commands/lint.ts
CHANGED
|
@@ -141,7 +141,13 @@ function lexiconResolutionDiagnostic(projectRoot: string, error: Error): LintDia
|
|
|
141
141
|
*/
|
|
142
142
|
async function loadAllPluginRules(
|
|
143
143
|
projectPath: string,
|
|
144
|
-
): Promise<{
|
|
144
|
+
): Promise<{
|
|
145
|
+
rules: Map<string, LintRule>;
|
|
146
|
+
intrinsics: IntrinsicDef[];
|
|
147
|
+
propertyClasses: Set<string>;
|
|
148
|
+
plugins: LexiconPlugin[];
|
|
149
|
+
lexiconError?: Error;
|
|
150
|
+
}> {
|
|
145
151
|
const rules = new Map<string, LintRule>();
|
|
146
152
|
|
|
147
153
|
// Load core COR/EVL rules directly
|
|
@@ -198,6 +204,12 @@ async function loadAllPluginRules(
|
|
|
198
204
|
// hence the guard.
|
|
199
205
|
const intrinsics = plugins.flatMap((plugin) => plugin.intrinsics?.() ?? []);
|
|
200
206
|
|
|
207
|
+
// chant #2957 — the class names these plugins declare property-kind, so
|
|
208
|
+
// COR001, COR004 and COR009 leave out declarables that live inside a
|
|
209
|
+
// resource (a Grafana panel inside its dashboard) instead of treating
|
|
210
|
+
// them as resources.
|
|
211
|
+
const propertyClasses = new Set(plugins.flatMap((plugin) => plugin.propertyClassNames?.() ?? []));
|
|
212
|
+
|
|
201
213
|
for (const plugin of plugins) {
|
|
202
214
|
if (plugin.lintRules) {
|
|
203
215
|
for (const r of plugin.lintRules()) {
|
|
@@ -219,7 +231,7 @@ async function loadAllPluginRules(
|
|
|
219
231
|
rules.set(r.id, r);
|
|
220
232
|
}
|
|
221
233
|
|
|
222
|
-
return { rules, intrinsics, plugins, ...(lexiconError ? { lexiconError } : {}) };
|
|
234
|
+
return { rules, intrinsics, propertyClasses, plugins, ...(lexiconError ? { lexiconError } : {}) };
|
|
223
235
|
}
|
|
224
236
|
|
|
225
237
|
/**
|
|
@@ -667,6 +679,7 @@ export async function lintCommand(options: LintOptions): Promise<LintResult> {
|
|
|
667
679
|
// flagging it. Computed once here regardless of which branch below runs,
|
|
668
680
|
// same as `allRules`.
|
|
669
681
|
const intrinsics = loaded.intrinsics;
|
|
682
|
+
const propertyClasses = loaded.propertyClasses;
|
|
670
683
|
|
|
671
684
|
// Merge in any config-level plugin rules (custom .ts rule files)
|
|
672
685
|
if (config.plugins && config.plugins.length > 0) {
|
|
@@ -681,7 +694,7 @@ export async function lintCommand(options: LintOptions): Promise<LintResult> {
|
|
|
681
694
|
let diagnostics: LintDiagnostic[];
|
|
682
695
|
let suppressed: Array<LintDiagnostic & { reason?: string }> = [];
|
|
683
696
|
if (options.rules) {
|
|
684
|
-
const result = await runLint(files, options.rules, undefined, intrinsics, projectConfig);
|
|
697
|
+
const result = await runLint(files, options.rules, undefined, intrinsics, projectConfig, propertyClasses);
|
|
685
698
|
diagnostics = result.diagnostics;
|
|
686
699
|
suppressed = result.suppressed;
|
|
687
700
|
} else if (hasOverrides) {
|
|
@@ -689,13 +702,13 @@ export async function lintCommand(options: LintOptions): Promise<LintResult> {
|
|
|
689
702
|
for (const file of files) {
|
|
690
703
|
const relativePath = relative(projectRoot, file);
|
|
691
704
|
const { rules: fileRules, ruleOptions } = getDefaultRules(projectRoot, relativePath, allRules);
|
|
692
|
-
const result = await runLint([file], fileRules, ruleOptions, intrinsics, projectConfig);
|
|
705
|
+
const result = await runLint([file], fileRules, ruleOptions, intrinsics, projectConfig, propertyClasses);
|
|
693
706
|
diagnostics.push(...result.diagnostics);
|
|
694
707
|
suppressed.push(...result.suppressed);
|
|
695
708
|
}
|
|
696
709
|
} else {
|
|
697
710
|
const { rules, ruleOptions } = getDefaultRules(projectRoot, undefined, allRules);
|
|
698
|
-
const result = await runLint(files, rules, ruleOptions, intrinsics, projectConfig);
|
|
711
|
+
const result = await runLint(files, rules, ruleOptions, intrinsics, projectConfig, propertyClasses);
|
|
699
712
|
diagnostics = result.diagnostics;
|
|
700
713
|
suppressed = result.suppressed;
|
|
701
714
|
}
|
|
@@ -736,7 +749,7 @@ export async function lintCommand(options: LintOptions): Promise<LintResult> {
|
|
|
736
749
|
|
|
737
750
|
// Re-lint after fixes to get updated diagnostics
|
|
738
751
|
if (options.rules) {
|
|
739
|
-
const postResult = await runLint(files, options.rules, undefined, intrinsics, projectConfig);
|
|
752
|
+
const postResult = await runLint(files, options.rules, undefined, intrinsics, projectConfig, propertyClasses);
|
|
740
753
|
diagnostics = postResult.diagnostics;
|
|
741
754
|
suppressed = postResult.suppressed;
|
|
742
755
|
} else if (hasOverrides) {
|
|
@@ -745,13 +758,13 @@ export async function lintCommand(options: LintOptions): Promise<LintResult> {
|
|
|
745
758
|
for (const file of files) {
|
|
746
759
|
const relativePath = relative(projectRoot, file);
|
|
747
760
|
const { rules: fileRules, ruleOptions } = getDefaultRules(projectRoot, relativePath, allRules);
|
|
748
|
-
const postResult = await runLint([file], fileRules, ruleOptions, intrinsics, projectConfig);
|
|
761
|
+
const postResult = await runLint([file], fileRules, ruleOptions, intrinsics, projectConfig, propertyClasses);
|
|
749
762
|
diagnostics.push(...postResult.diagnostics);
|
|
750
763
|
suppressed.push(...postResult.suppressed);
|
|
751
764
|
}
|
|
752
765
|
} else {
|
|
753
766
|
const { rules, ruleOptions } = getDefaultRules(projectRoot, undefined, allRules);
|
|
754
|
-
const postResult = await runLint(files, rules, ruleOptions, intrinsics, projectConfig);
|
|
767
|
+
const postResult = await runLint(files, rules, ruleOptions, intrinsics, projectConfig, propertyClasses);
|
|
755
768
|
diagnostics = postResult.diagnostics;
|
|
756
769
|
suppressed = postResult.suppressed;
|
|
757
770
|
}
|
package/src/cli/handlers/misc.ts
CHANGED
|
@@ -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);
|