@intentius/chant 0.105.1 → 0.106.1

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 (93) hide show
  1. package/dist/cli/commands/lint.d.ts.map +1 -1
  2. package/dist/cli/handlers/build.d.ts.map +1 -1
  3. package/dist/cli/handlers/fan-out.d.ts.map +1 -1
  4. package/dist/cli/handlers/operator.d.ts.map +1 -1
  5. package/dist/cli/handlers/pr.d.ts +16 -2
  6. package/dist/cli/handlers/pr.d.ts.map +1 -1
  7. package/dist/cli/handlers/promote.d.ts.map +1 -1
  8. package/dist/cli/handlers/run.d.ts.map +1 -1
  9. package/dist/cli/main.d.ts.map +1 -1
  10. package/dist/cli/registry.d.ts +7 -0
  11. package/dist/cli/registry.d.ts.map +1 -1
  12. package/dist/components/changed-files.d.ts +8 -2
  13. package/dist/components/changed-files.d.ts.map +1 -1
  14. package/dist/components/fan-out-record.d.ts +24 -0
  15. package/dist/components/fan-out-record.d.ts.map +1 -1
  16. package/dist/components/pr-pipeline.d.ts +60 -4
  17. package/dist/components/pr-pipeline.d.ts.map +1 -1
  18. package/dist/components/pr-run.d.ts +92 -3
  19. package/dist/components/pr-run.d.ts.map +1 -1
  20. package/dist/gated-waves.d.ts +4 -1
  21. package/dist/gated-waves.d.ts.map +1 -1
  22. package/dist/lexicon.d.ts +13 -2
  23. package/dist/lexicon.d.ts.map +1 -1
  24. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  25. package/dist/op/gate-name.d.ts +7 -0
  26. package/dist/op/gate-name.d.ts.map +1 -1
  27. package/dist/op/gate-summary.d.ts +2 -0
  28. package/dist/op/gate-summary.d.ts.map +1 -1
  29. package/dist/op/gate.d.ts +10 -2
  30. package/dist/op/gate.d.ts.map +1 -1
  31. package/dist/op/index.d.ts +2 -2
  32. package/dist/op/index.d.ts.map +1 -1
  33. package/dist/op/local-executor.d.ts.map +1 -1
  34. package/dist/op/local-output.d.ts +2 -2
  35. package/dist/op/local-output.d.ts.map +1 -1
  36. package/dist/op/runtime.d.ts +19 -2
  37. package/dist/op/runtime.d.ts.map +1 -1
  38. package/dist/op/runtimes/local.d.ts.map +1 -1
  39. package/dist/pr-loop.d.ts +29 -4
  40. package/dist/pr-loop.d.ts.map +1 -1
  41. package/dist/workspace/lineage-upgrade-cli.d.ts.map +1 -1
  42. package/dist/workspace/member-pipeline.d.ts +14 -1
  43. package/dist/workspace/member-pipeline.d.ts.map +1 -1
  44. package/package.json +1 -1
  45. package/src/__fixtures__/change-set/warden.change-set.golden.json +2 -2
  46. package/src/__fixtures__/change-set/warden.plan.json +47 -40
  47. package/src/__fixtures__/plan-summary/normalization-vectors.json +275 -2
  48. package/src/change-set.test.ts +7 -8
  49. package/src/cli/commands/lint.ts +34 -1
  50. package/src/cli/handlers/build-lexicon-output.test.ts +94 -0
  51. package/src/cli/handlers/build.ts +65 -0
  52. package/src/cli/handlers/fan-out.test.ts +12 -1
  53. package/src/cli/handlers/fan-out.ts +7 -3
  54. package/src/cli/handlers/operator.ts +1 -2
  55. package/src/cli/handlers/pr.ts +220 -35
  56. package/src/cli/handlers/promote.test.ts +15 -1
  57. package/src/cli/handlers/promote.ts +5 -3
  58. package/src/cli/handlers/run.test.ts +164 -1
  59. package/src/cli/handlers/run.ts +22 -11
  60. package/src/cli/main.ts +13 -1
  61. package/src/cli/mcp/never-over-mcp.test.ts +74 -0
  62. package/src/cli/registry.ts +7 -0
  63. package/src/components/changed-files.test.ts +58 -0
  64. package/src/components/changed-files.ts +19 -4
  65. package/src/components/fan-out-record.test.ts +47 -2
  66. package/src/components/fan-out-record.ts +53 -0
  67. package/src/components/pr-pipeline.ts +89 -6
  68. package/src/components/pr-run.test.ts +300 -3
  69. package/src/components/pr-run.ts +196 -5
  70. package/src/gated-waves.test.ts +11 -0
  71. package/src/gated-waves.ts +7 -2
  72. package/src/lexicon.ts +13 -1
  73. package/src/lifecycle/gate-ledger.ts +4 -3
  74. package/src/op/gate-name.ts +17 -0
  75. package/src/op/gate-summary.test.ts +12 -0
  76. package/src/op/gate-summary.ts +3 -1
  77. package/src/op/gate.test.ts +15 -1
  78. package/src/op/gate.ts +21 -4
  79. package/src/op/index.ts +2 -2
  80. package/src/op/local-executor.test.ts +35 -0
  81. package/src/op/local-executor.ts +2 -2
  82. package/src/op/local-output.test.ts +20 -0
  83. package/src/op/local-output.ts +4 -4
  84. package/src/op/runtime.ts +20 -2
  85. package/src/op/runtimes/local.test.ts +15 -0
  86. package/src/op/runtimes/local.ts +3 -1
  87. package/src/pr-loop.test.ts +41 -1
  88. package/src/pr-loop.ts +38 -8
  89. package/src/workspace/lineage-upgrade-cli.ts +4 -3
  90. package/src/workspace/lineage-upgrade.test.ts +40 -0
  91. package/src/workspace/member-pipeline.test.ts +28 -0
  92. package/src/workspace/member-pipeline.ts +20 -3
  93. package/src/workspace/pr-report.schema.json +14 -1
