@intentius/chant 0.108.1 → 0.110.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 (46) hide show
  1. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  2. package/dist/composite.d.ts +5 -0
  3. package/dist/composite.d.ts.map +1 -1
  4. package/dist/discovery/collect.d.ts.map +1 -1
  5. package/dist/discovery/fold-import.d.ts +15 -0
  6. package/dist/discovery/fold-import.d.ts.map +1 -1
  7. package/dist/discovery/param-deps.d.ts +59 -4
  8. package/dist/discovery/param-deps.d.ts.map +1 -1
  9. package/dist/fold/subset.d.ts +17 -2
  10. package/dist/fold/subset.d.ts.map +1 -1
  11. package/dist/fold-provenance.d.ts +49 -4
  12. package/dist/fold-provenance.d.ts.map +1 -1
  13. package/dist/import/ir-utils.d.ts.map +1 -1
  14. package/dist/import/parser.d.ts +8 -0
  15. package/dist/import/parser.d.ts.map +1 -1
  16. package/dist/provenance.d.ts +49 -0
  17. package/dist/provenance.d.ts.map +1 -1
  18. package/dist/serializer-walker.d.ts.map +1 -1
  19. package/dist/stack-output.d.ts +5 -0
  20. package/dist/stack-output.d.ts.map +1 -1
  21. package/dist/yaml.d.ts.map +1 -1
  22. package/package.json +1 -1
  23. package/src/cli/commands/import.test.ts +5 -4
  24. package/src/cli/handlers/lifecycle.test.ts +106 -1
  25. package/src/cli/handlers/lifecycle.ts +26 -5
  26. package/src/composite.ts +37 -0
  27. package/src/discovery/collect.ts +13 -3
  28. package/src/discovery/fold-composite.test.ts +22 -0
  29. package/src/discovery/fold-host-composite.test.ts +23 -0
  30. package/src/discovery/fold-import.ts +324 -30
  31. package/src/discovery/fold-package-composite.test.ts +14 -1
  32. package/src/discovery/fold-project-provenance.test.ts +584 -0
  33. package/src/discovery/param-deps.test.ts +163 -3
  34. package/src/discovery/param-deps.ts +173 -4
  35. package/src/fold/subset-public-export.test.ts +38 -0
  36. package/src/fold/subset.ts +17 -2
  37. package/src/fold-provenance.test.ts +190 -16
  38. package/src/fold-provenance.ts +158 -13
  39. package/src/import/ir-utils.ts +6 -1
  40. package/src/import/parser.ts +8 -0
  41. package/src/provenance.ts +59 -2
  42. package/src/serializer-walker.test.ts +10 -0
  43. package/src/serializer-walker.ts +17 -1
  44. package/src/stack-output.ts +6 -1
  45. package/src/yaml.test.ts +6 -0
  46. package/src/yaml.ts +4 -0
@@ -1,9 +1,10 @@
1
1
  import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
2
- import { sep } from "node:path";
2
+ import { join, sep } from "node:path";
3
3
  import { createMockPlugin, staticDescribeResources, staticObservation, staticDeepObservation, staticListArtifacts } from "@intentius/chant-test-utils";
4
4
  import type { LexiconPlugin, ResourceMetadata } from "../../lexicon";
5
5
  import { deepObservation } from "../../deep-observation";
6
6
  import { heldElsewhere } from "../../held-elsewhere";
7
+ import { setProvenance } from "../../provenance";
7
8
  import type { BuildResult } from "../../build";
8
9
  import type { ParsedArgs } from "../registry";
9
10
 
@@ -739,6 +740,110 @@ describe("runLifecycleDiff --live", () => {
739
740
  });
740
741
  });
741
742
 
