@intentius/chant 0.28.0 → 0.30.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 (106) hide show
  1. package/dist/cli/handlers/components.d.ts.map +1 -1
  2. package/dist/cli/handlers/graph.d.ts.map +1 -1
  3. package/dist/cli/handlers/lifecycle.d.ts +5 -3
  4. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  5. package/dist/cli/main.d.ts.map +1 -1
  6. package/dist/cli/registry.d.ts +14 -0
  7. package/dist/cli/registry.d.ts.map +1 -1
  8. package/dist/config.d.ts +46 -4
  9. package/dist/config.d.ts.map +1 -1
  10. package/dist/deep-observation.d.ts +257 -0
  11. package/dist/deep-observation.d.ts.map +1 -0
  12. package/dist/discovery/fold-import.d.ts +153 -17
  13. package/dist/discovery/fold-import.d.ts.map +1 -1
  14. package/dist/discovery/sandbox/config-wire.d.ts +3 -2
  15. package/dist/discovery/sandbox/config-wire.d.ts.map +1 -1
  16. package/dist/env.d.ts +5 -2
  17. package/dist/env.d.ts.map +1 -1
  18. package/dist/fold/fold.d.ts +35 -3
  19. package/dist/fold/fold.d.ts.map +1 -1
  20. package/dist/fold/subset.d.ts +9 -0
  21. package/dist/fold/subset.d.ts.map +1 -1
  22. package/dist/graph-ir.d.ts +73 -4
  23. package/dist/graph-ir.d.ts.map +1 -1
  24. package/dist/index.d.ts +2 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/kubectl-context.d.ts +27 -0
  27. package/dist/kubectl-context.d.ts.map +1 -1
  28. package/dist/lexicon.d.ts +78 -6
  29. package/dist/lexicon.d.ts.map +1 -1
  30. package/dist/lifecycle/change-set.d.ts +26 -5
  31. package/dist/lifecycle/change-set.d.ts.map +1 -1
  32. package/dist/lifecycle/deep-diff.d.ts +103 -0
  33. package/dist/lifecycle/deep-diff.d.ts.map +1 -0
  34. package/dist/lifecycle/deep-observe.d.ts +62 -0
  35. package/dist/lifecycle/deep-observe.d.ts.map +1 -0
  36. package/dist/lifecycle/index.d.ts +3 -0
  37. package/dist/lifecycle/index.d.ts.map +1 -1
  38. package/dist/lifecycle/live-diff.d.ts +25 -1
  39. package/dist/lifecycle/live-diff.d.ts.map +1 -1
  40. package/dist/lifecycle/observation-baseline.d.ts +118 -0
  41. package/dist/lifecycle/observation-baseline.d.ts.map +1 -0
  42. package/dist/lifecycle/observe.d.ts +4 -2
  43. package/dist/lifecycle/observe.d.ts.map +1 -1
  44. package/dist/lifecycle/snapshot.d.ts.map +1 -1
  45. package/dist/lifecycle/status.d.ts +26 -1
  46. package/dist/lifecycle/status.d.ts.map +1 -1
  47. package/dist/lifecycle/types.d.ts +8 -0
  48. package/dist/lifecycle/types.d.ts.map +1 -1
  49. package/dist/live-endpoint.d.ts +92 -0
  50. package/dist/live-endpoint.d.ts.map +1 -0
  51. package/dist/observation.d.ts +123 -0
  52. package/dist/observation.d.ts.map +1 -0
  53. package/dist/stack-output.d.ts.map +1 -1
  54. package/package.json +1 -1
  55. package/src/cli/handlers/components.test.ts +63 -4
  56. package/src/cli/handlers/components.ts +78 -35
  57. package/src/cli/handlers/graph.test.ts +155 -6
  58. package/src/cli/handlers/graph.ts +124 -29
  59. package/src/cli/handlers/lifecycle.test.ts +410 -6
  60. package/src/cli/handlers/lifecycle.ts +475 -182
  61. package/src/cli/main.test.ts +6 -0
  62. package/src/cli/main.ts +12 -0
  63. package/src/cli/registry.ts +14 -0
  64. package/src/config.test.ts +75 -0
  65. package/src/config.ts +61 -3
  66. package/src/deep-observation.test.ts +234 -0
  67. package/src/deep-observation.ts +489 -0
  68. package/src/discovery/fold-composite.test.ts +594 -0
  69. package/src/discovery/fold-import.test.ts +372 -1
  70. package/src/discovery/fold-import.ts +1216 -116
  71. package/src/discovery/sandbox/config-wire.ts +3 -2
  72. package/src/env.test.ts +12 -0
  73. package/src/env.ts +12 -4
  74. package/src/fold/fold.test.ts +105 -0
  75. package/src/fold/fold.ts +100 -20
  76. package/src/fold/subset.test.ts +38 -7
  77. package/src/fold/subset.ts +9 -0
  78. package/src/graph-ir-live.test.ts +28 -1
  79. package/src/graph-ir.ts +115 -12
  80. package/src/index.ts +2 -0
  81. package/src/kubectl-context.ts +81 -0
  82. package/src/lexicon.ts +100 -6
  83. package/src/lifecycle/change-set.test.ts +93 -1
  84. package/src/lifecycle/change-set.ts +65 -13
  85. package/src/lifecycle/deep-diff.test.ts +157 -0
  86. package/src/lifecycle/deep-diff.ts +213 -0
  87. package/src/lifecycle/deep-observe.test.ts +174 -0
  88. package/src/lifecycle/deep-observe.ts +173 -0
  89. package/src/lifecycle/index.ts +3 -0
  90. package/src/lifecycle/live-diff.test.ts +39 -0
  91. package/src/lifecycle/live-diff.ts +51 -5
  92. package/src/lifecycle/observation-baseline.test.ts +99 -0
  93. package/src/lifecycle/observation-baseline.ts +217 -0
  94. package/src/lifecycle/observe.test.ts +74 -3
  95. package/src/lifecycle/observe.ts +82 -22
  96. package/src/lifecycle/snapshot.test.ts +39 -1
  97. package/src/lifecycle/snapshot.ts +40 -20
  98. package/src/lifecycle/status.test.ts +89 -8
  99. package/src/lifecycle/status.ts +53 -3
  100. package/src/lifecycle/types.ts +8 -0
  101. package/src/live-endpoint.test.ts +115 -0
  102. package/src/live-endpoint.ts +148 -0
  103. package/src/observation.test.ts +96 -0
  104. package/src/observation.ts +213 -0
  105. package/src/stack-output.test.ts +55 -0
  106. package/src/stack-output.ts +41 -20