@@ -1,44 +1,51 @@
1
- {
2
- "org": "acme",
3
- "entries": [
4
- {
5
- "kind": "update",
6
- "resourceType": "team",
7
- "key": "platform",
8
- "before": {
9
- "privacy": "secret",
10
- "description": "Platform team"
1
+ [
2
+ {
3
+ "org": "acme",
4
+ "entries": [
5
+ {
6
+ "kind": "delete",
7
+ "resourceType": "team",
8
+ "key": "contractors",
9
+ "before": {
10
+ "privacy": "closed",
11
+ "description": "Old contractors"
12
+ },
13
+ "verb": "membership"
11
14
  },
12
- "after": {
13
- "privacy": "closed",
14
- "description": "Platform team"
15
+ {
16
+ "kind": "update",
17
+ "resourceType": "team",
18
+ "key": "platform",
19
+ "before": {
20
+ "privacy": "secret",
21
+ "description": "Platform team"
22
+ },
23
+ "after": {
24
+ "privacy": "closed",
25
+ "description": "Platform team"
26
+ },
27
+ "fields": [
28
+ {
29
+ "field": "privacy",
30
+ "before": "secret",
31
+ "after": "closed"
32
+ }
33
+ ],
34
+ "verb": "membership"
15
35
  },
16
- "fields": [
17
- {
18
- "field": "privacy",
19
- "before": "secret",
20
- "after": "closed"
21
- }
22
- ]
23
- },
24
- {
25
- "kind": "create",
26
- "resourceType": "team",
27
- "key": "release",
28
- "after": {
29
- "privacy": "secret",
30
- "description": "Release managers"
31
- }
32
- },
33
- {
34
- "kind": "delete",
35
- "resourceType": "team",
36
- "key": "contractors",
37
- "before": {
38
- "privacy": "closed",
39
- "description": "Old contractors"
36
+ {
37
+ "kind": "create",
38
+ "resourceType": "team",
39
+ "key": "release",
40
+ "after": {
41
+ "privacy": "secret",
42
+ "description": "Release managers"
43
+ },
44
+ "verb": "membership"
40
45
  }
46
+ ],
47
+ "managedCounts": {
48
+ "team": 2
41
49
  }
42
- ],
43
- "managedCount": 2
44
- }
50
+ }
51
+ ]
@@ -5,7 +5,8 @@
5
5
  "Each repo keeps its own grouping code and runs every row in CI.",
6
6
  "rootPairs: a and b are two roots of a choudoufu live-plan-set -json document; summarize a set holding exactly the two, and they must land in one group iff group is true.",
7
7
  "instancePairs: a and b are two resource_changes of one terraform show -json plan; summarize that plan, and the two instances must land in one group iff group is true.",
8
- "Red first: a naive grouper (equal raw resource_changes) must fail every rootPairs row with group true, and a grouper that blanks every string value in before and after must falsely group exactly the rootPairs rows named in overEagerFalselyGroups."
8
+ "Red first: a naive grouper (equal raw resource_changes) must fail every rootPairs row with group true, and a grouper that blanks every string value in before and after must falsely group exactly the rootPairs rows named in overEagerFalselyGroups.",
9
+ "sensitive attribute: chant's change-set carries no value for an attribute marked in before_sensitive or after_sensitive, so two sensitive attributes group by path alone, and a sensitive attribute never groups with an unmarked one. read entry: a data source read carries no attributes, so reads of one address group whatever they returned."
9
10
  ],
10
11
  "rootPairs": [
11
12
  {
@@ -1379,6 +1380,277 @@
1379
1380
  ]
1380
1381
  }
1381
1382
  }
1383
+ },
1384
+ {
1385
+ "name": "sensitive attribute with different values",
1386
+ "group": true,
1387
+ "a": {
1388
+ "root": "roots/acme",
1389
+ "estate": "acme",
1390
+ "status": "planned",
1391
+ "plan": {
1392
+ "format_version": "1.2",
1393
+ "resource_changes": [
1394
+ {
1395
+ "address": "aws_db_instance.main",
1396
+ "change": {
1397
+ "actions": [
1398
+ "update"
1399
+ ],
1400
+ "before": {
1401
+ "password": "old-a",
1402
+ "instance_class": "db.t3.small"
1403
+ },
1404
+ "after": {
1405
+ "password": "new-a",
1406
+ "instance_class": "db.t3.small"
1407
+ },
1408
+ "before_sensitive": {
1409
+ "password": true
1410
+ },
1411
+ "after_sensitive": {
1412
+ "password": true
1413
+ }
1414
+ }
1415
+ }
1416
+ ]
1417
+ }
1418
+ },
1419
+ "b": {
1420
+ "root": "roots/globex",
1421
+ "estate": "globex",
1422
+ "status": "planned",
1423
+ "plan": {
1424
+ "format_version": "1.2",
1425
+ "resource_changes": [
1426
+ {
1427
+ "address": "aws_db_instance.main",
1428
+ "change": {
1429
+ "actions": [
1430
+ "update"
1431
+ ],
1432
+ "before": {
1433
+ "password": "old-b",
1434
+ "instance_class": "db.t3.small"
1435
+ },
1436
+ "after": {
1437
+ "password": "new-b",
1438
+ "instance_class": "db.t3.small"
1439
+ },
1440
+ "before_sensitive": {
1441
+ "password": true
1442
+ },
1443
+ "after_sensitive": {
1444
+ "password": true
1445
+ }
1446
+ }
1447
+ }
1448
+ ]
1449
+ }
1450
+ }
1451
+ },
1452
+ {
1453
+ "name": "sensitive against not sensitive on the same attribute",
1454
+ "group": false,
1455
+ "a": {
1456
+ "root": "roots/acme",
1457
+ "estate": "acme",
1458
+ "status": "planned",
1459
+ "plan": {
1460
+ "format_version": "1.2",
1461
+ "resource_changes": [
1462
+ {
1463
+ "address": "aws_db_instance.main",
1464
+ "change": {
1465
+ "actions": [
1466
+ "update"
1467
+ ],
1468
+ "before": {
1469
+ "password": "same-old",
1470
+ "instance_class": "db.t3.small"
1471
+ },
1472
+ "after": {
1473
+ "password": "same-new",
1474
+ "instance_class": "db.t3.small"
1475
+ },
1476
+ "before_sensitive": {
1477
+ "password": true
1478
+ },
1479
+ "after_sensitive": {
1480
+ "password": true
1481
+ }
1482
+ }
1483
+ }
1484
+ ]
1485
+ }
1486
+ },
1487
+ "b": {
1488
+ "root": "roots/globex",
1489
+ "estate": "globex",
1490
+ "status": "planned",
1491
+ "plan": {
1492
+ "format_version": "1.2",
1493
+ "resource_changes": [
1494
+ {
1495
+ "address": "aws_db_instance.main",
1496
+ "change": {
1497
+ "actions": [
1498
+ "update"
1499
+ ],
1500
+ "before": {
1501
+ "password": "same-old",
1502
+ "instance_class": "db.t3.small"
1503
+ },
1504
+ "after": {
1505
+ "password": "same-new",
1506
+ "instance_class": "db.t3.small"
1507
+ }
1508
+ }
1509
+ }
1510
+ ]
1511
+ }
1512
+ }
1513
+ },
1514
+ {
1515
+ "name": "read entries whose results differ",
1516
+ "group": true,
1517
+ "a": {
1518
+ "root": "roots/acme",
1519
+ "estate": "acme",
1520
+ "status": "planned",
1521
+ "plan": {
1522
+ "format_version": "1.2",
1523
+ "resource_changes": [
1524
+ {
1525
+ "address": "data.aws_ami.base",
1526
+ "change": {
1527
+ "actions": [
1528
+ "read"
1529
+ ],
1530
+ "after": {
1531
+ "id": "ami-0aaa111",
1532
+ "image_id": "ami-0aaa111",
1533
+ "owner": "amazon"
1534
+ },
1535
+ "after_unknown": {}
1536
+ }
1537
+ }
1538
+ ]
1539
+ }
1540
+ },
1541
+ "b": {
1542
+ "root": "roots/globex",
1543
+ "estate": "globex",
1544
+ "status": "planned",
1545
+ "plan": {
1546
+ "format_version": "1.2",
1547
+ "resource_changes": [
1548
+ {
1549
+ "address": "data.aws_ami.base",
1550
+ "change": {
1551
+ "actions": [
1552
+ "read"
1553
+ ],
1554
+ "after": {
1555
+ "id": "ami-0bbb222",
1556
+ "image_id": "ami-0bbb222",
1557
+ "owner": "amazon"
1558
+ },
1559
+ "after_unknown": {}
1560
+ }
1561
+ }
1562
+ ]
1563
+ }
1564
+ }
1565
+ },
1566
+ {
1567
+ "name": "a read of a different data source",
1568
+ "group": false,
1569
+ "a": {
1570
+ "root": "roots/acme",
1571
+ "estate": "acme",
1572
+ "status": "planned",
1573
+ "plan": {
1574
+ "format_version": "1.2",
1575
+ "resource_changes": [
1576
+ {
1577
+ "address": "data.aws_ami.base",
1578
+ "change": {
1579
+ "actions": [
1580
+ "read"
1581
+ ],
1582
+ "after": {
1583
+ "id": "ami-0aaa111",
1584
+ "image_id": "ami-0aaa111",
1585
+ "owner": "amazon"
1586
+ },
1587
+ "after_unknown": {}
1588
+ }
1589
+ }
1590
+ ]
1591
+ }
1592
+ },
1593
+ "b": {
1594
+ "root": "roots/globex",
1595
+ "estate": "globex",
1596
+ "status": "planned",
1597
+ "plan": {
1598
+ "format_version": "1.2",
1599
+ "resource_changes": [
1600
+ {
1601
+ "address": "data.aws_ami.other",
1602
+ "change": {
1603
+ "actions": [
1604
+ "read"
1605
+ ],
1606
+ "after": {
1607
+ "id": "ami-0aaa111",
1608
+ "image_id": "ami-0aaa111",
1609
+ "owner": "amazon"
1610
+ },
1611
+ "after_unknown": {}
1612
+ }
1613
+ }
1614
+ ]
1615
+ }
1616
+ }
1617
+ },
1618
+ {
1619
+ "name": "a read against a plan with no entry for it",
1620
+ "group": false,
1621
+ "a": {
1622
+ "root": "roots/acme",
1623
+ "estate": "acme",
1624
+ "status": "planned",
1625
+ "plan": {
1626
+ "format_version": "1.2",
1627
+ "resource_changes": [
1628
+ {
1629
+ "address": "data.aws_ami.base",
1630
+ "change": {
1631
+ "actions": [
1632
+ "read"
1633
+ ],
1634
+ "after": {
1635
+ "id": "ami-0aaa111",
1636
+ "image_id": "ami-0aaa111",
1637
+ "owner": "amazon"
1638
+ },
1639
+ "after_unknown": {}
1640
+ }
1641
+ }
1642
+ ]
1643
+ }
1644
+ },
1645
+ "b": {
1646
+ "root": "roots/globex",
1647
+ "estate": "globex",
1648
+ "status": "planned",
1649
+ "plan": {
1650
+ "format_version": "1.2",
1651
+ "resource_changes": []
1652
+ }
1653
+ }
1382
1654
  }
1383
1655
  ],
