@intentius/chant 0.38.0 → 0.39.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 (85) hide show
  1. package/dist/build.d.ts +21 -0
  2. package/dist/build.d.ts.map +1 -1
  3. package/dist/cli/commands/build.d.ts.map +1 -1
  4. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  5. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  6. package/dist/cli/handlers/run-client.d.ts.map +1 -1
  7. package/dist/cli/handlers/search.d.ts +30 -1
  8. package/dist/cli/handlers/search.d.ts.map +1 -1
  9. package/dist/cli/main.d.ts.map +1 -1
  10. package/dist/cli/plugins.d.ts +20 -0
  11. package/dist/cli/plugins.d.ts.map +1 -1
  12. package/dist/codegen/registry.d.ts +23 -0
  13. package/dist/codegen/registry.d.ts.map +1 -0
  14. package/dist/components/sandbox/driver.d.ts.map +1 -1
  15. package/dist/composite.d.ts +23 -4
  16. package/dist/composite.d.ts.map +1 -1
  17. package/dist/deep-observation.d.ts +11 -0
  18. package/dist/deep-observation.d.ts.map +1 -1
  19. package/dist/discovery/sandbox/driver.d.ts.map +1 -1
  20. package/dist/graph-declared.d.ts.map +1 -1
  21. package/dist/graph-ir.d.ts +17 -3
  22. package/dist/graph-ir.d.ts.map +1 -1
  23. package/dist/graph-refs.d.ts +24 -0
  24. package/dist/graph-refs.d.ts.map +1 -1
  25. package/dist/kubectl-context.d.ts.map +1 -1
  26. package/dist/lexicon-config.d.ts +61 -0
  27. package/dist/lexicon-config.d.ts.map +1 -0
  28. package/dist/lexicon.d.ts +19 -0
  29. package/dist/lexicon.d.ts.map +1 -1
  30. package/dist/lifecycle/deep-diff.d.ts +11 -0
  31. package/dist/lifecycle/deep-diff.d.ts.map +1 -1
  32. package/dist/lifecycle/digest.d.ts.map +1 -1
  33. package/dist/lifecycle/identity.d.ts +52 -0
  34. package/dist/lifecycle/identity.d.ts.map +1 -0
  35. package/dist/lifecycle/observe.d.ts +5 -0
  36. package/dist/lifecycle/observe.d.ts.map +1 -1
  37. package/dist/lifecycle/replay.d.ts.map +1 -1
  38. package/dist/lifecycle/types.d.ts +30 -0
  39. package/dist/lifecycle/types.d.ts.map +1 -1
  40. package/dist/managed-fields.d.ts +11 -0
  41. package/dist/managed-fields.d.ts.map +1 -1
  42. package/package.json +1 -1
  43. package/src/build.ts +24 -0
  44. package/src/cli/commands/build.ts +10 -0
  45. package/src/cli/commands/check-lexicon.ts +20 -1
  46. package/src/cli/handlers/lifecycle.ts +10 -0
  47. package/src/cli/handlers/run-client.ts +3 -1
  48. package/src/cli/handlers/search-kind.test.ts +45 -0
  49. package/src/cli/handlers/search.ts +102 -4
  50. package/src/cli/main.ts +32 -10
  51. package/src/cli/param-flag-scope.test.ts +69 -0
  52. package/src/cli/plugins.test.ts +33 -1
  53. package/src/cli/plugins.ts +55 -0
  54. package/src/codegen/registry.test.ts +56 -0
  55. package/src/codegen/registry.ts +69 -0
  56. package/src/components/SPRAWL-VALIDATION.md +5 -5
  57. package/src/components/sandbox/driver.test.ts +27 -0
  58. package/src/components/sandbox/driver.ts +12 -0
  59. package/src/composite.ts +33 -4
  60. package/src/deep-observation.ts +11 -0
  61. package/src/discovery/sandbox/driver.test.ts +34 -0
  62. package/src/discovery/sandbox/driver.ts +19 -0
  63. package/src/graph-declared.test.ts +86 -0
  64. package/src/graph-declared.ts +14 -2
  65. package/src/graph-ir.ts +32 -8
  66. package/src/graph-refs.test.ts +56 -0
  67. package/src/graph-refs.ts +37 -1
  68. package/src/kubectl-context.ts +4 -1
  69. package/src/lexicon-config.test.ts +111 -0
  70. package/src/lexicon-config.ts +92 -0
  71. package/src/lexicon.ts +20 -0
  72. package/src/lifecycle/deep-diff.test.ts +48 -1
  73. package/src/lifecycle/deep-diff.ts +16 -0
  74. package/src/lifecycle/digest.test.ts +81 -0
  75. package/src/lifecycle/digest.ts +34 -3
  76. package/src/lifecycle/identity.test.ts +39 -0
  77. package/src/lifecycle/identity.ts +61 -0
  78. package/src/lifecycle/observe.test.ts +75 -1
  79. package/src/lifecycle/observe.ts +28 -2
  80. package/src/lifecycle/replay.test.ts +251 -0
  81. package/src/lifecycle/replay.ts +67 -19
  82. package/src/lifecycle/types.ts +26 -0
  83. package/src/managed-fields.test.ts +50 -0
  84. package/src/managed-fields.ts +25 -6
  85. package/src/meta/peer-deps.test.ts +111 -14
