@intentius/chant 0.37.2 → 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 (130) 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-mcp.d.ts +44 -0
  5. package/dist/cli/commands/check-lexicon-mcp.d.ts.map +1 -0
  6. package/dist/cli/commands/check-lexicon-plugin.d.ts +57 -0
  7. package/dist/cli/commands/check-lexicon-plugin.d.ts.map +1 -0
  8. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  9. package/dist/cli/handlers/emulator.d.ts.map +1 -1
  10. package/dist/cli/handlers/graph.d.ts.map +1 -1
  11. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  12. package/dist/cli/handlers/run-client.d.ts.map +1 -1
  13. package/dist/cli/handlers/search.d.ts +30 -1
  14. package/dist/cli/handlers/search.d.ts.map +1 -1
  15. package/dist/cli/main.d.ts.map +1 -1
  16. package/dist/cli/mcp/server.d.ts +26 -2
  17. package/dist/cli/mcp/server.d.ts.map +1 -1
  18. package/dist/cli/plugins.d.ts +20 -0
  19. package/dist/cli/plugins.d.ts.map +1 -1
  20. package/dist/codegen/registry.d.ts +23 -0
  21. package/dist/codegen/registry.d.ts.map +1 -0
  22. package/dist/components/sandbox/driver.d.ts.map +1 -1
  23. package/dist/composite.d.ts +23 -4
  24. package/dist/composite.d.ts.map +1 -1
  25. package/dist/deep-observation.d.ts +11 -0
  26. package/dist/deep-observation.d.ts.map +1 -1
  27. package/dist/discovery/sandbox/driver.d.ts.map +1 -1
  28. package/dist/graph-declared.d.ts.map +1 -1
  29. package/dist/graph-ir.d.ts +17 -3
  30. package/dist/graph-ir.d.ts.map +1 -1
  31. package/dist/graph-refs.d.ts +24 -0
  32. package/dist/graph-refs.d.ts.map +1 -1
  33. package/dist/kubectl-context.d.ts.map +1 -1
  34. package/dist/lexicon-config.d.ts +61 -0
  35. package/dist/lexicon-config.d.ts.map +1 -0
  36. package/dist/lexicon.d.ts +85 -37
  37. package/dist/lexicon.d.ts.map +1 -1
  38. package/dist/lifecycle/deep-diff.d.ts +11 -0
  39. package/dist/lifecycle/deep-diff.d.ts.map +1 -1
  40. package/dist/lifecycle/digest.d.ts.map +1 -1
  41. package/dist/lifecycle/identity.d.ts +52 -0
  42. package/dist/lifecycle/identity.d.ts.map +1 -0
  43. package/dist/lifecycle/observe.d.ts +5 -0
  44. package/dist/lifecycle/observe.d.ts.map +1 -1
  45. package/dist/lifecycle/replay.d.ts.map +1 -1
  46. package/dist/lifecycle/types.d.ts +30 -0
  47. package/dist/lifecycle/types.d.ts.map +1 -1
  48. package/dist/live-endpoint.d.ts +21 -22
  49. package/dist/live-endpoint.d.ts.map +1 -1
  50. package/dist/managed-fields.d.ts +11 -0
  51. package/dist/managed-fields.d.ts.map +1 -1
  52. package/dist/op/emulator-freshness.d.ts +44 -0
  53. package/dist/op/emulator-freshness.d.ts.map +1 -0
  54. package/dist/op/emulator-lifecycle.d.ts +36 -0
  55. package/dist/op/emulator-lifecycle.d.ts.map +1 -1
  56. package/dist/op/index.d.ts +4 -2
  57. package/dist/op/index.d.ts.map +1 -1
  58. package/dist/ownership.d.ts +33 -0
  59. package/dist/ownership.d.ts.map +1 -1
  60. package/dist/serializer.d.ts +15 -0
  61. package/dist/serializer.d.ts.map +1 -1
  62. package/package.json +1 -1
  63. package/src/audit/catalog.test.ts +58 -6
  64. package/src/build.ts +24 -0
  65. package/src/cli/commands/build.ts +10 -0
  66. package/src/cli/commands/check-lexicon-doc-drift.test.ts +73 -0
  67. package/src/cli/commands/check-lexicon-mcp.test.ts +93 -0
  68. package/src/cli/commands/check-lexicon-mcp.ts +103 -0
  69. package/src/cli/commands/check-lexicon-plugin.test.ts +149 -0
  70. package/src/cli/commands/check-lexicon-plugin.ts +115 -0
  71. package/src/cli/commands/check-lexicon.ts +176 -26
  72. package/src/cli/handlers/components.test.ts +17 -0
  73. package/src/cli/handlers/components.ts +1 -1
  74. package/src/cli/handlers/emulator.ts +12 -8
  75. package/src/cli/handlers/graph.test.ts +71 -12
  76. package/src/cli/handlers/graph.ts +46 -5
  77. package/src/cli/handlers/lifecycle.test.ts +25 -4
  78. package/src/cli/handlers/lifecycle.ts +19 -3
  79. package/src/cli/handlers/run-client.ts +3 -1
  80. package/src/cli/handlers/search-kind.test.ts +45 -0
  81. package/src/cli/handlers/search.ts +102 -4
  82. package/src/cli/main.ts +32 -10
  83. package/src/cli/mcp/server.test.ts +82 -0
  84. package/src/cli/mcp/server.ts +40 -5
  85. package/src/cli/param-flag-scope.test.ts +69 -0
  86. package/src/cli/plugins.test.ts +33 -1
  87. package/src/cli/plugins.ts +55 -0
  88. package/src/codegen/registry.test.ts +56 -0
  89. package/src/codegen/registry.ts +69 -0
  90. package/src/components/SPRAWL-VALIDATION.md +5 -5
  91. package/src/components/sandbox/driver.test.ts +27 -0
  92. package/src/components/sandbox/driver.ts +12 -0
  93. package/src/composite.ts +33 -4
  94. package/src/deep-observation.ts +11 -0
  95. package/src/discovery/sandbox/driver.test.ts +34 -0
  96. package/src/discovery/sandbox/driver.ts +19 -0
  97. package/src/graph-declared.test.ts +86 -0
  98. package/src/graph-declared.ts +14 -2
  99. package/src/graph-ir.ts +32 -8
  100. package/src/graph-refs.test.ts +56 -0
  101. package/src/graph-refs.ts +37 -1
  102. package/src/kubectl-context.ts +4 -1
  103. package/src/lexicon-config.test.ts +111 -0
  104. package/src/lexicon-config.ts +92 -0
  105. package/src/lexicon-doc-coverage.test.ts +128 -0
  106. package/src/lexicon-seams.test.ts +113 -0
  107. package/src/lexicon.ts +88 -38
  108. package/src/lifecycle/deep-diff.test.ts +48 -1
  109. package/src/lifecycle/deep-diff.ts +16 -0
  110. package/src/lifecycle/digest.test.ts +81 -0
  111. package/src/lifecycle/digest.ts +34 -3
  112. package/src/lifecycle/identity.test.ts +39 -0
  113. package/src/lifecycle/identity.ts +61 -0
  114. package/src/lifecycle/observe.test.ts +75 -1
  115. package/src/lifecycle/observe.ts +28 -2
  116. package/src/lifecycle/replay.test.ts +251 -0
  117. package/src/lifecycle/replay.ts +67 -19
  118. package/src/lifecycle/types.ts +26 -0
  119. package/src/live-endpoint.test.ts +51 -12
  120. package/src/live-endpoint.ts +32 -33
  121. package/src/managed-fields.test.ts +50 -0
  122. package/src/managed-fields.ts +25 -6
  123. package/src/meta/peer-deps.test.ts +111 -14
  124. package/src/op/emulator-declaration.test.ts +63 -0
  125. package/src/op/emulator-freshness.test.ts +135 -0
  126. package/src/op/emulator-freshness.ts +102 -0
  127. package/src/op/emulator-lifecycle.ts +49 -0
  128. package/src/op/index.ts +4 -2
  129. package/src/ownership.ts +41 -0
  130. package/src/serializer.ts +16 -0