1384
1656
  "instancePairs": [
@@ -1588,6 +1860,7 @@
1588
1860
  "different policy document",
1589
1861
  "different tag value that does not carry the estate",
1590
1862
  "a tofu-estate marker naming some other estate",
1591
- "the estate name inside a longer word is not the estate"
1863
+ "the estate name inside a longer word is not the estate",
1864
+ "sensitive against not sensitive on the same attribute"
1592
1865
  ]
1593
1866
  }
@@ -10,10 +10,11 @@
10
10
  * update. The update, delete, effect and hole mappings are pinned on typed
11
11
  * entries below.
12
12
  *
13
- * `warden.plan.json` is the reconcile change set chant's own
14
- * `diffCollection` produces for a warden's teams cycle: one create, one
15
- * update and one owned delete. github-warden prints its plan as text today,
16
- * so this is the shape it would write.
13
+ * `warden.plan.json` is what github-warden's `reconcile --plan-json` wrote
14
+ * (github-warden#66) for a teams cycle in an org that declares `owned: true`:
15
+ * one create, one update and one owned delete, as a JSON array of reconcile
16
+ * change sets. It was produced by warden's own `runReconcile` and
17
+ * `teamsCycle` against a stubbed live state, not built here.
17
18
  *
18
19
  * Goldens regenerate with `UPDATE_GOLDEN=1`.
19
20
  */
@@ -140,10 +141,8 @@ describe("lifecyclePlanPart", () => {
140
141
  });
141
142
 
142
143
  describe("reconcilePlanPart", () => {
143
- test("a warden's teams cycle, as chant's reconcile primitives plan it", () => {
144
- const plan = wardenPlan();
145
- golden("warden.plan.json", plan);
146
- const part = reconcilePlanPart({ member: "warden", plan: read(DIR, "warden.plan.json") as ReconcileChangeSet });
144
+ test("a warden's teams cycle, as `reconcile --plan-json` wrote it", () => {
145
+ const part = reconcilePlanPart({ member: "warden", plan: read(DIR, "warden.plan.json") as ReconcileChangeSet[] });
147
146
  expect(part.entries.map((e) => [e.address, e.action, e.disruption ?? null])).toEqual([
148
147
  ["team.contractors", "delete", "destroy"],
149
148
  ["team.platform", "update", "unknown"],
@@ -4,7 +4,8 @@ import { readFileSync, writeFileSync, readdirSync } from "fs";
4
4
  import { runLint, parseDisableComments } from "../../lint/engine";
5
5
  import type { LintRule, LintDiagnostic, LintFix } from "../../lint/rule";
6
6
  import type { IntrinsicDef, LexiconPlugin } from "../../lexicon";
7
- import { loadPlugins, resolveProjectLexicons } from "../plugins";
7
+ import { loadPlugin, loadPlugins, resolveProjectLexicons } from "../plugins";
8
+ import { findInfraFiles, detectLexicons } from "../../index";
8
9
  import { formatStylish, formatJson, formatSarif } from "../reporters/stylish";
9
10
  import { loadLocalRules } from "../../lint/rule-loader";
10
11
  import { loadCoreRules } from "../../lint/rules/index";
@@ -129,6 +130,29 @@ function lexiconResolutionDiagnostic(projectRoot: string, error: Error): LintDia
129
130
  };
130
131
  }
131
132
 
133
+ /**
134
+ * The plugins of lexicons that the project's source imports from and that are not in `configured`, for their
135
+ * property-kind class names (#3184). A package that is not installed, or that fails to load, is skipped.
136
+ */
137
+ async function loadImportedPropertyPlugins(projectPath: string, configured: readonly string[]): Promise<LexiconPlugin[]> {
138
+ let imported: string[];
139
+ try {
140
+ imported = await detectLexicons(await findInfraFiles(projectPath));
141
+ } catch {
142
+ return [];
143
+ }
144
+ const plugins: LexiconPlugin[] = [];
145
+ for (const name of imported) {
146
+ if (configured.includes(name)) continue;
147
+ try {
148
+ plugins.push(await loadPlugin(name));
149
+ } catch {
150
+ // Not installed here; the import is the build's problem to report, not lint's.
151
+ }
152
+ }
153
+ return plugins;
154
+ }
155
+
132
156
  /**
133
157
  * Load all lint rules: core COR/EVL rules, then lexicon plugin rules.
134
158
  *
@@ -210,6 +234,15 @@ async function loadAllPluginRules(
210
234
  // them as resources.
211
235
  const propertyClasses = new Set(plugins.flatMap((plugin) => plugin.propertyClassNames?.() ?? []));
212
236
 
237
+ // chant #3184: a lexicon the source imports from but the config does not list (grafana in a k8s project that
238
+ // holds a dashboard ConfigMap, after `chant import`) still declares which of its classes are property-kind. Only
239
+ // those names are taken from it; its rules and intrinsics stay out, since the project did not ask for them.
240
+ if (!lexiconError) {
241
+ for (const plugin of await loadImportedPropertyPlugins(projectPath, lexiconNames)) {
242
+ for (const name of plugin.propertyClassNames?.() ?? []) propertyClasses.add(name);
243
+ }
244
+ }
245
+
213
246
  for (const plugin of plugins) {
214
247
  if (plugin.lintRules) {
215
248
  for (const r of plugin.lintRules()) {
@@ -0,0 +1,94 @@
1
+ /**
2
+ * `chant build --lexicon-output <lexicon>=<path>`: each named lexicon is built
3
+ * into its own file, so one project can declare otel and prometheus. The build
4
+ * itself is mocked; this checks how the handler splits the serializers and
5
+ * the output paths.
6
+ */
7
+ import { describe, test, expect, vi, beforeEach } from "vitest";
8
+ import type { ParsedArgs, CommandContext } from "../registry";
9
+ import type { Serializer } from "../../serializer";
10
+
11
+ const buildCommandMock = vi.fn();
12
+
13
+ vi.mock("../commands/build", async () => {
14
+ const actual = await vi.importActual<typeof import("../commands/build")>("../commands/build");
15
+ return { ...actual, buildCommand: (...args: unknown[]) => buildCommandMock(...args) };
16
+ });
17
+
18
+ const { runBuild } = await import("./build");
19
+
20
+ const ser = (name: string) => ({ name }) as unknown as Serializer;
21
+
22
+ function ctx(overrides: Partial<ParsedArgs>, names = ["otel", "prometheus", "grafana"]): CommandContext {
23
+ const args = {
24
+ command: "build",
25
+ path: ".",
26
+ format: "",
27
+ fix: false,
28
+ watch: false,
29
+ verbose: false,
30
+ help: false,
31
+ live: false,
32
+ ...overrides,
33
+ } as ParsedArgs;
34
+ return { args, plugins: [], serializers: names.map(ser) } as unknown as CommandContext;
35
+ }
36
+
37
+ describe("runBuild --lexicon-output", () => {
38
+ beforeEach(() => {
39
+ buildCommandMock.mockReset().mockResolvedValue({ success: true, errors: [], warnings: [], resourceCount: 1, fileCount: 1 });
40
+ vi.spyOn(console, "error").mockImplementation(() => {});
41
+ });
42
+
43
+ test("builds each named lexicon into its own file", async () => {
44
+ const code = await runBuild(ctx({ lexiconOutput: ["otel=out/collector.yml", "prometheus=out/prometheus.yml"] }));
45
+ expect(code).toBe(0);
46
+ expect(buildCommandMock).toHaveBeenCalledTimes(2);
47
+ const calls = buildCommandMock.mock.calls.map(([o]) => [o.serializers.map((s: Serializer) => s.name), o.output, o.format]);
48
+ expect(calls).toEqual([
49
+ [["otel"], "out/collector.yml", "yaml"],
50
+ [["prometheus"], "out/prometheus.yml", "yaml"],
51
+ ]);
52
+ });
53
+
54
+ test("lexicons not named go to --output when given, and are skipped otherwise", async () => {
55
+ await runBuild(ctx({ lexiconOutput: ["otel=a.yml"], output: "rest.json" }));
56
+ expect(buildCommandMock.mock.calls.map(([o]) => [o.serializers.map((s: Serializer) => s.name), o.output])).toEqual([
57
+ [["otel"], "a.yml"],
58
+ [["prometheus", "grafana"], "rest.json"],
59
+ ]);
60
+ buildCommandMock.mockClear();
61
+ await runBuild(ctx({ lexiconOutput: ["otel=a.yml"] }));
62
+ expect(buildCommandMock).toHaveBeenCalledTimes(1);
63
+ });
64
+
65
+ test("drops the 'No serializer found' warning for lexicons another file carries, and repeats nothing", async () => {
66
+ const warn = "No serializer found for lexicon \"grafana\"";
67
+ buildCommandMock.mockResolvedValue({ success: true, errors: [], warnings: [warn, "something else"], resourceCount: 1, fileCount: 1 });
68
+ const err = vi.spyOn(console, "error").mockImplementation(() => {});
69
+ await runBuild(ctx({ lexiconOutput: ["otel=a.yml", "prometheus=b.yml"] }));
70
+ const printed = err.mock.calls.map((c) => String(c[0])).join("\n");
71
+ expect(printed).not.toContain("No serializer found");
72
+ expect(printed.match(/something else/g)).toHaveLength(1);
73
+ });
74
+
75
+ test("a failed build fails the command, and the other files are still written", async () => {
76
+ buildCommandMock.mockResolvedValueOnce({ success: false, errors: ["boom"], warnings: [], resourceCount: 0, fileCount: 0 });
77
+ const code = await runBuild(ctx({ lexiconOutput: ["otel=a.yml", "prometheus=b.yml"] }));
78
+ expect(code).toBe(1);
79
+ expect(buildCommandMock).toHaveBeenCalledTimes(2);
80
+ });
81
+
82
+ test("refuses a malformed spec, an unknown lexicon, a repeat, and --lexicon", async () => {
83
+ for (const a of [
84
+ { lexiconOutput: ["otel"] },
85
+ { lexiconOutput: ["otel="] },
86
+ { lexiconOutput: ["nope=a.yml"] },
87
+ { lexiconOutput: ["otel=a.yml", "otel=b.yml"] },
88
+ { lexiconOutput: ["otel=a.yml"], lexicon: "otel" },
89
+ ]) {
90
+ expect(await runBuild(ctx(a))).toBe(1);
91
+ }
92
+ expect(buildCommandMock).not.toHaveBeenCalled();
93
+ });
94
+ });
@@ -65,6 +65,8 @@ async function runGenerateComponents(ctx: CommandContext): Promise<number> {
65
65
  output: args.output,
66
66
  params: args.param,
67
67
  paramsFile: args.paramsFile,
68
+ ...(args.waveGate ? { waveGate: { gate: args.waveGate, ...(args.canary?.length ? { canary: args.canary } : {}), ...(args.base ? { base: args.base } : {}) } } : {}),
69
+ ...(args.prLoop ? { prLoop: { ...(args.gate ? { gate: args.gate } : {}), ...(args.branch ? { branch: args.branch } : {}) } } : {}),
68
70
  });
69
71
  } catch (err) {
70
72
  console.error(formatError({ message: memberPipeline.describeError(err) }));
@@ -177,6 +179,69 @@ export async function runBuild(ctx: CommandContext): Promise<number> {
177
179
  // #1064 — `--param name=value`, repeated, into a flat { name: value } record.
178
180
  const params = parseParamFlags(args.param);
179
181
 
182
+ // `--lexicon-output <lexicon>=<path>`: each named lexicon builds into its own
183
+ // file. One build per file, over the same project, so every write, check and
184
+ // diagnostic path is the one a single-lexicon `--lexicon` build already takes.
185
+ if (args.lexiconOutput?.length) {
186
+ if (args.watch || args.lexicon) {
187
+ console.error(formatError({ message: "--lexicon-output cannot be combined with --watch or --lexicon" }));
188
+ return 1;
189
+ }
190
+ const targets = new Map<string, string>();
191
+ for (const spec of args.lexiconOutput) {
192
+ const eq = (spec ?? "").indexOf("=");
193
+ if (eq <= 0 || eq === spec.length - 1) {
194
+ console.error(formatError({ message: `Invalid --lexicon-output "${spec}". Expected <lexicon>=<path>.` }));
195
+ return 1;
196
+ }
197
+ const name = spec.slice(0, eq);
198
+ if (targets.has(name)) {
199
+ console.error(formatError({ message: `--lexicon-output names lexicon "${name}" twice.` }));
200
+ return 1;
201
+ }
202
+ if (!serializers.some((s) => s.name === name)) {
203
+ console.error(formatError({ message: `No serializer found for lexicon "${name}". Available: ${serializers.map((s) => s.name).join(", ")}. List the lexicon in chant.config.ts.` }));
204
+ return 1;
205
+ }
206
+ targets.set(name, spec.slice(eq + 1));
207
+ }
208
+ const groups: { serializers: typeof serializers; output: string }[] = [...targets].map(([name, output]) => ({
209
+ serializers: serializers.filter((s) => s.name === name),
210
+ output,
211
+ }));
212
+ const rest = serializers.filter((s) => !targets.has(s.name));
213
+ if (args.output && rest.length > 0) groups.push({ serializers: rest, output: args.output });
214
+
215
+ const seenWarnings = new Set<string>();
216
+ let failed = false;
217
+ for (const group of groups) {
218
+ const { format, warning } = resolveBuildFormat(args.format, group.output);
219
+ if (warning) console.error(formatInfo(warning));
220
+ const result = await buildCommand({
221
+ path: args.path,
222
+ output: group.output,
223
+ format,
224
+ serializers: group.serializers,
225
+ plugins,
226
+ verbose: args.verbose,
227
+ env: args.env,
228
+ fold: args.fold,
229
+ sandbox: args.sandbox,
230
+ foldRank: args.foldRank,
231
+ foldRankCollapsedFile: args.foldRankCollapsedFile,
232
+ params: parseParamFlags(args.param),
233
+ paramsFile: args.paramsFile,
234
+ });
235
+ // The lexicons built by another group are not missing serializers.
236
+ const own = result.warnings.filter((w) => !w.includes("No serializer found for lexicon") && !seenWarnings.has(w));
237
+ for (const w of own) seenWarnings.add(w);
238
+ printWarnings(own);
239
+ printErrors(result.errors);
240
+ if (!result.success) failed = true;
241
+ }
242
+ return failed ? 1 : 0;
243
+ }
244
+
180
245
  if (args.watch) {
181
246
  const cleanup = buildCommandWatch({
182
247
  path: args.path,