@@ -0,0 +1,69 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { readFileSync, readdirSync } from "node:fs";
3
+ import { join, dirname } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ /**
7
+ * `--param`/`--params-file` started as build-only flags and grew: `chant graph`
8
+ * gained them with #1359 (graph and build disagreed about the same source),
9
+ * `chant run --components` with #1108. The help text in `./main.ts` kept saying
10
+ * "(build)", so the CLI's own documentation told users the flag did nothing on
11
+ * the two commands that had just learned it.
12
+ *
13
+ * Asserting the property rather than the wording: every handler that actually
14
+ * reads `args.param` must be named in the flag's help annotation. A new handler
15
+ * honoring the flag fails this test until the help is updated with it.
16
+ */
17
+ const here = dirname(fileURLToPath(import.meta.url));
18
+ const handlersDir = join(here, "handlers");
19
+
20
+ /** The command name a handler file implements — `handlers/graph.ts` backs `chant graph`. */
21
+ function commandOf(file: string): string {
22
+ return file.replace(/\.ts$/, "");
23
+ }
24
+
25
+ function handlersReadingParamFlags(): string[] {
26
+ return readdirSync(handlersDir)
27
+ .filter((f) => f.endsWith(".ts") && !f.endsWith(".test.ts"))
28
+ .filter((f) => /\bargs\.param\b/.test(readFileSync(join(handlersDir, f), "utf-8")))
29
+ .map(commandOf)
30
+ .sort();
31
+ }
32
+
33
+ /**
34
+ * The help text for one flag: everything from its marker up to the next flag
35
+ * entry or the end of the options block, whichever comes first. Both bounds
36
+ * matter — the LAST flag in a block has no `--` after it, and without the
37
+ * blank-line bound its "block" would run to the end of the file and match
38
+ * every command name incidentally.
39
+ */
40
+ function helpBlockFor(flag: string): string {
41
+ const source = readFileSync(join(here, "main.ts"), "utf-8");
42
+ const start = source.indexOf(` ${flag}`);
43
+ expect(start, `${flag} is not documented in main.ts's help`).toBeGreaterThan(-1);
44
+ const rest = source.slice(start + flag.length);
45
+ const ends = [rest.search(/\n {2}--/), rest.search(/\n\s*\n/)].filter((i) => i !== -1);
46
+ expect(ends.length, `${flag}'s help block has no terminator`).toBeGreaterThan(0);
47
+ return rest.slice(0, Math.min(...ends));
48
+ }
49
+
50
+ describe("--param help annotation tracks the handlers that honor it", () => {
51
+ test("at least build, graph and run read the flag", () => {
52
+ // Guards the test itself: if the detection regex ever stops matching, the
53
+ // assertions below would pass vacuously against an empty set.
54
+ expect(handlersReadingParamFlags()).toEqual(expect.arrayContaining(["build", "graph", "run"]));
55
+ });
56
+
57
+ test.each(["--param <name=value>", "--params-file <path>"])(
58
+ "%s names every command that honors it",
59
+ (flag) => {
60
+ const block = helpBlockFor(flag);
61
+ for (const command of handlersReadingParamFlags()) {
62
+ expect(
63
+ block.includes(command),
64
+ `handlers/${command}.ts reads args.param, but ${flag}'s help does not mention "${command}"`,
65
+ ).toBe(true);
66
+ }
67
+ },
68
+ );
69
+ });
@@ -1,5 +1,5 @@
1
1
  import { describe, test, expect } from "vitest";
2
- import { loadPlugin, loadPlugins } from "./plugins";
2
+ import { loadPlugin, loadPlugins, resolveLexiconVersions } from "./plugins";
3
3
  import { isLexiconPlugin } from "../lexicon";
4
4
 
5
5
  describe("loadPlugin", () => {
@@ -29,3 +29,35 @@ describe("loadPlugins", () => {
29
29
  expect(plugins).toHaveLength(0);
30
30
  });
31
31
  });
32
+
33
+ /**
34
+ * chant #1442 — the installed version of each lexicon package, read for the
35
+ * build digest.
36
+ */
37
+ describe("resolveLexiconVersions", () => {
38
+ test("reads the real installed version of a workspace lexicon", () => {
39
+ const versions = resolveLexiconVersions(["k8s"]);
40
+ expect(versions.k8s).toMatch(/^\d+\.\d+\.\d+/);
41
+ });
42
+
43
+ test("resolves several lexicons in one call", () => {
44
+ const versions = resolveLexiconVersions(["k8s", "aws"]);
45
+ expect(Object.keys(versions).sort()).toEqual(["aws", "k8s"]);
46
+ });
47
+
48
+ test("omits a lexicon that is not installed rather than inventing a version", () => {
49
+ // "unknown" would compare unequal to itself across builds and report a
50
+ // version change on every diff.
51
+ expect(resolveLexiconVersions(["definitely-not-a-lexicon"])).toEqual({});
52
+ });
53
+
54
+ test("an unresolvable name does not prevent the resolvable ones", () => {
55
+ const versions = resolveLexiconVersions(["definitely-not-a-lexicon", "k8s"]);
56
+ expect(versions.k8s).toBeDefined();
57
+ expect(versions["definitely-not-a-lexicon"]).toBeUndefined();
58
+ });
59
+
60
+ test("no names yields an empty map", () => {
61
+ expect(resolveLexiconVersions([])).toEqual({});
62
+ });
63
+ });
@@ -1,3 +1,6 @@
1
+ import { createRequire } from "node:module";
2
+ import { dirname, join } from "node:path";
3
+ import { existsSync, readFileSync } from "node:fs";
1
4
  import { isLexiconPlugin, type LexiconPlugin } from "../lexicon";