@@ -27,8 +27,9 @@
27
27
  * ## What `ChantConfig` legally holds
28
28
  *
29
29
  * Every field of `ChantConfig` (`../../config.ts`) is JSON data: string arrays
30
- * (`lexicons`, `capabilities`, `environments`), strings (`sourceDir`), nested
31
- * plain objects of strings/booleans (`ownership`, `build`, `release`, `sbom`,
30
+ * (`lexicons`, `capabilities`), strings (`sourceDir`), an array of strings or
31
+ * plain `{ name, endpoint }` objects (`environments`, #1166), nested plain
32
+ * objects of strings/booleans (`ownership`, `build`, `release`, `sbom`,
32
33
  * `signing`, `vulnPolicy`), arrays of plain objects (`stacks`), and records of
33
34
  * plain objects (`buildParams`). `lint` is a `LintConfig`, whose rule values
34
35
  * are a severity string or a `[severity, options]` tuple, and whose `plugins`
package/src/env.test.ts CHANGED
@@ -40,6 +40,18 @@ describe("unknownEnvError", () => {
40
40
  test("rejects an undeclared env with a clear message", () => {
41
41
  expect(unknownEnvError("stage", ["dev", "prod"])).toMatch(/Unknown environment "stage".*dev, prod/);
42
42
  });
43
+
44
+ // #1166 — `environments` entries may now be `{ name, endpoint }`; validation
45
+ // reduces to names either way, unaffected by an object entry's endpoint.
46
+ test("accepts an object-form declared env, mixed with bare strings", () => {
47
+ expect(unknownEnvError("floci", ["prod", { name: "floci", endpoint: "http://localhost:4566" }])).toBeUndefined();
48
+ });
49
+
50
+ test("rejects an undeclared env against a mixed string/object list, naming both", () => {
51
+ expect(
52
+ unknownEnvError("stage", ["prod", { name: "floci", endpoint: "http://localhost:4566" }]),
53
+ ).toMatch(/Unknown environment "stage".*prod, floci/);
54
+ });
43
55
  });
44
56
 
45
57
  describe("env-aware discovery (#505)", () => {
package/src/env.ts CHANGED
@@ -18,6 +18,8 @@
18
18
  * pinhole renders/diffs them to show environment drift (INTENTIUS/pinhole#3).
19
19
  */
20
20
 
21
+ import { environmentNames, type EnvironmentDeclaration } from "./config";
22
+
21
23
  /** The environment variable the CLI sets from `--env`. */
22
24
  export const ENV_VAR = "CHANT_ENV";
23
25
 
@@ -30,10 +32,16 @@ export function env(fallback?: string): string | undefined {
30
32
  * Validate a requested environment against the project's declared `environments`
31
33
  * (`chant.config`). Returns an error message for an unknown env, or `undefined`
32
34
  * when it's valid (or when the project declares no environments, in which case
33
- * any name is accepted).
35
+ * any name is accepted). `declared` entries may be a bare name or `{ name,
36
+ * endpoint }` (#1166) — {@link environmentNames} reduces either to the names
37
+ * this checks against.
34
38
  */
35
- export function unknownEnvError(requested: string | undefined, declared: string[] | undefined): string | undefined {
39
+ export function unknownEnvError(
40
+ requested: string | undefined,
41
+ declared: EnvironmentDeclaration[] | undefined,
42
+ ): string | undefined {
36
43
  if (!requested || !declared || declared.length === 0) return undefined;
37
- if (declared.includes(requested)) return undefined;
38
- return `Unknown environment "${requested}". Declared environments: ${declared.join(", ")}.`;
44
+ const names = environmentNames(declared) ?? [];
45
+ if (names.includes(requested)) return undefined;
46
+ return `Unknown environment "${requested}". Declared environments: ${names.join(", ")}.`;
39
47
  }
@@ -810,3 +810,108 @@ describe("foldModule", () => {
810
810
  expect(result.bad?.ok).toBe(false);
811
811
  });
812
812
  });
813
+
814
+ /**
815
+ * chant #1169 — a `new Type(...)` used as a VALUE.
816
+ *
817
+ * `fold()` produces the same {@link FoldedResource} envelope for a nested
818
+ * construction as for a top-level one, and it is symbolic in exactly the sense
819
+ * `{__intrinsic}`/`{__helper}` are: nothing is executed here, the constructor's
820
+ * NAME and folded arguments are recorded, and ../discovery/fold-import.ts
821
+ * resolves the name through the file's own imports and builds the real
822
+ * instance. These tests own the envelope; the construction half is
823
+ * ../discovery/fold-import.test.ts's.
824
+ */
825
+ describe("constructions as values (chant #1169)", () => {
826
+ test("a nested construction folds to a `{__resource}` envelope, recursively", () => {
827
+ const src = `
828
+ export const job = new Job({
829
+ image: new Image({ name: "node:22" }),
830
+ rules: [new Rule({ if: "$CI" })],
831
+ deep: { inner: new Guest({ cpus: new Count({ n: 1 }) }) },
832
+ });
833
+ `;
834
+ expect(foldModule(src).job).toEqual({
835
+ ok: true,
836
+ spec: {
837
+ __resource: "Job",
838
+ props: {
839
+ image: { __resource: "Image", props: { name: "node:22" } },
840
+ rules: [{ __resource: "Rule", props: { if: "$CI" } }],
841
+ deep: {
842
+ inner: { __resource: "Guest", props: { cpus: { __resource: "Count", props: { n: 1 } } } },
843
+ },
844
+ },
845
+ },
846
+ });
847
+ });
848
+
849
+ test("a nested construction with a non-props-first signature keeps its positional `args`", () => {
850
+ // chant #1082's shape, nested. `props` stays the first object-literal
851
+ // argument (a view, for readers); `args` is what the entity is built from.
852
+ const src = `export const p = new Outer({ x: new Parameter("String", { Default: "dev" }) });`;
853
+ const result = foldModule(src);
854
+ expect(result.p?.ok).toBe(true);
855
+ if (!result.p?.ok) return;
856
+ expect(result.p.spec.props.x).toEqual({
857
+ __resource: "Parameter",
858
+ props: { Default: "dev" },
859
+ args: ["String", { Default: "dev" }],
860
+ });
861
+ });
862
+
863
+ test("a nested `new ns.Type(...)` is rejected — the class must be reachable through a named import", () => {
864
+ const src = `export const job = new Job({ image: new ns.Image({ name: "x" }) });`;
865
+ const result = foldModule(src);
866
+ expect(result.job?.ok).toBe(false);
867
+ if (result.job?.ok) return;
868
+ expect(result.job?.error).toContain("needs a plain imported constructor");
869
+ });
870
+
871
+ test("a BARE reference to a same-file resource const is rejected without a caller that can construct", () => {
872
+ // `fold()` on its own has no module graph, so it cannot hand back the
873
+ // instance the reference means — and re-folding the initializer would build
874
+ // a duplicate. Rejected; `externals` (the bridge's pre-resolved same-file
875
+ // instances) is the only thing that answers this.
876
+ const src = `
877
+ const db = new DbCluster({ engine: "aurora" });
878
+ export const worker = new Instance({ needs: [db] });
879
+ `;
880
+ const result = foldModule(src);
881
+ expect(result.worker?.ok).toBe(false);
882
+ if (result.worker?.ok) return;
883
+ expect(result.worker?.error).toContain("same-file resource `db` used as a value is not foldable");
884
+ });
885
+
886
+ test("`externals` answers a same-file resource reference when a caller pre-resolved it", () => {
887
+ const src = `
888
+ const db = new DbCluster({ engine: "aurora" });
889
+ export const worker = new Instance({ needs: [db] });
890
+ `;
891
+ const sourceFile = ts.createSourceFile("t.ts", src, ts.ScriptTarget.Latest, true);
892
+ const consts = collectConsts(sourceFile);
893
+ const liveDb = { pretendInstance: true };
894
+
895
+ const folded = fold(consts.get("worker") as ts.Expression, consts, [], new Map([["db", liveDb]]));
896
+ // The exact object, not a copy of it: identity is what makes the reference
897
+ // and the registered entity the same thing.
898
+ expect((folded as { props: { needs: unknown[] } }).props.needs[0]).toBe(liveDb);
899
+ });
900
+
901
+ test("an ATTRIBUTE reference to a same-file resource is unaffected — still the `{__attrRef}` envelope", () => {
902
+ const src = `
903
+ const db = new DbCluster({ engine: "aurora" });
904
+ export const worker = new Instance({ host: db.Endpoint });
905
+ `;
906
+ const sourceFile = ts.createSourceFile("t.ts", src, ts.ScriptTarget.Latest, true);
907
+ const consts = collectConsts(sourceFile);
908
+
909
+ const folded = fold(consts.get("worker") as ts.Expression, consts, [], new Map([["db", { live: true }]]));
910
+ // `consts` is consulted before `externals` for a property access, so a
911
+ // pre-resolved instance does not change what a sibling attribute reference
912
+ // folds to.
913
+ expect((folded as { props: Record<string, unknown> }).props.host).toEqual({
914
+ __attrRef: { entity: "db", attribute: "Endpoint" },
915
+ });
916
+ });
917
+ });
package/src/fold/fold.ts CHANGED
@@ -27,12 +27,23 @@ import { isFoldableHelperName } from "./foldable-helpers";
27
27
  * cross-resource `{ __attrRef }` case, literal-key-only), unary `!`/`-`,
28
28
  * the binary operators `+ - * / === !== > < >= <=`, short-circuit
29
29
  * `&& || ??`, conditional expressions, `as`/`satisfies`/`!`/parenthesized
30
- * unwrapping, a nested `new Type({...})` resource-as-value, and registered
31
- * lexicon intrinsic tagged templates — is defined ONCE, in {@link "./subset"}
32
- * ({@link findSubsetViolation}), and shared with EVL001/EVL003
30
+ * unwrapping, a nested `new Type({...})` resource-as-value (chant #1169), and
31
+ * registered lexicon intrinsic tagged templates — is defined ONCE, in
32
+ * {@link "./subset"} ({@link findSubsetViolation}), and shared with EVL001/EVL003
33
33
  * ({@link "../lint/rules/evl001-non-literal-expression"}), so the linted
34
34
  * subset and the folded subset can never drift apart (#1024).
35
35
  *
36
+ * chant #1169 closed the largest of the documented fold/EVL divergences in the
37
+ * process: a nested `new Type(...)` used as a value was shape-valid for
38
+ * `./subset` and rejected by `fold()`, because `fold()` could only produce the
39
+ * `{__resource, props}` envelope and nothing constructed it. Now it folds to
40
+ * that envelope and ../discovery/fold-import.ts constructs the REAL instance
41
+ * from it. The remaining divergences are the resolution-dependent ones
42
+ * ./subset's own module doc enumerates, plus one this change adds in the same
43
+ * safe direction: a BARE identifier bound to a same-file `new` is shape-valid
44
+ * there and rejected here, because folding it would build a duplicate of a
45
+ * resource discovery already registered — see {@link fold}'s identifier branch.
46
+ *
36
47
  * A `CallExpression` has almost no case — a function call as a value is
37
48
  * structurally unrepresentable, not merely linted against. Composite
38
49
  * factory calls are out of scope here (epic Phase 5, #1023). There are
@@ -170,6 +181,15 @@ export interface SymbolicValue {
170
181
 
171
182
  /**
172
183
  * The result of folding a resource constructor: `new Type({ ...props })`.
184
+ *
185
+ * chant #1169 — produced for a NESTED `new Type(...)` used as a value too, not
186
+ * just for a file's own top-level resource declaration. It is symbolic in
187
+ * exactly the sense {@link FoldedIntrinsic} and {@link FoldedHelperCall} are:
188
+ * `fold()` executes nothing, it records which constructor the source named and
189
+ * with what arguments. ../discovery/fold-import.ts resolves that name through
190
+ * the folding file's own imports and calls the real class, so a folded
191
+ * construction and a run construction are the same construction. An envelope
192
+ * must never reach a serializer — see the `new` branch of {@link fold}.
173
193
  */
174
194
  export interface FoldedResource {
175
195
  __resource: string;
@@ -298,8 +318,18 @@ export function collectConsts(sourceFile: ts.SourceFile): Map<string, ts.Express
298
318
  return consts;
299
319
  }
300
320
 
301
- /** A property/element key foldable without execution: identifier, string, or numeric literal. */
302
- function propName(node: ts.PropertyName): string {
321
+ /**
322
+ * A property/element key foldable without execution: identifier, string, or
323
+ * numeric literal.
324
+ *
325
+ * Exported for chant #1023's composite-factory interpreter
326
+ * (../discovery/fold-import.ts), which walks object literals itself — a
327
+ * factory body may construct a resource inside one, which {@link fold} has no
328
+ * case for — and must reject a computed key with the identical message
329
+ * {@link fold} would, rather than growing a second, silently divergent copy of
330
+ * this rule.
331
+ */
332
+ export function propName(node: ts.PropertyName): string {
303
333
  if (isLiteralPropertyName(node)) return node.text;
304
334
  throw foldError(node, computedPropertyNameMessage(node));
305
335
  }
@@ -536,7 +566,37 @@ export function fold(
536
566
  }
537
567
  throw foldError(node, `unresolved identifier: ${node.text}`);
538
568
  }
539
- return fold(consts.get(node.text) as ts.Expression, consts, intrinsics, externals);
569
+ const initializer = consts.get(node.text) as ts.Expression;
570
+ // chant #1169 — a BARE reference to a same-file `const x = new T(...)` is a
571
+ // reference to THAT resource instance, and the ONE thing it must never
572
+ // become is a second one. Re-folding the initializer here would do exactly
573
+ // that now that the `new` branch below constructs: the consumer would get a
574
+ // duplicate object discovery never registered, whose `AttrRef`s could never
575
+ // be assigned a logical name ("Cannot serialize AttrRef …: logical name not
576
+ // set") and whose `Ref` would silently inline instead of referencing. A
577
+ // crash or wrong output, not drift.
578
+ //
579
+ // So `externals` — and ONLY `externals` — answers this one. A caller with a
580
+ // module graph (../discovery/fold-import.ts) pre-resolves each of this
581
+ // file's `new`-valued consts to ONE instance, in source order, before any
582
+ // declarator is folded, and puts it here; every reference in the file then
583
+ // reads that same object, exactly as running the module top-to-bottom
584
+ // would. A caller without one (`foldModule`, a unit test) has no way to
585
+ // construct anything, so the reference stays a rejection and the file falls
586
+ // back to run.
587
+ //
588
+ // `bucket.name` is a different question and keeps its answer regardless: the
589
+ // property-access branch below consults `consts` first, so a sibling
590
+ // attribute reference is still the symbolic `{__attrRef}` the serializer
591
+ // resolves by NAME. Nothing about the existing envelope changes.
592
+ if (ts.isNewExpression(initializer)) {
593
+ if (externals?.has(node.text)) return externals.get(node.text) as FoldedValue;
594
+ throw foldError(
595
+ node,
596
+ `same-file resource \`${node.text}\` used as a value is not foldable — falls back to run`,
597
+ );
598
+ }
599
+ return fold(initializer, consts, intrinsics, externals);
540
600
  }
541
601
 
542
602
  if (ts.isPropertyAccessExpression(node)) {
@@ -626,20 +686,40 @@ export function fold(
626
686
  }
627
687
 
628
688
  if (ts.isNewExpression(node)) {
629
- // A nested `new Type({...})` used as a property VALUE is not leaf-foldable.
630
- // fold can only produce the {__resource, props} envelope, and unlike a
631
- // TOP-LEVEL resource, which fold-import constructs into a real Declarable
632
- // a nested one is never constructed, so the envelope leaks into serialization
633
- // as the wrong value (real fold-vs-run drift; the #1025 differential caught
634
- // this on gitlab/multi-stage-deploy, where `new Image({...})` as a job's
635
- // `image:` must serialize as `{ name }`, not `{ __resource, props }`).
636
- // Reject so the file falls back to run, which constructs and serializes it
637
- // correctly. EVL permits this statically — it's a documented fold/EVL
638
- // divergence, like identifier resolution and spread runtime type.
639
- throw foldError(
640
- node,
641
- `nested \`new ${briefNodeText(node.expression)}(...)\` as a value is not foldable falls back to run`,
642
- );
689
+ // chant #1169 — a nested `new Type({...})` used as a property VALUE folds
690
+ // to the SAME {@link FoldedResource} envelope a top-level resource
691
+ // declaration does, and is constructed for real by the same bridge.
692
+ //
693
+ // This used to be an unconditional rejection, and the reason it was is
694
+ // worth keeping in view: `fold()` alone can only produce the envelope, and
695
+ // an envelope that reaches serialization is the wrong value the #1025
696
+ // differential caught exactly that on gitlab/multi-stage-deploy, where
697
+ // `new Image({...})` as a job's `image:` must serialize as the constructed
698
+ // Image's own shape, not as `{__resource, props}`. What changed is not the
699
+ // envelope's safety but who consumes it: ../discovery/fold-import.ts's
700
+ // `reviveFoldedValue` now REVIVES a `{__resource}` node into a real
701
+ // instance, built by the class the file's own `import` names, resolved
702
+ // through the same provenance-checked machinery that already constructs a
703
+ // top-level resource (and that #1023's factory interpreter already uses to
704
+ // construct a nested `new` inside a factory body — the asymmetry that
705
+ // motivated this change). Nothing symbolic survives into the serializer:
706
+ // the value the outer constructor receives is the same object the run path
707
+ // would have handed it, with the same prototype, the same `props`, and the
708
+ // same `toJSON`/`kind` the serializer's walker dispatches on.
709
+ //
710
+ // Only a PLAIN IDENTIFIER constructor is admissible. `new ns.Type(...)`
711
+ // cannot be resolved to a live class through the file's named imports, so
712
+ // it stays a rejection rather than folding to an envelope nothing can
713
+ // revive — the same bare-identifier rule #1023's `checkFactoryExpression`
714
+ // applies to a body-level construction, and the same one `fold()` applies
715
+ // to an intrinsic call and an authoring helper.
716
+ if (!ts.isIdentifier(node.expression)) {
717
+ throw foldError(
718
+ node,
719
+ `nested \`new ${briefNodeText(node.expression)}(...)\` as a value needs a plain imported constructor — falls back to run`,
720
+ );
721
+ }
722
+ return foldResource(node, consts, intrinsics, externals);
643
723
  }
644
724
 
645
725
  if (ts.isCallExpression(node)) {
@@ -258,18 +258,49 @@ describe("documented divergences — NOT unified by design (see subset.ts module
258
258
  expect(evl001NonLiteralExpressionRule.check(context)).toHaveLength(0);
259
259
  });
260
260
 
261
- test("nested resource construction: fold rejects a nested `new Type()` used as a value (falls back to run); EVL001 allows it statically", () => {
262
- // A top-level `new Type()` folds (fold-import constructs a real Declarable),
263
- // but a NESTED one as a property value can only fold to a {__resource,props}
264
- // envelope that is never constructed, so it would serialize wrong (real
265
- // fold-vs-run drift, caught by the #1025 differential on gitlab). fold()
266
- // therefore rejects it, falling the file back to run; EVL allows it (a valid
267
- // TS constructor call), so this is an inherent, out-of-scope divergence.
261
+ test("nested resource construction: NO LONGER a divergence (chant #1169) both fold and EVL001 accept it", () => {
262
+ // This was the largest divergence in the table until #1169: a nested
263
+ // `new Type()` as a property value could only fold to a {__resource, props}
264
+ // envelope nothing constructed, so it would have serialized wrong (real
265
+ // fold-vs-run drift, caught by the #1025 differential on gitlab), and fold()
266
+ // rejected it while EVL allowed it.
267
+ //
268
+ // fold() now produces the envelope and ../discovery/fold-import.ts revives
269
+ // it into a REAL instance of the class the file imported, so the two sides
270
+ // agree. Kept as a test rather than deleted: it is the assertion that the
271
+ // divergence stays closed, and that the envelope carries the nested
272
+ // constructor's own name and props for the bridge to build from.
268
273
  const source = `const bad = new Thing({ x: new Inner({ y: 1 }) });`;
269
274
  const sourceFile = ts.createSourceFile("t.ts", source, ts.ScriptTarget.Latest, true);
270
275
  const consts = collectConsts(sourceFile);
271
276
  const badInit = consts.get("bad") as ts.NewExpression;
272
277
 
278
+ expect(foldResource(badInit, consts, [])).toEqual({
279
+ __resource: "Thing",
280
+ props: { x: { __resource: "Inner", props: { y: 1 } } },
281
+ });
282
+
283
+ const context: LintContext = { sourceFile, entities: [], filePath: "t.ts", lexicon: undefined };
284
+ expect(evl001NonLiteralExpressionRule.check(context)).toHaveLength(0);
285
+ });
286
+
287
+ test("same-file resource used as a bare value: fold rejects it (would build a duplicate); EVL001 allows it statically", () => {
288
+ // chant #1169's own new divergence, in the same safe direction as every
289
+ // other one here. `DependsOn: [dbCluster]` hands the run path THE instance
290
+ // this file already exported; folding the identifier would construct a
291
+ // SECOND one, which discovery never registers and whose AttrRefs can never
292
+ // be named. Rejected, so the file falls back to run — where both references
293
+ // are the same object by construction. EVL sees a plain identifier and has
294
+ // no binding resolver (subset.ts module doc, point 1), so it stays
295
+ // permissive.
296
+ const source = `
297
+ const db = new DbCluster({ engine: "aurora" });
298
+ const bad = new Instance({ x: 1 }, { DependsOn: [db] });
299
+ `;
300
+ const sourceFile = ts.createSourceFile("t.ts", source, ts.ScriptTarget.Latest, true);
301
+ const consts = collectConsts(sourceFile);
302
+ const badInit = consts.get("bad") as ts.NewExpression;
303
+
273
304
  expect(() => foldResource(badInit, consts, [])).toThrow(FoldError);
274
305
 
275
306
  const context: LintContext = { sourceFile, entities: [], filePath: "t.ts", lexicon: undefined };
@@ -35,6 +35,15 @@ import { intrinsicCallFolds, type IntrinsicDef } from "../lexicon";
35
35
  * intentional asymmetry, not a bug: it can only ever be a *false
36
36
  * negative* on EVL's part (EVL passes something `fold()` might later
37
37
  * reject for being unresolved), never the reverse.
38
+ *
39
+ * chant #1169 adds one more resolution-dependent rejection in the same
40
+ * direction: an identifier bound to a same-file `const x = new T(...)`,
41
+ * used as a VALUE. It is shape-valid here; `fold()` answers it only when
42
+ * its caller pre-resolved that const to the one real instance the file
43
+ * built (`../discovery/fold-import.ts`), and rejects otherwise, because
44
+ * re-folding the initializer would construct a duplicate of a resource
45
+ * discovery has already registered. Shape cannot see the difference, and
46
+ * the rejection is a fall-back-to-run, never a wrong value.
38
47
  * 2. Tagged-template *tag registration* — needs a lexicon's intrinsics
39
48
  * manifest, which isn't available to a syntax-only lint rule. `fold()`
40
49
  * alone checks it; this module treats any tag name as shape-valid and
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { buildLiveGraphIr, overlayGraphs, sourceOverlayGraphs, type LiveObservation, type GraphIR } from "./graph-ir";
2
+ import { buildLiveGraphIr, collectUnobserved, overlayGraphs, sourceOverlayGraphs, type LiveObservation, type GraphIR } from "./graph-ir";
3
3
 
4
4
  // A fixture "snapshot" — what a lexicon's describeResources() returns for a live
5
5
  // environment (managed-only). Two AWS resources; the subnet references the VPC by
@@ -140,4 +140,31 @@ describe("sourceOverlayGraphs (#821 source-anchored overlay)", () => {
140
140
  const ir = sourceOverlayGraphs(declared, liveDup);
141
141
  expect(ir.edges.filter((e) => e.from === "app-ingress" && e.to === "web-vpc")).toHaveLength(1);
142
142
  });
143
+
144
+ // #1089 — "not deployed yet" is a claim the read has to support.
145
+ it("paints a declared node nobody could read `neutral`, not `accent`", () => {
146
+ const ir = sourceOverlayGraphs(declared, live, {
147
+ unobserved: { "planned-db": { reason: "no-binding", detail: "no kubectl context" } },
148
+ });
149
+ const node = ir.nodes.find((x) => x.id === "planned-db")!;
150
+ expect((node.attrs as { _status?: string })._status).toBe("neutral");
151
+ expect((node.attrs as { _unobserved?: string })._unobserved).toBe("no-binding");
152
+ });
153
+
154
+ it("still paints a confirmed-absent declared node `accent`", () => {
155
+ const ir = sourceOverlayGraphs(declared, live, { unobserved: {} });
156
+ expect((ir.nodes.find((x) => x.id === "planned-db")!.attrs as { _status?: string })._status).toBe("accent");
157
+ });
158
+ });
159
+
160
+ describe("collectUnobserved (#1089)", () => {
161
+ it("unions every observation's holes", () => {
162
+ expect(
163
+ collectUnobserved([
164
+ { lexicon: "aws", resources: {}, unobserved: { a: { reason: "read-failed" } } },
165
+ { lexicon: "k8s", resources: {} },
166
+ { lexicon: "gcp", resources: {}, unobserved: { b: { reason: "no-binding" } } },
167
+ ]),
168
+ ).toEqual({ a: { reason: "read-failed" }, b: { reason: "no-binding" } });
169
+ });
143
170
  });
package/src/graph-ir.ts CHANGED
@@ -6,6 +6,7 @@ import { isLexiconOutput, type LexiconOutput } from "./lexicon-output";
6
6
  import { getProvenance } from "./provenance";
7
7
  import { INTRINSIC_MARKER } from "./intrinsic";
8
8
  import type { ResourceMetadata } from "./lexicon";
9
+ import type { UnobservedEntity } from "./observation";
9
10
 
10
11
  /**
11
12
  * Graph IR — the engine-neutral, lint-gated representation of a project's
@@ -123,6 +124,51 @@ export interface IRImport {
123
124
  node: string;
124
125
  }
125
126
 
127
+ /**
128
+ * One generated CI job in the pipeline projection (`chant graph --components
129
+ * --format ir --projection <lexicon>`, #989) — the same job a CI-provider
130
+ * lexicon's `generateComponentPipeline` synthesizes for `chant build
131
+ * --components --generate <lexicon>` (see `ComponentPipelineJob`,
132
+ * ./lexicon.ts), reshaped into the IR's node vocabulary.
133
+ */
134
+ export interface IRPipelineNode {
135
+ /** CI job name (the generator's job id — a safe YAML/workflow key). */
136
+ id: string;
137
+ kind: "CIJob";
138
+ /** The component this job triggers. */
139
+ component: string;
140
+ /** The stage/wave this job runs in — the same wave index as the component
141
+ * graph's `groups.byWave` (one CI stage/`needs:`-level per wave). */
142
+ stage: string;
143
+ }
144
+
145
+ /** A `needs:` dependency between two generated CI jobs — mirrors the
146
+ * `dependsOn` edge it derives from, consumer job → producer job. */
147
+ export interface IRPipelineEdge {
148
+ from: string;
149
+ to: string;
150
+ kind: "needs";
151
+ }
152
+
153
+ /**
154
+ * The CI/pipeline projection of a component graph (#989): the stages/jobs/
155
+ * `needs` a CI-provider lexicon (gitlab, github, forgejo, or any lexicon
156
+ * implementing `generateComponentPipeline`) would synthesize for `chant build
157
+ * --components --generate <lexicon>`, reused here — never re-derived — as
158
+ * first-class IR nodes/edges. A consumer (e.g. behold) reads this alongside
159
+ * the component graph's `nodes`/`edges`/`groups.byWave` to render the CI
160
+ * shape without parsing generated YAML. Present only when `chant graph
161
+ * --components --format ir` is invoked with `--projection <lexicon>`.
162
+ */
163
+ export interface IRPipeline {
164
+ /** The CI-provider lexicon that produced this projection (e.g. "gitlab"). */
165
+ provider: string;
166
+ /** Wave-ordered stage names — 1:1 with the component graph's `groups.byWave` keys. */
167
+ stages: string[];
168
+ nodes: IRPipelineNode[];
169
+ edges: IRPipelineEdge[];
170
+ }
171
+
126
172
  /** The full graph IR for a project at the default (declarable) detail level. */
127
173
  export interface GraphIR {
128
174
  nodes: IRNode[];
@@ -134,6 +180,8 @@ export interface GraphIR {
134
180
  * `name` to another stack's export `name` to draw the cross-stack edge; the
135
181
  * parameter's in-stack consumers are ordinary `$ref` edges to it (#513). */
136
182
  imports?: IRImport[];
183
+ /** The CI/pipeline projection alongside the component graph (#989) — see {@link IRPipeline}. */
184
+ pipeline?: IRPipeline;
137
185
  }
138
186
 
139
187
  /** A node is anything that serializes to a resource — not a property or output. */
@@ -419,6 +467,12 @@ function sortKeys(rec: Record<string, string[]>): Record<string, string[]> {
419
467
  export interface LiveObservation {
420
468
  lexicon: string;
421
469
  resources: Record<string, ResourceMetadata>;
470
+ /**
471
+ * Declared entities the lexicon could not observe (#1089), keyed by name.
472
+ * They are not live nodes — but they are not confirmed-absent either, so the
473
+ * overlay must not paint them "pending". See {@link sourceOverlayGraphs}.
474
+ */
475
+ unobserved?: Record<string, UnobservedEntity>;
422
476
  }
423
477
 
424
478
  /**
@@ -446,7 +500,10 @@ export function buildLiveGraphIr(observations: LiveObservation[]): GraphIR {
446
500
  attrs: meta.attributes ?? {},
447
501
  };
448
502
  if (meta.physicalId) node.physicalId = meta.physicalId;
449
- if (meta.ownership) node.ownership = meta.ownership;
503
+ // `unknown` is a legitimate verdict on the metadata (#1089) but carries no
504
+ // information for a painter, and the IR's `ownership` field means "a
505
+ // verdict was reached" — so only owned/foreign land on the node.
506
+ if (meta.ownership === "owned" || meta.ownership === "foreign") node.ownership = meta.ownership;
450
507
  nodes.push(node);
451
508
  (byLexicon[lexicon] ??= []).push(name);
452
509
  // A live lexicon maps to one deployable stack, same as the source IR.
@@ -465,22 +522,60 @@ export function buildLiveGraphIr(observations: LiveObservation[]): GraphIR {
465
522
  return { nodes, edges: [], groups };
466
523
  }
467
524
 
525
+ /** How an overlay learns which declared nodes were never looked at (#1089). */
526
+ export interface OverlayOptions {
527
+ /**
528
+ * Declared entities the observation could not read, keyed by name (union of
529
+ * every {@link LiveObservation}'s `unobserved`). They are tagged `neutral`
530
+ * instead of `accent`: "not yet provisioned" is a claim the read never
531
+ * supported.
532
+ */
533
+ unobserved?: Record<string, UnobservedEntity>;
534
+ }
535
+
536
+ /** The union of every observation's unobserved entities — the input to the overlays. */
537
+ export function collectUnobserved(observations: LiveObservation[]): Record<string, UnobservedEntity> {
538
+ const out: Record<string, UnobservedEntity> = {};
539
+ for (const o of observations) Object.assign(out, o.unobserved ?? {});
540
+ return out;
541
+ }
542
+
543
+ /** Paint status a node carries in an overlay. `neutral` = chant could not look. */
544
+ type OverlayNodeStatus = "good" | "warn" | "accent" | "neutral";
545
+
546
+ function tagStatus(n: IRNode, status: OverlayNodeStatus, unobserved?: UnobservedEntity): IRNode {
547
+ return {
548
+ ...n,
549
+ attrs: {
550
+ ...n.attrs,
551
+ _status: status,
552
+ ...(unobserved ? { _unobserved: unobserved.reason } : {}),
553
+ },
554
+ };
555
+ }
556
+
468
557
  /**
469
558
  * Overlay the declared graph on the provisioned one (#780, `chant graph --live
470
559
  * --overlay`) and classify each resource, tagging a `_status` a renderer colours:
471
560
  * - **managed** (declared + provisioned) → `good`
472
561
  * - **foreign** (provisioned, not declared) → `warn`
473
- * - **pending** (declared, not yet provisioned) → `accent`
562
+ * - **pending** (declared, provider confirmed absent) → `accent`
563
+ * - **unobserved** (declared, chant could not look — #1089) → `neutral`,
564
+ * plus an `_unobserved` attr carrying the reason
474
565
  * Live nodes keep their edges/containment; pending nodes are appended (they have
475
566
  * no live edges). Sorted; the live groups pass through unchanged.
476
567
  */
477
- export function overlayGraphs(live: GraphIR, declared: GraphIR): GraphIR {
568
+ export function overlayGraphs(live: GraphIR, declared: GraphIR, opts?: OverlayOptions): GraphIR {
478
569
  const declaredIds = new Set(declared.nodes.map((n) => n.id));
479
570
  const liveIds = new Set(live.nodes.map((n) => n.id));
480
- const tagged = (n: IRNode, status: "good" | "warn" | "accent"): IRNode => ({ ...n, attrs: { ...n.attrs, _status: status } });
571
+ const unobserved = opts?.unobserved ?? {};
481
572
 
482
- const nodes: IRNode[] = live.nodes.map((n) => tagged(n, declaredIds.has(n.id) ? "good" : "warn"));
483
- for (const n of declared.nodes) if (!liveIds.has(n.id)) nodes.push(tagged(n, "accent"));
573
+ const nodes: IRNode[] = live.nodes.map((n) => tagStatus(n, declaredIds.has(n.id) ? "good" : "warn"));
574
+ for (const n of declared.nodes) {
575
+ if (liveIds.has(n.id)) continue;
576
+ const u = unobserved[n.id];
577
+ nodes.push(u ? tagStatus(n, "neutral", u) : tagStatus(n, "accent"));
578
+ }
484
579
  nodes.sort((a, b) => a.id.localeCompare(b.id));
485
580
 
486
581
  return { ...live, nodes };
@@ -497,27 +592,35 @@ export function overlayGraphs(live: GraphIR, declared: GraphIR): GraphIR {
497
592
  * Each declared node is classified against live observation and tagged `_status`:
498
593
  * - **managed** (declared + provisioned) → `good`, carrying the observed
499
594
  * `physicalId` / `ownership` onto the declared node
500
- * - **pending** (declared, not provisioned) → `accent`
595
+ * - **pending** (declared, provider confirmed absent) → `accent`
596
+ * - **unobserved** (declared, chant could not look — #1089) → `neutral`, with
597
+ * the reason on `_unobserved`. A wrong-cluster or unsupported-kind read used
598
+ * to paint the whole estate "pending", which is the diagram equivalent of
599
+ * planning a create for something that already exists.
501
600
  * **Foreign** resources (provisioned, not declared) are appended and tagged
502
601
  * `warn`, together with any live-reconstructed edges that touch them — a declared
503
602
  * edge cannot describe an undeclared resource. Declared groups/exports pass
504
603
  * through unchanged; nodes and edges are sorted for deterministic output.
505
604
  */
506
- export function sourceOverlayGraphs(declared: GraphIR, live: GraphIR): GraphIR {
605
+ export function sourceOverlayGraphs(declared: GraphIR, live: GraphIR, opts?: OverlayOptions): GraphIR {
507
606
  const liveById = new Map(live.nodes.map((n) => [n.id, n]));
508
607
  const declaredIds = new Set(declared.nodes.map((n) => n.id));
509
608
  const foreignIds = new Set(live.nodes.filter((n) => !declaredIds.has(n.id)).map((n) => n.id));
510
- const tagged = (n: IRNode, status: "good" | "warn" | "accent"): IRNode => ({ ...n, attrs: { ...n.attrs, _status: status } });
609
+ const unobserved = opts?.unobserved ?? {};
511
610
 
512
611
  const nodes: IRNode[] = declared.nodes.map((n) => {
513
612
  const obs = liveById.get(n.id);
514
- if (!obs) return tagged(n, "accent"); // pending — declared, not provisioned
613
+ if (!obs) {
614
+ const u = unobserved[n.id];
615
+ // unobserved — declared, and nobody looked; not "pending"
616
+ return u ? tagStatus(n, "neutral", u) : tagStatus(n, "accent");
617
+ }
515
618
  const merged: IRNode = { ...n }; // managed — carry the observed identity
516
619
  if (obs.physicalId) merged.physicalId = obs.physicalId;
517
620
  if (obs.ownership) merged.ownership = obs.ownership;
518
- return tagged(merged, "good");
621
+ return tagStatus(merged, "good");
519
622
  });
520
- for (const n of live.nodes) if (foreignIds.has(n.id)) nodes.push(tagged(n, "warn")); // foreign
623
+ for (const n of live.nodes) if (foreignIds.has(n.id)) nodes.push(tagStatus(n, "warn")); // foreign
521
624
  nodes.sort((a, b) => a.id.localeCompare(b.id));
522
625
 
523
626
  // Declared edges are the canvas (the cross-substrate topology). Add only the