@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
@@ -45,7 +45,7 @@ function sleep(ms: number): Promise<void> {
45
45
  }
46
46
 
47
47
  describe("chant operator --steward — a repeated SIGTERM (#2872)", () => {
48
- test("a second and third SIGTERM sent in quick succession still release the steward's lease", async () => {
48
+ test("repeated SIGTERMs sent in quick succession still release the steward's lease and exit 0", async () => {
49
49
  const dir = repo(
50
50
  {
51
51
  "chant.config.ts": "export default { lexicons: [] };\n",
@@ -77,16 +77,24 @@ describe("chant operator --steward — a repeated SIGTERM (#2872)", () => {
77
77
  await sleep(50);
78
78
  }
79
79
 
80
- // Three SIGTERMs, each given a moment to actually reach the child's event
81
- // loop before the next: with `process.once`, the first one's auto-removal
82
- // happens the instant it fires, so the second (or third) finds no
83
- // listener left and the process exits right there, skipping the
84
- // `finally` that releases the lease.
85
- child.kill("SIGTERM");
86
- await sleep(30);
87
- child.kill("SIGTERM");
88
- await sleep(30);
89
- child.kill("SIGTERM");
80
+ // A SIGTERM, then more every few ms until the child is gone, each given
81
+ // a moment to reach the child's event loop before the next. With
82
+ // `process.once`, the first one's auto-removal happens the instant it
83
+ // fires, so the second finds no listener left and the process exits
84
+ // right there, skipping the `finally` that releases the lease. Firing
85
+ // until the child closes (not a fixed three) also covers the stretch
86
+ // after that `finally`, up to `process.exit`: when the handler removed
87
+ // its listeners there, a SIGTERM arriving in that stretch ended the
88
+ // process by signal (exit code null) though the lease was released. With
89
+ // three signals 30ms apart that failed CI now and then (runs
90
+ // 36357297831, 36358285603, 36368551353); signalling to the end hit it
91
+ // every time.
92
+ let closed = false;
93
+ child.on("close", () => (closed = true));
94
+ for (let sent = 0; !closed && sent < 1000; sent++) {
95
+ child.kill("SIGTERM");
96
+ await sleep(5);
97
+ }
90
98
 
91
99
  const code = await exited;
92
100
  expect(code, `chant operator --steward did not exit cleanly; stderr:\n${stderr}`).toBe(0);
@@ -216,7 +216,12 @@ export async function runOperator(ctx: CommandContext): Promise<number> {
216
216
  });
217
217
  return 0;
218
218
  } finally {
219
- process.removeListener("SIGINT", onSigint);
219
+ // Only when no signal started this shutdown. After one did, the process
220
+ // exits right after this returns, and removing the listener would put
221
+ // SIGINT back to its default disposition for that last stretch: a repeat
222
+ // signal arriving there ends the process by signal instead of the exit
223
+ // code (see the steward's `finally` below).
224
+ if (!stopping) process.removeListener("SIGINT", onSigint);
220
225
  }
221
226
  }
222
227
 
@@ -347,8 +352,17 @@ export async function runStewardOperator(ctx: CommandContext): Promise<number> {
347
352
  // A courtesy: the lease expires on its own if this never runs.
348
353
  const { record } = await readLease(stewardLeaseName(steward.name)).catch(() => ({ record: undefined }));
349
354
  if (record?.holder === holder) await releaseLease(stewardLeaseName(steward.name), holder, record.token).catch(() => false);
350
- process.removeListener("SIGINT", onSigint);
351
- process.removeListener("SIGTERM", onSigint);
355
+ // Removed only when no signal started this shutdown. After one did, the
356
+ // CLI goes on to `flushAndExit` (../main.ts) and `process.exit`, and
357
+ // removing the last SIGTERM listener hands the signal back to its
358
+ // default disposition for that stretch: a repeat SIGTERM arriving there
359
+ // ended the process by signal (exit code null) with the lease already
360
+ // released. That was the operator-steward-signal e2e test's intermittent
361
+ // failure; a SIGTERM every 2ms after the first hit it 8 runs of 8.
362
+ if (!stopping) {
363
+ process.removeListener("SIGINT", onSigint);
364
+ process.removeListener("SIGTERM", onSigint);
365
+ }
352
366
  }
353
367
  }
354
368
 
package/src/cli/main.ts CHANGED
@@ -1091,7 +1091,8 @@ Options:
1091
1091
  - build: json (default) or yaml
1092
1092
  - list: text (default) or json
1093
1093
  - lint: stylish (default), json, or sarif
1094
- -d, --lexicon <name> Build only the specified lexicon (e.g. aws, gitlab)
1094
+ -d, --lexicon <name> Build only the specified lexicon (e.g. aws, gitlab);
1095
+ import: use this lexicon instead of detecting one
1095
1096
  --env <name> Active environment: sets CHANT_ENV so env-aware source
1096
1097
  re-evaluates for that environment (build + graph), and
1097
1098
  drives organizational policy. Must be in chant.config
@@ -1201,6 +1202,7 @@ Examples:
1201
1202
  chant run --components all --env production
1202
1203
  chant components export prod --component search-service -o ./dist/search-service
1203
1204
  chant import template.json --output ./infra/
1205
+ chant import manifest.yaml --lexicon k8s --output ./infra/
1204
1206
  chant import --from prod --name my-bucket --output src/
1205
1207
  chant lint ./infra/
1206
1208
  chant lint ./infra/ --format sarif
@@ -17,6 +17,10 @@ export const importTool = {
17
17
  type: "string",
18
18
  description: "Output directory (default: ./infra/)",
19
19
  },
20
+ lexicon: {
21
+ type: "string",
22
+ description: "Lexicon to import with (e.g. k8s); skips template detection",
23
+ },
20
24
  },
21
25
  required: ["source"],
22
26
  },
@@ -28,11 +32,13 @@ export const importTool = {
28
32
  export async function handleImport(params: Record<string, unknown>): Promise<unknown> {
29
33
  const source = params.source as string;
30
34
  const output = params.output as string | undefined;
35
+ const lexicon = params.lexicon as string | undefined;
31
36
 
32
37
  const result = await importCommand({
33
38
  templatePath: source,
34
39
  output,
35
40
  force: true,
41
+ lexicon,
36
42
  });
37
43
 
38
44
  if (!result.success) {
@@ -2,8 +2,9 @@ import { importLexiconModule, importLexiconPackage, lexiconModulePath, lexiconNa
2
2
  import { readLexiconDeclarationsStatically, type StaticLexiconRead } from "../config-static";
3
3
  export { unknownPathLexiconsNotice } from "../config-static";
4
4
  import { createRequire } from "node:module";
5
- import { dirname, join } from "node:path";
6
- import { existsSync, readFileSync } from "node:fs";
5
+ import { dirname, join, resolve } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { existsSync, readdirSync, readFileSync, type Dirent } from "node:fs";
7
8
  import { isLexiconPlugin, type LexiconPlugin } from "../lexicon";
8
9
  import { loadChantConfigUpward } from "../config";
9
10
  import { findInfraFiles, detectLexicons } from "../index";
@@ -80,6 +81,44 @@ export async function loadPlugin(lexiconName: string): Promise<LexiconPlugin> {
80
81
  throw new Error(`Package ${packageName} does not export a LexiconPlugin or Serializer`);
81
82
  }
82
83
 
84
+ const LEXICON_PACKAGE_PREFIX = "chant-lexicon-";
85
+
86
+ /**
87
+ * The names of the lexicon packages installed where {@link loadPlugin} can
88
+ * load them (#2965), sorted. `loadPlugin` imports
89
+ * `@intentius/chant-lexicon-<name>` from chant's own install and then from the
90
+ * project (`importLexiconPackage`), so this lists the `@intentius` scope of
91
+ * every `node_modules` directory Node would search from either place: each
92
+ * ancestor of chant's own module and each ancestor of `projectDir`. A name
93
+ * being listed does not promise it loads; the caller still handles a failure.
94
+ */
95
+ export function listInstalledLexicons(projectDir: string = process.cwd()): string[] {
96
+ const names = new Set<string>();
97
+ const starts = [dirname(fileURLToPath(import.meta.url)), resolve(projectDir)];
98
+ for (const start of starts) {
99
+ let dir = start;
100
+ for (;;) {
101
+ const scope = join(dir, "node_modules", "@intentius");
102
+ let entries: Dirent[] = [];
103
+ try {
104
+ entries = readdirSync(scope, { withFileTypes: true });
105
+ } catch {
106
+ // No @intentius scope here.
107
+ }
108
+ for (const entry of entries) {
109
+ if (!entry.name.startsWith(LEXICON_PACKAGE_PREFIX)) continue;
110
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
111
+ const name = entry.name.slice(LEXICON_PACKAGE_PREFIX.length);
112
+ if (name !== "") names.add(name);
113
+ }
114
+ const parent = dirname(dir);
115
+ if (parent === dir) break;
116
+ dir = parent;
117
+ }
118
+ }
119
+ return [...names].sort();
120
+ }
121
+
83
122
  /**
84
123
  * The installed version of each named lexicon package (chant #1442).
85
124
  *
@@ -309,6 +309,27 @@ describe("deepPathSet", () => {
309
309
  const set = deepPathSet({ Tags: [{ Key: "a" }] });
310
310
  expect([...set].sort()).toEqual(["Tags", "Tags[0]", "Tags[0].Key", "Tags[]", "Tags[].Key"]);
311
311
  });
312
+
313
+ // #2946: the path set has to agree with the normalized tree, which inlines
314
+ // a property-kind declarable's props (#1314). Otherwise a live node under
315
+ // one reads `counterpart: "absent"` and is pruned as an undeclared default.
316
+ test("walks a property-kind declarable as its props, as normalization inlines it", () => {
317
+ class Panel {
318
+ readonly entityType = "Grafana::Panel::stat";
319
+ readonly kind = "property";
320
+ constructor(readonly props: Record<string, unknown>) {}
321
+ }
322
+ const set = deepPathSet({ panels: [new Panel({ gridPos: { w: 6 } })] });
323
+ expect(set.has("panels[0].gridPos.w")).toBe(true);
324
+ expect(set.has("panels[].gridPos.w")).toBe(true);
325
+
326
+ const hooks = { prune: (n: { side: string; counterpart: string }) => n.side === "live" && n.counterpart === "absent" };
327
+ const live = normalizeDeepProperties(
328
+ { panels: [{ gridPos: { w: 6, x: 0 } }] },
329
+ { entityType: "T", side: "live", hooks, counterpartPaths: set },
330
+ );
331
+ expect(live).toEqual({ panels: [{ gridPos: { w: 6 } }] });
332
+ });
312
333
  });
313
334
 
314
335
  describe("deepValueEqual", () => {
@@ -363,6 +363,15 @@ export function deepPathSet(tree: Record<string, unknown>): Set<string> {
363
363
  // gives it, so this path set agrees with what the normalized tree
364
364
  // actually looks like a path down.
365
365
  if (isHeldElsewhere(value)) return;
366
+ // A property-kind declarable is walked as its props, exactly as
367
+ // `normalizeDeepProperties` inlines it (#1314). Stopping at it left every
368
+ // path under a typed nested property out of the set, so the other tree's
369
+ // nodes there read `counterpart: "absent"` and default subtraction fired
370
+ // on properties the author did declare.
371
+ if (isPropertyDeclarableValue(value)) {
372
+ walk((value as { props?: unknown }).props ?? {}, path, pattern);
373
+ return;
374
+ }
366
375
  if (Array.isArray(value)) {
367
376
  value.forEach((el, i) => walk(el, joinIndex(path, i), joinPattern(pattern)));
368
377
  return;
@@ -0,0 +1,153 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import {
3
+ EmbeddedImports,
4
+ EmbeddedImportScope,
5
+ embeddedDocument,
6
+ exportedNames,
7
+ isEmbeddedReference,
8
+ renderEmbeddedReference,
9
+ type EmbeddedContent,
10
+ type EmbeddedContentImporter,
11
+ } from "./embedded";
12
+
13
+ const site = (over: Partial<EmbeddedContent> = {}): EmbeddedContent => ({
14
+ host: "k8s",
15
+ hostType: "K8s::Core::ConfigMap",
16
+ location: 'ConfigMap agent data["config.yaml"]',
17
+ directory: "agent",
18
+ text: "kind: widget\nsize: 3\n",
19
+ ...over,
20
+ });
21
+
22
+ /** Imports any `kind: widget` document as `widgets.ts`, passed through `render`. */
23
+ const widgets: EmbeddedContentImporter = {
24
+ what: "a widget",
25
+ matches: (c) => (c.document as { kind?: string } | undefined)?.kind === "widget",
26
+ import: () => ({
27
+ files: [{ path: "widgets.ts", content: "const w = 1;\n\nexport { w };\n" }],
28
+ value: { bindings: [{ from: "widgets.ts", name: "w" }], shape: "list", through: { from: "@acme/widgets", name: "render" } },
29
+ warnings: ["dropped the colour"],
30
+ }),
31
+ };
32
+
33
+ describe("EmbeddedImports", () => {
34
+ test("an importer that matches gets the content, with its document parsed from the text", () => {
35
+ const seen: EmbeddedContent[] = [];
36
+ const resolver = new EmbeddedImports([{ lexicon: "acme", importer: { ...widgets, import: (c) => (seen.push(c), widgets.import(c)) } }]);
37
+ const ref = resolver.resolve(site());
38
+ expect(seen[0].document).toEqual({ kind: "widget", size: 3 });
39
+ expect(isEmbeddedReference(ref)).toBe(true);
40
+ // Local modules are rebased under the content's directory; packages stay as they are.
41
+ expect(ref!.$embedded).toEqual({
42
+ lexicon: "acme",
43
+ what: "a widget",
44
+ location: 'ConfigMap agent data["config.yaml"]',
45
+ value: { bindings: [{ from: "agent/widgets.ts", name: "w" }], shape: "list", through: { from: "@acme/widgets", name: "render" } },
46
+ });
47
+ expect(resolver.files).toEqual([{ path: "agent/widgets.ts", content: "const w = 1;\n\nexport { w };\n" }]);
48
+ expect(resolver.warnings).toEqual(['ConfigMap agent data["config.yaml"]: dropped the colour']);
49
+ expect(resolver.offered).toHaveLength(1);
50
+ });
51
+
52
+ test("two pieces of content wanting one directory get two", () => {
53
+ const resolver = new EmbeddedImports([{ lexicon: "acme", importer: widgets }]);
54
+ resolver.resolve(site({ directory: "Agent Config" }));
55
+ resolver.resolve(site({ directory: "agent-config" }));
56
+ expect(resolver.files.map((f) => f.path)).toEqual(["agent-config/widgets.ts", "agent-config-2/widgets.ts"]);
57
+ });
58
+
59
+ test("content nobody claims is kept, with a warning only when the host names its expected owner", () => {
60
+ const resolver = new EmbeddedImports([{ lexicon: "acme", importer: widgets }]);
61
+ expect(resolver.resolve(site({ text: "kind: gadget\n" }))).toBeUndefined();
62
+ expect(resolver.warnings).toEqual([]);
63
+ expect(resolver.resolve(site({ text: "kind: gadget\n", expectedOwner: { lexicon: "gadgets", what: "a gadget" } }))).toBeUndefined();
64
+ expect(resolver.warnings).toHaveLength(1);
65
+ expect(resolver.warnings[0]).toContain("looks like a gadget");
66
+ expect(resolver.warnings[0]).toContain("@intentius/chant-lexicon-gadgets");
67
+ });
68
+
69
+ test("a quiet probe collects what is offered and warns about nothing", () => {
70
+ const probe = new EmbeddedImports([], { quiet: true });
71
+ expect(probe.resolve(site({ expectedOwner: { lexicon: "gadgets", what: "a gadget" } }))).toBeUndefined();
72
+ expect(probe.offered).toHaveLength(1);
73
+ expect(probe.warnings).toEqual([]);
74
+ });
75
+
76
+ test("a failing import keeps the content and says why; a throwing matcher is a non-match", () => {
77
+ const resolver = new EmbeddedImports([
78
+ { lexicon: "broken", importer: { what: "a widget", matches: () => { throw new Error("no"); }, import: widgets.import } },
79
+ { lexicon: "acme", importer: { ...widgets, import: () => { throw new Error("bad size"); } } },
80
+ ]);
81
+ expect(resolver.resolve(site())).toBeUndefined();
82
+ expect(resolver.files).toEqual([]);
83
+ expect(resolver.warnings).toEqual([
84
+ 'ConfigMap agent data["config.yaml"] looks like a widget, but the acme import failed, so it is kept as written: bad size',
85
+ ]);
86
+ });
87
+
88
+ test("when two lexicons match, the first imports it and the other is named", () => {
89
+ const resolver = new EmbeddedImports([
90
+ { lexicon: "acme", importer: widgets },
91
+ { lexicon: "other", importer: widgets },
92
+ ]);
93
+ expect(resolver.resolve(site())!.$embedded.lexicon).toBe("acme");
94
+ expect(resolver.warnings[0]).toContain("also importable by other");
95
+ });
96
+ });
97
+
98
+ describe("renderEmbeddedReference", () => {
99
+ const ref = (bindings: Array<{ from: string; name: string; member?: string }>, shape: "list" | "single", through?: { from: string; name: string }) => ({
100
+ $embedded: { lexicon: "acme", what: "a widget", location: "here", value: { bindings, shape, ...(through ? { through } : {}) } },
101
+ });
102
+
103
+ test("imports each name once, relative to the module, and aliases a name already taken", () => {
104
+ const scope = new EmbeddedImportScope("", ["otlp"]);
105
+ const a = renderEmbeddedReference(
106
+ ref([{ from: "agent/receivers.ts", name: "otlp" }, { from: "agent/pipelines.ts", name: "traces" }], "list", { from: "@acme/otel", name: "collectorYaml" }),
107
+ scope,
108
+ );
109
+ const b = renderEmbeddedReference(ref([{ from: "gateway/receivers.ts", name: "otlp" }], "list"), scope);
110
+ expect(a).toBe("collectorYaml([otlp2, traces])");
111
+ expect(b).toBe("[otlp3]");
112
+ expect(scope.lines()).toEqual([
113
+ 'import { collectorYaml } from "@acme/otel";',
114
+ 'import { traces } from "./agent/pipelines";',
115
+ 'import { otlp as otlp2 } from "./agent/receivers";',
116
+ 'import { otlp as otlp3 } from "./gateway/receivers";',
117
+ ]);
118
+ });
119
+
120
+ test("a single binding, a member, and a module in a subdirectory", () => {
121
+ const scope = new EmbeddedImportScope("infra");
122
+ expect(renderEmbeddedReference(ref([{ from: "rules/slos.ts", name: "api", member: "rules" }], "single"), scope)).toBe("api.rules");
123
+ expect(scope.lines()).toEqual(['import { api } from "../rules/slos";']);
124
+ });
125
+
126
+ test("a long list is broken one binding per line", () => {
127
+ const names = Array.from({ length: 12 }, (_, i) => ({ from: "c/x.ts", name: `component${i}` }));
128
+ const out = renderEmbeddedReference(ref(names, "list"), new EmbeddedImportScope(""), 4);
129
+ expect(out.split("\n")).toHaveLength(14);
130
+ expect(out.split("\n")[1]).toBe(" component0,");
131
+ expect(out.endsWith("\n ]")).toBe(true);
132
+ });
133
+ });
134
+
135
+ describe("helpers", () => {
136
+ test("embeddedDocument reads JSON or one YAML document", () => {
137
+ expect(embeddedDocument('{"a": 1}')).toEqual({ a: 1 });
138
+ expect(embeddedDocument("a: 1\n")).toEqual({ a: 1 });
139
+ expect(embeddedDocument("a: 1\n---\nb: 2\n")).toBeUndefined();
140
+ });
141
+
142
+ test("exportedNames reads one-line and multi-line export lists", () => {
143
+ expect(exportedNames("const a = 1;\n\nexport { a, b };\n")).toEqual(["a", "b"]);
144
+ expect(exportedNames("export {\n one,\n two as three,\n};\n")).toEqual(["one", "three"]);
145
+ expect(exportedNames("export const x = 1;\n")).toEqual([]);
146
+ });
147
+
148
+ test("isEmbeddedReference", () => {
149
+ expect(isEmbeddedReference({ $embedded: { value: { bindings: [] } } })).toBe(true);
150
+ expect(isEmbeddedReference({ $embedded: true })).toBe(false);
151
+ expect(isEmbeddedReference("x")).toBe(false);
152
+ });
153
+ });