2
5
  import { loadChantConfigUpward } from "../config";
3
6
  import { findInfraFiles, detectLexicons } from "../index";
@@ -40,6 +43,58 @@ export async function loadPlugin(lexiconName: string): Promise<LexiconPlugin> {
40
43
  throw new Error(`Package ${packageName} does not export a LexiconPlugin or Serializer`);
41
44
  }
42
45
 
46
+ /**
47
+ * The installed version of each named lexicon package (chant #1442).
48
+ *
49
+ * A `LexiconPlugin` does not carry its own version — `LexiconManifest` does,
50
+ * but that is an artifact type describing generated output, not something the
51
+ * plugin object exposes. The version wanted here is a property of the
52
+ * *installed package*, so it is read from the package's own `package.json`.
53
+ *
54
+ * Resolved by walking up from the package's entry point rather than resolving
55
+ * `<pkg>/package.json` directly: lexicon packages declare an `exports` map
56
+ * that does not include `./package.json`, so the direct specifier is blocked
57
+ * by Node. `./manifest` (→ `dist/manifest.json`) is not used either — it is a
58
+ * `prepack` build artifact, absent in a monorepo dev tree, and its absence
59
+ * once read as four unrelated bugs (#1367).
60
+ *
61
+ * A lexicon whose version cannot be determined is omitted rather than
62
+ * recorded as `"unknown"`: a digest that says nothing is honest, one that
63
+ * says `unknown` compares unequal to itself across builds.
64
+ */
65
+ export function resolveLexiconVersions(lexiconNames: readonly string[]): Record<string, string> {
66
+ const require_ = createRequire(import.meta.url);
67
+ const versions: Record<string, string> = {};
68
+
69
+ for (const name of lexiconNames) {
70
+ const packageName = `@intentius/chant-lexicon-${name}`;
71
+ try {
72
+ let dir = dirname(require_.resolve(packageName));
73
+ // Bounded walk — a resolved entry point is never deeply nested inside
74
+ // its own package, and an unbounded loop here would climb to `/`.
75
+ for (let depth = 0; depth < 10; depth++) {
76
+ const candidate = join(dir, "package.json");
77
+ if (existsSync(candidate)) {
78
+ const pkg = JSON.parse(readFileSync(candidate, "utf-8")) as { name?: string; version?: string };
79
+ // Only the lexicon's OWN package.json — a nested dependency's
80
+ // manifest would otherwise be read as the lexicon's version.
81
+ if (pkg.name === packageName && pkg.version) {
82
+ versions[name] = pkg.version;
83
+ break;
84
+ }
85
+ }
86
+ const parent = dirname(dir);
87
+ if (parent === dir) break;
88
+ dir = parent;
89
+ }
90
+ } catch {
91
+ // Not installed, or not resolvable from here — omit it.
92
+ }
93
+ }
94
+
95
+ return versions;
96
+ }
97
+
43
98
  /**
44
99
  * Load plugins for all detected lexicon names.
45
100
  * Calls `init()` on each plugin if present.
@@ -0,0 +1,56 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { loadLexiconRegistry, LexiconRegistryMissingError } from "./registry";
6
+
7
+ const REGISTRY = { Bucket: { resourceType: "AWS::S3::Bucket", kind: "resource" } };
8
+
9
+ function pkg(): string {
10
+ return mkdtempSync(join(tmpdir(), "chant-registry-"));
11
+ }
12
+
13
+ describe("loadLexiconRegistry (#1367)", () => {
14
+ test("reads the dev copy — src/generated, which is what a working checkout has", () => {
15
+ const dir = pkg();
16
+ mkdirSync(join(dir, "src", "generated"), { recursive: true });
17
+ writeFileSync(join(dir, "src", "generated", "lexicon-aws.json"), JSON.stringify(REGISTRY));
18
+ expect(loadLexiconRegistry(dir, "aws")).toEqual(REGISTRY);
19
+ });
20
+
21
+ test("falls back to dist/meta.json — what an installed package ships", () => {
22
+ const dir = pkg();
23
+ mkdirSync(join(dir, "dist"), { recursive: true });
24
+ writeFileSync(join(dir, "dist", "meta.json"), JSON.stringify(REGISTRY));
25
+ expect(loadLexiconRegistry(dir, "aws")).toEqual(REGISTRY);
26
+ });
27
+
28
+ test("prefers the dev copy when both exist", () => {
29
+ const dir = pkg();
30
+ mkdirSync(join(dir, "src", "generated"), { recursive: true });
31
+ mkdirSync(join(dir, "dist"), { recursive: true });
32
+ writeFileSync(join(dir, "src", "generated", "lexicon-aws.json"), JSON.stringify({ Dev: REGISTRY.Bucket }));
33
+ writeFileSync(join(dir, "dist", "meta.json"), JSON.stringify({ Dist: REGISTRY.Bucket }));
34
+ expect(Object.keys(loadLexiconRegistry(dir, "aws"))).toEqual(["Dev"]);
35
+ });
36
+
37
+ test("throws with the command to run, rather than a module-not-found or an empty map", () => {
38
+ // The whole point. An empty map reads downstream as a lexicon with no
39
+ // resource types, which is how azure's import came to emit
40
+ // `// Unknown resource type: Microsoft.…` and look like a coverage gap.
41
+ const dir = pkg();
42
+ expect(() => loadLexiconRegistry(dir, "azure")).toThrow(LexiconRegistryMissingError);
43
+ expect(() => loadLexiconRegistry(dir, "azure")).toThrow(/npm run --prefix lexicons\/azure prepack/);
44
+ });
45
+
46
+ test("names the lexicon that is missing, not a generic one", () => {
47
+ const dir = pkg();
48
+ try {
49
+ loadLexiconRegistry(dir, "gcp");
50
+ throw new Error("should have thrown");
51
+ } catch (err) {
52
+ expect((err as LexiconRegistryMissingError).lexicon).toBe("gcp");
53
+ expect((err as Error).message).toContain("lexicons/gcp");
54
+ }
55
+ });
56
+ });
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Locating a lexicon's generated registry (#1367).
3
+ *
4
+ * The registry — every resource type the lexicon knows, keyed by class name —
5
+ * is written to two places by `npm run --prefix lexicons/<name> prepack`:
6
+ * `src/generated/lexicon-<name>.json` by the generate step, and `dist/meta.json`
7
+ * by the bundle step, byte-identical. Both are gitignored, so a fresh clone has
8
+ * neither until that command runs; CI runs it explicitly before anything else.
9
+ *
10
+ * That is a legitimate state. Being silent about it is not, and the two readers
11
+ * that needed the registry were silent in opposite directions:
12
+ *
13
+ * - AWS's import generator `require`d `dist/meta.json` with no catch, so a
14
+ * fresh clone got `Cannot find module …/lexicons/aws/dist/meta.json` — from
15
+ * `chant import`, and from twelve tests that mention neither modules nor
16
+ * registries.
17
+ * - Azure's caught the failure and continued with an empty map, so the import
18
+ * emitted `// Unknown resource type: Microsoft.Network/virtualNetworks` and
19
+ * its round-trip tests failed as though the registry were incomplete. That is
20
+ * the worse of the two: it looks like a coverage gap in the lexicon.
21
+ *
22
+ * Both now go through here, which tries the dev path first (matching what the
23
+ * azure serializer already did) and, failing both, says what to run.
24
+ */
25
+ import { readFileSync } from "node:fs";
26
+ import { join } from "node:path";
27
+
28
+ /** Thrown when neither copy of the registry exists — carries the fix. */
29
+ export class LexiconRegistryMissingError extends Error {
30
+ constructor(readonly lexicon: string) {
31
+ super(
32
+ `${lexicon} lexicon registry not found — it is a build artifact and this checkout has not built it.\n` +
33
+ `Run: npm run --prefix lexicons/${lexicon} prepack\n` +
34
+ `(generate writes src/generated/lexicon-${lexicon}.json; bundle writes dist/meta.json)`,
35
+ );
36
+ this.name = "LexiconRegistryMissingError";
37
+ }
38
+ }
39
+
40
+ /** One entry in a lexicon's generated registry. */
41
+ export interface LexiconRegistryEntry {
42
+ resourceType: string;
43
+ kind: string;
44
+ apiVersion?: string;
45
+ }
46
+
47
+ /**
48
+ * Read a lexicon's generated registry, dev copy first.
49
+ *
50
+ * `pkgDir` is the lexicon PACKAGE directory (the one holding `src/` and
51
+ * `dist/`) — callers pass an `import.meta.dirname`-derived path, so this stays
52
+ * free of assumptions about where it is imported from. Throws
53
+ * {@link LexiconRegistryMissingError} when neither copy exists, rather than
54
+ * returning an empty map that reads downstream as a lexicon with no resource
55
+ * types at all.
56
+ */
57
+ export function loadLexiconRegistry(pkgDir: string, lexicon: string): Record<string, LexiconRegistryEntry> {
58
+ for (const candidate of [
59
+ join(pkgDir, "src", "generated", `lexicon-${lexicon}.json`),
60
+ join(pkgDir, "dist", "meta.json"),
61
+ ]) {
62
+ try {
63
+ return JSON.parse(readFileSync(candidate, "utf-8")) as Record<string, LexiconRegistryEntry>;
64
+ } catch {
65
+ // Try the next candidate; only both failing is an error.
66
+ }
67
+ }
68
+ throw new LexiconRegistryMissingError(lexicon);
69
+ }
@@ -16,7 +16,7 @@ generic `runInterpretDriver` (#556, [`../driver.ts`](./driver.ts)), unchanged.
16
16
  ## Before / after: the ALB/ECS pipeline glue this replaces
17
17
 
18
18
  The component model's whole reason for existing is visible in one concrete
19
- diff. [`examples/gitlab-aws-alb-api/src/pipeline.ts`](../../../../../examples/gitlab-aws-alb-api/src/pipeline.ts)
19
+ diff. [`examples/gitlab-aws-alb-api/src/pipeline.ts`](../../../../examples/gitlab-aws-alb-api/src/pipeline.ts)
20
20
  hand-rolls a `deployService` job that shells out to CloudFormation and greps
21
21
  its own infra stack's outputs before it can deploy:
22
22
 
@@ -49,7 +49,7 @@ No `describe-stacks`, no `jq`, no shell string-building. `dependsOn:
49
49
  ["shared-alb"]` plus three `stackOutput()` references replace the whole glue
50
50
  block; `docker build`/`docker push` become the `docker-build`/`publish-image`
51
51
  capabilities the driver dispatches to. [`driver.test.ts`](./driver.test.ts)
52
- and [`pilots/pilots-e2e.test.ts`](./pilots/pilots-e2e.test.ts) exercise this
52
+ and [`pilots/pilots.test.ts`](./pilots/pilots.test.ts) exercise this
53
53
  exact wiring end to end against a mock `CloudExecutor` — `cfn-deploy`
54
54
  resolves the `stackOutput` reference itself; no orchestrator code parses a
55
55
  CloudFormation output.
@@ -59,7 +59,7 @@ CloudFormation output.
59
59
  [`driver.test.ts`](./driver.test.ts)'s `"runs the three pilots through one
60
60
  driver instance with zero per-component driver code (sprawl metric)"` test
61
61
  (pre-existing from #556/#557, still green) and
62
- [`pilots-e2e.test.ts`](./pilots/pilots-e2e.test.ts)'s first `describe` block
62
+ [`pilots.test.ts`](./pilots/pilots.test.ts)'s first `describe` block
63
63
  run Neo4j fan-out, DynamoDB (sticky apply), and ALB/ECS (cross-stack, build)
64
64
  through the same `runInterpretDriver` call, dispatching to the real,
65
65
  `MockCloudExecutor`-backed capability implementations from #557
@@ -103,7 +103,7 @@ uses carries the image reference into the apply step.
103
103
 
104
104
  ### The one new capability: `lambda-deploy`
105
105
 
106
- [`verbs/apply.ts`](./verbs/apply.ts)'s `createLambdaDeployCapability` is the
106
+ [`lexicons/aws/src/components/apply.ts`](../../../../lexicons/aws/src/components/apply.ts)'s `createLambdaDeployCapability` is the
107
107
  only new leaf this component required, built the same way #557 built the
108
108
  other real leaves: typed input/output, an injectable `CloudExecutor`
109
109
  (extended with a `lambda` client — [`verbs/cloud-executor.ts`](./verbs/cloud-executor.ts)),
@@ -121,7 +121,7 @@ No other verb was touched or added. `driver.ts` was not edited.
121
121
 
122
122
  [`driver.test.ts`](./driver.test.ts)'s `"runs all four components ... through
123
123
  one driver instance with zero per-component driver code (sprawl metric,
124
- extended)"` test and [`pilots-e2e.test.ts`](./pilots/pilots-e2e.test.ts)'s
124
+ extended)"` test and [`pilots.test.ts`](./pilots/pilots.test.ts)'s
125
125
  second `describe`-block test run all five components (`shared-alb`,
126
126
  `orders-table`, `neo4j-cluster`, `search-service`, `image-processor-lambda`)
127
127
  through one `runInterpretDriver` call, dispatching `image-processor-lambda`'s
@@ -0,0 +1,27 @@
1
+ import { describe, test, expect, afterEach } from "vitest";
2
+ import { resolve } from "node:path";
3
+ import { generateComponentDriverSource } from "./driver";
4
+ import { setBuildParams } from "../../params";
5
+
6
+ /**
7
+ * chant #1108 — same re-binding the entity driver does (../../discovery/
8
+ * sandbox/driver.test.ts): a `*.component.ts` file imported in the sandboxed
9
+ * child must see the parent's resolved build-time parameters, not `{}`.
10
+ */
11
+ describe("generateComponentDriverSource — build-time parameters (#1108)", () => {
12
+ afterEach(() => {
13
+ setBuildParams({});
14
+ });
15
+
16
+ test("embeds a snapshot of the parent's current params, bound before any component import", () => {
17
+ setBuildParams({ stage: "prod" });
18
+ const source = generateComponentDriverSource({ files: ["proj/svc.component.ts"] });
19
+
20
+ expect(source).toContain("import { setBuildParams } from ");
21
+ const bindAt = source.indexOf(`setBuildParams(${JSON.stringify({ stage: "prod" })});`);
22
+ const firstImportAt = source.indexOf(`await import(${JSON.stringify(resolve("proj/svc.component.ts"))})`);
23
+ expect(bindAt).toBeGreaterThan(-1);
24
+ expect(firstImportAt).toBeGreaterThan(-1);
25
+ expect(bindAt).toBeLessThan(firstImportAt);
26
+ });
27
+ });
@@ -1,4 +1,5 @@
1
1
  import { join, resolve } from "node:path";
2
+ import { params as currentBuildParams } from "../../params";
2
3
 
3
4
  /**
4
5
  * chant #1051 — generates the source of the "driver" module that runs INSIDE
@@ -29,6 +30,12 @@ const COMPONENTS_DIR = join(HERE, "..");
29
30
  /** Absolute paths to chant's OWN trusted modules the generated driver imports — resolved relative to THIS file's own location on disk, exactly like `../../discovery/sandbox/driver.ts` does for the entity path (works whether chant runs from the monorepo or a consumer's `node_modules`). */
30
31
  const DISCOVER_MODULE = join(COMPONENTS_DIR, "discover.ts");
31
32
  const CHILD_ERRORS_MODULE = join(COMPONENTS_DIR, "..", "discovery", "sandbox", "child-errors.ts");
33
+ // chant #1108 — same re-binding the entity driver does (see ../../discovery/
34
+ // sandbox/driver.ts's PARAMS_MODULE doc): the child's copy of the shared
35
+ // `params` object starts empty, so a `*.component.ts` file importing
36
+ // `@intentius/chant/params` would otherwise see `{}` under sandboxed
37
+ // discovery no matter what the parent resolved.
38
+ const PARAMS_MODULE = join(COMPONENTS_DIR, "..", "params.ts");
32
39
 
33
40
  export interface GenerateComponentDriverOptions {
34
41
  /** Absolute paths to every discovered `*.component.ts` file for this build. */
@@ -52,6 +59,11 @@ export function generateComponentDriverSource(options: GenerateComponentDriverOp
52
59
  const lines: string[] = [
53
60
  `import { collectComponents } from ${lit(DISCOVER_MODULE)};`,
54
61
  `import { classifyChildError } from ${lit(CHILD_ERRORS_MODULE)};`,
62
+ `import { setBuildParams } from ${lit(PARAMS_MODULE)};`,
63
+ ``,
64
+ // chant #1108 — snapshot of the parent's resolved build-time parameter
65
+ // values (scalars only), bound before any component file is imported.
66
+ `setBuildParams(${lit({ ...currentBuildParams })});`,
55
67
  ``,
56
68
  `function send(payload) {`,
57
69
  ` if (typeof process.send === "function") process.send(payload);`,
package/src/composite.ts CHANGED
@@ -7,14 +7,43 @@ import { setProvenance } from "./provenance";
7
7
  export const COMPOSITE_MARKER = Symbol.for("chant.composite");
8
8
 
9
9
  /**
10
- * A record of named Declarable members produced by a composite factory.
10
+ * A record of named members produced by a composite factory.
11
+ *
12
+ * What a consumer reads off `instance.members`. Deliberately narrow: every
13
+ * value here is a real Declarable, so `.entityType` and friends resolve without
14
+ * narrowing. What a factory may RETURN is wider — see
15
+ * {@link CompositeFactoryMembers}.
11
16
  */
12
17
  export type CompositeMembers = Record<string, Declarable>;
13
18
 
19
+ /**
20
+ * What a factory is allowed to RETURN — {@link CompositeMembers} plus
21
+ * `undefined`, used only as the generic constraint.
22
+ *
23
+ * A member produced by a conditional spread (`...(cond ? { policy } : {})`) is
24
+ * typed optional, and an optional property is not assignable to a
25
+ * required-value record. Widening the constraint lets such a factory typecheck;
26
+ * widening `CompositeMembers` itself would make every member possibly-undefined
27
+ * for everyone reading `instance.members`, which is a worse trade.
28
+ *
29
+ * The key is absent at runtime rather than present-and-undefined, so nothing
30
+ * reaches the validation in `Composite` below.
31
+ */
32
+ export type CompositeFactoryMembers =
33
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
34
+ | Record<string, Declarable | CompositeInstance<any> | undefined>
35
+ // A pass-through composite returns another composite's INSTANCE rather than
36
+ // building a record (`return FargateService({...})`). That works at runtime
37
+ // because `members` and `_definition` are defined non-enumerable below
38
+ // precisely so an instance exposes only its member resources — but a type
39
+ // cannot say "non-enumerable", so the instance has to be admitted directly.
40
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
41
+ | CompositeInstance<any>;
42
+
14
43
  /**
15
44
  * The result of instantiating a composite — contains the marker and expanded members.
16
45
  */
17
- export interface CompositeInstance<M extends CompositeMembers = CompositeMembers> {
46
+ export interface CompositeInstance<M extends CompositeFactoryMembers = CompositeMembers> {
18
47
  readonly [COMPOSITE_MARKER]: true;
19
48
  readonly members: M;
20
49
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -24,7 +53,7 @@ export interface CompositeInstance<M extends CompositeMembers = CompositeMembers
24
53
  /**
25
54
  * A composite definition: a callable that produces a CompositeInstance.
26
55
  */
27
- export interface CompositeDefinition<P, M extends CompositeMembers = CompositeMembers> {
56
+ export interface CompositeDefinition<P, M extends CompositeFactoryMembers = CompositeMembers> {
28
57
  (props: P): CompositeInstance<M> & M;
29
58
  readonly compositeName: string;
30
59
  readonly _id: symbol;
@@ -78,7 +107,7 @@ export class CompositeRegistry {
78
107
  * export const storage = SecureStorage({ name: "data" });
79
108
  * ```
80
109
  */
81
- export function Composite<P, M extends CompositeMembers = CompositeMembers>(
110
+ export function Composite<P, M extends CompositeFactoryMembers = CompositeMembers>(
82
111
  factory: (props: P) => M,
83
112
  name?: string,
84
113
  ): CompositeDefinition<P, M> {
@@ -56,6 +56,17 @@ export interface DeepResourceObservation {
56
56
  physicalId?: string;
57
57
  /** The normalized live property tree. JSON-safe. */
58
58
  properties: Record<string, unknown>;
59
+ /**
60
+ * Path → the field manager that owns it, where the substrate records one
61
+ * (#1189). Additive to the v1 envelope: a lexicon with no per-field ownership
62
+ * omits it, and every consumer behaves exactly as before.
63
+ *
64
+ * Only Kubernetes has this — the API server maintains `managedFields`
65
+ * server-side. It is the difference between "this field is foreign" and
66
+ * "`hpa-controller` owns `spec.replicas`", which are the same category and
67
+ * very different answers to an operator.
68
+ */
69
+ fieldOwners?: Record<string, string>;
59
70
  }
60
71
 
61
72
  /**
@@ -0,0 +1,34 @@
1
+ import { describe, test, expect, afterEach } from "vitest";
2
+ import { generateDriverSource } from "./driver";
3
+ import { setBuildParams } from "../../params";
4
+
5
+ /**
6
+ * chant #1108 — the generated child driver must re-bind the parent's resolved
7
+ * build-time parameters BEFORE importing any project file: the child process
8
+ * starts with its own empty copy of ../../params.ts's `params` object, so
9
+ * without this a run-fallback file reading `params.<name>` saw `undefined`
10
+ * under `--sandbox` while the fold path (substituting in the parent) saw the
11
+ * resolved value.
12
+ */
13
+ describe("generateDriverSource — build-time parameters (#1108)", () => {
14
+ afterEach(() => {
15
+ setBuildParams({});
16
+ });
17
+
18
+ test("embeds a snapshot of the parent's current params, bound before any project import", () => {
19
+ setBuildParams({ tier: "production-ha", replicas: 3, ha: true });
20
+ const source = generateDriverSource({ files: ["/tmp/project/a.ts"], buildRoot: "/tmp/project" });
21
+
22
+ expect(source).toContain("import { setBuildParams } from ");
23
+ const bindAt = source.indexOf(`setBuildParams(${JSON.stringify({ tier: "production-ha", replicas: 3, ha: true })});`);
24
+ const firstImportAt = source.indexOf(`await import("/tmp/project/a.ts")`);
25
+ expect(bindAt).toBeGreaterThan(-1);
26
+ expect(firstImportAt).toBeGreaterThan(-1);
27
+ expect(bindAt).toBeLessThan(firstImportAt);
28
+ });
29
+
30
+ test("with no params resolved, binds an empty snapshot (explicit, not absent)", () => {
31
+ const source = generateDriverSource({ files: ["/tmp/project/a.ts"], buildRoot: "/tmp/project" });
32
+ expect(source).toContain("setBuildParams({});");
33
+ });
34
+ });
@@ -1,4 +1,5 @@
1
1
  import { dirname, join } from "node:path";
2
+ import { params as currentBuildParams } from "../../params";
2
3
 
3
4
  /**
4
5
  * chant #1045 Phase 2 — generates the source of the "driver" module that runs
@@ -46,6 +47,16 @@ const CONFIG_WIRE_MODULE = join(HERE, "config-wire.ts");
46
47
  // chant #1131 — the policy driver's build-result decoding + diagnostics contract.
47
48
  const POLICY_WIRE_MODULE = join(HERE, "policy-wire.ts");
48
49
  const POST_SYNTH_MODULE = join(dirname(DISCOVERY_DIR), "lint", "post-synth.ts");
50
+ // chant #1108 — the shared build-time params module (../params.ts). The child
51
+ // process starts with its own, EMPTY copy of that module's `params` object;
52
+ // without re-binding, every run-fallback file imported in the child would see
53
+ // `params.* === undefined` while the fold path (which substitutes values in
54
+ // the PARENT) sees the resolved values — the exact split-brain #1064 exists
55
+ // to prevent. esbuild resolves a project file's own
56
+ // `import { params } from "@intentius/chant/params"` to this same absolute
57
+ // file, so the driver's `setBuildParams` call below mutates the one object
58
+ // every bundled import observes.
59
+ const PARAMS_MODULE = join(dirname(DISCOVERY_DIR), "params.ts");
49
60
 
50
61
  export interface GenerateDriverOptions {
51
62
  /** Absolute paths to the run-fallback files this build decided NOT to fold — see `discover()`'s fold/taint loop in `../index.ts`. */
@@ -73,9 +84,17 @@ export function generateDriverSource(options: GenerateDriverOptions): string {
73
84
  `import { encodeEntitySet } from ${lit(ENTITY_WIRE_CODEC_MODULE)};`,
74
85
  `import { classifyChildError } from ${lit(CHILD_ERRORS_MODULE)};`,
75
86
  `import { getProvenance } from ${lit(PROVENANCE_MODULE)};`,
87
+ `import { setBuildParams } from ${lit(PARAMS_MODULE)};`,
76
88
  ``,
77
89
  `const BUILD_ROOT = ${lit(buildRoot)};`,
78
90
  ``,
91
+ // chant #1108 — a snapshot of the PARENT's resolved build-time parameter
92
+ // values, embedded as a literal at generation time (the parent bound them
93
+ // via applyBuildParams/discover() before this source was generated).
94
+ // Values are declared-scalar only (BuildParamValue), so JSON round-trips
95
+ // them exactly. Bound before any project import below.
96
+ `setBuildParams(${lit({ ...currentBuildParams })});`,
97
+ ``,
79
98
  `function send(payload) {`,
80
99
  ` if (typeof process.send === "function") process.send(payload);`,
81
100
  ` else console.log(JSON.stringify(payload));`,
@@ -0,0 +1,86 @@
1
+ import { describe, test, expect, beforeAll, afterAll } from "vitest";
2
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { buildDeclaredPerStack } from "./graph-declared";
6
+
7
+ /**
8
+ * Side-by-side stacks (#1433) — several stacks declared in config, each with its
9
+ * own `src`, composed into one graph.
10
+ *
11
+ * This is chant's ordinary multi-stack shape, as distinct from directory
12
+ * partitioning (subdirectories of one `src/`), and it is the one where stack
13
+ * membership is *declared* rather than inferred: the caller passes the names, and
14
+ * `buildDeclaredPerStack` already renames every node `${stack}::${id}`. The
15
+ * grouping was being discarded, so `groups.byStack` — the axis a boundary-box
16
+ * renderer reads — came back empty for exactly the projects that have real
17
+ * stacks to box.
18
+ */
19
+ let dir: string;
20
+
21
+ // The shape discovery actually recognises — a plain object carrying the
22
+ // declarable marker, as `env.test.ts`'s fixtures do.
23
+ const stackSrc = (resource: string) =>
24
+ `export const ${resource} = { entityType: "Thing", lexicon: "aws", kind: "resource", [Symbol.for("chant.declarable")]: true };\n`;
25
+
26
+ beforeAll(() => {
27
+ dir = mkdtempSync(join(tmpdir(), "chant-side-by-side-"));
28
+ for (const [stack, resource] of [
29
+ ["network", "vpc"],
30
+ ["app", "service"],
31
+ ] as const) {
32
+ mkdirSync(join(dir, stack, "src"), { recursive: true });
33
+ writeFileSync(join(dir, stack, "src", "main.ts"), stackSrc(resource));
34
+ }
35
+ });
36
+ afterAll(() => rmSync(dir, { recursive: true, force: true }));
37
+
38
+ describe("buildDeclaredPerStack groups by declared stack (#1433)", () => {
39
+ test("every node lands in the stack that declared it", async () => {
40
+ const ir = await buildDeclaredPerStack(
41
+ [
42
+ { name: "network", src: "network/src" },
43
+ { name: "app", src: "app/src" },
44
+ ],
45
+ dir,
46
+ );
47
+ const byStack = ir.groups.byStack ?? {};
48
+ expect(Object.keys(byStack).sort()).toEqual(["app", "network"]);
49
+ // Membership uses the SAME qualified ids the nodes carry — a consumer joins
50
+ // the two without re-deriving anything.
51
+ const ids = new Set(ir.nodes.map((n) => n.id));
52
+ for (const members of Object.values(byStack)) {
53
+ for (const id of members) expect(ids.has(id)).toBe(true);
54
+ }
55
+ // Every node is in exactly one stack: a boundary-box renderer that drew from
56
+ // this would otherwise leave resources outside every box.
57
+ expect(Object.values(byStack).flat().sort()).toEqual([...ids].sort());
58
+ });
59
+
60
+ test("a stack with no src contributes no group, as it contributes no nodes", async () => {
61
+ const ir = await buildDeclaredPerStack(
62
+ [{ name: "network", src: "network/src" }, { name: "unbuilt" }],
63
+ dir,
64
+ );
65
+ expect(Object.keys(ir.groups.byStack ?? {})).toEqual(["network"]);
66
+ });
67
+
68
+ test("no stacks with src means no grouping at all, not an empty box", async () => {
69
+ const ir = await buildDeclaredPerStack([{ name: "unbuilt" }], dir);
70
+ expect(ir.groups.byStack).toBeUndefined();
71
+ expect(ir.nodes).toEqual([]);
72
+ });
73
+
74
+ test("members are sorted, so the IR stays byte-stable across runs", async () => {
75
+ const stacks = [
76
+ { name: "network", src: "network/src" },
77
+ { name: "app", src: "app/src" },
78
+ ];
79
+ const a = await buildDeclaredPerStack(stacks, dir);
80
+ const b = await buildDeclaredPerStack(stacks, dir);
81
+ expect(a.groups.byStack).toEqual(b.groups.byStack);
82
+ for (const members of Object.values(a.groups.byStack ?? {})) {
83
+ expect(members).toEqual([...members].sort());
84
+ }
85
+ });
86
+ });