@@ -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
+ });
@@ -22,12 +22,24 @@ export async function buildDeclaredPerStack(
22
22
  ): Promise<GraphIR> {
23
23
  const nodes: IRNode[] = [];
24
24
  const edges: GraphIR["edges"] = [];
25
+ // Which nodes belong to which stack (#1433). This is not derived or guessed:
26
+ // the stack is declared in config and every node here is being renamed with
27
+ // its name on the line below. The membership was always known and thrown
28
+ // away, which left `byStack` — the axis consumers draw boundary boxes from —
29
+ // empty for the one project shape that genuinely has side-by-side stacks.
30
+ const byStack: Record<string, string[]> = {};
25
31
  for (const st of stacks) {
26
32
  if (!st.src) continue;
27
33
  const g = buildGraphIr((await discover(resolve(projectPath, st.src))).entities, projectPath);
28
34
  const q = (id: string) => `${st.name}::${id}`;
29
- for (const n of g.nodes) nodes.push({ ...n, id: q(n.id) });
35
+ for (const n of g.nodes) {
36
+ const id = q(n.id);
37
+ nodes.push({ ...n, id });
38
+ (byStack[st.name] ??= []).push(id);
39
+ }
30
40
  for (const e of g.edges) edges.push({ ...e, from: q(e.from), to: q(e.to) });
31
41
  }
32
- return { nodes, edges, groups: {} };
42
+ for (const ids of Object.values(byStack)) ids.sort();
43
+ const groups: GraphIR["groups"] = Object.keys(byStack).length ? { byStack } : {};
44
+ return { nodes, edges, groups };
33
45
  }