743
+ // #3597 — a drifted field names where it came from: the composite argument
744
+ // and its line, the composite that fixes it, or an unknown origin and why.
745
+ describe("drift names its origin in source (#3597)", () => {
746
+ const app = join(process.cwd(), "src", "app.ts");
747
+ const call = { file: app, line: 10, column: 20 };
748
+ const names = ["webDeployment", "webService", "worker", "legacy"];
749
+
750
+ const runOriginDiff = async (args: Partial<ParsedArgs> = {}) => {
751
+ const build = makeBuildResult({ k8s: names });
752
+ const declare = (name: string, provenance?: Parameters<typeof setProvenance>[1]) => {
753
+ const entity = { lexicon: "k8s", entityType: "Deployment", props: { spec: { replicas: 3 } } };
754
+ if (provenance) setProvenance(entity, provenance);
755
+ build.entities.set(name, entity as never);
756
+ };
757
+ declare("webDeployment", {
758
+ sourceFile: app,
759
+ composite: "WebApp",
760
+ compositeInstance: "web",
761
+ compositeCall: call,
762
+ paths: {
763
+ "spec.replicas": {
764
+ kind: "composite-parameter",
765
+ composite: "WebApp",
766
+ parameters: ["replicas"],
767
+ arguments: [{ parameter: "replicas", file: app, line: 12, column: 3, text: "replicas: 3" }],
768
+ },
769
+ },
770
+ });
771
+ declare("webService", {
772
+ sourceFile: app,
773
+ composite: "WebApp",
774
+ compositeInstance: "web",
775
+ compositeCall: call,
776
+ paths: { "spec.replicas": { kind: "composite-literal", composite: "WebApp" } },
777
+ });
778
+ declare("worker", { sourceFile: app });
779
+ declare("legacy", { sourceFile: app, composite: "LegacyApp", compositeInstance: "legacy" });
780
+ buildMock.mockResolvedValue(build);
781
+ fetchLifecycleMock.mockResolvedValue(undefined);
782
+ readSnapshotMock.mockResolvedValue(null);
783
+ const live = Object.fromEntries(names.map((n) => [n, { type: "Deployment", properties: { spec: { replicas: 5 } } }]));
784
+ const plugins: LexiconPlugin[] = [
785
+ createMockPlugin({
786
+ name: "k8s",
787
+ describeResources: staticObservation(Object.fromEntries(names.map((n) => [n, meta({ type: "Deployment" })]))),
788
+ observeResourcesDeep: staticDeepObservation(live),
789
+ }),
790
+ ];
791
+ return runLifecycleDiff({
792
+ args: makeArgs({ command: "state", path: "diff", extraPositional: "prod", live: true, ...args }),
793
+ plugins,
794
+ serializers: plugins.map((p) => p.serializer),
795
+ } as never);
796
+ };
797
+
798
+ test("the human report names the argument and line, the fixing composite, and an unknown origin", async () => {
799
+ await runOriginDiff();
800
+ const output = stdoutBuf.join("\n");
801
+ expect(output).toContain(
802
+ "spec.replicas on Deployment webDeployment comes from WebApp({ replicas: 3 }) at src/app.ts:12",
803
+ );
804
+ expect(output).toContain("change parameter `replicas` of the `web` call of composite WebApp at src/app.ts:12");
805
+ expect(output).toContain(
806
+ "spec.replicas on Deployment webService is fixed inside composite WebApp; no argument to the call at src/app.ts:10 moves it",
807
+ );
808
+ expect(output).toContain(
809
+ "spec.replicas on Deployment legacy has an unknown origin: expanded by a composite whose factory this build did not interpret",
810
+ );
811
+ // Direct keeps today's output: the row, and nothing under it.
812
+ expect(output).not.toContain("on Deployment worker");
813
+ const workerRow = output.split("\n").findIndex((l) => l.includes("- worker (Deployment)"));
814
+ expect(output.split("\n")[workerRow + 1]).toMatch(/^ {6}spec\.replicas: 3 → 5/);
815
+ expect(output.split("\n")[workerRow + 2] ?? "").not.toMatch(/^ {8}\S/);
816
+ // An unknown origin is never shown as a direct declaration.
817
+ expect(output).not.toMatch(/legacy.*declared directly/);
818
+ });
819
+
820
+ test("--json carries the origin object for every drifted field", async () => {
821
+ await runOriginDiff({ json: true });
822
+ const payload = JSON.parse(stdoutBuf.join("\n")) as {
823
+ lexicons: { k8s: { reconcile: Array<{ entity: string; origin: Record<string, unknown>; summary: string }> } };
824
+ };
825
+ const byEntity = Object.fromEntries(payload.lexicons.k8s.reconcile.map((r) => [r.entity, r]));
826
+ expect(byEntity.webDeployment.origin).toEqual({
827
+ kind: "composite-parameter",
828
+ composite: "WebApp",
829
+ instance: "web",
830
+ parameters: ["replicas"],
831
+ call,
832
+ arguments: [{ parameter: "replicas", file: app, line: 12, column: 3, text: "replicas: 3" }],
833
+ });
834
+ expect(byEntity.webService.origin).toEqual({ kind: "composite-literal", composite: "WebApp", instance: "web", call });
835
+ expect(byEntity.worker.origin).toEqual({ kind: "direct" });
836
+ expect(byEntity.legacy.origin).toEqual({ kind: "unknown", reason: "composite-not-interpreted" });
837
+ expect(byEntity.webDeployment.summary).toContain("at src/app.ts:12");
838
+ });
839
+
840
+ test("the diff builds the way `chant build` does, folding by default", async () => {
841
+ await runOriginDiff();
842
+ const options = buildMock.mock.calls[0][3] as { fold?: boolean };
843
+ expect(options.fold).toBe(true);
844
+ });
845
+ });
846
+
742
847
  // #2162 — a `heldElsewhere()` marker reports as held, not drift, and a
