@intentius/chant 0.1.13 → 0.1.15
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/package.json +1 -1
- package/src/build.ts +18 -2
- package/src/cli/commands/build.ts +9 -1
- package/src/cli/commands/import-live.test.ts +126 -0
- package/src/cli/commands/import.ts +152 -2
- package/src/cli/commands/migrate.ts +2 -2
- package/src/cli/handlers/{state.test.ts → lifecycle.test.ts} +37 -37
- package/src/cli/handlers/{state.ts → lifecycle.ts} +148 -36
- package/src/cli/handlers/misc.ts +31 -2
- package/src/cli/handlers/run.test.ts +98 -0
- package/src/cli/handlers/run.ts +123 -0
- package/src/cli/main.test.ts +14 -0
- package/src/cli/main.ts +38 -12
- package/src/cli/mcp/{state-tools.ts → lifecycle-tools.ts} +9 -9
- package/src/cli/mcp/op-tools.ts +2 -2
- package/src/cli/mcp/resource-handlers.ts +1 -1
- package/src/cli/mcp/server.test.ts +2 -2
- package/src/cli/mcp/server.ts +1 -1
- package/src/cli/registry.ts +21 -2
- package/src/codegen/fetch.test.ts +103 -2
- package/src/codegen/fetch.ts +62 -10
- package/src/config.ts +31 -0
- package/src/detectLexicon.test.ts +2 -2
- package/src/index.ts +2 -2
- package/src/lexicon-export.test.ts +92 -0
- package/src/lexicon.ts +88 -9
- package/src/lifecycle/change-set.test.ts +151 -0
- package/src/lifecycle/change-set.ts +172 -0
- package/src/{state → lifecycle}/git.test.ts +15 -15
- package/src/{state → lifecycle}/git.ts +14 -14
- package/src/{state → lifecycle}/index.ts +2 -0
- package/src/{state → lifecycle}/snapshot.test.ts +5 -5
- package/src/{state → lifecycle}/snapshot.ts +9 -9
- package/src/{state → lifecycle}/types.ts +1 -1
- package/src/op/activity-registry.test.ts +59 -0
- package/src/op/activity-registry.ts +91 -0
- package/src/op/builders.ts +3 -3
- package/src/op/index.ts +6 -1
- package/src/op/local-executor.test.ts +247 -0
- package/src/op/local-executor.ts +300 -0
- package/src/op/local-output.test.ts +54 -0
- package/src/op/local-output.ts +63 -0
- package/src/op/op.test.ts +4 -4
- package/src/op/types.ts +1 -1
- package/src/ownership.test.ts +109 -0
- package/src/ownership.ts +142 -0
- package/src/serializer.ts +19 -1
- package/src/toml-parse.ts +3 -3
- /package/src/{state → lifecycle}/digest.test.ts +0 -0
- /package/src/{state → lifecycle}/digest.ts +0 -0
- /package/src/{state → lifecycle}/live-diff.test.ts +0 -0
- /package/src/{state → lifecycle}/live-diff.ts +0 -0
package/package.json
CHANGED
package/src/build.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Declarable } from "./declarable";
|
|
2
2
|
import type { Serializer, SerializerResult } from "./serializer";
|
|
3
|
+
import type { OwnershipMarker } from "./ownership";
|
|
3
4
|
import type { DiscoveryError, BuildError } from "./errors";
|
|
4
5
|
import { BuildError as BuildErrorClass } from "./errors";
|
|
5
6
|
import { LexiconOutput, isLexiconOutput } from "./lexicon-output";
|
|
@@ -24,6 +25,17 @@ export interface BuildManifest {
|
|
|
24
25
|
/**
|
|
25
26
|
* Result of the build process
|
|
26
27
|
*/
|
|
28
|
+
/**
|
|
29
|
+
* Optional inputs to the build pipeline.
|
|
30
|
+
*/
|
|
31
|
+
export interface BuildOptions {
|
|
32
|
+
/**
|
|
33
|
+
* When set, serializers stamp this ownership marker into each resource's
|
|
34
|
+
* native metadata channel. Resolved from project config by the caller.
|
|
35
|
+
*/
|
|
36
|
+
ownership?: OwnershipMarker;
|
|
37
|
+
}
|
|
38
|
+
|
|
27
39
|
export interface BuildResult {
|
|
28
40
|
/** Map of lexicon name to serialized output (string or multi-file result) */
|
|
29
41
|
outputs: Map<string, string | SerializerResult>;
|
|
@@ -310,6 +322,7 @@ export async function build(
|
|
|
310
322
|
path: string,
|
|
311
323
|
serializers: Serializer[],
|
|
312
324
|
parentBuildStack?: Set<string>,
|
|
325
|
+
options?: BuildOptions,
|
|
313
326
|
): Promise<BuildResult> {
|
|
314
327
|
const warnings: string[] = [];
|
|
315
328
|
const errors: Array<DiscoveryError | BuildError> = [];
|
|
@@ -363,7 +376,7 @@ export async function build(
|
|
|
363
376
|
);
|
|
364
377
|
continue;
|
|
365
378
|
}
|
|
366
|
-
const childResult = await build(childPath, serializers, buildStack);
|
|
379
|
+
const childResult = await build(childPath, serializers, buildStack, options);
|
|
367
380
|
entity.buildResult = childResult;
|
|
368
381
|
if (childResult.errors.length > 0) {
|
|
369
382
|
for (const err of childResult.errors) {
|
|
@@ -429,7 +442,10 @@ export async function build(
|
|
|
429
442
|
...(outputsByLexicon.get(lexiconName) ?? []),
|
|
430
443
|
...unassignedOutputs,
|
|
431
444
|
];
|
|
432
|
-
outputs.set(
|
|
445
|
+
outputs.set(
|
|
446
|
+
lexiconName,
|
|
447
|
+
serializer.serialize(lexiconEntities, lexiconLexiconOutputs, { ownership: options?.ownership }),
|
|
448
|
+
);
|
|
433
449
|
} else {
|
|
434
450
|
warnings.push(`No serializer found for lexicon "${lexiconName}"`);
|
|
435
451
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { build } from "../../build";
|
|
2
|
+
import { loadChantConfig, resolveOwnershipMarker } from "../../config";
|
|
2
3
|
import type { Serializer, SerializerResult } from "../../serializer";
|
|
3
4
|
import type { LexiconPlugin } from "../../lexicon";
|
|
4
5
|
import { runPostSynthChecks } from "../../lint/post-synth";
|
|
@@ -53,8 +54,15 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
|
|
|
53
54
|
// Resolve the path
|
|
54
55
|
const infraPath = resolve(options.path);
|
|
55
56
|
|
|
57
|
+
// Resolve opt-in ownership marking from project config (search the infra dir
|
|
58
|
+
// and its parent, where chant.config.* usually lives).
|
|
59
|
+
const { config } = await loadChantConfig(infraPath).then((r) =>
|
|
60
|
+
r.configPath ? r : loadChantConfig(dirname(infraPath)),
|
|
61
|
+
);
|
|
62
|
+
const ownership = resolveOwnershipMarker(config);
|
|
63
|
+
|
|
56
64
|
// Run the build
|
|
57
|
-
const result = await build(infraPath, options.serializers);
|
|
65
|
+
const result = await build(infraPath, options.serializers, undefined, { ownership });
|
|
58
66
|
|
|
59
67
|
// Format errors
|
|
60
68
|
for (const error of result.errors) {
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { liveImportFromPlugins } from "./import";
|
|
3
|
+
import { mkdir, rm, readFile } from "node:fs/promises";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import type { LexiconPlugin, ExportedTemplate, ResourceSelector } from "../../lexicon";
|
|
8
|
+
import type { TypeScriptGenerator } from "../../import/generator";
|
|
9
|
+
import type { TemplateIR } from "../../import/parser";
|
|
10
|
+
|
|
11
|
+
const generator: TypeScriptGenerator = {
|
|
12
|
+
generate(ir: TemplateIR) {
|
|
13
|
+
return [
|
|
14
|
+
{
|
|
15
|
+
path: "main.ts",
|
|
16
|
+
content: ir.resources
|
|
17
|
+
.map((r) => `export const ${r.logicalId} = ${JSON.stringify(r.properties)};`)
|
|
18
|
+
.join("\n"),
|
|
19
|
+
},
|
|
20
|
+
];
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function fakeExporter(name: string, ir: ExportedTemplate): LexiconPlugin {
|
|
25
|
+
return {
|
|
26
|
+
name,
|
|
27
|
+
serializer: {} as never,
|
|
28
|
+
generate: async () => {},
|
|
29
|
+
validate: async () => {},
|
|
30
|
+
coverage: async () => {},
|
|
31
|
+
package: async () => {},
|
|
32
|
+
templateGenerator: () => generator,
|
|
33
|
+
async exportResources(opts: { selector?: ResourceSelector }): Promise<ExportedTemplate> {
|
|
34
|
+
if (!opts.selector) return ir;
|
|
35
|
+
return {
|
|
36
|
+
...ir,
|
|
37
|
+
resources: ir.resources.filter(
|
|
38
|
+
(r) =>
|
|
39
|
+
(opts.selector!.type === undefined || r.type === opts.selector!.type) &&
|
|
40
|
+
(opts.selector!.name === undefined || r.logicalId === opts.selector!.name),
|
|
41
|
+
),
|
|
42
|
+
};
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const sampleIR: ExportedTemplate = {
|
|
48
|
+
resources: [
|
|
49
|
+
{ logicalId: "bucket", type: "Fake::Bucket", properties: { versioning: true } },
|
|
50
|
+
{ logicalId: "queue", type: "Fake::Queue", properties: { fifo: false } },
|
|
51
|
+
],
|
|
52
|
+
parameters: [],
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
describe("liveImportFromPlugins (#114)", () => {
|
|
56
|
+
let outputDir: string;
|
|
57
|
+
|
|
58
|
+
beforeEach(async () => {
|
|
59
|
+
outputDir = join(tmpdir(), `chant-live-import-${Date.now()}-${Math.random()}`);
|
|
60
|
+
await mkdir(outputDir, { recursive: true });
|
|
61
|
+
});
|
|
62
|
+
afterEach(async () => {
|
|
63
|
+
await rm(outputDir, { recursive: true, force: true });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("regenerates resources from a live exporter", async () => {
|
|
67
|
+
const result = await liveImportFromPlugins([fakeExporter("fake", sampleIR)], {
|
|
68
|
+
environment: "prod",
|
|
69
|
+
output: outputDir,
|
|
70
|
+
force: true,
|
|
71
|
+
});
|
|
72
|
+
expect(result.success).toBe(true);
|
|
73
|
+
expect(result.lexicon).toBe("fake");
|
|
74
|
+
const content = await readFile(join(outputDir, result.generatedFiles[0]), "utf-8");
|
|
75
|
+
expect(content).toContain("bucket");
|
|
76
|
+
expect(content).toContain("queue");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("--name selector narrows the regenerated source", async () => {
|
|
80
|
+
const result = await liveImportFromPlugins([fakeExporter("fake", sampleIR)], {
|
|
81
|
+
environment: "prod",
|
|
82
|
+
output: outputDir,
|
|
83
|
+
force: true,
|
|
84
|
+
selector: { name: "queue" },
|
|
85
|
+
});
|
|
86
|
+
const content = await readFile(join(outputDir, result.generatedFiles[0]), "utf-8");
|
|
87
|
+
expect(content).toContain("queue");
|
|
88
|
+
expect(content).not.toContain("bucket");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("errors when no lexicon supports live export", async () => {
|
|
92
|
+
const nonExporter: LexiconPlugin = {
|
|
93
|
+
name: "noexport",
|
|
94
|
+
serializer: {} as never,
|
|
95
|
+
generate: async () => {},
|
|
96
|
+
validate: async () => {},
|
|
97
|
+
coverage: async () => {},
|
|
98
|
+
package: async () => {},
|
|
99
|
+
};
|
|
100
|
+
const result = await liveImportFromPlugins([nonExporter], {
|
|
101
|
+
environment: "prod",
|
|
102
|
+
output: outputDir,
|
|
103
|
+
});
|
|
104
|
+
expect(result.success).toBe(false);
|
|
105
|
+
expect(result.error).toContain("live export");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("--lexicon narrows to the named exporter", async () => {
|
|
109
|
+
const result = await liveImportFromPlugins(
|
|
110
|
+
[fakeExporter("a", sampleIR), fakeExporter("b", sampleIR)],
|
|
111
|
+
{ environment: "prod", output: outputDir, force: true, lexicon: "b" },
|
|
112
|
+
);
|
|
113
|
+
expect(result.success).toBe(true);
|
|
114
|
+
expect(result.lexicon).toBe("b");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("errors when the environment exports nothing", async () => {
|
|
118
|
+
const empty = fakeExporter("fake", { resources: [], parameters: [] });
|
|
119
|
+
const result = await liveImportFromPlugins([empty], {
|
|
120
|
+
environment: "prod",
|
|
121
|
+
output: outputDir,
|
|
122
|
+
});
|
|
123
|
+
expect(result.success).toBe(false);
|
|
124
|
+
expect(result.error).toContain("No resources exported");
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync } from "fs";
|
|
2
2
|
import { join, resolve, basename, dirname } from "path";
|
|
3
3
|
import { formatSuccess, formatWarning, formatError } from "../format";
|
|
4
|
-
import type { TemplateIR, ResourceIR, TemplateParser } from "../../import/parser";
|
|
4
|
+
import type { TemplateIR, ResourceIR, ParameterIR, TemplateParser } from "../../import/parser";
|
|
5
5
|
import type { GeneratedFile, TypeScriptGenerator } from "../../import/generator";
|
|
6
6
|
import { loadPlugins, resolveProjectLexicons } from "../plugins";
|
|
7
|
-
import type { LexiconPlugin } from "../../lexicon";
|
|
7
|
+
import type { LexiconPlugin, ResourceSelector } from "../../lexicon";
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Import command options
|
|
@@ -310,6 +310,156 @@ export async function importCommand(options: ImportOptions): Promise<ImportResul
|
|
|
310
310
|
};
|
|
311
311
|
}
|
|
312
312
|
|
|
313
|
+
/**
|
|
314
|
+
* Live import options — read from a running cloud/cluster instead of a file.
|
|
315
|
+
*/
|
|
316
|
+
export interface LiveImportOptions {
|
|
317
|
+
/** Environment to resolve (passed to each lexicon's exportResources). */
|
|
318
|
+
environment: string;
|
|
319
|
+
/** Restrict to one lexicon by name (e.g. "aws", "k8s"). */
|
|
320
|
+
lexicon?: string;
|
|
321
|
+
/** Output directory (defaults to ./infra/). */
|
|
322
|
+
output?: string;
|
|
323
|
+
/** Force overwrite existing files. */
|
|
324
|
+
force?: boolean;
|
|
325
|
+
/** Selector forwarded to the lexicon. */
|
|
326
|
+
selector?: ResourceSelector;
|
|
327
|
+
/** Restrict to chant-owned resources (inert until ownership marking lands). */
|
|
328
|
+
owned?: boolean;
|
|
329
|
+
/** Keep server-defaulted fields instead of stripping to declared shape. */
|
|
330
|
+
verbatim?: boolean;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Merge several template IRs into one. Resources and parameters concatenate;
|
|
335
|
+
* later metadata wins on key collisions.
|
|
336
|
+
*/
|
|
337
|
+
function mergeIR(parts: TemplateIR[]): TemplateIR {
|
|
338
|
+
const resources: ResourceIR[] = [];
|
|
339
|
+
const parameters: ParameterIR[] = [];
|
|
340
|
+
let metadata: Record<string, unknown> | undefined;
|
|
341
|
+
for (const part of parts) {
|
|
342
|
+
resources.push(...part.resources);
|
|
343
|
+
parameters.push(...part.parameters);
|
|
344
|
+
if (part.metadata) metadata = { ...(metadata ?? {}), ...part.metadata };
|
|
345
|
+
}
|
|
346
|
+
return { resources, parameters, metadata };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Import directly from a live environment: ask each lexicon's exportResources
|
|
351
|
+
* for full-fidelity IR, then generate chant TypeScript from it.
|
|
352
|
+
*
|
|
353
|
+
* Unlike file import, the live config may contain secrets — the caller prints a
|
|
354
|
+
* warning. Reuses the same by-category output organization as file import.
|
|
355
|
+
*/
|
|
356
|
+
export async function importFromLive(options: LiveImportOptions): Promise<ImportResult> {
|
|
357
|
+
const projectDir = resolve(options.output ? dirname(options.output) : ".");
|
|
358
|
+
|
|
359
|
+
// Resolve project lexicons, then hand off to the testable core.
|
|
360
|
+
let plugins: LexiconPlugin[];
|
|
361
|
+
try {
|
|
362
|
+
const lexiconNames = await resolveProjectLexicons(projectDir);
|
|
363
|
+
plugins = await loadPlugins(lexiconNames);
|
|
364
|
+
} catch {
|
|
365
|
+
plugins = [];
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return liveImportFromPlugins(plugins, options);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Live-import core: given resolved plugins, export and generate. Split from
|
|
373
|
+
* plugin resolution so it can be tested with fake exporters (no cloud calls).
|
|
374
|
+
*/
|
|
375
|
+
export async function liveImportFromPlugins(
|
|
376
|
+
plugins: LexiconPlugin[],
|
|
377
|
+
options: LiveImportOptions,
|
|
378
|
+
): Promise<ImportResult> {
|
|
379
|
+
const outputDir = resolve(options.output ?? "./infra/");
|
|
380
|
+
const warnings: string[] = [];
|
|
381
|
+
|
|
382
|
+
let exporters = plugins.filter((p) => p.exportResources && p.templateGenerator);
|
|
383
|
+
if (options.lexicon) {
|
|
384
|
+
exporters = exporters.filter((p) => p.name === options.lexicon);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (exporters.length === 0) {
|
|
388
|
+
return {
|
|
389
|
+
success: false,
|
|
390
|
+
generatedFiles: [],
|
|
391
|
+
warnings: [],
|
|
392
|
+
error: options.lexicon
|
|
393
|
+
? `Lexicon "${options.lexicon}" does not support live export, or is not in this project.`
|
|
394
|
+
: "No project lexicon supports live export (exportResources).",
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Collect IR from every exporter, tagging which lexicon produced output.
|
|
399
|
+
const irParts: TemplateIR[] = [];
|
|
400
|
+
let generatorLexicon: LexiconPlugin | undefined;
|
|
401
|
+
for (const plugin of exporters) {
|
|
402
|
+
let ir: TemplateIR;
|
|
403
|
+
try {
|
|
404
|
+
ir = await plugin.exportResources!({
|
|
405
|
+
environment: options.environment,
|
|
406
|
+
selector: options.selector,
|
|
407
|
+
owned: options.owned,
|
|
408
|
+
verbatim: options.verbatim,
|
|
409
|
+
});
|
|
410
|
+
} catch (err) {
|
|
411
|
+
warnings.push(`${plugin.name}: live export failed — ${err instanceof Error ? err.message : String(err)}`);
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
if (ir.resources.length === 0) continue;
|
|
415
|
+
irParts.push(ir);
|
|
416
|
+
generatorLexicon ??= plugin;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (irParts.length === 0 || !generatorLexicon) {
|
|
420
|
+
return {
|
|
421
|
+
success: false,
|
|
422
|
+
generatedFiles: [],
|
|
423
|
+
warnings,
|
|
424
|
+
error: `No resources exported from environment "${options.environment}".`,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (exporters.length > 1 && irParts.length > 1) {
|
|
429
|
+
warnings.push("Multiple lexicons exported resources; generated with the first. Use --lexicon to target one.");
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const ir = mergeIR(irParts);
|
|
433
|
+
const generator = generatorLexicon.templateGenerator!();
|
|
434
|
+
|
|
435
|
+
if (!existsSync(outputDir)) {
|
|
436
|
+
mkdirSync(outputDir, { recursive: true });
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const files = generateOrganizedFiles(ir, generator);
|
|
440
|
+
const generatedFiles: string[] = [];
|
|
441
|
+
for (const file of files) {
|
|
442
|
+
const filePath = join(outputDir, file.path);
|
|
443
|
+
const dirPath = join(outputDir, file.path.split("/").slice(0, -1).join("/"));
|
|
444
|
+
if (dirPath && !existsSync(dirPath)) {
|
|
445
|
+
mkdirSync(dirPath, { recursive: true });
|
|
446
|
+
}
|
|
447
|
+
if (existsSync(filePath) && !options.force) {
|
|
448
|
+
warnings.push(`File ${file.path} already exists, skipping`);
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
writeFileSync(filePath, file.content);
|
|
452
|
+
generatedFiles.push(file.path);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
return {
|
|
456
|
+
success: true,
|
|
457
|
+
generatedFiles,
|
|
458
|
+
warnings,
|
|
459
|
+
lexicon: generatorLexicon.name,
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
313
463
|
/**
|
|
314
464
|
* Print import result
|
|
315
465
|
*/
|
|
@@ -241,7 +241,7 @@ async function loadMigrationRules(targetLexicon: string): Promise<LintRule[]> {
|
|
|
241
241
|
* Format the migration report as Markdown, mirroring the output format
|
|
242
242
|
* prescribed by the upstream gitlab-org/ci-cd/github-actions-to-gitlab-ci
|
|
243
243
|
* skill: overview, classification, diagnostic table, aggregated manual
|
|
244
|
-
* setup steps, suggested GitLab improvements,
|
|
244
|
+
* setup steps, suggested GitLab improvements, caveats.
|
|
245
245
|
*
|
|
246
246
|
* The same data backs the SARIF report (via formatSarif); this is the
|
|
247
247
|
* human-readable surface.
|
|
@@ -310,7 +310,7 @@ function formatMarkdownSummary(
|
|
|
310
310
|
|
|
311
311
|
if (totals.error > 0 || totals.warning > 0) {
|
|
312
312
|
lines.push("");
|
|
313
|
-
lines.push("###
|
|
313
|
+
lines.push("### Caveats");
|
|
314
314
|
lines.push("");
|
|
315
315
|
lines.push(`The translation has ${totals.error} item${totals.error === 1 ? "" : "s"} needing review and ${totals.warning} approximation${totals.warning === 1 ? "" : "s"}. Review the diagnostics above before pushing the generated YAML.`);
|
|
316
316
|
}
|