package/src/graph-ir.ts CHANGED
@@ -94,9 +94,23 @@ export interface IREdge {
94
94
  export interface IRGroups {
95
95
  byLexicon?: Record<string, string[]>;
96
96
  byComposite?: Record<string, string[]>;
97
- /** Deployable-stack grouping (`stackName → nodeIds`). A stack is a lexicon
98
- * partition today; #513 phase 2 regroups by nested child-project. Consumers
99
- * (e.g. pinhole's boundary boxes) read this rather than inferring stacks. */
97
+ /**
98
+ * Deployable-stack grouping (`stackName nodeIds`). Consumers (e.g. pinhole's
99
+ * boundary boxes) read this rather than inferring stacks — which is the point,
100
+ * so it should never require one.
101
+ *
102
+ * Two sources, depending on how the project is shaped:
103
+ *
104
+ * - **side-by-side stacks**, declared in config and composed by
105
+ * `buildDeclaredPerStack` — keys are the declared stack names. Nothing is
106
+ * inferred; the project stated both the names and the membership.
107
+ * - **one source tree** — keys are lexicon partitions, since each lexicon
108
+ * serialises to one deployable stack.
109
+ *
110
+ * Formerly documented as awaiting "#513 phase 2" to regroup by nested
111
+ * child-project. #513 is closed and that phase was never filed; directory
112
+ * partitioning is the exception rather than the rule (#1433).
113
+ */
100
114
  byStack?: Record<string, string[]>;
101
115
  /** Live containment (#779): a container node id → the node ids directly inside
102
116
  * it (VPC → subnets/SGs, subnet → instances/service). Nested *flatly* — a
@@ -415,11 +429,21 @@ export function buildGraphIr(
415
429
  nodes.push(node);
416
430
 
417
431
  (byLexicon[entity.lexicon] ??= []).push(name);
418
- // A stack is a lexicon partition (each lexicon serialises to one deployable
419
- // stack a CloudFormation template, a CI config). `byStack` mirrors that
420
- // today; #513 phase 2 will regroup it by nested child-project. It's a
421
- // distinct axis from `byLexicon` (which is for provenance/colouring), so it's
422
- // emitted separately even where the two currently coincide.
432
+ // Within ONE source tree a stack is a lexicon partition each lexicon
433
+ // serialises to one deployable stack (a CloudFormation template, a CI
434
+ // config) so that is what `byStack` reports here. It stays a distinct axis
435
+ // from `byLexicon` (which is for provenance/colouring), emitted separately
436
+ // even where the two coincide.
437
+ //
438
+ // A project with genuinely separate, side-by-side stacks declares them in
439
+ // config, and `buildDeclaredPerStack` (./graph-declared.ts) groups those by
440
+ // their declared names — no inference, since the project already said. That
441
+ // is the multi-stack shape chant steers toward (#1433).
442
+ //
443
+ // This previously promised that "#513 phase 2 will regroup it by nested
444
+ // child-project". #513 is closed, that phase was never filed, and directory
445
+ // partitioning is the exception rather than the rule — so the promise is
446
+ // withdrawn rather than left pointing at a closed issue.
423
447
  (byStack[entity.lexicon] ??= []).push(name);
424
448
  if (prov?.composite) (byComposite[prov.composite] ??= []).push(name);
425
449
  }
@@ -188,3 +188,59 @@ describe("traversal name vs rendering label (#1275)", () => {
188
188
  expect(edges).toEqual([]);
189
189
  });
190
190
  });
191
+
192
+ describe("containment is traversable without being drawn", () => {
193
+ // "What is inside this" is a query, not a picture. The two had been decided
194
+ // by one field: a containment rule became a real edge only if it set
195
+ // `viaAttr`, which a renderer also reads. So making a relationship queryable
196
+ // meant drawing it, and the list of which ones had been remembered was the
197
+ // list of which questions could be asked.
198
+ const catalog: ReferenceCatalog = {
199
+ identities: [
200
+ { kind: "Subnet", ids: ["SubnetId"] },
201
+ { kind: "Vpc", ids: ["VpcId"] },
202
+ ],
203
+ refs: [
204
+ { from: "Eni", path: "SubnetId", targetKind: "Subnet", relation: "containment", label: "in subnet" },
205
+ { from: "Subnet", path: "VpcId", targetKind: "Vpc", relation: "containment", label: "in VPC" },
206
+ ],
207
+ };
208
+ const nodes = [
209
+ { id: "vpc-1", kind: "Vpc", lexicon: "x", attrs: { VpcId: "vpc-1" } },
210
+ { id: "sub-1", kind: "Subnet", lexicon: "x", attrs: { SubnetId: "sub-1", VpcId: "vpc-1" } },
211
+ { id: "sub-2", kind: "Subnet", lexicon: "x", attrs: { SubnetId: "sub-2", VpcId: "vpc-1" } },
212
+ { id: "eni-1", kind: "Eni", lexicon: "x", attrs: { SubnetId: "sub-1" } },
213
+ ];
214
+
215
+ it("still draws no containment lines — the boundary stays a boundary", () => {
216
+ const { edges } = reconstructEdges(nodes, catalog);
217
+ expect(edges).toEqual([]);
218
+ });
219
+
220
+ it("still reports the boundary pairs a renderer groups by", () => {
221
+ const { containment } = reconstructEdges(nodes, catalog);
222
+ expect(containment).toContainEqual({ child: "eni-1", parent: "sub-1", label: "in subnet" });
223
+ expect(containment).toContainEqual({ child: "sub-1", parent: "vpc-1", label: "in VPC" });
224
+ });
225
+
226
+ it("offers the same pairs as edges a query can walk", () => {
227
+ const { containmentEdges } = reconstructEdges(nodes, catalog);
228
+ expect(containmentEdges).toContainEqual({ from: "eni-1", to: "sub-1", kind: "ref", viaAttr: "SubnetId" });
229
+ expect(containmentEdges).toContainEqual({ from: "sub-1", to: "vpc-1", kind: "ref", viaAttr: "VpcId" });
230
+ });
231
+
232
+ it("no rule had to opt in — neither declares viaAttr", () => {
233
+ // The point of the change. Both rules above are plain containment; the
234
+ // traversal name is derived from the attribute the containment was read
235
+ // through, so a new kind is queryable the day its rule is written.
236
+ expect(catalog.refs.every((r) => r.viaAttr === undefined)).toBe(true);
237
+ // eni-1 -> sub-1, and both subnets -> vpc-1.
238
+ const { containmentEdges } = reconstructEdges(nodes, catalog);
239
+ expect(containmentEdges).toHaveLength(3);
240
+ });
241
+
242
+ it("an empty container is reached by nothing, which is the whole question", () => {
243
+ const { containmentEdges } = reconstructEdges(nodes, catalog);
244
+ expect(containmentEdges.some((e) => e.to === "sub-2")).toBe(false);
245
+ });
246
+ });
package/src/graph-refs.ts CHANGED
@@ -86,6 +86,30 @@ export interface DanglingRef {
86
86
  export interface ReconstructedEdges {
87
87
  edges: IREdge[];
88
88
  containment: ContainmentPair[];
89
+ /**
90
+ * The same containment pairs, as edges a query can walk.
91
+ *
92
+ * Containment is a boundary when you are drawing it and a relationship when
93
+ * you are asking about it, and those two consumers had been served by one
94
+ * decision. `edges` is what a renderer draws as lines, so putting "is in this
95
+ * VPC" there would draw a line from every resource to its VPC and undo the
96
+ * boxes; that is why containment is kept out of it, and why it stays out.
97
+ *
98
+ * But `->`/`<-` is asking which nodes reach which, and being inside something
99
+ * is a way of reaching it. "Which subnets have no network interfaces in them"
100
+ * and "which VPCs have no instances in them" are the same question, and both
101
+ * are containment. With only `edges` to walk, the negation matched everything
102
+ * and reported an estate where nothing is anywhere.
103
+ *
104
+ * The escape hatch this replaces was per-rule: a containment rule could set
105
+ * `viaAttr` and become a real edge. That put the query layer's needs in a
106
+ * field the renderer also reads, and it had to be remembered per rule —
107
+ * `AWS::EC2::Instance -> Subnet` had it and `AWS::EC2::NetworkInterface ->
108
+ * Subnet` did not, which is the kind of gap hand-maintained lists always
109
+ * develop. Deriving them here means a containment rule is traversable because
110
+ * it is a containment rule, not because someone remembered.
111
+ */
112
+ containmentEdges: IREdge[];
89
113
  dangling: DanglingRef[];
90
114
  }
91
115
 
@@ -158,6 +182,8 @@ export function reconstructEdges(nodes: IRNode[], catalog: ReferenceCatalog): Re
158
182
 
159
183
  const edges: IREdge[] = [];
160
184
  const containment: ContainmentPair[] = [];
185
+ const containmentEdges: IREdge[] = [];
186
+ const seenContEdge = new Set<string>();
161
187
  const dangling: DanglingRef[] = [];
162
188
  const seenEdge = new Set<string>();
163
189
  const seenCont = new Set<string>();
@@ -185,6 +211,15 @@ export function reconstructEdges(nodes: IRNode[], catalog: ReferenceCatalog): Re
185
211
  seenCont.add(k);
186
212
  containment.push({ child: node.id, parent: match.id, ...(rule.label ? { label: rule.label } : {}) });
187
213
  }