743
848
  // held property nothing ever writes is flagged suspicious on stderr.
744
849
  describe("heldElsewhere() markers (#2162)", () => {
@@ -41,6 +41,7 @@ import { loadChantConfig, environmentNames, matchesDeclaredEnvironment, resolveO
41
41
  import { unknownEnvError, isProdLikeEnvironment } from "../../env";
42
42
  import { planTeardown, executeTeardown, type TeardownPlan, type TeardownReport } from "../../lifecycle/teardown";
43
43
  import { collectBuildRootContributors } from "../plugins";
44
+ import { resolveBuildModes, resolveProjectBuildOptions } from "../build-options";
44
45
  import { applyLiveEndpoint } from "../../live-endpoint";
45
46
  import { isObservableDeclarable } from "../../declarable";
46
47
  import { formatError, formatWarning, formatSuccess, formatBold } from "../format";
@@ -378,11 +379,22 @@ export async function runLifecycleDiff(ctx: CommandContext): Promise<number> {
378
379
  const endpointResult = applyLiveEndpoint(config.environments, environment, liveLexicons);
379
380
  if (endpointResult.notice) console.error(formatWarning({ message: endpointResult.notice }));
380
381
 
381
- const diffBuildRoots = collectBuildRootContributors(plugins, config as unknown as Record<string, unknown>, resolve("."));
382
+ // The build `chant build` would produce for this project (#2002's one
383
+ // place), so the diff folds by default as `chant build` does. A run build
384
+ // cannot say which composite argument produced a field, so without this
385
+ // every composite field in the live diff read as unknown (#3597).
386
+ const projectRoot = resolve(".");
387
+ const diffBuildOptions = resolveProjectBuildOptions({
388
+ config,
389
+ configDir: projectRoot,
390
+ plugins,
391
+ modes: resolveBuildModes(config, { fold: args.fold, sandbox: args.sandbox }),
392
+ buildParams: declaredParams,
393
+ });
382
394
 
383
395
  try {
384
396
  for (const target of targets) {
385
- const buildResult = await build(target.root, targetSerializers, undefined, { buildParams: declaredParams, buildRoots: diffBuildRoots });
397
+ const buildResult = await build(target.root, targetSerializers, undefined, diffBuildOptions);
386
398
  if (buildResult.errors.length > 0) {
387
399
  const label = target.stack ? `stack "${target.stack}"` : "project";
388
400
  console.error(formatError({ message: `Build failed for ${label} — fix errors before diffing` }));
@@ -434,6 +446,7 @@ export async function runLifecycleDiff(ctx: CommandContext): Promise<number> {
434
446
  baseline,
435
447
  updateBaseline: args.updateBaseline,
436
448
  ...(args.namespace ? { namespace: args.namespace } : {}),
449
+ projectRoot,
437
450
  });
438
451
  totalDrift += r.totalDrift;
439
452
  totalUnobserved += r.totalUnobserved;
@@ -687,6 +700,8 @@ interface LiveDiffArgs {
687
700
  /** `--namespace <ns>` (#1629): where to read entities that declare no
688
701
  * namespace of their own. */
689
702
  namespace?: string;
703
+ /** Directory source locations in the report are printed relative to (#3597). */
704
+ projectRoot?: string;
690
705
  }
691
706
 
692
707
  interface LiveDiffOutcome {
@@ -902,8 +917,8 @@ async function runLifecycleDiffLive(args: LiveDiffArgs): Promise<LiveDiffOutcome
902
917
  if (args.json) {
903
918
  const entry = (byLexicon[lexiconName] ??= {});
904
919
  entry.deep = deep;
905
- entry.reconcile = resolveDeepDrift(deep.drifted, provenanceOf);
906
- } else renderDeepDiff(lexiconName, deep, provenanceOf);
920
+ entry.reconcile = resolveDeepDrift(deep.drifted, provenanceOf, { root: args.projectRoot });
921
+ } else renderDeepDiff(lexiconName, deep, provenanceOf, args.projectRoot);
907
922
  // Printed on stderr regardless of `--json` (#2162), same reason the
908
923
  // unobserved/disruption notices are: a shape with no column for
909
924
  // "suspicious" must not read as clean.
@@ -971,6 +986,7 @@ function renderDeepDiff(
971
986
  lexiconName: string,
972
987
  deep: DeepDiffResult,
973
988
  provenanceOf: (entity: string) => EntityProvenance | undefined = () => undefined,
989
+ projectRoot?: string,
974
990
  ): void {
975
991
  const drift = countPropertyDrift(deep);
976
992
  const unclaimedCount = countUnclaimed(deep);
@@ -1018,15 +1034,20 @@ function renderDeepDiff(
1018
1034
  // something today's behaviour would get wrong. A field the author
1019
1035
  // declared directly is left unremarked: editing it IS today's
1020
1036
  // behaviour, and saying so on every row would bury the two that matter.
1021
- const { resolution } = resolveDriftedField({
1037
+ // #3597 — the line before the proposal names the composite call and
1038
+ // the argument behind the field, with the file and line it was written on.
1039
+ const { resolution, summary } = resolveDriftedField({
1022
1040
  entity: entity.name,
1041
+ entityType: entity.type,
1023
1042
  path: change.path,
1024
1043
  ...("declared" in change ? { declared: change.declared } : {}),
1025
1044
  ...("live" in change ? { live: change.live } : {}),
1026
1045
  ...(change.origin ? { origin: change.origin } : {}),
1027
1046
  ...(provenance ? { provenance } : {}),
1047
+ ...(projectRoot ? { root: projectRoot } : {}),
1028
1048
  });
1029
1049
  if (resolution.kind !== "edit-declaration") {
1050
+ console.log(` ${summary}`);
1030
1051
  console.log(` ${resolution.description}`);
1031
1052
  }
1032
1053
  }
package/src/composite.ts CHANGED
@@ -134,6 +134,16 @@ export function Composite<P, M extends CompositeFactoryMembers = CompositeMember
134
134
  }
135
135
  }
136
136
 
137
+ // chant#3608 — every member records the composite that built it, here,
138
+ // when the instance is made. A member destructured from the call
139
+ // (`export const { vpc } = VpcDefault({})`) is never expanded, so this is
140
+ // the only trace that a composite wrote it; `collectEntities` reads it
141
+ // through {@link compositeOfMember}. A nested instance recorded its own
142
+ // members first and the first record is kept, so a member keeps its
143
+ // innermost composite. Kept off the provenance record so the composite
144
+ // `expandComposite` records for an instance exported whole is unchanged.
145
+ markCompositeMembers(members, compositeName);
146
+
137
147
  // Define `members` and `_definition` as non-enumerable so spreading a
138
148
  // composite instance (`...someComposite`) only exposes the actual member
139
149
  // resources, not the framework's bookkeeping properties. Without this, a
@@ -156,6 +166,33 @@ export function Composite<P, M extends CompositeFactoryMembers = CompositeMember
156
166
  return definition;
157
167
  }
158
168
 
169
+ /** Non-enumerable, symbol-keyed: invisible to serializers, spreads and comparisons, like provenance. */
170
+ const COMPOSITE_MEMBER_OF = Symbol.for("chant.composite.memberOf");
171
+
172
+ /** Record `compositeName` on each declarable member, descending into nested instances. First record wins. */
173
+ function markCompositeMembers(members: object, compositeName: string): void {
174
+ for (const member of Object.values(members)) {
175
+ if (isCompositeInstance(member)) {
176
+ markCompositeMembers(member.members, compositeName);
177
+ } else if (
178
+ isDeclarable(member) &&
179
+ Object.isExtensible(member) &&
180
+ !Object.prototype.hasOwnProperty.call(member, COMPOSITE_MEMBER_OF)
181
+ ) {
182
+ Object.defineProperty(member, COMPOSITE_MEMBER_OF, { value: compositeName, enumerable: false, configurable: true });
183
+ }
184
+ }
185
+ }
186
+
187
+ /**
188
+ * chant#3608 — the composite whose call built `entity`, or `undefined` for an
189
+ * entity no chant composite built.
190
+ */
191
+ export function compositeOfMember(entity: object): string | undefined {
192
+ const name = (entity as Record<symbol, unknown>)[COMPOSITE_MEMBER_OF];
193
+ return typeof name === "string" ? name : undefined;
194
+ }
195
+
159
196
  /**
160
197
  * Expands a CompositeInstance into a flat Map of prefixed entity names to Declarables.
161
198
  * Handles nested composites recursively.
@@ -1,6 +1,6 @@
1
1
  import { basename, dirname, relative, resolve } from "node:path";
2
2
  import { isDeclarable, type Declarable } from "../declarable";
3
- import { isCompositeInstance, expandComposite } from "../composite";
3
+ import { isCompositeInstance, expandComposite, compositeOfMember } from "../composite";
4
4
  import { isLexiconOutput } from "../lexicon-output";
5
5
  import { DiscoveryError } from "../errors";
6
6
  import { setProvenance, type EntityProvenance } from "../provenance";
@@ -43,6 +43,16 @@ function stackPrefix(file: string, buildRoot: string | undefined): string {
43
43
  */
44
44
  const AUTHORED_ROOT: EntityProvenance["paths"] = { "": { kind: "authored" } };
45
45
 
46
+ /**
47
+ * The record for an entity exported on its own. chant#3608: one destructured
48
+ * from a composite call also names that composite, so fold provenance reads
49
+ * its unattributed paths as `unknown` rather than as the file's own `direct`.
50
+ */
51
+ function authoredProvenance(entity: Declarable, file: string): EntityProvenance {
52
+ const composite = compositeOfMember(entity);
53
+ return { sourceFile: file, ...(composite ? { composite } : {}), paths: AUTHORED_ROOT };
54
+ }
55
+
46
56
  /** One entity to be placed into the map, produced by {@link enumerateEntries}. */
47
57
  interface PendingEntry {
48
58
  /** The un-disambiguated map key this entity would take (export name, indexed
@@ -82,12 +92,12 @@ function enumerateEntries(
82
92
  for (const [rawName, value] of sortedExports) {
83
93
  const name = exportKey(rawName, file);
84
94
  if (isDeclarable(value)) {
85
- entries.push({ bareKey: name, value, file, provenance: { sourceFile: file, paths: AUTHORED_ROOT } });
95
+ entries.push({ bareKey: name, value, file, provenance: authoredProvenance(value, file) });
86
96
  } else if (Array.isArray(value)) {
87
97
  for (let i = 0; i < value.length; i++) {
88
98
  const item = value[i];
89
99
  if (isDeclarable(item)) {
90
- entries.push({ bareKey: `${name}_${i}`, value: item, file, provenance: { sourceFile: file, paths: AUTHORED_ROOT } });
100
+ entries.push({ bareKey: `${name}_${i}`, value: item, file, provenance: authoredProvenance(item, file) });
91
101
  } else if (isCompositeInstance(item)) {
92
102
  const indexedName = `${name}_${i}`;
93
103
  for (const [expandedName, entity] of expandComposite(indexedName, item)) {
@@ -353,6 +353,28 @@ describe("composite factory interpretation (chant #1023)", () => {
353
353
  expect(JSON.parse(folded).readersReader.props.Description).toEqual({ GetAtt: ["webBucket", "Arn"] });
354
354
  });
355
355
 
356
+ test("a same-file call result read in an argument is bound for that argument only (chant#3610)", async () => {
357
+ await writeComposite(
358
+ ADMISSIBLE_BODY,
359
+ `export const Reader = Composite((props) => {
360
+ const reader = new Role({ RoleName: props.name, Description: props.bucket.Arn });
361
+ return { reader };
362
+ }, "Reader");`,
363
+ );
364
+ // `plain` reads `web` outside any argument. Under F-Call step 6 that read
365
+ // does not resolve, whichever declarator comes first, so the file runs.
366
+ await writeMain(`
367
+ import { Reader, WebApp } from "../composites";
368
+ export const web = WebApp({ name: "data" });
369
+ export const readers = Reader({ name: "readers", bucket: web.bucket });
370
+ export const plain = { inner: web.bucket };
371
+ `);
372
+
373
+ const result = await build(srcDir, [specSerializer], undefined, { fold: true, lexicons: [LEXICON_NAME] });
374
+
375
+ expect(result.foldDecisions.map((d) => d.mode)).toEqual(["run"]);
376
+ });
377
+
356
378
  test("provenance is stamped from the source's own composite name", async () => {
357
379
  await writeComposite(ADMISSIBLE_BODY);
358
380
  await writeMain(CALL_WEBAPP);
@@ -86,6 +86,29 @@ describe("a host's composite registration form (chant#2442)", () => {
86
86
  });
87
87
  });
88
88
 
89
+ test("a host composite's result read inside an argument folds (chant#3610)", async () => {
90
+ writeFileSync(
91
+ join(root, "shapes.ts"),
92
+ 'import { Bucket, Composite } from "@tsad/shapes";\n' +
93
+ 'export const Store = Composite(({ name }) => ({ bucket: new Bucket({ name }) }), "Store");\n' +
94
+ 'export const Wrap = Composite(({ inner }) => ({ inner }), "Wrap");\n',
95
+ );
96
+ const file = app(
97
+ 'import { Store, Wrap } from "./shapes";\n' +
98
+ 'export const s = Store({ name: "a" });\n' +
99
+ "export const w = Wrap({ inner: s.bucket });\n",
100
+ );
101
+
102
+ const verdict = (
103
+ await foldProject([file], [], { lexiconPackages: ["@tsad/shapes"], sandbox: true })
104
+ ).get(file)!;
105
+
106
+ expect(verdict.verdict).toBe("fold");
107
+ const exports = Object.fromEntries(verdict.exports!) as Record<string, { bucket?: unknown; inner?: unknown }>;
108
+ // One instance per file: the argument reads the declarator's own result.
109
+ expect(exports.w.inner).toBe(exports.s.bucket);
110
+ });
111
+
89
112
  test("a factory outside the subset is still refused rather than invoked", async () => {
90
113
  // `S-FactoryParams`: two parameters is outside it, so interpretation
91
114
  // declines and the invocation arm refuses under the sandbox.