214
+ // Traversable by construction. The attribute the containment was read
215
+ // through is its traversal name, so `<-attr:` can still discriminate
216
+ // between two ways of being inside something.
217
+ const via = rule.viaAttr ?? rule.path;
218
+ const ke = `${node.id}|${match.id}|${via}`;
219
+ if (!seenContEdge.has(ke)) {
220
+ seenContEdge.add(ke);
221
+ containmentEdges.push({ from: node.id, to: match.id, kind: "ref", viaAttr: via });
222
+ }
188
223
  // A containment relation is a boundary hint, not an edge — unless the
189
224
  // rule declares a traversal name (#1275). A fold's first hop is
190
225
  // sometimes exactly a containment ("an instance is in a subnet"), and
@@ -202,10 +237,11 @@ export function reconstructEdges(nodes: IRNode[], catalog: ReferenceCatalog): Re
202
237
  }
203
238
 
204
239
  edges.sort((a, b) => `${a.from}|${a.to}|${a.viaAttr}`.localeCompare(`${b.from}|${b.to}|${b.viaAttr}`));
240
+ containmentEdges.sort((a, b) => `${a.from}|${a.to}|${a.viaAttr}`.localeCompare(`${b.from}|${b.to}|${b.viaAttr}`));
205
241
  containment.sort((a, b) => `${a.child}|${a.parent}`.localeCompare(`${b.child}|${b.parent}`));
206
242
  dangling.sort((a, b) => `${a.from}|${a.path}|${a.value}`.localeCompare(`${b.from}|${b.path}|${b.value}`));
207
243
 
208
- return { edges, containment, dangling };
244
+ return { edges, containment, containmentEdges, dangling };
209
245
  }
210
246
 
211
247
  /**
@@ -126,7 +126,10 @@ export async function resolveClusterTarget(
126
126
  lexiconName: string,
127
127
  options: ResolveClusterTargetOptions = {},
128
128
  ): Promise<ResolvedClusterTarget> {
129
- const k8sConfig = config.k8s as K8sConfigShape | undefined;
129
+ // #1344 the k8s lexicon declares this namespace and core validates it at
130
+ // load, so the shape is checked rather than asserted. `K8sConfigShape` stays
131
+ // as core's local description for the case where the lexicon is absent.
132
+ const k8sConfig = (config as { k8s?: K8sConfigShape }).k8s;
130
133
  const bound = k8sConfig?.profiles?.[environment]?.context;
131
134
 
132
135
  if (!bound) {